Skip to content

feat(mcp): support tool-search-managed MCP servers#12

Open
jms830 wants to merge 4 commits into
M0Rf30:mainfrom
jms830:feat/mcp-integration
Open

feat(mcp): support tool-search-managed MCP servers#12
jms830 wants to merge 4 commits into
M0Rf30:mainfrom
jms830:feat/mcp-integration

Conversation

@jms830
Copy link
Copy Markdown

@jms830 jms830 commented Apr 29, 2026

Add first-class MCP server support inside opencode-tool-search so MCP tools can be discovered, indexed, deferred, and invoked through the same tool-search flow as native OpenCode tools.

OpenCode’s native MCP registration path bypasses plugin tool.definition hooks, which means large MCP tool sets always enter model context in full. This change lets users configure MCP servers under the tool-search plugin instead, wrapping each MCP tool as a plugin tool so descriptions can be deferred, searched, and loaded on demand.

The implementation supports local stdio MCP servers and remote streamable HTTP MCP servers, including optional auth headers. It also aligns MCP tool execution with OpenCode’s native behavior by passing CallToolResultSchema, enabling progress-aware timeout handling, and normalizing empty-argument tools with an internal _placeholder field that is stripped before calling the MCP server.

This also adds a defensive message-history transform that repairs orphaned assistant tool_use parts by inserting synthetic tool_result parts. This prevents provider errors when prior tool results are missing after compaction or interrupted tool execution.

Finally, MCP schemas are normalized before Zod conversion by recursively stripping Anthropic-incompatible JSON Schema metadata keywords such as contentEncoding and contentMediaType, while preserving ordinary properties with those names.

Review focus:

  • Whether plugin-owned MCP registration is the right long-term shape versus native OpenCode MCP integration.
  • Whether _placeholder is acceptable for zero-argument tools, or if there is a cleaner provider-agnostic pattern.
  • Whether the orphan tool_use repair hook belongs in this plugin or should be split out.
  • Whether the MCP config shape should remain under plugin options as mcp.servers.

Validation run locally:

  • npm run typecheck
  • npm run lint
  • npm run test
  • npm run build
  • npm run check:exports

Local integration smoke test connected the plugin to nine MCP servers and discovered 158 plugin-managed tools, including context-mode, qmd, scrapling, jcodemunch, agent-mail, and remote gateway tools.

jms830 added 4 commits April 28, 2026 21:41
Add opt-in MCP support directly inside opencode-tool-search,
eliminating the need for a separate opencode-mcp-adapter plugin.

When config.mcp.servers is set, the plugin connects to each MCP
server (local stdio or remote HTTP with auth headers) at startup,
lists their tools, and registers each as a native plugin tool.
The standard tool.definition deferral logic then applies, including
alwaysLoad exemption, without requiring opencode core changes.

This solves opencode-tool-search#9: MCP tools previously bypassed
the tool.definition hook in opencode core, so descriptions could
not be deferred. By wrapping MCP tools as native plugin tools,
they pass through the hook naturally.

Architecture:
- src/mcp/client.ts: per-server connection (stdio + http with headers)
- src/mcp/wrap.ts: json-schema -> zod conversion + tool wrapping
- src/mcp/index.ts: connectAll orchestrator (parallel, isolated)
- src/types.ts: MCPServerConfig + MCPConfig public API
- src/plugin.ts: 5-line integration spreading mcpTools into tool map

Connection failures are isolated; one bad server does not prevent
other servers' tools from being registered.

Tests: 11 new tests across connectAll and jsonSchemaToZod (54 total).
Build: clean ESM bundle, dist size ~15KB.
…ive OpenCode

Empty-args MCP tools (no `properties` declared) were producing tool
definitions with `properties: {}` that strict providers reject:
- Anthropic: `content.0.tool_use.input: Field required`
- OpenAI Responses: `Missing required parameter: input[N].arguments`
- Vertex Gemini, SGLang, Antigravity: similar empty-schema rejections

The rejection orphaned the recorded `tool_use` block (no `tool_result`
ever produced), cascading into "tool_use ids were found without
tool_result blocks" on every subsequent turn.

Fixes (src/mcp/wrap.ts):
- Inject optional `_placeholder` field when wrapped MCP tool has zero
  properties so the rendered JSON Schema always has at least one prop
- Strip `_placeholder` from args before forwarding to MCP server
- Coerce `undefined` args to `{}` (handles Claude no-arg tool calls,
  see anomalyco/opencode #9020)

Robustness (src/mcp/client.ts):
- Pass `CallToolResultSchema` to `client.callTool` matching native
  OpenCode `convertMcpTool` invocation
- Pass `{ resetTimeoutOnProgress: true, timeout: 60_000 }` so
  long-running MCP tools (browser automation, search indexers) keep
  the timeout window alive via progress notifications

Tests (tests/mcp.test.ts): +9 cases covering injection, stripping,
real-args preservation, undefined-args handling, error/no-output paths.

Refs: anomalyco/opencode #9020 #8184 #9233 #9131 #15041 #20637
Adds an `experimental.chat.messages.transform` hook that walks the
message history before each request and synthesizes placeholder
`tool_result` blocks for any `tool_use` (Anthropic-shape) or `tool`
(OpenCode-internal shape) parts that lack a matching `tool_result`.

Why:

Once an orphan `tool_use` enters the message history (interrupted
tool call, mid-stream provider error, aborted shell command, model
provider returning before a tool_result lands, etc.), the session
becomes permanently rejected by Anthropic with:

    "tool_use ids were found without tool_result blocks immediately
     after"

Without this hook the only recovery is `/undo` or starting a fresh
session. The validator heals history in-flight on every request so
corrupted sessions become recoverable: the next turn ships a
synthetic placeholder result for the orphan and the conversation
continues.

Refs: anomalyco/opencode #21326 #21489 #16749 #14367 #17065

Implementation:

- New file: src/hooks/tool-pair-validator.ts (~200 LoC, single dep on
  @opencode-ai/sdk types).
- Adapted from oh-my-openagent (MIT, code-yeongyu) — replaced their
  shared logger with a no-op default that callers can override via
  `setToolPairValidatorLogger`.
- Wired into src/plugin.ts as a second hook entry alongside the
  existing `tool.definition` and `experimental.chat.system.transform`
  hooks.
- 11 new tests covering: matching pairs (both shapes), missing
  results, synthetic user injection, partial matches, dedup,
  sessionID preservation, no-op cases, logger override.

Total tests: 74 passing (63 prior + 11 new).
Strip `contentEncoding` and `contentMediaType` keywords from the
`inputSchema` before Zod conversion. Anthropic rejects tool schemas
that contain these keywords (which appear on MCP tools handling
binary blobs, e.g., base64-encoded image params).

The strip is recursive and skips key matching when descending
through a `properties` map, so a property literally named
`contentEncoding` survives.

Defensive-only at the moment — Zod's current `toJSONSchema` does
not propagate these keywords on output, so users haven't observed
the bug yet. Adding the strip futureproofs us against:
- Future Zod versions that may preserve the keywords via .meta()
- MCP servers we haven't wrapped yet that expose binary params

Pattern adapted from oh-my-openagent's `sanitizeJsonSchema`
(MIT, code-yeongyu).

Tests: +4 cases covering top-level strip, nested object property
strip, array item strip, and the "property literally named
contentEncoding survives" edge case.

Total tests: 78 passing (74 prior + 4 new).
@draxxris
Copy link
Copy Markdown

draxxris commented May 5, 2026

Should include updates to the README.md as well?

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.

2 participants