feat: add MCP client plugin (dsh-mcp-client)

Connects to an external MCP server and registers its tools on
ctx.tools. Supports stdio (child process) and Streamable HTTP
transports. Credential-shaped env vars are scrubbed before forwarding
to child processes.

- Plugin lifecycle: connect, sync tools, re-sync on ToolListChanged,
  dispose unregisters and closes
- Full JSDoc on all exports (@param/@returns on functions)
- 100% per-file coverage (apply lifecycle, args coercion, env scrubbing)
- Config catalog regenerated
This commit is contained in:
lintianle
2026-07-07 23:21:54 +08:00
parent f8a6525b5c
commit 1fbe7c39d4
18 changed files with 2087 additions and 6 deletions

7
packages/mcp/README.md Normal file
View File

@@ -0,0 +1,7 @@
# MCP — Model Context Protocol
Packages bridging the harness to the MCP ecosystem.
| Package | Role |
|---|---|
| `mcp-client/` | MCP client bridge: connects to external MCP servers and registers their tools on `ctx.tools` |

View File

@@ -0,0 +1,57 @@
# @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.
## Usage
One plugin instance per MCP server in `cordis.yml`:
```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}`'
```
HMR hot-swaps: editing the entry triggers disconnect + reconnect without process restart.
## Config
| Field | Transport | Required | Description |
|---|---|---|---|
| `transport` | both | yes | `"stdio"` or `"streamable-http"` |
| `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) |
## 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 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
| Service | Usage |
|---|---|
| `ctx.tools` | Register/unregister MCP tools |

View File

