From b82e9112683b198e4f2e6315ebc099081d7827d0 Mon Sep 17 00:00:00 2001 From: rupesh-kumar-sah Date: Sat, 17 Oct 2026 15:22:57 +0545 Subject: [PATCH] feat(mcp): add Prometheus metrics for tool calls, durations, and errors Previously, src/metrics.ts had no visibility into the MCP server surface. Add Prometheus metrics registered on the shared registry: - mcp_tool_calls_total (Counter): counts tool calls by tool name ('bazaar_search', 'paid_call', 'unknown') and outcome ('success', 'error') - mcp_tool_duration_seconds (Histogram): records execution duration per tool with bounded buckets - mcp_errors_total (Counter): counts errors partitioned by MCP_ERROR_CODES values - All labels come from strictly closed, bounded sets to prevent cardinality explosion Closes #425 --- src/mcp/server.ts | 43 +++++++++++++++++++++----- src/metrics.ts | 27 +++++++++++++++++ tests/mcp.test.ts | 77 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 7 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 4e3ead34..13eb9d94 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -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 @@ -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; /** @@ -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', @@ -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 @@ -787,6 +814,8 @@ export class BazaarMcpServer { } private createErrorResponse(code: McpErrorCode, reason: string, details?: Record) { + 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, diff --git a/src/metrics.ts b/src/metrics.ts index 8550de3f..cf58cccc 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -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 { diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 1cdd791d..dbd23dc2 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -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 }; @@ -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"'); + }); });