Files
deepseek-harness/packages/mcp/mcp-client/src/tools.ts
T
imccyu ec601ca13d build(vendor): rescope the vendored Cordis packages into @deepseek-ai
Machine-produced by `pnpm run rescope-vendor --apply` plus the regeneration it
prints: `pnpm install` for the lockfile, `pnpm run gen-third-party-notices`,
`verify-translation-pairing --write` for the touched bilingual pairs,
`gen-doc-graphs`, and one typert snapshot whose ids embed character offsets.
`pnpm run rescope-vendor --check` verifies the result.

Renames nine vendored packages (cordis, cosmokit, schemastery and the six
@cordisjs plugins) and every reference that resolves them: manifest names and
dependency keys, module specifiers including declare-module merges, cordis.yml
plugin names, tsconfig paths, every Markdown fence, and `docs/` prose.
Directory names, upstream versions, and dependency ranges are unchanged, so
vendor/README.md still reads as an upstream snapshot; its manifest table gains
an upstream-name column so THIRD_PARTY_NOTICES keeps MIT attribution pointed
at each fork's origin.

The tutorial tier follows the rename end to end: its yaml fences named plugins
the Loader can no longer resolve, its `ts ignore-check` fences disagreed with
the compiled fences beside them, and its prose quoted both. The contracts that
told readers to keep upstream names — the root convention and the vendoring
cookbook's tree comment and manifest invariant — now say to rescope instead.

Two rules read `@deepseek-ai/` as "another workspace plugin": the client bundle
purity gate now names the vendored libraries a browser bundle inlines, and the
files where a bare `cordis` is an agent-preset id keep that product data.
2026-08-10 22:04:13 +08:00

318 lines
12 KiB
TypeScript

/**
* 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 Agent Note "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 { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'
import { z } from 'zod'
import type { Context } from '@deepseek-ai/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 {
/** Whether a registry conflict is contained or rejects this synchronization. */
registrationFailure: 'contain' | 'throw'
serverName: string
toolCallTimeoutMs: number
}
/** 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.
*/
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
/** Raw result record: the bridge owns JSON-value validation after transport. */
const RawCallToolResultSchema = z.record(z.string(), z.unknown())
/** List without mutating the SDK's per-page output-validator cache. */
function listToolsUncached(client: Client, cursor?: string) {
return client.request(
{ method: 'tools/list', ...cursor === undefined ? {} : { params: { cursor } } },
ListToolsResultSchema,
)
}
/** Call without the SDK pre-validating an output schema the bridge may not support. */
function callToolUncached(
client: Client,
rawName: string,
args: Record<string, unknown>,
exec: ToolExecution,
opts: ToolBridgeOptions,
) {
return client.request(
{ method: 'tools/call', params: { name: rawName, arguments: args } },
RawCallToolResultSchema,
{
signal: exec.signal,
timeout: opts.toolCallTimeoutMs,
},
)
}
/**
* 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 uncached `tools/list` 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) and logged. Initial strict
* synchronization may propagate the conflict so its parent transaction
* rejects; ordinary clients and later re-syncs return an empty map.
*
* @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 listToolsUncached(client, cursor)
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,
output: createOutput(tool.name, supportedOutputSchema(tool.outputSchema)),
execute: createExecutor(client, tool.name, tool.execution?.taskSupport === 'required', 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)}`)
if (opts.registrationFailure === 'throw') throw 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
}
/** 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: structuredSchema === undefined ? ['content'] : ['content', 'structuredContent'],
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 sends an uncached `tools/call` request with it (never
* the public name), with abort signal and timeout, then maps the result to
* harness ContentBlocks. Owning the raw request prevents the SDK's internal
* per-page schema cache from pre-validating a different contract.
*
* 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,
taskRequired: boolean,
opts: ToolBridgeOptions,
): ToolDefinition['execute'] {
return async (args: unknown, exec: ToolExecution) => {
if (taskRequired) {
throw new Error(`Tool "${rawName}" requires task-based execution, which this bridge does not support`)
}
// 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 callToolUncached(client, rawName, argsObj, exec, opts)
// The SDK may return a legacy `toolResult` shape; normalize to content array.
if (!Array.isArray(result.content)) {
const rendered: unknown = 'toolResult' in result
? JSON.stringify(result.toolResult)
: '(no output)'
const text = typeof rendered === 'string' ? rendered : '(no output)'
if (result.isError === true) throw new Error(text)
return {
content: [{ type: 'text', text }],
...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; extractText
// validates each element.
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.
if (result.isError === true) {
throw new Error(text)
}
return {
content,
...result.structuredContent !== undefined
? { structuredContent: result.structuredContent as JsonValue }
: {},
}
}
}
/**
* 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: JsonValue[], toolName: string): string {
const parts: string[] = []
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)
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)`
}