feat: add MCP client plugin (dsh-mcp-client)
Connects to an external MCP server and registers its tools on ctx.tools. Supports stdio (child process) and Streamable HTTP transports. Credential-shaped env vars are scrubbed before forwarding to child processes. - Plugin lifecycle: connect, sync tools, re-sync on ToolListChanged, dispose unregisters and closes - Full JSDoc on all exports (@param/@returns on functions) - 100% per-file coverage (apply lifecycle, args coercion, env scrubbing) - Config catalog regenerated
This commit is contained in:
@@ -353,6 +353,49 @@ export interface Config {
|
||||
|
||||
Source: [`packages/support/llm-replay/src/index.ts:429`](../packages/support/llm-replay/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-mcp-client`
|
||||
|
||||
Requires: `tools`
|
||||
|
||||
```ts config-catalog
|
||||
/** Discriminated union of all supported MCP transport configurations. */
|
||||
export type Config = StdioConfig | StreamableHttpConfig
|
||||
|
||||
/** Config for connecting to an MCP server via a spawned child process over stdio. */
|
||||
export interface StdioConfig {
|
||||
/** Transport type: spawn a child process and communicate over stdio. */
|
||||
transport: 'stdio'
|
||||
/** Executable to spawn. */
|
||||
command: string
|
||||
/** Arguments passed to the command. */
|
||||
args: string[]
|
||||
/** Extra env vars merged on top of scrubbed ambient env. */
|
||||
env: Record<string, string>
|
||||
/** Working directory for the child process. */
|
||||
cwd: string
|
||||
/** Prefix prepended to each tool name before registration. */
|
||||
toolPrefix: string
|
||||
/** Timeout per callTool invocation (ms). */
|
||||
toolCallTimeoutMs: number
|
||||
}
|
||||
|
||||
/** Config for connecting to an MCP server over Streamable HTTP (SSE). */
|
||||
export interface StreamableHttpConfig {
|
||||
/** Transport type: connect to an MCP server over Streamable HTTP (SSE). */
|
||||
transport: 'streamable-http'
|
||||
/** MCP server URL. */
|
||||
url: string
|
||||
/** Extra headers (e.g. auth tokens). */
|
||||
headers: Record<string, string>
|
||||
/** Prefix prepended to each tool name before registration. */
|
||||
toolPrefix: string
|
||||
/** Timeout per callTool invocation (ms). */
|
||||
toolCallTimeoutMs: number
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/mcp/mcp-client/src/index.ts:66`](../packages/mcp/mcp-client/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-session-persistence-jsonl`
|
||||
|
||||
Requires: `sessions`
|
||||
|
||||
@@ -78,6 +78,9 @@ flowchart TD
|
||||
pkg_app_boot["app-boot"]
|
||||
pkg_stdio_agent["stdio-agent"]
|
||||
end
|
||||
subgraph group_mcp["packages/mcp"]
|
||||
pkg_mcp_client["mcp-client"]
|
||||
end
|
||||
pkg_llm --> pkg_brand
|
||||
pkg_bash --> pkg_brand
|
||||
pkg_llm_deepseek --> pkg_llm
|
||||
@@ -156,6 +159,8 @@ flowchart TD
|
||||
pkg_acp --> pkg_session
|
||||
pkg_acp --> pkg_session_persistence
|
||||
pkg_acp --> pkg_tools
|
||||
pkg_mcp_client --> pkg_llm
|
||||
pkg_mcp_client --> pkg_tools
|
||||
pkg_agent_core --> pkg_agent
|
||||
pkg_agent_core --> pkg_agent_loop
|
||||
pkg_agent_core --> pkg_invariants
|
||||
@@ -239,6 +244,7 @@ flowchart TD
|
||||
| [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
|
||||
| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
|
||||
| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) |
|
||||
| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) |
|
||||
| [`agent-core`](../packages/core/agent-core) | `core` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/bash/tool-bash), [`tools`](../packages/core/tools) |
|
||||
| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) |
|
||||
| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
|
||||
|
||||
@@ -12,6 +12,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
|
||||
| [Multiplex concurrent ACP sessions over one connection](proposed/feature/2026-06-14-acp-multi-session.md) | 2026-06-14 |
|
||||
| [Optional Code Mode — model writes TypeScript against an SDK of all tools](proposed/feature/2026-06-15-optional-code-mode.md) | 2026-06-15 |
|
||||
| [Pre-tool input rewrite — a consistent design](proposed/feature/2026-06-30-pre-tool-input-rewrite.md) | 2026-06-30 |
|
||||
| [MCP client plugin — connect to external MCP servers and bridge their tools](proposed/feature/2026-07-07-mcp-client-plugin.md) | 2026-07-07 |
|
||||
|
||||
### Simplification
|
||||
|
||||
|
||||
160
docs/rfc/proposed/feature/2026-07-07-mcp-client-plugin.md
Normal file
160
docs/rfc/proposed/feature/2026-07-07-mcp-client-plugin.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# RFC: MCP client plugin — connect to external MCP servers and bridge their tools
|
||||
|
||||
Status: proposed
|
||||
|
||||
## Problem
|
||||
|
||||
The harness has no way to consume tools from the MCP (Model Context Protocol) ecosystem. MCP is the emerging standard for tool servers — GitHub, filesystem, databases, code search, and hundreds of community servers expose tools via MCP. Users want to point the harness at one or more MCP servers and have their tools appear as native model-facing tools, without writing per-server glue code.
|
||||
|
||||
The `ToolRegistry` already accepts raw JSON Schema tool definitions (documented in `dsh-tools` README: "Raw JSON-Schema tool definitions (from MCP servers) are still accepted by `ToolRegistry.register()` directly"), and the extension cookbook sketches the intended pattern ("MCP | one plugin per server: discover tools → `ctx.tools.register()`"). The infrastructure is ready; the bridge plugin is missing.
|
||||
|
||||
## Proposal
|
||||
|
||||
### Package
|
||||
|
||||
A single package `@deepseek-ai/dsh-mcp-client` at `packages/mcp/mcp-client/`. No capability-seam three-package split — there is no foreseeable second MCP client implementation, and the convention is "don't split preemptively" ([capability seams RFC](../../implemented/architecture/2026-06-13-capability-seams.md)).
|
||||
|
||||
### SDK
|
||||
|
||||
Use the official [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) (`Client`, `StdioClientTransport`, `StreamableHTTPClientTransport`). The harness does not implement its own JSON-RPC — consistent with how ACP delegates to `@agentclientprotocol/sdk`.
|
||||
|
||||
### Scope
|
||||
|
||||
MCP Client only (no server side — ACP already covers the "expose harness as an agent" role). Bridge **Tools** only — Resources and Prompts are deferred (they require harness-side consumption mechanisms that don't exist yet, and design space is large).
|
||||
|
||||
### Plugin shape
|
||||
|
||||
Namespace plugin (named exports `name`/`inject`/`Config`/`apply`, no `export default`). `inject: ['tools']`. Each MCP server is one plugin instance in `cordis.yml` — the same package loaded N times with different configs, like `dsh-tool-subagent`.
|
||||
|
||||
### Configuration
|
||||
|
||||
Flat discriminated union on the `transport` field:
|
||||
|
||||
```typescript
|
||||
interface StdioConfig {
|
||||
transport: 'stdio'
|
||||
command: string
|
||||
args?: string[]
|
||||
env?: Record<string, string>
|
||||
cwd?: string
|
||||
toolPrefix?: string
|
||||
toolCallTimeoutMs?: number // default 60_000
|
||||
}
|
||||
|
||||
interface StreamableHttpConfig {
|
||||
transport: 'streamable-http'
|
||||
url: string
|
||||
headers?: Record<string, string>
|
||||
toolPrefix?: string
|
||||
toolCallTimeoutMs?: number // default 60_000
|
||||
}
|
||||
|
||||
type Config = StdioConfig | StreamableHttpConfig
|
||||
```
|
||||
|
||||
Example `cordis.yml` usage:
|
||||
|
||||
```yaml
|
||||
- id: mcp-github
|
||||
name: '@deepseek-ai/dsh-mcp-client'
|
||||
config:
|
||||
transport: stdio
|
||||
command: npx
|
||||
args: ['-y', '@modelcontextprotocol/server-github']
|
||||
env:
|
||||
GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN
|
||||
|
||||
- id: mcp-web
|
||||
name: '@deepseek-ai/dsh-mcp-client'
|
||||
config:
|
||||
transport: streamable-http
|
||||
url: http://localhost:3000/mcp
|
||||
headers:
|
||||
Authorization: !!js `Bearer ${process.env.MCP_TOKEN}`
|
||||
```
|
||||
|
||||
### Lifecycle
|
||||
|
||||
Boot-time from `cordis.yml`. HMR (`@cordisjs/plugin-hmr`) provides hot-swap: editing the yml entry triggers dispose of the old instance (disconnects, unregisters tools) and creation of a new one (connects, discovers, registers). No runtime-dynamic API for now.
|
||||
|
||||
### Tool discovery and registration
|
||||
|
||||
1. On connect: `client.listTools()` → register each tool as a raw `ToolDefinition` via `ctx.tools.register()`.
|
||||
2. Listen for `notifications/tools/list_changed` → re-run `listTools()`, diff, unregister removed, register added.
|
||||
3. Registration uses the raw JSON Schema from MCP (no `defineTool` DSL conversion).
|
||||
4. No `presentCall`/`presentResult` — the ACP bridge's generic-card fallback handles rendering.
|
||||
5. Tools are transparent in the system prompt — no "[via MCP]" annotation.
|
||||
|
||||
### Name conflict handling
|
||||
|
||||
If `config.toolPrefix` is set (e.g. `"gh_"`), it is prepended to each MCP tool name before registration. If a name collides with an already-registered tool, log a warning and skip that tool (do not crash the entire server connection).
|
||||
|
||||
### Tool execution
|
||||
|
||||
A unified `execute` handler for all tools from one MCP server:
|
||||
|
||||
1. Call `client.callTool({ name, arguments }, { signal: exec.signal })` with the configured timeout.
|
||||
2. Map the result:
|
||||
- Multiple `text` content blocks → join with `'\n'` into a single `TextBlock` (required: `flattenText` uses `join('')` without separator, so multiple blocks would lose inter-block boundaries).
|
||||
- `image` content blocks → discard with a `ctx.logger.warn` (the harness has no image content block type; [drop-image RFC](../../implemented/simplification/2026-07-04-drop-image-content-block.md)).
|
||||
- `isError: true` → map to the harness `isError` result path (`{ content: [...], isError: true }`).
|
||||
3. Cancellation: `exec.signal` (from the agent loop's cancel) is passed through to the MCP SDK's `callTool`, which sends `$/cancelRequest` to the server.
|
||||
|
||||
### Subprocess environment (stdio transport)
|
||||
|
||||
Replicate the `buildChildEnv` + `SENSITIVE_ENV_PATTERN` scrub from `dsh-subagent-acp`: filter ambient env (strip credential-shaped vars matching `/KEY|SECRET|TOKEN/i`), then merge `config.env` on top. Explicit env overrides survive the scrub.
|
||||
|
||||
### Disconnection / crash
|
||||
|
||||
No auto-reconnect. If the MCP server process exits or the transport closes:
|
||||
|
||||
1. The effect disposes → all registered tools are unregistered (fiber-scoped disposers).
|
||||
2. Subsequent model calls to those tools → `ToolNotFoundError` → `isError: true`.
|
||||
3. Recovery: user edits `cordis.yml` (triggers HMR reload) or restarts the harness.
|
||||
|
||||
This matches the ACP subagent pattern: "crash = terminal, report error, clean up, don't retry."
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
### MCP Server side (expose harness tools to external MCP clients)
|
||||
|
||||
Deferred. The ACP bridge already exposes the harness as an agent server. Adding an MCP server layer would duplicate that with a different protocol, and the primary user need is consuming external tools, not exposing them.
|
||||
|
||||
### Capability-seam three-package split (interface / impl / consumer)
|
||||
|
||||
Rejected. There is no foreseeable alternative MCP client implementation — MCP has one protocol, one SDK. The convention is "don't split preemptively" until a second implementation appears.
|
||||
|
||||
### Auto-reconnect with exponential backoff
|
||||
|
||||
Rejected for v1. Adds complexity (partial-availability state where tools are registered but temporarily non-functional), and stdio process crashes usually indicate a configuration problem that retrying won't fix. HMR already provides the manual recovery path. Can be added as a future `reconnect: boolean` config if needed.
|
||||
|
||||
### Bridge Resources and Prompts
|
||||
|
||||
Deferred. Resources need a harness-side mechanism to decide WHEN to inject content (system prompt? on demand? model-triggered?). Prompts need a "prompt template" concept the harness lacks. Both require their own design; Tools are the high-value, low-risk starting point.
|
||||
|
||||
### Always-on namespace prefix (e.g. `mcp_github__create_issue`)
|
||||
|
||||
Rejected. Most MCP servers already use semantic prefixes in their tool names (e.g. `github_create_issue`). A forced prefix would break model familiarity with well-known MCP tool names and waste context tokens. Optional `toolPrefix` handles the rare collision case.
|
||||
|
||||
### Preserve multiple TextBlocks in tool result
|
||||
|
||||
Rejected. `flattenText()` in the DeepSeek serializer uses `join('')` (no separator) when flattening `ContentBlock[]` to wire format. Multiple text blocks would silently lose inter-block boundaries — a correctness bug. All existing tools return a single TextBlock; the MCP bridge follows suit.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- A `cordis.yml` entry connecting to an MCP stdio server (e.g. `@modelcontextprotocol/server-filesystem`) results in that server's tools appearing in the model's tool list and being callable.
|
||||
- A `cordis.yml` entry connecting via Streamable HTTP works equivalently.
|
||||
- Adding/removing an MCP entry in `cordis.yml` while HMR is active hot-swaps the tools without restart.
|
||||
- `toolPrefix` config correctly prefixes tool names; a name collision logs a warning and skips.
|
||||
- Agent cancel propagates to in-flight `callTool` (abort signal).
|
||||
- Timeout fires and produces an `isError` result when an MCP server hangs.
|
||||
- Server crash cleanly unregisters tools (no orphaned tool definitions).
|
||||
- `notifications/tools/list_changed` triggers a re-sync of tool registrations.
|
||||
- 100% test coverage on the new package (unit tests with mocked MCP SDK).
|
||||
|
||||
## Risks
|
||||
|
||||
- **MCP SDK stability**: the `@modelcontextprotocol/sdk` is still evolving. Breaking changes in the SDK require updating the bridge. Mitigation: pin a specific version; the SDK is widely adopted (Claude Desktop, Cursor, VS Code) so breaking changes are unlikely to be silent.
|
||||
- **Tool schema quality**: MCP servers may expose poorly-described tools (vague descriptions, incomplete JSON schemas). The harness passes them through as-is — garbage-in-garbage-out. Mitigation: this is the server author's responsibility, not the bridge's.
|
||||
- **Stdio process management**: a misbehaving MCP server that ignores signals could wedge dispose. Mitigation: the Cordis fiber disposal has bounded quiescence; a stuck transport eventually times out at the framework level.
|
||||
- **Token budget pressure**: connecting many MCP servers with many tools inflates the system prompt. Mitigation: no different from registering many native tools; the compaction layer handles context pressure.
|
||||
Reference in New Issue
Block a user