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..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 -EXPOSE 8080 +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 32a14387a..679da158b 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -1490,6 +1490,133 @@ CONTEXT7_API_KEY=your_api_key_here } ``` +### OpenTelemetry observability + +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 +[SEP-414](https://modelcontextprotocol.io/seps/414-request-meta). + +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 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 +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: + +- `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. + +`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, +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, +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 + 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_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` +- `nodejs_eventloop_*`, `v8js_gc_duration`, `v8js_memory_heap_*`, and + `v8js_resource_active` from the official OpenTelemetry Node runtime instrumentation + +Tool outcomes on the standard MCP operation metric distinguish `success`, `not_found`, and +`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 +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 +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, 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 +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. + +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 +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 diff --git a/packages/mcp/package.json b/packages/mcp/package.json index f2cfdb435..2416150bc 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -48,6 +48,11 @@ "dependencies": { "@modelcontextprotocol/node": "2.0.0", "@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", "commander": "^13.1.0", "express": "^5.1.0", @@ -57,6 +62,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", "esbuild": "^0.28.2", "typescript": "^5.8.2", diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index d118149cd..6656dcbce 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,8 +1,13 @@ #!/usr/bin/env node import { toNodeHandler } from "@modelcontextprotocol/node"; -import { serveStdio } from "@modelcontextprotocol/server/stdio"; -import { McpServer, createMcpHandler, type ServerContext } from "@modelcontextprotocol/server"; +import { StdioServerTransport, serveStdio } from "@modelcontextprotocol/server/stdio"; +import { + McpServer, + 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"; @@ -24,12 +29,24 @@ import { OPENAI_APPS_CHALLENGE_TOKEN, } 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 { installProcessShutdown } from "./lib/process-shutdown.js"; import { getMaxSubscriptions } from "./lib/subscriptions.js"; +import { + forceFlushTelemetry, + initializeTelemetry, + observeAuthentication, + observeUpstreamRequest, + recordToolCallOutcome, +} from "./lib/telemetry-runtime.js"; import { mcpBodyErrorHandler } from "./lib/mcp-body-error-handler.js"; /** Default HTTP server port */ const DEFAULT_PORT = 3000; +const OAUTH_METADATA_TIMEOUT_MS = 10_000; const CLAUDE_CODE_PLUGIN = "claude-code-plugin"; +type McpInstrumentation = NonNullable>>; +let mcpInstrumentation: McpInstrumentation | undefined; function getPluginFromRequest(req: express.Request): typeof CLAUDE_CODE_PLUGIN | undefined { return req.query.client === CLAUDE_CODE_PLUGIN ? CLAUDE_CODE_PLUGIN : undefined; @@ -96,6 +113,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; @@ -161,35 +180,36 @@ function aliasArgs(aliases: AliasMap) { }; } -function createMcpServer() { - const server = new McpServer( - { - 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. +function createMcpServer(mcpContext: McpRequestContext) { + 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.`, - } - ); + }; + const server = mcpInstrumentation + ? mcpInstrumentation.createServer(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. @@ -254,10 +274,11 @@ 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"); return { content: [ { - type: "text", + type: "text" as const, text, }, ], @@ -267,10 +288,11 @@ 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"); return { content: [ { - type: "text", + type: "text" as const, text: responseText, }, ], @@ -279,7 +301,7 @@ IMPORTANT: Do not call this tool more than 3 times per question. If you cannot f ); 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. @@ -313,10 +335,11 @@ 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); return { content: [ { - type: "text", + type: "text" as const, text: response.data, }, ], @@ -328,6 +351,11 @@ Do not call this tool more than 3 times per question.`, } async function main() { + mcpInstrumentation = await initializeTelemetry({ + allowEmbeddedPrometheus: TRANSPORT_TYPE === "http", + serviceVersion: SERVER_VERSION, + }); + if (TRANSPORT_TYPE === "http") { const initialPort = CLI_PORT ?? DEFAULT_PORT; @@ -397,11 +425,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(() => createMcpServer(), { + 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, { @@ -415,39 +446,49 @@ async function main() { const baseUrl = new URL(RESOURCE_URL).origin; // OAuth discovery info header, used by MCP clients to discover the authorization server - // TODO: @modelcontextprotocol/server now ships canonical OAuth helpers - // (bearerAuthChallengeResponse, buildOAuthProtectedResourceMetadata, - // oauthMetadataResponse) — replace this hand-rolled header and the - // /.well-known/oauth-protected-resource route with them. res.set( "WWW-Authenticate", `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"` ); if (requiresAuthentication(req, plugin)) { - if (!apiKey) { - return res.status(401).json({ + 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, }); - } - - if (isJWT(apiKey)) { - const validationResult = await validateJWT(apiKey); - if (!validationResult.valid) { - return res.status(401).json({ - jsonrpc: "2.0", - error: { - code: -32001, - message: validationResult.error || "Invalid token. Please re-authenticate.", - }, - id: null, - }); - } + return; } } @@ -512,16 +553,27 @@ async function main() { const authServerUrl = OAUTH_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 abortSignal = AbortSignal.timeout(OAUTH_METADATA_TIMEOUT_MS); + const upstream = await observeUpstreamRequest( + "oauth_metadata", + () => + 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); + 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({ @@ -554,8 +606,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) { @@ -578,14 +662,14 @@ async function main() { } else { stdioApiKey = cliOptions.apiKey || process.env.CONTEXT7_API_KEY; stdioSessionId = randomUUID(); + const rawStdioTransport = new StdioServerTransport(); + const stdioTransport = mcpInstrumentation + ? mcpInstrumentation.instrumentStdioTransport(rawStdioTransport) + : rawStdioTransport; - process.stdin.on("end", () => process.exit(0)); - process.stdin.on("close", () => process.exit(0)); - process.on("SIGHUP", () => process.exit(0)); - - serveStdio( - () => { - const server = createMcpServer(); + const stdioHandle = serveStdio( + (mcpContext) => { + const server = createMcpServer(mcpContext); // Capture client info from MCP initialize handshake (stdio only — HTTP // mode plumbs client info through requestContext per request). @@ -602,9 +686,15 @@ async function main() { return server; }, { + transport: stdioTransport, onerror: (error) => console.error("MCP stdio error:", error), } ); + installProcessShutdown(stdioHandle, { + flush: forceFlushTelemetry, + input: process.stdin, + 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 d8d702399..2416a2fde 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-runtime.js"; /** * Ceiling on a single Context7 API call. Without a signal a stalled backend @@ -126,16 +127,23 @@ export async function searchLibraries( url.searchParams.set("libraryName", libraryName); 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; + const abortSignal = AbortSignal.timeout(API_TIMEOUT_MS); + + return await observeUpstreamRequest( + "search_libraries", + () => fetch(url, { headers, signal: abortSignal }), + 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; + }, + { abortSignal } + ); } catch (error) { const errorMessage = `Error searching libraries: ${error}`; console.error(errorMessage); @@ -159,25 +167,33 @@ export async function fetchLibraryContext( url.searchParams.set("libraryId", request.libraryId); 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 }; + const abortSignal = AbortSignal.timeout(API_TIMEOUT_MS); + + return await observeUpstreamRequest( + "fetch_context", + () => fetch(url, { headers, signal: abortSignal }), + async (response) => { + readPromptSignal(response, context); + if (!response.ok) { + const errorMessage = await parseErrorResponse(response, context.apiKey); + console.error(errorMessage); + 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.", + outcome: "not_found", + }; + } + 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 }; + 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-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 new file mode 100644 index 000000000..bbe482e92 --- /dev/null +++ b/packages/mcp/src/lib/mcp-telemetry.ts @@ -0,0 +1,787 @@ +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 McpHttpHandler, + 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"; +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 = [ + 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: 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 McpObservationConfig = SubscriptionObservation; + +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; + toolOutcome?: ToolCallOutcome; +} + +interface ServerResponseClassification { + errorType?: string; + rpcStatusCode?: string; + statusDescription?: string; +} + +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 }, + }), + 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}", + }), + }; +} + +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 EMPTY_TRACE_CARRIER; + + const metadata = asRecord(asRecord(message.params)?._meta); + if (!metadata) return EMPTY_TRACE_CARRIER; + + 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, + transportProtocolVersion?: string +): McpOperation | undefined { + if (!isJSONRPCRequest(message) && !isJSONRPCNotification(message)) return undefined; + + const method = normalizeMcpMethodName(message.method); + const tool = normalizedTool(message); + const protocolVersion = + messageProtocolVersion(message) ?? transportProtocolVersion ?? 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 parentContext = propagation.extract(ROOT_CONTEXT, mcpTraceCarrier(message)); + const span = trace.getTracer(INSTRUMENTATION_NAME).startSpan( + tool ? `${method} ${tool}` : method, + { + 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, + 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: 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); + 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: () => T): T { + return runInMcpOperationScope(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; + } + 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; + 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; +} + +function configFromRequestContext(requestContext: McpRequestContext): McpObservationConfig { + const request = requestContext.requestInfo; + if (!request) { + return { + route: "stdio", + networkTransport: "pipe", + protocolVersion: requestContext.era === "modern" ? MODERN_MCP_PROTOCOL_VERSION : undefined, + }; + } + + return { + abortSignal: request.signal, + route: mcpRouteFromUrl(request.url), + networkProtocol: "http", + networkTransport: "tcp", + protocolVersion: + normalizedProtocolVersion(request.headers.get("mcp-protocol-version")) ?? + (requestContext.era === "modern" ? MODERN_MCP_PROTOCOL_VERSION : undefined), + }; +} + +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(); + 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); + }; + + async start(): Promise { + await 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; + 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 (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 { + if (operation) { + this.removeInFlight(operation); + finishOperation(operation); + } + } + } + + private receive( + message: JSONRPCMessage, + extra: MessageExtraInfo | undefined, + handler: NonNullable + ): void { + const operation = startOperation(message, this.config, this.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); + } + } +} + +/** + * 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(); + private readonly subscriptions = new StdioSubscriptionTelemetry( + startSubscriptionEntryOperation, + MODERN_MCP_PROTOCOL_VERSION + ); + + 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 = () => { + this.subscriptions.close("connection_closed"); + 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.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); + }; + + 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.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; + } + } + + 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, + * 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)) + ); + } +} 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/telemetry-config.ts b/packages/mcp/src/lib/telemetry-config.ts new file mode 100644 index 000000000..9e594d9a0 --- /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?.trim().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-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-provider.ts b/packages/mcp/src/lib/telemetry-provider.ts new file mode 100644 index 000000000..03ec5223d --- /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 = "127.0.0.1"; +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-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 new file mode 100644 index 000000000..677b6a0c6 --- /dev/null +++ b/packages/mcp/src/lib/telemetry.ts @@ -0,0 +1,234 @@ +import { metrics, trace, type Attributes } from "@opentelemetry/api"; +import { markCurrentMcpOperationError, markCurrentMcpToolOutcome } from "./mcp-operation-scope.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 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 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 { + AuthenticationOutcome, + ObservedAuthentication, + UpstreamObservationOptions, + UpstreamOperation, + UpstreamOutcome, +} from "./telemetry-contracts.js"; + +function createInstruments() { + const meter = metrics.getMeter(METER_NAME); + return { + 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}", + }), + 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; + +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"; +} + +export function classifyUpstreamError( + error: unknown, + abortSignal?: AbortSignal, + fallback: "network_error" | "response_error" = "network_error" +): "cancelled" | "network_error" | "response_error" | "timeout" { + let timeout = false; + let cancelled = false; + let networkError = false; + const rootCount = abortSignal?.aborted ? 2 : 1; + + 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) { + 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; + } + } + + if (timeout) return "timeout"; + if (cancelled || abortSignal?.aborted) return "cancelled"; + if (networkError) return "network_error"; + return fallback; +} + +export function recordToolCallOutcome(outcome: ToolCallOutcome): void { + if (outcome === "error") markCurrentMcpOperationError(); + markCurrentMcpToolOutcome(outcome); +} + +export async function observeUpstreamRequest( + operationName: UpstreamOperation, + request: () => 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: UpstreamOutcome = "network_error"; + let responseStatus: number | undefined; + let responseStatusClass = "none"; + activeUpstreamRequests.add(1, activeAttributes); + + try { + 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 = classifyUpstreamError(error, options.abortSignal, "response_error"); + throw error; + } + } finally { + 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); + } +} + +interface FlushableProvider { + forceFlush(): Promise; +} + +interface DelegatingProvider { + getDelegate(): unknown; +} + +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 { + 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); + } +} 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 47cacce2e..2c569b230 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,6 +32,7 @@ export type ContextRequest = { export type ContextResponse = { data: string; + outcome: ToolCallOutcome; }; export interface ClientContext { 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 9daa9f23a..cff8bf91c 100644 --- a/packages/mcp/test/integration.test.ts +++ b/packages/mcp/test/integration.test.ts @@ -18,8 +18,13 @@ 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"; +const NO_RESULTS_QUERY = "force-no-results"; +const UPSTREAM_ERROR_QUERY = "force-upstream-error"; +const INVALID_JSON_QUERY = "force-invalid-json"; const CLIENT_IP_ASSERTION_KEY = "0123456789abcdef".repeat(4); function decryptClientIpAssertion(value: string): string { @@ -49,6 +54,29 @@ let stubServer: http.Server; let childEnv: Record; 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) => { + 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) => { @@ -57,6 +85,14 @@ 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; + } + if (url.searchParams.get("query") === NO_RESULTS_QUERY) { + res.end(JSON.stringify({ results: [] })); + return; + } res.end( JSON.stringify({ results: [ @@ -74,34 +110,55 @@ 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); + res.end(url.searchParams.get("query") === EMPTY_CONTEXT_QUERY ? "" : STUB_DOCS); } else { res.statusCode = 404; 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`); }); }); } -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}`)); @@ -112,11 +169,15 @@ 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, + OTEL_EXPORTER_PROMETHEUS_HOST: "127.0.0.1", + OTEL_EXPORTER_PROMETHEUS_PORT: String(metricsPort), MCP_CLIENT_IP_ASSERTION_KEY: CLIENT_IP_ASSERTION_KEY, }; ({ child: httpChild, url: httpUrl } = await startHttpChild()); @@ -417,6 +478,228 @@ 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({ + 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, { + 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 { + 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 }, + }); + 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(); + } + + 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 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") + .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( + 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( + 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]/ + ); + 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); + + 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(); + } + }); +}); + const INITIALIZE = { jsonrpc: "2.0", id: 1, 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 new file mode 100644 index 000000000..af5ec4e24 --- /dev/null +++ b/packages/mcp/test/mcp-telemetry-lifecycle.test.ts @@ -0,0 +1,565 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } 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, + DataPointType, + InMemoryMetricExporter, + MeterProvider, + PeriodicExportingMetricReader, +} from "@opentelemetry/sdk-metrics"; +import { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from "@opentelemetry/sdk-trace-base"; +import { + InstrumentedMcpServer, + MODERN_MCP_PROTOCOL_VERSION, + classifyServerResponse, + instrumentStdioTransport, +} from "../src/lib/mcp-telemetry.js"; +import { + forceFlushTelemetry, + observeUpstreamRequest, + type UpstreamOperation, +} from "../src/lib/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?.(); + } + + 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(() => { + 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 serverFor( + transport: ControlledTransport, + requestInfo?: Request, + era: "legacy" | "modern" = "legacy" +): InstrumentedMcpServer { + const server = new InstrumentedMcpServer( + { name: "telemetry-lifecycle-test", version: "1.0.0" }, + {}, + { 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", + 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("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(); + 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) + ); + + await Promise.all([handle.close(), handle.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"] === 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); + }); + + 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("marks a session failed when terminal transport close rejects", async () => { + await metricProvider.forceFlush(); + const beforeErrors = sessionErrorObservationCount("transport_error"); + const wire = new ControlledTransport(); + wire.closeOperation = async () => { + throw new Error("close failed"); + }; + const session = instrumentStdioTransport(wire); + + await expect(session.close()).rejects.toThrow("close failed"); + await metricProvider.forceFlush(); + + expect(sessionErrorObservationCount("transport_error")).toBe(beforeErrors + 1); + }); + + test("marks the eventual session failed after a wire send rejects", async () => { + await metricProvider.forceFlush(); + const beforeErrors = sessionErrorObservationCount("transport_error"); + const wire = new ControlledTransport(); + wire.sendOperation = async () => { + throw new Error("send failed"); + }; + 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(); + + 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); + }); + + 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(); + }); +}); + +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..404f19e73 --- /dev/null +++ b/packages/mcp/test/stdio-shutdown.test.ts @@ -0,0 +1,125 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, test } from "vitest"; +import type { StdioServerHandle } from "@modelcontextprotocol/server/stdio"; +import { installProcessShutdown } from "../src/lib/process-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("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(); + const handle: StdioServerHandle = { + close: async () => { + events.push("close"); + }, + }; + + installProcessShutdown(handle, { + exit: (code) => events.push(`exit:${code}`), + flush: async () => { + events.push("flush"); + }, + input, + signals, + }); + signals.emit("SIGTERM"); + signals.emit("SIGINT"); + signals.emit("SIGHUP"); + input.emit("end"); + input.emit("close"); + + 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(); + + installProcessShutdown( + { + 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("SIGINT"); + + 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(); + + installProcessShutdown( + { 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" }); + }); + + 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 new file mode 100644 index 000000000..f7ab564fa --- /dev/null +++ b/packages/mcp/test/telemetry-disabled.test.ts @@ -0,0 +1,65 @@ +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 { + forceFlushTelemetry, + initializeTelemetry, + observeAuthentication, + recordToolCallOutcome, + observeUpstreamRequest, +} = await import("../src/lib/telemetry-runtime.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 () => { + await expect( + initializeTelemetry({ allowEmbeddedPrometheus: true, serviceVersion: "test" }) + ).resolves.toBeUndefined(); + 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 forceFlushTelemetry(); + 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 new file mode 100644 index 000000000..2c5b3935e --- /dev/null +++ b/packages/mcp/test/telemetry.test.ts @@ -0,0 +1,201 @@ +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"; +import { classifyUpstreamError } from "../src/lib/telemetry.js"; +import { embeddedPrometheusIsEnabled, telemetryIsDisabled } from "../src/lib/telemetry-config.js"; + +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("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("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("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("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); + }); + + 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( + { 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 90e0996d2..d4c26dcbc 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,21 @@ 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/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) + '@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 @@ -137,6 +152,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 @@ -148,7 +172,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/opencode: devDependencies: @@ -184,7 +208,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: @@ -202,7 +226,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: @@ -229,7 +253,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 @@ -1318,10 +1342,76 @@ packages: '@opencode-ai/sdk@1.18.11': resolution: {integrity: sha512-yDImmNv4PhxdMgtiHVNWQWEVwQlAm7Dr0y4XU7CT4dOIbzgO+VP+9I02lAP7Zva1FhGeyI7oKMI2tzB9RUsWaQ==} + '@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'} + '@opentelemetry/api@1.9.1': + 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} + 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/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} + 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/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'} + '@pkgr/core@0.3.6': resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -1878,6 +1968,9 @@ packages: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} + cjs-module-lexer@2.2.1: + resolution: {integrity: sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==} + cli-boxes@3.0.0: resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} engines: {node: '>=10'} @@ -2409,6 +2502,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'} @@ -2637,6 +2734,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'} @@ -2925,6 +3025,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'} @@ -4529,8 +4633,78 @@ snapshots: dependencies: cross-spawn: 7.0.6 + '@opentelemetry/api-logs@0.221.0': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api@1.9.0': {} + '@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 + '@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/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 + '@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/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': {} '@protobufjs/aspromise@1.1.2': {} @@ -5122,6 +5296,8 @@ snapshots: ci-info@3.9.0: {} + cjs-module-lexer@2.2.1: {} + cli-boxes@3.0.0: {} cli-cursor@5.0.0: @@ -5779,6 +5955,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.1 + es-module-lexer: 2.3.0 + module-details-from-path: 1.0.4 + imurmurhash@0.1.4: {} inherits@2.0.4: {} @@ -5963,6 +6145,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: {} @@ -6229,6 +6413,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: {} @@ -6611,7 +6802,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)) @@ -6634,12 +6825,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)) @@ -6662,12 +6853,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)) @@ -6690,7 +6881,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