Skip to content
Closed
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
8 changes: 3 additions & 5 deletions src/mcp/codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,8 +28,10 @@ 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<string, unknown>;
validTools?: readonly string[];
}

/**
Expand Down
23 changes: 19 additions & 4 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down Expand Up @@ -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
);
});
}

Expand All @@ -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
Expand Down Expand Up @@ -567,13 +575,20 @@ export class BazaarMcpServer {
);
}

private createErrorResponse(code: McpErrorCode, reason: string, details?: Record<string, unknown>) {
private createErrorResponse(
code: McpErrorCode,
reason: string,
details?: Record<string, unknown> | 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 } : {}),
...(extraDetails ? { details: extraDetails } : {}),
...(validTools ? { validTools } : {}),
};
return {
content: [{ type: 'text', text: JSON.stringify(payload) }],
Expand Down
66 changes: 66 additions & 0 deletions tests/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Loading