diff --git a/package-lock.json b/package-lock.json index 38c6259..ccff495 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@fastify/pre-commit": "^2.2.0", "@modelcontextprotocol/inspector": "^0.22.0", "@modelcontextprotocol/sdk": "^1.13.3", + "@opentelemetry/api": "^1.9.0", "@sinclair/typebox": "^0.34.37", "@types/node": "^24.0.10", "eslint": "^9.30.0", @@ -37,7 +38,13 @@ "undici": "^7.11.0" }, "peerDependencies": { + "@opentelemetry/api": ">=1.0.0", "@sinclair/typebox": "^0.34.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } } }, "node_modules/@babel/code-frame": { @@ -928,6 +935,7 @@ "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", @@ -1001,6 +1009,16 @@ "node": ">= 8" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@oven/bun-darwin-aarch64": { "version": "1.3.9", "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.9.tgz", @@ -2323,6 +2341,7 @@ "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -2397,6 +2416,7 @@ "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", @@ -2661,6 +2681,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4096,6 +4117,7 @@ "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5398,6 +5420,7 @@ "integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -7698,6 +7721,7 @@ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -7711,6 +7735,7 @@ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -9369,6 +9394,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9830,6 +9856,7 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 9e2b75d..57b7e3e 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@fastify/pre-commit": "^2.2.0", "@modelcontextprotocol/inspector": "^0.22.0", "@modelcontextprotocol/sdk": "^1.13.3", + "@opentelemetry/api": "^1.9.0", "@sinclair/typebox": "^0.34.37", "@types/node": "^24.0.10", "eslint": "^9.30.0", @@ -62,8 +63,14 @@ "safe-stable-stringify": "^2.5.0" }, "peerDependencies": { + "@opentelemetry/api": ">=1.0.0", "@sinclair/typebox": "^0.34.0" }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + }, "files": [ "dist", "examples", diff --git a/src/handlers.ts b/src/handlers.ts index 50db4c6..ed6add1 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -31,7 +31,7 @@ import { } from './schema.ts' import type { RequestId } from './schema.ts' -import type { MCPTool, MCPResource, MCPPrompt, MCPPluginOptions, ResourceHandlers, McpCallToolOutcome, ToolAccessOperation, MCPToolCallCompleteEvent } from './types.ts' +import type { MCPTool, MCPResource, MCPPrompt, MCPPluginOptions, ResourceHandlers, McpCallToolOutcome, ToolAccessOperation, MCPToolCallCompleteEvent, TracerLike } from './types.ts' import type { SessionStore } from './stores/session-store.ts' import type { TaskStore, TaskRecord, TaskWaiters } from './stores/task-store.ts' import { isTerminal, toWireTask } from './stores/task-store.ts' @@ -45,8 +45,16 @@ import { import { validate, CallToolRequestSchema, ReadResourceRequestSchema, GetPromptRequestSchema, isTypeBoxSchema } from './validation/index.ts' import type { JsonSchemaValidator } from './validation/json-schema-validator.ts' import { sanitizeToolParams, assessToolSecurity } from './security.ts' +import { MCP_ATTR, type SpanAttributeValue } from './telemetry-constants.ts' -type HandlerDependencies = { +// Lazy-loaded telemetry module — only imported when a tracer is configured +let _telemetry: typeof import('./telemetry.ts') | undefined +async function getTelemetry () { + if (!_telemetry) _telemetry = await import('./telemetry.ts') + return _telemetry +} + +export type HandlerDependencies = { app: FastifyInstance opts: MCPPluginOptions capabilities: any @@ -58,6 +66,7 @@ type HandlerDependencies = { request: FastifyRequest reply: FastifyReply authContext?: AuthorizationContext + tracer?: TracerLike sessionStore?: SessionStore taskStore?: TaskStore taskWaiters?: TaskWaiters @@ -311,7 +320,12 @@ async function handleToolsCall ( // the model cannot correct itself out of missing access. const resolved = await resolveRegisteredTool(toolName, dependencies) if (!resolved.ok) { - await emitToolCallComplete('json-rpc', toolName, params.arguments || {}, resolved, startedAt, dependencies) + // Keep the JSON-RPC observer aligned with the privacy-preserving protocol + // response: denied registered tools are indistinguishable from unknown ones. + const observedOutcome: McpCallToolOutcome = resolved.reason === 'access-denied' + ? { ok: false, reason: 'not-found' } + : resolved + await emitToolCallComplete('json-rpc', toolName, params.arguments || {}, observedOutcome, startedAt, dependencies) return toolCallOutcomeToJsonRpc(request.id, toolName, resolved) } @@ -1315,6 +1329,88 @@ async function handleResourcesUnsubscribe ( } } +const STDIO_TRANSPORT_HEADER = 'x-platformatic-mcp-transport' + +function mcpContextCarrier (params: unknown): Record | undefined { + if (typeof params !== 'object' || params === null || !('_meta' in params)) return undefined + const meta = params._meta + if (typeof meta !== 'object' || meta === null) return undefined + + const carrier: Record = {} + for (const [key, value] of Object.entries(meta)) { + if (typeof value === 'string' || (Array.isArray(value) && value.every(item => typeof item === 'string'))) { + carrier[key] = value as string | string[] + } + } + return Object.keys(carrier).length > 0 ? carrier : undefined +} + +function normalizedNetworkProtocolVersion (version: string): string { + return version.endsWith('.0') ? version.slice(0, -2) : version +} + +async function withMcpServerSpan ( + message: JSONRPCRequest | JSONRPCNotification, + sessionId: string | undefined, + dependencies: HandlerDependencies, + fn: () => Promise +): Promise { + const { tracer, request } = dependencies + if (!tracer) return fn() + + const params = message.params as any + const extraAttrs: Record = {} + let target: string | undefined + + if ('id' in message && message.id !== null && message.id !== undefined) { + extraAttrs[MCP_ATTR.JSONRPC_REQUEST_ID] = String(message.id) + } + + if (message.method === 'tools/call' && params?.name) { + target = params.name + extraAttrs[MCP_ATTR.TOOL_NAME] = params.name + extraAttrs[MCP_ATTR.OPERATION_NAME] = 'execute_tool' + } + if (message.method === 'prompts/get' && params?.name) { + target = params.name + extraAttrs[MCP_ATTR.PROMPT_NAME] = params.name + } + if (['resources/read', 'resources/subscribe', 'resources/unsubscribe', 'notifications/resources/updated'].includes(message.method) && params?.uri) { + extraAttrs[MCP_ATTR.RESOURCE_URI] = params.uri + } + + const protocolVersion = message.method === 'initialize' + ? negotiateProtocolVersion(params?.protocolVersion) + : dependencies.protocolVersion + if (protocolVersion) extraAttrs[MCP_ATTR.PROTOCOL_VERSION] = protocolVersion + + const isStdio = request.headers[STDIO_TRANSPORT_HEADER] === 'stdio' + if (isStdio) { + extraAttrs[MCP_ATTR.NETWORK_TRANSPORT] = 'pipe' + } else { + const httpVersion = request.raw.httpVersion + extraAttrs[MCP_ATTR.NETWORK_TRANSPORT] = httpVersion.startsWith('3') ? 'quic' : 'tcp' + extraAttrs[MCP_ATTR.NETWORK_PROTOCOL_NAME] = 'http' + extraAttrs[MCP_ATTR.NETWORK_PROTOCOL_VERSION] = normalizedNetworkProtocolVersion(httpVersion) + if (request.ip) extraAttrs[MCP_ATTR.CLIENT_ADDRESS] = request.ip + if (request.socket.remotePort !== undefined) extraAttrs[MCP_ATTR.CLIENT_PORT] = request.socket.remotePort + } + + const { withSpan, buildSpanAttributes } = await getTelemetry() + const spanName = target ? `${message.method} ${target}` : message.method + return withSpan( + tracer, + spanName, + buildSpanAttributes(message.method, sessionId, extraAttrs), + fn, + { + kind: 'server', + carrier: mcpContextCarrier(params), + recordMcpResponse: true + } + ) +} + export async function handleRequest ( request: JSONRPCRequest, sessionId: string | undefined, @@ -1329,45 +1425,47 @@ export async function handleRequest ( }, `JSON-RPC method invoked: ${request.method}`) try { - switch (request.method) { - case 'initialize': - return await handleInitialize(request, sessionId, dependencies) - case 'ping': - return handlePing(request) - case 'tools/list': - return await handleToolsList(request, dependencies) - case 'resources/list': - return handleResourcesList(request, dependencies) - case 'resources/templates/list': - return handleResourceTemplatesList(request, dependencies) - case 'prompts/list': - return handlePromptsList(request, dependencies) - case 'tools/call': - return await handleToolsCall(request, sessionId, dependencies) - case 'resources/read': - return await handleResourcesRead(request, sessionId, dependencies) - case 'resources/subscribe': - return await handleResourcesSubscribe(request, sessionId, dependencies) - case 'resources/unsubscribe': - return await handleResourcesUnsubscribe(request, sessionId, dependencies) - case 'prompts/get': - return await handlePromptsGet(request, sessionId, dependencies) - case 'tasks/get': - case 'tasks/result': - case 'tasks/list': - case 'tasks/cancel': - // Tasks arrived in 2025-11-25; to an older client these methods simply - // do not exist, and we never advertised them. - if (!supportsTasks(dependencies.protocolVersion)) { + return await withMcpServerSpan(request, sessionId, dependencies, async () => { + switch (request.method) { + case 'initialize': + return await handleInitialize(request, sessionId, dependencies) + case 'ping': + return handlePing(request) + case 'tools/list': + return await handleToolsList(request, dependencies) + case 'resources/list': + return handleResourcesList(request, dependencies) + case 'resources/templates/list': + return handleResourceTemplatesList(request, dependencies) + case 'prompts/list': + return handlePromptsList(request, dependencies) + case 'tools/call': + return await handleToolsCall(request, sessionId, dependencies) + case 'resources/read': + return await handleResourcesRead(request, sessionId, dependencies) + case 'resources/subscribe': + return await handleResourcesSubscribe(request, sessionId, dependencies) + case 'resources/unsubscribe': + return await handleResourcesUnsubscribe(request, sessionId, dependencies) + case 'prompts/get': + return await handlePromptsGet(request, sessionId, dependencies) + case 'tasks/get': + case 'tasks/result': + case 'tasks/list': + case 'tasks/cancel': + // Tasks arrived in 2025-11-25; to an older client these methods simply + // do not exist, and we never advertised them. + if (!supportsTasks(dependencies.protocolVersion)) { + return createError(request.id, METHOD_NOT_FOUND, `Method ${request.method} not found`) + } + if (request.method === 'tasks/get') return await handleTasksGet(request, dependencies) + if (request.method === 'tasks/result') return await handleTasksResult(request, dependencies) + if (request.method === 'tasks/list') return await handleTasksList(request, dependencies) + return await handleTasksCancel(request, dependencies) + default: return createError(request.id, METHOD_NOT_FOUND, `Method ${request.method} not found`) - } - if (request.method === 'tasks/get') return await handleTasksGet(request, dependencies) - if (request.method === 'tasks/result') return await handleTasksResult(request, dependencies) - if (request.method === 'tasks/list') return await handleTasksList(request, dependencies) - return await handleTasksCancel(request, dependencies) - default: - return createError(request.id, METHOD_NOT_FOUND, `Method ${request.method} not found`) - } + } + }) } catch (error) { return createError(request.id, INTERNAL_ERROR, 'Internal server error', error) } @@ -1400,8 +1498,10 @@ export async function processMessage ( if ('id' in message && 'method' in message) { return await handleRequest(message as JSONRPCRequest, sessionId, dependencies) } else if ('method' in message) { - handleNotification(message as JSONRPCNotification, dependencies.app) - return null + return await withMcpServerSpan(message as JSONRPCNotification, sessionId, dependencies, async () => { + handleNotification(message as JSONRPCNotification, dependencies.app) + return null + }) } else { throw new Error('Invalid JSON-RPC message') } diff --git a/src/index.ts b/src/index.ts index a077ca2..3182e05 100644 --- a/src/index.ts +++ b/src/index.ts @@ -296,9 +296,15 @@ export type { SSESession, ResourceHandlers, ResourceSubscribeHandler, - ResourceUnsubscribeHandler + ResourceUnsubscribeHandler, + TracerLike } from './types.ts' +// Export telemetry utilities for advanced consumers +export { MCP_ATTR, buildSpanAttributes } from './telemetry-constants.ts' +export { withSpan } from './telemetry.ts' +export type { HandlerDependencies } from './handlers.ts' + // Export authorization types export type { AuthorizationConfig, diff --git a/src/routes/mcp.ts b/src/routes/mcp.ts index cb727ed..280c509 100644 --- a/src/routes/mcp.ts +++ b/src/routes/mcp.ts @@ -340,6 +340,7 @@ const mcpPubSubRoutesPlugin: FastifyPluginAsync = async request, reply, authContext, + tracer: opts.telemetry?.tracer, sessionStore, taskStore, taskWaiters, diff --git a/src/stdio.ts b/src/stdio.ts index 24361e3..2feb79f 100644 --- a/src/stdio.ts +++ b/src/stdio.ts @@ -211,7 +211,8 @@ export class StdioTransport { url: '/mcp', headers: { 'content-type': 'application/json', - accept: 'application/json' // Explicitly request JSON, not SSE + accept: 'application/json', // Explicitly request JSON, not SSE + 'x-platformatic-mcp-transport': 'stdio' }, payload: message }) diff --git a/src/telemetry-constants.ts b/src/telemetry-constants.ts new file mode 100644 index 0000000..0a27b9d --- /dev/null +++ b/src/telemetry-constants.ts @@ -0,0 +1,46 @@ +/** + * MCP semantic convention attribute keys. + * Source: https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/mcp.md + * + * Kept in a separate module with no @opentelemetry/api dependency so they can be + * imported statically by any module without pulling in OTel at runtime. + * + * MCP conventions are still in development. The stable JS semantic-conventions + * entry point does not export them, and the package recommends copying unstable + * definitions into instrumentation libraries instead of importing its incubating + * entry point. Keep this small local set aligned with the GenAI conventions repo. + */ +export const MCP_ATTR = { + ERROR_TYPE: 'error.type', + JSONRPC_REQUEST_ID: 'jsonrpc.request.id', + METHOD_NAME: 'mcp.method.name', + SESSION_ID: 'mcp.session.id', + PROTOCOL_VERSION: 'mcp.protocol.version', + RESOURCE_URI: 'mcp.resource.uri', + RPC_RESPONSE_STATUS_CODE: 'rpc.response.status_code', + CLIENT_ADDRESS: 'client.address', + CLIENT_PORT: 'client.port', + NETWORK_PROTOCOL_NAME: 'network.protocol.name', + NETWORK_PROTOCOL_VERSION: 'network.protocol.version', + NETWORK_TRANSPORT: 'network.transport', + OPERATION_NAME: 'gen_ai.operation.name', + TOOL_NAME: 'gen_ai.tool.name', + PROMPT_NAME: 'gen_ai.prompt.name' +} as const + +export type SpanAttributeValue = string | number | boolean + +/** + * Build span attributes for an MCP operation using semconv keys. + */ +export function buildSpanAttributes ( + methodName: string, + sessionId?: string, + extra?: Record +): Record { + return { + [MCP_ATTR.METHOD_NAME]: methodName, + ...(sessionId ? { [MCP_ATTR.SESSION_ID]: sessionId } : {}), + ...extra + } +} diff --git a/src/telemetry.ts b/src/telemetry.ts new file mode 100644 index 0000000..0a34204 --- /dev/null +++ b/src/telemetry.ts @@ -0,0 +1,109 @@ +import type { TracerLike } from './types.ts' +import { MCP_ATTR, type SpanAttributeValue } from './telemetry-constants.ts' + +export { MCP_ATTR, buildSpanAttributes } from './telemetry-constants.ts' + +let _otel: typeof import('@opentelemetry/api') | undefined + +export interface WithSpanOptions { + kind?: 'internal' | 'server' | 'client' + /** MCP `params._meta` carrier used to extract the remote MCP parent context. */ + carrier?: Record + /** Add JSON-RPC response status/error attributes defined by the MCP conventions. */ + recordMcpResponse?: boolean +} + +function isJsonRpcError (value: unknown): value is { error: { code: number, message?: string } } { + return typeof value === 'object' && value !== null && + 'error' in value && typeof value.error === 'object' && value.error !== null && + 'code' in value.error && typeof value.error.code === 'number' +} + +function isToolError (value: unknown): boolean { + if (typeof value !== 'object' || value === null || !('result' in value)) return false + const result = value.result + return typeof result === 'object' && result !== null && 'isError' in result && result.isError === true +} + +const NON_ERROR_JSONRPC_CODES = new Set([-32700, -32600, -32601, -32602, -32002]) + +function recordMcpResponse (span: any, result: unknown, SpanStatusCode: typeof import('@opentelemetry/api').SpanStatusCode): void { + if (isJsonRpcError(result)) { + const code = String(result.error.code) + span.setAttribute(MCP_ATTR.RPC_RESPONSE_STATUS_CODE, code) + if (!NON_ERROR_JSONRPC_CODES.has(result.error.code)) { + span.setAttribute(MCP_ATTR.ERROR_TYPE, code) + span.setStatus({ code: SpanStatusCode.ERROR, message: result.error.message }) + } + return + } + + if (isToolError(result)) { + span.setAttribute(MCP_ATTR.ERROR_TYPE, 'tool_error') + span.setStatus({ code: SpanStatusCode.ERROR }) + } +} + +function spanKind ( + kind: WithSpanOptions['kind'], + SpanKind: typeof import('@opentelemetry/api').SpanKind +): number { + if (kind === 'server') return SpanKind.SERVER + if (kind === 'client') return SpanKind.CLIENT + return SpanKind.INTERNAL +} + +/** + * Wraps `fn` in an active OTel span. If no tracer is provided, calls fn directly. + * `@opentelemetry/api` is loaded dynamically so it is never required at runtime + * for users who don't configure telemetry. + */ +export async function withSpan ( + tracer: TracerLike | undefined, + spanName: string, + attributes: Record, + fn: () => Promise, + options: WithSpanOptions = {} +): Promise { + if (!tracer) return fn() + + _otel ??= await import('@opentelemetry/api') + const otel = _otel + const ambientContext = otel.context.active() + let parentContext = ambientContext + const links: Array<{ context: any }> = [] + + if (options.carrier && Object.keys(options.carrier).length > 0) { + const extractedContext = otel.propagation.extract(ambientContext, options.carrier) + const extractedSpanContext = otel.trace.getSpanContext(extractedContext) + if (extractedSpanContext && otel.isSpanContextValid(extractedSpanContext)) { + const ambientSpanContext = otel.trace.getSpanContext(ambientContext) + if (ambientSpanContext && otel.isSpanContextValid(ambientSpanContext) && + (ambientSpanContext.traceId !== extractedSpanContext.traceId || ambientSpanContext.spanId !== extractedSpanContext.spanId)) { + links.push({ context: ambientSpanContext }) + } + parentContext = extractedContext + } + } + + const spanOptions = { + attributes, + kind: spanKind(options.kind, otel.SpanKind), + ...(links.length > 0 ? { links } : {}) + } + + return tracer.startActiveSpan(spanName, spanOptions, parentContext, async (span: any) => { + try { + const result = await fn() + if (options.recordMcpResponse) recordMcpResponse(span, result, otel.SpanStatusCode) + return result + } catch (err: any) { + span.recordException(err) + span.setAttribute(MCP_ATTR.ERROR_TYPE, err?.name ?? '_OTHER') + span.setStatus({ code: otel.SpanStatusCode.ERROR, message: err?.message ?? String(err) }) + throw err + } finally { + span.end() + } + }) +} diff --git a/src/types.ts b/src/types.ts index 53be26a..f106d7c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -181,6 +181,18 @@ export interface UnsafeMCPPrompt { handler?: UnsafePromptHandler } +/** + * Minimal tracer interface compatible with `@opentelemetry/api`'s `Tracer`. + * Defined locally so consumers don't need `@opentelemetry/api` installed just + * to import this package's types. Any real OTel `Tracer` satisfies this structurally. + * + * @see https://open-telemetry.github.io/opentelemetry-js/interfaces/_opentelemetry_api.Tracer.html + */ +export interface TracerLike { + startActiveSpan (name: string, options: any, fn: (span: any) => any): any + startActiveSpan (name: string, options: any, context: any, fn: (span: any) => any): any +} + /** * Which operation `canAccessTool` is deciding: `list` for `tools/list` * visibility, `call` for `tools/call` execution (including HTTP, @@ -337,6 +349,15 @@ export interface MCPPluginOptions { tls?: Record } authorization?: AuthorizationConfig + /** + * Optional OpenTelemetry instrumentation. + * Provide a Tracer to enable per-operation spans with MCP semantic convention attributes. + * Any `Tracer` from `@opentelemetry/api` satisfies `TracerLike`. + * `@opentelemetry/api` must be installed as a peer dependency when using this option. + */ + telemetry?: { + tracer: TracerLike + } } export interface SSESession { diff --git a/test/telemetry-integration.test.ts b/test/telemetry-integration.test.ts new file mode 100644 index 0000000..af90cfb --- /dev/null +++ b/test/telemetry-integration.test.ts @@ -0,0 +1,125 @@ +import { describe, it, mock } from 'node:test' +import assert from 'node:assert/strict' +import Fastify from 'fastify' +import { SpanKind, type Tracer, type Span } from '@opentelemetry/api' +import mcpPlugin from '../src/index.ts' +import { MCP_ATTR } from '../src/telemetry.ts' + +function makeSpan (): Span & { end: ReturnType, setStatus: ReturnType, recordException: ReturnType } { + return { + setAttribute: mock.fn(), + setStatus: mock.fn(), + recordException: mock.fn(), + end: mock.fn() + } as unknown as any +} + +function makeTracer (): { tracer: Tracer, spans: Span[], spanNames: string[], spanAttrs: Record[], spanKinds: number[] } { + const spans: Span[] = [] + const spanNames: string[] = [] + const spanAttrs: Record[] = [] + const spanKinds: number[] = [] + + const tracer: Tracer = { + startActiveSpan (name: string, opts: any, ...args: any[]) { + spanNames.push(name) + spanAttrs.push(opts?.attributes ?? {}) + spanKinds.push(opts?.kind) + const span = makeSpan() + spans.push(span) + const fn = args.at(-1) as (s: Span) => any + return fn(span) + } + } as unknown as Tracer + + return { tracer, spans, spanNames, spanAttrs, spanKinds } +} + +async function buildApp (tracer: Tracer) { + const app = Fastify({ logger: false }) + await app.register(mcpPlugin, { + telemetry: { tracer }, + capabilities: { tools: {}, resources: {}, prompts: {} } + }) + + app.mcpAddTool( + { name: 'echo', description: 'echo', inputSchema: { type: 'object', properties: { msg: { type: 'string' } } } }, + async ({ msg }: any) => ({ content: [{ type: 'text' as const, text: msg }] }) + ) + + await app.ready() + return app +} + +describe('telemetry integration', () => { + describe('tools/call', () => { + it('creates a span with gen_ai.tool.name attribute', async () => { + const { tracer, spanNames, spanAttrs, spanKinds, spans } = makeTracer() + const app = await buildApp(tracer) + + const res = await app.inject({ + method: 'POST', + url: '/mcp', + headers: { 'content-type': 'application/json' }, + payload: { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'echo', arguments: { msg: 'hi' } } } + }) + + assert.equal(res.statusCode, 200) + assert.ok(spanNames.includes('tools/call echo'), `expected tools/call span, got: ${spanNames}`) + const idx = spanNames.indexOf('tools/call echo') + assert.equal(spanKinds[idx], SpanKind.SERVER) + assert.equal(spanAttrs[idx][MCP_ATTR.METHOD_NAME], 'tools/call') + assert.equal(spanAttrs[idx][MCP_ATTR.TOOL_NAME], 'echo') + assert.equal(spanAttrs[idx][MCP_ATTR.OPERATION_NAME], 'execute_tool') + assert.equal(spanAttrs[idx][MCP_ATTR.JSONRPC_REQUEST_ID], '1') + assert.equal(spanAttrs[idx][MCP_ATTR.NETWORK_TRANSPORT], 'tcp') + assert.equal(spanAttrs[idx][MCP_ATTR.NETWORK_PROTOCOL_NAME], 'http') + assert.equal(typeof spanAttrs[idx][MCP_ATTR.PROTOCOL_VERSION], 'string') + assert.equal((spans[idx] as any).setStatus.mock.calls.length, 0) + assert.equal((spans[idx] as any).end.mock.calls.length, 1) + + await app.close() + }) + }) + + describe('tools/list', () => { + it('creates a span with mcp.method.name attribute', async () => { + const { tracer, spanNames, spanAttrs, spans } = makeTracer() + const app = await buildApp(tracer) + + await app.inject({ + method: 'POST', + url: '/mcp', + headers: { 'content-type': 'application/json' }, + payload: { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} } + }) + + assert.ok(spanNames.includes('tools/list'), `expected tools/list span, got: ${spanNames}`) + const idx = spanNames.indexOf('tools/list') + assert.equal(spanAttrs[idx][MCP_ATTR.METHOD_NAME], 'tools/list') + assert.equal((spans[idx] as any).end.mock.calls.length, 1) + + await app.close() + }) + }) + + describe('no tracer', () => { + it('processes requests normally without a tracer', async () => { + const app = Fastify({ logger: false }) + await app.register(mcpPlugin, { + capabilities: { tools: {}, resources: {}, prompts: {} } + }) + await app.ready() + + const res = await app.inject({ + method: 'POST', + url: '/mcp', + headers: { 'content-type': 'application/json' }, + payload: { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} } + }) + + assert.equal(res.statusCode, 200) + await app.close() + }) + }) +}) diff --git a/test/telemetry-types.test.ts b/test/telemetry-types.test.ts new file mode 100644 index 0000000..23c3912 --- /dev/null +++ b/test/telemetry-types.test.ts @@ -0,0 +1,17 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import type { MCPPluginOptions, TracerLike } from '../src/types.ts' + +describe('MCPPluginOptions telemetry', () => { + it('accepts optional telemetry config', () => { + const opts: MCPPluginOptions = { + telemetry: { tracer: {} as TracerLike } + } + assert.ok(opts.telemetry) + }) + + it('is optional', () => { + const opts: MCPPluginOptions = {} + assert.equal(opts.telemetry, undefined) + }) +}) diff --git a/test/telemetry.test.ts b/test/telemetry.test.ts new file mode 100644 index 0000000..b7738d7 --- /dev/null +++ b/test/telemetry.test.ts @@ -0,0 +1,140 @@ +import { describe, it, mock } from 'node:test' +import assert from 'node:assert/strict' +import { withSpan, buildSpanAttributes, MCP_ATTR } from '../src/telemetry.ts' +import type { Tracer, Span } from '@opentelemetry/api' +import { SpanKind, SpanStatusCode } from '@opentelemetry/api' + +function makeSpan (): Span & { + setAttribute: ReturnType + setStatus: ReturnType + recordException: ReturnType + end: ReturnType +} { + return { + setAttribute: mock.fn(), + setStatus: mock.fn(), + recordException: mock.fn(), + end: mock.fn() + } as unknown as any +} + +function makeTracer (span: Span, options?: any[]): Tracer { + return { + startActiveSpan: (_name: string, opts: any, ...args: any[]) => { + options?.push(opts) + const fn = args.at(-1) as (s: Span) => any + return fn(span) + } + } as unknown as Tracer +} + +describe('withSpan', () => { + it('calls fn and returns result when tracer provided', async () => { + const span = makeSpan() + const options: any[] = [] + const tracer = makeTracer(span, options) + + const result = await withSpan( + tracer, + 'tools/call', + { 'mcp.method.name': 'tools/call' }, + async () => 42, + { kind: 'server' } + ) + + assert.equal(result, 42) + assert.equal(span.end.mock.calls.length, 1) + assert.equal(span.setStatus.mock.calls.length, 0) + assert.equal(options[0].kind, SpanKind.SERVER) + }) + + it('records exception and rethrows on error', async () => { + const span = makeSpan() + const tracer = makeTracer(span) + const err = new Error('boom') + + await assert.rejects( + withSpan(tracer, 'tools/call', {}, async () => { throw err }), + /boom/ + ) + + assert.equal(span.recordException.mock.calls.length, 1) + assert.equal(span.recordException.mock.calls[0].arguments[0], err) + assert.equal((span.setStatus.mock.calls[0].arguments[0] as any).code, SpanStatusCode.ERROR) + assert.equal(span.end.mock.calls.length, 1) + }) + + it('calls fn directly when no tracer', async () => { + const result = await withSpan(undefined, 'tools/call', {}, async () => 'direct') + assert.equal(result, 'direct') + }) + + it('records JSON-RPC response errors without marking caller errors as span errors', async () => { + const span = makeSpan() + const tracer = makeTracer(span) + + await withSpan( + tracer, + 'tools/call', + {}, + async () => ({ jsonrpc: '2.0', id: 1, error: { code: -32602, message: 'Invalid params' } }), + { recordMcpResponse: true } + ) + + assert.deepEqual(span.setAttribute.mock.calls[0].arguments, [MCP_ATTR.RPC_RESPONSE_STATUS_CODE, '-32602']) + assert.equal(span.setStatus.mock.calls.length, 0) + }) + + it('marks tool errors as span errors', async () => { + const span = makeSpan() + const tracer = makeTracer(span) + + await withSpan( + tracer, + 'tools/call', + {}, + async () => ({ jsonrpc: '2.0', id: 1, result: { isError: true } }), + { recordMcpResponse: true } + ) + + assert.deepEqual(span.setAttribute.mock.calls[0].arguments, [MCP_ATTR.ERROR_TYPE, 'tool_error']) + assert.equal((span.setStatus.mock.calls[0].arguments[0] as any).code, SpanStatusCode.ERROR) + }) +}) + +describe('buildSpanAttributes', () => { + it('includes method name', () => { + const attrs = buildSpanAttributes('tools/call') + assert.equal(attrs[MCP_ATTR.METHOD_NAME], 'tools/call') + }) + + it('includes sessionId when provided', () => { + const attrs = buildSpanAttributes('tools/call', 'sess-123') + assert.equal(attrs[MCP_ATTR.SESSION_ID], 'sess-123') + }) + + it('omits sessionId when not provided', () => { + const attrs = buildSpanAttributes('tools/call') + assert.equal(attrs[MCP_ATTR.SESSION_ID], undefined) + }) + + it('merges extra attributes', () => { + const attrs = buildSpanAttributes('tools/call', undefined, { [MCP_ATTR.TOOL_NAME]: 'myTool' }) + assert.equal(attrs[MCP_ATTR.TOOL_NAME], 'myTool') + }) +}) + +describe('MCP_ATTR', () => { + it('has expected attribute keys', () => { + assert.equal(MCP_ATTR.METHOD_NAME, 'mcp.method.name') + assert.equal(MCP_ATTR.JSONRPC_REQUEST_ID, 'jsonrpc.request.id') + assert.equal(MCP_ATTR.SESSION_ID, 'mcp.session.id') + assert.equal(MCP_ATTR.PROTOCOL_VERSION, 'mcp.protocol.version') + assert.equal(MCP_ATTR.RESOURCE_URI, 'mcp.resource.uri') + assert.equal(MCP_ATTR.RPC_RESPONSE_STATUS_CODE, 'rpc.response.status_code') + assert.equal(MCP_ATTR.NETWORK_TRANSPORT, 'network.transport') + assert.equal(MCP_ATTR.OPERATION_NAME, 'gen_ai.operation.name') + assert.equal(MCP_ATTR.TOOL_NAME, 'gen_ai.tool.name') + assert.equal(MCP_ATTR.PROMPT_NAME, 'gen_ai.prompt.name') + }) +})