Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,13 @@ async function rpc(toolName, args) {
});
const data = JSON.parse(typeof res.text === 'function' ? await res.text() : res.body);
if (data.error) throw new Error(data.error);
if (data.result && data.result.__toolError) {
const err = new Error(data.result.message);
err.tool = data.result.tool;
err.isToolError = true;
err.details = data.result.details;
throw err;
}
return data.result;
}

Expand Down
21 changes: 21 additions & 0 deletions packages/nuxt-mcp-toolkit/src/runtime/server/mcp/codemode/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,22 @@ function buildDispatchFunctions(
// Normalize string/number returns before code mode consumes them
const result = normalizeToolResult(rawResult as Parameters<typeof normalizeToolResult>[0])

// Errors must win over structuredContent — otherwise isError + structuredContent
// would be returned as a successful value and never throw in the sandbox.
if (result.isError) {
const errorText = result.content
?.filter((c): c is { type: 'text', text: string } => c.type === 'text')
.map(c => c.text)
.join('\n') ?? 'Tool execution failed'

return {
__toolError: true,
message: errorText,
tool: sanitized,
details: result.structuredContent ?? undefined,
}
}

// Prefer structuredContent when available (preserves typed data)
if (result.structuredContent != null) {
return result.structuredContent
Expand All @@ -290,6 +306,11 @@ function buildDispatchFunctions(
return fns
}

/**
* @internal
*/
export { buildDispatchFunctions }

/**
* Check if a tool name needs sanitization for JavaScript
*/
Expand Down
99 changes: 98 additions & 1 deletion packages/nuxt-mcp-toolkit/test/codemode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
formatSearchResults,
sanitizeToolName,
} from '../src/runtime/server/mcp/codemode/types'
import { createCodemodeTools, disposeCodeMode } from '../src/runtime/server/mcp/codemode/index'
import { createCodemodeTools, disposeCodeMode, buildDispatchFunctions } from '../src/runtime/server/mcp/codemode/index'
import { normalizeCode } from '../src/runtime/server/mcp/codemode/executor'
import type { McpToolDefinition, McpToolDefinitionListItem } from '../src/runtime/server/mcp/definitions/tools'
import type { McpRequestExtra } from '../src/runtime/server/mcp/definitions/sdk-extra'
Expand Down Expand Up @@ -404,6 +404,103 @@ describe('normalizeCode', () => {
})
})

describe('buildDispatchFunctions — error handling', () => {
it('returns __toolError sentinel for isError results', async () => {
const tool: McpToolDefinition = {
name: 'fail-tool',
description: 'A tool that fails',
inputSchema: { id: z.string() },
handler: async () => ({
isError: true,
content: [{ type: 'text' as const, text: 'Item not found' }],
}),
}
const { toolNameMap } = generateTypesFromTools([tool])
const fns = buildDispatchFunctions([tool], toolNameMap)
const result = await fns.fail_tool!({ id: 'nonexistent' }) as Record<string, unknown>

expect(result.__toolError).toBe(true)
expect(result.message).toBe('Item not found')
expect(result.tool).toBe('fail_tool')
})

it('includes structuredContent as details in error sentinel', async () => {
const tool: McpToolDefinition = {
name: 'validate-tool',
description: 'A validation tool',
inputSchema: { id: z.string() },
handler: async () => ({
isError: true,
structuredContent: { ok: false, error: { category: 'validation', retryable: false } },
content: [{ type: 'text' as const, text: 'Validation failed' }],
}),
}
const { toolNameMap } = generateTypesFromTools([tool])
const fns = buildDispatchFunctions([tool], toolNameMap)
const result = await fns.validate_tool!({ id: 'bad' }) as Record<string, unknown>

expect(result.__toolError).toBe(true)
expect(result.message).toBe('Validation failed')
expect(result.details).toEqual({ ok: false, error: { category: 'validation', retryable: false } })
})

it('error sentinel has no details when no structuredContent', async () => {
const tool: McpToolDefinition = {
name: 'simple-fail',
description: 'Simple failure',
inputSchema: {},
handler: async () => ({
isError: true,
content: [{ type: 'text' as const, text: 'Something went wrong' }],
}),
}
const { toolNameMap } = generateTypesFromTools([tool])
const fns = buildDispatchFunctions([tool], toolNameMap)
const result = await fns.simple_fail!({}) as Record<string, unknown>

expect(result.__toolError).toBe(true)
expect(result.message).toBe('Something went wrong')
expect(result.details).toBeUndefined()
})

it('non-error results are unaffected by error handling', async () => {
const tool: McpToolDefinition = {
name: 'ok-tool',
description: 'A tool that succeeds',
inputSchema: {},
handler: async () => ({
content: [{ type: 'text' as const, text: '{"status":"ok"}' }],
}),
}
const { toolNameMap } = generateTypesFromTools([tool])
const fns = buildDispatchFunctions([tool], toolNameMap)
const result = await fns.ok_tool!({})

expect(result).toEqual({ status: 'ok' })
})

it('isError with structuredContent prioritizes error over structured data', async () => {
const tool: McpToolDefinition = {
name: 'error-with-data',
description: 'Error with data',
inputSchema: {},
handler: async () => ({
isError: true,
structuredContent: { field: 'id', expected: 'valid ObjectId' },
content: [{ type: 'text' as const, text: 'Invalid ID format' }],
}),
}
const { toolNameMap } = generateTypesFromTools([tool])
const fns = buildDispatchFunctions([tool], toolNameMap)
const result = await fns.error_with_data!({}) as Record<string, unknown>

// Should be an error, not the structuredContent
expect(result.__toolError).toBe(true)
expect(result.message).toBe('Invalid ID format')
expect(result.details).toEqual({ field: 'id', expected: 'valid ObjectId' })
})
})

describe('disposeCodeMode', () => {
it('is exported and callable', () => {
expect(typeof disposeCodeMode).toBe('function')
Expand Down
Loading