Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions apps/server/src/mcp/McpHttpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<Tracer.NativeSpan> = [];
const order: Array<string> = [];
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<string, unknown>) =>
Effect.gen(function* () {
const server = yield* McpServer.McpServer;
Expand Down Expand Up @@ -178,19 +199,29 @@ 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 }]);
expect(result.structuredContent).toMatchObject({
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(
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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",
Expand All @@ -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)),
);
Expand Down
52 changes: 44 additions & 8 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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* <A, E, R>(
registration: Effect.Effect<A, E, R>,
) {
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<string, Tool.Any>,
Expand Down Expand Up @@ -1074,7 +1108,9 @@ export const toolkitRegistration = <Tools extends Record<string, Tool.Any>, EX,
) =>
Layer.effectDiscard(
withToolAnalytics(
withAdvertisedSchemas(toolkit, McpServer.registerToolkit(decodeAsAdvertised(toolkit))),
withToolSpans(
withAdvertisedSchemas(toolkit, McpServer.registerToolkit(decodeAsAdvertised(toolkit))),
),
),
).pipe(
Layer.provide(
Expand All @@ -1090,7 +1126,7 @@ const imageToolRegistration = <Tools extends Record<string, Tool.Any>, A, E, R,
register: Effect.Effect<A, E, R>,
handlers: McpToolAccess.HandlersLayer<Tools, EX, RX>,
) =>
Layer.effectDiscard(withToolAnalytics(register)).pipe(
Layer.effectDiscard(withToolAnalytics(withToolSpans(register))).pipe(
Layer.provide(McpToolAccess.HandlersLayer.layer(handlers)),
);

Expand Down
5 changes: 5 additions & 0 deletions docs/operations/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.…/<method>` span, and each tool call has its own `mcp.tool.<name>` 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
Expand Down
Loading