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
29 changes: 27 additions & 2 deletions apps/cloud/src/mcp/agent-handler.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as OtelTracer from "@effect/opentelemetry/Tracer";
import { Effect, Predicate } from "effect";

import {
Expand All @@ -17,8 +18,10 @@ import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-
import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";

import { wrapMcpSseResponse } from "../observability/memory-metrics";
import { WorkerTelemetryLive } from "../observability/telemetry";
import { cloudMcpAuth } from "./auth-provider";
import { McpSessionDOSqlite } from "./session-durable-object";
import { parseTraceparent } from "./traceparent";

const corsPreflightResponse = (): Response =>
new Response(null, {
Expand Down Expand Up @@ -82,6 +85,28 @@ const authenticate = (request: Request) =>
return { auth, outcome };
}).pipe(Effect.provide(cloudMcpAuth));

// The pre-Agents envelope ran the MCP auth path inside the Effect app, whose
// HttpMiddleware provided the OTEL tracer — that is where the `mcp.request`
// span (client fingerprint, rpc method, auth outcome) exported from. This
// handler dispatches from the raw worker entry instead, so a bare
// `Effect.runPromise` leaves every span in Effect's no-op default tracer and
// they silently never export. Run each program under the worker telemetry
// layer, parented to the edge `http.server` span server.ts stamps onto the
// forwarded request's traceparent — which also makes `currentPropagationHeaders`
// (via Effect.currentParentSpan) ferry that same trace into the session DO
// instead of letting the DO start a fresh root per request.
const runTraced = <A>(request: Request, program: Effect.Effect<A>): Promise<A> => {
const parsed = parseTraceparent(
request.headers.get("traceparent"),
request.headers.get("tracestate"),
);
return Effect.runPromise(
(parsed ? OtelTracer.withSpanContext(program, parsed) : program).pipe(
Effect.provide(WorkerTelemetryLive),
),
);
};

// The MCP resource the request targets. `server.ts` routes both the bare `/mcp`
// and `/mcp/toolkits/<slug>` to this handler (`prepareMcpOrgScope` strips the org
// selector but keeps the toolkit segment), so a session minted on a toolkit path
Expand Down Expand Up @@ -142,7 +167,7 @@ export const makeCloudMcpAgentHandler = () => {
}
const sessionId = request.headers.get("mcp-session-id");

const { auth, outcome } = await Effect.runPromise(authenticate(request));
const { auth, outcome } = await runTraced(request, authenticate(request));
if (!Predicate.isTagged(outcome, "Authenticated")) {
// Destroying a live session on auth grounds requires a POSITIVE
// determination that access is genuinely gone — only `Forbidden` carries
Expand Down Expand Up @@ -191,7 +216,7 @@ export const makeCloudMcpAgentHandler = () => {
}

const resource = resourceFromPath(request);
const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal, resource));
const props = await runTraced(request, propsForPrincipal(request, outcome.principal, resource));
(ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
const forwarded = withVerifiedIdentityHeaders(
request,
Expand Down
30 changes: 1 addition & 29 deletions apps/cloud/src/mcp/session-durable-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
// ---------------------------------------------------------------------------

import { env } from "cloudflare:workers";
import { createTraceState } from "@opentelemetry/api";
import { Data, Effect, Layer } from "effect";
import type { Cause } from "effect";
import * as OtelTracer from "@effect/opentelemetry/Tracer";
Expand Down Expand Up @@ -73,6 +72,7 @@ import {
captureCauseEffect as reportCauseEffect,
tagCurrentSentryScopeWithCurrentOtelSpan,
} from "../observability";
import { parseTraceparent } from "./traceparent";

// Re-export the shared types so existing cloud importers
// (`auth/handlers.ts`, etc.) keep their `../mcp/session-durable-object` path.
Expand Down Expand Up @@ -113,34 +113,6 @@ class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForward
readonly cause: unknown;
}> {}

// W3C propagation across the worker→DO boundary. The worker injects its
// `traceparent` and forwards incoming `tracestate` / `baggage`; we parse the
// context and use `OtelTracer.withSpanContext` to stitch the DO's root span
// under the worker span so the entire logical request lives in one trace.
const TRACEPARENT_PATTERN = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;

type IncomingSpanContext = {
readonly traceId: string;
readonly spanId: string;
readonly traceFlags: number;
readonly traceState?: ReturnType<typeof createTraceState>;
};

const parseTraceparent = (
traceparent: string | null | undefined,
tracestate: string | null | undefined,
): IncomingSpanContext | null => {
if (!traceparent) return null;
const match = TRACEPARENT_PATTERN.exec(traceparent);
if (!match) return null;
return {
traceId: match[2]!,
spanId: match[3]!,
traceFlags: parseInt(match[4]!, 16),
...(tracestate ? { traceState: createTraceState(tracestate) } : {}),
};
};

/**
* The DO keeps one postgres.js client for the MCP session runtime. postgres.js
* closes idle sockets quickly, while the runtime object stays alive so the MCP
Expand Down
32 changes: 32 additions & 0 deletions apps/cloud/src/mcp/traceparent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// ---------------------------------------------------------------------------
// W3C traceparent parsing shared by the worker edge (server.ts), the MCP agent
// handler, and the session DO. Single-sourced so the producer (the worker span
// stamping traceparent onto the forwarded request) and the consumers (the
// Effect programs joining that span) cannot drift on the header grammar.
// ---------------------------------------------------------------------------

import { createTraceState } from "@opentelemetry/api";

const TRACEPARENT_PATTERN = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;

export type IncomingSpanContext = {
readonly traceId: string;
readonly spanId: string;
readonly traceFlags: number;
readonly traceState?: ReturnType<typeof createTraceState>;
};

export const parseTraceparent = (
traceparent: string | null | undefined,
tracestate: string | null | undefined,
): IncomingSpanContext | null => {
if (!traceparent) return null;
const match = TRACEPARENT_PATTERN.exec(traceparent);
if (!match) return null;
return {
traceId: match[2]!,
spanId: match[3]!,
traceFlags: parseInt(match[4]!, 16),
...(tracestate ? { traceState: createTraceState(tracestate) } : {}),
};
};
76 changes: 56 additions & 20 deletions apps/cloud/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import handler from "@tanstack/react-start/server-entry";
import { isAppOwnedPath } from "./app-paths";
import { makeCloudMcpAgentHandler } from "./mcp/agent-handler";
import { classifyMcpPath, prepareMcpOrgScope } from "./mcp/mount";
import { parseTraceparent } from "./mcp/traceparent";
import { McpSessionDOSqlite as McpSessionDOBase } from "./mcp/session-durable-object";
import {
beforeSendWithOtelCorrelation,
Expand Down Expand Up @@ -124,16 +125,66 @@ const cloudflareHandler: ExportedHandler<Env> = {
const url = new URL(request.url);
const mcpRoute = classifyMcpPath(url.pathname);
const tracingInstalled = installTracerProvider();
// Join the caller's W3C trace when the request carries one — the web UI
// sends traceparent on every API fetch, so the browser's spans and this
// request share one trace id end to end. Same parsing the DO path does
// in session-durable-object.ts.
const inbound = parseTraceparent(request.headers.get("traceparent"), null);
const parentContext = inbound
? trace.setSpanContext(context.active(), {
traceId: inbound.traceId,
spanId: inbound.spanId,
traceFlags: inbound.traceFlags,
isRemote: true,
})
: context.active();
if (mcpRoute?.kind === "mcp") {
// The Cloudflare Agents MCP bridge needs the platform ExecutionContext
// to pass authenticated session props into the hibernatable DO.
// Discovery docs still flow through the app-level MCP envelope.
// oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; keep trace export alive after the Agents bridge resolves or rejects
try {
return await mcpAgentHandler(prepareMcpOrgScope(request), env, ctx);
} finally {
if (tracingInstalled) ctx.waitUntil(flushTracerProvider());
const forwarded = prepareMcpOrgScope(request);
if (!tracingInstalled) {
return mcpAgentHandler(forwarded, env, ctx);
}
// /mcp left the Effect app in the Agents-bridge migration, so no
// downstream HttpMiddleware.tracer opens the request envelope anymore —
// this worker span is now THE `http.server` span for MCP traffic. Its
// context is stamped onto the forwarded request's traceparent so the
// agent handler's Effect programs (mcp.request and children) and the
// session DO parent under it instead of exporting orphaned roots.
return tracer.startActiveSpan(
`http.server ${request.method}`,
{ kind: SpanKind.SERVER },
parentContext,
async (span) => {
span.setAttribute(ATTR_HTTP_REQUEST_METHOD, request.method);
span.setAttribute(ATTR_URL_FULL, request.url);
span.setAttribute(ATTR_URL_PATH, url.pathname);
span.setAttribute(ATTR_URL_SCHEME, url.protocol.replace(/:$/, ""));
const spanContext = span.spanContext();
const headers = new Headers(forwarded.headers);
headers.set(
"traceparent",
`00-${spanContext.traceId}-${spanContext.spanId}-${(spanContext.traceFlags & 0xff).toString(16).padStart(2, "0")}`,
);
// oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; observe response/error for span status, keep trace export alive after the Agents bridge resolves or rejects
try {
const response = await mcpAgentHandler(new Request(forwarded, { headers }), env, ctx);
span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, response.status);
if (response.status >= 500) {
span.setStatus({ code: SpanStatusCode.ERROR });
}
return response;
} catch (err) {
span.setStatus({ code: SpanStatusCode.ERROR });
// oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; preserve original error to Cloudflare runtime
throw err;
} finally {
span.end();
ctx.waitUntil(flushTracerProvider());
}
},
);
}
if (!tracingInstalled) {
return fetchHandler(request, env, ctx);
Expand All @@ -150,21 +201,6 @@ const cloudflareHandler: ExportedHandler<Env> = {
ctx.waitUntil(flushTracerProvider());
}
}
// Join the caller's W3C trace when the request carries one — the web UI
// sends traceparent on every API fetch, so the browser's spans and this
// request share one trace id end to end. Same parsing the DO path does
// in session-durable-object.ts.
const traceparentMatch = /^[0-9a-f]{2}-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/.exec(
request.headers.get("traceparent") ?? "",
);
const parentContext = traceparentMatch
? trace.setSpanContext(context.active(), {
traceId: traceparentMatch[1]!,
spanId: traceparentMatch[2]!,
traceFlags: parseInt(traceparentMatch[3]!, 16),
isRemote: true,
})
: context.active();
return tracer.startActiveSpan(
`http.server ${request.method}`,
{ kind: SpanKind.SERVER },
Expand Down
106 changes: 106 additions & 0 deletions e2e/cloud/mcp-telemetry-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Cloud: the /mcp observability contract. Every MCP request must land in the
// EXPORTED trace store as an `mcp.request` span carrying the client
// fingerprint (clientInfo name/version from initialize) and the auth verdict.
// This is the per-client visibility production debugging depends on: which
// client is failing auth, which client sees slow initializes.
//
// Pins the regression shipped with the hibernatable-DO migration (2026-06-28):
// /mcp moved off the Effect app onto the raw worker Agents bridge, whose
// `Effect.runPromise` ran the auth path under Effect's no-op default tracer —
// spans were still created, but never exported. Absence looks exactly like
// health, so the contract is asserted where the data is read (the OTLP store
// the dev stack exports to, the same exporter layer that ships prod spans to
// Axiom).
import { randomUUID } from "node:crypto";

import { expect } from "@effect/vitest";
import { Effect } from "effect";

import { scenario } from "../src/scenario";
import { Mcp, Target, Telemetry } from "../src/services";
import type { Identity } from "../src/target";

const JSON_AND_SSE = "application/json, text/event-stream";
const CLIENT_NAME = "executor-e2e-telemetry-probe";
const CLIENT_VERSION = "9.9.9";

const initializeRequest = {
jsonrpc: "2.0" as const,
id: 1,
method: "initialize",
params: {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: { name: CLIENT_NAME, version: CLIENT_VERSION },
},
};

const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label;

const mcpPost = (url: string | URL, bearer: string | null, body: unknown): Promise<Response> =>
fetch(url, {
method: "POST",
headers: {
accept: JSON_AND_SSE,
"content-type": "application/json",
...(bearer ? { authorization: `Bearer ${bearer}` } : {}),
},
body: JSON.stringify(body),
});

scenario(
"MCP telemetry · an authenticated initialize exports mcp.request with the client fingerprint",
{},
Effect.gen(function* () {
const target = yield* Target;
const mcp = yield* Mcp;
const telemetry = yield* Telemetry;
const identity = yield* target.newIdentity();
const bearer = yield* mcp.mintBearer(emailOf(identity));

const response = yield* Effect.promise(() => mcpPost(target.mcpUrl, bearer, initializeRequest));
yield* Effect.promise(() => response.text());
expect(response.status, "initialize opens a session").toBe(200);

const span = yield* telemetry.expectSpan({
operation: "mcp.request",
attributes: { "mcp.client.name": CLIENT_NAME },
});
expect(span.span.tags["mcp.client.version"], "the client version is fingerprinted").toBe(
CLIENT_VERSION,
);
expect(span.span.tags["mcp.rpc.method"], "the rpc method is visible").toBe("initialize");
expect(span.span.tags["mcp.auth.verified"], "the auth verdict is on the span").toBe("true");
}),
);

scenario(
"MCP telemetry · a rejected bearer exports mcp.request with the failure fingerprint",
{},
Effect.gen(function* () {
const target = yield* Target;
const telemetry = yield* Telemetry;

// A garbage bearer: the 401 is the designed first step of the OAuth flow,
// and per-client 401 attribution is exactly what production debugging of
// "client X cannot log in" runs on.
const marker = `rejected-${randomUUID().slice(0, 8)}`;
const response = yield* Effect.promise(() =>
mcpPost(new URL(`/mcp?probe=${marker}`, target.baseUrl), "bogus.bogus.bogus", {
...initializeRequest,
params: {
...initializeRequest.params,
clientInfo: { name: marker, version: CLIENT_VERSION },
},
}),
);
yield* Effect.promise(() => response.text());
expect(response.status, "the bearer is rejected").toBe(401);

const span = yield* telemetry.expectSpan({
operation: "mcp.request",
attributes: { "mcp.auth.has_bearer": "true", "mcp.auth.verified": "false" },
});
expect(span.span.tags["mcp.request.method"], "the request method is on the span").toBe("POST");
}),
);
Loading
Loading