1
/**2
* Tool bridge: discovers MCP tools, registers them on the harness ToolRuntime3
* under deterministic server-qualified public names, and handles re-sync when4
* the server's tool list changes.5
*6
* Naming contract (see the mcp-client Agent Note "Naming invariants"): every MCP tool7
* has the stable identity `(serverName, rawName)`; the model-facing public name8
* is `mcp__<serverName>__<rawName>`, normalized to the DeepSeek function-name9
* constraints. The raw name is only ever sent on the wire (`tools/call`); the10
* public name is never parsed to recover it.11
*12
* @module13
*/15
import { createHash } from 'node:crypto'16
import { isDeepStrictEqual } from 'node:util'17
import { specTypeSchemas, type Client, type ImageContent } from '@modelcontextprotocol/client'18
import type { Context } from '@deepseek-ai/cordis'19
import { isImageAdmissionError } from '@deepseek-ai/dsh-attachment'20
import type { AttachmentStore, ImageAttachmentRef, ImageMediaType, SaveImageAttachment } from '@deepseek-ai/dsh-attachment'21
import type { ContentBlock } from '@deepseek-ai/dsh-llm'22
import type { ToolDefinition, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'23
import { assertSupportedJsonSchema } from '@deepseek-ai/dsh-tools'24
import type { JsonSchemaNode } from '@deepseek-ai/dsh-tools'25
import type { JsonValue } from '@deepseek-ai/dsh-util-values'27
/** Resolved options relevant to tool bridging. */28
export interface ToolBridgeOptions {29
/** Whether a registry conflict is contained or rejects this synchronization. */30
registrationFailure: 'contain' | 'throw'31
serverName: string32
toolCallTimeoutMs: number33
}35
/** State for one sync generation: the current set of disposers keyed by public name. */36
export type ToolDisposers = Map<string, () => void>38
/** Canonical MCP result exposed to PTC mode without discarding protocol blocks. */39
export type McpResult<Structured extends JsonValue = JsonValue> = {40
content: JsonValue[]41
structuredContent?: Structured42
}44
/**45
* DeepSeek function-name contract: at most 64 characters. Wire-protocol46
* constant, not configuration.47
*/48
const MAX_PUBLIC_NAME_LENGTH = 6450
/** DeepSeek function-name contract: only `[A-Za-z0-9_-]` is allowed. */51
const INVALID_NAME_CHARS = /[^A-Za-z0-9_-]/g53
/** Hex chars of the SHA-256 identity hash appended on lossy normalization. */54
const HASH_LENGTH = 1256
/** Raster formats supported by the durable attachment vocabulary. */57
const IMAGE_MEDIA_TYPES: readonly ImageMediaType[] = [58
'image/png',59
'image/jpeg',60
'image/webp',61
'image/gif',62
]64
/** Canonical RFC 4648 base64, excluding whitespace and URL-safe aliases. */65
const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/67
/**68
* Derive the model-facing public name for one MCP tool.69
*70
* Deterministic pure function of `(serverName, rawName)`: the clean case is71
* `mcp__<serverName>__<rawName>` verbatim. When character replacement or72
* 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 the74
* identity is appended so distinct MCP identities never collapse into the75
* 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
*/81
export 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 normalized85
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
}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 next95
* generation of `ToolDefinition`s under public names. Any failure here96
* (network error or duplicate raw name) rejects97
* and leaves the previous generation registered untouched.98
* 2. Swap: dispose the previous generation, register the new one. A registry99
* conflict here can only mean a foreign registration squats on this100
* server's `mcp__<serverName>__` namespace — the partial generation is101
* rolled back (zero tools from this server) and logged. Initial strict102
* synchronization may propagate the conflict so its parent transaction103
* 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; disposed109
* during the swap phase (only after the fetch phase succeeded).110
* @returns A map of registered public tool names to their unregister111
* disposers — the exact set of live registrations owned by this server.112
*/113
export 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 === undefined122
? { 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
}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 foreign154
// registration occupies this server's namespace. Roll back so the model155
// 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 error159
return new Map()160
}161
return disposers162
}164
/** Fields read from canonical content, including policy-owned value replacements. */165
interface McpContentBlock {166
type: string167
text?: string168
mimeType?: string169
data?: string170
name?: string171
uri?: string172
}174
/** Async rich projection staged for one exact ToolRuntime execution. */175
interface PreparedProjection {176
/** Canonical MCP value returned by execute before registry materialization. */177
value: McpResult178
/** Synchronous output.render projection expected before finalization. */179
fallback: ContentBlock[]180
/** Image-enriched or explicit-refusal projection prepared during execute. */181
content: ContentBlock[]182
}184
/** Keep a supported advertised schema; unsupported MCP vocabulary falls back to JsonValue. */185
function supportedOutputSchema(candidate: unknown): JsonSchemaNode | undefined {186
if (candidate === undefined) return undefined187
try {188
assertSupportedJsonSchema(candidate)189
return candidate190
} catch {191
return undefined192
}193
}195
/** One upstream MCP tool and the callback that obtains its raw protocol result. */196
export interface McpToolDefinitionOptions {197
/** ToolRuntime name presented to the model. */198
name: string199
/** Upstream name used in result diagnostics. */200
rawName: string201
/** Upstream model-facing description. */202
description: string203
/** Upstream JSON input schema. */204
inputSchema: Record<string, unknown>205
/** Advertised structured output schema, when present. */206
outputSchema?: unknown207
/** Whether the upstream tool requires the unsupported task execution extension. */208
taskRequired?: boolean209
/**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
}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
*/225
export function createMcpToolDefinition(226
ctx: Context,227
options: McpToolDefinitionOptions,228
): ToolDefinition {229
const { name, rawName, description, inputSchema } = options230
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 undefined240
projections.delete(exec)241
if (result.isError) return undefined242
if (!isDeepStrictEqual(result.value, projection.value)) return undefined243
if (!isDeepStrictEqual(result.content, projection.fallback)) return undefined244
return projection.content245
},246
}247
}249
/** Build the canonical result schema and existing Native text projection. */250
function 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 McpResult263
return [{ type: 'text', text: extractText(result.content, rawName) }]264
},265
}266
}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
*/272
function createExecutor(273
ctx: Context,274
options: McpToolDefinitionOptions,275
projections: WeakMap<ToolExecution, PreparedProjection>,276
): ToolDefinition['execute'] {277
const { rawName, taskRequired } = options278
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 an283
// object, but can be any JSON value if the model misbehaves (outputs a bare284
// string/number/null). Fallback to {} lets the MCP server produce a285
// 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.value293
const content = result.content as unknown as JsonValue[]294
const text = extractText(content, rawName)296
// MCP isError → throw so ToolRuntime produces an isError result for the model.297
if (result.isError === true) {298
throw new Error(text)299
}301
const value: McpResult = {302
content,303
...result.structuredContent !== undefined304
? { 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 value313
}314
}316
/** Whether an untrusted MCP content array contains a declared image block. */317
function containsImage(content: JsonValue[]): boolean {318
return content.some(value => isRecord(value) && value.type === 'image')319
}321
/** Narrow one JSON value to a string-keyed object. */322
function isRecord(value: JsonValue): value is { [key: string]: JsonValue } {323
return typeof value === 'object' && value !== null && !Array.isArray(value)324
}326
/** Narrow a declared MIME string to the durable image vocabulary. */327
function isImageMediaType(value: string): value is ImageMediaType {328
return IMAGE_MEDIA_TYPES.includes(value as ImageMediaType)329
}331
/** Decode one projected image without accepting base64 aliases. */332
function 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
}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
*/352
async 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()?.config356
const provider = routed?.provider ?? exec.agent?.options.provider357
const model = routed?.model ?? exec.agent?.options.model358
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 attachments373
}375
/** Stable diagnostic text for an image block that was not admitted. */376
function 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
}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 raw384
* value for programmatic callers.385
*/386
async 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') continue397
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
}415
let attachments: AttachmentStore416
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).message421
return projectContent(content, toolName, block => ({ type: 'text', text: imageDiagnostic(block, reason) }))422
}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
}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 placeholder446
*447
* Policy-owned canonical-value replacements may omit fields required on the MCP wire.448
*/449
function extractText(mcpContent: JsonValue[], toolName: string): string {450
const content = projectContent(mcpContent, toolName)451
// The default image projector below also returns text, so this local call452
// cannot produce a core image block.453
return content.map(block => (block as Extract<ContentBlock, { type: 'text' }>).text).join('\n')454
}456
/**457
* Project ordered MCP blocks into the core content vocabulary.458
* Text-like runs are newline-coalesced; admitted images split those runs at459
* their original position.460
*/461
function 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) return473
projected.push({ type: 'text', text: text.splice(0).join('\n') })474
}476
for (const [index, value] of mcpContent.entries()) {477
if (!isRecord(value)) {478
text.push('[unsupported MCP content block: expected an object]')479
continue480
}481
const block = value as unknown as McpContentBlock482
switch (block.type) {483
case 'text':484
if (block.text !== undefined) text.push(block.text)485
break486
case 'image':487
flushText()488
projected.push(image(block, index))489
break490
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
break497
case 'audio':498
text.push(`[audio result unsupported: ${block.mimeType ?? 'unknown media type'}; raw audio data remains available to programmatic callers]`)499
break500
case 'resource':501
text.push('[embedded resource unsupported; raw resource data remains available to programmatic callers]')502
break503
default:504
text.push(`[unsupported MCP content type: ${block.type}]`)505
}506
}507
flushText()508
return projected.length > 0509
? projected510
: [{ type: 'text', text: `(${toolName} returned no model-visible content)` }]511
}