inspector/clients/web/src/components/groups/protocolUtils.ts
Cliff Hall 73d9b181fe
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
History: MRTR conversation grouping + modern message vocabulary (#1705)
* History: MRTR conversation grouping + modern message vocabulary

Teach the Protocol/History view the modern (2026-07-28) message vocabulary,
correlating it off the frames the transport already captures — no change to how
MRTR is driven (manual driving is deferred to #1704).

- Correlation helpers in protocolUtils: extractResultType, extractRequestState,
  extractSubscriptionId, isModernFrame, and groupProtocolEntries — clustering the
  contiguous rounds of one logical operation (original call -> input_required ->
  retried call with a new id -> final result) that share a requestState token.
- New MrtrConversation group renders that cluster as one expandable unit with a
  status derived from the final round, reusing ProtocolEntry for each round.
- ProtocolEntry: resultType badge (input required / complete), a "modern" frame
  badge for server/discover, subscriptions/listen, and the acknowledged
  notification, and a copyable subscriptionId.
- ProtocolListPanel folds each section through the grouping pass and shows an era
  badge in the header. Era is threaded from the negotiated protocolEra
  (App -> InspectorView -> ProtocolScreen -> panel) and never inferred from
  frames, respecting the transcript-classification caveat.
- Shared EraBadge element (+ eraUtils); ConnectionInfoContent reuses it.
- test-servers/configs/modern-mrtr-http.json serves an elicitation tool over the
  modern leg for eyeballing a real MRTR round-trip.
- Pre-bundle @modelcontextprotocol/client in optimizeDeps.include so the
  Storybook vitest project doesn't re-optimize mid-run once protocolUtils pulls
  the client barrel into the browser graph.

Closes #1627

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test-servers: real MRTR tool + live-verified History grouping

While driving the modern flow in the browser, the elicitation-based config
proved unable to produce MRTR: the modern (2026-07-28) SDK server rejects
`server.elicitInput` — server→client requests aren't allowed on that leg; a
tool must instead return `inputRequired(...)`. So the shipped
`modern-mrtr-http.json` (and its README claim) were wrong.

- Add a genuine MRTR tool preset (`mrtr_confirm` / `createMrtrTool`): its handler
  returns `inputRequired({ inputRequests: { confirm: elicit(...) } })` on the
  first call and the final result once the client echoes the answer back on the
  retry.
- Teach the composable test-server to support it: the tool wrapper forwards an
  `InputRequiredResult` verbatim (instead of coercing it to a CallToolResult),
  and `toHandlerExtra` surfaces `inputResponses`/`requestState` so a handler can
  branch between the original call and the retry.
- Point `modern-mrtr-http.json` at the new preset; correct the README to
  describe the real mechanism.
- Add an integration test that drives the full round-trip through InspectorClient
  (auto-fulfilling the embedded elicitation) and asserts the final result is only
  reachable after the input_required round + retry — regression cover that would
  have caught the original config bug.

Verified live in the browser: the History view renders the round-trip as one
grouped "MRTR conversation" (Round 1 input_required → Round 2 complete), keyed by
the real requestState, under the Modern era badge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Address review notes: dedupe status badge, unique MRTR token, import alias

Non-blocking review feedback on #1705:

- ProtocolEntry: suppress the redundant green "OK" status badge when a
  `resultType` badge already conveys the outcome (a modern success shows only
  `complete`/`input required`). Errors/pending have no `resultType`, so their
  status badge still shows.
- createMrtrTool: mint a unique `requestState` per original call (closure
  counter) so two same-action invocations don't fold into one conversation
  during manual eyeballing. Real SDK tokens are already unique per operation.
- ProtocolEntry: align the `MessageEntry` import to the `@inspector/core` alias
  used by its siblings (was a deep relative path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Review round 2: green `complete` badge, lighter embedded MRTR container

Non-blocking observations from the second review:

- ProtocolEntry: `resultType` "complete" now renders green (was gray) so a modern
  success keeps the at-a-glance green success affordance a legacy "OK" had, now
  that the redundant OK badge is suppressed. `input_required` stays yellow.
- MrtrConversation: in the narrow monitoring (embedded) column, drop the outer
  container border and tighten padding — the per-round entries are already
  bordered cards, so this avoids a busy border-in-border nesting. The full-width
  view keeps the bordered surface that sets a conversation apart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Review round 3: module-scope MRTR counter; revert embedded border change

- test-servers: hoist the MRTR `requestState` counter to module scope. The
  modern (2026-07-28) leg is stateless — `createMcpHandler` rebuilds the server
  (and every tool closure) per request — so the previous per-closure counter
  reset to 0 on every request and every token ended in `:1`, defeating its
  purpose. A module-level counter survives the per-request factory recreation
  and increments monotonically across the process, so same-action invocations
  get distinct tokens and don't fold into one conversation. (Review finding.)
- MrtrConversation: revert the embedded borderless/tighter-padding change — per
  maintainer feedback from manual testing, dropping the border didn't improve
  the narrow-column visual, so the group keeps its border in all layouts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 20:02:11 -04:00

169 lines
6.6 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.
/**
* Frame methods that only exist in the modern era: the discovery probe, the
* push-notification listen stream, and its acknowledgement. Generic viewers
* already display them; this set drives the small "modern frame" affordance.
*/
export const MODERN_METHODS: ReadonlySet<string> = new Set([
"server/discover",
"subscriptions/listen",
"notifications/subscriptions/acknowledged",
]);
export function isModernFrame(method: string): boolean {
return MODERN_METHODS.has(method);
}
// 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;
}