fix(web): expand resource templates per RFC 6570
The Resources screen discovered and substituted template variables with a
bare `/\{(\w+)\}/g` regex, which is wrong two ways: it cannot see an
expression carrying an operator, so `foobar://events{?topic}` rendered no
input at all; and it splices the raw value in, so a `/`, `?`, `#`, `%`,
space or non-ASCII character in a simple `{topic}` landed unencoded and
changed the URI's structure -- `foo/bar` produced an extra path segment
that a conforming matcher rejects with `-32602 Resource not found`.
Delegate expansion to the SDK's `UriTemplate`, the same implementation
`InspectorClient.readResourceFromTemplate` (and so the TUI) already
expands through, so the two clients cannot disagree about what a template
means. The new `utils/uriTemplate` supplies only what that class does
not: which variables to render an input for, which of them a read cannot
proceed without, and a partially-expanded preview.
Required-ness follows the operator. Under `?`, `&`, `.` or `/` the whole
expression is omitted when the variable is undefined, so those fields are
marked Optional and reading with them blank is a legitimate request for
the unfiltered resource; under `""`, `+` or `#` the variable sits mid-URI,
so it stays required. Blank fields are dropped before expanding so an
untouched optional field reads as undefined rather than as the empty
string, which would expand to a valueless `?topic=`.
Adds the `rfc6570_templates` preset and a `rfc6570-templates-http.json`
showcase server serving the two templates from the issue.
Closes #1919
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
8dd0fdf28b
commit
83658db2dd
2 changed files with 578 additions and 34 deletions
168
clients/web/src/utils/uriTemplate.test.ts
Normal file
168
clients/web/src/utils/uriTemplate.test.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
expandUriTemplate,
|
||||
parseUriTemplate,
|
||||
previewUriTemplate,
|
||||
templateVariables,
|
||||
} from "./uriTemplate";
|
||||
|
||||
describe("parseUriTemplate", () => {
|
||||
it("splits literals from expressions", () => {
|
||||
expect(parseUriTemplate("foobar://events/{topic}")).toEqual([
|
||||
{ kind: "literal", text: "foobar://events/" },
|
||||
{
|
||||
kind: "expression",
|
||||
source: "{topic}",
|
||||
operator: "",
|
||||
names: ["topic"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads the operator and the comma-separated name list", () => {
|
||||
expect(parseUriTemplate("x://{?a,b*}")).toEqual([
|
||||
{ kind: "literal", text: "x://" },
|
||||
{
|
||||
kind: "expression",
|
||||
source: "{?a,b*}",
|
||||
operator: "?",
|
||||
names: ["a", "b"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats an unclosed expression as trailing literal text", () => {
|
||||
expect(parseUriTemplate("x://a/{oops")).toEqual([
|
||||
{ kind: "literal", text: "x://a/{oops" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns nothing for an empty template", () => {
|
||||
expect(parseUriTemplate("")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("templateVariables", () => {
|
||||
it("finds a simple variable and marks it required", () => {
|
||||
expect(templateVariables("foobar://events/{topic}")).toEqual([
|
||||
{ name: "topic", operator: "", required: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds a query variable the old `\\{(\\w+)\\}` regex could not see", () => {
|
||||
expect(templateVariables("foobar://events{?topic}")).toEqual([
|
||||
{ name: "topic", operator: "?", required: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["{+path}", "+", true],
|
||||
["{#frag}", "#", true],
|
||||
["{.label}", ".", false],
|
||||
["{/segment}", "/", false],
|
||||
["{&extra}", "&", false],
|
||||
])("classifies %s", (expression, operator, required) => {
|
||||
const [variable] = templateVariables(`x://a${expression}`);
|
||||
expect(variable.operator).toBe(operator);
|
||||
expect(variable.required).toBe(required);
|
||||
});
|
||||
|
||||
it("deduplicates a repeated name and keeps it required if any use is", () => {
|
||||
expect(templateVariables("x://{?id}/{id}")).toEqual([
|
||||
{ name: "id", operator: "?", required: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty list for a template with no expressions", () => {
|
||||
expect(templateVariables("file:///static.txt")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandUriTemplate", () => {
|
||||
it("percent-encodes a reserved character in a simple variable (#1919)", () => {
|
||||
expect(
|
||||
expandUriTemplate("foobar://events/{topic}", { topic: "foo/bar" }),
|
||||
).toBe("foobar://events/foo%2Fbar");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["?", "a?b", "a%3Fb"],
|
||||
["#", "a#b", "a%23b"],
|
||||
["%", "a%b", "a%25b"],
|
||||
["space", "a b", "a%20b"],
|
||||
["unicode", "caffè", "caff%C3%A8"],
|
||||
])("encodes %s", (_label, value, encoded) => {
|
||||
expect(expandUriTemplate("x://{v}", { v: value })).toBe(`x://${encoded}`);
|
||||
});
|
||||
|
||||
it("builds an encoded query expression", () => {
|
||||
expect(
|
||||
expandUriTemplate("foobar://events{?topic}", { topic: "foo/bar" }),
|
||||
).toBe("foobar://events?topic=foo%2Fbar");
|
||||
});
|
||||
|
||||
it("leaves reserved characters intact under the + operator", () => {
|
||||
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(
|
||||
"foobar://events",
|
||||
);
|
||||
});
|
||||
|
||||
it("joins two query expressions with & rather than a second ?", () => {
|
||||
expect(expandUriTemplate("x://a{?one}{?two}", { one: "1", two: "2" })).toBe(
|
||||
"x://a?one=1&two=2",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the raw template when the SDK cannot parse it", () => {
|
||||
expect(expandUriTemplate("x://a/{oops", { oops: "v" })).toBe("x://a/{oops");
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewUriTemplate", () => {
|
||||
it("shows the template verbatim before anything is entered", () => {
|
||||
expect(previewUriTemplate("file:///users/{userId}/profile", {})).toBe(
|
||||
"file:///users/{userId}/profile",
|
||||
);
|
||||
});
|
||||
|
||||
it("substitutes only the expressions that are filled", () => {
|
||||
expect(
|
||||
previewUriTemplate("db://{tableName}/rows/{rowId}", {
|
||||
tableName: "users",
|
||||
rowId: "",
|
||||
}),
|
||||
).toBe("db://users/rows/{rowId}");
|
||||
});
|
||||
|
||||
it("encodes the filled values the same way expansion does", () => {
|
||||
expect(
|
||||
previewUriTemplate("foobar://events/{topic}", { topic: "foo/bar" }),
|
||||
).toBe("foobar://events/foo%2Fbar");
|
||||
});
|
||||
|
||||
it("keeps a multi-name expression whole until every name is filled", () => {
|
||||
expect(previewUriTemplate("x://a{?one,two}", { one: "1" })).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" }),
|
||||
).toBe("x://a?one=1&two=2");
|
||||
});
|
||||
|
||||
it("restores a deferred expression that follows a resolved query expression", () => {
|
||||
expect(previewUriTemplate("x://a{?one}{?two}", { one: "1" })).toBe(
|
||||
"x://a?one=1{?two}",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the raw template when the SDK cannot parse it", () => {
|
||||
expect(previewUriTemplate("x://a/{oops", {})).toBe("x://a/{oops");
|
||||
});
|
||||
});
|
||||
219
clients/web/src/utils/uriTemplate.ts
Normal file
219
clients/web/src/utils/uriTemplate.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
/**
|
||||
* RFC 6570 URI Template support for the Resources screen.
|
||||
*
|
||||
* The web client used to discover and substitute variables with a bare
|
||||
* `/\{(\w+)\}/g` regex, which is wrong in two ways (#1919): it cannot see an
|
||||
* expression carrying an operator (`{?topic}`, `{/path}`, `{#frag}`, ...), so
|
||||
* no input is rendered for it; and it splices the raw value in, so a `/`, `?`,
|
||||
* `#` or space in a simple `{var}` lands unencoded and silently changes the
|
||||
* URI's structure.
|
||||
*
|
||||
* Expansion itself is delegated to the SDK's `UriTemplate` -- the same
|
||||
* implementation `InspectorClient.readResourceFromTemplate` (and therefore the
|
||||
* TUI) already expands through, so the two clients cannot disagree about what a
|
||||
* template means. What lives here is the surrounding form/preview logic the SDK
|
||||
* class does not provide: which variables to render an input for, which of them
|
||||
* a read cannot proceed without, and a partially-expanded preview string.
|
||||
*/
|
||||
|
||||
import { UriTemplate } from "@modelcontextprotocol/client";
|
||||
|
||||
/** The RFC 6570 operators, in the order the SDK's parser tests for them. */
|
||||
const OPERATORS = ["+", "#", ".", "/", "?", "&"] as const;
|
||||
|
||||
/**
|
||||
* Operators whose expansion omits cleanly when the variables in it are
|
||||
* undefined -- the whole expression, separator included, simply disappears.
|
||||
* A variable under any *other* operator is interpolated into the middle of the
|
||||
* URI, so leaving it out produces a different resource path rather than a
|
||||
* shorter one; those we require before allowing a read.
|
||||
*/
|
||||
const OMITTABLE_OPERATORS = new Set([".", "/", "?", "&"]);
|
||||
|
||||
interface TemplateLiteral {
|
||||
kind: "literal";
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface TemplateExpression {
|
||||
kind: "expression";
|
||||
/** The expression including its braces, e.g. `{?topic}`. */
|
||||
source: string;
|
||||
/** The RFC 6570 operator, or `""` for a simple expression. */
|
||||
operator: string;
|
||||
/** Variable names in the expression, `*` (explode) and whitespace stripped. */
|
||||
names: string[];
|
||||
}
|
||||
|
||||
export type TemplatePart = TemplateLiteral | TemplateExpression;
|
||||
|
||||
export interface TemplateVariable {
|
||||
name: string;
|
||||
/** The operator of the expression the variable was first seen in. */
|
||||
operator: string;
|
||||
/**
|
||||
* True when omitting the variable would change the URI's structure rather
|
||||
* than shorten it -- see {@link OMITTABLE_OPERATORS}.
|
||||
*/
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a template into literal runs and expressions.
|
||||
*
|
||||
* Deliberately mirrors the SDK parser's own scanning rules (first matching
|
||||
* operator character wins, names split on `,`, `*` stripped) so this never
|
||||
* disagrees with the class that ultimately does the expanding. An unclosed
|
||||
* `{` yields a trailing literal, which is what makes the callers below degrade
|
||||
* to "render no inputs, show the template verbatim" rather than throw.
|
||||
*/
|
||||
export function parseUriTemplate(uriTemplate: string): TemplatePart[] {
|
||||
const parts: TemplatePart[] = [];
|
||||
let literal = "";
|
||||
let i = 0;
|
||||
|
||||
while (i < uriTemplate.length) {
|
||||
if (uriTemplate[i] !== "{") {
|
||||
literal += uriTemplate[i];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const end = uriTemplate.indexOf("}", i);
|
||||
if (end === -1) {
|
||||
// Unclosed expression -- the rest is not a template, treat it as text.
|
||||
literal += uriTemplate.slice(i);
|
||||
break;
|
||||
}
|
||||
if (literal) {
|
||||
parts.push({ kind: "literal", text: literal });
|
||||
literal = "";
|
||||
}
|
||||
const body = uriTemplate.slice(i + 1, end);
|
||||
const operator = OPERATORS.find((op) => body.startsWith(op)) ?? "";
|
||||
const names = body
|
||||
.slice(operator.length)
|
||||
.split(",")
|
||||
.map((name) => name.replace("*", "").trim())
|
||||
.filter((name) => name.length > 0);
|
||||
parts.push({
|
||||
kind: "expression",
|
||||
source: uriTemplate.slice(i, end + 1),
|
||||
operator,
|
||||
names,
|
||||
});
|
||||
i = end + 1;
|
||||
}
|
||||
|
||||
if (literal) parts.push({ kind: "literal", text: literal });
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* The variables a form should render an input for, in template order and
|
||||
* deduplicated by name. A name appearing under more than one operator is
|
||||
* required if *any* of its occurrences is.
|
||||
*/
|
||||
export function templateVariables(uriTemplate: string): TemplateVariable[] {
|
||||
const byName = new Map<string, TemplateVariable>();
|
||||
|
||||
for (const part of parseUriTemplate(uriTemplate)) {
|
||||
if (part.kind !== "expression") continue;
|
||||
const required = !OMITTABLE_OPERATORS.has(part.operator);
|
||||
for (const name of part.names) {
|
||||
const existing = byName.get(name);
|
||||
if (existing) {
|
||||
existing.required = existing.required || required;
|
||||
} else {
|
||||
byName.set(name, { name, operator: part.operator, required });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...byName.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops empty entries so an untouched optional field reads as *undefined* to
|
||||
* the SDK (the expression disappears) rather than as the empty string (which
|
||||
* would expand to a valueless `?topic=`).
|
||||
*/
|
||||
function definedValues(values: Record<string, string>): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(values).filter(([, value]) => value.length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands a template against the entered values per RFC 6570 -- percent-
|
||||
* encoding each value according to its operator, and omitting expressions whose
|
||||
* variables were left blank.
|
||||
*
|
||||
* A template the SDK refuses to parse falls back to the raw template string,
|
||||
* which is what the user already sees in the preview and what the server will
|
||||
* reject with a legible error; throwing here would take out the whole panel.
|
||||
*/
|
||||
export function expandUriTemplate(
|
||||
uriTemplate: string,
|
||||
values: Record<string, string>,
|
||||
): string {
|
||||
try {
|
||||
return new UriTemplate(uriTemplate).expand(definedValues(values));
|
||||
} catch {
|
||||
return uriTemplate;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A placeholder standing in for an expression the user has not filled in yet.
|
||||
*
|
||||
* `U+0000` cannot appear in a URI template, so a token built from it can never
|
||||
* collide with real template text; and because it is emitted as *literal* text
|
||||
* rather than as a variable value, expansion passes it through unencoded.
|
||||
*/
|
||||
const deferredToken = (index: number) => `\u0000${index}\u0000`;
|
||||
|
||||
/**
|
||||
* A partially-expanded template for display: expressions whose variables are
|
||||
* all filled are expanded exactly as {@link expandUriTemplate} would, and the
|
||||
* rest are left standing as written so the user can see what is still needed.
|
||||
*
|
||||
* 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 `&`).
|
||||
*/
|
||||
export function previewUriTemplate(
|
||||
uriTemplate: string,
|
||||
values: Record<string, string>,
|
||||
): string {
|
||||
const parts = parseUriTemplate(uriTemplate);
|
||||
const deferred: string[] = [];
|
||||
const defined = definedValues(values);
|
||||
|
||||
const rewritten = parts
|
||||
.map((part) => {
|
||||
if (part.kind === "literal") return part.text;
|
||||
if (part.names.every((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;
|
||||
}
|
||||
|
||||
// Restored by exact-string replacement rather than by a pattern: a regex
|
||||
// matching the token would have to embed U+0000 literally, which `eslint`
|
||||
// rejects (`no-control-regex`) -- and each token's text is already known
|
||||
// here, so there is nothing to match on.
|
||||
return deferred.reduce(
|
||||
(uri, source, index) => uri.replaceAll(deferredToken(index), source),
|
||||
expanded,
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue