diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index e3622194ec40f..a4c3e4fc198f7 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -24,6 +24,7 @@ import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as Tracer from "effect/Tracer"; import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai"; import { HttpBody, HttpClient, HttpClientRequest, HttpRouter } from "effect/http"; @@ -140,6 +141,26 @@ const serveSnapshots = (clientId: string, result: unknown) => return inputs; }); +/** Records every span and the order spans start and end in, by span ID. */ +const recordSpans = () => { + const spans: Array = []; + const order: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + order.push(`start ${span.spanId}`); + const end = span.end.bind(span); + span.end = (endTime, exit) => { + end(endTime, exit); + order.push(`end ${span.spanId}`); + }; + return span; + }, + }); + return { tracer, spans, order }; +}; + const callTool = (name: string, args: Record) => Effect.gen(function* () { const server = yield* McpServer.McpServer; @@ -178,12 +199,14 @@ it.effect.each([ ({ input, open, message, ...options }) => Effect.gen(function* () { const server = yield* McpServer.McpServer; + const traced = recordSpans(); const result = yield* server.callTool({ name: "device_screenshot", arguments: input }).pipe( Effect.provideService(McpInvocationContext.McpInvocationContext, { ...invocation, capabilities: new Set(["device"] as const), }), Effect.provideService(McpSchema.McpServerClient, client), + Effect.withTracer(traced.tracer), ); expect(result.isError).toBe(true); expect(result.content).toEqual([{ type: "text", text: message }]); @@ -191,6 +214,14 @@ it.effect.each([ error: { message, operation: "screenshot" }, }); expect(JSON.stringify(result)).not.toContain("private hub output"); + // The tool's span names it, never its arguments. + expect( + traced.spans + .filter((span) => span.name.startsWith("mcp.tool.")) + .map((span) => Object.fromEntries(span.attributes)), + ).toEqual([ + { "rpc.system": "mcp", "rpc.method": "tools/call", "mcp.tool.name": "device_screenshot" }, + ]); }).pipe( Effect.provide( McpHttpServer.layerDeviceToolkit.pipe( @@ -904,8 +935,10 @@ it.effect("admits provider and OAuth client credentials and points only clients }), ), ); + const traced = recordSpans(); yield* HttpRouter.serve(serverLayer, { disableListenLog: true, disableLogger: true }).pipe( Layer.build, + Effect.withTracer(traced.tracer), ); const httpClient = yield* HttpClient.HttpClient; const call = (token: string | undefined) => @@ -936,6 +969,21 @@ it.effect("admits provider and OAuth client credentials and points only clients }); expect((yield* call(providerToken)).status).toBe(200); + // The tool gets its own span, without its arguments, which starts once authentication has ended. + const toolSpan = traced.spans.find((span) => span.name === "mcp.tool.probe"); + expect(Object.fromEntries(toolSpan?.attributes ?? [])).toEqual({ + "rpc.system": "mcp", + "rpc.method": "tools/call", + "mcp.tool.name": "probe", + }); + const authSpan = traced.spans.find( + (span) => + span.name === "McpHttpServer.authenticateRequest" && span.traceId === toolSpan?.traceId, + ); + expect(authSpan).toBeDefined(); + expect(traced.order.slice(0, traced.order.indexOf(`start ${toolSpan?.spanId}`))).toContain( + `end ${authSpan?.spanId}`, + ); expect((yield* call(clientToken)).status).toBe(200); expect(seen.map((scope) => scope.requestNamespace)).toEqual([ "provider-session", @@ -952,9 +1000,22 @@ it.effect("admits provider and OAuth client credentials and points only clients expect(expiredClient.headers["www-authenticate"]).toContain("resource_metadata="); expect(expiredClient.headers["www-authenticate"]).toContain('error="invalid_token"'); + const spansBeforeRejection = traced.spans.length; const deadProvider = yield* call("deadProviderToken"); expect(deadProvider.status).toBe(401); expect(deadProvider.headers["www-authenticate"]).toBe('Bearer error="invalid_token"'); + // A rejected credential is recorded on the auth span, and nothing is handled. + const rejectedSpans = traced.spans.slice(spansBeforeRejection); + expect( + rejectedSpans + .filter((span) => span.name === "McpHttpServer.authenticateRequest") + .map((span) => span.events.map(([name]) => name)), + ).toEqual([[expect.stringContaining("unknown_or_expired_token")]]); + expect( + rejectedSpans.filter( + (span) => span.name.startsWith("mcp.tool.") || span.name.startsWith("McpServer."), + ), + ).toEqual([]); }), ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 9bc3b9da43d94..66fdd446fc991 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -203,8 +203,9 @@ const makeMcpAuthMiddleware = Effect.gen(function* () { Effect.map((settings) => settings.linkedEnvironmentsEnabled), Effect.orElseSucceed(() => false), ); - return Effect.fn("McpHttpServer.authenticateRequest")(function* (httpEffect) { - const request = yield* HttpServerRequest.HttpServerRequest; + const authenticateRequest = Effect.fn("McpHttpServer.authenticateRequest")(function* ( + request: HttpServerRequest.HttpServerRequest, + ) { const authorization = request.headers.authorization; const token = parseBearerToken(request) ?? ""; const invocation = @@ -234,11 +235,14 @@ const makeMcpAuthMiddleware = Effect.gen(function* () { const limited = McpInvocationContext.withRuntimeModeCeiling(asked, runtimeModeCeiling); const linkedEnvironments = yield* linksEnabled; if (limited.client?.linked === true && !linkedEnvironments) return linkedEnvironmentsOff; + return { ...limited, linkedEnvironments }; + }); + // Untraced: a span here would wrap the tool call; the HTTP server span already covers it. + return Effect.fnUntraced(function* (httpEffect) { + const admitted = yield* authenticateRequest(yield* HttpServerRequest.HttpServerRequest); + if (HttpServerResponse.isHttpServerResponse(admitted)) return admitted; return yield* httpEffect.pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, { - ...limited, - linkedEnvironments, - }), + Effect.provideService(McpInvocationContext.McpInvocationContext, admitted), Effect.map(normalizeMcpHttpResponse), ); }) satisfies McpAuthMiddleware; @@ -1022,6 +1026,36 @@ const withToolAnalytics = return yield* registration.pipe(Effect.provideService(McpServer.McpServer, recordingServer)); }); +/** Each tool call gets a span named for its tool; arguments stay out, as they carry prompts and IDs. */ +const withToolSpans = Effect.fn("McpHttpServer.withToolSpans")(function* ( + registration: Effect.Effect, +) { + const server = yield* McpServer.McpServer; + const tracingServer = McpServer.McpServer.of({ + ...server, + addTool: Effect.fn("McpServer.addTool")(function (options) { + const name = options.tool.name; + return server.addTool({ + ...options, + // Never the current span: Toolkit.handle annotates that one with the raw arguments. + handle: (payload) => + Effect.useSpan( + `mcp.tool.${name}`, + { + attributes: { + "rpc.system": "mcp", + "rpc.method": "tools/call", + "mcp.tool.name": name, + }, + }, + () => options.handle(payload), + ), + }); + }), + }); + return yield* registration.pipe(Effect.provideService(McpServer.McpServer, tracingServer)); +}); + /** `McpServer.registerToolkit` advertises the full codec schema; agents get the compact one. */ const withAdvertisedSchemas = Effect.fn("McpHttpServer.withAdvertisedSchemas")(function* < Tools extends Record, @@ -1074,7 +1108,9 @@ export const toolkitRegistration = , EX, ) => Layer.effectDiscard( withToolAnalytics( - withAdvertisedSchemas(toolkit, McpServer.registerToolkit(decodeAsAdvertised(toolkit))), + withToolSpans( + withAdvertisedSchemas(toolkit, McpServer.registerToolkit(decodeAsAdvertised(toolkit))), + ), ), ).pipe( Layer.provide( @@ -1090,7 +1126,7 @@ const imageToolRegistration = , A, E, R, register: Effect.Effect, handlers: McpToolAccess.HandlersLayer, ) => - Layer.effectDiscard(withToolAnalytics(register)).pipe( + Layer.effectDiscard(withToolAnalytics(withToolSpans(register))).pipe( Layer.provide(McpToolAccess.HandlersLayer.layer(handlers)), ); diff --git a/docs/operations/observability.md b/docs/operations/observability.md index ab27a5f87c607..3edebc634cd79 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -63,6 +63,11 @@ span spent waiting for that connection, behind other SQL. That wait does not blo so subtract it before you blame a slow span on the query. Statements inside a transaction use its connection and do not carry these attributes. +An MCP request over HTTP has a `McpHttpServer.authenticateRequest` span that covers only admitting +it: the credential check, the mode limit, and the Linked environments gate. The request runs after +it, under the `McpServer.…/` span, and each tool call has its own `mcp.tool.` span +that times it. That span's attributes name the tool, never its arguments. + DPoP proof failures include the safe `environment.dpop.failure_code` span attribute. A `time_window` failure means that a signed proof was too old or too far in the future for the environment server's allowed window. It can point to