| import { createHash } from 'node:crypto'; |
| import { jsonSchema } from 'ai'; |
| import type { McpCallResult, McpToolDescriptor } from '@maka/core'; |
| import type { ToolCategory } from '@maka/core'; |
| import type { ToolRecoveryMode } from '@maka/core'; |
| import type { ToolResultContentPart, ToolResultOutput } from './model-protocol.js'; |
| import type { MakaTool } from './tool-runtime.js'; |
| |
| const MAX_PROVIDER_TOOL_NAME = 64; |
| const HASH_CHARS = 10; |
| const MAX_NATIVE_IMAGE_BASE64_CHARS = 20_000_000; |
| const MAX_NATIVE_IMAGES = 4; |
| const MAX_MODEL_TEXT_CHARS = 200_000; |
| const MAX_SUMMARIZED_BLOCKS = 100; |
| const TRUNCATION_MARKER = '\n…[truncated by Maka]'; |
| |
| export interface McpToolProvider { |
| tools(): readonly McpToolDescriptor[]; |
| bindTool?( |
| serverId: string, |
| toolName: string, |
| ): (args: Record<string, unknown>, options: McpToolCallOptions) => Promise<McpCallResult>; |
| callTool( |
| serverId: string, |
| toolName: string, |
| args: Record<string, unknown>, |
| options: McpToolCallOptions, |
| ): Promise<McpCallResult>; |
| } |
| |
| export interface McpToolCallOptions { |
| readonly signal?: AbortSignal; |
| readonly timeoutMs?: number; |
| readonly context: McpToolInvocationContext; |
| } |
| |
| export interface McpToolInvocationContext { |
| readonly sessionId: string; |
| readonly turnId: string; |
| readonly toolCallId: string; |
| readonly cwd: string; |
| } |
| |
| export interface BuildMcpToolsOptions { |
| callTimeoutMs?: number; |
| categoryHint?: ToolCategory; |
| recoveryMode?: ToolRecoveryMode; |
| } |
| |
| export function buildMcpTools( |
| provider: McpToolProvider, |
| options: BuildMcpToolsOptions = {}, |
| ): MakaTool[] { |
| const names = new Map<string, string>(); |
| return provider.tools().map((descriptor) => { |
| const identity = `${descriptor.serverId}\0${descriptor.name}`; |
| const name = mcpProxyToolName(descriptor.serverId, descriptor.name); |
| const collision = names.get(name); |
| if (collision && collision !== identity) { |
| throw new Error(`MCP proxy tool name collision: ${name}`); |
| } |
| names.set(name, identity); |
| const callTool = |
| provider.bindTool?.(descriptor.serverId, descriptor.name) ?? |
| ((args: Record<string, unknown>, callOptions: McpToolCallOptions) => |
| provider.callTool(descriptor.serverId, descriptor.name, args, callOptions)); |
| return { |
| name, |
| description: |
| descriptor.description?.trim() || |
| `MCP tool ${descriptor.name} provided by ${descriptor.serverId}`, |
| displayName: descriptor.annotations?.title?.trim() || descriptor.name, |
| activityKind: 'tool', |
| // MCP annotations are advisory provider claims, not a security boundary. |
| // The trusted composition may select a stricter open-world category; |
| // ordinary MCP servers retain the side-effecting network default. |
| categoryHint: options.categoryHint ?? 'network_send', |
| ...(options.recoveryMode ? { recoveryMode: options.recoveryMode } : {}), |
| parameters: jsonSchema(descriptor.inputSchema), |
| impl: async (args: unknown, context) => { |
| // Managed network authority applies equally to Direct and nested CodeMode dispatch. |
| if ( |
| context.executionBoundary?.kind === 'managed' && |
| context.executionBoundary.profile.network.kind !== 'enabled' |
| ) { |
| if (!context.requestSandboxBoundary) { |
| throw new Error('MCP network access requires sandbox boundary approval'); |
| } |
| const settlement = await context.requestSandboxBoundary( |
| { network: { enabled: true } }, |
| `Call MCP tool ${descriptor.serverId}/${descriptor.name}.`, |
| ); |
| if (settlement.request.status !== 'approved') { |
| throw new Error('MCP network access denied'); |
| } |
| } |
| return callTool(asArguments(args), { |
| signal: context.abortSignal, |
| timeoutMs: options.callTimeoutMs, |
| context: { |
| sessionId: context.sessionId, |
| turnId: context.turnId, |
| toolCallId: context.toolCallId, |
| cwd: context.cwd, |
| }, |
| }); |
| }, |
| toModelOutput: ({ output }) => mcpResultToModelOutput(output), |
| } satisfies MakaTool; |
| }); |
| } |
| |
| export function mcpProxyToolName(serverId: string, toolName: string): string { |
| const raw = `mcp__${sanitizeNamePart(serverId)}__${sanitizeNamePart(toolName)}`; |
| if (raw.length <= MAX_PROVIDER_TOOL_NAME) return raw; |
| const hash = createHash('sha256') |
| .update(`${serverId}\0${toolName}`) |
| .digest('hex') |
| .slice(0, HASH_CHARS); |
| return `${raw.slice(0, MAX_PROVIDER_TOOL_NAME - HASH_CHARS - 2)}__${hash}`; |
| } |
| |
| function sanitizeNamePart(value: string): string { |
| const sanitized = value |
| .normalize('NFKD') |
| .replace(/[^A-Za-z0-9_-]+/gu, '_') |
| .replace(/^_+|_+$/gu, ''); |
| return sanitized || 'unnamed'; |
| } |
| |
| function asArguments(value: unknown): Record<string, unknown> { |
| if (typeof value === 'object' && value !== null && !Array.isArray(value)) { |
| return value as Record<string, unknown>; |
| } |
| throw new Error('MCP tool arguments must be an object'); |
| } |
| |
| function mcpResultToModelOutput(output: unknown): Extract<ToolResultOutput, { type: 'content' }> { |
| const result = output as Partial<McpCallResult>; |
| const blocks = Array.isArray(result.content) ? result.content : []; |
| const value: ToolResultContentPart[] = []; |
| const nonVisual: unknown[] = []; |
| let remainingTextChars = MAX_MODEL_TEXT_CHARS; |
| let imageChars = 0; |
| let imageCount = 0; |
| let omittedSummaryBlocks = 0; |
| |
| const appendText = (text: string): void => { |
| if (remainingTextChars <= 0) return; |
| const clipped = clipModelText(text, remainingTextChars); |
| remainingTextChars -= clipped.length; |
| value.push({ type: 'text', text: clipped }); |
| }; |
| const appendSummary = (summary: unknown): void => { |
| if (nonVisual.length < MAX_SUMMARIZED_BLOCKS) nonVisual.push(summary); |
| else omittedSummaryBlocks += 1; |
| }; |
| |
| for (const block of blocks) { |
| if (block.type === 'text') appendText(block.text); |
| else if ( |
| block.type === 'image' && |
| imageCount < MAX_NATIVE_IMAGES && |
| imageChars + block.data.length <= MAX_NATIVE_IMAGE_BASE64_CHARS |
| ) { |
| value.push({ |
| type: 'file', |
| data: { type: 'data', data: block.data }, |
| mediaType: block.mimeType, |
| }); |
| imageCount += 1; |
| imageChars += block.data.length; |
| } else appendSummary(summarizeNonVisualBlock(block)); |
| } |
| if (nonVisual.length || omittedSummaryBlocks || result.structuredContent !== undefined) { |
| appendText( |
| safeJsonStringify({ |
| ...(nonVisual.length ? { content: nonVisual } : {}), |
| ...(omittedSummaryBlocks ? { omittedContentBlocks: omittedSummaryBlocks } : {}), |
| ...(result.structuredContent !== undefined |
| ? { structuredContent: result.structuredContent } |
| : {}), |
| }), |
| ); |
| } |
| if (value.length === 0) value.push({ type: 'text', text: 'MCP tool completed with no content.' }); |
| return { type: 'content', value }; |
| } |
| |
| function summarizeNonVisualBlock(block: McpCallResult['content'][number]): unknown { |
| if (block.type === 'audio') { |
| return { |
| type: block.type, |
| mimeType: block.mimeType, |
| base64Chars: block.data.length, |
| }; |
| } |
| if (block.type === 'resource') { |
| return { |
| ...block, |
| ...(block.text ? { text: clipModelText(block.text, MAX_MODEL_TEXT_CHARS) } : {}), |
| ...(block.blob ? { blob: undefined, base64Chars: block.blob.length } : {}), |
| }; |
| } |
| if (block.type === 'image') { |
| return { |
| type: block.type, |
| mimeType: block.mimeType, |
| base64Chars: block.data.length, |
| omitted: 'too_large', |
| }; |
| } |
| if (block.type === 'unknown') return { type: block.type, omitted: true }; |
| return block; |
| } |
| |
| function clipModelText(value: string, limit: number): string { |
| if (value.length <= limit) return value; |
| if (limit <= TRUNCATION_MARKER.length) return TRUNCATION_MARKER.slice(0, limit); |
| return `${value.slice(0, limit - TRUNCATION_MARKER.length)}${TRUNCATION_MARKER}`; |
| } |
| |
| function safeJsonStringify(value: unknown): string { |
| try { |
| return JSON.stringify(value); |
| } catch { |
| return '{"content":"MCP output could not be serialized"}'; |
| } |
| } |