Skip to content

feat(mcp): add mcp.call.before plugin hook for per-call MCP request headers#28319

Open
egze wants to merge 13 commits into
anomalyco:devfrom
egze:feature/mcp-call-before-hook
Open

feat(mcp): add mcp.call.before plugin hook for per-call MCP request headers#28319
egze wants to merge 13 commits into
anomalyco:devfrom
egze:feature/mcp-call-before-hook

Conversation

@egze

@egze egze commented May 19, 2026

Copy link
Copy Markdown
Contributor

Issue for this PR

Closes #28225

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Adds a mcp.call.before plugin hook fired once per outbound MCP client.callTool, with { server, tool, sessionID, callID } in scope. Plugins can mutate output.headers (pre-populated with the server's static mcp.<name>.headers config) to inject identity / tracing headers like X-Session-Id on a per-call basis. The static headers config keeps working unchanged.

The motivation is forwarding identity (e.g. the current sessionID or a user id from the caller) to remote MCP servers. Today mcp.headers is static, resolved once at config load, and the SDK transports are shared across sessions — so headers can't vary per call. The MCP tool execute closure also doesn't see sessionID/callID, so a per-call template substitution ({session:id}) wouldn't have anywhere to resolve from. There's no path today, via config or plugin, to attach the current sessionID to outbound MCP HTTP requests.

How it works:

  • New optional hook in packages/plugin/src/index.ts, modeled on chat.headers.
  • New module-level AsyncLocalStorage<McpCallStore> (McpCallContext) in packages/opencode/src/mcp/index.ts carrying the resolved header map for the current call.
  • New makeMcpFetch FetchLike wrapper passed via the fetch constructor option on both StreamableHTTPClientTransport and SSEClientTransport. It reads the ALS store and merges its headers on top of init.headers; falls through unchanged when no store is set (SDK handshake / OAuth probe).
  • Hook is fired in packages/opencode/src/session/tools.ts where Plugin.Service is already in scope. Plugin defects are caught with Effect.catchCause and logged at warn — the call proceeds.
  • MCP tools carry a non-enumerable __mcp = { server, tool } marker so the tool resolver knows which dispatched tools are MCP-sourced.
  • All header keys are lowercased at merge time so plugin-set keys correctly override SDK-set keys regardless of case.

Example plugin:

export default {
  "mcp.call.before": async (input, output) => {
    output.headers["x-session-id"] = input.sessionID
    output.headers["x-call-id"] = input.callID
  },
}

Out of scope: stdio MCP servers (different transport mechanism), listTools/getPrompt/readResource (only callTool), forwarding inbound REST request headers from POST /session/:id/message into MCP calls (separate feature, can be built on top of this hook later).

Relationship to #28299. That PR adds W3C traceparent propagation on the same MCP HTTP/SSE transports via its own built-in fetch wrapper. The two PRs touch the same extension point (the transports' fetch option) and will conflict if merged independently — the second to merge needs to compose the two wrappers (call one's FetchLike, then the other). They are not duplicates: #28299 is a specific built-in feature (W3C trace headers, gated on tracing config); this PR is a general plugin hook. A plugin could in principle inject traceparent itself given an OTel API handle, but the dedicated tracing path in #28299 is the right shape for the W3C standard headers, and I'd rather leave that to its own PR.

How did you verify your code works?

Unit tests in packages/opencode/test/mcp/call-before.test.ts cover the McpCallContext AsyncLocalStorage primitive (in-scope / out-of-scope / concurrent isolation), the __mcp metadata marker on tools, and makeMcpFetch's merge semantics (no-store passthrough, store-overrides-init, Headers-instance handling, lowercase normalization, plugin-overrides-SDK regardless of case, init-key preservation when not shadowed by store).

A small new file packages/opencode/test/mcp/transport-fetch-wiring.test.ts mocks both transport constructors and asserts each receives a fetch function.

An integration test at packages/opencode/test/mcp/call-before-integration.test.ts stands up a real Plugin.Service (same layer pattern as test/plugin/trigger.test.ts), registers a fake plugin via a tmpdir + opencode.json, fires mcp.call.before, and asserts the resolved headers reach a stubbed fetch. A second test verifies the plugin-throws path: the fake plugin mutates output.headers["x-from-plugin"] before throwing, the trigger swallows the defect via Effect.catchCause, and the pre-throw mutations still reach fetch.

47/47 tests in test/mcp/ pass. tsc --noEmit is clean for both packages/opencode and packages/plugin.

Also verified end-to-end against a local Streamable HTTP MCP server — a tiny Bun script that exposes one echo_headers tool returning the HTTP headers it received. Two modes:

  • TUI — launched the opencode TUI against a project that declared the echo server in opencode.json plus a plugin implementing mcp.call.before to add x-session-id / x-call-id / x-user-id. Asked the agent to call the tool; the headers arrived at the MCP server alongside the static Authorization from mcp.<server>.headers, and the tool echoed the same set back into the conversation.
  • REST — same plugin/config, but driven via opencode serve and HTTP. POST /session to create a session, then POST /session/:id/message. The x-session-id that reached the MCP server matched the session ID returned by POST /session exactly. Connect-time handshake / SSE-reconnect traffic correctly carried only the static config headers, with no per-call leak.

Screenshots / recordings

image

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

@github-actions github-actions Bot added contributor needs:compliance This means the issue will auto-close after 2 hours. labels May 19, 2026
@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

Potential duplicate found:

@egze egze force-pushed the feature/mcp-call-before-hook branch from 04aecbf to c8ade75 Compare May 19, 2026 08:51
@github-actions github-actions Bot removed the needs:compliance This means the issue will auto-close after 2 hours. label May 19, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for updating your PR! It now meets our contributing guidelines. 👍

@egze egze force-pushed the feature/mcp-call-before-hook branch 4 times, most recently from 1f6427e to 2523b78 Compare May 20, 2026 10:24
Aleksandr Lossenko and others added 13 commits June 1, 2026 10:16
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…reservation; cleanup

- Remove unused _server param from makeMcpFetch (isolation is structural via McpCallContext.run)
- Add @internal JSDoc to McpCallStore, McpCallContext, and makeMcpFetch exports
- Add unit test: init headers not shadowed by store are preserved (Test A)
- Add transport-fetch-wiring.test.ts asserting both transports receive a fetch wrapper (Test B)
- Add integration test: headers mutated before a plugin throw still reach the fetch wrapper (Test C)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Re-applied after rebase onto upstream/dev. The original change targeted
session/prompt.ts's resolveTools function; upstream extracted that into
session/tools.ts (commit 996928b series), so the same logic is ported
into the new home. Behavior is unchanged from the squashed effect of the
prior 0915805 + fe9889d commits:

- Read __mcp metadata from MCP-sourced tools.
- For MCP tools, look up static config headers (lowercased), fire
  mcp.call.before via plugin.trigger, swallow plugin defects via
  Effect.catchCause + log.warn.
- Run the underlying execute(args, opts) inside
  MCP.McpCallContext.run({ server, tool, sessionID, callID, headers })
  so makeMcpFetch sees the resolved headers.
- Pass Config.Service explicitly via Effect.provideService at the
  SessionTools.resolve call site in prompt.ts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds an "MCP Events" entry to the events list in plugins.mdx, plus a
worked example showing per-call header injection for session identity
forwarding. Also adds a pointer from the remote-MCP `headers` config
section in mcp-servers.mdx, since users looking for per-call header
control will likely land on that page first.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@egze egze force-pushed the feature/mcp-call-before-hook branch from 16ad53b to 861c386 Compare June 1, 2026 08:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE]: add mcp.call.before plugin hook for per-call MCP request headers (e.g. sessionID forwarding)

1 participant