feat: add canonical typed tool outputs

This commit is contained in:
Tianyi Cui
2026-07-21 03:08:35 +08:00
parent 8500974fd4
commit 66c36e7325
173 changed files with 3298 additions and 954 deletions

View File

@@ -56,8 +56,9 @@ Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call`
- 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).
- Tool execute: `client.callTool({ name: rawName, arguments }, { signal })` with timeout + abort support—the public name is never sent to the server.
- Canonical success is `{ content: JsonValue[], structuredContent? }`; complete JSON MCP blocks survive for programmatic callers. A supported advertised `outputSchema` validates `structuredContent`; unsupported schema vocabulary falls back to unconstrained `JsonValue`.
- Native/model rendering keeps the existing text projection: text blocks join with newlines while image, audio, resource, and unsupported blocks become placeholders.
- On disconnect/crash: all tools are unregistered; no auto-reconnect.
## Services consumed
@@ -86,7 +87,7 @@ Prefix-stable while the discovered tool set and schemas are unchanged. A re-sync
#### 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.
The public tool name and JSON arguments remain in assistant history. Text result blocks are joined with newlines into one retained Native text result; image, audio, resource, and unsupported blocks become short placeholders there. Their full JSON blocks and optional structured content remain in the execution-local canonical value, and MCP `isError` rejects the call through the registry's error path.
#### Token effect
@@ -101,4 +102,5 @@ Append-only; newly visible content follows the reusable request prefix and does
- **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.
- **Native non-text rendering is lossy** — image, audio, and resource payloads become placeholders in model context even though the execution-local canonical value preserves their JSON blocks. Richer Native multimedia projection is deferred.
- **Unsupported MCP output schemas are not enforced** — `structuredContent` falls back to `JsonValue` when the advertised schema uses vocabulary outside the harness subset.

View File

@@ -22,6 +22,8 @@ import { syncTools } from './tools.ts'
// Side-effect type import: declaration-merges `ctx.tools` onto Context.
import type {} from '@deepseek-ai/dsh-tools'
export type { McpResult } from './tools.ts'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'mcp-client'

View File

@@ -16,6 +16,8 @@ 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'
import { assertSupportedJsonSchema } from '@deepseek-ai/dsh-tools'
import type { JsonSchemaNode, JsonValue } from '@deepseek-ai/dsh-tools'
/** Resolved options relevant to tool bridging. */
export interface ToolBridgeOptions {
@@ -26,6 +28,12 @@ export interface ToolBridgeOptions {
/** State for one sync generation: the current set of disposers keyed by public name. */
export type ToolDisposers = Map<string, () => void>
/** Canonical MCP result exposed to Code Mode without discarding protocol blocks. */
export type McpResult<Structured extends JsonValue = JsonValue> = {
content: JsonValue[]
structuredContent?: Structured
}
/**
* DeepSeek function-name contract: at most 64 characters. Wire-protocol
* constant, not configuration.
@@ -105,6 +113,7 @@ export async function syncTools(
name: publicName,
description: tool.description ?? '',
parameters: tool.inputSchema,
output: createOutput(tool.name, supportedOutputSchema(tool.outputSchema)),
execute: createExecutor(client, tool.name, opts),
})
}
@@ -141,6 +150,36 @@ interface McpContentBlock {
mimeType?: string
}
/** Keep a supported advertised schema; unsupported MCP vocabulary falls back to JsonValue. */
function supportedOutputSchema(candidate: unknown): JsonSchemaNode | undefined {
if (candidate === undefined) return undefined
try {
assertSupportedJsonSchema(candidate)
return candidate
} catch {
return undefined
}
}
/** Build the canonical result schema and existing Native text projection. */
function createOutput(rawName: string, structuredSchema: JsonSchemaNode | undefined): ToolDefinition['output'] {
return {
schema: {
type: 'object',
properties: {
content: { type: 'array', items: {} },
structuredContent: structuredSchema ?? {},
},
required: ['content'],
additionalProperties: false,
},
render(_args, value) {
const result = value as unknown as McpResult
return [{ type: 'text', text: extractText(result.content, rawName) }]
},
}
}
/**
* 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
@@ -172,18 +211,24 @@ function createExecutor(
// 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
const rendered: unknown = 'toolResult' in result
? JSON.stringify(result.toolResult)
: '(no output)'
return [{ type: 'text' as const, text }]
const text = typeof rendered === 'string' ? rendered : '(no output)'
if ('isError' in result && result.isError === true) throw new Error(text)
return {
content: [{ type: 'text', text }],
...'structuredContent' in result && result.structuredContent !== undefined
? { structuredContent: result.structuredContent as JsonValue }
: {},
}
}
// 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 content = result.content as unknown as JsonValue[]
const text = extractText(content, rawName)
// MCP isError → throw so ToolRegistry produces an isError result for the model.
@@ -191,7 +236,12 @@ function createExecutor(
throw new Error(text)
}
return [{ type: 'text', text }]
return {
content,
...'structuredContent' in result && result.structuredContent !== undefined
? { structuredContent: result.structuredContent as JsonValue }
: {},
}
}
}
@@ -203,10 +253,15 @@ function createExecutor(
* 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 {
function extractText(mcpContent: JsonValue[], toolName: string): string {
const parts: string[] = []
for (const block of mcpContent) {
for (const value of mcpContent) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
parts.push('[unsupported content type: unknown]')
continue
}
const block = value as unknown as McpContentBlock
switch (block.type) {
case 'text':
if (block.text !== undefined) parts.push(block.text)

View File

@@ -2,7 +2,7 @@ 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 ToolRegistry, { type JsonValue } 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'
@@ -13,10 +13,12 @@ interface MockTool {
name: string
description?: string
inputSchema: Record<string, unknown>
outputSchema?: Record<string, unknown>
}
interface MockCallResult {
content: Array<{ type: string; text?: string; mimeType?: string }>
content: JsonValue[]
structuredContent?: JsonValue
isError?: boolean
}
@@ -114,7 +116,8 @@ describe('syncTools', () => {
name: 'search',
description: 'Native search',
parameters: { type: 'object' },
execute: async () => [{ type: 'text', text: 'native' }],
output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value as string }] },
execute: async () => 'native',
})
const client = createMockClient([{ name: 'search', inputSchema: { type: 'object' } }])
@@ -156,7 +159,8 @@ describe('syncTools', () => {
name: 'mcp__srv__taken',
description: 'Squatter',
parameters: { type: 'object' },
execute: async () => [{ type: 'text', text: 'squatter' }],
output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value as string }] },
execute: async () => 'squatter',
})
const client = createMockClient([
{ name: 'free', inputSchema: { type: 'object' } },
@@ -221,6 +225,8 @@ describe('tool execution', () => {
expect(result.isError).toBe(false)
expect(result.content).toEqual([{ type: 'text', text: 'hello world' }])
if (result.isError) throw new Error('expected MCP success')
expect(result.value).toEqual({ content: [{ 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' } },
@@ -259,16 +265,85 @@ describe('tool execution', () => {
expect(result.content).toEqual([{ type: 'text', text: 'line1\nline2' }])
})
it('discards image content with placeholder', async () => {
it('preserves full JSON MCP blocks while Native rendering uses placeholders', async () => {
const blocks = [
{ type: 'text', text: 'before' },
{ type: 'image', mimeType: 'image/png', data: 'base64-data', annotations: { audience: ['assistant'] } },
] satisfies JsonValue[]
const client = createMockClient(
[{ name: 'img', inputSchema: { type: 'object' } }],
{ content: [{ type: 'text', text: 'before' }, { type: 'image', mimeType: 'image/png' }] },
{ content: blocks },
)
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]' })
if (result.isError) throw new Error('expected MCP success')
expect(result.value).toEqual({ content: blocks })
})
it('preserves primitive JSON MCP blocks while Native rendering marks them unsupported', async () => {
const blocks = [42, null, ['nested']] satisfies JsonValue[]
const client = createMockClient(
[{ name: 'primitive-blocks', inputSchema: { type: 'object' } }],
{ content: blocks },
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({
callId: CallId('primitive'), name: 'mcp__srv__primitive-blocks', arguments: {},
})
expect(result.content[0]).toEqual({
type: 'text',
text: '[unsupported content type: unknown]\n[unsupported content type: unknown]\n[unsupported content type: unknown]',
})
if (result.isError) throw new Error('expected primitive MCP blocks to remain a successful JSON value')
expect(result.value).toEqual({ content: blocks })
})
it('validates structuredContent when the advertised output schema is supported', async () => {
const outputSchema = {
type: 'object',
additionalProperties: false,
properties: { answer: { type: 'integer' } },
required: ['answer'],
}
const valid = createMockClient(
[{ name: 'structured', inputSchema: { type: 'object' }, outputSchema }],
{ content: [{ type: 'text', text: '42' }], structuredContent: { answer: 42 } },
)
await syncTools(valid as never, ctx, defaultOpts, new Map())
const success = await ctx.tools.execute({ callId: CallId('valid'), name: 'mcp__srv__structured', arguments: {} })
if (success.isError) throw new Error('expected supported structuredContent to validate')
expect(success.value).toEqual({ content: [{ type: 'text', text: '42' }], structuredContent: { answer: 42 } })
const invalidCtx = await mountRegistry()
const invalid = createMockClient(
[{ name: 'structured', inputSchema: { type: 'object' }, outputSchema }],
{ content: [{ type: 'text', text: 'wrong' }], structuredContent: { answer: 'forty-two' } },
)
await syncTools(invalid as never, invalidCtx, defaultOpts, new Map())
const failure = await invalidCtx.tools.execute({ callId: CallId('invalid'), name: 'mcp__srv__structured', arguments: {} })
expect(failure.error).toMatchObject({ info: { code: 'INVALID_TOOL_OUTPUT' } })
expect(failure.content[0]?.type === 'text' ? failure.content[0].text : '')
.toContain('value.structuredContent.answer')
})
it('falls back to JsonValue for unsupported advertised output schemas', async () => {
const client = createMockClient(
[{
name: 'future-schema',
inputSchema: { type: 'object' },
outputSchema: { type: 'object', patternProperties: { '^x-': { type: 'string' } } },
}],
{ content: [], structuredContent: ['kept', { nested: true }] },
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('fallback'), name: 'mcp__srv__future-schema', arguments: {} })
if (result.isError) throw new Error('unsupported MCP output schemas must fall back')
expect(result.value).toEqual({ content: [], structuredContent: ['kept', { nested: true }] })
})
it('maps isError to an error result via throw', async () => {
@@ -282,6 +357,7 @@ describe('tool execution', () => {
expect(result.isError).toBe(true)
expect(result.content[0]).toEqual({ type: 'text', text: 'Error: something went wrong' })
expect('value' in result).toBe(false)
})
it('passes abort signal to callTool', async () => {
@@ -313,6 +389,38 @@ describe('tool execution', () => {
expect(result.isError).toBe(false)
expect(result.content[0]).toEqual({ type: 'text', text: '{"key":"value"}' })
})
it('preserves structuredContent on a successful legacy result', async () => {
const client = createMockClient([{ name: 'legacy-structured', inputSchema: { type: 'object' } }])
client.callTool.mockResolvedValue({
toolResult: 'legacy',
structuredContent: { answer: 42 },
})
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({
callId: CallId('legacy-structured'), name: 'mcp__srv__legacy-structured', arguments: {},
})
if (result.isError) throw new Error('expected legacy structured result success')
expect(result.value).toEqual({
content: [{ type: 'text', text: '"legacy"' }],
structuredContent: { answer: 42 },
})
})
it('maps a legacy isError reply to failure', async () => {
const client = createMockClient([{ name: 'legacy-error', inputSchema: { type: 'object' } }])
client.callTool.mockResolvedValue({ toolResult: { reason: 'nope' }, isError: true })
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({
callId: CallId('legacy-error'), name: 'mcp__srv__legacy-error', arguments: {},
})
expect(result.isError).toBe(true)
expect(result.error?.message).toBe('{"reason":"nope"}')
})
})
describe('tool execution edge cases', () => {
@@ -423,7 +531,7 @@ describe('tool execution edge cases', () => {
const client = createMockClient(
[{ name: 'legacy2', inputSchema: { type: 'object' } }],
)
client.callTool.mockResolvedValue({})
client.callTool.mockResolvedValue({ toolResult: undefined, structuredContent: undefined })
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__legacy2', arguments: {} })
@@ -431,6 +539,18 @@ describe('tool execution edge cases', () => {
expect(result.content[0]).toEqual({ type: 'text', text: '(no output)' })
})
it('handles a legacy result with neither content nor toolResult', async () => {
const client = createMockClient(
[{ name: 'legacy-empty', inputSchema: { type: 'object' } }],
)
client.callTool.mockResolvedValue({})
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('legacy-empty'), name: 'mcp__srv__legacy-empty', 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' } }],