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:
Cliff Hall 2026-08-17 08:17:54 -04:00 • committed by GitHub
commit f8d8c77783
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 3060 additions and 59 deletions

View file

@ -79,7 +79,34 @@ v2/main/
│ │ # a nullable field entirely — #1928/#2015)
│ ├── logging/ # Silent pino logger singleton
│ ├── mcp/ # InspectorClient runtime + state stores
│ │ # (modernTaskSchemas.ts: SEP-2663 modern Tasks
│ │ # (uriTemplate.ts: RFC 6570 parse/classify/expand.
│ │ # The ONE expander for the web Resources form and
│ │ # readResourceFromTemplate (TUI); the CLI is NOT a
│ │ # consumer — it has no template form and passes an
│ │ # already-expanded --uri to readResource. Both
│ │ # consumers derive their FORM FIELDS from it too
│ │ # (clients/tui uriTemplateToForm),
│ │ # which is what makes the sharing real: a form
│ │ # submits under the names it rendered, so a mangled
│ │ # name silently drops the value at expansion. The
│ │ # SDK's UriTemplate is used only to VALIDATE; its
│ │ # expander is wrong for `{a,b}` (raw-joined),
│ │ # `{;id}` (operator missing), `{id:3}` (modifier in
│ │ # the name), `{+v}`/`{#v}` (encodeURI mangles `[`/`]`
│ │ # and double-encodes pct-triplets), and `{v}`
│ │ # (encodeURIComponent leaves `!'()*` bare).
│ │ # Requiredness is per EXPRESSION, not per variable —
│ │ # requiredGroups + hasRequiredValues. A template that
│ │ # cannot expand ({id:abc}, {}, {a,}) WITHHOLDS the read:
│ │ # tryExpandUriTemplate returns the reason as a value, and
│ │ # the panel disables Read Resource rather than sending the
│ │ # raw template — expandUriTemplate's raw-template fallback
│ │ # is for DISPLAY (the preview) only. An UNDEFINED
│ │ # variable omits its expression; one defined as ""
│ │ # expands (`x{?q}` → `x?q=`), so each FORM — not the
│ │ # expander — drops its untouched blanks via
│ │ # definedValues — #1919;
│ │ # modernTaskSchemas.ts: SEP-2663 modern Tasks
│ │ # extension wire schemas + normalize/handle helpers,
│ │ # used by the raw-wire tasks/* channel — #1631;
│ │ # listSalvage.ts: per-item salvage for list results —

View file

