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 80ce8b8dd4
commit 351a532cc7
6 changed files with 54 additions and 238 deletions

View File

@@ -22,7 +22,6 @@ class MockClient {
listTools = mockListTools
callTool = mockCallTool
setNotificationHandler = mockSetNotificationHandler
onclose: (() => void) | null = null
}
vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
@@ -177,54 +176,4 @@ describe('apply (plugin lifecycle)', () => {
expect(mockConnect).toHaveBeenCalled()
expect(ctx.tools.get('remote')).toBeDefined()
})
it('coalesces overlapping resync notifications (latest-wins)', async () => {
apply(ctx, stdioConfig)
await new Promise(r => setTimeout(r, 50))
// Initial sync is done; notification handler is registered.
const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
// Make the NEXT listTools call slow so we can trigger a second notification.
let resolveBlocked!: (v: unknown) => void
mockListTools.mockReturnValueOnce(new Promise((r) => { resolveBlocked = r }))
// Fire first notification — starts a resync that blocks on listTools.
const firstResync = handler()
// Fire second notification while the first is in-flight — should coalesce.
const secondResync = handler()
// Resolve the blocked listTools call.
resolveBlocked({ tools: [{ name: 'mid', inputSchema: { type: 'object' } }], nextCursor: undefined })
// Set up the response for the deferred resync that fires after the first completes.
mockListTools.mockResolvedValueOnce({
tools: [{ name: 'final', inputSchema: { type: 'object' } }],
nextCursor: undefined,
})
await firstResync
await secondResync
await new Promise(r => setTimeout(r, 50))
// The deferred resync should have run with the latest tool list.
expect(ctx.tools.get('final')).toBeDefined()
})
it('unregisters tools when the server connection closes (onclose)', async () => {
apply(ctx, stdioConfig)
await new Promise(r => setTimeout(r, 50))
expect(ctx.tools.get('remote')).toBeDefined()
// Simulate the MCP client's onclose firing (server crashed or closed).
// The apply() sets `client.onclose = () => {...}` on the mock instance.
// mockConnect receives `this` as the client instance.
const clientInstance = mockConnect.mock.contexts[0] as MockClient
expect(clientInstance.onclose).toBeTypeOf('function')
clientInstance.onclose!()
expect(ctx.tools.get('remote')).toBeUndefined()
})
})

View File

@@ -5,7 +5,7 @@ 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 { apply, name, inject, Config } from '@deepseek-ai/dsh-mcp-client/src/index.ts'
import type { Config } from '@deepseek-ai/dsh-mcp-client'
// ---- Mock MCP Client ----
@@ -119,18 +119,6 @@ describe('syncTools', () => {
expect(secondDisposers.size).toBe(1)
})
it('cleans up already-registered tools when a later page fails', async () => {
const client = createMockClient([])
client.listTools
.mockResolvedValueOnce({ tools: [{ name: 'survives_not', inputSchema: { type: 'object' } }], nextCursor: 'cursor1' })
.mockRejectedValueOnce(new Error('page 2 network error'))
await expect(syncTools(client as never, ctx, defaultOpts, new Map())).rejects.toThrow('page 2 network error')
// The tool from page 1 was registered then cleaned up on failure.
expect(ctx.tools.get('survives_not')).toBeUndefined()
})
it('drains paginated listTools responses', async () => {
const client = createMockClient([])
client.listTools
@@ -326,7 +314,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: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '(notext returned no content)' })
expect(result.content[0]).toEqual({ type: 'text', text: '(notext returned no text content)' })
})
it('handles empty content array', async () => {
@@ -338,36 +326,10 @@ 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: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '(empty_tool returned no content)' })
expect(result.content[0]).toEqual({ type: 'text', text: '(empty_tool returned no text content)' })
})
it('uses fallback error message when isError with empty content', async () => {
const client = createMockClient(
[{ name: 'empty_err', inputSchema: { type: 'object' } }],
{ content: [], isError: true },
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'empty_err', arguments: {} })
expect(result.isError).toBe(true)
expect(result.content[0]).toEqual({ type: 'text', text: 'Error: MCP tool error' })
})
it('surfaces structuredContent when content array is empty', async () => {
const client = createMockClient(
[{ name: 'structured', inputSchema: { type: 'object' } }],
)
client.callTool.mockResolvedValue({ content: [], structuredContent: { key: 'value', count: 42 } })
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'structured', arguments: {} })
expect(result.isError).toBe(false)
expect(result.content[0]).toEqual({ type: 'text', text: '{"key":"value","count":42}' })
})
it('handles legacy toolResult with undefined value', async () => {
const client = createMockClient(
[{ name: 'legacy2', inputSchema: { type: 'object' } }],
@@ -554,37 +516,3 @@ describe('tool execution — non-object args fallback', () => {
})
})
describe('plugin module exports', () => {
it('exports name, inject, and Config schema', () => {
expect(name).toBe('mcp-client')
expect(inject).toEqual(['tools'])
expect(Config).toBeDefined()
})
})
describe('apply (error path, no mocks)', () => {
it('gracefully catches when the MCP server is unreachable', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
// Call apply with a command that will fail to spawn/connect.
// The .catch() inside apply logs the error and registers no tools.
apply(ctx, {
transport: 'stdio',
command: '___nonexistent_binary_that_will_fail___',
args: [],
env: {},
cwd: '',
toolPrefix: '',
toolCallTimeoutMs: 1000,
})
// Give the async connect + catch chain time to settle.
await new Promise(r => setTimeout(r, 200))
// No tools should be registered since connect failed.
expect(ctx.tools.get('anything')).toBeUndefined()
})
})