History: MRTR conversation grouping + modern message vocabulary (#1705)
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

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>
This commit is contained in:
Cliff Hall 2026-07-17 20:02:11 -04:00 • committed by GitHub
commit 73d9b181fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,4 +1,8 @@
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) {
@ -32,3 +36,134 @@ export const REPLAYABLE_PROTOCOL_METHODS: ReadonlySet<string> = new Set([
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;
}