feat(mcp): adopt mainstream server-qualified MCP tool naming

Research across 8 multi-server agent clients (Claude Code, Codex, Gemini
CLI, VS Code, Cline, Roo Code, Goose, OpenCode) showed all of them keep
the server namespace in model-facing MCP tool names; the RFC's premise
for raw names ("servers already prefix their tools") is false for the
official GitHub/filesystem/Sentry servers.

- Config: drop toolPrefix; require serverName ([A-Za-z0-9_-]{1,32}),
  duplicate serverName fails the later instance at load (per-root
  reservation, released on dispose)
- Names: always mcp__<serverName>__<rawName>; normalize to the DeepSeek
  64-char [A-Za-z0-9_-] contract with a deterministic 12-hex identity
  hash on lossy normalization; raw name is the only thing sent on the
  wire (tools/call)
- Sync: two-phase fetch/swap — fetch failure keeps the previous
  generation; a swap conflict rolls back the whole generation (never a
  partial set); duplicate raw names reject the tool list
- RFC: moved to implemented/ (status + skeleton rewritten per the
  format contract), naming design + tier-level test coverage recorded
- Tests: naming algorithm unit suite; keyless Streamable HTTP e2e
  against an in-process StreamableHTTPServerTransport (namespace
  discovery, execution, per-request auth headers); dotted-name
  normalization e2e via a new fixture tool