@ -35,7 +35,8 @@ inspector/
│ ├── json/ # JSON + parameter/argument conversion utilities, and the nullable-union
│ │ # schema collapse shared by the web and TUI form builders
│ ├── logging/ # Silent pino logger singleton
│ ├── mcp/ # InspectorClient runtime, state stores, transports, config import
│ ├── mcp/ # InspectorClient runtime, state stores, transports, config import,
│ │ # and the RFC 6570 URI-template helpers the web form and TUI expand through
│ ├── node/ # Node-only shared helpers: version reader, hostUrl (host normalize/canonicalize + all-interfaces/loopback detection)
│ ├── react/ # React hooks over the state stores
│ └── storage/ # File I/O helpers for the OAuth persist backends
@ -147,6 +148,7 @@ Each config below is a ready-made server for exercising one feature by hand. Loa
| `structured-output-http.json` | Tools tab: a result's `structuredContent` section | [#1908](https://github.com/modelcontextprotocol/inspector/issues/1908) |
| `duplicate-tool-names-http.json` | A `tools/list` that repeats a tool name | [#1957](https://github.com/modelcontextprotocol/inspector/issues/1957) |
| `nullable-fields-http.json` | Tools tab: nullable (`anyOf` + `null`) arguments | [#1928](https://github.com/modelcontextprotocol/inspector/issues/1928) |
| `rfc6570-templates-http.json` | Resources tab: RFC 6570 resource-template expansion | [#1919](https://github.com/modelcontextprotocol/inspector/issues/1919) |
| `advertised-extensions-http.json` | Tool registration gated on advertised extensions | [#1739](https://github.com/modelcontextprotocol/inspector/issues/1739) |
| `logging-{legacy,modern}-http.json` | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) |
| `subscriptions-{legacy,modern}-http.json` | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) |
@ -250,6 +252,38 @@ Open the Tools tab and select `record_shipment`: `direction` must render as a **
The **TUI** had the same gap and is worth checking against the same server (`--tui`, then test `record_shipment`): `direction` is a select, `quantity` an integer field, `express` a boolean. Both clients now share one collapse step — `normalizeNullableUnion` in [`core/json/nullableUnion.ts`](./core/json/nullableUnion.ts) — precisely so they cannot drift on which schemas they can render.
#### RFC 6570 resource templates
`rfc6570-templates-http.json` serves two resource templates straight out of [#1919](https://github.com/modelcontextprotocol/inspector/issues/1919) — `events_by_topic` (`foobar://events/{topic}`) and `events_by_query` (`foobar://events{?topic}`) — each echoing the URI it was matched against, plus a plain `foobar://events` resource (see below). Plain streamable-HTTP; connect with the **default (legacy)** protocol era.
Open the Resources tab and pick **events_by_topic**, then enter `foo/bar`. The request must go out as `foobar://events/foo%2Fbar`, and the result echoes back the URI the server matched. On the broken build the value was spliced in raw, so the slash created a second path segment and the SDK's matcher answered `-32602 Resource not found: foobar://events/foo/bar` — the exact failure in the issue. The same holds for `?`, `#`, `%`, spaces, and non-ASCII text.
**events_by_query** is the half that was invisible: the old `/\{(\w+)\}/g` scan could not see an expression carrying an operator, so no `topic` input was rendered at all. It now appears, marked **Optional** — RFC 6570 drops the whole expression when the variable is undefined, so reading with the field blank requests `foobar://events`, and filling it in requests `foobar://events?topic=foo%2Fbar`. The URI preview beside the title shows the partially-expanded form as you type, leaving unfilled expressions standing as written.
> The plain `foobar://events` resource is registered deliberately, not as filler. The SDK's `UriTemplate.match()` compiles `{?topic}` to a **required** `\?topic=([^&]+)`, so a template alone cannot serve the blank read — `match("foobar://events")` returns `null`. A real server exposes the unfiltered collection as its own resource; the showcase does the same so that step actually resolves.
The web client and the TUI expand through one shared helper, [`core/mcp/uriTemplate.ts`](./core/mcp/uriTemplate.ts) — the web Resources form directly, the TUI via `InspectorClient.readResourceFromTemplate` — and both derive their **form fields** from its parser too, which is the half that makes the sharing real: a form submits values under the names it rendered, so a parser that mangles a name silently drops the value at expansion time. (The CLI is not a consumer: it has no template form, and its `resources/read` passes the already-expanded `--uri` straight through.)
The SDK's `UriTemplate` is still used, but only to *validate* a template (constructing it is what rejects an unclosed expression). Its expander is not, because it is incomplete in five ways — each measured against the pinned SDK, not inferred:
| Shape | SDK behavior |
| --- | --- |
| `{a,b}` | raw-joins the values — no encoding, operator prefix dropped |
| `{;id}` | `;` is missing from its operator list, so the variable parses as `;id` |
| `{id:3}` | the prefix modifier is folded into the name, giving `id:3` |
| `{+v}` / `{#v}` | `encodeURI` mangles reserved `[`/`]` (`[::1]` → `%5B::1%5D`) and double-encodes pct-triplets (`%2F` → `%252F`) |
| `{v}` | `encodeURIComponent` leaves the sub-delims `!'()*` bare, which RFC 6570 requires encoded |
The `;` and `:3` rows are the ones a user sees directly: on the SDK's parse the form renders fields literally labelled `;id` and `id:3`. The `+`/`#` row is silent corruption rather than over-escaping — an IPv6 literal or an already-encoded path arrives at the server altered.
A template that cannot be expanded at all — an out-of-grammar modifier (`{id:abc}`), or an expression declaring no variable (`{}`, `{a,}`, `{?}`) — **withholds the read** rather than sending something. Pick **events_malformed** (`foobar://events/{topic:abc}`) to see it: Read Resource is disabled, the reason is printed under the form, and the preview shows the template as the server declared it. The alternative is worse than it looks: `x://{}` would otherwise expand to `x://` with no inputs rendered, so the form's "everything required is filled" check passes vacuously and it reads a URI that is not the template the server published.
Literals are pct-encoded on expansion too (RFC 6570 §3.1): `café/{var}` sends `caf%C3%A9/value`, not raw UTF-8 in the path — something the SDK's expander does not do either. And the *names* a template may use are RFC 6570's `varchar` plus a labelled tolerance for `-` and `~`: the conformance suite rejects `{default-graph-uri}`, but real servers publish such names and the SDK's matcher round-trips them, so the Inspector expands them and marks the variable `conforming: false` rather than refusing a resource that demonstrably works.
An **undefined** variable is what omits its expression — a variable defined as the empty string expands (`x{?q}` gives `x?q=`, `x{;q}` gives `x;q`, per RFC 6570 §3.2.7). The expander honors that distinction, so a caller such as `readResourceFromTemplate` can request either URI. Collapsing the two is a *form* concern, not a template one: both clients seed every declared variable with `""` and a text input cannot express "defined but empty", so each form drops its blanks (`definedValues`) on the way in.
Requiredness is a property of the **expression**, not the variable: RFC 6570 drops undefined names from a multi-name expression, so `{a,b}` with only `a` filled is expandable and a form must not block it. `requiredGroups` returns one entry per non-omittable expression and `hasRequiredValues` asks that each be satisfied by any one of its names — which no per-variable flag can express once a name recurs across expressions (`{a,b}{a,c}` is satisfied by filling `b` and `c`).
#### Advertised extensions
`advertised-extensions-http.json` serves `echo` (always) and a `get_weather` tool **gated on the `io.modelcontextprotocol/tasks` extension** (`extensionGatedTools`): the tool is registered but starts disabled, and the server enables it on `notifications/initialized` only when the client declared that extension in its `capabilities.extensions`.

View file

@ -77,6 +77,73 @@ const renderAndSubmit = async (
};
describe("ResourceTestModal", () => {
// RFC 6570 keeps a required expression satisfied by any ONE of its names, so
// `{a,b}` cannot be expressed with ink-form's per-field `required` flag and
// its members are left optional there. Without the modal's own group check
// the TUI would submit blank -- dropping the expression and reading a
// different resource -- while the web panel blocks the same request (#1919).
describe("required-group validation", () => {
it("refuses to submit when no name in a required group has a value", async () => {
const read = vi.fn();
const { onClose, unmount } = await renderAndSubmit(
fakeClient(read),
makeTemplate({ uriTemplate: "x://{a,b}" }),
{ a: "", b: "" },
);
expect(read).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
unmount();
});
it("blocks a required variable whose name collides with Object.prototype", async () => {
// `constructor` is a valid RFC 6570 varname, and the modal's own filter
// used to read `Object` (length 1) as a filled value. The *message* built
// from that filter came out naming no field; since frames render empty
// here (see the header note), that half is asserted on
// `unmetRequiredGroups` in the core suite. This asserts the gate.
const read = vi.fn();
const { onClose, unmount } = await renderAndSubmit(
fakeClient(read),
makeTemplate({ uriTemplate: "x://{constructor}" }),
{ constructor: "" },
);
expect(read).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
unmount();
});
it("submits once any one name in the group has a value", async () => {
const read = vi.fn().mockResolvedValue({
result: { contents: [] },
expandedUri: "x://only-a",
});
const { unmount } = await renderAndSubmit(
fakeClient(read),
makeTemplate({ uriTemplate: "x://{a,b}" }),
{ a: "only-a", b: "" },
);
// The untouched field is dropped before the read: `""` is a *defined*
// RFC 6570 value that would expand to a valueless pair, and ink-form
// cannot distinguish "never touched" from "deliberately empty".
expect(read).toHaveBeenCalledWith("x://{a,b}", { a: "only-a" });
unmount();
});
it("does not block a template whose only expression is omittable", async () => {
const read = vi.fn().mockResolvedValue({
result: { contents: [] },
expandedUri: "x://events",
});
const { unmount } = await renderAndSubmit(
fakeClient(read),
makeTemplate({ uriTemplate: "x://events{?topic}" }),
{ topic: "" },
);
expect(read).toHaveBeenCalled();
unmount();
});
});
it("renders the form initially without invoking the client", async () => {
const read = vi.fn();
const api = render(

View file

@ -11,7 +11,38 @@ describe("uriTemplateToForm", () => {
expect(form.title).toBe("Read Resource: file");
const fields = form.sections[0]!.fields;
expect(fields.map((f) => f.name)).toEqual(["path", "name"]);
expect(fields[0]).toMatchObject({ type: "string", required: false });
// Simple `{path}` sits mid-URI, so omitting it would leave an empty path
// segment rather than a shorter URI -- it is mandatory, matching the web
// panel. Both clients read this from core's `requiredGroups`.
expect(fields[0]).toMatchObject({ type: "string", required: true });
});
it("names fields as the expander looks them up, not as the SDK parses them", () => {
// The SDK reports these as ";id" and "id:3"; submitting under those keys
// would make the expander find nothing and drop the expression (#1919).
expect(
uriTemplateToForm("x://a{;id}", "matrix").sections[0]!.fields.map(
(f) => f.name,
),
).toEqual(["id"]);
expect(
uriTemplateToForm("x://a/{id:3}", "prefix").sections[0]!.fields.map(
(f) => f.name,
),
).toEqual(["id"]);
});
it("leaves a shared required group optional rather than demanding every name", () => {
// `{a,b}` is satisfied by either name, and ink-form cannot say "any one
// of"; marking both required would refuse input the expander accepts.
const fields = uriTemplateToForm("x://{a,b}", "pair").sections[0]!.fields;
expect(fields.map((f) => f.name)).toEqual(["a", "b"]);
expect(fields.every((f) => f.required === false)).toBe(true);
});
it("marks an omittable variable optional", () => {
const fields = uriTemplateToForm("x://a{?topic}", "q").sections[0]!.fields;
expect(fields[0]).toMatchObject({ name: "topic", required: false });
});
it("returns an empty Template Variables section for a static URI", () => {
@ -33,3 +64,15 @@ describe("uriTemplateToForm", () => {
expect(form.sections[0]!.fields).toEqual([]);
});
});
describe("a name repeated inside one expression", () => {
it("is required, not treated as a shared group", () => {
// `{a,a}` is one requirement named twice. Before core deduplicated the
// group, `requiredGroups` returned ["a","a"], so this form's
// `length === 1` test left the field optional while ResourceTestModal's
// submit guard still refused a blank -- an un-submittable form.
const [field] = uriTemplateToForm("x://{a,a}", "T").sections[0].fields;
expect(field.name).toBe("a");
expect(field.required).toBe(true);
});
});

View file

@ -5,6 +5,11 @@ import { InspectorClient } from "@inspector/core/mcp/index.js";
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
import type { ReadResourceResult } from "@modelcontextprotocol/client";
import { uriTemplateToForm } from "../utils/uriTemplateToForm.js";
import {
definedValues,
requiredGroups,
unmetRequiredGroups,
} from "@inspector/core/mcp/uriTemplate.js";
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
// Helper to extract error message from various error types
@ -133,14 +138,45 @@ export function ResourceTestModal({
const handleFormSubmit = async (values: Record<string, string>) => {
if (!inspectorClient || !template) return;
// RFC 6570 keeps a required expression satisfied by any ONE of its names,
// so `{a,b}` cannot be expressed with ink-form's per-field `required` flag
// and its members are left optional there. Without this check the TUI would
// submit `{a,b}` completely blank -- dropping the expression and reading a
// different resource -- while the web panel blocks the same request (#1919).
// The message and the gate come from the SAME pass. Filtering the groups
// again here with a bare `values[name]` disagreed with the gate for a
// variable named `constructor` or `toString`: the inherited member read as
// filled, so the list came out empty and the error named no field at all.
const unmetGroups = unmetRequiredGroups(
requiredGroups(template.uriTemplate),
values,
);
if (unmetGroups.length > 0) {
const unmet = unmetGroups.map((names) => names.join(" or "));
setResult({
input: values,
output: null,
error: `Missing required template variable(s): ${unmet.join(", ")}`,
duration: 0,
uri: template.uriTemplate,
});
setState("results");
return;
}
setState("loading");
const startTime = Date.now();
try {
// Use InspectorClient's readResourceFromTemplate method which encapsulates template expansion and resource reading
// Blanks are dropped HERE, not in the expander: a key present with `""`
// is a defined RFC 6570 value that legitimately expands to `?topic=`,
// but ink-form hands back `""` for every field the user never touched,
// so this form cannot tell the two apart. The web panel does the same at
// its own boundary.
const invocation = await inspectorClient.readResourceFromTemplate(
template.uriTemplate,
values,
definedValues(values),
);
const duration = Date.now() - startTime;

View file

@ -4,32 +4,50 @@
import type { FormStructure, FormSection, FormField } from "ink-form";
import { UriTemplate } from "@modelcontextprotocol/client";
import {
requiredGroups,
templateVariables,
} from "@inspector/core/mcp/uriTemplate.js";
/**
* Converts a URI Template to ink-form structure
* Converts a URI Template to ink-form structure.
*
* Fields come from core's `templateVariables`, the same parser
* `InspectorClient.readResourceFromTemplate` expands through, so the key this
* form submits is the key the expander looks up. Using the SDK's
* `variableNames` here instead is not merely untidy -- it mangles the name:
* `{;id}` yields `";id"` and `{id:3}` yields `"id:3"`, so the form would submit
* `{ ";id": "7" }` while the expander looks for `id`, silently dropping the
* value and the whole expression with it (#1919).
*
* The SDK template is still constructed, but only to validate: core's parser is
* deliberately lenient (an unclosed `{` becomes literal text), so this is what
* still surfaces a malformed template as an empty form plus a logged error.
*/
export function uriTemplateToForm(
uriTemplate: string,
templateName: string,
): FormStructure {
const fields: FormField[] = [];
let fields: FormField[] = [];
try {
const template = new UriTemplate(uriTemplate);
/* v8 ignore next -- UriTemplate.variableNames is a getter that always
returns a string[]; the `|| []` fallback is an unreachable guard. */
const variableNames = template.variableNames || [];
for (const variableName of variableNames) {
const field: FormField = {
name: variableName,
label: variableName,
type: "string",
required: false, // URI template variables are typically optional
};
fields.push(field);
}
new UriTemplate(uriTemplate);
// Only a variable that is the *sole* member of a non-omittable expression
// is genuinely mandatory. RFC 6570 drops undefined names from a multi-name
// expression, so `{a,b}` needs only one of the two -- ink-form cannot
// express "any one of these", and marking both required would refuse input
// the expander accepts.
const mandatory = new Set(
requiredGroups(uriTemplate)
.filter((names) => names.length === 1)
.map(([name]) => name),
);
fields = templateVariables(uriTemplate).map(({ name }) => ({
name,
label: name,
type: "string",
required: mandatory.has(name),
}));
} catch (error) {
// If parsing fails, return empty form
console.error("Failed to parse URI template:", error);

View file

@ -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();

View file

@ -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

View file

@ -0,0 +1,970 @@
import { describe, it, expect } from "vitest";
import {
definedValues,
expandUriTemplate,
expandUriTemplateStrict,
hasRequiredValues,
parseUriTemplate,
requiredGroups,
templateVariables,
templateError,
tryExpandUriTemplate,
unmetRequiredGroups,
valueLengthError,
} from "@inspector/core/mcp/uriTemplate.js";
describe("parseUriTemplate", () => {
it("splits literals from expressions", () => {
expect(parseUriTemplate("foobar://events/{topic}")).toEqual([
{ kind: "literal", text: "foobar://events/" },
{
kind: "expression",
source: "{topic}",
operator: "",
varspecs: [{ name: "topic" }],
names: ["topic"],
invalid: false,
},
]);
});
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: "?",
varspecs: [{ name: "a" }, { name: "b" }],
names: ["a", "b"],
invalid: false,
},
]);
});
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, conforming: true },
]);
});
it("finds a query variable the old `\\{(\\w+)\\}` regex could not see", () => {
expect(templateVariables("foobar://events{?topic}")).toEqual([
{ name: "topic", operator: "?", required: false, conforming: true },
]);
});
// Required iff omitting the variable leaves an empty slot mid-URI rather
// than a shorter, still-well-formed URI. Verified against the pinned SDK:
// `x://a/{+path}` with no `path` expands to "x://a/" (empty segment), while
// `x://a{#frag}` expands to exactly "x://a".
it.each([
["{+path}", "+", true],
["{#frag}", "#", false],
["{.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, conforming: true },
]);
});
it.each([
["file:///users/{userId}/profile", "file:///users//profile"],
["x://a/{+path}", "x://a/"],
])(
"requires %s because omitting it leaves an empty slot (%s)",
(template, omitted) => {
expect(templateVariables(template)[0].required).toBe(true);
// The reason, asserted rather than asserted-about: this is what the URI
// would become if the field were left blank.
expect(expandUriTemplate(template, {})).toBe(omitted);
},
);
it("does not require {#frag}, which omits to a well-formed URI", () => {
expect(templateVariables("x://a{#frag}")[0].required).toBe(false);
expect(expandUriTemplate("x://a{#frag}", {})).toBe("x://a");
});
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 is undefined", () => {
expect(expandUriTemplate("foobar://events{?topic}", {})).toBe(
"foobar://events",
);
// A form's untouched field arrives as "", which is a *defined* value; the
// form drops it on the way in. See "a value defined as the empty string".
expect(
expandUriTemplate(
"foobar://events{?topic}",
definedValues({ topic: "" }),
),
).toBe("foobar://events");
});
it("expands each query expression independently, per RFC 6570", () => {
// NOT `?one=1&two=2`. The SDK rewrites the second `?` to `&`, but measured
// against the pinned SDK its own matcher then rejects the result:
// match("x?one=1&two=2") on `x{?one}{?two}` is null, while
// match("x?one=1?two=2") returns both variables. A server wanting a
// continuation advertises `{?one}{&two}` -- see the test below.
expect(expandUriTemplate("x://a{?one}{?two}", { one: "1", two: "2" })).toBe(
"x://a?one=1?two=2",
);
});
it("emits & only where the template asks for the continuation operator", () => {
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("expandUriTemplate - multi-name expressions (SDK correction)", () => {
// The pinned SDK's `expandPart` takes an early `names.length > 1` branch that
// raw-joins the values, skipping BOTH encodeValue and the operator prefix.
// Measured directly: `x://{a,b}` -> "x://foo/bar,q", `x://a{/p,q}` ->
// "x://ax y,z". These assert the corrected output.
it("encodes each value in a simple multi-name expression", () => {
expect(expandUriTemplate("x://{a,b}", { a: "foo/bar", b: "q" })).toBe(
"x://foo%2Fbar,q",
);
});
it("keeps the / operator prefix and separator, and encodes", () => {
expect(expandUriTemplate("x://a{/p,q}", { p: "x y", q: "z" })).toBe(
"x://a/x%20y/z",
);
});
it("keeps the . operator prefix and separator", () => {
expect(expandUriTemplate("x://a{.p,q}", { p: "x/y", q: "z" })).toBe(
"x://a.x%2Fy.z",
);
});
it("keeps the # prefix and leaves reserved characters under it", () => {
expect(expandUriTemplate("x://a{#p,q}", { p: "x/y", q: "z" })).toBe(
"x://a#x/y,z",
);
});
it("leaves reserved characters under the + operator", () => {
expect(expandUriTemplate("x://{+a,b}", { a: "x/y", b: "z" })).toBe(
"x://x/y,z",
);
});
it("drops only the undefined names, keeping the rest", () => {
expect(expandUriTemplate("x://a{/p,q}", { q: "z" })).toBe("x://a/z");
});
it("omits the whole expression when no name has a value", () => {
expect(expandUriTemplate("x://a{/p,q}", {})).toBe("x://a");
});
// Naming, not delegation: a multi-name QUERY expression pairs each name with
// its own value and joins with `&` (RFC 6570 §3.2.8), unlike the bare
// comma-joined list every non-named operator above produces.
it("pairs each name with its value in a multi-name query expression", () => {
expect(expandUriTemplate("x://a{?p,q}", { p: "x/y", q: "z" })).toBe(
"x://a?p=x%2Fy&q=z",
);
});
});
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, conforming: true },
]);
});
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("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, conforming: true },
]);
});
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("requiredGroups / 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 groups = requiredGroups("x://{a,b}");
expect(groups).toEqual([["a", "b"]]);
expect(hasRequiredValues(groups, { a: "only-a", b: "" })).toBe(true);
expect(
expandUriTemplate("x://{a,b}", definedValues({ a: "only-a", b: "" })),
).toBe("x://only-a");
});
it("rejects a multi-name expression with nothing filled", () => {
expect(
hasRequiredValues(requiredGroups("x://{a,b}"), { a: "", b: "" }),
).toBe(false);
});
it("still requires a lone required variable", () => {
const groups = requiredGroups("file:///users/{userId}/profile");
expect(hasRequiredValues(groups, { userId: "" })).toBe(false);
expect(hasRequiredValues(groups, { userId: "alice" })).toBe(true);
});
it("never blocks on an omittable expression", () => {
expect(requiredGroups("foobar://events{?topic}")).toEqual([]);
expect(
hasRequiredValues(requiredGroups("foobar://events{?topic}"), {
topic: "",
}),
).toBe(true);
});
it("is satisfied by a template with no variables at all", () => {
expect(hasRequiredValues(requiredGroups("file:///static.txt"), {})).toBe(
true,
);
});
it("tracks a name that recurs under a different operator", () => {
// A per-variable model keeping only the first occurrence's group would
// mark both names required with singleton groups and refuse this input;
// the SDK expands the same template with just `a` to "x?a=11".
const groups = requiredGroups("x{?a}{?b}{a,b}");
expect(groups).toEqual([["a", "b"]]);
expect(hasRequiredValues(groups, { a: "1", b: "" })).toBe(true);
expect(expandUriTemplate("x{?a}{?b}{a,b}", { a: "1" })).toBe("x?a=11");
});
it("satisfies two required expressions sharing a name, from the others", () => {
// `{a,b}{a,c}`: filling only b and c satisfies both groups. No
// per-variable flag can express this, which is why groups are separate.
const groups = requiredGroups("x://{a,b}{a,c}");
expect(groups).toEqual([
["a", "b"],
["a", "c"],
]);
expect(hasRequiredValues(groups, { b: "B", c: "C" })).toBe(true);
expect(hasRequiredValues(groups, { b: "B" })).toBe(false);
});
});
describe("a name repeated inside one expression", () => {
// `{a,a}` is one requirement named twice, not two names either of which
// would do. Undeduplicated it read as a shared group everywhere downstream:
// the TUI's `length === 1` test marked `a` optional while its submit guard
// still refused a blank, and the web panel offered "Any one of: a, a".
it("collapses to a single-name required group", () => {
expect(requiredGroups("x://{a,a}")).toEqual([["a"]]);
});
it("still expands both occurrences, which RFC 6570 requires", () => {
expect(expandUriTemplate("x://{a,a}", { a: "1" })).toBe("x://1,1");
});
it("leaves genuinely distinct names alone", () => {
expect(requiredGroups("x://{a,b}")).toEqual([["a", "b"]]);
});
});
describe("expression independence", () => {
it("does not rewrite a later query expression on the own-expansion path", () => {
expect(
expandUriTemplate("x://a{;k}{?one}{?two}", {
k: "v",
one: "1",
two: "2",
}),
).toBe("x://a;k=v?one=1?two=2");
});
it("omits an expression with no value without affecting its neighbours", () => {
expect(
expandUriTemplate("x://a{;k}{?one}{?two}", { k: "v", two: "2" }),
).toBe("x://a;k=v?two=2");
});
});
describe("allow-reserved encoding under + and #", () => {
// The SDK uses `encodeURI` for these operators, which corrupts two classes
// of value rather than merely over-escaping: measured, encodeURI("[::1]")
// is "%5B::1%5D" and encodeURI("%2F") is "%252F".
it.each(["+", "#"])("leaves reserved [ and ] intact under %s", (operator) => {
const prefix = operator === "#" ? "#" : "";
expect(expandUriTemplate(`x://{${operator}v}`, { v: "[::1]" })).toBe(
`x://${prefix}[::1]`,
);
});
it.each(["+", "#"])(
"does not double-encode an existing pct-triplet under %s",
(operator) => {
const prefix = operator === "#" ? "#" : "";
expect(expandUriTemplate(`x://{${operator}v}`, { v: "a%2Fb" })).toBe(
`x://${prefix}a%2Fb`,
);
},
);
it("still encodes a lone % that is not a triplet", () => {
expect(expandUriTemplate("x://{+v}", { v: "100%" })).toBe("x://100%25");
});
it("still encodes characters outside the allowed set", () => {
expect(expandUriTemplate("x://{+v}", { v: "a b" })).toBe("x://a%20b");
});
it("encodes an astral character whole rather than as surrogates", () => {
expect(expandUriTemplate("x://{+v}", { v: "\u{1F600}" })).toBe(
`x://${encodeURIComponent("\u{1F600}")}`,
);
});
it("applies the same encoding in a multi-name + expression", () => {
expect(expandUriTemplate("x://{+a,b}", { a: "[::1]", b: "%2F" })).toBe(
"x://[::1],%2F",
);
});
it("still percent-encodes reserved characters under the simple operator", () => {
// Only + and # allow reserved through; the default path is unchanged.
expect(expandUriTemplate("x://{v}", { v: "[::1]" })).toBe(
"x://%5B%3A%3A1%5D",
);
});
});
describe("unreserved encoding under the non-reserved operators", () => {
// `encodeURIComponent` leaves the sub-delims !'()* bare, but RFC 6570 only
// allows *unreserved* characters through for these operators.
it.each([
["", "x://"],
[".", "x://a."],
["/", "x://a/"],
])("encodes !'()* under the %s operator", (operator, prefix) => {
const base = operator === "" ? "x://" : "x://a";
expect(
expandUriTemplate(`${base}{${operator}v}`, { v: "a!b'c(d)e*f" }),
).toBe(`${prefix}a%21b%27c%28d%29e%2Af`);
});
it("encodes them in a named (query) expression too", () => {
expect(expandUriTemplate("x://a{?v}", { v: "a!b" })).toBe("x://a?v=a%21b");
});
it("encodes them in a matrix expression too", () => {
expect(expandUriTemplate("x://a{;v}", { v: "a!b" })).toBe("x://a;v=a%21b");
});
it("leaves them alone under + and #, where reserved characters are allowed", () => {
expect(expandUriTemplate("x://{+v}", { v: "a!b'c(d)e*f" })).toBe(
"x://a!b'c(d)e*f",
);
});
it("still leaves the unreserved set itself untouched", () => {
expect(expandUriTemplate("x://{v}", { v: "aZ0-._~" })).toBe("x://aZ0-._~");
});
});
describe("prefix-modifier grammar", () => {
// RFC 6570: max-length = %x31-39 0*3DIGIT -- 1..9999, no leading zero.
// The SDK's constructor accepts these shapes, so nothing else rejects them;
// treating `{id:abc}` as a plain `{id}` would send a URI the server never
// advertised, with nothing to alert anyone.
it.each(["x://{id:}", "x://{id:0}", "x://{id:abc}", "x://{id:10000}"])(
"strict rejects the invalid template %s",
(template) => {
expect(() => expandUriTemplateStrict(template, { id: "abcdef" })).toThrow(
/Invalid RFC 6570 varspec/,
);
},
);
it.each(["x://{id:}", "x://{id:abc}"])(
"lenient returns %s unchanged rather than guessing",
(template) => {
expect(expandUriTemplate(template, { id: "abcdef" })).toBe(template);
},
);
it.each([
["x://{id:1}", "a"],
["x://{id:9999}", "abcdef"],
])("accepts the in-range modifier %s", (template, expected) => {
expect(expandUriTemplate(template, { id: "abcdef" })).toBe(
`x://${expected}`,
);
});
});
describe("variable names that collide with Object.prototype", () => {
// `toString`, `constructor`, `valueOf` and `__proto__` are all valid RFC 6570
// varnames. A bare `values[name]` lookup finds the prototype's member for
// every one of them, so a *blank* field read as supplied: measured,
// `({})["toString"] !== undefined` is true and its typeof is "function".
it.each(["toString", "constructor", "valueOf", "hasOwnProperty"])(
"reads a blank {?%s} as its own empty value, not a prototype member",
(name) => {
// The own value is "", so the expression expands to a valueless pair --
// NOT the inherited function's body, which is what a bare lookup gave.
expect(expandUriTemplate(`x://a{?${name}}`, { [name]: "" })).toBe(
`x://a?${name}=`,
);
// And with the key absent, the expression is omitted entirely.
expect(expandUriTemplate(`x://a{?${name}}`, {})).toBe("x://a");
},
);
it("omits the expression when the key is absent entirely", () => {
expect(expandUriTemplate("x://a{?toString}", {})).toBe("x://a");
});
it("still expands such a variable when it really has a value", () => {
expect(expandUriTemplate("x://a{?toString}", { toString: "v" })).toBe(
"x://a?toString=v",
);
});
it("does not treat an inherited member as satisfying a required group", () => {
// `Object` (the inherited constructor) has length 1, so the old
// `(values[name] ?? "").length > 0` test reported this as satisfied.
expect(hasRequiredValues([["constructor"]], {})).toBe(false);
expect(hasRequiredValues([["constructor"]], { constructor: "c" })).toBe(
true,
);
});
it("handles __proto__ as an ordinary variable name", () => {
expect(expandUriTemplate("x://a{?__proto__}", {})).toBe("x://a");
});
});
describe("an expression that declares no variable", () => {
// RFC 6570 requires at least one varspec per expression and admits no empty
// member, so each of these is a malformed template rather than one with a
// member to skip. Skipping is the dangerous reading: `x://{}` would expand to
// `x://` while the form rendered no inputs, so its "everything required is
// filled" check is vacuously true and it submits a URI that is not the
// template the server published.
it.each([
["an empty expression", "x://{}"],
["only a separator", "x://{,}"],
["a missing member", "x://{a,}"],
["an operator and no name", "x://a{?}"],
// `*` is an explode modifier, not a name -- it is stripped before the
// emptiness test, so this must be rejected the same way.
["only an explode modifier", "x://{*}"],
])("strict rejects %s", (_label, template) => {
expect(() => expandUriTemplateStrict(template, { a: "1" })).toThrow(
/Invalid RFC 6570 varspec/,
);
});
it("declares no required group for it, so only the expansion gate reports it", () => {
// An empty group could never be satisfied, and the form's "any one of"
// message built from it would name no fields -- the accurate reason is the
// malformed template, which tryExpandUriTemplate gives.
expect(requiredGroups("x://{}")).toEqual([]);
expect(tryExpandUriTemplate("x://{}", {}).error).toMatch(
/Invalid RFC 6570 varspec/,
);
});
it("still reports the names a partly-empty expression does declare", () => {
expect(templateVariables("x://{a,}").map((v) => v.name)).toEqual(["a"]);
});
it("leaves the template unexpanded on the display path", () => {
expect(expandUriTemplate("x://{}", {})).toBe("x://{}");
});
});
describe("varspec grammar", () => {
// Each of these expanded rather than being refused before the anchored
// grammar landed -- measured on the previous commit: `{*id}` and `{ id }`
// both yielded "x://abcdef", and `{id*:3}` silently truncated to "x://abc"
// through a modifier combination RFC 6570 does not allow.
it.each([
["explode in the leading position", "x://{*id}"],
["explode combined with a prefix", "x://{id*:3}"],
["whitespace around the name", "x://{ id }"],
["whitespace inside the name", "x://{a b}"],
["a name outside the character set", "x://{a/b}"],
])("strict rejects %s", (_label, template) => {
expect(() =>
expandUriTemplateStrict(template, {
id: "abcdef",
a: "1",
"a/b": "1",
"a b": "1",
}),
).toThrow(/Invalid RFC 6570 varspec/);
});
it.each([
// RFC 6570 varchar, plus the two RFC 3986 unreserved characters the set is
// deliberately widened by -- a name is emitted verbatim by `?`/`&`/`;`, so
// the rule is "needs no encoding", and `{user-id}` is common in the wild.
["a plain name", "x://{id}", { id: "7" }, "x://7"],
["a hyphen", "x://{user-id}", { "user-id": "7" }, "x://7"],
["a tilde", "x://{a~b}", { "a~b": "7" }, "x://7"],
["an underscore", "x://{a_b}", { a_b: "7" }, "x://7"],
["a dot separator", "x://{a.b}", { "a.b": "7" }, "x://7"],
["a pct-encoded varchar", "x://{a%2Fb}", { "a%2Fb": "7" }, "x://7"],
["a trailing explode", "x://{id*}", { id: "7" }, "x://7"],
["a trailing prefix", "x://{id:3}", { id: "abcdef" }, "x://abc"],
])("accepts %s", (_label, template, values, expected) => {
expect(expandUriTemplate(template, values)).toBe(expected);
});
it.each(["x://{a.}", "x://{a..b}"])(
"rejects the misplaced dot in %s",
(template) => {
// varname = varchar *( ["."] varchar ) -- a dot separates, it cannot
// trail or double.
expect(() => expandUriTemplateStrict(template, {})).toThrow(
/Invalid RFC 6570 varspec/,
);
},
);
it("reads a leading dot as the label operator, not a malformed name", () => {
// `{.a}` is label expansion of `a` -- the one "misplaced dot" that is not
// one, since the operator is stripped before the varspec is parsed.
expect(expandUriTemplate("x://a{.ext}", { ext: "json" })).toBe(
"x://a.json",
);
});
});
describe("unmetRequiredGroups", () => {
it("names the groups that are missing", () => {
expect(unmetRequiredGroups([["a", "b"], ["c"]], { c: "1" })).toEqual([
["a", "b"],
]);
});
it("agrees with hasRequiredValues on a prototype-collision name", () => {
// The TUI derived its "Missing required template variable(s)" list with its
// own bare `values[name]` filter: `({})["constructor"]` is `Object`, whose
// `.length` is 1, so the group read as satisfied and the message named no
// field while the gate still blocked the submit.
const groups = [["constructor"]];
expect(hasRequiredValues(groups, {})).toBe(false);
expect(unmetRequiredGroups(groups, {})).toEqual([["constructor"]]);
});
});
describe("literal normalization", () => {
// RFC 6570 3.1: a literal may contain non-ASCII, but expansion emits it
// pct-encoded -- the conformance case `café/{var}` expands to
// `caf%C3%A9/value`. Literals used to pass through verbatim, and since this
// module replaced the SDK's expander for both clients, nothing else was
// going to encode them. (Measured: the pinned SDK returns `café/value` too.)
it("percent-encodes a non-ASCII literal", () => {
expect(expandUriTemplate("café/{var}", { var: "value" })).toBe(
"caf%C3%A9/value",
);
});
it("encodes a space in a literal", () => {
expect(expandUriTemplate("a b/{v}", { v: "x" })).toBe("a%20b/x");
});
it.each([
["URI delimiters", "x://a/b?c=d#e{?q}", { q: "1" }, "x://a/b?c=d#e?q=1"],
["an existing pct-triplet", "a%20b/{v}", { v: "x" }, "a%20b/x"],
])("preserves %s", (_label, template, values, expected) => {
expect(expandUriTemplate(template, values)).toBe(expected);
});
it("passes a brace through, so a malformed template stays legible", () => {
// A brace reaches a literal only from a malformed template (an unclosed
// `{` becomes trailing text). Encoding it to %7B would leave a string
// resembling neither the template nor anything the server could match.
expect(expandUriTemplate("x://a/{oops", { oops: "v" })).toBe("x://a/{oops");
});
});
describe("names outside RFC 6570 varchar", () => {
// The conformance suite rejects `{~thing}` and `{default-graph-uri}`. The
// Inspector expands them anyway -- real servers publish hyphenated names and
// the SDK's matcher round-trips them -- but the tolerance is labelled rather
// than folded into the grammar, so a caller wanting RFC-exact behavior can
// refuse on the flag.
it.each(["user-id", "a~b"])("expands the tolerated name %s", (name) => {
expect(expandUriTemplate(`x://{${name}}`, { [name]: "7" })).toBe("x://7");
});
it.each([
["default-graph-uri", false],
["~thing", false],
["plain_name", true],
["a.b", true],
["%61%62", true],
])("marks %s conforming=%s", (name, conforming) => {
expect(templateVariables(`x://{${name}}`)[0].conforming).toBe(conforming);
});
it("marks a name non-conforming if any occurrence needed the tolerance", () => {
expect(templateVariables("x://{user-id}{?user-id}")[0].conforming).toBe(
false,
);
});
});
describe("prefix modifier truncation", () => {
// RFC 6570 2.4.1 counts characters, and says a pct-encoded triplet counts as
// ONE. Truncating by code point cut the sixth triplet in half -- measured
// before the fix, `{+v:5}` over "%61%62%63%64%65%66" produced "x%61%256",
// because the orphaned "%6" was no longer a triplet and its `%` was then
// (correctly) encoded. A byte the caller never asked for.
it.each([
["+", "x{+v:5}", "x%61%62%63%64%65"],
["#", "x{#v:5}", "x#%61%62%63%64%65"],
])("keeps pct-triplets whole under %s", (_op, template, expected) => {
expect(expandUriTemplate(template, { v: "%61%62%63%64%65%66" })).toBe(
expected,
);
});
it.each([
// The counting rule is operator-INDEPENDENT: a triplet is one character
// wherever it appears. An earlier revision grouped only under `+`/`#`,
// which made the same input mean two things and truncated `{v:1}` to a `%`
// that was never a character of the value (it emitted "%25").
["x/{v:1}", "x/%2561"],
["x/{v:2}", "x/%2561%2562"],
["x/{v:3}", "x/%2561%2562"],
])(
"counts a triplet as one character under a simple expansion (%s)",
(template, expected) => {
// What the operator decides is the *encoding* afterwards -- a simple
// expansion still escapes the retained triplet's `%`, so `%61` -> `%2561`.
expect(expandUriTemplate(template, { v: "%61%62" })).toBe(expected);
},
);
it.each([
// A pct-encoded UTF-8 sequence is ONE character: `%C3%A9` is `é`. Counting
// per triplet returned `%C3`, a lone lead byte decoding to nothing.
["x{+v:1}", "%C3%A9x", "x%C3%A9"],
["x{+v:2}", "%C3%A9x", "x%C3%A9x"],
// Three-octet (`€`) and four-octet (an emoji) sequences, same rule.
["x{+v:1}", "%E2%82%ACy", "x%E2%82%AC"],
["x{+v:1}", "%F0%9F%98%80z", "x%F0%9F%98%80"],
// A malformed sequence -- a lead byte with too few continuations -- falls
// back to per-triplet counting rather than swallowing what follows.
["x{+v:1}", "%C3x", "x%C3"],
// A stray continuation byte is not a sequence start.
["x{+v:1}", "%A9%C3%A9", "x%A9"],
// RFC 3629 well-formedness, not just the length-announcing lead byte: an
// overlong encoding, a UTF-16 surrogate, and a code point past U+10FFFF
// are each refused the grouping and counted per triplet.
["x{+v:1}", "%C0%80x", "x%C0"],
["x{+v:1}", "%E0%80%80x", "x%E0"],
["x{+v:1}", "%ED%A0%80x", "x%ED"],
["x{+v:1}", "%F4%90%80%80x", "x%F4"],
// The boundary cases that ARE well-formed stay whole.
["x{+v:1}", "%E0%A0%80x", "x%E0%A0%80"],
["x{+v:1}", "%ED%9F%BFx", "x%ED%9F%BF"],
["x{+v:1}", "%F4%8F%BF%BFx", "x%F4%8F%BF%BF"],
])(
"keeps a multi-octet sequence whole (%s over %s)",
(template, value, expected) => {
expect(expandUriTemplate(template, { v: value })).toBe(expected);
},
);
it("counts a triplet as one character under a named operator too", () => {
expect(expandUriTemplate("x{?v:1}", { v: "%61%62" })).toBe("x?v=%2561");
});
it("truncates by code point, not UTF-16 code unit", () => {
// An astral character is one character; `slice` would cut the surrogate
// pair in half and yield a lone surrogate.
expect(
expandUriTemplate("x/{v:2}", { v: "\u{1F600}\u{1F601}\u{1F602}" }),
).toBe("x/%F0%9F%98%80%F0%9F%98%81");
expect(expandUriTemplate("x{+v:2}", { v: "\u{1F600}\u{1F601}" })).toBe(
"x%F0%9F%98%80%F0%9F%98%81",
);
});
it("leaves a value shorter than the prefix untouched", () => {
expect(expandUriTemplate("x{+v:9}", { v: "%61%62" })).toBe("x%61%62");
});
});
describe("a value defined as the empty string", () => {
// RFC 6570 distinguishes an *undefined* variable (the expression is omitted)
// from one defined as "". The expander used to collapse the two, which made
// these URIs unrequestable through any caller -- `readResourceFromTemplate`
// included. Dropping blanks is a *form* concern (a text input cannot express
// "defined but empty"), so each client applies `definedValues` on its way in.
it.each([
["a query expression keeps the =", "x{?q}", "x?q="],
["a continuation keeps the =", "x?a=1{&q}", "x?a=1&q="],
// RFC 6570 3.2.7: the matrix operator drops the `=` for an empty value.
["the matrix operator drops the =", "x{;q}", "x;q"],
["a path segment expands to a bare separator", "x{/q}", "x/"],
["a label expands to a bare separator", "x{.q}", "x."],
["a simple expression contributes nothing visible", "x/{q}", "x/"],
])("%s", (_label, template, expected) => {
expect(expandUriTemplate(template, { q: "" })).toBe(expected);
});
it("omits the expression only when the key is absent", () => {
expect(expandUriTemplate("x{?q}", {})).toBe("x");
expect(expandUriTemplate("x{;q}", {})).toBe("x");
});
it("keeps a defined-but-empty name alongside a filled one", () => {
expect(expandUriTemplate("x{?a,b}", { a: "", b: "2" })).toBe("x?a=&b=2");
});
it("definedValues is what a form applies to drop its untouched fields", () => {
expect(expandUriTemplate("x{?q}", definedValues({ q: "" }))).toBe("x");
});
});
describe("unmatched braces", () => {
// The SDK's constructor rejects an unclosed `{` but reads a stray `}` as
// literal text, and this parser does the same -- so `x://a}` used to expand
// to itself, a "URI" carrying a brace, with the panel enabling the read.
it.each([
["a stray closing brace", "x://a}"],
["a closing brace after an expression", "x://{a}}"],
["an unclosed opening brace", "x://a/{oops"],
])("strict refuses %s", (_label, template) => {
// The stray `}` is ours to catch; the unclosed `{` the SDK's constructor
// already rejects ("Unclosed template expression"). Either way the read is
// withheld, which is the property under test.
expect(() => expandUriTemplateStrict(template, { a: "1" })).toThrow(
/Unmatched brace|Unclosed template expression/,
);
});
it("leaves such a template unexpanded on the display path", () => {
expect(expandUriTemplate("x://a}", {})).toBe("x://a}");
expect(expandUriTemplate("x://a/{oops", {})).toBe("x://a/{oops");
});
it("still accepts a well-formed expression", () => {
expect(expandUriTemplate("x://{a}/b", { a: "1" })).toBe("x://1/b");
});
});
describe("the per-value length ceiling", () => {
// Replacing `UriTemplate.expand` dropped the SDK's own 1,000,000-character
// guard, leaving the allocation-heavy encoders unbounded for a pasted or
// programmatically supplied value.
it("refuses a value past the limit", () => {
const result = tryExpandUriTemplate("x://{v}", {
v: "a".repeat(1_000_001),
});
expect(result.uri).toBeUndefined();
expect(result.error).toMatch(/exceeds the 1000000-character limit/);
});
it("accepts a value at the limit", () => {
const value = "a".repeat(1_000_000);
expect(tryExpandUriTemplate("x://{v}", { v: value }).uri).toBe(
`x://${value}`,
);
});
it("ignores an oversized value the template never references", () => {
// RFC 6570 ignores an extra variable, and the SDK path this replaced
// validated a value only after looking one up by declared name -- so
// checking the whole map refused `x://{id}` over a stale key beside it.
expect(
tryExpandUriTemplate("x://{id}", {
id: "7",
stale: "a".repeat(1_000_001),
}).uri,
).toBe("x://7");
});
it("scopes valueLengthError to the names it is given", () => {
const values = { id: "7", stale: "a".repeat(1_000_001) };
expect(valueLengthError(values, ["id"])).toBeNull();
expect(valueLengthError(values, ["id", "stale"])).not.toBeNull();
// Unscoped, every entry is examined -- the behavior the preview and the
// read both moved off.
expect(valueLengthError(values)).not.toBeNull();
});
it("names the offending variable", () => {
expect(
tryExpandUriTemplate("x://{a}/{b}", { a: "1", b: "b".repeat(1_000_001) })
.error,
).toMatch(/"b"/);
});
});
describe("templateError", () => {
it.each([
["a stray closing brace", "x://{a}}"],
["an unclosed brace", "x://{oops"],
["an invalid varspec", "x://{a,}"],
["an out-of-grammar modifier", "x://{id:abc}"],
])("reports %s", (_label, template) => {
expect(templateError(template)).not.toBeNull();
});
it.each(["x://{a}", "x://a{?b,c}", "x://a{;d:3}", "café/{var}"])(
"passes the valid template %s",
(template) => {
expect(templateError(template)).toBeNull();
},
);
it("gives strict expansion its verdict, so the two cannot disagree", () => {
// Every template the error reports must also refuse to expand.
for (const template of ["x://{a}}", "x://{oops", "x://{a,}"]) {
expect(templateError(template)).not.toBeNull();
expect(() => expandUriTemplateStrict(template, { a: "1" })).toThrow();
}
});
});
describe("valueLengthError", () => {
it("names the first oversized value", () => {
expect(valueLengthError({ a: "1", b: "b".repeat(1_000_001) })).toMatch(
/"b" exceeds the 1000000-character limit/,
);
});
it("passes values at or under the limit", () => {
expect(valueLengthError({ a: "a".repeat(1_000_000) })).toBeNull();
});
});
describe("tryExpandUriTemplate", () => {
it("returns the URI and no error when the template expands", () => {
expect(
tryExpandUriTemplate("foobar://events/{topic}", { topic: "foo/bar" }),
).toEqual({ uri: "foobar://events/foo%2Fbar" });
});
it("returns the reason instead of a URI for a malformed template", () => {
const result = tryExpandUriTemplate("x://{id:abc}", { id: "7" });
expect(result.uri).toBeUndefined();
expect(result.error).toMatch(/Invalid RFC 6570 varspec/);
});
it("reports a value that cannot be encoded rather than throwing", () => {
// A lone surrogate has no UTF-8 encoding, so encodeURIComponent throws
// URIError on it -- and a text input can hold one via paste.
const result = tryExpandUriTemplate("x://{v}", { v: "\ud800" });
expect(result.uri).toBeUndefined();
expect(result.error).toBeTruthy();
});
});

