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>
The Tools and Apps tabs' "Edit as JSON" editor sent its draft through the
string-to-declared-type conversion `callTool` applies for the widget form, so
`{"count":"01"}` against a numeric schema left as `{"count":1}` — the editor
showed one payload and the wire carried another.
Both JSON-authoring surfaces now refuse such a draft and name the value to
rewrite. Edit-and-replay already did; this makes the Tools and Apps forms
agree with it, which is the consistency #2171's acceptance asks for.
`coercedArgumentNames` and `coercedArgumentsError` move into core/json so the
two surfaces share one predicate and one sentence — the check runs the real
conversion and compares rather than restating its rules, so it cannot drift
from what would actually be sent. Enforcement is opt-in
(`SchemaFormProps.enforceToolArgumentTypes`): an elicitation renders through
the same form and its values are never converted, so enforcing there would
refuse a draft for a reason that is not true of it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot's eighth pass, three comments, all real.
- The precision check now reads the **source text**. Inferring the literal from
`String(value)` was wrong in both directions: it accepted every full-form
integer at or above 1e21 (`1000000000000000000001` parses to `1e21` and is
written back as `1e+21`), and refused `2^54`, which is written out in full
and exactly representable. `hasImpreciseIntegerLiteral` scans the draft for
numeric tokens — skipping strings, so a digit-string value is untouched — and
compares `BigInt(literal)` against `BigInt(Number(literal))`, which is exact.
- `""` is now reported as a surviving cursor for `tools/list` only. It builds
its params with `cursor !== undefined` and carries the empty string
deliberately; the other four list adapters use a truthiness check and drop
it, so the editor showed `{"cursor":""}` while `{}` went out. The asymmetry
looks like a latent bug in those four, but this function's job is to describe
what the dispatch does.
- `callTool` runs every string-valued argument through `convertToolParameters`,
so `{"count":"2"}` against a numeric schema is sent as `{"count":2}`.
`reshapedReplayParam` now takes the tool and detects that by running the same
conversion and comparing, rather than restating its rules — the two cannot
drift. `tools` is threaded to `ProtocolEntry` for it, optional throughout: an
unknown tool skips the check rather than blocking a replay.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot's seventh pass, three comments, all the same class: the editor showing
one value while the wire carries another.
- `JSON.parse` rounds a whole number past 2^53−1 to the nearest double, so a
draft reading `{"id":9007199254740993}` submits `…992`. New
`hasRoundedInteger`, applied by all four JSON-draft parsers — the raw
arguments editor, `SchemaJsonField`, the Edit-and-replay modal, and
`parseJsonObjectDraft`, which Copilot did not flag but shares the defect.
It is narrower than "unsafe integer": it also requires the number's shortest
form to be written out in full, because JS stringifies from 1e21 upward in
exponent form and such a value was typed that way — `{"n":1e308}` is a
legitimate `_meta` value that `parseJsonObjectDraft`'s tests already pin as
accepted. It still refuses the rare full-form integer that happens to be
exactly representable, erring toward refusing a sendable value rather than
misreporting an unsendable one — the trade `toNumericValue` already makes.
- `getPrompt` runs `convertPromptArguments`, which JSON-stringifies every
non-string argument, so `{"count":2}` leaves as `{"count":"2"}` while the
editor still shows a number. `reshapedReplayParam` now rejects a non-string
prompt argument. Not a workaround: the spec types
`GetPromptRequest.params.arguments` as `Record<string, string>`, so a string
is the only thing a prompt argument can be. `tools/call` is untouched —
`callTool` sends its arguments as given.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot's sixth pass generated no new inline comments; both suppressed ones
were right.
- `arguments` surviving the key check is not the same as being sent as
written. `replayProtocolRequest` applies `arguments ?? {}`, so a `null` shown
in the editor reaches the wire as `{}`; an array or scalar is not nullish and
rides the cast into a call whose type says it is a named-argument record.
`reshapedReplayParam` rejects both before Send. `name` and `uri` are
deliberately not checked there — the dispatch already refuses those with a
reason the caller shows as a toast, so they fail visibly rather than
silently.
- `NetworkEntry` never forwarded the body's content type, so the Network tab
could not reach the declared-JSON branch at all and a malformed
`application/json` response read as plain text with no sign it was meant to
be JSON. Forwarded — but only when the declared type *is* JSON: passing it
wholesale would also route an HTML body into the sandboxed frame and a CSV
one into a table, which is a change to what the Network tab is and not one
this makes.
The `NetworkEntry > Auth Success` play function moved off `textContent` for
body assertions. Ace repaints its rows on an animation frame and virtualizes
them, so reading the DOM straight after Reveal returned the previous rows
under load — it passed in isolation and failed in the full run. It now reads
the editors' documents alongside the DOM text, and waits.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot's fourth pass, one inline plus two suppressed. All three were right.
- The modal presented the whole captured frame as editable, but replay does
not re-send a frame: it dispatches through the typed InspectorClient
methods, whose signatures have no room for `_meta` (a captured `tools/call`
carries `_meta.progressToken`) or for any list-request key but `cursor`. So
editing `_meta` and pressing Send transmitted something other than what was
on screen. `replayableParams` now projects the frame down to what the
dispatcher actually reads, the modal seeds from that, and it names what was
left out rather than leaving the difference to be discovered. The projection
lives beside the dispatch switch because the two are halves of one contract.
- `ContentViewer` gains `jsonLabel`, so a read-only JSON editor can say what
it holds. Every one of them announced "JSON content", and an expanded
Protocol entry has two while the list has many pairs — leaving a screen
reader with indistinguishable textboxes and only the visible headings to
tell them apart. Protocol entries now name theirs per method and direction,
Network entries per request/response body.
- `isJsonDocument` became `formatJsonDocument`, returning the formatted text
instead of a boolean. The heuristic path parsed the payload to decide and
then parsed it again to render; Protocol and Network hand over serialized
bodies as untyped text and are not virtualized, so a long history paid twice
per payload per render.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
Extracts the Ace integration #2094 carried inline into a shared
`elements/JsonEditor`, and renders it on every surface where the web
client types or displays JSON.
The element is deliberately text in, text out. The two editing contracts
above it disagree about what an unparseable draft means — JsonObjectInput
keeps the last valid object and says nothing to its parent, SchemaJsonField
reports `undefined` and blocks submission (#2020) — so parsing, and the
draft/value split that goes with it, stays with each caller.
Surfaces: SchemaJsonField (inherited by all four SchemaForm consumers), a
new "Edit as JSON" switch for the whole arguments object, the server.json
File Contents box, the raw JSON-RPC request box, ContentViewer's JSON
branch (read-only, inherited by its eleven consumers), and a new "Edit and
replay" modal on a Protocol entry.
Ace read-only replaces the CodeHighlight JSON path and the Prism `json`
grammar loader is dropped: folding, not colorization, is what it adds, and
two highlighters for one language drift. `wrap={false}` callers and untyped
text that only looks like JSON keep the plain renderer.
Closes#2151
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
- Move REPLAYABLE_PROTOCOL_METHODS / isReplayableProtocolMethod out of
components/groups/protocolUtils.ts into utils/replayableProtocolMethods.ts.
It is a pure predicate over a string set, so by the project's own lib-vs-utils
rule it never belonged under components/ — and keeping it there was what made
lib/protocolReplay.ts import 'up' into the component graph. Both consumers now
import from utils/; the predicate keeps its own test.
- Give replayProtocolRequest an exported ReplayClient = Pick<InspectorClient, ...>
naming the nine methods it calls, so a caller (and the test) can satisfy the
real contract instead of casting a stub into a whole client.
- Rebuild the protocolReplay fixtures as plain typed MessageEntry values. The
double cast had been hiding a real defect: MessageEntry.timestamp is a Date
and the fixture passed a number, so the assertion confirmed a shape
messagesToLogEntries cannot produce.
- Replace the window casts in authToken.test.ts with Reflect.set /
Reflect.deleteProperty. No 'as unknown as' remains in either test.
- Drop the stale 'floats top-right' sentence on ReAuthBannerBar, which moved
verbatim and arrived contradicting the paragraph below it.
Declined: re-homing ReAuthBannerBar's transform/zIndex as a theme variant.
'styles' is the Styles API rather than an inline style, Mantine exposes neither
property as a prop, and a styling change is out of scope for a phase whose
safety argument is that nothing was edited.
Signed-off-by: cliffhall <cliff@futurescale.com>
Phase 0 of #2126. Lines 239-668 of clients/web/src/App.tsx were already at
module scope — helpers, formatters, constants, and the toast subcomponents,
none of which close over component state — so the whole block moves verbatim
into files. App.tsx drops from 5,303 to 4,892 lines with no behavior change.
Placement follows the lib-vs-utils rule in AGENTS.md: pure computation to
utils/, anything touching the environment to lib/.
EMPTY_SETTINGS and the two loose toast ids deliberately do NOT land in a new
top-level src/constants.ts, which the issue suggested: the web coverage
include is a whitelist naming components/hooks/theme/lib/utils/server, so a
module there would have fallen out of the >=90 gate silently. They go under
utils/ instead.
ReAuthBannerBar is in the block but not in the issue's table — it was added
after the issue was written (#2108). It moves to components/groups/ReAuthBanner/,
beside the banner it wraps.
Every new module is at 100% on all four coverage dimensions. Two v8 ignores,
each justified in place: getAuthToken's typeof-window guard (a happy-dom
inherent path) and replayProtocolRequest's provably-dead default case.
Signed-off-by: cliffhall <cliff@futurescale.com>