From 5e09de370dc0469bbcfac7bce4b4b17820f40b9f Mon Sep 17 00:00:00 2001 From: sparkysolutions10-pixelmy Date: Mon, 19 Oct 2026 22:30:12 +0530 Subject: [PATCH 1/2] fix(mcp): return structured error with valid tool list on unknown tool (#415) --- src/mcp/codes.ts | 9 +++---- src/mcp/server.ts | 16 +++++++++--- tests/mcp.test.ts | 66 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 9 deletions(-) diff --git a/src/mcp/codes.ts b/src/mcp/codes.ts index caf9261d..c72ff695 100644 --- a/src/mcp/codes.ts +++ b/src/mcp/codes.ts @@ -10,12 +10,8 @@ export const MCP_ERROR_CODES = { ERR_PAIR_NOT_WATCHED: 'ERR_PAIR_NOT_WATCHED', ERR_ASSET_ISSUER_MISMATCH: 'ERR_ASSET_ISSUER_MISMATCH', ERR_BUDGET_EXCEEDED: 'ERR_BUDGET_EXCEEDED', - // A request that succeeded upstream and was still refused: the body came back - // too large to hand to an agent without consuming its whole context. Not a - // reachability answer (ERR_SERVICE_UNREACHABLE) and not a refusal to ask - // (ERR_URL_BLOCKED / ERR_HOST_NOT_ALLOWED) — we did fetch it, we will not dump - // it. Carries the cap in `details` so the caller can retry with a smaller page. ERR_RESPONSE_TOO_LARGE: 'ERR_RESPONSE_TOO_LARGE', + ERR_UNKNOWN_TOOL: 'ERR_UNKNOWN_TOOL', // Refused before any request was made, for two different reasons: // ERR_URL_BLOCKED the target resolves somewhere we will never fetch // (private, loopback, link-local, metadata) — #488 @@ -32,6 +28,7 @@ export interface McpErrorResponse { isError: true; code: McpErrorCode; reason: string; + /** Structured context for the caller, e.g. the spend budget behind ERR_BUDGET_EXCEEDED. */ details?: Record; } @@ -183,4 +180,6 @@ function classifyByText( return { code: MCP_ERROR_CODES.ERR_SERVICE_UNREACHABLE, signal: 'unreachable' }; } return null; + validTools?: readonly string[]; +} } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 0ab584e7..826f9fa7 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -72,6 +72,8 @@ function hostOf(url: string): string { } } +export const VALID_MCP_TOOLS = ['bazaar_search', 'paid_call'] as const; + export interface McpServerConfig { fetchWithPayment: typeof fetch; /** @@ -479,7 +481,13 @@ export class BazaarMcpServer { } } - throw new Error(`Unknown tool: ${request.params.name}`); + const rawName = String(request.params?.name ?? ''); + const sanitizedName = rawName.slice(0, 64).replace(/[^\x20-\x7E]/g, ''); + return this.createErrorResponse( + 'ERR_UNKNOWN_TOOL', + `Unknown tool '${sanitizedName}'. Available tools: ${VALID_MCP_TOOLS.join(', ')}`, + VALID_MCP_TOOLS + ); }); } @@ -494,7 +502,7 @@ export class BazaarMcpServer { return { limit, offset: requested.offset ?? 0 }; } - /** +/** * Serialize a successful result compactly — indentation is for humans and * costs the agent context — and within `budget`, so no upstream response, * large or hostile, can fill a context window in one call. Truncation is @@ -567,13 +575,13 @@ export class BazaarMcpServer { ); } - private createErrorResponse(code: McpErrorCode, reason: string, details?: Record) { - // For MCP, returning isError: true with a JSON payload is the standard way to return tool errors. + private createErrorResponse(code: McpErrorCode, reason: string, details?: Record) { // For MCP, returning isError: true with a JSON payload is the standard way to return tool errors. const payload = { isError: true, code, reason, ...(details ? { details } : {}), + ...((details as any)?.validTools ? { validTools: (details as any).validTools } : {}), }; return { content: [{ type: 'text', text: JSON.stringify(payload) }], diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 5a029272..9f262a8e 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -854,3 +854,69 @@ describe('cumulative spend budget', () => { expect(() => parseUsdToNanodollars('$1.0000000000')).toThrow(/more than 9 decimal places/); }) }) +describe('BazaarMcpServer — Unknown Tool Handling (#415)', () => { + function captureToolHandler(server: any) { + let handler: any = null; + server.server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) handler = fn; + }; + server.setupHandlers(); + return handler; + } + + it('returns a structured error instead of throwing a raw exception', async () => { + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn() }); + const handler = captureToolHandler(server); + + // Call with unknown tool name + const response = await handler({ + params: { name: 'non_existent_tool_123', arguments: {} }, + }); + + expect(response.isError).toBe(true); + expect(response.content).toHaveLength(1); + expect(response.content[0].type).toBe('text'); + + const error = JSON.parse(response.content[0].text); + expect(error.code).toBe('ERR_UNKNOWN_TOOL'); + expect(error.reason).toContain('non_existent_tool_123'); + }); + + it('lists all valid tool names in the error response', async () => { + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn() }); + const handler = captureToolHandler(server); + + const response = await handler({ + params: { name: 'random_tool', arguments: {} }, + }); + + const error = JSON.parse(response.content[0].text); + expect(error.validTools).toBeDefined(); + expect(error.validTools).toContain('bazaar_search'); + expect(error.validTools).toContain('paid_call'); + expect(error.reason).toContain('bazaar_search'); + expect(error.reason).toContain('paid_call'); + }); + + it('length-bounds and sanitises untrusted input in tool name echo', async () => { + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn() }); + const handler = captureToolHandler(server); + + // Huge name with control characters + const dirtyHugeName = 'A'.repeat(200) + '\x00\x1f\x7f' + 'B'.repeat(50); + const response = await handler({ + params: { name: dirtyHugeName, arguments: {} }, + }); + + expect(response.isError).toBe(true); + const error = JSON.parse(response.content[0].text); + expect(error.code).toBe('ERR_UNKNOWN_TOOL'); + + // The echoed tool name in reason must be bounded to 64 chars and have no control characters + expect(error.reason).not.toContain('\x00'); + expect(error.reason).not.toContain('\x1f'); + // Ensure the 250-character input was capped + expect(error.reason.length).toBeLessThan(160); + }); +}); + From 71817d6afc77f8da7fd1885d010c9a4bdec10aa2 Mon Sep 17 00:00:00 2001 From: sparkysolutions10-pixelmy Date: Fri, 23 Oct 2026 13:41:06 +0530 Subject: [PATCH 2/2] fix(mcp): support array or object details in createErrorResponse --- src/mcp/codes.ts | 3 +-- src/mcp/server.ts | 13 ++++++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/mcp/codes.ts b/src/mcp/codes.ts index c72ff695..4240d5bf 100644 --- a/src/mcp/codes.ts +++ b/src/mcp/codes.ts @@ -31,6 +31,7 @@ export interface McpErrorResponse { /** Structured context for the caller, e.g. the spend budget behind ERR_BUDGET_EXCEEDED. */ details?: Record; + validTools?: readonly string[]; } /** @@ -180,6 +181,4 @@ function classifyByText( return { code: MCP_ERROR_CODES.ERR_SERVICE_UNREACHABLE, signal: 'unreachable' }; } return null; - validTools?: readonly string[]; -} } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 826f9fa7..e566f661 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -575,13 +575,20 @@ export class BazaarMcpServer { ); } - private createErrorResponse(code: McpErrorCode, reason: string, details?: Record) { // For MCP, returning isError: true with a JSON payload is the standard way to return tool errors. + private createErrorResponse( + code: McpErrorCode, + reason: string, + details?: Record | readonly string[] + ) { + // For MCP, returning isError: true with a JSON payload is the standard way to return tool errors. + const validTools = Array.isArray(details) ? details : (details as any)?.validTools; + const extraDetails = details && !Array.isArray(details) ? details : undefined; const payload = { isError: true, code, reason, - ...(details ? { details } : {}), - ...((details as any)?.validTools ? { validTools: (details as any).validTools } : {}), + ...(extraDetails ? { details: extraDetails } : {}), + ...(validTools ? { validTools } : {}), }; return { content: [{ type: 'text', text: JSON.stringify(payload) }],