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:
7
packages/mcp/README.md
Normal file
7
packages/mcp/README.md
Normal 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` |
|
||||
57
packages/mcp/mcp-client/README.md
Normal file
57
packages/mcp/mcp-client/README.md
Normal 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 |
|
||||
38
packages/mcp/mcp-client/package.json
Normal file
38
packages/mcp/mcp-client/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
128
packages/mcp/mcp-client/src/index.ts
Normal file
128
packages/mcp/mcp-client/src/index.ts
Normal 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')
|
||||
}
|
||||
168
packages/mcp/mcp-client/src/tools.ts
Normal file
168
packages/mcp/mcp-client/src/tools.ts
Normal 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)`
|
||||
}
|
||||
56
packages/mcp/mcp-client/src/transport.ts
Normal file
56
packages/mcp/mcp-client/src/transport.ts
Normal 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
|
||||
}
|
||||
}
|
||||
179
packages/mcp/mcp-client/tests/apply.spec.ts
Normal file
179
packages/mcp/mcp-client/tests/apply.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
518
packages/mcp/mcp-client/tests/mcp-client.spec.ts
Normal file
518
packages/mcp/mcp-client/tests/mcp-client.spec.ts
Normal 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(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
15
packages/mcp/mcp-client/tsconfig.json
Normal file
15
packages/mcp/mcp-client/tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user