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
|
|
@ -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 = {
|
||||
args: {
|
||||
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
|
||||
// in a horizontal scroll area rather than truncating with an ellipsis. Wrapped
|
||||
// in the pinned column's width so the overflow is visible.
|
||||
|
|
|
|||
|
|
@ -369,3 +369,135 @@ describe("ProtocolEntry", () => {
|
|||
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,
|
||||
Text,
|
||||
} 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 { CopyButton } from "../../elements/CopyButton/CopyButton";
|
||||
import { MessageDirectionBadge } from "../../elements/MessageDirectionBadge/MessageDirectionBadge";
|
||||
|
|
@ -18,7 +18,13 @@ import { MethodBadge } from "../../elements/MethodBadge/MethodBadge";
|
|||
import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle";
|
||||
import { PinToggle } from "../../elements/PinToggle/PinToggle";
|
||||
import { ReplayButton } from "../../elements/ReplayButton/ReplayButton";
|
||||
import { extractMethod, isReplayableProtocolMethod } from "../protocolUtils.js";
|
||||
import {
|
||||
extractMethod,
|
||||
extractResultType,
|
||||
extractSubscriptionId,
|
||||
isModernFrame,
|
||||
isReplayableProtocolMethod,
|
||||
} from "../protocolUtils.js";
|
||||
|
||||
export interface ProtocolEntryProps {
|
||||
entry: MessageEntry;
|
||||
|
|
@ -83,6 +89,46 @@ const DurationText = Text.withProps({
|
|||
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({
|
||||
variant: "subtle",
|
||||
size: "xs",
|
||||
|
|
@ -164,6 +210,9 @@ export function ProtocolEntry({
|
|||
const resourceUri = extractResourceUri(entry);
|
||||
const status = extractStatus(entry);
|
||||
const canReplay = isReplayableProtocolMethod(method);
|
||||
const resultType = extractResultType(entry);
|
||||
const subscriptionId = extractSubscriptionId(entry);
|
||||
const isModern = isModernFrame(method);
|
||||
|
||||
useEffect(() => {
|
||||
setIsExpanded(isListExpanded);
|
||||
|
|
@ -174,11 +223,33 @@ export function ProtocolEntry({
|
|||
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">
|
||||
{statusLabel(status)}
|
||||
</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 && (
|
||||
<DurationText>{formatDuration(entry.duration)}</DurationText>
|
||||
);
|
||||
|
|
@ -198,12 +269,15 @@ export function ProtocolEntry({
|
|||
</HeaderCluster>
|
||||
<ControlsCluster>
|
||||
{durationText}
|
||||
{resultTypeBadge}
|
||||
{statusBadge}
|
||||
</ControlsCluster>
|
||||
</HeaderRow>
|
||||
<HeaderRow>
|
||||
<HeaderCluster flex={1}>
|
||||
<MethodBadge method={method} />
|
||||
{modernFrameBadge}
|
||||
{subscriptionBadge}
|
||||
{target && (
|
||||
<>
|
||||
{resourceUri && <CopyButton value={resourceUri} />}
|
||||
|
|
@ -241,6 +315,8 @@ export function ProtocolEntry({
|
|||
</TimestampText>
|
||||
{directionBadge}
|
||||
<MethodBadge method={method} />
|
||||
{modernFrameBadge}
|
||||
{subscriptionBadge}
|
||||
{target && (
|
||||
<>
|
||||
{resourceUri && <CopyButton value={resourceUri} />}
|
||||
|
|
@ -250,6 +326,7 @@ export function ProtocolEntry({
|
|||
</Group>
|
||||
<Group gap="sm">
|
||||
{durationText}
|
||||
{resultTypeBadge}
|
||||
{statusBadge}
|
||||
</Group>
|
||||
</HeaderRow>
|
||||
|
|
|
|||
Loading…
Reference in a new issue