返回源码地图

packages/mcp/mcp-client/src/tools.ts

main snapshot · da00f7f5358f · 正文引用章节 12 / 12 / 12 / 12 / 12 / 12 / 12;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1/**
2 * Tool bridge: discovers MCP tools, registers them on the harness ToolRuntime
3 * under deterministic server-qualified public names, and handles re-sync when
4 * the server's tool list changes.
5 *
6 * Naming contract (see the mcp-client Agent Note "Naming invariants"): every MCP tool
7 * has the stable identity `(serverName, rawName)`; the model-facing public name
8 * is `mcp__<serverName>__<rawName>`, normalized to the DeepSeek function-name
9 * constraints. The raw name is only ever sent on the wire (`tools/call`); the
10 * public name is never parsed to recover it.
11 *
12 * @module
13 */
14
15import { createHash } from 'node:crypto'
16import { isDeepStrictEqual } from 'node:util'
17import { specTypeSchemas, type Client, type ImageContent } from '@modelcontextprotocol/client'
18import type { Context } from '@deepseek-ai/cordis'
19import { isImageAdmissionError } from '@deepseek-ai/dsh-attachment'
20import type { AttachmentStore, ImageAttachmentRef, ImageMediaType, SaveImageAttachment } from '@deepseek-ai/dsh-attachment'
21import type { ContentBlock } from '@deepseek-ai/dsh-llm'
22import type { ToolDefinition, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
23import { assertSupportedJsonSchema } from '@deepseek-ai/dsh-tools'
24import type { JsonSchemaNode } from '@deepseek-ai/dsh-tools'
25import type { JsonValue } from '@deepseek-ai/dsh-util-values'
26
27/** Resolved options relevant to tool bridging. */
28export interface ToolBridgeOptions {
29 /** Whether a registry conflict is contained or rejects this synchronization. */
30 registrationFailure: 'contain' | 'throw'
31 serverName: string
32 toolCallTimeoutMs: number
33}
34
35/** State for one sync generation: the current set of disposers keyed by public name. */
36export type ToolDisposers = Map<string, () => void>
37
38/** Canonical MCP result exposed to PTC mode without discarding protocol blocks. */
39export type McpResult<Structured extends JsonValue = JsonValue> = {
40 content: JsonValue[]
41 structuredContent?: Structured
42}
43
44/**
45 * DeepSeek function-name contract: at most 64 characters. Wire-protocol
46 * constant, not configuration.
47 */
48const MAX_PUBLIC_NAME_LENGTH = 64
49
50/** DeepSeek function-name contract: only `[A-Za-z0-9_-]` is allowed. */
51const INVALID_NAME_CHARS = /[^A-Za-z0-9_-]/g
52
53/** Hex chars of the SHA-256 identity hash appended on lossy normalization. */
54const HASH_LENGTH = 12
55
56/** Raster formats supported by the durable attachment vocabulary. */
57const IMAGE_MEDIA_TYPES: readonly ImageMediaType[] = [
58 'image/png',
59 'image/jpeg',
60 'image/webp',
61 'image/gif',
62]
63
64/** Canonical RFC 4648 base64, excluding whitespace and URL-safe aliases. */
65const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
66
67/**
68 * Derive the model-facing public name for one MCP tool.
69 *
70 * Deterministic pure function of `(serverName, rawName)`: the clean case is
71 * `mcp__<serverName>__<rawName>` verbatim. When character replacement or
72 * truncation to the DeepSeek function-name contract (64 chars,
73 * `[A-Za-z0-9_-]`) changes the name, a 12-hex-char SHA-256 hash of the
74 * identity is appended so distinct MCP identities never collapse into the
75 * same public name.
76 *
77 * @param serverName - Stable local namespace from plugin config.
78 * @param rawName - The MCP server's own tool name.
79 * @returns The globally unique, model-facing ToolRuntime name.
80 */
81export function publicToolName(serverName: string, rawName: string): string {
82 const joined = `mcp__${serverName}__${rawName}`
83 const normalized = joined.replace(INVALID_NAME_CHARS, '_')
84 if (normalized === joined && normalized.length <= MAX_PUBLIC_NAME_LENGTH) return normalized
85 const hash = createHash('sha256').update(`${serverName}\0${rawName}`).digest('hex').slice(0, HASH_LENGTH)
86 return `${normalized.slice(0, MAX_PUBLIC_NAME_LENGTH - HASH_LENGTH - 1)}_${hash}`
87}
88
89/**
90 * Sync the MCP server's tool list into the harness ToolRuntime.
91 *
92 * Two phases keep the swap safe:
93 *
94 * 1. Fetch: let the SDK aggregate `tools/list` and build the full next
95 * generation of `ToolDefinition`s under public names. Any failure here
96 * (network error or duplicate raw name) rejects
97 * and leaves the previous generation registered untouched.
98 * 2. Swap: dispose the previous generation, register the new one. A registry
99 * conflict here can only mean a foreign registration squats on this
100 * server's `mcp__<serverName>__` namespace — the partial generation is
101 * rolled back (zero tools from this server) and logged. Initial strict
102 * synchronization may propagate the conflict so its parent transaction
103 * rejects; ordinary clients and later re-syncs return an empty map.
104 *
105 * @param client - Connected MCP Client instance used to list and call tools.
106 * @param ctx - Cordis context providing the `tools` service for registration.
107 * @param opts - Bridge options: server namespace and per-call timeout.
108 * @param previous - Disposer map from the prior sync generation; disposed
109 * during the swap phase (only after the fetch phase succeeded).
110 * @returns A map of registered public tool names to their unregister
111 * disposers — the exact set of live registrations owned by this server.
112 */
113export async function syncTools(
114 client: Client,
115 ctx: Context,
116 opts: ToolBridgeOptions,
117 previous: ToolDisposers,
118): Promise<ToolDisposers> {
119 // Phase 1: fetch and build the next generation without touching the registry.
120 const definitions = new Map<string, ToolDefinition>()
121 const response = client.getServerCapabilities()?.tools === undefined
122 ? { tools: [] }
123 : await client.listTools(undefined, { cacheMode: 'refresh' })
124 for (const tool of response.tools) {
125 const publicName = publicToolName(opts.serverName, tool.name)
126 if (definitions.has(publicName)) {
127 throw new Error(
128 `mcp-client(${opts.serverName}): server listed tool "${tool.name}" more than once — invalid tool list`,
129 )
130 }
131 definitions.set(publicName, createMcpToolDefinition(ctx, {
132 name: publicName,
133 rawName: tool.name,
134 description: tool.description ?? '',
135 inputSchema: tool.inputSchema,
136 outputSchema: tool.outputSchema,
137 taskRequired: tool.execution?.taskSupport === 'required',
138 call: (args, execution) => client.callTool(
139 { name: tool.name, arguments: args },
140 { signal: execution.signal, timeout: opts.toolCallTimeoutMs, toolDefinition: tool },
141 ),
142 }))
143 }
144
145 // Phase 2: swap generations.
146 for (const dispose of previous.values()) dispose()
147 const disposers: ToolDisposers = new Map()
148 try {
149 for (const [publicName, definition] of definitions) {
150 disposers.set(publicName, ctx.tools.register(definition))
151 }
152 } catch (error) {
153 // A conflict on an `mcp__<serverName>__`-qualified name means a foreign
154 // registration occupies this server's namespace. Roll back so the model
155 // sees either the full generation or none of it — never a partial set.
156 for (const dispose of disposers.values()) dispose()
157 ctx.logger.error(`mcp-client(${opts.serverName}): tool registration failed, no tools registered: ${String(error)}`)
158 if (opts.registrationFailure === 'throw') throw error
159 return new Map()
160 }
161 return disposers
162}
163
164/** Fields read from canonical content, including policy-owned value replacements. */
165interface McpContentBlock {
166 type: string
167 text?: string
168 mimeType?: string
169 data?: string
170 name?: string
171 uri?: string
172}
173
174/** Async rich projection staged for one exact ToolRuntime execution. */
175interface PreparedProjection {
176 /** Canonical MCP value returned by execute before registry materialization. */
177 value: McpResult
178 /** Synchronous output.render projection expected before finalization. */
179 fallback: ContentBlock[]
180 /** Image-enriched or explicit-refusal projection prepared during execute. */
181 content: ContentBlock[]
182}
183
184/** Keep a supported advertised schema; unsupported MCP vocabulary falls back to JsonValue. */
185function supportedOutputSchema(candidate: unknown): JsonSchemaNode | undefined {
186 if (candidate === undefined) return undefined
187 try {
188 assertSupportedJsonSchema(candidate)
189 return candidate
190 } catch {
191 return undefined
192 }
193}
194
195/** One upstream MCP tool and the callback that obtains its raw protocol result. */
196export interface McpToolDefinitionOptions {
197 /** ToolRuntime name presented to the model. */
198 name: string
199 /** Upstream name used in result diagnostics. */
200 rawName: string
201 /** Upstream model-facing description. */
202 description: string
203 /** Upstream JSON input schema. */
204 inputSchema: Record<string, unknown>
205 /** Advertised structured output schema, when present. */
206 outputSchema?: unknown
207 /** Whether the upstream tool requires the unsupported task execution extension. */
208 taskRequired?: boolean
209 /**
210 * Obtain one raw MCP result from the provider.
211 * @param args - model arguments admitted by the ToolRuntime.
212 * @param execution - exact ToolRuntime invocation, including its Agent and cancellation.
213 * @returns the external result object, validated before content projection.
214 */
215 call(args: Record<string, unknown>, execution: ToolExecution): Promise<unknown>
216}
217
218/**
219 * Adapt an upstream MCP tool to canonical values and durable image content.
220 * Registration, provider lifetime, deadlines, and transport belong to the caller.
221 * @param ctx - plugin context carrying optional attachment and model services.
222 * @param options - upstream tool fields and its raw-result callback.
223 * @returns the unregistered ToolRuntime definition.
224 */
225export function createMcpToolDefinition(
226 ctx: Context,
227 options: McpToolDefinitionOptions,
228): ToolDefinition {
229 const { name, rawName, description, inputSchema } = options
230 const projections = new WeakMap<ToolExecution, PreparedProjection>()
231 return {
232 name,
233 description,
234 parameters: inputSchema,
235 output: createOutput(rawName, supportedOutputSchema(options.outputSchema)),
236 execute: createExecutor(ctx, options, projections),
237 projectContent(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>) {
238 const projection = projections.get(exec)
239 if (projection === undefined) return undefined
240 projections.delete(exec)
241 if (result.isError) return undefined
242 if (!isDeepStrictEqual(result.value, projection.value)) return undefined
243 if (!isDeepStrictEqual(result.content, projection.fallback)) return undefined
244 return projection.content
245 },
246 }
247}
248
249/** Build the canonical result schema and existing Native text projection. */
250function createOutput(rawName: string, structuredSchema: JsonSchemaNode | undefined): ToolDefinition['output'] {
251 return {
252 schema: {
253 type: 'object',
254 properties: {
255 content: { type: 'array', items: {} },
256 structuredContent: structuredSchema ?? {},
257 },
258 required: structuredSchema === undefined ? ['content'] : ['content', 'structuredContent'],
259 additionalProperties: false,
260 },
261 render(_args: unknown, value: JsonValue) {
262 const result = value as McpResult
263 return [{ type: 'text', text: extractText(result.content, rawName) }]
264 },
265 }
266}
267
268/**
269 * Invoke the caller-owned raw-result callback and prepare canonical content.
270 * MCP isError results reject before image storage so ToolRuntime records failure.
271 */
272function createExecutor(
273 ctx: Context,
274 options: McpToolDefinitionOptions,
275 projections: WeakMap<ToolExecution, PreparedProjection>,
276): ToolDefinition['execute'] {
277 const { rawName, taskRequired } = options
278 return async (args: unknown, exec: ToolExecution) => {
279 if (taskRequired) {
280 throw new Error(`Tool "${rawName}" requires task-based execution, which this bridge does not support`)
281 }
282 // The agent loop passes `JSON.parse(model_arguments)` which is usually an
283 // object, but can be any JSON value if the model misbehaves (outputs a bare
284 // string/number/null). Fallback to {} lets the MCP server produce a
285 // specific "missing required param" error the model can learn from.
286 const argsObj = (typeof args === 'object' && args !== null ? args : {}) as Record<string, unknown>
287 const parsed = specTypeSchemas.CallToolResult['~standard'].validate(await options.call(argsObj, exec))
288 if (parsed.issues !== undefined) {
289 throw new Error(`Tool "${rawName}" returned an invalid MCP result: ${parsed.issues.map(issue => issue.message).join('; ')}`)
290 }
291 const result = parsed.value
292
293 const content = result.content as unknown as JsonValue[]
294 const text = extractText(content, rawName)
295
296 // MCP isError → throw so ToolRuntime produces an isError result for the model.
297 if (result.isError === true) {
298 throw new Error(text)
299 }
300
301 const value: McpResult = {
302 content,
303 ...result.structuredContent !== undefined
304 ? { structuredContent: result.structuredContent as JsonValue }
305 : {},
306 }
307 if (containsImage(content)) {
308 const fallback: ContentBlock[] = [{ type: 'text', text: extractText(content, rawName) }]
309 const projected = await prepareImageProjection(ctx, exec, content, rawName)
310 projections.set(exec, { value, fallback, content: projected })
311 }
312 return value
313 }
314}
315
316/** Whether an untrusted MCP content array contains a declared image block. */
317function containsImage(content: JsonValue[]): boolean {
318 return content.some(value => isRecord(value) && value.type === 'image')
319}
320
321/** Narrow one JSON value to a string-keyed object. */
322function isRecord(value: JsonValue): value is { [key: string]: JsonValue } {
323 return typeof value === 'object' && value !== null && !Array.isArray(value)
324}
325
326/** Narrow a declared MIME string to the durable image vocabulary. */
327function isImageMediaType(value: string): value is ImageMediaType {
328 return IMAGE_MEDIA_TYPES.includes(value as ImageMediaType)
329}
330
331/** Decode one projected image without accepting base64 aliases. */
332function decodeImage(block: ImageContent): SaveImageAttachment {
333 if (!isImageMediaType(block.mimeType)) {
334 throw new Error('the declared media type is not PNG, JPEG, WebP, or GIF')
335 }
336 if (!CANONICAL_BASE64.test(block.data)) {
337 throw new Error('the image data is not canonical base64')
338 }
339 const data = Buffer.from(block.data, 'base64')
340 if (data.toString('base64') !== block.data) {
341 throw new Error('the image data is not canonical base64')
342 }
343 return { data, mediaType: block.mimeType }
344}
345
346/**
347 * Resolve the active model route and durable store for an image-bearing result.
348 * @param ctx - plugin context with optional services.
349 * @param exec - exact tool execution whose agent supplies the latest route.
350 * @returns the attachment store after exact positive image-capability proof.
351 */
352async function resolveImageAdmission(ctx: Context, exec: ToolExecution): Promise<AttachmentStore> {
353 const attachments = ctx.get('attachments')
354 if (attachments === undefined) throw new Error('no attachment store is mounted')
355 const routed = exec.agent?.session.requestHeader()?.config
356 const provider = routed?.provider ?? exec.agent?.options.provider
357 const model = routed?.model ?? exec.agent?.options.model
358 const llm = ctx.get('llm')
359 if (provider === undefined || model === undefined || llm === undefined) {
360 throw new Error('the current model route could not be resolved')
361 }
362 let info: Awaited<ReturnType<typeof llm.resolveModelInfo>>
363 try {
364 info = await llm.resolveModelInfo(provider, model, exec.signal)
365 } catch {
366 throw new Error('the current model route could not be verified')
367 }
368 if (info.inputModalities === undefined || !info.inputModalities.includes('image')) {
369 throw new Error(`model "${model}" does not declare image input`)
370 }
371 if (exec.signal.aborted) throw new Error('the tool call was canceled before image storage')
372 return attachments
373}
374
375/** Stable diagnostic text for an image block that was not admitted. */
376function imageDiagnostic(block: McpContentBlock, reason: string): string {
377 const mediaType = block.mimeType ?? 'unknown media type'
378 return `[image unavailable: ${mediaType}; ${reason}; raw image data remains available to programmatic callers]`
379}
380
381/**
382 * Decode, preflight, and durably save one MCP result's ordered image batch.
383 * Any refusal projects every image as text while retaining the canonical raw
384 * value for programmatic callers.
385 */
386async function prepareImageProjection(
387 ctx: Context,
388 exec: ToolExecution,
389 content: JsonValue[],
390 toolName: string,
391): Promise<ContentBlock[]> {
392 const decoded: SaveImageAttachment[] = []
393 const validationErrors = new Map<number, string>()
394 const imageIndexes: number[] = []
395 for (const [index, value] of content.entries()) {
396 if (!isRecord(value) || value.type !== 'image') continue
397 imageIndexes.push(index)
398 try {
399 decoded.push(decodeImage(value as unknown as ImageContent))
400 } catch (error: unknown) {
401 // decodeImage owns every throw above and always produces Error.
402 validationErrors.set(index, (error as Error).message)
403 }
404 }
405 if (validationErrors.size > 0) {
406 return projectContent(content, toolName, (block, index) => ({
407 type: 'text',
408 text: imageDiagnostic(
409 block,
410 validationErrors.get(index) ?? 'another image in the same result was invalid',
411 ),
412 }))
413 }
414
415 let attachments: AttachmentStore
416 try {
417 attachments = await resolveImageAdmission(ctx, exec)
418 } catch (error: unknown) {
419 // resolveImageAdmission contains provider failures and throws Error only.
420 const reason = (error as Error).message
421 return projectContent(content, toolName, block => ({ type: 'text', text: imageDiagnostic(block, reason) }))
422 }
423
424 try {
425 const refs = await attachments.saveImages(decoded)
426 const byIndex = new Map(imageIndexes.map((index, offset) => [index, refs[offset] as ImageAttachmentRef] as const))
427 return projectContent(content, toolName, (_block, index) => ({
428 type: 'image',
429 attachment: byIndex.get(index) as ImageAttachmentRef,
430 }))
431 } catch (error: unknown) {
432 const reason = isImageAdmissionError(error)
433 ? `image admission rejected the result: ${error.message}`
434 : 'durable image storage rejected the result'
435 return projectContent(content, toolName, block => ({
436 type: 'text',
437 text: imageDiagnostic(block, reason),
438 }))
439 }
440}
441
442/**
443 * Extract text from an MCP content array into a single string.
444 * - text blocks: join with '\n'
445 * - image/audio/resource blocks: replaced with a placeholder
446 *
447 * Policy-owned canonical-value replacements may omit fields required on the MCP wire.
448 */
449function extractText(mcpContent: JsonValue[], toolName: string): string {
450 const content = projectContent(mcpContent, toolName)
451 // The default image projector below also returns text, so this local call
452 // cannot produce a core image block.
453 return content.map(block => (block as Extract<ContentBlock, { type: 'text' }>).text).join('\n')
454}
455
456/**
457 * Project ordered MCP blocks into the core content vocabulary.
458 * Text-like runs are newline-coalesced; admitted images split those runs at
459 * their original position.
460 */
461function projectContent(
462 mcpContent: JsonValue[],
463 toolName: string,
464 image: (block: McpContentBlock, index: number) => ContentBlock = block => ({
465 type: 'text',
466 text: imageDiagnostic(block, 'this result was not admitted to durable model context'),
467 }),
468): ContentBlock[] {
469 const projected: ContentBlock[] = []
470 const text: string[] = []
471 const flushText = (): void => {
472 if (text.length === 0) return
473 projected.push({ type: 'text', text: text.splice(0).join('\n') })
474 }
475
476 for (const [index, value] of mcpContent.entries()) {
477 if (!isRecord(value)) {
478 text.push('[unsupported MCP content block: expected an object]')
479 continue
480 }
481 const block = value as unknown as McpContentBlock
482 switch (block.type) {
483 case 'text':
484 if (block.text !== undefined) text.push(block.text)
485 break
486 case 'image':
487 flushText()
488 projected.push(image(block, index))
489 break
490 case 'resource_link':
491 if (block.name === undefined || block.uri === undefined) {
492 text.push('[resource link unavailable: the MCP block is missing its name or URI]')
493 } else {
494 text.push(`Resource link: ${block.name} (${block.uri})`)
495 }
496 break
497 case 'audio':
498 text.push(`[audio result unsupported: ${block.mimeType ?? 'unknown media type'}; raw audio data remains available to programmatic callers]`)
499 break
500 case 'resource':
501 text.push('[embedded resource unsupported; raw resource data remains available to programmatic callers]')
502 break
503 default:
504 text.push(`[unsupported MCP content type: ${block.type}]`)
505 }
506 }
507 flushText()
508 return projected.length > 0
509 ? projected
510 : [{ type: 'text', text: `(${toolName} returned no model-visible content)` }]
511}