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
4 changed files with 467 additions and 139 deletions
|
|
@ -16,6 +16,7 @@ import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge"
|
|||
import { CopyButton } from "../../elements/CopyButton/CopyButton";
|
||||
import {
|
||||
expandUriTemplate,
|
||||
hasRequiredValues,
|
||||
previewUriTemplate,
|
||||
templateVariables,
|
||||
} from "../../../utils/uriTemplate";
|
||||
|
|
@ -215,12 +216,12 @@ export function ResourceTemplatePanel({
|
|||
void runCompletion(varName, value, buildContext(varName));
|
||||
}
|
||||
|
||||
// Only the variables whose absence would change the URI's shape gate the
|
||||
// Only the expressions whose absence would change the URI's shape gate the
|
||||
// read; an unfilled `{?topic}` is a legitimate request for the unfiltered
|
||||
// resource, and RFC 6570 drops the whole expression for it.
|
||||
const canSubmit = declaredVariables.every(
|
||||
(v) => !v.required || variables[v.name]?.length > 0,
|
||||
);
|
||||
// resource, and RFC 6570 drops the whole expression for it. The rule is
|
||||
// per-expression rather than per-variable -- `{a,b}` with only `a` filled
|
||||
// expands to `a`'s value -- so it lives in core beside the expander.
|
||||
const canSubmit = hasRequiredValues(declaredVariables, variables);
|
||||
|
||||
function handleSubmit() {
|
||||
onReadResource(expandUriTemplate(uriTemplate, variables));
|
||||
|
|
@ -239,12 +240,19 @@ export function ResourceTemplatePanel({
|
|||
</HeaderRow>
|
||||
{description && <DescriptionText>{description}</DescriptionText>}
|
||||
<Stack gap="sm">
|
||||
{declaredVariables.map(({ name: varName, required }) => {
|
||||
{declaredVariables.map(({ name: varName, required, groupNames }) => {
|
||||
/* v8 ignore next -- `?? ""` fallback unreachable: `variables` is seeded with every declared variable, so the key is always present. */
|
||||
const fieldValue = variables[varName] ?? "";
|
||||
// RFC 6570 omits an undefined variable under a query/path-segment
|
||||
// operator entirely, so those fields are genuinely optional.
|
||||
const description = required ? undefined : "Optional";
|
||||
// operator entirely, so those fields are genuinely optional. In a
|
||||
// required multi-name expression no single field is mandatory either
|
||||
// -- any one of them satisfies it -- so say which, rather than
|
||||
// marking each one required and blocking valid input.
|
||||
const description = !required
|
||||
? "Optional"
|
||||
: groupNames.length > 1
|
||||
? `Any one of: ${groupNames.join(", ")}`
|
||||
: undefined;
|
||||
return useAutocomplete ? (
|
||||
<Autocomplete
|
||||
key={varName}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
expandUriTemplate,
|
||||
expandUriTemplateStrict,
|
||||
hasRequiredValues,
|
||||
parseUriTemplate,
|
||||
templateVariables,
|
||||
} from "@inspector/core/mcp/uriTemplate.js";
|
||||
|
|
@ -13,6 +15,7 @@ describe("parseUriTemplate", () => {
|
|||
kind: "expression",
|
||||
source: "{topic}",
|
||||
operator: "",
|
||||
varspecs: [{ name: "topic" }],
|
||||
names: ["topic"],
|
||||
},
|
||||
]);
|
||||
|
|
@ -25,6 +28,7 @@ describe("parseUriTemplate", () => {
|
|||
kind: "expression",
|
||||
source: "{?a,b*}",
|
||||
operator: "?",
|
||||
varspecs: [{ name: "a" }, { name: "b" }],
|
||||
names: ["a", "b"],
|
||||
},
|
||||
]);
|
||||
|
|
@ -44,13 +48,13 @@ describe("parseUriTemplate", () => {
|
|||
describe("templateVariables", () => {
|
||||
it("finds a simple variable and marks it required", () => {
|
||||
expect(templateVariables("foobar://events/{topic}")).toEqual([
|
||||
{ name: "topic", operator: "", required: true },
|
||||
{ name: "topic", operator: "", required: true, groupNames: ["topic"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds a query variable the old `\\{(\\w+)\\}` regex could not see", () => {
|
||||
expect(templateVariables("foobar://events{?topic}")).toEqual([
|
||||
{ name: "topic", operator: "?", required: false },
|
||||
{ name: "topic", operator: "?", required: false, groupNames: ["topic"] },
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -72,7 +76,7 @@ describe("templateVariables", () => {
|
|||
|
||||
it("deduplicates a repeated name and keeps it required if any use is", () => {
|
||||
expect(templateVariables("x://{?id}/{id}")).toEqual([
|
||||
{ name: "id", operator: "?", required: true },
|
||||
{ name: "id", operator: "?", required: true, groupNames: ["id"] },
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -192,3 +196,161 @@ describe("expandUriTemplate - multi-name expressions (SDK correction)", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("varspec modifiers", () => {
|
||||
// The pinned SDK folds a `:length` modifier into the variable name --
|
||||
// `new UriTemplate("x://a/{id:3}").variableNames` is `["id:3"]`, and it
|
||||
// expands to "x://a/" -- so a form built on it would render a field the
|
||||
// user cannot usefully fill.
|
||||
it("parses a prefix modifier off the variable name", () => {
|
||||
expect(templateVariables("x://a/{id:3}")).toEqual([
|
||||
{ name: "id", operator: "", required: true, groupNames: ["id"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("truncates the value to the prefix length before encoding", () => {
|
||||
expect(expandUriTemplate("x://a/{id:3}", { id: "abcdef" })).toBe(
|
||||
"x://a/abc",
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes what survives truncation", () => {
|
||||
expect(expandUriTemplate("x://a/{id:3}", { id: "a/bcdef" })).toBe(
|
||||
"x://a/a%2Fb",
|
||||
);
|
||||
});
|
||||
|
||||
it("truncates by code point, never splitting an astral character", () => {
|
||||
// "\u{1F600}" is one code point but two UTF-16 units, so a naive
|
||||
// `slice(0, 1)` would emit a lone surrogate.
|
||||
expect(expandUriTemplate("x://{v:1}", { v: "\u{1F600}x" })).toBe(
|
||||
`x://${encodeURIComponent("\u{1F600}")}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores a malformed modifier rather than inventing a truncation", () => {
|
||||
expect(templateVariables("x://{id:}")[0].name).toBe("id");
|
||||
expect(expandUriTemplate("x://{id:}", { id: "abcdef" })).toBe("x://abcdef");
|
||||
});
|
||||
|
||||
it("strips the explode modifier from the name", () => {
|
||||
expect(templateVariables("x://{id*}")[0].name).toBe("id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the ; (path-parameter) operator", () => {
|
||||
// Absent from the SDK's operator list entirely: it parses `{;id}` as a
|
||||
// variable named ";id" and expands to "".
|
||||
it("is recognised as an operator, not part of the name", () => {
|
||||
expect(templateVariables("x://a{;id}")).toEqual([
|
||||
{ name: "id", operator: ";", required: false, groupNames: ["id"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("expands to a named path parameter", () => {
|
||||
expect(expandUriTemplate("x://a{;id}", { id: "7" })).toBe("x://a;id=7");
|
||||
});
|
||||
|
||||
it("repeats its separator per pair", () => {
|
||||
expect(expandUriTemplate("x://a{;a,b}", { a: "1", b: "2" })).toBe(
|
||||
"x://a;a=1;b=2",
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes the value", () => {
|
||||
expect(expandUriTemplate("x://a{;p}", { p: "x/y" })).toBe("x://a;p=x%2Fy");
|
||||
});
|
||||
|
||||
it("omits cleanly when undefined", () => {
|
||||
expect(expandUriTemplate("x://a{;id}", {})).toBe("x://a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasRequiredValues", () => {
|
||||
// A required *expression* is satisfied by any one of its names, because
|
||||
// RFC 6570 drops the undefined ones -- verified against the SDK:
|
||||
// `x://{a,b}` with only `a` expands to "x://only-a".
|
||||
it("accepts a multi-name expression with only one name filled", () => {
|
||||
const vars = templateVariables("x://{a,b}");
|
||||
expect(hasRequiredValues(vars, { a: "only-a", b: "" })).toBe(true);
|
||||
expect(expandUriTemplate("x://{a,b}", { a: "only-a", b: "" })).toBe(
|
||||
"x://only-a",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a multi-name expression with nothing filled", () => {
|
||||
const vars = templateVariables("x://{a,b}");
|
||||
expect(hasRequiredValues(vars, { a: "", b: "" })).toBe(false);
|
||||
});
|
||||
|
||||
it("still requires a lone required variable", () => {
|
||||
const vars = templateVariables("file:///users/{userId}/profile");
|
||||
expect(hasRequiredValues(vars, { userId: "" })).toBe(false);
|
||||
expect(hasRequiredValues(vars, { userId: "alice" })).toBe(true);
|
||||
});
|
||||
|
||||
it("never blocks on an omittable expression", () => {
|
||||
const vars = templateVariables("foobar://events{?topic}");
|
||||
expect(hasRequiredValues(vars, { topic: "" })).toBe(true);
|
||||
});
|
||||
|
||||
it("is satisfied by a template with no variables at all", () => {
|
||||
expect(hasRequiredValues(templateVariables("file:///static.txt"), {})).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cross-expression query joining", () => {
|
||||
it("rewrites a second ? to & on the own-expansion path too", () => {
|
||||
// Forced onto the own-expansion path by the `;` expression; the `?`-to-`&`
|
||||
// rewrite must still apply, exactly as the SDK does it.
|
||||
expect(
|
||||
expandUriTemplate("x://a{;k}{?one}{?two}", {
|
||||
k: "v",
|
||||
one: "1",
|
||||
two: "2",
|
||||
}),
|
||||
).toBe("x://a;k=v?one=1&two=2");
|
||||
});
|
||||
|
||||
it("uses ? for the first query expression that actually emits", () => {
|
||||
expect(
|
||||
expandUriTemplate("x://a{;k}{?one}{?two}", { k: "v", two: "2" }),
|
||||
).toBe("x://a;k=v?two=2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("strict vs lenient expansion", () => {
|
||||
// `readResourceFromTemplate` wraps the thrown error with the template name;
|
||||
// the web panel instead needs the raw template back, because an invalid
|
||||
// template comes from the server and throwing would take out the panel.
|
||||
it.each(["file:///{unclosed", "{a,b,c"])(
|
||||
"strict throws on the invalid template %s",
|
||||
(template) => {
|
||||
expect(() => expandUriTemplateStrict(template, { x: "1" })).toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["file:///{unclosed", "{a,b,c"])(
|
||||
"lenient returns %s unchanged",
|
||||
(template) => {
|
||||
expect(expandUriTemplate(template, { x: "1" })).toBe(template);
|
||||
},
|
||||
);
|
||||
|
||||
it("validates syntax even when taking the own-expansion path", () => {
|
||||
// `{;a}` forces own-expansion, and this module's parser treats the
|
||||
// unclosed tail as literal text -- so without the unconditional SDK
|
||||
// construction nothing would reject this.
|
||||
expect(() => expandUriTemplateStrict("x://{;a}{b,c", { a: "1" })).toThrow();
|
||||
});
|
||||
|
||||
it("agrees with the lenient variant on a valid template", () => {
|
||||
const template = "foobar://events{?topic}";
|
||||
const values = { topic: "foo/bar" };
|
||||
expect(expandUriTemplateStrict(template, values)).toBe(
|
||||
expandUriTemplate(template, values),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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