🐛 fix: propagate hub tool errors

This commit is contained in:
Vaayne 2025-12-30 17:25:30 +08:00
parent 479f180b1e
commit 1554f082ec
7 changed files with 47 additions and 10 deletions

View File

@ -176,6 +176,7 @@ describe('HubServer Integration', () => {
const execOutput = JSON.parse(execResult.content[0].text) const execOutput = JSON.parse(execResult.content[0].text)
expect(execOutput.error).toBe('test error') expect(execOutput.error).toBe('test error')
expect(execOutput.isError).toBe(true)
}) })
}) })
@ -213,6 +214,7 @@ describe('HubServer Integration', () => {
expect(execOutput.error).toBe('Execution timed out after 60000ms') expect(execOutput.error).toBe('Execution timed out after 60000ms')
expect(execOutput.result).toBeUndefined() expect(execOutput.result).toBeUndefined()
expect(execOutput.isError).toBe(true)
expect(execOutput.logs).toContain('[log] starting') expect(execOutput.logs).toContain('[log] starting')
expect(vi.mocked(mcpService.abortTool)).toHaveBeenCalled() expect(vi.mocked(mcpService.abortTool)).toHaveBeenCalled()
}) })

View File

@ -85,6 +85,7 @@ describe('Runtime', () => {
expect(result.result).toBeUndefined() expect(result.result).toBeUndefined()
expect(result.error).toBe('test error') expect(result.error).toBe('test error')
expect(result.isError).toBe(true)
}) })
it('supports parallel helper', async () => { it('supports parallel helper', async () => {
@ -139,5 +140,20 @@ describe('Runtime', () => {
expect(result.result).toBe(30) expect(result.result).toBe(30)
}) })
it('stops execution when a tool throws', async () => {
const runtime = new Runtime()
const tools = [
createMockTool({
functionName: 'server__failing_tool'
})
]
const result = await runtime.execute('return await server__failing_tool({})', tools)
expect(result.result).toBeUndefined()
expect(result.error).toBe('Tool failed')
expect(result.isError).toBe(true)
})
}) })
}) })

View File

@ -175,7 +175,8 @@ export class HubServer {
type: 'text', type: 'text',
text: JSON.stringify(result, null, 2) text: JSON.stringify(result, null, 2)
} }
] ],
isError: result.isError
} }
} }
} }

View File

@ -4,7 +4,7 @@
*/ */
import mcpService from '@main/services/MCPService' import mcpService from '@main/services/MCPService'
import { generateMcpToolFunctionName } from '@shared/mcp' import { generateMcpToolFunctionName } from '@shared/mcp'
import type { MCPTool } from '@types' import type { MCPCallToolResponse, MCPTool, MCPToolResultContent } from '@types'
import type { GeneratedTool } from './types' import type { GeneratedTool } from './types'
@ -47,10 +47,12 @@ export const callMcpTool = async (functionName: string, params: unknown, callId?
} }
const toolId = `${retryToolInfo.serverId}__${retryToolInfo.toolName}` const toolId = `${retryToolInfo.serverId}__${retryToolInfo.toolName}`
const result = await mcpService.callToolById(toolId, params, callId) const result = await mcpService.callToolById(toolId, params, callId)
throwIfToolError(result)
return extractToolResult(result) return extractToolResult(result)
} }
const toolId = `${toolInfo.serverId}__${toolInfo.toolName}` const toolId = `${toolInfo.serverId}__${toolInfo.toolName}`
const result = await mcpService.callToolById(toolId, params, callId) const result = await mcpService.callToolById(toolId, params, callId)
throwIfToolError(result)
return extractToolResult(result) return extractToolResult(result)
} }
@ -58,7 +60,7 @@ export const abortMcpTool = async (callId: string): Promise<boolean> => {
return mcpService.abortTool(null as unknown as Electron.IpcMainInvokeEvent, callId) return mcpService.abortTool(null as unknown as Electron.IpcMainInvokeEvent, callId)
} }
function extractToolResult(result: { content: Array<{ type: string; text?: string }> }): unknown { function extractToolResult(result: MCPCallToolResponse): unknown {
if (!result.content || result.content.length === 0) { if (!result.content || result.content.length === 0) {
return null return null
} }
@ -74,3 +76,21 @@ function extractToolResult(result: { content: Array<{ type: string; text?: strin
return result.content return result.content
} }
function throwIfToolError(result: MCPCallToolResponse): void {
if (!result.isError) {
return
}
const textContent = extractTextContent(result.content)
throw new Error(textContent ?? 'Tool execution failed')
}
function extractTextContent(content: MCPToolResultContent[] | undefined): string | undefined {
if (!content || content.length === 0) {
return undefined
}
const textBlock = content.find((item) => item.type === 'text' && item.text)
return textBlock?.text
}

View File

@ -103,7 +103,8 @@ export class Runtime {
{ {
result: undefined, result: undefined,
logs: resolvedLogs.length > 0 ? resolvedLogs : undefined, logs: resolvedLogs.length > 0 ? resolvedLogs : undefined,
error: errorMessage error: errorMessage,
isError: true
}, },
terminateWorker terminateWorker
) )

View File

@ -26,10 +26,11 @@ export interface ExecInput {
code: string code: string
} }
export interface ExecOutput { export type ExecOutput = {
result: unknown result: unknown
logs?: string[] logs?: string[]
error?: string error?: string
isError?: boolean
} }
export interface ToolRegistryOptions { export interface ToolRegistryOptions {

View File

@ -195,11 +195,7 @@ class McpService {
* Call a tool by its full ID (serverId__toolName format). * Call a tool by its full ID (serverId__toolName format).
* Used by Hub server's runtime. * Used by Hub server's runtime.
*/ */
public async callToolById( public async callToolById(toolId: string, params: unknown, callId?: string): Promise<MCPCallToolResponse> {
toolId: string,
params: unknown,
callId?: string
): Promise<{ content: Array<{ type: string; text?: string }> }> {
const parts = toolId.split('__') const parts = toolId.split('__')
if (parts.length < 2) { if (parts.length < 2) {
throw new Error(`Invalid tool ID format: ${toolId}`) throw new Error(`Invalid tool ID format: ${toolId}`)