View file

@ -0,0 +1,216 @@
import { describe, it, expect, afterEach } from "vitest";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
import {
definedValues,
expandUriTemplate,
tryExpandUriTemplate,
} from "@inspector/core/mcp/uriTemplate.js";
import {
createTestServerHttp,
type TestServerHttp,
createTestServerInfo,
loadConfig,
resolveConfig,
} from "@modelcontextprotocol/inspector-test-server";
/**
* Live coverage of `test-servers/configs/rfc6570-templates-http.json` — the
* documented manual reproduction for #1919.
*
* The unit tests assert what `expandUriTemplate` *produces*. They cannot assert
* that what it produces is what a spec-compliant server *accepts*, and that
* second half is the entire bug: the old string substitution emitted a URI the
* Inspector was perfectly happy with and the server rejected. So this drives
* both directions over a real transport — the encoded URI must resolve, and the
* unencoded one the old code produced must still be refused, so a regression
* cannot pass by quietly loosening the server.
*
* The server is built by **resolving the checked-in config** rather than by
* calling the fixture factories, which is the difference between covering the
* wiring and merely asserting the factory: a misspelt preset name in
* `preset-registry.ts`, or a config naming a preset that no longer exists,
* fails here instead of only when someone runs the repro by hand.
*/
describe("RFC 6570 resource templates over the wire (#1919)", () => {
let client: InspectorClient | null = null;
let server: TestServerHttp | null = null;
const configPath = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../../../../../test-servers/configs/rfc6570-templates-http.json",
);
afterEach(async () => {
if (client) {
try {
await client.disconnect();
} catch {
// ignore
}
client = null;
}
if (server) {
try {
await server.stop();
} catch {
// ignore
}
server = null;
}
});
/**
* Boot the showcase config. The harness picks the port rather than using the
* config's fixed one, so this cannot collide with a showcase server someone
* is running by hand.
*/
async function connectToShowcase(): Promise<InspectorClient> {
const resolved = resolveConfig(loadConfig(configPath));
const started = createTestServerHttp({
serverInfo: createTestServerInfo("rfc6570-templates-test", "1.0.0"),
tools: resolved.tools,
resources: resolved.resources,
resourceTemplates: resolved.resourceTemplates,
});
await started.start();
server = started;
const connected = new InspectorClient(
{ type: "streamable-http", url: started.url },
{ environment: { transport: createTransportNode } },
);
await connected.connect();
client = connected;
return connected;
}
it("advertises every template from the checked-in config", async () => {
const connected = await connectToShowcase();
const { resourceTemplates } = await connected.listAllResourceTemplates();
expect(resourceTemplates.map((entry) => entry.uriTemplate).sort()).toEqual([
// The malformed one is advertised precisely so a client has to decide
// what to do with it; the SDK's own constructor accepts it, so nothing
// upstream of the Inspector rejects it. See the refusal test below.
"foobar://events/{topic:abc}",
"foobar://events/{topic}",
"foobar://events{?topic}",
]);
});
it("refuses to expand the malformed template rather than guessing", async () => {
// `abc` is not RFC 6570's `max-length` production, so there is no URI to
// send. Guessing `{topic}` would read something the server never
// advertised -- and this server would answer it, which is exactly why the
// check has to happen client-side.
const result = tryExpandUriTemplate("foobar://events/{topic:abc}", {
topic: "news",
});
expect(result.uri).toBeUndefined();
expect(result.error).toMatch(/Invalid RFC 6570 varspec/);
});
it("resolves the encoded URI the simple expression expands to", async () => {
const connected = await connectToShowcase();
const uri = expandUriTemplate("foobar://events/{topic}", {
topic: "foo/bar",
});
expect(uri).toBe("foobar://events/foo%2Fbar");
const { result } = await connected.readResource(uri);
expect(result.contents[0]?.uri).toBe(uri);
});
it("rejects the unencoded URI the old string substitution produced", async () => {
const connected = await connectToShowcase();
// This is what the pre-fix client sent, and why #1919 was filed: the raw
// `/` creates a second path segment that the template cannot match.
await expect(
connected.readResource("foobar://events/foo/bar"),
).rejects.toThrow(/not found/i);
});
it("resolves the encoded URI the query expression expands to", async () => {
const connected = await connectToShowcase();
const uri = expandUriTemplate("foobar://events{?topic}", {
topic: "foo/bar",
});
expect(uri).toBe("foobar://events?topic=foo%2Fbar");
const { result } = await connected.readResource(uri);
expect(result.contents[0]?.uri).toBe(uri);
});
// The tests above expand with the helper and then call `readResource`, which
// leaves `InspectorClient.readResourceFromTemplate` -- the API the TUI drives
// and the one this PR rewired -- unproven: they pass whether or not that
// method still uses the SDK's expander. These drive it end to end with a
// value that the two expanders disagree about, so reverting the wiring fails
// here rather than silently.
it("expands and reads a simple expression through readResourceFromTemplate", async () => {
const connected = await connectToShowcase();
const invocation = await connected.readResourceFromTemplate(
"foobar://events/{topic}",
{ topic: "foo/bar" },
);
expect(invocation.expandedUri).toBe("foobar://events/foo%2Fbar");
expect(invocation.result.contents[0]?.uri).toBe(invocation.expandedUri);
});
it("encodes a sub-delim the SDK's expander leaves bare", async () => {
const connected = await connectToShowcase();
// Verified by reverting the wiring: `encodeURIComponent` does encode `/`,
// so `foo/bar` alone does NOT distinguish the two expanders -- the SDK
// handles that case correctly and #1919 was the web panel's own string
// substitution. `!` is where they differ: it is a sub-delim, which RFC 6570
// requires encoded and `encodeURIComponent` leaves alone, so the SDK
// expander returns `foobar://events/a!b` and fails this assertion.
const invocation = await connected.readResourceFromTemplate(
"foobar://events/{topic}",
{ topic: "a!b" },
);
expect(invocation.expandedUri).toBe("foobar://events/a%21b");
expect(invocation.result.contents[0]?.uri).toBe(invocation.expandedUri);
});
it("expands and reads a query expression through readResourceFromTemplate", async () => {
const connected = await connectToShowcase();
const invocation = await connected.readResourceFromTemplate(
"foobar://events{?topic}",
{ topic: "foo/bar" },
);
expect(invocation.expandedUri).toBe("foobar://events?topic=foo%2Fbar");
expect(invocation.result.contents[0]?.uri).toBe(invocation.expandedUri);
});
it("refuses the malformed template through readResourceFromTemplate", async () => {
const connected = await connectToShowcase();
await expect(
connected.readResourceFromTemplate("foobar://events/{topic:abc}", {
topic: "news",
}),
).rejects.toThrow(/Invalid RFC 6570 varspec/);
});
it("resolves the base URI a blank query expression expands to", async () => {
const connected = await connectToShowcase();
// RFC 6570 drops the whole expression when the variable is *undefined*, so
// an unfilled `topic` legitimately requests the unfiltered collection. The
// form is what turns its untouched blank into "undefined" (`definedValues`)
// — a key present with "" is a defined value and would expand to
// `?topic=`, which is a different (and here, unregistered) resource. The
// SDK's matcher compiles `{?topic}` to a *required* `\?topic=([^&]+)`, so a
// template alone cannot serve this — the config registers the plain
// resource, which is what a real server would do.
const uri = expandUriTemplate(
"foobar://events{?topic}",
definedValues({ topic: "" }),
);
expect(uri).toBe("foobar://events");
const { result } = await connected.readResource(uri);
expect(result.contents[0]?.uri).toBe(uri);
});
});

