From 83658db2dd1c81ec49192a68378072c93d4d84f5 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 16 Aug 2026 18:31:35 -0400 Subject: [PATCH] fix(web): expand resource templates per RFC 6570 The Resources screen discovered and substituted template variables with a bare `/\{(\w+)\}/g` regex, which is wrong two ways: it cannot see an expression carrying an operator, so `foobar://events{?topic}` rendered no input at all; and it splices the raw value in, so a `/`, `?`, `#`, `%`, space or non-ASCII character in a simple `{topic}` landed unencoded and changed the URI's structure -- `foo/bar` produced an extra path segment that a conforming matcher rejects with `-32602 Resource not found`. Delegate expansion to the SDK's `UriTemplate`, the same implementation `InspectorClient.readResourceFromTemplate` (and so the TUI) already expands through, so the two clients cannot disagree about what a template means. The new `utils/uriTemplate` supplies only what that class does not: which variables to render an input for, which of them a read cannot proceed without, and a partially-expanded preview. Required-ness follows the operator. Under `?`, `&`, `.` or `/` the whole expression is omitted when the variable is undefined, so those fields are marked Optional and reading with them blank is a legitimate request for the unfiltered resource; under `""`, `+` or `#` the variable sits mid-URI, so it stays required. Blank fields are dropped before expanding so an untouched optional field reads as undefined rather than as the empty string, which would expand to a valueless `?topic=`. Adds the `rfc6570_templates` preset and a `rfc6570-templates-http.json` showcase server serving the two templates from the issue. Closes #1919 Signed-off-by: cliffhall --- README.md | 11 + .../ResourceTemplatePanel.test.tsx | 91 ++++++++ .../ResourceTemplatePanel.tsx | 61 +++-- clients/web/src/utils/uriTemplate.test.ts | 168 ++++++++++++++ clients/web/src/utils/uriTemplate.ts | 219 ++++++++++++++++++ .../configs/rfc6570-templates-http.json | 12 + test-servers/src/preset-registry.ts | 3 + test-servers/src/test-server-fixtures.ts | 47 ++++ 8 files changed, 578 insertions(+), 34 deletions(-) create mode 100644 clients/web/src/utils/uriTemplate.test.ts create mode 100644 clients/web/src/utils/uriTemplate.ts create mode 100644 test-servers/configs/rfc6570-templates-http.json 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 */