Uh oh!
There was an error while loading. Please reload this page.
feat(mcp): add mcp.call.before plugin hook for per-call MCP request headers - #28319
feat(mcp): add mcp.call.before plugin hook for per-call MCP request headers#28319egze wants to merge 1 commit into
Conversation
The following comment was made by an LLM, it may be inaccurate: Potential duplicate found:
|
04aecbf to
c8ade75CompareThanks for updating your PR! It now meets our contributing guidelines. 👍 |
1f6427e to
2523b78Compare16ad53b to
861c386Comparezinepush
commented
Jul 16, 2026
Hi @egze — thanks for this hook. We're using it for per-session MCP credential isolation (injecting a per-user token into a shared MCP server connection without it leaking across sessions) and it works great. I rebased your PR onto the current While doing it I also fixed a test-isolation bug: Rebased branch — single commit, credited to you as co-author:
Happy to open a PR against your branch so #28319 updates in place, or you can just cherry-pick the commit — whatever you prefer. If a maintainer would rather I open a fresh PR, glad to do that too. |
egze
commented
Jul 31, 2026
Hi @zinepush Thank you for testing it and improving. I'll try to cherry-pick and report back if I don't succeed. |
861c386 to
6a32387Compare…eaders
Introduce a mcp.call.before plugin hook that runs immediately before each
MCP tool invocation and can mutate the outgoing request headers for that
single call. This enables per-session/per-user credentials to be injected
into shared MCP server connections without leaking across sessions.
Core pieces:
- McpCallContext (AsyncLocalStorage) carries the resolved per-call headers.
- makeMcpFetch() wraps the transport fetch and merges the context headers
on top of the static config headers at request time.
- The streamable-http and sse remote transports are wired through the
wrapper; McpTool now records its owning server so the prompt loop can
build the { server, tool, sessionID, callID } context for the hook.
Rebases and continues anomalyco#28319 onto current dev, where the MCP catalog was
refactored (tool conversion moved to McpCatalog.convertTool), so the
original __mcp tool metadata is derived from the McpTool.server field
instead. Includes unit and plugin-harness integration coverage.
Co-authored-by: Aleksandr Lossenko <29657+egze@users.noreply.github.com>6a32387 to
c78e682Compare…eaders
Introduce a mcp.call.before plugin hook that runs immediately before each
MCP tool invocation and can mutate the outgoing request headers for that
single call. This enables per-session/per-user credentials to be injected
into shared MCP server connections without leaking across sessions.
Core pieces:
- McpCallContext (AsyncLocalStorage) carries the resolved per-call headers.
- makeMcpFetch() wraps the transport fetch and merges the context headers
on top of the static config headers at request time.
- The streamable-http and sse remote transports are wired through the
wrapper; McpTool now records its owning server so the prompt loop can
build the { server, tool, sessionID, callID } context for the hook.
Rebases and continues anomalyco#28319 onto current dev, where the MCP catalog was
refactored (tool conversion moved to McpCatalog.convertTool), so the
original __mcp tool metadata is derived from the McpTool.server field
instead. Includes unit and plugin-harness integration coverage.
Co-authored-by: Aleksandr Lossenko <29657+egze@users.noreply.github.com>egze
commented
Jul 31, 2026
@zinepush I think I did it, please double check. |
…eaders
Introduce a mcp.call.before plugin hook that runs immediately before each
MCP tool invocation and can mutate the outgoing request headers for that
single call. This enables per-session/per-user credentials to be injected
into shared MCP server connections without leaking across sessions.
Core pieces:
- McpCallContext (AsyncLocalStorage) carries the resolved per-call headers.
- makeMcpFetch() wraps the transport fetch and merges the context headers
on top of the static config headers at request time.
- The streamable-http and sse remote transports are wired through the
wrapper; McpTool now records its owning server so the prompt loop can
build the { server, tool, sessionID, callID } context for the hook.
Rebases and continues anomalyco#28319 onto current dev, where the MCP catalog was
refactored (tool conversion moved to McpCatalog.convertTool), so the
original __mcp tool metadata is derived from the McpTool.server field
instead. Includes unit and plugin-harness integration coverage.
Co-authored-by: Aleksandr Lossenko <29657+egze@users.noreply.github.com>c78e682 to
0197b26Compare…eaders
Introduce a mcp.call.before plugin hook that runs immediately before each
MCP tool invocation and can mutate the outgoing request headers for that
single call. This enables per-session/per-user credentials to be injected
into shared MCP server connections without leaking across sessions.
Core pieces:
- McpCallContext (AsyncLocalStorage) carries the resolved per-call headers.
- makeMcpFetch() wraps the transport fetch and merges the context headers
on top of the static config headers at request time.
- The streamable-http and sse remote transports are wired through the
wrapper; McpTool now records its owning server so the prompt loop can
build the { server, tool, sessionID, callID } context for the hook.
Rebases and continues anomalyco#28319 onto current dev, where the MCP catalog was
refactored (tool conversion moved to McpCatalog.convertTool), so the
original __mcp tool metadata is derived from the McpTool.server field
instead. Includes unit and plugin-harness integration coverage.
Co-authored-by: Aleksandr Lossenko <29657+egze@users.noreply.github.com>0197b26 to
6919074Compare
Issue for this PR
Closes#28225
Type of change
What does this PR do?
Adds a
mcp.call.beforeplugin hook fired once per outbound MCPclient.callTool, with{ server, tool, sessionID, callID }in scope. Plugins can mutateoutput.headers(pre-populated with the server's staticmcp.<name>.headersconfig) to inject identity / tracing headers likeX-Session-Idon a per-call basis. The staticheadersconfig keeps working unchanged.The motivation is forwarding identity (e.g. the current
sessionIDor a user id from the caller) to remote MCP servers. Todaymcp.headersis 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 seesessionID/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 currentsessionIDto outbound MCP HTTP requests.How it works:
packages/plugin/src/index.ts, modeled onchat.headers.AsyncLocalStorage<McpCallStore>(McpCallContext) inpackages/opencode/src/mcp/index.tscarrying the resolved header map for the current call.makeMcpFetchFetchLikewrapper passed via thefetchconstructor option on bothStreamableHTTPClientTransportandSSEClientTransport. It reads the ALS store and merges its headers on top ofinit.headers; falls through unchanged when no store is set (SDK handshake / OAuth probe).packages/opencode/src/session/tools.tswherePlugin.Serviceis already in scope. Plugin defects are caught withEffect.catchCauseand logged atwarn— the call proceeds.__mcp = { server, tool }marker so the tool resolver knows which dispatched tools are MCP-sourced.Example plugin:
Out of scope: stdio MCP servers (different transport mechanism),
listTools/getPrompt/readResource(onlycallTool), forwarding inbound REST request headers fromPOST /session/:id/messageinto MCP calls (separate feature, can be built on top of this hook later).Relationship to #28299. That PR adds W3C
traceparentpropagation on the same MCP HTTP/SSE transports via its own built-infetchwrapper. The two PRs touch the same extension point (the transports'fetchoption) and will conflict if merged independently — the second to merge needs to compose the two wrappers (call one'sFetchLike, 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 injecttraceparentitself 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.tscover theMcpCallContextAsyncLocalStorage primitive (in-scope / out-of-scope / concurrent isolation), the__mcpmetadata marker on tools, andmakeMcpFetch'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.tsmocks both transport constructors and asserts each receives afetchfunction.An integration test at
packages/opencode/test/mcp/call-before-integration.test.tsstands up a realPlugin.Service(same layer pattern astest/plugin/trigger.test.ts), registers a fake plugin via a tmpdir +opencode.json, firesmcp.call.before, and asserts the resolved headers reach a stubbedfetch. A second test verifies the plugin-throws path: the fake plugin mutatesoutput.headers["x-from-plugin"]before throwing, the trigger swallows the defect viaEffect.catchCause, and the pre-throw mutations still reachfetch.47/47 tests in
test/mcp/pass.tsc --noEmitis clean for bothpackages/opencodeandpackages/plugin.Also verified end-to-end against a local Streamable HTTP MCP server — a tiny Bun script that exposes one
echo_headerstool returning the HTTP headers it received. Two modes:opencode.jsonplus a plugin implementingmcp.call.beforeto addx-session-id/x-call-id/x-user-id. Asked the agent to call the tool; the headers arrived at the MCP server alongside the staticAuthorizationfrommcp.<server>.headers, and the tool echoed the same set back into the conversation.opencode serveand HTTP.POST /sessionto create a session, thenPOST /session/:id/message. Thex-session-idthat reached the MCP server matched the session ID returned byPOST /sessionexactly. Connect-time handshake / SSE-reconnect traffic correctly carried only the static config headers, with no per-call leak.Screenshots / recordings
Checklist