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
2 changed files with 126 additions and 21 deletions

View file

@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import {
definedValues,
expandUriTemplate,
expandUriTemplateStrict,
hasRequiredValues,
@ -135,10 +136,18 @@ describe("expandUriTemplate", () => {
expect(expandUriTemplate("x://{+path}", { path: "a/b" })).toBe("x://a/b");
});
it("omits an expression whose variable was left blank", () => {
expect(expandUriTemplate("foobar://events{?topic}", { topic: "" })).toBe(
it("omits an expression whose variable is undefined", () => {
expect(expandUriTemplate("foobar://events{?topic}", {})).toBe(
"foobar://events",
);
// A form's untouched field arrives as "", which is a *defined* value; the
// form drops it on the way in. See "a value defined as the empty string".
expect(
expandUriTemplate(
"foobar://events{?topic}",
definedValues({ topic: "" }),
),
).toBe("foobar://events");
});
it("expands each query expression independently, per RFC 6570", () => {
@ -288,9 +297,9 @@ describe("requiredGroups / hasRequiredValues", () => {
const groups = requiredGroups("x://{a,b}");
expect(groups).toEqual([["a", "b"]]);
expect(hasRequiredValues(groups, { a: "only-a", b: "" })).toBe(true);
expect(expandUriTemplate("x://{a,b}", { a: "only-a", b: "" })).toBe(
"x://only-a",
);
expect(
expandUriTemplate("x://{a,b}", definedValues({ a: "only-a", b: "" })),
).toBe("x://only-a");
});
it("rejects a multi-name expression with nothing filled", () => {
@ -480,11 +489,15 @@ describe("variable names that collide with Object.prototype", () => {
// every one of them, so a *blank* field read as supplied: measured,
// `({})["toString"] !== undefined` is true and its typeof is "function".
it.each(["toString", "constructor", "valueOf", "hasOwnProperty"])(
"omits a blank {?%s} instead of expanding a prototype member",
"reads a blank {?%s} as its own empty value, not a prototype member",
(name) => {
// The own value is "", so the expression expands to a valueless pair --
// NOT the inherited function's body, which is what a bare lookup gave.
expect(expandUriTemplate(`x://a{?${name}}`, { [name]: "" })).toBe(
"x://a",
`x://a?${name}=`,
);
// And with the key absent, the expression is omitted entirely.
expect(expandUriTemplate(`x://a{?${name}}`, {})).toBe("x://a");
},
);
@ -628,6 +641,38 @@ describe("unmetRequiredGroups", () => {
});
});
describe("a value defined as the empty string", () => {
// RFC 6570 distinguishes an *undefined* variable (the expression is omitted)
// from one defined as "". The expander used to collapse the two, which made
// these URIs unrequestable through any caller -- `readResourceFromTemplate`
// included. Dropping blanks is a *form* concern (a text input cannot express
// "defined but empty"), so each client applies `definedValues` on its way in.
it.each([
["a query expression keeps the =", "x{?q}", "x?q="],
["a continuation keeps the =", "x?a=1{&q}", "x?a=1&q="],
// RFC 6570 3.2.7: the matrix operator drops the `=` for an empty value.
["the matrix operator drops the =", "x{;q}", "x;q"],
["a path segment expands to a bare separator", "x{/q}", "x/"],
["a label expands to a bare separator", "x{.q}", "x."],
["a simple expression contributes nothing visible", "x/{q}", "x/"],
])("%s", (_label, template, expected) => {
expect(expandUriTemplate(template, { q: "" })).toBe(expected);
});
it("omits the expression only when the key is absent", () => {
expect(expandUriTemplate("x{?q}", {})).toBe("x");
expect(expandUriTemplate("x{;q}", {})).toBe("x");
});
it("keeps a defined-but-empty name alongside a filled one", () => {
expect(expandUriTemplate("x{?a,b}", { a: "", b: "2" })).toBe("x?a=&b=2");
});
it("definedValues is what a form applies to drop its untouched fields", () => {
expect(expandUriTemplate("x{?q}", definedValues({ q: "" }))).toBe("x");
});
});
describe("tryExpandUriTemplate", () => {
it("returns the URI and no error when the template expands", () => {
expect(

View file

@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
import {
definedValues,
expandUriTemplate,
tryExpandUriTemplate,
} from "@inspector/core/mcp/uriTemplate.js";
@ -144,12 +145,18 @@ describe("RFC 6570 resource templates over the wire (#1919)", () => {
it("resolves the base URI a blank query expression expands to", async () => {
const connected = await connectToShowcase();
// RFC 6570 drops the whole expression when the variable is undefined, so
// RFC 6570 drops the whole expression when the variable is *undefined*, so
// an unfilled `topic` legitimately requests the unfiltered collection. The
// form is what turns its untouched blank into "undefined" (`definedValues`)
// — a key present with "" is a defined value and would expand to
// `?topic=`, which is a different (and here, unregistered) resource. The
// SDK's matcher compiles `{?topic}` to a *required* `\?topic=([^&]+)`, so a
// template alone cannot serve this — the config registers the plain
// resource, which is what a real server would do.
const uri = expandUriTemplate("foobar://events{?topic}", { topic: "" });
const uri = expandUriTemplate(
"foobar://events{?topic}",
definedValues({ topic: "" }),
);
expect(uri).toBe("foobar://events");
const { result } = await connected.readResource(uri);