Context
src/mcp/server.ts's handleMcpRequest (the single entrypoint for every /mcp tools/call request) calls
recordMcpToolTelemetry(...) synchronously and un-awaited, immediately before returning the response (or
before re-throwing):
export async function handleMcpRequest(c: AppContext): Promise<Response> {
...
try {
const response = await createMcpHandler(...)(...);
if (typeof usageMetadata.toolName === "string") {
recordMcpToolTelemetry(c.env, usageMetadata.toolName, response.status < 400, Date.now() - startedAt);
}
await recordProductUsageEvent(c.env, { ... }).catch(() => undefined);
return response;
} catch (error) {
if (typeof usageMetadata.toolName === "string") {
recordMcpToolTelemetry(c.env, usageMetadata.toolName, false, Date.now() - startedAt);
}
...
throw error;
}
}
function recordMcpToolTelemetry(env: Env, tool: string, ok: boolean, durationMs: number): void {
try {
recordMcpToolCall(env, { tool, callerType: "remote", ok, durationMs });
} catch { ... }
}
recordMcpToolCall (src/mcp/telemetry.ts) does real async I/O internally — it constructs a new PostHog(apiKey, { host, flushAt: 1, flushInterval: 0 }) and calls client.capture(...), which schedules a
network request to PostHog. recordMcpToolTelemetry is a synchronous (non-async) function that never
awaits or returns that work, and handleMcpRequest never passes it through
c.executionCtx.waitUntil(...) either — it just falls through to await recordProductUsageEvent(...) (a
different, already-awaited call) and then return response;.
On Cloudflare Workers, once a request handler returns its Response (or the request context otherwise
finishes), any in-flight I/O that was not registered via waitUntil is not guaranteed to complete — the
runtime can tear down the execution context mid-flight. That means the PostHog capture() call from
recordMcpToolCall can be silently cut off before its HTTP request to api/us.i.posthog.com (or the
configured POSTHOG_HOST) actually lands, dropping the tool-call event this whole #6228/#6237 telemetry
feature exists to record — with no error surfaced anywhere, since recordMcpToolCall and
recordMcpToolTelemetry both intentionally swallow every error by design (try { } catch { }, documented as
"Telemetry must never affect the tool response").
This repo has an established, already-used pattern for exactly this situation — deferring background work
past the point the response is returned, via Cloudflare's waitUntil:
src/orb/webhook.ts's scheduleAfterResponse(c, task) wraps (c.executionCtx as ...).waitUntil(task)
(falling back to fire-and-forget only when there is no execution context, e.g. a unit-test harness or the
self-host server's own shim), specifically so a slow background task "can't delay the webhook ACK past
GitHub's ~10s delivery deadline" — the same shape of problem (must not block the response, but must not be
silently dropped either).
src/mcp/server.ts itself already has direct access to the execution context in this exact function via
getExecutionContext(c) (used two lines earlier to construct createMcpHandler(...)'s third argument), so
the fix requires no new plumbing.
Requirements
recordMcpToolTelemetry's call to recordMcpToolCall (both call sites inside handleMcpRequest — the
success path and the catch path) must be scheduled via c.executionCtx.waitUntil(...) (or
getExecutionContext(c).waitUntil(...), already available in this function), not fired synchronously and
discarded.
recordMcpToolCall itself is synchronous today (returns void, not Promise<void>) because
client.capture() is fire-and-forget by the PostHog SDK's own design — the fix must give waitUntil
something awaitable to hold the execution context open for (e.g. by having recordMcpToolCall await
client.flush() / client.shutdownAsync() internally and return that promise, or by wrapping the call at
the recordMcpToolTelemetry call site).
- Must not change
recordMcpToolCall's no-throw guarantee (telemetry failures still must never surface into
the MCP tool response) or its "safe no-op when POSTHOG_API_KEY is unset" behavior.
- Must not delay
handleMcpRequest's own response — the fix defers the telemetry work past the response via
waitUntil, it does not make handleMcpRequest await it inline (that would reintroduce the added-latency
tradeoff recordProductUsageEvent's direct await already accepts for a different call).
Deliverables
Test Coverage Requirements
Both src/mcp/server.ts and src/mcp/telemetry.ts are under src/**, so codecov/patch (99% target,
branch-counted, 0% threshold) gates this change directly on every changed line/branch. Cover both the
success-path and catch-path call sites in handleMcpRequest, and the new awaitable-flush path in
recordMcpToolCall/telemetry.ts — including the already-existing "unconfigured / capture throws" no-op
branches, which must remain covered after the change.
Expected Outcome
MCP tool-call telemetry events (mcp_tool_call in PostHog) are reliably delivered instead of being
silently dropped whenever the Workers runtime tears down the request's execution context before an
unregistered background fetch completes — bringing recordMcpToolCall's dispatch in line with this repo's
own established waitUntil convention for exactly this class of "must not block the response, must not be
silently lost" background work.
Links & Resources
Context
src/mcp/server.ts'shandleMcpRequest(the single entrypoint for every/mcptools/callrequest) callsrecordMcpToolTelemetry(...)synchronously and un-awaited, immediately before returning the response (orbefore re-throwing):
recordMcpToolCall(src/mcp/telemetry.ts) does real async I/O internally — it constructs anew PostHog(apiKey, { host, flushAt: 1, flushInterval: 0 })and callsclient.capture(...), which schedules anetwork request to PostHog.
recordMcpToolTelemetryis a synchronous (non-async) function that neverawaits or returns that work, and
handleMcpRequestnever passes it throughc.executionCtx.waitUntil(...)either — it just falls through toawait recordProductUsageEvent(...)(adifferent, already-awaited call) and then
return response;.On Cloudflare Workers, once a request handler returns its
Response(or the request context otherwisefinishes), any in-flight I/O that was not registered via
waitUntilis not guaranteed to complete — theruntime can tear down the execution context mid-flight. That means the PostHog
capture()call fromrecordMcpToolCallcan be silently cut off before its HTTP request toapi/us.i.posthog.com(or theconfigured
POSTHOG_HOST) actually lands, dropping the tool-call event this whole#6228/#6237telemetryfeature exists to record — with no error surfaced anywhere, since
recordMcpToolCallandrecordMcpToolTelemetryboth intentionally swallow every error by design (try { } catch { }, documented as"Telemetry must never affect the tool response").
This repo has an established, already-used pattern for exactly this situation — deferring background work
past the point the response is returned, via Cloudflare's
waitUntil:src/orb/webhook.ts'sscheduleAfterResponse(c, task)wraps(c.executionCtx as ...).waitUntil(task)(falling back to fire-and-forget only when there is no execution context, e.g. a unit-test harness or the
self-host server's own shim), specifically so a slow background task "can't delay the webhook ACK past
GitHub's ~10s delivery deadline" — the same shape of problem (must not block the response, but must not be
silently dropped either).
src/mcp/server.tsitself already has direct access to the execution context in this exact function viagetExecutionContext(c)(used two lines earlier to constructcreateMcpHandler(...)'s third argument), sothe fix requires no new plumbing.
Requirements
recordMcpToolTelemetry's call torecordMcpToolCall(both call sites insidehandleMcpRequest— thesuccess path and the
catchpath) must be scheduled viac.executionCtx.waitUntil(...)(orgetExecutionContext(c).waitUntil(...), already available in this function), not fired synchronously anddiscarded.
recordMcpToolCallitself is synchronous today (returnsvoid, notPromise<void>) becauseclient.capture()is fire-and-forget by the PostHog SDK's own design — the fix must givewaitUntilsomething awaitable to hold the execution context open for (e.g. by having
recordMcpToolCallawaitclient.flush()/client.shutdownAsync()internally and return that promise, or by wrapping the call atthe
recordMcpToolTelemetrycall site).recordMcpToolCall's no-throw guarantee (telemetry failures still must never surface intothe MCP tool response) or its "safe no-op when
POSTHOG_API_KEYis unset" behavior.handleMcpRequest's own response — the fix defers the telemetry work past the response viawaitUntil, it does not makehandleMcpRequestawaitit inline (that would reintroduce the added-latencytradeoff
recordProductUsageEvent's directawaitalready accepts for a different call).Deliverables
recordMcpToolTelemetry(...)call sites inhandleMcpRequest(success and catch branches) runinside
getExecutionContext(c).waitUntil(...)(or equivalent), matchingscheduleAfterResponse'spattern in
src/orb/webhook.ts.recordMcpToolCall(or a thin wrapper) exposes a promise that resolves once the PostHog client hasactually flushed the event, so
waitUntilhas real work to hold open.POSTHOG_API_KEYset), the capture callis passed to the execution context's
waitUntilrather than only fired synchronously and forgotten.Test Coverage Requirements
Both
src/mcp/server.tsandsrc/mcp/telemetry.tsare undersrc/**, socodecov/patch(99% target,branch-counted, 0% threshold) gates this change directly on every changed line/branch. Cover both the
success-path and catch-path call sites in
handleMcpRequest, and the new awaitable-flush path inrecordMcpToolCall/telemetry.ts— including the already-existing "unconfigured / capture throws" no-opbranches, which must remain covered after the change.
Expected Outcome
MCP tool-call telemetry events (
mcp_tool_callin PostHog) are reliably delivered instead of beingsilently dropped whenever the Workers runtime tears down the request's execution context before an
unregistered background fetch completes — bringing
recordMcpToolCall's dispatch in line with this repo'sown established
waitUntilconvention for exactly this class of "must not block the response, must not besilently lost" background work.
Links & Resources
src/mcp/server.ts(handleMcpRequest,recordMcpToolTelemetry,getExecutionContext)src/mcp/telemetry.ts(recordMcpToolCall)src/orb/webhook.ts(scheduleAfterResponse, wrapsc.executionCtx.waitUntil)tool-dispatch chokepoint)