feat(mcp): bridge runtime-added MCP tools into the core tool registry#37684
Open
paperview wants to merge 633 commits into
Open
feat(mcp): bridge runtime-added MCP tools into the core tool registry#37684paperview wants to merge 633 commits into
paperview wants to merge 633 commits into
Conversation
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>
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.
Author
|
Stacking note: this PR's branch is currently stacked on top of #37678 for local verification. If maintainers prefer, I can rebase off Rebase caveat: |
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
Follow-up to #37678 (comment thread). Fixes the runtime MCP feature
(#37308) for the primary user-facing prompt path.
Type of change
What does this PR do?
The daemon has two independent MCP services:
@opencode/MCP(packages/opencode/src/mcp) — target of the runtimePOST /mcpendpoint added in feat(core): add runtime MCP controls #37308. Used bySessionPrompt.prompt(the direct v2/session/:id/messageand/session/:id/prompt_asyncendpoints).@opencode/v2/MCP(packages/core/src/mcp) — the service the v2SessionRunnerreads tools from viaToolRegistry.materialize. Only populated from static config files at layer construction.They live in different Effect layer graphs (opencode's server app graph vs. the
locationServicesgraph) 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/mcpendpoint 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:
Runtimemap +RuntimeToolEntryshape to the existing core reconciler (packages/core/src/tool/mcp.ts). The reconcile function now iterates bothmcp.tools()andRuntime.list(), wrapping each runtime entry into aTool.makewith a promise-basedexecutethat calls back into the opencode-side MCP client via an injected closure. Same permission/output shape as MCP-service-sourced tools.packages/opencode/src/mcp), afterstoreClientsucceeds (or theToolListChangednotification fires), callMcpTool.Runtime.set(server, tools)with a wrappedclient.callToolexecute. Onclient.onclose, callMcpTool.Runtime.remove(server).McpEvent.ToolsChangedevent (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):POST /mcp?directory=<sync_dir>with{name: \"sync-bot-65\", config: {type: \"remote\", url: \".../mcp/chatbot/65?turn_token=...\"}}→ daemon connects, fetchestools/list, storesupdate_fieldins.defs[\"sync-bot-65\"]and callsMcpTool.Runtime.set.POST /session?directory=<sync_dir>), sets model to kimi-for-coding/k3, posts a user prompt.SessionModelRequest.preparebuilds the LLM request withsync-bot-65_update_fieldin the tools list — becauseToolRegistry.materializesees it viaTools.Service.registerfrom the bridged reconcile.method: \"tools/call\", params: {name: \"update_field\", arguments: {field: \"favorite_color\", value: \"blue\"}}).Before this PR: Kimi's assistant response was "I don't have an
update_fieldtool 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
@opencode/v2/MCP.tools()path unchanged.toolChoicefor the legacy/api/session/:id/promptendpoint (which goes through SessionPending → SessionRunner) is still hard-coded atpackages/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