inspector/core/mcp/messageTrackingTransport.ts
cliffhall ec09628705 fix: address Copilot review round 3 on #2318
- fetchTracking: the close report is guarded so a throwing listener
  cannot become an unhandled rejection off the discarded watcher; a
  bare CR that is the last byte of the stream terminates its event; a
  moving event count is coalesced to one report per interval (default
  250ms, 0 in tests), with the open and the close still immediate.
- MessageTrackingTransport rolls back a request whose signal was already
  aborted before the send began, and suppresses the rollback only for an
  abort that lands during the send.
- useTickingClock takes an `enabled` flag; Connection Info installs no
  interval when there is no activity section.
- The diagnostics-callbacks test justifies its private-seam 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>
2026-09-13 22:53:08 -04:00

270 lines
10 KiB
TypeScript

import type {
Transport,
TransportSendOptions,
} from "@modelcontextprotocol/client";
import type {
JSONRPCMessage,
MessageExtraInfo,
} from "@modelcontextprotocol/client";
import type {
JSONRPCRequest,
JSONRPCNotification,
JSONRPCResultResponse,
JSONRPCErrorResponse,
} from "@modelcontextprotocol/client";
import type { MessageOrigin } from "./types.js";
export interface MessageTrackingCallbacks {
trackRequest?: (message: JSONRPCRequest, origin: MessageOrigin) => void;
trackResponse?: (
message: JSONRPCResultResponse | JSONRPCErrorResponse,
origin: MessageOrigin,
) => void;
trackNotification?: (
message: JSONRPCNotification,
origin: MessageOrigin,
) => void;
/**
* An outgoing request the base transport failed to send (the connection
* 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). Not fired when the caller aborted the
* request through `requestSignal` *during* the send: 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. A signal already aborted
* before the send began is a failed send like any other.
*/
trackSendFailure?: (message: JSONRPCRequest, error: unknown) => void;
}
/**
* Optional rewrite of an incoming response BEFORE it reaches the SDK's codec.
* Used for extension result shapes the SDK v2 codec would reject outright — e.g.
* a modern (SEP-2663) `resultType: "task"` result, which the codec has no
* knowledge of (tasks were removed from the SDK). The ORIGINAL message is still
* what `trackResponse` logs (so the Protocol/Network tabs show the true wire);
* only the copy handed to the SDK is rewritten. Return the message unchanged to
* pass it through untouched.
*/
export type IncomingResultRewriter = (
message: JSONRPCResultResponse,
) => JSONRPCMessage;
/**
* Optional consumer for an incoming response the SDK Client did not originate —
* used for the raw-wire channel that drives extension methods the SDK v2 era
* gate refuses to send (e.g. modern `tasks/get`/`tasks/update`/`tasks/cancel`,
* which are spec-method names absent from the 2026-07-28 era). When this returns
* `true` the response is treated as fully handled and is NOT forwarded to the
* SDK Client (which has no pending request for it). The response is still logged
* by `trackResponse` first, so the Protocol/Network tabs see the true frame.
*/
export type IncomingResponseConsumer = (
message: JSONRPCResultResponse | JSONRPCErrorResponse,
) => boolean;
export interface MessageTrackingHooks {
rewriteIncomingResult?: IncomingResultRewriter;
consumeIncomingResponse?: IncomingResponseConsumer;
}
// Transport wrapper that intercepts all messages for tracking
export class MessageTrackingTransport implements Transport {
private baseTransport: Transport;
private callbacks: MessageTrackingCallbacks;
private negotiatedProtocolVersion?: string;
private hooks: MessageTrackingHooks;
constructor(
baseTransport: Transport,
callbacks: MessageTrackingCallbacks,
hooks: MessageTrackingHooks = {},
) {
this.baseTransport = baseTransport;
this.callbacks = callbacks;
this.hooks = hooks;
}
async start(): Promise<void> {
return this.baseTransport.start();
}
async send(
message: JSONRPCMessage,
options?: TransportSendOptions,
): Promise<void> {
// Track outgoing traffic symmetrically to onmessage. The client issues
// requests (client→server), answers server→client requests — roots/list,
// sampling, elicitation (responses, which messageLogState folds back into
// the originating request by id) — and emits its own notifications
// (initialized, progress, roots/list_changed). All are tagged origin
// "client".
if ("id" in message && message.id !== null && message.id !== undefined) {
if ("result" in message || "error" in message) {
this.callbacks.trackResponse?.(
message as JSONRPCResultResponse | JSONRPCErrorResponse,
"client",
);
} else if ("method" in message) {
const request = message as JSONRPCRequest;
this.callbacks.trackRequest?.(request, "client");
// A signal already aborted here means the frame will not go out at
// all — that is a failed send. One that becomes aborted while the
// send is in flight is a cancellation of a request that may well
// have reached the server, which stays unanswered.
const abortedBeforeSend = options?.requestSignal?.aborted === true;
return this.baseTransport.send(message, options).catch((err) => {
if (abortedBeforeSend || !options?.requestSignal?.aborted) {
this.callbacks.trackSendFailure?.(request, err);
}
throw err;
});
}
} else if ("method" in message) {
this.callbacks.trackNotification?.(
message as JSONRPCNotification,
"client",
);
}
return this.baseTransport.send(message, options);
}
async close(): Promise<void> {
return this.baseTransport.close();
}
get onclose(): (() => void) | undefined {
return this.baseTransport.onclose;
}
set onclose(handler: (() => void) | undefined) {
this.baseTransport.onclose = handler;
}
get onerror(): ((error: Error) => void) | undefined {
return this.baseTransport.onerror;
}
set onerror(handler: ((error: Error) => void) | undefined) {
this.baseTransport.onerror = handler;
}
get onmessage():
| (<T extends JSONRPCMessage>(message: T, extra?: MessageExtraInfo) => void)
| undefined {
return this.baseTransport.onmessage;
}
set onmessage(
handler:
| (<T extends JSONRPCMessage>(
message: T,
extra?: MessageExtraInfo,
) => void)
| undefined,
) {
if (handler) {
// Wrap the handler to track incoming messages
this.baseTransport.onmessage = <T extends JSONRPCMessage>(
message: T,
extra?: MessageExtraInfo,
) => {
// Track incoming messages
if (
"id" in message &&
message.id !== null &&
message.id !== undefined
) {
// Check if it's a response (has 'result' or 'error' property)
if ("result" in message || "error" in message) {
this.callbacks.trackResponse?.(
message as JSONRPCResultResponse | JSONRPCErrorResponse,
"server",
);
// Consume a response to a raw-wire request the SDK never sent (e.g.
// a modern `tasks/get`); handled entirely by the caller, not the SDK.
if (
this.hooks.consumeIncomingResponse?.(
message as JSONRPCResultResponse | JSONRPCErrorResponse,
)
) {
return;
}
// Rewrite a result the SDK codec can't decode (e.g. a modern
// `resultType: "task"` handle) AFTER logging the true wire, so the
// SDK receives a shape it accepts while the Protocol/Network tabs
// still show the real frame.
if (this.hooks.rewriteIncomingResult && "result" in message) {
const rewritten = this.hooks.rewriteIncomingResult(
message as JSONRPCResultResponse,
);
if (rewritten !== message) {
handler(rewritten as T, extra);
return;
}
}
} else if ("method" in message) {
// This is a request coming from the server
this.callbacks.trackRequest?.(message as JSONRPCRequest, "server");
}
} else if ("method" in message) {
// Notification (no ID, has method)
this.callbacks.trackNotification?.(
message as JSONRPCNotification,
"server",
);
}
// Call the original handler
handler(message, extra);
};
} else {
this.baseTransport.onmessage = undefined;
}
}
get sessionId(): string | undefined {
return this.baseTransport.sessionId;
}
/**
* Forward the base transport's per-request-stream capability (#2140).
*
* The SDK's `Protocol.request` reads this off the transport it was handed —
* which is always this wrapper — to decide how a cancelled request is
* signalled: abort that request's own stream (the 2026-07-28 signal for
* Streamable HTTP) or POST a `notifications/cancelled` (the stdio one).
*
* Not forwarding it made the wrapper *answer* the question, `undefined`, for
* every client. So the Cancel button POSTed a notification even on a modern
* Streamable HTTP connection, where a spec-compliant server acknowledges it
* `202` and drops it — the tool kept running and only a disconnect really
* cancelled anything. That hit the CLI and TUI as much as the web client:
* their real `StreamableHTTPClientTransport` advertises the flag correctly
* and this wrapper hid it.
*
* A base transport that says nothing stays `undefined` rather than becoming
* `false`, since the SDK's check is `=== true` either way and inventing a
* value here would misreport what the transport claimed.
*/
get hasPerRequestStream(): boolean | undefined {
return this.baseTransport.hasPerRequestStream;
}
// Implemented as a concrete method (rather than delegating the base
// transport's optional `setProtocolVersion`) so the SDK Client always
// invokes it after the initialize handshake — including for stdio, whose
// base transport has no `setProtocolVersion`. We capture the negotiated
// version for the UI here, then forward to the base transport when it
// cares (HTTP transports stamp it into subsequent request headers).
setProtocolVersion(version: string): void {
this.negotiatedProtocolVersion = version;
this.baseTransport.setProtocolVersion?.(version);
}
/** MCP protocol version negotiated during initialize, once connected. */
get protocolVersion(): string | undefined {
return this.negotiatedProtocolVersion;
}
}