fix: address Copilot review round 2 on #2318

- fetchTracking: media types and HTTP methods compare case-insensitively;
  a `Request` input's own method is honoured; the SSE line scanner
  accepts LF, CRLF and a bare CR, holding a trailing CR until the next
  chunk decides whether it is half of a CRLF.
- MessageTrackingTransport does not report a send failure for a request
  the caller aborted through `requestSignal`, and InspectorClient keeps a
  request whose send failed with a request timeout — both are sent,
  unanswered requests, which is the state to keep reporting.
- createTransportNode builds a fetch tracker when any tracking callback
  is supplied, not only `onFetchRequest`.
- RemoteClientTransport's relay wait expires with the SDK's own timeout
  shape, so the web client's relay timeout is annotated like every other.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APKSYHGxXBUpnR7ErFiLJn
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-13 22:27:54 -04:00
commit c565b56fcd
5 changed files with 266 additions and 27 deletions

View file

@ -222,11 +222,13 @@ export function isLongLivedStreamResponse(
method: string,
contentType: string | null | undefined,
): boolean {
if (method !== "GET") return false;
if (method.toUpperCase() !== "GET") return false;
if (!contentType) return false;
// Media types are case-insensitive (RFC 9110 §8.3.1); a server may send
// `Text/Event-Stream` and still be sending an event stream.
const type = contentType.toLowerCase();
return (
contentType.includes("text/event-stream") ||
contentType.includes("application/x-ndjson")
type.includes("text/event-stream") || type.includes("application/x-ndjson")
);
}
@ -261,7 +263,9 @@ export type LongLivedStreamFraming = "sse" | "ndjson";
export function longLivedStreamFraming(
contentType: string | null | undefined,
): LongLivedStreamFraming {
return contentType?.includes("application/x-ndjson") ? "ndjson" : "sse";
return contentType?.toLowerCase().includes("application/x-ndjson")
? "ndjson"
: "sse";
}
/**
@ -314,8 +318,7 @@ function watchLongLivedStream(
// stream with no state to show — the count only moves on an event.
update(id, { eventCount });
const consumeLine = (rawLine: string): void => {
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
const consumeLine = (line: string): void => {
if (framing === "ndjson") {
if (line.trim() === "") return;
eventCount += 1;
@ -335,18 +338,35 @@ function watchLongLivedStream(
if (line === "data" || line.startsWith("data:")) blockHasData = true;
};
// Split off every complete line in `pending`. The SSE grammar (and the
// SDK's parser) accept LF, CRLF and a bare CR as a line ending, so all three
// end a line here; a CR that is the last byte so far is held back, since
// the LF of a CRLF pair may still be in flight in the next chunk.
const consumeCompleteLines = (): void => {
for (;;) {
const cr = pending.indexOf("\r");
const lf = pending.indexOf("\n");
const end = cr === -1 ? lf : lf === -1 ? cr : Math.min(cr, lf);
if (end === -1) return;
if (pending[end] === "\r") {
if (end === pending.length - 1) return;
const width = pending[end + 1] === "\n" ? 2 : 1;
consumeLine(pending.slice(0, end));
pending = pending.slice(end + width);
} else {
consumeLine(pending.slice(0, end));
pending = pending.slice(end + 1);
}
}
};
const pump = async (): Promise<void> => {
try {
for (;;) {
const { value, done } = await reader.read();
if (done) break;
pending += decoder.decode(value, { stream: true });
let newline = pending.indexOf("\n");
while (newline !== -1) {
consumeLine(pending.slice(0, newline));
pending = pending.slice(newline + 1);
newline = pending.indexOf("\n");
}
consumeCompleteLines();
}
} catch {
// An errored or aborted stream has still ended — fall through to report
@ -384,7 +404,12 @@ export function createFetchTracker(
: input instanceof URL
? input.toString()
: input.url;
const method = init?.method || "GET";
// The method decides the long-lived-stream classification below, so it
// has to be the one the request actually carries: a `Request` input
// carries its own, and HTTP methods compare case-insensitively.
const method = (
init?.method ?? (input instanceof Request ? input.method : "GET")
).toUpperCase();
// Extract headers, redacting sensitive values BEFORE they reach any
// downstream sink (logger, in-memory list, persisted session storage).

View file

@ -273,6 +273,7 @@ import {
import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
import {
annotateRequestTimeout,
isRequestTimeoutError,
type ConnectionDiagnostics,
type LastResponse,
type NotificationStreamState,
@ -1131,11 +1132,16 @@ export class InspectorClient extends InspectorClientEventTarget {
};
this.dispatchTypedEvent("message", entry);
},
trackSendFailure: (message: JSONRPCRequest) => {
// The frame never reached the wire, so it is not awaiting a response.
// Leaving it would report a request the server never saw as
// unanswered, in every timeout message and Connection Info row, until
// the session resets (#2318).
trackSendFailure: (message: JSONRPCRequest, error: unknown) => {
// A send that failed because the request timed out is not a send
// that failed: the browser's remote transport awaits the response
// inside `send`, so its relay timeout surfaces here — the request
// reached the server and went unanswered, which is the state to
// keep reporting. Every other failure means the frame never reached
// the wire, and leaving it would report a request the server never
// saw as unanswered, in every timeout message and Connection Info
// row, until the session resets (#2318).
if (isRequestTimeoutError(error)) return;
if (this.outstandingRequests.delete(message.id)) {
this.dispatchConnectionDiagnosticsChange();
}

View file

@ -29,7 +29,11 @@ export interface MessageTrackingCallbacks {
* closed, the fetch failed before the frame reached the wire). Fires after
* `trackRequest` already recorded it, so a consumer keeping "requests still
* awaiting a response" can roll that entry back — a request that never went
* out is not unanswered (#2318).
* out is not unanswered (#2318). Not fired when the caller aborted the
* request through `requestSignal`: the modern transport aborts an in-flight
* request's own stream to cancel it (and the SDK does the same on a
* per-request timeout), and a request that was sent and then given up on
* is exactly the unanswered kind.
*/
trackSendFailure?: (message: JSONRPCRequest, error: unknown) => void;
}
@ -106,7 +110,9 @@ export class MessageTrackingTransport implements Transport {
const request = message as JSONRPCRequest;
this.callbacks.trackRequest?.(request, "client");
return this.baseTransport.send(message, options).catch((err) => {
this.callbacks.trackSendFailure?.(request, err);
if (!options?.requestSignal?.aborted) {
this.callbacks.trackSendFailure?.(request, err);
}
throw err;
});
}

