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
3 changed files with 1785 additions and 58 deletions
|
|
@ -116,3 +116,77 @@ export const Empty: Story = {
|
|||
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" }));
|
||||
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,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import type { ProtocolEra } from "@modelcontextprotocol/client";
|
||||
import type {
|
||||
MessageEntry,
|
||||
MessageMethod,
|
||||
MessageOrigin,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
import { ProtocolEntry } from "../ProtocolEntry/ProtocolEntry";
|
||||
import { MrtrConversation } from "../MrtrConversation/MrtrConversation";
|
||||
import { ListToggle } from "../../elements/ListToggle/ListToggle";
|
||||
import { EraBadge } from "../../elements/EraBadge/EraBadge";
|
||||
import {
|
||||
SortToggle,
|
||||
type SortDirection,
|
||||
} from "../../elements/SortToggle/SortToggle";
|
||||
import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea";
|
||||
import { extractMethod } from "../protocolUtils.js";
|
||||
import { extractMethod, groupProtocolEntries } from "../protocolUtils.js";
|
||||
import { useScrollMemory } from "../../../hooks/useScrollMemory";
|
||||
|
||||
export interface ProtocolListPanelProps {
|
||||
|
|
@ -31,6 +34,13 @@ export interface ProtocolListPanelProps {
|
|||
methodFilter?: MessageMethod;
|
||||
/** Which message directions to show, keyed by entry origin. */
|
||||
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;
|
||||
onExport: () => void;
|
||||
/** Clear just one section's entries (pinned vs unpinned history). */
|
||||
|
|
@ -215,6 +225,7 @@ export function ProtocolListPanel({
|
|||
searchText,
|
||||
methodFilter,
|
||||
visibleDirections,
|
||||
protocolEra,
|
||||
onClearAll,
|
||||
onExport,
|
||||
onClearSection,
|
||||
|
|
@ -273,10 +284,43 @@ export function ProtocolListPanel({
|
|||
// with a single section the panel-level Clear/Export already covers it.
|
||||
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 (
|
||||
<PanelContainer>
|
||||
<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">
|
||||
<SortToggle
|
||||
value={sortDirection}
|
||||
|
|
@ -321,17 +365,7 @@ export function ProtocolListPanel({
|
|||
) : undefined
|
||||
}
|
||||
>
|
||||
{pinnedEntries.map((entry) => (
|
||||
<ProtocolEntry
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isPinned={true}
|
||||
isListExpanded={!compact}
|
||||
embedded={embedded}
|
||||
onReplay={() => onReplay(entry.id)}
|
||||
onTogglePin={() => onTogglePin(entry.id)}
|
||||
/>
|
||||
))}
|
||||
{renderRows(pinnedEntries, true)}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
|
|
@ -353,17 +387,7 @@ export function ProtocolListPanel({
|
|||
) : undefined
|
||||
}
|
||||
>
|
||||
{unpinnedEntries.map((entry) => (
|
||||
<ProtocolEntry
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isPinned={false}
|
||||
isListExpanded={!compact}
|
||||
embedded={embedded}
|
||||
onReplay={() => onReplay(entry.id)}
|
||||
onTogglePin={() => onTogglePin(entry.id)}
|
||||
/>
|
||||
))}
|
||||
{renderRows(unpinnedEntries, false)}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
</Stack>
|
||||
|
|
|
|||
Loading…
Reference in a new issue