inspector/clients/web/src/components/groups/protocolUtils.ts
Cliff Hall 320daf2f67
Resources: subscriptions/listen model on modern servers (era fork) (#1630) (#1730)
Fork resource subscriptions by protocol era: legacy keeps resources/subscribe; modern (2026-07-28) models subscriptions as a filter over one long-lived subscriptions/listen stream with reconnect-by-re-listen (capped backoff), an optimistic Connecting→Listening header badge, and a fix so the browser proxy no longer blocks listen() ~60s. Closes #1630.
2026-07-20 19:33:40 -04:00

154 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { MessageEntry, MessageMethod } from "@inspector/core/mcp/types.js";
import {
isInputRequiredResult,
SUBSCRIPTION_ID_META_KEY,
} from "@modelcontextprotocol/client";
export function extractMethod(entry: MessageEntry): MessageMethod {
if ("method" in entry.message) {
// Cast: SDK types message.method as `string`, but every entry in this
// app's MessageEntry log originates from MCP SDK schemas.
return entry.message.method as MessageMethod;
}
return "response";
}
/**
* Request methods the Protocol Replay action can re-issue (client→server reads
* and calls). Server→client requests (roots/list, sampling, elicitation) and
* side-effectful methods (logging/setLevel, subscribe) are intentionally
* excluded. Single source of truth: `ProtocolEntry` hides the Replay button for
* anything not listed here, and App's `replayProtocolRequest` gates dispatch on
* the same set.
*/
export const REPLAYABLE_PROTOCOL_METHODS: ReadonlySet<string> = new Set([
"tools/call",
"prompts/get",
"resources/read",
"tools/list",
"prompts/list",
"resources/list",
"resources/templates/list",
"tasks/list",
"ping",
]);
export function isReplayableProtocolMethod(method: string): boolean {
return REPLAYABLE_PROTOCOL_METHODS.has(method);
}
// --- Modern-era (2026-07-28) message vocabulary -----------------------------
//
// The modern era changes the over-the-wire conversation (spec §7.2–7.4): every
// result carries a `resultType`, server→client interactions become MRTR (the
// server *returns* `input_required` and the client *retries* with a new id),
// and push notifications move to a `subscriptions/listen` stream. These helpers
// read that vocabulary off the transport-level `MessageEntry` log so the
// Protocol view can render and correlate it. IMPORTANT: they classify individual
// *frames* — they must NOT be used to decide a connection's era. The modern
// probe carries the `_meta` envelope before the era is negotiated, so "saw an
// envelope/resultType" ≠ "modern negotiated" (spec §8.3). Era labeling comes
// from the negotiated `protocolEra` connection state, threaded in as a prop.
// The successful `result` object of a request entry's paired response, or
// undefined when there is no response or it was an error. (messageLogState folds
// a request's response onto the request entry by JSON-RPC id.)
function getResponseResult(
entry: MessageEntry,
): Record<string, unknown> | undefined {
const response = entry.response;
if (!response || "error" in response) return undefined;
const result = response.result;
return result && typeof result === "object"
? (result as Record<string, unknown>)
: undefined;
}
function getMessageParams(
entry: MessageEntry,
): Record<string, unknown> | undefined {
const msg = entry.message;
if (!("params" in msg) || !msg.params) return undefined;
return msg.params as Record<string, unknown>;
}
/**
* The modern `resultType` discriminator on a request's paired result:
* `"input_required"` (the server needs input before it can complete) or
* `"complete"`. Undefined for legacy results (no `resultType` on the wire),
* errors, notifications, and pending requests — so a `resultType` badge only
* shows where the modern era actually put one.
*/
export function extractResultType(
entry: MessageEntry,
): "complete" | "input_required" | undefined {
const result = getResponseResult(entry);
if (!result) return undefined;
if (isInputRequiredResult(result)) return "input_required";
return result.resultType === "complete" ? "complete" : undefined;
}
/**
* The opaque MRTR `requestState` token that links the rounds of one logical
* operation across multiple JSON-RPC ids. It appears on the `input_required`
* *result* (original call) and is echoed back in the *params* of the retried
* request (spec §7.3). Returns undefined for non-MRTR traffic.
*/
export function extractRequestState(entry: MessageEntry): string | undefined {
const result = getResponseResult(entry);
const fromResult = result?.requestState;
if (typeof fromResult === "string" && fromResult.length > 0)
return fromResult;
const params = getMessageParams(entry);
const fromParams = params?.requestState;
if (typeof fromParams === "string" && fromParams.length > 0)
return fromParams;
return undefined;
}
/**
* The `subscriptionId` a modern push notification is tagged with, carried in
* `params._meta` under `io.modelcontextprotocol/subscriptionId` (spec §7.4).
* Undefined for untagged frames.
*/
export function extractSubscriptionId(entry: MessageEntry): string | undefined {
const params = getMessageParams(entry);
const meta = params?._meta as Record<string, unknown> | undefined;
const id = meta?.[SUBSCRIPTION_ID_META_KEY];
return typeof id === "string" ? id : undefined;
}
/**
* A rendered row in the Protocol list: either a single message entry, or an
* MRTR conversation — the contiguous run of entries sharing one `requestState`
* (original call → `input_required` → retried call → final result), grouped so
* one logical operation renders as one expandable unit.
*/
export type ProtocolRow =
| { kind: "single"; entry: MessageEntry }
| { kind: "mrtr"; requestState: string; rounds: MessageEntry[] };
/**
* Fold a (already filtered/sorted) entry list into rows, clustering contiguous
* entries that share a non-empty `requestState` into one MRTR row. Contiguity is
* safe because the SDK auto-fulfils MRTR input in-process (no intervening wire
* frames) so an operation's rounds are adjacent in the log. Order is preserved;
* everything without a `requestState` stays a `single` row.
*/
export function groupProtocolEntries(entries: MessageEntry[]): ProtocolRow[] {
const rows: ProtocolRow[] = [];
for (const entry of entries) {
const requestState = extractRequestState(entry);
if (requestState) {
const last = rows[rows.length - 1];
if (last?.kind === "mrtr" && last.requestState === requestState) {
last.rounds.push(entry);
continue;
}
rows.push({ kind: "mrtr", requestState, rounds: [entry] });
continue;
}
rows.push({ kind: "single", entry });
}
return rows;
}