diff --git a/package-lock.json b/package-lock.json index 54aafb9..a561218 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@fastify/pre-commit": "^2.2.0", "@modelcontextprotocol/inspector": "^0.21.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", @@ -35,7 +36,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": { @@ -941,6 +948,7 @@ "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", @@ -1014,6 +1022,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", @@ -2237,7 +2255,8 @@ "version": "0.34.49", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@stylistic/eslint-plugin": { "version": "2.11.0", @@ -2335,6 +2354,7 @@ "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2409,6 +2429,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", @@ -2673,6 +2694,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4109,6 +4131,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5406,6 +5429,7 @@ "integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -7711,6 +7735,7 @@ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -7724,6 +7749,7 @@ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -9375,6 +9401,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9836,6 +9863,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 28fa497..259ea89 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@fastify/pre-commit": "^2.2.0", "@modelcontextprotocol/inspector": "^0.21.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", @@ -60,8 +61,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 ff33794..52d5f91 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -23,12 +23,20 @@ import { INVALID_PARAMS } from './schema.ts' -import type { MCPTool, MCPResource, MCPPrompt, MCPPluginOptions, ResourceHandlers } from './types.ts' +import type { MCPTool, MCPResource, MCPPrompt, MCPPluginOptions, ResourceHandlers, TracerLike } from './types.ts' import type { AuthorizationContext } from './types/auth-types.ts' import { validate, CallToolRequestSchema, ReadResourceRequestSchema, GetPromptRequestSchema, isTypeBoxSchema } from './validation/index.ts' import { sanitizeToolParams, assessToolSecurity, SECURITY_WARNINGS } from './security.ts' +import { MCP_ATTR } 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 @@ -40,6 +48,7 @@ type HandlerDependencies = { request: FastifyRequest reply: FastifyReply authContext?: AuthorizationContext + tracer?: TracerLike } export function createResponse (id: string | number, result: any): JSONRPCResponse { @@ -525,27 +534,43 @@ export async function handleRequest ( }, `JSON-RPC method invoked: ${request.method}`) try { + const { tracer } = dependencies + + // Build method-specific extra span attributes before dispatching + const extraAttrs: Record = {} + const params = request.params as any + if (request.method === 'tools/call' && params?.name) extraAttrs[MCP_ATTR.TOOL_NAME] = params.name + if (request.method === 'resources/read' && params?.uri) extraAttrs[MCP_ATTR.RESOURCE_URI] = params.uri + if (request.method === 'prompts/get' && params?.name) extraAttrs[MCP_ATTR.PROMPT_NAME] = params.name + + const wrap = tracer + ? async (fn: () => Promise) => { + const { withSpan, buildSpanAttributes } = await getTelemetry() + return withSpan(tracer, request.method, buildSpanAttributes(request.method, sessionId, extraAttrs), fn) + } + : async (fn: () => Promise) => fn() + switch (request.method) { case 'initialize': - return handleInitialize(request, dependencies) + return wrap(async () => handleInitialize(request, dependencies)) case 'ping': - return handlePing(request) + return wrap(async () => handlePing(request)) case 'tools/list': - return handleToolsList(request, dependencies) + return wrap(async () => handleToolsList(request, dependencies)) case 'resources/list': - return handleResourcesList(request, dependencies) + return wrap(async () => handleResourcesList(request, dependencies)) case 'prompts/list': - return handlePromptsList(request, dependencies) + return wrap(async () => handlePromptsList(request, dependencies)) case 'tools/call': - return await handleToolsCall(request, sessionId, dependencies) + return wrap(() => handleToolsCall(request, sessionId, dependencies)) case 'resources/read': - return await handleResourcesRead(request, sessionId, dependencies) + return wrap(() => handleResourcesRead(request, sessionId, dependencies)) case 'resources/subscribe': - return await handleResourcesSubscribe(request, sessionId, dependencies) + return wrap(() => handleResourcesSubscribe(request, sessionId, dependencies)) case 'resources/unsubscribe': - return await handleResourcesUnsubscribe(request, sessionId, dependencies) + return wrap(() => handleResourcesUnsubscribe(request, sessionId, dependencies)) case 'prompts/get': - return await handlePromptsGet(request, sessionId, dependencies) + return wrap(() => handlePromptsGet(request, sessionId, dependencies)) default: return createError(request.id, METHOD_NOT_FOUND, `Method ${request.method} not found`) } diff --git a/src/index.ts b/src/index.ts index 2b58bed..9471496 100644 --- a/src/index.ts +++ b/src/index.ts @@ -194,9 +194,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 abcf82d..6159786 100644 --- a/src/routes/mcp.ts +++ b/src/routes/mcp.ts @@ -189,7 +189,8 @@ const mcpPubSubRoutesPlugin: FastifyPluginAsync = async resourceHandlers, request, reply, - authContext + authContext, + tracer: opts.telemetry?.tracer }) if (response) { return response diff --git a/src/telemetry-constants.ts b/src/telemetry-constants.ts new file mode 100644 index 0000000..155e286 --- /dev/null +++ b/src/telemetry-constants.ts @@ -0,0 +1,42 @@ +/** + * MCP semantic convention attribute keys. + * Source: https://opentelemetry.io/docs/specs/semconv/registry/attributes/mcp/ + * + * 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. + * + * Why inlined instead of imported from `@opentelemetry/semantic-conventions`: + * - As of @opentelemetry/semantic-conventions 1.40.0, only METHOD_NAME, + * PROTOCOL_VERSION, RESOURCE_URI, and SESSION_ID are exported. TOOL_NAME and + * PROMPT_NAME are in the MCP spec but not yet in the JS semconv package, + * so mixing would force a partial import + hardcoded strings anyway. + * - MCP attrs live under `/experimental` in the semconv package. That export + * path is explicitly unstable — coupling to it would trade a stable set of + * six local strings for a drift risk on every semconv release. + * - These keys are stable in the MCP spec itself (the source of truth the + * semconv package tracks), so local drift is minimal. + * Revisit once all six attrs are exported from a stable semconv path. + */ +export const MCP_ATTR = { + METHOD_NAME: 'mcp.method.name', + SESSION_ID: 'mcp.session.id', + PROTOCOL_VERSION: 'mcp.protocol.version', + RESOURCE_URI: 'mcp.resource.uri', + TOOL_NAME: 'mcp.tool.name', + PROMPT_NAME: 'mcp.prompt.name' +} as const + +/** + * 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..c9c6217 --- /dev/null +++ b/src/telemetry.ts @@ -0,0 +1,40 @@ +import type { TracerLike } from './types.ts' + +export { MCP_ATTR, buildSpanAttributes } from './telemetry-constants.ts' + +// SpanStatusCode cached after first withSpan call — not re-imported per invocation +let _SpanStatusCode: typeof import('@opentelemetry/api').SpanStatusCode | undefined + +/** + * 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 +): Promise { + if (!tracer) return fn() + + if (!_SpanStatusCode) { + const otel = await import('@opentelemetry/api') + _SpanStatusCode = otel.SpanStatusCode + } + const SpanStatusCode = _SpanStatusCode + + return tracer.startActiveSpan(spanName, { attributes }, async (span: any) => { + try { + const result = await fn() + span.setStatus({ code: SpanStatusCode.OK }) + return result + } catch (err: any) { + span.recordException(err) + span.setStatus({ code: SpanStatusCode.ERROR, message: err?.message ?? String(err) }) + throw err + } finally { + span.end() + } + }) +} diff --git a/src/types.ts b/src/types.ts index 5dfb261..5552928 100644 --- a/src/types.ts +++ b/src/types.ts @@ -152,6 +152,17 @@ 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 +} + export interface MCPPluginOptions { serverInfo?: Implementation capabilities?: ServerCapabilities @@ -167,6 +178,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..d1c3b55 --- /dev/null +++ b/test/telemetry-integration.test.ts @@ -0,0 +1,115 @@ +import { describe, it, mock } from 'node:test' +import assert from 'node:assert/strict' +import Fastify from 'fastify' +import type { Tracer, 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[] } { + const spans: Span[] = [] + const spanNames: string[] = [] + const spanAttrs: Record[] = [] + + const tracer: Tracer = { + startActiveSpan (name: string, opts: any, fn: (s: Span) => any) { + spanNames.push(name) + spanAttrs.push(opts?.attributes ?? {}) + const span = makeSpan() + spans.push(span) + return fn(span) + } + } as unknown as Tracer + + return { tracer, spans, spanNames, spanAttrs } +} + +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 mcp.tool.name attribute', async () => { + const { tracer, spanNames, spanAttrs, 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'), `expected tools/call span, got: ${spanNames}`) + const idx = spanNames.indexOf('tools/call') + assert.equal(spanAttrs[idx][MCP_ATTR.METHOD_NAME], 'tools/call') + assert.equal(spanAttrs[idx][MCP_ATTR.TOOL_NAME], 'echo') + 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..4f6587f --- /dev/null +++ b/test/telemetry.test.ts @@ -0,0 +1,91 @@ +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 { SpanStatusCode } from '@opentelemetry/api' + +function makeSpan (): Span & { + 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): Tracer { + return { + startActiveSpan: (_name: string, _opts: any, fn: (s: Span) => any) => fn(span) + } as unknown as Tracer +} + +describe('withSpan', () => { + it('calls fn and returns result when tracer provided', async () => { + const span = makeSpan() + const tracer = makeTracer(span) + + const result = await withSpan(tracer, 'tools/call', { 'mcp.method.name': 'tools/call' }, async () => 42) + + assert.equal(result, 42) + assert.equal(span.end.mock.calls.length, 1) + assert.equal((span.setStatus.mock.calls[0].arguments[0] as any).code, SpanStatusCode.OK) + }) + + 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') + }) +}) + +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.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.TOOL_NAME, 'mcp.tool.name') + assert.equal(MCP_ATTR.PROMPT_NAME, 'mcp.prompt.name') + }) +})