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
1 changed files with 1785 additions and 58 deletions
|
|
@ -1,6 +1,33 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { SUBSCRIPTION_ID_META_KEY } from "@modelcontextprotocol/client";
|
||||
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", () => {
|
||||
it("returns the method name for a request entry", () => {
|
||||
|
|
@ -58,3 +85,326 @@ describe("extractMethod", () => {
|
|||
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] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue