fix: address Copilot review round 1 on #2318

- fetchTracking: count NDJSON streams line by line instead of leaving
  them at zero forever; justify the detached watcher promise; document
  that a partial SSE block at end-of-stream is dropped, as the SDK's own
  parser (no flush) and the SSE spec drop it.
- uncapturedBodyNote: case-insensitive content-type lookup, and a
  stream label (SSE / NDJSON) for the Network badge.
- InspectorClient: `openedAt` is when the response headers arrived, not
  when the GET went out; the snapshot copies its request records; a
  request the transport failed to send is rolled back out of the
  unanswered set via a new `trackSendFailure` callback; a raw-wire
  timeout is built as the SDK's own timeout shape and annotated the
  same way, so callers see one kind of timeout.
- Remote session storage revives the nested `stream.closedAt` Date.
- Hook test models a legacy double with `Object.defineProperty`, not a
  double cast.

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:03:00 -04:00
commit 1a5dd6820d
9 changed files with 267 additions and 20 deletions

View file

@ -17,6 +17,7 @@ import { RiErrorWarningLine } from "react-icons/ri";
import type { FetchRequestEntry } from "@inspector/core/mcp/types.js";
import {
isLongLivedStreamEntry,
longLivedStreamLabel,
uncapturedBodyNote,
} from "../../../utils/uncapturedBodyNote";
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
@ -549,7 +550,9 @@ export function NetworkEntry({
{entry.duration != null && (
<DurationText>{formatDuration(entry.duration)}</DurationText>
)}
{isLongLivedStreamEntry(entry) && <StreamBadge>SSE</StreamBadge>}
{isLongLivedStreamEntry(entry) && (
<StreamBadge>{longLivedStreamLabel(entry)}</StreamBadge>
)}
<Badge color={statusColor(entry)} variant="status">
{statusLabel(entry)}
</Badge>

View file

@ -792,6 +792,40 @@ describe("createFetchTracker long-lived stream watching (#2318)", () => {
expect(updates.map((u) => u.stream.eventCount)).toEqual([0, 1, 2]);
});
it("counts every non-blank line on an NDJSON stream", async () => {
const server = controlledStream();
const updates: Array<{ id: string; stream: FetchStreamState }> = [];
const fetcher = createFetchTracker(
(async () =>
new Response(server.stream, {
headers: { "content-type": "application/x-ndjson" },
})) as typeof fetch,
{ updateStream: (id, state) => updates.push({ id, stream: state }) },
);
await fetcher("https://example.com/mcp", { method: "GET" });
server.push('{"jsonrpc":"2.0","method":"a"}\n\n');
server.push('{"jsonrpc":"2.0","method":"b"}\r\n');
// A partial line is not an event until its newline arrives.
server.push('{"jsonrpc":"2.0",');
await flush();
expect(updates.map((u) => u.stream.eventCount)).toEqual([0, 1, 2]);
server.push('"method":"c"}\n');
await flush();
expect(updates.at(-1)!.stream.eventCount).toBe(3);
});
it("drops a partial SSE block at end-of-stream, as the SDK's parser does", async () => {
const server = controlledStream();
const { updates } = await trackStream(server.stream);
// A data line with no dispatching blank line before the stream ends.
server.push("data: one\n\ndata: two\n");
server.end();
await flush();
const last = updates.at(-1)!.stream;
expect(last.eventCount).toBe(1);
expect(last.closedAt).toBeInstanceOf(Date);
});
it("reports the close when the server ends the stream", async () => {
const server = controlledStream();
const { updates, id } = await trackStream(server.stream);

View file

@ -1,4 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { SdkErrorCode } from "@modelcontextprotocol/client";
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import { ModernGetTaskResultSchema } from "@inspector/core/mcp/modernTaskSchemas.js";
@ -134,7 +135,15 @@ describe("InspectorClient raw-wire channel (#1631)", () => {
{},
ModernGetTaskResultSchema,
);
const assertion = expect(promise).rejects.toThrow(/timed out/);
// The same annotated shape an SDK request's timeout carries (#2318):
// the SDK's code, the budget, the method, and the connection state.
const assertion = expect(promise).rejects.toMatchObject({
code: SdkErrorCode.RequestTimeout,
message: expect.stringMatching(
/^Request timed out after 10ms \(tasks\/get\)\. No requests are unanswered\./,
),
data: expect.objectContaining({ timeout: 10, method: "tasks/get" }),
});
await vi.advanceTimersByTimeAsync(20);
await assertion;
} finally {

View file

@ -33,6 +33,30 @@ function makeTracked() {
}
describe("MessageTrackingTransport.send", () => {
it("reports a request the base transport failed to send, after tracking it (#2318)", async () => {
const { callbacks, base, tracked } = makeTracked();
const trackSendFailure = vi.fn();
const failing = new MessageTrackingTransport(base, {
...callbacks,
trackSendFailure,
});
const boom = new Error("connection closed");
base.send = async () => {
throw boom;
};
const request = { jsonrpc: "2.0", id: 1, method: "tools/list" } as const;
await expect(failing.send(request)).rejects.toBe(boom);
expect(callbacks.trackRequest).toHaveBeenCalledWith(request, "client");
expect(trackSendFailure).toHaveBeenCalledWith(request, boom);
// Only requests roll back: a failed notification has no pending entry.
trackSendFailure.mockClear();
await expect(
failing.send({ jsonrpc: "2.0", method: "notifications/initialized" }),
).rejects.toBe(boom);
expect(trackSendFailure).not.toHaveBeenCalled();
void tracked;
});
it("tracks an outgoing request", async () => {
const { callbacks, tracked } = makeTracked();
const request = { jsonrpc: "2.0", id: 1, method: "tools/list" } as const;

View file

@ -110,6 +110,45 @@ describe("RemoteInspectorClientStorage", () => {
);
});
it("loadSession revives a stream's closedAt alongside the entry timestamp (#2318)", async () => {
const fetchFn = vi.fn<typeof fetch>(
async () =>
new Response(
JSON.stringify({
fetchRequests: [
{
id: "open",
timestamp: "2026-01-01T00:00:00.000Z",
method: "GET",
url: "http://example.com/mcp",
requestHeaders: {},
stream: { eventCount: 2 },
},
{
id: "closed",
timestamp: "2026-01-01T00:00:00.000Z",
method: "GET",
url: "http://example.com/mcp",
requestHeaders: {},
stream: { eventCount: 3, closedAt: "2026-01-01T00:00:05.000Z" },
},
],
createdAt: 1735689600000,
updatedAt: 1735689600001,
}),
{ status: 200 },
),
);
const storage = makeStorage(fetchFn);
const state = await storage.loadSession("sid");
// An open stream's state passes through untouched.
expect(state?.fetchRequests[0]?.stream).toEqual({ eventCount: 2 });
const closed = state?.fetchRequests[1]?.stream;
expect(closed?.eventCount).toBe(3);
expect(closed?.closedAt).toBeInstanceOf(Date);
expect(closed?.closedAt?.toISOString()).toBe("2026-01-01T00:00:05.000Z");
});
it("loadSession preserves Date timestamps already provided as Date instances", async () => {
const fetchFn = vi.fn<typeof fetch>(
async () =>

View file

@ -90,9 +90,9 @@ describe("useInspectorClient", () => {
const client = new FakeInspectorClient();
// `getConnectionDiagnostics` is optional on the protocol; a double that
// predates it must still yield the one shape consumers expect.
(
client as unknown as { getConnectionDiagnostics?: unknown }
).getConnectionDiagnostics = undefined;
Object.defineProperty(client, "getConnectionDiagnostics", {
value: undefined,
});
const { result } = renderHook(() => useInspectorClient(client));
expect(result.current.connectionDiagnostics).toEqual({
capturedAt: 0,

View file

@ -271,6 +271,21 @@ describe("InspectorClient request-timeout diagnostics (#2318)", () => {
const stream = c.getConnectionDiagnostics().notificationStream!;
expect(stream.url).toBe(server.url);
expect(stream.closedAt).toBeUndefined();
// Open from when the headers arrived, not from when the GET went out.
const getEntryAtOpen = log
.getFetchRequests()
.find((entry) => entry.method === "GET")!;
expect(stream.openedAt).toBe(
getEntryAtOpen.timestamp.getTime() + (getEntryAtOpen.duration ?? 0),
);
// The snapshot is a copy: mutating it does not reach the client.
const snapshot = c.getConnectionDiagnostics();
snapshot.outstandingRequests.push({
id: "x",
method: "bogus",
sentAt: 0,
});
expect(c.getConnectionDiagnostics().outstandingRequests).toEqual([]);
// A server push on the notification stream is an event.
server.pushEvent({

View file

@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import type { FetchRequestEntry } from "@inspector/core/mcp/types.js";
import {
isLongLivedStreamEntry,
longLivedStreamLabel,
uncapturedBodyNote,
} from "./uncapturedBodyNote";
@ -26,6 +27,27 @@ describe("isLongLivedStreamEntry", () => {
isLongLivedStreamEntry({ ...streamEntry, responseHeaders: undefined }),
).toBe(false);
});
it("reads the content type whatever casing the entry recorded it with", () => {
expect(
isLongLivedStreamEntry({
...streamEntry,
responseHeaders: { "Content-Type": "text/event-stream" },
}),
).toBe(true);
});
});
describe("longLivedStreamLabel", () => {
it("names the framing the stream uses", () => {
expect(longLivedStreamLabel(streamEntry)).toBe("SSE");
expect(
longLivedStreamLabel({
...streamEntry,
responseHeaders: { "Content-Type": "application/x-ndjson" },
}),
).toBe("NDJSON");
});
});
describe("uncapturedBodyNote", () => {

View file

@ -1,17 +1,38 @@
// The Network entry's body placeholder for a response whose body was not
// captured (#2318). Pure: a string from the entry alone.
import { isLongLivedStreamResponse } from "@inspector/core/mcp/fetchTracking.js";
import {
findHeader,
isLongLivedStreamResponse,
longLivedStreamFraming,
} from "@inspector/core/mcp/fetchTracking.js";
import type { FetchRequestEntry } from "@inspector/core/mcp/types.js";
/** Whether the entry is an unbounded server-to-client stream (never buffered). */
/**
* Whether the entry is an unbounded server-to-client stream (never
* buffered). The header lookup is case-insensitive: a recorded entry keeps
* the wire casing (`Content-Type` on some hosts, and in a restored session),
* and a miss here would silently reclassify the stream as a bounded body.
*/
export function isLongLivedStreamEntry(entry: FetchRequestEntry): boolean {
return isLongLivedStreamResponse(
entry.method,
entry.responseHeaders?.["content-type"],
findHeader(entry.responseHeaders, "content-type"),
);
}
/**
* The badge label for a long-lived stream entry — `SSE` or `NDJSON`, by the
* same content-type split the tracker counts events with.
*/
export function longLivedStreamLabel(entry: FetchRequestEntry): string {
return longLivedStreamFraming(
findHeader(entry.responseHeaders, "content-type"),
) === "ndjson"
? "NDJSON"
: "SSE";
}
/**
* What stands in for a body that was not captured. A long-lived stream is
* never buffered, so its placeholder reports what the tracker learned by