Skip to content

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

Open
egze wants to merge 1 commit 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 1 commit into
anomalyco:devfrom
egze:feature/mcp-call-before-hook

Conversation

@egze

@egzeegze 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 makeMcpFetchFetchLike 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:

exportdefault{"mcp.call.before": async(input,output)=>{output.headers["x-session-id"]=input.sessionIDoutput.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-actionsgithub-actionsBot 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
egzeforce-pushed the feature/mcp-call-before-hook branch from 04aecbf to c8ade75CompareMay 19, 2026 08:51
@github-actionsgithub-actionsBot 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
egzeforce-pushed the feature/mcp-call-before-hook branch 4 times, most recently from 1f6427e to 2523b78CompareMay 20, 2026 10:24
@egze
egzeforce-pushed the feature/mcp-call-before-hook branch from 16ad53b to 861c386CompareJune 1, 2026 08:16
@zinepush

Copy link
Copy Markdown

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 dev, where the MCP catalog was refactored since you opened this: tool conversion moved into McpCatalog.convertTool (in session/tools.ts), so the __mcp tool metadata the PR relied on no longer exists. I reworked that part to derive the { server, tool, sessionID, callID } call context from a new McpTool.server field instead. Everything else (the McpCallContext AsyncLocalStorage + makeMcpFetch header merge, the streamable-http/sse fetch wiring) applied cleanly.

While doing it I also fixed a test-isolation bug: transport-fetch-wiring.test.ts registered a global mock.module for the SDK transports that leaked into the other MCP test files and made their real-connection tests fail. I rewrote that test to verify the wrapper against a real in-process server plus a direct makeMcpFetch unit, so no global module mock is needed.

Rebased branch — single commit, credited to you as co-author:
https://github.com/zinepush/opencode/tree/feature/mcp-call-before-hook

typecheck and the full MCP suite pass (72/0) on current dev.

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

egze commented Jul 31, 2026

Copy link
Copy Markdown
ContributorAuthor

Hi @zinepush Thank you for testing it and improving. I'll try to cherry-pick and report back if I don't succeed.

@egze
egzeforce-pushed the feature/mcp-call-before-hook branch from 861c386 to 6a32387CompareJuly 31, 2026 10:18
egze added a commit to egze/opencode that referenced this pull request Jul 31, 2026
…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
egzeforce-pushed the feature/mcp-call-before-hook branch from 6a32387 to c78e682CompareJuly 31, 2026 10:19
egze added a commit to egze/opencode that referenced this pull request Jul 31, 2026
…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

egze commented Jul 31, 2026

Copy link
Copy Markdown
ContributorAuthor

@zinepush I think I did it, please double check.

egze added a commit to egze/opencode that referenced this pull request Jul 31, 2026
…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
egzeforce-pushed the feature/mcp-call-before-hook branch from c78e682 to 0197b26CompareJuly 31, 2026 13:37
…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
egzeforce-pushed the feature/mcp-call-before-hook branch from 0197b26 to 6919074CompareAugust 4, 2026 08:17
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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)

2 participants

@egze@zinepush