From 5bed6bca80f3bf412e141a6615c66557a7b6e21f Mon Sep 17 00:00:00 2001 From: Doji <138183055+BeanDz@users.noreply.github.com> Date: Sat, 7 Mar 2026 17:39:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(stage-*):=20can=E2=80=99t=20get=20mcp=20ser?= =?UTF-8?q?vers=20when=20use=20remote=20api=20(#1181)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/services/airi/mcp-servers/index.ts | 182 ++++++++++++------ .../main/windows/chat/rpc/index.electron.ts | 14 +- .../main/windows/main/rpc/index.electron.ts | 6 +- .../windows/settings/rpc/index.electron.ts | 15 +- apps/stage-tamagotchi/src/shared/eventa.ts | 3 +- packages/stage-ui/src/stores/llm.ts | 89 ++++++--- .../stage-ui/src/stores/mcp-tool-bridge.ts | 3 +- packages/stage-ui/src/tools/mcp.test.ts | 118 +++++++++++- packages/stage-ui/src/tools/mcp.ts | 14 +- 9 files changed, 341 insertions(+), 103 deletions(-) diff --git a/apps/stage-tamagotchi/src/main/services/airi/mcp-servers/index.ts b/apps/stage-tamagotchi/src/main/services/airi/mcp-servers/index.ts index 01fa1b110..f412b9794 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/mcp-servers/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/mcp-servers/index.ts @@ -13,6 +13,7 @@ import type { import { mkdir, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' +import { env } from 'node:process' import { useLogg } from '@guiiai/logg' import { Client } from '@modelcontextprotocol/sdk/client/index.js' @@ -62,8 +63,11 @@ const defaultMcpConfig: ElectronMcpStdioConfigFile = { mcpServers: {}, } const toolNameSeparator = '::' +const mcpServerConnectTimeoutMsec = 10_000 const mcpRequestTimeoutMsec = 10_000 const mcpRequestMaxTotalTimeoutMsec = 15_000 +const mcpToolRequestIdCacheTtlMsec = 30_000 +const mcpToolRequestIdCacheMaxSize = 512 function stringifyError(error: unknown) { if (error instanceof Error) { @@ -89,20 +93,33 @@ function parseQualifiedToolName(name: string) { } } -function resolveFallbackToolName(toolName: string): string | undefined { - const normalizedTransportPrefix = toolName - .replace(/^\.(?:stdio|stdo)::/, '') - .replace(/^(?:stdio|stdo)::/, '') - if (normalizedTransportPrefix !== toolName) { - return normalizedTransportPrefix +async function withTimeout(task: Promise, timeoutMsec: number, timeoutMessage: string): Promise { + let timeoutId: NodeJS.Timeout | undefined + try { + return await Promise.race([ + task, + new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMsec) + }), + ]) + } + finally { + if (timeoutId) { + clearTimeout(timeoutId) + } + } +} + +function createSpawnEnv(overrides?: Record): Record { + const baseEnv = Object.fromEntries(Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === 'string')) + if (!overrides) { + return baseEnv } - const lastSeparatorIndex = toolName.lastIndexOf(toolNameSeparator) - if (lastSeparatorIndex <= 0 || lastSeparatorIndex === toolName.length - toolNameSeparator.length) { - return undefined + return { + ...baseEnv, + ...overrides, } - - return toolName.slice(lastSeparatorIndex + toolNameSeparator.length) } async function closeSession(session: McpServerSession) { @@ -118,8 +135,26 @@ export function createMcpStdioManager(): McpStdioManager { const log = useLogg('main/mcp-stdio').useGlobalConfig() const sessions = new Map() const runtimeStatuses = new Map() + const inFlightToolCallsByRequestId = new Map>() + const completedToolCallsByRequestId = new Map() let updatedAt = Date.now() + const pruneCompletedToolCalls = (now = Date.now()) => { + for (const [requestId, cached] of completedToolCallsByRequestId.entries()) { + if (cached.expiresAt <= now) { + completedToolCallsByRequestId.delete(requestId) + } + } + + while (completedToolCallsByRequestId.size > mcpToolRequestIdCacheMaxSize) { + const oldestRequestId = completedToolCallsByRequestId.keys().next().value + if (!oldestRequestId) { + break + } + completedToolCallsByRequestId.delete(oldestRequestId) + } + } + const setRuntimeStatus = (status: ElectronMcpStdioServerRuntimeStatus) => { runtimeStatuses.set(status.name, status) updatedAt = Date.now() @@ -171,13 +206,16 @@ export function createMcpStdioManager(): McpStdioManager { }) sessions.delete(name) } + + inFlightToolCallsByRequestId.clear() + completedToolCallsByRequestId.clear() } const startServer = async (name: string, config: ElectronMcpStdioServerConfig) => { const transport = new StdioClientTransport({ command: config.command, args: config.args ?? [], - env: config.env, + env: createSpawnEnv(config.env), cwd: config.cwd, stderr: 'pipe', }) @@ -187,7 +225,11 @@ export function createMcpStdioManager(): McpStdioManager { }) try { - await client.connect(transport) + await withTimeout( + client.connect(transport), + mcpServerConnectTimeoutMsec, + `mcp server connect timeout (${mcpServerConnectTimeoutMsec}ms): ${name}`, + ) transport.stderr?.on('data', (data) => { const text = data.toString('utf-8').trim() if (text) { @@ -204,6 +246,7 @@ export function createMcpStdioManager(): McpStdioManager { }) } catch (error) { + log.withFields({ serverName: name }).withError(error).warn('failed to connect mcp stdio server') await transport.close().catch(() => {}) throw error } @@ -285,58 +328,73 @@ export function createMcpStdioManager(): McpStdioManager { } const callTool = async (payload: ElectronMcpCallToolPayload): Promise => { - const { serverName, toolName } = parseQualifiedToolName(payload.name) - const session = sessions.get(serverName) - if (!session) { - throw new Error(`mcp server is not running: ${serverName}`) + const normalizedRequestId = payload.requestId?.trim() + if (normalizedRequestId) { + pruneCompletedToolCalls() + + const cached = completedToolCallsByRequestId.get(normalizedRequestId) + if (cached && cached.expiresAt > Date.now()) { + return cached.result + } + + const inFlight = inFlightToolCallsByRequestId.get(normalizedRequestId) + if (inFlight) { + return inFlight + } } - let result - try { - result = await session.client.callTool({ + const executeCall = async (): Promise => { + const { serverName, toolName } = parseQualifiedToolName(payload.name) + const session = sessions.get(serverName) + if (!session) { + throw new Error(`mcp server is not running: ${serverName}`) + } + + const result = await session.client.callTool({ name: toolName, arguments: payload.arguments ?? {}, }, undefined, { timeout: mcpRequestTimeoutMsec, maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec, }) - } - catch (error) { - const fallbackToolName = resolveFallbackToolName(toolName) - if (!fallbackToolName || fallbackToolName === toolName) { - throw error + + const normalized: ElectronMcpCallToolResult = {} + if ('content' in result && Array.isArray(result.content)) { + normalized.content = result.content as Array> + } + if ('structuredContent' in result) { + normalized.structuredContent = result.structuredContent + } + if ('isError' in result && typeof result.isError === 'boolean') { + normalized.isError = result.isError + } + if ('toolResult' in result) { + normalized.toolResult = result.toolResult } - log.withFields({ - serverName, - requestedToolName: toolName, - fallbackToolName, - }).warn('retrying mcp tool call with normalized tool name') + return normalized + } - result = await session.client.callTool({ - name: fallbackToolName, - arguments: payload.arguments ?? {}, - }, undefined, { - timeout: mcpRequestTimeoutMsec, - maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec, + const execution = executeCall() + if (!normalizedRequestId) { + return execution + } + + inFlightToolCallsByRequestId.set(normalizedRequestId, execution) + + try { + const result = await execution + const now = Date.now() + completedToolCallsByRequestId.set(normalizedRequestId, { + result, + expiresAt: now + mcpToolRequestIdCacheTtlMsec, }) + pruneCompletedToolCalls(now) + return result } - - const normalized: ElectronMcpCallToolResult = {} - if ('content' in result && Array.isArray(result.content)) { - normalized.content = result.content as Array> + finally { + inFlightToolCallsByRequestId.delete(normalizedRequestId) } - if ('structuredContent' in result && result.structuredContent && typeof result.structuredContent === 'object' && !Array.isArray(result.structuredContent)) { - normalized.structuredContent = result.structuredContent as Record - } - if ('isError' in result && typeof result.isError === 'boolean') { - normalized.isError = result.isError - } - if ('toolResult' in result) { - normalized.toolResult = result.toolResult - } - - return normalized } const getRuntimeStatus = (): ElectronMcpStdioRuntimeStatus => { @@ -378,18 +436,20 @@ export async function setupMcpStdioManager() { return manager } -export function createMcpServersService(params: { context: ReturnType['context'], manager: McpStdioManager }) { - defineInvokeHandler(params.context, electronMcpOpenConfigFile, async () => { - return params.manager.openConfigFile() - }) +export function createMcpServersService(params: { context: ReturnType['context'], manager: McpStdioManager, allowManageConfig?: boolean }) { + if (params.allowManageConfig) { + defineInvokeHandler(params.context, electronMcpOpenConfigFile, async () => { + return params.manager.openConfigFile() + }) - defineInvokeHandler(params.context, electronMcpApplyAndRestart, async () => { - return params.manager.applyAndRestart() - }) + defineInvokeHandler(params.context, electronMcpApplyAndRestart, async () => { + return params.manager.applyAndRestart() + }) - defineInvokeHandler(params.context, electronMcpGetRuntimeStatus, async () => { - return params.manager.getRuntimeStatus() - }) + defineInvokeHandler(params.context, electronMcpGetRuntimeStatus, async () => { + return params.manager.getRuntimeStatus() + }) + } defineInvokeHandler(params.context, electronMcpListTools, async () => { return params.manager.listTools() diff --git a/apps/stage-tamagotchi/src/main/windows/chat/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/chat/rpc/index.electron.ts index 25f228e9f..0729af39e 100644 --- a/apps/stage-tamagotchi/src/main/windows/chat/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/chat/rpc/index.electron.ts @@ -10,9 +10,10 @@ import { createContext } from '@moeru/eventa/adapters/electron/main' import { ipcMain } from 'electron' import { electronOpenMainDevtools } from '../../../../shared/eventa' +import { createServerChannelService } from '../../../services/airi/channel-server' import { createMcpServersService } from '../../../services/airi/mcp-servers' import { createWidgetsService } from '../../../services/airi/widgets' -import { setupBaseWindowElectronInvokes } from '../../shared/window' +import { createScreenService, createWindowService } from '../../../services/electron' export async function setupChatWindowElectronInvokes(params: { window: BrowserWindow @@ -26,12 +27,15 @@ export async function setupChatWindowElectronInvokes(params: { // manage events within eventa's context system. ipcMain.setMaxListeners(0) - const { context } = createContext(ipcMain, params.window) - - await setupBaseWindowElectronInvokes({ context, window: params.window, i18n: params.i18n, serverChannel: params.serverChannel }) + const { context } = createContext(ipcMain, params.window, { + onlySameWindow: true, + }) + createScreenService({ context, window: params.window }) + createWindowService({ context, window: params.window }) createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.window }) - createMcpServersService({ context, manager: params.mcpStdioManager }) + createServerChannelService({ serverChannel: params.serverChannel }) + createMcpServersService({ context, manager: params.mcpStdioManager, allowManageConfig: false }) defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' })) } diff --git a/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts index f9eb06e00..fde3b694a 100644 --- a/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts @@ -34,12 +34,14 @@ export async function setupMainWindowElectronInvokes(params: { // manage events within eventa's context system. ipcMain.setMaxListeners(0) - const { context } = createContext(ipcMain, params.window) + const { context } = createContext(ipcMain, params.window, { + onlySameWindow: true, + }) await setupBaseWindowElectronInvokes({ context, window: params.window, serverChannel: params.serverChannel, i18n: params.i18n }) createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.window }) createAutoUpdaterService({ context, window: params.window, service: params.autoUpdater }) - createMcpServersService({ context, manager: params.mcpStdioManager }) + createMcpServersService({ context, manager: params.mcpStdioManager, allowManageConfig: false }) defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' })) defineInvokeHandler(context, electronOpenSettings, async () => toggleWindowShow(await params.settingsWindow())) diff --git a/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts index ce29005aa..7f3626d4d 100644 --- a/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts @@ -12,10 +12,10 @@ import { createContext } from '@moeru/eventa/adapters/electron/main' import { ipcMain } from 'electron' import { electronOpenDevtoolsWindow, electronOpenSettingsDevtools } from '../../../../shared/eventa' +import { createServerChannelService } from '../../../services/airi/channel-server' import { createMcpServersService } from '../../../services/airi/mcp-servers' import { createWidgetsService } from '../../../services/airi/widgets' -import { createAutoUpdaterService } from '../../../services/electron' -import { setupBaseWindowElectronInvokes } from '../../shared/window' +import { createAutoUpdaterService, createScreenService, createWindowService } from '../../../services/electron' export async function setupSettingsWindowInvokes(params: { settingsWindow: BrowserWindow @@ -31,13 +31,16 @@ export async function setupSettingsWindowInvokes(params: { // manage events within eventa's context system. ipcMain.setMaxListeners(0) - const { context } = createContext(ipcMain, params.settingsWindow) - - await setupBaseWindowElectronInvokes({ context, window: params.settingsWindow, i18n: params.i18n, serverChannel: params.serverChannel }) + const { context } = createContext(ipcMain, params.settingsWindow, { + onlySameWindow: true, + }) + createScreenService({ context, window: params.settingsWindow }) + createWindowService({ context, window: params.settingsWindow }) createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.settingsWindow }) createAutoUpdaterService({ context, window: params.settingsWindow, service: params.autoUpdater }) - createMcpServersService({ context, manager: params.mcpStdioManager }) + createServerChannelService({ serverChannel: params.serverChannel }) + createMcpServersService({ context, manager: params.mcpStdioManager, allowManageConfig: true }) defineInvokeHandler(context, electronOpenSettingsDevtools, async () => params.settingsWindow.webContents.openDevTools({ mode: 'detach' })) defineInvokeHandler(context, electronOpenDevtoolsWindow, async (payload) => { diff --git a/apps/stage-tamagotchi/src/shared/eventa.ts b/apps/stage-tamagotchi/src/shared/eventa.ts index 8ac4e0fa5..088079f5c 100644 --- a/apps/stage-tamagotchi/src/shared/eventa.ts +++ b/apps/stage-tamagotchi/src/shared/eventa.ts @@ -173,11 +173,12 @@ export interface ElectronMcpToolDescriptor { export interface ElectronMcpCallToolPayload { name: string arguments?: Record + requestId?: string } export interface ElectronMcpCallToolResult { content?: Array> - structuredContent?: Record + structuredContent?: unknown toolResult?: unknown isError?: boolean } diff --git a/packages/stage-ui/src/stores/llm.ts b/packages/stage-ui/src/stores/llm.ts index 8adb6efb5..28c21ff3b 100644 --- a/packages/stage-ui/src/stores/llm.ts +++ b/packages/stage-ui/src/stores/llm.ts @@ -25,6 +25,30 @@ export interface StreamOptions { tools?: Tool[] | (() => Promise) } +function createToolsCompatibilityKey(model: string, chatProvider: ChatProvider): string { + return `${chatProvider.chat(model).baseURL}-${model}` +} + +function isKnownToolsUnsupportedError(error: unknown): boolean { + const message = String(error).toLowerCase() + return ( + // OpenAI / Azure OpenAI / Ollama + message.includes('does not support tools') + // OpenRouter + || message.includes('no endpoints found that support tool use') + // Anthropic + || message.includes('does not support tool use') + // Together AI + || message.includes('tool use is not supported') + // Fireworks AI + || message.includes('tools are not supported') + // Google Gemini + || message.includes('tool use with function calling is unsupported') + // Cloudflare Workers AI / vLLM / SGLang + || message.includes('function calling is not supported') + ) +} + // TODO: proper format for other error messages. function sanitizeMessages(messages: unknown[]): Message[] { return messages.map((m: any) => { @@ -39,7 +63,16 @@ function sanitizeMessages(messages: unknown[]): Message[] { } function streamOptionsToolsCompatibilityOk(model: string, chatProvider: ChatProvider, _: Message[], options?: StreamOptions): boolean { - return !!(options?.supportsTools || options?.toolsCompatibility?.get(`${chatProvider.chat(model).baseURL}-${model}`)) + if (typeof options?.supportsTools === 'boolean') { + return options.supportsTools + } + + const discovered = options?.toolsCompatibility?.get(createToolsCompatibilityKey(model, chatProvider)) + if (typeof discovered === 'boolean') { + return discovered + } + + return true } async function streamFrom(model: string, chatProvider: ChatProvider, messages: Message[], options?: StreamOptions) { @@ -97,15 +130,22 @@ async function streamFrom(model: string, chatProvider: ChatProvider, messages: M } try { - streamText({ + const stream = streamText({ ...chatConfig, maxSteps: 10, + parallelToolCalls: false, messages: sanitized, headers, // TODO: we need Automatic tools discovery tools, onEvent, }) + + void stream.steps.then(() => { + resolveOnce() + }).catch((error) => { + rejectOnce(error) + }) } catch (err) { rejectOnce(err) @@ -120,23 +160,8 @@ export async function attemptForToolsCompatibilityDiscovery(model: string, chatP return true } catch (err) { - if (err instanceof Error && err.name === new XSAIError('').name) { - // TODO: if you encountered many more errors like these, please, add them here. - - // Ollama - /** - * {"error":{"message":"registry.ollama.ai// does not support tools","type":"api_error","param":null,"code":null}} - */ - if (String(err).includes('does not support tools')) { - return false - } - // OpenRouter - /** - * {"error":{"message":"No endpoints found that support tool use. To learn more about provider routing, visit: https://openrouter.ai/docs/provider-routing","code":404}} - */ - if (String(err).includes('No endpoints found that support tool use.')) { - return false - } + if (err instanceof Error && err.name === new XSAIError('').name && isKnownToolsUnsupportedError(err)) { + return false } throw err @@ -187,17 +212,35 @@ export const useLLM = defineStore('llm', () => { const toolsCompatibility = ref>(new Map()) async function discoverToolsCompatibility(model: string, chatProvider: ChatProvider, _: Message[], options?: Omit) { + const key = createToolsCompatibilityKey(model, chatProvider) + // Cached, no need to discover again - if (toolsCompatibility.value.has(`${chatProvider.chat(model).baseURL}-${model}`)) { + if (toolsCompatibility.value.has(key)) { return } const res = await attemptForToolsCompatibilityDiscovery(model, chatProvider, _, { ...options, toolsCompatibility: toolsCompatibility.value }) - toolsCompatibility.value.set(`${chatProvider.chat(model).baseURL}-${model}`, res) + toolsCompatibility.value.set(key, res) } - function stream(model: string, chatProvider: ChatProvider, messages: Message[], options?: StreamOptions) { - return streamFrom(model, chatProvider, messages, { ...options, toolsCompatibility: toolsCompatibility.value }) + async function stream(model: string, chatProvider: ChatProvider, messages: Message[], options?: StreamOptions) { + const key = createToolsCompatibilityKey(model, chatProvider) + + try { + return await streamFrom(model, chatProvider, messages, { ...options, toolsCompatibility: toolsCompatibility.value }) + } + catch (error) { + if (isKnownToolsUnsupportedError(error)) { + toolsCompatibility.value.set(key, false) + return streamFrom(model, chatProvider, messages, { + ...options, + supportsTools: false, + toolsCompatibility: toolsCompatibility.value, + }) + } + + throw error + } } async function models(apiUrl: string, apiKey: string) { diff --git a/packages/stage-ui/src/stores/mcp-tool-bridge.ts b/packages/stage-ui/src/stores/mcp-tool-bridge.ts index 6dc87a930..799710c33 100644 --- a/packages/stage-ui/src/stores/mcp-tool-bridge.ts +++ b/packages/stage-ui/src/stores/mcp-tool-bridge.ts @@ -9,11 +9,12 @@ export interface McpToolDescriptor { export interface McpCallToolPayload { name: string arguments?: Record + requestId?: string } export interface McpCallToolResult { content?: Array> - structuredContent?: Record + structuredContent?: unknown toolResult?: unknown isError?: boolean } diff --git a/packages/stage-ui/src/tools/mcp.test.ts b/packages/stage-ui/src/tools/mcp.test.ts index c1906c3e0..897801ea2 100644 --- a/packages/stage-ui/src/tools/mcp.test.ts +++ b/packages/stage-ui/src/tools/mcp.test.ts @@ -1,10 +1,20 @@ import type { JsonSchema } from 'xsschema' -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { clearMcpToolBridge, setMcpToolBridge } from '../stores/mcp-tool-bridge' import { mcp } from './mcp' describe('tools mcp schema', () => { + beforeEach(() => { + clearMcpToolBridge() + }) + + afterEach(() => { + clearMcpToolBridge() + vi.restoreAllMocks() + }) + it('emits strict parameter objects', async () => { const tools = await mcp() const toolNames = [ @@ -28,4 +38,110 @@ describe('tools mcp schema', () => { expect(items).toBeDefined() expect(items.additionalProperties).toBe(false) }) + + it('keeps mcp_call_tool parameter value schema explicit for remote providers', async () => { + const tools = await mcp() + const callTool = tools.find(entry => entry.function.name === 'mcp_call_tool') + + expect(callTool).toBeDefined() + const items = ((callTool!.function.parameters as JsonSchema).properties?.parameters as JsonSchema)?.items as JsonSchema + const valueSchema = items.properties?.value as JsonSchema + + expect(valueSchema).toBeDefined() + expect(Array.isArray(valueSchema.anyOf)).toBe(true) + expect((valueSchema.anyOf || []).length).toBeGreaterThan(0) + }) + + it('mcp_list_tools returns bridge tools and falls back to empty array on error', async () => { + const listTools = vi.fn().mockResolvedValueOnce([ + { + serverName: 'demo', + name: 'demo::tools-list', + toolName: 'tools-list', + inputSchema: { type: 'object' }, + }, + ]).mockRejectedValueOnce(new Error('boom')) + + const callTool = vi.fn() + setMcpToolBridge({ listTools, callTool }) + + const tools = await mcp() + const list = tools.find(entry => entry.function.name === 'mcp_list_tools') + expect(list).toBeDefined() + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const first = await list!.execute({}, undefined as never) + const second = await list!.execute({}, undefined as never) + + expect(first).toEqual([ + { + serverName: 'demo', + name: 'demo::tools-list', + toolName: 'tools-list', + inputSchema: { type: 'object' }, + }, + ]) + expect(second).toEqual([]) + expect(listTools).toHaveBeenCalledTimes(2) + expect(warn).toHaveBeenCalled() + }) + + it('mcp_call_tool maps parameters and returns fallback error payload', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const callTool = vi.fn() + .mockResolvedValueOnce({ + content: [{ type: 'text', text: 'ok' }], + isError: false, + }) + .mockRejectedValueOnce(new Error('tool failed')) + const listTools = vi.fn().mockResolvedValue([]) + + setMcpToolBridge({ listTools, callTool }) + + const tools = await mcp() + const call = tools.find(entry => entry.function.name === 'mcp_call_tool') + expect(call).toBeDefined() + + const first = await call!.execute({ + name: 'demo::echo', + parameters: [ + { name: 'message', value: 'hello' }, + { name: 'count', value: 2 }, + ], + }, { + toolCallId: 'tool-call-1', + messages: [], + } as never) + + const second = await call!.execute({ + name: 'demo::echo', + parameters: [ + { name: 'message', value: 'hello' }, + ], + }, undefined as never) + + expect(callTool).toHaveBeenNthCalledWith(1, { + name: 'demo::echo', + arguments: { + message: 'hello', + count: 2, + }, + requestId: 'tool-call-1', + }) + expect(first).toEqual({ + content: [{ type: 'text', text: 'ok' }], + isError: false, + }) + expect(second).toEqual({ + isError: true, + content: [ + { + type: 'text', + text: 'tool failed', + }, + ], + }) + }) }) diff --git a/packages/stage-ui/src/tools/mcp.ts b/packages/stage-ui/src/tools/mcp.ts index 8f6e437de..bad340d07 100644 --- a/packages/stage-ui/src/tools/mcp.ts +++ b/packages/stage-ui/src/tools/mcp.ts @@ -3,6 +3,13 @@ import { z } from 'zod' import { getMcpToolBridge } from '../stores/mcp-tool-bridge' +const mcpParameterPrimitiveSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]) +const mcpParameterValueSchema = z.union([ + mcpParameterPrimitiveSchema, + z.array(mcpParameterPrimitiveSchema), + z.object({}).catchall(mcpParameterPrimitiveSchema), +]) + const tools = [ tool({ name: 'mcp_list_tools', @@ -21,17 +28,18 @@ const tools = [ tool({ name: 'mcp_call_tool', description: 'Call a tool on the MCP server. The result is a list of content and a boolean indicating whether the tool call is an error.', - execute: async ({ name, parameters }) => { + execute: async ({ name, parameters }, options) => { try { const parametersObject = Object.fromEntries(parameters.map(({ name, value }) => [name, value])) const result = await getMcpToolBridge().callTool({ name, arguments: parametersObject, + ...(options?.toolCallId ? { requestId: options.toolCallId } : {}), }) return result satisfies { content?: Record[] isError?: boolean - structuredContent?: Record + structuredContent?: unknown toolResult?: unknown } } @@ -52,7 +60,7 @@ const tools = [ name: z.string().describe('The qualified tool name to call. Use format "::"'), parameters: z.array(z.object({ name: z.string().describe('The name of the parameter'), - value: z.unknown().describe('The value of the parameter'), + value: mcpParameterValueSchema.describe('The value of the parameter'), }).strict()).describe('The parameters to pass to the tool'), }).strict(), }),