This commit is contained in:
lintianle
2026-07-13 23:46:49 +08:00
parent 7fff51d848
commit af3152fefe
11 changed files with 923 additions and 422 deletions
+13 -5
View File
@@ -365,6 +365,12 @@ export type Config = StdioConfig | StreamableHttpConfig
export interface StdioConfig {
/** Transport type: spawn a child process and communicate over stdio. */
transport: 'stdio'
/**
* Stable local namespace for this server's model-facing tool names
* (`mcp__<serverName>__<rawName>`). Must match `[A-Za-z0-9_-]{1,32}` and be
* unique across live mcp-client instances.
*/
serverName: string
/** Executable to spawn. */
command: string
/** Arguments passed to the command. */
@@ -373,8 +379,6 @@ export interface StdioConfig {
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
}
@@ -383,18 +387,22 @@ export interface StdioConfig {
export interface StreamableHttpConfig {
/** Transport type: connect to an MCP server over Streamable HTTP (SSE). */
transport: 'streamable-http'
/**
* Stable local namespace for this server's model-facing tool names
* (`mcp__<serverName>__<rawName>`). Must match `[A-Za-z0-9_-]{1,32}` and be
* unique across live mcp-client instances.
*/
serverName: string
/** 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)
Source: [`packages/mcp/mcp-client/src/index.ts:91`](../packages/mcp/mcp-client/src/index.ts)
## `@deepseek-ai/dsh-session-persistence-jsonl`
+1 -1
View File
@@ -13,7 +13,6 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
| [Code Mode — the model writes TypeScript against the tool registry](proposed/feature/2026-06-15-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 |
| [Claude Code and Codex subagent backends (out-of-process delegation to external coding agents)](proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md) | 2026-07-07 |
| [MCP client plugin — connect to external MCP servers and bridge their tools](proposed/feature/2026-07-07-mcp-client-plugin.md) | 2026-07-07 |
| [Repeat-tool-call guard plugin](proposed/feature/2026-07-08-repeat-tool-guard.md) | 2026-07-08 |
### Simplification
@@ -64,6 +63,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
| [SessionStore fork API](implemented/feature/2026-06-30-session-store-fork-api.md) | 2026-06-30 |
| [Subagent lifecycle enrichment — lastAssistantMessage (observe-only)](implemented/feature/2026-06-30-subagent-observe-enrich.md) | 2026-06-30 |
| [Explicit model-facing tool order](implemented/feature/2026-07-06-explicit-tool-order.md) | 2026-07-06 |
| [MCP client plugin — connect to external MCP servers and bridge their tools](implemented/feature/2026-07-07-mcp-client-plugin.md) | 2026-07-07 |
### Simplification
@@ -0,0 +1,212 @@
# RFC: MCP client plugin — connect to external MCP servers and bridge their tools
Status: implemented
## Problem
The harness had 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 was ready; the bridge plugin was missing.
## Decision
### 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'
serverName: string // required namespace, ^[A-Za-z0-9_-]{1,32}$
command: string
args?: string[]
env?: Record<string, string>
cwd?: string
toolCallTimeoutMs?: number // default 60_000
}
interface StreamableHttpConfig {
transport: 'streamable-http'
serverName: string // required namespace, ^[A-Za-z0-9_-]{1,32}$
url: string
headers?: Record<string, string>
toolCallTimeoutMs?: number // default 60_000
}
type Config = StdioConfig | StreamableHttpConfig
```
`serverName` is the stable local identity that namespaces this server's tools in the model-facing name (below). It is deliberately user configuration, NOT the remote `serverInfo.name`: the remote name is untrusted input, is not unique across deployments (prod and staging instances of one server report the same name), and may change on server upgrade — none of which may silently rename model-facing tools. A duplicate `serverName` across live instances is a configuration error: the later instance fails at load with an actionable message, never silent shadowing or skipping. A short `serverName` (`gh`) is also the knob for shortening public names.
Example `cordis.yml` usage:
```yaml
- id: mcp-github
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: github
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:
serverName: web
transport: streamable-http
url: http://localhost:3000/mcp
headers:
Authorization: !!js `Bearer ${process.env.MCP_TOKEN}`
```
The model sees `mcp__github__create_issue`, `mcp__github__search_code`, `mcp__web__search`.
### 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. Public names are pure functions of `(serverName, rawName)`, so an HMR swap that keeps `serverName` recreates identical model-facing names — session history and permission rules stay valid — and adding or removing an unrelated server never renames an existing tool.
### Tool discovery and registration
Every MCP tool has two names:
- `rawName` — the exact MCP `Tool.name`, used only on the wire (`tools/call`).
- `publicName` — the globally unique model-facing name registered in the `ToolRegistry`:
mcp__<serverName>__<rawName>
This server-qualified shape is the de-facto standard among multi-server agent clients — every surveyed end-user product qualifies MCP tools by server ([Claude Code](https://code.claude.com/docs/en/agent-sdk/mcp#tool-naming-convention) `mcp__github__list_issues`, [Codex](https://openai.com/index/unrolling-the-codex-agent-loop/) `mcp__weather__get-forecast`, [Gemini CLI](https://geminicli.com/docs/tools/mcp-server/#3-tool-naming-and-namespaces), [VS Code](https://github.com/microsoft/vscode/blob/ab9ec62c6a61e429a9abd612ff220c3f4834c9ea/src/vs/workbench/contrib/mcp/common/mcpServer.ts#L217-L260), [Cline](https://github.com/cline/cline/blob/52fdbb1d72f7324a28142a7ba7678d4b53c902f4/sdk/packages/core/src/extensions/mcp/name-transform.ts#L20-L35), [Roo Code](https://github.com/RooCodeInc/Roo-Code/blob/b867ec9145750d0ae1ff7f02d35406e9bf2a0b16/src/utils/mcp-name.ts#L117-L140), [Goose](https://github.com/block/goose/blob/b3a012cbdde854b0fe14f95b1c48543bf6517c0a/crates/goose/src/agents/extension_manager.rs#L1391-L1441), [OpenCode](https://github.com/anomalyco/opencode/blob/d199b1bff90282a4f9cd6251b5fc7b16875a52f6/packages/opencode/src/mcp/catalog.ts#L117-L120)); the exact `mcp__<server>__<tool>` spelling follows Claude Code and Codex. The `mcp__` marker keeps MCP registrations out of the native tools' namespace and gives permission/telemetry rules a stable shape (`mcp__*`, `mcp__github__*`).
1. On connect: drain `client.listTools()` pagination, derive every tool's `publicName`, then register each as a raw `ToolDefinition` via `ctx.tools.register()`. The MCP JSON Schema and description pass through unchanged (no `defineTool` DSL conversion); only the model-facing `name` is replaced.
2. Listen for `notifications/tools/list_changed` → re-run the same sync (dispose previous generation, register new). Deterministic names mean unchanged tools keep their names across re-syncs.
3. The executor closes over `rawName`; the public name is never sent to the server and never parsed to recover the raw name.
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 beyond the name itself.
### Public name normalization
MCP allows tool names up to 128 characters including `.`; the DeepSeek function-name contract allows `[A-Za-z0-9_-]` and at most 64. Public names are normalized deterministically: invalid characters become `_`, and when replacement or truncation changed the name, a 12-hex-char SHA-256 hash of the `(serverName, rawName)` identity is appended so distinct MCP identities can never collapse into the same public name:
```typescript
function publicToolName(serverName: string, rawName: string): string {
const joined = `mcp__${serverName}__${rawName}`
const normalized = joined.replace(/[^A-Za-z0-9_-]/g, '_')
if (normalized === joined && normalized.length <= 64) return normalized
const hash = sha256(`${serverName}\0${rawName}`).slice(0, 12)
return `${normalized.slice(0, 64 - 13)}_${hash}`
}
```
### Name conflict handling
MCP guarantees tool-name uniqueness only [within one server](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names); cross-server collisions are the norm, not the exception (a [Microsoft Research survey](https://www.microsoft.com/en-us/research/blog/tool-space-interference-in-the-mcp-era-designing-for-agent-compatibility-at-scale/#namespacing-issues-and-naming-ambiguity) of 1,470 servers found 775 colliding tool names; `search` alone appears in 32 servers, and the official GitHub server publishes bare `create_issue`). The always-on namespace makes collisions structurally impossible instead of handling them at collision time:
- Two servers publishing `search` coexist as `mcp__github__search` and `mcp__web__search`.
- A native harness tool named `search` is unaffected.
- Duplicate `serverName` config fails the later instance at load (see Configuration).
- A server listing the same tool name twice is an invalid tool list: the sync throws and the previous generation stays registered.
- A registry conflict during the swap can only mean a foreign tool squats on this server's `mcp__<serverName>__` namespace: the partial generation is rolled back (zero tools from this server) and the error is logged loudly.
Tools are never silently skipped; which tools are available never depends on plugin load order.
### Naming invariants
1. Every MCP tool has the stable identity `(serverName, rawName)`; every active identity has exactly one public name.
2. Public names are deterministic, globally unique, and satisfy the DeepSeek 64-char `[A-Za-z0-9_-]` contract.
3. MCP `tools/call` always receives the original raw name.
4. Connecting, disconnecting, or re-syncing an unrelated server never renames an existing tool.
5. Registration order never determines which tool is available.
### Tool execution
A unified `execute` handler for all tools from one MCP server:
1. Resolve `rawName` (the executor closes over it) and call `client.callTool({ name: rawName, arguments }, { signal: exec.signal })` with the configured timeout — the public name is never sent to the server.
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.
### Raw model-facing tool names with an optional `toolPrefix`
Rejected — this was the original proposal, built on the premise that "most MCP servers already use semantic prefixes in their tool names (e.g. `github_create_issue`)". The premise is false: the official GitHub server publishes `create_issue`, the reference filesystem server `read_file`, Sentry `search_issues` — and the Microsoft survey above shows collisions are common at ecosystem scale. Collision-time prefixing (or warn-and-skip) also makes the available tool set depend on plugin load order, and a tool could be silently renamed when an unrelated server is added — invalidating session history and permission rules mid-conversation. No surveyed multi-server agent product ships raw names.
### Server-only namespace (`github__create_issue`, no `mcp__` marker)
Rejected for v1. It prevents cross-server collisions but does not separate MCP registrations from native harness tools, and it forfeits MCP-wide policy shapes (`mcp__*`). The marker costs 5 characters; the `mcp__<server>__<tool>` spelling matches Claude Code and Codex, maximizing model familiarity. If the ToolRegistry later grows source-aware namespaces, dropping the literal marker can be revisited as a naming-policy change.
### Deriving the namespace from the server-announced `serverInfo.name`
Rejected. The remote name is untrusted, non-unique across deployments, and changeable on upgrade; tool identity and permission rules must not silently follow it. The namespace is local configuration.
### 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.
## Testing
Coverage is named per tier; each behavior lives at the cheapest tier that can express it.
- **Unit** (`tests/mcp-client.spec.ts`, `tests/apply.spec.ts`, mocked MCP SDK): the `publicToolName` algorithm (clean, normalize, truncate-and-hash, determinism, distinct-identity separation), raw-vs-public wire discipline, cross-server and native-tool coexistence, duplicate-`serverName` load failure and reservation release, invalid-tool-list rejection, generation swap/rollback, failed-re-sync retention, result mapping, cancellation, config schema validation. 100% per-file coverage gates the package.
- **E2E** (`tests/mcp-client.e2e.ts`, keyless): the real MCP protocol against the in-repo fixture server, `@modelcontextprotocol/server-everything`, and `@modelcontextprotocol/server-filesystem` over stdio, and against an in-process `StreamableHTTPServerTransport` server over Streamable HTTP — discovery under the namespace, dotted-name normalization end to end, execution round-trips, duplicate-`serverName` rejection, disposal.
- **Snapshot**: deliberately none. MCP tools introduce no new transcript surface — they register as raw `ToolDefinition`s and render through the ACP bridge's generic-card fallback, which the bridge's unit suite already pins (`packages/ui/acp/tests/stream-update.spec.ts`). Adding an MCP server to the snapshot example's `cordis.yml` would mutate the pinned `text-turn` system-prompt fixture (forcing a with-key re-record of every recorded golden) and make every replay depend on spawning an external MCP server process — for zero new rendering behavior. If a later change gives MCP tools their own render intent, that change names its snapshot coverage then.
## Consequences
- A `cordis.yml` entry per MCP server is the entire integration cost: `serverName: filesystem` + a stdio command (or a Streamable HTTP URL) puts `mcp__filesystem__read_file` in the model's tool list, callable, with the raw `read_file` on the wire.
- Public names are part of session history and permission/config surfaces; the naming algorithm is a v1 contract pinned by tests, and changing it after release is a breaking change.
- The `mcp__<serverName>__` qualifier costs tokens on every name. Accepted: descriptions and JSON schemas dominate tool-definition tokens, and the qualifier buys stable identity, collision isolation, and MCP-wide policy shapes (`mcp__*`, `mcp__github__*`).
- **MCP SDK stability**: the `@modelcontextprotocol/sdk` is still evolving; breaking changes require updating the bridge. The version is pinned, and 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; that is the server author's responsibility, not the bridge's.
- **Stdio process management**: a misbehaving MCP server that ignores signals could wedge dispose. The Cordis fiber disposal has bounded quiescence; a stuck transport eventually times out at the framework level.
- Crash recovery is manual (HMR edit or restart) — accepted for v1; a `reconnect` config remains open as future work.
@@ -1,160 +0,0 @@
# 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.
+18 -8
View File
@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-mcp-client
MCP client bridge plugin: connects to external [Model Context Protocol](https://modelcontextprotocol.io/) servers and registers their tools on `ctx.tools`, making them available to the model as native tools.
MCP client bridge plugin: connects to external [Model Context Protocol](https://modelcontextprotocol.io/) servers and registers their tools on `ctx.tools`, making them available to the model as native tools under server-qualified names (`mcp__<serverName>__<rawName>`).
## Usage
@@ -10,6 +10,7 @@ One plugin instance per MCP server in `cordis.yml`:
- id: mcp-github
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: github
transport: stdio
command: npx
args: ['-y', '@modelcontextprotocol/server-github']
@@ -19,36 +20,45 @@ One plugin instance per MCP server in `cordis.yml`:
- id: mcp-web
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: web
transport: streamable-http
url: http://localhost:3000/mcp
headers:
Authorization: !!js '`Bearer ${process.env.MCP_TOKEN}`'
```
HMR hot-swaps: editing the entry triggers disconnect + reconnect without process restart.
The model sees `mcp__github__create_issue`, `mcp__web__search`, … — the same server-qualified shape Claude Code and Codex use. HMR hot-swaps: editing the entry triggers disconnect + reconnect without process restart; an unchanged `serverName` reproduces identical tool names.
## Config
| Field | Transport | Required | Description |
|---|---|---|---|
| `transport` | both | yes | `"stdio"` or `"streamable-http"` |
| `serverName` | both | yes | Namespace for this server's model-facing tool names; `[A-Za-z0-9_-]{1,32}`, unique across live instances |
| `command` | stdio | yes | Executable to spawn |
| `args` | stdio | no | Arguments passed to the command |
| `env` | stdio | no | Extra env vars merged on top of scrubbed ambient env |
| `cwd` | stdio | no | Working directory for the child process |
| `url` | http | yes | MCP server URL |
| `headers` | http | no | Extra headers (e.g. auth tokens) |
| `toolPrefix` | both | no | Prefix prepended to each tool name before registration |
| `toolCallTimeoutMs` | both | no | Timeout per `callTool` invocation (default 60000) |
## Tool naming
Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call`) and the public name `mcp__<serverName>__<rawName>` registered on `ctx.tools`. Public names are normalized to the DeepSeek function-name contract (64 chars, `[A-Za-z0-9_-]`); when replacement or truncation changes the name, a deterministic 12-hex-char hash of `(serverName, rawName)` is appended so distinct tools never collapse into one name. Names are pure functions of `(serverName, rawName)` — connection order, re-syncs, and other servers never rename a tool.
- Two servers publishing the same raw name (e.g. `search`) coexist under their namespaces.
- A duplicate `serverName` across live instances fails the later plugin instance at load.
- A server listing the same tool name twice is rejected as an invalid tool list.
- A foreign registration squatting on this server's namespace rolls back the whole generation (never a partial set), with a loud error.
## Behavior
- On connect: `listTools()` → registers each tool via `ctx.tools.register()`.
- Listens for `notifications/tools/list_changed` → re-syncs tool registrations.
- Tool execute: `client.callTool({ name, arguments }, { signal })` with timeout + abort support.
- Image content in results is discarded with a warning (the harness has no image block type).
- On connect: `listTools()` → registers each tool via `ctx.tools.register()` under its public name.
- Listens for `notifications/tools/list_changed` → re-syncs; a failed re-sync keeps the previous generation registered.
- Tool execute: `client.callTool({ name: rawName, arguments }, { signal })` with timeout + abort support — the public name is never sent to the server.
- Image content in results is discarded with a placeholder (the harness has no image block type).
- On disconnect/crash: all tools are unregistered; no auto-reconnect.
- Name conflicts: if a tool name collides, it is skipped with a warning. Use `toolPrefix` to disambiguate.
## Services consumed
+75 -26
View File
@@ -1,11 +1,14 @@
/**
* MCP client bridge plugin: connects to an external MCP server and registers
* its tools on `ctx.tools`. Each plugin instance connects to one MCP server;
* load multiple instances in `cordis.yml` for multiple servers.
* its tools on `ctx.tools` under server-qualified public names
* (`mcp__<serverName>__<rawName>`). Each plugin instance connects to one MCP
* server; load multiple instances in `cordis.yml` for multiple servers.
*
* Namespace plugin (named exports, no default export). Lifecycle is
* effect-scoped: disposal disconnects from the server and unregisters all
* tools. HMR hot-swaps by disposing the old instance and creating a new one.
* effect-scoped: disposal disconnects from the server, unregisters all tools,
* and releases the `serverName` namespace reservation. HMR hot-swaps by
* disposing the old instance and creating a new one; identical `serverName`
* reproduces identical public tool names.
*
* @module @deepseek-ai/dsh-mcp-client
*/
@@ -28,12 +31,32 @@ export const inject = ['tools']
/** Default timeout for individual MCP tool calls (ms). */
const DEFAULT_TOOL_CALL_TIMEOUT_MS = 60_000
/**
* Valid `serverName`: 132 chars of `[A-Za-z0-9_-]`. Kept well under the
* 64-char public-name budget so typical raw tool names survive unhashed.
*/
const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/
/**
* Live `serverName` reservations per app, keyed off `ctx.root` (multiple apps
* in one process — tests — must not see each other's names). A duplicate
* namespace is a configuration error surfaced at plugin load, never silent
* shadowing.
*/
const activeServerNames = new WeakMap<Context, Set<string>>()
// ---- Config ----
/** 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'
/**
* Stable local namespace for this server's model-facing tool names
* (`mcp__<serverName>__<rawName>`). Must match `[A-Za-z0-9_-]{1,32}` and be
* unique across live mcp-client instances.
*/
serverName: string
/** Executable to spawn. */
command: string
/** Arguments passed to the command. */
@@ -42,8 +65,6 @@ export interface StdioConfig {
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
}
@@ -52,12 +73,16 @@ export interface StdioConfig {
export interface StreamableHttpConfig {
/** Transport type: connect to an MCP server over Streamable HTTP (SSE). */
transport: 'streamable-http'
/**
* Stable local namespace for this server's model-facing tool names
* (`mcp__<serverName>__<rawName>`). Must match `[A-Za-z0-9_-]{1,32}` and be
* unique across live mcp-client instances.
*/
serverName: string
/** 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
}
@@ -68,18 +93,18 @@ export type Config = StdioConfig | StreamableHttpConfig
export const Config = z.union([
z.object({
transport: z.const('stdio'),
serverName: z.string().required().pattern(SERVER_NAME_PATTERN),
command: z.string().required(),
args: z.array(String).default([]),
env: z.dict(String).default({}),
cwd: z.string().default(''),
toolPrefix: z.string().default(''),
toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS),
}),
z.object({
transport: z.const('streamable-http'),
serverName: z.string().required().pattern(SERVER_NAME_PATTERN),
url: z.string().required(),
headers: z.dict(String).default({}),
toolPrefix: z.string().default(''),
toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS),
}),
]) as unknown as z<Config>
@@ -87,42 +112,66 @@ export const Config = z.union([
// ---- Plugin apply ----
export function apply(ctx: Context, config: Config): void {
// Reserve the namespace first: a duplicate `serverName` fails THIS instance
// at load with an actionable error and leaves the earlier instance intact.
ctx.effect(() => {
let names = activeServerNames.get(ctx.root)
if (!names) {
names = new Set()
activeServerNames.set(ctx.root, names)
}
if (names.has(config.serverName)) {
throw new Error(
`mcp-client: serverName "${config.serverName}" is already in use by another mcp-client instance — pick a unique serverName in cordis.yml`,
)
}
names.add(config.serverName)
return () => void names.delete(config.serverName)
}, 'mcp-client.serverName')
const transport = createTransport(config)
const client = new Client(
{ name: 'dsh-mcp-client', version: '0.0.1' },
{ capabilities: {} },
)
// Connect and set up tools. Errors during connect are logged, not thrown
// (the plugin simply has no tools registered).
const opts = {
serverName: config.serverName,
toolCallTimeoutMs: config.toolCallTimeoutMs,
}
// Connect and set up tools. Errors during connect/first sync are logged,
// not thrown (the plugin simply has no tools registered). `ready` resolves
// to an accessor for the CURRENT disposer generation, so the effect
// disposer below always unregisters the live set, not the first one.
const ready = (async () => {
await client.connect(transport)
let disposers = await syncTools(client, ctx, {
toolPrefix: config.toolPrefix,
toolCallTimeoutMs: config.toolCallTimeoutMs,
}, new Map())
let disposers = await syncTools(client, ctx, opts, new Map())
client.setNotificationHandler(
ToolListChangedNotificationSchema,
async () => {
ctx.logger.info('mcp-client: tool list changed, re-syncing')
disposers = await syncTools(client, ctx, {
toolPrefix: config.toolPrefix,
toolCallTimeoutMs: config.toolCallTimeoutMs,
}, disposers)
ctx.logger.info(`mcp-client(${config.serverName}): tool list changed, re-syncing`)
try {
disposers = await syncTools(client, ctx, opts, disposers)
} catch (error) {
// Fetch-phase failure: the previous generation is still registered
// and `disposers` still owns it — keep serving the last good list.
ctx.logger.error(`mcp-client(${config.serverName}): tool re-sync failed: ${String(error)}`)
}
},
)
return disposers
return () => disposers
})().catch((error: unknown) => {
ctx.logger.error(`mcp-client: failed to connect: ${String(error)}`)
return new Map<string, () => void>()
ctx.logger.error(`mcp-client(${config.serverName}): failed to connect: ${String(error)}`)
return () => new Map<string, () => void>()
})
ctx.effect(() => async () => {
const disposers = await ready
for (const dispose of disposers.values()) dispose()
const live = await ready
for (const dispose of live().values()) dispose()
try { await client.close() } catch { /* transport already gone */ }
}, 'mcp-client.connection')
}
+96 -34
View File
@@ -1,37 +1,87 @@
/**
* Tool bridge: discovers MCP tools, registers them on the harness ToolRegistry,
* and handles re-sync when the server's tool list changes.
* Tool bridge: discovers MCP tools, registers them on the harness ToolRegistry
* under deterministic server-qualified public names, and handles re-sync when
* the server's tool list changes.
*
* Naming contract (see the mcp-client RFC "Naming invariants"): every MCP tool
* has the stable identity `(serverName, rawName)`; the model-facing public name
* is `mcp__<serverName>__<rawName>`, normalized to the DeepSeek function-name
* constraints. The raw name is only ever sent on the wire (`tools/call`); the
* public name is never parsed to recover it.
*
* @module
*/
import { createHash } from 'node:crypto'
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
import type { Context } from 'cordis'
import type { ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools'
/** Resolved options relevant to tool bridging. */
export interface ToolBridgeOptions {
toolPrefix: string
serverName: string
toolCallTimeoutMs: number
}
/** State for one sync generation: the current set of disposers keyed by tool name. */
type ToolDisposers = Map<string, () => void>
/** State for one sync generation: the current set of disposers keyed by public name. */
export type ToolDisposers = Map<string, () => void>
/**
* DeepSeek function-name contract: at most 64 characters. Wire-protocol
* constant, not configuration.
*/
const MAX_PUBLIC_NAME_LENGTH = 64
/** DeepSeek function-name contract: only `[A-Za-z0-9_-]` is allowed. */
const INVALID_NAME_CHARS = /[^A-Za-z0-9_-]/g
/** Hex chars of the SHA-256 identity hash appended on lossy normalization. */
const HASH_LENGTH = 12
/**
* Derive the model-facing public name for one MCP tool.
*
* Deterministic pure function of `(serverName, rawName)`: the clean case is
* `mcp__<serverName>__<rawName>` verbatim. When character replacement or
* truncation to the DeepSeek function-name contract (64 chars,
* `[A-Za-z0-9_-]`) changes the name, a 12-hex-char SHA-256 hash of the
* identity is appended so distinct MCP identities never collapse into the
* same public name.
*
* @param serverName - Stable local namespace from plugin config.
* @param rawName - The MCP server's own tool name.
* @returns The globally unique, model-facing ToolRegistry name.
*/
export function publicToolName(serverName: string, rawName: string): string {
const joined = `mcp__${serverName}__${rawName}`
const normalized = joined.replace(INVALID_NAME_CHARS, '_')
if (normalized === joined && normalized.length <= MAX_PUBLIC_NAME_LENGTH) return normalized
const hash = createHash('sha256').update(`${serverName}\0${rawName}`).digest('hex').slice(0, HASH_LENGTH)
return `${normalized.slice(0, MAX_PUBLIC_NAME_LENGTH - HASH_LENGTH - 1)}_${hash}`
}
/**
* Sync the MCP server's tool list into the harness ToolRegistry.
*
* - Calls `client.listTools()` (paginated: drains all pages).
* - Registers each tool as a raw `ToolDefinition`.
* - On name conflict: logs a warning and skips that tool.
* - Returns a disposer map; call each value to unregister.
* Two phases keep the swap safe:
*
* 1. Fetch: drain `client.listTools()` pagination and build the full next
* generation of `ToolDefinition`s under public names. Any failure here
* (network error, duplicate raw name in the server's list) rejects and
* leaves the previous generation registered untouched.
* 2. Swap: dispose the previous generation, register the new one. A registry
* conflict here can only mean a foreign registration squats on this
* server's `mcp__<serverName>__` namespace — the partial generation is
* rolled back (zero tools from this server), the error is logged, and an
* empty map is returned.
*
* @param client - Connected MCP Client instance used to list and call tools.
* @param ctx - Cordis context providing the `tools` service for registration.
* @param opts - Bridge options: tool name prefix and per-call timeout.
* @param previous - Disposer map from a prior sync generation; all entries are
* disposed before re-registering.
* @returns A map of registered tool names to their unregister disposers.
* @param opts - Bridge options: server namespace and per-call timeout.
* @param previous - Disposer map from the prior sync generation; disposed
* during the swap phase (only after the fetch phase succeeded).
* @returns A map of registered public tool names to their unregister
* disposers — the exact set of live registrations owned by this server.
*/
export async function syncTools(
client: Client,
@@ -39,32 +89,43 @@ export async function syncTools(
opts: ToolBridgeOptions,
previous: ToolDisposers,
): Promise<ToolDisposers> {
for (const dispose of previous.values()) dispose()
const disposers: ToolDisposers = new Map()
// Phase 1: fetch and build the next generation without touching the registry.
const definitions = new Map<string, ToolDefinition>()
let cursor: string | undefined
do {
const response = await client.listTools(cursor ? { cursor } : undefined)
for (const tool of response.tools) {
const registeredName = opts.toolPrefix + tool.name
const definition: ToolDefinition = {
name: registeredName,
const publicName = publicToolName(opts.serverName, tool.name)
if (definitions.has(publicName)) {
throw new Error(
`mcp-client(${opts.serverName}): server listed tool "${tool.name}" more than once — invalid tool list`,
)
}
definitions.set(publicName, {
name: publicName,
description: tool.description ?? '',
parameters: tool.inputSchema,
execute: createExecutor(client, tool.name, opts),
}
try {
const dispose = ctx.tools.register(definition)
disposers.set(registeredName, dispose)
} catch {
// Name conflict — another tool with this name is already registered.
ctx.logger.warn(`mcp-client: skipping tool "${registeredName}" (name conflict)`)
}
})
}
cursor = response.nextCursor
} while (cursor)
// Phase 2: swap generations.
for (const dispose of previous.values()) dispose()
const disposers: ToolDisposers = new Map()
try {
for (const [publicName, definition] of definitions) {
disposers.set(publicName, ctx.tools.register(definition))
}
} catch (error) {
// A conflict on an `mcp__<serverName>__`-qualified name means a foreign
// registration occupies this server's namespace. Roll back so the model
// sees either the full generation or none of it — never a partial set.
for (const dispose of disposers.values()) dispose()
ctx.logger.error(`mcp-client(${opts.serverName}): tool registration failed, no tools registered: ${String(error)}`)
return new Map()
}
return disposers
}
@@ -81,16 +142,17 @@ interface McpContentBlock {
}
/**
* Create an execute function for one MCP tool. The executor calls
* `client.callTool` with abort signal and timeout, then maps the result
* to harness ContentBlocks.
* Create an execute function for one MCP tool. The executor closes over the
* raw MCP tool name and calls `client.callTool` with it (never the public
* name), with abort signal and timeout, then maps the result to harness
* ContentBlocks.
*
* When the MCP server returns `isError: true`, the executor throws so that
* the ToolRegistry's catch path produces an `isError` result for the model.
*/
function createExecutor(
client: Client,
mcpToolName: string,
rawName: string,
opts: ToolBridgeOptions,
): ToolDefinition['execute'] {
return async (args: unknown, exec: ToolExecution) => {
@@ -100,7 +162,7 @@ function createExecutor(
// specific "missing required param" error the model can learn from.
const argsObj = (typeof args === 'object' && args !== null ? args : {}) as Record<string, unknown>
const result = await client.callTool(
{ name: mcpToolName, arguments: argsObj },
{ name: rawName, arguments: argsObj },
undefined,
{
...exec.signal ? { signal: exec.signal } : {},
@@ -122,7 +184,7 @@ function createExecutor(
// with optional fallbacks).
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const content: McpContentBlock[] = result.content
const text = extractText(content, mcpToolName)
const text = extractText(content, rawName)
// MCP isError → throw so ToolRegistry produces an isError result for the model.
if ('isError' in result && result.isError === true) {
+151 -48
View File
@@ -10,19 +10,23 @@ import type { Config } from '@deepseek-ai/dsh-mcp-client'
// ---- Mock MCP SDK ----
const mockConnect = vi.fn<() => Promise<void>>()
const mockClose = vi.fn<() => Promise<void>>()
const mockListTools = vi.fn()
const mockCallTool = vi.fn()
const mockSetNotificationHandler = vi.fn()
class MockClient {
connect = mockConnect
close = mockClose
listTools = mockListTools
callTool = mockCallTool
setNotificationHandler = mockSetNotificationHandler
}
// vi.mock factories are hoisted above every import/const, so the mock fns and
// class must be created inside vi.hoisted to exist when the factories run.
const { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotificationHandler, MockClient } = vi.hoisted(() => {
const mockConnect = vi.fn<() => Promise<void>>()
const mockClose = vi.fn<() => Promise<void>>()
const mockListTools = vi.fn()
const mockCallTool = vi.fn()
const mockSetNotificationHandler = vi.fn()
class MockClient {
connect = mockConnect
close = mockClose
listTools = mockListTools
callTool = mockCallTool
setNotificationHandler = mockSetNotificationHandler
}
return { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotificationHandler, MockClient }
})
vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
Client: MockClient,
@@ -36,11 +40,9 @@ vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({
StreamableHTTPClientTransport: vi.fn(),
}))
// ---- Import under test (after mocks) ----
const { apply, name, inject, Config: ConfigSchema } = await import(
'@deepseek-ai/dsh-mcp-client/src/index.ts',
)
// vi.mock is hoisted above static imports, so the module under test sees the
// mocked SDK even through a static import.
import { apply, name, inject, Config as ConfigSchema } from '@deepseek-ai/dsh-mcp-client/src/index.ts'
// ---- Helpers ----
@@ -51,13 +53,22 @@ async function mountRegistry(): Promise<Context> {
return ctx
}
function sleep(ms: number): Promise<void> {
// Annotated binding (not withResolvers<void>()): the tests lint layer runs
// no-invalid-void-type with default options, which rejects the explicit
// type argument in call position but accepts the inferred form.
const gate: PromiseWithResolvers<void> = Promise.withResolvers()
setTimeout(gate.resolve, ms)
return gate.promise
}
const stdioConfig: Config = {
transport: 'stdio',
serverName: 'srv',
command: 'echo',
args: [],
env: {},
cwd: '',
toolPrefix: '',
toolCallTimeoutMs: 60_000,
}
@@ -69,6 +80,37 @@ describe('mcp-client plugin module exports', () => {
expect(inject).toEqual(['tools'])
expect(ConfigSchema).toBeDefined()
})
it('Config schema rejects a missing serverName', () => {
expect(() => ConfigSchema({
transport: 'stdio',
command: 'echo',
} as never)).toThrow()
})
it('Config schema rejects an invalid serverName', () => {
// schemastery unions wrap branch errors in a generic "expected ... but got"
// message, so assert the throw, not the inner pattern text.
expect(() => ConfigSchema({
transport: 'stdio',
serverName: 'bad name!',
command: 'echo',
} as never)).toThrow()
expect(() => ConfigSchema({
transport: 'stdio',
serverName: 'x'.repeat(33),
command: 'echo',
} as never)).toThrow()
})
it('Config schema accepts a valid serverName', () => {
const resolved = ConfigSchema({
transport: 'stdio',
serverName: 'github-prod_1',
command: 'echo',
} as never)
expect(resolved.serverName).toBe('github-prod_1')
})
})
describe('apply (plugin lifecycle)', () => {
@@ -86,39 +128,78 @@ describe('apply (plugin lifecycle)', () => {
ctx = await mountRegistry()
})
it('connects, syncs tools, and registers a notification handler', async () => {
it('connects, syncs tools under the namespace, and registers a notification handler', async () => {
apply(ctx, stdioConfig)
await new Promise(r => setTimeout(r, 50))
await sleep(50)
expect(mockConnect).toHaveBeenCalled()
expect(mockListTools).toHaveBeenCalled()
expect(mockSetNotificationHandler).toHaveBeenCalled()
expect(ctx.tools.get('remote')).toBeDefined()
})
it('applies toolPrefix from config during sync', async () => {
apply(ctx, { ...stdioConfig, toolPrefix: 'mcp_' })
await new Promise(r => setTimeout(r, 50))
expect(ctx.tools.get('mcp_remote')).toBeDefined()
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
expect(ctx.tools.get('remote')).toBeUndefined()
})
it('logs error and registers no tools when connect fails', async () => {
it('rejects a duplicate serverName at load and leaves the first instance intact', async () => {
apply(ctx, stdioConfig)
await sleep(50)
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
expect(() => { apply(ctx, stdioConfig) }).toThrow(/serverName "srv" is already in use/)
// First instance unaffected.
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
})
it('releases the serverName reservation on dispose', async () => {
const first = new Context()
await first.plugin(SystemPrompt)
await first.plugin(ToolRegistry)
apply(first, stdioConfig)
await sleep(50)
await first.fiber.dispose()
await sleep(50)
// Same root would conflict; a fresh app root reuses the name freely,
// and the disposed instance no longer holds the reservation on its root.
const second = new Context()
await second.plugin(SystemPrompt)
await second.plugin(ToolRegistry)
expect(() => { apply(second, stdioConfig) }).not.toThrow()
})
it('scopes serverName reservations per app root', async () => {
const other = await mountRegistry()
apply(ctx, stdioConfig)
// Same serverName on a DIFFERENT root is fine.
expect(() => { apply(other, stdioConfig) }).not.toThrow()
await sleep(50)
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
expect(other.tools.get('mcp__srv__remote')).toBeDefined()
})
it('logs error and registers no tools when connect fails; dispose is a no-op', async () => {
mockConnect.mockRejectedValue(new Error('connection refused'))
apply(ctx, stdioConfig)
await new Promise(r => setTimeout(r, 50))
await sleep(50)
expect(mockListTools).not.toHaveBeenCalled()
expect(ctx.tools.get('remote')).toBeUndefined()
expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
// Disposal exercises the empty fallback accessor: nothing to unregister,
// close still attempted, no throw.
await ctx.fiber.dispose()
await sleep(50)
expect(mockClose).toHaveBeenCalled()
})
it('re-syncs tools on ToolListChanged notification', async () => {
apply(ctx, stdioConfig)
await new Promise(r => setTimeout(r, 50))
await sleep(50)
expect(ctx.tools.get('remote')).toBeDefined()
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
// Simulate the notification handler being invoked with a new tool list.
mockListTools.mockResolvedValue({
@@ -130,33 +211,55 @@ describe('apply (plugin lifecycle)', () => {
const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
await handler()
expect(ctx.tools.get('remote')).toBeUndefined()
expect(ctx.tools.get('updated')).toBeDefined()
expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
expect(ctx.tools.get('mcp__srv__updated')).toBeDefined()
})
it('effect disposer unregisters tools and closes client', async () => {
it('keeps the previous generation when a re-sync fails', async () => {
apply(ctx, stdioConfig)
await new Promise(r => setTimeout(r, 50))
await sleep(50)
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
expect(ctx.tools.get('remote')).toBeDefined()
mockListTools.mockRejectedValue(new Error('flaky server'))
const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
// Must not reject (contained), and must keep the last good generation.
await handler()
// Trigger disposal by disposing a child scope.
// Cordis ctx.effect registers the disposer; calling scope dispose runs it.
await ctx.fiber.dispose()
await new Promise(r => setTimeout(r, 50))
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
})
it('effect disposer unregisters the CURRENT generation and closes client', async () => {
// Load through ctx.plugin so ONLY the plugin's fiber is disposed — the
// registry must survive to observe the unregistration.
const fiber = ctx.plugin({ name: 'mcp-client', inject: ['tools'], apply }, stdioConfig)
await sleep(50)
// Advance to a second generation first.
mockListTools.mockResolvedValue({
tools: [{ name: 'updated', inputSchema: { type: 'object' } }],
nextCursor: undefined,
})
const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
await handler()
expect(ctx.tools.get('mcp__srv__updated')).toBeDefined()
await fiber.dispose()
await sleep(50)
expect(mockClose).toHaveBeenCalled()
// The live (second) generation was unregistered, not just the first.
expect(ctx.tools.get('mcp__srv__updated')).toBeUndefined()
})
it('effect disposer handles client.close failure gracefully', async () => {
mockClose.mockRejectedValue(new Error('already closed'))
apply(ctx, stdioConfig)
await new Promise(r => setTimeout(r, 50))
await sleep(50)
// Should not throw when dispose is triggered.
await ctx.fiber.dispose()
await new Promise(r => setTimeout(r, 50))
await sleep(50)
expect(mockClose).toHaveBeenCalled()
})
@@ -164,16 +267,16 @@ describe('apply (plugin lifecycle)', () => {
it('uses streamable-http config path', async () => {
const httpConfig: Config = {
transport: 'streamable-http',
serverName: 'web',
url: 'http://localhost:3000/mcp',
headers: { Authorization: 'Bearer x' },
toolPrefix: '',
toolCallTimeoutMs: 30_000,
}
apply(ctx, httpConfig)
await new Promise(r => setTimeout(r, 50))
await sleep(50)
expect(mockConnect).toHaveBeenCalled()
expect(ctx.tools.get('remote')).toBeDefined()
expect(ctx.tools.get('mcp__web__remote')).toBeDefined()
})
})
@@ -51,5 +51,15 @@ server.registerTool('image', {
],
}))
// Dotted name: legal in MCP, illegal in the DeepSeek function-name contract.
// Exercises the bridge's normalize-and-hash public-name path end to end.
server.registerTool('admin.reset', {
title: 'Admin Reset Tool',
description: 'Tool with a dotted name (normalization test).',
inputSchema: {},
}, async () => ({
content: [{ type: 'text', text: 'reset done' }],
}))
const transport = new StdioServerTransport()
await server.connect(transport)
+202 -79
View File
@@ -1,22 +1,29 @@
/**
* End-to-end tests for dsh-mcp-client. Exercises the REAL MCP protocol over
* stdio transport against:
* 1. A self-written fixture server (controlled edge cases)
* End-to-end tests for dsh-mcp-client. Exercises the REAL MCP protocol against:
* 1. A self-written fixture server over stdio (controlled edge cases)
* 2. @modelcontextprotocol/server-everything (official integration test server)
* 3. @modelcontextprotocol/server-filesystem (real filesystem operations)
* 4. An in-process StreamableHTTPServerTransport server over Streamable HTTP
*
* No API key needed — all servers are local/keyless.
*/
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import { z } from 'zod'
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import { CallId } from '@deepseek-ai/dsh-llm'
import { apply } from '@deepseek-ai/dsh-mcp-client/src/index.ts'
import { publicToolName } from '@deepseek-ai/dsh-mcp-client/src/tools.ts'
import type { Config } from '@deepseek-ai/dsh-mcp-client'
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
@@ -38,16 +45,31 @@ async function mountRegistry(): Promise<Context> {
/** Apply the MCP client plugin and wait for tools to be registered. */
async function applyAndWait(ctx: Context, config: Config, timeoutMs = 20_000): Promise<void> {
const { apply } = await import('@deepseek-ai/dsh-mcp-client/src/index.ts')
const toolsReady = new Promise<void>((resolve, reject) => {
const timer = setTimeout(
() => { reject(new Error(`applyAndWait timed out after ${timeoutMs}ms — no tools/change event`)) },
timeoutMs,
)
ctx.on('tools/change', () => { clearTimeout(timer); resolve() })
})
// Annotated bindings (not withResolvers<void>()): the tests lint layer runs
// no-invalid-void-type with default options, which rejects the explicit
// type argument in call position but accepts the inferred form.
const gate: PromiseWithResolvers<void> = Promise.withResolvers()
const timer = setTimeout(
() => { gate.reject(new Error(`applyAndWait timed out after ${timeoutMs}ms — no tools/change event`)) },
timeoutMs,
)
ctx.on('tools/change', () => { clearTimeout(timer); gate.resolve() })
apply(ctx, config)
await toolsReady
await gate.promise
}
function sleep(ms: number): Promise<void> {
const gate: PromiseWithResolvers<void> = Promise.withResolvers()
setTimeout(gate.resolve, ms)
return gate.promise
}
/** Narrow a result content block to its text, failing the test on any other shape. */
function textOf(block: unknown): string {
if (block && typeof block === 'object' && 'text' in block && typeof block.text === 'string') {
return block.text
}
throw new Error(`expected a text content block, got ${JSON.stringify(block)}`)
}
let callSeq = 0
@@ -62,11 +84,11 @@ describe('fixture server — controlled scenarios', () => {
const fixtureConfig: Config = {
transport: 'stdio',
serverName: 'fixture',
command: process.execPath,
args: ['--import', tsxLoader, fixtureServerPath],
env: { TSX_TSCONFIG_PATH: repoTsconfig },
cwd: packageDir,
toolPrefix: '',
toolCallTimeoutMs: 15_000,
}
@@ -77,21 +99,37 @@ describe('fixture server — controlled scenarios', () => {
afterAll(async () => {
if (ctx) await ctx.fiber.dispose()
await new Promise(r => setTimeout(r, 200))
await sleep(200)
})
it('discovers all fixture tools', () => {
it('discovers all fixture tools under the server namespace', () => {
const schemas = ctx.tools.schemas()
const names = schemas.map(s => s.name)
expect(names).toContain('add')
expect(names).toContain('greet')
expect(names).toContain('fail')
expect(names).toContain('image')
expect(names).toContain('mcp__fixture__add')
expect(names).toContain('mcp__fixture__greet')
expect(names).toContain('mcp__fixture__fail')
expect(names).toContain('mcp__fixture__image')
// Raw names are not registered.
expect(names).not.toContain('add')
})
it('normalizes the dotted tool name with a deterministic hash suffix', () => {
const publicName = publicToolName('fixture', 'admin.reset')
expect(publicName).toMatch(/^mcp__fixture__admin_reset_[0-9a-f]{12}$/)
expect(ctx.tools.get(publicName)).toBeDefined()
})
it('executes the dotted tool via its normalized public name', async () => {
const result = await ctx.tools.execute({
callId: nextCallId(), name: publicToolName('fixture', 'admin.reset'), arguments: {},
})
expect(result.isError).toBe(false)
expect(result.content[0]).toEqual({ type: 'text', text: 'reset done' })
})
it('executes add(2, 3) → "5"', async () => {
const result = await ctx.tools.execute({
callId: nextCallId(), name: 'add', arguments: { a: 2, b: 3 },
callId: nextCallId(), name: 'mcp__fixture__add', arguments: { a: 2, b: 3 },
})
expect(result.isError).toBe(false)
expect(result.content[0]).toEqual({ type: 'text', text: '5' })
@@ -99,7 +137,7 @@ describe('fixture server — controlled scenarios', () => {
it('executes greet("World") → "Hello, World!"', async () => {
const result = await ctx.tools.execute({
callId: nextCallId(), name: 'greet', arguments: { name: 'World' },
callId: nextCallId(), name: 'mcp__fixture__greet', arguments: { name: 'World' },
})
expect(result.isError).toBe(false)
expect(result.content[0]).toEqual({ type: 'text', text: 'Hello, World!' })
@@ -107,7 +145,7 @@ describe('fixture server — controlled scenarios', () => {
it('executes fail() → isError result', async () => {
const result = await ctx.tools.execute({
callId: nextCallId(), name: 'fail', arguments: {},
callId: nextCallId(), name: 'mcp__fixture__fail', arguments: {},
})
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ type: 'text' })
@@ -115,49 +153,35 @@ describe('fixture server — controlled scenarios', () => {
it('executes image() → image placeholder', async () => {
const result = await ctx.tools.execute({
callId: nextCallId(), name: 'image', arguments: {},
callId: nextCallId(), name: 'mcp__fixture__image', arguments: {},
})
expect(result.isError).toBe(false)
const text = (result.content[0] as { type: string; text: string }).text
const text = textOf(result.content[0])
expect(text).toContain('Here is an image:')
expect(text).toContain('[image: image/png, content discarded]')
expect(text).toContain('End of image.')
})
})
describe('fixture server — toolPrefix', () => {
let ctx: Context
beforeAll(async () => {
ctx = await mountRegistry()
await applyAndWait(ctx, {
describe('fixture server — duplicate serverName', () => {
it('rejects a second instance with the same serverName on one root', async () => {
const ctx = await mountRegistry()
const config: Config = {
transport: 'stdio',
serverName: 'dup',
command: process.execPath,
args: ['--import', tsxLoader, fixtureServerPath],
env: { TSX_TSCONFIG_PATH: repoTsconfig },
cwd: packageDir,
toolPrefix: 'fx_',
toolCallTimeoutMs: 15_000,
})
}
await applyAndWait(ctx, config)
expect(() => { apply(ctx, config) }).toThrow(/serverName "dup" is already in use/)
await ctx.fiber.dispose()
await sleep(200)
}, 30_000)
afterAll(async () => {
if (ctx) await ctx.fiber.dispose()
await new Promise(r => setTimeout(r, 200))
})
it('registers tools with prefix', () => {
expect(ctx.tools.get('fx_add')).toBeDefined()
expect(ctx.tools.get('fx_greet')).toBeDefined()
expect(ctx.tools.get('add')).toBeUndefined()
})
it('executes prefixed tool', async () => {
const result = await ctx.tools.execute({
callId: nextCallId(), name: 'fx_add', arguments: { a: 10, b: 20 },
})
expect(result.content[0]).toEqual({ type: 'text', text: '30' })
})
})
describe('fixture server — disposal', () => {
@@ -165,21 +189,21 @@ describe('fixture server — disposal', () => {
const ctx = await mountRegistry()
await applyAndWait(ctx, {
transport: 'stdio',
serverName: 'fixture',
command: process.execPath,
args: ['--import', tsxLoader, fixtureServerPath],
env: { TSX_TSCONFIG_PATH: repoTsconfig },
cwd: packageDir,
toolPrefix: '',
toolCallTimeoutMs: 15_000,
})
// Tools are registered before dispose.
expect(ctx.tools.get('add')).toBeDefined()
expect(ctx.tools.get('mcp__fixture__add')).toBeDefined()
expect(ctx.tools.schemas().length).toBeGreaterThanOrEqual(4)
// Dispose should complete without throwing.
await ctx.fiber.dispose()
await new Promise(r => setTimeout(r, 200))
await sleep(200)
}, 30_000)
})
@@ -190,11 +214,11 @@ describe('server-everything — official test server', () => {
const config: Config = {
transport: 'stdio',
serverName: 'everything',
command: join(localBin, 'mcp-server-everything'),
args: ['stdio'],
env: {},
cwd: '',
toolPrefix: '',
toolCallTimeoutMs: 30_000,
}
@@ -205,43 +229,40 @@ describe('server-everything — official test server', () => {
afterAll(async () => {
if (ctx) await ctx.fiber.dispose()
await new Promise(r => setTimeout(r, 500))
await sleep(500)
})
it('discovers tools from server-everything', () => {
const schemas = ctx.tools.schemas()
const names = schemas.map(s => s.name)
expect(names).toContain('echo')
expect(names).toContain('get-sum')
expect(names).toContain('get-tiny-image')
expect(names).toContain('mcp__everything__echo')
expect(names).toContain('mcp__everything__get-sum')
expect(names).toContain('mcp__everything__get-tiny-image')
expect(names.length).toBeGreaterThanOrEqual(8)
})
it('executes echo({ message: "hello" }) → "Echo: hello"', async () => {
const result = await ctx.tools.execute({
callId: nextCallId(), name: 'echo', arguments: { message: 'hello' },
callId: nextCallId(), name: 'mcp__everything__echo', arguments: { message: 'hello' },
})
expect(result.isError).toBe(false)
const text = (result.content[0] as { type: string; text: string }).text
expect(text).toBe('Echo: hello')
expect(textOf(result.content[0])).toBe('Echo: hello')
})
it('executes get-sum({ a: 3, b: 7 }) → contains "10"', async () => {
const result = await ctx.tools.execute({
callId: nextCallId(), name: 'get-sum', arguments: { a: 3, b: 7 },
callId: nextCallId(), name: 'mcp__everything__get-sum', arguments: { a: 3, b: 7 },
})
expect(result.isError).toBe(false)
const text = (result.content[0] as { type: string; text: string }).text
expect(text).toContain('10')
expect(textOf(result.content[0])).toContain('10')
})
it('executes get-tiny-image → image placeholder', async () => {
const result = await ctx.tools.execute({
callId: nextCallId(), name: 'get-tiny-image', arguments: {},
callId: nextCallId(), name: 'mcp__everything__get-tiny-image', arguments: {},
})
expect(result.isError).toBe(false)
const text = (result.content[0] as { type: string; text: string }).text
expect(text).toContain('[image: image/png, content discarded]')
expect(textOf(result.content[0])).toContain('[image: image/png, content discarded]')
})
})
@@ -257,11 +278,11 @@ describe('server-filesystem — real filesystem operations', () => {
ctx = await mountRegistry()
const config: Config = {
transport: 'stdio',
serverName: 'filesystem',
command: join(localBin, 'mcp-server-filesystem'),
args: [tempDir],
env: {},
cwd: '',
toolPrefix: '',
toolCallTimeoutMs: 30_000,
}
await applyAndWait(ctx, config)
@@ -269,16 +290,16 @@ describe('server-filesystem — real filesystem operations', () => {
afterAll(async () => {
if (ctx) await ctx.fiber.dispose()
await new Promise(r => setTimeout(r, 500))
await sleep(500)
await rm(tempDir, { recursive: true, force: true })
})
it('discovers filesystem tools', () => {
const schemas = ctx.tools.schemas()
const names = schemas.map(s => s.name)
expect(names).toContain('read_file')
expect(names).toContain('write_file')
expect(names).toContain('list_directory')
expect(names).toContain('mcp__filesystem__read_file')
expect(names).toContain('mcp__filesystem__write_file')
expect(names).toContain('mcp__filesystem__list_directory')
})
it('write_file + read_file round-trip', async () => {
@@ -287,7 +308,7 @@ describe('server-filesystem — real filesystem operations', () => {
// Write via MCP tool
const writeResult = await ctx.tools.execute({
callId: nextCallId(), name: 'write_file', arguments: { path: filePath, content },
callId: nextCallId(), name: 'mcp__filesystem__write_file', arguments: { path: filePath, content },
})
expect(writeResult.isError).toBe(false)
@@ -297,11 +318,10 @@ describe('server-filesystem — real filesystem operations', () => {
// Read back via MCP tool
const readResult = await ctx.tools.execute({
callId: nextCallId(), name: 'read_file', arguments: { path: filePath },
callId: nextCallId(), name: 'mcp__filesystem__read_file', arguments: { path: filePath },
})
expect(readResult.isError).toBe(false)
const text = (readResult.content[0] as { type: string; text: string }).text
expect(text).toContain(content)
expect(textOf(readResult.content[0])).toContain(content)
})
it('list_directory shows written file', async () => {
@@ -309,10 +329,113 @@ describe('server-filesystem — real filesystem operations', () => {
await writeFile(join(tempDir, 'listed.txt'), 'listed')
const result = await ctx.tools.execute({
callId: nextCallId(), name: 'list_directory', arguments: { path: tempDir },
callId: nextCallId(), name: 'mcp__filesystem__list_directory', arguments: { path: tempDir },
})
expect(result.isError).toBe(false)
const text = (result.content[0] as { type: string; text: string }).text
expect(text).toContain('listed.txt')
expect(textOf(result.content[0])).toContain('listed.txt')
})
})
// ---- Streamable HTTP transport ----
describe('streamable-http — in-process MCP server', () => {
let ctx: Context
let httpServer: Server
let baseUrl: string
/** Authorization header values observed by the HTTP server, in arrival order. */
const seenAuth: Array<string | undefined> = []
/**
* Stateless Streamable HTTP endpoint: a fresh McpServer + server transport
* per request (the SDK's documented stateless pattern — no session id, no
* SSE stream to keep). The tool set mirrors a minimal fixture server.
*/
async function handleMcpRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
seenAuth.push(req.headers.authorization)
const server = new McpServer(
{ name: 'http-fixture', version: '1.0.0' },
{ capabilities: { tools: {} } },
)
server.registerTool('ping', {
description: 'Replies pong.',
inputSchema: {},
}, async () => ({
content: [{ type: 'text', text: 'pong' }],
}))
server.registerTool('shout', {
description: 'Upper-cases a message.',
inputSchema: { message: z.string().describe('Message to upper-case') },
}, async args => ({
content: [{ type: 'text', text: args.message.toUpperCase() }],
}))
// Stateless mode: sessionIdGenerator ABSENT (the runtime treats absent and
// explicit-undefined identically; exactOptionalPropertyTypes forbids the
// SDK-documented explicit `sessionIdGenerator: undefined` spelling).
const transport = new StreamableHTTPServerTransport({})
res.on('close', () => { void transport.close(); void server.close() })
// Same exactOptionalPropertyTypes mismatch the client transport factory
// documents (src/transport.ts): the SDK types optional callbacks without
// `| undefined`. The SDK constructed the object; the cast is safe.
await server.connect(transport as Transport)
await transport.handleRequest(req, res)
}
beforeAll(async () => {
httpServer = createServer((req, res) => {
handleMcpRequest(req, res).catch((error: unknown) => {
res.writeHead(500).end(String(error))
})
})
const listening: PromiseWithResolvers<void> = Promise.withResolvers()
httpServer.listen(0, '127.0.0.1', listening.resolve)
await listening.promise
const address = httpServer.address()
if (address === null || typeof address === 'string') throw new Error(`expected a TCP AddressInfo, got ${String(address)}`)
baseUrl = `http://127.0.0.1:${address.port}/mcp`
ctx = await mountRegistry()
const config: Config = {
transport: 'streamable-http',
serverName: 'web',
url: baseUrl,
headers: { Authorization: 'Bearer e2e-test-token' },
toolCallTimeoutMs: 15_000,
}
await applyAndWait(ctx, config)
}, 30_000)
afterAll(async () => {
if (ctx) await ctx.fiber.dispose()
await sleep(200)
const closed: PromiseWithResolvers<void> = Promise.withResolvers()
httpServer.close(() => { closed.resolve() })
await closed.promise
})
it('discovers tools under the server namespace over HTTP', () => {
const names = ctx.tools.schemas().map(s => s.name)
expect(names).toContain('mcp__web__ping')
expect(names).toContain('mcp__web__shout')
})
it('executes ping() → "pong" over HTTP', async () => {
const result = await ctx.tools.execute({
callId: nextCallId(), name: 'mcp__web__ping', arguments: {},
})
expect(result.isError).toBe(false)
expect(result.content[0]).toEqual({ type: 'text', text: 'pong' })
})
it('executes shout({ message }) with args over HTTP', async () => {
const result = await ctx.tools.execute({
callId: nextCallId(), name: 'mcp__web__shout', arguments: { message: 'quiet' },
})
expect(result.isError).toBe(false)
expect(result.content[0]).toEqual({ type: 'text', text: 'QUIET' })
})
it('sends configured headers on every HTTP request', () => {
expect(seenAuth.length).toBeGreaterThan(0)
for (const auth of seenAuth) expect(auth).toBe('Bearer e2e-test-token')
})
})
+145 -61
View File
@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import { syncTools, type ToolBridgeOptions } from '@deepseek-ai/dsh-mcp-client/src/tools.ts'
import { publicToolName, syncTools, type ToolBridgeOptions } from '@deepseek-ai/dsh-mcp-client/src/tools.ts'
import { createTransport } from '@deepseek-ai/dsh-mcp-client/src/transport.ts'
import type { Config } from '@deepseek-ai/dsh-mcp-client'
@@ -40,12 +40,41 @@ async function mountRegistry(): Promise<Context> {
}
const defaultOpts: ToolBridgeOptions = {
toolPrefix: '',
serverName: 'srv',
toolCallTimeoutMs: 60_000,
}
// ---- Tests ----
describe('publicToolName', () => {
it('joins clean names verbatim', () => {
expect(publicToolName('github', 'create_issue')).toBe('mcp__github__create_issue')
expect(publicToolName('everything', 'get-sum')).toBe('mcp__everything__get-sum')
})
it('replaces invalid characters and appends an identity hash', () => {
const name = publicToolName('srv', 'admin.reset')
expect(name).toMatch(/^mcp__srv__admin_reset_[0-9a-f]{12}$/)
expect(name.length).toBeLessThanOrEqual(64)
})
it('truncates over-long names and appends an identity hash', () => {
const rawName = 'a'.repeat(80)
const name = publicToolName('srv', rawName)
expect(name).toHaveLength(64)
expect(name).toMatch(/_[0-9a-f]{12}$/)
expect(name.startsWith('mcp__srv__aaa')).toBe(true)
})
it('is deterministic and collision-free for distinct identities', () => {
// Two raw names that normalize to the same base must not collapse.
const a = publicToolName('srv', 'admin.reset')
const b = publicToolName('srv', 'admin_reset')
expect(a).toBe(publicToolName('srv', 'admin.reset'))
expect(a).not.toBe(b)
})
})
describe('syncTools', () => {
let ctx: Context
@@ -53,7 +82,7 @@ describe('syncTools', () => {
ctx = await mountRegistry()
})
it('registers tools from listTools response', async () => {
it('registers tools under server-qualified public names', async () => {
const client = createMockClient([
{ name: 'greet', description: 'Say hello', inputSchema: { type: 'object', properties: { name: { type: 'string' } } } },
{ name: 'add', description: 'Add numbers', inputSchema: { type: 'object', properties: {} } },
@@ -62,44 +91,85 @@ describe('syncTools', () => {
const disposers = await syncTools(client as never, ctx, defaultOpts, new Map())
expect(disposers.size).toBe(2)
expect(ctx.tools.get('greet')).toBeDefined()
expect(ctx.tools.get('add')).toBeDefined()
expect(ctx.tools.get('mcp__srv__greet')).toBeDefined()
expect(ctx.tools.get('mcp__srv__add')).toBeDefined()
// Raw names are NOT registered.
expect(ctx.tools.get('greet')).toBeUndefined()
expect(ctx.tools.get('add')).toBeUndefined()
})
it('applies toolPrefix to registered names', async () => {
const client = createMockClient([
{ name: 'create_issue', description: 'Create an issue', inputSchema: { type: 'object' } },
])
it('lets two servers publish the same raw name side by side', async () => {
const clientA = createMockClient([{ name: 'search', inputSchema: { type: 'object' } }])
const clientB = createMockClient([{ name: 'search', inputSchema: { type: 'object' } }])
const disposers = await syncTools(client as never, ctx, { ...defaultOpts, toolPrefix: 'gh_' }, new Map())
await syncTools(clientA as never, ctx, { ...defaultOpts, serverName: 'github' }, new Map())
await syncTools(clientB as never, ctx, { ...defaultOpts, serverName: 'web' }, new Map())
expect(disposers.size).toBe(1)
expect(ctx.tools.get('gh_create_issue')).toBeDefined()
expect(ctx.tools.get('create_issue')).toBeUndefined()
expect(ctx.tools.get('mcp__github__search')).toBeDefined()
expect(ctx.tools.get('mcp__web__search')).toBeDefined()
})
it('skips tools with conflicting names and logs warning', async () => {
// Pre-register a tool with the same name.
it('coexists with a native tool of the same raw name', async () => {
ctx.tools.register({
name: 'existing',
description: 'Already here',
name: 'search',
description: 'Native search',
parameters: { type: 'object' },
execute: async () => [{ type: 'text', text: 'native' }],
})
const client = createMockClient([{ name: 'search', inputSchema: { type: 'object' } }])
await syncTools(client as never, ctx, defaultOpts, new Map())
expect(ctx.tools.get('search')).toBeDefined()
expect(ctx.tools.get('mcp__srv__search')).toBeDefined()
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'search', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: 'native' })
})
it('rejects a tool list where one raw name appears twice', async () => {
const client = createMockClient([
{ name: 'existing', description: 'Conflicts', inputSchema: { type: 'object' } },
{ name: 'unique', description: 'No conflict', inputSchema: { type: 'object' } },
{ name: 'dup', inputSchema: { type: 'object' } },
{ name: 'dup', inputSchema: { type: 'object' } },
])
await expect(syncTools(client as never, ctx, defaultOpts, new Map()))
.rejects.toThrow(/listed tool "dup" more than once/)
// Nothing registered, previous generation untouched (it was empty).
expect(ctx.tools.get('mcp__srv__dup')).toBeUndefined()
})
it('keeps the previous generation when the fetch phase fails', async () => {
const client = createMockClient([{ name: 'stable', inputSchema: { type: 'object' } }])
const first = await syncTools(client as never, ctx, defaultOpts, new Map())
expect(ctx.tools.get('mcp__srv__stable')).toBeDefined()
client.listTools.mockRejectedValue(new Error('network down'))
await expect(syncTools(client as never, ctx, defaultOpts, first)).rejects.toThrow('network down')
// The previous generation is still live.
expect(ctx.tools.get('mcp__srv__stable')).toBeDefined()
})
it('rolls back the whole generation when a foreign tool squats on the namespace', async () => {
// A foreign registration occupies one of this server's public names.
ctx.tools.register({
name: 'mcp__srv__taken',
description: 'Squatter',
parameters: { type: 'object' },
execute: async () => [{ type: 'text', text: 'squatter' }],
})
const client = createMockClient([
{ name: 'free', inputSchema: { type: 'object' } },
{ name: 'taken', inputSchema: { type: 'object' } },
])
const disposers = await syncTools(client as never, ctx, defaultOpts, new Map())
// Only the non-conflicting tool registers.
expect(disposers.size).toBe(1)
expect(ctx.tools.get('unique')).toBeDefined()
// Original tool unchanged.
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'existing', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: 'native' })
// All-or-nothing: the non-conflicting tool is rolled back too.
expect(disposers.size).toBe(0)
expect(ctx.tools.get('mcp__srv__free')).toBeUndefined()
// The squatter is untouched.
expect(ctx.tools.get('mcp__srv__taken')).toBeDefined()
})
it('unregisters previous tools before re-syncing', async () => {
@@ -108,14 +178,14 @@ describe('syncTools', () => {
])
const firstDisposers = await syncTools(client as never, ctx, defaultOpts, new Map())
expect(ctx.tools.get('old_tool')).toBeDefined()
expect(ctx.tools.get('mcp__srv__old_tool')).toBeDefined()
// Second sync with different tools should remove old_tool.
client.listTools.mockResolvedValue({ tools: [{ name: 'new_tool', inputSchema: { type: 'object' } }], nextCursor: undefined })
const secondDisposers = await syncTools(client as never, ctx, defaultOpts, firstDisposers)
expect(ctx.tools.get('old_tool')).toBeUndefined()
expect(ctx.tools.get('new_tool')).toBeDefined()
expect(ctx.tools.get('mcp__srv__old_tool')).toBeUndefined()
expect(ctx.tools.get('mcp__srv__new_tool')).toBeDefined()
expect(secondDisposers.size).toBe(1)
})
@@ -128,8 +198,8 @@ describe('syncTools', () => {
const disposers = await syncTools(client as never, ctx, defaultOpts, new Map())
expect(disposers.size).toBe(2)
expect(ctx.tools.get('page1')).toBeDefined()
expect(ctx.tools.get('page2')).toBeDefined()
expect(ctx.tools.get('mcp__srv__page1')).toBeDefined()
expect(ctx.tools.get('mcp__srv__page2')).toBeDefined()
})
})
@@ -140,17 +210,18 @@ describe('tool execution', () => {
ctx = await mountRegistry()
})
it('calls MCP callTool and returns text content', async () => {
it('calls MCP callTool with the RAW name and returns text content', async () => {
const client = createMockClient(
[{ name: 'echo', inputSchema: { type: 'object' } }],
{ content: [{ type: 'text', text: 'hello world' }] },
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { msg: 'hi' } })
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__echo', arguments: { msg: 'hi' } })
expect(result.isError).toBe(false)
expect(result.content).toEqual([{ type: 'text', text: 'hello world' }])
// The wire sees the raw MCP name, never the public name.
expect(client.callTool).toHaveBeenCalledWith(
{ name: 'echo', arguments: { msg: 'hi' } },
undefined,
@@ -158,6 +229,24 @@ describe('tool execution', () => {
)
})
it('sends the raw name for normalized public names', async () => {
const client = createMockClient(
[{ name: 'admin.reset', inputSchema: { type: 'object' } }],
{ content: [{ type: 'text', text: 'reset done' }] },
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const publicName = publicToolName('srv', 'admin.reset')
const result = await ctx.tools.execute({ callId: CallId('c1'), name: publicName, arguments: {} })
expect(result.isError).toBe(false)
expect(client.callTool).toHaveBeenCalledWith(
{ name: 'admin.reset', arguments: {} },
undefined,
expect.anything(),
)
})
it('joins multiple text blocks with newline', async () => {
const client = createMockClient(
[{ name: 'multi', inputSchema: { type: 'object' } }],
@@ -165,7 +254,7 @@ describe('tool execution', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'multi', arguments: {} })
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__multi', arguments: {} })
expect(result.content).toEqual([{ type: 'text', text: 'line1\nline2' }])
})
@@ -177,7 +266,7 @@ describe('tool execution', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'img', arguments: {} })
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__img', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: 'before\n[image: image/png, content discarded]' })
})
@@ -189,7 +278,7 @@ describe('tool execution', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'fail', arguments: {} })
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__fail', arguments: {} })
expect(result.isError).toBe(true)
expect(result.content[0]).toEqual({ type: 'text', text: 'Error: something went wrong' })
@@ -203,7 +292,7 @@ describe('tool execution', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
await ctx.tools.execute({ callId: CallId('c1'), name: 'slow', arguments: {}, signal: controller.signal })
await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__slow', arguments: {}, signal: controller.signal })
expect(client.callTool).toHaveBeenCalledWith(
expect.anything(),
@@ -219,7 +308,7 @@ describe('tool execution', () => {
client.callTool.mockResolvedValue({ toolResult: { key: 'value' } })
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'legacy', arguments: {} })
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__legacy', arguments: {} })
expect(result.isError).toBe(false)
expect(result.content[0]).toEqual({ type: 'text', text: '{"key":"value"}' })
@@ -240,7 +329,7 @@ describe('tool execution edge cases', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'audio_tool', arguments: {} })
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__audio_tool', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '[audio: audio/mp3, content discarded]' })
})
@@ -252,7 +341,7 @@ describe('tool execution edge cases', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'res_tool', arguments: {} })
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__res_tool', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '[resource: content discarded]' })
})
@@ -264,7 +353,7 @@ describe('tool execution edge cases', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'link_tool', arguments: {} })
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__link_tool', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '[resource: content discarded]' })
})
@@ -276,7 +365,7 @@ describe('tool execution edge cases', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'unknown_tool', arguments: {} })
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__unknown_tool', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '[unsupported content type: video]' })
})
@@ -288,7 +377,7 @@ describe('tool execution edge cases', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'img2', arguments: {} })
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__img2', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '[image: unknown, content discarded]' })
})
@@ -300,7 +389,7 @@ describe('tool execution edge cases', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'audio_no_mime', arguments: {} })
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__audio_no_mime', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '[audio: unknown, content discarded]' })
})
@@ -312,7 +401,7 @@ describe('tool execution edge cases', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'notext', arguments: {} })
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__notext', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '(notext returned no text content)' })
})
@@ -324,7 +413,7 @@ describe('tool execution edge cases', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'empty_tool', arguments: {} })
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__empty_tool', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '(empty_tool returned no text content)' })
})
@@ -337,7 +426,7 @@ describe('tool execution edge cases', () => {
client.callTool.mockResolvedValue({})
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'legacy2', arguments: {} })
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__legacy2', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '(no output)' })
})
@@ -349,13 +438,9 @@ describe('tool execution edge cases', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'err_notext', arguments: {} })
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__err_notext', arguments: {} })
expect(result.isError).toBe(true)
// The error message falls back to 'MCP tool error' when content[0] is not text.
// But mapContent converts image to text placeholder, so it should use that.
// Actually mapContent ALWAYS returns text, so the ternary always takes the truthy branch.
// Let me check: mapContent returns [{type:'text', text:'[image: ...]'}], so content[0].type IS 'text'.
expect(result.content[0]).toEqual({ type: 'text', text: 'Error: [image: image/png, content discarded]' })
})
@@ -366,7 +451,7 @@ describe('tool execution edge cases', () => {
])
await syncTools(client as never, ctx, defaultOpts, new Map())
const tool = ctx.tools.get('described')
const tool = ctx.tools.get('mcp__srv__described')
expect(tool?.description).toBe('A described tool')
})
@@ -376,7 +461,7 @@ describe('tool execution edge cases', () => {
])
await syncTools(client as never, ctx, defaultOpts, new Map())
const tool = ctx.tools.get('nodesc')
const tool = ctx.tools.get('mcp__srv__nodesc')
expect(tool?.description).toBe('')
})
})
@@ -385,11 +470,11 @@ describe('createTransport', () => {
it('creates StdioClientTransport for stdio config', () => {
const config: Config = {
transport: 'stdio',
serverName: 'srv',
command: 'node',
args: ['server.js'],
env: {},
cwd: '/tmp',
toolPrefix: '',
toolCallTimeoutMs: 60_000,
}
const transport = createTransport(config)
@@ -401,9 +486,9 @@ describe('createTransport', () => {
it('creates StreamableHTTPClientTransport for http config without headers', () => {
const config: Config = {
transport: 'streamable-http',
serverName: 'srv',
url: 'http://localhost:3000/mcp',
headers: {},
toolPrefix: '',
toolCallTimeoutMs: 60_000,
}
const transport = createTransport(config)
@@ -415,9 +500,9 @@ describe('createTransport', () => {
it('creates StreamableHTTPClientTransport for http config with headers', () => {
const config: Config = {
transport: 'streamable-http',
serverName: 'srv',
url: 'http://localhost:3000/mcp',
headers: { Authorization: 'Bearer token' },
toolPrefix: '',
toolCallTimeoutMs: 60_000,
}
const transport = createTransport(config)
@@ -436,11 +521,11 @@ describe('createTransport', () => {
const config: Config = {
transport: 'stdio',
serverName: 'srv',
command: 'echo',
args: [],
env: { EXTRA: 'injected' },
cwd: '',
toolPrefix: '',
toolCallTimeoutMs: 60_000,
}
// createTransport internally calls buildChildEnv; we verify by inspecting
@@ -463,11 +548,11 @@ describe('createTransport', () => {
it('merges explicit env on top of scrubbed ambient env', () => {
const config: Config = {
transport: 'stdio',
serverName: 'srv',
command: 'echo',
args: [],
env: { CUSTOM: 'value' },
cwd: '',
toolPrefix: '',
toolCallTimeoutMs: 60_000,
}
const transport = createTransport(config)
@@ -490,7 +575,7 @@ describe('tool execution — non-object args fallback', () => {
await syncTools(client as never, ctx, defaultOpts, new Map())
// Simulate model emitting `null` as tool arguments (malformed).
await ctx.tools.execute({ callId: CallId('c1'), name: 'coerce', arguments: null })
await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__coerce', arguments: null })
expect(client.callTool).toHaveBeenCalledWith(
{ name: 'coerce', arguments: {} },
@@ -506,7 +591,7 @@ describe('tool execution — non-object args fallback', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
await ctx.tools.execute({ callId: CallId('c1'), name: 'coerce2', arguments: 'bad' })
await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__coerce2', arguments: 'bad' })
expect(client.callTool).toHaveBeenCalledWith(
{ name: 'coerce2', arguments: {} },
@@ -515,4 +600,3 @@ describe('tool execution — non-object args fallback', () => {
)
})
})