Merge remote-tracking branch 'origin/master' into codex/simp-hide-web-provider-helpers

This commit is contained in:
Tianyi Cui
2026-07-15 16:39:24 +08:00
49 changed files with 3488 additions and 204 deletions

View File

@@ -288,9 +288,17 @@ describe('bash tool', () => {
it('contributes the exit-code habit as its prompt section (guidance the descriptions cannot carry)', async () => {
const ctx = await setup()
ctx.systemPrompt.section({ name: 'test:before-bash', order: 104, text: 'before' })
ctx.systemPrompt.section({ name: 'test:after-bash', order: 106, text: 'after' })
const assembly = await ctx.systemPrompt.assemble()
const section = assembly.sections.find(s => s.name === 'tool:bash')
expect(section?.order).toBe(105)
expect(assembly.sections.map(s => s.name)).toEqual([
'harness:identity',
'deployment:persona',
'test:before-bash',
'tool:bash',
'test:after-bash',
])
expect(section?.text).toContain('[exit code: N]')
})

View File

@@ -23,7 +23,7 @@ Every field is validated (positive numbers) and defaulted; there are no other tu
- **Type-strip host-side, in execution context** — the program is wrapped in an async-function shell, stripped with `node:module`'s `stripTypeScriptTypes` (erasable syntax only — `enum`/namespaces are rejected as a program `exception` and no worker spawns), and sliced back out byte-positioned; it then executes as the body of an `AsyncFunction`, so top-level `await`/`return` work.
- **The port assumes a hostile peer** — model code can reach `parentPort` and forge traffic, so every inbound message is shape-validated and REBUILT before anything reads it (`null`, primitives, junk types, and malformed payloads drop without a throw; forged extra fields never ride along), the host answers each call id at most once, resolves binding names as OWN properties only (a forged `constructor` cannot walk a prototype chain), drops post-settlement replies, and converts a non-cloneable binding resolution into an error reply. Forged `log`/`done` messages cannot bypass the caps: one host-side ledger bounds everything that lands in `logs`, and the completion value is re-capped host-side. Worker-side namespaces are null-prototype with `defineProperty`, so `__proto__`-shaped binding names are ordinary keys.
- **Two independent budgets, because the peer is hostile** — `computeMs` meters the worker's MEASURED busy time (`worker.performance.eventLoopUtilization()` polling): a hot loop cannot hide behind a pending decoy dispatch, and a program awaiting a slow tool accrues nothing. `maxWallMs` backstops what busy time cannot see (awaiting a promise nobody resolves). Both funnel into `worker.terminate()`, which ends hot synchronous loops too; heap overflow surfaces as the worker's OOM exit (`kind: 'worker-exit'`).
- **Logs stream eagerly** — console/stdout/stderr entries cross the port as they happen, so a timed-out or killed program still shows what it printed. ONE shared `maxLogBytes` ledger bounds everything: streamed entries, forged port traffic, and pipe bytes that bypass the patched streams (appended after), with the overflow marked in-band once.
- **Logs stream eagerly** — console/stdout/stderr text crosses the port in emission order, so a timed-out or killed program still shows what it printed. ONE shared `maxLogBytes` ledger bounds everything: streamed text, forged port traffic, and pipe bytes that bypass the patched streams (appended after), with the overflow marked in-band once.
- **Empty environment** — the worker gets `env: {}` and `execArgv: []`: no ambient credentials (stronger than the scrubbed-env rule for spawned commands) and no inherited loader flags.
- **Dispose to quiescence** — teardown fails in-flight runs as `abort` and AWAITS each worker's exit before resolving.
@@ -31,6 +31,8 @@ Every field is validated (positive numbers) and defaulted; there are no other tu
Source mode loads erasable-only `src/worker.ts` through Node's native type stripping. Built mode passes the sibling `lib/worker.cjs` as a filesystem path because pkg's VFS Worker hook expects CommonJS; the same path works under ordinary Node. `tests/built-lib.e2e.ts` pins the real load path required by [docs/testing.md](../../../docs/testing.md).
The SDK surface is the default/named `WorkerCodeRuntime` class plus `Config`. The operational `./worker` subpath exists only as the packaged spawn entry; the wire protocol and bootstrap helpers are source-private implementation details.
## Model Experience
Indirectly, through Code Mode in [`dsh-tools`](../../core/tools/README.md), which renders this worker's capped printed or returned data and exact `[dsh-code-runtime-worker] log capture truncated at <maxLogBytes> bytes` and `… [truncated]` markers into a retained `run_code` result. Binding traffic and worker internals stay outside context.

View File

@@ -15,7 +15,6 @@
"types": "./lib/types/worker.d.ts",
"default": "./lib/worker.cjs"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [

View File

@@ -7,7 +7,6 @@
import { inspect } from 'node:util'
import { serialize } from 'node:v8'
import type { CodeLogEntry } from '@deepseek-ai/dsh-code-runtime'
import { logTruncationMarker } from './protocol.ts'
import type { DoneMessage, ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
@@ -28,12 +27,12 @@ export interface PatchableStream {
}
/**
* Ordered log capture under one shared byte budget, delivered to a sink as
* each entry lands (the real sink streams entries over the port eagerly, so
* Ordered text capture under one shared byte budget, delivered to a sink as
* each item lands (the real sink streams text over the port eagerly, so
* captured output survives a mid-run termination). Once the budget is
* exhausted it emits exactly one in-band marker entry (on the `stderr`
* diagnostics channel) and silently drops everything after — the cap is a
* blast-radius bound, so "how much was lost" intentionally stays unmeasured.
* exhausted it emits exactly one in-band marker and silently drops everything
* after. The cap is a blast-radius bound, so "how much was lost" intentionally
* stays unmeasured.
*/
export class LogBuffer {
private remaining: number
@@ -42,28 +41,28 @@ export class LogBuffer {
// under Node's native strip-only mode, which rejects non-erasable syntax —
// and parameter properties are non-erasable.
private readonly maxBytes: number
private readonly sink: (entry: CodeLogEntry) => void
private readonly sink: (text: string) => void
constructor(maxBytes: number, sink: (entry: CodeLogEntry) => void) {
constructor(maxBytes: number, sink: (text: string) => void) {
this.maxBytes = maxBytes
this.sink = sink
this.remaining = maxBytes
}
/**
* Emit one entry to the sink, charging its text against the budget (drops + marks once exhausted).
* @param entry - the log entry to deliver.
* Emit text to the sink, charging it against the budget (drops + marks once exhausted).
* @param text - the captured text to deliver.
*/
push(entry: CodeLogEntry): void {
push(text: string): void {
if (this.truncated) return
const cost = Buffer.byteLength(entry.text, 'utf8')
const cost = Buffer.byteLength(text, 'utf8')
if (cost > this.remaining) {
this.truncated = true
this.sink({ source: 'stderr', text: logTruncationMarker(this.maxBytes) })
this.sink(logTruncationMarker(this.maxBytes))
return
}
this.remaining -= cost
this.sink(entry)
this.sink(text)
}
}
@@ -84,7 +83,7 @@ export function makeConsoleShim(logs: LogBuffer): Record<(typeof CONSOLE_LEVELS)
args.map(arg => typeof arg === 'string' ? arg : inspect(arg, INSPECT_OPTIONS)).join(' ')
const shim = Object.create(null) as Record<(typeof CONSOLE_LEVELS)[number], (...args: unknown[]) => void>
for (const level of CONSOLE_LEVELS) {
shim[level] = (...args: unknown[]) => { logs.push({ source: 'console', level, text: render(args) }) }
shim[level] = (...args: unknown[]) => { logs.push(render(args)) }
}
return shim
}
@@ -98,17 +97,16 @@ export function makeConsoleShim(logs: LogBuffer): Record<(typeof CONSOLE_LEVELS)
*
* @param logs - the buffer captured writes are pushed into.
* @param stream - the stream whose `write` slot is patched.
* @param source - the log source the captured writes are attributed to.
* @returns the restore function (the in-process tests un-patch; the real
* worker never needs to).
*/
export function captureStreamWrites(logs: LogBuffer, stream: PatchableStream, source: 'stdout' | 'stderr'): () => void {
export function captureStreamWrites(logs: LogBuffer, stream: PatchableStream): () => void {
// The slot's VALUE is stored for restore and reassigned — never invoked
// detached, so the unbound-method concern does not apply.
// eslint-disable-next-line @typescript-eslint/unbound-method
const original = stream.write
stream.write = (chunk: unknown, ...rest: unknown[]): boolean => {
logs.push({ source, text: typeof chunk === 'string' ? chunk : String(chunk) })
logs.push(typeof chunk === 'string' ? chunk : String(chunk))
// Node's optional-encoding shape: the callback is whichever of the next
// two positions holds a function (a non-function there is the encoding).
const callback = [rest[0], rest[1]].find(
@@ -256,9 +254,9 @@ export async function runWorkerMain(
data: WorkerBootData,
streams: { stdout: PatchableStream; stderr: PatchableStream },
): Promise<void> {
const logs = new LogBuffer(data.maxLogBytes, (entry) => { port.postMessage({ type: 'log', entry }) })
captureStreamWrites(logs, streams.stdout, 'stdout')
captureStreamWrites(logs, streams.stderr, 'stderr')
const logs = new LogBuffer(data.maxLogBytes, (text) => { port.postMessage({ type: 'log', text }) })
captureStreamWrites(logs, streams.stdout)
captureStreamWrites(logs, streams.stderr)
const pending = new Map<number, PendingCall>()
wireReplies(port, pending)

View File

@@ -12,14 +12,11 @@ import { fileURLToPath } from 'node:url'
import { Context } from 'cordis'
import z from 'schemastery'
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import type { CodeBindingFunction, CodeLogEntry, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import type { CodeBindingFunction, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import { prepareValue, truncateUtf8Bytes } from './bootstrap.ts'
import { logTruncationMarker } from './protocol.ts'
import type { ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
export type { BootstrapPort, PatchableStream } from './bootstrap.ts'
export type { CallMessage, DoneMessage, ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
/** Plugin config: every execution cap, changeable from `cordis.yml` (no hardcoded tunables). */
export interface Config {
/**
@@ -112,10 +109,6 @@ function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/** The log sources / console levels the seam vocabulary admits, as runtime sets for inbound-message validation. */
const LOG_SOURCES = new Set<string>(['console', 'stdout', 'stderr'])
const LOG_LEVELS = new Set<string>(['log', 'info', 'warn', 'error', 'debug'])
/**
* Runtime shape gate for inbound port traffic. The peer runs MODEL CODE and
* can post anything — `null`, primitives, objects with poisoned fields — so
@@ -134,20 +127,8 @@ function parseWorkerMessage(raw: unknown): WorkerToHost | undefined {
return { type: 'call', id: m.id, global: m.global, name: m.name, args: m.args }
}
case 'log': {
const entry = m.entry
if (typeof entry !== 'object' || entry === null) return undefined
const e = entry as Record<string, unknown>
if (typeof e.text !== 'string') return undefined
if (typeof e.source !== 'string' || !LOG_SOURCES.has(e.source)) return undefined
if (e.level !== undefined && (typeof e.level !== 'string' || !LOG_LEVELS.has(e.level))) return undefined
return {
type: 'log',
entry: {
source: e.source as CodeLogEntry['source'],
...e.level !== undefined ? { level: e.level as Exclude<CodeLogEntry['level'], undefined> } : {},
text: e.text,
},
}
if (typeof m.text !== 'string') return undefined
return { type: 'log', text: m.text }
}
case 'done': {
if (m.error === undefined) return { type: 'done', ...m.value !== undefined ? { value: m.value } : {} }
@@ -291,33 +272,33 @@ export class WorkerCodeRuntime extends CodeRuntime {
return new Promise<CodeRunResult>((resolve) => {
let settled = false
const answered = new Set<number>()
const logs: CodeLogEntry[] = []
const strayLogs: CodeLogEntry[] = []
const logs: string[] = []
const strayLogs: string[] = []
// One host-side budget covers normal, forged, and stray-pipe log entries. The first
// overflow emits the shared in-band marker and drops everything after it.
let logBudget = this.config.maxLogBytes
let logsTruncated = false
const admit = (entry: CodeLogEntry, sink: CodeLogEntry[]): void => {
const admit = (text: string, sink: string[]): void => {
if (logsTruncated) return
const cost = Buffer.byteLength(entry.text, 'utf8')
const cost = Buffer.byteLength(text, 'utf8')
if (cost > logBudget) {
logsTruncated = true
sink.push({ source: 'stderr', text: logTruncationMarker(this.config.maxLogBytes) })
sink.push(logTruncationMarker(this.config.maxLogBytes))
return
}
logBudget -= cost
sink.push(entry)
sink.push(text)
}
// No settled guard: `finish` snapshots the arrays when it resolves, so
// a chunk flushing after settlement mutates only the discarded buffers,
// and the ledger bounds that growth until the pipes close.
const captureStray = (source: 'stdout' | 'stderr') => (chunk: Buffer) => {
admit({ source, text: chunk.toString('utf8') }, strayLogs)
const captureStray = (chunk: Buffer): void => {
admit(chunk.toString('utf8'), strayLogs)
}
worker.stdout.on('data', captureStray('stdout'))
worker.stderr.on('data', captureStray('stderr'))
worker.stdout.on('data', captureStray)
worker.stderr.on('data', captureStray)
// Exactly one outcome wins. Every path cleans up, terminates, and awaits the worker;
// logs captured before timeout, abort, or failure remain in the result.
@@ -386,7 +367,7 @@ export class WorkerCodeRuntime extends CodeRuntime {
// this listener would crash the host process. Junk drops silently.
const message = parseWorkerMessage(raw)
if (!message) return
if (message.type === 'log' && !settled) admit(message.entry, logs)
if (message.type === 'log' && !settled) admit(message.text, logs)
onCall(message)
onDone(message)
})

View File

@@ -5,8 +5,6 @@
* @module @deepseek-ai/dsh-code-runtime-worker/src/protocol
*/
import type { CodeLogEntry } from '@deepseek-ai/dsh-code-runtime'
/** What the host hands the worker at spawn, via `workerData`. */
export interface WorkerBootData {
/** The type-stripped (plain JS) program body. */
@@ -20,7 +18,7 @@ export interface WorkerBootData {
}
/** Worker → host: one bridged binding call. */
export interface CallMessage {
interface CallMessage {
type: 'call'
/** Worker-issued correlation id; the host answers each id at most once and ignores duplicates. */
id: number
@@ -32,10 +30,10 @@ export interface CallMessage {
args: unknown
}
/** Worker → host: one captured log entry, streamed eagerly so output survives a mid-run termination (timeout, abort, OOM). */
export interface LogMessage {
/** Worker → host: captured text, streamed eagerly so output survives a mid-run termination (timeout, abort, OOM). */
interface LogMessage {
type: 'log'
entry: CodeLogEntry
text: string
}
/**

View File

@@ -1,9 +1,8 @@
import { describe, expect, it } from 'vitest'
import { EventEmitter } from 'node:events'
import { LogBuffer, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareValue, runWorkerMain, truncateUtf8Bytes, wireReplies } from '@deepseek-ai/dsh-code-runtime-worker/src/bootstrap.ts'
import type { BootstrapPort, PatchableStream, PendingCall } from '@deepseek-ai/dsh-code-runtime-worker/src/bootstrap.ts'
import type { ReplyMessage, WorkerToHost } from '@deepseek-ai/dsh-code-runtime-worker/src/protocol.ts'
import type { CodeLogEntry } from '@deepseek-ai/dsh-code-runtime'
import { LogBuffer, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareValue, runWorkerMain, truncateUtf8Bytes, wireReplies } from '../src/bootstrap.ts'
import type { BootstrapPort, PatchableStream, PendingCall } from '../src/bootstrap.ts'
import type { ReplyMessage, WorkerToHost } from '../src/protocol.ts'
/**
* An in-process stand-in for the worker's parentPort: the test plays the
@@ -31,8 +30,8 @@ class FakePort implements BootstrapPort {
this.emitter.emit('message', message)
}
logs(): CodeLogEntry[] {
return this.sent.filter(message => message.type === 'log').map(message => message.entry)
logs(): string[] {
return this.sent.filter(message => message.type === 'log').map(message => message.text)
}
done(): WorkerToHost | undefined {
@@ -48,12 +47,12 @@ const BOOT = { maxLogBytes: 65_536, maxValueBytes: 32_768 }
describe('LogBuffer', () => {
it('streams entries to the sink until the byte budget, then emits one marker and drops the rest', () => {
const seen: CodeLogEntry[] = []
const buffer = new LogBuffer(10, entry => seen.push(entry))
buffer.push({ source: 'console', level: 'log', text: '12345' })
buffer.push({ source: 'console', level: 'log', text: '123456' })
buffer.push({ source: 'console', level: 'log', text: 'dropped' })
expect(seen.map(entry => entry.text)).toEqual([
const seen: string[] = []
const buffer = new LogBuffer(10, text => seen.push(text))
buffer.push('12345')
buffer.push('123456')
buffer.push('dropped')
expect(seen).toEqual([
'12345',
'[dsh-code-runtime-worker] log capture truncated at 10 bytes',
])
@@ -61,40 +60,37 @@ describe('LogBuffer', () => {
})
describe('makeConsoleShim', () => {
it('captures the five levels and renders non-strings inspect-style', () => {
const seen: CodeLogEntry[] = []
const shim = makeConsoleShim(new LogBuffer(1_000, entry => seen.push(entry)))
it('captures the five methods and renders non-strings inspect-style', () => {
const seen: string[] = []
const shim = makeConsoleShim(new LogBuffer(1_000, text => seen.push(text)))
shim.log('plain', { a: 1 })
shim.info('i')
shim.warn('w')
shim.error('e')
shim.debug('d')
expect(seen.map(entry => entry.level)).toEqual(['log', 'info', 'warn', 'error', 'debug'])
expect(seen[0]?.text).toBe('plain { a: 1 }')
expect(seen.every(entry => entry.source === 'console')).toBe(true)
expect(seen).toEqual(['plain { a: 1 }', 'i', 'w', 'e', 'd'])
})
})
describe('captureStreamWrites', () => {
it('redirects writes into the buffer and restores on request', () => {
const seen: CodeLogEntry[] = []
const buffer = new LogBuffer(1_000, entry => seen.push(entry))
const seen: string[] = []
const buffer = new LogBuffer(1_000, text => seen.push(text))
let underlying = ''
const stream: PatchableStream = { write: (chunk: unknown) => { underlying += String(chunk); return true } }
const restore = captureStreamWrites(buffer, stream, 'stdout')
const restore = captureStreamWrites(buffer, stream)
stream.write('captured', 'utf8')
stream.write(Buffer.from('bytes'))
restore()
stream.write('after')
expect(seen.map(entry => entry.text)).toEqual(['captured', 'bytes'])
expect(seen[0]).toMatchObject({ source: 'stdout' })
expect(seen).toEqual(['captured', 'bytes'])
expect(underlying).toBe('after')
})
it('invokes the write callback asynchronously, in both optional-encoding shapes', async () => {
const buffer = new LogBuffer(1_000, () => {})
const stream: PatchableStream = { write: () => true }
captureStreamWrites(buffer, stream, 'stdout')
captureStreamWrites(buffer, stream)
const calls: (Error | null | undefined)[] = []
stream.write('two-arg', (error?: Error | null) => calls.push(error))
stream.write('three-arg', 'utf8', (error?: Error | null) => calls.push(error))
@@ -107,7 +103,7 @@ describe('captureStreamWrites', () => {
it('still fires the callback for a write the exhausted budget drops', async () => {
const buffer = new LogBuffer(4, () => {})
const stream: PatchableStream = { write: () => true }
captureStreamWrites(buffer, stream, 'stdout')
captureStreamWrites(buffer, stream)
stream.write('this write overflows the budget and is dropped')
await new Promise<void>(resolve => stream.write('also dropped', resolve))
})
@@ -210,7 +206,7 @@ describe('runWorkerMain', () => {
code: 'const doubled = await tools.double({ n: 21 }); console.log("got", doubled); return { doubled };',
namespaces: [{ global: 'tools', names: ['double'] }],
}, fakeStreams())
expect(port.logs()).toEqual([{ source: 'console', level: 'log', text: 'got 42' }])
expect(port.logs()).toEqual(['got 42'])
expect(port.done()).toEqual({ type: 'done', value: { doubled: 42 } })
})
@@ -268,6 +264,6 @@ describe('runWorkerMain', () => {
// The patch stays installed for the worker's lifetime; writes during the
// program landed in order. Here the program wrote nothing via streams, so
// only the post-run write above went through the patched slot.
expect(port.logs().at(-1)).toMatchObject({ source: 'stdout' })
expect(port.logs().at(-1)).toBe('never seen — already restored? no: patch persists in worker')
})
})

View File

@@ -38,9 +38,9 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
expect(exitCode, `stderr:\n${stderr}`).toBe(0)
const lastLine = stdout.trim().split('\n').at(-1) ?? ''
const result = JSON.parse(lastLine) as { value?: unknown; logs: { source: string; level?: string; text: string }[]; error?: unknown }
const result = JSON.parse(lastLine) as { value?: unknown; logs: string[]; error?: unknown }
expect(result.error).toBeUndefined()
expect(result.value).toBe(42)
expect(result.logs).toContainEqual({ source: 'console', level: 'log', text: 'halfway 42' })
expect(result.logs).toContain('halfway 42')
})
})

View File

@@ -28,7 +28,7 @@ describe('WorkerCodeRuntime — programs and bindings (real workers)', () => {
expect(runtime.isolation).toBe('worker-thread')
})
it('runs TypeScript (erasable syntax), captures console/stdout in order, returns the value', async () => {
it('runs TypeScript (erasable syntax), captures output in order, returns the value', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: `
@@ -43,12 +43,7 @@ describe('WorkerCodeRuntime — programs and bindings (real workers)', () => {
})
expect(result.error).toBeUndefined()
expect(result.value).toBe(3)
expect(result.logs.map(entry => [entry.source, entry.level ?? null])).toEqual([
['console', 'log'],
['stdout', null],
['console', 'warn'],
])
expect(result.logs[0]?.text).toBe('point { x: 1, y: 2 }')
expect(result.logs).toEqual(['point { x: 1, y: 2 }', 'raw-out\n', 'careful'])
})
it('bridges binding calls both ways and rejects the program-side call on a host rejection', async () => {
@@ -115,7 +110,7 @@ describe('WorkerCodeRuntime — programs and bindings (real workers)', () => {
bindings: [],
})
expect(result.error?.kind).toBe('exception')
expect(result.logs.map(entry => entry.text)).toContain('before')
expect(result.logs).toContain('before')
})
})
@@ -210,8 +205,8 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => {
program: 'for (let i = 0; i < 1000; i++) console.log("spam line", i); return 1',
bindings: [],
})
expect(result.logs.at(-1)?.text).toContain('truncated at 300 bytes')
const total = result.logs.reduce((sum, entry) => sum + Buffer.byteLength(entry.text, 'utf8'), 0)
expect(result.logs.at(-1)).toContain('truncated at 300 bytes')
const total = result.logs.reduce((sum, text) => sum + Buffer.byteLength(text, 'utf8'), 0)
expect(total).toBeLessThan(1_000)
})
@@ -241,7 +236,7 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => {
})
expect(result.error).toBeUndefined()
expect(result.value).toBe('done')
expect(result.logs).toContainEqual({ source: 'stdout', text: 'flushed' })
expect(result.logs).toContain('flushed')
})
it('caps a huge container whose bounded rendering is small (wire size, not rendering, is what counts)', async () => {
@@ -268,8 +263,8 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => {
bindings: [],
})
expect(result.error).toBeUndefined()
expect(result.logs).toContainEqual({ source: 'stdout', text: 'abcd' })
expect(result.logs.map(entry => entry.text)).not.toContain('ef')
expect(result.logs).toContain('abcd')
expect(result.logs).not.toContain('ef')
}, 15_000)
})
@@ -304,11 +299,9 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
{ type: 'call', id: 1e9, global: 7, name: 'real', args: {} },
{ type: 'call', id: 1e9, global: 'tools', name: 7, args: {} },
{ type: 'log' },
{ type: 'log', entry: null },
{ type: 'log', entry: { source: 'stdout', text: 7 } },
{ type: 'log', entry: { source: 'nope', text: 'x' } },
{ type: 'log', entry: { source: 'console', level: 'nope', text: 'x' } },
{ type: 'log', entry: { source: 'console', level: 7, text: 'x' } },
{ type: 'log', text: null },
{ type: 'log', text: 7 },
{ type: 'log', text: {} },
{ type: 'done', error: 5 },
{ type: 'done', error: { message: 5 } },
]) parentPort.postMessage(junk);
@@ -329,7 +322,7 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
// code and an unbounded result.
program: `
const { parentPort } = await import('node:worker_threads');
for (let i = 0; i < 50; i++) parentPort.postMessage({ type: 'log', entry: { source: 'stdout', text: 'F'.repeat(100), forged: true } });
for (let i = 0; i < 50; i++) parentPort.postMessage({ type: 'log', text: 'F'.repeat(100), forged: true });
parentPort.postMessage({ type: 'done', value: 'V'.repeat(100000) });
for (;;) {}
`,
@@ -341,10 +334,9 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
expect(value.endsWith('… [truncated]')).toBe(true)
expect(value.length).toBeLessThan(120)
const marker = '[dsh-code-runtime-worker] log capture truncated at 200 bytes'
const total = result.logs.reduce((sum, entry) => sum + Buffer.byteLength(entry.text, 'utf8'), 0)
const total = result.logs.reduce((sum, text) => sum + Buffer.byteLength(text, 'utf8'), 0)
expect(total).toBeLessThanOrEqual(200 + Buffer.byteLength(marker, 'utf8'))
expect(result.logs.at(-1)?.text).toBe(marker)
expect(result.logs.every(entry => !('forged' in entry))).toBe(true)
expect(result.logs.at(-1)).toBe(marker)
})
it('accepts a forged done carrying both value and error (self-sabotage, contained)', async () => {

View File

@@ -16,7 +16,7 @@ Semantics every implementation must honor (contract details in the class JSDoc):
## Vocabulary
`CodeRunRequest` (`program`, `bindings`, `signal?`) carries everything the runtime acts on — defaulting (time budgets, output caps) is the implementation's validated config, never a hidden `??` inside `run()`. `bindings` is a list of `CodeBindingNamespace`s (`global` + `functions`), each exposed to the program as one global object of async callables. `CodeRunResult` reports the completion `value?`, the ordered `logs` (`CodeLogEntry`: `console`/`stdout`/`stderr` source, console `level`, capped text), and the `error?` (`CodeRunFailure`: `kind` + model-feedable `message`). See `src/types.ts` for the full contracts.
`CodeRunRequest` (`program`, `bindings`, `signal?`) carries everything the runtime acts on — defaulting (time budgets, output caps) is the implementation's validated config, never a hidden `??` inside `run()`. `bindings` is a list of `CodeBindingNamespace`s (`global` + `functions`), each exposed to the program as one global object of async callables. `CodeRunResult` reports the completion `value?`, ordered capped `logs: string[]`, and the `error?` (`CodeRunFailure`: `kind` + model-feedable `message`). See `src/types.ts` for the full contracts.
## Model Experience

View File

@@ -10,7 +10,6 @@ import type { CodeRunRequest, CodeRunResult } from './types.ts'
export type {
CodeBindingFunction,
CodeBindingNamespace,
CodeLogEntry,
CodeRunFailure,
CodeRunRequest,
CodeRunResult,

View File

@@ -54,20 +54,6 @@ export interface CodeRunRequest {
signal?: AbortSignal
}
/**
* One captured output entry, in emission order. `source` says which channel
* produced it: the program's `console` (shimmed by the runtime), or a stray
* write to the underlying stdout/stderr streams.
*/
export interface CodeLogEntry {
/** Which channel produced the text. */
source: 'console' | 'stdout' | 'stderr'
/** The console method used; present only when `source` is `'console'`. */
level?: 'log' | 'info' | 'warn' | 'error' | 'debug'
/** The captured text (possibly truncated by the implementation's caps, marked in-band). */
text: string
}
/**
* Why a run failed. The kinds are orthogonal outcomes reported independently
* (per docs/defensive-patterns.md): a budget expiry is not an exception, an
@@ -98,8 +84,8 @@ export interface CodeRunResult {
* or value-less run leaves this absent.
*/
value?: unknown
/** Everything the program emitted, in order (capped by the implementation). */
logs: CodeLogEntry[]
/** Text the program emitted, in order (capped by the implementation). */
logs: string[]
/** Present iff the run failed; see {@link CodeRunFailure} for the taxonomy. */
error?: CodeRunFailure
}

View File

@@ -55,7 +55,7 @@ describe('CodeRuntime service seam', () => {
it('reports a failed run as an error field on a resolved result, never a rejection', async () => {
const { runtime } = await setup()
runtime.nextResult = {
logs: [{ source: 'console', level: 'error', text: 'boom' }],
logs: ['boom'],
error: { kind: 'exception', message: 'boom' },
}
const result = await runtime.run({ program: 'throw new Error("boom")', bindings: [] })

View File

@@ -555,7 +555,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'AssembledSection',
declaration: 'export interface AssembledSection {\n name: string;\n order: number;\n text: string;\n}',
declaration: 'export interface AssembledSection {\n name: string;\n text: string;\n}',
},
{
name: 'BashExecRequest',
@@ -609,10 +609,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'CodeBindingNamespace',
declaration: 'export interface CodeBindingNamespace {\n global: string;\n functions: Record<string, CodeBindingFunction>;\n}',
},
{
name: 'CodeLogEntry',
declaration: 'export interface CodeLogEntry {\n source: \'console\' | \'stdout\' | \'stderr\';\n level?: \'log\' | \'info\' | \'warn\' | \'error\' | \'debug\';\n text: string;\n}',
},
{
name: 'CodeRunFailure',
declaration: 'export interface CodeRunFailure {\n kind: \'exception\' | \'timeout\' | \'abort\' | \'worker-exit\';\n message: string;\n}',
@@ -623,7 +619,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'CodeRunResult',
declaration: 'export interface CodeRunResult {\n value?: unknown;\n logs: CodeLogEntry[];\n error?: CodeRunFailure;\n}',
declaration: 'export interface CodeRunResult {\n value?: unknown;\n logs: string[];\n error?: CodeRunFailure;\n}',
},
{
name: 'CollectedOutput',

View File

@@ -65,10 +65,6 @@ export interface PromptSection {
export interface AssembledSection {
/** The contributing section's unique name. */
name: string
// TODO(assembled-section-order): drop this output field; registry order has
// already sorted the array, and no production renderer/listener reads it.
/** The contributing section's order (sections arrive sorted ascending). */
order: number
/** The resolved (but not yet interpolated) section text. */
text: string
}
@@ -403,12 +399,11 @@ export class SystemPrompt extends Service {
}
const assembly: PromptAssembly = {
sections: [...sectionByName.values()]
.sort((a, b) => a.order - b.order)
.map(section => ({
name: section.name,
order: section.order,
text: typeof section.text === 'function' ? section.text(context) : section.text,
}))
.sort((a, b) => a.order - b.order),
})),
tools: orderTools(collected, this.toolOrder, knownNames),
variables,
}

View File

@@ -139,7 +139,7 @@ describe('scoped assemble dispatch', () => {
scope.ctx.on('system-prompt/assemble', async (_assembly: PromptAssembly, context, next: () => Promise<PromptAssembly>) => {
shaped.push(context.scope)
const result = await next()
result.sections.push({ name: 'listener:extra', order: 999, text: 'listener text' })
result.sections.push({ name: 'listener:extra', text: 'listener text' })
return result
})

View File

@@ -21,9 +21,9 @@ describe('SystemPrompt', () => {
await ctx.plugin(SystemPrompt, { persona: 'You are DeepSeek Harness SDK.' })
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.sections.map(s => [s.name, s.order])).toEqual([
['harness:identity', -100],
['deployment:persona', 0],
expect(assembly.sections.map(s => s.name)).toEqual([
'harness:identity',
'deployment:persona',
])
expect(renderPrompt(assembly)).toBe(`${IDENTITY}\n\nYou are DeepSeek Harness SDK.`)
// The names are reserved by the plugin — one owner per section.
@@ -183,7 +183,7 @@ describe('SystemPrompt', () => {
const contexts: AssembleContext[] = []
ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, context, next) => {
contexts.push(context)
assembly.sections.push({ name: 'from-a', order: 100, text: 'a' })
assembly.sections.push({ name: 'from-a', text: 'a' })
return next()
})
// Listener B (registered later, runs after A) sees A's contribution.
@@ -235,8 +235,8 @@ describe('SystemPrompt', () => {
it('filters out empty section text from renderPrompt', () => {
const result = renderPrompt({
sections: [
{ name: 'empty', order: 0, text: '' },
{ name: 'real', order: 1, text: 'content' },
{ name: 'empty', text: '' },
{ name: 'real', text: 'content' },
],
tools: [],
variables: {},
@@ -356,13 +356,13 @@ describe('SystemPrompt', () => {
})
it('names "(none)" when no variables are registered at all', () => {
expect(() => renderPrompt({ sections: [{ name: 's', order: 0, text: '{{x}}' }], tools: [], variables: {} }))
expect(() => renderPrompt({ sections: [{ name: 's', text: '{{x}}' }], tools: [], variables: {} }))
.toThrow('unknown prompt variable "{{x}}" in section "s"; registered variables: (none)')
})
it('throws when a referenced variable has no value for this assembly', () => {
expect(() => renderPrompt({
sections: [{ name: 'persona', order: 0, text: 'in {{cwd}}' }],
sections: [{ name: 'persona', text: 'in {{cwd}}' }],
tools: [],
variables: { cwd: undefined },
})).toThrow('prompt variable "{{cwd}}" has no value for this assembly (section "persona")')
@@ -370,7 +370,7 @@ describe('SystemPrompt', () => {
it('throws on a malformed complete reference, e.g. inner spaces', () => {
expect(() => renderPrompt({
sections: [{ name: 's', order: 0, text: 'on {{ model }}' }],
sections: [{ name: 's', text: 'on {{ model }}' }],
tools: [],
variables: { model: 'm' },
})).toThrow('malformed prompt variable reference "{{ model }}" in section "s"')
@@ -378,7 +378,7 @@ describe('SystemPrompt', () => {
it('leaves a lone {{ verbatim only when NO }} follows anywhere after it', () => {
const text = renderPrompt({
sections: [{ name: 's', order: 0, text: 'shell ${X:-{{fallback} stays' }],
sections: [{ name: 's', text: 'shell ${X:-{{fallback} stays' }],
tools: [],
variables: {},
})
@@ -390,7 +390,7 @@ describe('SystemPrompt', () => {
{ text: 'x {{a{b}} y {{model}}', label: 'nested brace inside a would-be group' },
])('throws on a mangled reference with a }} still following ($label)', ({ text }) => {
expect(() => renderPrompt({
sections: [{ name: 's', order: 0, text }],
sections: [{ name: 's', text }],
tools: [],
variables: { model: 'm' },
})).toThrow('malformed prompt variable reference at')
@@ -400,7 +400,7 @@ describe('SystemPrompt', () => {
// `in` would find Object.prototype.constructor and splice function
// source into the prompt; Object.hasOwn must reject it instead.
expect(() => renderPrompt({
sections: [{ name: 's', order: 0, text: 'on {{constructor}}' }],
sections: [{ name: 's', text: 'on {{constructor}}' }],
tools: [],
variables: { model: 'm' },
})).toThrow('unknown prompt variable "{{constructor}}"')
@@ -416,7 +416,7 @@ describe('SystemPrompt', () => {
it('never re-scans substituted values (a value containing {{sneaky}} stays literal)', () => {
const text = renderPrompt({
sections: [{ name: 's', order: 0, text: 'v = {{model}}!' }],
sections: [{ name: 's', text: 'v = {{model}}!' }],
tools: [],
variables: { model: 'literal {{sneaky}} inside' },
})

View File

@@ -108,14 +108,13 @@ function renderValue(value: unknown): string {
/** The run_code result's `meta` payload (JSON-serializable; `presentResult` narrows it back). */
interface RunCodeMeta {
logs: CodeRunResult['logs']
dispatches: number
}
/** Soft-narrow a result `meta` back to {@link RunCodeMeta} (replay may carry older shapes; presentation must not throw). */
function asRunCodeMeta(meta: unknown): RunCodeMeta | undefined {
if (typeof meta !== 'object' || meta === null) return undefined
const m = meta as Record<string, unknown>
if (!Array.isArray(m.logs) || typeof m.dispatches !== 'number') return undefined
if (!Array.isArray(m.logs) || !m.logs.every(log => typeof log === 'string')) return undefined
return m as unknown as RunCodeMeta
}
@@ -251,12 +250,12 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
}
if (result.error) {
const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.map(entry => entry.text).join('\n')}` : ''
const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.join('\n')}` : ''
throw new CodeRunFailedError(`code run failed (${result.error.kind}): ${result.error.message}${logsText}`)
}
const rendered = renderValue(result.value)
const parts = [result.logs.map(entry => entry.text).join('\n'), rendered].filter(part => part.length > 0)
const meta: RunCodeMeta = { logs: result.logs, dispatches }
const parts = [result.logs.join('\n'), rendered].filter(part => part.length > 0)
const meta: RunCodeMeta = { logs: result.logs }
return {
content: [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }],
meta,
@@ -278,7 +277,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
presentResult: (_args, result) => {
const meta = asRunCodeMeta(result.meta)
if (!meta) return undefined
const output = meta.logs.map(entry => entry.text).join('\n')
const output = meta.logs.join('\n')
return {
card: 'generic',
...output.length > 0 ? { content: [{ type: 'text' as const, text: output }] } : {},

View File

@@ -328,7 +328,7 @@ describe('the run_code dispatch bridge', () => {
const tools = request.bindings[0]!.functions
const first = await tools.echo!({ value: 'one' })
const second = await tools.echo!({ value: 'two' })
return { logs: [{ source: 'console', level: 'log', text: `saw ${String(first)}` }], value: second }
return { logs: [`saw ${String(first)}`], value: second }
}
const result = await runCode(ctx, 'const …: string = …', { agent })
expect(result.isError).toBe(false)
@@ -339,7 +339,7 @@ describe('the run_code dispatch bridge', () => {
{ parentCallId: 'call-1', subCallId: 'call-1:code:1', name: 'echo', arguments: { value: 'one' }, isError: false, resultSummary: 'echo:one' },
{ parentCallId: 'call-1', subCallId: 'call-1:code:2', name: 'echo', arguments: { value: 'two' }, isError: false, resultSummary: 'echo:two' },
])
expect(result.meta).toEqual({ logs: [{ source: 'console', level: 'log', text: 'saw echo:one' }], dispatches: 2 })
expect(result.meta).toEqual({ logs: ['saw echo:one'] })
})
it('exposes only an opaque parent token to nested result observers', async () => {
@@ -503,7 +503,7 @@ describe('the run_code dispatch bridge', () => {
it('converts a failed run into a structured isError result carrying kind, message, and captured logs', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
runtime.behavior = () => Promise.resolve({
logs: [{ source: 'console', level: 'log', text: 'got this far' }],
logs: ['got this far'],
error: { kind: 'timeout', message: 'compute budget exhausted (300ms busy)' },
})
const result = await runCode(ctx, 'program')
@@ -627,7 +627,7 @@ describe('the run_code dispatch bridge', () => {
const view = tool.presentResult?.({ code: 'return 1' }, {
content: [{ type: 'text', text: 'model-facing' }],
isError: false,
meta: { logs: [{ source: 'console', level: 'log', text: 'printed' }], dispatches: 1 },
meta: { logs: ['printed'] },
})
// The result omits the title — an update replaces only provided fields,
// so the pending card's program title persists through completion.
@@ -636,9 +636,10 @@ describe('the run_code dispatch bridge', () => {
content: [{ type: 'text', text: 'printed' }],
})
// No captured output → no content either; everything pending persists.
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { logs: [], dispatches: 2 } }))
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { logs: [] } }))
.toEqual({ card: 'generic' })
// Replay with an unrecognizable meta falls back to the generic rendering.
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { logs: [{ text: 'legacy' }], dispatches: 1 } })).toBeUndefined()
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { other: true } })).toBeUndefined()
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false })).toBeUndefined()
})

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,88 @@
# @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 under server-qualified names (`mcp__<serverName>__<rawName>`).
## Usage
One plugin instance per MCP server in `cordis.yml`:
```yaml
- id: mcp-github
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: github
transport: stdio
command: npx
args: ['-y', '@modelcontextprotocol/server-github']
env:
GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN
- id: mcp-web
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: web
transport: streamable-http
url: http://localhost:3000/mcp
headers:
Authorization: !!js '`Bearer ${process.env.MCP_TOKEN}`'
```
The model sees `mcp__github__create_issue`, `mcp__web__search`, … — the same server-qualified shape Claude Code and Codex use. HMR hot-swaps: editing the entry triggers disconnect + reconnect without process restart; an unchanged `serverName` reproduces identical tool names.
## Config
| Field | Transport | Required | Description |
|---|---|---|---|
| `transport` | both | yes | `"stdio"` or `"streamable-http"` |
| `serverName` | both | yes | Namespace for this server's model-facing tool names; `[A-Za-z0-9_-]{1,32}`, unique across live instances |
| `command` | stdio | yes | Executable to spawn |
| `args` | stdio | no | Arguments passed to the command |
| `env` | stdio | no | Extra env vars merged on top of scrubbed ambient env |
| `cwd` | stdio | no | Working directory for the child process |
| `url` | http | yes | MCP server URL |
| `headers` | http | no | Extra headers (e.g. auth tokens) |
| `toolCallTimeoutMs` | both | no | Timeout per `callTool` invocation (default 60000) |
## Tool naming
Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call`) and the public name `mcp__<serverName>__<rawName>` registered on `ctx.tools`. Public names are normalized to the DeepSeek function-name contract (64 chars, `[A-Za-z0-9_-]`); when replacement or truncation changes the name, a deterministic 12-hex-char hash of `(serverName, rawName)` is appended so distinct tools never collapse into one name. Names are pure functions of `(serverName, rawName)` — connection order, re-syncs, and other servers never rename a tool.
- Two servers publishing the same raw name (e.g. `search`) coexist under their namespaces.
- A duplicate `serverName` across live instances fails the later plugin instance at load.
- A server listing the same tool name twice is rejected as an invalid tool list.
- A foreign registration squatting on this server's namespace rolls back the whole generation (never a partial set), with a loud error.
## Behavior
- On connect: `listTools()` → registers each tool via `ctx.tools.register()` under its public name.
- Listens for `notifications/tools/list_changed` → re-syncs; a failed re-sync keeps the previous generation registered.
- Tool execute: `client.callTool({ name: rawName, arguments }, { signal })` with timeout + abort support — the public name is never sent to the server.
- Image content in results is discarded with a placeholder (the harness has no image block type).
- On disconnect/crash: all tools are unregistered; no auto-reconnect.
## Services consumed
| Service | Usage |
|---|---|
| `ctx.tools` | Register/unregister MCP tools |
## Model Experience
### Discovered MCP tools
**What the model sees**: After initial discovery succeeds, each advertised MCP tool appears as a native tool named `mcp__<serverName>__<rawName>` (or its deterministic normalized form), with the server-provided description and input schema. A successful re-sync replaces the generation; plugin disposal removes it.
**Token effect**: Data-dependent schema cost is paid on every request while the tools are registered. Re-sync replaces rather than accumulates schemas, and the server-qualified name adds tokens to every tool definition and call.
### Tool-call history and results
**What the model sees**: The public tool name and JSON arguments remain in assistant history. Text result blocks are joined with newlines into one retained text result; image, audio, resource, and unsupported blocks become short placeholders, and MCP `isError` results follow the registry's model-visible error path.
**Token effect**: Arguments and mapped text are retained until compaction. Binary and resource payloads are discarded rather than added to context.
## Known Limitations and Deferred Work
- **Initial discovery is asynchronous** — plugin load does not wait for connection and `listTools()`, so a turn started immediately after boot or HMR can assemble before the MCP tools are registered.
- **Tools are the only bridged MCP capability** — Resources and Prompts have no harness consumption surface and are deferred.
- **Crash recovery is manual** — transport closure unregisters the server's tools, but reconnect requires an HMR reload or harness restart.
- **Non-text results are lossy** — image, audio, and resource payloads are replaced with placeholders, and a structured-only result has no model-visible structured representation.

View File

@@ -0,0 +1,41 @@
{
"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.7"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0",
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@modelcontextprotocol/server-everything": "^2026.7.4",
"@modelcontextprotocol/server-filesystem": "^2026.7.4",
"cordis": "^4.0.0-rc.7",
"zod": "^4.4.3"
}
}

View File

@@ -0,0 +1,177 @@
/**
* MCP client bridge plugin: connects to an external MCP server and registers
* its tools on `ctx.tools` under server-qualified public names
* (`mcp__<serverName>__<rawName>`). Each plugin instance connects to one MCP
* server; load multiple instances in `cordis.yml` for multiple servers.
*
* Namespace plugin (named exports, no default export). Lifecycle is
* effect-scoped: disposal disconnects from the server, unregisters all tools,
* and releases the `serverName` namespace reservation. HMR hot-swaps by
* disposing the old instance and creating a new one; identical `serverName`
* reproduces identical public tool names.
*
* @module @deepseek-ai/dsh-mcp-client
*/
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
/**
* Valid `serverName`: 132 chars of `[A-Za-z0-9_-]`. Kept well under the
* 64-char public-name budget so typical raw tool names survive unhashed.
*/
const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/
/**
* Live `serverName` reservations per app, keyed off `ctx.root` (multiple apps
* in one process — tests — must not see each other's names). A duplicate
* namespace is a configuration error surfaced at plugin load, never silent
* shadowing.
*/
const activeServerNames = new WeakMap<Context, Set<string>>()
// ---- Config ----
/** Config for connecting to an MCP server via a spawned child process over stdio. */
export interface StdioConfig {
/** Transport type: spawn a child process and communicate over stdio. */
transport: 'stdio'
/**
* Stable local namespace for this server's model-facing tool names
* (`mcp__<serverName>__<rawName>`). Must match `[A-Za-z0-9_-]{1,32}` and be
* unique across live mcp-client instances.
*/
serverName: string
/** Executable to spawn. */
command: string
/** Arguments passed to the command. */
args: string[]
/** Extra env vars merged on top of scrubbed ambient env. */
env: Record<string, string>
/** Working directory for the child process. */
cwd: 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'
/**
* Stable local namespace for this server's model-facing tool names
* (`mcp__<serverName>__<rawName>`). Must match `[A-Za-z0-9_-]{1,32}` and be
* unique across live mcp-client instances.
*/
serverName: string
/** MCP server URL. */
url: string
/** Extra headers (e.g. auth tokens). */
headers: Record<string, string>
/** 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'),
serverName: z.string().required().pattern(SERVER_NAME_PATTERN),
command: z.string().required(),
args: z.array(String).default([]),
env: z.dict(String).default({}),
cwd: z.string().default(''),
toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS),
}),
z.object({
transport: z.const('streamable-http'),
serverName: z.string().required().pattern(SERVER_NAME_PATTERN),
url: z.string().required(),
headers: z.dict(String).default({}),
toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS),
}),
]) as unknown as z<Config>
// ---- Plugin apply ----
export function apply(ctx: Context, config: Config): void {
// Reserve the namespace first: a duplicate `serverName` fails THIS instance
// at load with an actionable error and leaves the earlier instance intact.
ctx.effect(() => {
let names = activeServerNames.get(ctx.root)
if (!names) {
names = new Set()
activeServerNames.set(ctx.root, names)
}
if (names.has(config.serverName)) {
throw new Error(
`mcp-client: serverName "${config.serverName}" is already in use by another mcp-client instance — pick a unique serverName in cordis.yml`,
)
}
names.add(config.serverName)
return () => void names.delete(config.serverName)
}, 'mcp-client.serverName')
const transport = createTransport(config)
const client = new Client(
{ name: 'dsh-mcp-client', version: '0.0.1' },
{ capabilities: {} },
)
const opts = {
serverName: config.serverName,
toolCallTimeoutMs: config.toolCallTimeoutMs,
}
// Connect and set up tools. Errors during connect/first sync are logged,
// not thrown (the plugin simply has no tools registered). `ready` resolves
// to an accessor for the CURRENT disposer generation, so the effect
// disposer below always unregisters the live set, not the first one.
const ready = (async () => {
await client.connect(transport)
let disposers = await syncTools(client, ctx, opts, new Map())
client.setNotificationHandler(
ToolListChangedNotificationSchema,
async () => {
ctx.logger.info(`mcp-client(${config.serverName}): tool list changed, re-syncing`)
try {
disposers = await syncTools(client, ctx, opts, disposers)
} catch (error) {
// Fetch-phase failure: the previous generation is still registered
// and `disposers` still owns it — keep serving the last good list.
ctx.logger.error(`mcp-client(${config.serverName}): tool re-sync failed: ${String(error)}`)
}
},
)
return () => disposers
})().catch((error: unknown) => {
ctx.logger.error(`mcp-client(${config.serverName}): failed to connect: ${String(error)}`)
return () => new Map<string, () => void>()
})
ctx.effect(() => async () => {
const live = await ready
for (const dispose of live().values()) dispose()
try { await client.close() } catch { /* transport already gone */ }
}, 'mcp-client.connection')
}

View File

@@ -0,0 +1,230 @@
/**
* Tool bridge: discovers MCP tools, registers them on the harness ToolRegistry
* under deterministic server-qualified public names, and handles re-sync when
* the server's tool list changes.
*
* Naming contract (see the mcp-client RFC "Naming invariants"): every MCP tool
* has the stable identity `(serverName, rawName)`; the model-facing public name
* is `mcp__<serverName>__<rawName>`, normalized to the DeepSeek function-name
* constraints. The raw name is only ever sent on the wire (`tools/call`); the
* public name is never parsed to recover it.
*
* @module
*/
import { createHash } from 'node:crypto'
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
import type { Context } from 'cordis'
import type { ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools'
/** Resolved options relevant to tool bridging. */
export interface ToolBridgeOptions {
serverName: string
toolCallTimeoutMs: number
}
/** State for one sync generation: the current set of disposers keyed by public name. */
export type ToolDisposers = Map<string, () => void>
/**
* DeepSeek function-name contract: at most 64 characters. Wire-protocol
* constant, not configuration.
*/
const MAX_PUBLIC_NAME_LENGTH = 64
/** DeepSeek function-name contract: only `[A-Za-z0-9_-]` is allowed. */
const INVALID_NAME_CHARS = /[^A-Za-z0-9_-]/g
/** Hex chars of the SHA-256 identity hash appended on lossy normalization. */
const HASH_LENGTH = 12
/**
* Derive the model-facing public name for one MCP tool.
*
* Deterministic pure function of `(serverName, rawName)`: the clean case is
* `mcp__<serverName>__<rawName>` verbatim. When character replacement or
* truncation to the DeepSeek function-name contract (64 chars,
* `[A-Za-z0-9_-]`) changes the name, a 12-hex-char SHA-256 hash of the
* identity is appended so distinct MCP identities never collapse into the
* same public name.
*
* @param serverName - Stable local namespace from plugin config.
* @param rawName - The MCP server's own tool name.
* @returns The globally unique, model-facing ToolRegistry name.
*/
export function publicToolName(serverName: string, rawName: string): string {
const joined = `mcp__${serverName}__${rawName}`
const normalized = joined.replace(INVALID_NAME_CHARS, '_')
if (normalized === joined && normalized.length <= MAX_PUBLIC_NAME_LENGTH) return normalized
const hash = createHash('sha256').update(`${serverName}\0${rawName}`).digest('hex').slice(0, HASH_LENGTH)
return `${normalized.slice(0, MAX_PUBLIC_NAME_LENGTH - HASH_LENGTH - 1)}_${hash}`
}
/**
* Sync the MCP server's tool list into the harness ToolRegistry.
*
* Two phases keep the swap safe:
*
* 1. Fetch: drain `client.listTools()` pagination and build the full next
* generation of `ToolDefinition`s under public names. Any failure here
* (network error, duplicate raw name in the server's list) rejects and
* leaves the previous generation registered untouched.
* 2. Swap: dispose the previous generation, register the new one. A registry
* conflict here can only mean a foreign registration squats on this
* server's `mcp__<serverName>__` namespace — the partial generation is
* rolled back (zero tools from this server), the error is logged, and an
* empty map is returned.
*
* @param client - Connected MCP Client instance used to list and call tools.
* @param ctx - Cordis context providing the `tools` service for registration.
* @param opts - Bridge options: server namespace and per-call timeout.
* @param previous - Disposer map from the prior sync generation; disposed
* during the swap phase (only after the fetch phase succeeded).
* @returns A map of registered public tool names to their unregister
* disposers — the exact set of live registrations owned by this server.
*/
export async function syncTools(
client: Client,
ctx: Context,
opts: ToolBridgeOptions,
previous: ToolDisposers,
): Promise<ToolDisposers> {
// Phase 1: fetch and build the next generation without touching the registry.
const definitions = new Map<string, ToolDefinition>()
let cursor: string | undefined
do {
const response = await client.listTools(cursor ? { cursor } : undefined)
for (const tool of response.tools) {
const publicName = publicToolName(opts.serverName, tool.name)
if (definitions.has(publicName)) {
throw new Error(
`mcp-client(${opts.serverName}): server listed tool "${tool.name}" more than once — invalid tool list`,
)
}
definitions.set(publicName, {
name: publicName,
description: tool.description ?? '',
parameters: tool.inputSchema,
execute: createExecutor(client, tool.name, opts),
})
}
cursor = response.nextCursor
} while (cursor)
// Phase 2: swap generations.
for (const dispose of previous.values()) dispose()
const disposers: ToolDisposers = new Map()
try {
for (const [publicName, definition] of definitions) {
disposers.set(publicName, ctx.tools.register(definition))
}
} catch (error) {
// A conflict on an `mcp__<serverName>__`-qualified name means a foreign
// registration occupies this server's namespace. Roll back so the model
// sees either the full generation or none of it — never a partial set.
for (const dispose of disposers.values()) dispose()
ctx.logger.error(`mcp-client(${opts.serverName}): tool registration failed, no tools registered: ${String(error)}`)
return new Map()
}
return disposers
}
/**
* 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 closes over the
* raw MCP tool name and calls `client.callTool` with it (never the public
* name), with abort signal and timeout, then maps the result to harness
* ContentBlocks.
*
* When the MCP server returns `isError: true`, the executor throws so that
* the ToolRegistry's catch path produces an `isError` result for the model.
*/
function createExecutor(
client: Client,
rawName: 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: rawName, 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, rawName)
// 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,282 @@
/**
* 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 ----
// vi.mock factories are hoisted above every import/const, so the mock fns and
// class must be created inside vi.hoisted to exist when the factories run.
const { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotificationHandler, MockClient } = vi.hoisted(() => {
const mockConnect = vi.fn<() => Promise<void>>()
const mockClose = vi.fn<() => Promise<void>>()
const mockListTools = vi.fn()
const mockCallTool = vi.fn()
const mockSetNotificationHandler = vi.fn()
class MockClient {
connect = mockConnect
close = mockClose
listTools = mockListTools
callTool = mockCallTool
setNotificationHandler = mockSetNotificationHandler
}
return { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotificationHandler, MockClient }
})
vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
Client: MockClient,
}))
vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
StdioClientTransport: vi.fn(),
}))
vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({
StreamableHTTPClientTransport: vi.fn(),
}))
// vi.mock is hoisted above static imports, so the module under test sees the
// mocked SDK even through a static import.
import { apply, name, inject, Config as ConfigSchema } from '@deepseek-ai/dsh-mcp-client/src/index.ts'
// ---- Helpers ----
async function mountRegistry(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
return ctx
}
function sleep(ms: number): Promise<void> {
// Annotated binding (not withResolvers<void>()): the tests lint layer runs
// no-invalid-void-type with default options, which rejects the explicit
// type argument in call position but accepts the inferred form.
const gate: PromiseWithResolvers<void> = Promise.withResolvers()
setTimeout(gate.resolve, ms)
return gate.promise
}
const stdioConfig: Config = {
transport: 'stdio',
serverName: 'srv',
command: 'echo',
args: [],
env: {},
cwd: '',
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()
})
it('Config schema rejects a missing serverName', () => {
expect(() => ConfigSchema({
transport: 'stdio',
command: 'echo',
} as never)).toThrow()
})
it('Config schema rejects an invalid serverName', () => {
// schemastery unions wrap branch errors in a generic "expected ... but got"
// message, so assert the throw, not the inner pattern text.
expect(() => ConfigSchema({
transport: 'stdio',
serverName: 'bad name!',
command: 'echo',
} as never)).toThrow()
expect(() => ConfigSchema({
transport: 'stdio',
serverName: 'x'.repeat(33),
command: 'echo',
} as never)).toThrow()
})
it('Config schema accepts a valid serverName', () => {
const resolved = ConfigSchema({
transport: 'stdio',
serverName: 'github-prod_1',
command: 'echo',
} as never)
expect(resolved.serverName).toBe('github-prod_1')
})
})
describe('apply (plugin lifecycle)', () => {
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 under the namespace, and registers a notification handler', async () => {
apply(ctx, stdioConfig)
await sleep(50)
expect(mockConnect).toHaveBeenCalled()
expect(mockListTools).toHaveBeenCalled()
expect(mockSetNotificationHandler).toHaveBeenCalled()
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
expect(ctx.tools.get('remote')).toBeUndefined()
})
it('rejects a duplicate serverName at load and leaves the first instance intact', async () => {
apply(ctx, stdioConfig)
await sleep(50)
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
expect(() => { apply(ctx, stdioConfig) }).toThrow(/serverName "srv" is already in use/)
// First instance unaffected.
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
})
it('releases the serverName reservation on dispose', async () => {
const first = new Context()
await first.plugin(SystemPrompt)
await first.plugin(ToolRegistry)
apply(first, stdioConfig)
await sleep(50)
await first.fiber.dispose()
await sleep(50)
// Same root would conflict; a fresh app root reuses the name freely,
// and the disposed instance no longer holds the reservation on its root.
const second = new Context()
await second.plugin(SystemPrompt)
await second.plugin(ToolRegistry)
expect(() => { apply(second, stdioConfig) }).not.toThrow()
})
it('scopes serverName reservations per app root', async () => {
const other = await mountRegistry()
apply(ctx, stdioConfig)
// Same serverName on a DIFFERENT root is fine.
expect(() => { apply(other, stdioConfig) }).not.toThrow()
await sleep(50)
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
expect(other.tools.get('mcp__srv__remote')).toBeDefined()
})
it('logs error and registers no tools when connect fails; dispose is a no-op', async () => {
mockConnect.mockRejectedValue(new Error('connection refused'))
apply(ctx, stdioConfig)
await sleep(50)
expect(mockListTools).not.toHaveBeenCalled()
expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
// Disposal exercises the empty fallback accessor: nothing to unregister,
// close still attempted, no throw.
await ctx.fiber.dispose()
await sleep(50)
expect(mockClose).toHaveBeenCalled()
})
it('re-syncs tools on ToolListChanged notification', async () => {
apply(ctx, stdioConfig)
await sleep(50)
expect(ctx.tools.get('mcp__srv__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('mcp__srv__remote')).toBeUndefined()
expect(ctx.tools.get('mcp__srv__updated')).toBeDefined()
})
it('keeps the previous generation when a re-sync fails', async () => {
apply(ctx, stdioConfig)
await sleep(50)
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
mockListTools.mockRejectedValue(new Error('flaky server'))
const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
// Must not reject (contained), and must keep the last good generation.
await handler()
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
})
it('effect disposer unregisters the CURRENT generation and closes client', async () => {
// Load through ctx.plugin so ONLY the plugin's fiber is disposed — the
// registry must survive to observe the unregistration.
const fiber = ctx.plugin({ name: 'mcp-client', inject: ['tools'], apply }, stdioConfig)
await sleep(50)
// Advance to a second generation first.
mockListTools.mockResolvedValue({
tools: [{ name: 'updated', inputSchema: { type: 'object' } }],
nextCursor: undefined,
})
const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
await handler()
expect(ctx.tools.get('mcp__srv__updated')).toBeDefined()
await fiber.dispose()
await sleep(50)
expect(mockClose).toHaveBeenCalled()
// The live (second) generation was unregistered, not just the first.
expect(ctx.tools.get('mcp__srv__updated')).toBeUndefined()
})
it('effect disposer handles client.close failure gracefully', async () => {
mockClose.mockRejectedValue(new Error('already closed'))
apply(ctx, stdioConfig)
await sleep(50)
// Should not throw when dispose is triggered.
await ctx.fiber.dispose()
await sleep(50)
expect(mockClose).toHaveBeenCalled()
})
it('uses streamable-http config path', async () => {
const httpConfig: Config = {
transport: 'streamable-http',
serverName: 'web',
url: 'http://localhost:3000/mcp',
headers: { Authorization: 'Bearer x' },
toolCallTimeoutMs: 30_000,
}
apply(ctx, httpConfig)
await sleep(50)
expect(mockConnect).toHaveBeenCalled()
expect(ctx.tools.get('mcp__web__remote')).toBeDefined()
})
})

View File

@@ -0,0 +1,65 @@
/**
* Minimal MCP server over stdio for e2e testing of the dsh-mcp-client plugin.
* Registers controlled tools with predictable behavior for asserting edge cases.
*
* Run: node --import tsx fixture-server.ts
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { z } from 'zod'
const server = new McpServer(
{ name: 'fixture-server', version: '1.0.0' },
{ capabilities: { tools: { listChanged: true } } },
)
server.registerTool('add', {
title: 'Add Tool',
description: 'Adds two numbers.',
inputSchema: { a: z.number().describe('First number'), b: z.number().describe('Second number') },
}, async args => ({
content: [{ type: 'text', text: String(args.a + args.b) }],
}))
server.registerTool('greet', {
title: 'Greet Tool',
description: 'Greets a person by name.',
inputSchema: { name: z.string().describe('Name to greet') },
}, async args => ({
content: [{ type: 'text', text: `Hello, ${args.name}!` }],
}))
server.registerTool('fail', {
title: 'Fail Tool',
description: 'Always returns an error.',
inputSchema: {},
}, async () => ({
content: [{ type: 'text', text: 'Something went wrong' }],
isError: true,
}))
server.registerTool('image', {
title: 'Image Tool',
description: 'Returns an image content block.',
inputSchema: {},
}, async () => ({
content: [
{ type: 'text', text: 'Here is an image:' },
{ type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' },
{ type: 'text', text: 'End of image.' },
],
}))
// Dotted name: legal in MCP, illegal in the DeepSeek function-name contract.
// Exercises the bridge's normalize-and-hash public-name path end to end.
server.registerTool('admin.reset', {
title: 'Admin Reset Tool',
description: 'Tool with a dotted name (normalization test).',
inputSchema: {},
}, async () => ({
content: [{ type: 'text', text: 'reset done' }],
}))
const transport = new StdioServerTransport()
await server.connect(transport)

View File

@@ -0,0 +1,29 @@
/**
* Real-load-path guard for @deepseek-ai/dsh-mcp-client. `mcp-client` is a
* NAMESPACE plugin with `inject` — so a stray `export default apply` would
* make the cordis Loader's `unwrapExports` (`exports.default ?? exports`)
* collapse the module to the bare `apply` function, DROPPING `inject`. The
* plugin would then read `ctx.tools` without having injected it and throw
* `cannot get property … without inject` the moment it loads (postmortem 0001).
*
* This test unwraps the module through the REAL `Loader.prototype.unwrapExports`
* and verifies the namespace shape is preserved.
*/
import { describe, expect, it } from 'vitest'
import Loader from '@cordisjs/plugin-loader'
import * as mcpClient from '@deepseek-ai/dsh-mcp-client'
describe('dsh-mcp-client real-load-path guard', () => {
it('has no default export and keeps name/inject/Config through unwrapExports', () => {
expect('default' in mcpClient).toBe(false)
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(mcpClient) as Record<string, unknown>
expect(unwrapped).toBe(mcpClient)
expect(unwrapped.name).toBe('mcp-client')
expect(unwrapped.inject).toEqual(['tools'])
expect(typeof unwrapped.apply).toBe('function')
expect(unwrapped.Config).toBeDefined()
})
})

View File

@@ -0,0 +1,441 @@
/**
* End-to-end tests for dsh-mcp-client. Exercises the REAL MCP protocol against:
* 1. A self-written fixture server over stdio (controlled edge cases)
* 2. @modelcontextprotocol/server-everything (official integration test server)
* 3. @modelcontextprotocol/server-filesystem (real filesystem operations)
* 4. An in-process StreamableHTTPServerTransport server over Streamable HTTP
*
* No API key needed — all servers are local/keyless.
*/
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import { z } from 'zod'
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import { CallId } from '@deepseek-ai/dsh-llm'
import { apply } from '@deepseek-ai/dsh-mcp-client/src/index.ts'
import { publicToolName } from '@deepseek-ai/dsh-mcp-client/src/tools.ts'
import type { Config } from '@deepseek-ai/dsh-mcp-client'
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
const fixtureServerPath = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
// Resolve package-local .bin for pnpm-hoisted MCP server binaries.
const packageDir = fileURLToPath(new URL('..', import.meta.url))
const localBin = join(packageDir, 'node_modules', '.bin')
// ---- Helpers ----
async function mountRegistry(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
return ctx
}
/** Apply the MCP client plugin and wait for tools to be registered. */
async function applyAndWait(ctx: Context, config: Config, timeoutMs = 20_000): Promise<void> {
// Annotated bindings (not withResolvers<void>()): the tests lint layer runs
// no-invalid-void-type with default options, which rejects the explicit
// type argument in call position but accepts the inferred form.
const gate: PromiseWithResolvers<void> = Promise.withResolvers()
const timer = setTimeout(
() => { gate.reject(new Error(`applyAndWait timed out after ${timeoutMs}ms — no tools/change event`)) },
timeoutMs,
)
ctx.on('tools/change', () => { clearTimeout(timer); gate.resolve() })
apply(ctx, config)
await gate.promise
}
function sleep(ms: number): Promise<void> {
const gate: PromiseWithResolvers<void> = Promise.withResolvers()
setTimeout(gate.resolve, ms)
return gate.promise
}
/** Narrow a result content block to its text, failing the test on any other shape. */
function textOf(block: unknown): string {
if (block && typeof block === 'object' && 'text' in block && typeof block.text === 'string') {
return block.text
}
throw new Error(`expected a text content block, got ${JSON.stringify(block)}`)
}
let callSeq = 0
function nextCallId(): CallId {
return CallId(`e2e-${++callSeq}`)
}
// ---- Fixture server tests ----
describe('fixture server — controlled scenarios', () => {
let ctx: Context
const fixtureConfig: Config = {
transport: 'stdio',
serverName: 'fixture',
command: process.execPath,
args: ['--import', tsxLoader, fixtureServerPath],
env: { TSX_TSCONFIG_PATH: repoTsconfig },
cwd: packageDir,
toolCallTimeoutMs: 15_000,
}
beforeAll(async () => {
ctx = await mountRegistry()
await applyAndWait(ctx, fixtureConfig)
}, 30_000)
afterAll(async () => {
if (ctx) await ctx.fiber.dispose()
await sleep(200)
})
it('discovers all fixture tools under the server namespace', () => {
const schemas = ctx.tools.schemas()
const names = schemas.map(s => s.name)
expect(names).toContain('mcp__fixture__add')
expect(names).toContain('mcp__fixture__greet')
expect(names).toContain('mcp__fixture__fail')
expect(names).toContain('mcp__fixture__image')
// Raw names are not registered.
expect(names).not.toContain('add')
})
it('normalizes the dotted tool name with a deterministic hash suffix', () => {
const publicName = publicToolName('fixture', 'admin.reset')
expect(publicName).toMatch(/^mcp__fixture__admin_reset_[0-9a-f]{12}$/)
expect(ctx.tools.get(publicName)).toBeDefined()
})
it('executes the dotted tool via its normalized public name', async () => {
const result = await ctx.tools.execute({
callId: nextCallId(), name: publicToolName('fixture', 'admin.reset'), arguments: {},
})
expect(result.isError).toBe(false)
expect(result.content[0]).toEqual({ type: 'text', text: 'reset done' })
})
it('executes add(2, 3) → "5"', async () => {
const result = await ctx.tools.execute({
callId: nextCallId(), name: 'mcp__fixture__add', arguments: { a: 2, b: 3 },
})
expect(result.isError).toBe(false)
expect(result.content[0]).toEqual({ type: 'text', text: '5' })
})
it('executes greet("World") → "Hello, World!"', async () => {
const result = await ctx.tools.execute({
callId: nextCallId(), name: 'mcp__fixture__greet', arguments: { name: 'World' },
})
expect(result.isError).toBe(false)
expect(result.content[0]).toEqual({ type: 'text', text: 'Hello, World!' })
})
it('executes fail() → isError result', async () => {
const result = await ctx.tools.execute({
callId: nextCallId(), name: 'mcp__fixture__fail', arguments: {},
})
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ type: 'text' })
})
it('executes image() → image placeholder', async () => {
const result = await ctx.tools.execute({
callId: nextCallId(), name: 'mcp__fixture__image', arguments: {},
})
expect(result.isError).toBe(false)
const text = textOf(result.content[0])
expect(text).toContain('Here is an image:')
expect(text).toContain('[image: image/png, content discarded]')
expect(text).toContain('End of image.')
})
})
describe('fixture server — duplicate serverName', () => {
it('rejects a second instance with the same serverName on one root', async () => {
const ctx = await mountRegistry()
const config: Config = {
transport: 'stdio',
serverName: 'dup',
command: process.execPath,
args: ['--import', tsxLoader, fixtureServerPath],
env: { TSX_TSCONFIG_PATH: repoTsconfig },
cwd: packageDir,
toolCallTimeoutMs: 15_000,
}
await applyAndWait(ctx, config)
expect(() => { apply(ctx, config) }).toThrow(/serverName "dup" is already in use/)
await ctx.fiber.dispose()
await sleep(200)
}, 30_000)
})
describe('fixture server — disposal', () => {
it('disposes cleanly without error', async () => {
const ctx = await mountRegistry()
await applyAndWait(ctx, {
transport: 'stdio',
serverName: 'fixture',
command: process.execPath,
args: ['--import', tsxLoader, fixtureServerPath],
env: { TSX_TSCONFIG_PATH: repoTsconfig },
cwd: packageDir,
toolCallTimeoutMs: 15_000,
})
// Tools are registered before dispose.
expect(ctx.tools.get('mcp__fixture__add')).toBeDefined()
expect(ctx.tools.schemas().length).toBeGreaterThanOrEqual(4)
// Dispose should complete without throwing.
await ctx.fiber.dispose()
await sleep(200)
}, 30_000)
})
// ---- @modelcontextprotocol/server-everything ----
describe('server-everything — official test server', () => {
let ctx: Context
const config: Config = {
transport: 'stdio',
serverName: 'everything',
command: join(localBin, 'mcp-server-everything'),
args: ['stdio'],
env: {},
cwd: '',
toolCallTimeoutMs: 30_000,
}
beforeAll(async () => {
ctx = await mountRegistry()
await applyAndWait(ctx, config)
}, 60_000)
afterAll(async () => {
if (ctx) await ctx.fiber.dispose()
await sleep(500)
})
it('discovers tools from server-everything', () => {
const schemas = ctx.tools.schemas()
const names = schemas.map(s => s.name)
expect(names).toContain('mcp__everything__echo')
expect(names).toContain('mcp__everything__get-sum')
expect(names).toContain('mcp__everything__get-tiny-image')
expect(names.length).toBeGreaterThanOrEqual(8)
})
it('executes echo({ message: "hello" }) → "Echo: hello"', async () => {
const result = await ctx.tools.execute({
callId: nextCallId(), name: 'mcp__everything__echo', arguments: { message: 'hello' },
})
expect(result.isError).toBe(false)
expect(textOf(result.content[0])).toBe('Echo: hello')
})
it('executes get-sum({ a: 3, b: 7 }) → contains "10"', async () => {
const result = await ctx.tools.execute({
callId: nextCallId(), name: 'mcp__everything__get-sum', arguments: { a: 3, b: 7 },
})
expect(result.isError).toBe(false)
expect(textOf(result.content[0])).toContain('10')
})
it('executes get-tiny-image → image placeholder', async () => {
const result = await ctx.tools.execute({
callId: nextCallId(), name: 'mcp__everything__get-tiny-image', arguments: {},
})
expect(result.isError).toBe(false)
expect(textOf(result.content[0])).toContain('[image: image/png, content discarded]')
})
})
// ---- @modelcontextprotocol/server-filesystem ----
describe('server-filesystem — real filesystem operations', () => {
let ctx: Context
let tempDir: string
beforeAll(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'mcp-fs-e2e-'))
ctx = await mountRegistry()
const config: Config = {
transport: 'stdio',
serverName: 'filesystem',
command: join(localBin, 'mcp-server-filesystem'),
args: [tempDir],
env: {},
cwd: '',
toolCallTimeoutMs: 30_000,
}
await applyAndWait(ctx, config)
}, 60_000)
afterAll(async () => {
if (ctx) await ctx.fiber.dispose()
await sleep(500)
await rm(tempDir, { recursive: true, force: true })
})
it('discovers filesystem tools', () => {
const schemas = ctx.tools.schemas()
const names = schemas.map(s => s.name)
expect(names).toContain('mcp__filesystem__read_file')
expect(names).toContain('mcp__filesystem__write_file')
expect(names).toContain('mcp__filesystem__list_directory')
})
it('write_file + read_file round-trip', async () => {
const filePath = join(tempDir, 'test.txt')
const content = 'Hello from MCP e2e test!'
// Write via MCP tool
const writeResult = await ctx.tools.execute({
callId: nextCallId(), name: 'mcp__filesystem__write_file', arguments: { path: filePath, content },
})
expect(writeResult.isError).toBe(false)
// Verify file was actually written (world verification)
const onDisk = await readFile(filePath, 'utf8')
expect(onDisk).toBe(content)
// Read back via MCP tool
const readResult = await ctx.tools.execute({
callId: nextCallId(), name: 'mcp__filesystem__read_file', arguments: { path: filePath },
})
expect(readResult.isError).toBe(false)
expect(textOf(readResult.content[0])).toContain(content)
})
it('list_directory shows written file', async () => {
// Ensure a file exists
await writeFile(join(tempDir, 'listed.txt'), 'listed')
const result = await ctx.tools.execute({
callId: nextCallId(), name: 'mcp__filesystem__list_directory', arguments: { path: tempDir },
})
expect(result.isError).toBe(false)
expect(textOf(result.content[0])).toContain('listed.txt')
})
})
// ---- Streamable HTTP transport ----
describe('streamable-http — in-process MCP server', () => {
let ctx: Context
let httpServer: Server
let baseUrl: string
/** Authorization header values observed by the HTTP server, in arrival order. */
const seenAuth: Array<string | undefined> = []
/**
* Stateless Streamable HTTP endpoint: a fresh McpServer + server transport
* per request (the SDK's documented stateless pattern — no session id, no
* SSE stream to keep). The tool set mirrors a minimal fixture server.
*/
async function handleMcpRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
seenAuth.push(req.headers.authorization)
const server = new McpServer(
{ name: 'http-fixture', version: '1.0.0' },
{ capabilities: { tools: {} } },
)
server.registerTool('ping', {
description: 'Replies pong.',
inputSchema: {},
}, async () => ({
content: [{ type: 'text', text: 'pong' }],
}))
server.registerTool('shout', {
description: 'Upper-cases a message.',
inputSchema: { message: z.string().describe('Message to upper-case') },
}, async args => ({
content: [{ type: 'text', text: args.message.toUpperCase() }],
}))
// Stateless mode: sessionIdGenerator ABSENT (the runtime treats absent and
// explicit-undefined identically; exactOptionalPropertyTypes forbids the
// SDK-documented explicit `sessionIdGenerator: undefined` spelling).
const transport = new StreamableHTTPServerTransport({})
res.on('close', () => { void transport.close(); void server.close() })
// Same exactOptionalPropertyTypes mismatch the client transport factory
// documents (src/transport.ts): the SDK types optional callbacks without
// `| undefined`. The SDK constructed the object; the cast is safe.
await server.connect(transport as Transport)
await transport.handleRequest(req, res)
}
beforeAll(async () => {
httpServer = createServer((req, res) => {
handleMcpRequest(req, res).catch((error: unknown) => {
res.writeHead(500).end(String(error))
})
})
const listening: PromiseWithResolvers<void> = Promise.withResolvers()
httpServer.listen(0, '127.0.0.1', listening.resolve)
await listening.promise
const address = httpServer.address()
if (address === null || typeof address === 'string') throw new Error(`expected a TCP AddressInfo, got ${String(address)}`)
baseUrl = `http://127.0.0.1:${address.port}/mcp`
ctx = await mountRegistry()
const config: Config = {
transport: 'streamable-http',
serverName: 'web',
url: baseUrl,
headers: { Authorization: 'Bearer e2e-test-token' },
toolCallTimeoutMs: 15_000,
}
await applyAndWait(ctx, config)
}, 30_000)
afterAll(async () => {
if (ctx) await ctx.fiber.dispose()
await sleep(200)
const closed: PromiseWithResolvers<void> = Promise.withResolvers()
httpServer.close(() => { closed.resolve() })
await closed.promise
})
it('discovers tools under the server namespace over HTTP', () => {
const names = ctx.tools.schemas().map(s => s.name)
expect(names).toContain('mcp__web__ping')
expect(names).toContain('mcp__web__shout')
})
it('executes ping() → "pong" over HTTP', async () => {
const result = await ctx.tools.execute({
callId: nextCallId(), name: 'mcp__web__ping', arguments: {},
})
expect(result.isError).toBe(false)
expect(result.content[0]).toEqual({ type: 'text', text: 'pong' })
})
it('executes shout({ message }) with args over HTTP', async () => {
const result = await ctx.tools.execute({
callId: nextCallId(), name: 'mcp__web__shout', arguments: { message: 'quiet' },
})
expect(result.isError).toBe(false)
expect(result.content[0]).toEqual({ type: 'text', text: 'QUIET' })
})
it('sends configured headers on every HTTP request', () => {
expect(seenAuth.length).toBeGreaterThan(0)
for (const auth of seenAuth) expect(auth).toBe('Bearer e2e-test-token')
})
})

View File

@@ -0,0 +1,602 @@
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 { publicToolName, syncTools, type ToolBridgeOptions } from '@deepseek-ai/dsh-mcp-client/src/tools.ts'
import { createTransport } from '@deepseek-ai/dsh-mcp-client/src/transport.ts'
import type { Config } from '@deepseek-ai/dsh-mcp-client'
// ---- 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 = {
serverName: 'srv',
toolCallTimeoutMs: 60_000,
}
// ---- Tests ----
describe('publicToolName', () => {
it('joins clean names verbatim', () => {
expect(publicToolName('github', 'create_issue')).toBe('mcp__github__create_issue')
expect(publicToolName('everything', 'get-sum')).toBe('mcp__everything__get-sum')
})
it('replaces invalid characters and appends an identity hash', () => {
const name = publicToolName('srv', 'admin.reset')
expect(name).toMatch(/^mcp__srv__admin_reset_[0-9a-f]{12}$/)
expect(name.length).toBeLessThanOrEqual(64)
})
it('truncates over-long names and appends an identity hash', () => {
const rawName = 'a'.repeat(80)
const name = publicToolName('srv', rawName)
expect(name).toHaveLength(64)
expect(name).toMatch(/_[0-9a-f]{12}$/)
expect(name.startsWith('mcp__srv__aaa')).toBe(true)
})
it('is deterministic and collision-free for distinct identities', () => {
// Two raw names that normalize to the same base must not collapse.
const a = publicToolName('srv', 'admin.reset')
const b = publicToolName('srv', 'admin_reset')
expect(a).toBe(publicToolName('srv', 'admin.reset'))
expect(a).not.toBe(b)
})
})
describe('syncTools', () => {
let ctx: Context
beforeEach(async () => {
ctx = await mountRegistry()
})
it('registers tools under server-qualified public names', async () => {
const client = createMockClient([
{ name: 'greet', description: 'Say hello', inputSchema: { type: 'object', properties: { name: { type: 'string' } } } },
{ name: 'add', description: 'Add numbers', inputSchema: { type: 'object', properties: {} } },
])
const disposers = await syncTools(client as never, ctx, defaultOpts, new Map())
expect(disposers.size).toBe(2)
expect(ctx.tools.get('mcp__srv__greet')).toBeDefined()
expect(ctx.tools.get('mcp__srv__add')).toBeDefined()
// Raw names are NOT registered.
expect(ctx.tools.get('greet')).toBeUndefined()
expect(ctx.tools.get('add')).toBeUndefined()
})
it('lets two servers publish the same raw name side by side', async () => {
const clientA = createMockClient([{ name: 'search', inputSchema: { type: 'object' } }])
const clientB = createMockClient([{ name: 'search', inputSchema: { type: 'object' } }])
await syncTools(clientA as never, ctx, { ...defaultOpts, serverName: 'github' }, new Map())
await syncTools(clientB as never, ctx, { ...defaultOpts, serverName: 'web' }, new Map())
expect(ctx.tools.get('mcp__github__search')).toBeDefined()
expect(ctx.tools.get('mcp__web__search')).toBeDefined()
})
it('coexists with a native tool of the same raw name', async () => {
ctx.tools.register({
name: 'search',
description: 'Native search',
parameters: { type: 'object' },
execute: async () => [{ type: 'text', text: 'native' }],
})
const client = createMockClient([{ name: 'search', inputSchema: { type: 'object' } }])
await syncTools(client as never, ctx, defaultOpts, new Map())
expect(ctx.tools.get('search')).toBeDefined()
expect(ctx.tools.get('mcp__srv__search')).toBeDefined()
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'search', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: 'native' })
})
it('rejects a tool list where one raw name appears twice', async () => {
const client = createMockClient([
{ name: 'dup', inputSchema: { type: 'object' } },
{ name: 'dup', inputSchema: { type: 'object' } },
])
await expect(syncTools(client as never, ctx, defaultOpts, new Map()))
.rejects.toThrow(/listed tool "dup" more than once/)
// Nothing registered, previous generation untouched (it was empty).
expect(ctx.tools.get('mcp__srv__dup')).toBeUndefined()
})
it('keeps the previous generation when the fetch phase fails', async () => {
const client = createMockClient([{ name: 'stable', inputSchema: { type: 'object' } }])
const first = await syncTools(client as never, ctx, defaultOpts, new Map())
expect(ctx.tools.get('mcp__srv__stable')).toBeDefined()
client.listTools.mockRejectedValue(new Error('network down'))
await expect(syncTools(client as never, ctx, defaultOpts, first)).rejects.toThrow('network down')
// The previous generation is still live.
expect(ctx.tools.get('mcp__srv__stable')).toBeDefined()
})
it('rolls back the whole generation when a foreign tool squats on the namespace', async () => {
// A foreign registration occupies one of this server's public names.
ctx.tools.register({
name: 'mcp__srv__taken',
description: 'Squatter',
parameters: { type: 'object' },
execute: async () => [{ type: 'text', text: 'squatter' }],
})
const client = createMockClient([
{ name: 'free', inputSchema: { type: 'object' } },
{ name: 'taken', inputSchema: { type: 'object' } },
])
const disposers = await syncTools(client as never, ctx, defaultOpts, new Map())
// All-or-nothing: the non-conflicting tool is rolled back too.
expect(disposers.size).toBe(0)
expect(ctx.tools.get('mcp__srv__free')).toBeUndefined()
// The squatter is untouched.
expect(ctx.tools.get('mcp__srv__taken')).toBeDefined()
})
it('unregisters previous tools before re-syncing', async () => {
const client = createMockClient([
{ name: 'old_tool', inputSchema: { type: 'object' } },
])
const firstDisposers = await syncTools(client as never, ctx, defaultOpts, new Map())
expect(ctx.tools.get('mcp__srv__old_tool')).toBeDefined()
// Second sync with different tools should remove old_tool.
client.listTools.mockResolvedValue({ tools: [{ name: 'new_tool', inputSchema: { type: 'object' } }], nextCursor: undefined })
const secondDisposers = await syncTools(client as never, ctx, defaultOpts, firstDisposers)
expect(ctx.tools.get('mcp__srv__old_tool')).toBeUndefined()
expect(ctx.tools.get('mcp__srv__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('mcp__srv__page1')).toBeDefined()
expect(ctx.tools.get('mcp__srv__page2')).toBeDefined()
})
})
describe('tool execution', () => {
let ctx: Context
beforeEach(async () => {
ctx = await mountRegistry()
})
it('calls MCP callTool with the RAW name and returns text content', async () => {
const client = createMockClient(
[{ name: 'echo', inputSchema: { type: 'object' } }],
{ content: [{ type: 'text', text: 'hello world' }] },
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__echo', arguments: { msg: 'hi' } })
expect(result.isError).toBe(false)
expect(result.content).toEqual([{ type: 'text', text: 'hello world' }])
// The wire sees the raw MCP name, never the public name.
expect(client.callTool).toHaveBeenCalledWith(
{ name: 'echo', arguments: { msg: 'hi' } },
undefined,
expect.objectContaining({ timeout: 60_000 }),
)
})
it('sends the raw name for normalized public names', async () => {
const client = createMockClient(
[{ name: 'admin.reset', inputSchema: { type: 'object' } }],
{ content: [{ type: 'text', text: 'reset done' }] },
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const publicName = publicToolName('srv', 'admin.reset')
const result = await ctx.tools.execute({ callId: CallId('c1'), name: publicName, arguments: {} })
expect(result.isError).toBe(false)
expect(client.callTool).toHaveBeenCalledWith(
{ name: 'admin.reset', arguments: {} },
undefined,
expect.anything(),
)
})
it('joins multiple text blocks with newline', async () => {
const client = createMockClient(
[{ name: 'multi', inputSchema: { type: 'object' } }],
{ 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: 'mcp__srv__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: 'mcp__srv__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: 'mcp__srv__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: 'mcp__srv__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: 'mcp__srv__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: 'mcp__srv__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: 'mcp__srv__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: 'mcp__srv__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: 'mcp__srv__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: 'mcp__srv__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: 'mcp__srv__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: 'mcp__srv__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: 'mcp__srv__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: 'mcp__srv__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: 'mcp__srv__err_notext', arguments: {} })
expect(result.isError).toBe(true)
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('mcp__srv__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('mcp__srv__nodesc')
expect(tool?.description).toBe('')
})
})
describe('createTransport', () => {
it('creates StdioClientTransport for stdio config', () => {
const config: Config = {
transport: 'stdio',
serverName: 'srv',
command: 'node',
args: ['server.js'],
env: {},
cwd: '/tmp',
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',
serverName: 'srv',
url: 'http://localhost:3000/mcp',
headers: {},
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',
serverName: 'srv',
url: 'http://localhost:3000/mcp',
headers: { Authorization: 'Bearer token' },
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',
serverName: 'srv',
command: 'echo',
args: [],
env: { EXTRA: 'injected' },
cwd: '',
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',
serverName: 'srv',
command: 'echo',
args: [],
env: { CUSTOM: 'value' },
cwd: '',
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: 'mcp__srv__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: 'mcp__srv__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" }
]
}