Skip to content

Commit e055c59

Browse files
authored
fix(mcp): derive the remote request-level ok from the tool result, not HTTP status (#10231)
enableJsonResponse means a refused tools/call is still HTTP 200, so the legacy mcp_tool_call event and the product-usage outcome -- both keyed off response.status -- reported every refusal as a success even though the dispatch chokepoint's own usage_event already recorded it as a failure. handleMcpRequest now reads the JSON-RPC body the same way it already reads the request, falling back to the status on any parse failure, and only for tools/call so every other request keeps its current behavior. Co-authored-by: bitfathers94 <237535319+bitfathers94@users.noreply.github.com>
1 parent 6946385 commit e055c59

4 files changed

Lines changed: 105 additions & 5 deletions

File tree

src/mcp/dispatch-telemetry.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,11 @@ function describe(toolName: string): { category: string; excluded: boolean } {
8787
* Wrap one tool handler with dispatch telemetry.
8888
*
8989
* `ok` follows the CALLER-VISIBLE outcome: a handler that reports failure by returning an error
90-
* envelope did not succeed, even though it never threw. That matches what the HTTP-level telemetry
91-
* has always recorded (`response.status < 400`) so the two views of the same call agree.
90+
* envelope did not succeed, even though it never threw. The HTTP-level telemetry in src/mcp/server.ts
91+
* used to derive its own view from `response.status < 400` alone, which never agreed with this
92+
* `ok` for a refused `tools/call` -- `enableJsonResponse: true` means a refusal is still HTTP 200.
93+
* `handleMcpRequest` now reads the same JSON-RPC body this wrapper's caller produced (`result.isError`
94+
* / a top-level `error`) before falling back to the status, so the two views agree there too.
9295
*/
9396
export function instrumentToolDispatch<TArgs extends unknown[], TResult extends ToolResultLike>(
9497
toolName: string,

src/mcp/server.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -691,12 +691,17 @@ export async function handleMcpRequest(c: AppContext): Promise<Response> {
691691
const server = mcp.createServer();
692692
try {
693693
const response = await createMcpHandler(server, { route: "/mcp", enableJsonResponse: true })(c.req.raw, c.env, executionCtx);
694+
const statusOk = response.status < 400;
695+
// #10035: `enableJsonResponse: true` above means a refused `tools/call` is still HTTP 200 -- the
696+
// failure lives in the JSON-RPC body (`result.isError`), not the status line. Only a `tools/call`
697+
// carries that body shape, so every other request keeps the status-derived outcome unchanged.
698+
const ok = usageMetadata.rpcMethod === "tools/call" ? await resolveMcpToolCallOk(response, statusOk) : statusOk;
694699
if (typeof usageMetadata.toolName === "string") {
695-
executionCtx.waitUntil(recordMcpToolTelemetry(c.env, usageMetadata.toolName, response.status < 400, Date.now() - startedAt));
700+
executionCtx.waitUntil(recordMcpToolTelemetry(c.env, usageMetadata.toolName, ok, Date.now() - startedAt));
696701
}
697702
// #10175: PostHog's canonical protocol-level events. Only on a request that actually succeeded, so
698703
// a rejected handshake never inflates the session/client counts.
699-
if (response.status < 400) {
704+
if (statusOk) {
700705
if (usageMetadata.rpcMethod === "initialize") {
701706
recordMcpInitialize(c.env, defer, readInitializeHandshake(envelope), analyticsContext);
702707
} else if (usageMetadata.rpcMethod === "tools/list") {
@@ -712,7 +717,7 @@ export async function handleMcpRequest(c: AppContext): Promise<Response> {
712717
route: "/mcp",
713718
actor: identity.actor,
714719
sessionId: identity.kind === "session" ? identity.session.id : undefined,
715-
outcome: response.status >= 400 ? "error" : "success",
720+
outcome: ok ? "success" : "error",
716721
latencyMs: Date.now() - startedAt,
717722
clientName: telemetry.clientName,
718723
clientVersion: telemetry.clientVersion,
@@ -793,6 +798,29 @@ async function readMcpRequestEnvelope(request: Request): Promise<McpRequestEnvel
793798
return body && typeof body === "object" ? (body as McpRequestEnvelope) : null;
794799
}
795800

801+
/** The JSON-RPC response fields that reveal a `tools/call`'s CALLER-VISIBLE outcome: a top-level `error`
802+
* (the request itself was rejected) or a `result.isError` envelope (the tool answered no). Structural and
803+
* permissive like {@link McpRequestEnvelope}, for the same reason -- this is the MCP SDK's own response,
804+
* not a contract this module owns. */
805+
type McpToolCallResponseEnvelope = { error?: unknown; result?: { isError?: unknown } };
806+
807+
/**
808+
* Derive a `tools/call` response's `ok` from its JSON-RPC body rather than the HTTP status (#10035):
809+
* `enableJsonResponse: true` above means a refused tool call is still a 200, so `statusOk` alone reports a
810+
* clean sheet for every refusal. Reads the response the same way {@link readMcpRequestEnvelope} reads the
811+
* request -- clone before consuming, so the caller's own response body is untouched -- and falls back to
812+
* `statusOk` on any parse failure rather than throwing: telemetry must never turn a working call into a
813+
* failed one (src/mcp/dispatch-telemetry.ts's own guarantee).
814+
*/
815+
export async function resolveMcpToolCallOk(response: Response, statusOk: boolean): Promise<boolean> {
816+
const body = await response.clone().json().catch(() => null);
817+
if (!body || typeof body !== "object") return statusOk;
818+
const envelope = body as McpToolCallResponseEnvelope;
819+
if (envelope.error) return false;
820+
if (envelope.result?.isError === true) return false;
821+
return statusOk;
822+
}
823+
796824
function describeMcpUsageRequest(
797825
envelope: McpRequestEnvelope | null,
798826
method: string,

test/integration/api.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5844,6 +5844,45 @@ describe("api routes", () => {
58445844
expect(JSON.stringify(mcpUsageEvents)).not.toMatch(/oktofeesh1|\/Users|github_pat|ghp_|source code|wallet|hotkey|raw trust/i);
58455845
}, 15_000);
58465846

5847+
it("records a refused MCP tool call as a telemetry failure, not a success (#10035)", async () => {
5848+
const app = createApp();
5849+
const env = createTestEnv();
5850+
const { token: mcpSessionToken } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 12345 });
5851+
5852+
const refusedToolCall = await app.request(
5853+
"/mcp",
5854+
{
5855+
method: "POST",
5856+
headers: { ...mcpHeaders(env), authorization: `Bearer ${mcpSessionToken}` },
5857+
body: JSON.stringify({
5858+
jsonrpc: "2.0",
5859+
id: "wrong-login-10035",
5860+
method: "tools/call",
5861+
params: { name: "loopover_get_decision_pack", arguments: { login: "someone-else" } },
5862+
}),
5863+
},
5864+
env,
5865+
);
5866+
// enableJsonResponse means a refused tool call is still HTTP 200 -- the failure lives in the
5867+
// JSON-RPC body, not the status line.
5868+
expect(refusedToolCall.status).toBe(200);
5869+
await expect(mcpJson(refusedToolCall)).resolves.toMatchObject({
5870+
result: { isError: true, content: [expect.objectContaining({ text: expect.stringContaining("authenticated GitHub login") })] },
5871+
});
5872+
5873+
const usageEvents = await listProductUsageEvents(env, { limit: 20 });
5874+
expect(usageEvents).toEqual(
5875+
expect.arrayContaining([
5876+
expect.objectContaining({
5877+
surface: "mcp",
5878+
eventName: "mcp_tool_called",
5879+
outcome: "error",
5880+
metadata: expect.objectContaining({ toolName: "loopover_get_decision_pack", rpcMethod: "tools/call" }),
5881+
}),
5882+
]),
5883+
);
5884+
});
5885+
58475886
it("gates the MCP contributor profile and redacts miner financial fields", async () => {
58485887
const app = createApp();
58495888
const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "oktofeesh1,other" });

test/unit/mcp-dispatch-telemetry.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
} from "@loopover/contract";
2929
import { FORBIDDEN_CONTENT } from "../../scripts/forbidden-content";
3030
import { instrumentToolDispatch, NOOP_DISPATCH_SINK, type DispatchTelemetrySink } from "../../src/mcp/dispatch-telemetry";
31+
import { resolveMcpToolCallOk } from "../../src/mcp/server";
3132

3233
const call: McpToolCallTelemetry = { tool: "loopover_get_repo_context", category: "maintainer", surface: "remote", ok: true, durationMs: 12 };
3334

@@ -529,3 +530,32 @@ describe("PostHog canonical MCP analytics contract (#10175)", () => {
529530
expect(buildMcpToolsListProperties([]).$mcp_listed_tool_names).toEqual([]);
530531
});
531532
});
533+
534+
describe("resolveMcpToolCallOk (#10035)", () => {
535+
it("reports failure for a tools/call response whose result carries isError", async () => {
536+
const response = new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { isError: true, content: [] } }), { status: 200 });
537+
await expect(resolveMcpToolCallOk(response, true)).resolves.toBe(false);
538+
});
539+
540+
it("reports failure for a tools/call response with a top-level JSON-RPC error", async () => {
541+
const response = new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, error: { code: -32602, message: "bad params" } }), { status: 200 });
542+
await expect(resolveMcpToolCallOk(response, true)).resolves.toBe(false);
543+
});
544+
545+
it("reports success for a normal tool result", async () => {
546+
const response = new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { structuredContent: {} } }), { status: 200 });
547+
await expect(resolveMcpToolCallOk(response, true)).resolves.toBe(true);
548+
});
549+
550+
it("falls back to the status-derived outcome when the body does not parse as JSON, rather than throwing", async () => {
551+
const response = new Response("not-json", { status: 200 });
552+
await expect(resolveMcpToolCallOk(response, true)).resolves.toBe(true);
553+
await expect(resolveMcpToolCallOk(response.clone(), false)).resolves.toBe(false);
554+
});
555+
556+
it("leaves the response body available for the caller after reading it for telemetry", async () => {
557+
const response = new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { isError: true, content: [] } }), { status: 200 });
558+
await resolveMcpToolCallOk(response, true);
559+
await expect(response.json()).resolves.toMatchObject({ result: { isError: true } });
560+
});
561+
});

0 commit comments

Comments
 (0)