Merge remote-tracking branch 'origin/v2/main' into v2/feat/2294-skills-catalog-budget-settings

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-14 01:22:26 -04:00
commit 621b712229
6 changed files with 3427 additions and 63 deletions

View file

@ -58,6 +58,7 @@ export function createRemoteTransport(
onStderr: transportOptions.onStderr,
onFetchRequest: transportOptions.onFetchRequest,
onFetchResponseBody: transportOptions.onFetchResponseBody,
onFetchStreamUpdate: transportOptions.onFetchStreamUpdate,
authProvider: transportOptions.authProvider,
settings: transportOptions.settings,
},

View file

@ -4,7 +4,7 @@
import type { Transport } from "@modelcontextprotocol/client";
import type { JSONRPCMessage } from "@modelcontextprotocol/client";
import type { FetchRequestEntryBase } from "../../types.js";
import type { FetchRequestEntryBase, FetchStreamState } from "../../types.js";
import type { RemoteEvent } from "../types.js";
import type { AuthChallenge } from "../../../auth/challenge.js";
import { AuthChallengeError } from "../../../auth/challenge.js";
@ -328,6 +328,17 @@ export class RemoteSession {
});
}
onFetchStreamUpdate(id: string, stream: FetchStreamState): void {
this.pushEvent({
type: "fetch_stream_update",
data: {
id,
eventCount: stream.eventCount,
...(stream.closedAt && { closedAt: stream.closedAt.toISOString() }),
},
});
}
onStderr(entry: { timestamp: Date; message: string }): void {
this.pushEvent({
type: "stdio_log",

View file

@ -869,6 +869,8 @@ export function createRemoteApp(
onFetchRequest: (entry) => session.onFetchRequest(entry),
onFetchResponseBody: (id, body) =>
session.onFetchResponseBody(id, body),
onFetchStreamUpdate: (id, stream) =>
session.onFetchStreamUpdate(id, stream),
authProvider: authHandle?.provider,
settings: body.settings,
// Always intercept 401/403 on the node MCP transport. Without this, the

View file

@ -11,8 +11,10 @@ import type {
JSONRPCMessage,
MessageExtraInfo,
} from "@modelcontextprotocol/client";
import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
import { findHeader, isLongLivedStreamResponse } from "../fetchTracking.js";
import type { InspectorServerSettings, StderrLogEntry } from "../types.js";
import type { FetchRequestEntryBase } from "../types.js";
import type { FetchRequestEntryBase, FetchStreamState } from "../types.js";
import type {
AuthChallenge,
AuthChallengeOutcome,
@ -61,6 +63,9 @@ export interface RemoteTransportOptions {
/** Callback for async response-body updates to a previously tracked fetch. */
onFetchResponseBody?: (id: string, responseBody: string) => void;
/** Callback for a previously tracked long-lived stream's events and close (#2318). */
onFetchStreamUpdate?: (id: string, stream: FetchStreamState) => void;
/** Optional OAuth client provider for Bearer authentication */
authProvider?: import("@modelcontextprotocol/client").OAuthClientProvider;
@ -261,6 +266,14 @@ export class RemoteClientTransport implements Transport {
private eventStreamConsumeTask: Promise<void> | null = null;
private restartingEventStream = false;
private closed = false;
/**
* Long-lived streams the backend has reported open, by fetch entry id, with
* the last event count seen for each. `close()` reports them closed
* itself: it aborts the event channel before asking the backend to
* disconnect, so the backend watcher's own close report lands on an
* unconsumed queue and is lost with the session (#2318).
*/
private openStreams = new Map<string, number>();
private readonly sseResponseWaits = new Map<
string | number,
SseResponseWait
@ -539,12 +552,23 @@ export class RemoteClientTransport implements Transport {
this.resetSseResponseWait(progressToken);
}
this.onmessage?.(msg, undefined);
} else if (
parsed.type === "fetch_request" &&
this.options.onFetchRequest
) {
} else if (parsed.type === "fetch_request") {
const entry = parsed.data;
this.options.onFetchRequest({
// Stream bookkeeping runs whether or not the consumer logs
// requests: `onFetchStreamUpdate` is documented to work on its
// own, and `close()` needs to know which streams are open. It
// exists only to serve that callback, though — without one the
// set would only ever grow, one entry per reconnect.
if (
this.options.onFetchStreamUpdate &&
isLongLivedStreamResponse(
entry.method,
findHeader(entry.responseHeaders, "content-type"),
)
) {
this.openStreams.set(entry.id, 0);
}
this.options.onFetchRequest?.({
...entry,
timestamp:
typeof entry.timestamp === "string"
@ -559,6 +583,19 @@ export class RemoteClientTransport implements Transport {
parsed.data.id,
parsed.data.responseBody,
);
} else if (
parsed.type === "fetch_stream_update" &&
this.options.onFetchStreamUpdate
) {
const { id, eventCount, closedAt } = parsed.data;
if (closedAt !== undefined) this.openStreams.delete(id);
else if (this.openStreams.has(id)) {
this.openStreams.set(id, eventCount);
}
this.options.onFetchStreamUpdate(id, {
eventCount,
...(closedAt !== undefined && { closedAt: new Date(closedAt) }),
});
} else if (parsed.type === "stdio_log" && this.options.onStderr) {
this.options.onStderr({
timestamp: new Date(parsed.data.timestamp),
@ -693,10 +730,17 @@ export class RemoteClientTransport implements Transport {
const arm = () => {
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);
};
@ -836,6 +880,13 @@ export class RemoteClientTransport implements Transport {
this.closed = true;
this.cancelAllSseWaits(new Error("Transport closed"));
await this.stopEventStream();
// The disconnect below ends every stream the backend still holds open,
// and its close reports cannot reach us any more (see `openStreams`).
const closedAt = new Date();
for (const [id, eventCount] of this.openStreams) {
this.options.onFetchStreamUpdate?.(id, { eventCount, closedAt });
}
this.openStreams.clear();
if (this._sessionId) {
try {

View file

@ -119,7 +119,10 @@ export class RemoteInspectorClientStorage implements InspectorClientStorage {
const data = (await res.json()) as InspectorClientSessionState;
// Deserialize state (convert ISO strings back to Date objects)
// Deserialize state (convert ISO strings back to Date objects). The
// nested `stream.closedAt` is a Date too, and `JSON.stringify` turned it
// into an ISO string on the way out; left as a string it would fail the
// `getTime()` every consumer of the type performs (#2318).
return {
...data,
fetchRequests: data.fetchRequests.map((req) => ({
@ -130,6 +133,9 @@ export class RemoteInspectorClientStorage implements InspectorClientStorage {
: req.timestamp instanceof Date
? req.timestamp
: new Date(req.timestamp),
...(req.stream?.closedAt !== undefined && {
stream: { ...req.stream, closedAt: new Date(req.stream.closedAt) },
}),
})),
};
}

View file

@ -137,6 +137,7 @@ export type RemoteEventType =
| "message"
| "fetch_request"
| "fetch_request_body_update"
| "fetch_stream_update"
| "stdio_log"
| "transport_error"
| "auth_challenge";
@ -156,6 +157,16 @@ export interface RemoteEventFetchRequestBodyUpdate {
data: { id: string; responseBody: string };
}
/**
* A long-lived stream's lifecycle, forwarded from the backend's fetch tracker
* (#2318). `closedAt` is an ISO string on the wire; the browser transport
* rebuilds the `Date` the way it does for `fetch_request`'s timestamp.
*/
export interface RemoteEventFetchStreamUpdate {
type: "fetch_stream_update";
data: { id: string; eventCount: number; closedAt?: string };
}
export interface RemoteEventStdioLog {
type: "stdio_log";
data: { timestamp: string; message: string };
@ -178,6 +189,7 @@ export type RemoteEvent =
| RemoteEventMessage
| RemoteEventFetchRequest
| RemoteEventFetchRequestBodyUpdate
| RemoteEventFetchStreamUpdate
| RemoteEventStdioLog
| RemoteEventTransportError
| RemoteEventAuthChallenge;