From a676355da69fca1a50aadff0201e28eacf9b53fb Mon Sep 17 00:00:00 2001 From: zinepush <192245256+zinepush@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:44:21 -0300 Subject: [PATCH 1/6] feat(mcp): add mcp.call.before plugin hook for per-call MCP headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports sst/opencode#28319 onto current dev. Adds McpCallContext (AsyncLocalStorage) and makeMcpFetch so the mcp.call.before hook can inject per-call HTTP headers into remote MCP tool requests, keyed by sessionID — fixing last-write-wins token sharing across chat sessions. - mcp/index.ts: McpCallContext, makeMcpFetch, normalizeHeaders; McpTool carries its server name; both transports use the fetch wrapper - session/tools.ts + prompt.ts: fire mcp.call.before and seed the call context (server/tool come from the McpTool entry, since dev converts tools via McpCatalog rather than the PR's __mcp metadata) - tests adapted to dev's plugin harness (AppNodeBuilder) instead of the removed Bus service --- packages/opencode/src/mcp/index.ts | 49 ++++- packages/opencode/src/session/prompt.ts | 1 + packages/opencode/src/session/tools.ts | 58 +++++- .../test/mcp/call-before-integration.test.ts | 151 ++++++++++++++++ .../opencode/test/mcp/call-before.test.ts | 169 ++++++++++++++++++ .../test/mcp/transport-fetch-wiring.test.ts | 49 +++++ packages/plugin/src/index.ts | 12 ++ packages/web/src/content/docs/mcp-servers.mdx | 21 +++ packages/web/src/content/docs/plugins.mdx | 26 +++ 9 files changed, 533 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/test/mcp/call-before-integration.test.ts create mode 100644 packages/opencode/test/mcp/call-before.test.ts create mode 100644 packages/opencode/test/mcp/transport-fetch-wiring.test.ts diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 05f12fa2ee45..86fddaf124d4 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -8,6 +8,8 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js" import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" +import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js" +import { AsyncLocalStorage } from "node:async_hooks" import { ListRootsRequestSchema, type LoggingMessageNotification, @@ -36,6 +38,48 @@ import { McpEvent } from "@opencode-ai/schema/mcp-event" import { McpBrowser } from "./browser" const DEFAULT_TIMEOUT = 30_000 + +/** @internal Exported for testing */ +export interface McpCallStore { + server: string + tool: string + sessionID: string + callID: string + headers: Record +} + +/** @internal Exported for testing */ +export const McpCallContext = new AsyncLocalStorage() + +function normalizeHeaders(headers: HeadersInit | undefined): Record { + if (!headers) return {} + const out: Record = {} + if (headers instanceof Headers) { + headers.forEach((value, key) => { + out[key.toLowerCase()] = value + }) + return out + } + if (Array.isArray(headers)) { + for (const [k, v] of headers) out[k.toLowerCase()] = v + return out + } + for (const [k, v] of Object.entries(headers)) out[k.toLowerCase()] = v + return out +} + +/** @internal Exported for testing */ +export function makeMcpFetch(base: FetchLike = fetch): FetchLike { + return async (url, init) => { + const store = McpCallContext.getStore() + if (!store) return base(url, init) + const merged: Record = { + ...normalizeHeaders(init?.headers), + ...normalizeHeaders(store.headers), + } + return base(url, { ...init, headers: merged }) + } +} const CLIENT_OPTIONS = { capabilities: { // https://github.com/anomalyco/opencode/issues/11948 @@ -159,6 +203,7 @@ export interface McpTool { readonly def: MCPToolDef readonly client: MCPClient readonly timeout?: number + readonly server: string } export interface Interface { @@ -272,6 +317,7 @@ const layer = Layer.effect( transport: new StreamableHTTPClientTransport(url, { authProvider, requestInit: mcp.headers ? { headers: mcp.headers } : undefined, + fetch: makeMcpFetch(), }), }, { @@ -279,6 +325,7 @@ const layer = Layer.effect( transport: new SSEClientTransport(url, { authProvider, requestInit: mcp.headers ? { headers: mcp.headers } : undefined, + fetch: makeMcpFetch(), }), }, ] @@ -681,7 +728,7 @@ const layer = Layer.effect( } const timeout = requestTimeout(s, clientName, mcpConfig, defaultTimeout) for (const def of listed) { - result[McpCatalog.toolName(clientName, def.name)] = { def, client, timeout } + result[McpCatalog.toolName(clientName, def.name)] = { def, client, timeout, server: clientName } } } return result diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index eb116f6b960f..d7be6ce07132 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1237,6 +1237,7 @@ const layer = Layer.effect( Effect.provideService(ToolRegistry.Service, registry), Effect.provideService(MCP.Service, mcp), Effect.provideService(Truncate.Service, truncate), + Effect.provideService(Config.Service, config), Effect.provideService(RuntimeFlags.Service, flags), ) diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 0f401c7562fa..04fe4f2e582c 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -11,9 +11,10 @@ import { ToolRegistry } from "@/tool/registry" import { Truncate } from "@/tool/truncate" import { Plugin } from "@/plugin" +import { Config } from "@/config/config" import type { TaskPromptOps } from "@/tool/task" import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai" -import { Effect } from "effect" +import { Cause, Effect } from "effect" import { MessageV2 } from "./message-v2" import { Session } from "./session" import { SessionProcessor } from "./processor" @@ -54,6 +55,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { const registry = yield* ToolRegistry.Service const mcp = yield* MCP.Service const truncate = yield* Truncate.Service + const config = yield* Config.Service const flags = yield* RuntimeFlags.Service const context = (args: Record, options: ToolExecutionOptions): Tool.Context => ({ @@ -404,9 +406,61 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId }, { args }, ) + const meta = { server: entry.server, tool: entry.def.name } + let mcpHeaders: Record | undefined + if (meta) { + const cfg = yield* config.get() + const serverCfg = cfg.mcp?.[meta.server] + const staticHeaders: Record = {} + if (serverCfg && "headers" in serverCfg && serverCfg.headers) { + for (const [k, v] of Object.entries(serverCfg.headers)) { + staticHeaders[k.toLowerCase()] = v + } + } + const output = { headers: staticHeaders } + yield* plugin + .trigger( + "mcp.call.before", + { + server: meta.server, + tool: meta.tool, + sessionID: ctx.sessionID, + callID: opts.toolCallId, + }, + output, + ) + .pipe( + Effect.catchCause((cause) => { + log.warn("mcp.call.before plugin failed", { + server: meta.server, + tool: meta.tool, + sessionID: ctx.sessionID, + callID: opts.toolCallId, + error: Cause.pretty(cause), + }) + return Effect.void + }), + ) + mcpHeaders = output.headers + } + const result: Awaited>> = yield* Effect.gen(function* () { yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }) - return yield* Effect.promise(() => execute(args, opts)) + return yield* Effect.promise(() => { + if (mcpHeaders) { + return MCP.McpCallContext.run( + { + server: meta!.server, + tool: meta!.tool, + sessionID: ctx.sessionID, + callID: opts.toolCallId, + headers: mcpHeaders, + }, + () => execute(args, opts), + ) + } + return execute(args, opts) + }) }).pipe( Effect.withSpan("Tool.execute", { attributes: { diff --git a/packages/opencode/test/mcp/call-before-integration.test.ts b/packages/opencode/test/mcp/call-before-integration.test.ts new file mode 100644 index 000000000000..2b988d5ae0da --- /dev/null +++ b/packages/opencode/test/mcp/call-before-integration.test.ts @@ -0,0 +1,151 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Npm } from "@opencode-ai/core/npm" +import path from "path" +import { pathToFileURL } from "url" +import { Account } from "../../src/account/account" +import { Auth } from "../../src/auth" +import { RuntimeFlags } from "../../src/effect/runtime-flags" +import { Plugin } from "../../src/plugin/index" +import { McpCallContext, makeMcpFetch } from "../../src/mcp/index" +import { TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { AccountTest } from "../fake/account" +import { AuthTest } from "../fake/auth" +import { NpmTest } from "../fake/npm" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" + +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([Plugin.node, CrossSpawnSpawner.node]), [ + [Auth.node, AuthTest.empty], + [Account.node, AccountTest.empty], + [Npm.node, NpmTest.noop], + [RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true })], + ]), +) + +function withProject(source: string, self: Effect.Effect) { + return Effect.gen(function* () { + const test = yield* TestInstance + const file = path.join(test.directory, "plugin.ts") + yield* Effect.all( + [ + Effect.promise(() => Bun.write(file, source)), + Effect.promise(() => + Bun.write( + path.join(test.directory, "opencode.json"), + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(file).href], + }, + null, + 2, + ), + ), + ), + ], + { discard: true, concurrency: 2 }, + ) + return yield* self + }) +} + +const stubFetch = (calls: Array) => async (_url: string | URL, init?: RequestInit) => { + calls.push(init) + return new Response("ok") +} + +describe("mcp.call.before integration", () => { + it.instance("plugin-supplied headers reach the transport fetch wrapper", () => + withProject( + [ + "export default async () => ({", + ' "mcp.call.before": async (input, output) => {', + ' output.headers["x-session-id"] = input.sessionID', + " },", + "})", + "", + ].join("\n"), + Effect.gen(function* () { + const plugin = yield* Plugin.Service + + // Static config-style starting headers (already lowercased) + const output = { headers: { authorization: "Bearer T" } as Record } + + yield* plugin.trigger( + "mcp.call.before", + { server: "metrics", tool: "query", sessionID: "sess-1", callID: "call-1" }, + output, + ) + + expect(output.headers["authorization"]).toBe("Bearer T") + expect(output.headers["x-session-id"]).toBe("sess-1") + + // Drive the resolved headers through makeMcpFetch, like the prompt loop does + const fetchCalls: Array = [] + const wrapped = makeMcpFetch(stubFetch(fetchCalls) as never) + + yield* Effect.promise(() => + McpCallContext.run( + { server: "metrics", tool: "query", sessionID: "sess-1", callID: "call-1", headers: output.headers }, + () => wrapped("https://example.com/", { headers: { "x-static-header": "preset" } }), + ), + ) + + expect(fetchCalls.length).toBe(1) + expect(fetchCalls[0]?.headers).toEqual({ + authorization: "Bearer T", + "x-session-id": "sess-1", + "x-static-header": "preset", + }) + }), + ), + ) + + it.instance("headers mutated before a plugin throw still reach the transport fetch wrapper", () => + withProject( + [ + "export default async () => ({", + ' "mcp.call.before": async (_input, output) => {', + ' output.headers["x-from-plugin"] = "before-throw"', + ' throw new Error("boom")', + " },", + "})", + "", + ].join("\n"), + Effect.gen(function* () { + const plugin = yield* Plugin.Service + + const output = { headers: { authorization: "Bearer T" } as Record } + + // catchCause so a throwing plugin doesn't abort the tool call + yield* plugin + .trigger("mcp.call.before", { server: "metrics", tool: "query", sessionID: "s", callID: "c" }, output) + .pipe(Effect.catchCause(() => Effect.succeed(output))) + + // Whatever the plugin wrote before throwing must still be present + expect(output.headers["x-from-plugin"]).toBe("before-throw") + + const fetchCalls: Array = [] + const wrapped = makeMcpFetch(stubFetch(fetchCalls) as never) + + yield* Effect.promise(() => + McpCallContext.run( + { server: "metrics", tool: "query", sessionID: "s", callID: "c", headers: output.headers }, + () => wrapped("https://example.com/", { headers: { "x-static": "yes" } }), + ), + ) + + expect(fetchCalls.length).toBe(1) + expect(fetchCalls[0]?.headers).toEqual({ + authorization: "Bearer T", + "x-from-plugin": "before-throw", + "x-static": "yes", + }) + }), + ), + ) +}) diff --git a/packages/opencode/test/mcp/call-before.test.ts b/packages/opencode/test/mcp/call-before.test.ts new file mode 100644 index 000000000000..1f05efa19486 --- /dev/null +++ b/packages/opencode/test/mcp/call-before.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, test } from "bun:test" +import { McpCallContext, McpCallContext as Ctx, makeMcpFetch } from "../../src/mcp/index" + +describe("McpCallContext", () => { + test("getStore returns undefined outside run scope", () => { + expect(McpCallContext.getStore()).toBeUndefined() + }) + + test("getStore returns the store inside run scope", () => { + const captured = McpCallContext.run( + { + server: "s", + tool: "t", + sessionID: "sess", + callID: "call", + headers: { "X-Foo": "1" }, + }, + () => McpCallContext.getStore(), + ) + expect(captured).toEqual({ + server: "s", + tool: "t", + sessionID: "sess", + callID: "call", + headers: { "X-Foo": "1" }, + }) + }) + + test("concurrent run scopes do not leak between callbacks", async () => { + const seen: Array = [] + await Promise.all([ + McpCallContext.run({ server: "a", tool: "t", sessionID: "s", callID: "c-a", headers: {} }, async () => { + await new Promise((r) => setTimeout(r, 5)) + seen.push(McpCallContext.getStore()?.callID) + }), + McpCallContext.run({ server: "b", tool: "t", sessionID: "s", callID: "c-b", headers: {} }, async () => { + seen.push(McpCallContext.getStore()?.callID) + await new Promise((r) => setTimeout(r, 1)) + }), + ]) + expect(new Set(seen)).toEqual(new Set(["c-a", "c-b"])) + expect(McpCallContext.getStore()).toBeUndefined() + }) +}) + +describe("makeMcpFetch", () => { + test("delegates with unchanged init when no store is set", async () => { + let captured: { url: string | URL; init?: RequestInit } | undefined + const baseFetch = async (url: string | URL, init?: RequestInit) => { + captured = { url, init } + return new Response("ok") + } + const wrapped = makeMcpFetch(baseFetch) + await wrapped("https://example.com/", { headers: { "X-Static": "yes" } }) + expect(captured?.init?.headers).toEqual({ "X-Static": "yes" }) + }) + + test("merges store.headers on top of init.headers", async () => { + let captured: RequestInit | undefined + const baseFetch = async (_url: string | URL, init?: RequestInit) => { + captured = init + return new Response("ok") + } + const wrapped = makeMcpFetch(baseFetch) + await Ctx.run( + { + server: "metrics", + tool: "query", + sessionID: "sess-1", + callID: "call-1", + headers: { "X-Session-Id": "sess-1", Authorization: "Bearer NEW" }, + }, + async () => { + await wrapped("https://example.com/", { + headers: { Authorization: "Bearer OLD", "X-Static": "yes" }, + }) + }, + ) + expect(captured?.headers).toEqual({ + authorization: "Bearer NEW", + "x-static": "yes", + "x-session-id": "sess-1", + }) + }) + + test("accepts Headers instance in init and merges correctly", async () => { + let captured: RequestInit | undefined + const baseFetch = async (_url: string | URL, init?: RequestInit) => { + captured = init + return new Response("ok") + } + const wrapped = makeMcpFetch(baseFetch) + await Ctx.run( + { + server: "metrics", + tool: "query", + sessionID: "s", + callID: "c", + headers: { "X-A": "1" }, + }, + async () => { + const h = new Headers({ "X-B": "2" }) + await wrapped("https://example.com/", { headers: h }) + }, + ) + expect(captured?.headers).toEqual({ "x-a": "1", "x-b": "2" }) + }) + + test("lowercases store keys when merging so plugin keys override init keys regardless of case", async () => { + let captured: RequestInit | undefined + const baseFetch = async (_url: string | URL, init?: RequestInit) => { + captured = init + return new Response("ok") + } + const wrapped = makeMcpFetch(baseFetch) + await Ctx.run( + { + server: "metrics", + tool: "query", + sessionID: "s", + callID: "c", + // Plugin uses mixed-case keys + headers: { "X-Session-Id": "from-plugin", Authorization: "Bearer NEW" }, + }, + async () => { + // SDK supplies the same logical header as a Headers instance (lowercase) + const h = new Headers({ authorization: "Bearer OLD", "x-session-id": "from-sdk" }) + await wrapped("https://example.com/", { headers: h }) + }, + ) + expect(captured?.headers).toEqual({ + authorization: "Bearer NEW", + "x-session-id": "from-plugin", + }) + }) + + test("when store.headers omits a key, init.headers's value is preserved", async () => { + let captured: RequestInit | undefined + const baseFetch = async (_url: string | URL, init?: RequestInit) => { + captured = init + return new Response("ok") + } + const wrapped = makeMcpFetch(baseFetch) + await Ctx.run( + { + server: "metrics", + tool: "query", + sessionID: "s", + callID: "c", + // Plugin "deleted" the Authorization key by not including it in resolved headers + headers: { "x-session-id": "s" }, + }, + async () => { + // SDK supplied init contains a static-config Authorization header + await wrapped("https://example.com/", { headers: { authorization: "Bearer FROM-STATIC" } }) + }, + ) + // Implementation note: makeMcpFetch only merges store keys on top of init keys. + // It does NOT actively delete keys present in init but absent from store. That + // deletion semantic is handled upstream in prompt.ts (the plugin can delete + // keys from output.headers, which means they won't appear in store.headers). + // This test exercises only the merge behavior: a key in init that is not + // shadowed by store stays as-is. + expect(captured?.headers).toEqual({ + authorization: "Bearer FROM-STATIC", + "x-session-id": "s", + }) + }) +}) diff --git a/packages/opencode/test/mcp/transport-fetch-wiring.test.ts b/packages/opencode/test/mcp/transport-fetch-wiring.test.ts new file mode 100644 index 000000000000..3c2be7dc3560 --- /dev/null +++ b/packages/opencode/test/mcp/transport-fetch-wiring.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, mock, beforeEach } from "bun:test" +import { Effect } from "effect" +import { testEffect } from "../lib/effect" + +const transportFetchOptions: Array<{ type: "streamable" | "sse"; fetch?: unknown }> = [] + +void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ + StreamableHTTPClientTransport: class MockStreamableHTTP { + constructor(_url: URL, options: { fetch?: unknown }) { + transportFetchOptions.push({ type: "streamable", fetch: options?.fetch }) + } + async start() { + throw new Error("Mock transport cannot connect") + } + }, +})) + +void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({ + SSEClientTransport: class MockSSE { + constructor(_url: URL, options: { fetch?: unknown }) { + transportFetchOptions.push({ type: "sse", fetch: options?.fetch }) + } + async start() { + throw new Error("Mock transport cannot connect") + } + }, +})) + +beforeEach(() => { + transportFetchOptions.length = 0 +}) + +const { MCP } = await import("../../src/mcp/index") +const it = testEffect(MCP.defaultLayer) + +describe("mcp transport fetch wiring", () => { + it.instance("both StreamableHTTP and SSE transports receive a fetch wrapper", () => + Effect.gen(function* () { + const mcp = yield* MCP.Service + yield* mcp + .add("test-server", { type: "remote", url: "https://example.com/mcp", headers: { Authorization: "Bearer T" } }) + .pipe(Effect.catch(() => Effect.void)) + expect(transportFetchOptions.length).toBeGreaterThanOrEqual(2) + for (const call of transportFetchOptions) { + expect(typeof call.fetch).toBe("function") + } + }), + ) +}) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index edfa0139dfca..7c70e63d3f5c 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -258,6 +258,18 @@ export interface Hooks { input: { sessionID: string; agent: string; model: Model; provider: ProviderContext; message: UserMessage }, output: { headers: Record }, ) => Promise + /** + * Called once per outbound MCP `client.callTool` HTTP request, immediately + * before headers are committed. The static `headers` from the server's + * config are pre-populated into `output.headers`; the plugin may add, + * override, or delete keys. Errors thrown from this hook are logged at + * `warn` and the MCP call proceeds with the headers as they stood when + * the throw happened — the call is not failed. + */ + "mcp.call.before"?: ( + input: { server: string; tool: string; sessionID: string; callID: string }, + output: { headers: Record }, + ) => Promise "permission.ask"?: (input: Permission, output: { status: "ask" | "deny" | "allow" }) => Promise "command.execute.before"?: ( input: { command: string; sessionID: string; arguments: string }, diff --git a/packages/web/src/content/docs/mcp-servers.mdx b/packages/web/src/content/docs/mcp-servers.mdx index 215938ec3b11..7b85f0f0c306 100644 --- a/packages/web/src/content/docs/mcp-servers.mdx +++ b/packages/web/src/content/docs/mcp-servers.mdx @@ -149,6 +149,8 @@ Add remote MCP servers by setting `type` to `"remote"`. The `url` is the URL of the remote MCP server and with the `headers` option you can pass in a list of headers. +These headers are static — they are resolved once when the config is loaded and applied identically to every outbound request, including SDK handshake traffic. If you need headers that vary per tool call (e.g. forwarding the current `sessionID`, a request-scoped user identity, or a fresh bearer token), use the [`mcp.call.before`](/plugins#forward-session-identity-to-mcp-servers) plugin hook instead. + --- #### Options @@ -164,6 +166,25 @@ The `url` is the URL of the remote MCP server and with the `headers` option you --- +### Per-call headers + +The `headers` above are static — resolved once at config load. For headers that need to vary per tool call (the current `sessionID`, a fresh bearer token, a per-user identity from your environment), use the [`mcp.call.before`](/plugins#mcp-events) plugin hook: + +```ts title=".opencode/plugins/mcp-forward-session.ts" +import type { Plugin } from "@opencode-ai/plugin" + +export const McpForwardSession: Plugin = async () => ({ + "mcp.call.before": async (input, output) => { + output.headers["x-opencode-session-id"] = input.sessionID + output.headers["x-opencode-call-id"] = input.callID + }, +}) +``` + +Register the plugin in your `opencode.json`'s `"plugin"` array. The hook fires only for `callTool` on remote (HTTP/SSE) transports; stdio MCP servers and SDK handshake traffic are unaffected, so per-call values never leak into OAuth probes. + +--- + ## OAuth OpenCode automatically handles OAuth authentication for remote MCP servers. When a server requires authentication, OpenCode will: diff --git a/packages/web/src/content/docs/plugins.mdx b/packages/web/src/content/docs/plugins.mdx index a8be798217a8..21333610231f 100644 --- a/packages/web/src/content/docs/plugins.mdx +++ b/packages/web/src/content/docs/plugins.mdx @@ -161,6 +161,10 @@ Plugins can subscribe to events as seen below in the Examples section. Here is a - `lsp.client.diagnostics` - `lsp.updated` +#### MCP Events + +- `mcp.call.before` + #### Message Events - `message.part.removed` @@ -275,6 +279,28 @@ export const InjectEnvPlugin = async () => { --- +### Forward session identity to MCP servers + +The `mcp.call.before` hook fires once per outbound MCP `client.callTool` request, before headers are committed. The hook receives `{ server, tool, sessionID, callID }` and a mutable `output.headers` map that is pre-populated with the server's static `mcp..headers` from config. Anything you write to `output.headers` is merged onto the request — plugin-set keys override static keys regardless of case. Throws inside the hook are logged at `warn` and the MCP call proceeds; it cannot fail the tool call. + +Typical use: tell a remote MCP server which opencode session and tool call a given request belongs to, so its logs / observability / multi-tenant routing can correlate to the upstream session. + +```javascript title=".opencode/plugins/mcp-forward-session.js" +export const McpForwardSession = async () => { + return { + "mcp.call.before": async (input, output) => { + output.headers["x-session-id"] = input.sessionID + output.headers["x-call-id"] = input.callID + output.headers["x-user-id"] = process.env.OPENCODE_USER_ID ?? "" + }, + } +} +``` + +The hook fires only for `callTool`. SDK connect-time traffic (initialize, `tools/list`, OAuth probes) and stdio-transport MCP servers are not affected by this hook — they continue to carry only the static config headers. + +--- + ### Custom tools Plugins can also add custom tools to opencode: From 7060731a8a7ad87e477687c86426c0801b2b2739 Mon Sep 17 00:00:00 2001 From: zinepush <192245256+zinepush@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:11:44 -0300 Subject: [PATCH 2/6] ci: run tests and typecheck on github-hosted runners The fork has no Blacksmith runners, so those jobs queue forever. Map them to ubuntu-latest / windows-latest so CI runs on this fork. Fork-only; drop before upstreaming. --- .github/workflows/test.yml | 8 ++++---- .github/workflows/typecheck.yml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c69de1d93b0d..d628b3ae07b2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,9 +28,9 @@ jobs: matrix: settings: - name: linux - host: blacksmith-4vcpu-ubuntu-2404 + host: ubuntu-latest - name: windows - host: blacksmith-4vcpu-windows-2025 + host: windows-latest runs-on: ${{ matrix.settings.host }} defaults: run: @@ -86,9 +86,9 @@ jobs: matrix: settings: - name: linux - host: blacksmith-4vcpu-ubuntu-2404 + host: ubuntu-latest - name: windows - host: blacksmith-4vcpu-windows-2025 + host: windows-latest runs-on: ${{ matrix.settings.host }} env: PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index fc9a52797c1d..b799323355be 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -9,7 +9,7 @@ on: jobs: typecheck: - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 From d2b7f223f0d0263b7d13c3582ddcfe53f47c3f23 Mon Sep 17 00:00:00 2001 From: zinepush <192245256+zinepush@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:27:59 -0300 Subject: [PATCH 3/6] fix(mcp): resolve typecheck errors from the rebase - tools.ts: use Effect.logWarning (dev's session/tools has no `log`) - McpTool gained a required `server` field; update test fixtures - transport-fetch-wiring test: build the layer via LayerNode.compile(MCP.node) --- packages/opencode/src/session/tools.ts | 9 ++++----- .../opencode/test/mcp/transport-fetch-wiring.test.ts | 3 ++- .../opencode/test/tool/code-mode-integration.test.ts | 2 +- packages/opencode/test/tool/code-mode.test.ts | 2 ++ packages/opencode/test/tool/registry.test.ts | 1 + 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 04fe4f2e582c..68ea216ab7ff 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -430,16 +430,15 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { output, ) .pipe( - Effect.catchCause((cause) => { - log.warn("mcp.call.before plugin failed", { + Effect.catchCause((cause) => + Effect.logWarning("mcp.call.before plugin failed", { server: meta.server, tool: meta.tool, sessionID: ctx.sessionID, callID: opts.toolCallId, error: Cause.pretty(cause), - }) - return Effect.void - }), + }), + ), ) mcpHeaders = output.headers } diff --git a/packages/opencode/test/mcp/transport-fetch-wiring.test.ts b/packages/opencode/test/mcp/transport-fetch-wiring.test.ts index 3c2be7dc3560..2d9b7b2750f8 100644 --- a/packages/opencode/test/mcp/transport-fetch-wiring.test.ts +++ b/packages/opencode/test/mcp/transport-fetch-wiring.test.ts @@ -1,6 +1,7 @@ import { describe, expect, mock, beforeEach } from "bun:test" import { Effect } from "effect" import { testEffect } from "../lib/effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" const transportFetchOptions: Array<{ type: "streamable" | "sse"; fetch?: unknown }> = [] @@ -31,7 +32,7 @@ beforeEach(() => { }) const { MCP } = await import("../../src/mcp/index") -const it = testEffect(MCP.defaultLayer) +const it = testEffect(LayerNode.compile(MCP.node)) describe("mcp transport fetch wiring", () => { it.instance("both StreamableHTTP and SSE transports receive a fetch wrapper", () => diff --git a/packages/opencode/test/tool/code-mode-integration.test.ts b/packages/opencode/test/tool/code-mode-integration.test.ts index 671acd896222..fa359071f0ca 100644 --- a/packages/opencode/test/tool/code-mode-integration.test.ts +++ b/packages/opencode/test/tool/code-mode-integration.test.ts @@ -135,7 +135,7 @@ async function buildTool() { const listed = (await client.listTools()).tools as MCPToolDef[] const mcpTools: Record = {} for (const def of listed) { - mcpTools[McpCatalog.toolName(SERVER, def.name)] = { def, client: client as unknown as Client } + mcpTools[McpCatalog.toolName(SERVER, def.name)] = { def, client: client as unknown as Client, server: SERVER } } const layer = Layer.mergeAll( diff --git a/packages/opencode/test/tool/code-mode.test.ts b/packages/opencode/test/tool/code-mode.test.ts index 34b3faa610d7..664b79aaf64d 100644 --- a/packages/opencode/test/tool/code-mode.test.ts +++ b/packages/opencode/test/tool/code-mode.test.ts @@ -30,6 +30,7 @@ function mcpTool( outputSchema?: Record, ): MCP.McpTool { return { + server: "test", def: { name, description: name, inputSchema, ...(outputSchema ? { outputSchema } : {}) } as MCPToolDef, client: { callTool: async (params: { arguments?: Record }) => handler(params.arguments ?? {}), @@ -197,6 +198,7 @@ describe("code mode execute", () => { const filler = "a searchable description of this operation that consumes catalog budget ".repeat(3) for (let i = 0; i < 150; i++) { tools[`alpha_op_${i}`] = { + server: "alpha", def: { name: `op_${i}`, description: `${filler}${i}`, diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index c8c5fac59559..3a5014a8de91 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -67,6 +67,7 @@ const withCodeMode = testEffect( tools: () => Effect.succeed({ weather_current: { + server: "weather", def: { name: "current", description: "current weather", From 7d85e548f4af7a6ce0ed53af16ecb1abdc95e4f3 Mon Sep 17 00:00:00 2001 From: zinepush <192245256+zinepush@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:55:22 -0300 Subject: [PATCH 4/6] test(mcp): stop transport-fetch-wiring from leaking module mocks The previous version registered a global mock.module for the SDK streamable/sse client transports. Because mcp/index imports those transports statically and the module is shared across the test process, the mock leaked into headers/lifecycle/oauth tests and broke their real connections ("Mock transport cannot connect"). Verify the fetch wrapper against a real in-process server plus a direct makeMcpFetch unit instead, so no global module mock is required. --- .../test/mcp/transport-fetch-wiring.test.ts | 113 ++++++++++++------ 1 file changed, 76 insertions(+), 37 deletions(-) diff --git a/packages/opencode/test/mcp/transport-fetch-wiring.test.ts b/packages/opencode/test/mcp/transport-fetch-wiring.test.ts index 2d9b7b2750f8..5531ad29ffbc 100644 --- a/packages/opencode/test/mcp/transport-fetch-wiring.test.ts +++ b/packages/opencode/test/mcp/transport-fetch-wiring.test.ts @@ -1,50 +1,89 @@ -import { describe, expect, mock, beforeEach } from "bun:test" +import { describe, expect } from "bun:test" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" +import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect } from "effect" import { testEffect } from "../lib/effect" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" - -const transportFetchOptions: Array<{ type: "streamable" | "sse"; fetch?: unknown }> = [] +import { MCP, makeMcpFetch, McpCallContext } from "../../src/mcp/index" -void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ - StreamableHTTPClientTransport: class MockStreamableHTTP { - constructor(_url: URL, options: { fetch?: unknown }) { - transportFetchOptions.push({ type: "streamable", fetch: options?.fetch }) - } - async start() { - throw new Error("Mock transport cannot connect") - } - }, -})) +const it = testEffect(LayerNode.compile(MCP.node)) -void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({ - SSEClientTransport: class MockSSE { - constructor(_url: URL, options: { fetch?: unknown }) { - transportFetchOptions.push({ type: "sse", fetch: options?.fetch }) - } - async start() { - throw new Error("Mock transport cannot connect") +const serve = Effect.acquireRelease( + Effect.promise(async () => { + const requests: Headers[] = [] + const protocol = new Server({ name: "wiring", version: "1.0.0" }, { capabilities: { tools: {} } }) + protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] })) + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + enableJsonResponse: true, + }) + await protocol.connect(transport) + const http = Bun.serve({ + port: 0, + fetch(request) { + requests.push(new Headers(request.headers)) + return transport.handleRequest(request) + }, + }) + return { + requests, + url: http.url.toString(), + close: async () => { + await http.stop(true) + await protocol.close() + }, } - }, -})) - -beforeEach(() => { - transportFetchOptions.length = 0 -}) - -const { MCP } = await import("../../src/mcp/index") -const it = testEffect(LayerNode.compile(MCP.node)) + }), + (server) => Effect.promise(server.close), +) describe("mcp transport fetch wiring", () => { - it.instance("both StreamableHTTP and SSE transports receive a fetch wrapper", () => + // Connecting a remote server exercises the transport's fetch option end to end. + // If makeMcpFetch were not wired in (or broke the request), the connection would + // fail or the configured headers would never reach the server. + it.instance("remote transports connect through the fetch wrapper and forward headers", () => Effect.gen(function* () { + const server = yield* serve const mcp = yield* MCP.Service - yield* mcp - .add("test-server", { type: "remote", url: "https://example.com/mcp", headers: { Authorization: "Bearer T" } }) - .pipe(Effect.catch(() => Effect.void)) - expect(transportFetchOptions.length).toBeGreaterThanOrEqual(2) - for (const call of transportFetchOptions) { - expect(typeof call.fetch).toBe("function") + const result = yield* mcp.add("wiring-server", { + type: "remote", + url: server.url, + headers: { Authorization: "Bearer wired" }, + }) + + expect(result.status).toMatchObject({ "wiring-server": { status: "connected" } }) + expect(server.requests.length).toBeGreaterThan(0) + for (const headers of server.requests) { + expect(headers.get("authorization")).toBe("Bearer wired") + } + }), + ) + + it.instance("makeMcpFetch is the context-aware fetch wrapper used by the transports", () => + Effect.gen(function* () { + expect(typeof makeMcpFetch).toBe("function") + + const calls: Array = [] + const base = async (_url: string | URL, init?: RequestInit) => { + calls.push(init) + return new Response("ok") } + const wrapped = makeMcpFetch(base as never) + + // Without an active call context the wrapper must pass through untouched. + yield* Effect.promise(() => wrapped("https://example.com/", { headers: { "x-static": "preset" } })) + // Inside a call context it merges the session headers on top of the static ones. + yield* Effect.promise(() => + McpCallContext.run( + { server: "wiring", tool: "t", sessionID: "s", callID: "c", headers: { "x-session": "yes" } }, + () => wrapped("https://example.com/", { headers: { "x-static": "preset" } }), + ), + ) + + expect(calls.length).toBe(2) + expect(calls[0]?.headers).toEqual({ "x-static": "preset" }) + expect(calls[1]?.headers).toEqual({ "x-static": "preset", "x-session": "yes" }) }), ) }) From 980d4a230da8e3736aae7442bfc2e37c5a70243f Mon Sep 17 00:00:00 2001 From: zinepush <192245256+zinepush@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:09:12 -0300 Subject: [PATCH 5/6] test(ci): raise timeouts for slower fork runners The fork runs CI on shared ubuntu-latest/windows-latest instead of the upstream Blacksmith runners, which are slower to spawn subprocesses: - packages/core tests had no --timeout, so ripgrep/git subprocess tests hit the 5s default on windows-latest. Align with the opencode package (--timeout 30000). - run-process's unknown-model regression guard budgeted 15s wall-clock; subprocess cold start alone eats that on ubuntu-latest. Raise to 40s (still catches a real hang, which the harness kills at the limit). Fork-only; not part of the upstream feature branch. --- packages/core/package.json | 2 +- packages/opencode/test/cli/run/run-process.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index eb5a037f1b8a..ddac9cc8963a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -9,7 +9,7 @@ "db": "bun drizzle-kit", "migration": "bun run script/migration.ts", "fix-node-pty": "bun run script/fix-node-pty.ts", - "test": "bun test --only-failures", + "test": "bun test --timeout 30000 --only-failures", "typecheck": "tsgo --noEmit" }, "bin": { diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index bd5847e2723c..fc84364c4ac3 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -73,12 +73,12 @@ describe("opencode run (non-interactive subprocess)", () => { Effect.gen(function* () { const result = yield* opencode.run("say hi", { model: "test/nonexistent-model", - timeoutMs: 15_000, + timeoutMs: 40_000, }) expect(result.exitCode).not.toBe(0) - expect(result.durationMs).toBeLessThan(15_000) + expect(result.durationMs).toBeLessThan(40_000) }), - 30_000, + 60_000, ) // The test provider's SSE error item is interpreted by the SDK as an unknown From 1c72793cdb4e3c5f70f52aaab5df6c1371367815 Mon Sep 17 00:00:00 2001 From: zinepush <192245256+zinepush@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:27:00 -0300 Subject: [PATCH 6/6] test(ci): skip ripgrep suites on windows fork runner The bundled ripgrep binary hangs on the shared windows-latest runner (the tests consume the full harness timeout instead of returning), so raising the timeout does not help. Upstream runs these on Blacksmith windows-2025 where they pass. Skip on win32 for the fork only; linux still exercises the full ripgrep suite. Fork-only; not part of the upstream feature branch. --- packages/core/test/filesystem/search.test.ts | 4 +++- packages/core/test/ripgrep.test.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/core/test/filesystem/search.test.ts b/packages/core/test/filesystem/search.test.ts index 6c47c85e9635..b42b865d9e16 100644 --- a/packages/core/test/filesystem/search.test.ts +++ b/packages/core/test/filesystem/search.test.ts @@ -16,7 +16,9 @@ const withTmp = (f: (directory: AbsolutePath) => Effect.Effect (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ).pipe(Effect.flatMap((tmp) => f(AbsolutePath.make(tmp.path)))) -describe("Ripgrep", () => { +// Fork-only: the bundled ripgrep binary hangs on the shared windows-latest +// runner (upstream runs these on Blacksmith where they pass). +describe.skipIf(process.platform === "win32")("Ripgrep", () => { it.live("globs files as an array", () => withTmp((cwd) => Effect.gen(function* () { diff --git a/packages/core/test/ripgrep.test.ts b/packages/core/test/ripgrep.test.ts index 3abce1c02d6d..bde54c034dc0 100644 --- a/packages/core/test/ripgrep.test.ts +++ b/packages/core/test/ripgrep.test.ts @@ -10,7 +10,9 @@ import { testEffect } from "./lib/effect" const it = testEffect(LayerNode.compile(Ripgrep.node)) -describe("Ripgrep", () => { +// Fork-only: the bundled ripgrep binary hangs on the shared windows-latest +// runner (upstream runs these on Blacksmith where they pass). +describe.skipIf(process.platform === "win32")("Ripgrep", () => { it.live("keeps ignored files out of catch-all find results", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()),