Skip to content

feat(mcp): bridge runtime-added MCP tools into the core tool registry - #37684

Closed
paperview wants to merge 633 commits into
anomalyco:devfrom
paperview:feat/mcp-runtime-bridge
Closed

feat(mcp): bridge runtime-added MCP tools into the core tool registry#37684
paperview wants to merge 633 commits into
anomalyco:devfrom
paperview:feat/mcp-runtime-bridge

Conversation

@paperview

Copy link
Copy Markdown

Issue for this PR

Follow-up to #37678 (comment thread). Fixes the runtime MCP feature
(#37308) for the primary user-facing prompt path.

Type of change

  • Bug fix
  • New feature

What does this PR do?

The daemon has two independent MCP services:

  • @opencode/MCP (packages/opencode/src/mcp) — target of the runtime POST /mcp endpoint added in feat(core): add runtime MCP controls #37308. Used by SessionPrompt.prompt (the direct v2 /session/:id/message and /session/:id/prompt_async endpoints).
  • @opencode/v2/MCP (packages/core/src/mcp) — the service the v2 SessionRunner reads tools from via ToolRegistry.materialize. Only populated from static config files at layer construction.

They live in different Effect layer graphs (opencode's server app graph vs. the locationServices graph) so bridging them at the Effect service level requires a large layer-plumbing refactor — several unrelated deps (Location, Form, Integration, Credential) would need to enter the server graph. As a result, tools added via the runtime /mcp endpoint were invisible to the v2 SessionRunner — the primary user-facing prompt path — and the runtime MCP feature was effectively broken for MCP-mediated conversation flows.

This PR bridges them via a small in-process registry:

  • Add a module-level Runtime map + RuntimeToolEntry shape to the existing core reconciler (packages/core/src/tool/mcp.ts). The reconcile function now iterates bothmcp.tools() and Runtime.list(), wrapping each runtime entry into a Tool.make with a promise-based execute that calls back into the opencode-side MCP client via an injected closure. Same permission/output shape as MCP-service-sourced tools.
  • In the opencode-side MCP service (packages/opencode/src/mcp), after storeClient succeeds (or the ToolListChanged notification fires), call McpTool.Runtime.set(server, tools) with a wrapped client.callTool execute. On client.onclose, call McpTool.Runtime.remove(server).
  • The existing McpEvent.ToolsChanged event (already published by the opencode side) triggers the core reconcile which re-scans both sources, so runtime-added tools become visible to the SessionRunner on the next event fire.

Not a service-level bridge (which would require the layer-graph plumbing above) — a shared in-process registry, which the two independent Effect service instances both read/write. This is intentional: it's a smaller, contained diff that unblocks the primary user-facing path today and can be replaced with a proper unified MCP service later if maintainers prefer.

How did you verify your code works?

Full end-to-end via an external orchestrator (a sync_server chatbot bridge, Kimi K3 via kimi-for-coding/k3):

  1. Orchestrator: POST /mcp?directory=<sync_dir> with {name: \"sync-bot-65\", config: {type: \"remote\", url: \".../mcp/chatbot/65?turn_token=...\"}} → daemon connects, fetches tools/list, stores update_field in s.defs[\"sync-bot-65\"] and calls McpTool.Runtime.set.
  2. Orchestrator: creates a session (POST /session?directory=<sync_dir>), sets model to kimi-for-coding/k3, posts a user prompt.
  3. Runner: SessionModelRequest.prepare builds the LLM request with sync-bot-65_update_field in the tools list — because ToolRegistry.materialize sees it via Tools.Service.register from the bridged reconcile.
  4. Kimi K3 invokes the tool (method: \"tools/call\", params: {name: \"update_field\", arguments: {field: \"favorite_color\", value: \"blue\"}}).
  5. The orchestrator's MCP endpoint receives the call, applies the mutation, and downstream state reflects it.

Before this PR: Kimi's assistant response was "I don't have an update_field tool available in my toolset" — the tool never reached the LLM request. After: Kimi calls it correctly.

Typecheck clean: bun turbo typecheck --filter=@opencode-ai/schema --filter=@opencode-ai/core --filter=opencode.

Scope / not addressed

  • Static config-file MCP entries continue to flow through the core @opencode/v2/MCP.tools() path unchanged.
  • Prompts and resources on the runtime side are not bridged — this PR is tools-only, since that's the SessionRunner's read path. Bridging prompts/resources would follow the same pattern.
  • Per-turn toolChoice for the legacy /api/session/:id/prompt endpoint (which goes through SessionPending → SessionRunner) is still hard-coded at packages/core/src/session/model-request.ts:90. That's a separate wiring effort — see the follow-up call-out in feat(session): expose toolChoice via PromptInput and agent config #37678.

Related

Screenshots / recordings

Not a UI change.

Checklist

  • I have tested my changes locally (end-to-end via external MCP orchestrator + Kimi K3)
  • I have not included unrelated changes in this PR

kitlangtonand others added 30 commits July 10, 2026 09:46
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Julian Coy <julian@ex-machina.co>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: James Long <jlongster@users.noreply.github.com>
Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Dax Raad <d@ironbay.co>
kitlangtonand others added 21 commits July 17, 2026 11:55
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Dax Raad <thdxr@users.noreply.github.com>
Co-authored-by: Dax Raad <826656+thdxr@users.noreply.github.com>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
The internal LLM layer already accepts toolChoice ("auto" | "required" |
"none") end to end (aisdk.ts:421, session/llm.ts:47/234/319, native
runtime + request, per-provider lowerings). The only missing edges were:
- HTTP: PromptInput did not accept a toolChoice field, so external
orchestrators calling POST /session/:id/message or
/session/:id/prompt_async could not set it per turn.
- Config: agent v2 config did not accept tool_choice, so an unset value
was silently dropped and providers always received "auto".
This wires the missing edges:
1. Add optional toolChoice to PromptInput. When present, stored on a
session-keyed in-memory Map for the duration of the turn (avoids
changing SessionV1.User schema, which would have downstream decoding
knock-on effects). Cleared on loop exit; not persisted across daemon
restart (next turn falls back to agent config or the default).
2. Add tool_choice to the v1 agent config schema and KNOWN_KEYS
allowlist, and map it onto AgentInfo.toolChoice in the agent loader.
3. At the LLM request-build site in SessionPrompt.prompt (line ~1298),
replace the hard-coded ternary with:
pendingToolChoice.get(sessionID)
?? agent.toolChoice
?? (format.type === "json_schema" ? "required" : undefined)
Behavior when unset is unchanged: toolChoice stays undefined for regular
turns, "required" for json_schema output. Backwards compatible.
Motivation: models like Kimi K3 that under-invoke tools under "auto"
default cannot currently be forced to call MCP tools through the daemon.
This is especially painful for the runtime MCP feature (anomalyco#37308) where
external orchestrators mediating structured flows have no way to say
"this turn must produce a tool call."
Related dead PRs (both closed by 1-month auto-cleanup, not rejection):
- anomalyco#14090 (feature request, closed as completed but never landed)
- anomalyco#32521, anomalyco#32518 (parallel fix PRs, both auto-closed)
- anomalyco#32465 (still-open bug report)
See anomalyco#37672 for the full write-up.
Not addressed in this PR (deliberate scope):
- Legacy /api/session/:id/prompt (goes through SessionPending.admit +
the core SessionRunner) does not propagate toolChoice through the
pending queue. That's a separate wiring effort and is out of scope
here; direct v2 endpoints (/session/:id/message,
/session/:id/prompt_async) do carry it correctly.
- SessionV1.User schema was left unchanged on purpose; the session-map
route avoids invalidating persisted messages and share subscribers.
The daemon has two independent MCP services:
- @opencode/MCP (packages/opencode/src/mcp) — target of the runtime
POST /mcp endpoint added in anomalyco#37308. Used by SessionPrompt.prompt
(the direct v2 /session/:id/message and /session/:id/prompt_async
endpoints).
- @opencode/v2/MCP (packages/core/src/mcp) — the service the v2
SessionRunner reads tools from via ToolRegistry.materialize. Only
populated from static config files at layer construction.
They live in different Effect layer graphs (opencode's server app graph
vs. the locationServices graph) so they can't be bridged at the Effect
service level without a large layer-plumbing refactor. As a result,
tools added via the runtime /mcp endpoint were invisible to the v2
SessionRunner — the primary user-facing prompt path — and the runtime
MCP feature was effectively broken for MCP-mediated conversation flows.
Bridge them via a small in-process registry:
- Add a module-level `Runtime` map + `RuntimeToolEntry` shape to the
existing core reconciler (packages/core/src/tool/mcp.ts). The
reconcile function now iterates BOTH `mcp.tools()` and
`Runtime.list()`, wrapping each runtime entry into a Tool.make with
a promise-based execute that calls back into the opencode-side MCP
client via an injected closure. Same permission/output shape as
MCP-service-sourced tools.
- In the opencode-side MCP service (packages/opencode/src/mcp), after
`storeClient` succeeds (or the ToolListChanged notification fires),
call `McpTool.Runtime.set(server, tools)` with a wrapped
`client.callTool` execute. On `client.onclose`, call
`McpTool.Runtime.remove(server)`.
- The existing McpEvent.ToolsChanged event (already published by the
opencode side) triggers the core reconcile which re-scans both
sources, so runtime-added tools become visible to the SessionRunner
on the next event fire.
Verified end-to-end with an external orchestrator (sync_server chatbot
bridge, Kimi K3 via kimi-for-coding): runtime add → tools/list → LLM
turn → Kimi invokes the tool → mutation propagates through the
orchestrator's callback.
Related to anomalyco#37672 (my earlier consolidation issue). The other half of
this — threading per-turn `toolChoice` through SessionPending →
SessionRunner → SessionModelRequest.prepare — remains an open item;
see follow-up in anomalyco#37678.
@paperview

Copy link
Copy Markdown
Author

Stacking note: this PR's branch is currently stacked on top of #37678 for local verification. If maintainers prefer, I can rebase off dev and drop the toolChoice commit — or land #37678 first and this rebases cleanly.

Rebase caveat:dev moved a packages/core/src/tool/mcp.ts refactor after I branched. A rebase will need a light manual resolve on that file; the bridge lives at the same call sites so the resolve should be straightforward.

@github-actions

Copy link
Copy Markdown
Contributor

Automated PR Cleanup

Thank you for contributing to opencode.

Due to the high volume of PRs from users and AI agents, we periodically close older PRs using automated criteria so maintainers can focus review time on the most active and community-supported contributions.

This PR was closed because it matched the following cleanup criteria:

  • The PR was created more than 1 month ago
  • The PR had fewer than 2 positive reactions
  • Positive reactions are counted as thumbs-up, heart, celebration, or rocket reactions on the PR

PRs created within the last month are not affected by this cleanup.

If you believe this PR was closed incorrectly, or if you are still actively working on it, please leave a comment explaining why it should be reopened. A maintainer can review and reopen it if appropriate.

Thanks again for taking the time to contribute.

Sign up for freeto 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.

8 participants

@paperview@kitlangton@thdxr@jlongster@rekram1-node@nexxeln@StarpTech@simonklee