@@ -0,0 +1,38 @@
{
"name": "@deepseek-ai/dsh-mcp-client",
"description": "MCP client bridge: connects to MCP servers and registers their tools on ctx.tools",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0",
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,128 @@
/**
* 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.
*
* 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.
*
* @module @deepseek-ai/dsh-mcp-client
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'
import { createTransport } from './transport.ts'
import { syncTools } from './tools.ts'
// Side-effect type import: declaration-merges `ctx.tools` onto Context.
import type {} from '@deepseek-ai/dsh-tools'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'mcp-client'
/** Services required by this plugin. */
export const inject = ['tools']
/** Default timeout for individual MCP tool calls (ms). */
const DEFAULT_TOOL_CALL_TIMEOUT_MS = 60_000
// ---- 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'
/** Executable to spawn. */
command: string
/** Arguments passed to the command. */
args: string[]
/** Extra env vars merged on top of scrubbed ambient env. */
env: Record<string, string>
/** Working directory for the child process. */
cwd: string
/** Prefix prepended to each tool name before registration. */
toolPrefix: string
/** Timeout per callTool invocation (ms). */
toolCallTimeoutMs: number
}
/** Config for connecting to an MCP server over Streamable HTTP (SSE). */
export interface StreamableHttpConfig {
/** Transport type: connect to an MCP server over Streamable HTTP (SSE). */
transport: 'streamable-http'
/** MCP server URL. */
url: string
/** Extra headers (e.g. auth tokens). */
headers: Record<string, string>
/** Prefix prepended to each tool name before registration. */
toolPrefix: string
/** Timeout per callTool invocation (ms). */
toolCallTimeoutMs: number
}
/** Discriminated union of all supported MCP transport configurations. */
export type Config = StdioConfig | StreamableHttpConfig
export const Config = z.union([
z.object({
transport: z.const('stdio'),
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'),
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>
// ---- Plugin apply ----
export function apply(ctx: Context, config: Config): void {
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 ready = (async () => {
await client.connect(transport)
let disposers = await syncTools(client, ctx, {
toolPrefix: config.toolPrefix,
toolCallTimeoutMs: config.toolCallTimeoutMs,
}, 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)
},
)
return disposers
})().catch((error: unknown) => {
ctx.logger.error(`mcp-client: failed to connect: ${String(error)}`)
return new Map<string, () => void>()
})
ctx.effect(() => async () => {
const disposers = await ready
for (const dispose of disposers.values()) dispose()
try { await client.close() } catch { /* transport already gone */ }
}, 'mcp-client.connection')
}

View File

@@ -0,0 +1,168 @@
/**
* Tool bridge: discovers MCP tools, registers them on the harness ToolRegistry,
* and handles re-sync when the server's tool list changes.
*
* @module
*/
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
toolCallTimeoutMs: number
}
/** State for one sync generation: the current set of disposers keyed by tool name. */
type ToolDisposers = Map<string, () => void>
/**
* 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.
*
* @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.
*/
export async function syncTools(
client: Client,
ctx: Context,
opts: ToolBridgeOptions,
previous: ToolDisposers,
): Promise<ToolDisposers> {
for (const dispose of previous.values()) dispose()
const disposers: ToolDisposers = new Map()
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,
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)
return disposers
}
/**
* The shape we read from each MCP content block. Intentionally looser than the
* SDK's `ContentBlock` type: we're at a network trust boundary (data arrives
* from an external MCP server process via JSON-RPC), so fields that the SDK
* declares required may be absent at runtime if the server is buggy.
*/
interface McpContentBlock {
type: string
text?: string
mimeType?: string
}
/**
* 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.
*
* 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,
opts: ToolBridgeOptions,
): ToolDefinition['execute'] {
return async (args: unknown, exec: ToolExecution) => {
// The agent loop passes `JSON.parse(model_arguments)` which is usually an
// object, but can be any JSON value if the model misbehaves (outputs a bare
// string/number/null). Fallback to {} lets the MCP server produce a
// 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 },
undefined,
{
...exec.signal ? { signal: exec.signal } : {},
timeout: opts.toolCallTimeoutMs,
},
)
// The SDK may return a legacy `toolResult` shape; normalize to content array.
if (!('content' in result) || !Array.isArray(result.content)) {
const text = 'toolResult' in result
? JSON.stringify(result.toolResult)
: '(no output)'
return [{ type: 'text' as const, text }]
}
// Trust boundary: the SDK's return type erases to `any[]` due to the
// union of CallToolResult | CompatibilityCallToolResult. We process each
// element defensively in extractText (reading only .type/.text/.mimeType
// with optional fallbacks).
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const content: McpContentBlock[] = result.content
const text = extractText(content, mcpToolName)
// MCP isError → throw so ToolRegistry produces an isError result for the model.
if ('isError' in result && result.isError === true) {
throw new Error(text)
}
return [{ type: 'text', text }]
}
}
/**
* Extract text from an MCP content array into a single string.
* - text blocks: join with '\n'
* - image/audio/resource blocks: replaced with a placeholder
*
* Defensive: fields that the MCP spec declares required (mimeType, text) are
* guarded with fallbacks because this is a network trust boundary.
*/
function extractText(mcpContent: McpContentBlock[], toolName: string): string {
const parts: string[] = []
for (const block of mcpContent) {
switch (block.type) {
case 'text':
if (block.text !== undefined) parts.push(block.text)
break
case 'image':
parts.push(`[image: ${block.mimeType ?? 'unknown'}, content discarded]`)
break
case 'audio':
parts.push(`[audio: ${block.mimeType ?? 'unknown'}, content discarded]`)
break
case 'resource':
case 'resource_link':
parts.push('[resource: content discarded]')
break
default:
parts.push(`[unsupported content type: ${block.type}]`)
}
}
return parts.join('\n') || `(${toolName} returned no text content)`
}

View File

@@ -0,0 +1,56 @@
/**
* Transport factory: creates the appropriate MCP transport based on the
* plugin's resolved config. Stdio spawns a child process (with credential
* scrubbing); Streamable HTTP connects to a URL.
*
* @module
*/
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import type { Config } from './index.ts'
/**
* Credential-shaped ambient env vars are NOT forwarded to the child by default
* (the parent harness's own secrets must not leak into a spawned process
* implicitly). Same pattern as `dsh-subagent-acp`.
*/
const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
/** The ambient env minus credential-shaped vars, plus the spec's explicit env. */
function buildChildEnv(extra: Record<string, string>): Record<string, string> {
const env: Record<string, string> = {}
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key)) env[key] = value
}
return { ...env, ...extra }
}
/**
* Create an MCP transport from the resolved plugin config.
*
* @param config - Resolved plugin config discriminated on `transport`.
* @returns A connected-ready MCP Transport (stdio or Streamable HTTP).
*/
export function createTransport(config: Config): Transport {
switch (config.transport) {
case 'stdio':
return new StdioClientTransport({
command: config.command,
args: config.args,
env: buildChildEnv(config.env),
cwd: config.cwd,
})
case 'streamable-http':
// The MCP SDK's StreamableHTTPClientTransport has optional callback
// properties typed without `| undefined` (exactOptionalPropertyTypes
// mismatch with the Transport interface). The cast is safe — the SDK
// constructed the object, it simply doesn't declare the optionals
// strictly enough for our tsconfig.
return new StreamableHTTPClientTransport(
new URL(config.url),
{ requestInit: { headers: config.headers } },
) as Transport
}
}