View file

@ -0,0 +1,160 @@
import { describe, it, expect } from "vitest";
import { previewUriTemplate } from "./uriTemplate";
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("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=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("expands each query expression independently, matching the submit", () => {
// Not `?one=1&two=2` -- see the expander's tests: the SDK's own matcher
// rejects that for this template. The preview must show what will be sent.
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");
});
});
describe("previewUriTemplate - multi-name expressions", () => {
it("applies the same correction the real expansion does", () => {
// Must match expandUriTemplate("x://{a,b}", ...) exactly, or the preview
// would promise a URI that submitting does not send.
expect(previewUriTemplate("x://{a,b}", { a: "foo/bar", b: "q" })).toBe(
"x://foo%2Fbar,q",
);
});
});
describe("preview with Object.prototype-colliding names", () => {
it("leaves a blank {?toString} standing rather than treating it as filled", () => {
// A bare `defined[name] !== undefined` finds Object.prototype.toString and
// would expand the expression instead of showing the placeholder.
expect(previewUriTemplate("x://a{?toString}", { toString: "" })).toBe(
"x://a{?toString}",
);
});
it("expands it once it really has a value", () => {
expect(previewUriTemplate("x://a{?toString}", { toString: "v" })).toBe(
"x://a?toString=v",
);
});
});
describe("previewUriTemplate - literals", () => {
// The preview promises the URI that submitting would send, so it applies the
// same RFC 6570 3.1 literal encoding the wire does.
it("percent-encodes a non-ASCII literal", () => {
expect(previewUriTemplate("café/{var}", { var: "value" })).toBe(
"caf%C3%A9/value",
);
});
it("encodes the literal even while an expression is still unfilled", () => {
expect(previewUriTemplate("café/{var}", {})).toBe("caf%C3%A9/{var}");
});
it("shows a template the read would refuse exactly as the server wrote it", () => {
// Not even the literal is encoded: an unreadable template is displayed
// verbatim rather than half-normalized into something never published.
expect(previewUriTemplate("x://café/{oops", {})).toBe("x://café/{oops");
});
});
describe("previewUriTemplate - it must never throw or over-promise", () => {
// Both of these were free when the preview expanded a rewritten template
// through the lenient `expandUriTemplate`; assembling part by part has to
// reinstate them explicitly.
it("falls back to the raw template when a value cannot be encoded", () => {
// An unpaired surrogate has no UTF-8 encoding, so `encodeURIComponent`
// raises URIError -- and this runs during render, where an escaping throw
// unmounts the panel rather than disabling its button. A paste delivers it.
expect(previewUriTemplate("x://{v}", { v: "\ud800" })).toBe("x://{v}");
});
it("leaves an invalid expression standing rather than expanding it", () => {
// `{a,}` keeps `a` in its varspecs, so expanding it would preview a URI
// for a template whose submission is refused outright.
expect(previewUriTemplate("x://{a,}", { a: "1" })).toBe("x://{a,}");
expect(previewUriTemplate("x://{id:abc}", { "id:abc": "1" })).toBe(
"x://{id:abc}",
);
});
it.each([
["an invalid varspec", "x://{a,}/{b}"],
["a stray closing brace", "x://{a}}/{b}"],
])(
"does not expand the valid expressions around %s either",
(_label, template) => {
// The refusal is per TEMPLATE, not per expression: every read of these
// is refused, so previewing `x://{a,}/2` or `x://1}/2` would promise a
// URI this form can never send. An earlier revision did exactly that.
expect(previewUriTemplate(template, { a: "1", b: "2" })).toBe(template);
},
);
});
describe("previewUriTemplate - the value ceiling", () => {
it("falls back before encoding an oversized value", () => {
// Without this the guard was half a guard: the read was refused while the
// preview still encoded the same value on every keystroke, so a large
// paste froze the UI anyway.
expect(previewUriTemplate("x://{v}", { v: "a".repeat(1_000_001) })).toBe(
"x://{v}",
);
});
it("ignores an oversized value the template never references", () => {
expect(
previewUriTemplate("x://{id}", {
id: "7",
stale: "a".repeat(1_000_001),
}),
).toBe("x://7");
});
it("still previews a value at the limit", () => {
const value = "a".repeat(1_000_000);
expect(previewUriTemplate("x://{v}", { v: value })).toBe(`x://${value}`);
});
});

