From ef3f5c1d4b579504f8c2fb0af1ea731c688393f9 Mon Sep 17 00:00:00 2001 From: sparkysolutions10-pixelmy Date: Mon, 19 Oct 2026 22:30:12 +0530 Subject: [PATCH] fix(mcp): return structured error with valid tool list on unknown tool (#415) --- src/mcp/codes.ts | 2 ++ src/mcp/server.ts | 13 +++++++-- tests/mcp.test.ts | 67 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/mcp/codes.ts b/src/mcp/codes.ts index cacfe590..42f9f10f 100644 --- a/src/mcp/codes.ts +++ b/src/mcp/codes.ts @@ -9,6 +9,7 @@ export const MCP_ERROR_CODES = { ERR_INVALID_ASSET: 'ERR_INVALID_ASSET', ERR_PAIR_NOT_WATCHED: 'ERR_PAIR_NOT_WATCHED', ERR_ASSET_ISSUER_MISMATCH: 'ERR_ASSET_ISSUER_MISMATCH', + 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 @@ -25,4 +26,5 @@ export interface McpErrorResponse { isError: true; code: McpErrorCode; reason: string; + validTools?: readonly string[]; } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 8a6cd7de..80f8ba89 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -62,6 +62,8 @@ function hostOf(url: string): string { } } +export const VALID_MCP_TOOLS = ['bazaar_search', 'paid_call'] as const; + export interface McpServerConfig { fetchWithPayment: typeof fetch; /** @@ -288,7 +290,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 + ); }); } @@ -318,12 +326,13 @@ export class BazaarMcpServer { return this.createErrorResponse('ERR_INTERNAL_FAILURE', e.message || 'An unknown error occurred during the paid call.'); } - private createErrorResponse(code: McpErrorCode, reason: string) { + private createErrorResponse(code: McpErrorCode, reason: string, validTools?: readonly string[]) { // For MCP, returning isError: true with a JSON payload is the standard way to return tool errors. const payload = { isError: true, code, reason, + ...(validTools ? { validTools } : {}), }; return { content: [{ type: 'text', text: JSON.stringify(payload) }], diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index e6ee7007..e0dfbf89 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -483,3 +483,70 @@ describe('BazaarMcpServer bazaar_search upstream failures (#349)', () => { expect(body.reason).toContain('not valid JSON'); }); }); + +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); + }); +}); +