Skip to content
Open
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
2 changes: 2 additions & 0 deletions src/mcp/codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,4 +26,5 @@ export interface McpErrorResponse {
isError: true;
code: McpErrorCode;
reason: string;
validTools?: readonly string[];
}
13 changes: 11 additions & 2 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down Expand Up @@ -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
);
});
}

Expand Down Expand Up @@ -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) }],
Expand Down
67 changes: 67 additions & 0 deletions tests/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Loading