- matchesConstants checks own-property presence before reading a supplied
value, so an absent argument legally named `constructor` no longer reads the
inherited one and rules out every branch pinning that name.
- anyOfAdmitsNull asks `admitsNull` about each composition-free branch instead
of testing `type` alone, so a branch spelling its nullability as
`{ const: null }` or `{ nullable: true }` is recognized. Refusing composed
branches is what bounds the recursion.
- Drop a double cast from the malformed-applicator fixture.
Signed-off-by: cliffhall <cliff@futurescale.com>
The App deep link seeded defaults and spread its `appArgs` over them, which
replaces a nested object wholesale: `{ config: { kind: "sms" } }` discarded
the nested SMS branch's own defaults, which the form then displayed while the
submitted arguments omitted them. New seedSchemaValues merges the two per
level, picking each nested branch from the nested values, and the supplied
value still wins wherever the two meet.
Signed-off-by: cliffhall <cliff@futurescale.com>
collectSchemaDefaults recursed only when a nested object declared `properties`
directly, so one keeping its fields on a composition branch was skipped — while
SchemaForm rendered that branch and displayed its read-only discriminator. The
constant was shown and never submitted, and the server rejected the call. The
recursion now gates on declaresAnyFields.
Signed-off-by: cliffhall <cliff@futurescale.com>
- rootUnion reads every composition keyword through `membersOf` and every
`required` through `requiredOf`. These schemas describe the wire and each
member arrives as `unknown`, so `anyOf: {}` or `required: "x"` really
can arrive — and reading them as lists threw, taking all three clients down
rather than declining one malformed tool. Both web and TUI required-field
checks are guarded the same way.
- admitsNull evaluates a sibling `anyOf` beside a `const: null` instead of
refusing outright: the union decides in both directions, and
`{ const: null, anyOf: [{ type: "null" }] }` admits null as plainly as an
all-string union rejects it. Refusing it left a read-only required field
seeded with the only value it accepts and submit permanently disabled.
Signed-off-by: cliffhall <cliff@futurescale.com>
hasMissingRequiredFields treated every empty string as missing, so a required
field the schema pins to `""` — rendered read-only, and seeded with the only
value it accepts — left Execute and Open App permanently disabled. It now
matches the TUI check: an empty string counts as supplied when the property's
own `const` pins it there, and an ordinary blank required string still does
not.
Signed-off-by: cliffhall <cliff@futurescale.com>
- TUI missingRequiredFields treats an empty string as supplied when the
property's own `const` pins it there. The one-option control cannot produce
anything else, so reporting the seeded value as missing made such a branch
permanently uncallable.
- A nested SchemaForm is reset by the branch-aware key too. Two outer
alternatives can both carry the same nested object field, and a nested form
left mounted across an outer switch kept displaying the inner branch chosen
for the other one while the newly seeded values described another shape.
Signed-off-by: cliffhall <cliff@futurescale.com>
- The name-based narrowing is extracted as `narrowBySuppliedNames` and used
by the CLI coercion as well as the form: an undiscriminated union whose
branches type a shared name differently was losing the coercion for every
argument, including the ones that identify the branch unambiguously. The
type-agreement fallback now also polls only the const-compatible branches.
- TUI missingRequiredFields accepts `null` only where the property admits it,
matching the web gate: branch fields are rendered optional, so a
`default: null` on a non-nullable required field reached the check and the
call went out with a value the schema rejects.
Signed-off-by: cliffhall <cliff@futurescale.com>
- selectBranchIndex no longer stops when several branches share the supplied
constant. A branch is dropped only when a supplied value CONTRADICTS one of
its constants; the required-field and exclusive-name passes then run over the
remaining candidates, so { version: 1, phone: "555" } resolves to the SMS
branch instead of falling back to the first.
- hasMissingRequiredFields counts only the branches the values could be making
(new branchAcceptsValues): { kind: "sms", address: "x" } supplies
everything the EMAIL branch requires while contradicting its discriminator,
and would otherwise enable a submit the server refuses.
- convertToolParameters ignores a malformed property declaration when branches
agree on a type, rather than storing a non-schema as the coercion schema.
Signed-off-by: cliffhall <cliff@futurescale.com>
- rootUnion: a merged declaration pairing a `const` with an assertion this
module cannot evaluate (`minimum`, `pattern`, …) is declined. Proving the
conjunction safe is the requirement, not disproving it — `minimum: 10`
beside `const: 1` is as unsatisfiable as a type mismatch.
- declaresAnyFields counts a `$ref`: its shape is unknown rather than empty,
so `anyOf: [{ $ref }, { $ref }]` no longer reports an App tool as
input-free and auto-invokes it with `{}`.
- SchemaForm keys its draft-holding fields by the active branch as well, so a
half-typed `-` or an unparsed JSON draft cannot survive a switch into a
same-named field of the incoming branch — with both parent values
`undefined`, nothing else tells the field the entity changed.
Signed-off-by: cliffhall <cliff@futurescale.com>
- rootUnion: the added-names guard is now a shared `addsForbiddenNames` and
applies to the `allOf` fold as well as to union branches. A member adding a
name a restrictive root `additionalProperties` rejects would otherwise be
merged beside the keyword, where the name reads as allowed.
- selectBranchIndex tests `Object.hasOwn` before reading a pinned value: a
field legally named `constructor` resolved to the inherited one and ruled
out the very branch that pins it.
Signed-off-by: cliffhall <cliff@futurescale.com>
- rootUnion: keywords the two declarations state SEPARATELY can contradict too.
A merged `const` is now checked against its own `type` and `enum`, so
root `x: { type: "string" }` under branch `x: { const: 1 }` declines
instead of seeding an immutable 1 into a string field. An integer const still
satisfies a `number` type — the one direction JSON Schema widens.
- TUI missingRequiredFields tests `Object.hasOwn` first: an argument legally
named `constructor` resolved to the inherited one and read as supplied, and
the call went out without it.
Signed-off-by: cliffhall <cliff@futurescale.com>
collectSchemaDefaults assigned by name, so a legal field called `__proto__`
went to the legacy prototype setter instead of the result. A required one
pinned by `const` would then render read-only and be seeded with nothing,
leaving submit permanently disabled. Seeded through defineProperty now, the
way the other schema-record paths already build their maps.
Signed-off-by: cliffhall <cliff@futurescale.com>
applySchemaConstants only corrected top-level pinned fields, but the deep
link's overlay replaces a nested object wholesale rather than merging into it —
so `{ config: { kind: "sms" } }` slipped past a nested read-only
`kind: { const: "email" }` the form was displaying. It now recurses into
object-valued properties, resolving each one's own root union the same way.
Signed-off-by: cliffhall <cliff@futurescale.com>
- selectBranchIndex compares a discriminator structurally: a `const` may be
an object or array, and deep-link arguments arrive as freshly parsed
instances that could never be reference-equal to the schema's own.
- New applySchemaConstants, applied by InspectorView AFTER the deep link's
`appArgs` are spread over the seeded defaults. The form renders a pinned
field read-only, so a link disagreeing with one would otherwise auto-open an
App with a hidden value contradicting the shape on screen.
Signed-off-by: cliffhall <cliff@futurescale.com>
- rootUnion: schema values are compared through a key-sorted rendering, so two
discriminator constants written as {a,b} and {b,a} are recognized as the same
value rather than as mutually exclusive alternatives.
- selectBranchIndex: after the const and required passes, a name only ONE
alternative declares identifies that alternative; a name several declare is
ambiguous and says nothing.
- additionalProperties counts as restrictive only when its schema states an
assertion — an annotation-only `{ title: ... }` is the equivalent of
`true`, like `{}`.
- Drop the unjustified double casts from the ToolTestModal fixtures; the
schemas are assignable to Partial<Tool> as written.
Signed-off-by: cliffhall <cliff@futurescale.com>
- rootUnion: a `oneOf` demands that EXACTLY one alternative match, which
flattening cannot preserve, so it is offered only when a discriminator makes
the alternatives mutually exclusive; `anyOf` makes no such claim and is
unaffected. An empty `additionalProperties` schema is the equivalent of
`true` and no longer declines a union.
- selectBranchIndex: falls back to the branch whose own required fields the
values supply, so an undiscriminated union's values open the picker on the
shape they describe rather than on the first branch.
- admitsNull: honors a sibling `nullable: true` beside `const: null`.
- SchemaForm: a value is carried only where the incoming branch leaves the
root's declaration as it found it; anything it declares itself is reset,
since it may type the name differently from where the value was typed.
`hasOwn`/`fromEntries` throughout, so `constructor` is not read as a
supplied value and `__proto__` is not dropped into the prototype setter.
- TUI: the same prototype-safe construction on the way out of the form, and
the chosen branch's `required` list is checked at submit — rendering a
branch's fields optional makes the FORM satisfiable, not the call, so the
missing names are reported rather than a known-invalid call being sent.
Signed-off-by: cliffhall <cliff@futurescale.com>
- rootUnion: decline a union that adds fields under a restrictive root
`additionalProperties`. The keyword constrains what its SIBLING
`properties` does not name, so the original schema admits none of the
branch fields; flattening moves them beside the keyword, where they read as
allowed and the form would submit what the schema forbids.
- `default` joins the annotation set: it constrains nothing, so two
declarations suggesting different initial values no longer conflict.
- Property records are read with `Object.hasOwn` and built with
`fromEntries`/`defineProperty`. `properties` is a JSON record, so
`constructor` is a legal argument name that `in` reported as a collision
the root never made, and `__proto__` is one that assignment would drop
into the legacy prototype setter instead of keeping as a field.
- SchemaForm: a value survives a branch switch unless BOTH branches declare
the name. A root argument the outgoing branch merely specialized is still a
root argument in a branch that inherits it, so dropping it erased a valid
value.
Signed-off-by: cliffhall <cliff@futurescale.com>
- rootUnion: union members get the same flattenability test as allOf members,
so a branch whose nested `allOf: [false]` makes it unsatisfiable is no
longer offered as a callable alternative with that constraint erased.
- selectBranchIndex: weigh only the pinned constants the caller SUPPLIED. One
it did not supply is a value this identification exists to seed, so
requiring it meant a deep link naming `kind` alone matched no branch
whenever the branches also pinned, say, a `version`. A supplied constant
that disagrees is still evidence against.
- admitsNull: `const: null` is conjunctive with its siblings, not an
override — `{ type: "string", const: null }` and
`{ const: null, anyOf: [...] }` reject every value, so the claim is made
only where the type and a sibling union leave null on the table.
Signed-off-by: cliffhall <cliff@futurescale.com>
- rootUnion: an `allOf` member is foldable only when it states nothing beyond
what the merge applies (`type`/`properties`/`required` plus annotations),
so a nested `anyOf`, a `not`, or an `additionalProperties` can no longer
be erased along with the keyword; members are checked against the
ACCUMULATED merge, so two of them contradicting each other is caught even
when neither contradicts the root.
- admitsNull: `const: null` admits null, so a required field pinned to it is
no longer seeded with the one value it accepts and then reported missing.
- collectSchemaDefaults takes the values a caller is about to overlay, and
seeds the branch THEY identify — the App deep link passes its `appArgs`, so
branch 0's defaults can no longer sit invisibly under branch 1's arguments.
- The branch inference moves into core as `selectBranchIndex`, shared by the
seeding and the form's picker so the two cannot disagree.
Signed-off-by: cliffhall <cliff@futurescale.com>
The previous commit's coverage gate failed on `core/json/rootUnion.ts`
branches (89.81%). A property declared with JSON Schema's boolean form has no
keywords to merge, so the two sides agree only by being the same declaration —
now tested both ways, and the dead `baseProperty !== undefined` guard is gone
(`conflicts` is only reached for a name the base declares).
Signed-off-by: cliffhall <cliff@futurescale.com>
- rootUnion: a branch restating a constraint the root states differently is
declined (keywords at one level are conjunctive and no intersection is
computed here), while annotations may disagree freely. An `allOf` with a
member it cannot fold in — JSON Schema's boolean form, or a $ref whose
constraints are unknown rather than absent — is left intact instead of
merged-and-stripped, which would turn an unsatisfiable schema into a
fillable form.
- rootUnion: `declaresAnyFields` counts the raw composition members, so
`hasInputFields` reports fields for a union the resolver declines and an
App tool is never auto-invoked with empty arguments.
- SchemaForm: the picker opens on the branch the supplied values identify
(an App deep link overlays its args, so they can name another branch), and
a branch switch no longer carries a value the outgoing branch declared —
the branches may type the same name differently.
- TUI: a base property some branch redeclares is offered in EVERY branch's
section under that branch's inherited or specialized declaration, so a
branch that does not redeclare it can still supply a required root
argument.
Signed-off-by: cliffhall <cliff@futurescale.com>
- rootUnion: a name both the root and a branch declare is now MERGED rather
than replaced, so a root `minimum` survives a branch `maximum`; a branch
that contradicts the root on a property's `type` describes an unsatisfiable
value and is declined outright. Branch offerability also requires that
`properties` really is an object (a malformed `properties: null` threw)
and that the member's own `type` admits objects.
- rootUnion: `ownFields` becomes `declaredFields` — a branch commonly
specializes a root property, and consumers must render the specialization.
- TUI: the generated select and branch prefixes step aside from any name the
schema itself declares (JSON has no reserved namespace), decode filters them
by exact name, a branch's specialization renders in its own section, and a
`const` is rendered as a one-option select whose typed value is restored
from the schema on submit.
- convertToolParameters: identify the branch from the discriminator constants
the call supplies; when none identifies one, coerce only names every
declaring branch types the same way rather than picking an arbitrary branch.
Signed-off-by: cliffhall <cliff@futurescale.com>
- rootUnion: decline a schema carrying BOTH oneOf and anyOf rather than
reading one and silently dropping the other's constraints.
- SchemaForm: handle `const` before every type dispatch and for every
constant type, so a numeric/boolean constant, or a schema carrying both
`const` and `enum`, can no longer offer a forbidden value.
- collectSchemaDefaults / TUI initialValue: `const` outranks `default`,
which is an annotation a schema may set to something its const rejects.
- convertToolParameters: resolve from the first branch's merged schema, so a
branch's specialization of a root-declared property is what types the arg.
- TUI: ink-form keys values by field name across the whole form, so two
branches' `kind` were one field and the later section's initial value
decided what the earlier submitted. Branch fields now render under prefixed
names behind a Variant select, and decodeFormValues translates back on
submit, dropping every branch but the chosen one.
Signed-off-by: cliffhall <cliff@futurescale.com>
A tool's `inputSchema` may carry composition keywords at its root — the
2026-07-28 revision makes this explicit — but both form builders enumerate
the root's `properties` and nothing else. A schema keeping its fields on
`allOf`/`oneOf`/`anyOf` branches therefore rendered no controls at all: no
branch picker, no fields, not even the raw-JSON fallback a union-typed
*property* falls back to, so the tool could only be called with empty
arguments.
Adds `core/json/rootUnion.ts`, one flattening shared by all three clients so
they cannot drift on which schemas they can render:
- `allOf` is merged unconditionally (conjunctive — there is no choice to
present), a root `oneOf`/`anyOf` is returned as branches.
- Web `SchemaForm` renders a Variant picker above the fields and prunes the
outgoing branch's values on a switch, since they are no longer visible and
would describe a shape the call is not making.
- TUI `schemaToForm` gives each branch its own section with optional fields —
ink-form is static, and only one alternative applies to a call.
- `convertToolParameters` looks through the branches, so a CLI `--tool-arg`
is typed by the schema that declares it rather than sent as a string.
Required-field gating asks whether *any* branch is satisfied, which is
selection-independent and never blocks arguments the schema accepts. A
`const` is seeded like a `default` and rendered read-only: it admits exactly
one value, so anything else the user could type produces a rejected call.
Adds a `root-union-schemas-http.json` showcase server.
Closes#2123
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 1.
`refreshStoredAuthToken` consumes the same fallback and made a single SDK
discovery call, so preserving the path there — without the walk the other two
consumers got — would have regressed exactly the case the walk exists to keep:
a path-hosted MCP server whose authorization metadata is published at the
domain root.
It injects its own discovery function as a test seam, so the walk is now also
exposed as `discoverAuthorizationServerMetadataFromCandidates(candidates,
discover)`, which takes that function and reports *which* candidate answered —
the CLI needs the winning URL, since it is the base the token request is made
against, not just the metadata.
Also drops the `as unknown as typeof fetch` from the new test in favour of
`vi.fn<typeof fetch>()`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
Caught by CI's clients/web format:check, not by my local run — I had
formatted core/ and scripts/ but not the web client's src, which is where
the test lives.
Signed-off-by: cliffhall <cliff@futurescale.com>
When RFC 9728 protected-resource metadata names no authorization server, the
MCP server URL stands in as one. `getAuthorizationServerUrl` built that with
`new URL("/", serverUrl)`, which discards the path — exactly the part discovery
needs when the server is not hosted at the domain root. The SDK's
`buildDiscoveryUrls` derives the path-scoped well-known locations from that
pathname, so an origin-only value could only ever probe the domain root, which
404s for a server like `https://example.com/mod/minilesson/mcp.php`.
Keep the path (and strip query/fragment, which an RFC 8414 issuer never
carries). A straight swap would regress the case the origin-only value did
serve — a server that merely lives under a path while publishing its metadata
at the root — so the URL becomes the first of two candidates:
`getAuthorizationServerUrlCandidates` yields the path-scoped form then the bare
origin, and `discoverAuthorizationServerMetadataForServer` walks them, treating
a candidate's failure as non-fatal while another remains and rethrowing the
first error only when all of them fail.
`discoverScopes` and the CIMD pre-registration probe both go through the walk.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
parseSSE declared currentEvent/currentData inside the read loop, so an SSE
frame whose data: line arrived in one chunk and whose terminating blank
line arrived in the next was discarded. buffer already carried a partial
LINE across reads; nothing carried a partial FRAME.
Because this is the web client's transport, the dropped frame is a
JSON-RPC response that never settles: the request hangs forever with no
rejection, no console message, and nothing in the Network tab. The
payload does not arrive corrupted, it does not arrive at all, which is
why it presents as a hang rather than an error.
It has not bitten us because Chromium and Firefox happen to deliver whole
frames at the payload sizes this transport currently sees. That is luck,
not a guarantee: chunk boundaries depend on payload size, TCP
segmentation, and any intermediary. The exposure grows with payload size,
so a large resources/read is the most likely first victim.
The fix hoists both out of the loop and adds an end-of-stream flush — a
server that closes without a final blank line has still delivered a
complete frame, and dropping it would lose the last message of every such
stream.
parseSSE is now exported, solely so the test can reach it. That is the
only way to hit this deterministically: driving a real transport cannot
steer where a chunk boundary lands, but a test that supplies the reader
can put it exactly on the terminator.
The test keeps the two PASSING cases (whole frame, split mid-payload)
alongside the three that failed, because they are what make the diagnosis
precise. This is not "SSE parsing is broken" — mid-payload splits are
handled correctly today, and only the frame-terminator boundary loses
data. Verified against the unfixed code: 3 of 5 fail, all 5 pass after.
Signed-off-by: cliffhall <cliff@futurescale.com>
`record` read `serversRef.current` from inside the `setRecords` updater. A
functional updater must be pure -- React may defer or replay it -- so the
pairing resolved at whatever moment the updater happened to run rather than
when the record was written. A list read committing in between would then be
paired with the record as its baseline, and the very read that should have
superseded the override would instead certify it.
Read the list once, at call time, and use it for both the pairing and the
prune.
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>
The rollback's explanation still described the removed app-wide boolean and
claimed the override was applied only for the active server. That is now the
live client's rule alone: the override is keyed by this write's server and is
only ever displayed while that server is active, so it is recorded whatever
is active by the time the rejection arrives.
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>
The displayed `paginatedLists` value falls back to the active `servers`
entry, and that list only advances when a `GET /api/servers` succeeds — so
once a list read fails it keeps describing disk as it was before the writes
made since. The optimistic override papered over that, but it was a single
app-wide slot cleared on every change of the *active* entry, so switching to
another server and back dropped it and the UI fell back to the stale entry.
Two things then disagreed, and the one the user could see was the wrong one:
the toggle read `off` while disk, the write tracker, and the client just
built from it all said `on`; and because the client skipped its connect-time
aggregate walk, the all-pages UI rendered an aggregate that was never
fetched, with no Load-next-page control to fill it.
Hold the override per server instead, in `usePaginatedListsOverride`, paired
with that server's entry as of when it was recorded and believed only while
the list still carries that same entry object — identity, not equality, so a
read reporting the value the override replaced supersedes it too. A server's
override then survives a switch away and back, and is still superseded by
the first successful read that rebuilds its entry.
The read cannot instead move to `lastPersistedSettings.resolve(id)`: that is
called during render, and it both reads refs and prunes superseded records.
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>
#2114 landed on v2/main, which is the PR this one follows up. Two
conflicts, one of them silent.
The marked conflict was cosmetic: keep v2/main's comment on the
`provider.scope` read, which now notes the getter filters (#2068).
The real one git merged cleanly and would have left dead: #2114's
if (this.oauthConfig.requestRefreshToken === false && requestedScope) {
this.pendingAuthorizationScope = requestedScope;
}
sat directly above this branch's unconditional
`recordAuthorizationRequestScope`, which overwrites the same field a few
lines later. Dropping that gate is what #2117 is for, so it is removed
rather than kept as a no-op.
The two changes compose: the SDK adds `offline_access` only when
`clientMetadata.grant_types` includes `refresh_token`, which the provider
drops while the opt-out is on -- so reading the authorize URL yields the
filtered scope there, exactly what #2114's gated branch persisted.
#2114's third case pinned the gate and inverts here, as its issue
predicted. It now asserts the request is persisted even with the grant
on; that records no new claim, since the request equals what storage
already held.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>