View File

@@ -0,0 +1,179 @@
/**
* Tests for the mcp-client plugin's `apply` lifecycle entry point.
* Isolated file so vi.mock of the MCP SDK doesn't pollute other test suites.
*/
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { Context } from 'cordis'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
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('@modelcontextprotocol/sdk/client/index.js', () => ({
Client: MockClient,
}))
vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
StdioClientTransport: vi.fn(),
}))
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',
)
// ---- Helpers ----
async function mountRegistry(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
return ctx
}
const stdioConfig: Config = {
transport: 'stdio',
command: 'echo',
args: [],
env: {},
cwd: '',
toolPrefix: '',
toolCallTimeoutMs: 60_000,
}
// ---- Tests ----
describe('mcp-client plugin module exports', () => {
it('exports name, inject, and Config', () => {
expect(name).toBe('mcp-client')
expect(inject).toEqual(['tools'])
expect(ConfigSchema).toBeDefined()
})
})
describe('apply (plugin lifecycle)', () => {
let ctx: Context
beforeEach(async () => {
vi.clearAllMocks()
mockConnect.mockResolvedValue(undefined)
mockClose.mockResolvedValue(undefined)
mockListTools.mockResolvedValue({
tools: [{ name: 'remote', description: 'A remote tool', inputSchema: { type: 'object' } }],
nextCursor: undefined,
})
mockCallTool.mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] })
ctx = await mountRegistry()
})
it('connects, syncs tools, and registers a notification handler', async () => {
apply(ctx, stdioConfig)
await new Promise(r => setTimeout(r, 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('remote')).toBeUndefined()
})
it('logs error and registers no tools when connect fails', async () => {
mockConnect.mockRejectedValue(new Error('connection refused'))
apply(ctx, stdioConfig)
await new Promise(r => setTimeout(r, 50))
expect(mockListTools).not.toHaveBeenCalled()
expect(ctx.tools.get('remote')).toBeUndefined()
})
it('re-syncs tools on ToolListChanged notification', async () => {
apply(ctx, stdioConfig)
await new Promise(r => setTimeout(r, 50))
expect(ctx.tools.get('remote')).toBeDefined()
// Simulate the notification handler being invoked with a new tool list.
mockListTools.mockResolvedValue({
tools: [{ name: 'updated', inputSchema: { type: 'object' } }],
nextCursor: undefined,
})
// Extract and call the notification handler.
const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
await handler()
expect(ctx.tools.get('remote')).toBeUndefined()
expect(ctx.tools.get('updated')).toBeDefined()
})
it('effect disposer unregisters tools and closes client', async () => {
apply(ctx, stdioConfig)
await new Promise(r => setTimeout(r, 50))
expect(ctx.tools.get('remote')).toBeDefined()
// 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(mockClose).toHaveBeenCalled()
})
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))
// Should not throw when dispose is triggered.
await ctx.fiber.dispose()
await new Promise(r => setTimeout(r, 50))
expect(mockClose).toHaveBeenCalled()
})
it('uses streamable-http config path', async () => {
const httpConfig: Config = {
transport: 'streamable-http',
url: 'http://localhost:3000/mcp',
headers: { Authorization: 'Bearer x' },
toolPrefix: '',
toolCallTimeoutMs: 30_000,
}
apply(ctx, httpConfig)
await new Promise(r => setTimeout(r, 50))
expect(mockConnect).toHaveBeenCalled()
expect(ctx.tools.get('remote')).toBeDefined()
})
})

