From 849ef5e901da37313fa82e644035a71b72f1b727 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Sun, 16 Aug 2026 11:38:15 +0300 Subject: [PATCH 01/10] feat(mcp): add OpenTelemetry Prometheus metrics --- .changeset/clean-otters-observe.md | 5 + packages/mcp/Dockerfile | 2 +- packages/mcp/README.md | 40 ++++ packages/mcp/package.json | 4 + packages/mcp/src/index.ts | 135 ++++++++---- packages/mcp/src/lib/api.ts | 61 +++--- packages/mcp/src/lib/telemetry.ts | 299 ++++++++++++++++++++++++++ packages/mcp/src/lib/types.ts | 1 + packages/mcp/test/integration.test.ts | 116 +++++++++- packages/mcp/test/telemetry.test.ts | 18 ++ pnpm-lock.yaml | 111 +++++++++- 11 files changed, 710 insertions(+), 82 deletions(-) create mode 100644 .changeset/clean-otters-observe.md create mode 100644 packages/mcp/src/lib/telemetry.ts create mode 100644 packages/mcp/test/telemetry.test.ts diff --git a/.changeset/clean-otters-observe.md b/.changeset/clean-otters-observe.md new file mode 100644 index 000000000..77616ad91 --- /dev/null +++ b/.changeset/clean-otters-observe.md @@ -0,0 +1,5 @@ +--- +"@upstash/context7-mcp": minor +--- + +Add bounded OpenTelemetry metrics for MCP requests, tools, authentication, and upstream API calls, exposed for Prometheus on the HTTP server's internal telemetry port. diff --git a/packages/mcp/Dockerfile b/packages/mcp/Dockerfile index 81720b4e2..9ac1f2a4d 100644 --- a/packages/mcp/Dockerfile +++ b/packages/mcp/Dockerfile @@ -25,5 +25,5 @@ RUN pnpm install --frozen-lockfile --prod --filter @upstash/context7-mcp COPY --from=builder /app/packages/mcp/dist ./packages/mcp/dist WORKDIR /app/packages/mcp -EXPOSE 8080 +EXPOSE 8080 9464 CMD ["node", "dist/index.js", "--transport", "http", "--port", "8080"] diff --git a/packages/mcp/README.md b/packages/mcp/README.md index c8abc4d29..b54942199 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -1490,6 +1490,46 @@ CONTEXT7_API_KEY=your_api_key_here } ``` +### OpenTelemetry metrics + +The HTTP transport exposes OpenTelemetry metrics in Prometheus format on a dedicated internal +listener at `0.0.0.0:9464/metrics`. The stdio transport does not open a telemetry port. Keeping +this listener separate from the public MCP port prevents the metrics endpoint from being routed +through a catch-all gateway rule. + +The exporter uses the standard OpenTelemetry Prometheus settings: + +- `OTEL_EXPORTER_PROMETHEUS_HOST` changes the bind address (default `0.0.0.0`). +- `OTEL_EXPORTER_PROMETHEUS_PORT` changes the port (default `9464`). +- `OTEL_METRICS_EXPORTER=none` or `OTEL_SDK_DISABLED=true` disables the embedded exporter. + +Exporter bind or configuration failures are logged but do not prevent the MCP endpoint from +starting. If a Node preload has already registered a global OpenTelemetry `MeterProvider`, that +provider takes precedence and the embedded Prometheus listener is not started; use the preload's +configured reader/exporter in that mode. + +It reports bounded-cardinality counters, histograms, and in-flight gauges for MCP methods, tool +calls, authentication outcomes, and Context7 upstream requests. Prometheus receives these metric +families: + +- `context7_mcp_requests_total` and `context7_mcp_request_duration` +- `context7_mcp_tool_calls_total` and `context7_mcp_tool_call_duration` +- `context7_mcp_upstream_requests_total` and `context7_mcp_upstream_request_duration` +- `context7_mcp_authentication_attempts_total` +- `context7_mcp_requests_active`, `context7_mcp_tool_calls_active`, and + `context7_mcp_upstream_requests_active` + +The labels intentionally exclude API keys, client IPs, queries, library IDs, session IDs, and raw +error text. Expose port `9464` only to your Prometheus scraper or `ServiceMonitor`, not through the +public MCP ingress. + +```yaml +scrape_configs: + - job_name: context7-mcp + static_configs: + - targets: ["context7-mcp:9464"] +``` +
Local Configuration Example diff --git a/packages/mcp/package.json b/packages/mcp/package.json index cfbb99109..674c05a40 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -47,6 +47,10 @@ "dependencies": { "@modelcontextprotocol/node": "2.0.0", "@modelcontextprotocol/server": "2.0.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/exporter-prometheus": "^0.221.0", + "@opentelemetry/resources": "^2.10.0", + "@opentelemetry/sdk-metrics": "^2.10.0", "@types/express": "^5.0.4", "commander": "^13.1.0", "express": "^5.1.0", diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index fc2301da3..86a3a24f5 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -24,6 +24,14 @@ import { } from "./lib/constants.js"; import { maybeElicitAuthSignIn } from "./lib/auth/auth-prompt.js"; import { getClientIp } from "./lib/client-ip.js"; +import { + getMcpMethod, + observeMcpRequest, + observeToolCall, + observeUpstreamRequest, + recordAuthentication, + startPrometheusMetrics, +} from "./lib/telemetry.js"; /** Default HTTP server port */ const DEFAULT_PORT = 3000; @@ -231,33 +239,41 @@ IMPORTANT: Do not call this tool more than 3 times per question. If you cannot f }, }, async ({ query, libraryName }: { query: string; libraryName: string }, toolCtx) => { - const ctx = getClientContext(toolCtx); - const searchResponse = await searchLibraries(query, libraryName, ctx); + return observeToolCall("resolve-library-id", async () => { + const ctx = getClientContext(toolCtx); + const searchResponse = await searchLibraries(query, libraryName, ctx); + + if (!searchResponse.results || searchResponse.results.length === 0) { + const text = searchResponse.error ?? "No libraries found matching the provided name."; + maybeElicitAuthSignIn(server, ctx); + return { + outcome: searchResponse.error ? ("error" as const) : ("success" as const), + value: { + content: [ + { + type: "text" as const, + text, + }, + ], + }, + }; + } - if (!searchResponse.results || searchResponse.results.length === 0) { - const text = searchResponse.error ?? "No libraries found matching the provided name."; + const resultsText = formatSearchResults(searchResponse); + const responseText = `Available Libraries:\n\n${resultsText}`; maybeElicitAuthSignIn(server, ctx); return { - content: [ - { - type: "text", - text, - }, - ], - }; - } - - const resultsText = formatSearchResults(searchResponse); - const responseText = `Available Libraries:\n\n${resultsText}`; - maybeElicitAuthSignIn(server, ctx); - return { - content: [ - { - type: "text", - text: responseText, + outcome: "success" as const, + value: { + content: [ + { + type: "text" as const, + text: responseText, + }, + ], }, - ], - }; + }; + }); } ); @@ -293,17 +309,22 @@ Do not call this tool more than 3 times per question.`, }, }, async ({ query, libraryId }: { query: string; libraryId: string }, toolCtx) => { - const ctx = getClientContext(toolCtx); - const response = await fetchLibraryContext({ query, libraryId }, ctx); - maybeElicitAuthSignIn(server, ctx); - return { - content: [ - { - type: "text", - text: response.data, + return observeToolCall("query-docs", async () => { + const ctx = getClientContext(toolCtx); + const response = await fetchLibraryContext({ query, libraryId }, ctx); + maybeElicitAuthSignIn(server, ctx); + return { + outcome: response.error ? ("error" as const) : ("success" as const), + value: { + content: [ + { + type: "text" as const, + text: response.data, + }, + ], }, - ], - }; + }; + }); } ); @@ -313,6 +334,7 @@ Do not call this tool more than 3 times per question.`, async function main() { if (TRANSPORT_TYPE === "http") { const initialPort = CLI_PORT ?? DEFAULT_PORT; + await startPrometheusMetrics(SERVER_VERSION); const app = express(); app.use(express.json()); @@ -405,7 +427,8 @@ async function main() { if (requireAuth) { if (!apiKey) { - return res.status(401).json({ + recordAuthentication("missing"); + res.status(401).json({ jsonrpc: "2.0", error: { code: -32001, @@ -413,12 +436,14 @@ async function main() { }, id: null, }); + return; } if (isJWT(apiKey)) { const validationResult = await validateJWT(apiKey); if (!validationResult.valid) { - return res.status(401).json({ + recordAuthentication("invalid"); + res.status(401).json({ jsonrpc: "2.0", error: { code: -32001, @@ -426,8 +451,10 @@ async function main() { }, id: null, }); + return; } } + recordAuthentication("accepted"); } const context: ClientContext = { @@ -452,16 +479,30 @@ async function main() { } }; + const handleObservedMcpRequest = async ( + req: express.Request, + res: express.Response, + requireAuth: boolean + ) => { + const route = requireAuth ? "oauth" : "anonymous"; + const method = getMcpMethod(req.headers["mcp-method"], req.body); + await observeMcpRequest( + route, + method, + () => res.statusCode, + () => handleMcpRequest(req, res, requireAuth) + ); + }; + // Anonymous access endpoint - no authentication required app.all("/mcp", async (req, res) => { - await handleMcpRequest(req, res, false); + await handleObservedMcpRequest(req, res, false); }); // OAuth-protected endpoint - requires authentication app.all("/mcp/oauth", async (req, res) => { - await handleMcpRequest(req, res, true); + await handleObservedMcpRequest(req, res, true); }); - app.get("/ping", (_req: express.Request, res: express.Response) => { res.json({ status: "ok", message: "pong" }); }); @@ -486,16 +527,22 @@ async function main() { const authServerUrl = AUTH_SERVER_URL; try { - const response = await fetch(`${authServerUrl}/.well-known/oauth-authorization-server`); - if (!response.ok) { - console.error("[OAuth] Upstream error:", response.status); - return res.status(response.status).json({ + const upstream = await observeUpstreamRequest( + "oauth_metadata", + () => fetch(`${authServerUrl}/.well-known/oauth-authorization-server`), + async (response) => { + if (!response.ok) return { ok: false as const, status: response.status }; + return { ok: true as const, metadata: await response.json() }; + } + ); + if (!upstream.ok) { + console.error("[OAuth] Upstream error:", upstream.status); + return res.status(upstream.status).json({ error: "upstream_error", message: "Failed to fetch authorization server metadata", }); } - const metadata = await response.json(); - res.json(metadata); + res.json(upstream.metadata); } catch (error) { console.error("[OAuth] Error fetching OAuth metadata:", error); res.status(502).json({ diff --git a/packages/mcp/src/lib/api.ts b/packages/mcp/src/lib/api.ts index d8d702399..9184fe7a1 100644 --- a/packages/mcp/src/lib/api.ts +++ b/packages/mcp/src/lib/api.ts @@ -4,6 +4,7 @@ import { Agent, ProxyAgent, setGlobalDispatcher } from "undici"; import { CONTEXT7_API_BASE_URL } from "./constants.js"; import { readFileSync } from "fs"; import tls from "tls"; +import { observeUpstreamRequest } from "./telemetry.js"; /** * Ceiling on a single Context7 API call. Without a signal a stalled backend @@ -127,15 +128,20 @@ export async function searchLibraries( const headers = generateHeaders(context); - const response = await fetch(url, { headers, signal: AbortSignal.timeout(API_TIMEOUT_MS) }); - readPromptSignal(response, context); - if (!response.ok) { - const errorMessage = await parseErrorResponse(response, context.apiKey); - console.error(errorMessage); - return { results: [], error: errorMessage }; - } - const searchData = await response.json(); - return searchData as SearchResponse; + return await observeUpstreamRequest( + "search_libraries", + () => fetch(url, { headers, signal: AbortSignal.timeout(API_TIMEOUT_MS) }), + async (response) => { + readPromptSignal(response, context); + if (!response.ok) { + const errorMessage = await parseErrorResponse(response, context.apiKey); + console.error(errorMessage); + return { results: [], error: errorMessage }; + } + const searchData = await response.json(); + return searchData as SearchResponse; + } + ); } catch (error) { const errorMessage = `Error searching libraries: ${error}`; console.error(errorMessage); @@ -160,24 +166,29 @@ export async function fetchLibraryContext( const headers = generateHeaders(context); - const response = await fetch(url, { headers, signal: AbortSignal.timeout(API_TIMEOUT_MS) }); - readPromptSignal(response, context); - if (!response.ok) { - const errorMessage = await parseErrorResponse(response, context.apiKey); - console.error(errorMessage); - return { data: errorMessage }; - } - - const text = await response.text(); - if (!text) { - return { - data: "Documentation not found or not finalized for this library. This might have happened because you used an invalid Context7-compatible library ID. To get a valid Context7-compatible library ID, use the 'resolve-library-id' with the package name you wish to retrieve documentation for.", - }; - } - return { data: text }; + return await observeUpstreamRequest( + "fetch_context", + () => fetch(url, { headers, signal: AbortSignal.timeout(API_TIMEOUT_MS) }), + async (response) => { + readPromptSignal(response, context); + if (!response.ok) { + const errorMessage = await parseErrorResponse(response, context.apiKey); + console.error(errorMessage); + return { data: errorMessage, error: true }; + } + + const text = await response.text(); + if (!text) { + return { + data: "Documentation not found or not finalized for this library. This might have happened because you used an invalid Context7-compatible library ID. To get a valid Context7-compatible library ID, use the 'resolve-library-id' with the package name you wish to retrieve documentation for.", + }; + } + return { data: text }; + } + ); } catch (error) { const errorMessage = `Error fetching library context. Please try again later. ${error}`; console.error(errorMessage); - return { data: errorMessage }; + return { data: errorMessage, error: true }; } } diff --git a/packages/mcp/src/lib/telemetry.ts b/packages/mcp/src/lib/telemetry.ts new file mode 100644 index 000000000..0f096b851 --- /dev/null +++ b/packages/mcp/src/lib/telemetry.ts @@ -0,0 +1,299 @@ +import { metrics, type Attributes } from "@opentelemetry/api"; +import { PrometheusExporter } from "@opentelemetry/exporter-prometheus"; +import { defaultResource, resourceFromAttributes } from "@opentelemetry/resources"; +import { MeterProvider } from "@opentelemetry/sdk-metrics"; + +const METER_NAME = "io.github.upstash.context7.mcp"; +const DEFAULT_PROMETHEUS_HOST = "0.0.0.0"; +const DEFAULT_PROMETHEUS_PORT = 9464; +const DURATION_BUCKETS_SECONDS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60]; + +const KNOWN_MCP_METHODS = new Set([ + "initialize", + "notifications/cancelled", + "notifications/initialized", + "ping", + "prompts/list", + "resources/list", + "resources/templates/list", + "tools/call", + "tools/list", +]); + +export type McpMethod = + | "batch" + | "initialize" + | "notifications/cancelled" + | "notifications/initialized" + | "ping" + | "prompts/list" + | "resources/list" + | "resources/templates/list" + | "tools/call" + | "tools/list" + | "unknown"; + +export type McpTool = "query-docs" | "resolve-library-id"; +export type UpstreamOperation = "fetch_context" | "oauth_metadata" | "search_libraries"; +export type AuthenticationOutcome = "accepted" | "invalid" | "missing"; +export type ToolCallOutcome = "error" | "success"; + +export interface ObservedToolCall { + outcome: ToolCallOutcome; + value: T; +} + +function createInstruments() { + const meter = metrics.getMeter(METER_NAME); + return { + mcpRequests: meter.createCounter("context7.mcp.requests", { + description: "Number of MCP protocol requests handled", + unit: "{request}", + }), + mcpRequestDuration: meter.createHistogram("context7.mcp.request.duration", { + description: "Duration of MCP protocol requests", + unit: "s", + advice: { explicitBucketBoundaries: DURATION_BUCKETS_SECONDS }, + }), + activeMcpRequests: meter.createUpDownCounter("context7.mcp.requests.active", { + description: "Number of MCP protocol requests currently being handled", + unit: "{request}", + }), + toolCalls: meter.createCounter("context7.mcp.tool.calls", { + description: "Number of MCP tool calls handled", + unit: "{call}", + }), + toolCallDuration: meter.createHistogram("context7.mcp.tool.call.duration", { + description: "Duration of MCP tool calls", + unit: "s", + advice: { explicitBucketBoundaries: DURATION_BUCKETS_SECONDS }, + }), + activeToolCalls: meter.createUpDownCounter("context7.mcp.tool.calls.active", { + description: "Number of MCP tool calls currently being handled", + unit: "{call}", + }), + upstreamRequests: meter.createCounter("context7.mcp.upstream.requests", { + description: "Number of requests made to Context7 dependencies", + unit: "{request}", + }), + upstreamRequestDuration: meter.createHistogram("context7.mcp.upstream.request.duration", { + description: "Duration of requests made to Context7 dependencies", + unit: "s", + advice: { explicitBucketBoundaries: DURATION_BUCKETS_SECONDS }, + }), + activeUpstreamRequests: meter.createUpDownCounter("context7.mcp.upstream.requests.active", { + description: "Number of requests to Context7 dependencies currently in flight", + unit: "{request}", + }), + authenticationAttempts: meter.createCounter("context7.mcp.authentication.attempts", { + description: "Number of authentication attempts on the OAuth-protected MCP endpoint", + unit: "{attempt}", + }), + }; +} + +let instruments: ReturnType | undefined; + +function getInstruments(): ReturnType { + instruments ??= createInstruments(); + return instruments; +} + +function elapsedSeconds(startedAt: number): number { + return (performance.now() - startedAt) / 1_000; +} + +function statusClass(statusCode: number): string { + if (statusCode >= 100 && statusCode <= 599) { + return `${Math.floor(statusCode / 100)}xx`; + } + return "unknown"; +} + +function requestOutcome(statusCode: number): "client_error" | "server_error" | "success" { + if (statusCode >= 500) return "server_error"; + if (statusCode >= 400) return "client_error"; + return "success"; +} + +/** + * Reads the standard SEP-2243 method header, falling back to the JSON-RPC + * body for legacy clients. Unknown input is collapsed to a fixed label to + * prevent attacker-controlled Prometheus cardinality. + */ +export function getMcpMethod(header: unknown, body: unknown): McpMethod { + if (Array.isArray(body)) return "batch"; + + const headerValue = Array.isArray(header) ? header[0] : header; + const bodyMethod = + body && typeof body === "object" && "method" in body + ? (body as { method?: unknown }).method + : undefined; + const candidate = typeof headerValue === "string" ? headerValue : bodyMethod; + + return typeof candidate === "string" && KNOWN_MCP_METHODS.has(candidate) + ? (candidate as McpMethod) + : "unknown"; +} + +export async function observeMcpRequest( + route: "anonymous" | "oauth", + method: McpMethod, + statusCode: () => number, + operation: () => Promise +): Promise { + const { activeMcpRequests, mcpRequestDuration, mcpRequests } = getInstruments(); + const activeAttributes: Attributes = { "mcp.method.name": method, "mcp.route": route }; + const startedAt = performance.now(); + activeMcpRequests.add(1, activeAttributes); + + try { + return await operation(); + } finally { + const responseStatus = statusCode(); + const attributes: Attributes = { + ...activeAttributes, + "http.response.status_code_class": statusClass(responseStatus), + "mcp.request.outcome": requestOutcome(responseStatus), + }; + activeMcpRequests.add(-1, activeAttributes); + mcpRequests.add(1, attributes); + mcpRequestDuration.record(elapsedSeconds(startedAt), attributes); + } +} + +export async function observeToolCall( + tool: McpTool, + operation: () => Promise> +): Promise { + const { activeToolCalls, toolCallDuration, toolCalls } = getInstruments(); + const activeAttributes: Attributes = { "mcp.tool.name": tool }; + const startedAt = performance.now(); + let outcome: ToolCallOutcome = "error"; + activeToolCalls.add(1, activeAttributes); + + try { + const observed = await operation(); + outcome = observed.outcome; + return observed.value; + } finally { + const attributes = { ...activeAttributes, "mcp.tool.outcome": outcome }; + activeToolCalls.add(-1, activeAttributes); + toolCalls.add(1, attributes); + toolCallDuration.record(elapsedSeconds(startedAt), attributes); + } +} + +export async function observeUpstreamRequest( + operationName: UpstreamOperation, + request: () => Promise, + consumeResponse: (response: Response) => Promise +): Promise { + const { activeUpstreamRequests, upstreamRequestDuration, upstreamRequests } = getInstruments(); + const activeAttributes: Attributes = { "context7.upstream.operation": operationName }; + const startedAt = performance.now(); + let outcome = "network_error"; + let responseStatusClass = "none"; + activeUpstreamRequests.add(1, activeAttributes); + + try { + const response = await request(); + responseStatusClass = statusClass(response.status); + outcome = response.ok ? "success" : "http_error"; + try { + return await consumeResponse(response); + } catch (error) { + outcome = "response_error"; + throw error; + } + } finally { + const attributes = { + ...activeAttributes, + "http.response.status_code_class": responseStatusClass, + "context7.upstream.outcome": outcome, + }; + activeUpstreamRequests.add(-1, activeAttributes); + upstreamRequests.add(1, attributes); + upstreamRequestDuration.record(elapsedSeconds(startedAt), attributes); + } +} + +export function recordAuthentication(outcome: AuthenticationOutcome): void { + const { authenticationAttempts } = getInstruments(); + authenticationAttempts.add(1, { "context7.authentication.outcome": outcome }); +} + +function prometheusIsEnabled(environment: NodeJS.ProcessEnv): boolean { + if (environment.OTEL_SDK_DISABLED?.toLowerCase() === "true") return false; + + const configuredExporters = environment.OTEL_METRICS_EXPORTER; + if (!configuredExporters) return true; + + return configuredExporters + .split(",") + .map((value) => value.trim().toLowerCase()) + .includes("prometheus"); +} + +function prometheusPort(environment: NodeJS.ProcessEnv): number { + const configuredPort = environment.OTEL_EXPORTER_PROMETHEUS_PORT; + if (!configuredPort) return DEFAULT_PROMETHEUS_PORT; + + const port = Number(configuredPort); + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error(`Invalid OTEL_EXPORTER_PROMETHEUS_PORT: '${configuredPort}'`); + } + return port; +} + +/** + * Installs the embedded Prometheus MetricReader for the HTTP server. A provider + * installed by an OpenTelemetry preload script wins; in that case the + * instruments above continue reporting to that provider and no second SDK is + * installed. Stdio mode never calls this function, so local MCP processes do + * not contend for a metrics port. + */ +export async function startPrometheusMetrics( + serviceVersion: string, + environment: NodeJS.ProcessEnv = process.env +): Promise { + if (!prometheusIsEnabled(environment)) return undefined; + + let provider: MeterProvider | undefined; + let providerWasRegistered = false; + try { + const host = environment.OTEL_EXPORTER_PROMETHEUS_HOST || DEFAULT_PROMETHEUS_HOST; + const port = prometheusPort(environment); + const exporter = new PrometheusExporter({ host, port, preventServerStart: true }); + provider = new MeterProvider({ + resource: defaultResource().merge( + resourceFromAttributes({ + "service.name": "context7-mcp", + "service.version": serviceVersion, + }) + ), + readers: [exporter], + }); + + providerWasRegistered = metrics.setGlobalMeterProvider(provider); + if (!providerWasRegistered) { + await provider.shutdown(); + console.error( + "Embedded Prometheus exporter not started because a global OpenTelemetry MeterProvider is already registered" + ); + return undefined; + } + + await exporter.startServer(); + console.error(`OpenTelemetry metrics available at http://${host}:${port}/metrics`); + return provider; + } catch (error) { + if (providerWasRegistered) metrics.disable(); + await provider?.shutdown().catch(() => undefined); + console.error( + "Embedded Prometheus exporter failed to start; MCP serving will continue:", + error + ); + return undefined; + } +} diff --git a/packages/mcp/src/lib/types.ts b/packages/mcp/src/lib/types.ts index 3b5a024f0..c3b33f06d 100644 --- a/packages/mcp/src/lib/types.ts +++ b/packages/mcp/src/lib/types.ts @@ -30,6 +30,7 @@ export type ContextRequest = { export type ContextResponse = { data: string; + error?: true; }; export interface ClientContext { diff --git a/packages/mcp/test/integration.test.ts b/packages/mcp/test/integration.test.ts index 0c9d68401..94bfd98ea 100644 --- a/packages/mcp/test/integration.test.ts +++ b/packages/mcp/test/integration.test.ts @@ -19,6 +19,8 @@ const PKG_ROOT = path.resolve(fileURLToPath(new URL(".", import.meta.url)), ".." const DIST = path.join(PKG_ROOT, "dist", "index.js"); const BASE_PORT = 43117; const STUB_DOCS = "stub docs text"; +const UPSTREAM_ERROR_QUERY = "force-upstream-error"; +const INVALID_JSON_QUERY = "force-invalid-json"; interface RecordedRequest { path: string; @@ -31,6 +33,18 @@ let stubServer: http.Server; let childEnv: Record; let httpChild: ChildProcess; let httpUrl: string; +let metricsUrl: string; + +function getFreePort(): Promise { + const server = http.createServer(); + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address() as { port: number }; + server.close((error) => (error ? reject(error) : resolve(address.port))); + }); + }); +} function startStubApi(): Promise { stubServer = http.createServer((req, res) => { @@ -39,6 +53,10 @@ function startStubApi(): Promise { requests.push({ path: apiPath, query: url.searchParams, headers: req.headers }); if (apiPath === "/v2/libs/search") { res.setHeader("Content-Type", "application/json"); + if (url.searchParams.get("query") === INVALID_JSON_QUERY) { + res.end("not-json"); + return; + } res.end( JSON.stringify({ results: [ @@ -56,6 +74,12 @@ function startStubApi(): Promise { }) ); } else if (apiPath === "/v2/context") { + if (url.searchParams.get("query") === UPSTREAM_ERROR_QUERY) { + res.statusCode = 503; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ message: "stub upstream failure" })); + return; + } res.setHeader("Content-Type", "text/plain"); res.end(STUB_DOCS); } else { @@ -94,9 +118,16 @@ function startHttpChild(): Promise<{ child: ChildProcess; url: string }> { beforeAll(async () => { execSync("pnpm build", { cwd: PKG_ROOT, stdio: "pipe" }); const stubUrl = await startStubApi(); + const metricsPort = await getFreePort(); + metricsUrl = `http://127.0.0.1:${metricsPort}/metrics`; // getDefaultEnvironment() inherits only safe vars, so a real // CONTEXT7_API_KEY in the parent shell cannot leak into the children. - childEnv = { ...getDefaultEnvironment(), CONTEXT7_API_URL: stubUrl }; + childEnv = { + ...getDefaultEnvironment(), + CONTEXT7_API_URL: stubUrl, + OTEL_EXPORTER_PROMETHEUS_HOST: "127.0.0.1", + OTEL_EXPORTER_PROMETHEUS_PORT: String(metricsPort), + }; ({ child: httpChild, url: httpUrl } = await startHttpChild()); }, 120_000); @@ -230,3 +261,86 @@ describe.each([ expect(apiCall.headers["x-context7-client-version"]).toBe(expected.version); }); }); + +describe("OpenTelemetry metrics", () => { + test("exports bounded MCP, tool, upstream, and authentication metrics", async () => { + const client = await connect("http", "modern"); + try { + await client.callTool({ + name: "query-docs", + arguments: { libraryId: "/vercel/next.js", query: "app router" }, + }); + await client.callTool({ + name: "query-docs", + arguments: { libraryId: "/vercel/next.js", query: UPSTREAM_ERROR_QUERY }, + }); + await client.callTool({ + name: "resolve-library-id", + arguments: { libraryName: "Next.js", query: INVALID_JSON_QUERY }, + }); + } finally { + await client.close(); + } + + const protectedUrl = new URL(httpUrl); + protectedUrl.pathname = "/mcp/oauth"; + const unauthorizedResponse = await fetch(protectedUrl, { + method: "POST", + headers: { "content-type": "application/json", "mcp-method": "initialize" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }), + }); + expect(unauthorizedResponse.status).toBe(401); + + const response = await fetch(metricsUrl); + expect(response.status).toBe(200); + const exported = await response.text(); + + expect(exported).toMatch( + /context7_mcp_requests_total\{[^}]*mcp_method_name="tools\/call"[^}]*mcp_request_outcome="success"[^}]*\} [1-9]/ + ); + expect(exported).toMatch( + /context7_mcp_tool_calls_total\{[^}]*mcp_tool_name="query-docs"[^}]*mcp_tool_outcome="success"[^}]*\} [1-9]/ + ); + expect(exported).toMatch( + /context7_mcp_upstream_requests_total\{[^}]*context7_upstream_operation="fetch_context"[^}]*context7_upstream_outcome="success"[^}]*\} [1-9]/ + ); + expect(exported).toMatch( + /context7_mcp_tool_calls_total\{[^}]*mcp_tool_name="query-docs"[^}]*mcp_tool_outcome="error"[^}]*\} [1-9]/ + ); + expect(exported).toMatch( + /context7_mcp_upstream_requests_total\{[^}]*context7_upstream_operation="fetch_context"[^}]*http_response_status_code_class="5xx"[^}]*context7_upstream_outcome="http_error"[^}]*\} [1-9]/ + ); + expect(exported).toMatch( + /context7_mcp_upstream_requests_total\{[^}]*context7_upstream_operation="search_libraries"[^}]*http_response_status_code_class="2xx"[^}]*context7_upstream_outcome="response_error"[^}]*\} [1-9]/ + ); + expect(exported).toMatch( + /context7_mcp_authentication_attempts_total\{[^}]*context7_authentication_outcome="missing"[^}]*\} 1/ + ); + expect(exported).toContain("context7_mcp_request_duration_bucket"); + expect(exported).toMatch(/target_info\{[^}]*service_name="context7-mcp"/); + + expect(exported).not.toMatch(/(?:\{|,)(?:api_key|client_ip|library_id|query|session_id)="/i); + + const activeSamples = exported + .split("\n") + .filter((line) => /^context7_mcp_.*_active\{/.test(line)); + expect(activeSamples.length).toBeGreaterThan(0); + expect(activeSamples.every((line) => line.endsWith(" 0"))).toBe(true); + + const applicationMetricsResponse = await fetch(new URL("/metrics", httpUrl)); + expect(applicationMetricsResponse.status).toBe(404); + }); + + test("continues serving when the embedded exporter port is occupied", async () => { + const secondServer = await startHttpChild(); + try { + const pingUrl = new URL(secondServer.url); + pingUrl.pathname = "/ping"; + const response = await fetch(pingUrl); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ status: "ok" }); + } finally { + secondServer.child.kill(); + } + }); +}); diff --git a/packages/mcp/test/telemetry.test.ts b/packages/mcp/test/telemetry.test.ts new file mode 100644 index 000000000..75fcc3dce --- /dev/null +++ b/packages/mcp/test/telemetry.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "vitest"; +import { getMcpMethod } from "../src/lib/telemetry.js"; + +describe("getMcpMethod", () => { + test("prefers the modern MCP method header", () => { + expect(getMcpMethod("tools/call", { method: "tools/list" })).toBe("tools/call"); + }); + + test("falls back to a legacy JSON-RPC body", () => { + expect(getMcpMethod(undefined, { jsonrpc: "2.0", method: "tools/list" })).toBe("tools/list"); + }); + + test("uses bounded labels for batches and untrusted method names", () => { + expect(getMcpMethod(undefined, [{ method: "tools/list" }])).toBe("batch"); + expect(getMcpMethod("attacker-controlled-method", {})).toBe("unknown"); + expect(getMcpMethod(undefined, null)).toBe("unknown"); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3161a1d5..7f4dd1eff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,7 +105,7 @@ importers: version: 8.47.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) vitest: specifier: ^4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(vite@7.2.4(@types/node@22.19.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.19.7)(vite@7.2.4(@types/node@22.19.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) packages/mcp: dependencies: @@ -115,6 +115,18 @@ importers: '@modelcontextprotocol/server': specifier: 2.0.0 version: 2.0.0 + '@opentelemetry/api': + specifier: ^1.9.1 + version: 1.9.1 + '@opentelemetry/exporter-prometheus': + specifier: ^0.221.0 + version: 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': + specifier: ^2.10.0 + version: 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': + specifier: ^2.10.0 + version: 2.10.0(@opentelemetry/api@1.9.1) '@types/express': specifier: ^5.0.4 version: 5.0.5 @@ -145,7 +157,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(vite@7.2.4(@types/node@25.0.3)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.0.3)(vite@7.2.4(@types/node@25.0.3)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) packages/pi: devDependencies: @@ -166,7 +178,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@25.9.1)(vite@7.2.4(@types/node@25.9.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(vite@7.2.4(@types/node@25.9.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) packages/sdk: devDependencies: @@ -184,7 +196,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(vite@7.2.4(@types/node@25.0.3)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.0.3)(vite@7.2.4(@types/node@25.0.3)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) packages/tools-ai-sdk: devDependencies: @@ -211,7 +223,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(vite@7.2.4(@types/node@25.0.3)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.0.3)(vite@7.2.4(@types/node@25.0.3)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) zod: specifier: ^4.4.3 version: 4.4.3 @@ -1002,30 +1014,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-arm64-musl@0.3.9': resolution: {integrity: sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@mariozechner/clipboard-linux-riscv64-gnu@0.3.9': resolution: {integrity: sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-x64-gnu@0.3.9': resolution: {integrity: sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-x64-musl@0.3.9': resolution: {integrity: sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@mariozechner/clipboard-win32-arm64-msvc@0.3.9': resolution: {integrity: sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==} @@ -1097,6 +1114,38 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/core@2.10.0': + resolution: {integrity: sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-prometheus@0.221.0': + resolution: {integrity: sha512-kW79a20qWESIuAdDrxzg9WKM98twV/NBWBFRAH57ap/+ssZhiCo0hckzKT0zpuwR/gSHrFAQhJL0bYDrnEM34g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resources@2.10.0': + resolution: {integrity: sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.10.0': + resolution: {integrity: sha512-t6r1VSvXNtSDnPXU1FbZeetJb7yyovHmgu0wRSoftxtE0g2rSNhQZQUy69sRUCL+iioJpX8SN/S6wq6ZtvLySQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + '@pkgr/core@0.3.6': resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -1165,56 +1214,67 @@ packages: resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.53.3': resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.53.3': resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.53.3': resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.53.3': resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.53.3': resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.53.3': resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.53.3': resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.53.3': resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.53.3': resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.53.3': resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.53.3': resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==} @@ -4145,6 +4205,35 @@ snapshots: '@opentelemetry/api@1.9.0': {} + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/exporter-prometheus@0.221.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-metrics@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/semantic-conventions@1.43.0': {} + '@pkgr/core@0.3.6': {} '@protobufjs/aspromise@1.1.2': {} @@ -6143,7 +6232,7 @@ snapshots: tsx: 4.21.0 yaml: 2.9.0 - vitest@4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(vite@7.2.4(@types/node@22.19.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)): + vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@22.19.7)(vite@7.2.4(@types/node@22.19.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(vite@7.2.4(@types/node@22.19.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) @@ -6166,12 +6255,12 @@ snapshots: vite: 7.2.4(@types/node@22.19.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@opentelemetry/api': 1.9.0 + '@opentelemetry/api': 1.9.1 '@types/node': 22.19.7 transitivePeerDependencies: - msw - vitest@4.1.9(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(vite@7.2.4(@types/node@25.0.3)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)): + vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.0.3)(vite@7.2.4(@types/node@25.0.3)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(vite@7.2.4(@types/node@25.0.3)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) @@ -6194,12 +6283,12 @@ snapshots: vite: 7.2.4(@types/node@25.0.3)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@opentelemetry/api': 1.9.0 + '@opentelemetry/api': 1.9.1 '@types/node': 25.0.3 transitivePeerDependencies: - msw - vitest@4.1.9(@opentelemetry/api@1.9.0)(@types/node@25.9.1)(vite@7.2.4(@types/node@25.9.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)): + vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(vite@7.2.4(@types/node@25.9.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(vite@7.2.4(@types/node@25.9.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) @@ -6222,7 +6311,7 @@ snapshots: vite: 7.2.4(@types/node@25.9.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@opentelemetry/api': 1.9.0 + '@opentelemetry/api': 1.9.1 '@types/node': 25.9.1 transitivePeerDependencies: - msw From 7194682e0bd991c7cd829cca67ba987dd093889a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Sun, 16 Aug 2026 13:48:53 +0300 Subject: [PATCH 02/10] refactor(mcp): adopt native OpenTelemetry conventions --- packages/mcp/README.md | 34 +- packages/mcp/package.json | 3 + packages/mcp/src/index.ts | 41 +- packages/mcp/src/lib/mcp-telemetry.ts | 586 ++++++++++++++++++ packages/mcp/src/lib/telemetry.ts | 92 +-- packages/mcp/test/integration.test.ts | 60 +- .../mcp/test/mcp-telemetry-lifecycle.test.ts | 249 ++++++++ packages/mcp/test/telemetry.test.ts | 152 ++++- pnpm-lock.yaml | 46 ++ 9 files changed, 1120 insertions(+), 143 deletions(-) create mode 100644 packages/mcp/src/lib/mcp-telemetry.ts create mode 100644 packages/mcp/test/mcp-telemetry-lifecycle.test.ts diff --git a/packages/mcp/README.md b/packages/mcp/README.md index b54942199..6014ad6aa 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -1490,12 +1490,21 @@ CONTEXT7_API_KEY=your_api_key_here } ``` -### OpenTelemetry metrics - -The HTTP transport exposes OpenTelemetry metrics in Prometheus format on a dedicated internal -listener at `0.0.0.0:9464/metrics`. The stdio transport does not open a telemetry port. Keeping -this listener separate from the public MCP port prevents the metrics endpoint from being routed -through a catch-all gateway rule. +### OpenTelemetry observability + +Context7 instruments individual MCP requests and notifications dispatched to an MCP server +instance at the SDK transport boundary, including messages inside a valid batch. Requests rejected +by the SDK's HTTP envelope and protocol-version validation before dispatch remain visible in normal +HTTP/gateway telemetry, but are not reported as MCP operations. Dispatched operations follow the +development-status [OpenTelemetry MCP semantic conventions](https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/mcp.md) +for server metrics and spans. Trace context is extracted from the `traceparent`, `tracestate`, and +`baggage` fields in MCP `params._meta` as defined by +[SEP-414](https://modelcontextprotocol.io/seps/414-request-meta). + +The HTTP transport exposes metrics in Prometheus format on a dedicated internal listener at +`0.0.0.0:9464/metrics`. The stdio transport does not open a telemetry port. Keeping this listener +separate from the public MCP port prevents the metrics endpoint from being routed through a +catch-all gateway rule. The exporter uses the standard OpenTelemetry Prometheus settings: @@ -1504,20 +1513,21 @@ The exporter uses the standard OpenTelemetry Prometheus settings: - `OTEL_METRICS_EXPORTER=none` or `OTEL_SDK_DISABLED=true` disables the embedded exporter. Exporter bind or configuration failures are logged but do not prevent the MCP endpoint from -starting. If a Node preload has already registered a global OpenTelemetry `MeterProvider`, that -provider takes precedence and the embedded Prometheus listener is not started; use the preload's -configured reader/exporter in that mode. +starting. If a Node preload has already registered global OpenTelemetry providers, they take +precedence. The embedded Prometheus listener is not started when a global `MeterProvider` exists, +and MCP spans are exported through the preload's `TracerProvider`. This supports an OpenTelemetry +Node SDK or Kubernetes auto-instrumentation without creating a second provider in the application. It reports bounded-cardinality counters, histograms, and in-flight gauges for MCP methods, tool calls, authentication outcomes, and Context7 upstream requests. Prometheus receives these metric families: -- `context7_mcp_requests_total` and `context7_mcp_request_duration` +- `mcp_server_operation_duration` (its `_count` series is the MCP operation count) +- `context7_mcp_operations_active` - `context7_mcp_tool_calls_total` and `context7_mcp_tool_call_duration` - `context7_mcp_upstream_requests_total` and `context7_mcp_upstream_request_duration` - `context7_mcp_authentication_attempts_total` -- `context7_mcp_requests_active`, `context7_mcp_tool_calls_active`, and - `context7_mcp_upstream_requests_active` +- `context7_mcp_tool_calls_active` and `context7_mcp_upstream_requests_active` The labels intentionally exclude API keys, client IPs, queries, library IDs, session IDs, and raw error text. Expose port `9464` only to your Prometheus scraper or `ServiceMonitor`, not through the diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 674c05a40..9134a1af9 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -60,6 +60,9 @@ }, "devDependencies": { "@modelcontextprotocol/client": "2.0.0", + "@opentelemetry/context-async-hooks": "^2.10.0", + "@opentelemetry/core": "^2.10.0", + "@opentelemetry/sdk-trace-base": "^2.10.0", "@types/node": "^25.0.3", "typescript": "^5.8.2", "vitest": "^4.1.9" diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 86a3a24f5..1f880b4a9 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -2,7 +2,11 @@ import { toNodeHandler } from "@modelcontextprotocol/node"; import { serveStdio } from "@modelcontextprotocol/server/stdio"; -import { McpServer, createMcpHandler, type ServerContext } from "@modelcontextprotocol/server"; +import { + createMcpHandler, + type McpRequestContext, + type ServerContext, +} from "@modelcontextprotocol/server"; import { z } from "zod"; import { searchLibraries, fetchLibraryContext } from "./lib/api.js"; import type { ClientContext } from "./lib/types.js"; @@ -25,13 +29,12 @@ import { import { maybeElicitAuthSignIn } from "./lib/auth/auth-prompt.js"; import { getClientIp } from "./lib/client-ip.js"; import { - getMcpMethod, - observeMcpRequest, observeToolCall, observeUpstreamRequest, recordAuthentication, startPrometheusMetrics, } from "./lib/telemetry.js"; +import { InstrumentedMcpServer } from "./lib/mcp-telemetry.js"; /** Default HTTP server port */ const DEFAULT_PORT = 3000; @@ -152,8 +155,8 @@ function aliasArgs(aliases: AliasMap) { }; } -function createMcpServer() { - const server = new McpServer( +function createMcpServer(mcpContext: McpRequestContext) { + const server = new InstrumentedMcpServer( { name: "Context7", version: SERVER_VERSION, @@ -176,7 +179,8 @@ function createMcpServer() { instructions: `Use this server to fetch current documentation whenever the user asks about a library, framework, SDK, API, CLI tool, or cloud service — even well-known ones like React, Next.js, Prisma, Express, Tailwind, Django, or Spring Boot. This includes API syntax, configuration, version migration, library-specific debugging, setup instructions, and CLI tool usage. Use even when you think you know the answer — your training data may not reflect recent changes. Prefer this over web search for library docs. Do not use for: refactoring, writing scripts from scratch, debugging business logic, code review, or general programming concepts.`, - } + }, + mcpContext ); server.registerTool( @@ -396,7 +400,7 @@ async function main() { // then never closes the stream, and with heartbeats it survived until the // gateway's 1200s hard cap (the 2026-08-11 outage). Silent hangs instead // go idle and the gateway reaps them at streamIdleTimeout (300s). - const mcpHandler = createMcpHandler(() => createMcpServer(), { + const mcpHandler = createMcpHandler((mcpContext) => createMcpServer(mcpContext), { keepAliveMs: 0, onerror: (error) => console.error("MCP handler error:", error), }); @@ -479,29 +483,14 @@ async function main() { } }; - const handleObservedMcpRequest = async ( - req: express.Request, - res: express.Response, - requireAuth: boolean - ) => { - const route = requireAuth ? "oauth" : "anonymous"; - const method = getMcpMethod(req.headers["mcp-method"], req.body); - await observeMcpRequest( - route, - method, - () => res.statusCode, - () => handleMcpRequest(req, res, requireAuth) - ); - }; - // Anonymous access endpoint - no authentication required app.all("/mcp", async (req, res) => { - await handleObservedMcpRequest(req, res, false); + await handleMcpRequest(req, res, false); }); // OAuth-protected endpoint - requires authentication app.all("/mcp/oauth", async (req, res) => { - await handleObservedMcpRequest(req, res, true); + await handleMcpRequest(req, res, true); }); app.get("/ping", (_req: express.Request, res: express.Response) => { res.json({ status: "ok", message: "pong" }); @@ -605,8 +594,8 @@ async function main() { process.on("SIGHUP", () => process.exit(0)); serveStdio( - () => { - const server = createMcpServer(); + (mcpContext) => { + const server = createMcpServer(mcpContext); // Capture client info from MCP initialize handshake (stdio only — HTTP // mode plumbs client info through requestContext per request). diff --git a/packages/mcp/src/lib/mcp-telemetry.ts b/packages/mcp/src/lib/mcp-telemetry.ts new file mode 100644 index 000000000..7602c9714 --- /dev/null +++ b/packages/mcp/src/lib/mcp-telemetry.ts @@ -0,0 +1,586 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { + BAGGAGE_META_KEY, + McpServer, + PROTOCOL_VERSION_META_KEY, + SUPPORTED_PROTOCOL_VERSIONS, + TRACEPARENT_META_KEY, + TRACESTATE_META_KEY, + isCallToolResult, + isJSONRPCErrorResponse, + isJSONRPCNotification, + isJSONRPCRequest, + isJSONRPCResultResponse, + type Implementation, + type JSONRPCMessage, + type McpRequestContext, + type MessageExtraInfo, + type RequestId, + type ServerOptions, + type Transport, + type TransportSendOptions, +} from "@modelcontextprotocol/server"; +import { + ROOT_CONTEXT, + SpanKind, + SpanStatusCode, + context, + isSpanContextValid, + metrics, + propagation, + trace, + type Attributes, + type Context, + type Link, + type Span, +} from "@opentelemetry/api"; + +const INSTRUMENTATION_NAME = "io.github.upstash.context7.mcp"; +const MCP_DURATION_BUCKETS_SECONDS = [ + 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 120, 300, +]; +const KNOWN_MCP_METHODS = new Set([ + "completion/complete", + "elicitation/create", + "initialize", + "logging/setLevel", + "notifications/cancelled", + "notifications/elicitation/complete", + "notifications/initialized", + "notifications/message", + "notifications/progress", + "notifications/prompts/list_changed", + "notifications/resources/list_changed", + "notifications/resources/updated", + "notifications/roots/list_changed", + "notifications/subscriptions/acknowledged", + "notifications/tasks/status", + "notifications/tools/list_changed", + "ping", + "prompts/get", + "prompts/list", + "resources/list", + "resources/read", + "resources/subscribe", + "resources/templates/list", + "resources/unsubscribe", + "roots/list", + "sampling/createMessage", + "server/discover", + "subscriptions/listen", + "tasks/cancel", + "tasks/get", + "tasks/list", + "tasks/result", + "tools/call", + "tools/list", +]); +const KNOWN_TOOLS = new Set(["query-docs", "resolve-library-id"]); +const KNOWN_PROTOCOL_VERSIONS = new Set(SUPPORTED_PROTOCOL_VERSIONS); + +type McpRoute = "anonymous" | "oauth" | "stdio"; +type NetworkTransport = "pipe" | "tcp"; + +interface McpObservationConfig { + abortSignal?: AbortSignal; + route: McpRoute; + networkTransport: NetworkTransport; + networkProtocol?: "http"; + protocolVersion?: string; +} + +type McpOperationState = "finished" | "handling" | "sending"; + +interface McpOperation { + activeAttributes: Attributes; + attributes: Attributes; + context: Context; + errorType?: string; + requestId?: RequestId; + span: Span; + state: McpOperationState; + statusDescription?: string; + startedAt: number; +} + +interface ServerResponseClassification { + errorType?: string; + rpcStatusCode?: string; + statusDescription?: string; +} + +const operationStorage = new AsyncLocalStorage(); +const CALLER_FAULT_CODES = new Set([-32700, -32600, -32601, -32602, -32002]); + +function getInstruments() { + const meter = metrics.getMeter(INSTRUMENTATION_NAME); + return { + operationDuration: meter.createHistogram("mcp.server.operation.duration", { + description: + "MCP request or notification duration from receipt until the result or acknowledgement is sent", + unit: "s", + advice: { explicitBucketBoundaries: MCP_DURATION_BUCKETS_SECONDS }, + }), + activeOperations: meter.createUpDownCounter("context7.mcp.operations.active", { + description: "Number of MCP requests and notifications currently being handled", + unit: "{operation}", + }), + }; +} + +let instruments: ReturnType | undefined; + +function mcpInstruments(): ReturnType { + instruments ??= getInstruments(); + return instruments; +} + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +export function normalizeMcpMethodName(method: unknown): string { + return typeof method === "string" && KNOWN_MCP_METHODS.has(method) ? method : "unknown"; +} + +export function normalizeMcpToolName(tool: unknown): string { + return typeof tool === "string" && KNOWN_TOOLS.has(tool) ? tool : "unknown"; +} + +function normalizedTool(message: JSONRPCMessage): string | undefined { + if (!isJSONRPCRequest(message) || message.method !== "tools/call") return undefined; + + const name = asRecord(message.params)?.name; + return normalizeMcpToolName(name); +} + +function normalizedProtocolVersion(value: unknown): string | undefined { + return typeof value === "string" && KNOWN_PROTOCOL_VERSIONS.has(value) ? value : undefined; +} + +function messageProtocolVersion(message: JSONRPCMessage): string | undefined { + if (!isJSONRPCRequest(message) && !isJSONRPCNotification(message)) return undefined; + + const params = asRecord(message.params); + const metadata = asRecord(params?._meta); + return ( + normalizedProtocolVersion(metadata?.[PROTOCOL_VERSION_META_KEY]) ?? + normalizedProtocolVersion(params?.protocolVersion) + ); +} + +export function mcpTraceCarrier(message: JSONRPCMessage): Record { + if (!isJSONRPCRequest(message) && !isJSONRPCNotification(message)) return {}; + + const metadata = asRecord(asRecord(message.params)?._meta); + if (!metadata) return {}; + + const carrier: Record = {}; + for (const key of [TRACEPARENT_META_KEY, TRACESTATE_META_KEY, BAGGAGE_META_KEY]) { + const value = metadata[key]; + if (typeof value === "string") carrier[key] = value; + } + return carrier; +} + +function ambientLink(parentContext: Context): Link[] | undefined { + const ambient = trace.getSpan(context.active())?.spanContext(); + const parent = trace.getSpan(parentContext)?.spanContext(); + if (!ambient || !isSpanContextValid(ambient)) return undefined; + if (parent && ambient.traceId === parent.traceId && ambient.spanId === parent.spanId) { + return undefined; + } + return [{ context: ambient }]; +} + +function elapsedSeconds(startedAt: number): number { + return (performance.now() - startedAt) / 1_000; +} + +function requestIdAttribute(requestId: RequestId): string | undefined { + return requestId === null ? undefined : String(requestId); +} + +function startOperation( + message: JSONRPCMessage, + config: McpObservationConfig +): McpOperation | undefined { + if (!isJSONRPCRequest(message) && !isJSONRPCNotification(message)) return undefined; + + const method = normalizeMcpMethodName(message.method); + const tool = normalizedTool(message); + const protocolVersion = messageProtocolVersion(message) ?? config.protocolVersion; + const activeAttributes: Attributes = { + "context7.mcp.route": config.route, + "mcp.method.name": method, + }; + const attributes: Attributes = { + ...activeAttributes, + "network.transport": config.networkTransport, + }; + if (config.networkProtocol) attributes["network.protocol.name"] = config.networkProtocol; + if (protocolVersion) attributes["mcp.protocol.version"] = protocolVersion; + if (tool) { + activeAttributes["gen_ai.tool.name"] = tool; + attributes["gen_ai.tool.name"] = tool; + attributes["gen_ai.operation.name"] = "execute_tool"; + } + + const requestId = isJSONRPCRequest(message) ? message.id : undefined; + const spanAttributes: Attributes = { ...attributes }; + if (requestId !== undefined) { + const requestIdValue = requestIdAttribute(requestId); + if (requestIdValue) spanAttributes["jsonrpc.request.id"] = requestIdValue; + } + + const parentContext = propagation.extract(ROOT_CONTEXT, mcpTraceCarrier(message)); + const span = trace.getTracer(INSTRUMENTATION_NAME).startSpan( + tool ? `${method} ${tool}` : method, + { + attributes: spanAttributes, + kind: SpanKind.SERVER, + links: ambientLink(parentContext), + }, + parentContext + ); + const operation = { + activeAttributes, + attributes, + context: ROOT_CONTEXT, + requestId, + span, + state: "handling", + startedAt: performance.now(), + } satisfies McpOperation; + operation.context = trace.setSpan(parentContext, span); + mcpInstruments().activeOperations.add(1, activeAttributes); + return operation; +} + +function finishOperation(operation: McpOperation): void { + if (operation.state === "finished") return; + operation.state = "finished"; + + const attributes = { ...operation.attributes }; + if (operation.errorType) attributes["error.type"] = operation.errorType; + + const { activeOperations, operationDuration } = mcpInstruments(); + activeOperations.add(-1, operation.activeAttributes); + operationDuration.record(elapsedSeconds(operation.startedAt), attributes); + if (operation.errorType) { + operation.span.setAttribute("error.type", operation.errorType); + operation.span.setStatus({ + code: SpanStatusCode.ERROR, + message: operation.statusDescription, + }); + } + operation.span.end(); +} + +function operationIsFinished(operation: McpOperation): boolean { + return operation.state === "finished"; +} + +function runOperation(operation: McpOperation, handler: () => void): void { + operationStorage.run(operation, () => context.with(operation.context, handler)); +} + +export function classifyServerResponse( + message: JSONRPCMessage, + operationMethod: unknown +): ServerResponseClassification { + if (isJSONRPCErrorResponse(message)) { + return { + errorType: CALLER_FAULT_CODES.has(message.error.code) + ? undefined + : String(message.error.code), + rpcStatusCode: String(message.error.code), + statusDescription: message.error.message, + }; + } + if ( + isJSONRPCResultResponse(message) && + operationMethod === "tools/call" && + isCallToolResult(message.result) && + message.result.isError + ) { + return { errorType: "tool_error" }; + } + return {}; +} + +function applyServerResponse(message: JSONRPCMessage, operation: McpOperation): void { + const classification = classifyServerResponse(message, operation.attributes["mcp.method.name"]); + // A JSON-RPC error is the canonical final classification, including caller + // faults that intentionally clear a provisional server error. Successful + // envelopes retain an application-level tool_error captured by the tool + // wrapper when the SDK normalizes the result before transport serialization. + if (isJSONRPCErrorResponse(message) || classification.errorType) { + operation.errorType = classification.errorType; + } + operation.statusDescription = classification.statusDescription; + if (classification.rpcStatusCode) { + operation.attributes["rpc.response.status_code"] = classification.rpcStatusCode; + operation.span.setAttribute("rpc.response.status_code", classification.rpcStatusCode); + } +} + +function cancellationRequestId(message: JSONRPCMessage): RequestId | undefined { + if (!isJSONRPCNotification(message) || message.method !== "notifications/cancelled") { + return undefined; + } + + const requestId = asRecord(message.params)?.requestId; + return typeof requestId === "string" || typeof requestId === "number" ? requestId : undefined; +} + +export function mcpRouteFromUrl(url: string): McpRoute { + const pathname = new URL(url).pathname.replace(/\/+$/, ""); + return pathname === "/mcp/oauth" ? "oauth" : "anonymous"; +} + +function configFromRequestContext(requestContext: McpRequestContext): McpObservationConfig { + const request = requestContext.requestInfo; + if (!request) { + return { + route: "stdio", + networkTransport: "pipe", + protocolVersion: requestContext.era === "modern" ? "2026-07-28" : undefined, + }; + } + + return { + abortSignal: request.signal, + route: mcpRouteFromUrl(request.url), + networkProtocol: "http", + networkTransport: "tcp", + protocolVersion: + normalizedProtocolVersion(request.headers.get("mcp-protocol-version")) ?? + (requestContext.era === "modern" ? "2026-07-28" : undefined), + }; +} + +class InstrumentedTransport implements Transport { + private readonly abortSignal?: AbortSignal; + private readonly inFlight = new Map(); + private messageHandler: Transport["onmessage"]; + private closeHandler: Transport["onclose"]; + private errorHandler: Transport["onerror"]; + private protocolVersion?: string; + + constructor( + private readonly transport: Transport, + private readonly config: McpObservationConfig + ) { + this.abortSignal = config.abortSignal; + this.protocolVersion = config.protocolVersion; + this.onclose = transport.onclose; + this.onerror = transport.onerror; + this.onmessage = transport.onmessage; + this.abortSignal?.addEventListener("abort", this.handleAbort, { once: true }); + } + + private readonly handleAbort = (): void => { + this.finishAll("cancelled", true); + }; + + get hasPerRequestStream(): boolean | undefined { + return this.transport.hasPerRequestStream; + } + + get sessionId(): string | undefined { + return this.transport.sessionId; + } + + set sessionId(value: string | undefined) { + this.transport.sessionId = value; + } + + get onclose(): Transport["onclose"] { + return this.closeHandler; + } + + set onclose(handler: Transport["onclose"]) { + this.closeHandler = handler; + this.transport.onclose = () => { + this.finishAll("connection_closed"); + this.detachAbortHandler(); + handler?.(); + }; + } + + get onerror(): Transport["onerror"] { + return this.errorHandler; + } + + set onerror(handler: Transport["onerror"]) { + this.errorHandler = handler; + this.transport.onerror = handler; + } + + get onmessage(): Transport["onmessage"] { + return this.messageHandler; + } + + set onmessage(handler: Transport["onmessage"]) { + this.messageHandler = handler; + this.transport.onmessage = handler + ? (message, extra) => this.receive(message, extra, handler) + : undefined; + } + + setProtocolVersion = (version: string): void => { + this.protocolVersion = normalizedProtocolVersion(version); + this.transport.setProtocolVersion?.(version); + }; + + setSupportedProtocolVersions = (versions: string[]): void => { + this.transport.setSupportedProtocolVersions?.(versions); + }; + + start(): Promise { + return this.transport.start(); + } + + async close(): Promise { + try { + await this.transport.close(); + } finally { + this.finishAll("connection_closed"); + this.detachAbortHandler(); + } + } + + async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise { + const responseId = + isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message) ? message.id : undefined; + const operation = + responseId !== undefined && responseId !== null ? this.inFlight.get(responseId) : undefined; + if (!operation) { + await this.transport.send(message, options); + return; + } + + applyServerResponse(message, operation); + operation.state = "sending"; + try { + await this.transport.send(message, options); + } catch (error) { + if (!operationIsFinished(operation)) { + operation.errorType = "transport_error"; + operation.statusDescription = error instanceof Error ? error.message : undefined; + if (error instanceof Error) operation.span.recordException(error); + } + throw error; + } finally { + this.removeInFlight(operation); + finishOperation(operation); + } + } + + private receive( + message: JSONRPCMessage, + extra: MessageExtraInfo | undefined, + handler: NonNullable + ): void { + const operation = startOperation(message, { + ...this.config, + protocolVersion: this.protocolVersion ?? this.config.protocolVersion, + }); + if (!operation) { + handler(message, extra); + return; + } + + if (operation.requestId !== undefined && operation.requestId !== null) { + const previous = this.inFlight.get(operation.requestId); + if (previous) { + previous.errorType = "duplicate_request_id"; + finishOperation(previous); + } + this.inFlight.set(operation.requestId, operation); + } + + try { + runOperation(operation, () => handler(message, extra)); + } catch (error) { + operation.errorType = "handler_error"; + if (error instanceof Error) operation.span.recordException(error); + if (operation.requestId !== undefined && operation.requestId !== null) { + this.inFlight.delete(operation.requestId); + } + finishOperation(operation); + throw error; + } + + if (isJSONRPCNotification(message)) { + const cancelledRequestId = cancellationRequestId(message); + if (cancelledRequestId !== undefined) this.cancelOperation(cancelledRequestId); + finishOperation(operation); + } + } + + private cancelOperation(requestId: RequestId): void { + const operation = this.inFlight.get(requestId); + if (!operation) return; + + operation.errorType = "cancelled"; + this.inFlight.delete(requestId); + finishOperation(operation); + } + + private removeInFlight(operation: McpOperation): void { + const requestId = operation.requestId; + if ( + requestId !== undefined && + requestId !== null && + this.inFlight.get(requestId) === operation + ) { + this.inFlight.delete(requestId); + } + } + + private detachAbortHandler(): void { + this.abortSignal?.removeEventListener("abort", this.handleAbort); + } + + private finishAll(errorType: string, includeSending = false): void { + for (const [requestId, operation] of this.inFlight) { + // A normal per-request HTTP transport closes its stream from inside send(). + // Let an active send settle so its success or failure remains authoritative. + if (operation.state === "sending" && !includeSending) continue; + operation.errorType ??= errorType; + finishOperation(operation); + this.inFlight.delete(requestId); + } + } +} + +/** + * High-level MCP server with protocol-aware OpenTelemetry at the SDK transport + * boundary. This observes individual JSON-RPC operations for HTTP and stdio, + * including batched messages, instead of treating an HTTP envelope as one MCP + * operation. + */ +export class InstrumentedMcpServer extends McpServer { + constructor( + serverInfo: Implementation, + options: ServerOptions, + private readonly requestContext: McpRequestContext + ) { + super(serverInfo, options); + } + + override connect(transport: Transport): Promise { + return super.connect( + new InstrumentedTransport(transport, configFromRequestContext(this.requestContext)) + ); + } +} + +export function markCurrentMcpOperationError(errorType = "tool_error"): void { + const operation = operationStorage.getStore(); + if (operation) operation.errorType = errorType; +} diff --git a/packages/mcp/src/lib/telemetry.ts b/packages/mcp/src/lib/telemetry.ts index 0f096b851..47c809223 100644 --- a/packages/mcp/src/lib/telemetry.ts +++ b/packages/mcp/src/lib/telemetry.ts @@ -2,37 +2,13 @@ import { metrics, type Attributes } from "@opentelemetry/api"; import { PrometheusExporter } from "@opentelemetry/exporter-prometheus"; import { defaultResource, resourceFromAttributes } from "@opentelemetry/resources"; import { MeterProvider } from "@opentelemetry/sdk-metrics"; +import { markCurrentMcpOperationError } from "./mcp-telemetry.js"; const METER_NAME = "io.github.upstash.context7.mcp"; const DEFAULT_PROMETHEUS_HOST = "0.0.0.0"; const DEFAULT_PROMETHEUS_PORT = 9464; const DURATION_BUCKETS_SECONDS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60]; -const KNOWN_MCP_METHODS = new Set([ - "initialize", - "notifications/cancelled", - "notifications/initialized", - "ping", - "prompts/list", - "resources/list", - "resources/templates/list", - "tools/call", - "tools/list", -]); - -export type McpMethod = - | "batch" - | "initialize" - | "notifications/cancelled" - | "notifications/initialized" - | "ping" - | "prompts/list" - | "resources/list" - | "resources/templates/list" - | "tools/call" - | "tools/list" - | "unknown"; - export type McpTool = "query-docs" | "resolve-library-id"; export type UpstreamOperation = "fetch_context" | "oauth_metadata" | "search_libraries"; export type AuthenticationOutcome = "accepted" | "invalid" | "missing"; @@ -46,19 +22,6 @@ export interface ObservedToolCall { function createInstruments() { const meter = metrics.getMeter(METER_NAME); return { - mcpRequests: meter.createCounter("context7.mcp.requests", { - description: "Number of MCP protocol requests handled", - unit: "{request}", - }), - mcpRequestDuration: meter.createHistogram("context7.mcp.request.duration", { - description: "Duration of MCP protocol requests", - unit: "s", - advice: { explicitBucketBoundaries: DURATION_BUCKETS_SECONDS }, - }), - activeMcpRequests: meter.createUpDownCounter("context7.mcp.requests.active", { - description: "Number of MCP protocol requests currently being handled", - unit: "{request}", - }), toolCalls: meter.createCounter("context7.mcp.tool.calls", { description: "Number of MCP tool calls handled", unit: "{call}", @@ -110,58 +73,6 @@ function statusClass(statusCode: number): string { return "unknown"; } -function requestOutcome(statusCode: number): "client_error" | "server_error" | "success" { - if (statusCode >= 500) return "server_error"; - if (statusCode >= 400) return "client_error"; - return "success"; -} - -/** - * Reads the standard SEP-2243 method header, falling back to the JSON-RPC - * body for legacy clients. Unknown input is collapsed to a fixed label to - * prevent attacker-controlled Prometheus cardinality. - */ -export function getMcpMethod(header: unknown, body: unknown): McpMethod { - if (Array.isArray(body)) return "batch"; - - const headerValue = Array.isArray(header) ? header[0] : header; - const bodyMethod = - body && typeof body === "object" && "method" in body - ? (body as { method?: unknown }).method - : undefined; - const candidate = typeof headerValue === "string" ? headerValue : bodyMethod; - - return typeof candidate === "string" && KNOWN_MCP_METHODS.has(candidate) - ? (candidate as McpMethod) - : "unknown"; -} - -export async function observeMcpRequest( - route: "anonymous" | "oauth", - method: McpMethod, - statusCode: () => number, - operation: () => Promise -): Promise { - const { activeMcpRequests, mcpRequestDuration, mcpRequests } = getInstruments(); - const activeAttributes: Attributes = { "mcp.method.name": method, "mcp.route": route }; - const startedAt = performance.now(); - activeMcpRequests.add(1, activeAttributes); - - try { - return await operation(); - } finally { - const responseStatus = statusCode(); - const attributes: Attributes = { - ...activeAttributes, - "http.response.status_code_class": statusClass(responseStatus), - "mcp.request.outcome": requestOutcome(responseStatus), - }; - activeMcpRequests.add(-1, activeAttributes); - mcpRequests.add(1, attributes); - mcpRequestDuration.record(elapsedSeconds(startedAt), attributes); - } -} - export async function observeToolCall( tool: McpTool, operation: () => Promise> @@ -177,6 +88,7 @@ export async function observeToolCall( outcome = observed.outcome; return observed.value; } finally { + if (outcome === "error") markCurrentMcpOperationError(); const attributes = { ...activeAttributes, "mcp.tool.outcome": outcome }; activeToolCalls.add(-1, activeAttributes); toolCalls.add(1, attributes); diff --git a/packages/mcp/test/integration.test.ts b/packages/mcp/test/integration.test.ts index 94bfd98ea..e84f041ea 100644 --- a/packages/mcp/test/integration.test.ts +++ b/packages/mcp/test/integration.test.ts @@ -35,6 +35,17 @@ let httpChild: ChildProcess; let httpUrl: string; let metricsUrl: string; +function operationCount(exported: string, method: string): number { + return exported + .split("\n") + .filter( + (line) => + line.startsWith("mcp_server_operation_duration_count{") && + line.includes(`mcp_method_name="${method}"`) + ) + .reduce((total, line) => total + Number(line.slice(line.lastIndexOf(" ") + 1)), 0); +} + function getFreePort(): Promise { const server = http.createServer(); return new Promise((resolve, reject) => { @@ -263,6 +274,26 @@ describe.each([ }); describe("OpenTelemetry metrics", () => { + test("counts each dispatched operation in a legacy JSON-RPC batch", async () => { + const before = operationCount(await (await fetch(metricsUrl)).text(), "tools/list"); + const response = await fetch(httpUrl, { + method: "POST", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + }, + body: JSON.stringify([ + { jsonrpc: "2.0", id: 90_001, method: "tools/list", params: {} }, + { jsonrpc: "2.0", id: 90_002, method: "tools/list", params: {} }, + ]), + }); + expect(response.status).toBe(200); + await response.text(); + + const after = operationCount(await (await fetch(metricsUrl)).text(), "tools/list"); + expect(after - before).toBe(2); + }); + test("exports bounded MCP, tool, upstream, and authentication metrics", async () => { const client = await connect("http", "modern"); try { @@ -295,9 +326,30 @@ describe("OpenTelemetry metrics", () => { expect(response.status).toBe(200); const exported = await response.text(); - expect(exported).toMatch( - /context7_mcp_requests_total\{[^}]*mcp_method_name="tools\/call"[^}]*mcp_request_outcome="success"[^}]*\} [1-9]/ - ); + const operationCounts = exported + .split("\n") + .filter((line) => line.startsWith("mcp_server_operation_duration_count{")); + expect( + operationCounts.some( + (line) => + line.includes('mcp_method_name="tools/call"') && + line.includes('gen_ai_tool_name="query-docs"') && + !line.includes("error_type=") + ) + ).toBe(true); + expect( + operationCounts + .filter((line) => line.includes('mcp_method_name="tools/call"')) + .every((line) => !line.includes('error_type="connection_closed"')) + ).toBe(true); + expect( + operationCounts.some( + (line) => + line.includes('mcp_method_name="tools/call"') && + line.includes('gen_ai_tool_name="query-docs"') && + line.includes('error_type="tool_error"') + ) + ).toBe(true); expect(exported).toMatch( /context7_mcp_tool_calls_total\{[^}]*mcp_tool_name="query-docs"[^}]*mcp_tool_outcome="success"[^}]*\} [1-9]/ ); @@ -316,7 +368,7 @@ describe("OpenTelemetry metrics", () => { expect(exported).toMatch( /context7_mcp_authentication_attempts_total\{[^}]*context7_authentication_outcome="missing"[^}]*\} 1/ ); - expect(exported).toContain("context7_mcp_request_duration_bucket"); + expect(exported).toContain("mcp_server_operation_duration_bucket"); expect(exported).toMatch(/target_info\{[^}]*service_name="context7-mcp"/); expect(exported).not.toMatch(/(?:\{|,)(?:api_key|client_ip|library_id|query|session_id)="/i); diff --git a/packages/mcp/test/mcp-telemetry-lifecycle.test.ts b/packages/mcp/test/mcp-telemetry-lifecycle.test.ts new file mode 100644 index 000000000..6cac4e1cc --- /dev/null +++ b/packages/mcp/test/mcp-telemetry-lifecycle.test.ts @@ -0,0 +1,249 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest"; +import { + type JSONRPCMessage, + type MessageExtraInfo, + type Transport, + type TransportSendOptions, +} from "@modelcontextprotocol/server"; +import { SpanStatusCode, propagation, trace } from "@opentelemetry/api"; +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from "@opentelemetry/sdk-trace-base"; +import { InstrumentedMcpServer, classifyServerResponse } from "../src/lib/mcp-telemetry.js"; + +interface Deferred { + promise: Promise; + reject: (reason?: unknown) => void; + resolve: (value: T | PromiseLike) => void; +} + +function deferred(): Deferred { + let resolve!: Deferred["resolve"]; + let reject!: Deferred["reject"]; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +class ControlledTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: T, extra?: MessageExtraInfo) => void; + readonly sent: JSONRPCMessage[] = []; + sendOperation: (message: JSONRPCMessage, options?: TransportSendOptions) => Promise = + async () => undefined; + + async start(): Promise {} + + send(message: JSONRPCMessage, options?: TransportSendOptions): Promise { + this.sent.push(message); + return this.sendOperation(message, options); + } + + async close(): Promise { + this.onclose?.(); + } + + receive(message: JSONRPCMessage): void { + this.onmessage?.(message); + } + + triggerClose(): void { + this.onclose?.(); + } +} + +const spanExporter = new InMemorySpanExporter(); +const tracerProvider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(spanExporter)], +}); + +beforeAll(() => { + expect(trace.setGlobalTracerProvider(tracerProvider)).toBe(true); +}); + +beforeEach(() => { + spanExporter.reset(); +}); + +afterAll(async () => { + await tracerProvider.shutdown(); + trace.disable(); + propagation.disable(); +}); + +async function eventually(assertion: () => void): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < 50; attempt += 1) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setImmediate(resolve)); + } + } + throw lastError; +} + +function serverFor(transport: ControlledTransport, requestInfo?: Request): InstrumentedMcpServer { + const server = new InstrumentedMcpServer( + { name: "telemetry-lifecycle-test", version: "1.0.0" }, + {}, + { era: "legacy", requestInfo } + ); + server.server.onerror = () => undefined; + void server.connect(transport); + return server; +} + +function responseFor(id: string | number, code: number): JSONRPCMessage { + return { + jsonrpc: "2.0", + id, + error: { code, message: `error ${code}` }, + }; +} + +describe("MCP server response classification", () => { + test.each([-32700, -32600, -32601, -32602, -32002])( + "treats caller fault %i as a non-server error", + (code) => { + expect(classifyServerResponse(responseFor(1, code), "tools/call")).toEqual({ + errorType: undefined, + rpcStatusCode: String(code), + statusDescription: `error ${code}`, + }); + } + ); + + test("classifies internal JSON-RPC and logical tool errors", () => { + expect(classifyServerResponse(responseFor(1, -32603), "tools/call")).toMatchObject({ + errorType: "-32603", + rpcStatusCode: "-32603", + }); + expect( + classifyServerResponse( + { + jsonrpc: "2.0", + id: 1, + result: { content: [{ type: "text", text: "failed" }], isError: true }, + }, + "tools/call" + ) + ).toEqual({ errorType: "tool_error" }); + }); +}); + +describe("MCP operation lifecycle", () => { + test("records caller faults without marking the server span as failed", async () => { + const transport = new ControlledTransport(); + const server = serverFor(transport); + await eventually(() => expect(transport.onmessage).toBeTypeOf("function")); + + transport.receive({ jsonrpc: "2.0", id: 1, method: "not/a-real-method" }); + await eventually(() => expect(transport.sent).toHaveLength(1)); + await tracerProvider.forceFlush(); + + const span = spanExporter.getFinishedSpans().find((candidate) => candidate.name === "unknown"); + expect(span?.status.code).toBe(SpanStatusCode.UNSET); + expect(span?.attributes).toMatchObject({ "rpc.response.status_code": "-32601" }); + expect(span?.attributes).not.toHaveProperty("error.type"); + await server.close(); + }); + + test("marks true server failures and records the JSON-RPC status on the span", async () => { + const transport = new ControlledTransport(); + const server = serverFor(transport); + server.server.setRequestHandler("ping", async () => { + throw new Error("handler exploded"); + }); + await eventually(() => expect(transport.onmessage).toBeTypeOf("function")); + + transport.receive({ jsonrpc: "2.0", id: 2, method: "ping" }); + await eventually(() => expect(transport.sent).toHaveLength(1)); + await tracerProvider.forceFlush(); + + const span = spanExporter.getFinishedSpans().find((candidate) => candidate.name === "ping"); + expect(span?.status).toMatchObject({ code: SpanStatusCode.ERROR, message: "handler exploded" }); + expect(span?.attributes).toMatchObject({ + "error.type": "-32603", + "rpc.response.status_code": "-32603", + }); + await server.close(); + }); + + test("finishes the target operation when a cancellation notification arrives", async () => { + const transport = new ControlledTransport(); + const handler = deferred>(); + const server = serverFor(transport); + server.server.setRequestHandler("ping", () => handler.promise); + await eventually(() => expect(transport.onmessage).toBeTypeOf("function")); + + transport.receive({ jsonrpc: "2.0", id: 3, method: "ping" }); + transport.receive({ + jsonrpc: "2.0", + method: "notifications/cancelled", + params: { requestId: 3 }, + }); + await tracerProvider.forceFlush(); + + const target = spanExporter.getFinishedSpans().find((candidate) => candidate.name === "ping"); + expect(target?.status.code).toBe(SpanStatusCode.ERROR); + expect(target?.attributes["error.type"]).toBe("cancelled"); + handler.resolve({}); + await server.close(); + }); + + test("finishes an HTTP operation when the request stream is aborted", async () => { + const transport = new ControlledTransport(); + const handler = deferred>(); + const abortController = new AbortController(); + const server = serverFor( + transport, + new Request("http://127.0.0.1/mcp", { signal: abortController.signal }) + ); + server.server.setRequestHandler("ping", () => handler.promise); + await eventually(() => expect(transport.onmessage).toBeTypeOf("function")); + + transport.receive({ jsonrpc: "2.0", id: 4, method: "ping" }); + abortController.abort(); + await tracerProvider.forceFlush(); + + const target = spanExporter.getFinishedSpans().find((candidate) => candidate.name === "ping"); + expect(target?.attributes["error.type"]).toBe("cancelled"); + handler.resolve({}); + await server.close(); + }); + + test("lets an in-progress send settle before classifying a close", async () => { + const transport = new ControlledTransport(); + const send = deferred(); + transport.sendOperation = () => send.promise; + const server = serverFor(transport); + await eventually(() => expect(transport.onmessage).toBeTypeOf("function")); + + transport.receive({ jsonrpc: "2.0", id: 5, method: "ping" }); + await eventually(() => expect(transport.sent).toHaveLength(1)); + transport.triggerClose(); + await tracerProvider.forceFlush(); + expect(spanExporter.getFinishedSpans().some((candidate) => candidate.name === "ping")).toBe( + false + ); + + send.reject(new Error("broken output")); + await eventually(() => + expect( + spanExporter.getFinishedSpans().find((candidate) => candidate.name === "ping") + ).toBeDefined() + ); + const span = spanExporter.getFinishedSpans().find((candidate) => candidate.name === "ping"); + expect(span?.status.code).toBe(SpanStatusCode.ERROR); + expect(span?.attributes["error.type"]).toBe("transport_error"); + await server.close(); + }); +}); diff --git a/packages/mcp/test/telemetry.test.ts b/packages/mcp/test/telemetry.test.ts index 75fcc3dce..85c9d55ca 100644 --- a/packages/mcp/test/telemetry.test.ts +++ b/packages/mcp/test/telemetry.test.ts @@ -1,18 +1,148 @@ -import { describe, expect, test } from "vitest"; -import { getMcpMethod } from "../src/lib/telemetry.js"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { Client } from "@modelcontextprotocol/client"; +import { InMemoryTransport, type JSONRPCMessage } from "@modelcontextprotocol/server"; +import { + ROOT_CONTEXT, + SpanKind, + SpanStatusCode, + context, + propagation, + trace, +} from "@opentelemetry/api"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; +import { W3CTraceContextPropagator } from "@opentelemetry/core"; +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from "@opentelemetry/sdk-trace-base"; +import { z } from "zod"; +import { + InstrumentedMcpServer, + mcpRouteFromUrl, + mcpTraceCarrier, + normalizeMcpMethodName, + normalizeMcpToolName, +} from "../src/lib/mcp-telemetry.js"; -describe("getMcpMethod", () => { - test("prefers the modern MCP method header", () => { - expect(getMcpMethod("tools/call", { method: "tools/list" })).toBe("tools/call"); +const spanExporter = new InMemorySpanExporter(); +const tracerProvider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(spanExporter)], +}); +const contextManager = new AsyncLocalStorageContextManager(); + +beforeAll(() => { + expect(context.setGlobalContextManager(contextManager.enable())).toBe(true); + expect(trace.setGlobalTracerProvider(tracerProvider)).toBe(true); + expect(propagation.setGlobalPropagator(new W3CTraceContextPropagator())).toBe(true); +}); + +afterAll(async () => { + await tracerProvider.shutdown(); + contextManager.disable(); + context.disable(); + trace.disable(); + propagation.disable(); +}); + +describe("MCP telemetry cardinality", () => { + test("retains standard MCP methods", () => { + expect(normalizeMcpMethodName("tools/call")).toBe("tools/call"); + expect(normalizeMcpMethodName("completion/complete")).toBe("completion/complete"); + }); + + test("collapses untrusted method names", () => { + expect(normalizeMcpMethodName("attacker-controlled-method")).toBe("unknown"); + expect(normalizeMcpMethodName(undefined)).toBe("unknown"); }); - test("falls back to a legacy JSON-RPC body", () => { - expect(getMcpMethod(undefined, { jsonrpc: "2.0", method: "tools/list" })).toBe("tools/list"); + test("retains only registered Context7 tool names", () => { + expect(normalizeMcpToolName("query-docs")).toBe("query-docs"); + expect(normalizeMcpToolName("resolve-library-id")).toBe("resolve-library-id"); + expect(normalizeMcpToolName("attacker-controlled-tool")).toBe("unknown"); }); - test("uses bounded labels for batches and untrusted method names", () => { - expect(getMcpMethod(undefined, [{ method: "tools/list" }])).toBe("batch"); - expect(getMcpMethod("attacker-controlled-method", {})).toBe("unknown"); - expect(getMcpMethod(undefined, null)).toBe("unknown"); + test("labels both protected Express route spellings as OAuth", () => { + expect(mcpRouteFromUrl("https://example.com/mcp/oauth")).toBe("oauth"); + expect(mcpRouteFromUrl("https://example.com/mcp/oauth/")).toBe("oauth"); + expect(mcpRouteFromUrl("https://example.com/mcp")).toBe("anonymous"); + }); + + test("extracts only SEP-414 trace propagation fields", () => { + const message: JSONRPCMessage = { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "query-docs", + _meta: { + traceparent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", + tracestate: "vendor=value", + baggage: "tenant=example", + apiKey: "must-not-propagate", + }, + }, + }; + + expect(mcpTraceCarrier(message)).toEqual({ + traceparent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", + tracestate: "vendor=value", + baggage: "tenant=example", + }); + }); +}); + +describe("MCP trace instrumentation", () => { + test("creates a semantic server span parented by SEP-414 trace context", async () => { + const server = new InstrumentedMcpServer( + { name: "trace-test", version: "1.0.0" }, + { capabilities: { tools: {} } }, + { era: "legacy" } + ); + server.registerTool( + "query-docs", + { description: "trace test tool", inputSchema: z.object({}) }, + async () => ({ content: [{ type: "text", text: "ok" }] }) + ); + + const client = new Client({ name: "trace-test-client", version: "1.0.0" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + try { + const ambientSpan = trace.getTracer("http-test").startSpan("POST /mcp"); + await context.with(trace.setSpan(ROOT_CONTEXT, ambientSpan), () => + client.callTool({ + name: "query-docs", + arguments: {}, + _meta: { + traceparent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", + }, + }) + ); + ambientSpan.end(); + await tracerProvider.forceFlush(); + + const span = spanExporter + .getFinishedSpans() + .find((candidate) => candidate.name === "tools/call query-docs"); + expect(span).toBeDefined(); + expect(span?.kind).toBe(SpanKind.SERVER); + expect(span?.status.code).toBe(SpanStatusCode.UNSET); + expect(span?.attributes).toMatchObject({ + "gen_ai.operation.name": "execute_tool", + "gen_ai.tool.name": "query-docs", + "mcp.method.name": "tools/call", + "network.transport": "pipe", + }); + expect(span?.spanContext().traceId).toBe("4bf92f3577b34da6a3ce929d0e0e4736"); + expect(span?.parentSpanContext?.spanId).toBe("00f067aa0ba902b7"); + expect(span?.links).toHaveLength(1); + expect(span?.links[0].context.spanId).toBe(ambientSpan.spanContext().spanId); + } finally { + await client.close(); + await server.close(); + } }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f4dd1eff..9df239b04 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -149,6 +149,15 @@ importers: '@modelcontextprotocol/client': specifier: 2.0.0 version: 2.0.0 + '@opentelemetry/context-async-hooks': + specifier: ^2.10.0 + version: 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': + specifier: ^2.10.0 + version: 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': + specifier: ^2.10.0 + version: 2.10.0(@opentelemetry/api@1.9.1) '@types/node': specifier: ^25.0.3 version: 25.0.3 @@ -1118,6 +1127,12 @@ packages: resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} + '@opentelemetry/context-async-hooks@2.10.0': + resolution: {integrity: sha512-bvyMcgLEkozzSzpEEEo1OMoeQ97bxj6Qs2uN3mPrSdDvObMI1myffD/BPqcLlzZO9//d1SqQA/WPw7Cz2AiqhA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/core@2.10.0': resolution: {integrity: sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==} engines: {node: ^18.19.0 || >=20.6.0} @@ -1142,6 +1157,18 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.9.0 <1.10.0' + '@opentelemetry/sdk-trace-base@2.10.0': + resolution: {integrity: sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace@2.10.0': + resolution: {integrity: sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/semantic-conventions@1.43.0': resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} engines: {node: '>=14'} @@ -4207,6 +4234,10 @@ snapshots: '@opentelemetry/api@1.9.1': {} + '@opentelemetry/context-async-hooks@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -4232,6 +4263,21 @@ snapshots: '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-trace@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/semantic-conventions@1.43.0': {} '@pkgr/core@0.3.6': {} From e114a9a16bdd7eb145e9f302d6afaf5e6a48d471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Sun, 16 Aug 2026 14:31:49 +0300 Subject: [PATCH 03/10] feat(mcp): expand operational telemetry --- packages/mcp/README.md | 23 ++- packages/mcp/package.json | 1 + packages/mcp/src/index.ts | 83 +++++---- packages/mcp/src/lib/api.ts | 13 +- packages/mcp/src/lib/mcp-telemetry.ts | 71 ++++++-- packages/mcp/src/lib/stdio-shutdown.ts | 82 +++++++++ packages/mcp/src/lib/telemetry.ts | 163 ++++++++++++++++-- packages/mcp/src/lib/types.ts | 1 + packages/mcp/test/integration.test.ts | 55 +++++- .../mcp/test/mcp-telemetry-lifecycle.test.ts | 161 ++++++++++++++++- packages/mcp/test/stdio-shutdown.test.ts | 99 +++++++++++ packages/mcp/test/telemetry.test.ts | 38 ++++ pnpm-lock.yaml | 72 ++++++++ 13 files changed, 794 insertions(+), 68 deletions(-) create mode 100644 packages/mcp/src/lib/stdio-shutdown.ts create mode 100644 packages/mcp/test/stdio-shutdown.test.ts diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 6014ad6aa..aa44f396d 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -1504,7 +1504,8 @@ for server metrics and spans. Trace context is extracted from the `traceparent`, The HTTP transport exposes metrics in Prometheus format on a dedicated internal listener at `0.0.0.0:9464/metrics`. The stdio transport does not open a telemetry port. Keeping this listener separate from the public MCP port prevents the metrics endpoint from being routed through a -catch-all gateway rule. +catch-all gateway rule. On stdio EOF or SIGHUP, the server closes the SDK connection, records the +session duration, and best-effort flushes an externally installed SDK `MeterProvider` before exit. The exporter uses the standard OpenTelemetry Prometheus settings: @@ -1517,17 +1518,29 @@ starting. If a Node preload has already registered global OpenTelemetry provider precedence. The embedded Prometheus listener is not started when a global `MeterProvider` exists, and MCP spans are exported through the preload's `TracerProvider`. This supports an OpenTelemetry Node SDK or Kubernetes auto-instrumentation without creating a second provider in the application. +When an external SDK owns the provider, configure its Node runtime instrumentation there as well; +the application does not register a duplicate collector. It reports bounded-cardinality counters, histograms, and in-flight gauges for MCP methods, tool -calls, authentication outcomes, and Context7 upstream requests. Prometheus receives these metric -families: +calls, authentication outcomes, Context7 upstream requests, and Node runtime saturation. +Prometheus receives these metric families: - `mcp_server_operation_duration` (its `_count` series is the MCP operation count) +- `mcp_server_session_duration` for real stateful stdio sessions (stateless HTTP request transports + are intentionally excluded) - `context7_mcp_operations_active` - `context7_mcp_tool_calls_total` and `context7_mcp_tool_call_duration` - `context7_mcp_upstream_requests_total` and `context7_mcp_upstream_request_duration` -- `context7_mcp_authentication_attempts_total` -- `context7_mcp_tool_calls_active` and `context7_mcp_upstream_requests_active` +- `context7_mcp_authentication_attempts_total` and `context7_mcp_authentication_duration` +- `context7_mcp_tool_calls_active`, `context7_mcp_upstream_requests_active`, and + `context7_mcp_authentication_active` +- `nodejs_eventloop_*`, `v8js_gc_duration`, `v8js_memory_heap_*`, and + `v8js_resource_active` from the official OpenTelemetry Node runtime instrumentation + +Tool outcomes distinguish `success`, `not_found`, and `error`. Upstream outcomes distinguish +HTTP, response-decoding, network, timeout, and cancellation failures and include both the bounded +status-code class and the exact numeric HTTP status. Authentication reports accepted, missing, +invalid, and unexpected-error outcomes. The labels intentionally exclude API keys, client IPs, queries, library IDs, session IDs, and raw error text. Expose port `9464` only to your Prometheus scraper or `ServiceMonitor`, not through the diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 9134a1af9..bde4e65c1 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -49,6 +49,7 @@ "@modelcontextprotocol/server": "2.0.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/exporter-prometheus": "^0.221.0", + "@opentelemetry/instrumentation-runtime-node": "^0.34.0", "@opentelemetry/resources": "^2.10.0", "@opentelemetry/sdk-metrics": "^2.10.0", "@types/express": "^5.0.4", diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 1f880b4a9..45957fbe8 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -29,15 +29,18 @@ import { import { maybeElicitAuthSignIn } from "./lib/auth/auth-prompt.js"; import { getClientIp } from "./lib/client-ip.js"; import { + forceFlushMetrics, observeToolCall, observeUpstreamRequest, - recordAuthentication, + observeAuthentication, startPrometheusMetrics, } from "./lib/telemetry.js"; import { InstrumentedMcpServer } from "./lib/mcp-telemetry.js"; +import { installStdioShutdown } from "./lib/stdio-shutdown.js"; /** Default HTTP server port */ const DEFAULT_PORT = 3000; +const OAUTH_METADATA_TIMEOUT_MS = 10_000; // Parse CLI arguments using commander const program = new Command() @@ -90,6 +93,8 @@ const CLI_PORT = (() => { const requestContext = new AsyncLocalStorage(); +type AuthenticationResult = { accepted: true } | { accepted: false; error: string }; + // Global state for stdio mode only let stdioApiKey: string | undefined; let stdioClientInfo: { ide?: string; version?: string } | undefined; @@ -251,7 +256,7 @@ IMPORTANT: Do not call this tool more than 3 times per question. If you cannot f const text = searchResponse.error ?? "No libraries found matching the provided name."; maybeElicitAuthSignIn(server, ctx); return { - outcome: searchResponse.error ? ("error" as const) : ("success" as const), + outcome: searchResponse.error ? ("error" as const) : ("not_found" as const), value: { content: [ { @@ -318,7 +323,11 @@ Do not call this tool more than 3 times per question.`, const response = await fetchLibraryContext({ query, libraryId }, ctx); maybeElicitAuthSignIn(server, ctx); return { - outcome: response.error ? ("error" as const) : ("success" as const), + outcome: response.error + ? ("error" as const) + : response.notFound + ? ("not_found" as const) + : ("success" as const), value: { content: [ { @@ -430,35 +439,44 @@ async function main() { ); if (requireAuth) { - if (!apiKey) { - recordAuthentication("missing"); + const authentication = await observeAuthentication(async () => { + if (!apiKey) { + return { + outcome: "missing", + value: { + accepted: false, + error: "Authentication required. Please authenticate to use this MCP server.", + }, + }; + } + + if (isJWT(apiKey)) { + const validationResult = await validateJWT(apiKey); + if (!validationResult.valid) { + return { + outcome: "invalid", + value: { + accepted: false, + error: validationResult.error || "Invalid token. Please re-authenticate.", + }, + }; + } + } + + return { outcome: "accepted", value: { accepted: true } }; + }); + + if (!authentication.accepted) { res.status(401).json({ jsonrpc: "2.0", error: { code: -32001, - message: "Authentication required. Please authenticate to use this MCP server.", + message: authentication.error, }, id: null, }); return; } - - if (isJWT(apiKey)) { - const validationResult = await validateJWT(apiKey); - if (!validationResult.valid) { - recordAuthentication("invalid"); - res.status(401).json({ - jsonrpc: "2.0", - error: { - code: -32001, - message: validationResult.error || "Invalid token. Please re-authenticate.", - }, - id: null, - }); - return; - } - } - recordAuthentication("accepted"); } const context: ClientContext = { @@ -516,13 +534,18 @@ async function main() { const authServerUrl = AUTH_SERVER_URL; try { + const abortSignal = AbortSignal.timeout(OAUTH_METADATA_TIMEOUT_MS); const upstream = await observeUpstreamRequest( "oauth_metadata", - () => fetch(`${authServerUrl}/.well-known/oauth-authorization-server`), + () => + fetch(`${authServerUrl}/.well-known/oauth-authorization-server`, { + signal: abortSignal, + }), async (response) => { if (!response.ok) return { ok: false as const, status: response.status }; return { ok: true as const, metadata: await response.json() }; - } + }, + { abortSignal } ); if (!upstream.ok) { console.error("[OAuth] Upstream error:", upstream.status); @@ -589,11 +612,7 @@ async function main() { stdioApiKey = cliOptions.apiKey || process.env.CONTEXT7_API_KEY; stdioSessionId = randomUUID(); - process.stdin.on("end", () => process.exit(0)); - process.stdin.on("close", () => process.exit(0)); - process.on("SIGHUP", () => process.exit(0)); - - serveStdio( + const stdioHandle = serveStdio( (mcpContext) => { const server = createMcpServer(mcpContext); @@ -615,6 +634,10 @@ async function main() { onerror: (error) => console.error("MCP stdio error:", error), } ); + installStdioShutdown(stdioHandle, { + flush: forceFlushMetrics, + onerror: (error) => console.error("Failed to close MCP stdio server:", error), + }); console.error(`Context7 Documentation MCP Server v${SERVER_VERSION} running on stdio`); } diff --git a/packages/mcp/src/lib/api.ts b/packages/mcp/src/lib/api.ts index 9184fe7a1..b3e6b08a9 100644 --- a/packages/mcp/src/lib/api.ts +++ b/packages/mcp/src/lib/api.ts @@ -127,10 +127,11 @@ export async function searchLibraries( url.searchParams.set("libraryName", libraryName); const headers = generateHeaders(context); + const abortSignal = AbortSignal.timeout(API_TIMEOUT_MS); return await observeUpstreamRequest( "search_libraries", - () => fetch(url, { headers, signal: AbortSignal.timeout(API_TIMEOUT_MS) }), + () => fetch(url, { headers, signal: abortSignal }), async (response) => { readPromptSignal(response, context); if (!response.ok) { @@ -140,7 +141,8 @@ export async function searchLibraries( } const searchData = await response.json(); return searchData as SearchResponse; - } + }, + { abortSignal } ); } catch (error) { const errorMessage = `Error searching libraries: ${error}`; @@ -165,10 +167,11 @@ export async function fetchLibraryContext( url.searchParams.set("libraryId", request.libraryId); const headers = generateHeaders(context); + const abortSignal = AbortSignal.timeout(API_TIMEOUT_MS); return await observeUpstreamRequest( "fetch_context", - () => fetch(url, { headers, signal: AbortSignal.timeout(API_TIMEOUT_MS) }), + () => fetch(url, { headers, signal: abortSignal }), async (response) => { readPromptSignal(response, context); if (!response.ok) { @@ -181,10 +184,12 @@ export async function fetchLibraryContext( if (!text) { return { data: "Documentation not found or not finalized for this library. This might have happened because you used an invalid Context7-compatible library ID. To get a valid Context7-compatible library ID, use the 'resolve-library-id' with the package name you wish to retrieve documentation for.", + notFound: true, }; } return { data: text }; - } + }, + { abortSignal } ); } catch (error) { const errorMessage = `Error fetching library context. Please try again later. ${error}`; diff --git a/packages/mcp/src/lib/mcp-telemetry.ts b/packages/mcp/src/lib/mcp-telemetry.ts index 7602c9714..cc40bb3b3 100644 --- a/packages/mcp/src/lib/mcp-telemetry.ts +++ b/packages/mcp/src/lib/mcp-telemetry.ts @@ -121,6 +121,11 @@ function getInstruments() { unit: "s", advice: { explicitBucketBoundaries: MCP_DURATION_BUCKETS_SECONDS }, }), + sessionDuration: meter.createHistogram("mcp.server.session.duration", { + description: "Duration of a stateful MCP server session", + unit: "s", + advice: { explicitBucketBoundaries: MCP_DURATION_BUCKETS_SECONDS }, + }), activeOperations: meter.createUpDownCounter("context7.mcp.operations.active", { description: "Number of MCP requests and notifications currently being handled", unit: "{operation}", @@ -369,6 +374,12 @@ class InstrumentedTransport implements Transport { private closeHandler: Transport["onclose"]; private errorHandler: Transport["onerror"]; private protocolVersion?: string; + private explicitCloseInProgress = false; + private sendsInFlight = 0; + private sessionErrorType?: string; + private sessionFinishPending = false; + private sessionFinished = false; + private readonly sessionStartedAt = performance.now(); constructor( private readonly transport: Transport, @@ -407,6 +418,7 @@ class InstrumentedTransport implements Transport { this.transport.onclose = () => { this.finishAll("connection_closed"); this.detachAbortHandler(); + if (!this.explicitCloseInProgress) this.requestSessionFinish(); handler?.(); }; } @@ -440,16 +452,28 @@ class InstrumentedTransport implements Transport { this.transport.setSupportedProtocolVersions?.(versions); }; - start(): Promise { - return this.transport.start(); + async start(): Promise { + try { + await this.transport.start(); + } catch (error) { + this.sessionErrorType ??= "transport_error"; + this.requestSessionFinish(); + throw error; + } } async close(): Promise { + this.explicitCloseInProgress = true; try { await this.transport.close(); + } catch (error) { + this.sessionErrorType ??= "transport_error"; + throw error; } finally { + this.explicitCloseInProgress = false; this.finishAll("connection_closed"); this.detachAbortHandler(); + this.requestSessionFinish(); } } @@ -458,25 +482,31 @@ class InstrumentedTransport implements Transport { isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message) ? message.id : undefined; const operation = responseId !== undefined && responseId !== null ? this.inFlight.get(responseId) : undefined; - if (!operation) { - await this.transport.send(message, options); - return; - } - - applyServerResponse(message, operation); - operation.state = "sending"; + this.sendsInFlight += 1; try { + if (!operation) { + await this.transport.send(message, options); + return; + } + + applyServerResponse(message, operation); + operation.state = "sending"; await this.transport.send(message, options); } catch (error) { - if (!operationIsFinished(operation)) { + this.sessionErrorType ??= "transport_error"; + if (operation && !operationIsFinished(operation)) { operation.errorType = "transport_error"; operation.statusDescription = error instanceof Error ? error.message : undefined; if (error instanceof Error) operation.span.recordException(error); } throw error; } finally { - this.removeInFlight(operation); - finishOperation(operation); + if (operation) { + this.removeInFlight(operation); + finishOperation(operation); + } + this.sendsInFlight -= 1; + if (this.sessionFinishPending) this.requestSessionFinish(); } } @@ -546,6 +576,23 @@ class InstrumentedTransport implements Transport { this.abortSignal?.removeEventListener("abort", this.handleAbort); } + private requestSessionFinish(): void { + // HTTP serving is intentionally stateless: every request gets a fresh SDK + // transport, so treating it as an MCP session would only duplicate request + // duration. Stdio owns one real session for the life of the process. + if (this.config.route !== "stdio" || this.sessionFinished) return; + + this.sessionFinishPending = true; + if (this.sendsInFlight > 0) return; + + this.sessionFinished = true; + const attributes: Attributes = { "network.transport": this.config.networkTransport }; + const protocolVersion = this.protocolVersion ?? this.config.protocolVersion; + if (protocolVersion) attributes["mcp.protocol.version"] = protocolVersion; + if (this.sessionErrorType) attributes["error.type"] = this.sessionErrorType; + mcpInstruments().sessionDuration.record(elapsedSeconds(this.sessionStartedAt), attributes); + } + private finishAll(errorType: string, includeSending = false): void { for (const [requestId, operation] of this.inFlight) { // A normal per-request HTTP transport closes its stream from inside send(). diff --git a/packages/mcp/src/lib/stdio-shutdown.ts b/packages/mcp/src/lib/stdio-shutdown.ts new file mode 100644 index 000000000..52b069995 --- /dev/null +++ b/packages/mcp/src/lib/stdio-shutdown.ts @@ -0,0 +1,82 @@ +import type { StdioServerHandle } from "@modelcontextprotocol/server/stdio"; + +const DEFAULT_FLUSH_TIMEOUT_MS = 5_000; + +interface StdioInputLifecycle { + once(event: "close" | "end", listener: () => void): unknown; +} + +interface ProcessSignalLifecycle { + once(event: "SIGHUP", listener: () => void): unknown; +} + +interface StdioShutdownOptions { + exit?: (code: number) => void; + flush?: () => Promise; + flushTimeoutMs?: number; + input?: StdioInputLifecycle; + onerror?: (error: unknown) => void; + signals?: ProcessSignalLifecycle; +} + +/** + * Closes the SDK-owned stdio connection before exiting. This is intentionally + * idempotent because Node commonly emits both `end` and `close` for stdin. + */ +export function installStdioShutdown( + handle: StdioServerHandle, + options: StdioShutdownOptions = {} +): () => void { + const input = options.input ?? process.stdin; + const signals = options.signals ?? process; + const exit = options.exit ?? ((code: number) => process.exit(code)); + const flushTimeoutMs = options.flushTimeoutMs ?? DEFAULT_FLUSH_TIMEOUT_MS; + let shutdownPromise: Promise | undefined; + + const reportError = (error: unknown): void => { + try { + options.onerror?.(error); + } catch { + // A reporting callback must not prevent shutdown. + } + }; + + const shutdown = (): void => { + shutdownPromise ??= (async () => { + let exitCode = 0; + try { + await handle.close(); + } catch (error) { + exitCode = 1; + reportError(error); + } + + try { + if (options.flush) { + let timeout: NodeJS.Timeout | undefined; + try { + await Promise.race([ + options.flush(), + new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error(`OpenTelemetry flush exceeded ${flushTimeoutMs}ms`)), + flushTimeoutMs + ); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } + } + } catch (error) { + reportError(error); + } + exit(exitCode); + })(); + }; + + input.once("end", shutdown); + input.once("close", shutdown); + signals.once("SIGHUP", shutdown); + return shutdown; +} diff --git a/packages/mcp/src/lib/telemetry.ts b/packages/mcp/src/lib/telemetry.ts index 47c809223..ad3027f17 100644 --- a/packages/mcp/src/lib/telemetry.ts +++ b/packages/mcp/src/lib/telemetry.ts @@ -1,5 +1,6 @@ import { metrics, type Attributes } from "@opentelemetry/api"; import { PrometheusExporter } from "@opentelemetry/exporter-prometheus"; +import { RuntimeNodeInstrumentation } from "@opentelemetry/instrumentation-runtime-node"; import { defaultResource, resourceFromAttributes } from "@opentelemetry/resources"; import { MeterProvider } from "@opentelemetry/sdk-metrics"; import { markCurrentMcpOperationError } from "./mcp-telemetry.js"; @@ -7,18 +8,35 @@ import { markCurrentMcpOperationError } from "./mcp-telemetry.js"; const METER_NAME = "io.github.upstash.context7.mcp"; const DEFAULT_PROMETHEUS_HOST = "0.0.0.0"; const DEFAULT_PROMETHEUS_PORT = 9464; +const SHUTDOWN_METRIC_FLUSH_TIMEOUT_MS = 4_000; const DURATION_BUCKETS_SECONDS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60]; export type McpTool = "query-docs" | "resolve-library-id"; export type UpstreamOperation = "fetch_context" | "oauth_metadata" | "search_libraries"; -export type AuthenticationOutcome = "accepted" | "invalid" | "missing"; -export type ToolCallOutcome = "error" | "success"; +export type AuthenticationOutcome = "accepted" | "error" | "invalid" | "missing"; +export type ToolCallOutcome = "error" | "not_found" | "success"; +export type UpstreamOutcome = + | "cancelled" + | "http_error" + | "network_error" + | "response_error" + | "success" + | "timeout"; + +export interface ObservedAuthentication { + outcome: AuthenticationOutcome; + value: T; +} export interface ObservedToolCall { outcome: ToolCallOutcome; value: T; } +export interface UpstreamObservationOptions { + abortSignal?: AbortSignal; +} + function createInstruments() { const meter = metrics.getMeter(METER_NAME); return { @@ -52,10 +70,20 @@ function createInstruments() { description: "Number of authentication attempts on the OAuth-protected MCP endpoint", unit: "{attempt}", }), + authenticationDuration: meter.createHistogram("context7.mcp.authentication.duration", { + description: "Duration of authentication on the OAuth-protected MCP endpoint", + unit: "s", + advice: { explicitBucketBoundaries: DURATION_BUCKETS_SECONDS }, + }), + activeAuthentications: meter.createUpDownCounter("context7.mcp.authentication.active", { + description: "Number of OAuth-protected MCP requests currently authenticating", + unit: "{request}", + }), }; } let instruments: ReturnType | undefined; +let embeddedRuntimeInstrumentation: RuntimeNodeInstrumentation | undefined; function getInstruments(): ReturnType { instruments ??= createInstruments(); @@ -73,6 +101,63 @@ function statusClass(statusCode: number): string { return "unknown"; } +export function classifyUpstreamError( + error: unknown, + abortSignal?: AbortSignal, + fallback: "network_error" | "response_error" = "network_error" +): "cancelled" | "network_error" | "response_error" | "timeout" { + const values: unknown[] = [error]; + if (abortSignal?.aborted) values.push(abortSignal.reason); + + const chain: Array<{ code?: unknown; name?: unknown }> = []; + const seen = new Set(); + for (let value of values) { + for (let depth = 0; value && typeof value === "object" && depth < 8; depth += 1) { + if (seen.has(value)) break; + seen.add(value); + chain.push(value as { code?: unknown; name?: unknown }); + value = (value as { cause?: unknown }).cause; + } + } + + const names = chain.map((value) => value.name).filter((value) => typeof value === "string"); + const codes = chain.map((value) => value.code).filter((value) => typeof value === "string"); + if ( + names.includes("TimeoutError") || + codes.some((code) => + [ + "ETIMEDOUT", + "UND_ERR_BODY_TIMEOUT", + "UND_ERR_CONNECT_TIMEOUT", + "UND_ERR_HEADERS_TIMEOUT", + ].includes(code) + ) + ) { + return "timeout"; + } + if (names.includes("AbortError") || codes.includes("ABORT_ERR") || abortSignal?.aborted) { + return "cancelled"; + } + if ( + codes.some( + (code) => + code.startsWith("UND_ERR_") || + [ + "EAI_AGAIN", + "ECONNREFUSED", + "ECONNRESET", + "EHOSTUNREACH", + "ENETUNREACH", + "ENOTFOUND", + "EPIPE", + ].includes(code) + ) + ) { + return "network_error"; + } + return fallback; +} + export async function observeToolCall( tool: McpTool, operation: () => Promise> @@ -99,40 +184,84 @@ export async function observeToolCall( export async function observeUpstreamRequest( operationName: UpstreamOperation, request: () => Promise, - consumeResponse: (response: Response) => Promise + consumeResponse: (response: Response) => Promise, + options: UpstreamObservationOptions = {} ): Promise { const { activeUpstreamRequests, upstreamRequestDuration, upstreamRequests } = getInstruments(); const activeAttributes: Attributes = { "context7.upstream.operation": operationName }; const startedAt = performance.now(); - let outcome = "network_error"; + let outcome: UpstreamOutcome = "network_error"; + let responseStatus: number | undefined; let responseStatusClass = "none"; activeUpstreamRequests.add(1, activeAttributes); try { - const response = await request(); + let response: Response; + try { + response = await request(); + } catch (error) { + outcome = classifyUpstreamError(error, options.abortSignal); + throw error; + } + responseStatus = response.status; responseStatusClass = statusClass(response.status); outcome = response.ok ? "success" : "http_error"; try { return await consumeResponse(response); } catch (error) { - outcome = "response_error"; + outcome = classifyUpstreamError(error, options.abortSignal, "response_error"); throw error; } } finally { - const attributes = { + const attributes: Attributes = { ...activeAttributes, "http.response.status_code_class": responseStatusClass, "context7.upstream.outcome": outcome, }; + if (responseStatus !== undefined) { + attributes["http.response.status_code"] = responseStatus; + } activeUpstreamRequests.add(-1, activeAttributes); upstreamRequests.add(1, attributes); upstreamRequestDuration.record(elapsedSeconds(startedAt), attributes); } } -export function recordAuthentication(outcome: AuthenticationOutcome): void { - const { authenticationAttempts } = getInstruments(); - authenticationAttempts.add(1, { "context7.authentication.outcome": outcome }); +export async function forceFlushMetrics(): Promise { + const provider = metrics.getMeterProvider() as { + forceFlush?: (options?: { timeoutMillis?: number }) => Promise; + }; + if (!provider.forceFlush) return; + + try { + await provider.forceFlush.call(provider, { + timeoutMillis: SHUTDOWN_METRIC_FLUSH_TIMEOUT_MS, + }); + } catch (error) { + console.error("OpenTelemetry metrics failed to flush during shutdown:", error); + } +} + +export async function observeAuthentication( + operation: () => Promise> +): Promise { + const { activeAuthentications, authenticationAttempts, authenticationDuration } = + getInstruments(); + const activeAttributes: Attributes = { "context7.mcp.route": "oauth" }; + const startedAt = performance.now(); + let outcome: AuthenticationOutcome = "error"; + activeAuthentications.add(1, activeAttributes); + + try { + const observed = await operation(); + outcome = observed.outcome; + return observed.value; + } finally { + const attributes = { "context7.authentication.outcome": outcome }; + activeAuthentications.add(-1, activeAttributes); + authenticationAttempts.add(1, attributes); + authenticationDuration.record(elapsedSeconds(startedAt), attributes); + } } function prometheusIsEnabled(environment: NodeJS.ProcessEnv): boolean { @@ -197,6 +326,20 @@ export async function startPrometheusMetrics( } await exporter.startServer(); + try { + // Keep the native 10 ms precision. The same setting controls the + // monitorEventLoopDelay resolution, so increasing it to the scrape + // interval would make healthy delay percentiles appear artificially high. + embeddedRuntimeInstrumentation = new RuntimeNodeInstrumentation(); + embeddedRuntimeInstrumentation.setMeterProvider(provider); + embeddedRuntimeInstrumentation.enable(); + } catch (error) { + // Application metrics remain useful if a particular Node runtime cannot + // provide one of the optional process-level collectors. + embeddedRuntimeInstrumentation?.disable(); + embeddedRuntimeInstrumentation = undefined; + console.error("OpenTelemetry Node runtime metrics failed to start:", error); + } console.error(`OpenTelemetry metrics available at http://${host}:${port}/metrics`); return provider; } catch (error) { diff --git a/packages/mcp/src/lib/types.ts b/packages/mcp/src/lib/types.ts index c3b33f06d..1bac91ec7 100644 --- a/packages/mcp/src/lib/types.ts +++ b/packages/mcp/src/lib/types.ts @@ -31,6 +31,7 @@ export type ContextRequest = { export type ContextResponse = { data: string; error?: true; + notFound?: true; }; export interface ClientContext { diff --git a/packages/mcp/test/integration.test.ts b/packages/mcp/test/integration.test.ts index e84f041ea..244897b78 100644 --- a/packages/mcp/test/integration.test.ts +++ b/packages/mcp/test/integration.test.ts @@ -19,6 +19,8 @@ const PKG_ROOT = path.resolve(fileURLToPath(new URL(".", import.meta.url)), ".." const DIST = path.join(PKG_ROOT, "dist", "index.js"); const BASE_PORT = 43117; const STUB_DOCS = "stub docs text"; +const EMPTY_CONTEXT_QUERY = "force-empty-context"; +const NO_RESULTS_QUERY = "force-no-results"; const UPSTREAM_ERROR_QUERY = "force-upstream-error"; const INVALID_JSON_QUERY = "force-invalid-json"; @@ -68,6 +70,10 @@ function startStubApi(): Promise { res.end("not-json"); return; } + if (url.searchParams.get("query") === NO_RESULTS_QUERY) { + res.end(JSON.stringify({ results: [] })); + return; + } res.end( JSON.stringify({ results: [ @@ -92,7 +98,7 @@ function startStubApi(): Promise { return; } res.setHeader("Content-Type", "text/plain"); - res.end(STUB_DOCS); + res.end(url.searchParams.get("query") === EMPTY_CONTEXT_QUERY ? "" : STUB_DOCS); } else { res.statusCode = 404; res.end(); @@ -309,6 +315,14 @@ describe("OpenTelemetry metrics", () => { name: "resolve-library-id", arguments: { libraryName: "Next.js", query: INVALID_JSON_QUERY }, }); + await client.callTool({ + name: "resolve-library-id", + arguments: { libraryName: "does-not-exist", query: NO_RESULTS_QUERY }, + }); + await client.callTool({ + name: "query-docs", + arguments: { libraryId: "/missing/library", query: EMPTY_CONTEXT_QUERY }, + }); } finally { await client.close(); } @@ -322,9 +336,21 @@ describe("OpenTelemetry metrics", () => { }); expect(unauthorizedResponse.status).toBe(401); - const response = await fetch(metricsUrl); - expect(response.status).toBe(200); - const exported = await response.text(); + const acceptedResponse = await fetch(protectedUrl, { + method: "DELETE", + headers: { authorization: "Bearer ctx7sk-local-test" }, + }); + expect(acceptedResponse.status).toBe(405); + await acceptedResponse.text(); + + let exported = ""; + for (let attempt = 0; attempt < 30; attempt += 1) { + const response = await fetch(metricsUrl); + expect(response.status).toBe(200); + exported = await response.text(); + if (exported.includes("nodejs_eventloop_utilization")) break; + await new Promise((resolve) => setTimeout(resolve, 100)); + } const operationCounts = exported .split("\n") @@ -359,17 +385,38 @@ describe("OpenTelemetry metrics", () => { expect(exported).toMatch( /context7_mcp_tool_calls_total\{[^}]*mcp_tool_name="query-docs"[^}]*mcp_tool_outcome="error"[^}]*\} [1-9]/ ); + expect(exported).toMatch( + /context7_mcp_tool_calls_total\{[^}]*mcp_tool_outcome="not_found"[^}]*\} [1-9]/ + ); expect(exported).toMatch( /context7_mcp_upstream_requests_total\{[^}]*context7_upstream_operation="fetch_context"[^}]*http_response_status_code_class="5xx"[^}]*context7_upstream_outcome="http_error"[^}]*\} [1-9]/ ); expect(exported).toMatch( /context7_mcp_upstream_requests_total\{[^}]*context7_upstream_operation="search_libraries"[^}]*http_response_status_code_class="2xx"[^}]*context7_upstream_outcome="response_error"[^}]*\} [1-9]/ ); + expect( + exported + .split("\n") + .some( + (line) => + line.startsWith("context7_mcp_upstream_requests_total{") && + line.includes('context7_upstream_operation="fetch_context"') && + line.includes('http_response_status_code="503"') + ) + ).toBe(true); expect(exported).toMatch( /context7_mcp_authentication_attempts_total\{[^}]*context7_authentication_outcome="missing"[^}]*\} 1/ ); + expect(exported).toMatch( + /context7_mcp_authentication_attempts_total\{[^}]*context7_authentication_outcome="accepted"[^}]*\} 1/ + ); + expect(exported).toContain("context7_mcp_authentication_duration_count"); + expect(exported).toContain("context7_mcp_authentication_active"); expect(exported).toContain("mcp_server_operation_duration_bucket"); expect(exported).toMatch(/target_info\{[^}]*service_name="context7-mcp"/); + expect(exported).toContain("v8js_memory_heap_used"); + expect(exported).toContain("nodejs_eventloop_utilization"); + expect(exported).not.toContain("mcp_server_session_duration"); expect(exported).not.toMatch(/(?:\{|,)(?:api_key|client_ip|library_id|query|session_id)="/i); diff --git a/packages/mcp/test/mcp-telemetry-lifecycle.test.ts b/packages/mcp/test/mcp-telemetry-lifecycle.test.ts index 6cac4e1cc..9391b77e5 100644 --- a/packages/mcp/test/mcp-telemetry-lifecycle.test.ts +++ b/packages/mcp/test/mcp-telemetry-lifecycle.test.ts @@ -5,13 +5,21 @@ import { type Transport, type TransportSendOptions, } from "@modelcontextprotocol/server"; -import { SpanStatusCode, propagation, trace } from "@opentelemetry/api"; +import { SpanStatusCode, metrics, propagation, trace } from "@opentelemetry/api"; +import { + AggregationTemporality, + DataPointType, + InMemoryMetricExporter, + MeterProvider, + PeriodicExportingMetricReader, +} from "@opentelemetry/sdk-metrics"; import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor, } from "@opentelemetry/sdk-trace-base"; import { InstrumentedMcpServer, classifyServerResponse } from "../src/lib/mcp-telemetry.js"; +import { observeUpstreamRequest, type UpstreamOperation } from "../src/lib/telemetry.js"; interface Deferred { promise: Promise; @@ -34,6 +42,7 @@ class ControlledTransport implements Transport { onerror?: (error: Error) => void; onmessage?: (message: T, extra?: MessageExtraInfo) => void; readonly sent: JSONRPCMessage[] = []; + closeOperation: () => Promise = async () => undefined; sendOperation: (message: JSONRPCMessage, options?: TransportSendOptions) => Promise = async () => undefined; @@ -46,6 +55,7 @@ class ControlledTransport implements Transport { async close(): Promise { this.onclose?.(); + await this.closeOperation(); } receive(message: JSONRPCMessage): void { @@ -55,15 +65,29 @@ class ControlledTransport implements Transport { triggerClose(): void { this.onclose?.(); } + + triggerError(error: Error): void { + this.onerror?.(error); + } } const spanExporter = new InMemorySpanExporter(); const tracerProvider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(spanExporter)], }); +const metricExporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE); +const metricProvider = new MeterProvider({ + readers: [ + new PeriodicExportingMetricReader({ + exporter: metricExporter, + exportIntervalMillis: 60_000, + }), + ], +}); beforeAll(() => { expect(trace.setGlobalTracerProvider(tracerProvider)).toBe(true); + expect(metrics.setGlobalMeterProvider(metricProvider)).toBe(true); }); beforeEach(() => { @@ -71,7 +95,9 @@ beforeEach(() => { }); afterAll(async () => { + await metricProvider.shutdown(); await tracerProvider.shutdown(); + metrics.disable(); trace.disable(); propagation.disable(); }); @@ -90,17 +116,56 @@ async function eventually(assertion: () => void): Promise { throw lastError; } -function serverFor(transport: ControlledTransport, requestInfo?: Request): InstrumentedMcpServer { +function serverFor( + transport: ControlledTransport, + requestInfo?: Request, + era: "legacy" | "modern" = "legacy" +): InstrumentedMcpServer { const server = new InstrumentedMcpServer( { name: "telemetry-lifecycle-test", version: "1.0.0" }, {}, - { era: "legacy", requestInfo } + { era, requestInfo } ); server.server.onerror = () => undefined; void server.connect(transport); return server; } +function sessionObservationCount(): number { + const latest = metricExporter.getMetrics().at(-1); + const metric = latest?.scopeMetrics + .flatMap((scope) => scope.metrics) + .find((candidate) => candidate.descriptor.name === "mcp.server.session.duration"); + if (!metric || metric.dataPointType !== DataPointType.HISTOGRAM) return 0; + return metric.dataPoints.reduce((total, point) => total + point.value.count, 0); +} + +function sessionErrorObservationCount(errorType: string): number { + const latest = metricExporter.getMetrics().at(-1); + const metric = latest?.scopeMetrics + .flatMap((scope) => scope.metrics) + .find((candidate) => candidate.descriptor.name === "mcp.server.session.duration"); + if (!metric || metric.dataPointType !== DataPointType.HISTOGRAM) return 0; + return metric.dataPoints + .filter((point) => point.attributes["error.type"] === errorType) + .reduce((total, point) => total + point.value.count, 0); +} + +function upstreamObservationCount(operation: UpstreamOperation, outcome: string): number { + const latest = metricExporter.getMetrics().at(-1); + const metric = latest?.scopeMetrics + .flatMap((scope) => scope.metrics) + .find((candidate) => candidate.descriptor.name === "context7.mcp.upstream.requests"); + if (!metric || metric.dataPointType !== DataPointType.SUM) return 0; + return metric.dataPoints + .filter( + (point) => + point.attributes["context7.upstream.operation"] === operation && + point.attributes["context7.upstream.outcome"] === outcome + ) + .reduce((total, point) => total + point.value, 0); +} + function responseFor(id: string | number, code: number): JSONRPCMessage { return { jsonrpc: "2.0", @@ -140,6 +205,70 @@ describe("MCP server response classification", () => { }); describe("MCP operation lifecycle", () => { + test("records real stdio sessions but not stateless HTTP request transports", async () => { + await metricProvider.forceFlush(); + const before = sessionObservationCount(); + + const stdioTransport = new ControlledTransport(); + const stdioServer = serverFor(stdioTransport, undefined, "modern"); + await eventually(() => expect(stdioTransport.onmessage).toBeTypeOf("function")); + await stdioServer.close(); + await metricProvider.forceFlush(); + + expect(sessionObservationCount()).toBe(before + 1); + const latest = metricExporter.getMetrics().at(-1); + const sessionMetric = latest?.scopeMetrics + .flatMap((scope) => scope.metrics) + .find((candidate) => candidate.descriptor.name === "mcp.server.session.duration"); + expect( + sessionMetric?.dataPoints.some( + (point) => + point.attributes["network.transport"] === "pipe" && + point.attributes["mcp.protocol.version"] === "2026-07-28" + ) + ).toBe(true); + + const httpTransport = new ControlledTransport(); + const httpServer = serverFor(httpTransport, new Request("http://127.0.0.1/mcp")); + await eventually(() => expect(httpTransport.onmessage).toBeTypeOf("function")); + await httpServer.close(); + await metricProvider.forceFlush(); + + expect(sessionObservationCount()).toBe(before + 1); + }); + + test("does not fail a gracefully closed session after a nonfatal transport error event", async () => { + await metricProvider.forceFlush(); + const beforeTotal = sessionObservationCount(); + const beforeErrors = sessionErrorObservationCount("transport_error"); + const transport = new ControlledTransport(); + const server = serverFor(transport, undefined, "modern"); + await eventually(() => expect(transport.onerror).toBeTypeOf("function")); + + transport.triggerError(new Error("reported but recoverable")); + await server.close(); + await metricProvider.forceFlush(); + + expect(sessionObservationCount()).toBe(beforeTotal + 1); + expect(sessionErrorObservationCount("transport_error")).toBe(beforeErrors); + }); + + test("marks a session failed when terminal transport close rejects", async () => { + await metricProvider.forceFlush(); + const beforeErrors = sessionErrorObservationCount("transport_error"); + const transport = new ControlledTransport(); + transport.closeOperation = async () => { + throw new Error("close failed"); + }; + const server = serverFor(transport, undefined, "modern"); + await eventually(() => expect(transport.onmessage).toBeTypeOf("function")); + + await expect(server.close()).rejects.toThrow("close failed"); + await metricProvider.forceFlush(); + + expect(sessionErrorObservationCount("transport_error")).toBe(beforeErrors + 1); + }); + test("records caller faults without marking the server span as failed", async () => { const transport = new ControlledTransport(); const server = serverFor(transport); @@ -247,3 +376,29 @@ describe("MCP operation lifecycle", () => { await server.close(); }); }); + +describe("upstream request lifecycle", () => { + test.each([ + ["fetch_context", "timeout", new DOMException("timed out", "TimeoutError")], + ["search_libraries", "cancelled", new DOMException("cancelled", "AbortError")], + ] as const)("records body-phase %s failures as %s", async (operation, outcome, reason) => { + await metricProvider.forceFlush(); + const before = upstreamObservationCount(operation, outcome); + const abortController = new AbortController(); + + await expect( + observeUpstreamRequest( + operation, + async () => new Response("partial body"), + async () => { + abortController.abort(reason); + throw new DOMException("body aborted", "AbortError"); + }, + { abortSignal: abortController.signal } + ) + ).rejects.toThrow("body aborted"); + await metricProvider.forceFlush(); + + expect(upstreamObservationCount(operation, outcome)).toBe(before + 1); + }); +}); diff --git a/packages/mcp/test/stdio-shutdown.test.ts b/packages/mcp/test/stdio-shutdown.test.ts new file mode 100644 index 000000000..be0c3f9f9 --- /dev/null +++ b/packages/mcp/test/stdio-shutdown.test.ts @@ -0,0 +1,99 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, test } from "vitest"; +import type { StdioServerHandle } from "@modelcontextprotocol/server/stdio"; +import { installStdioShutdown } from "../src/lib/stdio-shutdown.js"; + +async function eventually(assertion: () => void): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < 50; attempt += 1) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 1)); + } + } + throw lastError; +} + +describe("stdio process shutdown", () => { + test("closes and flushes once before a successful exit", async () => { + const events: string[] = []; + const input = new EventEmitter(); + const signals = new EventEmitter(); + const handle: StdioServerHandle = { + close: async () => { + events.push("close"); + }, + }; + + installStdioShutdown(handle, { + exit: (code) => events.push(`exit:${code}`), + flush: async () => { + events.push("flush"); + }, + input, + signals, + }); + input.emit("end"); + input.emit("close"); + signals.emit("SIGHUP"); + + await eventually(() => expect(events).toEqual(["close", "flush", "exit:0"])); + }); + + test("flushes recorded terminal metrics and exits nonzero when close rejects", async () => { + const failure = new Error("close failed"); + const errors: unknown[] = []; + const events: string[] = []; + const input = new EventEmitter(); + const signals = new EventEmitter(); + + installStdioShutdown( + { + close: async () => { + events.push("close"); + throw failure; + }, + }, + { + exit: (code) => events.push(`exit:${code}`), + flush: async () => { + events.push("flush"); + }, + input, + onerror: (error) => errors.push(error), + signals, + } + ); + signals.emit("SIGHUP"); + + await eventually(() => expect(events).toEqual(["close", "flush", "exit:1"])); + expect(errors).toEqual([failure]); + }); + + test("exits after the deadline when an external metrics flush never settles", async () => { + const errors: unknown[] = []; + const exits: number[] = []; + const input = new EventEmitter(); + const signals = new EventEmitter(); + + installStdioShutdown( + { close: async () => undefined }, + { + exit: (code) => exits.push(code), + flush: () => new Promise(() => undefined), + flushTimeoutMs: 10, + input, + onerror: (error) => errors.push(error), + signals, + } + ); + input.emit("end"); + + await eventually(() => expect(exits).toEqual([0])); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ message: "OpenTelemetry flush exceeded 10ms" }); + }); +}); diff --git a/packages/mcp/test/telemetry.test.ts b/packages/mcp/test/telemetry.test.ts index 85c9d55ca..d424db389 100644 --- a/packages/mcp/test/telemetry.test.ts +++ b/packages/mcp/test/telemetry.test.ts @@ -24,6 +24,7 @@ import { normalizeMcpMethodName, normalizeMcpToolName, } from "../src/lib/mcp-telemetry.js"; +import { classifyUpstreamError } from "../src/lib/telemetry.js"; const spanExporter = new InMemorySpanExporter(); const tracerProvider = new BasicTracerProvider({ @@ -92,6 +93,43 @@ describe("MCP telemetry cardinality", () => { }); }); +describe("upstream failure classification", () => { + test("distinguishes timeout, cancellation, and other network failures", () => { + expect(classifyUpstreamError(new DOMException("timed out", "TimeoutError"))).toBe("timeout"); + expect(classifyUpstreamError(new DOMException("cancelled", "AbortError"))).toBe("cancelled"); + expect(classifyUpstreamError(new TypeError("connection refused"))).toBe("network_error"); + }); + + test("finds Undici timeout codes in nested fetch causes", () => { + const cause = Object.assign(new Error("connect timed out"), { + code: "UND_ERR_CONNECT_TIMEOUT", + }); + const failure = Object.assign(new TypeError("fetch failed"), { cause }); + expect(classifyUpstreamError(failure)).toBe("timeout"); + }); + + test("uses abort reasons during response-body consumption", () => { + const timeout = new AbortController(); + timeout.abort(new DOMException("body timed out", "TimeoutError")); + expect( + classifyUpstreamError( + new DOMException("body aborted", "AbortError"), + timeout.signal, + "response_error" + ) + ).toBe("timeout"); + + const cancellation = new AbortController(); + cancellation.abort(); + expect( + classifyUpstreamError(new Error("body stopped"), cancellation.signal, "response_error") + ).toBe("cancelled"); + expect( + classifyUpstreamError(new SyntaxError("invalid JSON"), undefined, "response_error") + ).toBe("response_error"); + }); +}); + describe("MCP trace instrumentation", () => { test("creates a semantic server span parented by SEP-414 trace context", async () => { const server = new InstrumentedMcpServer( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9df239b04..f9194f3b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,6 +121,9 @@ importers: '@opentelemetry/exporter-prometheus': specifier: ^0.221.0 version: 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-runtime-node': + specifier: ^0.34.0 + version: 0.34.0(@opentelemetry/api@1.9.1) '@opentelemetry/resources': specifier: ^2.10.0 version: 2.10.0(@opentelemetry/api@1.9.1) @@ -1119,6 +1122,10 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@opentelemetry/api-logs@0.221.0': + resolution: {integrity: sha512-OlanaW1vv7ufTqQ3/fPLI4arGt5ZoM+P8abOMki6uEYnpRazepSWDwDnnw+la7kE26SHVC18//SMccrDvLKOXQ==} + engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} @@ -1145,6 +1152,18 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/instrumentation-runtime-node@0.34.0': + resolution: {integrity: sha512-Yb3PcmuK/iIOWY49GSEcSGl7fR4r6UqhZnuy5EWvFaqKqqs9SYnQeyQUEAbnBnSougRpwFBDbdN1SSGcvMhbtQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.221.0': + resolution: {integrity: sha512-cCk80Z/iRDf/5gfsKMB4f74LqVA5yKETB/9ojPzVW/6/f70iu89nJvGxsFCxx4XfSohaOofkU19kiYm84AiAlw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/resources@2.10.0': resolution: {integrity: sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==} engines: {node: ^18.19.0 || >=20.6.0} @@ -1745,6 +1764,9 @@ packages: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + cli-boxes@3.0.0: resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} engines: {node: '>=10'} @@ -2256,6 +2278,10 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} + import-in-the-middle@3.3.3: + resolution: {integrity: sha512-AiohS3H80sXO6owEltjGX+glb7qXaDhBoJb9XcQVH4UI207xu/bDLUcadVKp7Qe576reg9yr/PXZjV5qx8gfbA==} + engines: {node: '>=18'} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -2477,6 +2503,9 @@ packages: mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -2748,6 +2777,10 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-in-the-middle@8.0.1: + resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} + engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -4230,6 +4263,10 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 + '@opentelemetry/api-logs@0.221.0': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api@1.9.0': {} '@opentelemetry/api@1.9.1': {} @@ -4251,6 +4288,24 @@ snapshots: '@opentelemetry/sdk-metrics': 2.10.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/instrumentation-runtime-node@0.34.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.221.0 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.221.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.221.0 + import-in-the-middle: 3.3.3 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + '@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -4873,6 +4928,8 @@ snapshots: ci-info@3.9.0: {} + cjs-module-lexer@2.2.0: {} + cli-boxes@3.0.0: {} cli-cursor@5.0.0: @@ -5479,6 +5536,12 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 + import-in-the-middle@3.3.3: + dependencies: + cjs-module-lexer: 2.2.0 + es-module-lexer: 2.3.0 + module-details-from-path: 1.0.4 + imurmurhash@0.1.4: {} inherits@2.0.4: {} @@ -5659,6 +5722,8 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.1 + module-details-from-path@1.0.4: {} + mri@1.2.0: {} ms@2.1.3: {} @@ -5900,6 +5965,13 @@ snapshots: require-from-string@2.0.2: optional: true + require-in-the-middle@8.0.1: + dependencies: + debug: 4.4.3 + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + resolve-from@4.0.0: {} resolve-from@5.0.0: {} From 570b7681b92bcb3aca282c91991f3e53b0e71842 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Sun, 16 Aug 2026 15:22:20 +0300 Subject: [PATCH 04/10] docs(mcp): define Envoy telemetry ownership --- packages/mcp/README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/mcp/README.md b/packages/mcp/README.md index aa44f396d..b19b981b9 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -1546,6 +1546,34 @@ The labels intentionally exclude API keys, client IPs, queries, library IDs, ses error text. Expose port `9464` only to your Prometheus scraper or `ServiceMonitor`, not through the public MCP ingress. +#### Signal ownership with an Envoy gateway + +Do not treat `mcp_server_operation_duration_count` as another HTTP request counter. An Envoy +Gateway observes HTTP envelopes, while this metric observes JSON-RPC requests and notifications +after SDK dispatch. A valid batch is one HTTP request but several MCP operations, and HTTP requests +rejected before MCP dispatch never increment the MCP metric. + +Context7 deliberately does **not** register generic inbound HTTP server metrics. Keep the following +signals in the existing Envoy scrape instead of collecting them again from the application: + +- downstream HTTP request/response totals, status classes, duration, active requests, connections, + resets, and gateway timeouts (`envoy_http_*_downstream_*`) +- Envoy-to-MCP backend request totals, status codes, duration, active/pending requests, connection + failures, retries, resets, timeouts, and circuit-breaker overflows (`envoy_cluster_upstream_*`) +- Envoy process health and resource metrics + +The application exporter owns only signals the ingress gateway cannot provide: MCP method and +protocol semantics (including batches and notifications), tool and authentication outcomes, +MCP-to-Context7 API calls, and Node event-loop/V8 health. In the Kubernetes deployment Envoy is a +Gateway API proxy rather than a sidecar in the MCP pod, so `context7_mcp_upstream_*` describes the +MCP server's outbound Context7 API dependency, not Envoy's inbound MCP backend cluster. Pod and +container CPU, memory, network, and restart metrics should continue to come from the Kubernetes +monitoring stack. + +See the [Envoy HTTP connection manager statistics](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/stats) +and [upstream cluster statistics](https://www.envoyproxy.io/docs/envoy/latest/configuration/upstream/cluster_manager/cluster_stats.html) +for the proxy-owned metric families. + ```yaml scrape_configs: - job_name: context7-mcp From d8bd5c20bf36816d67dd25f9037571113d341b2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Sun, 16 Aug 2026 17:08:26 +0300 Subject: [PATCH 05/10] perf(mcp): minimize telemetry overhead --- packages/mcp/README.md | 12 +- packages/mcp/src/index.ts | 161 ++++++------ packages/mcp/src/lib/api.ts | 8 +- packages/mcp/src/lib/mcp-operation-scope.ts | 23 ++ packages/mcp/src/lib/mcp-telemetry.ts | 246 +++++++++++++----- packages/mcp/src/lib/telemetry-config.ts | 15 ++ packages/mcp/src/lib/telemetry-provider.ts | 85 ++++++ packages/mcp/src/lib/telemetry.ts | 234 ++++------------- packages/mcp/src/lib/tool-names.ts | 7 + packages/mcp/src/lib/types.ts | 5 +- packages/mcp/test/integration.test.ts | 34 ++- .../mcp/test/mcp-telemetry-lifecycle.test.ts | 161 ++++++++++-- packages/mcp/test/telemetry-disabled.test.ts | 57 ++++ packages/mcp/test/telemetry.test.ts | 14 + 14 files changed, 681 insertions(+), 381 deletions(-) create mode 100644 packages/mcp/src/lib/mcp-operation-scope.ts create mode 100644 packages/mcp/src/lib/telemetry-config.ts create mode 100644 packages/mcp/src/lib/telemetry-provider.ts create mode 100644 packages/mcp/src/lib/tool-names.ts create mode 100644 packages/mcp/test/telemetry-disabled.test.ts diff --git a/packages/mcp/README.md b/packages/mcp/README.md index b19b981b9..5afd31e59 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -1522,22 +1522,22 @@ When an external SDK owns the provider, configure its Node runtime instrumentati the application does not register a duplicate collector. It reports bounded-cardinality counters, histograms, and in-flight gauges for MCP methods, tool -calls, authentication outcomes, Context7 upstream requests, and Node runtime saturation. +outcomes, authentication outcomes, Context7 upstream requests, and Node runtime saturation. Prometheus receives these metric families: -- `mcp_server_operation_duration` (its `_count` series is the MCP operation count) +- `mcp_server_operation_duration` (its `_count` series is the MCP operation count, and tool-call + series include the `context7_mcp_tool_outcome` label) - `mcp_server_session_duration` for real stateful stdio sessions (stateless HTTP request transports are intentionally excluded) - `context7_mcp_operations_active` -- `context7_mcp_tool_calls_total` and `context7_mcp_tool_call_duration` - `context7_mcp_upstream_requests_total` and `context7_mcp_upstream_request_duration` - `context7_mcp_authentication_attempts_total` and `context7_mcp_authentication_duration` -- `context7_mcp_tool_calls_active`, `context7_mcp_upstream_requests_active`, and - `context7_mcp_authentication_active` +- `context7_mcp_upstream_requests_active` and `context7_mcp_authentication_active` - `nodejs_eventloop_*`, `v8js_gc_duration`, `v8js_memory_heap_*`, and `v8js_resource_active` from the official OpenTelemetry Node runtime instrumentation -Tool outcomes distinguish `success`, `not_found`, and `error`. Upstream outcomes distinguish +Tool outcomes on the standard MCP operation metric distinguish `success`, `not_found`, and +`error`. Upstream outcomes distinguish HTTP, response-decoding, network, timeout, and cancellation failures and include both the bounded status-code class and the exact numeric HTTP status. Authentication reports accepted, missing, invalid, and unexpected-error outcomes. diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 45957fbe8..6c60d9265 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,8 +1,9 @@ #!/usr/bin/env node import { toNodeHandler } from "@modelcontextprotocol/node"; -import { serveStdio } from "@modelcontextprotocol/server/stdio"; +import { StdioServerTransport, serveStdio } from "@modelcontextprotocol/server/stdio"; import { + McpServer, createMcpHandler, type McpRequestContext, type ServerContext, @@ -30,17 +31,19 @@ import { maybeElicitAuthSignIn } from "./lib/auth/auth-prompt.js"; import { getClientIp } from "./lib/client-ip.js"; import { forceFlushMetrics, - observeToolCall, + recordToolCallOutcome, observeUpstreamRequest, observeAuthentication, - startPrometheusMetrics, } from "./lib/telemetry.js"; -import { InstrumentedMcpServer } from "./lib/mcp-telemetry.js"; +import { QUERY_DOCS_TOOL, RESOLVE_LIBRARY_ID_TOOL } from "./lib/tool-names.js"; +import { embeddedPrometheusIsEnabled, telemetryIsDisabled } from "./lib/telemetry-config.js"; import { installStdioShutdown } from "./lib/stdio-shutdown.js"; /** Default HTTP server port */ const DEFAULT_PORT = 3000; const OAUTH_METADATA_TIMEOUT_MS = 10_000; +const TELEMETRY_DISABLED = telemetryIsDisabled(); +let mcpTelemetry: typeof import("./lib/mcp-telemetry.js") | undefined; // Parse CLI arguments using commander const program = new Command() @@ -161,35 +164,35 @@ function aliasArgs(aliases: AliasMap) { } function createMcpServer(mcpContext: McpRequestContext) { - const server = new InstrumentedMcpServer( - { - name: "Context7", - version: SERVER_VERSION, - websiteUrl: "https://context7.com", - description: - "Context7 provides up-to-date documentation and code examples for libraries and frameworks.", - icons: [ - { - src: "https://context7.com/context7-icon-green.png", - mimeType: "image/png", - }, - ], - }, - { - // Declaring the capabilities makes the SDK install prompts/list, - // resources/list, and resources/templates/list handlers that answer - // with the registered (i.e. empty) collections, for clients that - // request them unconditionally. - capabilities: { prompts: {}, resources: {} }, - instructions: `Use this server to fetch current documentation whenever the user asks about a library, framework, SDK, API, CLI tool, or cloud service — even well-known ones like React, Next.js, Prisma, Express, Tailwind, Django, or Spring Boot. This includes API syntax, configuration, version migration, library-specific debugging, setup instructions, and CLI tool usage. Use even when you think you know the answer — your training data may not reflect recent changes. Prefer this over web search for library docs. + const serverInfo = { + name: "Context7", + version: SERVER_VERSION, + websiteUrl: "https://context7.com", + description: + "Context7 provides up-to-date documentation and code examples for libraries and frameworks.", + icons: [ + { + src: "https://context7.com/context7-icon-green.png", + mimeType: "image/png", + }, + ], + }; + const serverOptions = { + // Declaring the capabilities makes the SDK install prompts/list, + // resources/list, and resources/templates/list handlers that answer + // with the registered (i.e. empty) collections, for clients that + // request them unconditionally. + capabilities: { prompts: {}, resources: {} }, + instructions: `Use this server to fetch current documentation whenever the user asks about a library, framework, SDK, API, CLI tool, or cloud service — even well-known ones like React, Next.js, Prisma, Express, Tailwind, Django, or Spring Boot. This includes API syntax, configuration, version migration, library-specific debugging, setup instructions, and CLI tool usage. Use even when you think you know the answer — your training data may not reflect recent changes. Prefer this over web search for library docs. Do not use for: refactoring, writing scripts from scratch, debugging business logic, code review, or general programming concepts.`, - }, - mcpContext - ); + }; + const server = mcpTelemetry + ? new mcpTelemetry.InstrumentedMcpServer(serverInfo, serverOptions, mcpContext) + : new McpServer(serverInfo, serverOptions); server.registerTool( - "resolve-library-id", + RESOLVE_LIBRARY_ID_TOOL, { title: "Resolve Context7 Library ID", description: `Resolves a package/product name to a Context7-compatible library ID and returns matching libraries. @@ -248,46 +251,40 @@ IMPORTANT: Do not call this tool more than 3 times per question. If you cannot f }, }, async ({ query, libraryName }: { query: string; libraryName: string }, toolCtx) => { - return observeToolCall("resolve-library-id", async () => { - const ctx = getClientContext(toolCtx); - const searchResponse = await searchLibraries(query, libraryName, ctx); - - if (!searchResponse.results || searchResponse.results.length === 0) { - const text = searchResponse.error ?? "No libraries found matching the provided name."; - maybeElicitAuthSignIn(server, ctx); - return { - outcome: searchResponse.error ? ("error" as const) : ("not_found" as const), - value: { - content: [ - { - type: "text" as const, - text, - }, - ], - }, - }; - } + const ctx = getClientContext(toolCtx); + const searchResponse = await searchLibraries(query, libraryName, ctx); - const resultsText = formatSearchResults(searchResponse); - const responseText = `Available Libraries:\n\n${resultsText}`; + if (!searchResponse.results || searchResponse.results.length === 0) { + const text = searchResponse.error ?? "No libraries found matching the provided name."; maybeElicitAuthSignIn(server, ctx); + recordToolCallOutcome(searchResponse.error ? "error" : "not_found"); return { - outcome: "success" as const, - value: { - content: [ - { - type: "text" as const, - text: responseText, - }, - ], - }, + content: [ + { + type: "text" as const, + text, + }, + ], }; - }); + } + + const resultsText = formatSearchResults(searchResponse); + const responseText = `Available Libraries:\n\n${resultsText}`; + maybeElicitAuthSignIn(server, ctx); + recordToolCallOutcome("success"); + return { + content: [ + { + type: "text" as const, + text: responseText, + }, + ], + }; } ); server.registerTool( - "query-docs", + QUERY_DOCS_TOOL, { title: "Query Documentation", description: `Retrieves and queries up-to-date documentation and code examples from Context7 for any programming library or framework. @@ -318,26 +315,18 @@ Do not call this tool more than 3 times per question.`, }, }, async ({ query, libraryId }: { query: string; libraryId: string }, toolCtx) => { - return observeToolCall("query-docs", async () => { - const ctx = getClientContext(toolCtx); - const response = await fetchLibraryContext({ query, libraryId }, ctx); - maybeElicitAuthSignIn(server, ctx); - return { - outcome: response.error - ? ("error" as const) - : response.notFound - ? ("not_found" as const) - : ("success" as const), - value: { - content: [ - { - type: "text" as const, - text: response.data, - }, - ], + const ctx = getClientContext(toolCtx); + const response = await fetchLibraryContext({ query, libraryId }, ctx); + maybeElicitAuthSignIn(server, ctx); + recordToolCallOutcome(response.outcome); + return { + content: [ + { + type: "text" as const, + text: response.data, }, - }; - }); + ], + }; } ); @@ -345,9 +334,14 @@ Do not call this tool more than 3 times per question.`, } async function main() { + if (!TELEMETRY_DISABLED) mcpTelemetry = await import("./lib/mcp-telemetry.js"); + if (TRANSPORT_TYPE === "http") { const initialPort = CLI_PORT ?? DEFAULT_PORT; - await startPrometheusMetrics(SERVER_VERSION); + if (embeddedPrometheusIsEnabled()) { + const { startPrometheusMetrics } = await import("./lib/telemetry-provider.js"); + await startPrometheusMetrics(SERVER_VERSION); + } const app = express(); app.use(express.json()); @@ -611,6 +605,10 @@ async function main() { } else { stdioApiKey = cliOptions.apiKey || process.env.CONTEXT7_API_KEY; stdioSessionId = randomUUID(); + const rawStdioTransport = new StdioServerTransport(); + const stdioTransport = mcpTelemetry + ? mcpTelemetry.instrumentStdioTransport(rawStdioTransport) + : rawStdioTransport; const stdioHandle = serveStdio( (mcpContext) => { @@ -631,6 +629,7 @@ async function main() { return server; }, { + transport: stdioTransport, onerror: (error) => console.error("MCP stdio error:", error), } ); diff --git a/packages/mcp/src/lib/api.ts b/packages/mcp/src/lib/api.ts index b3e6b08a9..3436d9381 100644 --- a/packages/mcp/src/lib/api.ts +++ b/packages/mcp/src/lib/api.ts @@ -177,23 +177,23 @@ export async function fetchLibraryContext( if (!response.ok) { const errorMessage = await parseErrorResponse(response, context.apiKey); console.error(errorMessage); - return { data: errorMessage, error: true }; + return { data: errorMessage, outcome: "error" }; } const text = await response.text(); if (!text) { return { data: "Documentation not found or not finalized for this library. This might have happened because you used an invalid Context7-compatible library ID. To get a valid Context7-compatible library ID, use the 'resolve-library-id' with the package name you wish to retrieve documentation for.", - notFound: true, + outcome: "not_found", }; } - return { data: text }; + return { data: text, outcome: "success" }; }, { abortSignal } ); } catch (error) { const errorMessage = `Error fetching library context. Please try again later. ${error}`; console.error(errorMessage); - return { data: errorMessage, error: true }; + return { data: errorMessage, outcome: "error" }; } } diff --git a/packages/mcp/src/lib/mcp-operation-scope.ts b/packages/mcp/src/lib/mcp-operation-scope.ts new file mode 100644 index 000000000..b0bf2ba4f --- /dev/null +++ b/packages/mcp/src/lib/mcp-operation-scope.ts @@ -0,0 +1,23 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { ToolCallOutcome } from "./tool-names.js"; + +export interface McpOperationErrorTarget { + errorType?: string; + toolOutcome?: ToolCallOutcome; +} + +const operationScope = new AsyncLocalStorage(); + +export function runInMcpOperationScope(target: McpOperationErrorTarget, callback: () => T): T { + return operationScope.run(target, callback); +} + +export function markCurrentMcpOperationError(errorType = "tool_error"): void { + const target = operationScope.getStore(); + if (target) target.errorType = errorType; +} + +export function markCurrentMcpToolOutcome(outcome: ToolCallOutcome): void { + const target = operationScope.getStore(); + if (target) target.toolOutcome = outcome; +} diff --git a/packages/mcp/src/lib/mcp-telemetry.ts b/packages/mcp/src/lib/mcp-telemetry.ts index cc40bb3b3..6f51489ca 100644 --- a/packages/mcp/src/lib/mcp-telemetry.ts +++ b/packages/mcp/src/lib/mcp-telemetry.ts @@ -1,4 +1,3 @@ -import { AsyncLocalStorage } from "node:async_hooks"; import { BAGGAGE_META_KEY, McpServer, @@ -34,6 +33,8 @@ import { type Link, type Span, } from "@opentelemetry/api"; +import { MCP_TOOL_NAMES, type ToolCallOutcome } from "./tool-names.js"; +import { runInMcpOperationScope } from "./mcp-operation-scope.js"; const INSTRUMENTATION_NAME = "io.github.upstash.context7.mcp"; const MCP_DURATION_BUCKETS_SECONDS = [ @@ -75,8 +76,13 @@ const KNOWN_MCP_METHODS = new Set([ "tools/call", "tools/list", ]); -const KNOWN_TOOLS = new Set(["query-docs", "resolve-library-id"]); -const KNOWN_PROTOCOL_VERSIONS = new Set(SUPPORTED_PROTOCOL_VERSIONS); +const KNOWN_TOOLS: ReadonlySet = new Set(MCP_TOOL_NAMES); +export const MODERN_MCP_PROTOCOL_VERSION = "2026-07-28"; +const KNOWN_PROTOCOL_VERSIONS = new Set([ + ...SUPPORTED_PROTOCOL_VERSIONS, + MODERN_MCP_PROTOCOL_VERSION, +]); +const EMPTY_TRACE_CARRIER: Record = Object.freeze({}); type McpRoute = "anonymous" | "oauth" | "stdio"; type NetworkTransport = "pipe" | "tcp"; @@ -101,6 +107,7 @@ interface McpOperation { state: McpOperationState; statusDescription?: string; startedAt: number; + toolOutcome?: ToolCallOutcome; } interface ServerResponseClassification { @@ -109,7 +116,6 @@ interface ServerResponseClassification { statusDescription?: string; } -const operationStorage = new AsyncLocalStorage(); const CALLER_FAULT_CODES = new Set([-32700, -32600, -32601, -32602, -32002]); function getInstruments() { @@ -177,10 +183,10 @@ function messageProtocolVersion(message: JSONRPCMessage): string | undefined { } export function mcpTraceCarrier(message: JSONRPCMessage): Record { - if (!isJSONRPCRequest(message) && !isJSONRPCNotification(message)) return {}; + if (!isJSONRPCRequest(message) && !isJSONRPCNotification(message)) return EMPTY_TRACE_CARRIER; const metadata = asRecord(asRecord(message.params)?._meta); - if (!metadata) return {}; + if (!metadata) return EMPTY_TRACE_CARRIER; const carrier: Record = {}; for (const key of [TRACEPARENT_META_KEY, TRACESTATE_META_KEY, BAGGAGE_META_KEY]) { @@ -210,13 +216,15 @@ function requestIdAttribute(requestId: RequestId): string | undefined { function startOperation( message: JSONRPCMessage, - config: McpObservationConfig + config: McpObservationConfig, + transportProtocolVersion?: string ): McpOperation | undefined { if (!isJSONRPCRequest(message) && !isJSONRPCNotification(message)) return undefined; const method = normalizeMcpMethodName(message.method); const tool = normalizedTool(message); - const protocolVersion = messageProtocolVersion(message) ?? config.protocolVersion; + const protocolVersion = + messageProtocolVersion(message) ?? transportProtocolVersion ?? config.protocolVersion; const activeAttributes: Attributes = { "context7.mcp.route": config.route, "mcp.method.name": method, @@ -234,22 +242,20 @@ function startOperation( } const requestId = isJSONRPCRequest(message) ? message.id : undefined; - const spanAttributes: Attributes = { ...attributes }; - if (requestId !== undefined) { - const requestIdValue = requestIdAttribute(requestId); - if (requestIdValue) spanAttributes["jsonrpc.request.id"] = requestIdValue; - } - const parentContext = propagation.extract(ROOT_CONTEXT, mcpTraceCarrier(message)); const span = trace.getTracer(INSTRUMENTATION_NAME).startSpan( tool ? `${method} ${tool}` : method, { - attributes: spanAttributes, + attributes, kind: SpanKind.SERVER, links: ambientLink(parentContext), }, parentContext ); + if (requestId !== undefined) { + const requestIdValue = requestIdAttribute(requestId); + if (requestIdValue) span.setAttribute("jsonrpc.request.id", requestIdValue); + } const operation = { activeAttributes, attributes, @@ -268,8 +274,15 @@ function finishOperation(operation: McpOperation): void { if (operation.state === "finished") return; operation.state = "finished"; - const attributes = { ...operation.attributes }; + const attributes: Attributes = + operation.errorType || operation.toolOutcome + ? { ...operation.attributes } + : operation.attributes; if (operation.errorType) attributes["error.type"] = operation.errorType; + if (operation.toolOutcome) { + attributes["context7.mcp.tool.outcome"] = operation.toolOutcome; + operation.span.setAttribute("context7.mcp.tool.outcome", operation.toolOutcome); + } const { activeOperations, operationDuration } = mcpInstruments(); activeOperations.add(-1, operation.activeAttributes); @@ -289,7 +302,7 @@ function operationIsFinished(operation: McpOperation): boolean { } function runOperation(operation: McpOperation, handler: () => void): void { - operationStorage.run(operation, () => context.with(operation.context, handler)); + runInMcpOperationScope(operation, () => context.with(operation.context, handler)); } export function classifyServerResponse( @@ -325,6 +338,9 @@ function applyServerResponse(message: JSONRPCMessage, operation: McpOperation): if (isJSONRPCErrorResponse(message) || classification.errorType) { operation.errorType = classification.errorType; } + if (classification.errorType && operation.attributes["mcp.method.name"] === "tools/call") { + operation.toolOutcome = "error"; + } operation.statusDescription = classification.statusDescription; if (classification.rpcStatusCode) { operation.attributes["rpc.response.status_code"] = classification.rpcStatusCode; @@ -352,7 +368,7 @@ function configFromRequestContext(requestContext: McpRequestContext): McpObserva return { route: "stdio", networkTransport: "pipe", - protocolVersion: requestContext.era === "modern" ? "2026-07-28" : undefined, + protocolVersion: requestContext.era === "modern" ? MODERN_MCP_PROTOCOL_VERSION : undefined, }; } @@ -363,7 +379,7 @@ function configFromRequestContext(requestContext: McpRequestContext): McpObserva networkTransport: "tcp", protocolVersion: normalizedProtocolVersion(request.headers.get("mcp-protocol-version")) ?? - (requestContext.era === "modern" ? "2026-07-28" : undefined), + (requestContext.era === "modern" ? MODERN_MCP_PROTOCOL_VERSION : undefined), }; } @@ -374,12 +390,6 @@ class InstrumentedTransport implements Transport { private closeHandler: Transport["onclose"]; private errorHandler: Transport["onerror"]; private protocolVersion?: string; - private explicitCloseInProgress = false; - private sendsInFlight = 0; - private sessionErrorType?: string; - private sessionFinishPending = false; - private sessionFinished = false; - private readonly sessionStartedAt = performance.now(); constructor( private readonly transport: Transport, @@ -418,7 +428,6 @@ class InstrumentedTransport implements Transport { this.transport.onclose = () => { this.finishAll("connection_closed"); this.detachAbortHandler(); - if (!this.explicitCloseInProgress) this.requestSessionFinish(); handler?.(); }; } @@ -453,27 +462,15 @@ class InstrumentedTransport implements Transport { }; async start(): Promise { - try { - await this.transport.start(); - } catch (error) { - this.sessionErrorType ??= "transport_error"; - this.requestSessionFinish(); - throw error; - } + await this.transport.start(); } async close(): Promise { - this.explicitCloseInProgress = true; try { await this.transport.close(); - } catch (error) { - this.sessionErrorType ??= "transport_error"; - throw error; } finally { - this.explicitCloseInProgress = false; this.finishAll("connection_closed"); this.detachAbortHandler(); - this.requestSessionFinish(); } } @@ -482,7 +479,6 @@ class InstrumentedTransport implements Transport { isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message) ? message.id : undefined; const operation = responseId !== undefined && responseId !== null ? this.inFlight.get(responseId) : undefined; - this.sendsInFlight += 1; try { if (!operation) { await this.transport.send(message, options); @@ -493,7 +489,6 @@ class InstrumentedTransport implements Transport { operation.state = "sending"; await this.transport.send(message, options); } catch (error) { - this.sessionErrorType ??= "transport_error"; if (operation && !operationIsFinished(operation)) { operation.errorType = "transport_error"; operation.statusDescription = error instanceof Error ? error.message : undefined; @@ -505,8 +500,6 @@ class InstrumentedTransport implements Transport { this.removeInFlight(operation); finishOperation(operation); } - this.sendsInFlight -= 1; - if (this.sessionFinishPending) this.requestSessionFinish(); } } @@ -515,10 +508,7 @@ class InstrumentedTransport implements Transport { extra: MessageExtraInfo | undefined, handler: NonNullable ): void { - const operation = startOperation(message, { - ...this.config, - protocolVersion: this.protocolVersion ?? this.config.protocolVersion, - }); + const operation = startOperation(message, this.config, this.protocolVersion); if (!operation) { handler(message, extra); return; @@ -576,23 +566,6 @@ class InstrumentedTransport implements Transport { this.abortSignal?.removeEventListener("abort", this.handleAbort); } - private requestSessionFinish(): void { - // HTTP serving is intentionally stateless: every request gets a fresh SDK - // transport, so treating it as an MCP session would only duplicate request - // duration. Stdio owns one real session for the life of the process. - if (this.config.route !== "stdio" || this.sessionFinished) return; - - this.sessionFinishPending = true; - if (this.sendsInFlight > 0) return; - - this.sessionFinished = true; - const attributes: Attributes = { "network.transport": this.config.networkTransport }; - const protocolVersion = this.protocolVersion ?? this.config.protocolVersion; - if (protocolVersion) attributes["mcp.protocol.version"] = protocolVersion; - if (this.sessionErrorType) attributes["error.type"] = this.sessionErrorType; - mcpInstruments().sessionDuration.record(elapsedSeconds(this.sessionStartedAt), attributes); - } - private finishAll(errorType: string, includeSending = false): void { for (const [requestId, operation] of this.inFlight) { // A normal per-request HTTP transport closes its stream from inside send(). @@ -605,6 +578,146 @@ class InstrumentedTransport implements Transport { } } +/** + * Owns session telemetry at the one process-level stdio wire. The SDK may + * create multiple products while probing protocol eras, but they all share + * this transport, which also receives the actually negotiated version. + */ +class InstrumentedStdioTransport implements Transport { + private closeHandler: Transport["onclose"]; + private closePromise: Promise | undefined; + private closeRequested = false; + private errorType?: string; + private errorHandler: Transport["onerror"]; + private finished = false; + private messageHandler: Transport["onmessage"]; + private pendingTerminalError = false; + private protocolVersion?: string; + private readonly startedAt = performance.now(); + + constructor(private readonly transport: Transport) { + this.onclose = transport.onclose; + this.onerror = transport.onerror; + this.onmessage = transport.onmessage; + } + + get hasPerRequestStream(): boolean | undefined { + return this.transport.hasPerRequestStream; + } + + get sessionId(): string | undefined { + return this.transport.sessionId; + } + + set sessionId(value: string | undefined) { + this.transport.sessionId = value; + } + + get onclose(): Transport["onclose"] { + return this.closeHandler; + } + + set onclose(handler: Transport["onclose"]) { + this.closeHandler = handler; + this.transport.onclose = () => { + if (!this.closeRequested) { + this.finish(this.pendingTerminalError ? "transport_error" : undefined); + } + handler?.(); + }; + } + + get onerror(): Transport["onerror"] { + return this.errorHandler; + } + + set onerror(handler: Transport["onerror"]) { + this.errorHandler = handler; + this.transport.onerror = (error) => { + this.pendingTerminalError = true; + queueMicrotask(() => { + this.pendingTerminalError = false; + }); + handler?.(error); + }; + } + + get onmessage(): Transport["onmessage"] { + return this.messageHandler; + } + + set onmessage(handler: Transport["onmessage"]) { + this.messageHandler = handler; + this.transport.onmessage = handler + ? (message, extra) => { + this.protocolVersion = messageProtocolVersion(message) ?? this.protocolVersion; + handler(message, extra); + } + : undefined; + } + + setProtocolVersion = (version: string): void => { + this.protocolVersion = normalizedProtocolVersion(version); + this.transport.setProtocolVersion?.(version); + }; + + setSupportedProtocolVersions = (versions: string[]): void => { + this.transport.setSupportedProtocolVersions?.(versions); + }; + + async start(): Promise { + try { + await this.transport.start(); + } catch (error) { + this.errorType = "transport_error"; + this.finish("transport_error"); + throw error; + } + } + + close(): Promise { + if (!this.closePromise) { + this.closeRequested = true; + this.closePromise = this.closeTransport(); + } + return this.closePromise; + } + + async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise { + try { + await this.transport.send(message, options); + } catch (error) { + this.errorType ??= "transport_error"; + throw error; + } + } + + private async closeTransport(): Promise { + try { + await this.transport.close(); + this.finish(); + } catch (error) { + this.errorType = "transport_error"; + this.finish("transport_error"); + throw error; + } + } + + private finish(errorType = this.errorType): void { + if (this.finished) return; + this.finished = true; + + const attributes: Attributes = { "network.transport": "pipe" }; + if (this.protocolVersion) attributes["mcp.protocol.version"] = this.protocolVersion; + if (errorType) attributes["error.type"] = errorType; + mcpInstruments().sessionDuration.record(elapsedSeconds(this.startedAt), attributes); + } +} + +export function instrumentStdioTransport(transport: Transport): Transport { + return new InstrumentedStdioTransport(transport); +} + /** * High-level MCP server with protocol-aware OpenTelemetry at the SDK transport * boundary. This observes individual JSON-RPC operations for HTTP and stdio, @@ -626,8 +739,3 @@ export class InstrumentedMcpServer extends McpServer { ); } } - -export function markCurrentMcpOperationError(errorType = "tool_error"): void { - const operation = operationStorage.getStore(); - if (operation) operation.errorType = errorType; -} diff --git a/packages/mcp/src/lib/telemetry-config.ts b/packages/mcp/src/lib/telemetry-config.ts new file mode 100644 index 000000000..9e0825433 --- /dev/null +++ b/packages/mcp/src/lib/telemetry-config.ts @@ -0,0 +1,15 @@ +export function telemetryIsDisabled(environment: NodeJS.ProcessEnv = process.env): boolean { + return environment.OTEL_SDK_DISABLED?.toLowerCase() === "true"; +} + +export function embeddedPrometheusIsEnabled(environment: NodeJS.ProcessEnv = process.env): boolean { + if (telemetryIsDisabled(environment)) return false; + + const configuredExporters = environment.OTEL_METRICS_EXPORTER; + if (!configuredExporters) return true; + + return configuredExporters + .split(",") + .map((value) => value.trim().toLowerCase()) + .includes("prometheus"); +} diff --git a/packages/mcp/src/lib/telemetry-provider.ts b/packages/mcp/src/lib/telemetry-provider.ts new file mode 100644 index 000000000..4d6d85792 --- /dev/null +++ b/packages/mcp/src/lib/telemetry-provider.ts @@ -0,0 +1,85 @@ +import { metrics } from "@opentelemetry/api"; +import { PrometheusExporter } from "@opentelemetry/exporter-prometheus"; +import { RuntimeNodeInstrumentation } from "@opentelemetry/instrumentation-runtime-node"; +import { defaultResource, resourceFromAttributes } from "@opentelemetry/resources"; +import { MeterProvider } from "@opentelemetry/sdk-metrics"; +import { embeddedPrometheusIsEnabled } from "./telemetry-config.js"; + +const DEFAULT_PROMETHEUS_HOST = "0.0.0.0"; +const DEFAULT_PROMETHEUS_PORT = 9464; + +let embeddedRuntimeInstrumentation: RuntimeNodeInstrumentation | undefined; + +function prometheusPort(environment: NodeJS.ProcessEnv): number { + const configuredPort = environment.OTEL_EXPORTER_PROMETHEUS_PORT; + if (!configuredPort) return DEFAULT_PROMETHEUS_PORT; + + const port = Number(configuredPort); + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error(`Invalid OTEL_EXPORTER_PROMETHEUS_PORT: '${configuredPort}'`); + } + return port; +} + +/** + * Installs the embedded Prometheus MetricReader for HTTP serving. A provider + * installed by an OpenTelemetry preload script wins, so no second SDK is + * installed. This module is dynamically imported only when the embedded + * exporter is enabled; stdio and SDK-disabled processes never load it. + */ +export async function startPrometheusMetrics( + serviceVersion: string, + environment: NodeJS.ProcessEnv = process.env +): Promise { + if (!embeddedPrometheusIsEnabled(environment)) return undefined; + + let provider: MeterProvider | undefined; + let providerWasRegistered = false; + try { + const host = environment.OTEL_EXPORTER_PROMETHEUS_HOST || DEFAULT_PROMETHEUS_HOST; + const port = prometheusPort(environment); + const exporter = new PrometheusExporter({ host, port, preventServerStart: true }); + provider = new MeterProvider({ + resource: defaultResource().merge( + resourceFromAttributes({ + "service.name": "context7-mcp", + "service.version": serviceVersion, + }) + ), + readers: [exporter], + }); + + providerWasRegistered = metrics.setGlobalMeterProvider(provider); + if (!providerWasRegistered) { + await provider.shutdown(); + console.error( + "Embedded Prometheus exporter not started because a global OpenTelemetry MeterProvider is already registered" + ); + return undefined; + } + + await exporter.startServer(); + try { + // Keep the native 10 ms precision. The same setting controls the + // monitorEventLoopDelay resolution, so increasing it to the scrape + // interval would make healthy delay percentiles appear artificially high. + embeddedRuntimeInstrumentation = new RuntimeNodeInstrumentation(); + embeddedRuntimeInstrumentation.setMeterProvider(provider); + embeddedRuntimeInstrumentation.enable(); + } catch (error) { + embeddedRuntimeInstrumentation?.disable(); + embeddedRuntimeInstrumentation = undefined; + console.error("OpenTelemetry Node runtime metrics failed to start:", error); + } + console.error(`OpenTelemetry metrics available at http://${host}:${port}/metrics`); + return provider; + } catch (error) { + if (providerWasRegistered) metrics.disable(); + await provider?.shutdown().catch(() => undefined); + console.error( + "Embedded Prometheus exporter failed to start; MCP serving will continue:", + error + ); + return undefined; + } +} diff --git a/packages/mcp/src/lib/telemetry.ts b/packages/mcp/src/lib/telemetry.ts index ad3027f17..50e9e5f95 100644 --- a/packages/mcp/src/lib/telemetry.ts +++ b/packages/mcp/src/lib/telemetry.ts @@ -1,20 +1,30 @@ import { metrics, type Attributes } from "@opentelemetry/api"; -import { PrometheusExporter } from "@opentelemetry/exporter-prometheus"; -import { RuntimeNodeInstrumentation } from "@opentelemetry/instrumentation-runtime-node"; -import { defaultResource, resourceFromAttributes } from "@opentelemetry/resources"; -import { MeterProvider } from "@opentelemetry/sdk-metrics"; -import { markCurrentMcpOperationError } from "./mcp-telemetry.js"; +import { markCurrentMcpOperationError, markCurrentMcpToolOutcome } from "./mcp-operation-scope.js"; +import { telemetryIsDisabled } from "./telemetry-config.js"; +import type { ToolCallOutcome } from "./tool-names.js"; const METER_NAME = "io.github.upstash.context7.mcp"; -const DEFAULT_PROMETHEUS_HOST = "0.0.0.0"; -const DEFAULT_PROMETHEUS_PORT = 9464; const SHUTDOWN_METRIC_FLUSH_TIMEOUT_MS = 4_000; const DURATION_BUCKETS_SECONDS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60]; +const TELEMETRY_DISABLED = telemetryIsDisabled(); +const TIMEOUT_ERROR_CODES = new Set([ + "ETIMEDOUT", + "UND_ERR_BODY_TIMEOUT", + "UND_ERR_CONNECT_TIMEOUT", + "UND_ERR_HEADERS_TIMEOUT", +]); +const NETWORK_ERROR_CODES = new Set([ + "EAI_AGAIN", + "ECONNREFUSED", + "ECONNRESET", + "EHOSTUNREACH", + "ENETUNREACH", + "ENOTFOUND", + "EPIPE", +]); -export type McpTool = "query-docs" | "resolve-library-id"; export type UpstreamOperation = "fetch_context" | "oauth_metadata" | "search_libraries"; export type AuthenticationOutcome = "accepted" | "error" | "invalid" | "missing"; -export type ToolCallOutcome = "error" | "not_found" | "success"; export type UpstreamOutcome = | "cancelled" | "http_error" @@ -28,11 +38,6 @@ export interface ObservedAuthentication { value: T; } -export interface ObservedToolCall { - outcome: ToolCallOutcome; - value: T; -} - export interface UpstreamObservationOptions { abortSignal?: AbortSignal; } @@ -40,19 +45,6 @@ export interface UpstreamObservationOptions { function createInstruments() { const meter = metrics.getMeter(METER_NAME); return { - toolCalls: meter.createCounter("context7.mcp.tool.calls", { - description: "Number of MCP tool calls handled", - unit: "{call}", - }), - toolCallDuration: meter.createHistogram("context7.mcp.tool.call.duration", { - description: "Duration of MCP tool calls", - unit: "s", - advice: { explicitBucketBoundaries: DURATION_BUCKETS_SECONDS }, - }), - activeToolCalls: meter.createUpDownCounter("context7.mcp.tool.calls.active", { - description: "Number of MCP tool calls currently being handled", - unit: "{call}", - }), upstreamRequests: meter.createCounter("context7.mcp.upstream.requests", { description: "Number of requests made to Context7 dependencies", unit: "{request}", @@ -83,7 +75,6 @@ function createInstruments() { } let instruments: ReturnType | undefined; -let embeddedRuntimeInstrumentation: RuntimeNodeInstrumentation | undefined; function getInstruments(): ReturnType { instruments ??= createInstruments(); @@ -106,79 +97,41 @@ export function classifyUpstreamError( abortSignal?: AbortSignal, fallback: "network_error" | "response_error" = "network_error" ): "cancelled" | "network_error" | "response_error" | "timeout" { - const values: unknown[] = [error]; - if (abortSignal?.aborted) values.push(abortSignal.reason); + let timeout = false; + let cancelled = false; + let networkError = false; + const rootCount = abortSignal?.aborted ? 2 : 1; - const chain: Array<{ code?: unknown; name?: unknown }> = []; - const seen = new Set(); - for (let value of values) { + for (let rootIndex = 0; rootIndex < rootCount; rootIndex += 1) { + let value = rootIndex === 0 ? error : abortSignal?.reason; for (let depth = 0; value && typeof value === "object" && depth < 8; depth += 1) { - if (seen.has(value)) break; - seen.add(value); - chain.push(value as { code?: unknown; name?: unknown }); - value = (value as { cause?: unknown }).cause; + const current = value as { cause?: unknown; code?: unknown; name?: unknown }; + const name = current.name; + const code = current.code; + if (name === "TimeoutError" || (typeof code === "string" && TIMEOUT_ERROR_CODES.has(code))) { + timeout = true; + } else if (name === "AbortError" || code === "ABORT_ERR") { + cancelled = true; + } else if ( + typeof code === "string" && + (code.startsWith("UND_ERR_") || NETWORK_ERROR_CODES.has(code)) + ) { + networkError = true; + } + value = current.cause; } } - const names = chain.map((value) => value.name).filter((value) => typeof value === "string"); - const codes = chain.map((value) => value.code).filter((value) => typeof value === "string"); - if ( - names.includes("TimeoutError") || - codes.some((code) => - [ - "ETIMEDOUT", - "UND_ERR_BODY_TIMEOUT", - "UND_ERR_CONNECT_TIMEOUT", - "UND_ERR_HEADERS_TIMEOUT", - ].includes(code) - ) - ) { - return "timeout"; - } - if (names.includes("AbortError") || codes.includes("ABORT_ERR") || abortSignal?.aborted) { - return "cancelled"; - } - if ( - codes.some( - (code) => - code.startsWith("UND_ERR_") || - [ - "EAI_AGAIN", - "ECONNREFUSED", - "ECONNRESET", - "EHOSTUNREACH", - "ENETUNREACH", - "ENOTFOUND", - "EPIPE", - ].includes(code) - ) - ) { - return "network_error"; - } + if (timeout) return "timeout"; + if (cancelled || abortSignal?.aborted) return "cancelled"; + if (networkError) return "network_error"; return fallback; } -export async function observeToolCall( - tool: McpTool, - operation: () => Promise> -): Promise { - const { activeToolCalls, toolCallDuration, toolCalls } = getInstruments(); - const activeAttributes: Attributes = { "mcp.tool.name": tool }; - const startedAt = performance.now(); - let outcome: ToolCallOutcome = "error"; - activeToolCalls.add(1, activeAttributes); - - try { - const observed = await operation(); - outcome = observed.outcome; - return observed.value; - } finally { - if (outcome === "error") markCurrentMcpOperationError(); - const attributes = { ...activeAttributes, "mcp.tool.outcome": outcome }; - activeToolCalls.add(-1, activeAttributes); - toolCalls.add(1, attributes); - toolCallDuration.record(elapsedSeconds(startedAt), attributes); - } +export function recordToolCallOutcome(outcome: ToolCallOutcome): void { + if (TELEMETRY_DISABLED) return; + if (outcome === "error") markCurrentMcpOperationError(); + markCurrentMcpToolOutcome(outcome); } export async function observeUpstreamRequest( @@ -187,6 +140,8 @@ export async function observeUpstreamRequest( consumeResponse: (response: Response) => Promise, options: UpstreamObservationOptions = {} ): Promise { + if (TELEMETRY_DISABLED) return consumeResponse(await request()); + const { activeUpstreamRequests, upstreamRequestDuration, upstreamRequests } = getInstruments(); const activeAttributes: Attributes = { "context7.upstream.operation": operationName }; const startedAt = performance.now(); @@ -228,6 +183,8 @@ export async function observeUpstreamRequest( } export async function forceFlushMetrics(): Promise { + if (TELEMETRY_DISABLED) return; + const provider = metrics.getMeterProvider() as { forceFlush?: (options?: { timeoutMillis?: number }) => Promise; }; @@ -245,6 +202,8 @@ export async function forceFlushMetrics(): Promise { export async function observeAuthentication( operation: () => Promise> ): Promise { + if (TELEMETRY_DISABLED) return (await operation()).value; + const { activeAuthentications, authenticationAttempts, authenticationDuration } = getInstruments(); const activeAttributes: Attributes = { "context7.mcp.route": "oauth" }; @@ -263,92 +222,3 @@ export async function observeAuthentication( authenticationDuration.record(elapsedSeconds(startedAt), attributes); } } - -function prometheusIsEnabled(environment: NodeJS.ProcessEnv): boolean { - if (environment.OTEL_SDK_DISABLED?.toLowerCase() === "true") return false; - - const configuredExporters = environment.OTEL_METRICS_EXPORTER; - if (!configuredExporters) return true; - - return configuredExporters - .split(",") - .map((value) => value.trim().toLowerCase()) - .includes("prometheus"); -} - -function prometheusPort(environment: NodeJS.ProcessEnv): number { - const configuredPort = environment.OTEL_EXPORTER_PROMETHEUS_PORT; - if (!configuredPort) return DEFAULT_PROMETHEUS_PORT; - - const port = Number(configuredPort); - if (!Number.isInteger(port) || port < 1 || port > 65_535) { - throw new Error(`Invalid OTEL_EXPORTER_PROMETHEUS_PORT: '${configuredPort}'`); - } - return port; -} - -/** - * Installs the embedded Prometheus MetricReader for the HTTP server. A provider - * installed by an OpenTelemetry preload script wins; in that case the - * instruments above continue reporting to that provider and no second SDK is - * installed. Stdio mode never calls this function, so local MCP processes do - * not contend for a metrics port. - */ -export async function startPrometheusMetrics( - serviceVersion: string, - environment: NodeJS.ProcessEnv = process.env -): Promise { - if (!prometheusIsEnabled(environment)) return undefined; - - let provider: MeterProvider | undefined; - let providerWasRegistered = false; - try { - const host = environment.OTEL_EXPORTER_PROMETHEUS_HOST || DEFAULT_PROMETHEUS_HOST; - const port = prometheusPort(environment); - const exporter = new PrometheusExporter({ host, port, preventServerStart: true }); - provider = new MeterProvider({ - resource: defaultResource().merge( - resourceFromAttributes({ - "service.name": "context7-mcp", - "service.version": serviceVersion, - }) - ), - readers: [exporter], - }); - - providerWasRegistered = metrics.setGlobalMeterProvider(provider); - if (!providerWasRegistered) { - await provider.shutdown(); - console.error( - "Embedded Prometheus exporter not started because a global OpenTelemetry MeterProvider is already registered" - ); - return undefined; - } - - await exporter.startServer(); - try { - // Keep the native 10 ms precision. The same setting controls the - // monitorEventLoopDelay resolution, so increasing it to the scrape - // interval would make healthy delay percentiles appear artificially high. - embeddedRuntimeInstrumentation = new RuntimeNodeInstrumentation(); - embeddedRuntimeInstrumentation.setMeterProvider(provider); - embeddedRuntimeInstrumentation.enable(); - } catch (error) { - // Application metrics remain useful if a particular Node runtime cannot - // provide one of the optional process-level collectors. - embeddedRuntimeInstrumentation?.disable(); - embeddedRuntimeInstrumentation = undefined; - console.error("OpenTelemetry Node runtime metrics failed to start:", error); - } - console.error(`OpenTelemetry metrics available at http://${host}:${port}/metrics`); - return provider; - } catch (error) { - if (providerWasRegistered) metrics.disable(); - await provider?.shutdown().catch(() => undefined); - console.error( - "Embedded Prometheus exporter failed to start; MCP serving will continue:", - error - ); - return undefined; - } -} diff --git a/packages/mcp/src/lib/tool-names.ts b/packages/mcp/src/lib/tool-names.ts new file mode 100644 index 000000000..14c7ad0d8 --- /dev/null +++ b/packages/mcp/src/lib/tool-names.ts @@ -0,0 +1,7 @@ +export const QUERY_DOCS_TOOL = "query-docs" as const; +export const RESOLVE_LIBRARY_ID_TOOL = "resolve-library-id" as const; + +export const MCP_TOOL_NAMES = [QUERY_DOCS_TOOL, RESOLVE_LIBRARY_ID_TOOL] as const; + +export type McpTool = (typeof MCP_TOOL_NAMES)[number]; +export type ToolCallOutcome = "error" | "not_found" | "success"; diff --git a/packages/mcp/src/lib/types.ts b/packages/mcp/src/lib/types.ts index 1bac91ec7..5f03413bc 100644 --- a/packages/mcp/src/lib/types.ts +++ b/packages/mcp/src/lib/types.ts @@ -1,3 +1,5 @@ +import type { ToolCallOutcome } from "./tool-names.js"; + export interface SearchResult { id: string; title: string; @@ -30,8 +32,7 @@ export type ContextRequest = { export type ContextResponse = { data: string; - error?: true; - notFound?: true; + outcome: ToolCallOutcome; }; export interface ClientContext { diff --git a/packages/mcp/test/integration.test.ts b/packages/mcp/test/integration.test.ts index 244897b78..d7433361b 100644 --- a/packages/mcp/test/integration.test.ts +++ b/packages/mcp/test/integration.test.ts @@ -104,8 +104,14 @@ function startStubApi(): Promise { res.end(); } }); - return new Promise((resolve) => { + return new Promise((resolve, reject) => { + const handleListenError = (error: Error) => { + stubServer.close(); + reject(error); + }; + stubServer.once("error", handleListenError); stubServer.listen(0, "127.0.0.1", () => { + stubServer.off("error", handleListenError); const address = stubServer.address() as { port: number }; resolve(`http://127.0.0.1:${address.port}/api`); }); @@ -376,18 +382,26 @@ describe("OpenTelemetry metrics", () => { line.includes('error_type="tool_error"') ) ).toBe(true); - expect(exported).toMatch( - /context7_mcp_tool_calls_total\{[^}]*mcp_tool_name="query-docs"[^}]*mcp_tool_outcome="success"[^}]*\} [1-9]/ - ); + expect( + operationCounts.some( + (line) => + line.includes('gen_ai_tool_name="query-docs"') && + line.includes('context7_mcp_tool_outcome="success"') + ) + ).toBe(true); expect(exported).toMatch( /context7_mcp_upstream_requests_total\{[^}]*context7_upstream_operation="fetch_context"[^}]*context7_upstream_outcome="success"[^}]*\} [1-9]/ ); - expect(exported).toMatch( - /context7_mcp_tool_calls_total\{[^}]*mcp_tool_name="query-docs"[^}]*mcp_tool_outcome="error"[^}]*\} [1-9]/ - ); - expect(exported).toMatch( - /context7_mcp_tool_calls_total\{[^}]*mcp_tool_outcome="not_found"[^}]*\} [1-9]/ - ); + expect( + operationCounts.some( + (line) => + line.includes('gen_ai_tool_name="query-docs"') && + line.includes('context7_mcp_tool_outcome="error"') + ) + ).toBe(true); + expect( + operationCounts.some((line) => line.includes('context7_mcp_tool_outcome="not_found"')) + ).toBe(true); expect(exported).toMatch( /context7_mcp_upstream_requests_total\{[^}]*context7_upstream_operation="fetch_context"[^}]*http_response_status_code_class="5xx"[^}]*context7_upstream_outcome="http_error"[^}]*\} [1-9]/ ); diff --git a/packages/mcp/test/mcp-telemetry-lifecycle.test.ts b/packages/mcp/test/mcp-telemetry-lifecycle.test.ts index 9391b77e5..504f57f99 100644 --- a/packages/mcp/test/mcp-telemetry-lifecycle.test.ts +++ b/packages/mcp/test/mcp-telemetry-lifecycle.test.ts @@ -1,10 +1,14 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest"; import { + CLIENT_CAPABILITIES_META_KEY, + McpServer, + PROTOCOL_VERSION_META_KEY, type JSONRPCMessage, type MessageExtraInfo, type Transport, type TransportSendOptions, } from "@modelcontextprotocol/server"; +import { serveStdio } from "@modelcontextprotocol/server/stdio"; import { SpanStatusCode, metrics, propagation, trace } from "@opentelemetry/api"; import { AggregationTemporality, @@ -18,7 +22,12 @@ import { InMemorySpanExporter, SimpleSpanProcessor, } from "@opentelemetry/sdk-trace-base"; -import { InstrumentedMcpServer, classifyServerResponse } from "../src/lib/mcp-telemetry.js"; +import { + InstrumentedMcpServer, + MODERN_MCP_PROTOCOL_VERSION, + classifyServerResponse, + instrumentStdioTransport, +} from "../src/lib/mcp-telemetry.js"; import { observeUpstreamRequest, type UpstreamOperation } from "../src/lib/telemetry.js"; interface Deferred { @@ -205,14 +214,33 @@ describe("MCP server response classification", () => { }); describe("MCP operation lifecycle", () => { - test("records real stdio sessions but not stateless HTTP request transports", async () => { + test("records a real modern stdio session once with its envelope version", async () => { await metricProvider.forceFlush(); const before = sessionObservationCount(); + const wire = new ControlledTransport(); + const sessionTransport = instrumentStdioTransport(wire); + const handle = serveStdio( + () => new McpServer({ name: "modern-stdio-test", version: "1.0.0" }, {}), + { transport: sessionTransport } + ); + await eventually(() => expect(wire.onmessage).toBeTypeOf("function")); + + wire.receive({ + jsonrpc: "2.0", + id: 1, + method: "server/discover", + params: { + _meta: { + [PROTOCOL_VERSION_META_KEY]: MODERN_MCP_PROTOCOL_VERSION, + [CLIENT_CAPABILITIES_META_KEY]: {}, + }, + }, + }); + await eventually(() => + expect(wire.sent.some((message) => "id" in message && message.id === 1)).toBe(true) + ); - const stdioTransport = new ControlledTransport(); - const stdioServer = serverFor(stdioTransport, undefined, "modern"); - await eventually(() => expect(stdioTransport.onmessage).toBeTypeOf("function")); - await stdioServer.close(); + await Promise.all([handle.close(), handle.close()]); await metricProvider.forceFlush(); expect(sessionObservationCount()).toBe(before + 1); @@ -224,46 +252,125 @@ describe("MCP operation lifecycle", () => { sessionMetric?.dataPoints.some( (point) => point.attributes["network.transport"] === "pipe" && - point.attributes["mcp.protocol.version"] === "2026-07-28" + point.attributes["mcp.protocol.version"] === MODERN_MCP_PROTOCOL_VERSION ) ).toBe(true); + }); + + test("records one session across the SDK modern-probe to legacy fallback", async () => { + await metricProvider.forceFlush(); + const before = sessionObservationCount(); + const wire = new ControlledTransport(); + let createdProducts = 0; + const sessionTransport = instrumentStdioTransport(wire); + const rawHandle = serveStdio( + () => { + createdProducts += 1; + return new McpServer({ name: "stdio-fallback-test", version: "1.0.0" }, {}); + }, + { transport: sessionTransport } + ); + await eventually(() => expect(wire.onmessage).toBeTypeOf("function")); + + wire.receive({ + jsonrpc: "2.0", + id: 1, + method: "server/discover", + params: { + _meta: { + [PROTOCOL_VERSION_META_KEY]: MODERN_MCP_PROTOCOL_VERSION, + [CLIENT_CAPABILITIES_META_KEY]: {}, + }, + }, + }); + await eventually(() => { + expect(createdProducts).toBe(1); + expect(wire.sent.some((message) => "id" in message && message.id === 1)).toBe(true); + }); + + wire.receive({ + jsonrpc: "2.0", + id: 2, + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "fallback-client", version: "1.0.0" }, + }, + }); + await eventually(() => { + expect(createdProducts).toBe(2); + expect(wire.sent.some((message) => "id" in message && message.id === 2)).toBe(true); + }); - const httpTransport = new ControlledTransport(); - const httpServer = serverFor(httpTransport, new Request("http://127.0.0.1/mcp")); - await eventually(() => expect(httpTransport.onmessage).toBeTypeOf("function")); - await httpServer.close(); + wire.triggerClose(); await metricProvider.forceFlush(); expect(sessionObservationCount()).toBe(before + 1); + const latest = metricExporter.getMetrics().at(-1); + const sessionMetric = latest?.scopeMetrics + .flatMap((scope) => scope.metrics) + .find((candidate) => candidate.descriptor.name === "mcp.server.session.duration"); + expect( + sessionMetric?.dataPoints.some( + (point) => + point.attributes["network.transport"] === "pipe" && + point.attributes["mcp.protocol.version"] === "2025-11-25" + ) + ).toBe(true); + + await rawHandle.close(); + await metricProvider.forceFlush(); + expect(sessionObservationCount()).toBe(before + 1); }); - test("does not fail a gracefully closed session after a nonfatal transport error event", async () => { + test("marks a session failed when terminal transport close rejects", async () => { await metricProvider.forceFlush(); - const beforeTotal = sessionObservationCount(); const beforeErrors = sessionErrorObservationCount("transport_error"); - const transport = new ControlledTransport(); - const server = serverFor(transport, undefined, "modern"); - await eventually(() => expect(transport.onerror).toBeTypeOf("function")); + const wire = new ControlledTransport(); + wire.closeOperation = async () => { + throw new Error("close failed"); + }; + const session = instrumentStdioTransport(wire); - transport.triggerError(new Error("reported but recoverable")); - await server.close(); + await expect(session.close()).rejects.toThrow("close failed"); await metricProvider.forceFlush(); - expect(sessionObservationCount()).toBe(beforeTotal + 1); - expect(sessionErrorObservationCount("transport_error")).toBe(beforeErrors); + expect(sessionErrorObservationCount("transport_error")).toBe(beforeErrors + 1); }); - test("marks a session failed when terminal transport close rejects", async () => { + test("marks the eventual session failed after a wire send rejects", async () => { await metricProvider.forceFlush(); const beforeErrors = sessionErrorObservationCount("transport_error"); - const transport = new ControlledTransport(); - transport.closeOperation = async () => { - throw new Error("close failed"); + const wire = new ControlledTransport(); + wire.sendOperation = async () => { + throw new Error("send failed"); }; - const server = serverFor(transport, undefined, "modern"); - await eventually(() => expect(transport.onmessage).toBeTypeOf("function")); + const session = instrumentStdioTransport(wire); + + await expect(session.send({ jsonrpc: "2.0", id: 9, result: {} })).rejects.toThrow( + "send failed" + ); + await session.close(); + await metricProvider.forceFlush(); + + expect(sessionErrorObservationCount("transport_error")).toBe(beforeErrors + 1); + }); + + test("classifies only an error immediately followed by wire close as terminal", async () => { + await metricProvider.forceFlush(); + const beforeErrors = sessionErrorObservationCount("transport_error"); + + const fatalWire = new ControlledTransport(); + instrumentStdioTransport(fatalWire); + fatalWire.triggerError(new Error("fatal stdout failure")); + fatalWire.triggerClose(); - await expect(server.close()).rejects.toThrow("close failed"); + const recoverableWire = new ControlledTransport(); + const recoverableSession = instrumentStdioTransport(recoverableWire); + recoverableWire.triggerError(new Error("recoverable parse failure")); + await new Promise((resolve) => queueMicrotask(resolve)); + await recoverableSession.close(); await metricProvider.forceFlush(); expect(sessionErrorObservationCount("transport_error")).toBe(beforeErrors + 1); diff --git a/packages/mcp/test/telemetry-disabled.test.ts b/packages/mcp/test/telemetry-disabled.test.ts new file mode 100644 index 000000000..eb58007c7 --- /dev/null +++ b/packages/mcp/test/telemetry-disabled.test.ts @@ -0,0 +1,57 @@ +import { afterAll, beforeAll, expect, test } from "vitest"; +import { metrics } from "@opentelemetry/api"; +import { + AggregationTemporality, + InMemoryMetricExporter, + MeterProvider, + PeriodicExportingMetricReader, +} from "@opentelemetry/sdk-metrics"; + +const previousDisabled = process.env.OTEL_SDK_DISABLED; +process.env.OTEL_SDK_DISABLED = "true"; +const { forceFlushMetrics, observeAuthentication, recordToolCallOutcome, observeUpstreamRequest } = + await import("../src/lib/telemetry.js"); + +const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE); +const provider = new MeterProvider({ + readers: [ + new PeriodicExportingMetricReader({ + exporter, + exportIntervalMillis: 60_000, + }), + ], +}); + +beforeAll(() => { + expect(metrics.setGlobalMeterProvider(provider)).toBe(true); +}); + +afterAll(async () => { + await provider.shutdown(); + metrics.disable(); + if (previousDisabled === undefined) delete process.env.OTEL_SDK_DISABLED; + else process.env.OTEL_SDK_DISABLED = previousDisabled; +}); + +test("OTEL_SDK_DISABLED bypasses all application metric instruments", async () => { + expect(() => recordToolCallOutcome("success")).not.toThrow(); + await expect( + observeUpstreamRequest( + "fetch_context", + async () => new Response("ok"), + async (response) => response.text() + ) + ).resolves.toBe("ok"); + await expect( + observeAuthentication(async () => ({ outcome: "accepted", value: "auth" })) + ).resolves.toBe("auth"); + await forceFlushMetrics(); + await provider.forceFlush(); + + const metricNames = exporter + .getMetrics() + .flatMap((resource) => resource.scopeMetrics) + .flatMap((scope) => scope.metrics) + .map((metric) => metric.descriptor.name); + expect(metricNames).toEqual([]); +}); diff --git a/packages/mcp/test/telemetry.test.ts b/packages/mcp/test/telemetry.test.ts index d424db389..48c402eb4 100644 --- a/packages/mcp/test/telemetry.test.ts +++ b/packages/mcp/test/telemetry.test.ts @@ -25,6 +25,7 @@ import { normalizeMcpToolName, } from "../src/lib/mcp-telemetry.js"; import { classifyUpstreamError } from "../src/lib/telemetry.js"; +import { embeddedPrometheusIsEnabled, telemetryIsDisabled } from "../src/lib/telemetry-config.js"; const spanExporter = new InMemorySpanExporter(); const tracerProvider = new BasicTracerProvider({ @@ -130,6 +131,19 @@ describe("upstream failure classification", () => { }); }); +describe("telemetry configuration", () => { + test("uses OTEL_SDK_DISABLED as the complete telemetry off switch", () => { + expect(telemetryIsDisabled({ OTEL_SDK_DISABLED: "TRUE" })).toBe(true); + expect(embeddedPrometheusIsEnabled({ OTEL_SDK_DISABLED: "true" })).toBe(false); + }); + + test("enables only the configured embedded Prometheus exporter", () => { + expect(embeddedPrometheusIsEnabled({})).toBe(true); + expect(embeddedPrometheusIsEnabled({ OTEL_METRICS_EXPORTER: "none" })).toBe(false); + expect(embeddedPrometheusIsEnabled({ OTEL_METRICS_EXPORTER: "otlp, prometheus" })).toBe(true); + }); +}); + describe("MCP trace instrumentation", () => { test("creates a semantic server span parented by SEP-414 trace context", async () => { const server = new InstrumentedMcpServer( From 32a039a85a82aa22825339b3ec82d7206959a4b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Sun, 16 Aug 2026 17:09:51 +0300 Subject: [PATCH 06/10] docs(mcp): clarify telemetry hard-off mode --- packages/mcp/README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 5afd31e59..556a23f26 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -1513,6 +1513,11 @@ The exporter uses the standard OpenTelemetry Prometheus settings: - `OTEL_EXPORTER_PROMETHEUS_PORT` changes the port (default `9464`). - `OTEL_METRICS_EXPORTER=none` or `OTEL_SDK_DISABLED=true` disables the embedded exporter. +`OTEL_SDK_DISABLED=true` is the hard-off switch: provider modules are not loaded and MCP +transports and handlers are not wrapped, preserving the baseline request path. In contrast, +`OTEL_METRICS_EXPORTER=none` disables only the embedded Prometheus bootstrap, so a provider +installed by a Node preload can still receive the MCP signals. + Exporter bind or configuration failures are logged but do not prevent the MCP endpoint from starting. If a Node preload has already registered global OpenTelemetry providers, they take precedence. The embedded Prometheus listener is not started when a global `MeterProvider` exists, From e4ea2db861b8237bef2af22511a469f7d2ff2a36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Tue, 18 Aug 2026 10:23:52 +0300 Subject: [PATCH 07/10] fix(mcp): make telemetry hard-off truly lazy --- packages/mcp/src/index.ts | 44 ++++++++++++++----- packages/mcp/src/lib/api.ts | 22 +++++++++- packages/mcp/src/lib/telemetry-config.ts | 2 +- .../test/fixtures/module-load-recorder.mjs | 10 +++++ packages/mcp/test/integration.test.ts | 44 +++++++++++++++++-- packages/mcp/test/telemetry.test.ts | 1 + 6 files changed, 106 insertions(+), 17 deletions(-) create mode 100644 packages/mcp/test/fixtures/module-load-recorder.mjs diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 6c60d9265..384212cb7 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -29,12 +29,6 @@ import { } from "./lib/constants.js"; import { maybeElicitAuthSignIn } from "./lib/auth/auth-prompt.js"; import { getClientIp } from "./lib/client-ip.js"; -import { - forceFlushMetrics, - recordToolCallOutcome, - observeUpstreamRequest, - observeAuthentication, -} from "./lib/telemetry.js"; import { QUERY_DOCS_TOOL, RESOLVE_LIBRARY_ID_TOOL } from "./lib/tool-names.js"; import { embeddedPrometheusIsEnabled, telemetryIsDisabled } from "./lib/telemetry-config.js"; import { installStdioShutdown } from "./lib/stdio-shutdown.js"; @@ -43,8 +37,31 @@ import { installStdioShutdown } from "./lib/stdio-shutdown.js"; const DEFAULT_PORT = 3000; const OAUTH_METADATA_TIMEOUT_MS = 10_000; const TELEMETRY_DISABLED = telemetryIsDisabled(); +type TelemetryModule = typeof import("./lib/telemetry.js"); +type ObservedAuthentication = import("./lib/telemetry.js").ObservedAuthentication; +type UpstreamObservationOptions = import("./lib/telemetry.js").UpstreamObservationOptions; +type UpstreamOperation = import("./lib/telemetry.js").UpstreamOperation; + +let telemetry: TelemetryModule | undefined; let mcpTelemetry: typeof import("./lib/mcp-telemetry.js") | undefined; +async function observeAuthentication( + operation: () => Promise> +): Promise { + return telemetry ? telemetry.observeAuthentication(operation) : (await operation()).value; +} + +async function observeUpstreamRequest( + operationName: UpstreamOperation, + request: () => Promise, + consumeResponse: (response: Response) => Promise, + options: UpstreamObservationOptions = {} +): Promise { + return telemetry + ? telemetry.observeUpstreamRequest(operationName, request, consumeResponse, options) + : consumeResponse(await request()); +} + // Parse CLI arguments using commander const program = new Command() .version(SERVER_VERSION, "-v, --version", "output the current version") @@ -257,7 +274,7 @@ IMPORTANT: Do not call this tool more than 3 times per question. If you cannot f if (!searchResponse.results || searchResponse.results.length === 0) { const text = searchResponse.error ?? "No libraries found matching the provided name."; maybeElicitAuthSignIn(server, ctx); - recordToolCallOutcome(searchResponse.error ? "error" : "not_found"); + telemetry?.recordToolCallOutcome(searchResponse.error ? "error" : "not_found"); return { content: [ { @@ -271,7 +288,7 @@ IMPORTANT: Do not call this tool more than 3 times per question. If you cannot f const resultsText = formatSearchResults(searchResponse); const responseText = `Available Libraries:\n\n${resultsText}`; maybeElicitAuthSignIn(server, ctx); - recordToolCallOutcome("success"); + telemetry?.recordToolCallOutcome("success"); return { content: [ { @@ -318,7 +335,7 @@ Do not call this tool more than 3 times per question.`, const ctx = getClientContext(toolCtx); const response = await fetchLibraryContext({ query, libraryId }, ctx); maybeElicitAuthSignIn(server, ctx); - recordToolCallOutcome(response.outcome); + telemetry?.recordToolCallOutcome(response.outcome); return { content: [ { @@ -334,7 +351,12 @@ Do not call this tool more than 3 times per question.`, } async function main() { - if (!TELEMETRY_DISABLED) mcpTelemetry = await import("./lib/mcp-telemetry.js"); + if (!TELEMETRY_DISABLED) { + [telemetry, mcpTelemetry] = await Promise.all([ + import("./lib/telemetry.js"), + import("./lib/mcp-telemetry.js"), + ]); + } if (TRANSPORT_TYPE === "http") { const initialPort = CLI_PORT ?? DEFAULT_PORT; @@ -634,7 +656,7 @@ async function main() { } ); installStdioShutdown(stdioHandle, { - flush: forceFlushMetrics, + flush: telemetry?.forceFlushMetrics, onerror: (error) => console.error("Failed to close MCP stdio server:", error), }); diff --git a/packages/mcp/src/lib/api.ts b/packages/mcp/src/lib/api.ts index 3436d9381..5209cc462 100644 --- a/packages/mcp/src/lib/api.ts +++ b/packages/mcp/src/lib/api.ts @@ -4,7 +4,8 @@ import { Agent, ProxyAgent, setGlobalDispatcher } from "undici"; import { CONTEXT7_API_BASE_URL } from "./constants.js"; import { readFileSync } from "fs"; import tls from "tls"; -import { observeUpstreamRequest } from "./telemetry.js"; +import { telemetryIsDisabled } from "./telemetry-config.js"; +import type { UpstreamObservationOptions, UpstreamOperation } from "./telemetry.js"; /** * Ceiling on a single Context7 API call. Without a signal a stalled backend @@ -13,6 +14,25 @@ import { observeUpstreamRequest } from "./telemetry.js"; * day of production traffic. */ const API_TIMEOUT_MS = 60_000; +const TELEMETRY_DISABLED = telemetryIsDisabled(); +let telemetryModule: Promise | undefined; + +async function observeUpstreamRequest( + operationName: UpstreamOperation, + request: () => Promise, + consumeResponse: (response: Response) => Promise, + options: UpstreamObservationOptions = {} +): Promise { + if (TELEMETRY_DISABLED) return consumeResponse(await request()); + + telemetryModule ??= import("./telemetry.js"); + return (await telemetryModule).observeUpstreamRequest( + operationName, + request, + consumeResponse, + options + ); +} /** * Parses error response from the Context7 API diff --git a/packages/mcp/src/lib/telemetry-config.ts b/packages/mcp/src/lib/telemetry-config.ts index 9e0825433..9e594d9a0 100644 --- a/packages/mcp/src/lib/telemetry-config.ts +++ b/packages/mcp/src/lib/telemetry-config.ts @@ -1,5 +1,5 @@ export function telemetryIsDisabled(environment: NodeJS.ProcessEnv = process.env): boolean { - return environment.OTEL_SDK_DISABLED?.toLowerCase() === "true"; + return environment.OTEL_SDK_DISABLED?.trim().toLowerCase() === "true"; } export function embeddedPrometheusIsEnabled(environment: NodeJS.ProcessEnv = process.env): boolean { diff --git a/packages/mcp/test/fixtures/module-load-recorder.mjs b/packages/mcp/test/fixtures/module-load-recorder.mjs new file mode 100644 index 000000000..b1df63f39 --- /dev/null +++ b/packages/mcp/test/fixtures/module-load-recorder.mjs @@ -0,0 +1,10 @@ +const TELEMETRY_MODULE = + /\/dist\/lib\/(?:mcp-operation-scope|mcp-telemetry|telemetry|telemetry-provider)\.js$/; + +export async function load(url, context, nextLoad) { + const pathname = url.startsWith("file:") ? new URL(url).pathname : ""; + if (TELEMETRY_MODULE.test(pathname) || pathname.includes("/node_modules/@opentelemetry/")) { + process.stderr.write(`MCP_TELEMETRY_MODULE_LOADED ${url}\n`); + } + return nextLoad(url, context); +} diff --git a/packages/mcp/test/integration.test.ts b/packages/mcp/test/integration.test.ts index d7433361b..f1f4a928a 100644 --- a/packages/mcp/test/integration.test.ts +++ b/packages/mcp/test/integration.test.ts @@ -17,6 +17,7 @@ import path from "node:path"; const PKG_ROOT = path.resolve(fileURLToPath(new URL(".", import.meta.url)), ".."); const DIST = path.join(PKG_ROOT, "dist", "index.js"); +const MODULE_LOAD_RECORDER = path.join(PKG_ROOT, "test", "fixtures", "module-load-recorder.mjs"); const BASE_PORT = 43117; const STUB_DOCS = "stub docs text"; const EMPTY_CONTEXT_QUERY = "force-empty-context"; @@ -118,19 +119,28 @@ function startStubApi(): Promise { }); } -function startHttpChild(): Promise<{ child: ChildProcess; url: string }> { +interface HttpChildOptions { + environment?: Record; + nodeArgs?: string[]; + port?: number; +} + +function startHttpChild( + options: HttpChildOptions = {} +): Promise<{ child: ChildProcess; stderr: () => string; url: string }> { return new Promise((resolve, reject) => { + const port = options.port ?? BASE_PORT; const child = spawn( process.execPath, - [DIST, "--transport", "http", "--port", String(BASE_PORT)], - { env: childEnv, stdio: ["ignore", "ignore", "pipe"] } + [...(options.nodeArgs ?? []), DIST, "--transport", "http", "--port", String(port)], + { env: options.environment ?? childEnv, stdio: ["ignore", "ignore", "pipe"] } ); let stderr = ""; child.stderr!.on("data", (chunk: Buffer) => { stderr += chunk.toString(); // The binary retries on EADDRINUSE, so parse the actual port it settled on. const match = stderr.match(/running on HTTP at (http:\/\/localhost:\d+\/mcp)/); - if (match) resolve({ child, url: match[1] }); + if (match) resolve({ child, stderr: () => stderr, url: match[1] }); }); child.once("exit", (code) => { reject(new Error(`HTTP server exited before listening (code ${code}): ${stderr}`)); @@ -286,6 +296,32 @@ describe.each([ }); describe("OpenTelemetry metrics", () => { + test("hard-off mode does not load telemetry implementation modules", async () => { + const disabledPort = await getFreePort(); + const disabledServer = await startHttpChild({ + environment: { ...childEnv, OTEL_SDK_DISABLED: " true\n" }, + nodeArgs: ["--experimental-loader", MODULE_LOAD_RECORDER], + port: disabledPort, + }); + const client = new Client( + { name: "telemetry-disabled-test", version: "1.0.0" }, + { versionNegotiation: { mode: { pin: "2026-07-28" } } } + ); + + try { + await client.connect(new StreamableHTTPClientTransport(new URL(disabledServer.url))); + const result = await client.callTool({ + name: "query-docs", + arguments: { libraryId: "/vercel/next.js", query: "disabled telemetry" }, + }); + expect(result.isError).toBeFalsy(); + expect(disabledServer.stderr()).not.toContain("MCP_TELEMETRY_MODULE_LOADED"); + } finally { + await client.close(); + disabledServer.child.kill(); + } + }); + test("counts each dispatched operation in a legacy JSON-RPC batch", async () => { const before = operationCount(await (await fetch(metricsUrl)).text(), "tools/list"); const response = await fetch(httpUrl, { diff --git a/packages/mcp/test/telemetry.test.ts b/packages/mcp/test/telemetry.test.ts index 48c402eb4..2c5b3935e 100644 --- a/packages/mcp/test/telemetry.test.ts +++ b/packages/mcp/test/telemetry.test.ts @@ -134,6 +134,7 @@ describe("upstream failure classification", () => { describe("telemetry configuration", () => { test("uses OTEL_SDK_DISABLED as the complete telemetry off switch", () => { expect(telemetryIsDisabled({ OTEL_SDK_DISABLED: "TRUE" })).toBe(true); + expect(telemetryIsDisabled({ OTEL_SDK_DISABLED: " true\n" })).toBe(true); expect(embeddedPrometheusIsEnabled({ OTEL_SDK_DISABLED: "true" })).toBe(false); }); From ef69deae29150c4cf7a88f56bdb7594eb4691f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Mon, 31 Aug 2026 13:53:02 +0300 Subject: [PATCH 08/10] fix(mcp): secure Prometheus bind defaults --- packages/mcp/Dockerfile | 1 + packages/mcp/README.md | 18 ++++++++++------- packages/mcp/src/lib/api.ts | 5 ++++- packages/mcp/src/lib/telemetry-provider.ts | 2 +- packages/mcp/test/integration.test.ts | 23 ++++++++++++++++++++++ 5 files changed, 40 insertions(+), 9 deletions(-) diff --git a/packages/mcp/Dockerfile b/packages/mcp/Dockerfile index 9ac1f2a4d..cd88000f7 100644 --- a/packages/mcp/Dockerfile +++ b/packages/mcp/Dockerfile @@ -25,5 +25,6 @@ RUN pnpm install --frozen-lockfile --prod --filter @upstash/context7-mcp COPY --from=builder /app/packages/mcp/dist ./packages/mcp/dist WORKDIR /app/packages/mcp +ENV OTEL_EXPORTER_PROMETHEUS_HOST=0.0.0.0 EXPOSE 8080 9464 CMD ["node", "dist/index.js", "--transport", "http", "--port", "8080"] diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 556a23f26..566255557 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -1501,15 +1501,18 @@ for server metrics and spans. Trace context is extracted from the `traceparent`, `baggage` fields in MCP `params._meta` as defined by [SEP-414](https://modelcontextprotocol.io/seps/414-request-meta). -The HTTP transport exposes metrics in Prometheus format on a dedicated internal listener at -`0.0.0.0:9464/metrics`. The stdio transport does not open a telemetry port. Keeping this listener -separate from the public MCP port prevents the metrics endpoint from being routed through a -catch-all gateway rule. On stdio EOF or SIGHUP, the server closes the SDK connection, records the -session duration, and best-effort flushes an externally installed SDK `MeterProvider` before exit. +The HTTP transport exposes metrics in Prometheus format on a dedicated listener at +`127.0.0.1:9464/metrics` by default. The production Docker image explicitly binds that listener to +`0.0.0.0` so an internal Prometheus sidecar or `ServiceMonitor` can reach it. The stdio transport +does not open a telemetry port. Keeping this listener separate from the public MCP port prevents +the metrics endpoint from being routed through a catch-all gateway rule. On stdio EOF or SIGHUP, +the server closes the SDK connection, records the session duration, and best-effort flushes an +externally installed SDK `MeterProvider` before exit. The exporter uses the standard OpenTelemetry Prometheus settings: -- `OTEL_EXPORTER_PROMETHEUS_HOST` changes the bind address (default `0.0.0.0`). +- `OTEL_EXPORTER_PROMETHEUS_HOST` changes the bind address (default `127.0.0.1`; the Docker image + sets `0.0.0.0`). - `OTEL_EXPORTER_PROMETHEUS_PORT` changes the port (default `9464`). - `OTEL_METRICS_EXPORTER=none` or `OTEL_SDK_DISABLED=true` disables the embedded exporter. @@ -1545,7 +1548,8 @@ Tool outcomes on the standard MCP operation metric distinguish `success`, `not_f `error`. Upstream outcomes distinguish HTTP, response-decoding, network, timeout, and cancellation failures and include both the bounded status-code class and the exact numeric HTTP status. Authentication reports accepted, missing, -invalid, and unexpected-error outcomes. +invalid, and unexpected-error outcomes. The OAuth authorization-server metadata proxy caps its +upstream fetch at 10 seconds and returns `502` if that dependency times out. The labels intentionally exclude API keys, client IPs, queries, library IDs, session IDs, and raw error text. Expose port `9464` only to your Prometheus scraper or `ServiceMonitor`, not through the diff --git a/packages/mcp/src/lib/api.ts b/packages/mcp/src/lib/api.ts index 5209cc462..d9e14334f 100644 --- a/packages/mcp/src/lib/api.ts +++ b/packages/mcp/src/lib/api.ts @@ -25,7 +25,10 @@ async function observeUpstreamRequest( ): Promise { if (TELEMETRY_DISABLED) return consumeResponse(await request()); - telemetryModule ??= import("./telemetry.js"); + telemetryModule ??= import("./telemetry.js").catch((error) => { + telemetryModule = undefined; + throw error; + }); return (await telemetryModule).observeUpstreamRequest( operationName, request, diff --git a/packages/mcp/src/lib/telemetry-provider.ts b/packages/mcp/src/lib/telemetry-provider.ts index 4d6d85792..03ec5223d 100644 --- a/packages/mcp/src/lib/telemetry-provider.ts +++ b/packages/mcp/src/lib/telemetry-provider.ts @@ -5,7 +5,7 @@ import { defaultResource, resourceFromAttributes } from "@opentelemetry/resource import { MeterProvider } from "@opentelemetry/sdk-metrics"; import { embeddedPrometheusIsEnabled } from "./telemetry-config.js"; -const DEFAULT_PROMETHEUS_HOST = "0.0.0.0"; +const DEFAULT_PROMETHEUS_HOST = "127.0.0.1"; const DEFAULT_PROMETHEUS_PORT = 9464; let embeddedRuntimeInstrumentation: RuntimeNodeInstrumentation | undefined; diff --git a/packages/mcp/test/integration.test.ts b/packages/mcp/test/integration.test.ts index 18af2e9c1..be3d801df 100644 --- a/packages/mcp/test/integration.test.ts +++ b/packages/mcp/test/integration.test.ts @@ -369,6 +369,29 @@ describe.each([ }); describe("OpenTelemetry metrics", () => { + test("binds the default metrics listener to loopback", async () => { + const defaultMetricsPort = await getFreePort(); + const defaultHostEnvironment = { + ...childEnv, + OTEL_EXPORTER_PROMETHEUS_PORT: String(defaultMetricsPort), + }; + delete defaultHostEnvironment.OTEL_EXPORTER_PROMETHEUS_HOST; + const defaultHostServer = await startHttpChild({ + environment: defaultHostEnvironment, + port: await getFreePort(), + }); + + try { + expect(defaultHostServer.stderr()).toContain( + `OpenTelemetry metrics available at http://127.0.0.1:${defaultMetricsPort}/metrics` + ); + const response = await fetch(`http://127.0.0.1:${defaultMetricsPort}/metrics`); + expect(response.status).toBe(200); + } finally { + defaultHostServer.child.kill(); + } + }); + test("hard-off mode does not load telemetry implementation modules", async () => { const disabledPort = await getFreePort(); const disabledServer = await startHttpChild({ From 31ab0d755d9a49d7dd31ea246b395c607b9ff393 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Fri, 4 Sep 2026 11:52:32 +0300 Subject: [PATCH 09/10] fix(mcp): harden telemetry lifecycle --- packages/mcp/README.md | 31 +- packages/mcp/src/index.ts | 107 +-- packages/mcp/src/lib/api.ts | 25 +- .../mcp/src/lib/mcp-subscription-telemetry.ts | 589 ++++++++++++++ packages/mcp/src/lib/mcp-telemetry.ts | 90 ++- packages/mcp/src/lib/process-shutdown.ts | 96 +++ packages/mcp/src/lib/stdio-shutdown.ts | 82 -- packages/mcp/src/lib/telemetry-contracts.ts | 18 + packages/mcp/src/lib/telemetry-runtime.ts | 129 ++++ packages/mcp/src/lib/telemetry.ts | 88 ++- .../test/mcp-subscription-telemetry.test.ts | 724 ++++++++++++++++++ .../mcp/test/mcp-telemetry-lifecycle.test.ts | 58 +- packages/mcp/test/stdio-shutdown.test.ts | 42 +- packages/mcp/test/telemetry-disabled.test.ts | 14 +- 14 files changed, 1854 insertions(+), 239 deletions(-) create mode 100644 packages/mcp/src/lib/mcp-subscription-telemetry.ts create mode 100644 packages/mcp/src/lib/process-shutdown.ts delete mode 100644 packages/mcp/src/lib/stdio-shutdown.ts create mode 100644 packages/mcp/src/lib/telemetry-contracts.ts create mode 100644 packages/mcp/src/lib/telemetry-runtime.ts create mode 100644 packages/mcp/test/mcp-subscription-telemetry.test.ts diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 566255557..895170b9a 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -1492,10 +1492,11 @@ CONTEXT7_API_KEY=your_api_key_here ### OpenTelemetry observability -Context7 instruments individual MCP requests and notifications dispatched to an MCP server -instance at the SDK transport boundary, including messages inside a valid batch. Requests rejected -by the SDK's HTTP envelope and protocol-version validation before dispatch remain visible in normal -HTTP/gateway telemetry, but are not reported as MCP operations. Dispatched operations follow the +Context7 instruments individual MCP requests and notifications at the SDK transport boundary, +including messages inside a valid batch and MCP v2 `subscriptions/listen` operations handled by the +SDK entry layer. Requests rejected by the SDK's HTTP envelope and protocol-version validation before +dispatch remain visible in normal HTTP/gateway telemetry, but are not reported as MCP operations. +Observed operations follow the development-status [OpenTelemetry MCP semantic conventions](https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/mcp.md) for server metrics and spans. Trace context is extracted from the `traceparent`, `tracestate`, and `baggage` fields in MCP `params._meta` as defined by @@ -1505,9 +1506,10 @@ The HTTP transport exposes metrics in Prometheus format on a dedicated listener `127.0.0.1:9464/metrics` by default. The production Docker image explicitly binds that listener to `0.0.0.0` so an internal Prometheus sidecar or `ServiceMonitor` can reach it. The stdio transport does not open a telemetry port. Keeping this listener separate from the public MCP port prevents -the metrics endpoint from being routed through a catch-all gateway rule. On stdio EOF or SIGHUP, -the server closes the SDK connection, records the session duration, and best-effort flushes an -externally installed SDK `MeterProvider` before exit. +the metrics endpoint from being routed through a catch-all gateway rule. On SIGTERM, SIGINT, or +SIGHUP, both transports use a bounded shutdown path that stops serving, closes active MCP +connections and subscriptions, and best-effort flushes externally installed SDK metric and trace +providers before exit. Stdio EOF triggers the same path. The exporter uses the standard OpenTelemetry Prometheus settings: @@ -1529,8 +1531,9 @@ Node SDK or Kubernetes auto-instrumentation without creating a second provider i When an external SDK owns the provider, configure its Node runtime instrumentation there as well; the application does not register a duplicate collector. -It reports bounded-cardinality counters, histograms, and in-flight gauges for MCP methods, tool -outcomes, authentication outcomes, Context7 upstream requests, and Node runtime saturation. +It reports bounded-cardinality counters, histograms, and in-flight gauges for MCP methods, +subscriptions, tool outcomes, authentication outcomes, Context7 upstream requests, and Node runtime +saturation. Prometheus receives these metric families: - `mcp_server_operation_duration` (its `_count` series is the MCP operation count, and tool-call @@ -1538,6 +1541,7 @@ Prometheus receives these metric families: - `mcp_server_session_duration` for real stateful stdio sessions (stateless HTTP request transports are intentionally excluded) - `context7_mcp_operations_active` +- `context7_mcp_subscriptions_active` and `context7_mcp_subscription_duration` - `context7_mcp_upstream_requests_total` and `context7_mcp_upstream_request_duration` - `context7_mcp_authentication_attempts_total` and `context7_mcp_authentication_duration` - `context7_mcp_upstream_requests_active` and `context7_mcp_authentication_active` @@ -1545,7 +1549,9 @@ Prometheus receives these metric families: `v8js_resource_active` from the official OpenTelemetry Node runtime instrumentation Tool outcomes on the standard MCP operation metric distinguish `success`, `not_found`, and -`error`. Upstream outcomes distinguish +`error`. An acknowledged `subscriptions/listen` operation is timed through its acknowledgement; +the separate subscription metrics track the active stream and its bounded terminal outcome. +Upstream outcomes distinguish HTTP, response-decoding, network, timeout, and cancellation failures and include both the bounded status-code class and the exact numeric HTTP status. Authentication reports accepted, missing, invalid, and unexpected-error outcomes. The OAuth authorization-server metadata proxy caps its @@ -1572,8 +1578,9 @@ signals in the existing Envoy scrape instead of collecting them again from the a - Envoy process health and resource metrics The application exporter owns only signals the ingress gateway cannot provide: MCP method and -protocol semantics (including batches and notifications), tool and authentication outcomes, -MCP-to-Context7 API calls, and Node event-loop/V8 health. In the Kubernetes deployment Envoy is a +protocol semantics (including batches, notifications, and active MCP v2 subscriptions), tool and +authentication outcomes, MCP-to-Context7 API calls, and Node event-loop/V8 health. In the +Kubernetes deployment Envoy is a Gateway API proxy rather than a sidecar in the MCP pod, so `context7_mcp_upstream_*` describes the MCP server's outbound Context7 API dependency, not Envoy's inbound MCP backend cluster. Pod and container CPU, memory, network, and restart metrics should continue to come from the Kubernetes diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 691040332..0a9f9d376 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -30,38 +30,21 @@ import { } from "./lib/constants.js"; import { maybeElicitAuthSignIn } from "./lib/auth/auth-prompt.js"; import { QUERY_DOCS_TOOL, RESOLVE_LIBRARY_ID_TOOL } from "./lib/tool-names.js"; -import { embeddedPrometheusIsEnabled, telemetryIsDisabled } from "./lib/telemetry-config.js"; -import { installStdioShutdown } from "./lib/stdio-shutdown.js"; +import { installProcessShutdown } from "./lib/process-shutdown.js"; import { getMaxSubscriptions } from "./lib/subscriptions.js"; +import { + forceFlushTelemetry, + initializeTelemetry, + observeAuthentication, + observeUpstreamRequest, + recordToolCallOutcome, +} from "./lib/telemetry-runtime.js"; /** Default HTTP server port */ const DEFAULT_PORT = 3000; const OAUTH_METADATA_TIMEOUT_MS = 10_000; -const TELEMETRY_DISABLED = telemetryIsDisabled(); -type TelemetryModule = typeof import("./lib/telemetry.js"); -type ObservedAuthentication = import("./lib/telemetry.js").ObservedAuthentication; -type UpstreamObservationOptions = import("./lib/telemetry.js").UpstreamObservationOptions; -type UpstreamOperation = import("./lib/telemetry.js").UpstreamOperation; - -let telemetry: TelemetryModule | undefined; -let mcpTelemetry: typeof import("./lib/mcp-telemetry.js") | undefined; - -async function observeAuthentication( - operation: () => Promise> -): Promise { - return telemetry ? telemetry.observeAuthentication(operation) : (await operation()).value; -} - -async function observeUpstreamRequest( - operationName: UpstreamOperation, - request: () => Promise, - consumeResponse: (response: Response) => Promise, - options: UpstreamObservationOptions = {} -): Promise { - return telemetry - ? telemetry.observeUpstreamRequest(operationName, request, consumeResponse, options) - : consumeResponse(await request()); -} +type McpInstrumentation = NonNullable>>; +let mcpInstrumentation: McpInstrumentation | undefined; // Parse CLI arguments using commander const program = new Command() @@ -205,8 +188,8 @@ function createMcpServer(mcpContext: McpRequestContext) { Do not use for: refactoring, writing scripts from scratch, debugging business logic, code review, or general programming concepts.`, }; - const server = mcpTelemetry - ? new mcpTelemetry.InstrumentedMcpServer(serverInfo, serverOptions, mcpContext) + const server = mcpInstrumentation + ? mcpInstrumentation.createServer(serverInfo, serverOptions, mcpContext) : new McpServer(serverInfo, serverOptions); server.registerTool( @@ -275,7 +258,7 @@ IMPORTANT: Do not call this tool more than 3 times per question. If you cannot f if (!searchResponse.results || searchResponse.results.length === 0) { const text = searchResponse.error ?? "No libraries found matching the provided name."; maybeElicitAuthSignIn(server, ctx); - telemetry?.recordToolCallOutcome(searchResponse.error ? "error" : "not_found"); + recordToolCallOutcome(searchResponse.error ? "error" : "not_found"); return { content: [ { @@ -289,7 +272,7 @@ IMPORTANT: Do not call this tool more than 3 times per question. If you cannot f const resultsText = formatSearchResults(searchResponse); const responseText = `Available Libraries:\n\n${resultsText}`; maybeElicitAuthSignIn(server, ctx); - telemetry?.recordToolCallOutcome("success"); + recordToolCallOutcome("success"); return { content: [ { @@ -336,7 +319,7 @@ Do not call this tool more than 3 times per question.`, const ctx = getClientContext(toolCtx); const response = await fetchLibraryContext({ query, libraryId }, ctx); maybeElicitAuthSignIn(server, ctx); - telemetry?.recordToolCallOutcome(response.outcome); + recordToolCallOutcome(response.outcome); return { content: [ { @@ -352,19 +335,13 @@ Do not call this tool more than 3 times per question.`, } async function main() { - if (!TELEMETRY_DISABLED) { - [telemetry, mcpTelemetry] = await Promise.all([ - import("./lib/telemetry.js"), - import("./lib/mcp-telemetry.js"), - ]); - } + mcpInstrumentation = await initializeTelemetry({ + allowEmbeddedPrometheus: TRANSPORT_TYPE === "http", + serviceVersion: SERVER_VERSION, + }); if (TRANSPORT_TYPE === "http") { const initialPort = CLI_PORT ?? DEFAULT_PORT; - if (embeddedPrometheusIsEnabled()) { - const { startPrometheusMetrics } = await import("./lib/telemetry-provider.js"); - await startPrometheusMetrics(SERVER_VERSION); - } const app = express(); // Only private/local infrastructure may supply forwarding headers. Express @@ -430,11 +407,14 @@ async function main() { // then never closes the stream, and with heartbeats it survived until the // gateway's 1200s hard cap (the 2026-08-11 outage). Silent hangs instead // go idle and the gateway reaps them at streamIdleTimeout (300s). - const mcpHandler = createMcpHandler((mcpContext) => createMcpServer(mcpContext), { + const rawMcpHandler = createMcpHandler((mcpContext) => createMcpServer(mcpContext), { keepAliveMs: 0, maxSubscriptions: getMaxSubscriptions(), onerror: (error) => console.error("MCP handler error:", error), }); + const mcpHandler = mcpInstrumentation + ? mcpInstrumentation.instrumentHttpHandler(rawMcpHandler) + : rawMcpHandler; // Without onerror, request-conversion / handler.fetch throws are answered // with a bare 500 inside the adapter and never reach our express handler. const nodeHandler = toNodeHandler(mcpHandler, { @@ -612,8 +592,40 @@ async function main() { }); }); + let activeHttpServer: ReturnType | undefined; + installProcessShutdown( + { + close: async () => { + const server = activeHttpServer; + const operations: Promise[] = [mcpHandler.close()]; + if (server) { + operations.unshift( + new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }) + ); + } + const results = await Promise.allSettled(operations); + const failures = results + .filter((result): result is PromiseRejectedResult => result.status === "rejected") + .map((result) => result.reason); + if (failures.length > 0) { + throw new AggregateError(failures, "MCP HTTP server failed to close cleanly"); + } + }, + }, + { + flush: forceFlushTelemetry, + onerror: (error) => console.error("Failed to close MCP HTTP server:", error), + } + ); + const startServer = (port: number, maxAttempts = 10) => { const httpServer = app.listen(port); + activeHttpServer = httpServer; httpServer.once("error", (err: NodeJS.ErrnoException) => { if (err.code === "EADDRINUSE" && port < initialPort + maxAttempts) { @@ -637,8 +649,8 @@ async function main() { stdioApiKey = cliOptions.apiKey || process.env.CONTEXT7_API_KEY; stdioSessionId = randomUUID(); const rawStdioTransport = new StdioServerTransport(); - const stdioTransport = mcpTelemetry - ? mcpTelemetry.instrumentStdioTransport(rawStdioTransport) + const stdioTransport = mcpInstrumentation + ? mcpInstrumentation.instrumentStdioTransport(rawStdioTransport) : rawStdioTransport; const stdioHandle = serveStdio( @@ -664,8 +676,9 @@ async function main() { onerror: (error) => console.error("MCP stdio error:", error), } ); - installStdioShutdown(stdioHandle, { - flush: telemetry?.forceFlushMetrics, + installProcessShutdown(stdioHandle, { + flush: forceFlushTelemetry, + input: process.stdin, onerror: (error) => console.error("Failed to close MCP stdio server:", error), }); diff --git a/packages/mcp/src/lib/api.ts b/packages/mcp/src/lib/api.ts index d9e14334f..2416a2fde 100644 --- a/packages/mcp/src/lib/api.ts +++ b/packages/mcp/src/lib/api.ts @@ -4,8 +4,7 @@ import { Agent, ProxyAgent, setGlobalDispatcher } from "undici"; import { CONTEXT7_API_BASE_URL } from "./constants.js"; import { readFileSync } from "fs"; import tls from "tls"; -import { telemetryIsDisabled } from "./telemetry-config.js"; -import type { UpstreamObservationOptions, UpstreamOperation } from "./telemetry.js"; +import { observeUpstreamRequest } from "./telemetry-runtime.js"; /** * Ceiling on a single Context7 API call. Without a signal a stalled backend @@ -14,28 +13,6 @@ import type { UpstreamObservationOptions, UpstreamOperation } from "./telemetry. * day of production traffic. */ const API_TIMEOUT_MS = 60_000; -const TELEMETRY_DISABLED = telemetryIsDisabled(); -let telemetryModule: Promise | undefined; - -async function observeUpstreamRequest( - operationName: UpstreamOperation, - request: () => Promise, - consumeResponse: (response: Response) => Promise, - options: UpstreamObservationOptions = {} -): Promise { - if (TELEMETRY_DISABLED) return consumeResponse(await request()); - - telemetryModule ??= import("./telemetry.js").catch((error) => { - telemetryModule = undefined; - throw error; - }); - return (await telemetryModule).observeUpstreamRequest( - operationName, - request, - consumeResponse, - options - ); -} /** * Parses error response from the Context7 API diff --git a/packages/mcp/src/lib/mcp-subscription-telemetry.ts b/packages/mcp/src/lib/mcp-subscription-telemetry.ts new file mode 100644 index 000000000..bf37fe027 --- /dev/null +++ b/packages/mcp/src/lib/mcp-subscription-telemetry.ts @@ -0,0 +1,589 @@ +import { + classifyInboundRequest, + isJSONRPCErrorResponse, + isJSONRPCNotification, + isJSONRPCRequest, + isJSONRPCResultResponse, + SUBSCRIPTION_ID_META_KEY, + type JSONRPCMessage, + type McpHandlerRequestOptions, + type McpHttpHandler, + type MessageExtraInfo, + type RequestId, + type Transport, + type TransportSendOptions, +} from "@modelcontextprotocol/server"; +import { metrics, type Attributes } from "@opentelemetry/api"; + +const INSTRUMENTATION_NAME = "io.github.upstash.context7.mcp"; +const SUBSCRIPTION_DURATION_BUCKETS_SECONDS = [ + 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 120, 300, +]; + +export type McpRoute = "anonymous" | "oauth" | "stdio"; +export type SubscriptionOutcome = + | "cancelled" + | "completed" + | "connection_closed" + | "replaced" + | "transport_error"; + +export interface SubscriptionObservation { + abortSignal?: AbortSignal; + networkProtocol?: "http"; + networkTransport: "pipe" | "tcp"; + protocolVersion?: string; + route: McpRoute; +} + +export interface SubscriptionEntryOperation { + applyResponse(response: JSONRPCMessage): void; + fail(errorType: string, error?: unknown): void; + finish(): void; + run(operation: () => T): T; +} + +export type StartSubscriptionEntryOperation = ( + message: JSONRPCMessage, + observation: SubscriptionObservation +) => SubscriptionEntryOperation; + +function createInstruments() { + const meter = metrics.getMeter(INSTRUMENTATION_NAME); + return { + activeSubscriptions: meter.createUpDownCounter("context7.mcp.subscriptions.active", { + description: "Number of accepted MCP subscriptions currently open", + unit: "{subscription}", + }), + subscriptionDuration: meter.createHistogram("context7.mcp.subscription.duration", { + description: "Duration of an accepted MCP subscription", + unit: "s", + advice: { explicitBucketBoundaries: SUBSCRIPTION_DURATION_BUCKETS_SECONDS }, + }), + }; +} + +let instruments: ReturnType | undefined; + +function getInstruments(): ReturnType { + instruments ??= createInstruments(); + return instruments; +} + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function elapsedSeconds(startedAt: number): number { + return (performance.now() - startedAt) / 1_000; +} + +export class SubscriptionLifecycle { + private readonly abortSignal?: AbortSignal; + private readonly metricAttributes: Attributes; + private finished = false; + private readonly startedAt = performance.now(); + + constructor( + observation: SubscriptionObservation, + private readonly onFinish?: () => void + ) { + this.abortSignal = observation.abortSignal; + this.metricAttributes = { + "context7.mcp.route": observation.route, + "network.transport": observation.networkTransport, + ...(observation.protocolVersion + ? { "mcp.protocol.version": observation.protocolVersion } + : {}), + }; + getInstruments().activeSubscriptions.add(1, this.metricAttributes); + this.abortSignal?.addEventListener("abort", this.handleAbort, { once: true }); + } + + private readonly handleAbort = (): void => { + this.finish("cancelled"); + }; + + finish(outcome: SubscriptionOutcome): void { + if (this.finished) return; + this.finished = true; + this.abortSignal?.removeEventListener("abort", this.handleAbort); + const { activeSubscriptions, subscriptionDuration } = getInstruments(); + activeSubscriptions.add(-1, this.metricAttributes); + subscriptionDuration.record(elapsedSeconds(this.startedAt), { + ...this.metricAttributes, + "context7.mcp.subscription.outcome": outcome, + }); + this.onFinish?.(); + } +} + +export function subscriptionAcknowledgementId(message: JSONRPCMessage): RequestId | undefined { + if ( + !isJSONRPCNotification(message) || + message.method !== "notifications/subscriptions/acknowledged" + ) { + return undefined; + } + const subscriptionId = asRecord(asRecord(message.params)?._meta)?.[SUBSCRIPTION_ID_META_KEY]; + return typeof subscriptionId === "string" || typeof subscriptionId === "number" + ? subscriptionId + : undefined; +} + +export function completedSubscriptionId(message: JSONRPCMessage): RequestId | undefined { + if (!isJSONRPCResultResponse(message)) return undefined; + const result = asRecord(message.result); + if (result?.resultType !== "complete") return undefined; + const subscriptionId = asRecord(result._meta)?.[SUBSCRIPTION_ID_META_KEY]; + return typeof subscriptionId === "string" || typeof subscriptionId === "number" + ? subscriptionId + : undefined; +} + +function cancellationRequestId(message: JSONRPCMessage): RequestId | undefined { + if (!isJSONRPCNotification(message) || message.method !== "notifications/cancelled") { + return undefined; + } + const requestId = asRecord(message.params)?.requestId; + return typeof requestId === "string" || typeof requestId === "number" ? requestId : undefined; +} + +interface ListenAttempt { + cancelRequested: boolean; + operation: SubscriptionEntryOperation; + phase: "acknowledging" | "pending" | "rejecting"; +} + +interface StdioSubscription { + acknowledgementWritePending: boolean; + lifecycle: SubscriptionLifecycle; + terminalWritePending: boolean; +} + +interface StdioSubscriptionState { + attempts: ListenAttempt[]; + subscription?: StdioSubscription; +} + +/** Owns the entry-handled listen/cancel state that never reaches an MCP server transport. */ +export class StdioSubscriptionTelemetry { + private connectionClosed = false; + private readonly states = new Map(); + + constructor( + private readonly startOperation: StartSubscriptionEntryOperation, + private readonly modernProtocolVersion: string + ) {} + + receive( + message: JSONRPCMessage, + extra: MessageExtraInfo | undefined, + handler: NonNullable, + protocolVersion?: string + ): boolean { + const observation = this.observation(protocolVersion); + if ( + protocolVersion === this.modernProtocolVersion && + isJSONRPCRequest(message) && + message.method === "subscriptions/listen" + ) { + const operation = this.startOperation(message, observation); + const state = this.stateFor(message.id); + const attempt: ListenAttempt = { cancelRequested: false, operation, phase: "pending" }; + state.attempts.push(attempt); + try { + operation.run(() => handler(message, extra)); + } catch (error) { + operation.fail("handler_error", error); + this.removeAttempt(message.id, state, attempt); + throw error; + } + return true; + } + + const cancelledId = cancellationRequestId(message); + const state = cancelledId === undefined ? undefined : this.states.get(cancelledId); + const pendingAttempt = state?.attempts.at(-1); + const activeSubscription = + state?.subscription !== undefined && + !state.subscription.acknowledgementWritePending && + !state.subscription.terminalWritePending; + if ( + protocolVersion !== this.modernProtocolVersion || + cancelledId === undefined || + (!pendingAttempt && !activeSubscription) + ) { + return false; + } + + const operation = this.startOperation(message, observation); + try { + operation.run(() => handler(message, extra)); + if (pendingAttempt) { + pendingAttempt.cancelRequested = true; + } else if (state?.subscription && activeSubscription) { + state.subscription.lifecycle.finish("cancelled"); + state.subscription = undefined; + this.prune(cancelledId, state); + } + operation.finish(); + } catch (error) { + operation.fail("handler_error", error); + throw error; + } + return true; + } + + async send( + message: JSONRPCMessage, + options: TransportSendOptions | undefined, + send: (message: JSONRPCMessage, options?: TransportSendOptions) => Promise, + protocolVersion?: string + ): Promise { + const acknowledgementId = subscriptionAcknowledgementId(message); + const rejectionId = isJSONRPCErrorResponse(message) ? message.id : undefined; + const attemptId = acknowledgementId ?? rejectionId; + const state = attemptId === undefined ? undefined : this.states.get(attemptId); + const pendingAttempt = state?.attempts[0]; + const attempt = pendingAttempt?.phase === "pending" ? pendingAttempt : undefined; + const completionId = completedSubscriptionId(message); + const completionState = completionId === undefined ? undefined : this.states.get(completionId); + const completing = + completionState?.subscription && !completionState.subscription.terminalWritePending + ? completionState.subscription + : undefined; + let acknowledged: StdioSubscription | undefined; + + if (attempt && acknowledgementId !== undefined && state) { + state.subscription?.lifecycle.finish("replaced"); + acknowledged = { + acknowledgementWritePending: true, + lifecycle: new SubscriptionLifecycle(this.observation(protocolVersion)), + terminalWritePending: false, + }; + state.subscription = acknowledged; + attempt.phase = "acknowledging"; + } else if (attempt && rejectionId !== undefined && state) { + attempt.operation.applyResponse(message); + attempt.phase = "rejecting"; + } + if (completing && completionState) { + completing.terminalWritePending = true; + } + + try { + await send(message, options); + if (attempt && acknowledgementId !== undefined && state && acknowledged) { + this.settleAcknowledgement(acknowledgementId, state, attempt, acknowledged, false); + } else if (attempt && rejectionId !== undefined && state) { + this.settleRejection(rejectionId, state, attempt); + } + if (completionId !== undefined && completionState && completing) { + completing.lifecycle.finish("completed"); + if (completionState.subscription === completing) { + completionState.subscription = undefined; + } + this.prune(completionId, completionState); + } + } catch (error) { + attempt?.operation.fail("transport_error", error); + if (attempt && acknowledgementId !== undefined && state && acknowledged) { + this.settleAcknowledgement(acknowledgementId, state, attempt, acknowledged, true); + } else if (attempt && rejectionId !== undefined && state) { + this.settleRejection(rejectionId, state, attempt); + } + if (completionId !== undefined && completionState && completing) { + completing.lifecycle.finish("transport_error"); + if (completionState.subscription === completing) { + completionState.subscription = undefined; + } + this.prune(completionId, completionState); + } + throw error; + } finally { + attempt?.operation.finish(); + } + } + + close(outcome: "connection_closed" | "transport_error", includeSending = false): void { + this.connectionClosed = true; + for (const [requestId, state] of this.states) { + state.attempts = state.attempts.filter((attempt) => { + if (!includeSending && attempt.phase !== "pending") return true; + attempt.operation.fail(outcome); + return false; + }); + const subscription = state.subscription; + if ( + subscription && + (includeSending || + (!subscription.acknowledgementWritePending && !subscription.terminalWritePending)) + ) { + subscription.lifecycle.finish(outcome); + state.subscription = undefined; + } + this.prune(requestId, state); + } + } + + private settleAcknowledgement( + requestId: RequestId, + state: StdioSubscriptionState, + attempt: ListenAttempt, + subscription: StdioSubscription, + sendFailed: boolean + ): void { + this.removeAttempt(requestId, state, attempt); + if (state.subscription !== subscription) return; + subscription.acknowledgementWritePending = false; + if (subscription.terminalWritePending) { + this.prune(requestId, state); + return; + } + if (attempt.cancelRequested) { + subscription.lifecycle.finish("cancelled"); + state.subscription = undefined; + } else if (this.connectionClosed) { + subscription.lifecycle.finish(sendFailed ? "transport_error" : "connection_closed"); + state.subscription = undefined; + } + // The SDK retains an accepted subscription even when only the ACK write + // fails, so it must continue consuming capacity until cancel/close. + this.prune(requestId, state); + } + + private settleRejection( + requestId: RequestId, + state: StdioSubscriptionState, + attempt: ListenAttempt + ): void { + this.removeAttempt(requestId, state, attempt); + // The queued cancellation is processed after this rejected listen. It can + // only cancel a pre-existing subscription; otherwise it is a no-op. + if ( + attempt.cancelRequested && + state.subscription && + !state.subscription.acknowledgementWritePending && + !state.subscription.terminalWritePending + ) { + state.subscription.lifecycle.finish("cancelled"); + state.subscription = undefined; + } + this.prune(requestId, state); + } + + private removeAttempt( + requestId: RequestId, + state: StdioSubscriptionState, + attempt: ListenAttempt + ): void { + const index = state.attempts.findIndex( + (candidate) => candidate.operation === attempt.operation + ); + if (index !== -1) state.attempts.splice(index, 1); + this.prune(requestId, state); + } + + private stateFor(requestId: RequestId): StdioSubscriptionState { + let state = this.states.get(requestId); + if (!state) { + state = { attempts: [] }; + this.states.set(requestId, state); + } + return state; + } + + private prune(requestId: RequestId, state: StdioSubscriptionState): void { + if ( + state.attempts.length === 0 && + !state.subscription && + this.states.get(requestId) === state + ) { + this.states.delete(requestId); + } + } + + private observation(protocolVersion?: string): SubscriptionObservation { + return { networkTransport: "pipe", protocolVersion, route: "stdio" }; + } +} + +export function mcpRouteFromUrl(url: string): McpRoute { + const pathname = new URL(url).pathname.replace(/\/+$/, "").toLowerCase(); + return pathname === "/mcp/oauth" ? "oauth" : "anonymous"; +} + +interface ModernListenRequest { + message: JSONRPCMessage; + protocolVersion?: string; +} + +async function modernListenRequest( + request: Request, + options?: McpHandlerRequestOptions +): Promise { + let body = options?.parsedBody; + if (body === undefined) { + // Avoid cloning and decoding every request. Modern clients identify this + // method in the standard header, and Node adapters pass parsedBody anyway. + if (request.headers.get("mcp-method") !== "subscriptions/listen") return undefined; + try { + body = await request.clone().json(); + } catch { + return undefined; + } + } + + if (!isJSONRPCRequest(body) || body.method !== "subscriptions/listen") return undefined; + const classified = classifyInboundRequest({ + body, + httpMethod: request.method, + mcpMethodHeader: request.headers.get("mcp-method") ?? undefined, + mcpNameHeader: request.headers.get("mcp-name") ?? undefined, + protocolVersionHeader: request.headers.get("mcp-protocol-version") ?? undefined, + }); + if ( + classified.kind !== "modern" || + classified.messageKind !== "request" || + classified.message.method !== "subscriptions/listen" + ) { + return undefined; + } + return { + message: classified.message, + protocolVersion: classified.classification.revision, + }; +} + +function isSubscriptionStream(response: Response): boolean { + return ( + response.headers.get("content-type")?.toLowerCase().startsWith("text/event-stream") === true + ); +} + +function wrapSubscriptionStream( + response: Response, + lifecycle: SubscriptionLifecycle, + abortSignal: AbortSignal +): Response { + const source = response.body; + if (!source) { + lifecycle.finish("transport_error"); + return response; + } + + const reader = source.getReader(); + const observed = new ReadableStream({ + async pull(controller) { + try { + const chunk = await reader.read(); + if (chunk.done) { + lifecycle.finish("completed"); + controller.close(); + return; + } + controller.enqueue(chunk.value); + } catch (error) { + lifecycle.finish(abortSignal.aborted ? "cancelled" : "transport_error"); + controller.error(error); + } + }, + async cancel(reason) { + lifecycle.finish("cancelled"); + await reader.cancel(reason); + }, + }); + return new Response(observed, { + headers: response.headers, + status: response.status, + statusText: response.statusText, + }); +} + +/** + * Observes only the MCP v2 listen route that createMcpHandler serves before it + * connects a server transport. All other HTTP traffic delegates untouched, so + * Envoy remains the owner of generic inbound HTTP telemetry. + */ +export function instrumentMcpHttpHandler( + handler: McpHttpHandler, + startOperation: StartSubscriptionEntryOperation +): McpHttpHandler { + const subscriptions = new Set(); + const finishSubscriptions = (outcome: SubscriptionOutcome): void => { + for (const subscription of subscriptions) subscription.finish(outcome); + }; + return { + bus: handler.bus, + notify: handler.notify, + close: async () => { + try { + await handler.close(); + finishSubscriptions("completed"); + } catch (error) { + finishSubscriptions("transport_error"); + throw error; + } + }, + fetch: async (request, options) => { + const listen = await modernListenRequest(request, options); + if (!listen) return handler.fetch(request, options); + + const observation: SubscriptionObservation = { + abortSignal: request.signal, + networkProtocol: "http", + networkTransport: "tcp", + protocolVersion: listen.protocolVersion, + route: mcpRouteFromUrl(request.url), + }; + const operation = startOperation(listen.message, observation); + const abortOperation = (): void => operation.fail("cancelled"); + request.signal.addEventListener("abort", abortOperation, { once: true }); + if (request.signal.aborted) abortOperation(); + + try { + const response = await operation.run(() => handler.fetch(request, options)); + if (!isSubscriptionStream(response)) { + try { + const message: unknown = await response.clone().json(); + if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { + operation.applyResponse(message); + operation.finish(); + } else if (response.status >= 500) { + operation.fail(`http_${response.status}`); + } else { + operation.finish(); + } + } catch { + if (response.status >= 500) operation.fail(`http_${response.status}`); + else operation.finish(); + } + return response; + } + + // The SDK enqueues the mandatory acknowledgement before resolving + // fetch. The semantic operation therefore ends here; only the custom + // subscription lifecycle remains active for the SSE stream lifetime. + operation.finish(); + let lifecycle!: SubscriptionLifecycle; + lifecycle = new SubscriptionLifecycle(observation, () => subscriptions.delete(lifecycle)); + subscriptions.add(lifecycle); + if (request.signal.aborted) lifecycle.finish("cancelled"); + try { + return wrapSubscriptionStream(response, lifecycle, request.signal); + } catch (error) { + lifecycle.finish("transport_error"); + throw error; + } + } catch (error) { + operation.fail(request.signal.aborted ? "cancelled" : "handler_error", error); + throw error; + } finally { + request.signal.removeEventListener("abort", abortOperation); + } + }, + }; +} diff --git a/packages/mcp/src/lib/mcp-telemetry.ts b/packages/mcp/src/lib/mcp-telemetry.ts index 6f51489ca..bbe482e92 100644 --- a/packages/mcp/src/lib/mcp-telemetry.ts +++ b/packages/mcp/src/lib/mcp-telemetry.ts @@ -13,6 +13,7 @@ import { type Implementation, type JSONRPCMessage, type McpRequestContext, + type McpHttpHandler, type MessageExtraInfo, type RequestId, type ServerOptions, @@ -35,6 +36,15 @@ import { } from "@opentelemetry/api"; import { MCP_TOOL_NAMES, type ToolCallOutcome } from "./tool-names.js"; import { runInMcpOperationScope } from "./mcp-operation-scope.js"; +import { + StdioSubscriptionTelemetry, + instrumentMcpHttpHandler as instrumentHttpSubscriptions, + mcpRouteFromUrl, + type SubscriptionEntryOperation, + type SubscriptionObservation, +} from "./mcp-subscription-telemetry.js"; + +export { mcpRouteFromUrl } from "./mcp-subscription-telemetry.js"; const INSTRUMENTATION_NAME = "io.github.upstash.context7.mcp"; const MCP_DURATION_BUCKETS_SECONDS = [ @@ -84,16 +94,7 @@ const KNOWN_PROTOCOL_VERSIONS = new Set([ ]); const EMPTY_TRACE_CARRIER: Record = Object.freeze({}); -type McpRoute = "anonymous" | "oauth" | "stdio"; -type NetworkTransport = "pipe" | "tcp"; - -interface McpObservationConfig { - abortSignal?: AbortSignal; - route: McpRoute; - networkTransport: NetworkTransport; - networkProtocol?: "http"; - protocolVersion?: string; -} +type McpObservationConfig = SubscriptionObservation; type McpOperationState = "finished" | "handling" | "sending"; @@ -301,8 +302,8 @@ function operationIsFinished(operation: McpOperation): boolean { return operation.state === "finished"; } -function runOperation(operation: McpOperation, handler: () => void): void { - runInMcpOperationScope(operation, () => context.with(operation.context, handler)); +function runOperation(operation: McpOperation, handler: () => T): T { + return runInMcpOperationScope(operation, () => context.with(operation.context, handler)); } export function classifyServerResponse( @@ -357,11 +358,6 @@ function cancellationRequestId(message: JSONRPCMessage): RequestId | undefined { return typeof requestId === "string" || typeof requestId === "number" ? requestId : undefined; } -export function mcpRouteFromUrl(url: string): McpRoute { - const pathname = new URL(url).pathname.replace(/\/+$/, ""); - return pathname === "/mcp/oauth" ? "oauth" : "anonymous"; -} - function configFromRequestContext(requestContext: McpRequestContext): McpObservationConfig { const request = requestContext.requestInfo; if (!request) { @@ -383,6 +379,37 @@ function configFromRequestContext(requestContext: McpRequestContext): McpObserva }; } +function startSubscriptionEntryOperation( + message: JSONRPCMessage, + observation: SubscriptionObservation +): SubscriptionEntryOperation { + const operation = startOperation(message, { + ...observation, + protocolVersion: normalizedProtocolVersion(observation.protocolVersion), + }); + if (!operation) throw new TypeError("Expected an MCP request or notification"); + return { + applyResponse(response) { + if (!operationIsFinished(operation)) applyServerResponse(response, operation); + }, + fail(errorType, error) { + if (operationIsFinished(operation)) return; + operation.errorType = errorType; + operation.statusDescription = error instanceof Error ? error.message : undefined; + if (error instanceof Error) operation.span.recordException(error); + finishOperation(operation); + }, + finish() { + finishOperation(operation); + }, + run: (callback: () => T): T => runOperation(operation, callback), + }; +} + +export function instrumentMcpHttpHandler(handler: McpHttpHandler): McpHttpHandler { + return instrumentHttpSubscriptions(handler, startSubscriptionEntryOperation); +} + class InstrumentedTransport implements Transport { private readonly abortSignal?: AbortSignal; private readonly inFlight = new Map(); @@ -594,6 +621,10 @@ class InstrumentedStdioTransport implements Transport { private pendingTerminalError = false; private protocolVersion?: string; private readonly startedAt = performance.now(); + private readonly subscriptions = new StdioSubscriptionTelemetry( + startSubscriptionEntryOperation, + MODERN_MCP_PROTOCOL_VERSION + ); constructor(private readonly transport: Transport) { this.onclose = transport.onclose; @@ -620,6 +651,7 @@ class InstrumentedStdioTransport implements Transport { set onclose(handler: Transport["onclose"]) { this.closeHandler = handler; this.transport.onclose = () => { + this.subscriptions.close("connection_closed"); if (!this.closeRequested) { this.finish(this.pendingTerminalError ? "transport_error" : undefined); } @@ -649,10 +681,7 @@ class InstrumentedStdioTransport implements Transport { set onmessage(handler: Transport["onmessage"]) { this.messageHandler = handler; this.transport.onmessage = handler - ? (message, extra) => { - this.protocolVersion = messageProtocolVersion(message) ?? this.protocolVersion; - handler(message, extra); - } + ? (message, extra) => this.receive(message, extra, handler) : undefined; } @@ -685,19 +714,36 @@ class InstrumentedStdioTransport implements Transport { async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise { try { - await this.transport.send(message, options); + await this.subscriptions.send( + message, + options, + (outbound, sendOptions) => this.transport.send(outbound, sendOptions), + this.protocolVersion + ); } catch (error) { this.errorType ??= "transport_error"; throw error; } } + private receive( + message: JSONRPCMessage, + extra: MessageExtraInfo | undefined, + handler: NonNullable + ): void { + this.protocolVersion = messageProtocolVersion(message) ?? this.protocolVersion; + if (this.subscriptions.receive(message, extra, handler, this.protocolVersion)) return; + handler(message, extra); + } + private async closeTransport(): Promise { try { await this.transport.close(); + this.subscriptions.close("connection_closed", true); this.finish(); } catch (error) { this.errorType = "transport_error"; + this.subscriptions.close("transport_error", true); this.finish("transport_error"); throw error; } diff --git a/packages/mcp/src/lib/process-shutdown.ts b/packages/mcp/src/lib/process-shutdown.ts new file mode 100644 index 000000000..f39fd8ab6 --- /dev/null +++ b/packages/mcp/src/lib/process-shutdown.ts @@ -0,0 +1,96 @@ +const DEFAULT_CLOSE_TIMEOUT_MS = 5_000; +const DEFAULT_FLUSH_TIMEOUT_MS = 5_000; + +interface ShutdownHandle { + close(): Promise; +} + +interface InputLifecycle { + once(event: "close" | "end", listener: () => void): unknown; +} + +interface SignalLifecycle { + once(event: "SIGHUP" | "SIGINT" | "SIGTERM", listener: () => void): unknown; +} + +interface ProcessShutdownOptions { + closeTimeoutMs?: number; + exit?: (code: number) => void; + flush?: () => Promise; + flushTimeoutMs?: number; + input?: InputLifecycle; + onerror?: (error: unknown) => void; + signals?: SignalLifecycle; +} + +async function withTimeout( + operation: () => Promise, + timeoutMs: number, + description: string +): Promise { + let timeout: NodeJS.Timeout | undefined; + try { + await Promise.race([ + operation(), + new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error(`${description} exceeded ${timeoutMs}ms`)), + timeoutMs + ); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +} + +/** + * Coordinates one bounded, idempotent process shutdown for HTTP and stdio. + * Passing an input lifecycle additionally treats stdio EOF as termination. + */ +export function installProcessShutdown( + handle: ShutdownHandle, + options: ProcessShutdownOptions = {} +): () => void { + const signals = options.signals ?? process; + const exit = options.exit ?? ((code: number) => process.exit(code)); + const closeTimeoutMs = options.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS; + const flushTimeoutMs = options.flushTimeoutMs ?? DEFAULT_FLUSH_TIMEOUT_MS; + let shutdownPromise: Promise | undefined; + + const reportError = (error: unknown): void => { + try { + options.onerror?.(error); + } catch { + // A reporting callback must not prevent shutdown. + } + }; + + const shutdown = (): void => { + shutdownPromise ??= (async () => { + let exitCode = 0; + try { + await withTimeout(() => handle.close(), closeTimeoutMs, "Server close"); + } catch (error) { + exitCode = 1; + reportError(error); + } + + try { + if (options.flush) { + await withTimeout(options.flush, flushTimeoutMs, "OpenTelemetry flush"); + } + } catch (error) { + reportError(error); + } + exit(exitCode); + })(); + }; + + options.input?.once("end", shutdown); + options.input?.once("close", shutdown); + signals.once("SIGHUP", shutdown); + signals.once("SIGINT", shutdown); + signals.once("SIGTERM", shutdown); + return shutdown; +} diff --git a/packages/mcp/src/lib/stdio-shutdown.ts b/packages/mcp/src/lib/stdio-shutdown.ts deleted file mode 100644 index 52b069995..000000000 --- a/packages/mcp/src/lib/stdio-shutdown.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { StdioServerHandle } from "@modelcontextprotocol/server/stdio"; - -const DEFAULT_FLUSH_TIMEOUT_MS = 5_000; - -interface StdioInputLifecycle { - once(event: "close" | "end", listener: () => void): unknown; -} - -interface ProcessSignalLifecycle { - once(event: "SIGHUP", listener: () => void): unknown; -} - -interface StdioShutdownOptions { - exit?: (code: number) => void; - flush?: () => Promise; - flushTimeoutMs?: number; - input?: StdioInputLifecycle; - onerror?: (error: unknown) => void; - signals?: ProcessSignalLifecycle; -} - -/** - * Closes the SDK-owned stdio connection before exiting. This is intentionally - * idempotent because Node commonly emits both `end` and `close` for stdin. - */ -export function installStdioShutdown( - handle: StdioServerHandle, - options: StdioShutdownOptions = {} -): () => void { - const input = options.input ?? process.stdin; - const signals = options.signals ?? process; - const exit = options.exit ?? ((code: number) => process.exit(code)); - const flushTimeoutMs = options.flushTimeoutMs ?? DEFAULT_FLUSH_TIMEOUT_MS; - let shutdownPromise: Promise | undefined; - - const reportError = (error: unknown): void => { - try { - options.onerror?.(error); - } catch { - // A reporting callback must not prevent shutdown. - } - }; - - const shutdown = (): void => { - shutdownPromise ??= (async () => { - let exitCode = 0; - try { - await handle.close(); - } catch (error) { - exitCode = 1; - reportError(error); - } - - try { - if (options.flush) { - let timeout: NodeJS.Timeout | undefined; - try { - await Promise.race([ - options.flush(), - new Promise((_resolve, reject) => { - timeout = setTimeout( - () => reject(new Error(`OpenTelemetry flush exceeded ${flushTimeoutMs}ms`)), - flushTimeoutMs - ); - }), - ]); - } finally { - if (timeout) clearTimeout(timeout); - } - } - } catch (error) { - reportError(error); - } - exit(exitCode); - })(); - }; - - input.once("end", shutdown); - input.once("close", shutdown); - signals.once("SIGHUP", shutdown); - return shutdown; -} diff --git a/packages/mcp/src/lib/telemetry-contracts.ts b/packages/mcp/src/lib/telemetry-contracts.ts new file mode 100644 index 000000000..6c9b2bbf2 --- /dev/null +++ b/packages/mcp/src/lib/telemetry-contracts.ts @@ -0,0 +1,18 @@ +export type UpstreamOperation = "fetch_context" | "oauth_metadata" | "search_libraries"; +export type AuthenticationOutcome = "accepted" | "error" | "invalid" | "missing"; +export type UpstreamOutcome = + | "cancelled" + | "http_error" + | "network_error" + | "response_error" + | "success" + | "timeout"; + +export interface ObservedAuthentication { + outcome: AuthenticationOutcome; + value: T; +} + +export interface UpstreamObservationOptions { + abortSignal?: AbortSignal; +} diff --git a/packages/mcp/src/lib/telemetry-runtime.ts b/packages/mcp/src/lib/telemetry-runtime.ts new file mode 100644 index 000000000..168d50ae2 --- /dev/null +++ b/packages/mcp/src/lib/telemetry-runtime.ts @@ -0,0 +1,129 @@ +import type { + McpHttpHandler, + McpRequestContext, + McpServer, + Implementation, + ServerOptions, + Transport, +} from "@modelcontextprotocol/server"; +import type { + ObservedAuthentication, + UpstreamObservationOptions, + UpstreamOperation, +} from "./telemetry-contracts.js"; +import { embeddedPrometheusIsEnabled, telemetryIsDisabled } from "./telemetry-config.js"; +import type { ToolCallOutcome } from "./tool-names.js"; + +interface TelemetryStartupOptions { + allowEmbeddedPrometheus: boolean; + serviceVersion: string; +} + +interface McpInstrumentation { + createServer( + serverInfo: Implementation, + serverOptions: ServerOptions, + requestContext: McpRequestContext + ): McpServer; + instrumentHttpHandler(handler: McpHttpHandler): McpHttpHandler; + instrumentStdioTransport(transport: Transport): Transport; +} + +type TelemetryImplementation = typeof import("./telemetry.js"); +type McpTelemetryImplementation = typeof import("./mcp-telemetry.js"); + +const TELEMETRY_DISABLED = telemetryIsDisabled(); +let implementation: TelemetryImplementation | undefined; +let implementationPromise: Promise | undefined; +let mcpImplementationPromise: Promise | undefined; +let prometheusStartup: Promise | undefined; + +function loadImplementation(): Promise { + implementationPromise ??= import("./telemetry.js") + .then((loaded) => { + implementation = loaded; + return loaded; + }) + .catch((error) => { + implementationPromise = undefined; + throw error; + }); + return implementationPromise; +} + +function loadMcpImplementation(): Promise { + mcpImplementationPromise ??= import("./mcp-telemetry.js").catch((error) => { + mcpImplementationPromise = undefined; + throw error; + }); + return mcpImplementationPromise; +} + +async function startEmbeddedPrometheus(serviceVersion: string): Promise { + prometheusStartup ??= import("./telemetry-provider.js") + .then(({ startPrometheusMetrics }) => startPrometheusMetrics(serviceVersion)) + .catch((error) => { + prometheusStartup = undefined; + throw error; + }); + await prometheusStartup; +} + +export async function initializeTelemetry( + options: TelemetryStartupOptions +): Promise { + if (TELEMETRY_DISABLED) return undefined; + + const [loadedTelemetry, loadedMcpTelemetry] = await Promise.all([ + loadImplementation(), + loadMcpImplementation(), + ]); + implementation = loadedTelemetry; + + if (options.allowEmbeddedPrometheus && embeddedPrometheusIsEnabled()) { + await startEmbeddedPrometheus(options.serviceVersion); + } + + return { + createServer: (serverInfo, serverOptions, requestContext) => + new loadedMcpTelemetry.InstrumentedMcpServer(serverInfo, serverOptions, requestContext), + instrumentHttpHandler: loadedMcpTelemetry.instrumentMcpHttpHandler, + instrumentStdioTransport: loadedMcpTelemetry.instrumentStdioTransport, + }; +} + +export async function observeAuthentication( + operation: () => Promise> +): Promise { + if (TELEMETRY_DISABLED) return (await operation()).value; + return (await loadImplementation()).observeAuthentication(operation); +} + +export async function observeUpstreamRequest( + operationName: UpstreamOperation, + request: () => Promise, + consumeResponse: (response: Response) => Promise, + options: UpstreamObservationOptions = {} +): Promise { + if (TELEMETRY_DISABLED) return consumeResponse(await request()); + return (await loadImplementation()).observeUpstreamRequest( + operationName, + request, + consumeResponse, + options + ); +} + +export function recordToolCallOutcome(outcome: ToolCallOutcome): void { + implementation?.recordToolCallOutcome(outcome); +} + +export async function forceFlushTelemetry(): Promise { + await implementation?.forceFlushTelemetry(); +} + +export type { + ObservedAuthentication, + UpstreamObservationOptions, + UpstreamOperation, +} from "./telemetry-contracts.js"; diff --git a/packages/mcp/src/lib/telemetry.ts b/packages/mcp/src/lib/telemetry.ts index 50e9e5f95..677b6a0c6 100644 --- a/packages/mcp/src/lib/telemetry.ts +++ b/packages/mcp/src/lib/telemetry.ts @@ -1,12 +1,16 @@ -import { metrics, type Attributes } from "@opentelemetry/api"; +import { metrics, trace, type Attributes } from "@opentelemetry/api"; import { markCurrentMcpOperationError, markCurrentMcpToolOutcome } from "./mcp-operation-scope.js"; -import { telemetryIsDisabled } from "./telemetry-config.js"; +import type { + AuthenticationOutcome, + ObservedAuthentication, + UpstreamObservationOptions, + UpstreamOperation, + UpstreamOutcome, +} from "./telemetry-contracts.js"; import type { ToolCallOutcome } from "./tool-names.js"; const METER_NAME = "io.github.upstash.context7.mcp"; -const SHUTDOWN_METRIC_FLUSH_TIMEOUT_MS = 4_000; const DURATION_BUCKETS_SECONDS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60]; -const TELEMETRY_DISABLED = telemetryIsDisabled(); const TIMEOUT_ERROR_CODES = new Set([ "ETIMEDOUT", "UND_ERR_BODY_TIMEOUT", @@ -23,24 +27,13 @@ const NETWORK_ERROR_CODES = new Set([ "EPIPE", ]); -export type UpstreamOperation = "fetch_context" | "oauth_metadata" | "search_libraries"; -export type AuthenticationOutcome = "accepted" | "error" | "invalid" | "missing"; -export type UpstreamOutcome = - | "cancelled" - | "http_error" - | "network_error" - | "response_error" - | "success" - | "timeout"; - -export interface ObservedAuthentication { - outcome: AuthenticationOutcome; - value: T; -} - -export interface UpstreamObservationOptions { - abortSignal?: AbortSignal; -} +export type { + AuthenticationOutcome, + ObservedAuthentication, + UpstreamObservationOptions, + UpstreamOperation, + UpstreamOutcome, +} from "./telemetry-contracts.js"; function createInstruments() { const meter = metrics.getMeter(METER_NAME); @@ -129,7 +122,6 @@ export function classifyUpstreamError( } export function recordToolCallOutcome(outcome: ToolCallOutcome): void { - if (TELEMETRY_DISABLED) return; if (outcome === "error") markCurrentMcpOperationError(); markCurrentMcpToolOutcome(outcome); } @@ -140,8 +132,6 @@ export async function observeUpstreamRequest( consumeResponse: (response: Response) => Promise, options: UpstreamObservationOptions = {} ): Promise { - if (TELEMETRY_DISABLED) return consumeResponse(await request()); - const { activeUpstreamRequests, upstreamRequestDuration, upstreamRequests } = getInstruments(); const activeAttributes: Attributes = { "context7.upstream.operation": operationName }; const startedAt = performance.now(); @@ -182,28 +172,48 @@ export async function observeUpstreamRequest( } } -export async function forceFlushMetrics(): Promise { - if (TELEMETRY_DISABLED) return; +interface FlushableProvider { + forceFlush(): Promise; +} - const provider = metrics.getMeterProvider() as { - forceFlush?: (options?: { timeoutMillis?: number }) => Promise; - }; - if (!provider.forceFlush) return; +interface DelegatingProvider { + getDelegate(): unknown; +} - try { - await provider.forceFlush.call(provider, { - timeoutMillis: SHUTDOWN_METRIC_FLUSH_TIMEOUT_MS, - }); - } catch (error) { - console.error("OpenTelemetry metrics failed to flush during shutdown:", error); +function flushableProvider(provider: unknown): FlushableProvider | undefined { + let current = provider; + for (let depth = 0; current && typeof current === "object" && depth < 4; depth += 1) { + if ("forceFlush" in current && typeof current.forceFlush === "function") { + return current as FlushableProvider; + } + if (!("getDelegate" in current) || typeof current.getDelegate !== "function") return undefined; + + const delegate = (current as DelegatingProvider).getDelegate(); + if (delegate === current) return undefined; + current = delegate; + } + return undefined; +} + +export async function forceFlushTelemetry(): Promise { + const providers = [ + flushableProvider(metrics.getMeterProvider()), + flushableProvider(trace.getTracerProvider()), + ].filter((provider): provider is FlushableProvider => provider !== undefined); + const results = await Promise.allSettled( + providers.map((provider) => provider.forceFlush.call(provider)) + ); + const failures = results + .filter((result): result is PromiseRejectedResult => result.status === "rejected") + .map((result) => result.reason); + if (failures.length > 0) { + throw new AggregateError(failures, "OpenTelemetry providers failed to flush during shutdown"); } } export async function observeAuthentication( operation: () => Promise> ): Promise { - if (TELEMETRY_DISABLED) return (await operation()).value; - const { activeAuthentications, authenticationAttempts, authenticationDuration } = getInstruments(); const activeAttributes: Attributes = { "context7.mcp.route": "oauth" }; diff --git a/packages/mcp/test/mcp-subscription-telemetry.test.ts b/packages/mcp/test/mcp-subscription-telemetry.test.ts new file mode 100644 index 000000000..a55c0244c --- /dev/null +++ b/packages/mcp/test/mcp-subscription-telemetry.test.ts @@ -0,0 +1,724 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest"; +import { + CLIENT_CAPABILITIES_META_KEY, + PROTOCOL_VERSION_META_KEY, + createMcpHandler, + type JSONRPCMessage, + type MessageExtraInfo, + type Transport, + type TransportSendOptions, +} from "@modelcontextprotocol/server"; +import { serveStdio } from "@modelcontextprotocol/server/stdio"; +import { SpanStatusCode, metrics, propagation, trace } from "@opentelemetry/api"; +import { + AggregationTemporality, + DataPointType, + InMemoryMetricExporter, + MeterProvider, + PeriodicExportingMetricReader, +} from "@opentelemetry/sdk-metrics"; +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from "@opentelemetry/sdk-trace-base"; +import { + InstrumentedMcpServer, + MODERN_MCP_PROTOCOL_VERSION, + instrumentMcpHttpHandler, + instrumentStdioTransport, +} from "../src/lib/mcp-telemetry.js"; + +interface Deferred { + promise: Promise; + reject: (reason?: unknown) => void; + resolve: (value: T | PromiseLike) => void; +} + +function deferred(): Deferred { + let resolve!: Deferred["resolve"]; + let reject!: Deferred["reject"]; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +class ControlledTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: T, extra?: MessageExtraInfo) => void; + readonly sent: JSONRPCMessage[] = []; + closeOperation: () => Promise = async () => undefined; + sendOperation: (message: JSONRPCMessage, options?: TransportSendOptions) => Promise = + async () => undefined; + + async start(): Promise {} + + send(message: JSONRPCMessage, options?: TransportSendOptions): Promise { + this.sent.push(message); + return this.sendOperation(message, options); + } + + async close(): Promise { + this.onclose?.(); + await this.closeOperation(); + } + + receive(message: JSONRPCMessage): void { + this.onmessage?.(message); + } + + triggerClose(): void { + this.onclose?.(); + } +} + +const spanExporter = new InMemorySpanExporter(); +const tracerProvider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(spanExporter)], +}); +const metricExporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE); +const metricProvider = new MeterProvider({ + readers: [ + new PeriodicExportingMetricReader({ + exporter: metricExporter, + exportIntervalMillis: 60_000, + }), + ], +}); + +beforeAll(() => { + expect(trace.setGlobalTracerProvider(tracerProvider)).toBe(true); + expect(metrics.setGlobalMeterProvider(metricProvider)).toBe(true); +}); + +beforeEach(() => { + spanExporter.reset(); +}); + +afterAll(async () => { + await metricProvider.shutdown(); + await tracerProvider.shutdown(); + metrics.disable(); + trace.disable(); + propagation.disable(); +}); + +async function eventually(assertion: () => void): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < 50; attempt += 1) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setImmediate(resolve)); + } + } + throw lastError; +} + +function sumMetricValue( + metricName: string, + matches: (attributes: Record) => boolean = () => true +): number { + const latest = metricExporter.getMetrics().at(-1); + const metric = latest?.scopeMetrics + .flatMap((scope) => scope.metrics) + .find((candidate) => candidate.descriptor.name === metricName); + if (!metric || metric.dataPointType !== DataPointType.SUM) return 0; + return metric.dataPoints + .filter((point) => matches(point.attributes)) + .reduce((total, point) => total + point.value, 0); +} + +function histogramObservationCount( + metricName: string, + matches: (attributes: Record) => boolean = () => true +): number { + const latest = metricExporter.getMetrics().at(-1); + const metric = latest?.scopeMetrics + .flatMap((scope) => scope.metrics) + .find((candidate) => candidate.descriptor.name === metricName); + if (!metric || metric.dataPointType !== DataPointType.HISTOGRAM) return 0; + return metric.dataPoints + .filter((point) => matches(point.attributes)) + .reduce((total, point) => total + point.value.count, 0); +} + +function activeSubscriptionCount(route: "anonymous" | "oauth" | "stdio"): number { + return sumMetricValue( + "context7.mcp.subscriptions.active", + (attributes) => attributes["context7.mcp.route"] === route + ); +} + +function subscriptionDurationCount(outcome: string, route?: string): number { + return histogramObservationCount( + "context7.mcp.subscription.duration", + (attributes) => + attributes["context7.mcp.subscription.outcome"] === outcome && + (route === undefined || attributes["context7.mcp.route"] === route) + ); +} + +function operationCount(method: string): number { + return histogramObservationCount( + "mcp.server.operation.duration", + (attributes) => attributes["mcp.method.name"] === method + ); +} + +function modernListenRequest( + id: string | number, + notifications: Record | null = {} +): JSONRPCMessage { + return { + jsonrpc: "2.0", + id, + method: "subscriptions/listen", + params: { + ...(notifications === null ? {} : { notifications }), + _meta: { + [PROTOCOL_VERSION_META_KEY]: MODERN_MCP_PROTOCOL_VERSION, + [CLIENT_CAPABILITIES_META_KEY]: {}, + }, + }, + }; +} + +function modernHttpRequest(method: string, signal?: AbortSignal): Request { + return new Request("http://127.0.0.1/mcp", { + method: "POST", + headers: { + "content-type": "application/json", + "mcp-method": method, + "mcp-protocol-version": MODERN_MCP_PROTOCOL_VERSION, + }, + signal, + }); +} + +function createInstrumentedHttpHandler(maxSubscriptions = 4) { + const raw = createMcpHandler( + (requestContext) => + new InstrumentedMcpServer( + { name: "subscription-http-test", version: "1.0.0" }, + {}, + requestContext + ), + { keepAliveMs: 0, maxSubscriptions, onerror: () => undefined } + ); + return instrumentMcpHttpHandler(raw); +} + +describe("MCP v2 subscription telemetry", () => { + test("ends the HTTP operation at acknowledgement and tracks the stream until abort", async () => { + await metricProvider.forceFlush(); + const beforeOperations = operationCount("subscriptions/listen"); + const beforeActive = activeSubscriptionCount("anonymous"); + const beforeCancelled = subscriptionDurationCount("cancelled", "anonymous"); + const handler = createInstrumentedHttpHandler(); + const abort = new AbortController(); + const message = modernListenRequest(101); + + const response = await handler.fetch(modernHttpRequest("subscriptions/listen", abort.signal), { + parsedBody: message, + }); + expect(response.headers.get("content-type")).toContain("text/event-stream"); + const reader = response.body!.getReader(); + const acknowledgement = await reader.read(); + expect(new TextDecoder().decode(acknowledgement.value)).toContain( + "notifications/subscriptions/acknowledged" + ); + + await metricProvider.forceFlush(); + await tracerProvider.forceFlush(); + expect(operationCount("subscriptions/listen")).toBe(beforeOperations + 1); + expect(activeSubscriptionCount("anonymous")).toBe(beforeActive + 1); + expect( + spanExporter.getFinishedSpans().filter((span) => span.name === "subscriptions/listen") + ).toHaveLength(1); + + abort.abort(); + await metricProvider.forceFlush(); + expect(activeSubscriptionCount("anonymous")).toBe(beforeActive); + expect(subscriptionDurationCount("cancelled", "anonymous")).toBe(beforeCancelled + 1); + await reader.cancel(); + await handler.close(); + }); + + test("classifies HTTP invalid params and capacity rejection without opening subscriptions", async () => { + await metricProvider.forceFlush(); + const beforeOperations = operationCount("subscriptions/listen"); + const beforeActive = activeSubscriptionCount("anonymous"); + const handler = createInstrumentedHttpHandler(1); + + const invalid = modernListenRequest(102, null); + const invalidResponse = await handler.fetch(modernHttpRequest("subscriptions/listen"), { + parsedBody: invalid, + }); + expect(await invalidResponse.json()).toMatchObject({ error: { code: -32602 }, id: 102 }); + + const abort = new AbortController(); + const accepted = modernListenRequest(103); + const acceptedResponse = await handler.fetch( + modernHttpRequest("subscriptions/listen", abort.signal), + { parsedBody: accepted } + ); + expect(acceptedResponse.headers.get("content-type")).toContain("text/event-stream"); + + const rejected = modernListenRequest(104); + const rejectedResponse = await handler.fetch(modernHttpRequest("subscriptions/listen"), { + parsedBody: rejected, + }); + expect(await rejectedResponse.json()).toMatchObject({ error: { code: -32603 }, id: 104 }); + + await metricProvider.forceFlush(); + await tracerProvider.forceFlush(); + expect(operationCount("subscriptions/listen")).toBe(beforeOperations + 3); + expect(activeSubscriptionCount("anonymous")).toBe(beforeActive + 1); + const listenSpans = spanExporter + .getFinishedSpans() + .filter((span) => span.name === "subscriptions/listen"); + expect( + listenSpans.find((span) => span.attributes["rpc.response.status_code"] === "-32602")?.status + .code + ).toBe(SpanStatusCode.UNSET); + expect( + listenSpans.find((span) => span.attributes["rpc.response.status_code"] === "-32603") + ?.attributes["error.type"] + ).toBe("-32603"); + + abort.abort(); + await handler.close(); + }); + + test("handler close completes an unconsumed HTTP subscription without leaking the gauge", async () => { + await metricProvider.forceFlush(); + const beforeActive = activeSubscriptionCount("anonymous"); + const beforeCompleted = subscriptionDurationCount("completed", "anonymous"); + const handler = createInstrumentedHttpHandler(); + + const response = await handler.fetch(modernHttpRequest("subscriptions/listen"), { + parsedBody: modernListenRequest(105), + }); + expect(response.body).not.toBeNull(); + await metricProvider.forceFlush(); + expect(activeSubscriptionCount("anonymous")).toBe(beforeActive + 1); + + await handler.close(); + await metricProvider.forceFlush(); + expect(activeSubscriptionCount("anonymous")).toBe(beforeActive); + expect(subscriptionDurationCount("completed", "anonymous")).toBe(beforeCompleted + 1); + }); + + test("delegates ordinary HTTP operations without double counting them", async () => { + await metricProvider.forceFlush(); + const before = operationCount("tools/list"); + const handler = createInstrumentedHttpHandler(); + const message: JSONRPCMessage = { + jsonrpc: "2.0", + id: 106, + method: "tools/list", + params: { + _meta: { + [PROTOCOL_VERSION_META_KEY]: MODERN_MCP_PROTOCOL_VERSION, + [CLIENT_CAPABILITIES_META_KEY]: {}, + }, + }, + }; + + const response = await handler.fetch(modernHttpRequest("tools/list"), { + parsedBody: message, + }); + await response.text(); + await metricProvider.forceFlush(); + expect(operationCount("tools/list")).toBe(before + 1); + await handler.close(); + }); + + test("tracks stdio rejection, acknowledgement, cancellation, and reused ids exactly once", async () => { + await metricProvider.forceFlush(); + const beforeActive = activeSubscriptionCount("stdio"); + const beforeCancelled = subscriptionDurationCount("cancelled", "stdio"); + const beforeCompleted = subscriptionDurationCount("completed", "stdio"); + const wire = new ControlledTransport(); + const handle = serveStdio( + (requestContext) => + new InstrumentedMcpServer( + { name: "subscription-stdio-test", version: "1.0.0" }, + {}, + requestContext + ), + { maxSubscriptions: 1, transport: instrumentStdioTransport(wire) } + ); + await eventually(() => expect(wire.onmessage).toBeTypeOf("function")); + + wire.receive({ + jsonrpc: "2.0", + id: 110, + method: "server/discover", + params: { + _meta: { + [PROTOCOL_VERSION_META_KEY]: MODERN_MCP_PROTOCOL_VERSION, + [CLIENT_CAPABILITIES_META_KEY]: {}, + }, + }, + }); + await eventually(() => + expect(wire.sent.some((message) => "id" in message && message.id === 110)).toBe(true) + ); + + wire.receive(modernListenRequest("reused", null)); + await eventually(() => + expect( + wire.sent.some( + (message) => + "id" in message && + message.id === "reused" && + "error" in message && + message.error.code === -32602 + ) + ).toBe(true) + ); + wire.receive(modernListenRequest("reused")); + await eventually(() => + expect( + wire.sent.some( + (message) => + "method" in message && message.method === "notifications/subscriptions/acknowledged" + ) + ).toBe(true) + ); + await metricProvider.forceFlush(); + expect(activeSubscriptionCount("stdio")).toBe(beforeActive + 1); + + wire.receive(modernListenRequest("capacity")); + await eventually(() => + expect( + wire.sent.some( + (message) => + "id" in message && + message.id === "capacity" && + "error" in message && + message.error.code === -32603 + ) + ).toBe(true) + ); + wire.receive({ + jsonrpc: "2.0", + method: "notifications/cancelled", + params: { requestId: "unknown" }, + }); + wire.receive({ + jsonrpc: "2.0", + method: "notifications/cancelled", + params: { requestId: "reused" }, + }); + await metricProvider.forceFlush(); + await tracerProvider.forceFlush(); + expect(activeSubscriptionCount("stdio")).toBe(beforeActive); + expect(subscriptionDurationCount("cancelled", "stdio")).toBe(beforeCancelled + 1); + expect( + spanExporter.getFinishedSpans().filter((span) => span.name === "notifications/cancelled") + ).toHaveLength(2); + + wire.receive(modernListenRequest("reused")); + await eventually(() => + expect( + wire.sent.filter( + (message) => + "method" in message && message.method === "notifications/subscriptions/acknowledged" + ) + ).toHaveLength(2) + ); + await handle.close(); + await metricProvider.forceFlush(); + expect(activeSubscriptionCount("stdio")).toBe(beforeActive); + expect(subscriptionDurationCount("completed", "stdio")).toBe(beforeCompleted + 1); + }); + + test("does not carry a pre-rejection cancellation into a reused stdio request id", async () => { + await metricProvider.forceFlush(); + const beforeActive = activeSubscriptionCount("stdio"); + const beforeCompleted = subscriptionDurationCount("completed", "stdio"); + const wire = new ControlledTransport(); + const handle = serveStdio( + (requestContext) => + new InstrumentedMcpServer( + { name: "subscription-reuse-regression-test", version: "1.0.0" }, + {}, + requestContext + ), + { transport: instrumentStdioTransport(wire) } + ); + await eventually(() => expect(wire.onmessage).toBeTypeOf("function")); + wire.receive({ + jsonrpc: "2.0", + id: 115, + method: "server/discover", + params: { + _meta: { + [PROTOCOL_VERSION_META_KEY]: MODERN_MCP_PROTOCOL_VERSION, + [CLIENT_CAPABILITIES_META_KEY]: {}, + }, + }, + }); + await eventually(() => + expect(wire.sent.some((message) => "id" in message && message.id === 115)).toBe(true) + ); + + wire.receive(modernListenRequest("retry-after-rejection", null)); + wire.receive({ + jsonrpc: "2.0", + method: "notifications/cancelled", + params: { requestId: "retry-after-rejection" }, + }); + await eventually(() => + expect( + wire.sent.some( + (message) => + "id" in message && + message.id === "retry-after-rejection" && + "error" in message && + message.error.code === -32602 + ) + ).toBe(true) + ); + + wire.receive(modernListenRequest("retry-after-rejection")); + await eventually(() => + expect( + wire.sent.some( + (message) => + "method" in message && message.method === "notifications/subscriptions/acknowledged" + ) + ).toBe(true) + ); + await metricProvider.forceFlush(); + expect(activeSubscriptionCount("stdio")).toBe(beforeActive + 1); + + await handle.close(); + await metricProvider.forceFlush(); + expect(activeSubscriptionCount("stdio")).toBe(beforeActive); + expect(subscriptionDurationCount("completed", "stdio")).toBe(beforeCompleted + 1); + }); + + test("keeps the accepted stdio resource active when only acknowledgement send fails", async () => { + await metricProvider.forceFlush(); + const beforeActive = activeSubscriptionCount("stdio"); + const beforeCompleted = subscriptionDurationCount("completed", "stdio"); + const wire = new ControlledTransport(); + const handle = serveStdio( + (requestContext) => + new InstrumentedMcpServer( + { name: "subscription-send-failure-test", version: "1.0.0" }, + {}, + requestContext + ), + { transport: instrumentStdioTransport(wire) } + ); + await eventually(() => expect(wire.onmessage).toBeTypeOf("function")); + wire.receive({ + jsonrpc: "2.0", + id: 120, + method: "server/discover", + params: { + _meta: { + [PROTOCOL_VERSION_META_KEY]: MODERN_MCP_PROTOCOL_VERSION, + [CLIENT_CAPABILITIES_META_KEY]: {}, + }, + }, + }); + await eventually(() => + expect(wire.sent.some((message) => "id" in message && message.id === 120)).toBe(true) + ); + wire.sendOperation = async (message) => { + if ("method" in message && message.method === "notifications/subscriptions/acknowledged") { + throw new Error("ack write failed"); + } + }; + + wire.receive(modernListenRequest(121)); + await eventually(() => + expect( + spanExporter + .getFinishedSpans() + .some( + (span) => + span.name === "subscriptions/listen" && + span.attributes["error.type"] === "transport_error" + ) + ).toBe(true) + ); + await metricProvider.forceFlush(); + expect(activeSubscriptionCount("stdio")).toBe(beforeActive + 1); + await handle.close(); + await metricProvider.forceFlush(); + expect(activeSubscriptionCount("stdio")).toBe(beforeActive); + expect(subscriptionDurationCount("completed", "stdio")).toBe(beforeCompleted + 1); + }); + + test("honors cancellation received while the stdio acknowledgement write is pending", async () => { + await metricProvider.forceFlush(); + const beforeActive = activeSubscriptionCount("stdio"); + const beforeCancelled = subscriptionDurationCount("cancelled", "stdio"); + const wire = new ControlledTransport(); + const handle = serveStdio( + (requestContext) => + new InstrumentedMcpServer( + { name: "subscription-deferred-ack-test", version: "1.0.0" }, + {}, + requestContext + ), + { transport: instrumentStdioTransport(wire) } + ); + await eventually(() => expect(wire.onmessage).toBeTypeOf("function")); + wire.receive({ + jsonrpc: "2.0", + id: 130, + method: "server/discover", + params: { + _meta: { + [PROTOCOL_VERSION_META_KEY]: MODERN_MCP_PROTOCOL_VERSION, + [CLIENT_CAPABILITIES_META_KEY]: {}, + }, + }, + }); + await eventually(() => + expect(wire.sent.some((message) => "id" in message && message.id === 130)).toBe(true) + ); + + const acknowledgementWrite = deferred(); + wire.sendOperation = (message) => + "method" in message && message.method === "notifications/subscriptions/acknowledged" + ? acknowledgementWrite.promise + : Promise.resolve(); + wire.receive(modernListenRequest(131)); + await eventually(() => + expect( + wire.sent.some( + (message) => + "method" in message && message.method === "notifications/subscriptions/acknowledged" + ) + ).toBe(true) + ); + await metricProvider.forceFlush(); + expect(activeSubscriptionCount("stdio")).toBe(beforeActive + 1); + + wire.receive({ + jsonrpc: "2.0", + method: "notifications/cancelled", + params: { requestId: 131 }, + }); + await metricProvider.forceFlush(); + expect(activeSubscriptionCount("stdio")).toBe(beforeActive + 1); + + acknowledgementWrite.resolve(undefined); + await eventually(() => { + expect( + spanExporter + .getFinishedSpans() + .some( + (span) => + span.name === "subscriptions/listen" && + span.attributes["jsonrpc.request.id"] === "131" + ) + ).toBe(true); + }); + await metricProvider.forceFlush(); + expect(activeSubscriptionCount("stdio")).toBe(beforeActive); + expect(subscriptionDurationCount("cancelled", "stdio")).toBe(beforeCancelled + 1); + await handle.close(); + }); + + test("completes once when acknowledgement and terminal writes overlap shutdown", async () => { + await metricProvider.forceFlush(); + const beforeActive = activeSubscriptionCount("stdio"); + const beforeCompleted = subscriptionDurationCount("completed", "stdio"); + const beforeConnectionClosed = subscriptionDurationCount("connection_closed", "stdio"); + const wire = new ControlledTransport(); + const handle = serveStdio( + (requestContext) => + new InstrumentedMcpServer( + { name: "subscription-deferred-terminal-test", version: "1.0.0" }, + {}, + requestContext + ), + { transport: instrumentStdioTransport(wire) } + ); + await eventually(() => expect(wire.onmessage).toBeTypeOf("function")); + wire.receive({ + jsonrpc: "2.0", + id: 140, + method: "server/discover", + params: { + _meta: { + [PROTOCOL_VERSION_META_KEY]: MODERN_MCP_PROTOCOL_VERSION, + [CLIENT_CAPABILITIES_META_KEY]: {}, + }, + }, + }); + await eventually(() => + expect(wire.sent.some((message) => "id" in message && message.id === 140)).toBe(true) + ); + + const acknowledgementWrite = deferred(); + wire.sendOperation = (message) => + "method" in message && message.method === "notifications/subscriptions/acknowledged" + ? acknowledgementWrite.promise + : Promise.resolve(); + wire.receive(modernListenRequest(141)); + await eventually(() => + expect( + wire.sent.some( + (message) => + "method" in message && message.method === "notifications/subscriptions/acknowledged" + ) + ).toBe(true) + ); + await metricProvider.forceFlush(); + expect(activeSubscriptionCount("stdio")).toBe(beforeActive + 1); + + const terminalWrite = deferred(); + wire.sendOperation = (message) => + "id" in message && message.id === 141 && "result" in message + ? terminalWrite.promise + : Promise.resolve(); + const closePromise = handle.close(); + await eventually(() => + expect( + wire.sent.some((message) => "id" in message && message.id === 141 && "result" in message) + ).toBe(true) + ); + + wire.triggerClose(); + await metricProvider.forceFlush(); + expect(activeSubscriptionCount("stdio")).toBe(beforeActive + 1); + + acknowledgementWrite.resolve(undefined); + await eventually(() => + expect( + spanExporter + .getFinishedSpans() + .some( + (span) => + span.name === "subscriptions/listen" && + span.attributes["jsonrpc.request.id"] === "141" + ) + ).toBe(true) + ); + await metricProvider.forceFlush(); + expect(activeSubscriptionCount("stdio")).toBe(beforeActive + 1); + + terminalWrite.resolve(undefined); + await closePromise; + await metricProvider.forceFlush(); + expect(activeSubscriptionCount("stdio")).toBe(beforeActive); + expect(subscriptionDurationCount("completed", "stdio")).toBe(beforeCompleted + 1); + expect(subscriptionDurationCount("connection_closed", "stdio")).toBe(beforeConnectionClosed); + }); +}); diff --git a/packages/mcp/test/mcp-telemetry-lifecycle.test.ts b/packages/mcp/test/mcp-telemetry-lifecycle.test.ts index 504f57f99..af5ec4e24 100644 --- a/packages/mcp/test/mcp-telemetry-lifecycle.test.ts +++ b/packages/mcp/test/mcp-telemetry-lifecycle.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; import { CLIENT_CAPABILITIES_META_KEY, McpServer, @@ -28,7 +28,11 @@ import { classifyServerResponse, instrumentStdioTransport, } from "../src/lib/mcp-telemetry.js"; -import { observeUpstreamRequest, type UpstreamOperation } from "../src/lib/telemetry.js"; +import { + forceFlushTelemetry, + observeUpstreamRequest, + type UpstreamOperation, +} from "../src/lib/telemetry.js"; interface Deferred { promise: Promise; @@ -213,6 +217,56 @@ describe("MCP server response classification", () => { }); }); +describe("OpenTelemetry provider lifecycle", () => { + test("flushes both the meter provider and the tracer provider delegate", async () => { + const metricFlush = vi.spyOn(metricProvider, "forceFlush"); + const traceFlush = vi.spyOn(tracerProvider, "forceFlush"); + + try { + await forceFlushTelemetry(); + expect(metricFlush).toHaveBeenCalledOnce(); + expect(traceFlush).toHaveBeenCalledOnce(); + } finally { + metricFlush.mockRestore(); + traceFlush.mockRestore(); + } + }); + + test("waits for every provider before reporting flush failures", async () => { + const traceCompletion = deferred(); + const failure = new Error("metric flush failed"); + const metricFlush = vi.spyOn(metricProvider, "forceFlush").mockRejectedValue(failure); + const traceFlush = vi + .spyOn(tracerProvider, "forceFlush") + .mockImplementation(() => traceCompletion.promise); + + try { + const flushing = forceFlushTelemetry(); + await eventually(() => { + expect(metricFlush).toHaveBeenCalledOnce(); + expect(traceFlush).toHaveBeenCalledOnce(); + }); + let settled = false; + void flushing.then( + () => { + settled = true; + }, + () => { + settled = true; + } + ); + await new Promise((resolve) => setImmediate(resolve)); + expect(settled).toBe(false); + + traceCompletion.resolve(); + await expect(flushing).rejects.toMatchObject({ errors: [failure] }); + } finally { + metricFlush.mockRestore(); + traceFlush.mockRestore(); + } + }); +}); + describe("MCP operation lifecycle", () => { test("records a real modern stdio session once with its envelope version", async () => { await metricProvider.forceFlush(); diff --git a/packages/mcp/test/stdio-shutdown.test.ts b/packages/mcp/test/stdio-shutdown.test.ts index be0c3f9f9..404f19e73 100644 --- a/packages/mcp/test/stdio-shutdown.test.ts +++ b/packages/mcp/test/stdio-shutdown.test.ts @@ -1,7 +1,7 @@ import { EventEmitter } from "node:events"; import { describe, expect, test } from "vitest"; import type { StdioServerHandle } from "@modelcontextprotocol/server/stdio"; -import { installStdioShutdown } from "../src/lib/stdio-shutdown.js"; +import { installProcessShutdown } from "../src/lib/process-shutdown.js"; async function eventually(assertion: () => void): Promise { let lastError: unknown; @@ -17,8 +17,8 @@ async function eventually(assertion: () => void): Promise { throw lastError; } -describe("stdio process shutdown", () => { - test("closes and flushes once before a successful exit", async () => { +describe("process shutdown", () => { + test("closes and flushes once across SIGTERM and overlapping termination events", async () => { const events: string[] = []; const input = new EventEmitter(); const signals = new EventEmitter(); @@ -28,7 +28,7 @@ describe("stdio process shutdown", () => { }, }; - installStdioShutdown(handle, { + installProcessShutdown(handle, { exit: (code) => events.push(`exit:${code}`), flush: async () => { events.push("flush"); @@ -36,9 +36,11 @@ describe("stdio process shutdown", () => { input, signals, }); + signals.emit("SIGTERM"); + signals.emit("SIGINT"); + signals.emit("SIGHUP"); input.emit("end"); input.emit("close"); - signals.emit("SIGHUP"); await eventually(() => expect(events).toEqual(["close", "flush", "exit:0"])); }); @@ -50,7 +52,7 @@ describe("stdio process shutdown", () => { const input = new EventEmitter(); const signals = new EventEmitter(); - installStdioShutdown( + installProcessShutdown( { close: async () => { events.push("close"); @@ -67,7 +69,7 @@ describe("stdio process shutdown", () => { signals, } ); - signals.emit("SIGHUP"); + signals.emit("SIGINT"); await eventually(() => expect(events).toEqual(["close", "flush", "exit:1"])); expect(errors).toEqual([failure]); @@ -79,7 +81,7 @@ describe("stdio process shutdown", () => { const input = new EventEmitter(); const signals = new EventEmitter(); - installStdioShutdown( + installProcessShutdown( { close: async () => undefined }, { exit: (code) => exits.push(code), @@ -96,4 +98,28 @@ describe("stdio process shutdown", () => { expect(errors).toHaveLength(1); expect(errors[0]).toMatchObject({ message: "OpenTelemetry flush exceeded 10ms" }); }); + + test("flushes and exits nonzero after the deadline when close never settles", async () => { + const errors: unknown[] = []; + const events: string[] = []; + const signals = new EventEmitter(); + + installProcessShutdown( + { close: () => new Promise(() => undefined) }, + { + closeTimeoutMs: 10, + exit: (code) => events.push(`exit:${code}`), + flush: async () => { + events.push("flush"); + }, + onerror: (error) => errors.push(error), + signals, + } + ); + signals.emit("SIGTERM"); + + await eventually(() => expect(events).toEqual(["flush", "exit:1"])); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ message: "Server close exceeded 10ms" }); + }); }); diff --git a/packages/mcp/test/telemetry-disabled.test.ts b/packages/mcp/test/telemetry-disabled.test.ts index eb58007c7..f7ab564fa 100644 --- a/packages/mcp/test/telemetry-disabled.test.ts +++ b/packages/mcp/test/telemetry-disabled.test.ts @@ -9,8 +9,13 @@ import { const previousDisabled = process.env.OTEL_SDK_DISABLED; process.env.OTEL_SDK_DISABLED = "true"; -const { forceFlushMetrics, observeAuthentication, recordToolCallOutcome, observeUpstreamRequest } = - await import("../src/lib/telemetry.js"); +const { + forceFlushTelemetry, + initializeTelemetry, + observeAuthentication, + recordToolCallOutcome, + observeUpstreamRequest, +} = await import("../src/lib/telemetry-runtime.js"); const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE); const provider = new MeterProvider({ @@ -34,6 +39,9 @@ afterAll(async () => { }); test("OTEL_SDK_DISABLED bypasses all application metric instruments", async () => { + await expect( + initializeTelemetry({ allowEmbeddedPrometheus: true, serviceVersion: "test" }) + ).resolves.toBeUndefined(); expect(() => recordToolCallOutcome("success")).not.toThrow(); await expect( observeUpstreamRequest( @@ -45,7 +53,7 @@ test("OTEL_SDK_DISABLED bypasses all application metric instruments", async () = await expect( observeAuthentication(async () => ({ outcome: "accepted", value: "auth" })) ).resolves.toBe("auth"); - await forceFlushMetrics(); + await forceFlushTelemetry(); await provider.forceFlush(); const metricNames = exporter From 2d031438f76f1a5016feff8d675176541a19d0a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahreddin=20=C3=96zcan?= Date: Fri, 4 Sep 2026 12:47:17 +0300 Subject: [PATCH 10/10] docs(mcp): document per-pod metrics scraping --- packages/mcp/README.md | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 895170b9a..b5eeea744 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -1504,7 +1504,7 @@ for server metrics and spans. Trace context is extracted from the `traceparent`, The HTTP transport exposes metrics in Prometheus format on a dedicated listener at `127.0.0.1:9464/metrics` by default. The production Docker image explicitly binds that listener to -`0.0.0.0` so an internal Prometheus sidecar or `ServiceMonitor` can reach it. The stdio transport +`0.0.0.0` so an internal Prometheus pod scraper or `PodMonitor` can reach it. The stdio transport does not open a telemetry port. Keeping this listener separate from the public MCP port prevents the metrics endpoint from being routed through a catch-all gateway rule. On SIGTERM, SIGINT, or SIGHUP, both transports use a bounded shutdown path that stops serving, closes active MCP @@ -1590,12 +1590,32 @@ See the [Envoy HTTP connection manager statistics](https://www.envoyproxy.io/doc and [upstream cluster statistics](https://www.envoyproxy.io/docs/envoy/latest/configuration/upstream/cluster_manager/cluster_stats.html) for the proxy-owned metric families. +For a replicated Kubernetes deployment, discover and scrape every MCP pod directly. Do not use one +static, load-balanced Service target: successive scrapes can reach different replicas and produce +incomplete per-process counters and runtime series. For an annotation-based `kubernetes-pods` +scrape job, add the following fields to the MCP workload's pod template: + ```yaml -scrape_configs: - - job_name: context7-mcp - static_configs: - - targets: ["context7-mcp:9464"] -``` +spec: + template: + metadata: + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "9464" + prometheus.io/path: /metrics + spec: + containers: + - name: mcp + ports: + - name: metrics + containerPort: 9464 + protocol: TCP +``` + +Prometheus will then scrape `http://:9464/metrics` for each replica. Declaring +`EXPOSE 9464` in the image does not add the Kubernetes `containerPort` metadata. The scrape interval +is controlled by Prometheus; the exporter does not impose one. If the monitoring stack uses the +Prometheus Operator instead, configure the equivalent per-pod endpoint with a `PodMonitor`.
Local Configuration Example