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
30 changes: 29 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
49 changes: 37 additions & 12 deletions src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -40,6 +48,7 @@ type HandlerDependencies = {
request: FastifyRequest
reply: FastifyReply
authContext?: AuthorizationContext
tracer?: TracerLike
}

export function createResponse (id: string | number, result: any): JSONRPCResponse {
Expand Down Expand Up @@ -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<string, string> = {}
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<JSONRPCResponse | JSONRPCError>) => {
const { withSpan, buildSpanAttributes } = await getTelemetry()
return withSpan(tracer, request.method, buildSpanAttributes(request.method, sessionId, extraAttrs), fn)
}
: async (fn: () => Promise<JSONRPCResponse | JSONRPCError>) => 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`)
}
Expand Down
8 changes: 7 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/routes/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,8 @@ const mcpPubSubRoutesPlugin: FastifyPluginAsync<MCPPubSubRoutesOptions> = async
resourceHandlers,
request,
reply,
authContext
authContext,
tracer: opts.telemetry?.tracer
})
if (response) {
return response
Expand Down
42 changes: 42 additions & 0 deletions src/telemetry-constants.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
): Record<string, string> {
return {
[MCP_ATTR.METHOD_NAME]: methodName,
...(sessionId ? { [MCP_ATTR.SESSION_ID]: sessionId } : {}),
...extra
}
}
40 changes: 40 additions & 0 deletions src/telemetry.ts
Original file line number Diff line number Diff line change
@@ -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<T> (
tracer: TracerLike | undefined,
spanName: string,
attributes: Record<string, string>,
fn: () => Promise<T>
): Promise<T> {
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()
}
})
}
20 changes: 20 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -167,6 +178,15 @@ export interface MCPPluginOptions {
tls?: Record<string, unknown>
}
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 {
Expand Down
Loading