View file

@ -0,0 +1,115 @@
/**
* The web Resources form's view of an RFC 6570 URI template (#1919).
*
* Parsing, variable classification, and expansion live in
* `@inspector/core/mcp/uriTemplate.js` so the web form and the TUI cannot
* disagree about what a template means -- they are re-exported here so the
* panel has a single import. (The CLI is not a consumer: it has no template
* form, and its `resources/read` passes an already-expanded `--uri` straight to
* `readResource`.) What this module adds is the one piece that is purely a
* display concern: the partially-expanded preview string.
*/
import {
declaredNames,
definedValues,
encodeLiteral,
expandTemplateExpression,
parseUriTemplate,
templateError,
valueLengthError,
} from "@inspector/core/mcp/uriTemplate.js";
export {
definedValues,
encodeLiteral,
expandUriTemplate,
hasRequiredValues,
parseUriTemplate,
requiredGroups,
templateError,
templateVariables,
tryExpandUriTemplate,
unmetRequiredGroups,
valueLengthError,
} from "@inspector/core/mcp/uriTemplate.js";
export type {
TemplateExpansion,
TemplatePart,
TemplateVariable,
VarSpec,
} from "@inspector/core/mcp/uriTemplate.js";
/**
* A partially-expanded template for display: an expression with at least one
* value is expanded exactly as the wire would render it, and the rest are left
* standing as written so the user can see what is still needed.
*
* Assembled part by part rather than by rewriting the template and re-parsing
* it. The rewrite approach needed a placeholder to stand in for each unexpanded
* expression, and a placeholder is exactly what cannot survive the round trip
* now that literals are pct-encoded on expansion (RFC 6570 §3.1) -- whatever
* token stood in for `{?topic}` would come back encoded and no longer match.
* Going part by part removes the token, and with it the question of what text
* could never collide with a server-supplied template.
*
* Each half still comes from the shared expander -- `encodeLiteral` for literal
* runs, `expandTemplateExpression` for filled ones -- so the preview cannot
* promise a URI that submitting would not send.
*
* Two guards keep that promise, both of them things the previous
* expand-the-whole-template implementation got for free from the lenient
* `expandUriTemplate` it called:
*
* - **A template the read would refuse is shown verbatim.** `{a,}` still
* parses `a` into its varspecs, and `x://{a}}/{b}` parses two perfectly good
* expressions around a stray `}`, so expanding either previewed a URI whose
* submission is refused outright. `templateError` is the read's own verdict,
* so consulting it first is what keeps the two from disagreeing -- and it
* subsumes the narrower "skip the invalid part" rule this had at first,
* which left the *rest* of such a template expanding.
* - **The whole thing is wrapped.** This runs during render, where a throw
* unmounts the panel instead of disabling its button, and encoding can throw
* for real input: `encodeURIComponent` raises `URIError` on an unpaired
* surrogate, which a paste can deliver. The panel already reports that case
* through `tryExpandUriTemplate`; here it degrades to the raw template.
*/
export function previewUriTemplate(
uriTemplate: string,
values: Record<string, string>,
): string {
const defined = definedValues(values);
// The same verdict the read is gated on. Without it this expanded expression
// by expression regardless: `x://{a}}/{b}` with both fields filled previewed
// `x://1}/2`, a URI whose stray `}` makes every read refuse -- the preview
// promising something the form it sits in can never send.
if (templateError(uriTemplate) !== null) return uriTemplate;
const parts = parseUriTemplate(uriTemplate);
// The same ceiling `tryExpandUriTemplate` enforces for the read, scoped the
// same way -- to the names this template references. Without it the guard was
// half a guard: submission was refused while this path still handed the
// oversized value to the encoders on every keystroke, so a large paste froze
// the UI anyway. Checked before any encoding happens.
if (valueLengthError(defined, declaredNames(parts)) !== null) {
return uriTemplate;
}
try {
return parts
.map((part) => {
if (part.kind === "literal") return encodeLiteral(part.text);
// `Object.hasOwn`, not a bare lookup: `toString` and `constructor` are
// valid RFC 6570 variable names, and a plain lookup would find
// `Object.prototype`'s member and treat a blank field as filled.
return part.names.some((name) => Object.hasOwn(defined, name))
? expandTemplateExpression(part, defined)
: part.source;
})
.join("");
} catch {
return uriTemplate;
}
}

