inspector/clients/web/src/lib/protocolReplay.ts
cliffhall 9b35e530fc fix: send an empty-string pagination cursor verbatim on every list adapter (#2220)
An MCP cursor is an opaque string: the spec constrains neither its content
nor its length, so `""` is a `nextCursor` a server may legitimately return
and the client is required to send back verbatim.

`listPrompts`, `listResources`, `listResourceTemplates` and
`listRequestorTasks` built their request params with `cursor ? { cursor } : {}`,
a truthiness check that cannot distinguish "no cursor" from "the cursor is
the empty string". Against a server that paginates with `""` they dropped it
and re-requested page one — a list that silently stops at the first page, or
a walk that loops on it forever. Nothing surfaced an error, because the
request was well-formed; it just asked the wrong question. `listTools` (and
the later `listSkills`) already used `cursor !== undefined`; this brings the
other four into line.

`getReplayableParams` in `protocolReplay.ts` existed partly to mirror the
asymmetry — its `method === "tools/list" || params.cursor !== ""` special
case collapses to `typeof params.cursor === "string"` now that the adapters
agree, and its long comment is updated rather than left describing a state
that no longer exists.

Coverage:

- `inspectorClient-list-cursor.test.ts` asserts the outbound params of all
  five adapters — `""` carried, an absent cursor sending no `cursor` key,
  a non-empty cursor forwarded unchanged. `listTools` is the control.
- `test-servers/configs/empty-cursor-http.json` (`emptyStringCursor`) hands
  out `""` as the cursor for page two, the shape no other fixture produces,
  and `empty-cursor.test.ts` walks all three of its lists end to end.
- Removing the fix from any one adapter fails exactly its two cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EN2UfrkQZvtrviMtqURRm4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-06 13:28:44 -04:00

290 lines
12 KiB
TypeScript

import type {
LoggingMessageNotification,
Tool,
} from "@modelcontextprotocol/client";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
import type { JsonValue } from "@inspector/core/mcp/index.js";
import type { MessageEntry } from "@inspector/core/mcp/types.js";
// Type-only, so this creates no runtime edge into `components/` and the
// `components → lib → utils` direction still holds: `LogEntryData` is the shape
// the Logs screen renders, and this module exists to produce exactly that.
import type { LogEntryData } from "../components/elements/LogEntry/LogEntry";
import {
coercedArgumentNames,
coercedArgumentsError,
} from "@inspector/core/json/jsonUtils.js";
import { isReplayableProtocolMethod } from "../utils/replayableProtocolMethods";
// Derive `LogEntryData[]` from the MessageLog by filtering for the
// `notifications/message` notifications the server emits in response to
// `logging/setLevel`. The Logs screen renders these; we transform here
// rather than in the screen so the view stays prop-driven.
export function messagesToLogEntries(messages: MessageEntry[]): LogEntryData[] {
const out: LogEntryData[] = [];
for (const m of messages) {
if (m.direction !== "notification") continue;
// MessageEntry.message is a JSONRPC union; notifications have `method`
// but not `id`. Narrow with an `in` check, then confirm the method.
if (!("method" in m.message)) continue;
if (m.message.method !== "notifications/message") continue;
// The method check pins this to a logging notification; its `params` are
// only generically typed on the JSONRPC union, so cast just that value.
const params = m.message.params as LoggingMessageNotification["params"];
out.push({
receivedAt: m.timestamp,
params,
});
}
return out;
}
// Re-issue the original request behind a Protocol entry. The call goes through
// InspectorClient → tracked transport → message log, so the replayed
// request+response surface as a fresh Protocol entry (protocol-local) — it
// intentionally does NOT touch the Tools/Prompts/Resources panels. Returns a
// human-readable reason when the entry can't be replayed (unsupported method,
// or a tool that's no longer present), or null on a dispatched replay.
/**
* The slice of `InspectorClient` a replay actually reaches. Naming it — rather
* than taking the whole client — is what lets a caller (and a test) supply a
* value that satisfies the real contract instead of casting one in.
*/
export type ReplayClient = Pick<
InspectorClient,
| "callTool"
| "getPrompt"
| "readResource"
| "listTools"
| "listPrompts"
| "listResources"
| "listResourceTemplates"
| "listRequestorTasks"
| "ping"
>;
/**
* Params supplied by the Edit-and-replay editor, in place of the entry's own
* (#2151).
*
* `undefined` and `null` are **not** interchangeable, which is why this is not
* just an optional object: `undefined` means the user did not edit anything —
* replay the entry verbatim — while `null` means they cleared the editor, which
* is a legitimate replay of a params-less request. Collapsing the two would
* make an emptied editor silently re-send the original params.
*/
export type ReplayParamsOverride = Record<string, unknown> | null | undefined;
/**
* The params of a replayed request that `replayProtocolRequest` actually reads,
* per method.
*
* This is the other half of the switch below, and the two must move together.
* Replay does not re-send a captured frame verbatim — it dispatches through the
* typed `InspectorClient` methods, which take named arguments. Everything those
* signatures have no room for is dropped: `_meta` on any method (a captured
* `tools/call` carries `_meta.progressToken`), and every key of a list request
* other than `cursor`.
*
* That is invisible and harmless for one-click Replay, which re-sends what it
* was given. It is neither once the params are put in front of the user to
* edit (#2151): an editor showing `_meta` invites them to change it, and Send
* would then transmit something other than what they were looking at. So the
* Edit-and-replay modal seeds from *this* rather than from the raw frame, and
* names what it left out.
*/
export function replayableParams(
method: string,
params: Record<string, unknown> | undefined,
): { params: Record<string, unknown> | undefined; dropped: string[] } {
if (!params) return { params: undefined, dropped: [] };
const keep = (...names: string[]): string[] =>
names.filter((name) => Object.hasOwn(params, name));
let kept: string[];
switch (method) {
case "tools/call":
case "prompts/get":
kept = keep("name", "arguments");
break;
case "resources/read":
kept = keep("uri");
break;
case "tools/list":
case "prompts/list":
case "resources/list":
case "resources/templates/list":
case "tasks/list":
// Only a *string* cursor survives: the dispatcher ignores any other type,
// so keeping it would put a value in the editor that changes nothing.
//
// An **empty** string is a cursor like any other, and every one of these
// five adapters now builds its params with `cursor !== undefined` (#2220
// brought the other four into line with `listTools`), so `""` reaches the
// wire on all of them and is reported as kept rather than dropped.
kept = typeof params.cursor === "string" ? ["cursor"] : [];
break;
default:
// `ping` takes nothing, and an unreplayable method never reaches here.
kept = [];
break;
}
const dropped = Object.keys(params).filter((name) => !kept.includes(name));
// `fromEntries`, never an assignment loop: an argument legitimately named
// `__proto__` would otherwise reach the prototype setter.
return {
params:
kept.length > 0
? Object.fromEntries(kept.map((name) => [name, params[name]]))
: undefined,
dropped,
};
}
/**
* A param the dispatch below would *reshape* on the way out, or null when every
* kept param is sent as written.
*
* {@link replayableParams} answers which keys survive; this answers whether the
* surviving ones survive *intact*. Only `arguments` can fail that, and it fails
* three ways, each of which the Edit-and-replay editor has to catch before
* Send:
*
* - `arguments: null` is nullish, so `?? {}` replaces it — the editor shows
* `null` and the wire carries `{}`.
* - `arguments: [1,2]` or `arguments: 4` is not nullish, so the cast carries it
* through unchanged into a call whose type says it is a named-argument
* record. Nothing reshapes it, but nothing can send it either.
* - For **`prompts/get`**, a non-string *value*. `getPrompt` runs
* `convertPromptArguments`, which `JSON.stringify`s anything that is not
* already a string — so `{"count": 2}` is sent as `{"count": "2"}` while the
* editor still shows the number. This is not a quirk to route around: the
* spec types `GetPromptRequest.params.arguments` as `Record<string, string>`,
* so a string is the only thing a prompt argument can be.
* - For **`tools/call`**, the mirror image. `callTool` runs every *string*
* entry through `convertToolParameters`, because the widget form hands
* everything over as text — so `{"count": "2"}` against a schema declaring a
* number is sent as `{"count": 2}`. Detected by running that same conversion
* and comparing, rather than by reimplementing its rules, so the two cannot
* drift. Needs the `tool`; without one this check is skipped, since nothing
* can be said about a coercion whose schema is unknown.
*
* The Tools and Apps tabs' own "Edit as JSON" switch refuses the identical
* draft, through the identical helper (#2171). Both are JSON documents whose
* values already carry their types, so in both the conversion would send
* something other than what the editor showed — and one of the two answering
* differently is the inconsistency #2171 was filed to remove.
*
* `name` and `uri` are deliberately not checked here: the dispatch already
* refuses a missing or non-string one with a reason the caller surfaces as a
* toast, so those fail visibly rather than silently.
*/
export function reshapedReplayParam(
method: string,
params: Record<string, unknown> | undefined,
tool?: Tool,
): string | null {
if (!params) return null;
if (method !== "tools/call" && method !== "prompts/get") return null;
if (!Object.hasOwn(params, "arguments")) return null;
const args = params.arguments;
const isRecord =
args !== null && typeof args === "object" && !Array.isArray(args);
if (!isRecord) {
return args === null
? "`arguments` is sent as `{}` when null — remove it, or give it an object"
: "`arguments` must be a JSON object (`{ … }`)";
}
if (method === "prompts/get") {
const coerced = Object.entries(args as Record<string, unknown>)
.filter(([, value]) => typeof value !== "string")
.map(([key]) => `\`${key}\``);
if (coerced.length > 0) {
return `A prompt argument is always a string — ${coerced.join(", ")} would be sent as JSON text`;
}
}
if (method === "tools/call" && tool) {
// The same check the Tools/Apps form's raw-JSON editor makes, from the same
// helper, so the two JSON-authoring surfaces cannot disagree about which
// drafts are sendable (#2171).
const coerced = coercedArgumentNames(
tool.inputSchema,
args as Record<string, unknown>,
);
if (coerced.length > 0) {
return coercedArgumentsError(coerced, tool.name);
}
}
return null;
}
export async function replayProtocolRequest(
client: ReplayClient,
method: string,
params: Record<string, unknown> | undefined,
tools: Tool[],
): Promise<string | null> {
// Gate on the shared replayable-method set (the same one ProtocolEntry uses to
// show/hide the Replay button) so the two can't drift.
if (!isReplayableProtocolMethod(method)) {
return `Replay isn't supported for "${method}".`;
}
// Pagination cursor carried by the */list requests; replaying the same page
// reproduces the original call.
const cursor = typeof params?.cursor === "string" ? params.cursor : undefined;
switch (method) {
case "tools/call": {
const name = typeof params?.name === "string" ? params.name : undefined;
const tool = tools.find((t) => t.name === name);
if (!tool) {
return `Tool "${name ?? "?"}" is no longer available to replay.`;
}
await client.callTool(
tool,
(params?.arguments ?? {}) as Record<string, JsonValue>,
);
return null;
}
case "prompts/get": {
const name = typeof params?.name === "string" ? params.name : undefined;
if (!name) return "Prompt name is missing; cannot replay.";
await client.getPrompt(
name,
(params?.arguments ?? {}) as Record<string, JsonValue>,
);
return null;
}
case "resources/read": {
const uri = typeof params?.uri === "string" ? params.uri : undefined;
if (!uri) return "Resource URI is missing; cannot replay.";
await client.readResource(uri);
return null;
}
case "tools/list":
await client.listTools(cursor);
return null;
case "prompts/list":
await client.listPrompts(cursor);
return null;
case "resources/list":
await client.listResources(cursor);
return null;
case "resources/templates/list":
await client.listResourceTemplates(cursor);
return null;
case "tasks/list":
await client.listRequestorTasks(cursor);
return null;
case "ping":
await client.ping();
return null;
/* v8 ignore start -- unreachable: the guard above admits exactly the nine
methods this switch enumerates. Kept so a method added to
REPLAYABLE_PROTOCOL_METHODS without a case here reports a reason rather
than silently resolving as a dispatched replay. */
default:
return `Replay isn't supported for "${method}".`;
/* v8 ignore stop */
}
}