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
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:
parent
e06c0a45f7
commit
73d9b181fe
28 changed files with 1785 additions and 58 deletions
|
|
@ -110,7 +110,7 @@ npm run test-servers:build # (from clients/web) → tsc -p test-servers, emits
|
||||||
|
|
||||||
The Vite alias `@modelcontextprotocol/inspector-test-server` (in `clients/web/vite.config.ts`) points at `test-servers/build/index.js` so `getTestMcpServerPath()` resolves to a real `.js` path.
|
The Vite alias `@modelcontextprotocol/inspector-test-server` (in `clients/web/vite.config.ts`) points at `test-servers/build/index.js` so `getTestMcpServerPath()` resolves to a real `.js` path.
|
||||||
|
|
||||||
A streamable-HTTP server can also serve the **modern (2026-07-28) protocol era** via the SDK's `createMcpHandler` — set `transport.modern` in the JSON config (`true` for dual-era stateless serving, or `{ "legacy": "reject" }` for modern-only strict), or pass `modern` on the `ServerConfig` for an in-process `createTestServerHttp`. This is what lets an Inspector connection negotiating `protocolEra: "auto" | "modern"` reach the modern leg (populated `server/discover`, sessionless). See `test-servers/configs/modern-http.json`.
|
A streamable-HTTP server can also serve the **modern (2026-07-28) protocol era** via the SDK's `createMcpHandler` — set `transport.modern` in the JSON config (`true` for dual-era stateless serving, or `{ "legacy": "reject" }` for modern-only strict), or pass `modern` on the `ServerConfig` for an in-process `createTestServerHttp`. This is what lets an Inspector connection negotiating `protocolEra: "auto" | "modern"` reach the modern leg (populated `server/discover`, sessionless). See `test-servers/configs/modern-http.json`. `test-servers/configs/modern-mrtr-http.json` additionally serves the `mrtr_confirm` tool (preset `mrtr_confirm`, `createMrtrTool`) over the modern leg: its handler returns `inputRequired(...)` embedding a form elicitation, so invoking it produces a real MRTR round-trip (`input_required` → the client fulfils the embedded elicitation and retries with a new id → `complete`) — useful for eyeballing the Protocol view's MRTR conversation grouping. (The legacy `collect_elicitation` preset calls `server.elicitInput`, which errors on the 2026-07-28 leg — server→client requests aren't allowed there; MRTR is the modern replacement.)
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,14 @@ export function getViteBaseConfig() {
|
||||||
return {
|
return {
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
exclude: [...NODE_ONLY_OPTIMIZE_DEPS_EXCLUDE],
|
exclude: [...NODE_ONLY_OPTIMIZE_DEPS_EXCLUDE],
|
||||||
|
// Pre-bundle the browser-safe client barrel up front so Vitest's
|
||||||
|
// Storybook (browser) project doesn't discover it lazily and re-optimize
|
||||||
|
// mid-run — which reloads the worker and fails in-flight dynamic story
|
||||||
|
// imports with "Failed to fetch dynamically imported module". Several
|
||||||
|
// components import it at runtime (e.g. `protocolUtils`'s
|
||||||
|
// `isInputRequiredResult` / `SUBSCRIPTION_ID_META_KEY`), so listing it
|
||||||
|
// here keeps the dep graph stable across story files.
|
||||||
|
include: ["@modelcontextprotocol/client"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3869,6 +3869,7 @@ function App() {
|
||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
progressByTaskId={progressByTaskId}
|
progressByTaskId={progressByTaskId}
|
||||||
protocol={messages}
|
protocol={messages}
|
||||||
|
protocolEra={protocolEra}
|
||||||
network={fetchRequests}
|
network={fetchRequests}
|
||||||
stderrLogs={stderrLogs}
|
stderrLogs={stderrLogs}
|
||||||
toolCallState={toolCallState}
|
toolCallState={toolCallState}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { EraBadge } from "./EraBadge";
|
||||||
|
|
||||||
|
const meta: Meta<typeof EraBadge> = {
|
||||||
|
title: "Elements/EraBadge",
|
||||||
|
component: EraBadge,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof EraBadge>;
|
||||||
|
|
||||||
|
export const Modern: Story = {
|
||||||
|
args: { era: "modern" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Legacy: Story = {
|
||||||
|
args: { era: "legacy" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NotConnected: Story = {
|
||||||
|
args: { era: undefined },
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||||
|
import { EraBadge } from "./EraBadge";
|
||||||
|
import { formatEra, isModernEra } from "./eraUtils";
|
||||||
|
|
||||||
|
describe("isModernEra / formatEra", () => {
|
||||||
|
it("treats only 'modern' as the modern era", () => {
|
||||||
|
expect(isModernEra("modern")).toBe(true);
|
||||||
|
expect(isModernEra("legacy")).toBe(false);
|
||||||
|
expect(isModernEra(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels the era", () => {
|
||||||
|
expect(formatEra("modern")).toBe("Modern");
|
||||||
|
expect(formatEra("legacy")).toBe("Legacy");
|
||||||
|
expect(formatEra(undefined)).toBe("Legacy");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EraBadge", () => {
|
||||||
|
it("renders Modern for a modern connection", () => {
|
||||||
|
renderWithMantine(<EraBadge era="modern" />);
|
||||||
|
expect(screen.getByText("Modern")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Legacy for a legacy connection", () => {
|
||||||
|
renderWithMantine(<EraBadge era="legacy" />);
|
||||||
|
expect(screen.getByText("Legacy")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Legacy when the era is undefined", () => {
|
||||||
|
renderWithMantine(<EraBadge era={undefined} />);
|
||||||
|
expect(screen.getByText("Legacy")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
19
clients/web/src/components/elements/EraBadge/EraBadge.tsx
Normal file
19
clients/web/src/components/elements/EraBadge/EraBadge.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { Badge } from "@mantine/core";
|
||||||
|
import type { ProtocolEra } from "@modelcontextprotocol/client";
|
||||||
|
import { formatEra, isModernEra } from "./eraUtils";
|
||||||
|
|
||||||
|
export interface EraBadgeProps {
|
||||||
|
/** The negotiated protocol era; `undefined` renders as Legacy. */
|
||||||
|
era: ProtocolEra | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Labels a connection's negotiated protocol era (SEP §7.8). Feed it from
|
||||||
|
// connection state only — see the note in `eraUtils` on why the era must never
|
||||||
|
// be inferred from individual message frames.
|
||||||
|
export function EraBadge({ era }: EraBadgeProps) {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" color={isModernEra(era) ? "blue" : "gray"}>
|
||||||
|
{formatEra(era)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
clients/web/src/components/elements/EraBadge/eraUtils.ts
Normal file
15
clients/web/src/components/elements/EraBadge/eraUtils.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import type { ProtocolEra } from "@modelcontextprotocol/client";
|
||||||
|
|
||||||
|
// The SDK reports an era for every connected server, including a plain legacy
|
||||||
|
// connect (`"legacy"`); it's `undefined` only when not connected. Anything other
|
||||||
|
// than `"modern"` is the legacy era. IMPORTANT: this reflects the *negotiated*
|
||||||
|
// connection era — it must be fed from connection state, never inferred from
|
||||||
|
// individual message frames (the modern probe carries a `_meta` envelope before
|
||||||
|
// the era is known; spec §8.3).
|
||||||
|
export function isModernEra(era: ProtocolEra | undefined): boolean {
|
||||||
|
return era === "modern";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatEra(era: ProtocolEra | undefined): string {
|
||||||
|
return isModernEra(era) ? "Modern" : "Legacy";
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,8 @@ import {
|
||||||
type CapabilityKey,
|
type CapabilityKey,
|
||||||
} from "../../elements/CapabilityItem/CapabilityItem";
|
} from "../../elements/CapabilityItem/CapabilityItem";
|
||||||
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
||||||
|
import { EraBadge } from "../../elements/EraBadge/EraBadge";
|
||||||
|
import { isModernEra } from "../../elements/EraBadge/eraUtils";
|
||||||
import { OAuthAccessTokenField } from "./OAuthAccessTokenField";
|
import { OAuthAccessTokenField } from "./OAuthAccessTokenField";
|
||||||
|
|
||||||
export interface OAuthDetails {
|
export interface OAuthDetails {
|
||||||
|
|
@ -111,16 +113,8 @@ function formatClientRegistrationKind(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The SDK reports the era for every connected era, including a plain legacy
|
// `isModernEra` / `formatEra` are shared with the Protocol view via the
|
||||||
// connect (`"legacy"`); it's undefined only when not connected. Anything other
|
// EraBadge element (single source of truth for the legacy/modern distinction).
|
||||||
// than `"modern"` renders as the legacy era.
|
|
||||||
function isModernEra(era: ProtocolEra | undefined): boolean {
|
|
||||||
return era === "modern";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatEra(era: ProtocolEra | undefined): string {
|
|
||||||
return isModernEra(era) ? "Modern" : "Legacy";
|
|
||||||
}
|
|
||||||
|
|
||||||
// The session concept is HTTP-only: modern HTTP connections are sessionless (no
|
// The session concept is HTTP-only: modern HTTP connections are sessionless (no
|
||||||
// `Mcp-Session-Id`, nothing to DELETE on disconnect) while a legacy HTTP
|
// `Mcp-Session-Id`, nothing to DELETE on disconnect) while a legacy HTTP
|
||||||
|
|
@ -209,12 +203,7 @@ export function ConnectionInfoContent({
|
||||||
<Badge variant="outline">{transport}</Badge>
|
<Badge variant="outline">{transport}</Badge>
|
||||||
|
|
||||||
<Text size="sm">Era</Text>
|
<Text size="sm">Era</Text>
|
||||||
<Badge
|
<EraBadge era={protocolEra} />
|
||||||
variant="outline"
|
|
||||||
color={isModernEra(protocolEra) ? "blue" : "gray"}
|
|
||||||
>
|
|
||||||
{formatEra(protocolEra)}
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
<Text size="sm">Session</Text>
|
<Text size="sm">Session</Text>
|
||||||
<ValueText>{formatSession(protocolEra, transport)}</ValueText>
|
<ValueText>{formatSession(protocolEra, transport)}</ValueText>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { Box } from "@mantine/core";
|
||||||
|
import { expect, fn, userEvent, within } from "storybook/test";
|
||||||
|
import type { MessageEntry } from "../../../../../../core/mcp/types.js";
|
||||||
|
import { MrtrConversation } from "./MrtrConversation";
|
||||||
|
|
||||||
|
const meta: Meta<typeof MrtrConversation> = {
|
||||||
|
title: "Groups/MrtrConversation",
|
||||||
|
component: MrtrConversation,
|
||||||
|
args: {
|
||||||
|
requestState: "opaque-server-token",
|
||||||
|
pinnedIds: new Set<string>(),
|
||||||
|
isListExpanded: true,
|
||||||
|
onReplay: fn(),
|
||||||
|
onTogglePin: fn(),
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<Box maw={720}>
|
||||||
|
<Story />
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof MrtrConversation>;
|
||||||
|
|
||||||
|
const original: MessageEntry = {
|
||||||
|
id: "orig",
|
||||||
|
timestamp: new Date("2026-07-28T10:00:00Z"),
|
||||||
|
direction: "request",
|
||||||
|
origin: "client",
|
||||||
|
message: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1,
|
||||||
|
method: "tools/call",
|
||||||
|
params: { name: "book_flight", arguments: { destination: "SFO" } },
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1,
|
||||||
|
result: {
|
||||||
|
resultType: "input_required",
|
||||||
|
requestState: "opaque-server-token",
|
||||||
|
inputRequests: {
|
||||||
|
"1": {
|
||||||
|
method: "elicitation/create",
|
||||||
|
params: { message: "Confirm passenger name" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
duration: 28,
|
||||||
|
};
|
||||||
|
|
||||||
|
const retry: MessageEntry = {
|
||||||
|
id: "retry",
|
||||||
|
timestamp: new Date("2026-07-28T10:00:05Z"),
|
||||||
|
direction: "request",
|
||||||
|
origin: "client",
|
||||||
|
message: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 2,
|
||||||
|
method: "tools/call",
|
||||||
|
params: {
|
||||||
|
name: "book_flight",
|
||||||
|
requestState: "opaque-server-token",
|
||||||
|
inputResponses: { "1": { content: { name: "Ada Lovelace" } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 2,
|
||||||
|
result: {
|
||||||
|
resultType: "complete",
|
||||||
|
content: [{ type: "text", text: "Booked flight to SFO" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
duration: 44,
|
||||||
|
};
|
||||||
|
|
||||||
|
// A completed two-round conversation: original call → input_required → retry →
|
||||||
|
// complete.
|
||||||
|
export const Completed: Story = {
|
||||||
|
args: {
|
||||||
|
rounds: [original, retry],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// The conversation collapsed; expanding it reveals the individual rounds.
|
||||||
|
export const CollapsedThenExpanded: Story = {
|
||||||
|
args: {
|
||||||
|
rounds: [original, retry],
|
||||||
|
isListExpanded: false,
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
const toggle = await canvas.findByRole("button", {
|
||||||
|
name: "Expand MRTR conversation",
|
||||||
|
});
|
||||||
|
await userEvent.click(toggle);
|
||||||
|
await expect(
|
||||||
|
await canvas.findByRole("button", {
|
||||||
|
name: "Collapse MRTR conversation",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Still awaiting input: the original call returned input_required and the user
|
||||||
|
// has not answered yet, so the conversation has a single round.
|
||||||
|
export const AwaitingInput: Story = {
|
||||||
|
args: {
|
||||||
|
rounds: [original],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import type { MessageEntry } from "@inspector/core/mcp/types.js";
|
||||||
|
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||||
|
import { MrtrConversation } from "./MrtrConversation";
|
||||||
|
|
||||||
|
function round(
|
||||||
|
id: string,
|
||||||
|
jsonRpcId: number,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
result: Record<string, unknown> | undefined,
|
||||||
|
at: number,
|
||||||
|
): MessageEntry {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
timestamp: new Date(at),
|
||||||
|
direction: "request",
|
||||||
|
origin: "client",
|
||||||
|
message: { jsonrpc: "2.0", id: jsonRpcId, method: "tools/call", params },
|
||||||
|
response: result ? { jsonrpc: "2.0", id: jsonRpcId, result } : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const original = round(
|
||||||
|
"orig",
|
||||||
|
1,
|
||||||
|
{ name: "book_flight" },
|
||||||
|
{ resultType: "input_required", requestState: "tok" },
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
const retry = round(
|
||||||
|
"retry",
|
||||||
|
2,
|
||||||
|
{ name: "book_flight", requestState: "tok", inputResponses: {} },
|
||||||
|
{ resultType: "complete", content: [{ type: "text", text: "booked" }] },
|
||||||
|
2000,
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
requestState: "tok",
|
||||||
|
pinnedIds: new Set<string>(),
|
||||||
|
isListExpanded: true,
|
||||||
|
onReplay: vi.fn(),
|
||||||
|
onTogglePin: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("MrtrConversation", () => {
|
||||||
|
it("renders the method, an MRTR label, and the round count", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<MrtrConversation {...baseProps} rounds={[original, retry]} />,
|
||||||
|
);
|
||||||
|
// The header carries a method badge; each round also carries one, so the
|
||||||
|
// method appears more than once — the MRTR label and count are unique.
|
||||||
|
expect(screen.getAllByText("tools/call").length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getByText("MRTR")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("2 rounds")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("singularizes a one-round conversation", () => {
|
||||||
|
renderWithMantine(<MrtrConversation {...baseProps} rounds={[original]} />);
|
||||||
|
expect(screen.getByText("1 round")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives Complete status from the final round", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<MrtrConversation {...baseProps} rounds={[original, retry]} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("mrtr-status")).toHaveTextContent("Complete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Awaiting input when the final round is still input_required", () => {
|
||||||
|
renderWithMantine(<MrtrConversation {...baseProps} rounds={[original]} />);
|
||||||
|
expect(screen.getByTestId("mrtr-status")).toHaveTextContent(
|
||||||
|
"Awaiting input",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Error when the final round failed", () => {
|
||||||
|
const failed: MessageEntry = {
|
||||||
|
id: "err",
|
||||||
|
timestamp: new Date(3000),
|
||||||
|
direction: "request",
|
||||||
|
origin: "client",
|
||||||
|
message: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 3,
|
||||||
|
method: "tools/call",
|
||||||
|
params: { requestState: "tok" },
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 3,
|
||||||
|
error: { code: -32602, message: "bad" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
renderWithMantine(
|
||||||
|
<MrtrConversation {...baseProps} rounds={[original, failed]} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("mrtr-status")).toHaveTextContent("Error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Pending when the final round has no response yet", () => {
|
||||||
|
const pending = round("p", 4, { requestState: "tok" }, undefined, 4000);
|
||||||
|
renderWithMantine(
|
||||||
|
<MrtrConversation {...baseProps} rounds={[original, pending]} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("mrtr-status")).toHaveTextContent("Pending");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels each round in chronological order regardless of input order", () => {
|
||||||
|
// Pass the rounds newest-first (as a newest-first list sort would); the
|
||||||
|
// conversation still reads original (Round 1) → retry (Round 2).
|
||||||
|
renderWithMantine(
|
||||||
|
<MrtrConversation {...baseProps} rounds={[retry, original]} />,
|
||||||
|
);
|
||||||
|
const round1 = screen.getByText("Round 1");
|
||||||
|
const round2 = screen.getByText("Round 2");
|
||||||
|
expect(round1).toBeInTheDocument();
|
||||||
|
expect(round2).toBeInTheDocument();
|
||||||
|
// Round 1 appears before Round 2 in the DOM.
|
||||||
|
expect(
|
||||||
|
round1.compareDocumentPosition(round2) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses and expands the rounds", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithMantine(
|
||||||
|
<MrtrConversation
|
||||||
|
{...baseProps}
|
||||||
|
rounds={[original, retry]}
|
||||||
|
isListExpanded={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const toggle = screen.getByRole("button", {
|
||||||
|
name: "Expand MRTR conversation",
|
||||||
|
});
|
||||||
|
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||||
|
await user.click(toggle);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Collapse MRTR conversation" }),
|
||||||
|
).toHaveAttribute("aria-expanded", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards per-round pin toggles by entry id", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onTogglePin = vi.fn();
|
||||||
|
renderWithMantine(
|
||||||
|
<MrtrConversation
|
||||||
|
{...baseProps}
|
||||||
|
rounds={[original, retry]}
|
||||||
|
onTogglePin={onTogglePin}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// Each round has its own Pin control; clicking the first pins "orig".
|
||||||
|
const pinButtons = screen.getAllByRole("button", { name: "Pin" });
|
||||||
|
await user.click(pinButtons[0]);
|
||||||
|
expect(onTogglePin).toHaveBeenCalledWith("orig");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,189 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Collapse,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import type { MessageEntry } from "@inspector/core/mcp/types.js";
|
||||||
|
import { ProtocolEntry } from "../ProtocolEntry/ProtocolEntry";
|
||||||
|
import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle";
|
||||||
|
import { MethodBadge } from "../../elements/MethodBadge/MethodBadge";
|
||||||
|
import { extractMethod, extractResultType } from "../protocolUtils.js";
|
||||||
|
|
||||||
|
export interface MrtrConversationProps {
|
||||||
|
/** The opaque MRTR token that links this conversation's rounds. */
|
||||||
|
requestState: string;
|
||||||
|
/** The entries belonging to this conversation (one per JSON-RPC id). */
|
||||||
|
rounds: MessageEntry[];
|
||||||
|
/** Which of this conversation's rounds are pinned, by entry id. */
|
||||||
|
pinnedIds: Set<string>;
|
||||||
|
/** Whether rounds start expanded (mirrors the list-level compact toggle). */
|
||||||
|
isListExpanded: boolean;
|
||||||
|
/** Compact per-round layout for the narrow monitoring column. */
|
||||||
|
embedded?: boolean;
|
||||||
|
onReplay: (id: string) => void;
|
||||||
|
onTogglePin: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MRTR (multi-round-trip request, spec §7.3) makes one logical operation span
|
||||||
|
// several JSON-RPC ids: the original call returns `input_required`, the client
|
||||||
|
// answers and retries with a NEW id echoing `requestState`, repeating until a
|
||||||
|
// final `complete` result. This groups those rounds into one expandable unit so
|
||||||
|
// the operation reads as a single conversation instead of scattered calls.
|
||||||
|
|
||||||
|
const ConversationContainer = Paper.withProps({
|
||||||
|
withBorder: true,
|
||||||
|
p: "md",
|
||||||
|
radius: "md",
|
||||||
|
});
|
||||||
|
|
||||||
|
const HeaderRow = Group.withProps({
|
||||||
|
justify: "space-between",
|
||||||
|
wrap: "nowrap",
|
||||||
|
});
|
||||||
|
|
||||||
|
const HeaderLeft = Group.withProps({
|
||||||
|
gap: "sm",
|
||||||
|
wrap: "nowrap",
|
||||||
|
miw: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const HeaderRight = Group.withProps({
|
||||||
|
gap: "sm",
|
||||||
|
wrap: "nowrap",
|
||||||
|
});
|
||||||
|
|
||||||
|
const MrtrLabel = Text.withProps({
|
||||||
|
size: "sm",
|
||||||
|
fw: 600,
|
||||||
|
c: "dimmed",
|
||||||
|
});
|
||||||
|
|
||||||
|
const RoundLabel = Text.withProps({
|
||||||
|
size: "xs",
|
||||||
|
fw: 600,
|
||||||
|
c: "dimmed",
|
||||||
|
});
|
||||||
|
|
||||||
|
type ConversationStatus = "pending" | "awaiting" | "error" | "complete";
|
||||||
|
|
||||||
|
// The conversation's status is that of its final (latest) round: still awaiting
|
||||||
|
// input if the last result is `input_required`, otherwise the ordinary
|
||||||
|
// pending/error/complete lifecycle of that round.
|
||||||
|
function conversationStatus(finalRound: MessageEntry): ConversationStatus {
|
||||||
|
if (!finalRound.response) return "pending";
|
||||||
|
if ("error" in finalRound.response) return "error";
|
||||||
|
if (extractResultType(finalRound) === "input_required") return "awaiting";
|
||||||
|
return "complete";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusColor(status: ConversationStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case "complete":
|
||||||
|
return "green";
|
||||||
|
case "error":
|
||||||
|
return "red";
|
||||||
|
case "awaiting":
|
||||||
|
return "yellow";
|
||||||
|
default:
|
||||||
|
return "gray";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: ConversationStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case "complete":
|
||||||
|
return "Complete";
|
||||||
|
case "error":
|
||||||
|
return "Error";
|
||||||
|
case "awaiting":
|
||||||
|
return "Awaiting input";
|
||||||
|
default:
|
||||||
|
return "Pending";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRoundsLabel(count: number): string {
|
||||||
|
return count === 1 ? "1 round" : `${count} rounds`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRoundLabel(index: number): string {
|
||||||
|
return `Round ${index + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MrtrConversation({
|
||||||
|
requestState,
|
||||||
|
rounds,
|
||||||
|
pinnedIds,
|
||||||
|
isListExpanded,
|
||||||
|
embedded = false,
|
||||||
|
onReplay,
|
||||||
|
onTogglePin,
|
||||||
|
}: MrtrConversationProps) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(isListExpanded);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsExpanded(isListExpanded);
|
||||||
|
}, [isListExpanded]);
|
||||||
|
|
||||||
|
// Always read the conversation chronologically (original → retries → final),
|
||||||
|
// regardless of the list's newest-first/oldest-first sort.
|
||||||
|
const ordered = [...rounds].sort(
|
||||||
|
(a, b) => a.timestamp.getTime() - b.timestamp.getTime(),
|
||||||
|
);
|
||||||
|
const method = extractMethod(ordered[0]);
|
||||||
|
const status = conversationStatus(ordered[ordered.length - 1]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConversationContainer aria-label={`MRTR conversation ${requestState}`}>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<HeaderRow>
|
||||||
|
<HeaderLeft>
|
||||||
|
<MethodBadge method={method} />
|
||||||
|
<MrtrLabel>MRTR</MrtrLabel>
|
||||||
|
<Badge color="blue" variant="outline">
|
||||||
|
{formatRoundsLabel(ordered.length)}
|
||||||
|
</Badge>
|
||||||
|
</HeaderLeft>
|
||||||
|
<HeaderRight>
|
||||||
|
<Badge
|
||||||
|
color={statusColor(status)}
|
||||||
|
variant="status"
|
||||||
|
data-testid="mrtr-status"
|
||||||
|
>
|
||||||
|
{statusLabel(status)}
|
||||||
|
</Badge>
|
||||||
|
<ExpandToggle
|
||||||
|
expanded={isExpanded}
|
||||||
|
ariaLabel={`${isExpanded ? "Collapse" : "Expand"} MRTR conversation`}
|
||||||
|
onToggle={() => setIsExpanded((v) => !v)}
|
||||||
|
/>
|
||||||
|
</HeaderRight>
|
||||||
|
</HeaderRow>
|
||||||
|
|
||||||
|
<Collapse in={isExpanded}>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Divider />
|
||||||
|
{ordered.map((round, index) => (
|
||||||
|
<Stack key={round.id} gap="xs">
|
||||||
|
<RoundLabel>{formatRoundLabel(index)}</RoundLabel>
|
||||||
|
<ProtocolEntry
|
||||||
|
entry={round}
|
||||||
|
isPinned={pinnedIds.has(round.id)}
|
||||||
|
isListExpanded={isListExpanded}
|
||||||
|
embedded={embedded}
|
||||||
|
onReplay={() => onReplay(round.id)}
|
||||||
|
onTogglePin={() => onTogglePin(round.id)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Collapse>
|
||||||
|
</Stack>
|
||||||
|
</ConversationContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -120,6 +120,67 @@ const pendingEntry: MessageEntry = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Modern (2026-07-28) era frames. An `input_required` result is the first round
|
||||||
|
// of an MRTR conversation; the retried call returns `complete`.
|
||||||
|
const inputRequiredEntry: MessageEntry = {
|
||||||
|
id: "mrtr-1",
|
||||||
|
timestamp: new Date("2026-07-28T10:00:00Z"),
|
||||||
|
direction: "request",
|
||||||
|
origin: "client",
|
||||||
|
message: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1,
|
||||||
|
method: "tools/call",
|
||||||
|
params: { name: "book_flight", arguments: { destination: "SFO" } },
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1,
|
||||||
|
result: {
|
||||||
|
resultType: "input_required",
|
||||||
|
requestState: "opaque-server-token",
|
||||||
|
inputRequests: {
|
||||||
|
"1": {
|
||||||
|
method: "elicitation/create",
|
||||||
|
params: { message: "Confirm passenger name" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
duration: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
const discoverEntry: MessageEntry = {
|
||||||
|
id: "disc-1",
|
||||||
|
timestamp: new Date("2026-07-28T09:59:00Z"),
|
||||||
|
direction: "request",
|
||||||
|
origin: "client",
|
||||||
|
message: { jsonrpc: "2.0", id: 0, method: "server/discover" },
|
||||||
|
response: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 0,
|
||||||
|
result: {
|
||||||
|
supportedVersions: ["2026-07-28"],
|
||||||
|
capabilities: { tools: {}, resources: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
duration: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscriptionNotificationEntry: MessageEntry = {
|
||||||
|
id: "sub-1",
|
||||||
|
timestamp: new Date("2026-07-28T10:05:00Z"),
|
||||||
|
direction: "notification",
|
||||||
|
origin: "server",
|
||||||
|
message: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "notifications/resources/list_changed",
|
||||||
|
params: {
|
||||||
|
_meta: { "io.modelcontextprotocol/subscriptionId": "sub-abc123" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const SuccessCollapsed: Story = {
|
export const SuccessCollapsed: Story = {
|
||||||
args: {
|
args: {
|
||||||
entry: toolCallEntry,
|
entry: toolCallEntry,
|
||||||
|
|
@ -160,6 +221,34 @@ export const Pending: Story = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Modern-era: an `input_required` result, labeled with a yellow badge (the
|
||||||
|
// operation isn't done — it needs input and will be retried).
|
||||||
|
export const ModernInputRequired: Story = {
|
||||||
|
args: {
|
||||||
|
entry: inputRequiredEntry,
|
||||||
|
isPinned: false,
|
||||||
|
isListExpanded: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Modern-era: a `server/discover` probe, flagged with the "modern" frame badge.
|
||||||
|
export const ModernDiscoverFrame: Story = {
|
||||||
|
args: {
|
||||||
|
entry: discoverEntry,
|
||||||
|
isPinned: false,
|
||||||
|
isListExpanded: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Modern-era: a push notification tagged with a subscriptionId (copyable).
|
||||||
|
export const ModernSubscriptionNotification: Story = {
|
||||||
|
args: {
|
||||||
|
entry: subscriptionNotificationEntry,
|
||||||
|
isPinned: false,
|
||||||
|
isListExpanded: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Embedded (monitoring-sidebar) layout with a long resource URI: the target sits
|
// Embedded (monitoring-sidebar) layout with a long resource URI: the target sits
|
||||||
// in a horizontal scroll area rather than truncating with an ellipsis. Wrapped
|
// in a horizontal scroll area rather than truncating with an ellipsis. Wrapped
|
||||||
// in the pinned column's width so the overflow is visible.
|
// in the pinned column's width so the overflow is visible.
|
||||||
|
|
|
||||||
|
|
@ -369,3 +369,135 @@ describe("ProtocolEntry", () => {
|
||||||
expect(screen.queryByText(/ms$/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/ms$/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Modern-era (2026-07-28) vocabulary rendering ---------------------------
|
||||||
|
|
||||||
|
const inputRequiredEntry: MessageEntry = {
|
||||||
|
id: "mrtr-1",
|
||||||
|
timestamp: new Date("2026-07-28T10:00:00Z"),
|
||||||
|
direction: "request",
|
||||||
|
origin: "client",
|
||||||
|
message: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1,
|
||||||
|
method: "tools/call",
|
||||||
|
params: { name: "book_flight" },
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1,
|
||||||
|
result: {
|
||||||
|
resultType: "input_required",
|
||||||
|
requestState: "opaque-token",
|
||||||
|
inputRequests: { "1": { method: "elicitation/create", params: {} } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeEntry: MessageEntry = {
|
||||||
|
id: "mrtr-2",
|
||||||
|
timestamp: new Date("2026-07-28T10:00:02Z"),
|
||||||
|
direction: "request",
|
||||||
|
origin: "client",
|
||||||
|
message: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 2,
|
||||||
|
method: "tools/call",
|
||||||
|
params: { name: "book_flight", requestState: "opaque-token" },
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 2,
|
||||||
|
result: { resultType: "complete", content: [{ type: "text", text: "ok" }] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const discoverEntry: MessageEntry = {
|
||||||
|
id: "disc-1",
|
||||||
|
timestamp: new Date("2026-07-28T09:59:00Z"),
|
||||||
|
direction: "request",
|
||||||
|
origin: "client",
|
||||||
|
message: { jsonrpc: "2.0", id: 0, method: "server/discover" },
|
||||||
|
response: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 0,
|
||||||
|
result: { supportedVersions: ["2026-07-28"], capabilities: {} },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscriptionNotificationEntry: MessageEntry = {
|
||||||
|
id: "sub-1",
|
||||||
|
timestamp: new Date("2026-07-28T10:05:00Z"),
|
||||||
|
direction: "notification",
|
||||||
|
origin: "server",
|
||||||
|
message: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "notifications/resources/list_changed",
|
||||||
|
params: {
|
||||||
|
_meta: { "io.modelcontextprotocol/subscriptionId": "sub-abc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("ProtocolEntry — modern vocabulary", () => {
|
||||||
|
it("labels an input_required result", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ProtocolEntry {...baseProps} entry={inputRequiredEntry} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("input required")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels a modern complete result", () => {
|
||||||
|
renderWithMantine(<ProtocolEntry {...baseProps} entry={completeEntry} />);
|
||||||
|
expect(screen.getByText("complete")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses the redundant OK status badge when a resultType badge shows", () => {
|
||||||
|
// A modern success carries both a green OK (transport-level) and a
|
||||||
|
// resultType badge; the resultType is the single signal, so OK is hidden.
|
||||||
|
renderWithMantine(<ProtocolEntry {...baseProps} entry={completeEntry} />);
|
||||||
|
expect(screen.getByText("complete")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("OK")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still shows the OK status badge on a legacy result with no resultType", () => {
|
||||||
|
renderWithMantine(<ProtocolEntry {...baseProps} entry={successEntry} />);
|
||||||
|
expect(screen.getByText("OK")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not label resultType on a legacy result", () => {
|
||||||
|
renderWithMantine(<ProtocolEntry {...baseProps} entry={successEntry} />);
|
||||||
|
expect(screen.queryByText("input required")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("complete")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a modern-only frame with a 'modern' badge", () => {
|
||||||
|
renderWithMantine(<ProtocolEntry {...baseProps} entry={discoverEntry} />);
|
||||||
|
expect(screen.getByText("server/discover")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("modern")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not flag an ordinary frame as modern", () => {
|
||||||
|
renderWithMantine(<ProtocolEntry {...baseProps} entry={successEntry} />);
|
||||||
|
expect(screen.queryByText("modern")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a copyable subscriptionId on a tagged notification", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ProtocolEntry {...baseProps} entry={subscriptionNotificationEntry} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("sub-abc")).toBeInTheDocument();
|
||||||
|
// One Copy button beside the subscription id (the notification carries no
|
||||||
|
// params ContentViewer copy affordance when collapsed).
|
||||||
|
expect(
|
||||||
|
screen.getAllByRole("button", { name: "Copy" }).length,
|
||||||
|
).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the modern badges in the embedded compact layout", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ProtocolEntry {...baseProps} entry={inputRequiredEntry} embedded />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("input required")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import type { MessageEntry } from "../../../../../../core/mcp/types.js";
|
import type { MessageEntry } from "@inspector/core/mcp/types.js";
|
||||||
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
||||||
import { CopyButton } from "../../elements/CopyButton/CopyButton";
|
import { CopyButton } from "../../elements/CopyButton/CopyButton";
|
||||||
import { MessageDirectionBadge } from "../../elements/MessageDirectionBadge/MessageDirectionBadge";
|
import { MessageDirectionBadge } from "../../elements/MessageDirectionBadge/MessageDirectionBadge";
|
||||||
|
|
@ -18,7 +18,13 @@ import { MethodBadge } from "../../elements/MethodBadge/MethodBadge";
|
||||||
import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle";
|
import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle";
|
||||||
import { PinToggle } from "../../elements/PinToggle/PinToggle";
|
import { PinToggle } from "../../elements/PinToggle/PinToggle";
|
||||||
import { ReplayButton } from "../../elements/ReplayButton/ReplayButton";
|
import { ReplayButton } from "../../elements/ReplayButton/ReplayButton";
|
||||||
import { extractMethod, isReplayableProtocolMethod } from "../protocolUtils.js";
|
import {
|
||||||
|
extractMethod,
|
||||||
|
extractResultType,
|
||||||
|
extractSubscriptionId,
|
||||||
|
isModernFrame,
|
||||||
|
isReplayableProtocolMethod,
|
||||||
|
} from "../protocolUtils.js";
|
||||||
|
|
||||||
export interface ProtocolEntryProps {
|
export interface ProtocolEntryProps {
|
||||||
entry: MessageEntry;
|
entry: MessageEntry;
|
||||||
|
|
@ -83,6 +89,46 @@ const DurationText = Text.withProps({
|
||||||
c: "dimmed",
|
c: "dimmed",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Flags a frame whose method only exists in the modern (2026-07-28) era
|
||||||
|
// (`server/discover`, `subscriptions/listen`, its acknowledgement). This labels
|
||||||
|
// the *frame*, not the connection's negotiated era (see the transcript caveat in
|
||||||
|
// protocolUtils) — those methods genuinely cannot occur on a legacy connection.
|
||||||
|
const ModernFrameBadge = Badge.withProps({
|
||||||
|
color: "blue",
|
||||||
|
variant: "outline",
|
||||||
|
});
|
||||||
|
|
||||||
|
// The `subscriptionId` tag on a modern push notification (spec §7.4). Shown with
|
||||||
|
// a copy button so the id can be correlated against the `subscriptions/listen`
|
||||||
|
// stream that opened it.
|
||||||
|
const SubscriptionLabel = Text.withProps({
|
||||||
|
size: "sm",
|
||||||
|
c: "dimmed",
|
||||||
|
});
|
||||||
|
|
||||||
|
const SubscriptionId = Text.withProps({
|
||||||
|
size: "sm",
|
||||||
|
ff: "monospace",
|
||||||
|
});
|
||||||
|
|
||||||
|
const SubscriptionCluster = Group.withProps({
|
||||||
|
gap: 4,
|
||||||
|
wrap: "nowrap",
|
||||||
|
miw: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// `complete` is green — it's the success signal now that the redundant "OK"
|
||||||
|
// status badge is suppressed, so a modern success keeps the same at-a-glance
|
||||||
|
// green affordance a legacy success has. `input_required` is yellow (in
|
||||||
|
// progress: awaiting input before the retry).
|
||||||
|
function resultTypeColor(resultType: "complete" | "input_required"): string {
|
||||||
|
return resultType === "input_required" ? "yellow" : "green";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultTypeLabel(resultType: "complete" | "input_required"): string {
|
||||||
|
return resultType === "input_required" ? "input required" : "complete";
|
||||||
|
}
|
||||||
|
|
||||||
const SubtleButton = Button.withProps({
|
const SubtleButton = Button.withProps({
|
||||||
variant: "subtle",
|
variant: "subtle",
|
||||||
size: "xs",
|
size: "xs",
|
||||||
|
|
@ -164,6 +210,9 @@ export function ProtocolEntry({
|
||||||
const resourceUri = extractResourceUri(entry);
|
const resourceUri = extractResourceUri(entry);
|
||||||
const status = extractStatus(entry);
|
const status = extractStatus(entry);
|
||||||
const canReplay = isReplayableProtocolMethod(method);
|
const canReplay = isReplayableProtocolMethod(method);
|
||||||
|
const resultType = extractResultType(entry);
|
||||||
|
const subscriptionId = extractSubscriptionId(entry);
|
||||||
|
const isModern = isModernFrame(method);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsExpanded(isListExpanded);
|
setIsExpanded(isListExpanded);
|
||||||
|
|
@ -174,11 +223,33 @@ export function ProtocolEntry({
|
||||||
direction={entry.origin === "client" ? "outgoing" : "incoming"}
|
direction={entry.origin === "client" ? "outgoing" : "incoming"}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
const statusBadge = status !== "none" && (
|
// The modern `resultType` on the paired result (spec §7.3): `input_required`
|
||||||
|
// (the operation isn't done — it needs input and will be retried) vs the
|
||||||
|
// ordinary `complete`. Only present on modern results, so it doubles as a
|
||||||
|
// per-result modern signal without inferring the connection era.
|
||||||
|
const resultTypeBadge = resultType && (
|
||||||
|
<Badge color={resultTypeColor(resultType)} variant="status">
|
||||||
|
{resultTypeLabel(resultType)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
// Suppress the redundant green "OK" when a `resultType` badge already conveys
|
||||||
|
// the outcome (a modern success is `complete`/`input required`); errors and
|
||||||
|
// pending have no `resultType`, so their status badge still shows.
|
||||||
|
const statusBadge = status !== "none" && !resultType && (
|
||||||
<Badge color={statusColor(status)} variant="status">
|
<Badge color={statusColor(status)} variant="status">
|
||||||
{statusLabel(status)}
|
{statusLabel(status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
|
const modernFrameBadge = isModern && (
|
||||||
|
<ModernFrameBadge>modern</ModernFrameBadge>
|
||||||
|
);
|
||||||
|
const subscriptionBadge = subscriptionId && (
|
||||||
|
<SubscriptionCluster>
|
||||||
|
<SubscriptionLabel>sub</SubscriptionLabel>
|
||||||
|
<CopyButton value={subscriptionId} />
|
||||||
|
<SubscriptionId>{subscriptionId}</SubscriptionId>
|
||||||
|
</SubscriptionCluster>
|
||||||
|
);
|
||||||
const durationText = entry.duration != null && (
|
const durationText = entry.duration != null && (
|
||||||
<DurationText>{formatDuration(entry.duration)}</DurationText>
|
<DurationText>{formatDuration(entry.duration)}</DurationText>
|
||||||
);
|
);
|
||||||
|
|
@ -198,12 +269,15 @@ export function ProtocolEntry({
|
||||||
</HeaderCluster>
|
</HeaderCluster>
|
||||||
<ControlsCluster>
|
<ControlsCluster>
|
||||||
{durationText}
|
{durationText}
|
||||||
|
{resultTypeBadge}
|
||||||
{statusBadge}
|
{statusBadge}
|
||||||
</ControlsCluster>
|
</ControlsCluster>
|
||||||
</HeaderRow>
|
</HeaderRow>
|
||||||
<HeaderRow>
|
<HeaderRow>
|
||||||
<HeaderCluster flex={1}>
|
<HeaderCluster flex={1}>
|
||||||
<MethodBadge method={method} />
|
<MethodBadge method={method} />
|
||||||
|
{modernFrameBadge}
|
||||||
|
{subscriptionBadge}
|
||||||
{target && (
|
{target && (
|
||||||
<>
|
<>
|
||||||
{resourceUri && <CopyButton value={resourceUri} />}
|
{resourceUri && <CopyButton value={resourceUri} />}
|
||||||
|
|
@ -241,6 +315,8 @@ export function ProtocolEntry({
|
||||||
</TimestampText>
|
</TimestampText>
|
||||||
{directionBadge}
|
{directionBadge}
|
||||||
<MethodBadge method={method} />
|
<MethodBadge method={method} />
|
||||||
|
{modernFrameBadge}
|
||||||
|
{subscriptionBadge}
|
||||||
{target && (
|
{target && (
|
||||||
<>
|
<>
|
||||||
{resourceUri && <CopyButton value={resourceUri} />}
|
{resourceUri && <CopyButton value={resourceUri} />}
|
||||||
|
|
@ -250,6 +326,7 @@ export function ProtocolEntry({
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
{durationText}
|
{durationText}
|
||||||
|
{resultTypeBadge}
|
||||||
{statusBadge}
|
{statusBadge}
|
||||||
</Group>
|
</Group>
|
||||||
</HeaderRow>
|
</HeaderRow>
|
||||||
|
|
|
||||||
|
|
@ -116,3 +116,77 @@ export const Empty: Story = {
|
||||||
entries: [],
|
entries: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A modern (2026-07-28) connection: the era badge labels the traffic, and an
|
||||||
|
// MRTR round-trip (original call → input_required → retry → complete) renders as
|
||||||
|
// one grouped conversation.
|
||||||
|
const mrtrEntries: MessageEntry[] = [
|
||||||
|
{
|
||||||
|
id: "mrtr-orig",
|
||||||
|
timestamp: new Date("2026-07-28T10:00:00Z"),
|
||||||
|
direction: "request",
|
||||||
|
origin: "client",
|
||||||
|
message: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 10,
|
||||||
|
method: "tools/call",
|
||||||
|
params: { name: "book_flight", arguments: { destination: "SFO" } },
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 10,
|
||||||
|
result: {
|
||||||
|
resultType: "input_required",
|
||||||
|
requestState: "opaque-token",
|
||||||
|
inputRequests: {
|
||||||
|
"1": { method: "elicitation/create", params: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mrtr-retry",
|
||||||
|
timestamp: new Date("2026-07-28T10:00:05Z"),
|
||||||
|
direction: "request",
|
||||||
|
origin: "client",
|
||||||
|
message: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 11,
|
||||||
|
method: "tools/call",
|
||||||
|
params: {
|
||||||
|
name: "book_flight",
|
||||||
|
requestState: "opaque-token",
|
||||||
|
inputResponses: { "1": { content: { name: "Ada" } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 11,
|
||||||
|
result: {
|
||||||
|
resultType: "complete",
|
||||||
|
content: [{ type: "text", text: "Booked" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "disc-1",
|
||||||
|
timestamp: new Date("2026-07-28T09:59:00Z"),
|
||||||
|
direction: "request",
|
||||||
|
origin: "client",
|
||||||
|
message: { jsonrpc: "2.0", id: 0, method: "server/discover" },
|
||||||
|
response: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 0,
|
||||||
|
result: { supportedVersions: ["2026-07-28"], capabilities: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ModernWithMrtr: Story = {
|
||||||
|
args: {
|
||||||
|
entries: mrtrEntries,
|
||||||
|
protocolEra: "modern",
|
||||||
|
visibleDirections: { client: true, server: true },
|
||||||
|
compact: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -447,4 +447,71 @@ describe("ProtocolListPanel", () => {
|
||||||
await user.click(screen.getByRole("button", { name: "Expand all" }));
|
await user.click(screen.getByRole("button", { name: "Expand all" }));
|
||||||
expect(onToggleCompact).toHaveBeenCalledTimes(1);
|
expect(onToggleCompact).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows an era badge when a protocol era is provided", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ProtocolListPanel
|
||||||
|
{...baseProps}
|
||||||
|
entries={sampleEntries}
|
||||||
|
protocolEra="modern"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Modern")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the era badge when no era is provided", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ProtocolListPanel {...baseProps} entries={sampleEntries} />,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("Modern")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Legacy")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("groups contiguous MRTR rounds into one expandable conversation", () => {
|
||||||
|
const original: MessageEntry = {
|
||||||
|
id: "mrtr-orig",
|
||||||
|
timestamp: new Date("2026-07-28T10:00:00Z"),
|
||||||
|
direction: "request",
|
||||||
|
origin: "client",
|
||||||
|
message: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 10,
|
||||||
|
method: "tools/call",
|
||||||
|
params: { name: "book_flight" },
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 10,
|
||||||
|
result: { resultType: "input_required", requestState: "tok" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const retry: MessageEntry = {
|
||||||
|
id: "mrtr-retry",
|
||||||
|
timestamp: new Date("2026-07-28T10:00:05Z"),
|
||||||
|
direction: "request",
|
||||||
|
origin: "client",
|
||||||
|
message: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 11,
|
||||||
|
method: "tools/call",
|
||||||
|
params: { name: "book_flight", requestState: "tok" },
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 11,
|
||||||
|
result: { resultType: "complete", content: [] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
renderWithMantine(
|
||||||
|
<ProtocolListPanel
|
||||||
|
{...baseProps}
|
||||||
|
entries={[original, retry]}
|
||||||
|
compact={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// One MRTR conversation wrapper labeling the two rounds as a unit.
|
||||||
|
expect(screen.getByText("MRTR")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("2 rounds")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("mrtr-status")).toHaveTextContent("Complete");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,19 +9,22 @@ import {
|
||||||
Title,
|
Title,
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import type { ProtocolEra } from "@modelcontextprotocol/client";
|
||||||
import type {
|
import type {
|
||||||
MessageEntry,
|
MessageEntry,
|
||||||
MessageMethod,
|
MessageMethod,
|
||||||
MessageOrigin,
|
MessageOrigin,
|
||||||
} from "@inspector/core/mcp/types.js";
|
} from "@inspector/core/mcp/types.js";
|
||||||
import { ProtocolEntry } from "../ProtocolEntry/ProtocolEntry";
|
import { ProtocolEntry } from "../ProtocolEntry/ProtocolEntry";
|
||||||
|
import { MrtrConversation } from "../MrtrConversation/MrtrConversation";
|
||||||
import { ListToggle } from "../../elements/ListToggle/ListToggle";
|
import { ListToggle } from "../../elements/ListToggle/ListToggle";
|
||||||
|
import { EraBadge } from "../../elements/EraBadge/EraBadge";
|
||||||
import {
|
import {
|
||||||
SortToggle,
|
SortToggle,
|
||||||
type SortDirection,
|
type SortDirection,
|
||||||
} from "../../elements/SortToggle/SortToggle";
|
} from "../../elements/SortToggle/SortToggle";
|
||||||
import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea";
|
import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea";
|
||||||
import { extractMethod } from "../protocolUtils.js";
|
import { extractMethod, groupProtocolEntries } from "../protocolUtils.js";
|
||||||
import { useScrollMemory } from "../../../hooks/useScrollMemory";
|
import { useScrollMemory } from "../../../hooks/useScrollMemory";
|
||||||
|
|
||||||
export interface ProtocolListPanelProps {
|
export interface ProtocolListPanelProps {
|
||||||
|
|
@ -31,6 +34,13 @@ export interface ProtocolListPanelProps {
|
||||||
methodFilter?: MessageMethod;
|
methodFilter?: MessageMethod;
|
||||||
/** Which message directions to show, keyed by entry origin. */
|
/** Which message directions to show, keyed by entry origin. */
|
||||||
visibleDirections: Record<MessageOrigin, boolean>;
|
visibleDirections: Record<MessageOrigin, boolean>;
|
||||||
|
/**
|
||||||
|
* The connection's negotiated protocol era (SEP §7.8), shown as a badge so
|
||||||
|
* captured traffic is labeled by era. Must come from connection state — never
|
||||||
|
* inferred from the frames (the modern probe carries a `_meta` envelope before
|
||||||
|
* the era is known; spec §8.3). Undefined hides the badge.
|
||||||
|
*/
|
||||||
|
protocolEra?: ProtocolEra;
|
||||||
onClearAll: () => void;
|
onClearAll: () => void;
|
||||||
onExport: () => void;
|
onExport: () => void;
|
||||||
/** Clear just one section's entries (pinned vs unpinned history). */
|
/** Clear just one section's entries (pinned vs unpinned history). */
|
||||||
|
|
@ -215,6 +225,7 @@ export function ProtocolListPanel({
|
||||||
searchText,
|
searchText,
|
||||||
methodFilter,
|
methodFilter,
|
||||||
visibleDirections,
|
visibleDirections,
|
||||||
|
protocolEra,
|
||||||
onClearAll,
|
onClearAll,
|
||||||
onExport,
|
onExport,
|
||||||
onClearSection,
|
onClearSection,
|
||||||
|
|
@ -273,10 +284,43 @@ export function ProtocolListPanel({
|
||||||
// with a single section the panel-level Clear/Export already covers it.
|
// with a single section the panel-level Clear/Export already covers it.
|
||||||
const bothSections = pinnedEntries.length > 0 && unpinnedEntries.length > 0;
|
const bothSections = pinnedEntries.length > 0 && unpinnedEntries.length > 0;
|
||||||
|
|
||||||
|
// Render a section's entries, folding contiguous MRTR rounds (spec §7.3) into
|
||||||
|
// one MrtrConversation so an operation spanning several JSON-RPC ids reads as
|
||||||
|
// a single unit; everything else stays a plain ProtocolEntry. `sectionPinned`
|
||||||
|
// is the section's pin state (used for a lone entry's pin label).
|
||||||
|
const renderRows = (sectionEntries: MessageEntry[], sectionPinned: boolean) =>
|
||||||
|
groupProtocolEntries(sectionEntries).map((row) =>
|
||||||
|
row.kind === "mrtr" ? (
|
||||||
|
<MrtrConversation
|
||||||
|
key={`mrtr-${row.requestState}-${row.rounds[0].id}`}
|
||||||
|
requestState={row.requestState}
|
||||||
|
rounds={row.rounds}
|
||||||
|
pinnedIds={pinnedIds}
|
||||||
|
isListExpanded={!compact}
|
||||||
|
embedded={embedded}
|
||||||
|
onReplay={onReplay}
|
||||||
|
onTogglePin={onTogglePin}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ProtocolEntry
|
||||||
|
key={row.entry.id}
|
||||||
|
entry={row.entry}
|
||||||
|
isPinned={sectionPinned}
|
||||||
|
isListExpanded={!compact}
|
||||||
|
embedded={embedded}
|
||||||
|
onReplay={() => onReplay(row.entry.id)}
|
||||||
|
onTogglePin={() => onTogglePin(row.entry.id)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelContainer>
|
<PanelContainer>
|
||||||
<Group justify="space-between" mb="sm">
|
<Group justify="space-between" mb="sm">
|
||||||
<Title order={4}>Messages</Title>
|
<Group gap="sm">
|
||||||
|
<Title order={4}>Messages</Title>
|
||||||
|
{protocolEra && <EraBadge era={protocolEra} />}
|
||||||
|
</Group>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<SortToggle
|
<SortToggle
|
||||||
value={sortDirection}
|
value={sortDirection}
|
||||||
|
|
@ -321,17 +365,7 @@ export function ProtocolListPanel({
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{pinnedEntries.map((entry) => (
|
{renderRows(pinnedEntries, true)}
|
||||||
<ProtocolEntry
|
|
||||||
key={entry.id}
|
|
||||||
entry={entry}
|
|
||||||
isPinned={true}
|
|
||||||
isListExpanded={!compact}
|
|
||||||
embedded={embedded}
|
|
||||||
onReplay={() => onReplay(entry.id)}
|
|
||||||
onTogglePin={() => onTogglePin(entry.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -353,17 +387,7 @@ export function ProtocolListPanel({
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{unpinnedEntries.map((entry) => (
|
{renderRows(unpinnedEntries, false)}
|
||||||
<ProtocolEntry
|
|
||||||
key={entry.id}
|
|
||||||
entry={entry}
|
|
||||||
isPinned={false}
|
|
||||||
isListExpanded={!compact}
|
|
||||||
embedded={embedded}
|
|
||||||
onReplay={() => onReplay(entry.id)}
|
|
||||||
onTogglePin={() => onTogglePin(entry.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,33 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { SUBSCRIPTION_ID_META_KEY } from "@modelcontextprotocol/client";
|
||||||
import type { MessageEntry } from "@inspector/core/mcp/types.js";
|
import type { MessageEntry } from "@inspector/core/mcp/types.js";
|
||||||
import { extractMethod } from "./protocolUtils";
|
import {
|
||||||
|
extractMethod,
|
||||||
|
extractRequestState,
|
||||||
|
extractResultType,
|
||||||
|
extractSubscriptionId,
|
||||||
|
groupProtocolEntries,
|
||||||
|
isModernFrame,
|
||||||
|
} from "./protocolUtils";
|
||||||
|
|
||||||
|
// A tools/call request entry whose paired response is the given result. `at`
|
||||||
|
// seeds the timestamp so grouping/order assertions are deterministic.
|
||||||
|
function callEntry(
|
||||||
|
id: string,
|
||||||
|
jsonRpcId: number,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
result: Record<string, unknown> | undefined,
|
||||||
|
at = 0,
|
||||||
|
): MessageEntry {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
timestamp: new Date(at),
|
||||||
|
direction: "request",
|
||||||
|
origin: "client",
|
||||||
|
message: { jsonrpc: "2.0", id: jsonRpcId, method: "tools/call", params },
|
||||||
|
response: result ? { jsonrpc: "2.0", id: jsonRpcId, result } : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("extractMethod", () => {
|
describe("extractMethod", () => {
|
||||||
it("returns the method name for a request entry", () => {
|
it("returns the method name for a request entry", () => {
|
||||||
|
|
@ -58,3 +85,326 @@ describe("extractMethod", () => {
|
||||||
expect(extractMethod(entry)).toBe("response");
|
expect(extractMethod(entry)).toBe("response");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isModernFrame", () => {
|
||||||
|
it.each([
|
||||||
|
"server/discover",
|
||||||
|
"subscriptions/listen",
|
||||||
|
"notifications/subscriptions/acknowledged",
|
||||||
|
])("recognizes the modern frame %s", (method) => {
|
||||||
|
expect(isModernFrame(method)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for legacy/ordinary methods", () => {
|
||||||
|
expect(isModernFrame("tools/call")).toBe(false);
|
||||||
|
expect(isModernFrame("notifications/initialized")).toBe(false);
|
||||||
|
expect(isModernFrame("response")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractResultType", () => {
|
||||||
|
it("returns 'input_required' when the result carries the discriminator", () => {
|
||||||
|
const entry = callEntry(
|
||||||
|
"1",
|
||||||
|
1,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
resultType: "input_required",
|
||||||
|
requestState: "tok",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(extractResultType(entry)).toBe("input_required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'input_required' when only inputRequests is present", () => {
|
||||||
|
const entry = callEntry(
|
||||||
|
"1",
|
||||||
|
1,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
resultType: "input_required",
|
||||||
|
inputRequests: { "1": { method: "elicitation/create", params: {} } },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(extractResultType(entry)).toBe("input_required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'complete' for a modern complete result", () => {
|
||||||
|
const entry = callEntry(
|
||||||
|
"1",
|
||||||
|
1,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
resultType: "complete",
|
||||||
|
content: [],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(extractResultType(entry)).toBe("complete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for a legacy result with no resultType", () => {
|
||||||
|
const entry = callEntry("1", 1, {}, { content: [] });
|
||||||
|
expect(extractResultType(entry)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for a pending request (no response)", () => {
|
||||||
|
const entry = callEntry("1", 1, {}, undefined);
|
||||||
|
expect(extractResultType(entry)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for an error response", () => {
|
||||||
|
const entry: MessageEntry = {
|
||||||
|
id: "1",
|
||||||
|
timestamp: new Date(),
|
||||||
|
direction: "request",
|
||||||
|
message: { jsonrpc: "2.0", id: 1, method: "tools/call", params: {} },
|
||||||
|
response: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1,
|
||||||
|
error: { code: -32602, message: "bad" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(extractResultType(entry)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when the result is not an object", () => {
|
||||||
|
const entry: MessageEntry = {
|
||||||
|
id: "1",
|
||||||
|
timestamp: new Date(),
|
||||||
|
direction: "request",
|
||||||
|
message: { jsonrpc: "2.0", id: 1, method: "ping", params: {} },
|
||||||
|
// A primitive result (e.g. an EmptyResult serialized oddly) is not modern.
|
||||||
|
response: { jsonrpc: "2.0", id: 1, result: null as never },
|
||||||
|
};
|
||||||
|
expect(extractResultType(entry)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractRequestState", () => {
|
||||||
|
it("reads the token off an input_required result", () => {
|
||||||
|
const entry = callEntry(
|
||||||
|
"1",
|
||||||
|
1,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
resultType: "input_required",
|
||||||
|
requestState: "opaque-1",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(extractRequestState(entry)).toBe("opaque-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the token echoed on a retried request's params", () => {
|
||||||
|
const entry = callEntry(
|
||||||
|
"2",
|
||||||
|
2,
|
||||||
|
{ name: "greet", requestState: "opaque-1", inputResponses: {} },
|
||||||
|
{ resultType: "complete", content: [] },
|
||||||
|
);
|
||||||
|
expect(extractRequestState(entry)).toBe("opaque-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when neither result nor params carry a token", () => {
|
||||||
|
const entry = callEntry("1", 1, { name: "greet" }, { content: [] });
|
||||||
|
expect(extractRequestState(entry)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores an empty-string token", () => {
|
||||||
|
const entry = callEntry(
|
||||||
|
"1",
|
||||||
|
1,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
resultType: "input_required",
|
||||||
|
requestState: "",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(extractRequestState(entry)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractSubscriptionId", () => {
|
||||||
|
it("reads the subscriptionId off a tagged notification's _meta", () => {
|
||||||
|
const entry: MessageEntry = {
|
||||||
|
id: "1",
|
||||||
|
timestamp: new Date(),
|
||||||
|
direction: "notification",
|
||||||
|
origin: "server",
|
||||||
|
message: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "notifications/resources/list_changed",
|
||||||
|
params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: "sub-42" } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(extractSubscriptionId(entry)).toBe("sub-42");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for an untagged notification", () => {
|
||||||
|
const entry: MessageEntry = {
|
||||||
|
id: "1",
|
||||||
|
timestamp: new Date(),
|
||||||
|
direction: "notification",
|
||||||
|
message: { jsonrpc: "2.0", method: "notifications/initialized" },
|
||||||
|
};
|
||||||
|
expect(extractSubscriptionId(entry)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when params carry no _meta", () => {
|
||||||
|
const entry: MessageEntry = {
|
||||||
|
id: "1",
|
||||||
|
timestamp: new Date(),
|
||||||
|
direction: "notification",
|
||||||
|
message: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "notifications/message",
|
||||||
|
params: { level: "info" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(extractSubscriptionId(entry)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when _meta carries a non-string id", () => {
|
||||||
|
const entry: MessageEntry = {
|
||||||
|
id: "1",
|
||||||
|
timestamp: new Date(),
|
||||||
|
direction: "notification",
|
||||||
|
message: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "notifications/message",
|
||||||
|
params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: 99 } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(extractSubscriptionId(entry)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("groupProtocolEntries", () => {
|
||||||
|
it("leaves non-MRTR traffic as single rows, in order", () => {
|
||||||
|
const a = callEntry("a", 1, { name: "x" }, { content: [] }, 1);
|
||||||
|
const b = callEntry("b", 2, { name: "y" }, { content: [] }, 2);
|
||||||
|
const rows = groupProtocolEntries([a, b]);
|
||||||
|
expect(rows).toEqual([
|
||||||
|
{ kind: "single", entry: a },
|
||||||
|
{ kind: "single", entry: b },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clusters an original call + its retry into one MRTR row", () => {
|
||||||
|
const original = callEntry(
|
||||||
|
"orig",
|
||||||
|
1,
|
||||||
|
{ name: "greet" },
|
||||||
|
{ resultType: "input_required", requestState: "tok" },
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const retry = callEntry(
|
||||||
|
"retry",
|
||||||
|
2,
|
||||||
|
{ name: "greet", requestState: "tok", inputResponses: {} },
|
||||||
|
{ resultType: "complete", content: [] },
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
const rows = groupProtocolEntries([original, retry]);
|
||||||
|
expect(rows).toEqual([
|
||||||
|
{ kind: "mrtr", requestState: "tok", rounds: [original, retry] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clusters a multi-round MRTR conversation", () => {
|
||||||
|
const r1 = callEntry(
|
||||||
|
"r1",
|
||||||
|
1,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
resultType: "input_required",
|
||||||
|
requestState: "tok",
|
||||||
|
},
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const r2 = callEntry(
|
||||||
|
"r2",
|
||||||
|
2,
|
||||||
|
{ requestState: "tok" },
|
||||||
|
{ resultType: "input_required", requestState: "tok" },
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
const r3 = callEntry(
|
||||||
|
"r3",
|
||||||
|
3,
|
||||||
|
{ requestState: "tok" },
|
||||||
|
{ resultType: "complete", content: [] },
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
const rows = groupProtocolEntries([r1, r2, r3]);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0]).toEqual({
|
||||||
|
kind: "mrtr",
|
||||||
|
requestState: "tok",
|
||||||
|
rounds: [r1, r2, r3],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT merge adjacent MRTR runs with different tokens", () => {
|
||||||
|
const a1 = callEntry(
|
||||||
|
"a1",
|
||||||
|
1,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
resultType: "input_required",
|
||||||
|
requestState: "A",
|
||||||
|
},
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const a2 = callEntry(
|
||||||
|
"a2",
|
||||||
|
2,
|
||||||
|
{ requestState: "A" },
|
||||||
|
{ resultType: "complete", content: [] },
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
const b1 = callEntry(
|
||||||
|
"b1",
|
||||||
|
3,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
resultType: "input_required",
|
||||||
|
requestState: "B",
|
||||||
|
},
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
const rows = groupProtocolEntries([a1, a2, b1]);
|
||||||
|
expect(rows).toEqual([
|
||||||
|
{ kind: "mrtr", requestState: "A", rounds: [a1, a2] },
|
||||||
|
{ kind: "mrtr", requestState: "B", rounds: [b1] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an unrelated entry interleaved between two separate MRTR runs as its own row", () => {
|
||||||
|
const a1 = callEntry(
|
||||||
|
"a1",
|
||||||
|
1,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
resultType: "input_required",
|
||||||
|
requestState: "A",
|
||||||
|
},
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const middle = callEntry("mid", 2, { name: "other" }, { content: [] }, 2);
|
||||||
|
const a2 = callEntry(
|
||||||
|
"a2",
|
||||||
|
3,
|
||||||
|
{ requestState: "A" },
|
||||||
|
{ resultType: "complete", content: [] },
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
const rows = groupProtocolEntries([a1, middle, a2]);
|
||||||
|
// The unrelated frame breaks contiguity, so the two "A" entries become two
|
||||||
|
// separate single-round MRTR rows rather than one merged conversation.
|
||||||
|
expect(rows).toEqual([
|
||||||
|
{ kind: "mrtr", requestState: "A", rounds: [a1] },
|
||||||
|
{ kind: "single", entry: middle },
|
||||||
|
{ kind: "mrtr", requestState: "A", rounds: [a2] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
import type { MessageEntry, MessageMethod } from "@inspector/core/mcp/types.js";
|
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 {
|
export function extractMethod(entry: MessageEntry): MessageMethod {
|
||||||
if ("method" in entry.message) {
|
if ("method" in entry.message) {
|
||||||
|
|
@ -32,3 +36,134 @@ export const REPLAYABLE_PROTOCOL_METHODS: ReadonlySet<string> = new Set([
|
||||||
export function isReplayableProtocolMethod(method: string): boolean {
|
export function isReplayableProtocolMethod(method: string): boolean {
|
||||||
return REPLAYABLE_PROTOCOL_METHODS.has(method);
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,64 @@ export const WithEntries: Story = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A modern (2026-07-28) connection: the era badge labels the traffic and an
|
||||||
|
// MRTR round-trip renders as a single grouped conversation.
|
||||||
|
const modernEntries: MessageEntry[] = [
|
||||||
|
{
|
||||||
|
id: "mrtr-orig",
|
||||||
|
timestamp: new Date("2026-07-28T10:00:00Z"),
|
||||||
|
direction: "request",
|
||||||
|
origin: "client",
|
||||||
|
message: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 10,
|
||||||
|
method: "tools/call",
|
||||||
|
params: { name: "book_flight", arguments: { destination: "SFO" } },
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 10,
|
||||||
|
result: {
|
||||||
|
resultType: "input_required",
|
||||||
|
requestState: "opaque-token",
|
||||||
|
inputRequests: { "1": { method: "elicitation/create", params: {} } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mrtr-retry",
|
||||||
|
timestamp: new Date("2026-07-28T10:00:05Z"),
|
||||||
|
direction: "request",
|
||||||
|
origin: "client",
|
||||||
|
message: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 11,
|
||||||
|
method: "tools/call",
|
||||||
|
params: {
|
||||||
|
name: "book_flight",
|
||||||
|
requestState: "opaque-token",
|
||||||
|
inputResponses: { "1": { content: { name: "Ada" } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 11,
|
||||||
|
result: {
|
||||||
|
resultType: "complete",
|
||||||
|
content: [{ type: "text", text: "Booked" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ModernEraWithMrtr: Story = {
|
||||||
|
args: {
|
||||||
|
entries: modernEntries,
|
||||||
|
protocolEra: "modern",
|
||||||
|
compact: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const Empty: Story = {
|
export const Empty: Story = {
|
||||||
args: {
|
args: {
|
||||||
entries: [],
|
entries: [],
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue