diff --git a/README.md b/README.md
index cb57dd35..de94130e 100644
--- a/README.md
+++ b/README.md
@@ -147,6 +147,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) |
@@ -237,6 +238,16 @@ 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 and the variables the server decoded. 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.
+
+Both clients expand through the SDK's `UriTemplate` — the web client via [`clients/web/src/utils/uriTemplate.ts`](./clients/web/src/utils/uriTemplate.ts), the TUI via `InspectorClient.readResourceFromTemplate` — so they cannot disagree about what a template means.
+
#### 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`.
diff --git a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx
index 596a9340..4955b385 100644
--- a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx
+++ b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx
@@ -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,92 @@ describe("ResourceTemplatePanel", () => {
).not.toBeDisabled();
});
+ describe("RFC 6570 expansion (#1919)", () => {
+ it("renders an input for a query expression the old regex could not see", () => {
+ renderWithMantine(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ expect(screen.queryByText("Optional")).not.toBeInTheDocument();
+ });
+
+ it("previews the query expression verbatim until it is filled", async () => {
+ const user = userEvent.setup();
+ renderWithMantine(
+ ,
+ );
+ expect(screen.getByText("foobar://events{?topic}")).toBeInTheDocument();
+ await user.type(screen.getByLabelText("topic"), "news");
+ expect(
+ screen.getByText("foobar://events?topic=news"),
+ ).toBeInTheDocument();
+ });
+ });
+
describe("completions", () => {
it("fires a completion immediately on focus before any keystroke", async () => {
const user = userEvent.setup();
diff --git a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx
index 6b506228..eafd16f4 100644
--- a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx
+++ b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx
@@ -14,6 +14,11 @@ 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 {
+ expandUriTemplate,
+ previewUriTemplate,
+ templateVariables,
+} from "../../../utils/uriTemplate";
export interface ResourceTemplatePanelProps {
template: ResourceTemplate;
@@ -39,34 +44,6 @@ 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 {
- return uriTemplate.replace(/\{(\w+)\}/g, (_, key: string) => variables[key]);
-}
-
-function previewUri(
- uriTemplate: string,
- variables: Record,
-): string {
- return uriTemplate.replace(/\{(\w+)\}/g, (match, key: string) =>
- variables[key]?.length > 0 ? variables[key] : match,
- );
-}
-
const HeaderRow = Group.withProps({
justify: "space-between",
wrap: "nowrap",
@@ -108,10 +85,16 @@ 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],
+ );
const [variables, setVariables] = useState>(() =>
Object.fromEntries(variableNames.map((n) => [n, ""])),
@@ -232,13 +215,18 @@ export function ResourceTemplatePanel({
void runCompletion(varName, value, buildContext(varName));
}
- const canSubmit = variableNames.every((n) => variables[n]?.length > 0);
+ // Only the variables whose absence would change the URI's shape gate the
+ // read; an unfilled `{?topic}` is a legitimate request for the unfiltered
+ // resource, and RFC 6570 drops the whole expression for it.
+ const canSubmit = declaredVariables.every(
+ (v) => !v.required || variables[v.name]?.length > 0,
+ );
function handleSubmit() {
- onReadResource(resolveUri(uriTemplate, variables));
+ onReadResource(expandUriTemplate(uriTemplate, variables));
}
- const preview = previewUri(uriTemplate, variables);
+ const preview = previewUriTemplate(uriTemplate, variables);
return (
@@ -251,13 +239,17 @@ export function ResourceTemplatePanel({
{description && {description}}
- {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.
+ const description = required ? undefined : "Optional";
return useAutocomplete ? (
diff --git a/clients/web/src/utils/uriTemplate.test.ts b/clients/web/src/utils/uriTemplate.test.ts
new file mode 100644
index 00000000..b337a42b
--- /dev/null
+++ b/clients/web/src/utils/uriTemplate.test.ts
@@ -0,0 +1,168 @@
+import { describe, it, expect } from "vitest";
+import {
+ expandUriTemplate,
+ parseUriTemplate,
+ previewUriTemplate,
+ templateVariables,
+} from "./uriTemplate";
+
+describe("parseUriTemplate", () => {
+ it("splits literals from expressions", () => {
+ expect(parseUriTemplate("foobar://events/{topic}")).toEqual([
+ { kind: "literal", text: "foobar://events/" },
+ {
+ kind: "expression",
+ source: "{topic}",
+ operator: "",
+ names: ["topic"],
+ },
+ ]);
+ });
+
+ it("reads the operator and the comma-separated name list", () => {
+ expect(parseUriTemplate("x://{?a,b*}")).toEqual([
+ { kind: "literal", text: "x://" },
+ {
+ kind: "expression",
+ source: "{?a,b*}",
+ operator: "?",
+ names: ["a", "b"],
+ },
+ ]);
+ });
+
+ it("treats an unclosed expression as trailing literal text", () => {
+ expect(parseUriTemplate("x://a/{oops")).toEqual([
+ { kind: "literal", text: "x://a/{oops" },
+ ]);
+ });
+
+ it("returns nothing for an empty template", () => {
+ expect(parseUriTemplate("")).toEqual([]);
+ });
+});
+
+describe("templateVariables", () => {
+ it("finds a simple variable and marks it required", () => {
+ expect(templateVariables("foobar://events/{topic}")).toEqual([
+ { name: "topic", operator: "", required: true },
+ ]);
+ });
+
+ it("finds a query variable the old `\\{(\\w+)\\}` regex could not see", () => {
+ expect(templateVariables("foobar://events{?topic}")).toEqual([
+ { name: "topic", operator: "?", required: false },
+ ]);
+ });
+
+ it.each([
+ ["{+path}", "+", true],
+ ["{#frag}", "#", true],
+ ["{.label}", ".", false],
+ ["{/segment}", "/", false],
+ ["{&extra}", "&", false],
+ ])("classifies %s", (expression, operator, required) => {
+ const [variable] = templateVariables(`x://a${expression}`);
+ expect(variable.operator).toBe(operator);
+ expect(variable.required).toBe(required);
+ });
+
+ it("deduplicates a repeated name and keeps it required if any use is", () => {
+ expect(templateVariables("x://{?id}/{id}")).toEqual([
+ { name: "id", operator: "?", required: true },
+ ]);
+ });
+
+ it("returns an empty list for a template with no expressions", () => {
+ expect(templateVariables("file:///static.txt")).toEqual([]);
+ });
+});
+
+describe("expandUriTemplate", () => {
+ it("percent-encodes a reserved character in a simple variable (#1919)", () => {
+ expect(
+ expandUriTemplate("foobar://events/{topic}", { topic: "foo/bar" }),
+ ).toBe("foobar://events/foo%2Fbar");
+ });
+
+ it.each([
+ ["?", "a?b", "a%3Fb"],
+ ["#", "a#b", "a%23b"],
+ ["%", "a%b", "a%25b"],
+ ["space", "a b", "a%20b"],
+ ["unicode", "caffè", "caff%C3%A8"],
+ ])("encodes %s", (_label, value, encoded) => {
+ expect(expandUriTemplate("x://{v}", { v: value })).toBe(`x://${encoded}`);
+ });
+
+ it("builds an encoded query expression", () => {
+ expect(
+ expandUriTemplate("foobar://events{?topic}", { topic: "foo/bar" }),
+ ).toBe("foobar://events?topic=foo%2Fbar");
+ });
+
+ it("leaves reserved characters intact under the + operator", () => {
+ expect(expandUriTemplate("x://{+path}", { path: "a/b" })).toBe("x://a/b");
+ });
+
+ it("omits an expression whose variable was left blank", () => {
+ expect(expandUriTemplate("foobar://events{?topic}", { topic: "" })).toBe(
+ "foobar://events",
+ );
+ });
+
+ it("joins two query expressions with & rather than a second ?", () => {
+ expect(expandUriTemplate("x://a{?one}{?two}", { one: "1", two: "2" })).toBe(
+ "x://a?one=1&two=2",
+ );
+ });
+
+ it("falls back to the raw template when the SDK cannot parse it", () => {
+ expect(expandUriTemplate("x://a/{oops", { oops: "v" })).toBe("x://a/{oops");
+ });
+});
+
+describe("previewUriTemplate", () => {
+ it("shows the template verbatim before anything is entered", () => {
+ expect(previewUriTemplate("file:///users/{userId}/profile", {})).toBe(
+ "file:///users/{userId}/profile",
+ );
+ });
+
+ it("substitutes only the expressions that are filled", () => {
+ expect(
+ previewUriTemplate("db://{tableName}/rows/{rowId}", {
+ tableName: "users",
+ rowId: "",
+ }),
+ ).toBe("db://users/rows/{rowId}");
+ });
+
+ it("encodes the filled values the same way expansion does", () => {
+ expect(
+ previewUriTemplate("foobar://events/{topic}", { topic: "foo/bar" }),
+ ).toBe("foobar://events/foo%2Fbar");
+ });
+
+ it("keeps a multi-name expression whole until every name is filled", () => {
+ expect(previewUriTemplate("x://a{?one,two}", { one: "1" })).toBe(
+ "x://a{?one,two}",
+ );
+ });
+
+ it("still rewrites the second ? to & when both query expressions resolve", () => {
+ expect(
+ previewUriTemplate("x://a{?one}{?two}", { one: "1", two: "2" }),
+ ).toBe("x://a?one=1&two=2");
+ });
+
+ it("restores a deferred expression that follows a resolved query expression", () => {
+ expect(previewUriTemplate("x://a{?one}{?two}", { one: "1" })).toBe(
+ "x://a?one=1{?two}",
+ );
+ });
+
+ it("falls back to the raw template when the SDK cannot parse it", () => {
+ expect(previewUriTemplate("x://a/{oops", {})).toBe("x://a/{oops");
+ });
+});
diff --git a/clients/web/src/utils/uriTemplate.ts b/clients/web/src/utils/uriTemplate.ts
new file mode 100644
index 00000000..25cd027e
--- /dev/null
+++ b/clients/web/src/utils/uriTemplate.ts
@@ -0,0 +1,219 @@
+/**
+ * RFC 6570 URI Template support for the Resources screen.
+ *
+ * The web client used to discover and substitute variables with a bare
+ * `/\{(\w+)\}/g` regex, which is wrong in two ways (#1919): it cannot see an
+ * expression carrying an operator (`{?topic}`, `{/path}`, `{#frag}`, ...), so
+ * no input is rendered for it; and it splices the raw value in, so a `/`, `?`,
+ * `#` or space in a simple `{var}` lands unencoded and silently changes the
+ * URI's structure.
+ *
+ * Expansion itself is delegated to the SDK's `UriTemplate` -- the same
+ * implementation `InspectorClient.readResourceFromTemplate` (and therefore the
+ * TUI) already expands through, so the two clients cannot disagree about what a
+ * template means. What lives here is the surrounding form/preview logic the SDK
+ * class does not provide: which variables to render an input for, which of them
+ * a read cannot proceed without, and a partially-expanded preview string.
+ */
+
+import { UriTemplate } from "@modelcontextprotocol/client";
+
+/** The RFC 6570 operators, in the order the SDK's parser tests for them. */
+const OPERATORS = ["+", "#", ".", "/", "?", "&"] as const;
+
+/**
+ * Operators whose expansion omits cleanly when the variables in it are
+ * undefined -- the whole expression, separator included, simply disappears.
+ * A variable under any *other* operator is interpolated into the middle of the
+ * URI, so leaving it out produces a different resource path rather than a
+ * shorter one; those we require before allowing a read.
+ */
+const OMITTABLE_OPERATORS = new Set([".", "/", "?", "&"]);
+
+interface TemplateLiteral {
+ kind: "literal";
+ text: string;
+}
+
+interface TemplateExpression {
+ kind: "expression";
+ /** The expression including its braces, e.g. `{?topic}`. */
+ source: string;
+ /** The RFC 6570 operator, or `""` for a simple expression. */
+ operator: string;
+ /** Variable names in the expression, `*` (explode) and whitespace stripped. */
+ names: string[];
+}
+
+export type TemplatePart = TemplateLiteral | TemplateExpression;
+
+export interface TemplateVariable {
+ name: string;
+ /** The operator of the expression the variable was first seen in. */
+ operator: string;
+ /**
+ * True when omitting the variable would change the URI's structure rather
+ * than shorten it -- see {@link OMITTABLE_OPERATORS}.
+ */
+ required: boolean;
+}
+
+/**
+ * Splits a template into literal runs and expressions.
+ *
+ * Deliberately mirrors the SDK parser's own scanning rules (first matching
+ * operator character wins, names split on `,`, `*` stripped) so this never
+ * disagrees with the class that ultimately does the expanding. An unclosed
+ * `{` yields a trailing literal, which is what makes the callers below degrade
+ * to "render no inputs, show the template verbatim" rather than throw.
+ */
+export function parseUriTemplate(uriTemplate: string): TemplatePart[] {
+ const parts: TemplatePart[] = [];
+ let literal = "";
+ let i = 0;
+
+ while (i < uriTemplate.length) {
+ if (uriTemplate[i] !== "{") {
+ literal += uriTemplate[i];
+ i += 1;
+ continue;
+ }
+ const end = uriTemplate.indexOf("}", i);
+ if (end === -1) {
+ // Unclosed expression -- the rest is not a template, treat it as text.
+ literal += uriTemplate.slice(i);
+ break;
+ }
+ if (literal) {
+ parts.push({ kind: "literal", text: literal });
+ literal = "";
+ }
+ const body = uriTemplate.slice(i + 1, end);
+ const operator = OPERATORS.find((op) => body.startsWith(op)) ?? "";
+ const names = body
+ .slice(operator.length)
+ .split(",")
+ .map((name) => name.replace("*", "").trim())
+ .filter((name) => name.length > 0);
+ parts.push({
+ kind: "expression",
+ source: uriTemplate.slice(i, end + 1),
+ operator,
+ names,
+ });
+ i = end + 1;
+ }
+
+ if (literal) parts.push({ kind: "literal", text: literal });
+ return parts;
+}
+
+/**
+ * The variables a form should render an input for, in template order and
+ * deduplicated by name. A name appearing under more than one operator is
+ * required if *any* of its occurrences is.
+ */
+export function templateVariables(uriTemplate: string): TemplateVariable[] {
+ const byName = new Map();
+
+ for (const part of parseUriTemplate(uriTemplate)) {
+ if (part.kind !== "expression") continue;
+ const required = !OMITTABLE_OPERATORS.has(part.operator);
+ for (const name of part.names) {
+ const existing = byName.get(name);
+ if (existing) {
+ existing.required = existing.required || required;
+ } else {
+ byName.set(name, { name, operator: part.operator, required });
+ }
+ }
+ }
+
+ return [...byName.values()];
+}
+
+/**
+ * Drops empty entries so an untouched optional field reads as *undefined* to
+ * the SDK (the expression disappears) rather than as the empty string (which
+ * would expand to a valueless `?topic=`).
+ */
+function definedValues(values: Record): Record {
+ return Object.fromEntries(
+ Object.entries(values).filter(([, value]) => value.length > 0),
+ );
+}
+
+/**
+ * Expands a template against the entered values per RFC 6570 -- percent-
+ * encoding each value according to its operator, and omitting expressions whose
+ * variables were left blank.
+ *
+ * A template the SDK refuses to parse falls back to the raw template string,
+ * which is what the user already sees in the preview and what the server will
+ * reject with a legible error; throwing here would take out the whole panel.
+ */
+export function expandUriTemplate(
+ uriTemplate: string,
+ values: Record,
+): string {
+ try {
+ return new UriTemplate(uriTemplate).expand(definedValues(values));
+ } catch {
+ return uriTemplate;
+ }
+}
+
+/**
+ * A placeholder standing in for an expression the user has not filled in yet.
+ *
+ * `U+0000` cannot appear in a URI template, so a token built from it can never
+ * collide with real template text; and because it is emitted as *literal* text
+ * rather than as a variable value, expansion passes it through unencoded.
+ */
+const deferredToken = (index: number) => `\u0000${index}\u0000`;
+
+/**
+ * A partially-expanded template for display: expressions whose variables are
+ * all filled are expanded exactly as {@link expandUriTemplate} would, and the
+ * rest are left standing as written so the user can see what is still needed.
+ *
+ * Unfilled expressions are swapped for an inert token and restored after
+ * expansion -- rather than expanding each filled expression in isolation -- so
+ * the SDK still sees one whole template and applies its cross-expression rules
+ * (notably rewriting a second `?` query expression to `&`).
+ */
+export function previewUriTemplate(
+ uriTemplate: string,
+ values: Record,
+): string {
+ const parts = parseUriTemplate(uriTemplate);
+ const deferred: string[] = [];
+ const defined = definedValues(values);
+
+ const rewritten = parts
+ .map((part) => {
+ if (part.kind === "literal") return part.text;
+ if (part.names.every((name) => defined[name] !== undefined)) {
+ return part.source;
+ }
+ deferred.push(part.source);
+ return deferredToken(deferred.length - 1);
+ })
+ .join("");
+
+ let expanded: string;
+ try {
+ expanded = new UriTemplate(rewritten).expand(defined);
+ } catch {
+ return uriTemplate;
+ }
+
+ // Restored by exact-string replacement rather than by a pattern: a regex
+ // matching the token would have to embed U+0000 literally, which `eslint`
+ // rejects (`no-control-regex`) -- and each token's text is already known
+ // here, so there is nothing to match on.
+ return deferred.reduce(
+ (uri, source, index) => uri.replaceAll(deferredToken(index), source),
+ expanded,
+ );
+}
diff --git a/test-servers/configs/rfc6570-templates-http.json b/test-servers/configs/rfc6570-templates-http.json
new file mode 100644
index 00000000..c6949773
--- /dev/null
+++ b/test-servers/configs/rfc6570-templates-http.json
@@ -0,0 +1,12 @@
+{
+ "serverInfo": {
+ "name": "rfc6570-template-showcase",
+ "version": "1.0.0"
+ },
+ "resourceTemplates": [{ "preset": "rfc6570_templates" }],
+ "tools": [{ "preset": "echo" }],
+ "transport": {
+ "type": "streamable-http",
+ "port": 6603
+ }
+}
diff --git a/test-servers/src/preset-registry.ts b/test-servers/src/preset-registry.ts
index d78998ec..34fd53ad 100644
--- a/test-servers/src/preset-registry.ts
+++ b/test-servers/src/preset-registry.ts
@@ -62,6 +62,7 @@ import {
createFileResourceTemplate,
createUserResourceTemplate,
createNumberedResourceTemplates,
+ createRfc6570ResourceTemplates,
createSimplePrompt,
createArgsPrompt,
createNumberedPrompts,
@@ -260,6 +261,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}`);
}
diff --git a/test-servers/src/test-server-fixtures.ts b/test-servers/src/test-server-fixtures.ts
index 070b5750..89283e49 100644
--- a/test-servers/src/test-server-fixtures.ts
+++ b/test-servers/src/test-server-fixtures.ts
@@ -1474,6 +1474,53 @@ 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.
+ */
+export function createRfc6570ResourceTemplates(): ResourceTemplateDefinition[] {
+ const echo =
+ (label: string) => async (uri: URL, params: Record) => ({
+ 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}"),
+ },
+ ];
+}
+
/**
* Create a "user" resource template that returns user data by ID
*/