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:
parent
409dce37be
commit
25c3ad272e
2 changed files with 467 additions and 139 deletions
|
|
@ -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" }),
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
Loading…
Reference in a new issue