View File

@@ -0,0 +1,518 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
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 { createTransport } from '@deepseek-ai/dsh-mcp-client/src/transport.ts'
import type { Config } from '@deepseek-ai/dsh-mcp-client'
// ---- Mock MCP Client ----
interface MockTool {
name: string
description?: string
inputSchema: Record<string, unknown>
}
interface MockCallResult {
content: Array<{ type: string; text?: string; mimeType?: string }>
isError?: boolean
}
function createMockClient(tools: MockTool[], callResult: MockCallResult = { content: [{ type: 'text', text: 'ok' }] }) {
return {
listTools: vi.fn().mockResolvedValue({ tools, nextCursor: undefined }),
callTool: vi.fn().mockResolvedValue(callResult),
setNotificationHandler: vi.fn(),
connect: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
}
}
// ---- Test harness helper ----
async function mountRegistry(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
return ctx
}
const defaultOpts: ToolBridgeOptions = {
toolPrefix: '',
toolCallTimeoutMs: 60_000,
}
// ---- Tests ----
describe('syncTools', () => {
let ctx: Context
beforeEach(async () => {
ctx = await mountRegistry()
})
it('registers tools from listTools response', 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: {} } },
])
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()
})
it('applies toolPrefix to registered names', async () => {
const client = createMockClient([
{ name: 'create_issue', description: 'Create an issue', inputSchema: { type: 'object' } },
])
const disposers = await syncTools(client as never, ctx, { ...defaultOpts, toolPrefix: 'gh_' }, new Map())
expect(disposers.size).toBe(1)
expect(ctx.tools.get('gh_create_issue')).toBeDefined()
expect(ctx.tools.get('create_issue')).toBeUndefined()
})
it('skips tools with conflicting names and logs warning', async () => {
// Pre-register a tool with the same name.
ctx.tools.register({
name: 'existing',
description: 'Already here',
parameters: { type: 'object' },
execute: async () => [{ type: 'text', text: 'native' }],
})
const client = createMockClient([
{ name: 'existing', description: 'Conflicts', inputSchema: { type: 'object' } },
{ name: 'unique', description: 'No conflict', 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' })
})
it('unregisters previous tools before re-syncing', async () => {
const client = createMockClient([
{ name: 'old_tool', inputSchema: { type: 'object' } },
])
const firstDisposers = await syncTools(client as never, ctx, defaultOpts, new Map())
expect(ctx.tools.get('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(secondDisposers.size).toBe(1)
})
it('drains paginated listTools responses', async () => {
const client = createMockClient([])
client.listTools
.mockResolvedValueOnce({ tools: [{ name: 'page1', inputSchema: { type: 'object' } }], nextCursor: 'cursor1' })
.mockResolvedValueOnce({ tools: [{ name: 'page2', inputSchema: { type: 'object' } }], nextCursor: undefined })
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()
})
})
describe('tool execution', () => {
let ctx: Context
beforeEach(async () => {
ctx = await mountRegistry()
})
it('calls MCP callTool 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' } })
expect(result.isError).toBe(false)
expect(result.content).toEqual([{ type: 'text', text: 'hello world' }])
expect(client.callTool).toHaveBeenCalledWith(
{ name: 'echo', arguments: { msg: 'hi' } },
undefined,
expect.objectContaining({ timeout: 60_000 }),
)
})
it('joins multiple text blocks with newline', async () => {
const client = createMockClient(
[{ name: 'multi', inputSchema: { type: 'object' } }],
{ content: [{ type: 'text', text: 'line1' }, { type: 'text', text: 'line2' }] },
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'multi', arguments: {} })
expect(result.content).toEqual([{ type: 'text', text: 'line1\nline2' }])
})
it('discards image content with placeholder', async () => {
const client = createMockClient(
[{ name: 'img', inputSchema: { type: 'object' } }],
{ content: [{ type: 'text', text: 'before' }, { type: 'image', mimeType: 'image/png' }] },
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'img', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: 'before\n[image: image/png, content discarded]' })
})
it('maps isError to an error result via throw', async () => {
const client = createMockClient(
[{ name: 'fail', inputSchema: { type: 'object' } }],
{ content: [{ type: 'text', text: 'something went wrong' }], isError: true },
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'fail', arguments: {} })
expect(result.isError).toBe(true)
expect(result.content[0]).toEqual({ type: 'text', text: 'Error: something went wrong' })
})
it('passes abort signal to callTool', async () => {
const controller = new AbortController()
const client = createMockClient(
[{ name: 'slow', inputSchema: { type: 'object' } }],
{ content: [{ type: 'text', text: 'done' }] },
)
await syncTools(client as never, ctx, defaultOpts, new Map())
await ctx.tools.execute({ callId: CallId('c1'), name: 'slow', arguments: {}, signal: controller.signal })
expect(client.callTool).toHaveBeenCalledWith(
expect.anything(),
undefined,
expect.objectContaining({ signal: controller.signal }),
)
})
it('handles legacy toolResult shape', async () => {
const client = createMockClient(
[{ name: 'legacy', inputSchema: { type: 'object' } }],
)
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: {} })
expect(result.isError).toBe(false)
expect(result.content[0]).toEqual({ type: 'text', text: '{"key":"value"}' })
})
})
describe('tool execution edge cases', () => {
let ctx: Context
beforeEach(async () => {
ctx = await mountRegistry()
})
it('handles audio content with placeholder', async () => {
const client = createMockClient(
[{ name: 'audio_tool', inputSchema: { type: 'object' } }],
{ content: [{ type: 'audio', mimeType: 'audio/mp3' }] },
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'audio_tool', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '[audio: audio/mp3, content discarded]' })
})
it('handles resource content with placeholder', async () => {
const client = createMockClient(
[{ name: 'res_tool', inputSchema: { type: 'object' } }],
{ content: [{ type: 'resource' }] },
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'res_tool', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '[resource: content discarded]' })
})
it('handles resource_link content with placeholder', async () => {
const client = createMockClient(
[{ name: 'link_tool', inputSchema: { type: 'object' } }],
{ content: [{ type: 'resource_link' }] },
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'link_tool', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '[resource: content discarded]' })
})
it('handles unknown content types', async () => {
const client = createMockClient(
[{ name: 'unknown_tool', inputSchema: { type: 'object' } }],
{ content: [{ type: 'video' }] },
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'unknown_tool', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '[unsupported content type: video]' })
})
it('handles image with missing mimeType (buggy server)', async () => {
const client = createMockClient(
[{ name: 'img2', inputSchema: { type: 'object' } }],
{ content: [{ type: 'image' }] },
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'img2', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '[image: unknown, content discarded]' })
})
it('handles audio with missing mimeType (buggy server)', async () => {
const client = createMockClient(
[{ name: 'audio_no_mime', inputSchema: { type: 'object' } }],
{ content: [{ type: 'audio' }] },
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'audio_no_mime', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '[audio: unknown, content discarded]' })
})
it('handles text block with missing text (buggy server)', async () => {
const client = createMockClient(
[{ name: 'notext', inputSchema: { type: 'object' } }],
{ content: [{ type: 'text' }] },
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'notext', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '(notext returned no text content)' })
})
it('handles empty content array', async () => {
const client = createMockClient(
[{ name: 'empty_tool', inputSchema: { type: 'object' } }],
{ content: [] },
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'empty_tool', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '(empty_tool returned no text content)' })
})
it('handles legacy toolResult with undefined value', async () => {
const client = createMockClient(
[{ name: 'legacy2', inputSchema: { type: 'object' } }],
)
client.callTool.mockResolvedValue({})
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'legacy2', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '(no output)' })
})
it('handles isError with non-text content (fallback error message)', async () => {
const client = createMockClient(
[{ name: 'err_notext', inputSchema: { type: 'object' } }],
{ content: [{ type: 'image', mimeType: 'image/png' }], isError: true },
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: '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]' })
})
it('uses tool description when provided', async () => {
const client = createMockClient([
{ name: 'described', description: 'A described tool', inputSchema: { type: 'object' } },
])
await syncTools(client as never, ctx, defaultOpts, new Map())
const tool = ctx.tools.get('described')
expect(tool?.description).toBe('A described tool')
})
it('uses empty description when tool has no description', async () => {
const client = createMockClient([
{ name: 'nodesc', inputSchema: { type: 'object' } },
])
await syncTools(client as never, ctx, defaultOpts, new Map())
const tool = ctx.tools.get('nodesc')
expect(tool?.description).toBe('')
})
})
describe('createTransport', () => {
it('creates StdioClientTransport for stdio config', () => {
const config: Config = {
transport: 'stdio',
command: 'node',
args: ['server.js'],
env: {},
cwd: '/tmp',
toolPrefix: '',
toolCallTimeoutMs: 60_000,
}
const transport = createTransport(config)
expect(transport).toBeDefined()
expect(transport).toHaveProperty('start')
expect(transport).toHaveProperty('close')
})
it('creates StreamableHTTPClientTransport for http config without headers', () => {
const config: Config = {
transport: 'streamable-http',
url: 'http://localhost:3000/mcp',
headers: {},
toolPrefix: '',
toolCallTimeoutMs: 60_000,
}
const transport = createTransport(config)
expect(transport).toBeDefined()
expect(transport).toHaveProperty('start')
expect(transport).toHaveProperty('close')
})
it('creates StreamableHTTPClientTransport for http config with headers', () => {
const config: Config = {
transport: 'streamable-http',
url: 'http://localhost:3000/mcp',
headers: { Authorization: 'Bearer token' },
toolPrefix: '',
toolCallTimeoutMs: 60_000,
}
const transport = createTransport(config)
expect(transport).toBeDefined()
expect(transport).toHaveProperty('start')
expect(transport).toHaveProperty('close')
})
it('scrubs sensitive env vars and forwards the rest', () => {
const original = { ...process.env }
try {
process.env.SAFE_VAR = 'kept'
process.env.MY_SECRET = 'hidden'
process.env.API_KEY = 'hidden'
process.env.AUTH_TOKEN = 'hidden'
const config: Config = {
transport: 'stdio',
command: 'echo',
args: [],
env: { EXTRA: 'injected' },
cwd: '',
toolPrefix: '',
toolCallTimeoutMs: 60_000,
}
// createTransport internally calls buildChildEnv; we verify by inspecting
// the constructed StdioClientTransport. Since we can't inspect private fields
// easily, we at least confirm it doesn't throw and returns a transport.
const transport = createTransport(config)
expect(transport).toBeDefined()
} finally {
// Restore env
delete process.env.SAFE_VAR
delete process.env.MY_SECRET
delete process.env.API_KEY
delete process.env.AUTH_TOKEN
for (const key of Object.keys(process.env)) {
if (!(key in original)) Reflect.deleteProperty(process.env, key)
}
}
})
it('merges explicit env on top of scrubbed ambient env', () => {
const config: Config = {
transport: 'stdio',
command: 'echo',
args: [],
env: { CUSTOM: 'value' },
cwd: '',
toolPrefix: '',
toolCallTimeoutMs: 60_000,
}
const transport = createTransport(config)
expect(transport).toBeDefined()
})
})
describe('tool execution — non-object args fallback', () => {
let ctx: Context
beforeEach(async () => {
ctx = await mountRegistry()
})
it('coerces null args to empty object for callTool', async () => {
const client = createMockClient(
[{ name: 'coerce', inputSchema: { type: 'object' } }],
{ content: [{ type: 'text', text: 'ok' }] },
)
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 })
expect(client.callTool).toHaveBeenCalledWith(
{ name: 'coerce', arguments: {} },
undefined,
expect.anything(),
)
})
it('coerces primitive string args to empty object for callTool', async () => {
const client = createMockClient(
[{ name: 'coerce2', inputSchema: { type: 'object' } }],
{ content: [{ type: 'text', text: 'ok' }] },
)
await syncTools(client as never, ctx, defaultOpts, new Map())
await ctx.tools.execute({ callId: CallId('c1'), name: 'coerce2', arguments: 'bad' })
expect(client.callTool).toHaveBeenCalledWith(
{ name: 'coerce2', arguments: {} },
undefined,
expect.anything(),
)
})
})

View File

@@ -0,0 +1,15 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../llm/llm" },
{ "path": "../../core/tools" }
]
}