View file

@ -190,7 +190,7 @@ import {
convertToolParameters,
convertPromptArguments,
} from "../json/jsonUtils.js";
import { UriTemplate } from "@modelcontextprotocol/client";
import { expandUriTemplateStrict } from "./uriTemplate.js";
import {
InspectorClientEventTarget,
type TaskWithOptionalCreatedAt,
@ -4982,11 +4982,21 @@ export class InspectorClient extends InspectorClientEventTarget {
const uriTemplateString = uriTemplate;
// Expand the template's uriTemplate using the provided params
// Expand through the shared helper in ./uriTemplate.js so this and the web
// Resources form cannot disagree. It covers the expression shapes the SDK's
// own expander gets wrong -- `{a,b}`, `{;id}`, and the `{id:3}` prefix
// modifier (#1919).
//
// `params` is passed through AS GIVEN. Only an *absent* key omits its
// expression; a key present with `""` is a defined RFC 6570 value and
// expands (`{?q}` -> `?q=`, `{;q}` -> `;q`), which is what lets a caller
// request those URIs deliberately. A caller whose values come from a form
// -- where an untouched field is indistinguishable from a deliberately
// empty one -- drops its blanks with `definedValues` first, as the TUI's
// ResourceTestModal does.
let expandedUri: string;
try {
const uriTemplate = new UriTemplate(uriTemplateString);
expandedUri = uriTemplate.expand(params);
expandedUri = expandUriTemplateStrict(uriTemplateString, params);
} catch (error) {
throw new Error(
`Failed to expand URI template "${uriTemplate}": ${error instanceof Error ? error.message : String(error)}`,

893
core/mcp/uriTemplate.ts Normal file
View file

@ -0,0 +1,893 @@
/**
* RFC 6570 URI Template parsing and expansion, shared by the web and TUI
* clients (#1919).
*
* This lives in `core/` rather than in a client so the web Resources form and
* the TUI cannot disagree about what a template means. Both derive their form
* fields from {@link templateVariables} and expand through
* {@link expandUriTemplate} — the web panel directly, the TUI via
* `InspectorClient.readResourceFromTemplate`.
*
* The **CLI is deliberately not a consumer**: it has no template form, and its
* `resources/read` passes the already-expanded `--uri` straight to
* `readResource` (see `clients/cli/src/handlers/run-method.ts`). Nothing here
* runs for it.
*
* ## Why this is not simply `new UriTemplate(t).expand(v)`
*
* The SDK's `UriTemplate` is still used, but only to *validate* a template —
* constructing it is what rejects an unclosed expression. Its expander is not,
* because it is incomplete in ways a form makes visible: a form has to *name*
* the variables it asks the user to fill in, so a parser that mangles a name
* produces a field nobody can use. Each of these was measured against the
* pinned SDK, not inferred:
*
* | Shape | SDK behavior |
* | ---------------- | --------------------------------------------------------- |
* | `{a,b}` | raw-joins the values — no encoding, operator prefix dropped |
* | `{;id}` | `;` is not in its operator list, so the variable is `;id` |
* | `{id:3}` | the prefix modifier is folded into the name, giving `id:3` |
* | `{+v}` / `{#v}` | `encodeURI` mangles reserved `[`/`]` and double-encodes `%` |
* | `{v}` | `encodeURIComponent` leaves the sub-delims `!'()*` bare |
*
* An earlier revision delegated the shapes the SDK got right and took over only
* the rest. That split is gone: once the encoders themselves differed, the two
* paths would have encoded the *same value* differently depending on whether
* the expression happened to carry a modifier. One expander, one set of rules.
*/
import { UriTemplate } from "@modelcontextprotocol/client";
/**
* The RFC 6570 operators.
*
* `;` is here but **not** in the SDK's own list, which is why `{;id}` parses
* there as a variable literally named `;id`. Order matters only in that each is
* a distinct single character; the first match wins.
*/
const OPERATORS = ["+", "#", ".", "/", ";", "?", "&"] as const;
/**
* Operators whose expansion omits cleanly when the variables in it are
* undefined — the whole expression, separator and all, simply disappears and
* what is left is still a well-formed URI naming a real (broader) resource.
*
* The remaining operators — `""` (simple) and `+` (reserved) — interpolate the
* value into the *middle* of the URI, so omitting one leaves an empty path
* segment rather than a shorter URI: measured against the pinned SDK,
* `file:///users/{userId}/profile` expands to `file:///users//profile` and
* `x://a/{+path}` to `x://a/`, both of which name a different resource. Those
* are the ones a form must require before allowing a read.
*
* `#` is in the omittable set on the same measurement: `x://a{#frag}` with no
* `frag` expands to exactly `x://a`. A fragment is optional by construction.
*/
const OMITTABLE_OPERATORS = new Set(["#", ".", "/", ";", "?", "&"]);
/** Operators that expand to `name=value` pairs rather than bare values. */
const NAMED_OPERATORS = new Set([";", "?", "&"]);
/** A single variable reference inside an expression, e.g. `id` or `id:3`. */
export interface VarSpec {
name: string;
/**
* Absent (i.e. conforming) unless the name needed the `-`/`~` tolerance to
* parse — see {@link TOLERATED_VARCHAR}. Present and `false` marks a name RFC
* 6570's own conformance suite would reject, which the Inspector expands
* anyway because real servers publish such names and the SDK's matcher
* accepts them.
*/
conforming?: false;
/**
* The RFC 6570 prefix modifier (`{id:3}`), a maximum length in *characters*.
* Applied to the value before percent-encoding, per §3.2.1.
*/
maxLength?: number;
}
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;
/** The variable references, in order. */
varspecs: VarSpec[];
/** Bare variable names, `*` and any `:length` modifier stripped. */
names: string[];
/**
* True when a varspec is not one RFC 6570 admits — an out-of-grammar modifier
* (`{id:abc}`) or an empty member (`{}`, `{a,}`); see {@link parseVarSpec}.
* Strict expansion rejects such a template and the form withholds the read,
* while *discovery* stays lenient so the panel still renders whatever names
* the template does declare rather than going blank.
*/
invalid: boolean;
}
export type TemplatePart = TemplateLiteral | TemplateExpression;
export interface TemplateVariable {
name: string;
/** The operator of the expression the variable was first seen in. */
operator: string;
/**
* `false` when the name is outside RFC 6570's `varchar` and only parsed via
* the documented `-`/`~` tolerance (see {@link TOLERATED_VARCHAR}). A caller
* wanting RFC-exact behavior refuses on this rather than re-deriving the
* grammar; the Inspector itself expands such a template.
*/
conforming: boolean;
/**
* True when this variable appears in at least one expression that cannot be
* omitted without changing the URI's structure — see
* {@link OMITTABLE_OPERATORS}. Drives the form's "Optional" marker.
*
* It does **not** mean "the user must fill this field in". Requiredness 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. Gate submission on {@link hasRequiredValues} over
* {@link requiredGroups}, never by testing this flag per variable.
*/
required: boolean;
}
/**
* RFC 6570's `varchar`, exactly: `ALPHA / DIGIT / "_" / pct-encoded`.
*
* This is the *conformance* production, kept unwidened so the grammar in the
* code says what the RFC says. What the parser goes on to **accept** is one
* tier wider — see {@link TOLERATED_VARCHAR}.
*/
const VARCHAR = String.raw`(?:[A-Za-z0-9_]|%[0-9A-Fa-f]{2})`;
/**
* The two characters accepted in a name beyond `varchar`, as an explicitly
* separate tier rather than a widened grammar.
*
* `-` and `~` are RFC 3986 *unreserved*, so they satisfy the property that
* actually matters at expansion time: a name is the one thing emitted into the
* URI **without** encoding (`;`, `?` and `&` write `name=value`), and these two
* need none. RFC 6570's own conformance suite rejects `{~thing}` and
* `{default-graph-uri}`, so a template using them is non-conforming — but real
* servers publish hyphenated names, and the SDK's matcher round-trips them
* (`match()` compiles the hyphen literally). Refusing to read a resource that
* demonstrably works is a worse failure for a debugging tool than expanding a
* name one character outside the production.
*
* So the tolerance is *labelled* rather than hidden: a varspec matching only
* this tier is parsed with `conforming: false`, surfaced on
* {@link TemplateVariable}, and a caller that wants RFC-exact behavior can
* refuse on that flag without re-implementing the grammar.
*/
const TOLERATED_VARCHAR = String.raw`[-~]`;
/** `varname = varchar *( ["."] varchar )` — a dot separates, never leads or doubles. */
function varnameGrammar(varchar: string): string {
return `${varchar}(?:\\.?${varchar})*`;
}
/**
* RFC 6570's `varspec`: a `varname` with at most one trailing modifier, either
* the explode `*` or a `:max-length`, never both.
*
* `max-length` is `%x31-39 0*3DIGIT` — 1 to 9999, no leading zero — so `{id:}`,
* `{id:0}`, `{id:abc}` and `{id:10000}` are invalid *templates*, not templates
* with an ignorable modifier.
*
* Anchoring the whole varspec is what rejects the shapes a looser "strip the
* `*`, split on `:`" pass waves through. Measured before it landed, each
* expanded rather than being refused:
*
* | Varspec | Was | Why it is invalid |
* | --------- | -------------------- | ----------------------------------- |
* | `{*id}` | name `id` | explode is a *trailing* modifier |
* | `{id*:3}` | name `id`, truncated | the two modifiers are exclusive |
* | `{ id }` | name `id` | whitespace is not a `varchar` |
* | `{a b}` | name `a b` | ditto — and it emits into the URI |
*/
const MODIFIER = String.raw`(?:\*|:([1-9][0-9]{0,3}))?`;
const CONFORMING_VARSPEC = new RegExp(
`^(${varnameGrammar(VARCHAR)})${MODIFIER}$`,
);
const TOLERATED_VARSPEC = new RegExp(
`^(${varnameGrammar(`(?:${VARCHAR}|${TOLERATED_VARCHAR})`)})${MODIFIER}$`,
);
function parseVarSpec(raw: string): VarSpec | "invalid" {
const conforming = CONFORMING_VARSPEC.exec(raw);
const match = conforming ?? TOLERATED_VARSPEC.exec(raw);
if (match === null) return "invalid";
const [, name, maxLength] = match;
return {
name,
...(maxLength === undefined ? {} : { maxLength: +maxLength }),
...(conforming === null ? { conforming: false } : {}),
};
}
/**
* Splits a template into literal runs and expressions.
*
* 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 at the user.
*/
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 parsed = body.slice(operator.length).split(",").map(parseVarSpec);
const varspecs = parsed.filter(
(spec): spec is VarSpec => spec !== "invalid",
);
parts.push({
kind: "expression",
source: uriTemplate.slice(i, end + 1),
operator,
varspecs,
names: varspecs.map((spec) => spec.name),
invalid: parsed.includes("invalid"),
});
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);
// Iterating the varspecs (rather than the names, re-scanning the varspecs
// for each) keeps discovery linear. A template is server-controlled and the
// SDK admits one up to 1 MB, so a single expression can carry thousands of
// short comma-separated names -- and this runs on the render thread.
for (const spec of part.varspecs) {
const { name } = spec;
const conforming = spec.conforming !== false;
const existing = byName.get(name);
if (existing) {
existing.required = existing.required || required;
existing.conforming = existing.conforming && conforming;
} else {
byName.set(name, {
name,
operator: part.operator,
required,
conforming,
});
}
}
}
return [...byName.values()];
}
/**
* The variable names of each expression that cannot be omitted, in template
* order — one entry per expression, not per variable.
*
* This is deliberately *not* folded onto {@link TemplateVariable}. A name can
* appear in several expressions with different operators, and each required
* expression has to be satisfied on its own: in `x{?a}{?b}{a,b}` the only
* required expression is `{a,b}`, which either `a` or `b` satisfies (the SDK
* expands that template with just `a` to `x?a=11`), while a per-variable model
* that kept only the first occurrence's group would mark both names required
* with singleton groups and refuse it. And in `{a,b}{a,c}` — two required
* expressions sharing `a` — filling `b` and `c` satisfies both, which no
* per-variable flag can express at all.
*/
export function requiredGroups(uriTemplate: string): string[][] {
const groups: string[][] = [];
for (const part of parseUriTemplate(uriTemplate)) {
if (part.kind !== "expression") continue;
if (OMITTABLE_OPERATORS.has(part.operator)) continue;
// An expression naming no variable at all (`x://{}`) is a malformed
// template, which {@link tryExpandUriTemplate} refuses with that as the
// reason. Emitting an empty group here too would gate the form a second
// time on a condition nothing can satisfy, and the "any one of" message
// built from it would name no fields.
if (part.names.length === 0) continue;
// Deduplicated within the expression: `{a,a}` is one requirement named
// twice, not two names either of which would do. Left as-is it read as a
// shared group everywhere downstream -- the TUI's `length === 1` test
// marked `a` optional while its submit guard still refused a blank, and the
// web panel offered the useless hint "Any one of: a, a".
groups.push([...new Set(part.names)]);
}
return groups;
}
/**
* The required expressions `values` does **not** satisfy — none of their names
* filled in — in template order.
*
* Exported so a caller that has to *name* the missing fields derives them from
* the same pass that decides whether anything is missing at all. The TUI built
* its "Missing required template variable(s): …" list with its own bare
* `values[name]` filter, which for a variable legitimately named `constructor`
* or `toString` found `Object.prototype`'s member and judged the group
* satisfied — so {@link hasRequiredValues} blocked the submit while the message
* listed nothing.
*/
export function unmetRequiredGroups(
groups: string[][],
values: Record<string, string>,
): string[][] {
return groups.filter(
(names) =>
!names.some((name) => (readValue(values, name) ?? "").length > 0),
);
}
/**
* Whether `values` supplies everything expansion structurally needs: every
* required expression has at least one of its names filled in.
*/
export function hasRequiredValues(
groups: string[][],
values: Record<string, string>,
): boolean {
return unmetRequiredGroups(groups, values).length === 0;
}
/**
* Reads a variable, ignoring anything inherited from `Object.prototype`.
*
* `toString`, `constructor`, `valueOf` and `__proto__` are all valid RFC 6570
* variable names (`varname` allows ALPHA / DIGIT / `_` / pct-encoded), and a
* plain object lookup finds the prototype's member for every one of them. A
* bare `values[name] !== undefined` therefore reports a *blank* `{?toString}`
* as supplied and expands a function body into the URI; `hasRequiredValues`
* likewise saw `constructor` as satisfied because `Object.length` is 1.
*/
function readValue(
values: Record<string, string>,
name: string,
): string | undefined {
return Object.hasOwn(values, name) ? values[name] : undefined;
}
/**
* Drops empty entries so an untouched optional field reads as *undefined*
* (the expression disappears) rather than as the empty string (which would
* expand to a valueless `?topic=`).
*
* **A UI-boundary concern, deliberately not part of the expander.** RFC 6570
* distinguishes an undefined variable (the expression is omitted) from one
* defined as `""` (it expands — `x{?q}` gives `x?q=`, `x{;q}` gives `x;q`), and
* collapsing the two inside {@link expandUriTemplateStrict} made those URIs
* unrequestable through any caller, `readResourceFromTemplate` included.
*
* What forces the collapse is a *form*: both clients seed every declared
* variable with `""`, and a text input cannot express "defined but empty", so
* an untouched field is indistinguishable from a deliberately empty one. That
* is a property of the form, not of the template — so each form applies this
* on its way in, and the expander honors exactly what it is handed.
*/
export function definedValues(
values: Record<string, string>,
): Record<string, string> {
return Object.fromEntries(
Object.entries(values).filter(([, value]) => value.length > 0),
);
}
/**
* The characters RFC 6570 leaves alone under the `+` and `#` operators:
* RFC 3986 *unreserved* plus *reserved* (gen-delims and sub-delims).
*/
const ALLOW_RESERVED = /[^A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=]/gu;
/**
* The `allow-reserved` value encoding of RFC 6570 §3.2.1, used by `+` and `#`.
*
* The SDK reaches for `encodeURI` here, which is close but wrong twice over,
* and both cases corrupt the URI rather than merely over-escaping it:
*
* - It escapes `[` and `]`, which are *reserved* and must survive — so an IPv6
* literal `[::1]` becomes `%5B::1%5D`.
* - It escapes `%`, so an already-encoded value is double-encoded: `%2F`
* becomes `%252F`, and the server sees a literal "%2F" rather than a slash.
*
* The spec instead keeps existing pct-triplets intact, which is what the split
* below does: odd chunks are whole `%XX` triplets and pass through untouched,
* even chunks are scanned for anything outside the allowed set. A lone `%` is
* not a triplet, so it lands in an even chunk and is correctly encoded to
* `%25`. The `u` flag makes the class match by code point, so an astral
* character is handed to `encodeURIComponent` whole rather than as surrogates.
*/
/**
* Percent-encodes a template's **literal** text (RFC 6570 §3.1).
*
* A literal may legally contain non-ASCII (`ucschar`), but expansion must emit
* it pct-encoded: the conformance case `café/{var}` expands to
* `caf%C3%A9/value`. Literals used to be copied through verbatim, so such a
* template went out with raw UTF-8 in the path — and since this module replaced
* the SDK's expander for both clients, nothing else was going to encode it. (The
* pinned SDK does not either; measured, it also returns `café/value`.)
*
* The rule is the same one {@link encodeAllowReserved} implements — keep
* unreserved, keep reserved (a literal's `/`, `?` and `#` are *structure*, not
* data), keep an existing pct-triplet, encode everything else — with one
* exception: a brace passes through.
*
* A brace is not a legal literal character, so it reaches this function only
* from a **malformed** template, where {@link parseUriTemplate} treats an
* unclosed `{` as trailing text. That case degrades to "show the template as
* the server wrote it", and encoding the brace to `%7B` would make the
* degradation unreadable — a string resembling neither the template nor
* anything the server could match.
*/
export function encodeLiteral(text: string): string {
return text
.split(/([{}])/g)
.map((chunk, index) =>
index % 2 === 1 ? chunk : encodeAllowReserved(chunk),
)
.join("");
}
function encodeAllowReserved(value: string): string {
return value
.split(/(%[0-9A-Fa-f]{2})/g)
.map((chunk, index) =>
index % 2 === 1
? chunk
: chunk.replace(ALLOW_RESERVED, (char) => encodeURIComponent(char)),
)
.join("");
}
/**
* Percent-encodes everything outside RFC 3986's *unreserved* set, which is what
* every operator except `+` and `#` calls for.
*
* `encodeURIComponent` alone is not that set: it leaves `!`, `'`, `(`, `)` and
* `*` unescaped. Those are sub-delims, not unreserved, so RFC 6570 requires
* them encoded for simple, label, path, matrix and query expansion. They are
* substituted afterwards rather than hand-rolled, so `encodeURIComponent` still
* does the UTF-8 work for everything else.
*/
const FORCE_ENCODED: Record<string, string> = {
"!": "%21",
"'": "%27",
"(": "%28",
")": "%29",
"*": "%2A",
};
function encodeUnreserved(value: string): string {
return encodeURIComponent(value).replace(
/[!'()*]/g,
(char) => FORCE_ENCODED[char],
);
}
/**
* The two operators whose expansion preserves RFC 3986 *reserved* characters
* and existing pct-triplets rather than encoding them (RFC 6570 §3.2.3, §3.2.4).
*/
function allowsReserved(operator: string): boolean {
return operator === "+" || operator === "#";
}
/** Encodes one value for its operator: reserved characters survive `+` and `#`. */
function encodeValue(value: string, operator: string): string {
return allowsReserved(operator)
? encodeAllowReserved(value)
: encodeUnreserved(value);
}
/**
* One unit a prefix modifier counts: a code point, a pct-encoded octet, or a
* whole pct-encoded UTF-8 *sequence*.
*
* The sequence alternatives are what stop a multi-octet character being cut in
* half. `%C3%A9` is one character — `é` — so `{+v:1}` over `%C3%A9x` must keep
* both octets; counting per triplet returned `%C3`, a lone lead byte that
* decodes to nothing. The lead byte announces the length (RFC 3629: `C2-DF`
* two octets, `E0-EF` three, `F0-F4` four) and each continuation is `80-BF`,
* so a well-formed sequence is recognizable without decoding.
*
* Order matters: longest first, so a four-octet sequence is not matched as a
* two-octet one followed by loose triplets.
*
* The lead-byte ranges are RFC 3629's **well-formed** ones, not merely the
* length-announcing prefixes: `C2-DF` (so the overlong `%C0%80` is excluded),
* `E0` only with `A0-BF`, `ED` only with `80-9F` (excluding UTF-16
* surrogates), `F0` only with `90-BF`, `F4` only with `80-8F` (nothing past
* U+10FFFF). An earlier revision accepted the whole `C0-DF`/`E0-EF`/`F0-F4`
* space and so grouped those invalid sequences as one character — the
* opposite of the fallback this comment promised.
*
* A malformed sequence — an excluded lead byte, too few continuations, or a
* stray continuation — now matches no sequence alternative and falls through
* to the single-triplet rule, which is the conservative behavior: it counts as
* more units, so truncation keeps less rather than emitting something that was
* never in the value.
*/
const CONTINUATION = String.raw`%[89AB][0-9A-F]`;
/** RFC 3629 well-formed sequences only — `C2-DF`, and the restricted second
* bytes that exclude overlongs (`E0%A0-BF`, `F0%90-BF`), UTF-16 surrogates
* (`ED%80-9F`) and anything past U+10FFFF (`F4%80-8F`). */
const UTF8_SEQUENCE = [
String.raw`(?:%F0%(?:9[0-9A-F]|[AB][0-9A-F])|%F[1-3]${CONTINUATION}|%F4%8[0-9A-F])${CONTINUATION}${CONTINUATION}`,
String.raw`(?:%E0%[AB][0-9A-F]|%E[1-9ABCEF]${CONTINUATION}|%ED%[89][0-9A-F])${CONTINUATION}`,
String.raw`%(?:C[2-9A-F]|D[0-9A-F])${CONTINUATION}`,
].join("|");
const PREFIX_UNIT = new RegExp(`${UTF8_SEQUENCE}|%[0-9A-F]{2}|[\\s\\S]`, "giu");
/**
* Splits a value into the units a prefix modifier counts.
*
* RFC 6570 §2.4.1 counts *characters*, and is explicit that a pct-encoded
* triplet counts as a single one — so `{+v:5}` over `%61%62%63%64%65%66` stops
* after five whole triplets. Splitting on code points cut the sixth in half,
* and the orphaned `%6` was no longer a triplet, so `encodeAllowReserved`
* correctly escaped its `%`: the value went out as `%61%256`, a byte the caller
* never asked for.
*
* The rule is **operator-independent**, which is a correction of the narrower
* version this shipped first. Grouping only under `+`/`#` made the same input
* mean two different things — `%61` an indivisible character under `+`, three
* loose characters under a simple expression — and produced the matching
* defect one operator over: `{v:1}` over `%61%62` kept just the `%` and emitted
* `%25`, a truncation to a character that was never in the value. What the
* operator decides is how a unit is *encoded* afterwards (a simple expansion
* still escapes the `%`, so the retained `%61` goes out as `%2561`), not what
* counts as one.
*
* The `u` flag makes the final alternative match by code point, so an astral
* character is one unit rather than a surrogate pair that truncation could
* split.
*/
function prefixUnits(value: string): string[] {
return value.match(PREFIX_UNIT) ?? [];
}
/**
* Applies a prefix modifier, then encodes.
*
* Truncation counts the units {@link prefixUnits} defines rather than using
* `String.prototype.slice`, which counts UTF-16 code units and so can cut an
* astral character in half and yield a lone surrogate.
*/
function renderValue(value: string, spec: VarSpec, operator: string): string {
const truncated =
spec.maxLength === undefined
? value
: prefixUnits(value).slice(0, spec.maxLength).join("");
return encodeValue(truncated, operator);
}
/**
* Expands one expression per RFC 6570. Returns "" if no name has a value.
*
* Exported for the web client's URI *preview*, which expands the expressions
* the user has filled while leaving the rest standing as written — it cannot
* go through {@link expandParts}, which expands all or nothing, and routing a
* half-rewritten template string back through the parser would mean smuggling
* a placeholder past {@link encodeLiteral}.
*/
export function expandTemplateExpression(
part: TemplateExpression,
values: Record<string, string>,
): string {
// The value is carried through the filter rather than re-read afterwards, so
// the "is it defined" test and the read cannot disagree — and so nothing
// downstream needs a non-null assertion to convince the compiler.
const present = part.varspecs.flatMap((spec) => {
const value = readValue(values, spec.name);
return value === undefined ? [] : [{ spec, value }];
});
if (present.length === 0) return "";
const { operator } = part;
if (NAMED_OPERATORS.has(operator)) {
const pairs = present.map(({ spec, value }) => {
const rendered = renderValue(value, spec, operator);
// RFC 6570 §3.2.7: the matrix operator drops the `=` for an empty value,
// so `x{;q}` with `q = ""` is `x;q` — while `?`/`&` keep it (`x?q=`).
// Both are only reachable now that the expander honors a defined-but-
// empty value instead of collapsing it into "undefined"; see
// `definedValues` for where that collapse legitimately happens instead.
return operator === ";" && rendered.length === 0
? spec.name
: `${spec.name}=${rendered}`;
});
// `;` repeats its separator per pair; `?`/`&` join with `&`.
return operator === ";"
? `;${pairs.join(";")}`
: `${operator}${pairs.join("&")}`;
}
const rendered = present.map(({ spec, value }) =>
renderValue(value, spec, operator),
);
switch (operator) {
case "#":
return `#${rendered.join(",")}`;
case ".":
return `.${rendered.join(".")}`;
case "/":
return `/${rendered.join("/")}`;
// "" and "+" — a bare comma-joined list, no prefix.
default:
return rendered.join(",");
}
}
/**
* Expands a whole parsed template.
*
* Each expression is expanded **independently**, which is what RFC 6570
* specifies — expansion carries no cross-expression state. The SDK instead
* tracks whether a query expression has already emitted and rewrites a later
* `{?two}`'s leading `?` to `&`. That looks friendlier and is wrong: measured
* against the pinned SDK, `x{?one}{?two}` expands to `x?one=1&two=2`, and
* `UriTemplate.match` on that same template *rejects* it — `match("x?one=1&two=2")`
* is `null`, while `match("x?one=1?two=2")` returns both variables. So the
* rewrite emits a URI the advertised template cannot match, which is precisely
* the failure #1919 is about, one level up.
*
* A server that wants a continuation advertises it: `{?one}{&two}` expands to
* `x?one=1&two=2` and matches. Producing that shape is the server's choice to
* declare, not ours to infer.
*/
export function expandParts(
parts: TemplatePart[],
values: Record<string, string>,
): string {
return parts
.map((part) =>
part.kind === "literal"
? encodeLiteral(part.text)
: expandTemplateExpression(part, values),
)
.join("");
}
/**
* Expands a template against the supplied values per RFC 6570 — percent-encoding
* each value according to its operator, and omitting an expression none of
* whose variables are defined. **Throws** on a template that is not valid.
*
* A key present with an empty string is *defined*, and expands: `x{?q}` gives
* `x?q=` and `x{;q}` gives `x;q`. Only an absent key omits its expression. A
* form that cannot tell "untouched" from "deliberately empty" drops its blanks
* with {@link definedValues} before calling — which is where that judgement
* belongs, since it is a fact about the form rather than about the template.
*
* Every expression is expanded by {@link expandParts}; the SDK's `UriTemplate`
* is constructed only because that is what rejects an unclosed expression, and
* callers such as `readResourceFromTemplate` wrap the error it throws. Its own
* `expand` is deliberately unused — see the module comment for the five shapes
* it gets wrong.
*
* Its constructor is not a complete validator either: it accepts `{id:abc}`,
* whose modifier is not RFC 6570's `max-length` production. Treating that as a
* plain `{id}` would send a URI the server never advertised, so the owned
* parser's verdict is checked too.
*/
export function expandUriTemplateStrict(
uriTemplate: string,
values: Record<string, string>,
): string {
const sdk = sdkTemplateError(uriTemplate);
if (sdk) throw new Error(sdk);
// Parsed ONCE and reused for validation, the name list and the expansion --
// the template is server-controlled and may be up to 1 MB, so re-parsing it
// per step is real work on the render thread.
const parts = parseUriTemplate(uriTemplate);
const invalid = partsError(uriTemplate, parts);
if (invalid) throw new Error(invalid);
const tooLong = valueLengthError(values, declaredNames(parts));
if (tooLong) throw new Error(tooLong);
return expandParts(parts, values);
}
/**
* Why `uriTemplate` cannot be expanded **at all**, independent of any values,
* or `null` if it can.
*
* Exported because the preview needs the same verdict before it expands
* anything: it goes expression by expression, so without this it happily
* rendered `x://1}/2` for `x://{a}}/{b}` — a template whose stray `}` makes
* every read refuse, i.e. a URI the form it sits in can never send.
*
* Three sources, deliberately in one place so the two callers cannot drift:
* the SDK's constructor (an unclosed `{`, and its template-length,
* expression-count and variable-name-length limits), a brace surviving in a
* literal, and a varspec this module's own grammar rejects.
*/
export function templateError(uriTemplate: string): string | null {
return (
sdkTemplateError(uriTemplate) ??
partsError(uriTemplate, parseUriTemplate(uriTemplate))
);
}
/**
* What the SDK's constructor rejects: an unclosed `{`, and its template-length
* (1e6), expression-count (1e4) and variable-name-length (1e6) limits.
*/
function sdkTemplateError(uriTemplate: string): string | null {
try {
new UriTemplate(uriTemplate);
return null;
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
}
/** What this module's own grammar rejects, given the already-parsed template. */
function partsError(uriTemplate: string, parts: TemplatePart[]): string | null {
// A brace left in a literal is an unmatched one, and it is not a legal
// literal character. The SDK's constructor rejects an unclosed `{` but reads
// a stray `}` as text, and this parser does the same, so without this check
// `x://a}` expanded to itself -- a "URI" carrying a brace, with the panel
// happily enabling the read. Both halves are now refused the same way.
const stray = parts.find(
(part) => part.kind === "literal" && /[{}]/.test(part.text),
);
if (stray) {
return `Unmatched brace in "${uriTemplate}" — an expression must be a complete {…}.`;
}
const bad = parts.find((part) => part.kind === "expression" && part.invalid);
if (bad) {
return `Invalid RFC 6570 varspec in "${uriTemplate}": ${
bad.kind === "expression" ? bad.source : ""
} — each varspec must name a variable, optionally with an explode (*) or a 1-9999 prefix modifier.`;
}
return null;
}
/**
* The pinned SDK's per-value ceiling (`MAX_VARIABLE_LENGTH`, 1e6), enforced
* here because this module no longer calls `UriTemplate.expand` — which is
* where that check lived.
*
* It is the only guard that moved. Auditing the rest of the SDK's expander
* against what this replaced: the template-length (1e6), expression-count
* (1e4) and variable-name-length (1e6) limits all live in `parse()`, which
* runs from the **constructor** — and {@link expandUriTemplateStrict} still
* constructs a `UriTemplate`, so those remain enforced by it.
*/
const MAX_VALUE_LENGTH = 1_000_000;
/**
* The first value exceeding {@link MAX_VALUE_LENGTH}, as a message, or `null`.
*
* `names` limits the check to the variables a template actually references,
* and callers should pass it. RFC 6570 ignores an extra variable, and the SDK
* path this replaced validated a value only *after* looking one up by declared
* name — so checking the whole map made an unrelated entry fail a template
* that never mentions it: `x://{id}` with a valid `id` was refused because
* some stale megabyte-long key sat beside it in the caller's map.
*
* Exported because the *preview* needs the same verdict and cannot get it from
* {@link expandUriTemplateStrict}: it expands expression by expression, so it
* would otherwise hand an oversized value straight to the encoders during
* render — the guard would hold for the read while the UI still froze on the
* paste that triggered it.
*/
export function valueLengthError(
values: Record<string, string>,
names?: Iterable<string>,
): string | null {
const considered =
names === undefined ? Object.keys(values) : [...new Set(names)];
for (const name of considered) {
const value = readValue(values, name);
if (value !== undefined && value.length > MAX_VALUE_LENGTH) {
return `The value for "${name}" exceeds the ${MAX_VALUE_LENGTH}-character limit.`;
}
}
return null;
}
/** The variable names a parsed template references, in order, deduplicated. */
export function declaredNames(parts: TemplatePart[]): string[] {
return [
...new Set(
parts.flatMap((part) => (part.kind === "expression" ? part.names : [])),
),
];
}
/**
* The outcome of an expansion: the URI, or the reason there isn't one.
*
* This is what a form gates its submit on. The lenient
* {@link expandUriTemplate} below is for *display* — it answers with the raw
* template so a panel can keep rendering — and a caller that mistook that
* fallback for a URI would issue a `resources/read` for the template itself,
* literal braces and all. Making the failure a value rather than a string the
* caller has to recognize is what keeps that from being possible.
*
* Failure is not only the malformed-template case: a value carrying an
* unpaired surrogate has no UTF-8 encoding, so `encodeURIComponent` throws
* `URIError` on it, and a text input can hold one via paste.
*/
export type TemplateExpansion =
| { uri: string; error?: undefined }
| { uri?: undefined; error: string };
/**
* {@link expandUriTemplateStrict} with the throw turned into a value, for
* callers that must *decide* rather than degrade — chiefly the web panel, which
* disables Read Resource and shows the reason instead of sending a URI it knows
* is wrong.
*/
export function tryExpandUriTemplate(
uriTemplate: string,
values: Record<string, string>,
): TemplateExpansion {
try {
return { uri: expandUriTemplateStrict(uriTemplate, values) };
} catch (error) {
return { error: error instanceof Error ? error.message : String(error) };
}
}
/**
* {@link expandUriTemplateStrict}, but falling back to the raw template string
* instead of throwing.
*
* This is the **display** variant, and only that: it runs during render (the
* URI preview), where a throw would take out the whole panel, and the raw
* template is the honest thing to show for a template nothing can expand.
*
* Do **not** submit what it returns. A caller deciding whether to issue a read
* uses {@link tryExpandUriTemplate}, whose failure is a value it cannot
* mistake for a URI; `readResourceFromTemplate` uses the strict variant and
* wraps the error with the template's name.
*/
export function expandUriTemplate(
uriTemplate: string,
values: Record<string, string>,
): string {
return tryExpandUriTemplate(uriTemplate, values).uri ?? uriTemplate;
}

View file

@ -0,0 +1,25 @@
{
"serverInfo": {
"name": "rfc6570-template-showcase",
"version": "1.0.0"
},
"resourceTemplates": [
{
"preset": "rfc6570_templates"
}
],
"tools": [
{
"preset": "echo"
}
],
"transport": {
"type": "streamable-http",
"port": 6603
},
"resources": [
{
"preset": "rfc6570_base"
}
]
}

View file

@ -63,6 +63,8 @@ import {
createFileResourceTemplate,
createUserResourceTemplate,
createNumberedResourceTemplates,
createRfc6570ResourceTemplates,
createRfc6570BaseResource,
createSimplePrompt,
createArgsPrompt,
createNumberedPrompts,
@ -245,6 +247,8 @@ function resolveResourcePreset(
return createNumberedResources(Number(get("count")) || 3);
case "mcp_app_demo_widget":
return createMcpAppDemoResource();
case "rfc6570_base":
return createRfc6570BaseResource();
default:
throw new Error(`Unknown resource preset: ${name}`);
}
@ -263,6 +267,8 @@ function resolveResourceTemplatePreset(
return createUserResourceTemplate();
case "numbered_resource_templates":
return createNumberedResourceTemplates(Number(get("count")) || 3);
case "rfc6570_templates":
return createRfc6570ResourceTemplates();
default:
throw new Error(`Unknown resource template preset: ${name}`);
}

View file

@ -1518,6 +1518,92 @@ export function createFileResourceTemplate(
};
}
/**
* Resource templates that exercise RFC 6570 expansion (#1919).
*
* `events_by_topic` is the simple `{topic}` expression from the issue: a `/`,
* `?`, `#` or space in the value MUST be percent-encoded, or the URI gains a
* path segment and a conforming matcher rejects it. `events_by_query` is the
* `{?topic}` form, which the web client could not even render an input for.
*
* Both handlers echo the URI they were matched against plus the variables the
* server decoded, so the round-trip is visible in the read result.
*/
/**
* The plain `foobar://events` resource that a blank `{?topic}` read lands on.
*
* Registering it is not decoration. RFC 6570 omits a query expression whose
* variable is undefined, so leaving `topic` blank legitimately requests the
* unfiltered collection -- but the SDK's own matcher cannot serve that from the
* template: `UriTemplate.partToRegExp` compiles `{?topic}` to a **required**
* `\?topic=([^&]+)`, so `match("foobar://events")` returns null and the read
* would 404. A real server would expose the collection as its own resource;
* this fixture does the same so the showcase's "read it blank" step works.
*/
export function createRfc6570BaseResource(): ResourceDefinition {
return {
uri: "foobar://events",
name: "events",
description: "All events - what a blank `{?topic}` read resolves to",
mimeType: "application/json",
text: JSON.stringify({ collection: "events", filtered: false }, null, 2),
};
}
export function createRfc6570ResourceTemplates(): ResourceTemplateDefinition[] {
const echo =
(label: string) => async (uri: URL, params: Record<string, unknown>) => ({
contents: [
{
uri: uri.toString(),
mimeType: "application/json",
text: JSON.stringify(
{ template: label, matchedUri: uri.toString(), variables: params },
null,
2,
),
},
],
});
return [
{
name: "events_by_topic",
uriTemplate: "foobar://events/{topic}",
description:
"Simple expression - a reserved character in `topic` must be percent-encoded",
inputSchema: { topic: z.string().describe("Topic name") },
handler: echo("foobar://events/{topic}"),
},
{
name: "events_by_query",
uriTemplate: "foobar://events{?topic}",
description:
"Query expression - optional, and omitted entirely when `topic` is blank",
inputSchema: { topic: z.string().describe("Topic name") },
handler: echo("foobar://events{?topic}"),
},
{
// A template no client can honor: `abc` is not RFC 6570's `max-length`
// production (`%x31-39 0*3DIGIT`), so `{topic:abc}` is a malformed
// *template* rather than one with an ignorable modifier. The SDK's
// `UriTemplate` constructor accepts it, which is exactly why the
// Inspector checks the grammar itself and refuses the read -- guessing
// `{topic}` would send a URI this server never advertised.
//
// The handler is therefore unreachable through the Inspector by design.
// It is registered anyway so the template appears in
// `resources/templates/list`, which is what puts the refusal on screen.
name: "events_malformed",
uriTemplate: "foobar://events/{topic:abc}",
description:
"Malformed template - an out-of-grammar prefix modifier; the read is withheld",
inputSchema: { topic: z.string().describe("Topic name") },
handler: echo("foobar://events/{topic:abc}"),
},
];
}
/**
* Create a "user" resource template that returns user data by ID
*/