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:
@@ -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
|
||||
|
||||
|
||||
@@ -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`: 1–32 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')
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user