Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
29 changes: 28 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
36 changes: 35 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 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
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,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`.
Expand Down
67 changes: 67 additions & 0 deletions clients/tui/__tests__/ResourceTestModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
45 changes: 44 additions & 1 deletion clients/tui/__tests__/uriTemplateToForm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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);
});
});
Loading