View file

@ -55,6 +55,12 @@ export function createTransportNode(
interceptAuthChallenges = false,
onAuthChallengeObserved,
} = options;
// Any of the three tracking callbacks earns a tracker: each is optional on
// the tracker itself, and a caller that wants only stream updates (or only
// bodies) must not be silently handed the bare fetch (#2318).
const wantsFetchTracking = Boolean(
onFetchRequest || onFetchResponseBody || onFetchStreamUpdate,
);
// `optionsFetchFn` is the caller's whole fetch stack and already sits on top of
// a proxy-aware base when one is needed — the Node clients install
@ -115,7 +121,7 @@ export function createTransportNode(
const sseFetch = configuredSseFetch
? withChallengeObserver(configuredSseFetch)
: fetchWithOptionalAuthIntercept;
const trackedFetch = onFetchRequest
const trackedFetch = wantsFetchTracking
? createFetchTracker(sseFetch, {
trackRequest: onFetchRequest,
updateResponseBody: onFetchResponseBody,
@ -136,7 +142,7 @@ export function createTransportNode(
...(headers && { headers }),
};
const postFetch = onFetchRequest
const postFetch = wantsFetchTracking
? createFetchTracker(fetchWithOptionalAuthIntercept, {
trackRequest: onFetchRequest,
updateResponseBody: onFetchResponseBody,
@ -164,7 +170,7 @@ export function createTransportNode(
...(headers && { headers }),
};
const transportFetch = onFetchRequest
const transportFetch = wantsFetchTracking
? createFetchTracker(fetchWithOptionalAuthIntercept, {
trackRequest: onFetchRequest,
updateResponseBody: onFetchResponseBody,

View file

@ -11,6 +11,7 @@ import type {
JSONRPCMessage,
MessageExtraInfo,
} from "@modelcontextprotocol/client";
import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
import type { InspectorServerSettings, StderrLogEntry } from "../types.js";
import type { FetchRequestEntryBase, FetchStreamState } from "../types.js";
import type {
@ -689,10 +690,17 @@ export class RemoteClientTransport implements Transport {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.sseResponseWaits.delete(requestId);
// The SDK's own per-request timeout shape, not a plain `Error`: this
// wait can expire before the SDK's timer does (its budget is the
// relay's, not the request's), and the rejection travels up through
// `Client.request`, where `InspectorClient` annotates a timeout of
// exactly this shape with the connection's state (#2318). A plain
// error would pass that decorator untouched and reach the user as a
// bare timeout on the web client alone.
reject(
new Error(
`Timed out waiting for MCP response on SSE (${this.sseResponseTimeoutMs}ms)`,
),
new SdkError(SdkErrorCode.RequestTimeout, "Request timed out", {
timeout: this.sseResponseTimeoutMs,
}),
);
}, this.sseResponseTimeoutMs);
this.sseResponseWaits.set(requestId, {