diff --git a/.changeset/protocol-instrumentation.md b/.changeset/protocol-instrumentation.md new file mode 100644 index 0000000000..efe97b4abd --- /dev/null +++ b/.changeset/protocol-instrumentation.md @@ -0,0 +1,17 @@ +--- +'@modelcontextprotocol/core-internal': minor +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': minor +--- + +`Client`, `Server` and `McpServer` now emit OpenTelemetry spans and `mcp.client.operation.duration` / `mcp.server.operation.duration` histograms for every MCP request and notification except `notifications/message`, following the OpenTelemetry semantic conventions for MCP, and propagate W3C trace context through `params._meta` (SEP-414). There is nothing to configure: register an OpenTelemetry SDK and MCP traffic shows up. Without one, the OpenTelemetry API is a no-op, nothing is recorded and messages are unchanged. + +`@opentelemetry/api` (`^1.9.0`) is a new required peer dependency of `@modelcontextprotocol/client` and `@modelcontextprotocol/server`, so the SDK uses the same copy as the application's OpenTelemetry SDK. npm 7+, pnpm 8+ and Bun install it automatically. Setups that don't install peers must add `@opentelemetry/api` to their dependencies, or importing either package fails: Yarn (classic, and Berry with any linker), `npm --legacy-peer-deps`, and pnpm with `auto-install-peers=false`. + +What changes for applications that already run an OpenTelemetry SDK: + +- MCP spans and metrics appear under the `@modelcontextprotocol/client` and `@modelcontextprotocol/server` instrumentation scopes. To drop them (for example when Sentry's MCP integration already traces MCP), filter those scopes in your SDK. +- Outgoing requests and notifications carry the keys your propagator writes in `params._meta`: `traceparent` and `tracestate`, and `baggage` if your propagator includes W3C Baggage (the `NodeSDK` default), in which case baggage entries reach MCP peers, including third-party servers. Register `W3CTraceContextPropagator` alone to send trace context only. +- Servers that validate a custom method's params with a strict schema need an optional `_meta` member, as they already do for callers that pass `onprogress`. + +Spans do not record tool arguments, tool results or prompt arguments. They do record JSON-RPC error messages, handler exception messages and resource URIs. The span and attribute names are experimental while the MCP semantic conventions are in development. diff --git a/docs/.vitepress/nav.ts b/docs/.vitepress/nav.ts index 9c07c8c0ed..af7798e9a8 100644 --- a/docs/.vitepress/nav.ts +++ b/docs/.vitepress/nav.ts @@ -71,7 +71,8 @@ export const guideSidebar: DefaultTheme.SidebarItem[] = [ { text: 'Schema libraries', link: '/advanced/schema-libraries' }, { text: 'Custom transports', link: '/advanced/custom-transports' }, { text: 'Wire schemas', link: '/advanced/wire-schemas' }, - { text: 'Gateway', link: '/advanced/gateway' } + { text: 'Gateway', link: '/advanced/gateway' }, + { text: 'OpenTelemetry', link: '/advanced/opentelemetry' } ] }, { text: 'Testing', link: '/testing' }, diff --git a/docs/advanced/opentelemetry.md b/docs/advanced/opentelemetry.md new file mode 100644 index 0000000000..c62a782a0b --- /dev/null +++ b/docs/advanced/opentelemetry.md @@ -0,0 +1,138 @@ +--- +shape: how-to +--- + +# OpenTelemetry + +`Client`, `McpServer` and `Server` emit OpenTelemetry spans and metrics for every MCP request and notification (except `notifications/message`), following the [OpenTelemetry semantic conventions for MCP](https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/mcp.md). Register an OpenTelemetry SDK and they show up. + +```ts source="../../examples/guides/advanced/opentelemetry.examples.ts#sdk_setup" +import { context, metrics, propagation, trace } from '@opentelemetry/api'; +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; +import { W3CTraceContextPropagator } from '@opentelemetry/core'; +import { InMemorySpanExporter, SimpleSpanProcessor, TracerProvider } from '@opentelemetry/sdk-trace'; + +// Swap the in-memory exporter for an OTLP exporter to send spans to a backend. +const exporter = new InMemorySpanExporter(); +const tracerProvider = new TracerProvider({ spanProcessors: [new SimpleSpanProcessor({ exporter })] }); + +context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); +propagation.setGlobalPropagator(new W3CTraceContextPropagator()); +trace.setGlobalTracerProvider(tracerProvider); +metrics.setGlobalMeterProvider(new MeterProvider({ readers: [metricReader] })); +``` + +::: warning Experimental +The MCP semantic conventions are still in development. Span and attribute names can change in a minor release. +::: + +## Register an OpenTelemetry SDK + +The SDK packages talk to the OpenTelemetry API, which does nothing until an application registers an SDK. Without one, nothing is recorded and nothing is added to `params._meta`. + +```sh +npm install @opentelemetry/api @opentelemetry/sdk-trace @opentelemetry/context-async-hooks @opentelemetry/core +``` + +The server needs no telemetry configuration of its own. + +```ts source="../../examples/guides/advanced/opentelemetry.examples.ts#server_traced" +import { McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const server = new McpServer({ name: 'weather', version: '1.0.0' }); + +server.registerTool('get-weather', { inputSchema: z.object({ city: z.string() }) }, async ({ city }) => { + // Spans started here are children of the "tools/call get-weather" server span. + const forecast = await trace.getTracer('weather').startActiveSpan('fetch forecast', async span => { + span.end(); + return 'sunny'; + }); + return { content: [{ type: 'text', text: `${city}: ${forecast}` }] }; +}); +``` + +The context manager is what makes the MCP span active inside your handlers, so spans you start there nest under it. + +## See the spans + +Make a call inside a span of your own, here an agent run. + +```ts source="../../examples/guides/advanced/opentelemetry.examples.ts#callTool_traced" +await trace.getTracer('agent').startActiveSpan('invoke_agent', async agentSpan => { + await client.callTool({ name: 'get-weather', arguments: { city: 'Lisbon' } }); + agentSpan.end(); +}); +``` + +The client's span carries its trace context to the server in `params._meta`, so both sides land in one trace: + +``` +invoke_agent (internal) + tools/call get-weather (client) + tools/call get-weather (server) + fetch forecast (internal) +``` + +The server span comes from the `@modelcontextprotocol/server` instrumentation scope (client spans from `@modelcontextprotocol/client`) and carries the conventions' attributes, plus `jsonrpc.request.id`, left out here: + +``` +@modelcontextprotocol/server { + 'mcp.method.name': 'tools/call', + 'mcp.protocol.version': '2025-11-25', + 'gen_ai.operation.name': 'execute_tool', + 'gen_ai.tool.name': 'get-weather' +} +``` + +::: warning +If your propagator includes W3C Baggage (the `NodeSDK` default), baggage entries travel in `params._meta` to every MCP server you call, including third-party ones. Register `W3CTraceContextPropagator` on its own to send trace context only. +::: + +::: info +Any SDK that follows [SEP-414](https://modelcontextprotocol.io/community/seps/414-request-meta) reads the same `traceparent`, `tracestate` and `baggage` keys, so a Python or C# peer joins the trace too. +::: + +## Read the duration metrics + +Register a meter provider and each instance records `mcp.client.operation.duration` for what it sends and `mcp.server.operation.duration` for what it receives, in seconds, with the conventions' bucket boundaries. The tool call above recorded: + +``` +mcp.client.operation.duration (s) { + 'mcp.method.name': 'tools/call', + 'mcp.protocol.version': '2025-11-25', + 'gen_ai.operation.name': 'execute_tool', + 'gen_ai.tool.name': 'get-weather' +} +``` + +Metrics never carry session ids, request ids or resource URIs. A server records any method that is neither a spec method nor registered with its own handler as `_OTHER`, keeps a tool or prompt name only once its handler has recognised it, and only records protocol versions it supports, so a client can't grow your metric cardinality. + +## Know what counts as an error + +Failed operations set `error.type` and an `ERROR` status: + +- A JSON-RPC error records its code, such as `-32603`, in `error.type` and `rpc.response.status_code`. On the server, `-32700`, `-32600`, `-32601`, `-32602` and `-32002` mean the caller sent something unservable and are not errors. +- A tool result with `isError: true` records `tool_error`. Its content stays out of the span. +- Local failures record `timeout`, `cancelled`, `connection_closed`, or the SDK error code (`SEND_FAILED`, `INVALID_RESULT`). +- A handler that throws something other than a `ProtocolError` also gets an exception event on the server span. + +Spans don't record tool arguments, tool results or prompt arguments. They do record JSON-RPC error messages, handler exception messages and resource URIs. + +## Serve over HTTP + +With HTTP instrumentation active too, the MCP server span stays a child of the client's MCP span and links to the HTTP server span. `createMcpHandler` needs nothing extra. + +::: tip +Already tracing MCP with another tool, such as Sentry's MCP integration? Drop one of the two to avoid duplicate spans: filter the `@modelcontextprotocol/client` and `@modelcontextprotocol/server` instrumentation scopes out in your SDK's span processor, or turn off the other integration. +::: + +The `examples/opentelemetry` story runs a traced client and server in separate processes over stdio and Streamable HTTP, and checks the trace crosses the wire. + +## Recap + +- Register an OpenTelemetry SDK; `Client`, `McpServer` and `Server` need no configuration. +- Without a registered SDK, nothing is recorded and `params._meta` is untouched. +- Trace context travels in `params._meta`, so client and server spans join one trace and handler spans nest under the server span. +- Each instance records `mcp.client.operation.duration` and `mcp.server.operation.duration` with low-cardinality attributes. +- Spans don't record tool arguments, tool results or prompt arguments. diff --git a/docs/servers/logging-progress-cancellation.md b/docs/servers/logging-progress-cancellation.md index a23aff35b5..256bc20380 100644 --- a/docs/servers/logging-progress-cancellation.md +++ b/docs/servers/logging-progress-cancellation.md @@ -74,7 +74,7 @@ Only the result comes back: ## Log to the client ::: warning Deprecated — SEP-2577 -Log to `stderr` (stdio servers) or use OpenTelemetry instead. **MCP logging** is deprecated as of protocol version 2026-07-28 (SEP-2577) and stays functional through the deprecation window (at least twelve months) — see the [deprecated features registry](https://modelcontextprotocol.io/specification/2026-07-28/deprecated). +Log to `stderr` (stdio servers) or use [OpenTelemetry](../advanced/opentelemetry.md) instead. **MCP logging** is deprecated as of protocol version 2026-07-28 (SEP-2577) and stays functional through the deprecation window (at least twelve months) — see the [deprecated features registry](https://modelcontextprotocol.io/specification/2026-07-28/deprecated). ::: Declare the `logging` capability when you construct the server. diff --git a/examples/README.md b/examples/README.md index 51bc960a13..6563c03f01 100644 --- a/examples/README.md +++ b/examples/README.md @@ -43,6 +43,7 @@ The one exception to the generic commands is the reference pair: [`cli-client/`] | [`todos-server/`](./todos-server/README.md) | **Reference server** (pairs with cli-client): every server feature with a real job — CRUD tools, sampling, multi-round elicitation, subscriptions, progress | stdio + http | dual | | [`caching/`](./caching/README.md) | `cacheHints` stamping on cacheable results (2026-07-28) | stdio + http | modern | | [`gateway/`](./gateway/README.md) | `connect({ prior })` — probe once, zero-round-trip connect for every worker (gateway pattern) | http | modern | +| [`opentelemetry/`](./opentelemetry/README.md) | OpenTelemetry traces and duration metrics with no MCP-side configuration; trace context crosses the wire in `params._meta` | stdio + http | dual | | [`custom-methods/`](./custom-methods/README.md) | Vendor-prefixed methods + custom notifications | stdio + http | dual | | [`extension-capabilities/`](./extension-capabilities/README.md) | Declaring `capabilities.extensions` and reading the negotiated map | stdio + http | dual | | [`skills/`](./skills/README.md) | Skills over MCP (SEP-2640): `loadSkills` a folder, `SkillsClient` lists, loads and verifies | stdio + http | dual | diff --git a/examples/guides/advanced/opentelemetry.examples.ts b/examples/guides/advanced/opentelemetry.examples.ts new file mode 100644 index 0000000000..c5a3e6a9ae --- /dev/null +++ b/examples/guides/advanced/opentelemetry.examples.ts @@ -0,0 +1,109 @@ +/** + * Companion example for `docs/advanced/opentelemetry.md`. + * + * Every `ts` fence on that page is synced from a `//#region` in this file + * (`pnpm sync:snippets --check`). The file also runs: the harness below the + * regions connects an in-memory client and server and prints the spans and + * metrics the page quotes verbatim. + * + * pnpm --filter @modelcontextprotocol/examples typecheck + * npx tsx guides/advanced/opentelemetry.examples.ts # from examples/ + * + * @module + */ +/* eslint-disable no-console */ +import type { DataPoint, Histogram } from '@opentelemetry/sdk-metrics'; +import { MeterProvider, MetricReader } from '@opentelemetry/sdk-metrics'; +import type { ReadableSpan } from '@opentelemetry/sdk-trace'; + +class CollectingMetricReader extends MetricReader { + protected async onShutdown(): Promise {} + protected async onForceFlush(): Promise {} +} +const metricReader = new CollectingMetricReader(); + +// "Register an OpenTelemetry SDK" — the application's job, once, before any MCP traffic. +//#region sdk_setup +import { context, metrics, propagation, trace } from '@opentelemetry/api'; +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; +import { W3CTraceContextPropagator } from '@opentelemetry/core'; +import { InMemorySpanExporter, SimpleSpanProcessor, TracerProvider } from '@opentelemetry/sdk-trace'; + +// Swap the in-memory exporter for an OTLP exporter to send spans to a backend. +const exporter = new InMemorySpanExporter(); +const tracerProvider = new TracerProvider({ spanProcessors: [new SimpleSpanProcessor({ exporter })] }); + +context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); +propagation.setGlobalPropagator(new W3CTraceContextPropagator()); +trace.setGlobalTracerProvider(tracerProvider); +metrics.setGlobalMeterProvider(new MeterProvider({ readers: [metricReader] })); +//#endregion sdk_setup + +// "Register an OpenTelemetry SDK" — the server needs no telemetry configuration. +//#region server_traced +import { McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const server = new McpServer({ name: 'weather', version: '1.0.0' }); + +server.registerTool('get-weather', { inputSchema: z.object({ city: z.string() }) }, async ({ city }) => { + // Spans started here are children of the "tools/call get-weather" server span. + const forecast = await trace.getTracer('weather').startActiveSpan('fetch forecast', async span => { + span.end(); + return 'sunny'; + }); + return { content: [{ type: 'text', text: `${city}: ${forecast}` }] }; +}); +//#endregion server_traced + +// --------------------------------------------------------------------------- +// Harness (not shown on the page). Connects a client in memory, makes one tool +// call inside an agent span, and prints what was recorded. +// --------------------------------------------------------------------------- + +const { SpanKind } = await import('@opentelemetry/api'); +const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client'); +const client = new Client({ name: 'agent', version: '1.0.0' }); +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); +exporter.reset(); + +// "See the spans" — the call the page's span tree comes from. +//#region callTool_traced +await trace.getTracer('agent').startActiveSpan('invoke_agent', async agentSpan => { + await client.callTool({ name: 'get-weather', arguments: { city: 'Lisbon' } }); + agentSpan.end(); +}); +//#endregion callTool_traced + +const spans = exporter.getFinishedSpans(); +const kinds: Record = { [SpanKind.INTERNAL]: 'internal', [SpanKind.SERVER]: 'server', [SpanKind.CLIENT]: 'client' }; +const printTree = (parent: ReadableSpan | undefined, depth: number) => { + const children = spans + .filter(span => span.parentSpanContext?.spanId === parent?.spanContext().spanId) + .toSorted((a, b) => a.startTime[0] - b.startTime[0] || a.startTime[1] - b.startTime[1]); + for (const child of children) { + console.log(`${' '.repeat(depth)}${child.name} (${kinds[child.kind]})`); + printTree(child, depth + 1); + } +}; +printTree(undefined, 0); + +const toolServerSpan = spans.find(span => span.name === 'tools/call get-weather' && span.kind === SpanKind.SERVER); +if (toolServerSpan === undefined) throw new Error('opentelemetry.md claim failed: no tools/call server span'); +const { 'jsonrpc.request.id': _requestId, ...stableAttributes } = toolServerSpan.attributes; +console.log(toolServerSpan.instrumentationScope.name, stableAttributes); + +const { resourceMetrics } = await metricReader.collect(); +const clientDuration = resourceMetrics.scopeMetrics + .filter(scope => scope.scope.name === '@modelcontextprotocol/client') + .flatMap(scope => scope.metrics) + .find(metric => metric.descriptor.name === 'mcp.client.operation.duration'); +const toolCallPoint = (clientDuration?.dataPoints as DataPoint[] | undefined)?.find( + point => point.attributes['mcp.method.name'] === 'tools/call' +); +if (toolCallPoint === undefined) throw new Error('opentelemetry.md claim failed: no tools/call duration was recorded'); +console.log(`${clientDuration!.descriptor.name} (${clientDuration!.descriptor.unit})`, toolCallPoint.attributes); + +await client.close(); +await tracerProvider.shutdown(); diff --git a/examples/opentelemetry/README.md b/examples/opentelemetry/README.md new file mode 100644 index 0000000000..290feac7dc --- /dev/null +++ b/examples/opentelemetry/README.md @@ -0,0 +1,18 @@ +# opentelemetry + +Trace and measure MCP traffic with OpenTelemetry. Both processes register an OpenTelemetry SDK and configure nothing on the MCP side; the client's `tools/call` span carries its trace context to the server in `params._meta`, so the server span and anything the tool does land in the same trace. The client asserts the trace crossed the wire, the span parenting on both sides, and that `mcp.client.operation.duration` was recorded. + +```bash +pnpm --filter @mcp-examples/opentelemetry client +``` + +Sample output (ids differ per run): + +``` +trace 4bf92f3577b34da6a3ce929d0e0e4736 + invoke_agent (client process) + tools/call trace-info (client process) + tools/call trace-info (server process) +``` + +The server and client keep spans in memory to stay self-contained. In an application, add a span processor with an exporter (for example `OTLPTraceExporter`) to the tracer provider. See [the OpenTelemetry guide](../../docs/advanced/opentelemetry.md). diff --git a/examples/opentelemetry/client.ts b/examples/opentelemetry/client.ts new file mode 100644 index 0000000000..39485b8f63 --- /dev/null +++ b/examples/opentelemetry/client.ts @@ -0,0 +1,78 @@ +/** + * Drives the OpenTelemetry example: one tool call inside an agent span, then + * checks the trace on both sides of the wire. + * + * - The client's `tools/call trace-info` span is a child of the agent span. + * - The server's span for that call is in the same trace, and its parent is + * the client's span: trace context crossed the wire in `params._meta`. + * - A span the tool started nests under the server span. + * - The client recorded `mcp.client.operation.duration` for the call. + */ +import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { context, metrics, propagation, SpanKind, trace } from '@opentelemetry/api'; +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; +import { W3CTraceContextPropagator } from '@opentelemetry/core'; +import { MeterProvider, MetricReader } from '@opentelemetry/sdk-metrics'; +import { InMemorySpanExporter, SimpleSpanProcessor, TracerProvider } from '@opentelemetry/sdk-trace'; + +// The client application's OpenTelemetry SDK. In-memory here so the example can assert on what it recorded. +const exporter = new InMemorySpanExporter(); +const tracerProvider = new TracerProvider({ spanProcessors: [new SimpleSpanProcessor({ exporter })] }); +context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); +propagation.setGlobalPropagator(new W3CTraceContextPropagator()); +trace.setGlobalTracerProvider(tracerProvider); +class CollectingMetricReader extends MetricReader { + protected async onShutdown(): Promise {} + protected async onForceFlush(): Promise {} +} +const metricReader = new CollectingMetricReader(); +metrics.setGlobalMeterProvider(new MeterProvider({ readers: [metricReader] })); + +const { transport, url, era } = parseExampleArgs(); + +const client = new Client( + { name: 'opentelemetry-example-client', version: '1.0.0' }, + { versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } } +); + +await (transport === 'stdio' + ? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] })) + : client.connect(new StreamableHTTPClientTransport(new URL(url)))); + +const result = await trace.getTracer('agent').startActiveSpan('invoke_agent', async agentSpan => { + try { + return await client.callTool({ name: 'trace-info', arguments: {} }); + } finally { + agentSpan.end(); + } +}); +const server = result.structuredContent as { traceId: string; spanName: string; parentSpanId: string }; + +const spans = exporter.getFinishedSpans(); +const agentSpan = spans.find(span => span.name === 'invoke_agent')!; +const callSpan = spans.find(span => span.name === 'tools/call trace-info' && span.kind === SpanKind.CLIENT); +check.ok(callSpan, 'the client should record a "tools/call trace-info" CLIENT span'); +check.equal(callSpan.parentSpanContext?.spanId, agentSpan.spanContext().spanId, 'the call span should be a child of the agent span'); +check.equal(callSpan.attributes['gen_ai.tool.name'], 'trace-info'); +check.equal(callSpan.attributes['mcp.protocol.version'], era === 'modern' ? '2026-07-28' : '2025-11-25'); + +check.equal(server.traceId, callSpan.spanContext().traceId, 'the server span should be in the client trace'); +check.equal(server.parentSpanId, callSpan.spanContext().spanId, 'the server span should be a child of the client call span'); +check.equal(server.spanName, 'tools/call trace-info'); + +const { resourceMetrics } = await metricReader.collect(); +const duration = resourceMetrics.scopeMetrics + .flatMap(scope => scope.metrics) + .find(m => m.descriptor.name === 'mcp.client.operation.duration'); +check.ok(duration, 'the client should record mcp.client.operation.duration'); +check.ok(duration.dataPoints.some(point => point.attributes['gen_ai.tool.name'] === 'trace-info')); + +console.log(`trace ${server.traceId}`); +console.log(` ${agentSpan.name} (client process)`); +console.log(` ${callSpan.name} (client process)`); +console.log(` ${server.spanName} (server process)`); + +await client.close(); +await tracerProvider.shutdown(); diff --git a/examples/opentelemetry/package.json b/examples/opentelemetry/package.json new file mode 100644 index 0000000000..34f6b93395 --- /dev/null +++ b/examples/opentelemetry/package.json @@ -0,0 +1,25 @@ +{ + "name": "@mcp-examples/opentelemetry", + "private": true, + "type": "module", + "scripts": { + "server": "tsx server.ts", + "client": "tsx client.ts" + }, + "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", + "@mcp-examples/shared": "workspace:*", + "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/hono": "workspace:*", + "@modelcontextprotocol/server": "workspace:*", + "@opentelemetry/api": "catalog:otel", + "@opentelemetry/context-async-hooks": "catalog:otel", + "@opentelemetry/core": "catalog:otel", + "@opentelemetry/sdk-metrics": "catalog:otel", + "@opentelemetry/sdk-trace": "catalog:otel", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "tsx": "catalog:devTools" + } +} diff --git a/examples/opentelemetry/server.ts b/examples/opentelemetry/server.ts new file mode 100644 index 0000000000..ba8602bd2b --- /dev/null +++ b/examples/opentelemetry/server.ts @@ -0,0 +1,72 @@ +/** + * OpenTelemetry — a traced server. + * + * The application registers an OpenTelemetry SDK and nothing else: the SDK turns + * every MCP request the server handles into a SERVER span, parented on the + * client's span through `params._meta`. Code in a tool runs inside that span, so + * its own spans nest under it. + * + * The `trace-info` tool reports the span it ran in, so the client can check the + * trace crossed the process boundary. A real server would export its spans with + * an OTLP exporter instead. + */ +import { serve } from '@hono/node-server'; +import { parseExampleArgs } from '@mcp-examples/shared'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import { context, propagation, trace } from '@opentelemetry/api'; +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; +import { W3CTraceContextPropagator } from '@opentelemetry/core'; +import type { ReadableSpan } from '@opentelemetry/sdk-trace'; +import { TracerProvider } from '@opentelemetry/sdk-trace'; +import * as z from 'zod/v4'; + +// Register the OpenTelemetry SDK before any MCP traffic. That is all the MCP server needs. +// Add `spanProcessors: [new BatchSpanProcessor({ exporter: new OTLPTraceExporter() })]` to export the spans. +context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); +propagation.setGlobalPropagator(new W3CTraceContextPropagator()); +trace.setGlobalTracerProvider(new TracerProvider()); + +function buildServer(): McpServer { + const server = new McpServer({ name: 'opentelemetry-example', version: '1.0.0' }); + + server.registerTool( + 'trace-info', + { + description: 'Reports the span this tool call ran in', + inputSchema: z.object({}), + outputSchema: z.object({ traceId: z.string(), spanName: z.string(), parentSpanId: z.string() }) + }, + async () => { + // Your own spans nest under the MCP server span. + await trace.getTracer('opentelemetry-example').startActiveSpan('look up forecast', async span => span.end()); + + const active = trace.getActiveSpan() as (ReturnType & Partial) | undefined; + const structuredContent = { + traceId: active?.spanContext().traceId ?? '', + spanName: active?.name ?? '', + parentSpanId: active?.parentSpanContext?.spanId ?? '' + }; + return { content: [{ type: 'text', text: JSON.stringify(structuredContent) }], structuredContent }; + } + ); + + return server; +} + +const { transport, port } = parseExampleArgs(); + +if (transport === 'stdio') { + void serveStdio(buildServer); + console.error('[server] serving over stdio'); +} else { + const handler = createMcpHandler(buildServer); + // `createMcpHonoApp()` binds the endpoint behind localhost host/origin + // validation by default, matching the framework factories' defaults. + const app = createMcpHonoApp(); + app.all('/mcp', c => handler.fetch(c.req.raw)); + serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => { + console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); + }); +} diff --git a/examples/package.json b/examples/package.json index 79b480721a..e6f711fc1f 100644 --- a/examples/package.json +++ b/examples/package.json @@ -32,6 +32,11 @@ "@modelcontextprotocol/node": "workspace:^", "@modelcontextprotocol/server": "workspace:^", "@modelcontextprotocol/server-legacy": "workspace:^", + "@opentelemetry/api": "catalog:otel", + "@opentelemetry/context-async-hooks": "catalog:otel", + "@opentelemetry/core": "catalog:otel", + "@opentelemetry/sdk-metrics": "catalog:otel", + "@opentelemetry/sdk-trace": "catalog:otel", "@valibot/to-json-schema": "catalog:devTools", "ajv": "catalog:runtimeShared", "arktype": "catalog:devTools", diff --git a/packages/client/package.json b/packages/client/package.json index df1fd506a9..636456879d 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -154,7 +154,11 @@ "pkce-challenge": "catalog:runtimeShared", "zod": "catalog:runtimeShared" }, + "peerDependencies": { + "@opentelemetry/api": "catalog:otel" + }, "devDependencies": { + "@opentelemetry/api": "catalog:otel", "@modelcontextprotocol/core-internal": "workspace:^", "@modelcontextprotocol/tsconfig": "workspace:^", "@modelcontextprotocol/vitest-config": "workspace:^", diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 096a8660e2..db81017a1f 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -76,10 +76,12 @@ import { SdkError, SdkErrorCode, SERVER_INFO_META_KEY, + setProtocolInstrumentation, SUBSCRIPTION_ID_META_KEY, SUPPORTED_MODERN_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core-internal'; +import { defaultInstrumentation } from './openTelemetry'; import type { PriorDiscovery } from './probeClassifier'; import type { CacheMode, CacheScope, ResponseCacheStore } from './responseCache'; import { ClientResponseCache, InMemoryResponseCacheStore, MAX_CACHE_TTL_MS } from './responseCache'; @@ -656,6 +658,7 @@ export class Client extends Protocol { options?: ClientOptions ) { super(options); + setProtocolInstrumentation(this, defaultInstrumentation()); this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); this._enforceStrictCapabilities = options?.enforceStrictCapabilities ?? false; diff --git a/packages/client/src/client/openTelemetry.ts b/packages/client/src/client/openTelemetry.ts new file mode 100644 index 0000000000..4118705fc8 --- /dev/null +++ b/packages/client/src/client/openTelemetry.ts @@ -0,0 +1,9 @@ +import type { ProtocolInstrumentation } from '@modelcontextprotocol/core-internal'; +import { openTelemetryInstrumentation } from '@modelcontextprotocol/core-internal'; + +let shared: ProtocolInstrumentation | undefined; + +/** OpenTelemetry instrumentation every `Client` attaches; created on first use and shared. */ +export function defaultInstrumentation(): ProtocolInstrumentation { + return (shared ??= openTelemetryInstrumentation('@modelcontextprotocol/client')); +} diff --git a/packages/core-internal/package.json b/packages/core-internal/package.json index 7c8ebaf2b8..5e7612b729 100644 --- a/packages/core-internal/package.json +++ b/packages/core-internal/package.json @@ -56,6 +56,7 @@ }, "dependencies": { "@modelcontextprotocol/core": "workspace:*", + "@opentelemetry/api": "catalog:otel", "content-type": "catalog:runtimeShared", "json-schema-typed": "catalog:runtimeShared", "zod": "catalog:runtimeShared" diff --git a/packages/core-internal/src/index.ts b/packages/core-internal/src/index.ts index c93a604fa1..eb892cc49b 100644 --- a/packages/core-internal/src/index.ts +++ b/packages/core-internal/src/index.ts @@ -10,9 +10,11 @@ export * from './shared/inboundClassification'; export * from './shared/inputRequired'; export * from './shared/inputRequiredDriver'; export * from './shared/inputRequiredEngine'; +export * from './shared/instrumentation'; export * from './shared/mcpParamHeaders'; export * from './shared/mediaType'; export * from './shared/metadataUtils'; +export * from './shared/openTelemetry'; export * from './shared/protocol'; export * from './shared/protocolEras'; export * from './shared/resultCacheHints'; diff --git a/packages/core-internal/src/shared/instrumentation.ts b/packages/core-internal/src/shared/instrumentation.ts new file mode 100644 index 0000000000..abaecaf3c8 --- /dev/null +++ b/packages/core-internal/src/shared/instrumentation.ts @@ -0,0 +1,144 @@ +import { SdkError, SdkErrorCode } from '../errors/sdkErrors'; +import type { RequestId, Result } from '../types/index'; +import { BAGGAGE_META_KEY, ProtocolError, TRACEPARENT_META_KEY, TRACESTATE_META_KEY } from '../types/index'; + +// Observes the requests and notifications a Protocol instance sends and receives. `end` runs exactly once per scope. +export interface ProtocolInstrumentation { + send?(operation: OutboundOperation): OperationScope | undefined; + receive?(operation: InboundOperation): OperationScope | undefined; +} + +export interface OperationBase { + readonly method: string; + // Absent for notifications. + readonly id?: RequestId; + // As the params go on, or came off, the wire. Do not mutate. + readonly params?: Readonly>; + readonly sessionId?: string; + // The negotiated protocol version, once there is one. + readonly protocolVersion?: string; +} + +export interface OutboundOperation extends OperationBase { + // Keys written here go into `params._meta`; prefilled with caller-supplied trace-context keys. + readonly carrier: Record; +} + +export interface InboundOperation extends OperationBase { + readonly meta?: Readonly>; + // A spec method, or one with its own handler. Fallback handlers don't count: the name is client-chosen. + readonly knownMethod: boolean; + readonly httpRequest?: Request; +} + +export interface OperationScope { + // Runs the operation's work (the transport send, or the handler) in the instrumentation's context; must call `fn` once. + run(fn: () => T): T; + end(outcome: OperationOutcome): void; +} + +// `error` is a JSON-RPC error answer; `delivered` a sent or handled notification; `failed` any other local failure. +export type OperationOutcome = + | { type: 'result'; result: Result } + | { type: 'error'; error: { code: number; message: string }; cause?: unknown } + | { type: 'delivered' } + | { type: 'timeout' } + | { type: 'cancelled' } + | { type: 'closed' } + | { type: 'failed'; error: unknown }; + +/** An {@linkcode OperationScope} whose methods never throw and whose `end` runs at most once. */ +export interface GuardedScope { + run(fn: () => T): T; + end(outcome: OperationOutcome): void; +} + +const TRACE_CONTEXT_META_KEYS = [TRACEPARENT_META_KEY, TRACESTATE_META_KEY, BAGGAGE_META_KEY] as const; + +/** A carrier prefilled with the string trace-context keys found in `meta`. */ +export function traceContextCarrier(meta: unknown): Record { + const carrier: Record = {}; + if (meta === null || typeof meta !== 'object') return carrier; + for (const key of TRACE_CONTEXT_META_KEYS) { + const value = (meta as Record)[key]; + if (typeof value === 'string') carrier[key] = value; + } + return carrier; +} + +/** Calls `start` and guards the scope it returns; `report` receives anything the instrumentation throws. */ +export function startScope(start: () => OperationScope | undefined, report: (error: Error) => void): GuardedScope | undefined { + let scope: OperationScope | undefined; + try { + scope = start(); + } catch (error) { + report(asInstrumentationError(error)); + return undefined; + } + return scope === undefined ? undefined : guardScope(scope, report); +} + +/** Runs `fn` in `scope` when there is one. */ +export function runInScope(scope: GuardedScope | undefined, fn: () => T): T { + return scope === undefined ? fn() : scope.run(fn); +} + +/** The outcome for an error that settled an outbound request. */ +export function outcomeForError(error: unknown): OperationOutcome { + if (error instanceof ProtocolError) return { type: 'error', error: { code: error.code, message: error.message }, cause: error }; + if (error instanceof SdkError) { + if (error.code === SdkErrorCode.ConnectionClosed) return { type: 'closed' }; + if (error.code === SdkErrorCode.RequestTimeout) return { type: 'timeout' }; + } + return { type: 'failed', error }; +} + +/** The outcome for an inbound request whose handler was aborted. */ +export function outcomeForAbort(reason: unknown): OperationOutcome { + return reason instanceof SdkError && reason.code === SdkErrorCode.ConnectionClosed ? { type: 'closed' } : { type: 'cancelled' }; +} + +function guardScope(scope: OperationScope, report: (error: Error) => void): GuardedScope { + let settled = false; + return { + run(fn: () => T): T { + let state = 'pending' as 'pending' | 'ok' | 'threw'; + let value: T | undefined; + let thrown: unknown; + let runError: unknown; + try { + scope.run(() => { + try { + value = fn(); + state = 'ok'; + return value; + } catch (error) { + state = 'threw'; + thrown = error; + throw error; + } + }); + } catch (error) { + if (state !== 'threw') runError = error; + } + if (runError !== undefined) report(asInstrumentationError(runError)); + if (state === 'ok') return value as T; + if (state === 'threw') throw thrown; + if (runError === undefined) report(new Error('Instrumentation failed: scope.run() did not call fn')); + return fn(); + }, + end(outcome: OperationOutcome): void { + if (settled) return; + settled = true; + try { + scope.end(outcome); + } catch (error) { + report(asInstrumentationError(error)); + } + } + }; +} + +function asInstrumentationError(error: unknown): Error { + return new Error(`Instrumentation failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error }); +} diff --git a/packages/core-internal/src/shared/openTelemetry.ts b/packages/core-internal/src/shared/openTelemetry.ts new file mode 100644 index 0000000000..d9b735f7f0 --- /dev/null +++ b/packages/core-internal/src/shared/openTelemetry.ts @@ -0,0 +1,269 @@ +import type { Attributes, Context, Histogram, Link, MeterProvider, Span, TextMapGetter } from '@opentelemetry/api'; +import { + context, + defaultTextMapSetter, + isSpanContextValid, + metrics, + propagation, + SpanKind, + SpanStatusCode, + trace +} from '@opentelemetry/api'; + +import { SUPPORTED_PROTOCOL_VERSIONS } from '../types/index'; +import type { InboundOperation, OperationBase, OperationOutcome, OutboundOperation, ProtocolInstrumentation } from './instrumentation'; +import { SUPPORTED_MODERN_PROTOCOL_VERSIONS } from './protocolEras'; + +/** Bucket boundaries the MCP semantic conventions specify for every MCP duration histogram. */ +const DURATION_BUCKETS = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 120, 300]; + +/** Codes the conventions say a receiver should not treat as errors: the caller sent something it could not serve. */ +const NOT_RECEIVER_ERRORS = new Set([-32_700, -32_600, -32_601, -32_602, -32_002]); +const INVALID_PARAMS = -32_602; + +const RESOURCE_URI_METHODS = new Set(['resources/read', 'resources/subscribe', 'resources/unsubscribe', 'notifications/resources/updated']); + +/** Log notifications are deprecated (SEP-2577) and belong in logs, not spans. */ +const SKIPPED_METHODS = new Set(['notifications/message']); + +/** Protocol versions a receiver may record before negotiation; anything else a client sends is unbounded. */ +const KNOWN_PROTOCOL_VERSIONS = new Set([...SUPPORTED_PROTOCOL_VERSIONS, ...SUPPORTED_MODERN_PROTOCOL_VERSIONS]); + +const STANDARD_ERROR_NAMES = new Set(['Error', 'TypeError', 'RangeError', 'SyntaxError', 'AbortError', 'TimeoutError']); + +/** Attributes that may go on metrics; everything else is span-only. */ +const METRIC_ATTRIBUTES = [ + 'mcp.method.name', + 'mcp.protocol.version', + 'gen_ai.operation.name', + 'gen_ai.tool.name', + 'gen_ai.prompt.name', + 'network.transport', + 'network.protocol.name' +] as const; + +const metaGetter: TextMapGetter>> = { + keys: carrier => Object.keys(carrier), + get: (carrier, key) => { + const value = carrier[key]; + return typeof value === 'string' ? value : undefined; + } +}; + +type Side = 'client' | 'server'; + +// MCP spans, duration histograms and `_meta` trace context through the global OpenTelemetry API. +export function openTelemetryInstrumentation(scopeName: string): ProtocolInstrumentation { + const tracer = trace.getTracer(scopeName); + + // Recreated when the global meter provider changes: the metrics API has no proxy, so instruments from an earlier provider stay no-ops. + let durations: Record | undefined; + let durationsProvider: MeterProvider | undefined; + const duration = (side: Side): Histogram => { + const provider = metrics.getMeterProvider(); + if (durations === undefined || durationsProvider !== provider) { + durationsProvider = provider; + const meter = provider.getMeter(scopeName); + const histogram = (name: string, description: string) => + meter.createHistogram(name, { unit: 's', description, advice: { explicitBucketBoundaries: DURATION_BUCKETS } }); + durations = { + client: histogram( + 'mcp.client.operation.duration', + 'The duration of the MCP request or notification as observed on the sender from the time it was sent until the response or ack is received.' + ), + server: histogram( + 'mcp.server.operation.duration', + 'MCP request or notification duration as observed on the receiver from the time it was received until the result or ack is sent.' + ) + }; + } + return durations[side]; + }; + + const extract = (ctx: Context, carrier: Readonly>) => propagation.extract(ctx, carrier, metaGetter); + + const finish = ( + span: Span, + side: Side, + method: string, + target: string | undefined, + metricAttributes: Attributes, + outcome: OperationOutcome, + started: number + ) => { + let errorType: string | undefined; + switch (outcome.type) { + case 'result': { + if (method === 'tools/call' && outcome.result.isError === true) { + errorType = 'tool_error'; + span.setStatus({ code: SpanStatusCode.ERROR }); + } + break; + } + case 'error': { + const { code, message } = outcome.error; + span.setAttribute('rpc.response.status_code', String(code)); + // A server can answer with any code; outside the JSON-RPC reserved range it is bucketed on the client's metrics. + const metricCode = side === 'client' && !isReservedErrorCode(code) ? '_OTHER' : String(code); + metricAttributes['rpc.response.status_code'] = metricCode; + if (side === 'client' || !NOT_RECEIVER_ERRORS.has(code)) { + errorType = String(code); + metricAttributes['error.type'] = metricCode; + span.setStatus({ code: SpanStatusCode.ERROR, message }); + if (side === 'server' && isUnexpectedException(outcome.cause)) span.recordException(outcome.cause); + } + break; + } + case 'timeout': + case 'cancelled': { + errorType = outcome.type; + span.setStatus({ code: SpanStatusCode.ERROR }); + break; + } + case 'closed': { + errorType = 'connection_closed'; + span.setStatus({ code: SpanStatusCode.ERROR }); + break; + } + case 'failed': { + errorType = errorTypeOf(outcome.error); + span.setStatus({ code: SpanStatusCode.ERROR, ...(outcome.error instanceof Error && { message: outcome.error.message }) }); + break; + } + case 'delivered': { + break; + } + } + if (errorType !== undefined) { + span.setAttribute('error.type', errorType); + metricAttributes['error.type'] ??= errorType; + } + // On the server a tool or prompt name is client-chosen: keep it only once the handler has recognised it. + if (side === 'server' && target !== undefined && !recognisedTarget(outcome)) { + span.updateName(method); + delete metricAttributes['gen_ai.tool.name']; + delete metricAttributes['gen_ai.prompt.name']; + } + span.end(); + duration(side).record((performance.now() - started) / 1000, metricAttributes); + }; + + return { + send(operation: OutboundOperation) { + if (SKIPPED_METHODS.has(operation.method)) return; + const target = targetOf(operation); + const attributes = startAttributes(operation, operation.method, target, 'client'); + // Trace context the caller already put in `_meta` parents the span; otherwise the active context does. + const parent = extract(context.active(), operation.carrier); + const span = tracer.startSpan(spanName(operation.method, target), { kind: SpanKind.CLIENT, attributes }, parent); + const spanContext = trace.setSpan(parent, span); + const injected: Record = {}; + // Injected with the span's context active: some propagators (Sentry's) only inject the active context. + context.with(spanContext, () => propagation.inject(spanContext, injected, defaultTextMapSetter)); + Object.assign(operation.carrier, injected); + const metricAttributes = pickMetricAttributes(attributes); + const started = performance.now(); + return { + run: fn => context.with(spanContext, fn), + end: outcome => finish(span, 'client', operation.method, target, metricAttributes, outcome, started) + }; + }, + + receive(operation: InboundOperation) { + if (SKIPPED_METHODS.has(operation.method)) return; + // Any other method name is client-chosen: record it as `_OTHER` to bound cardinality. + const method = operation.knownMethod ? operation.method : '_OTHER'; + const target = operation.knownMethod ? targetOf(operation) : undefined; + const attributes = startAttributes(operation, method, target, 'server'); + if (operation.httpRequest !== undefined) { + attributes['network.protocol.name'] = 'http'; + attributes['network.transport'] = 'tcp'; + } + const active = context.active(); + const parent = operation.meta === undefined ? active : extract(active, operation.meta); + // With a remote parent from `_meta`, the ambient span (an HTTP server span, say) becomes a link. + const ambient = trace.getSpanContext(active); + const remote = trace.getSpanContext(parent); + const links: Link[] = + remote !== undefined && + remote !== ambient && + isSpanContextValid(remote) && + ambient !== undefined && + isSpanContextValid(ambient) + ? [{ context: ambient }] + : []; + const span = tracer.startSpan(spanName(method, target), { kind: SpanKind.SERVER, attributes, links }, parent); + const spanContext = trace.setSpan(parent, span); + const metricAttributes = pickMetricAttributes(attributes); + const started = performance.now(); + return { + run: fn => context.with(spanContext, fn), + end: outcome => finish(span, 'server', method, target, metricAttributes, outcome, started) + }; + } + }; +} + +/** The low-cardinality span-name target: the tool or prompt name. */ +function targetOf(operation: OperationBase): string | undefined { + if (operation.method !== 'tools/call' && operation.method !== 'prompts/get') return undefined; + const name = operation.params?.name; + return typeof name === 'string' ? name : undefined; +} + +function spanName(method: string, target: string | undefined): string { + return target === undefined ? method : `${method} ${target}`; +} + +function startAttributes(operation: OperationBase, method: string, target: string | undefined, side: Side): Attributes { + const attributes: Attributes = { 'mcp.method.name': method }; + if (operation.id !== undefined) attributes['jsonrpc.request.id'] = String(operation.id); + // Before negotiation `initialize` names a version; a receiver only records one it supports. + const requested = operation.method === 'initialize' ? operation.params?.protocolVersion : undefined; + const requestedVersion = + typeof requested === 'string' && (side === 'client' || KNOWN_PROTOCOL_VERSIONS.has(requested)) ? requested : undefined; + const protocolVersion = operation.protocolVersion ?? requestedVersion; + if (protocolVersion !== undefined) attributes['mcp.protocol.version'] = protocolVersion; + if (operation.sessionId !== undefined) attributes['mcp.session.id'] = operation.sessionId; + if (method === 'tools/call') { + attributes['gen_ai.operation.name'] = 'execute_tool'; + if (target !== undefined) attributes['gen_ai.tool.name'] = target; + } + if (method === 'prompts/get' && target !== undefined) attributes['gen_ai.prompt.name'] = target; + if (RESOURCE_URI_METHODS.has(method)) { + const uri = operation.params?.uri; + if (typeof uri === 'string') attributes['mcp.resource.uri'] = uri; + } + return attributes; +} + +function pickMetricAttributes(attributes: Attributes): Attributes { + const picked: Attributes = {}; + for (const key of METRIC_ATTRIBUTES) { + if (attributes[key] !== undefined) picked[key] = attributes[key]; + } + return picked; +} + +/** Whether a handler answered and recognised the tool or prompt: errors sent before dispatch carry no cause, and unknown names answer -32602. */ +function recognisedTarget(outcome: OperationOutcome): boolean { + return outcome.type === 'result' || (outcome.type === 'error' && outcome.cause !== undefined && outcome.error.code !== INVALID_PARAMS); +} + +/** JSON-RPC reserves -32768 to -32000; codes outside it are application-defined and unbounded. */ +function isReservedErrorCode(code: number): boolean { + return Number.isInteger(code) && code >= -32_768 && code <= -32_000; +} + +/** A thrown value that is not a protocol error (those carry a numeric `code`) and is worth an exception event. */ +function isUnexpectedException(cause: unknown): cause is Error { + return cause instanceof Error && typeof (cause as { code?: unknown }).code !== 'number'; +} + +/** A low-cardinality `error.type` for a local failure: an SDK or system error code, a standard error name, or `_OTHER`. */ +function errorTypeOf(error: unknown): string { + if (!(error instanceof Error)) return '_OTHER'; + const code = (error as { code?: unknown }).code; + if (typeof code === 'string' && /^[A-Z][A-Z0-9_]*$/.test(code)) return code; + return STANDARD_ERROR_NAMES.has(error.name) ? error.name : '_OTHER'; +} diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index 9d60be8efe..3707a19450 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -59,6 +59,8 @@ import { MODERN_WIRE_REVISION } from '../wire/codec'; import { manualInputRequiredValue, partitionInputResponses } from './inputRequiredEngine'; +import type { GuardedScope, ProtocolInstrumentation } from './instrumentation'; +import { outcomeForAbort, outcomeForError, runInScope, startScope, traceContextCarrier } from './instrumentation'; import type { Transport, TransportSendOptions } from './transport'; /** @@ -555,6 +557,16 @@ export function setNegotiatedProtocolVersion( writeNegotiatedProtocolVersion(instance, version); } +let writeInstrumentation: (instance: Protocol, instrumentation: ProtocolInstrumentation) => void; + +// Package-internal: `Client` and `Server` attach OpenTelemetry through this. Not public API. +export function setProtocolInstrumentation( + instance: Protocol, + instrumentation: ProtocolInstrumentation +): void { + writeInstrumentation(instance, instrumentation); +} + /** * Implements MCP protocol framing on top of a pluggable transport, including * features like request/response linking, notifications, and progress. @@ -587,8 +599,13 @@ export abstract class Protocol { writeNegotiatedProtocolVersion = (instance, version) => { instance._negotiatedProtocolVersion = version; }; + writeInstrumentation = (instance, instrumentation) => { + instance._instrumentation = instrumentation; + }; } + private _instrumentation?: ProtocolInstrumentation; + protected _supportedProtocolVersions: string[]; /** @@ -929,10 +946,22 @@ export abstract class Protocol { return; } + const scope = this._startInbound( + rawNotification, + this._notificationHandlers.has(notification.method) || isSpecNotificationMethod(notification.method), + extra + ); + // Starting with Promise.resolve() puts any synchronous errors into the monad as well. - Promise.resolve() - .then(() => (handler === undefined ? fallback!(notification) : handler(notification, codec))) - .catch(error => this._onerror(new Error(`Uncaught error in notification handler: ${error}`))); + runInScope(scope, () => + Promise.resolve().then(() => (handler === undefined ? fallback!(notification) : handler(notification, codec))) + ).then( + () => scope?.end({ type: 'delivered' }), + error => { + scope?.end({ type: 'failed', error }); + this._onerror(new Error(`Uncaught error in notification handler: ${error}`)); + } + ); } private _onrequest(rawRequest: JSONRPCRequest, extra?: MessageExtraInfo): void { @@ -961,7 +990,14 @@ export abstract class Protocol { // Capture the current transport at request time to ensure responses go to the correct client const capturedTransport = this._transport; + const scope = this._startInbound( + rawRequest, + this._requestHandlers.has(request.method) || isSpecRequestMethod(request.method), + extra + ); + const sendErrorResponse = (code: number, message: string, data?: unknown) => { + scope?.end({ type: 'error', error: { code, message } }); const errorResponse: JSONRPCErrorResponse = { jsonrpc: '2.0', id: request.id, @@ -1110,9 +1146,15 @@ export abstract class Protocol { }; const ctx = this.buildContext(baseCtx, extra); + if (scope !== undefined) { + abortController.signal.addEventListener('abort', () => scope.end(outcomeForAbort(abortController.signal.reason)), { + once: true + }); + } + // Starting with Promise.resolve() puts any synchronous errors into the monad as well. - Promise.resolve() - .then(() => handler(request, ctx)) + // Only the handler runs in the instrumentation's context; encoding and sending do not need it. + runInScope(scope, () => Promise.resolve().then(() => handler(request, ctx))) .then( async result => { if (abortController.signal.aborted) { @@ -1137,6 +1179,7 @@ export abstract class Protocol { return; } + scope?.end({ type: 'result', result: encoded }); const response: JSONRPCResponse = { result: encoded, jsonrpc: '2.0', @@ -1164,6 +1207,11 @@ export abstract class Protocol { ...(error['data'] !== undefined && { data: error['data'] }) } }; + scope?.end({ + type: 'error', + error: { code: errorResponse.error.code, message: errorResponse.error.message }, + cause: error + }); await capturedTransport?.send(errorResponse); } ) @@ -1458,15 +1506,16 @@ export abstract class Protocol { // both share the same `_meta`): a no-op on the legacy era — the // envelope seam returns undefined and the request goes out exactly as // built above. - const outbound = this._envelopeOutbound(jsonrpcRequest); + const { message: outbound, scope } = this._startOutbound(this._envelopeOutbound(jsonrpcRequest)); let responseReceived = false; - const cancel = (reason: unknown) => { + const cancel = (reason: unknown, outcome: 'timeout' | 'cancelled') => { if (responseReceived) { return; } this._progressHandlers.delete(messageId); + scope?.end({ type: outcome }); if (requestAbort === undefined) { // "A client MUST NOT attempt to cancel its `initialize` @@ -1478,19 +1527,17 @@ export abstract class Protocol { // legacy era can reach this — `initialize` is absent from the // modern registry, which negotiates via `server/discover`. if (request.method !== 'initialize') { - this._transport - ?.send( - this._envelopeOutbound({ - jsonrpc: '2.0', - method: 'notifications/cancelled', - params: { - requestId: messageId, - reason: String(reason) - } - }), - { relatedRequestId, resumptionToken, onresumptiontoken } - ) - .catch(error => this._onerror(new Error(`Failed to send cancellation: ${error}`))); + this._sendNotificationMessage( + this._envelopeOutbound({ + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { + requestId: messageId, + reason: String(reason) + } + }), + { relatedRequestId, resumptionToken, onresumptiontoken } + ).catch(error => this._onerror(new Error(`Failed to send cancellation: ${error}`))); } } else { // Modern-era per-request-stream transport: aborting the @@ -1513,6 +1560,7 @@ export abstract class Protocol { responseReceived = true; if (response instanceof Error) { + scope?.end(outcomeForError(response)); return reject(response); } @@ -1534,12 +1582,16 @@ export abstract class Protocol { try { decoded = codec.decodeResult(request.method, response.result); } catch (error) { - return reject(error instanceof Error ? error : new Error(String(error))); + const decodeError = error instanceof Error ? error : new Error(String(error)); + scope?.end({ type: 'failed', error: decodeError }); + return reject(decodeError); } if (decoded.kind === 'invalid') { + scope?.end({ type: 'failed', error: decoded.error }); return reject(decoded.error); } if (decoded.kind === 'input_required') { + scope?.end({ type: 'result', result: response.result }); // Manual mode (the primitive any driver layers over): // hand the input-required value back to the caller. if (options?.allowInputRequired === true) { @@ -1563,33 +1615,59 @@ export abstract class Protocol { legOptions ) }; - return resolve(this._resolveNonCompleteResult(decoded, flow) as Promise>); + // Embedded input requests are fulfilled in this leg's context, so their spans nest under it. + const inputRequired = decoded; + return resolve( + runInScope(scope, () => this._resolveNonCompleteResult(inputRequired, flow)) as Promise< + StandardSchemaV1.InferOutput + > + ); } const result = decoded.result; - validateStandardSchema(resultSchema, result).then(parseResult => { - if (parseResult.success) { - resolve(parseResult.data); - } else { - reject(new SdkError(SdkErrorCode.InvalidResult, `Invalid result for ${request.method}: ${parseResult.error}`)); + validateStandardSchema(resultSchema, result).then( + parseResult => { + if (parseResult.success) { + scope?.end({ type: 'result', result: response.result }); + resolve(parseResult.data); + } else { + const error = new SdkError( + SdkErrorCode.InvalidResult, + `Invalid result for ${request.method}: ${parseResult.error}` + ); + scope?.end({ type: 'failed', error }); + reject(error); + } + }, + error => { + scope?.end({ type: 'failed', error }); + reject(error); } - }, reject); + ); }); - onAbort = () => cancel(options?.signal?.reason); + onAbort = () => cancel(options?.signal?.reason, 'cancelled'); options?.signal?.addEventListener('abort', onAbort, { once: true }); const timeout = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC; - const timeoutHandler = () => cancel(new SdkError(SdkErrorCode.RequestTimeout, 'Request timed out', { timeout })); + const timeoutHandler = () => cancel(new SdkError(SdkErrorCode.RequestTimeout, 'Request timed out', { timeout }), 'timeout'); this._setupTimeout(messageId, timeout, options?.maxTotalTimeout, timeoutHandler, options?.resetTimeoutOnProgress ?? false); - this._transport - .send(outbound, { relatedRequestId, resumptionToken, onresumptiontoken, headers, requestSignal: requestAbort?.signal }) - .catch(error => { - this._progressHandlers.delete(messageId); - reject(error); - }); + const transport = this._transport; + runInScope(scope, () => + transport.send(outbound, { + relatedRequestId, + resumptionToken, + onresumptiontoken, + headers, + requestSignal: requestAbort?.signal + }) + ).catch(error => { + this._progressHandlers.delete(messageId); + scope?.end({ type: 'failed', error }); + reject(error); + }); }).finally(() => { // Per-request cleanup that must run on every exit path. Consolidated // here so new exit paths added to the promise body can't forget it. @@ -1667,14 +1745,81 @@ export abstract class Protocol { // Send the notification, but don't await it here to avoid blocking. // Handle potential errors with a .catch(). - this._transport?.send(jsonrpcNotification, options).catch(error => this._onerror(error)); + this._sendNotificationMessage(jsonrpcNotification, options).catch(error => this._onerror(error)); }); // Return immediately. return; } - await this._transport.send(jsonrpcNotification, options); + await this._sendNotificationMessage(jsonrpcNotification, options); + } + + /** Writes an outbound notification to the transport, observed by the instrumentation when one is configured. */ + private async _sendNotificationMessage(message: JSONRPCNotification, options?: TransportSendOptions): Promise { + const transport = this._transport; + if (!transport) return; + const { message: outbound, scope } = this._startOutbound(message); + if (scope === undefined) return transport.send(outbound, options); + try { + await scope.run(() => transport.send(outbound, options)); + } catch (error) { + scope.end({ type: 'failed', error }); + throw error; + } + scope.end({ type: 'delivered' }); + } + + /** Starts the instrumentation scope for an outbound message and merges its trace-context carrier into `params._meta`. */ + private _startOutbound(message: T): { message: T; scope: GuardedScope | undefined } { + const instrumentation = this._instrumentation; + if (instrumentation?.send === undefined) return { message, scope: undefined }; + const params = message.params; + const carrier = traceContextCarrier(params?._meta); + const scope = startScope( + () => + instrumentation.send?.({ + method: message.method, + ...('id' in message && { id: message.id }), + ...(params !== undefined && { params }), + ...this._operationSession(), + carrier + }), + error => this._onerror(error) + ); + if (Object.keys(carrier).length === 0) return { message, scope }; + return { message: { ...message, params: { ...params, _meta: { ...params?._meta, ...carrier } } }, scope }; + } + + /** Starts the instrumentation scope for an inbound request or notification. */ + private _startInbound( + message: JSONRPCRequest | JSONRPCNotification, + knownMethod: boolean, + extra?: MessageExtraInfo + ): GuardedScope | undefined { + const instrumentation = this._instrumentation; + if (instrumentation?.receive === undefined) return undefined; + const params = message.params; + const meta = params?._meta; + return startScope( + () => + instrumentation.receive?.({ + method: message.method, + ...('id' in message && { id: message.id }), + ...(params !== undefined && { params }), + ...(isPlainObject(meta) && { meta }), + ...this._operationSession(), + knownMethod, + ...(extra?.request !== undefined && { httpRequest: extra.request }) + }), + error => this._onerror(error) + ); + } + + private _operationSession(): { sessionId?: string; protocolVersion?: string } { + const sessionId = this._transport?.sessionId; + const protocolVersion = this._negotiatedProtocolVersion; + return { ...(sessionId !== undefined && { sessionId }), ...(protocolVersion !== undefined && { protocolVersion }) }; } /** diff --git a/packages/core-internal/test/shared/instrumentation.test.ts b/packages/core-internal/test/shared/instrumentation.test.ts new file mode 100644 index 0000000000..dce510b66e --- /dev/null +++ b/packages/core-internal/test/shared/instrumentation.test.ts @@ -0,0 +1,341 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod/v4'; + +import type { BaseContext, JSONRPCMessage } from '../../src/exports/public/index'; +import { isJSONRPCRequest, ProtocolError, ProtocolErrorCode, TRACEPARENT_META_KEY } from '../../src/exports/public/index'; +import type { InboundOperation, OperationOutcome, OutboundOperation, ProtocolInstrumentation } from '../../src/shared/instrumentation'; +import { Protocol, setProtocolInstrumentation } from '../../src/shared/protocol'; +import { InMemoryTransport } from '../../src/util/inMemory'; + +class TestProtocol extends Protocol { + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} +} + +type Sent = { direction: 'send'; operation: OutboundOperation; outcomes: OperationOutcome[] }; +type Received = { direction: 'receive'; operation: InboundOperation; outcomes: OperationOutcome[] }; +type Recorded = Sent | Received; + +const TRACEPARENT = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'; +const INJECTED = '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01'; + +/** Records every scope, injects `INJECTED`, and runs work inside `als` so tests can see the active scope. */ +function recorder(als = new AsyncLocalStorage()) { + const scopes: Recorded[] = []; + const instrumentation: ProtocolInstrumentation = { + send(operation) { + const entry: Sent = { direction: 'send', operation, outcomes: [] }; + scopes.push(entry); + operation.carrier[TRACEPARENT_META_KEY] = INJECTED; + return { run: fn => als.run(`send ${operation.method}`, fn), end: outcome => entry.outcomes.push(outcome) }; + }, + receive(operation) { + const entry: Received = { direction: 'receive', operation, outcomes: [] }; + scopes.push(entry); + return { run: fn => als.run(`receive ${operation.method}`, fn), end: outcome => entry.outcomes.push(outcome) }; + } + }; + function find(direction: 'send', method: string): Sent | undefined; + function find(direction: 'receive', method: string): Received | undefined; + function find(direction: Recorded['direction'], method: string): Recorded | undefined { + return scopes.find(s => s.direction === direction && s.operation.method === method); + } + return { scopes, instrumentation, als, find }; +} + +async function pair(instrumentation?: { a?: ProtocolInstrumentation; b?: ProtocolInstrumentation }) { + const [ta, tb] = InMemoryTransport.createLinkedPair(); + const a = new TestProtocol(); + const b = new TestProtocol(); + if (instrumentation?.a) setProtocolInstrumentation(a, instrumentation.a); + if (instrumentation?.b) setProtocolInstrumentation(b, instrumentation.b); + await a.connect(ta); + await b.connect(tb); + return { a, b, ta, tb }; +} + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)); +const Ok = z.object({ ok: z.boolean() }); + +describe('Protocol instrumentation', () => { + it('leaves outbound messages untouched when no instrumentation is configured', async () => { + const [ta, tb] = InMemoryTransport.createLinkedPair(); + const sent: JSONRPCMessage[] = []; + tb.onmessage = message => { + sent.push(message); + if (isJSONRPCRequest(message)) void tb.send({ jsonrpc: '2.0', id: message.id, result: { ok: true } }); + }; + await tb.start(); + const a = new TestProtocol(); + await a.connect(ta); + + await a.request({ method: 'acme/echo', params: { v: 1 } }, Ok); + await a.notification({ method: 'acme/event', params: { v: 2 } }); + + expect(sent).toEqual([ + { jsonrpc: '2.0', id: 0, method: 'acme/echo', params: { v: 1 } }, + { jsonrpc: '2.0', method: 'acme/event', params: { v: 2 } } + ]); + }); + + it('observes a request on both sides and carries the injected trace context to the peer', async () => { + const client = recorder(); + const server = recorder(client.als); + const { a, b } = await pair({ a: client.instrumentation, b: server.instrumentation }); + let activeInHandler: string | undefined; + let metaInHandler: unknown; + b.setRequestHandler('acme/echo', { params: z.object({ v: z.number() }) }, async (_params, ctx) => { + await tick(); + activeInHandler = server.als.getStore(); + metaInHandler = ctx.mcpReq._meta; + return { ok: true }; + }); + + await a.request({ method: 'acme/echo', params: { v: 1 } }, Ok); + + const sent = client.find('send', 'acme/echo')!; + expect(sent.operation).toMatchObject({ method: 'acme/echo', id: 0, params: { v: 1 } }); + expect(sent.outcomes).toEqual([{ type: 'result', result: { ok: true } }]); + + const received = server.find('receive', 'acme/echo')!; + expect(received.operation).toMatchObject({ method: 'acme/echo', id: 0, knownMethod: true, meta: { traceparent: INJECTED } }); + expect(received.outcomes).toEqual([{ type: 'result', result: { ok: true } }]); + expect(metaInHandler).toEqual({ traceparent: INJECTED }); + expect(activeInHandler).toBe('receive acme/echo'); + }); + + it('prefills the carrier with caller-supplied trace context without mutating the caller params', async () => { + const client = recorder(); + const { a, b } = await pair({ a: client.instrumentation }); + let metaInHandler: unknown; + b.setRequestHandler('acme/echo', { params: z.object({}) }, (_params, ctx) => { + metaInHandler = ctx.mcpReq._meta; + return { ok: true }; + }); + const params = { _meta: { [TRACEPARENT_META_KEY]: TRACEPARENT, other: 'kept' } }; + let prefilled: Record | undefined; + const original = client.instrumentation.send!; + client.instrumentation.send = operation => { + prefilled = { ...operation.carrier }; + return original(operation); + }; + + await a.request({ method: 'acme/echo', params }, Ok); + + expect(prefilled).toEqual({ traceparent: TRACEPARENT }); + expect(metaInHandler).toEqual({ traceparent: INJECTED, other: 'kept' }); + expect(params._meta.traceparent).toBe(TRACEPARENT); + }); + + it('runs the transport send in the outbound scope', async () => { + const client = recorder(); + const { a, b, ta } = await pair({ a: client.instrumentation }); + b.setRequestHandler('acme/echo', { params: z.object({}) }, () => ({ ok: true })); + const activeAtSend: (string | undefined)[] = []; + const send = ta.send.bind(ta); + ta.send = (message, options) => { + activeAtSend.push(client.als.getStore()); + return send(message, options); + }; + + await a.request({ method: 'acme/echo', params: {} }, Ok); + await a.notification({ method: 'acme/event' }); + + expect(activeAtSend).toEqual(['send acme/echo', 'send acme/event']); + }); + + it('ends both sides with the JSON-RPC error a handler throws', async () => { + const client = recorder(); + const server = recorder(); + const { a, b } = await pair({ a: client.instrumentation, b: server.instrumentation }); + const thrown = new ProtocolError(ProtocolErrorCode.InvalidParams, 'bad input'); + b.setRequestHandler('acme/fail', { params: z.object({}) }, () => { + throw thrown; + }); + + await expect(a.request({ method: 'acme/fail', params: {} }, Ok)).rejects.toThrow('bad input'); + + expect(server.find('receive', 'acme/fail')!.outcomes).toEqual([ + { type: 'error', error: { code: ProtocolErrorCode.InvalidParams, message: 'bad input' }, cause: thrown } + ]); + expect(client.find('send', 'acme/fail')!.outcomes).toMatchObject([ + { type: 'error', error: { code: ProtocolErrorCode.InvalidParams, message: 'bad input' } } + ]); + }); + + it('reports an unknown method with knownMethod: false and a method-not-found error', async () => { + const server = recorder(); + const { a } = await pair({ b: server.instrumentation }); + + await expect(a.request({ method: 'acme/missing', params: {} }, Ok)).rejects.toThrow(); + + const received = server.find('receive', 'acme/missing')!; + expect(received.operation.knownMethod).toBe(false); + expect(received.outcomes).toEqual([ + { type: 'error', error: { code: ProtocolErrorCode.MethodNotFound, message: 'Method not found' } } + ]); + }); + + it('ends a timed-out request with timeout and observes the cancellation notification', async () => { + const client = recorder(); + const server = recorder(); + const { a, b } = await pair({ a: client.instrumentation, b: server.instrumentation }); + b.setRequestHandler( + 'acme/slow', + { params: z.object({}) }, + (_params, ctx) => + new Promise<{ ok: boolean }>(resolve => ctx.mcpReq.signal.addEventListener('abort', () => resolve({ ok: true }))) + ); + + await expect(a.request({ method: 'acme/slow', params: {} }, Ok, { timeout: 10 })).rejects.toThrow(); + await tick(); + + expect(client.find('send', 'acme/slow')!.outcomes).toEqual([{ type: 'timeout' }]); + expect(client.find('send', 'notifications/cancelled')!.outcomes).toEqual([{ type: 'delivered' }]); + expect(server.find('receive', 'acme/slow')!.outcomes).toEqual([{ type: 'cancelled' }]); + }); + + it('ends a request aborted by the caller with cancelled', async () => { + const client = recorder(); + const { a, b } = await pair({ a: client.instrumentation }); + b.setRequestHandler('acme/slow', { params: z.object({}) }, () => new Promise(() => {})); + const controller = new AbortController(); + + const pending = a.request({ method: 'acme/slow', params: {} }, Ok, { signal: controller.signal }); + controller.abort('changed my mind'); + await expect(pending).rejects.toThrow(); + + expect(client.find('send', 'acme/slow')!.outcomes).toEqual([{ type: 'cancelled' }]); + }); + + it('ends in-flight operations on both sides with closed when the connection drops', async () => { + const client = recorder(); + const server = recorder(); + const { a, b } = await pair({ a: client.instrumentation, b: server.instrumentation }); + let started!: () => void; + const handlerStarted = new Promise(resolve => (started = resolve)); + b.setRequestHandler('acme/slow', { params: z.object({}) }, () => { + started(); + return new Promise(() => {}); + }); + + const pending = a.request({ method: 'acme/slow', params: {} }, Ok); + await handlerStarted; + await a.close(); + await expect(pending).rejects.toThrow('Connection closed'); + + expect(client.find('send', 'acme/slow')!.outcomes).toEqual([{ type: 'closed' }]); + expect(server.find('receive', 'acme/slow')!.outcomes).toEqual([{ type: 'closed' }]); + }); + + it('ends a result that fails validation with failed', async () => { + const client = recorder(); + const { a, b } = await pair({ a: client.instrumentation }); + b.setRequestHandler('acme/echo', { params: z.object({}) }, () => ({ ok: 'not a boolean' })); + + await expect(a.request({ method: 'acme/echo', params: {} }, Ok)).rejects.toThrow('Invalid result'); + + expect(client.find('send', 'acme/echo')!.outcomes).toMatchObject([{ type: 'failed' }]); + }); + + it('ends a failed send with failed', async () => { + const client = recorder(); + const { a, ta } = await pair({ a: client.instrumentation }); + const failure = new Error('socket gone'); + ta.send = () => Promise.reject(failure); + + await expect(a.request({ method: 'acme/echo', params: {} }, Ok)).rejects.toBe(failure); + await expect(a.notification({ method: 'acme/event' })).rejects.toBe(failure); + + expect(client.find('send', 'acme/echo')!.outcomes).toEqual([{ type: 'failed', error: failure }]); + expect(client.find('send', 'acme/event')!.outcomes).toEqual([{ type: 'failed', error: failure }]); + }); + + it('observes notifications on both sides, including a handler that throws', async () => { + const client = recorder(); + const server = recorder(); + const { a, b } = await pair({ a: client.instrumentation, b: server.instrumentation }); + const errors: Error[] = []; + b.onerror = error => errors.push(error); + let activeInHandler: string | undefined; + b.setNotificationHandler('acme/event', { params: z.object({}) }, () => { + activeInHandler = server.als.getStore(); + }); + const thrown = new Error('handler broke'); + b.setNotificationHandler('acme/broken', { params: z.object({}) }, () => { + throw thrown; + }); + + await a.notification({ method: 'acme/event', params: {} }); + await a.notification({ method: 'acme/broken', params: {} }); + await tick(); + + expect(client.find('send', 'acme/event')!.outcomes).toEqual([{ type: 'delivered' }]); + const received = server.find('receive', 'acme/event')!; + expect(received.operation).toMatchObject({ method: 'acme/event', knownMethod: true, meta: { traceparent: INJECTED } }); + expect(received.operation.id).toBeUndefined(); + expect(received.outcomes).toEqual([{ type: 'delivered' }]); + expect(activeInHandler).toBe('receive acme/event'); + expect(server.find('receive', 'acme/broken')!.outcomes).toEqual([{ type: 'failed', error: thrown }]); + expect(errors.map(e => e.message)).toEqual(['Uncaught error in notification handler: Error: handler broke']); + }); + + it('never lets a throwing instrumentation change the outcome', async () => { + const errors: Error[] = []; + const { a, b } = await pair({ + a: { + send: () => { + throw new Error('send hook broke'); + } + }, + b: { + receive: () => ({ + run: () => { + throw new Error('run broke'); + }, + end: () => { + throw new Error('end broke'); + } + }) + } + }); + a.onerror = error => errors.push(error); + b.onerror = error => errors.push(error); + let calls = 0; + b.setRequestHandler('acme/echo', { params: z.object({}) }, () => { + calls++; + return { ok: true }; + }); + + await expect(a.request({ method: 'acme/echo', params: {} }, Ok)).resolves.toEqual({ ok: true }); + + expect(calls).toBe(1); + expect(errors.map(e => e.message)).toEqual([ + 'Instrumentation failed: send hook broke', + 'Instrumentation failed: run broke', + 'Instrumentation failed: end broke' + ]); + }); + + it('runs the work itself when scope.run never calls it', async () => { + const errors: Error[] = []; + const { a, b } = await pair({ b: { receive: () => ({ run: () => undefined as never, end: () => {} }) } }); + b.onerror = error => errors.push(error); + let calls = 0; + b.setRequestHandler('acme/echo', { params: z.object({}) }, () => { + calls++; + return { ok: true }; + }); + + await expect(a.request({ method: 'acme/echo', params: {} }, Ok)).resolves.toEqual({ ok: true }); + + expect(calls).toBe(1); + expect(errors.map(e => e.message)).toEqual(['Instrumentation failed: scope.run() did not call fn']); + }); +}); diff --git a/packages/server/package.json b/packages/server/package.json index e147d8b516..b61acddc68 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -162,7 +162,11 @@ "@modelcontextprotocol/core": "workspace:*", "zod": "catalog:runtimeShared" }, + "peerDependencies": { + "@opentelemetry/api": "catalog:otel" + }, "devDependencies": { + "@opentelemetry/api": "catalog:otel", "@cfworker/json-schema": "catalog:runtimeShared", "ajv": "catalog:runtimeShared", "ajv-formats": "catalog:runtimeShared", diff --git a/packages/server/src/server/openTelemetry.ts b/packages/server/src/server/openTelemetry.ts new file mode 100644 index 0000000000..afc6beee7d --- /dev/null +++ b/packages/server/src/server/openTelemetry.ts @@ -0,0 +1,9 @@ +import type { ProtocolInstrumentation } from '@modelcontextprotocol/core-internal'; +import { openTelemetryInstrumentation } from '@modelcontextprotocol/core-internal'; + +let shared: ProtocolInstrumentation | undefined; + +/** OpenTelemetry instrumentation every `Server` attaches; created on first use and shared. */ +export function defaultInstrumentation(): ProtocolInstrumentation { + return (shared ??= openTelemetryInstrumentation('@modelcontextprotocol/server')); +} diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 441857f646..914f0f0195 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -60,12 +60,14 @@ import { ProtocolErrorCode, SdkError, SdkErrorCode, + setProtocolInstrumentation, SUPPORTED_MODERN_PROTOCOL_VERSIONS, withRequestStateValue } from '@modelcontextprotocol/core-internal'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; import { coerceEmbeddedInputRequest, LegacyInputRequiredShim, resolveLegacyShimOptions } from './legacyInputRequiredShim'; +import { defaultInstrumentation } from './openTelemetry'; import { perRequestClassificationOf } from './perRequestTransport'; /** @@ -320,6 +322,7 @@ export class Server extends Protocol { options?: ServerOptions ) { super(options); + setProtocolInstrumentation(this, defaultInstrumentation()); this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; this._instructions = options?.instructions; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea66e0cb5f..a095e50e80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,6 +81,22 @@ catalogs: wrangler: specifier: ^4.14.4 version: 4.78.0 + otel: + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.10.0 + '@opentelemetry/context-async-hooks': + specifier: ^3.0.0 + version: 3.0.0 + '@opentelemetry/core': + specifier: ^3.0.0 + version: 3.0.0 + '@opentelemetry/sdk-metrics': + specifier: ^3.0.0 + version: 3.0.0 + '@opentelemetry/sdk-trace': + specifier: ^3.0.0 + version: 3.0.0 runtimeClientOnly: cross-spawn: specifier: ^7.0.5 @@ -238,10 +254,10 @@ importers: version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) vitepress: specifier: ^1.6.4 - version: 1.6.4(@algolia/client-search@5.55.0)(@types/node@24.12.0)(change-case@5.4.4)(postcss@8.5.15)(search-insights@2.17.3)(typescript@5.9.3) + version: 1.6.4(@algolia/client-search@5.55.0)(@types/node@24.12.0)(change-case@5.4.4)(postcss@8.5.31)(search-insights@2.17.3)(typescript@5.9.3) vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(vite@7.3.0(@types/node@24.12.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@24.12.0)(vite@7.3.0(@types/node@24.12.0)(tsx@4.21.0)(yaml@2.8.3)) zod: specifier: catalog:runtimeShared version: 4.3.6 @@ -334,6 +350,21 @@ importers: '@modelcontextprotocol/server-legacy': specifier: workspace:^ version: link:../packages/server-legacy + '@opentelemetry/api': + specifier: catalog:otel + version: 1.10.0 + '@opentelemetry/context-async-hooks': + specifier: catalog:otel + version: 3.0.0(@opentelemetry/api@1.10.0) + '@opentelemetry/core': + specifier: catalog:otel + version: 3.0.0(@opentelemetry/api@1.10.0) + '@opentelemetry/sdk-metrics': + specifier: catalog:otel + version: 3.0.0(@opentelemetry/api@1.10.0) + '@opentelemetry/sdk-trace': + specifier: catalog:otel + version: 3.0.0(@opentelemetry/api@1.10.0) '@valibot/to-json-schema': specifier: catalog:devTools version: 1.6.0(valibot@1.3.1(typescript@5.9.3)) @@ -479,7 +510,7 @@ importers: version: 4.21.0 vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) examples/client-quickstart: dependencies: @@ -806,6 +837,46 @@ importers: specifier: catalog:devTools version: 4.21.0 + examples/opentelemetry: + dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 2.1.5(hono@4.12.9) + '@mcp-examples/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/client': + specifier: workspace:* + version: link:../../packages/client + '@modelcontextprotocol/hono': + specifier: workspace:* + version: link:../../packages/middleware/hono + '@modelcontextprotocol/server': + specifier: workspace:* + version: link:../../packages/server + '@opentelemetry/api': + specifier: catalog:otel + version: 1.10.0 + '@opentelemetry/context-async-hooks': + specifier: catalog:otel + version: 3.0.0(@opentelemetry/api@1.10.0) + '@opentelemetry/core': + specifier: catalog:otel + version: 3.0.0(@opentelemetry/api@1.10.0) + '@opentelemetry/sdk-metrics': + specifier: catalog:otel + version: 3.0.0(@opentelemetry/api@1.10.0) + '@opentelemetry/sdk-trace': + specifier: catalog:otel + version: 3.0.0(@opentelemetry/api@1.10.0) + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + tsx: + specifier: catalog:devTools + version: 4.21.0 + examples/parallel-calls: dependencies: '@hono/node-server': @@ -1028,7 +1099,7 @@ importers: version: link:../../packages/server better-auth: specifier: ^1.6.2 - version: 1.6.20(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)))(vue@3.5.38(typescript@5.9.3)) + version: 1.6.20(@opentelemetry/api@1.10.0)(better-sqlite3@12.8.0)(vitest@4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)))(vue@3.5.38(typescript@5.9.3)) better-sqlite3: specifier: ^12.6.2 version: 12.8.0 @@ -1086,7 +1157,7 @@ importers: version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) examples/skills: dependencies: @@ -1358,6 +1429,9 @@ importers: '@modelcontextprotocol/vitest-config': specifier: workspace:^ version: link:../../common/vitest-config + '@opentelemetry/api': + specifier: catalog:otel + version: 1.10.0 '@types/content-type': specifier: catalog:devTools version: 1.1.9 @@ -1399,7 +1473,7 @@ importers: version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) packages/codemod: dependencies: @@ -1454,7 +1528,7 @@ importers: version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) packages/core: dependencies: @@ -1500,13 +1574,16 @@ importers: version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) packages/core-internal: dependencies: '@modelcontextprotocol/core': specifier: workspace:* version: link:../core + '@opentelemetry/api': + specifier: catalog:otel + version: 1.10.0 content-type: specifier: catalog:runtimeShared version: 1.0.5 @@ -1579,7 +1656,7 @@ importers: version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) yaml: specifier: ^2.8.3 version: 2.8.3 @@ -1649,7 +1726,7 @@ importers: version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) packages/middleware/fastify: dependencies: @@ -1698,7 +1775,7 @@ importers: version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) packages/middleware/hono: dependencies: @@ -1747,7 +1824,7 @@ importers: version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) packages/middleware/node: dependencies: @@ -1805,7 +1882,7 @@ importers: version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) packages/server: dependencies: @@ -1837,6 +1914,9 @@ importers: '@modelcontextprotocol/vitest-config': specifier: workspace:^ version: link:../../common/vitest-config + '@opentelemetry/api': + specifier: catalog:otel + version: 1.10.0 '@types/eventsource': specifier: catalog:devTools version: 1.1.15 @@ -1875,7 +1955,7 @@ importers: version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) packages/server-legacy: dependencies: @@ -1963,7 +2043,7 @@ importers: version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) test/conformance: devDependencies: @@ -2054,6 +2134,18 @@ importers: '@modelcontextprotocol/vitest-config': specifier: workspace:^ version: link:../../common/vitest-config + '@opentelemetry/api': + specifier: catalog:otel + version: 1.10.0 + '@opentelemetry/context-async-hooks': + specifier: catalog:otel + version: 3.0.0(@opentelemetry/api@1.10.0) + '@opentelemetry/core': + specifier: catalog:otel + version: 3.0.0(@opentelemetry/api@1.10.0) + '@opentelemetry/sdk-trace': + specifier: catalog:otel + version: 3.0.0(@opentelemetry/api@1.10.0) '@types/cors': specifier: catalog:devTools version: 2.8.19 @@ -2092,7 +2184,7 @@ importers: version: 1.3.1(typescript@5.9.3) vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) zod: specifier: catalog:runtimeShared version: 4.3.6 @@ -2116,7 +2208,7 @@ importers: version: 7.0.0-dev.20260327.2 vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) zod: specifier: catalog:runtimeShared version: 4.3.6 @@ -2153,6 +2245,21 @@ importers: '@modelcontextprotocol/vitest-config': specifier: workspace:^ version: link:../../common/vitest-config + '@opentelemetry/api': + specifier: catalog:otel + version: 1.10.0 + '@opentelemetry/context-async-hooks': + specifier: catalog:otel + version: 3.0.0(@opentelemetry/api@1.10.0) + '@opentelemetry/core': + specifier: catalog:otel + version: 3.0.0(@opentelemetry/api@1.10.0) + '@opentelemetry/sdk-metrics': + specifier: catalog:otel + version: 3.0.0(@opentelemetry/api@1.10.0) + '@opentelemetry/sdk-trace': + specifier: catalog:otel + version: 3.0.0(@opentelemetry/api@1.10.0) '@typescript/native-preview': specifier: catalog:devTools version: 7.0.0-dev.20260327.2 @@ -2170,7 +2277,7 @@ importers: version: 1.3.1(typescript@5.9.3) vitest: specifier: catalog:devTools - version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) wrangler: specifier: catalog:devTools version: 4.78.0 @@ -2553,6 +2660,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.21.5': resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} engines: {node: '>=12'} @@ -2571,6 +2684,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.21.5': resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} engines: {node: '>=12'} @@ -2589,6 +2708,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.21.5': resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} engines: {node: '>=12'} @@ -2607,6 +2732,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.21.5': resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} engines: {node: '>=12'} @@ -2625,6 +2756,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.21.5': resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} engines: {node: '>=12'} @@ -2643,6 +2780,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} engines: {node: '>=12'} @@ -2661,6 +2804,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} engines: {node: '>=12'} @@ -2679,6 +2828,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.21.5': resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} engines: {node: '>=12'} @@ -2697,6 +2852,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.21.5': resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} engines: {node: '>=12'} @@ -2715,6 +2876,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.21.5': resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} engines: {node: '>=12'} @@ -2733,6 +2900,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.21.5': resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} engines: {node: '>=12'} @@ -2751,6 +2924,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.21.5': resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} engines: {node: '>=12'} @@ -2769,6 +2948,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.21.5': resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} engines: {node: '>=12'} @@ -2787,6 +2972,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.21.5': resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} engines: {node: '>=12'} @@ -2805,6 +2996,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.21.5': resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} engines: {node: '>=12'} @@ -2823,6 +3020,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.21.5': resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} engines: {node: '>=12'} @@ -2841,6 +3044,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.27.3': resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} engines: {node: '>=18'} @@ -2853,6 +3062,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} engines: {node: '>=12'} @@ -2871,6 +3086,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.27.3': resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} engines: {node: '>=18'} @@ -2883,6 +3104,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} engines: {node: '>=12'} @@ -2901,6 +3128,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.27.3': resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} engines: {node: '>=18'} @@ -2913,6 +3146,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.21.5': resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} engines: {node: '>=12'} @@ -2931,6 +3170,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.21.5': resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} engines: {node: '>=12'} @@ -2949,6 +3194,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.21.5': resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} engines: {node: '>=12'} @@ -2967,6 +3218,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.21.5': resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} engines: {node: '>=12'} @@ -2985,6 +3242,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -3285,6 +3548,13 @@ packages: '@cfworker/json-schema': optional: true + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -3367,10 +3637,40 @@ packages: '@octokit/types@16.0.0': resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} - '@opentelemetry/api@1.9.1': - resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + '@opentelemetry/api@1.10.0': + resolution: {integrity: sha512-lMkyes8iHrG+mk6WH7X70MdCLI2RS0FwNBY1PyQ8UemaijE2m5mv5A0Niy3HtbYTFrgkUEQjFMXmSAk2hQDuPQ==} engines: {node: '>=8.0.0'} + '@opentelemetry/context-async-hooks@3.0.0': + resolution: {integrity: sha512-qDZeKSgTT+qTaL99eRbpvWO5+oZ0MKvMH4pqvzi6xYKyhiMTDBxBeTLI6BpdeMt58T5TpxHJvFl9P52i6CDWGA==} + engines: {node: '>=22.15.0'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.11.0' + + '@opentelemetry/core@3.0.0': + resolution: {integrity: sha512-whOLlww0R/RLtCWGBXhK1fdvL9t2/TL0fqdjZvcaoUUuNilRPjXecqQdzyVwV68s6XEDA5oYbm7/bZT62GXF4A==} + engines: {node: '>=22.15.0'} + peerDependencies: + '@opentelemetry/api': '>=1.10.0 <1.11.0' + + '@opentelemetry/resources@3.0.0': + resolution: {integrity: sha512-4762KVmlDroRIt+4Ra8AnwcQqdXlkAR55jDrhzna8bzhJx1mUzvHW/Lh/OemFuY9+ouM4xaDc6CbUmoJmM6BFg==} + engines: {node: '>=22.15.0'} + peerDependencies: + '@opentelemetry/api': '>=1.10.0 <1.11.0' + + '@opentelemetry/sdk-metrics@3.0.0': + resolution: {integrity: sha512-YozyS2rIlizvUNLZwcWxH0x2FhX02/mcZwzM8hsl8yvQk+xeKmAc7vcSieWiO4oSwIB20B8eS/zwdopAd/J5zQ==} + engines: {node: '>=22.15.0'} + peerDependencies: + '@opentelemetry/api': '>=1.10.0 <1.11.0' + + '@opentelemetry/sdk-trace@3.0.0': + resolution: {integrity: sha512-4T0DbDGdtsjNmYK5/lNalHro4fsMZ0UZ3JE+C8fcqYP25xaJC22FHg8kdJNJ21srjJY5XoT7WNFCtj88NNTl4Q==} + engines: {node: '>=22.15.0'} + peerDependencies: + '@opentelemetry/api': '>=1.10.0 <1.11.0' + '@opentelemetry/semantic-conventions@1.40.0': resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} engines: {node: '>=14'} @@ -3613,139 +3913,277 @@ packages: cpu: [arm] os: [android] + '@rollup/rollup-android-arm-eabi@4.64.12': + resolution: {integrity: sha512-Z8lz+5UOiCJLonjeqwJlhUtHA/Sr6R1AidkccQxdWF0nigNzP81aLoU+udgOaGfL5s1uzFXzcux/pCwGAMu0lw==} + cpu: [arm] + os: [android] + '@rollup/rollup-android-arm64@4.60.0': resolution: {integrity: sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==} cpu: [arm64] os: [android] + '@rollup/rollup-android-arm64@4.64.12': + resolution: {integrity: sha512-+tKxNa2psFmiTb0KfXlXs4Yj7vkEhzAvZ0DrQ5+0gWHQJM10jSleQ3l1FIVm5uvD53YM8lHfM4mgCLDDN9f1Gg==} + cpu: [arm64] + os: [android] + '@rollup/rollup-darwin-arm64@4.60.0': resolution: {integrity: sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==} cpu: [arm64] os: [darwin] + '@rollup/rollup-darwin-arm64@4.64.12': + resolution: {integrity: sha512-iiripdfvyRBugV55/Dk7GplNXQtN/PG85OcKeveU1z2UkTg0MofbBF1qk//qgPWiqJbVvQcXAVCfE56oDGO4EQ==} + cpu: [arm64] + os: [darwin] + '@rollup/rollup-darwin-x64@4.60.0': resolution: {integrity: sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==} cpu: [x64] os: [darwin] + '@rollup/rollup-darwin-x64@4.64.12': + resolution: {integrity: sha512-BmATib8swSMAagWNIfDZMnb+k34RXR4bbGOUfZgK8lLlniuMyVoSDDXCOGkdhtUmBXL4aygDh7bhjQ+TmhTvIw==} + cpu: [x64] + os: [darwin] + '@rollup/rollup-freebsd-arm64@4.60.0': resolution: {integrity: sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==} cpu: [arm64] os: [freebsd] + '@rollup/rollup-freebsd-arm64@4.64.12': + resolution: {integrity: sha512-GzrO0CiUq8vcAI/hYJuIcp6wNm3T3YhOpumYtf9Z3Fz+cfN1VESCVfy7EPcM3/m2K8l+Gn2U+4XtNZjwhUXctQ==} + cpu: [arm64] + os: [freebsd] + '@rollup/rollup-freebsd-x64@4.60.0': resolution: {integrity: sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==} cpu: [x64] os: [freebsd] + '@rollup/rollup-freebsd-x64@4.64.12': + resolution: {integrity: sha512-Dpta6gigm4o6SYioXtTkZBlGLA/Sl6OpzgLqY+51uafJE2blPejAtR1w82CVLPN+F9cvo1+/em92x/zcOgY0uQ==} + cpu: [x64] + os: [freebsd] + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} cpu: [arm] os: [linux] libc: [glibc] + '@rollup/rollup-linux-arm-gnueabihf@4.64.12': + resolution: {integrity: sha512-YpeDwQ5uFaogRvytNC8RK0keDO65kI5wNEhGSs+4GiJc3KHhxj4j5cnQh2nCu9ybJj6RP0/ycB6bkN0yFJ9I+w==} + cpu: [arm] + os: [linux] + libc: [glibc] + '@rollup/rollup-linux-arm-musleabihf@4.60.0': resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} cpu: [arm] os: [linux] libc: [musl] + '@rollup/rollup-linux-arm-musleabihf@4.64.12': + resolution: {integrity: sha512-LQrB3/sImIr1ycrlEyaApOLH1rAy8JV4/dH4eoK817aWATdcs4fESXz53/w72NwDnwPM5zEUpoL7socH4ZDnwA==} + cpu: [arm] + os: [linux] + libc: [musl] + '@rollup/rollup-linux-arm64-gnu@4.60.0': resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} cpu: [arm64] os: [linux] libc: [glibc] + '@rollup/rollup-linux-arm64-gnu@4.64.12': + resolution: {integrity: sha512-iAF2iYu9S4SBfNsb4k0ewNBjO83WxHEltIuKQBGMzEKFgrYfmJn+gatBDXR2t/0zJN0UW0kzR4rfvQ1UHzkZ1A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@rollup/rollup-linux-arm64-musl@4.60.0': resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} cpu: [arm64] os: [linux] libc: [musl] + '@rollup/rollup-linux-arm64-musl@4.64.12': + resolution: {integrity: sha512-0MQdRtdbirJCXPIVeW/hd/LXLR8Qz96pc+2EBptuuV5q/zSGOzND7SZt8gBTE58NC9cUG/8UxcUXQiKRZ2WzzA==} + cpu: [arm64] + os: [linux] + libc: [musl] + '@rollup/rollup-linux-loong64-gnu@4.60.0': resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} cpu: [loong64] os: [linux] libc: [glibc] + '@rollup/rollup-linux-loong64-gnu@4.64.12': + resolution: {integrity: sha512-yGDEwn0AGnrY/KCz2oY8R9KKNiQnTcwNuCWXeSXF0VLxXzlqoCgaS5QHsf1lbQvKWwPuN2Czkjk1wXRCBONTOQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + '@rollup/rollup-linux-loong64-musl@4.60.0': resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} cpu: [loong64] os: [linux] libc: [musl] + '@rollup/rollup-linux-loong64-musl@4.64.12': + resolution: {integrity: sha512-AjlbD0efZEhHaJ3bsNjxSFDoguNq6VZvC+FKGJGBCA+ZN7BrZq2TuuSWFa6AI8IpOeuvhp2LoYcP7jiZwmtU0Q==} + cpu: [loong64] + os: [linux] + libc: [musl] + '@rollup/rollup-linux-ppc64-gnu@4.60.0': resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} cpu: [ppc64] os: [linux] libc: [glibc] + '@rollup/rollup-linux-ppc64-gnu@4.64.12': + resolution: {integrity: sha512-sDW0CiPoUCEEUV25uBG5sJ2gKe75DJqLpj91m1eK1vKffywyY7v4i6q1450ZuR6QW4NArXg7JpbMGgKU8di3HQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@rollup/rollup-linux-ppc64-musl@4.60.0': resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} cpu: [ppc64] os: [linux] libc: [musl] + '@rollup/rollup-linux-ppc64-musl@4.64.12': + resolution: {integrity: sha512-VTQYUhMjl8kv999VuYqScgv7P9cR9KuxI/6bkUsEa3bsswqG0+otFjuLZmJ1U+ffEP4IAz0jxOeV0DX6NRBhjg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + '@rollup/rollup-linux-riscv64-gnu@4.60.0': resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} cpu: [riscv64] os: [linux] libc: [glibc] + '@rollup/rollup-linux-riscv64-gnu@4.64.12': + resolution: {integrity: sha512-ppsKwpSjNrzJ8hge3Z3uiCcoRgAAQk2tU7auy3MFF348ffWRldFPnWIT/a/enK7huijlTfnwn4DYpDoapEb45A==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@rollup/rollup-linux-riscv64-musl@4.60.0': resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} cpu: [riscv64] os: [linux] libc: [musl] + '@rollup/rollup-linux-riscv64-musl@4.64.12': + resolution: {integrity: sha512-Gc3cVRC1trFH+GFWWTsJz6YuD4u75fzr/q99jqQGimonN/zvN6Wecck5WGzllOHj+XYXHcq0Ibor0tanGjfI6A==} + cpu: [riscv64] + os: [linux] + libc: [musl] + '@rollup/rollup-linux-s390x-gnu@4.60.0': resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} cpu: [s390x] os: [linux] libc: [glibc] + '@rollup/rollup-linux-s390x-gnu@4.64.12': + resolution: {integrity: sha512-pjmDTFStvwFE3shTeUDvSDiSp8/qAf5bQXejcEUDDavWz4jEhTIpCdpS06G0PEwSCSNK0Lngjz2v9l4hk3Z74A==} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@rollup/rollup-linux-x64-gnu@4.60.0': resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} cpu: [x64] os: [linux] libc: [glibc] + '@rollup/rollup-linux-x64-gnu@4.64.12': + resolution: {integrity: sha512-mkbjxPqpR91u1JD7xY4aLZhr/Mqka4/RqGcG4WNGgN/lC1o7TzyCyqJoSJXp9qVVxMwpJGFlEF1VlERb+Ghw1Q==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@rollup/rollup-linux-x64-musl@4.60.0': resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} cpu: [x64] os: [linux] libc: [musl] + '@rollup/rollup-linux-x64-musl@4.64.12': + resolution: {integrity: sha512-vIeut2LkuVc3gcsawLJPahNAzvzBcFRhu2O+yVb//BuQKz7PIItMF0piXRbN/lIDO3d68TSDd368Vg6YwgTybw==} + cpu: [x64] + os: [linux] + libc: [musl] + '@rollup/rollup-openbsd-x64@4.60.0': resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} cpu: [x64] os: [openbsd] + '@rollup/rollup-openbsd-x64@4.64.12': + resolution: {integrity: sha512-3Sx+D3r9oPkTdNUcPrIRM2Am+vzEV6VGWoImHG4ZVK2jJeOW+eDjLeTm6nvu/z/m0fiX2YmGLXIypblc336MXg==} + cpu: [x64] + os: [openbsd] + '@rollup/rollup-openharmony-arm64@4.60.0': resolution: {integrity: sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==} cpu: [arm64] os: [openharmony] + '@rollup/rollup-openharmony-arm64@4.64.12': + resolution: {integrity: sha512-Nn2xh0teFnl71EiiUrKvECWcdGXtWF8q0uGclYwXGVw6QClGAcME0/bJSGdK+xIvd6MLEKYmjkTUvCBV+7m5Kw==} + cpu: [arm64] + os: [openharmony] + '@rollup/rollup-win32-arm64-msvc@4.60.0': resolution: {integrity: sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==} cpu: [arm64] os: [win32] + '@rollup/rollup-win32-arm64-msvc@4.64.12': + resolution: {integrity: sha512-n1/0H9TS+kBa4xp6n9aV7yiynBNfmerjkj2nAMIfY0ZHv1zShWRXnsAuAPAH0sfEK4ZSMK+z24q2RjAPFLCN1w==} + cpu: [arm64] + os: [win32] + '@rollup/rollup-win32-ia32-msvc@4.60.0': resolution: {integrity: sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==} cpu: [ia32] os: [win32] + '@rollup/rollup-win32-ia32-msvc@4.64.12': + resolution: {integrity: sha512-AdlDbQpsLfGlsHr1NfLZFtdnvxCKNp9TwlYVYExs8JJLWjLafUQEKJk0r9zUbmU57mw9nglMEvzqPHuLWYmaKw==} + cpu: [ia32] + os: [win32] + '@rollup/rollup-win32-x64-gnu@4.60.0': resolution: {integrity: sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==} cpu: [x64] os: [win32] + '@rollup/rollup-win32-x64-gnu@4.64.12': + resolution: {integrity: sha512-u5SSUJBHod51UamjkigSr+JPre6xMy5vaWTTV2K0SnFG4RuKyYpf66dTSLFKX2cwaldpwyBurXPUCxq8riQMEg==} + cpu: [x64] + os: [win32] + '@rollup/rollup-win32-x64-msvc@4.60.0': resolution: {integrity: sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==} cpu: [x64] os: [win32] + '@rollup/rollup-win32-x64-msvc@4.64.12': + resolution: {integrity: sha512-j5rXD4R/4DM5+V991AA9xOnG2Rwu2g3p5TPgu1Eu7apju0NUrLl+Bn/KpWc6wYJtlXqzwz1r+LNV1JeBU4VGvw==} + cpu: [x64] + os: [win32] + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -3831,6 +4269,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/eventsource@1.1.15': resolution: {integrity: sha512-XQmGcbnxUNa06HR3VBVkc9+A2Vpi9ZyLJcdS5dwaQQ/4ZMWFO+5c90FnMUpbtMZwB/FChoYHwuVg8TvkECacTA==} @@ -4859,6 +5300,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -5809,6 +6255,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + nanostores@1.2.0: resolution: {integrity: sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg==} engines: {node: ^20.0.0 || >=22.0.0} @@ -6008,6 +6459,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + pify@4.0.1: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} @@ -6038,6 +6493,10 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.31: + resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==} + engines: {node: ^10 || ^12 || >=14} + postcss@8.5.8: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} @@ -6231,6 +6690,11 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rollup@4.64.12: + resolution: {integrity: sha512-GhAu8ZglZ61VWSX/ZW3K/qkazgURBIQL0/Yy51QsS8DPyebR5Cf/Z2PANJFpTU0aVZ9byJcyqR6xvBbkXhdGYQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + rou3@0.7.12: resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} @@ -6370,6 +6834,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} @@ -6500,6 +6968,10 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} @@ -7139,7 +7611,7 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0)': + '@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0)': dependencies: '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 @@ -7151,38 +7623,38 @@ snapshots: nanostores: 1.2.0 zod: 4.3.6 optionalDependencies: - '@opentelemetry/api': 1.9.1 + '@opentelemetry/api': 1.10.0 - '@better-auth/drizzle-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)': + '@better-auth/drizzle-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) + '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) '@better-auth/utils': 0.4.2 - '@better-auth/kysely-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)(kysely@0.29.2)': + '@better-auth/kysely-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)(kysely@0.29.2)': dependencies: - '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) + '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) '@better-auth/utils': 0.4.2 optionalDependencies: kysely: 0.29.2 - '@better-auth/memory-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)': + '@better-auth/memory-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) + '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)': + '@better-auth/mongo-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) + '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) '@better-auth/utils': 0.4.2 - '@better-auth/prisma-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)': + '@better-auth/prisma-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) + '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) '@better-auth/utils': 0.4.2 - '@better-auth/telemetry@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': + '@better-auth/telemetry@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': dependencies: - '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) + '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 @@ -7428,6 +7900,9 @@ snapshots: '@esbuild/aix-ppc64@0.27.4': optional: true + '@esbuild/aix-ppc64@0.27.7': + optional: true + '@esbuild/android-arm64@0.21.5': optional: true @@ -7437,6 +7912,9 @@ snapshots: '@esbuild/android-arm64@0.27.4': optional: true + '@esbuild/android-arm64@0.27.7': + optional: true + '@esbuild/android-arm@0.21.5': optional: true @@ -7446,6 +7924,9 @@ snapshots: '@esbuild/android-arm@0.27.4': optional: true + '@esbuild/android-arm@0.27.7': + optional: true + '@esbuild/android-x64@0.21.5': optional: true @@ -7455,6 +7936,9 @@ snapshots: '@esbuild/android-x64@0.27.4': optional: true + '@esbuild/android-x64@0.27.7': + optional: true + '@esbuild/darwin-arm64@0.21.5': optional: true @@ -7464,6 +7948,9 @@ snapshots: '@esbuild/darwin-arm64@0.27.4': optional: true + '@esbuild/darwin-arm64@0.27.7': + optional: true + '@esbuild/darwin-x64@0.21.5': optional: true @@ -7473,6 +7960,9 @@ snapshots: '@esbuild/darwin-x64@0.27.4': optional: true + '@esbuild/darwin-x64@0.27.7': + optional: true + '@esbuild/freebsd-arm64@0.21.5': optional: true @@ -7482,6 +7972,9 @@ snapshots: '@esbuild/freebsd-arm64@0.27.4': optional: true + '@esbuild/freebsd-arm64@0.27.7': + optional: true + '@esbuild/freebsd-x64@0.21.5': optional: true @@ -7491,6 +7984,9 @@ snapshots: '@esbuild/freebsd-x64@0.27.4': optional: true + '@esbuild/freebsd-x64@0.27.7': + optional: true + '@esbuild/linux-arm64@0.21.5': optional: true @@ -7500,6 +7996,9 @@ snapshots: '@esbuild/linux-arm64@0.27.4': optional: true + '@esbuild/linux-arm64@0.27.7': + optional: true + '@esbuild/linux-arm@0.21.5': optional: true @@ -7509,6 +8008,9 @@ snapshots: '@esbuild/linux-arm@0.27.4': optional: true + '@esbuild/linux-arm@0.27.7': + optional: true + '@esbuild/linux-ia32@0.21.5': optional: true @@ -7518,6 +8020,9 @@ snapshots: '@esbuild/linux-ia32@0.27.4': optional: true + '@esbuild/linux-ia32@0.27.7': + optional: true + '@esbuild/linux-loong64@0.21.5': optional: true @@ -7527,6 +8032,9 @@ snapshots: '@esbuild/linux-loong64@0.27.4': optional: true + '@esbuild/linux-loong64@0.27.7': + optional: true + '@esbuild/linux-mips64el@0.21.5': optional: true @@ -7536,6 +8044,9 @@ snapshots: '@esbuild/linux-mips64el@0.27.4': optional: true + '@esbuild/linux-mips64el@0.27.7': + optional: true + '@esbuild/linux-ppc64@0.21.5': optional: true @@ -7545,6 +8056,9 @@ snapshots: '@esbuild/linux-ppc64@0.27.4': optional: true + '@esbuild/linux-ppc64@0.27.7': + optional: true + '@esbuild/linux-riscv64@0.21.5': optional: true @@ -7554,6 +8068,9 @@ snapshots: '@esbuild/linux-riscv64@0.27.4': optional: true + '@esbuild/linux-riscv64@0.27.7': + optional: true + '@esbuild/linux-s390x@0.21.5': optional: true @@ -7563,6 +8080,9 @@ snapshots: '@esbuild/linux-s390x@0.27.4': optional: true + '@esbuild/linux-s390x@0.27.7': + optional: true + '@esbuild/linux-x64@0.21.5': optional: true @@ -7572,12 +8092,18 @@ snapshots: '@esbuild/linux-x64@0.27.4': optional: true + '@esbuild/linux-x64@0.27.7': + optional: true + '@esbuild/netbsd-arm64@0.27.3': optional: true '@esbuild/netbsd-arm64@0.27.4': optional: true + '@esbuild/netbsd-arm64@0.27.7': + optional: true + '@esbuild/netbsd-x64@0.21.5': optional: true @@ -7587,12 +8113,18 @@ snapshots: '@esbuild/netbsd-x64@0.27.4': optional: true + '@esbuild/netbsd-x64@0.27.7': + optional: true + '@esbuild/openbsd-arm64@0.27.3': optional: true '@esbuild/openbsd-arm64@0.27.4': optional: true + '@esbuild/openbsd-arm64@0.27.7': + optional: true + '@esbuild/openbsd-x64@0.21.5': optional: true @@ -7602,12 +8134,18 @@ snapshots: '@esbuild/openbsd-x64@0.27.4': optional: true + '@esbuild/openbsd-x64@0.27.7': + optional: true + '@esbuild/openharmony-arm64@0.27.3': optional: true '@esbuild/openharmony-arm64@0.27.4': optional: true + '@esbuild/openharmony-arm64@0.27.7': + optional: true + '@esbuild/sunos-x64@0.21.5': optional: true @@ -7617,6 +8155,9 @@ snapshots: '@esbuild/sunos-x64@0.27.4': optional: true + '@esbuild/sunos-x64@0.27.7': + optional: true + '@esbuild/win32-arm64@0.21.5': optional: true @@ -7626,6 +8167,9 @@ snapshots: '@esbuild/win32-arm64@0.27.4': optional: true + '@esbuild/win32-arm64@0.27.7': + optional: true + '@esbuild/win32-ia32@0.21.5': optional: true @@ -7635,6 +8179,9 @@ snapshots: '@esbuild/win32-ia32@0.27.4': optional: true + '@esbuild/win32-ia32@0.27.7': + optional: true + '@esbuild/win32-x64@0.21.5': optional: true @@ -7644,6 +8191,9 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true + '@esbuild/win32-x64@0.27.7': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': dependencies: eslint: 9.39.4 @@ -7938,6 +8488,9 @@ snapshots: transitivePeerDependencies: - supports-color + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.9.1 @@ -8033,8 +8586,35 @@ snapshots: dependencies: '@octokit/openapi-types': 27.0.0 - '@opentelemetry/api@1.9.1': - optional: true + '@opentelemetry/api@1.10.0': {} + + '@opentelemetry/context-async-hooks@3.0.0(@opentelemetry/api@1.10.0)': + dependencies: + '@opentelemetry/api': 1.10.0 + + '@opentelemetry/core@3.0.0(@opentelemetry/api@1.10.0)': + dependencies: + '@opentelemetry/api': 1.10.0 + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/resources@3.0.0(@opentelemetry/api@1.10.0)': + dependencies: + '@opentelemetry/api': 1.10.0 + '@opentelemetry/core': 3.0.0(@opentelemetry/api@1.10.0) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/sdk-metrics@3.0.0(@opentelemetry/api@1.10.0)': + dependencies: + '@opentelemetry/api': 1.10.0 + '@opentelemetry/core': 3.0.0(@opentelemetry/api@1.10.0) + '@opentelemetry/resources': 3.0.0(@opentelemetry/api@1.10.0) + + '@opentelemetry/sdk-trace@3.0.0(@opentelemetry/api@1.10.0)': + dependencies: + '@opentelemetry/api': 1.10.0 + '@opentelemetry/core': 3.0.0(@opentelemetry/api@1.10.0) + '@opentelemetry/resources': 3.0.0(@opentelemetry/api@1.10.0) + '@opentelemetry/semantic-conventions': 1.40.0 '@opentelemetry/semantic-conventions@1.40.0': {} @@ -8179,78 +8759,153 @@ snapshots: '@rollup/rollup-android-arm-eabi@4.60.0': optional: true + '@rollup/rollup-android-arm-eabi@4.64.12': + optional: true + '@rollup/rollup-android-arm64@4.60.0': optional: true + '@rollup/rollup-android-arm64@4.64.12': + optional: true + '@rollup/rollup-darwin-arm64@4.60.0': optional: true + '@rollup/rollup-darwin-arm64@4.64.12': + optional: true + '@rollup/rollup-darwin-x64@4.60.0': optional: true + '@rollup/rollup-darwin-x64@4.64.12': + optional: true + '@rollup/rollup-freebsd-arm64@4.60.0': optional: true + '@rollup/rollup-freebsd-arm64@4.64.12': + optional: true + '@rollup/rollup-freebsd-x64@4.60.0': optional: true + '@rollup/rollup-freebsd-x64@4.64.12': + optional: true + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': optional: true + '@rollup/rollup-linux-arm-gnueabihf@4.64.12': + optional: true + '@rollup/rollup-linux-arm-musleabihf@4.60.0': optional: true + '@rollup/rollup-linux-arm-musleabihf@4.64.12': + optional: true + '@rollup/rollup-linux-arm64-gnu@4.60.0': optional: true + '@rollup/rollup-linux-arm64-gnu@4.64.12': + optional: true + '@rollup/rollup-linux-arm64-musl@4.60.0': optional: true + '@rollup/rollup-linux-arm64-musl@4.64.12': + optional: true + '@rollup/rollup-linux-loong64-gnu@4.60.0': optional: true + '@rollup/rollup-linux-loong64-gnu@4.64.12': + optional: true + '@rollup/rollup-linux-loong64-musl@4.60.0': optional: true + '@rollup/rollup-linux-loong64-musl@4.64.12': + optional: true + '@rollup/rollup-linux-ppc64-gnu@4.60.0': optional: true + '@rollup/rollup-linux-ppc64-gnu@4.64.12': + optional: true + '@rollup/rollup-linux-ppc64-musl@4.60.0': optional: true + '@rollup/rollup-linux-ppc64-musl@4.64.12': + optional: true + '@rollup/rollup-linux-riscv64-gnu@4.60.0': optional: true + '@rollup/rollup-linux-riscv64-gnu@4.64.12': + optional: true + '@rollup/rollup-linux-riscv64-musl@4.60.0': optional: true + '@rollup/rollup-linux-riscv64-musl@4.64.12': + optional: true + '@rollup/rollup-linux-s390x-gnu@4.60.0': optional: true + '@rollup/rollup-linux-s390x-gnu@4.64.12': + optional: true + '@rollup/rollup-linux-x64-gnu@4.60.0': optional: true + '@rollup/rollup-linux-x64-gnu@4.64.12': + optional: true + '@rollup/rollup-linux-x64-musl@4.60.0': optional: true + '@rollup/rollup-linux-x64-musl@4.64.12': + optional: true + '@rollup/rollup-openbsd-x64@4.60.0': optional: true + '@rollup/rollup-openbsd-x64@4.64.12': + optional: true + '@rollup/rollup-openharmony-arm64@4.60.0': optional: true + '@rollup/rollup-openharmony-arm64@4.64.12': + optional: true + '@rollup/rollup-win32-arm64-msvc@4.60.0': optional: true + '@rollup/rollup-win32-arm64-msvc@4.64.12': + optional: true + '@rollup/rollup-win32-ia32-msvc@4.60.0': optional: true + '@rollup/rollup-win32-ia32-msvc@4.64.12': + optional: true + '@rollup/rollup-win32-x64-gnu@4.60.0': optional: true + '@rollup/rollup-win32-x64-gnu@4.64.12': + optional: true + '@rollup/rollup-win32-x64-msvc@4.60.0': optional: true + '@rollup/rollup-win32-x64-msvc@4.64.12': + optional: true + '@rtsao/scc@1.1.0': {} '@shikijs/core@2.5.0': @@ -8362,6 +9017,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} + '@types/eventsource@1.1.15': {} '@types/express-serve-static-core@5.1.1': @@ -8958,15 +9615,15 @@ snapshots: before-after-hook@4.0.0: {} - better-auth@1.6.20(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)))(vue@3.5.38(typescript@5.9.3)): + better-auth@1.6.20(@opentelemetry/api@1.10.0)(better-sqlite3@12.8.0)(vitest@4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)))(vue@3.5.38(typescript@5.9.3)): dependencies: - '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) - '@better-auth/drizzle-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2) - '@better-auth/kysely-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)(kysely@0.29.2) - '@better-auth/memory-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2) - '@better-auth/mongo-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2) - '@better-auth/prisma-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2) - '@better-auth/telemetry': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) + '@better-auth/drizzle-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2) + '@better-auth/kysely-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)(kysely@0.29.2) + '@better-auth/memory-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.10.0)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@noble/ciphers': 2.1.1 @@ -8979,7 +9636,7 @@ snapshots: zod: 4.3.6 optionalDependencies: better-sqlite3: 12.8.0 - vitest: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) vue: 3.5.38(typescript@5.9.3) transitivePeerDependencies: - '@cloudflare/workers-types' @@ -9481,6 +10138,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.4 '@esbuild/win32-x64': 0.27.4 + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -9807,6 +10493,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + fetch-blob@3.2.0: dependencies: node-domexception: 1.0.0 @@ -10514,6 +11204,8 @@ snapshots: nanoid@3.3.15: {} + nanoid@3.3.20: {} + nanostores@1.2.0: {} napi-build-utils@2.0.0: {} @@ -10691,6 +11383,8 @@ snapshots: picomatch@4.0.4: {} + picomatch@4.0.7: {} + pify@4.0.1: {} pino-abstract-transport@3.0.0: @@ -10725,6 +11419,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.31: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + postcss@8.5.8: dependencies: nanoid: 3.3.11 @@ -10979,6 +11679,38 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.0 fsevents: 2.3.3 + rollup@4.64.12: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.64.12 + '@rollup/rollup-android-arm64': 4.64.12 + '@rollup/rollup-darwin-arm64': 4.64.12 + '@rollup/rollup-darwin-x64': 4.64.12 + '@rollup/rollup-freebsd-arm64': 4.64.12 + '@rollup/rollup-freebsd-x64': 4.64.12 + '@rollup/rollup-linux-arm-gnueabihf': 4.64.12 + '@rollup/rollup-linux-arm-musleabihf': 4.64.12 + '@rollup/rollup-linux-arm64-gnu': 4.64.12 + '@rollup/rollup-linux-arm64-musl': 4.64.12 + '@rollup/rollup-linux-loong64-gnu': 4.64.12 + '@rollup/rollup-linux-loong64-musl': 4.64.12 + '@rollup/rollup-linux-ppc64-gnu': 4.64.12 + '@rollup/rollup-linux-ppc64-musl': 4.64.12 + '@rollup/rollup-linux-riscv64-gnu': 4.64.12 + '@rollup/rollup-linux-riscv64-musl': 4.64.12 + '@rollup/rollup-linux-s390x-gnu': 4.64.12 + '@rollup/rollup-linux-x64-gnu': 4.64.12 + '@rollup/rollup-linux-x64-musl': 4.64.12 + '@rollup/rollup-openbsd-x64': 4.64.12 + '@rollup/rollup-openharmony-arm64': 4.64.12 + '@rollup/rollup-win32-arm64-msvc': 4.64.12 + '@rollup/rollup-win32-ia32-msvc': 4.64.12 + '@rollup/rollup-win32-x64-gnu': 4.64.12 + '@rollup/rollup-win32-x64-msvc': 4.64.12 + fsevents: 2.3.3 + rou3@0.7.12: {} router@2.2.0: @@ -11183,6 +11915,8 @@ snapshots: source-map-js@1.2.1: {} + source-map-js@1.2.2: {} + space-separated-tokens@2.0.2: {} spawndamnit@3.0.1: @@ -11321,6 +12055,11 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + tinyrainbow@3.1.0: {} to-regex-range@5.0.1: @@ -11611,12 +12350,12 @@ snapshots: vite@7.3.0(@types/node@24.12.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: - esbuild: 0.27.4 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.15 - rollup: 4.60.0 - tinyglobby: 0.2.15 + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.31 + rollup: 4.64.12 + tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.12.0 fsevents: 2.3.3 @@ -11625,19 +12364,19 @@ snapshots: vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: - esbuild: 0.27.4 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.15 - rollup: 4.60.0 - tinyglobby: 0.2.15 + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.31 + rollup: 4.64.12 + tinyglobby: 0.2.17 optionalDependencies: '@types/node': 25.5.0 fsevents: 2.3.3 tsx: 4.21.0 yaml: 2.8.3 - vitepress@1.6.4(@algolia/client-search@5.55.0)(@types/node@24.12.0)(change-case@5.4.4)(postcss@8.5.15)(search-insights@2.17.3)(typescript@5.9.3): + vitepress@1.6.4(@algolia/client-search@5.55.0)(@types/node@24.12.0)(change-case@5.4.4)(postcss@8.5.31)(search-insights@2.17.3)(typescript@5.9.3): dependencies: '@docsearch/css': 3.8.2 '@docsearch/js': 3.8.2(@algolia/client-search@5.55.0)(search-insights@2.17.3) @@ -11658,7 +12397,7 @@ snapshots: vite: 5.4.21(@types/node@24.12.0) vue: 3.5.38(typescript@5.9.3) optionalDependencies: - postcss: 8.5.15 + postcss: 8.5.31 transitivePeerDependencies: - '@algolia/client-search' - '@types/node' @@ -11686,7 +12425,7 @@ snapshots: - typescript - universal-cookie - vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(vite@7.3.0(@types/node@24.12.0)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.2(@opentelemetry/api@1.10.0)(@types/node@24.12.0)(vite@7.3.0(@types/node@24.12.0)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.2 '@vitest/mocker': 4.1.2(vite@7.3.0(@types/node@24.12.0)(tsx@4.21.0)(yaml@2.8.3)) @@ -11709,12 +12448,12 @@ snapshots: vite: 7.3.0(@types/node@24.12.0)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: - '@opentelemetry/api': 1.9.1 + '@opentelemetry/api': 1.10.0 '@types/node': 24.12.0 transitivePeerDependencies: - msw - vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.2(@opentelemetry/api@1.10.0)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.2 '@vitest/mocker': 4.1.2(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) @@ -11737,7 +12476,7 @@ snapshots: vite: 7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: - '@opentelemetry/api': 1.9.1 + '@opentelemetry/api': 1.10.0 '@types/node': 25.5.0 transitivePeerDependencies: - msw diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 64875347ec..8361dde6a4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -46,6 +46,12 @@ catalogs: fastify: ^5.2.0 hono: ^4.11.4 raw-body: ^3.0.0 + otel: + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/context-async-hooks': ^3.0.0 + '@opentelemetry/core': ^3.0.0 + '@opentelemetry/sdk-metrics': ^3.0.0 + '@opentelemetry/sdk-trace': ^3.0.0 runtimeShared: '@cfworker/json-schema': ^4.1.1 ajv: ^8.17.1 diff --git a/test/e2e/package.json b/test/e2e/package.json index 2b052482e2..fde46e74cd 100644 --- a/test/e2e/package.json +++ b/test/e2e/package.json @@ -43,6 +43,10 @@ "@modelcontextprotocol/test-helpers": "workspace:^", "@modelcontextprotocol/tsconfig": "workspace:^", "@modelcontextprotocol/vitest-config": "workspace:^", + "@opentelemetry/api": "catalog:otel", + "@opentelemetry/context-async-hooks": "catalog:otel", + "@opentelemetry/core": "catalog:otel", + "@opentelemetry/sdk-trace": "catalog:otel", "@types/cors": "catalog:devTools", "@types/express": "catalog:devTools", "@valibot/to-json-schema": "catalog:devTools", diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 519d92acfa..d26f6524d2 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -1390,6 +1390,11 @@ export const REQUIREMENTS: Record = { source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic#_meta', behavior: "_meta sent in a request's params by the client is delivered intact to the server-side request handler." }, + 'protocol:meta:trace-context-otel': { + source: 'https://modelcontextprotocol.io/community/seps/414-request-meta', + behavior: + "With an OpenTelemetry SDK registered and no other configuration, a client's tools/call span carries its W3C trace context to the server in params._meta, the server's span for the call is its child, and the tool handler runs with the server span active." + }, 'protocol:meta:result-to-client': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic#_meta', behavior: diff --git a/test/e2e/scenarios/observability.test.ts b/test/e2e/scenarios/observability.test.ts new file mode 100644 index 0000000000..c3819bf594 --- /dev/null +++ b/test/e2e/scenarios/observability.test.ts @@ -0,0 +1,58 @@ +/** + * OpenTelemetry tests. They register a global OpenTelemetry SDK, so they live in + * their own file: every other scenario runs with the no-op API and an untouched wire. + */ + +import { Client } from '@modelcontextprotocol/client'; +import { McpServer } from '@modelcontextprotocol/server'; +import { context, propagation, SpanKind, trace } from '@opentelemetry/api'; +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; +import { W3CTraceContextPropagator } from '@opentelemetry/core'; +import { InMemorySpanExporter, SimpleSpanProcessor, TracerProvider } from '@opentelemetry/sdk-trace'; +import { afterAll, beforeEach, expect } from 'vitest'; +import { z } from 'zod/v4'; + +import { wire } from '../helpers/index'; +import { verifies } from '../helpers/verifies'; +import type { TestArgs } from '../types'; + +const exporter = new InMemorySpanExporter(); +const tracerProvider = new TracerProvider({ spanProcessors: [new SimpleSpanProcessor({ exporter })] }); +context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); +propagation.setGlobalPropagator(new W3CTraceContextPropagator()); +trace.setGlobalTracerProvider(tracerProvider); + +beforeEach(() => exporter.reset()); +afterAll(async () => { + await tracerProvider.shutdown(); + trace.disable(); + propagation.disable(); + context.disable(); +}); + +verifies('protocol:meta:trace-context-otel', async ({ transport }: TestArgs) => { + const handlerSaw: { traceparent?: unknown; activeSpanId?: string }[] = []; + const makeServer = () => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool('traced', { inputSchema: z.object({}) }, (_args, ctx) => { + handlerSaw.push({ traceparent: ctx.mcpReq._meta?.traceparent, activeSpanId: trace.getActiveSpan()?.spanContext().spanId }); + return { content: [{ type: 'text', text: 'ok' }] }; + }); + return s; + }; + const client = new Client({ name: 'c', version: '0' }); + await using _ = await wire(transport, makeServer, client); + exporter.reset(); + + await client.callTool({ name: 'traced', arguments: {} }); + + const spans = exporter.getFinishedSpans(); + const clientSpan = spans.find(s => s.name === 'tools/call traced' && s.kind === SpanKind.CLIENT); + const serverSpan = spans.find(s => s.name === 'tools/call traced' && s.kind === SpanKind.SERVER); + expect(clientSpan).toBeDefined(); + expect(serverSpan).toBeDefined(); + const { traceId, spanId } = clientSpan!.spanContext(); + expect(serverSpan!.spanContext().traceId).toBe(traceId); + expect(serverSpan!.parentSpanContext?.spanId).toBe(spanId); + expect(handlerSaw).toEqual([{ traceparent: `00-${traceId}-${spanId}-01`, activeSpanId: serverSpan!.spanContext().spanId }]); +}); diff --git a/test/integration/package.json b/test/integration/package.json index df35ac13a5..d7ffce0f5d 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -42,6 +42,11 @@ "@modelcontextprotocol/test-helpers": "workspace:^", "@modelcontextprotocol/tsconfig": "workspace:^", "@modelcontextprotocol/vitest-config": "workspace:^", + "@opentelemetry/api": "catalog:otel", + "@opentelemetry/context-async-hooks": "catalog:otel", + "@opentelemetry/core": "catalog:otel", + "@opentelemetry/sdk-metrics": "catalog:otel", + "@opentelemetry/sdk-trace": "catalog:otel", "@valibot/to-json-schema": "catalog:devTools", "@typescript/native-preview": "catalog:devTools", "arktype": "catalog:devTools", diff --git a/test/integration/test/otel/cardinality.test.ts b/test/integration/test/otel/cardinality.test.ts new file mode 100644 index 0000000000..87aca2ab92 --- /dev/null +++ b/test/integration/test/otel/cardinality.test.ts @@ -0,0 +1,150 @@ +import type { JSONRPCMessage } from '@modelcontextprotocol/client'; +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; +import { McpServer, Server } from '@modelcontextprotocol/server'; +import { SpanKind } from '@opentelemetry/api'; +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { registerTestSdk } from './sdk'; + +const sdk = registerTestSdk(); +beforeEach(() => sdk.reset()); +afterAll(() => sdk.unregister()); + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)); + +/** Connects a raw transport to `server`, so a test can send exactly the messages a hostile peer would. */ +async function rawPeer(server: McpServer | Server) { + const [peer, serverSide] = InMemoryTransport.createLinkedPair(); + const received: JSONRPCMessage[] = []; + peer.onmessage = message => received.push(message); + await server.connect(serverSide); + await peer.start(); + return { peer, received }; +} + +const initialize = (protocolVersion: string) => + ({ + jsonrpc: '2.0', + id: 0, + method: 'initialize', + params: { protocolVersion, capabilities: {}, clientInfo: { name: 'peer', version: '1' } } + }) as const; + +/** Metric attribute sets recorded for a histogram. */ +const metricAttributes = async (name: string) => { + const { points } = await sdk.histogram(name); + return points.map(point => point.attributes); +}; + +describe('client-chosen strings stay out of span names and metrics', () => { + it('records methods served only by a fallback handler as _OTHER, but keeps spec method names', async () => { + const server = new Server({ name: 'gateway', version: '1.0.0' }, { capabilities: { completions: {} } }); + server.fallbackRequestHandler = async () => ({}); + server.fallbackNotificationHandler = async () => {}; + const client = new Client({ name: 'agent', version: '1.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + + await client.request({ method: 'x/random-1234', params: {} }, z.object({})); + await client.notification({ method: 'x/random-5678', params: {} }); + await client.request( + { method: 'completion/complete', params: { ref: { type: 'ref/prompt', name: 'p' }, argument: { name: 'a', value: 'b' } } }, + z.object({}) + ); + await tick(); + + const serverSpans = sdk.spans().filter(span => span.kind === SpanKind.SERVER); + expect(serverSpans.filter(span => span.name === '_OTHER')).toHaveLength(2); + expect(serverSpans.map(span => span.name)).toContain('completion/complete'); + expect(serverSpans.map(span => span.name).join(',')).not.toContain('random'); + expect(JSON.stringify(await metricAttributes('mcp.server.operation.duration'))).not.toContain('random'); + await client.close(); + }); + + it('drops an unknown tool name when the call is cancelled before the server answers', async () => { + const server = new McpServer({ name: 'weather', version: '1.0.0' }); + server.registerTool('get-weather', { inputSchema: z.object({}) }, () => ({ content: [] })); + const { peer } = await rawPeer(server); + await peer.send(initialize('2025-11-25')); + await peer.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + // The cancellation lands before the -32602 the unknown tool would have produced. + await peer.send({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'random-tool-9876', arguments: {} } }); + await peer.send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 1, reason: 'never mind' } }); + await tick(); + + const span = sdk.span('tools/call', SpanKind.SERVER); + expect(span.attributes['error.type']).toBe('cancelled'); + expect(JSON.stringify(await metricAttributes('mcp.server.operation.duration'))).not.toContain('random-tool'); + await server.close(); + }); + + it('drops a tool name sent to a server that has no tool handler at all', async () => { + const server = new McpServer({ name: 'docs', version: '1.0.0' }); + server.registerResource('readme', 'file:///readme.md', {}, uri => ({ contents: [{ uri: uri.href, text: '# hi' }] })); + const { peer, received } = await rawPeer(server); + await peer.send(initialize('2025-11-25')); + await peer.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + await peer.send({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'random-tool-5555', arguments: {} } }); + await tick(); + + expect(received).toContainEqual(expect.objectContaining({ id: 1, error: expect.objectContaining({ code: -32_601 }) })); + const span = sdk.span('tools/call', SpanKind.SERVER); + expect(span.attributes['rpc.response.status_code']).toBe('-32601'); + expect(JSON.stringify(await metricAttributes('mcp.server.operation.duration'))).not.toContain('random-tool'); + await server.close(); + }); + + it('records the protocol version an initialize requests only when the server supports it', async () => { + const server = new McpServer({ name: 'weather', version: '1.0.0' }); + const { peer } = await rawPeer(server); + + await peer.send(initialize('bogus-version-4242')); + await tick(); + + const span = sdk.span('initialize', SpanKind.SERVER); + expect(span.attributes['mcp.protocol.version']).toBeUndefined(); + expect(JSON.stringify(await metricAttributes('mcp.server.operation.duration'))).not.toContain('bogus-version'); + await server.close(); + + sdk.reset(); + const supported = new McpServer({ name: 'weather', version: '1.0.0' }); + const second = await rawPeer(supported); + await second.peer.send(initialize('2025-11-25')); + await tick(); + expect(sdk.span('initialize', SpanKind.SERVER).attributes['mcp.protocol.version']).toBe('2025-11-25'); + await supported.close(); + }); + + it("buckets a server's application-defined error codes on the client's metrics, keeping the exact code on the span", async () => { + const [clientTransport, peer] = InMemoryTransport.createLinkedPair(); + peer.onmessage = message => { + if (!('id' in message) || !('method' in message)) return; + if (message.method === 'initialize') { + void peer.send({ + jsonrpc: '2.0', + id: message.id, + result: { protocolVersion: '2025-11-25', capabilities: {}, serverInfo: { name: 'hostile', version: '1' } } + }); + } else { + void peer.send({ jsonrpc: '2.0', id: message.id, error: { code: 918_273, message: 'made up' } }); + } + }; + await peer.start(); + const client = new Client({ name: 'agent', version: '1.0.0' }); + await client.connect(clientTransport); + + await expect(client.request({ method: 'x/anything', params: {} }, z.object({}))).rejects.toThrow('made up'); + + expect(sdk.span('x/anything', SpanKind.CLIENT).attributes).toMatchObject({ + 'error.type': '918273', + 'rpc.response.status_code': '918273' + }); + const clientMetrics = await metricAttributes('mcp.client.operation.duration'); + expect(clientMetrics).toContainEqual(expect.objectContaining({ 'error.type': '_OTHER', 'rpc.response.status_code': '_OTHER' })); + expect(JSON.stringify(clientMetrics)).not.toContain('918273'); + await client.close(); + }); +}); diff --git a/test/integration/test/otel/openTelemetry.test.ts b/test/integration/test/otel/openTelemetry.test.ts new file mode 100644 index 0000000000..3fac46af2f --- /dev/null +++ b/test/integration/test/otel/openTelemetry.test.ts @@ -0,0 +1,352 @@ +import type { ClientOptions } from '@modelcontextprotocol/client'; +import { Client, InMemoryTransport, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { createMcpHandler, McpServer, ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/server'; +import { context, propagation, SpanKind, SpanStatusCode } from '@opentelemetry/api'; +import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +import { registerTestSdk } from './sdk'; + +// The application's OpenTelemetry SDK. Client and McpServer need nothing else. +const sdk = registerTestSdk(); +beforeEach(() => sdk.reset()); +afterAll(() => sdk.unregister()); + +const cleanups: Array<() => Promise | unknown> = []; +afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!(); +}); + +/** A client and server over in-memory transports, with no telemetry configuration at all. */ +async function connect(buildServer: (server: McpServer) => void, clientOptions: ClientOptions = {}) { + const server = new McpServer({ name: 'weather', version: '1.0.0' }); + buildServer(server); + const client = new Client({ name: 'agent', version: '1.0.0' }, clientOptions); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + cleanups.push(() => client.close()); + return { client, server }; +} + +function registerGreet(server: McpServer) { + server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({ + content: [{ type: 'text', text: `hello ${name}` }], + structuredContent: { greeting: `hello ${name}` } + })); +} + +describe('OpenTelemetry spans', () => { + it('emits a CLIENT span and a SERVER child span for a tool call, named after the tool', async () => { + const { client } = await connect(registerGreet); + + await client.callTool({ name: 'greet', arguments: { name: 'Ada' } }); + + const clientSpan = sdk.span('tools/call greet', SpanKind.CLIENT); + const serverSpan = sdk.span('tools/call greet', SpanKind.SERVER); + expect(serverSpan.spanContext().traceId).toBe(clientSpan.spanContext().traceId); + expect(serverSpan.parentSpanContext?.spanId).toBe(clientSpan.spanContext().spanId); + expect(clientSpan.instrumentationScope.name).toBe('@modelcontextprotocol/client'); + expect(serverSpan.instrumentationScope.name).toBe('@modelcontextprotocol/server'); + expect({ ...clientSpan.attributes }).toEqual({ + 'mcp.method.name': 'tools/call', + 'gen_ai.operation.name': 'execute_tool', + 'gen_ai.tool.name': 'greet', + 'jsonrpc.request.id': expect.any(String), + 'mcp.protocol.version': '2025-11-25' + }); + expect({ ...serverSpan.attributes }).toMatchObject({ + 'mcp.method.name': 'tools/call', + 'gen_ai.tool.name': 'greet', + 'jsonrpc.request.id': clientSpan.attributes['jsonrpc.request.id'] + }); + expect(clientSpan.status.code).toBe(SpanStatusCode.UNSET); + expect(serverSpan.status.code).toBe(SpanStatusCode.UNSET); + }); + + it('parents the client span on the active span and makes the server span active inside the tool', async () => { + const { client } = await connect(server => { + server.registerTool('lookup', { inputSchema: z.object({}) }, async () => { + await sdk.tracer.startActiveSpan('db.query', async span => span.end()); + return { content: [] }; + }); + }); + + await sdk.tracer.startActiveSpan('invoke_agent', async agent => { + await client.callTool({ name: 'lookup', arguments: {} }); + agent.end(); + }); + + const agent = sdk.span('invoke_agent', SpanKind.INTERNAL); + const clientSpan = sdk.span('tools/call lookup', SpanKind.CLIENT); + const serverSpan = sdk.span('tools/call lookup', SpanKind.SERVER); + const dbSpan = sdk.span('db.query', SpanKind.INTERNAL); + expect(clientSpan.parentSpanContext?.spanId).toBe(agent.spanContext().spanId); + expect(serverSpan.parentSpanContext?.spanId).toBe(clientSpan.spanContext().spanId); + expect(dbSpan.parentSpanContext?.spanId).toBe(serverSpan.spanContext().spanId); + }); + + it('traces the handshake and notifications, and skips log notifications', async () => { + const { client } = await connect(server => { + server.server.registerCapabilities({ logging: {} }); + server.registerTool('noisy', { inputSchema: z.object({}) }, async (_args, ctx) => { + await ctx.mcpReq.log('info', 'working'); + return { content: [] }; + }); + }); + await client.setLoggingLevel('info'); + + await client.callTool({ name: 'noisy', arguments: {} }); + + expect(sdk.span('initialize', SpanKind.CLIENT).attributes['mcp.protocol.version']).toBe('2025-11-25'); + expect(sdk.span('initialize', SpanKind.SERVER)).toBeDefined(); + expect(sdk.span('notifications/initialized', SpanKind.CLIENT).attributes['jsonrpc.request.id']).toBeUndefined(); + expect(sdk.span('notifications/initialized', SpanKind.SERVER)).toBeDefined(); + expect(sdk.spans().filter(s => s.name === 'notifications/message')).toEqual([]); + }); + + it('marks a tool result with isError as tool_error on both sides', async () => { + const { client } = await connect(server => { + server.registerTool('fail', { inputSchema: z.object({}) }, () => ({ + content: [{ type: 'text', text: 'secret detail' }], + isError: true + })); + }); + + await client.callTool({ name: 'fail', arguments: {} }); + + for (const kind of [SpanKind.CLIENT, SpanKind.SERVER]) { + const span = sdk.span('tools/call fail', kind); + expect(span.attributes['error.type']).toBe('tool_error'); + expect(span.status).toEqual({ code: SpanStatusCode.ERROR }); + } + }); + + it('treats an unknown tool as a caller error: an error on the client, not on the server, and no tool name in the server span name', async () => { + const { client } = await connect(registerGreet); + + await expect(client.callTool({ name: 'nope', arguments: {} })).rejects.toThrow(/not found/); + + const clientSpan = sdk.span('tools/call nope', SpanKind.CLIENT); + expect(clientSpan.attributes).toMatchObject({ 'error.type': '-32602', 'rpc.response.status_code': '-32602' }); + expect(clientSpan.status.code).toBe(SpanStatusCode.ERROR); + const serverSpan = sdk.span('tools/call', SpanKind.SERVER); + expect(serverSpan.attributes['rpc.response.status_code']).toBe('-32602'); + expect(serverSpan.attributes['error.type']).toBeUndefined(); + expect(serverSpan.status.code).toBe(SpanStatusCode.UNSET); + }); + + it('records an exception event when a handler throws something other than a protocol error', async () => { + const { client } = await connect(server => { + server.server.setRequestHandler('acme/boom', { params: z.object({}) }, () => { + throw new Error('database unreachable'); + }); + server.server.setRequestHandler('acme/refuse', { params: z.object({}) }, () => { + throw new ProtocolError(-32_001, 'quota exceeded'); + }); + }); + + await expect(client.request({ method: 'acme/boom', params: {} }, z.object({}))).rejects.toThrow('database unreachable'); + await expect(client.request({ method: 'acme/refuse', params: {} }, z.object({}))).rejects.toThrow('quota exceeded'); + + const boom = sdk.span('acme/boom', SpanKind.SERVER); + expect(boom.attributes['error.type']).toBe(String(ProtocolErrorCode.InternalError)); + expect(boom.status).toEqual({ code: SpanStatusCode.ERROR, message: 'database unreachable' }); + expect(boom.events.map(e => [e.name, e.attributes?.['exception.message']])).toEqual([['exception', 'database unreachable']]); + const refuse = sdk.span('acme/refuse', SpanKind.SERVER); + expect(refuse.attributes['error.type']).toBe('-32001'); + expect(refuse.events).toEqual([]); + }); + + it('records a method the server does not handle as _OTHER', async () => { + const { client } = await connect(registerGreet); + + await expect(client.request({ method: 'attacker/chosen-name', params: {} }, z.object({}))).rejects.toThrow(); + + const serverSpan = sdk.span('_OTHER', SpanKind.SERVER); + expect(serverSpan.attributes['mcp.method.name']).toBe('_OTHER'); + expect(serverSpan.attributes['rpc.response.status_code']).toBe('-32601'); + expect(sdk.span('attacker/chosen-name', SpanKind.CLIENT).attributes['error.type']).toBe('-32601'); + }); + + it('names prompt spans after the prompt and records resource URIs on resource spans', async () => { + const { client } = await connect(server => { + server.registerPrompt('review', { argsSchema: z.object({ language: z.string() }) }, ({ language }) => ({ + messages: [{ role: 'user', content: { type: 'text', text: `review this ${language}` } }] + })); + server.registerResource('readme', 'file:///readme.md', {}, uri => ({ contents: [{ uri: uri.href, text: '# hi' }] })); + }); + + await client.getPrompt({ name: 'review', arguments: { language: 'TypeScript' } }); + await client.readResource({ uri: 'file:///readme.md' }); + + expect(sdk.span('prompts/get review', SpanKind.CLIENT).attributes['gen_ai.prompt.name']).toBe('review'); + expect(sdk.span('prompts/get review', SpanKind.SERVER).attributes['gen_ai.prompt.variable.language']).toBeUndefined(); + expect(sdk.span('resources/read', SpanKind.SERVER).attributes['mcp.resource.uri']).toBe('file:///readme.md'); + }); + + it('records no tool arguments, tool results or prompt arguments by default', async () => { + const { client } = await connect(registerGreet); + + await client.callTool({ name: 'greet', arguments: { name: 'Ada' } }); + + const recorded = JSON.stringify(sdk.spans().map(s => s.attributes)); + expect(recorded).not.toContain('Ada'); + expect(recorded).not.toContain('gen_ai.tool.call'); + }); + + it('follows a 2025-era elicitation from inside a tool back to the client', async () => { + const { client } = await connect( + server => { + server.registerTool('confirm', { inputSchema: z.object({}) }, async (_args, ctx) => { + const answer = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: 'Proceed?', + requestedSchema: { type: 'object', properties: { ok: { type: 'boolean' } } } + }); + return { content: [{ type: 'text', text: answer.action }] }; + }); + }, + { capabilities: { elicitation: {} } } + ); + client.setRequestHandler('elicitation/create', () => ({ action: 'accept', content: { ok: true } })); + + await client.callTool({ name: 'confirm', arguments: {} }); + + const toolSpan = sdk.span('tools/call confirm', SpanKind.SERVER); + const askSpan = sdk.span('elicitation/create', SpanKind.CLIENT); + const answerSpan = sdk.span('elicitation/create', SpanKind.SERVER); + expect(askSpan.parentSpanContext?.spanId).toBe(toolSpan.spanContext().spanId); + expect(answerSpan.parentSpanContext?.spanId).toBe(askSpan.spanContext().spanId); + }); + + it('ends a timed-out request with error.type timeout', async () => { + const { client } = await connect(server => { + server.registerTool('slow', { inputSchema: z.object({}) }, () => new Promise(() => {})); + }); + + await expect(client.callTool({ name: 'slow', arguments: {} }, { timeout: 20 })).rejects.toThrow(/timed out/); + + const span = sdk.span('tools/call slow', SpanKind.CLIENT); + expect(span.attributes['error.type']).toBe('timeout'); + expect(span.status.code).toBe(SpanStatusCode.ERROR); + expect(sdk.span('notifications/cancelled', SpanKind.CLIENT)).toBeDefined(); + }); + + it('parents the client span on trace context the caller put in _meta', async () => { + const { client } = await connect(registerGreet); + const upstream = '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01'; + + await client.callTool({ name: 'greet', arguments: { name: 'Ada' }, _meta: { traceparent: upstream } }); + + const clientSpan = sdk.span('tools/call greet', SpanKind.CLIENT); + expect(clientSpan.spanContext().traceId).toBe('0af7651916cd43dd8448eb211c80319c'); + expect(clientSpan.parentSpanContext?.spanId).toBe('b7ad6b7169203331'); + expect(sdk.span('tools/call greet', SpanKind.SERVER).parentSpanContext?.spanId).toBe(clientSpan.spanContext().spanId); + }); + + it('propagates baggage through _meta', async () => { + let baggageInTool: string | undefined; + const { client } = await connect(server => { + server.registerTool('whoami', { inputSchema: z.object({}) }, () => { + baggageInTool = propagation.getActiveBaggage()?.getEntry('tenant')?.value; + return { content: [] }; + }); + }); + + const withBaggage = propagation.setBaggage(context.active(), propagation.createBaggage({ tenant: { value: 'acme' } })); + await context.with(withBaggage, () => client.callTool({ name: 'whoami', arguments: {} })); + + expect(baggageInTool).toBe('acme'); + }); +}); + +describe('OpenTelemetry metrics', () => { + it('records operation durations with the semconv unit, buckets and low-cardinality attributes', async () => { + const { client } = await connect(server => { + registerGreet(server); + server.registerResource('readme', 'file:///readme.md', {}, uri => ({ contents: [{ uri: uri.href, text: '# hi' }] })); + }); + + await client.callTool({ name: 'greet', arguments: { name: 'Ada' } }); + await client.readResource({ uri: 'file:///readme.md' }); + await expect(client.callTool({ name: 'nope', arguments: {} })).rejects.toThrow(); + + const clientDuration = await sdk.histogram('mcp.client.operation.duration'); + expect(clientDuration.unit).toBe('s'); + expect(clientDuration.boundaries).toEqual([0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 120, 300]); + expect(clientDuration.points.map(p => p.attributes)).toEqual( + expect.arrayContaining([ + { + 'mcp.method.name': 'tools/call', + 'gen_ai.operation.name': 'execute_tool', + 'gen_ai.tool.name': 'greet', + 'mcp.protocol.version': '2025-11-25' + }, + { 'mcp.method.name': 'resources/read', 'mcp.protocol.version': '2025-11-25' }, + { + 'mcp.method.name': 'tools/call', + 'gen_ai.operation.name': 'execute_tool', + 'gen_ai.tool.name': 'nope', + 'mcp.protocol.version': '2025-11-25', + 'error.type': '-32602', + 'rpc.response.status_code': '-32602' + } + ]) + ); + + const serverDuration = await sdk.histogram('mcp.server.operation.duration'); + const serverToolCalls = serverDuration.points.filter(p => p.attributes['mcp.method.name'] === 'tools/call').map(p => p.attributes); + // The rejected tool name stays out of server metrics. + expect(serverToolCalls).toEqual( + expect.arrayContaining([ + { + 'mcp.method.name': 'tools/call', + 'gen_ai.operation.name': 'execute_tool', + 'gen_ai.tool.name': 'greet', + 'mcp.protocol.version': '2025-11-25' + }, + { + 'mcp.method.name': 'tools/call', + 'gen_ai.operation.name': 'execute_tool', + 'mcp.protocol.version': '2025-11-25', + 'rpc.response.status_code': '-32602' + } + ]) + ); + expect(JSON.stringify(serverDuration.points)).not.toContain('nope'); + expect(JSON.stringify(clientDuration.points)).not.toContain('file:///readme.md'); + }); +}); + +describe('OpenTelemetry over Streamable HTTP', () => { + it('serves a modern-era call through createMcpHandler: HTTP attributes, and a link to the ambient HTTP span', async () => { + const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'weather', version: '1.0.0' }); + registerGreet(server); + return server; + }); + cleanups.push(() => handler.close()); + // Stands in for HTTP instrumentation: each POST gets a span, active while the server handles it. + const fetchWithHttpSpan = (url: string | URL, init?: RequestInit) => + sdk.tracer.startActiveSpan('POST', { kind: SpanKind.SERVER }, span => + handler.fetch(new Request(url, init)).finally(() => span.end()) + ); + const client = new Client({ name: 'agent', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(new StreamableHTTPClientTransport(new URL('http://mcp.test/mcp'), { fetch: fetchWithHttpSpan })); + cleanups.push(() => client.close()); + + await client.callTool({ name: 'greet', arguments: { name: 'Ada' } }); + + const clientSpan = sdk.span('tools/call greet', SpanKind.CLIENT); + const serverSpan = sdk.span('tools/call greet', SpanKind.SERVER); + const httpSpan = sdk.spans().find(s => s.name === 'POST' && s.parentSpanContext?.spanId === clientSpan.spanContext().spanId)!; + expect(httpSpan).toBeDefined(); + expect(serverSpan.parentSpanContext?.spanId).toBe(clientSpan.spanContext().spanId); + expect(serverSpan.links.map(l => l.context.spanId)).toEqual([httpSpan.spanContext().spanId]); + expect(serverSpan.attributes).toMatchObject({ + 'mcp.protocol.version': '2026-07-28', + 'network.protocol.name': 'http', + 'network.transport': 'tcp' + }); + }); +}); diff --git a/test/integration/test/otel/sdk.ts b/test/integration/test/otel/sdk.ts new file mode 100644 index 0000000000..ff22b407ed --- /dev/null +++ b/test/integration/test/otel/sdk.ts @@ -0,0 +1,67 @@ +import type { Tracer } from '@opentelemetry/api'; +import { context, metrics, propagation, trace } from '@opentelemetry/api'; +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; +import { CompositePropagator, W3CBaggagePropagator, W3CTraceContextPropagator } from '@opentelemetry/core'; +import type { DataPoint, Histogram, ResourceMetrics } from '@opentelemetry/sdk-metrics'; +import { MeterProvider, MetricReader } from '@opentelemetry/sdk-metrics'; +import type { ReadableSpan } from '@opentelemetry/sdk-trace'; +import { InMemorySpanExporter, SimpleSpanProcessor, TracerProvider } from '@opentelemetry/sdk-trace'; + +class CollectingMetricReader extends MetricReader { + protected async onShutdown(): Promise {} + protected async onForceFlush(): Promise {} +} + +/** + * Registers an OpenTelemetry SDK globally, exactly as an application would, but keeping spans + * and metrics in memory. Call it once per test file; `unregister` puts the no-op API back. + */ +export function registerTestSdk() { + const exporter = new InMemorySpanExporter(); + const tracerProvider = new TracerProvider({ spanProcessors: [new SimpleSpanProcessor({ exporter })] }); + const reader = new CollectingMetricReader(); + const meterProvider = new MeterProvider({ readers: [reader] }); + context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); + propagation.setGlobalPropagator( + new CompositePropagator({ propagators: [new W3CTraceContextPropagator(), new W3CBaggagePropagator()] }) + ); + trace.setGlobalTracerProvider(tracerProvider); + metrics.setGlobalMeterProvider(meterProvider); + const tracer: Tracer = trace.getTracer('test'); + + const spans = (): ReadableSpan[] => exporter.getFinishedSpans(); + /** The one finished span with this name and kind; fails when there is not exactly one. */ + const span = (name: string, kind: number): ReadableSpan => { + const matches = spans().filter(s => s.name === name && s.kind === kind); + if (matches.length !== 1) { + throw new Error( + `expected one ${name} span of kind ${kind}, found ${matches.length} in [${spans() + .map(s => `${s.name}/${s.kind}`) + .join(', ')}]` + ); + } + return matches[0]!; + }; + /** Data points recorded so far for a histogram, across every instrumentation scope (client and server both record). */ + const histogram = async (name: string): Promise<{ unit: string; boundaries: number[]; points: DataPoint[] }> => { + const { resourceMetrics } = await reader.collect(); + const found = findMetrics(resourceMetrics, name); + if (found.length === 0) throw new Error(`no ${name} metric was recorded`); + const points = found.flatMap(metric => metric.dataPoints as DataPoint[]); + return { unit: found[0]!.descriptor.unit, boundaries: points[0]?.value.buckets.boundaries ?? [], points }; + }; + const unregister = async () => { + await tracerProvider.shutdown(); + await meterProvider.shutdown(); + trace.disable(); + metrics.disable(); + propagation.disable(); + context.disable(); + }; + + return { tracer, spans, span, histogram, reset: () => exporter.reset(), unregister }; +} + +function findMetrics(resourceMetrics: ResourceMetrics, name: string) { + return resourceMetrics.scopeMetrics.flatMap(scope => scope.metrics.filter(m => m.descriptor.name === name)); +} diff --git a/test/integration/test/otel/sdkSetupOrder.test.ts b/test/integration/test/otel/sdkSetupOrder.test.ts new file mode 100644 index 0000000000..cab3166744 --- /dev/null +++ b/test/integration/test/otel/sdkSetupOrder.test.ts @@ -0,0 +1,62 @@ +import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; +import { McpServer } from '@modelcontextprotocol/server'; +import { metrics, propagation, trace } from '@opentelemetry/api'; +import { W3CTraceContextPropagator } from '@opentelemetry/core'; +import { MeterProvider, MetricReader } from '@opentelemetry/sdk-metrics'; +import { InMemorySpanExporter, SimpleSpanProcessor, TracerProvider } from '@opentelemetry/sdk-trace'; +import { afterAll, describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +class CollectingMetricReader extends MetricReader { + protected async onShutdown(): Promise {} + protected async onForceFlush(): Promise {} +} + +// Deliberately incomplete setups: no context manager, and a meter provider registered after the first MCP traffic. +const exporter = new InMemorySpanExporter(); +const tracerProvider = new TracerProvider({ spanProcessors: [new SimpleSpanProcessor({ exporter })] }); +trace.setGlobalTracerProvider(tracerProvider); +propagation.setGlobalPropagator(new W3CTraceContextPropagator()); + +afterAll(async () => { + await tracerProvider.shutdown(); + trace.disable(); + propagation.disable(); + metrics.disable(); +}); + +async function callTool(onMeta: (meta: unknown) => void) { + const server = new McpServer({ name: 'weather', version: '1.0.0' }); + server.registerTool('get-weather', { inputSchema: z.object({}) }, (_args, ctx) => { + onMeta(ctx.mcpReq._meta); + return { content: [] }; + }); + const client = new Client({ name: 'agent', version: '1.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + await client.callTool({ name: 'get-weather', arguments: {} }); + await client.close(); +} + +describe('OpenTelemetry setups the SDK has to tolerate', () => { + it('injects trace context without a registered context manager', async () => { + let meta: unknown; + await callTool(m => (meta = m)); + + const clientSpan = exporter.getFinishedSpans().find(span => span.name === 'tools/call get-weather' && span.kind === 2)!; + const { traceId, spanId } = clientSpan.spanContext(); + expect(meta).toEqual({ traceparent: `00-${traceId}-${spanId}-01` }); + }); + + it('records duration metrics once a meter provider is registered after the first MCP traffic', async () => { + await callTool(() => {}); + const reader = new CollectingMetricReader(); + metrics.setGlobalMeterProvider(new MeterProvider({ readers: [reader] })); + + await callTool(() => {}); + + const { resourceMetrics } = await reader.collect(); + const names = resourceMetrics.scopeMetrics.flatMap(scope => scope.metrics.map(metric => metric.descriptor.name)); + expect(names).toEqual(expect.arrayContaining(['mcp.client.operation.duration', 'mcp.server.operation.duration'])); + }); +}); diff --git a/test/integration/test/otel/withoutSdk.test.ts b/test/integration/test/otel/withoutSdk.test.ts new file mode 100644 index 0000000000..aefaa61258 --- /dev/null +++ b/test/integration/test/otel/withoutSdk.test.ts @@ -0,0 +1,44 @@ +import type { Server as HttpServer } from 'node:http'; +import { createServer } from 'node:http'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; +import { trace } from '@opentelemetry/api'; +import { afterEach, describe, expect, it } from 'vitest'; +import * as z from 'zod/v4'; + +const cleanups: Array<() => Promise | unknown> = []; +afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!(); +}); + +describe('OpenTelemetry with no SDK registered', () => { + it('records nothing, injects nothing, and passes caller-supplied trace context through untouched', async () => { + const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'weather', version: '1.0.0' }); + server.registerTool('echo-meta', { inputSchema: z.object({}) }, (_args, ctx) => ({ + content: [{ type: 'text', text: JSON.stringify(ctx.mcpReq._meta ?? {}) }] + })); + return server; + }); + const httpServer: HttpServer = createServer(toNodeHandler(handler)); + const baseUrl = await listenOnRandomPort(httpServer); + cleanups.push(async () => { + await handler.close(); + httpServer.close(); + }); + const client = new Client({ name: 'agent', version: '1.0.0' }); + await client.connect(new StreamableHTTPClientTransport(baseUrl)); + cleanups.push(() => client.close()); + const upstream = '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01'; + + const plain = await client.callTool({ name: 'echo-meta', arguments: {} }); + const passthrough = await client.callTool({ name: 'echo-meta', arguments: {}, _meta: { traceparent: upstream } }); + + expect(plain.content).toEqual([{ type: 'text', text: '{}' }]); + expect(passthrough.content).toEqual([{ type: 'text', text: JSON.stringify({ traceparent: upstream }) }]); + expect(trace.getActiveSpan()).toBeUndefined(); + }); +}); diff --git a/test/integration/test/server/cloudflareWorkers.test.ts b/test/integration/test/server/cloudflareWorkers.test.ts index a30831ff67..a3366919af 100644 --- a/test/integration/test/server/cloudflareWorkers.test.ts +++ b/test/integration/test/server/cloudflareWorkers.test.ts @@ -82,16 +82,18 @@ function packWorkspacePackage(tempDir: string, packageDirName: string): string { scripts?: unknown; devDependencies?: unknown; dependencies?: Record; + peerDependencies?: Record; }; delete manifest.scripts; delete manifest.devDependencies; - const dependencies = manifest.dependencies ?? {}; - for (const [name, spec] of Object.entries(dependencies)) { - if (spec.startsWith('catalog:') || spec.startsWith('workspace:')) { - const installed = JSON.parse(fs.readFileSync(path.join(pkgPath, 'node_modules', name, 'package.json'), 'utf8')) as { - version: string; - }; - dependencies[name] = installed.version; + for (const dependencies of [manifest.dependencies ?? {}, manifest.peerDependencies ?? {}]) { + for (const [name, spec] of Object.entries(dependencies)) { + if (spec.startsWith('catalog:') || spec.startsWith('workspace:')) { + const installed = JSON.parse(fs.readFileSync(path.join(pkgPath, 'node_modules', name, 'package.json'), 'utf8')) as { + version: string; + }; + dependencies[name] = installed.version; + } } } fs.writeFileSync(path.join(stagingDir, 'package.json'), JSON.stringify(manifest, null, 2));