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
43 changes: 36 additions & 7 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { ssrfSafeFetch, SsrfError, type SsrfFetchOptions } from '../net/ssrf.js'
import { defaultResources, snapshotResource, type LensResourceDescriptor } from './resources.js';
import { readFileSync } from 'fs';
import { join } from 'path';
import { mcp_tool_calls_total, mcp_tool_duration_seconds, mcp_errors_total } from '../metrics.js';

/**
* The version advertised to MCP clients, read from `package.json` so it cannot
Expand Down Expand Up @@ -92,6 +93,7 @@ function hostOf(url: string): string {
export const VALID_TOOL_NAMES = ['bazaar_search', 'paid_call'] as const;
export type ValidToolName = (typeof VALID_TOOL_NAMES)[number];


export interface McpServerConfig {
fetchWithPayment: typeof fetch;
/**
Expand Down Expand Up @@ -442,7 +444,18 @@ export class BazaarMcpServer {
});

target.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === LENS_PRICE_TOOL_NAME) {
const toolName = request.params.name;
const tool =
(VALID_TOOL_NAMES as readonly string[]).includes(toolName) ||
toolName === LENS_PRICE_TOOL_NAME ||
toolName === 'payment_budget'
? toolName
: 'unknown';
const stopTimer = mcp_tool_duration_seconds.startTimer({ tool });

const execute = async () => {
if (request.params.name === LENS_PRICE_TOOL_NAME) {

if (!this.priceQueries) {
return this.createErrorResponse(
'ERR_INTERNAL_FAILURE',
Expand Down Expand Up @@ -684,15 +697,29 @@ export class BazaarMcpServer {
}
}

const rawName = String(request.params.name ?? '');
const boundedName = rawName.slice(0, 64).replace(/[\r\n\t]/g, ' ');
return this.createErrorResponse(
'ERR_UNKNOWN_TOOL',
`Unknown tool '${boundedName}'. Valid tools are: ${VALID_TOOL_NAMES.join(', ')}.`
);
const rawName = String(request.params.name ?? '');
const boundedName = rawName.slice(0, 64).replace(/[\r\n\t]/g, ' ');
return this.createErrorResponse(
'ERR_UNKNOWN_TOOL',
`Unknown tool '${boundedName}'. Valid tools are: ${VALID_TOOL_NAMES.join(', ')}.`
);
};

try {
const result = await execute();
const outcome = 'isError' in result && result.isError ? 'error' : 'success';
mcp_tool_calls_total.inc({ tool, outcome });
stopTimer();
return result;
} catch (err: any) {
mcp_tool_calls_total.inc({ tool, outcome: 'error' });
stopTimer();
throw err;
}
});
}


/**
* Clamp a caller-requested page to the operator's bounds. Zod has already
* rejected non-positive or non-integer values; this is the ceiling an agent
Expand Down Expand Up @@ -787,6 +814,8 @@ export class BazaarMcpServer {
}

private createErrorResponse(code: McpErrorCode, reason: string, details?: Record<string, unknown>) {
mcp_errors_total.inc({ code });

// For MCP, returning isError: true with a JSON payload is the standard way to return tool errors.
const payload = {
isError: true,
Expand Down
27 changes: 27 additions & 0 deletions src/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,33 @@ export function setPoolMetricsUpdater(updater: () => void) {
_poolMetricsUpdater = updater
}

// MCP metrics
// All label sets are closed to prevent high-cardinality label explosion.
// `tool` is restricted to known tool names ('bazaar_search', 'paid_call', 'lens_price') or 'unknown'.
// `outcome` is 'success' or 'error'.
// `code` is restricted to MCP_ERROR_CODES values.
export const mcp_tool_calls_total = new Counter({
name: 'mcp_tool_calls_total',
help: 'Total number of MCP tool calls served, by tool name and outcome',
labelNames: ['tool', 'outcome'],
registers: [register]
})

export const mcp_tool_duration_seconds = new Histogram({
name: 'mcp_tool_duration_seconds',
help: 'Duration of MCP tool calls in seconds, by tool name',
labelNames: ['tool'],
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
registers: [register]
})

export const mcp_errors_total = new Counter({
name: 'mcp_errors_total',
help: 'Total number of MCP tool errors, by error code',
labelNames: ['code'],
registers: [register]
})

export async function getMetrics() {
if (_poolMetricsUpdater) {
try {
Expand Down
77 changes: 77 additions & 0 deletions tests/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ import {
import { PAID_CALL_HTTP_METHODS } from '../src/mcp/toolSchemas';
import { readFileSync } from 'fs';
import { join } from 'path';
import {
mcp_tool_calls_total,
mcp_tool_duration_seconds,
mcp_errors_total,
getMetrics,
} from '../src/metrics';

const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8')) as { version: string };

Expand Down Expand Up @@ -1641,4 +1647,75 @@ describe('payment audit ledger', () => {
expect(ledger.records()).toEqual([]);
expect(ledger.totalSpend().attempts).toBe(0);
});

it('should record mcp_tool_calls_total, duration, and error metrics on tool execution', async () => {
const mockSuccess = vi.fn().mockResolvedValue({
ok: true,
status: 200,
text: async () => JSON.stringify({ success: true })
});

const mock402 = vi.fn().mockResolvedValue({
ok: false,
status: 402,
text: async () => 'Payment Required'
});

const serverSuccess = new BazaarMcpServer({ fetchWithPayment: mockSuccess });
let handlerSuccess: any = null;
(serverSuccess as any).server.setRequestHandler = (schema: any, fn: any) => {
if (schema === CallToolRequestSchema) {
handlerSuccess = fn;
}
};
(serverSuccess as any).setupHandlers();

// 1. Success tool call
await handlerSuccess({
params: {
name: 'paid_call',
arguments: { url: 'http://example.com/paid' }
}
});

// 2. Error tool call
const serverError = new BazaarMcpServer({ fetchWithPayment: mock402 });
let handlerError: any = null;
(serverError as any).server.setRequestHandler = (schema: any, fn: any) => {
if (schema === CallToolRequestSchema) {
handlerError = fn;
}
};
(serverError as any).setupHandlers();

await handlerError({
params: {
name: 'paid_call',
arguments: { url: 'http://example.com/paid' }
}
});

// 3. Unknown tool call
const unknownRes = await handlerError({
params: {
name: 'unknown_tool_xyz',
arguments: {}
}
});
expect(unknownRes.isError).toBe(true);
const parsedUnknown = JSON.parse(unknownRes.content[0].text);
expect(parsedUnknown.code).toBe('ERR_UNKNOWN_TOOL');

// 4. Verify metrics in registry output
const metricsOutput = await getMetrics();
expect(metricsOutput).toContain('mcp_tool_calls_total');
expect(metricsOutput).toContain('tool="paid_call"');
expect(metricsOutput).toContain('outcome="success"');
expect(metricsOutput).toContain('outcome="error"');
expect(metricsOutput).toContain('tool="unknown"');
expect(metricsOutput).toContain('mcp_tool_duration_seconds');
expect(metricsOutput).toContain('mcp_errors_total');
expect(metricsOutput).toContain('code="ERR_PAYMENT_REJECTED"');
expect(metricsOutput).toContain('code="ERR_UNKNOWN_TOOL"');
});
});
Loading