Skip to content

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

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

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 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.

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

kitlangton and 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>
opencode-agent Bot and others added 27 commits July 17, 2026 10:41
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants