fix: honor a defined-but-empty value, and announce the expansion error

Addresses Copilot's round-8 review on #2035 (one inline comment, one
suppressed). Both real.

1. RFC 6570 distinguishes an UNDEFINED variable, which omits its whole
   expression, from one defined as the empty string, which expands. The
   expander was calling `definedValues` on the way in, collapsing the two --
   so `x{?q}` with `{ q: "" }` gave `x` instead of `x?q=`, and there was no
   way for any caller, `readResourceFromTemplate` included, to request
   either that or `x;q`. Key presence is now honored, including §3.2.7's
   rule that the matrix operator drops the `=` for an empty value (`x{;q}`
   -> `x;q`, while `?`/`&` keep it).

   Dropping blanks is a FORM concern, so it moved to the two form
   boundaries: the web panel seeds every declared variable with "" and the
   TUI's ink-form returns "" for every untouched field, so neither can tell
   "never touched" from "deliberately empty" -- but that is a fact about a
   text input, not about the template, and it has no business deciding what
   a template means for every other caller.

   Six unit tests and one integration test encoded the old collapse and now
   assert the corrected meaning. The prototype-collision cases get stronger
   rather than weaker: a blank `{?toString}` expands to `?toString=` -- its
   OWN empty value -- where the original bug expanded the inherited
   function's body.

2. The expansion error is now `role="alert"`. It can appear while typing (a
   pasted value that cannot be encoded), and plain text is not announced, so
   a screen-reader user met a disabled Read Resource with no stated reason.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-16 23:08:30 -04:00
commit 6acaff70ca

View file

@ -15,6 +15,7 @@ import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprot
import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge";
import { CopyButton } from "../../elements/CopyButton/CopyButton";
import {
definedValues,
hasRequiredValues,
previewUriTemplate,
requiredGroups,
@ -71,9 +72,15 @@ const DescriptionText = Text.withProps({
// Why Read Resource is disabled when the template itself cannot be expanded.
// Without it the button is inert with nothing on screen explaining the refusal.
//
// `role="alert"` because the message can appear *while typing* -- a pasted
// value that cannot be encoded -- so a screen-reader user would otherwise meet
// a silently disabled action. An alert is an assertive live region, which is
// what announces text that arrives after first render.
const ExpansionErrorText = Text.withProps({
size: "sm",
c: "red",
role: "alert",
});
// Left-aligned so the action sits closest to the sidebar controls / the form
@ -242,7 +249,12 @@ export function ResourceTemplatePanel({
// unencodable, and in neither case is there a URI to send -- so withhold the
// request and say why, rather than reading the raw template with its braces
// intact and letting the server answer with a confusing "not found".
const expansion = tryExpandUriTemplate(uriTemplate, variables);
// `definedValues` is applied here rather than inside the expander: a key
// present with `""` is a *defined* RFC 6570 value and legitimately expands to
// `?topic=`, but this form seeds every declared variable with `""`, so an
// untouched field is indistinguishable from a deliberately empty one. That is
// a fact about the form, so the form is what resolves it.
const expansion = tryExpandUriTemplate(uriTemplate, definedValues(variables));
const canSubmit =
expansion.error === undefined && hasRequiredValues(groups, variables);