231 lines
9.0 KiB
TypeScript
231 lines
9.0 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 type { Context } from 'cordis'
|
|
import type { ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools'
|
|
|
|
/** Resolved options relevant to tool bridging. */
|
|
export interface ToolBridgeOptions {
|
|
serverName: string
|
|
toolCallTimeoutMs: number
|
|
}
|
|
|
|
/** State for one sync generation: the current set of disposers keyed by public name. */
|
|
export type ToolDisposers = Map<string, () => void>
|
|
|
|
/**
|
|
* 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
|
|
|
|
/**
|
|
* 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 `client.listTools()` 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), the error is logged, and an
|
|
* empty map is returned.
|
|
*
|
|
* @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 client.listTools(cursor ? { cursor } : undefined)
|
|
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,
|
|
execute: createExecutor(client, tool.name, 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)}`)
|
|
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
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* name), with abort signal and timeout, then maps the result to harness
|
|
* ContentBlocks.
|
|
*
|
|
* 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,
|
|
opts: ToolBridgeOptions,
|
|
): ToolDefinition['execute'] {
|
|
return async (args: unknown, exec: ToolExecution) => {
|
|
// 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 client.callTool(
|
|
{ name: rawName, arguments: argsObj },
|
|
undefined,
|
|
{
|
|
...exec.signal ? { signal: exec.signal } : {},
|
|
timeout: opts.toolCallTimeoutMs,
|
|
},
|
|
)
|
|
|
|
// 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
|
|
? JSON.stringify(result.toolResult)
|
|
: '(no output)'
|
|
return [{ type: 'text' as const, text }]
|
|
}
|
|
|
|
// 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 text = extractText(content, rawName)
|
|
|
|
// MCP isError → throw so ToolRegistry produces an isError result for the model.
|
|
if ('isError' in result && result.isError === true) {
|
|
throw new Error(text)
|
|
}
|
|
|
|
return [{ type: 'text', text }]
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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: McpContentBlock[], toolName: string): string {
|
|
const parts: string[] = []
|
|
|
|
for (const block of mcpContent) {
|
|
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)`
|
|
}
|