Merge pull request #2035 from modelcontextprotocol/v2/fix/1919-rfc6570-uri-template-expansion
fix(web): expand resource templates per RFC 6570
This commit is contained in:
commit
f8d8c77783
2 changed files with 3060 additions and 59 deletions
|
|
@ -27,6 +27,11 @@ const noVarTemplate: ResourceTemplate = {
|
|||
uriTemplate: "file:///static.txt",
|
||||
};
|
||||
|
||||
const queryTemplate: ResourceTemplate = {
|
||||
name: "Events",
|
||||
uriTemplate: "foobar://events{?topic}",
|
||||
};
|
||||
|
||||
describe("ResourceTemplatePanel", () => {
|
||||
it("renders the template title (or name) and description", () => {
|
||||
renderWithMantine(
|
||||
|
|
@ -154,6 +159,200 @@ describe("ResourceTemplatePanel", () => {
|
|||
).not.toBeDisabled();
|
||||
});
|
||||
|
||||
describe("RFC 6570 expansion (#1919)", () => {
|
||||
it("renders an input for a query expression the old regex could not see", () => {
|
||||
renderWithMantine(
|
||||
<ResourceTemplatePanel
|
||||
template={queryTemplate}
|
||||
onReadResource={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByLabelText("topic")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("percent-encodes a reserved character in a simple variable", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onReadResource = vi.fn();
|
||||
renderWithMantine(
|
||||
<ResourceTemplatePanel
|
||||
template={singleVarTemplate}
|
||||
onReadResource={onReadResource}
|
||||
/>,
|
||||
);
|
||||
await user.type(screen.getByLabelText("userId"), "foo/bar");
|
||||
await user.click(screen.getByRole("button", { name: "Read Resource" }));
|
||||
expect(onReadResource).toHaveBeenCalledWith(
|
||||
"file:///users/foo%2Fbar/profile",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds an encoded query expression for {?topic}", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onReadResource = vi.fn();
|
||||
renderWithMantine(
|
||||
<ResourceTemplatePanel
|
||||
template={queryTemplate}
|
||||
onReadResource={onReadResource}
|
||||
/>,
|
||||
);
|
||||
await user.type(screen.getByLabelText("topic"), "foo/bar");
|
||||
await user.click(screen.getByRole("button", { name: "Read Resource" }));
|
||||
expect(onReadResource).toHaveBeenCalledWith(
|
||||
"foobar://events?topic=foo%2Fbar",
|
||||
);
|
||||
});
|
||||
|
||||
it("marks a query variable Optional and does not gate the read on it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onReadResource = vi.fn();
|
||||
renderWithMantine(
|
||||
<ResourceTemplatePanel
|
||||
template={queryTemplate}
|
||||
onReadResource={onReadResource}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Optional")).toBeInTheDocument();
|
||||
const button = screen.getByRole("button", { name: "Read Resource" });
|
||||
expect(button).not.toBeDisabled();
|
||||
// Left blank, the whole expression drops out per RFC 6570.
|
||||
await user.click(button);
|
||||
expect(onReadResource).toHaveBeenCalledWith("foobar://events");
|
||||
});
|
||||
|
||||
it("does not mark a required simple variable Optional", () => {
|
||||
renderWithMantine(
|
||||
<ResourceTemplatePanel
|
||||
template={singleVarTemplate}
|
||||
onReadResource={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("Optional")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("previews the query expression verbatim until it is filled", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<ResourceTemplatePanel
|
||||
template={queryTemplate}
|
||||
onReadResource={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("foobar://events{?topic}")).toBeInTheDocument();
|
||||
await user.type(screen.getByLabelText("topic"), "news");
|
||||
expect(
|
||||
screen.getByText("foobar://events?topic=news"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// A template the server advertised can be malformed. There is no URI to
|
||||
// send for one, so the read is withheld and the reason shown -- reading the
|
||||
// raw template with its braces intact would draw a confusing "not found"
|
||||
// from the server for a defect that is not the user's.
|
||||
it.each([
|
||||
["an out-of-grammar prefix modifier", "x://items/{id:abc}"],
|
||||
["an expression declaring no variable", "x://items/{}"],
|
||||
])("refuses to read a template with %s", (_label, uriTemplate) => {
|
||||
const onReadResource = vi.fn();
|
||||
renderWithMantine(
|
||||
<ResourceTemplatePanel
|
||||
template={{ name: "Broken", uriTemplate }}
|
||||
onReadResource={onReadResource}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Read Resource" }),
|
||||
).toBeDisabled();
|
||||
expect(screen.getByText(/Invalid RFC 6570 varspec/)).toBeInTheDocument();
|
||||
expect(onReadResource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names every unmet group, so a disabled button always has a reason", async () => {
|
||||
// With `{a,b}{b,c}{a,c}`, filling `b` satisfies the first two groups. A
|
||||
// per-field hint then looks satisfied while Read Resource stays disabled
|
||||
// on the third -- so the outstanding requirement is stated form-level.
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<ResourceTemplatePanel
|
||||
template={{ name: "Shared", uriTemplate: "x://{a,b}{b,c}{a,c}" }}
|
||||
onReadResource={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/Still needed:/)).toHaveTextContent(
|
||||
"Still needed: a or b; b or c; a or c",
|
||||
);
|
||||
await user.type(screen.getByLabelText("b"), "1");
|
||||
expect(screen.getByText(/Still needed:/)).toHaveTextContent(
|
||||
"Still needed: a or c",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Read Resource" }),
|
||||
).toBeDisabled();
|
||||
await user.type(screen.getByLabelText("a"), "2");
|
||||
expect(screen.queryByText(/Still needed:/)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Read Resource" }),
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
it("hints every shared group a variable sits in, not just the first", () => {
|
||||
renderWithMantine(
|
||||
<ResourceTemplatePanel
|
||||
template={{ name: "Shared", uriTemplate: "x://{a,b}{a,c}" }}
|
||||
onReadResource={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Any one of each: a, b; a, c"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("treats a name repeated in one expression as a plain requirement", () => {
|
||||
renderWithMantine(
|
||||
<ResourceTemplatePanel
|
||||
template={{ name: "Twice", uriTemplate: "x://{a,a}" }}
|
||||
onReadResource={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
// Not "Any one of: a, a" -- it is one field, required.
|
||||
expect(screen.queryByText(/Any one of/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Still needed: a")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an autocomplete field for a name that collides with Object.prototype", async () => {
|
||||
// `toString` is a valid RFC 6570 varname and `completions` starts empty,
|
||||
// so a bare `completions[varName] ?? []` handed Mantine the prototype's
|
||||
// *function* as its options array (`??` catches only null/undefined) and
|
||||
// the field crashed on first render.
|
||||
const user = userEvent.setup();
|
||||
const onCompleteArgument = vi.fn().mockResolvedValue(["a", "b"]);
|
||||
const onReadResource = vi.fn();
|
||||
renderWithMantine(
|
||||
<ResourceTemplatePanel
|
||||
template={{ name: "Proto", uriTemplate: "x://{toString}" }}
|
||||
onReadResource={onReadResource}
|
||||
onCompleteArgument={onCompleteArgument}
|
||||
completionsSupported
|
||||
/>,
|
||||
);
|
||||
// By role, not label: "toString" also appears in the "Still needed" line.
|
||||
const input = screen.getByRole("textbox", { name: "toString" });
|
||||
expect(input).toBeInTheDocument();
|
||||
await user.type(input, "v");
|
||||
await user.click(screen.getByRole("button", { name: "Read Resource" }));
|
||||
expect(onReadResource).toHaveBeenCalledWith("x://v");
|
||||
});
|
||||
|
||||
it("shows the malformed template unexpanded in the preview", () => {
|
||||
renderWithMantine(
|
||||
<ResourceTemplatePanel
|
||||
template={{ name: "Broken", uriTemplate: "x://items/{}" }}
|
||||
onReadResource={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("x://items/{}")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("completions", () => {
|
||||
it("fires a completion immediately on focus before any keystroke", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
|
|
|||
|
|
@ -14,6 +14,14 @@ import { useValueChange } from "../../../hooks/useValueChange";
|
|||
import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprotocol/client";
|
||||
import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge";
|
||||
import { CopyButton } from "../../elements/CopyButton/CopyButton";
|
||||
import {
|
||||
definedValues,
|
||||
previewUriTemplate,
|
||||
requiredGroups,
|
||||
templateVariables,
|
||||
tryExpandUriTemplate,
|
||||
unmetRequiredGroups,
|
||||
} from "../../../utils/uriTemplate";
|
||||
|
||||
export interface ResourceTemplatePanelProps {
|
||||
template: ResourceTemplate;
|
||||
|
|
@ -39,32 +47,22 @@ export interface ResourceTemplatePanelProps {
|
|||
|
||||
const COMPLETION_DEBOUNCE_MS = 300;
|
||||
|
||||
function parseVariableNames(uriTemplate: string): string[] {
|
||||
const names: string[] = [];
|
||||
const regex = /\{(\w+)\}/g;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(uriTemplate)) !== null) {
|
||||
names.push(match[1]);
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
function resolveUri(
|
||||
uriTemplate: string,
|
||||
variables: Record<string, string>,
|
||||
): string {
|
||||
return uriTemplate.replace(/\{(\w+)\}/g, (_, key: string) => variables[key]);
|
||||
}
|
||||
|
||||
function previewUri(
|
||||
uriTemplate: string,
|
||||
variables: Record<string, string>,
|
||||
): string {
|
||||
return uriTemplate.replace(/\{(\w+)\}/g, (match, key: string) =>
|
||||
variables[key]?.length > 0 ? variables[key] : match,
|
||||
);
|
||||
/**
|
||||
* The completions fetched for one variable, ignoring anything inherited from
|
||||
* `Object.prototype`.
|
||||
*
|
||||
* `toString`, `constructor` and `__proto__` are valid RFC 6570 variable names,
|
||||
* and this map starts empty — so a bare `completions[varName] ?? []` returned
|
||||
* the prototype's *function* for such a name (`??` only catches null and
|
||||
* undefined) and handed it to Mantine as its `data` array, crashing the field
|
||||
* on first render. Same hazard the expansion path fixed for values; this is
|
||||
* the one place the component reads a name-keyed map it did not seed.
|
||||
*/
|
||||
function completionsFor(
|
||||
completions: Record<string, string[]>,
|
||||
varName: string,
|
||||
): string[] {
|
||||
return Object.hasOwn(completions, varName) ? completions[varName] : [];
|
||||
}
|
||||
|
||||
const HeaderRow = Group.withProps({
|
||||
|
|
@ -90,6 +88,26 @@ const DescriptionText = Text.withProps({
|
|||
c: "dimmed",
|
||||
});
|
||||
|
||||
// Why Read Resource is disabled when the template itself cannot be expanded.
|
||||
// Without it the button is inert with nothing on screen explaining the refusal.
|
||||
//
|
||||
// `role="alert"` because the message can appear *while typing* -- a pasted
|
||||
// value that cannot be encoded -- so a screen-reader user would otherwise meet
|
||||
// a silently disabled action. An alert is an assertive live region, which is
|
||||
// what announces text that arrives after first render.
|
||||
// What is still missing before Read Resource can fire. Dimmed rather than red:
|
||||
// an incomplete form is the expected starting state, not an error.
|
||||
const RequirementText = Text.withProps({
|
||||
size: "sm",
|
||||
c: "dimmed",
|
||||
});
|
||||
|
||||
const ExpansionErrorText = Text.withProps({
|
||||
size: "sm",
|
||||
c: "red",
|
||||
role: "alert",
|
||||
});
|
||||
|
||||
// Left-aligned so the action sits closest to the sidebar controls / the form
|
||||
// fields above; annotation badges trail it.
|
||||
const FooterRow = Group.withProps({
|
||||
|
|
@ -108,10 +126,20 @@ export function ResourceTemplatePanel({
|
|||
}: ResourceTemplatePanelProps) {
|
||||
const { name, title, uriTemplate, description, annotations } = template;
|
||||
|
||||
const variableNames = useMemo(
|
||||
() => parseVariableNames(uriTemplate),
|
||||
// Every variable the template declares, with the operator it appears under
|
||||
// and whether omitting it would change the URI's shape (see `utils/uriTemplate`).
|
||||
const declaredVariables = useMemo(
|
||||
() => templateVariables(uriTemplate),
|
||||
[uriTemplate],
|
||||
);
|
||||
const variableNames = useMemo(
|
||||
() => declaredVariables.map((v) => v.name),
|
||||
[declaredVariables],
|
||||
);
|
||||
// The names of each expression that cannot be omitted. Kept separate from
|
||||
// `declaredVariables` (which is deduplicated for rendering) because each
|
||||
// required expression has to be satisfied on its own — see `requiredGroups`.
|
||||
const groups = useMemo(() => requiredGroups(uriTemplate), [uriTemplate]);
|
||||
|
||||
const [variables, setVariables] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(variableNames.map((n) => [n, ""])),
|
||||
|
|
@ -196,7 +224,8 @@ export function ResourceTemplatePanel({
|
|||
// show ghost suggestions from the old keystroke while the new
|
||||
// request is in flight (300ms debounce + network latency).
|
||||
setCompletions((prev) => {
|
||||
if (prev[varName] === undefined) return prev;
|
||||
// Own-property, as above: an inherited member is not a stale dropdown.
|
||||
if (!Object.hasOwn(prev, varName)) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[varName];
|
||||
return next;
|
||||
|
|
@ -232,13 +261,40 @@ export function ResourceTemplatePanel({
|
|||
void runCompletion(varName, value, buildContext(varName));
|
||||
}
|
||||
|
||||
const canSubmit = variableNames.every((n) => variables[n]?.length > 0);
|
||||
// Two independent gates on the read.
|
||||
//
|
||||
// First, whether the values cover what expansion structurally needs. Only the
|
||||
// expressions whose absence would change the URI's shape count; an unfilled
|
||||
// `{?topic}` is a legitimate request for the unfiltered 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.
|
||||
//
|
||||
// Second, whether the template expands at all. A template the server
|
||||
// advertised can be malformed (`{id:abc}`, `{a,}`) and a pasted value can be
|
||||
// unencodable, and in neither case is there a URI to send -- so withhold the
|
||||
// request and say why, rather than reading the raw template with its braces
|
||||
// intact and letting the server answer with a confusing "not found".
|
||||
// `definedValues` is applied here rather than inside the expander: a key
|
||||
// present with `""` is a *defined* RFC 6570 value and legitimately expands to
|
||||
// `?topic=`, but this form seeds every declared variable with `""`, so an
|
||||
// untouched field is indistinguishable from a deliberately empty one. That is
|
||||
// a fact about the form, so the form is what resolves it.
|
||||
const expansion = tryExpandUriTemplate(uriTemplate, definedValues(variables));
|
||||
// Named rather than merely counted, so a disabled Read Resource always has a
|
||||
// reason on screen. Per-field hints cannot carry this: a variable shared
|
||||
// across several groups looks satisfied once any one of them is met.
|
||||
const unmet = unmetRequiredGroups(groups, variables);
|
||||
const canSubmit = expansion.error === undefined && unmet.length === 0;
|
||||
|
||||
function handleSubmit() {
|
||||
onReadResource(resolveUri(uriTemplate, variables));
|
||||
/* v8 ignore next -- unreachable: the button is disabled whenever the
|
||||
expansion failed, which is the only way `uri` is undefined. */
|
||||
if (expansion.uri === undefined) return;
|
||||
onReadResource(expansion.uri);
|
||||
}
|
||||
|
||||
const preview = previewUri(uriTemplate, variables);
|
||||
const preview = previewUriTemplate(uriTemplate, variables);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
|
|
@ -251,16 +307,47 @@ export function ResourceTemplatePanel({
|
|||
</HeaderRow>
|
||||
{description && <DescriptionText>{description}</DescriptionText>}
|
||||
<Stack gap="sm">
|
||||
{variableNames.map((varName) => {
|
||||
{declaredVariables.map(({ name: varName, required }) => {
|
||||
/* 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. 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.
|
||||
// A name can sit in a singleton required group *and* a shared one
|
||||
// (`x://{a}/{a,b}`). The singleton demands this exact field, so the
|
||||
// "any one of" hint would contradict the disabled submit button --
|
||||
// suppress it and let the field read as plainly required.
|
||||
const individuallyRequired = groups.some(
|
||||
(names) => names.length === 1 && names[0] === varName,
|
||||
);
|
||||
// EVERY shared group this variable sits in, not the first. With
|
||||
// `{a,b}{b,c}{a,c}`, showing only the first left `b` looking like it
|
||||
// satisfied everything while Read Resource stayed disabled on the
|
||||
// unmet `{a,c}` -- a hidden requirement is worse than a wordy hint.
|
||||
// The form-level "Still needed" line below names what is actually
|
||||
// outstanding, which is the part a per-field hint cannot express.
|
||||
const sharedGroups = individuallyRequired
|
||||
? []
|
||||
: groups.filter(
|
||||
(names) => names.length > 1 && names.includes(varName),
|
||||
);
|
||||
const description = !required
|
||||
? "Optional"
|
||||
: sharedGroups.length > 0
|
||||
? `Any one of${sharedGroups.length > 1 ? " each" : ""}: ${sharedGroups
|
||||
.map((names) => names.join(", "))
|
||||
.join("; ")}`
|
||||
: undefined;
|
||||
return useAutocomplete ? (
|
||||
<Autocomplete
|
||||
key={varName}
|
||||
label={varName}
|
||||
description={description}
|
||||
placeholder={`Enter ${varName}`}
|
||||
value={fieldValue}
|
||||
data={completions[varName] ?? []}
|
||||
data={completionsFor(completions, varName)}
|
||||
// The server already filtered the values for the typed
|
||||
// prefix; passing options through verbatim avoids hiding
|
||||
// valid suggestions when the input is empty or doesn't
|
||||
|
|
@ -273,6 +360,7 @@ export function ResourceTemplatePanel({
|
|||
<TextInput
|
||||
key={varName}
|
||||
label={varName}
|
||||
description={description}
|
||||
placeholder={`Enter ${varName}`}
|
||||
value={fieldValue}
|
||||
onChange={(e) =>
|
||||
|
|
@ -290,6 +378,14 @@ export function ResourceTemplatePanel({
|
|||
);
|
||||
})}
|
||||
</Stack>
|
||||
{expansion.error !== undefined && (
|
||||
<ExpansionErrorText>{expansion.error}</ExpansionErrorText>
|
||||
)}
|
||||
{expansion.error === undefined && unmet.length > 0 && (
|
||||
<RequirementText>
|
||||
Still needed: {unmet.map((names) => names.join(" or ")).join("; ")}
|
||||
</RequirementText>
|
||||
)}
|
||||
<FooterRow>
|
||||
<Button size="sm" disabled={!canSubmit} onClick={handleSubmit}>
|
||||
Read Resource
|
||||
|
|
|
|||
Loading…
Reference in a new issue