feat(mcp): add mcp.call.before plugin hook for per-call MCP request headers#28319
Open
egze wants to merge 13 commits into
Open
feat(mcp): add mcp.call.before plugin hook for per-call MCP request headers#28319egze wants to merge 13 commits into
egze wants to merge 13 commits into
Conversation
Contributor
|
The following comment was made by an LLM, it may be inaccurate: Potential duplicate found:
|
04aecbf to
c8ade75
Compare
Contributor
|
Thanks for updating your PR! It now meets our contributing guidelines. 👍 |
1f6427e to
2523b78
Compare
1 task
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>
16ad53b to
861c386
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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