From 1f58fda16d919508d895bb241091408ba1e00120 Mon Sep 17 00:00:00 2001 From: sparkysolutions10-pixelmy Date: Sat, 17 Oct 2026 13:56:53 +0530 Subject: [PATCH] fix(mcp): structured unknown-tool errors, successful empty search, and test coverage (#414, #415, #421) --- .changeset/mcp-error-handling-and-tests.md | 8 + src/mcp/codes.ts | 3 +- src/mcp/server.ts | 133 +++--- tests/mcp.test.ts | 521 +++++++++++++++++---- 4 files changed, 520 insertions(+), 145 deletions(-) create mode 100644 .changeset/mcp-error-handling-and-tests.md diff --git a/.changeset/mcp-error-handling-and-tests.md b/.changeset/mcp-error-handling-and-tests.md new file mode 100644 index 00000000..3f3b0845 --- /dev/null +++ b/.changeset/mcp-error-handling-and-tests.md @@ -0,0 +1,8 @@ +--- +'lens': patch +--- + +Structured error handling and comprehensive test coverage for MCP server: +- Handle empty search results as a success returning `{ count: 0, resources: [] }` rather than an error (resolves #414) +- Return structured `ERR_UNKNOWN_TOOL` with valid tool suggestions and bounded/sanitized names instead of throwing raw exceptions (resolves #415) +- Add comprehensive Vitest test suite exercising ListTools schema validation, bazaar_search execution paths (query, default, empty, non-2xx, malformed), and realistic upstream SDK errors (resolves #421) diff --git a/src/mcp/codes.ts b/src/mcp/codes.ts index a9631225..aa075b7e 100644 --- a/src/mcp/codes.ts +++ b/src/mcp/codes.ts @@ -6,7 +6,8 @@ export const MCP_ERROR_CODES = { ERR_SERVICE_UNREACHABLE: 'ERR_SERVICE_UNREACHABLE', ERR_NO_RESULTS: 'ERR_NO_RESULTS', ERR_BAD_REQUEST: 'ERR_BAD_REQUEST', - ERR_INTERNAL_FAILURE: 'ERR_INTERNAL_FAILURE' + ERR_INTERNAL_FAILURE: 'ERR_INTERNAL_FAILURE', + ERR_UNKNOWN_TOOL: 'ERR_UNKNOWN_TOOL' } as const; export type McpErrorCode = keyof typeof MCP_ERROR_CODES; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 3f69c5b1..4f144e99 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -4,6 +4,9 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot import { MCP_ERROR_CODES, McpErrorCode } from './codes.js'; import { fetchWithTimeout } from '../fetch.js'; +export const VALID_MCP_TOOLS = ['bazaar_search', 'paid_call'] as const; +export type ValidMcpToolName = typeof VALID_MCP_TOOLS[number]; + export interface McpServerConfig { fetchWithPayment: typeof fetch; } @@ -72,69 +75,93 @@ export class BazaarMcpServer { })); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { - if (request.params.name === 'bazaar_search') { - const url = String(request.params.arguments?.url); - const query = request.params.arguments?.query ? String(request.params.arguments?.query) : undefined; - - try { - const endpoint = query - ? `${url.replace(/\/$/, '')}/discovery/search?q=${encodeURIComponent(query)}` - : `${url.replace(/\/$/, '')}/discovery/resources`; - - const res = await fetchWithTimeout(endpoint); - - if (!res.ok) { - return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', `Service returned ${res.status}`); - } + try { + if (request.params.name === 'bazaar_search') { + const url = String(request.params.arguments?.url); + const query = request.params.arguments?.query ? String(request.params.arguments?.query) : undefined; - const data = await res.json(); - - const hasResources = (d: unknown): d is { resources: unknown[] } => - typeof d === 'object' && d !== null && 'resources' in d && Array.isArray((d as Record).resources); + try { + const endpoint = query + ? `${url.replace(/\/$/, '')}/discovery/search?q=${encodeURIComponent(query)}` + : `${url.replace(/\/$/, '')}/discovery/resources`; + + const res = await fetchWithTimeout(endpoint); + + if (!res.ok) { + return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', `Service returned ${res.status}`); + } + + const data = await res.json(); + + const hasResources = (d: unknown): d is { resources: unknown[] } => + typeof d === 'object' && d !== null && 'resources' in d && Array.isArray((d as Record).resources); - if ((Array.isArray(data) && data.length === 0) || (hasResources(data) && data.resources.length === 0)) { - return this.createErrorResponse('ERR_NO_RESULTS', 'No resources found'); - } - - return { - content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], - }; - } catch (e: any) { - return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', e.message); - } - } + if (Array.isArray(data) && data.length === 0) { + return { + content: [{ type: 'text', text: JSON.stringify({ count: 0, resources: [] }, null, 2) }], + }; + } - if (request.params.name === 'paid_call') { - const url = String(request.params.arguments?.url); - const method = request.params.arguments?.method ? String(request.params.arguments?.method) : 'GET'; - - try { - const res = await this.fetchWithPayment(url, { method }); - - if (!res.ok) { - if (res.status === 402) { - return this.createErrorResponse('ERR_PAYMENT_REJECTED', 'Payment required but was rejected or failed.'); + if (hasResources(data) && data.resources.length === 0) { + const emptyResult = { + count: 0, + resources: [], + ...(typeof data === 'object' && data !== null ? data : {}), + }; + emptyResult.count = 0; + emptyResult.resources = []; + return { + content: [{ type: 'text', text: JSON.stringify(emptyResult, null, 2) }], + }; } - return this.createErrorResponse('ERR_BAD_REQUEST', `Request failed with status ${res.status}`); + + return { + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + }; + } catch (e: any) { + return this.createErrorResponse('ERR_SERVICE_UNREACHABLE', e.message); } + } + + if (request.params.name === 'paid_call') { + const url = String(request.params.arguments?.url); + const method = request.params.arguments?.method ? String(request.params.arguments?.method) : 'GET'; - const text = await res.text(); - let parsed; try { - parsed = JSON.parse(text); - } catch { - parsed = text; + const res = await this.fetchWithPayment(url, { method }); + + if (!res.ok) { + if (res.status === 402) { + return this.createErrorResponse('ERR_PAYMENT_REJECTED', 'Payment required but was rejected or failed.'); + } + return this.createErrorResponse('ERR_BAD_REQUEST', `Request failed with status ${res.status}`); + } + + const text = await res.text(); + let parsed; + try { + parsed = JSON.parse(text); + } catch { + parsed = text; + } + + return { + content: [{ type: 'text', text: typeof parsed === 'string' ? parsed : JSON.stringify(parsed, null, 2) }], + }; + } catch (e: any) { + return this.handlePaidCallError(e); } - - return { - content: [{ type: 'text', text: typeof parsed === 'string' ? parsed : JSON.stringify(parsed, null, 2) }], - }; - } catch (e: any) { - return this.handlePaidCallError(e); } - } - throw new Error(`Unknown tool: ${request.params.name}`); + const rawName = String(request.params?.name ?? ''); + const sanitizedName = rawName.replace(/[\x00-\x1F\x7F]/g, '').slice(0, 64); + return this.createErrorResponse( + 'ERR_UNKNOWN_TOOL', + `Unknown tool "${sanitizedName}". Valid tools are: ${VALID_MCP_TOOLS.join(', ')}` + ); + } catch (err: any) { + return this.createErrorResponse('ERR_INTERNAL_FAILURE', err?.message || 'An internal error occurred during tool execution.'); + } }); } diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 8b979ad6..8a951519 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -1,123 +1,462 @@ -import { describe, it, expect, vi } from 'vitest'; -import { BazaarMcpServer } from '../src/mcp/server'; -import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { BazaarMcpServer, VALID_MCP_TOOLS } from '../src/mcp/server'; +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; + +function getHandlers(server: BazaarMcpServer) { + let callHandler: any = null; + let listHandler: any = null; + + (server as any).server.setRequestHandler = (schema: any, fn: any) => { + if (schema === CallToolRequestSchema) { + callHandler = fn; + } + if (schema === ListToolsRequestSchema) { + listHandler = fn; + } + }; + + (server as any).setupHandlers(); + return { + callTool: (req: any) => callHandler(req), + listTools: () => listHandler(), + }; +} describe('BazaarMcpServer', () => { - it('should map signature expiration ledger error to ERR_EXPIRED_AUTHORISATION', async () => { - const mockFetch = vi.fn().mockRejectedValue(new Error('Transaction signature expired. Signature expiration ledger 123 is less than current ledger 125')); - const server = new BazaarMcpServer({ fetchWithPayment: mockFetch }); - - // The SDK server doesn't expose the router directly without a client, but we can test the handlePaidCallError method directly, - // or by overriding setRequestHandler to capture our handler. - let handler: any = null; - (server as any).server.setRequestHandler = (schema: any, fn: any) => { - if (schema === CallToolRequestSchema) { - handler = fn; - } - }; + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('paid_call error mapping and execution (existing & realistic paths)', () => { + it('should map signature expiration ledger error to ERR_EXPIRED_AUTHORISATION', async () => { + const mockFetch = vi.fn().mockRejectedValue(new Error('Transaction signature expired. Signature expiration ledger 123 is less than current ledger 125')); + const server = new BazaarMcpServer({ fetchWithPayment: mockFetch }); + const { callTool } = getHandlers(server); + + const response = await callTool({ + params: { + name: 'paid_call', + arguments: { + url: 'http://example.com/paid' + } + } + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_EXPIRED_AUTHORISATION'); + }); + + it('should map missing trustline error to ERR_MISSING_TRUSTLINE', async () => { + const mockFetch = vi.fn().mockRejectedValue(new Error('op_no_trust: The account does not have a trustline for this asset')); + const server = new BazaarMcpServer({ fetchWithPayment: mockFetch }); + const { callTool } = getHandlers(server); + + const response = await callTool({ + params: { + name: 'paid_call', + arguments: { + url: 'http://example.com/paid' + } + } + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_MISSING_TRUSTLINE'); + }); + + it('should map 402 rejected error to ERR_PAYMENT_REJECTED', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 402, + text: async () => 'Payment Required' + }); + + const server = new BazaarMcpServer({ fetchWithPayment: mockFetch }); + const { callTool } = getHandlers(server); + + const response = await callTool({ + params: { + name: 'paid_call', + arguments: { + url: 'http://example.com/paid' + } + } + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_PAYMENT_REJECTED'); + }); - // Re-setup handlers to capture the handler - (server as any).setupHandlers(); + it('should return successfully on a successful call', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ success: true, data: 'hello' }) + }); + + const server = new BazaarMcpServer({ fetchWithPayment: mockFetch }); + const { callTool } = getHandlers(server); - const response = await handler({ - params: { - name: 'paid_call', - arguments: { - url: 'http://example.com/paid' + const response = await callTool({ + params: { + name: 'paid_call', + arguments: { + url: 'http://example.com/paid' + } } - } + }); + + expect(response.isError).toBeFalsy(); + const parsedText = JSON.parse(response.content[0].text); + expect(parsedText.success).toBe(true); }); - expect(response.isError).toBe(true); - const content = JSON.parse(response.content[0].text); - expect(content.code).toBe('ERR_EXPIRED_AUTHORISATION'); + it('should handle realistic upstream library Horizon error shapes resembling production SDK errors', async () => { + // Simulating Horizon transaction submission error object from @stellar/stellar-sdk + class HorizonTransactionError extends Error { + response: { status: number; data: { extras: { result_codes: { operations: string[] } } } }; + constructor() { + super('Horizon error: op_no_trust during transaction submission'); + this.name = 'HorizonTransactionError'; + this.response = { + status: 400, + data: { + extras: { + result_codes: { + operations: ['op_no_trust'] + } + } + } + }; + } + } + + const mockFetch = vi.fn().mockRejectedValue(new HorizonTransactionError()); + const server = new BazaarMcpServer({ fetchWithPayment: mockFetch }); + const { callTool } = getHandlers(server); + + const response = await callTool({ + params: { + name: 'paid_call', + arguments: { + url: 'https://bazaar.example.org/api/v1/resource' + } + } + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_MISSING_TRUSTLINE'); + expect(content.reason).toContain('Missing trustline'); + }); }); - it('should map missing trustline error to ERR_MISSING_TRUSTLINE', async () => { - const mockFetch = vi.fn().mockRejectedValue(new Error('op_no_trust: The account does not have a trustline for this asset')); - const server = new BazaarMcpServer({ fetchWithPayment: mockFetch }); + describe('ListTools schema validation (Issue #421)', () => { + it('should exercise ListTools and validate schema structure for each advertised tool', async () => { + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn() }); + const { listTools } = getHandlers(server); + + const result = await listTools(); + expect(result).toHaveProperty('tools'); + expect(Array.isArray(result.tools)).toBe(true); + expect(result.tools.length).toBeGreaterThanOrEqual(2); + + const toolNames = result.tools.map((t: any) => t.name); + expect(toolNames).toContain('bazaar_search'); + expect(toolNames).toContain('paid_call'); - let handler: any = null; - (server as any).server.setRequestHandler = (schema: any, fn: any) => { - if (schema === CallToolRequestSchema) { - handler = fn; + for (const tool of result.tools) { + expect(typeof tool.name).toBe('string'); + expect(tool.name.length).toBeGreaterThan(0); + expect(typeof tool.description).toBe('string'); + expect(tool.description.length).toBeGreaterThan(0); + + // Validate inputSchema structure + const schema = tool.inputSchema; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + expect(schema.properties).toBeDefined(); + expect(typeof schema.properties).toBe('object'); + expect(Array.isArray(schema.required)).toBe(true); + expect(schema.required).toContain('url'); + expect(schema.properties.url).toBeDefined(); + expect(schema.properties.url.type).toBe('string'); } - }; - (server as any).setupHandlers(); + }); + }); + + describe('bazaar_search execution paths (Issue #414 & #421)', () => { + it('should query discovery search endpoint when query argument is provided', async () => { + const mockData = { + resources: [ + { id: 'res-1', title: 'Stellar DEX Feeds', endpoint: '/feeds/sdex' } + ] + }; + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify(mockData), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ); + + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn() }); + const { callTool } = getHandlers(server); - const response = await handler({ - params: { - name: 'paid_call', - arguments: { - url: 'http://example.com/paid' + const response = await callTool({ + params: { + name: 'bazaar_search', + arguments: { + url: 'https://bazaar.stellar.org/', + query: 'sdex feeds' + } } - } + }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const calledUrl = fetchSpy.mock.calls[0][0].toString(); + expect(calledUrl).toBe('https://bazaar.stellar.org/discovery/search?q=sdex%20feeds'); + + expect(response.isError).toBeFalsy(); + const content = JSON.parse(response.content[0].text); + expect(content.resources).toHaveLength(1); + expect(content.resources[0].id).toBe('res-1'); }); - expect(response.isError).toBe(true); - const content = JSON.parse(response.content[0].text); - expect(content.code).toBe('ERR_MISSING_TRUSTLINE'); - }); + it('should query discovery resources endpoint when no query argument is provided', async () => { + const mockData = [ + { id: 'res-all', name: 'General Resource' } + ]; + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify(mockData), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ); - it('should map 402 rejected error to ERR_PAYMENT_REJECTED', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: false, - status: 402, - text: async () => 'Payment Required' + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn() }); + const { callTool } = getHandlers(server); + + const response = await callTool({ + params: { + name: 'bazaar_search', + arguments: { + url: 'https://bazaar.stellar.org' + } + } + }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const calledUrl = fetchSpy.mock.calls[0][0].toString(); + expect(calledUrl).toBe('https://bazaar.stellar.org/discovery/resources'); + + expect(response.isError).toBeFalsy(); + const content = JSON.parse(response.content[0].text); + expect(content).toHaveLength(1); + expect(content[0].id).toBe('res-all'); }); - - const server = new BazaarMcpServer({ fetchWithPayment: mockFetch }); - let handler: any = null; - (server as any).server.setRequestHandler = (schema: any, fn: any) => { - if (schema === CallToolRequestSchema) { - handler = fn; - } - }; - (server as any).setupHandlers(); + it('should return a SUCCESS response with empty collection when results array is empty (Issue #414)', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify([]), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ); - const response = await handler({ - params: { - name: 'paid_call', - arguments: { - url: 'http://example.com/paid' + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn() }); + const { callTool } = getHandlers(server); + + const response = await callTool({ + params: { + name: 'bazaar_search', + arguments: { + url: 'https://bazaar.stellar.org', + query: 'nonexistent-service' + } } - } + }); + + // Must succeed and not report as an error + expect(response.isError).toBeFalsy(); + const content = JSON.parse(response.content[0].text); + expect(content).toEqual({ + count: 0, + resources: [] + }); + }); + + it('should return a SUCCESS response with empty collection when resources property is empty array (Issue #414)', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ total: 0, resources: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ); + + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn() }); + const { callTool } = getHandlers(server); + + const response = await callTool({ + params: { + name: 'bazaar_search', + arguments: { + url: 'https://bazaar.stellar.org', + query: 'unknown-query' + } + } + }); + + // Distinguishes "none found" (success) from "could not tell" (error) + expect(response.isError).toBeFalsy(); + const content = JSON.parse(response.content[0].text); + expect(content.count).toBe(0); + expect(content.resources).toEqual([]); }); - expect(response.isError).toBe(true); - const content = JSON.parse(response.content[0].text); - expect(content.code).toBe('ERR_PAYMENT_REJECTED'); + it('should report structured error when remote service returns non-2xx status', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('Internal Server Error', { + status: 500, + statusText: 'Internal Server Error' + }) + ); + + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn() }); + const { callTool } = getHandlers(server); + + const response = await callTool({ + params: { + name: 'bazaar_search', + arguments: { + url: 'https://bazaar.stellar.org' + } + } + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_SERVICE_UNREACHABLE'); + expect(content.reason).toContain('Service returned 500'); + }); + + it('should report structured error when response body is malformed or unparseable', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('502 Bad Gateway', { + status: 200, // 200 with non-JSON HTML body + headers: { 'Content-Type': 'text/html' } + }) + ); + + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn() }); + const { callTool } = getHandlers(server); + + const response = await callTool({ + params: { + name: 'bazaar_search', + arguments: { + url: 'https://bazaar.stellar.org' + } + } + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_SERVICE_UNREACHABLE'); + }); + + it('should report structured error when network connection fails', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('getaddrinfo ENOTFOUND bazaar.stellar.org')); + + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn() }); + const { callTool } = getHandlers(server); + + const response = await callTool({ + params: { + name: 'bazaar_search', + arguments: { + url: 'https://bazaar.stellar.org' + } + } + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_SERVICE_UNREACHABLE'); + expect(content.reason).toContain('ENOTFOUND'); + }); }); - - it('should return successfully on a successful call', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - text: async () => JSON.stringify({ success: true, data: "hello" }) + + describe('Unknown tool handling and bounded input sanitization (Issue #415)', () => { + it('should return a structured error with valid tool names instead of throwing raw exception', async () => { + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn() }); + const { callTool } = getHandlers(server); + + // Must not throw an unhandled exception + const response = await callTool({ + params: { + name: 'invalid_tool_name', + arguments: {} + } + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_UNKNOWN_TOOL'); + expect(content.reason).toContain('Unknown tool "invalid_tool_name"'); + expect(content.reason).toContain('Valid tools are: bazaar_search, paid_call'); }); - - const server = new BazaarMcpServer({ fetchWithPayment: mockFetch }); - let handler: any = null; - (server as any).server.setRequestHandler = (schema: any, fn: any) => { - if (schema === CallToolRequestSchema) { - handler = fn; - } - }; - (server as any).setupHandlers(); + it('should bound length and sanitize control characters from untrusted tool names', async () => { + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn() }); + const { callTool } = getHandlers(server); - const response = await handler({ - params: { - name: 'paid_call', - arguments: { - url: 'http://example.com/paid' + const excessivelyLongName = 'x'.repeat(200) + '\x00\x1F\x7Fmalicious_payload'; + const response = await callTool({ + params: { + name: excessivelyLongName, + arguments: {} } + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_UNKNOWN_TOOL'); + // Must not include control chars + expect(content.reason).not.toContain('\x00'); + expect(content.reason).not.toContain('\x1F'); + expect(content.reason).not.toContain('\x7F'); + // Name in quotes must be bounded to at most 64 characters + const match = content.reason.match(/Unknown tool "([^"]+)"/); + expect(match).toBeTruthy(); + if (match) { + expect(match[1].length).toBeLessThanOrEqual(64); } }); - expect(response.isError).toBeFalsy(); - const parsedText = JSON.parse(response.content[0].text); - expect(parsedText.success).toBe(true); + it('should catch unexpected handler throws and return structured ERR_INTERNAL_FAILURE', async () => { + const server = new BazaarMcpServer({ fetchWithPayment: vi.fn() }); + const { callTool } = getHandlers(server); + + // Simulating edge case where params is missing or triggers property access fault + const response = await callTool({ + get params(): any { + throw new Error('Simulated runtime property access error'); + } + }); + + expect(response.isError).toBe(true); + const content = JSON.parse(response.content[0].text); + expect(content.code).toBe('ERR_INTERNAL_FAILURE'); + expect(content.reason).toContain('Simulated runtime property access error'); + }); }); });