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 f412b9794..01fa1b110 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,7 +13,6 @@ 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' @@ -63,11 +62,8 @@ 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) { @@ -93,33 +89,20 @@ function parseQualifiedToolName(name: string) { } } -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 +function resolveFallbackToolName(toolName: string): string | undefined { + const normalizedTransportPrefix = toolName + .replace(/^\.(?:stdio|stdo)::/, '') + .replace(/^(?:stdio|stdo)::/, '') + if (normalizedTransportPrefix !== toolName) { + return normalizedTransportPrefix } - return { - ...baseEnv, - ...overrides, + const lastSeparatorIndex = toolName.lastIndexOf(toolNameSeparator) + if (lastSeparatorIndex <= 0 || lastSeparatorIndex === toolName.length - toolNameSeparator.length) { + return undefined } + + return toolName.slice(lastSeparatorIndex + toolNameSeparator.length) } async function closeSession(session: McpServerSession) { @@ -135,26 +118,8 @@ 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() @@ -206,16 +171,13 @@ 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: createSpawnEnv(config.env), + env: config.env, cwd: config.cwd, stderr: 'pipe', }) @@ -225,11 +187,7 @@ export function createMcpStdioManager(): McpStdioManager { }) try { - await withTimeout( - client.connect(transport), - mcpServerConnectTimeoutMsec, - `mcp server connect timeout (${mcpServerConnectTimeoutMsec}ms): ${name}`, - ) + await client.connect(transport) transport.stderr?.on('data', (data) => { const text = data.toString('utf-8').trim() if (text) { @@ -246,7 +204,6 @@ 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 } @@ -328,73 +285,58 @@ export function createMcpStdioManager(): McpStdioManager { } const callTool = async (payload: ElectronMcpCallToolPayload): Promise => { - 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 - } + const { serverName, toolName } = parseQualifiedToolName(payload.name) + const session = sessions.get(serverName) + if (!session) { + throw new Error(`mcp server is not running: ${serverName}`) } - 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({ + let result + try { + result = await session.client.callTool({ name: toolName, arguments: payload.arguments ?? {}, }, undefined, { timeout: mcpRequestTimeoutMsec, maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec, }) - - 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 - } - - return normalized } + catch (error) { + const fallbackToolName = resolveFallbackToolName(toolName) + if (!fallbackToolName || fallbackToolName === toolName) { + throw error + } - const execution = executeCall() - if (!normalizedRequestId) { - return execution - } + log.withFields({ + serverName, + requestedToolName: toolName, + fallbackToolName, + }).warn('retrying mcp tool call with normalized tool name') - inFlightToolCallsByRequestId.set(normalizedRequestId, execution) - - try { - const result = await execution - const now = Date.now() - completedToolCallsByRequestId.set(normalizedRequestId, { - result, - expiresAt: now + mcpToolRequestIdCacheTtlMsec, + result = await session.client.callTool({ + name: fallbackToolName, + arguments: payload.arguments ?? {}, + }, undefined, { + timeout: mcpRequestTimeoutMsec, + maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec, }) - pruneCompletedToolCalls(now) - return result } - finally { - inFlightToolCallsByRequestId.delete(normalizedRequestId) + + const normalized: ElectronMcpCallToolResult = {} + if ('content' in result && Array.isArray(result.content)) { + normalized.content = result.content as Array> } + 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 => { @@ -436,20 +378,18 @@ export async function setupMcpStdioManager() { return manager } -export function createMcpServersService(params: { context: ReturnType['context'], manager: McpStdioManager, allowManageConfig?: boolean }) { - if (params.allowManageConfig) { - defineInvokeHandler(params.context, electronMcpOpenConfigFile, async () => { - return params.manager.openConfigFile() - }) +export function createMcpServersService(params: { context: ReturnType['context'], manager: McpStdioManager }) { + 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 0729af39e..25f228e9f 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,10 +10,9 @@ 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 { createScreenService, createWindowService } from '../../../services/electron' +import { setupBaseWindowElectronInvokes } from '../../shared/window' export async function setupChatWindowElectronInvokes(params: { window: BrowserWindow @@ -27,15 +26,12 @@ export async function setupChatWindowElectronInvokes(params: { // manage events within eventa's context system. ipcMain.setMaxListeners(0) - const { context } = createContext(ipcMain, params.window, { - onlySameWindow: true, - }) + const { context } = createContext(ipcMain, params.window) + + await setupBaseWindowElectronInvokes({ context, window: params.window, i18n: params.i18n, serverChannel: params.serverChannel }) - createScreenService({ context, window: params.window }) - createWindowService({ context, window: params.window }) createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.window }) - createServerChannelService({ serverChannel: params.serverChannel }) - createMcpServersService({ context, manager: params.mcpStdioManager, allowManageConfig: false }) + createMcpServersService({ context, manager: params.mcpStdioManager }) 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 fde3b694a..f9eb06e00 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,14 +34,12 @@ export async function setupMainWindowElectronInvokes(params: { // manage events within eventa's context system. ipcMain.setMaxListeners(0) - const { context } = createContext(ipcMain, params.window, { - onlySameWindow: true, - }) + const { context } = createContext(ipcMain, params.window) 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, allowManageConfig: false }) + createMcpServersService({ context, manager: params.mcpStdioManager }) 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 7f3626d4d..ce29005aa 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, createScreenService, createWindowService } from '../../../services/electron' +import { createAutoUpdaterService } from '../../../services/electron' +import { setupBaseWindowElectronInvokes } from '../../shared/window' export async function setupSettingsWindowInvokes(params: { settingsWindow: BrowserWindow @@ -31,16 +31,13 @@ export async function setupSettingsWindowInvokes(params: { // manage events within eventa's context system. ipcMain.setMaxListeners(0) - const { context } = createContext(ipcMain, params.settingsWindow, { - onlySameWindow: true, - }) + const { context } = createContext(ipcMain, params.settingsWindow) + + await setupBaseWindowElectronInvokes({ context, window: params.settingsWindow, i18n: params.i18n, serverChannel: params.serverChannel }) - 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 }) - createServerChannelService({ serverChannel: params.serverChannel }) - createMcpServersService({ context, manager: params.mcpStdioManager, allowManageConfig: true }) + createMcpServersService({ context, manager: params.mcpStdioManager }) 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 088079f5c..8ac4e0fa5 100644 --- a/apps/stage-tamagotchi/src/shared/eventa.ts +++ b/apps/stage-tamagotchi/src/shared/eventa.ts @@ -173,12 +173,11 @@ export interface ElectronMcpToolDescriptor { export interface ElectronMcpCallToolPayload { name: string arguments?: Record - requestId?: string } export interface ElectronMcpCallToolResult { content?: Array> - structuredContent?: unknown + structuredContent?: Record toolResult?: unknown isError?: boolean } diff --git a/packages/stage-ui/src/stores/llm.ts b/packages/stage-ui/src/stores/llm.ts index 28c21ff3b..8adb6efb5 100644 --- a/packages/stage-ui/src/stores/llm.ts +++ b/packages/stage-ui/src/stores/llm.ts @@ -25,30 +25,6 @@ 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) => { @@ -63,16 +39,7 @@ function sanitizeMessages(messages: unknown[]): Message[] { } function streamOptionsToolsCompatibilityOk(model: string, chatProvider: ChatProvider, _: Message[], options?: StreamOptions): boolean { - if (typeof options?.supportsTools === 'boolean') { - return options.supportsTools - } - - const discovered = options?.toolsCompatibility?.get(createToolsCompatibilityKey(model, chatProvider)) - if (typeof discovered === 'boolean') { - return discovered - } - - return true + return !!(options?.supportsTools || options?.toolsCompatibility?.get(`${chatProvider.chat(model).baseURL}-${model}`)) } async function streamFrom(model: string, chatProvider: ChatProvider, messages: Message[], options?: StreamOptions) { @@ -130,22 +97,15 @@ async function streamFrom(model: string, chatProvider: ChatProvider, messages: M } try { - const stream = streamText({ + 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) @@ -160,8 +120,23 @@ export async function attemptForToolsCompatibilityDiscovery(model: string, chatP return true } catch (err) { - if (err instanceof Error && err.name === new XSAIError('').name && isKnownToolsUnsupportedError(err)) { - return false + 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 + } } throw err @@ -212,35 +187,17 @@ 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(key)) { + if (toolsCompatibility.value.has(`${chatProvider.chat(model).baseURL}-${model}`)) { return } const res = await attemptForToolsCompatibilityDiscovery(model, chatProvider, _, { ...options, toolsCompatibility: toolsCompatibility.value }) - toolsCompatibility.value.set(key, res) + toolsCompatibility.value.set(`${chatProvider.chat(model).baseURL}-${model}`, res) } - 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 - } + function stream(model: string, chatProvider: ChatProvider, messages: Message[], options?: StreamOptions) { + return streamFrom(model, chatProvider, messages, { ...options, toolsCompatibility: toolsCompatibility.value }) } 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 799710c33..6dc87a930 100644 --- a/packages/stage-ui/src/stores/mcp-tool-bridge.ts +++ b/packages/stage-ui/src/stores/mcp-tool-bridge.ts @@ -9,12 +9,11 @@ export interface McpToolDescriptor { export interface McpCallToolPayload { name: string arguments?: Record - requestId?: string } export interface McpCallToolResult { content?: Array> - structuredContent?: unknown + structuredContent?: Record 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 897801ea2..c1906c3e0 100644 --- a/packages/stage-ui/src/tools/mcp.test.ts +++ b/packages/stage-ui/src/tools/mcp.test.ts @@ -1,20 +1,10 @@ import type { JsonSchema } from 'xsschema' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it } 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 = [ @@ -38,110 +28,4 @@ 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 bad340d07..8f6e437de 100644 --- a/packages/stage-ui/src/tools/mcp.ts +++ b/packages/stage-ui/src/tools/mcp.ts @@ -3,13 +3,6 @@ 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', @@ -28,18 +21,17 @@ 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 }, options) => { + execute: async ({ name, parameters }) => { 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?: unknown + structuredContent?: Record toolResult?: unknown } } @@ -60,7 +52,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: mcpParameterValueSchema.describe('The value of the parameter'), + value: z.unknown().describe('The value of the parameter'), }).strict()).describe('The parameters to pass to the tool'), }).strict(), }),