Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
83658db
fix(web): expand resource templates per RFC 6570
cliffhall Aug 16, 2026
409dce3
fix(core): share URI-template expansion and correct the SDK's multi-n…
cliffhall Aug 16, 2026
25c3ad2
fix(core): support ; and :length, and scope requiredness to the expre…
cliffhall Aug 16, 2026
539475c
Merge branch 'v2/main' into v2/fix/1919-rfc6570-uri-template-expansion
cliffhall Aug 16, 2026
e7d1246
fix(core): scope requiredness to expressions, and encode + / # per RF…
cliffhall Aug 16, 2026
1d29e4e
Merge remote-tracking branch 'origin/v2/fix/1919-rfc6570-uri-template…
cliffhall Aug 16, 2026
935113d
fix: encode per RFC 3986, and derive the TUI form from the shared parser
cliffhall Aug 17, 2026
ebc18e1
fix: expand each expression independently, and reject invalid varspecs
cliffhall Aug 17, 2026
1939136
fix: read template variables as own properties only
cliffhall Aug 17, 2026
1ad3edd
fix: refuse a template that cannot expand, instead of reading the raw…
cliffhall Aug 17, 2026
1c24eda
fix: validate the whole varspec, and derive the TUI's message from it…
cliffhall Aug 17, 2026
6acaff7
fix: honor a defined-but-empty value, and announce the expansion error
cliffhall Aug 17, 2026
63250a6
Merge branch 'v2/main' into v2/fix/1919-rfc6570-uri-template-expansion
cliffhall Aug 17, 2026
52abe8a
fix: count a pct-triplet as one character when truncating a prefix
cliffhall Aug 17, 2026
ef3f5e7
Merge branch 'v2/fix/1919-rfc6570-uri-template-expansion' of https://…
cliffhall Aug 17, 2026
2e06b36
fix: encode literals per RFC 6570 3.1, and label the varname tolerance
cliffhall Aug 17, 2026
c6fb627
fix: count a pct-triplet as one character under every operator
cliffhall Aug 17, 2026
6a345ae
fix: keep the URI preview from throwing or promising an unsendable URI
cliffhall Aug 17, 2026
1c595e9
fix: dedupe a repeated name in a group, and name every unmet requirement
cliffhall Aug 17, 2026
c6b17c5
fix: refuse a stray brace, bound value length, and drop a quadratic scan
cliffhall Aug 17, 2026
a3dd856
test: pin the InspectorClient wiring, and correct a false claim about…
cliffhall Aug 17, 2026
7d9410c
fix: keep a pct-encoded UTF-8 sequence whole, and bound the preview too
cliffhall Aug 17, 2026
05bbfe5
fix: read the completions map by own property, not through the prototype
cliffhall Aug 17, 2026
9caac43
fix: accept only well-formed UTF-8 sequences, and validate before pre…
cliffhall Aug 17, 2026
7bcf155
fix: scope the value ceiling to the names a template references
cliffhall Aug 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,21 @@ 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
│ │ # shared by the web Resources form and
│ │ # readResourceFromTemplate (TUI + CLI), so the
│ │ # clients cannot drift on what a template means;
│ │ # delegates to the SDK's UriTemplate for what it
│ │ # gets right, and takes over a whole template
│ │ # containing any of the three shapes it does not:
│ │ # `{a,b}` (raw-joined, unencoded, prefix dropped),
│ │ # `{;id}` (operator absent from its list), and
│ │ # `{id:3}` (prefix modifier folded into the name) —
│ │ # the last two would render form fields literally
│ │ # labelled `;id` / `id:3`. Requiredness is per
│ │ # EXPRESSION, not per variable (hasRequiredValues):
│ │ # `{a,b}` with only `a` filled is expandable — #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 —
Expand Down
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 all three clients 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
Expand Down Expand Up @@ -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) |
Expand Down Expand Up @@ -237,6 +239,28 @@ 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.

All three clients expand through one shared helper, [`core/mcp/uriTemplate.ts`](./core/mcp/uriTemplate.ts) — the web Resources form directly, the TUI and CLI via `InspectorClient.readResourceFromTemplate` — so they cannot disagree about what a template means. It delegates to the SDK's `UriTemplate` for every expression the SDK handles correctly, and takes over any template containing one of the three shapes it does not (each measured against the pinned SDK, not inferred):

| Shape | SDK `variableNames` | SDK expansion | Correct |
| --- | --- | --- | --- |
| `{a,b}` | `["a","b"]` | `foo/bar,q` — unencoded, operator prefix dropped | `foo%2Fbar,q` |
| `{;id}` | `[";id"]` | `""` — the `;` operator is not in its list | `;id=7` |
| `{id:3}` | `["id:3"]` | `""` — the prefix modifier is folded into the name | `abc` |

The last two matter beyond the URI: a form has to *name* the variables it asks the user to fill, so on the SDK's parse it would render fields literally labelled `;id` and `id:3`. Takeover is per **template**, not per expression, so the cross-expression `?`-to-`&` rewrite always sees every expression that actually emitted.

One consequence worth knowing when writing a test server: the SDK's **matcher** has the mirrored gaps (`partToRegExp` emits a single capture for `{a,b}` and knows no `;`), so an SDK-backed server cannot round-trip those templates whatever the client sends. Emitting a spec-correct URI is the half the client controls; the unit tests cover those shapes directly rather than through a showcase server.

#### 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`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
<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();
});
});

describe("completions", () => {
it("fires a completion immediately on focus before any keystroke", async () => {
const user = userEvent.setup();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ 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,
hasRequiredValues,
previewUriTemplate,
templateVariables,
} from "../../../utils/uriTemplate";

export interface ResourceTemplatePanelProps {
template: ResourceTemplate;
Expand All @@ -39,34 +45,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, 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,
);
}

const HeaderRow = Group.withProps({
justify: "space-between",
wrap: "nowrap",
Expand Down Expand Up @@ -108,10 +86,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<Record<string, string>>(() =>
Object.fromEntries(variableNames.map((n) => [n, ""])),
Expand Down Expand Up @@ -232,13 +216,18 @@ export function ResourceTemplatePanel({
void runCompletion(varName, value, buildContext(varName));
}

const canSubmit = variableNames.every((n) => variables[n]?.length > 0);
// Only the expressions 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. 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.
const canSubmit = hasRequiredValues(declaredVariables, variables);

function handleSubmit() {
onReadResource(resolveUri(uriTemplate, variables));
onReadResource(expandUriTemplate(uriTemplate, variables));
}

const preview = previewUri(uriTemplate, variables);
const preview = previewUriTemplate(uriTemplate, variables);
Comment thread
cliffhall marked this conversation as resolved.

return (
<Stack gap="md">
Expand All @@ -251,13 +240,24 @@ export function ResourceTemplatePanel({
</HeaderRow>
{description && <DescriptionText>{description}</DescriptionText>}
<Stack gap="sm">
{variableNames.map((varName) => {
{declaredVariables.map(({ name: varName, required, groupNames }) => {
/* 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.
const description = !required
? "Optional"
: groupNames.length > 1
? `Any one of: ${groupNames.join(", ")}`
: undefined;
return useAutocomplete ? (
<Autocomplete
key={varName}
label={varName}
description={description}
placeholder={`Enter ${varName}`}
value={fieldValue}
data={completions[varName] ?? []}
Expand All @@ -273,6 +273,7 @@ export function ResourceTemplatePanel({
<TextInput
key={varName}
label={varName}
description={description}
placeholder={`Enter ${varName}`}
value={fieldValue}
onChange={(e) =>
Expand Down
Loading