feat: add canonical typed tool outputs
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' } }],
|
||||
|
||||
Reference in New Issue
Block a user