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

@@ -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)