fix(core): support ; and :length, and scope requiredness to the expression

Addresses Copilot's round-2 review on #2035. It reported "no new comments"
but carried three *suppressed* ones; all three reproduced against the
pinned SDK.

1. The `;` path-parameter operator is absent from the SDK's operator list,
   so `{;id}` parsed as a variable literally named ";id" and expanded to
   nothing. Added the operator and its named expansion (`;a=1;b=2`).

2. An RFC 6570 prefix modifier was folded into the variable name: `{id:3}`
   yielded a variable called "id:3" and expanded to nothing. Varspecs are
   now parsed properly and the value truncated before encoding. Truncation
   is by code point, since `String.prototype.slice` counts UTF-16 units and
   would split an astral character into a lone surrogate.

   For both of these the wrong URI is the lesser problem: a form has to
   *name* the variables it asks the user to fill, so the panel was
   rendering fields labelled `;id` and `id:3` that nobody could use.

3. Requiredness was applied per variable, but it is a property of the
   *expression*: RFC 6570 drops undefined names from a multi-name
   expression, so `{a,b}` with only `a` filled expands to `a`'s value --
   the SDK does this too. The panel was refusing input the expander would
   have accepted. `hasRequiredValues` now encodes "any one name in a
   required group suffices", and such a field reads "Any one of: a, b"
   rather than falsely claiming each is mandatory.

Two structural consequences, each pinned by a test:

- Takeover is now per TEMPLATE rather than per expression. Splicing
  corrected fragments into a template the SDK re-expands would leave its
  cross-expression `?`-to-`&` rewrite blind to the fragments already
  resolved.

- Expansion is split into a strict variant that throws and a lenient one
  that returns the raw template. `readResourceFromTemplate` wraps the
  thrown error with the template name -- three pre-existing integration
  tests assert that -- while the form must not throw on a server-supplied
  template, since that would take out the panel on render. The strict
  variant constructs the SDK template unconditionally, because that
  construction is what validates syntax: otherwise `x://{;a}{b,c` would
  take the own-expansion path and its unclosed tail would pass as literal
  text with nothing objecting.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-16 19:17:05 -04:00
commit 25c3ad272e
2 changed files with 467 additions and 139 deletions

View file

@ -23,12 +23,18 @@ describe("previewUriTemplate", () => {
).toBe("foobar://events/foo%2Fbar");
});
it("keeps a multi-name expression whole until every name is filled", () => {
it("expands a partially-filled multi-name expression, as submitting would", () => {
// RFC 6570 drops the undefined names rather than the whole expression, so
// showing `{?one,two}` here would promise a URI the submit does not send.
expect(previewUriTemplate("x://a{?one,two}", { one: "1" })).toBe(
"x://a{?one,two}",
"x://a?one=1",
);
});
it("keeps a multi-name expression whole while none of its names is filled", () => {
expect(previewUriTemplate("x://a{?one,two}", {})).toBe("x://a{?one,two}");
});
it("still rewrites the second ? to & when both query expressions resolve", () => {
expect(
previewUriTemplate("x://a{?one}{?two}", { one: "1", two: "2" }),

View file

@ -9,20 +9,21 @@
*/
import {
applyMultiNameCorrection,
definedValues,
expandUriTemplate,
parseUriTemplate,
} from "@inspector/core/mcp/uriTemplate.js";
import { UriTemplate } from "@modelcontextprotocol/client";
export {
expandUriTemplate,
hasRequiredValues,
parseUriTemplate,
templateVariables,
} from "@inspector/core/mcp/uriTemplate.js";
export type {
TemplatePart,
TemplateVariable,
VarSpec,
} from "@inspector/core/mcp/uriTemplate.js";
/**
@ -41,8 +42,10 @@ const deferredToken = (index: number) => `\u0000${index}\u0000`;
*
* Unfilled expressions are swapped for an inert token and restored after
* expansion -- rather than expanding each filled expression in isolation -- so
* the SDK still sees one whole template and applies its cross-expression rules
* (notably rewriting a second `?` query expression to `&`).
* the expander still sees one whole template and applies its cross-expression
* rules (notably rewriting a second `?` query expression to `&`). Routing the
* rewritten template back through `expandUriTemplate` is what keeps the preview
* honest: it can never promise a URI that submitting would not send.
*/
export function previewUriTemplate(
uriTemplate: string,
@ -55,23 +58,15 @@ export function previewUriTemplate(
const rewritten = parts
.map((part) => {
if (part.kind === "literal") return part.text;
if (part.names.every((name) => defined[name] !== undefined)) {
// Route the kept expression through the same multi-name correction the
// real expansion applies, so the preview can never promise a URI that
// submitting would not actually send.
return applyMultiNameCorrection([part], defined);
if (part.names.some((name) => defined[name] !== undefined)) {
return part.source;
}
deferred.push(part.source);
return deferredToken(deferred.length - 1);
})
.join("");
let expanded: string;
try {
expanded = new UriTemplate(rewritten).expand(defined);
} catch {
return uriTemplate;
}
const expanded = expandUriTemplate(rewritten, defined);
// Restored by exact-string replacement rather than by a pattern: a regex
// matching the token would have to embed U+0000 literally, which `eslint`