Skip to content

fix: expand resource templates per RFC 6570 in the web client (#1919) - #2033

Closed
cliffhall wants to merge 13 commits into
v2/mainfrom
v2/fix/1919-rfc6570-resource-templates
Closed

fix: expand resource templates per RFC 6570 in the web client (#1919)#2033
cliffhall wants to merge 13 commits into
v2/mainfrom
v2/fix/1919-rfc6570-resource-templates

Conversation

@cliffhall

@cliffhall cliffhall commented Aug 16, 2026

Copy link
Copy Markdown
Member

Closes #1919

Problem

The Resources tab discovered a template's variables with /\{(\w+)\}/g and substituted them with a plain String.replace. Two consequences, both reported in #1919:

  1. That regex only matches a bare {name} expression. A query expression — foobar://events{?topic} — declared a variable the form never rendered an input for, and the enabled Read Resource button sent the unexpanded template.
  2. Values were inserted verbatim. A topic of foo/bar produced foobar://events/foo/bar — a second path segment — instead of foobar://events/foo%2Fbar. A spec-compliant matcher rejects that with -32602 Resource not found.

Fix

Variable discovery, expansion, and the URI preview now go through the SDK's UriTemplate (clients/web/src/utils/uriTemplate.ts) — the same RFC 6570 implementation the TUI's form builder and InspectorClient.readResourceFromTemplate already used, so web, CLI, and TUI agree on what a template's variables are and on how a value is encoded. The web client was the only surface still doing string replacement.

The preview keeps showing {name} for a variable that hasn't been filled yet, via a sentinel built from RFC 3986 unreserved characters so it survives expansion untouched under every operator and can be swapped back out. A filled value is previewed exactly as it will be sent, encoding included.

Test server

test-servers/configs/rfc6570-templates-http.json (preset rfc6570_templates) serves both templates from the issue — foobar://events/{topic} and foobar://events{?topic} — each echoing back the topic it received and the URI that matched. The server rejects a non-matching URI, so the old behavior fails visibly rather than silently.

Screenshots

All three pairs captured against that server, headless, through the prod --web build.

Simple expression — a / in the value must be percent-encoded. Before, the preview showed the raw value; after, it shows what will actually be sent.

Before After
encoding before encoding after

The resulting read. Before: Read Error — Resource not found: foobar://events/foo/bar. After: the resource loads, and the server confirms it matched foobar://events/foo%2Fbar.

Before After
read before read after

Query expression. Before: no topic input at all, and Read Resource enabled anyway. After: an input, and a preview showing where the value lands.

Before After
query before query after

Query expression. After: data input, and displayed in the preview.

resource-template-query-filled-after

Tests

  • clients/web/src/utils/uriTemplate.test.ts — new, 100% on all four dimensions. Covers every RFC 6570 operator for discovery ({+}, {#}, {/}, {.}, {?}, {&}), multi-variable and repeated-name expressions, the encoding cases the issue asks for (/, ?, #, %, space, Unicode), omission of unfilled variables, the preview's placeholder round-trip, and the malformed-template degradation.
  • ResourceTemplatePanel.test.tsx — four regression tests: a query expression renders an input, expands correctly on submit, a reserved character is percent-encoded rather than becoming a path segment, and the preview shows the encoded value.
  • New QueryExpression Storybook story.

npm run ci passes locally.

The Resources tab discovered template variables with `/\{(\w+)\}/g` and
substituted them with a plain `String.replace`. That regex only ever matched a
bare `{name}` expression, so a query expression like `foobar://events{?topic}`
declared a variable the form never offered an input for — and the substitution
inserted values verbatim, so a `topic` of `foo/bar` became a second path
segment instead of `foo%2Fbar`, which a spec-compliant matcher rejects with
`-32602 Resource not found`.

Discovery, expansion, and preview now go through the SDK's `UriTemplate` — the
same RFC 6570 implementation the TUI's form builder and
`InspectorClient.readResourceFromTemplate` already used, so all three clients
agree on a template's variables and on how a value is encoded.

The preview keeps showing `{name}` for a variable that hasn't been filled yet
(via an unreserved sentinel that survives expansion untouched), so the shape of
the URI stays legible while the form is being completed, while a filled value is
rendered exactly as it will be sent.

Adds `rfc6570-templates-http.json` (preset `rfc6570_templates`) serving both
templates from the issue, each echoing back the topic it received and the URI
that matched.

Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall cliffhall added the v2 Issues and PRs for v2 label Aug 16, 2026
@cliffhall
cliffhall requested a balanced review from Copilot August 16, 2026 22:26
Signed-off-by: cliffhall <cliff@futurescale.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds RFC 6570-compliant resource-template discovery, expansion, encoding, and previews to the web client.

Changes:

  • Introduces shared UriTemplate helpers and regression tests.
  • Updates the resource-template panel and Storybook coverage.
  • Adds an RFC 6570 test-server preset and documentation.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
test-servers/src/test-server-fixtures.ts Adds RFC 6570 resource fixtures.
test-servers/src/preset-registry.ts Registers the new fixture preset.
test-servers/configs/rfc6570-templates-http.json Configures the manual test server.
README.md Documents RFC 6570 testing.
clients/web/src/utils/uriTemplate.ts Adds template parsing, expansion, and preview helpers.
clients/web/src/utils/uriTemplate.test.ts Tests helper behavior and encoding.
clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx Uses the new helpers in the UI.
clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx Adds UI regressions.
clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.stories.tsx Adds a query-expression story.

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment thread clients/web/src/utils/uriTemplate.ts Outdated
Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
Member Author

Checked this against the SDK, and the stated failure mode doesn't reproduce — but it pointed at a boundary worth pinning, so I've added tests for it.

The SDK's UriTemplate doesn't implement prefix modifiers at all. It folds the modifier into the variable name rather than parsing it:

x://{topic:3}   variableNames: ["topic:3"]
                expand({"topic:3": "abcdef"})  → "x://abcdef"     (no truncation)
x://{?topic:3}  variableNames: ["topic:3"]
                expand({"topic:3": "abcdef"})  → "x://?topic:3=abcdef"

So expand never truncates the sentinel to zzI — there is no truncation step to hit. The sentinel round-trips intact and the preview restores {topic:3} like any other placeholder. (The x:// in a naive probe comes from keying the value as topic instead of topic:3, which just makes it an unset variable.)

Making the placeholder "modifier-aware" would therefore be building around a modifier the parser doesn't recognize — and the same parse is what the TUI's form builder and InspectorClient.readResourceFromTemplate already use, so any real modifier support belongs upstream in the SDK, not in three client-side workarounds.

What I did take from this:

  • clients/web/src/utils/uriTemplate.test.ts — two regression tests, one on discovery ({topic:3}["topic:3"]) and one on the preview (x://{topic:3}x://{topic:3}, x://{?topic:3}x://?topic:3={topic:3}). If a future SDK bump starts parsing modifiers, these fail and point straight at the sentinel logic.
  • templateVariableNames now documents the boundary in place, and notes that it's inherited from the SDK and shared with the other two clients.

Pushed in 1487404.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.

Suppressed comments (2)

clients/web/src/utils/uriTemplate.ts:124

  • The placeholder can collide with a real variable value. For example, previewTemplate("x://{a}/{b}", { a: "zzInspectorUnfilledzz1zz", b: "" }) expands both variables to the same token, and this replacement turns the preview into x://{b}/{b}, hiding the filled value even though the submitted URI remains different. Generate per-call placeholder tokens that are absent from the template and all filled values (and add a collision regression test) before restoring them.
  let preview = template.expand(values);
  names.forEach((name, index) => {
    if (filled[name] !== undefined) return;
    // The sentinel is unreserved, so it appears in the expansion unencoded.
    preview = preview.split(sentinelFor(index)).join(`{${name}}`);

test-servers/src/test-server-fixtures.ts:1485

  • The new RFC 6570 server preset is not exercised by any automated test, so the claimed end-to-end behavior—both templates register, encoded/query URIs match, and an unencoded path is rejected—can regress while the component/helper unit tests still pass. Add an integration test that loads rfc6570-templates-http.json (or this factory), lists both templates, and reads/rejects the representative URIs.
export function createRfc6570ResourceTemplates(): ResourceTemplateDefinition[] {

…rver (#1919)

Two follow-ups from review.

The preview's placeholder token was a fixed constant, so a user who typed that
exact string as *another* variable's value would see it rewritten into a
`{name}` on substitution — a preview disagreeing with the URI actually
submitted. The base is now extended until it appears in neither the template's
literal text nor any filled value, making the token unambiguous per call.

The new `rfc6570-templates-http.json` preset had no automated coverage: the
helper's unit tests assert what `expandTemplate` produces, but not that the
produced URI is what a spec-compliant server accepts — which is the whole bug,
since the old substitution emitted a URI the Inspector was happy with and the
server rejected. The integration test drives both directions against a real
server over a real transport, and pins the rejection of the unencoded URI so a
regression cannot pass by loosening the server. It resolves the checked-in
config rather than calling the factory, so a misspelt preset name fails there
too.

Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
Member Author

Round 2 reported "no new comments" but carried two suppressed ones. Both were right — fixed in 0cc492d.

1. Placeholder could collide with a real value (uriTemplate.ts)

Correct, and reproduced exactly as described: previewTemplate("x://{a}/{b}", { a: "zzInspectorUnfilledzz1zz", b: "" }) rewrote the filled value into {b}, so the preview disagreed with the URI actually submitted.

The token base is now chosen per call — extended with zs until it appears in neither the template's literal text nor any filled value, so it is unambiguous for that expansion:

function uncollidingBase(uriTemplate: string, filled: Record<string, string>) {
  const haystack = [uriTemplate, ...Object.values(filled)].join("\n");
  let base = UNFILLED_SENTINEL;
  while (haystack.includes(base)) base += "z";
  return base;
}

Checking the raw inputs is sufficient rather than the encoded ones: percent-encoding can only emit % plus hex digits, and the base contains characters outside that set, so encoding can never synthesize the token out of other content. That reasoning is now a comment on the constant.

Two regression tests added — one for a colliding filled value, one for the token appearing as literal text in the template itself.

2. The repro server had no automated coverage (test-server-fixtures.ts)

Also correct, and the sharper half of the point: the helper's unit tests assert what expandTemplate produces, but nothing asserted that the produced URI is what a spec-compliant server accepts — which is the entire bug, since the old substitution emitted a URI the Inspector was perfectly happy with and the server rejected.

New clients/web/src/test/integration/mcp/rfc6570-templates.test.ts (6 tests) drives it against a real server over a real transport:

  • both templates advertised, query expression included;
  • foobar://events/foo%2Fbar resolves, and the server echoes back the URI it matched;
  • foobar://events?topic=weather resolves;
  • foobar://events/foo/bar — what the old code produced — is still refused. That last one is deliberate: without it a regression could pass by loosening the server rather than by fixing the client.

It builds the server by resolving the checked-in config (loadConfigresolveConfig) rather than calling the factory, following the nullable-fields.test.ts precedent, so a misspelt preset name in preset-registry.ts or a config naming a dead preset fails here too.

npm run ci passes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.

Suppressed comments (1)

clients/web/src/utils/uriTemplate.ts:68

  • withoutEmptyValues changes RFC 6570 semantics for a defined empty string. RFC 6570 distinguishes an undefined variable from "": {?topic} with { topic: "" } expands to ?topic=, while only an absent key is omitted; the SDK already preserves that distinction. Pass variables through directly and update the unit test that currently locks in omission.
  const template = parseTemplate(uriTemplate);
  if (!template) return uriTemplate;
  return template.expand(withoutEmptyValues(variables));

`expandTemplate` filtered empty values before expanding, which collapsed two
states the spec keeps apart — and the SDK already honors the difference:

    {?topic} with { topic: "" }  →  ?topic=
    {?topic} with {}             →  (omitted)

Filtering made a deliberately-empty value unexpressible through the helper.
Values now pass through untouched, so the expansion is a faithful RFC 6570
expansion for any caller.

Treating an empty string as "not entered yet" is a *preview* concern, not an
expansion one: the panel seeds every declared variable with "" and a text input
cannot express "defined but empty", so the placeholder substitution keeps that
notion — now named `enteredValues` and documented as deliberately divergent,
with a test asserting the two helpers differ here on purpose.

Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
Member Author

Round 3 again reported "no new comments" with one suppressed. It's correct — fixed in bc1237c.

withoutEmptyValues collapsed RFC 6570's undefined-vs-empty-string distinction

Verified against the SDK, which does preserve it:

x://e{?topic}   { topic: "" } → "x://e?topic="     {} → "x://e"
x://e{&topic}   { topic: "" } → "x://e&topic="     {} → "x://e"

Filtering before expanding made a deliberately-empty value unexpressible through the helper. expandTemplate now passes values through untouched, so it is a faithful RFC 6570 expansion for any caller, and the doc comment states the distinction rather than the old (wrong) claim that omission was "what the spec prescribes".

One refinement on the suggestion: the empty-string-means-unfilled notion is still correct — just not in expandTemplate. It belongs to the preview, because the panel seeds every declared variable with "" and a text input has no way to express "defined but empty", so within the preview an empty string genuinely means "not entered yet" and should render {topic} rather than ?topic=. So the filter now lives only in previewTemplate, renamed enteredValues and documented as deliberately divergent from the expansion.

Tests updated accordingly:

  • the test that locked in omission now asserts { topic: "" }foobar://events?topic=;
  • the absent-key test is unchanged ({?a,b} with only a?a=1);
  • a new test asserts the two helpers differ here on purpose, so a future "consistency" cleanup that re-collapses them fails loudly:
expect(expandTemplate("x://e{?t}", { t: "" })).toBe("x://e?t=");
expect(previewTemplate("x://e{?t}", { t: "" })).toBe("x://e?t={t}");

No user-visible change: Read Resource is gated on every variable being non-empty, so the panel never reaches expandTemplate with one.

npm run ci passes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.

Suppressed comments (3)

clients/web/src/utils/uriTemplate.ts:72

  • @modelcontextprotocol/client 2.0.0 does not apply RFC 6570 encoding or operators to non-query expressions containing multiple names: its part.names.length > 1 branch joins the raw values before the encoding/operator switch. Consequently, expandTemplate("x://{a,b}", { a: "foo/bar", b: "x y" }) produces x://foo/bar,x y rather than x://foo%2Fbar,x%20y, and {#a,b} also loses the #. Since this change now discovers both fields and allows submission, these templates generate an invalid URI. Please add multi-variable expansion tests and use or update an expander that handles this branch correctly.
  return template.expand(variables);

clients/web/src/utils/uriTemplate.ts:152

  • UriTemplate.expand can throw even after parsing succeeds—for example, the pinned SDK rejects a variable value over 1,000,000 characters. This call runs while ResourceTemplatePanel renders its preview, and the text inputs have no matching limit, so pasting such a value throws out of render instead of showing validation; the submit path can throw similarly. Catch expansion failures and surface a controlled validation/error state rather than letting them escape.
  let preview = template.expand(values);

clients/web/src/utils/uriTemplate.ts:98

  • This collision loop has quadratic worst-case behavior on a server-supplied template. For example, a template containing zzInspectorUnfilledzz followed by a long run of z characters makes every successively extended candidate collide, and each iteration rescans the full haystack; the SDK accepts templates up to 1 MB, so selecting such a resource can freeze the render thread. Compute the longest conflicting suffix in one pass or use another bounded collision strategy.
  while (haystack.includes(base)) base += "z";

Three follow-ups from review, all confirmed against the SDK.

**Multi-name expressions lost their encoding and operator.** The SDK's
`expandPart` has a `part.names.length > 1` branch that joins the raw values and
returns before reaching the encode/operator switch, so `x://{a,b}` with
`a = "foo/bar"` produced `x://foo/bar,x y`, and `{#a,b}` dropped its `#`. Only
the query operators (`?`, `&`) take a different, correct path. This PR newly
exposes it: the old regex never matched `{a,b}`, so no inputs were rendered,
whereas discovery now offers them and lets the URI be submitted.

Rather than reimplement RFC 6570, such an expression is rewritten to a single
synthetic variable carrying an *array* of the defined values — which the SDK's
single-name branch encodes elementwise and joins with the operator's separator,
which is exactly the spec's rule. Templates without the shape are untouched.

Note the SDK's `UriTemplate.match` cannot match a multi-name expression either
(it returns null for every URI), so an SDK-based server can never route one and
there is no round trip to add to the integration suite. The client can still
emit the spec-correct URI a conforming server needs, and that is what the unit
tests assert; the comment records why the coverage sits there.

**Expansion could throw after parsing succeeded.** The SDK enforces a
1,000,000-character per-value ceiling at expansion time, and the inputs have no
matching limit. The preview expands during render, so a pasted value took the
panel down instead of showing a problem. Both helpers are now total:
`expandTemplate` returns `null` and the panel disables Read Resource, and
`previewTemplate` falls back to the raw template.

**The collision loop was quadratic.** A server-supplied template (up to 1 MB)
holding the token followed by a long run of `z`s made every extended candidate
collide in turn, each rescanning the whole input. It now measures the longest
following run in a single pass and clears it by one.

Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
Member Author

Round 4: three suppressed comments, all three confirmed against the SDK and all three fixed in 81fbaac. This was the strongest round — thanks.

1. Multi-name expressions lose encoding and the operator

Confirmed, and the root cause is exactly where it was pointed. expandPart returns early:

if (part.names.length > 1) {
  const values = part.names.map((name) => variables[name]).filter((v) => v !== void 0);
  if (values.length === 0) return "";
  return values.map((v) => Array.isArray(v) ? v[0] : v).join(",");   // ← never reaches encodeValue or the operator switch
}
x://{a,b}   { a: "foo/bar", b: "x y" }  →  "x://foo/bar,x y"
x://{#a,b}                              →  "x://foo/bar,x y"    (the # is gone)
x://{?a,b}                              →  "x://?a=foo%2Fbar&b=x%20y"   (correct — different branch)

And the point about this PR exposing it is the right one: the old regex never matched {a,b}, so no inputs were rendered; discovery now offers them and lets an invalid URI be submitted.

Fixed without reimplementing RFC 6570. Such an expression is rewritten to a single synthetic variable holding an array of the defined values — which the SDK's single-name branch encodes elementwise and joins with the operator's separator, which is precisely the spec's rule for a multi-name expression. x://{a,b} becomes x://{__inspectorGroup0__} expanded with ["foo/bar", "x y"]. Templates without the shape are returned untouched and pay nothing.

Seven tests added covering the simple, +, #, ., and / operators, the query form (unchanged), a partially-defined group, and a fully-undefined one. One correction to the suggested expectation: {#a,b} is a reserved expansion like {+…}, so / is preserved and only the space is encoded — x://e#foo/bar,x%20y.

One thing worth flagging as out of reach here: UriTemplate.match cannot match a multi-name expression either — it returns null for every URI, including a correctly-encoded one. So an SDK-based server can never route such a template and there is no round trip to add to the integration suite. The client can still emit the spec-correct URI a conforming server needs, which is what the unit tests assert; a comment records why that coverage lives there rather than in the integration file. The complete fix is upstream in the SDK, on both the expand and match sides.

2. expand can throw after parsing succeeds

Confirmed — the ceiling is per value and checked at expansion time:

len=1000000 → ok
len=1000001 → throws "Variable value exceeds maximum length of 1000000 characters"

Both helpers are now total. expandTemplate returns string | null, and the panel gates on it:

const expandedUri = allFilled ? expandTemplate(uriTemplate, variables) : null;
const canSubmit = expandedUri !== null;

so Read Resource stays disabled rather than sending a URI we know is wrong. previewTemplate falls back to the raw template — it runs during render, where an escaping throw unmounts the panel. Tests cover both, plus a panel test driving the oversized value through fireEvent and asserting the button stays disabled.

3. Quadratic collision loop

Correct. Now a single pass: measure the longest run of z following any occurrence of the token and clear it by one, which no occurrence can then match.

for (let at = haystack.indexOf(UNFILLED_SENTINEL); at !== -1; at = haystack.indexOf(...)) {
  // measure the run of `z` immediately after
}
return UNFILLED_SENTINEL + "z".repeat(longestRun + 1);

Regression test uses a template with a 5,000-character z run, which the old loop would have rescanned 5,000 times.

npm run ci passes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.

Suppressed comments (4)

clients/web/src/utils/uriTemplate.ts:124

  • Explode modifiers are retained in these group member names, but UriTemplate.variableNames strips *, so the form stores a while applyGroups looks up a*. For example, {/a*,b} silently drops a even when the user filled it. Normalize group names the same way as the SDK before projecting values.
      const names = body.split(",").map((name) => name.trim());

clients/web/src/utils/uriTemplate.ts:127

  • The synthetic name can collide with a legitimate template variable. For x://{a,b}/{__inspectorGroup0__}, both rewritten expressions reference __inspectorGroup0__, and applyGroups overwrites the user's value, producing the group twice. Generate a name that is absent from all original variable names before rewriting.
      const synthetic = groupName(groups.size);
      groups.set(synthetic, names);
      return `{${operator}${synthetic}}`;

clients/web/src/utils/uriTemplate.ts:95

  • The standard RFC 6570 ; operator is included here, but @modelcontextprotocol/client 2.0.0's UriTemplate does not recognize it. As a result, {;a} is treated as a variable literally named ;a and expands without the required ;a=, while {;a,b} is rewritten to a synthetic name that expands to nothing. Please either implement path-parameter expansion locally or reject these templates instead of sending a known-invalid URI, and cover both forms.

This issue also appears in the following locations of the same file:

  • line 124
  • line 125
const MULTI_NAME_EXPRESSION = /\{([+#./;]?)([^{}?&][^{}]*)\}/g;

clients/web/src/utils/uriTemplate.ts:208

  • Advancing by the full token length skips overlapping occurrences because this token starts and ends with zz. A literal such as zzInspectorUnfilledzzInspectorUnfilledzzz0zz contains an overlapping occurrence with a longer trailing run; it is missed, so the chosen sentinel collides and the preview rewrites literal URI text into a placeholder. Advance by one character so overlaps are measured too.
    at = haystack.indexOf(UNFILLED_SENTINEL, at + UNFILLED_SENTINEL.length)

…1919)

Four follow-ups, all in the multi-name machinery added last round, all
confirmed against the SDK.

**Exploded members were looked up under the wrong key.** `variableNames` strips
a trailing `*` (`{/a*,b}` → `["a","b"]`) so the form stores `a`, while the group
kept `a*` and `applyGroups` found nothing — silently dropping a filled value.
Member names are now normalized exactly as the SDK does: strip a trailing `*`,
keep a prefix modifier.

**The synthetic group name could collide with a real variable.** A template
declaring `__inspectorGroup0__` had the group overwrite the user's value and
emit itself twice. The prefix is now padded past any occurrence in the template.

**The `;` operator was made worse, not better.** The SDK does not implement it —
`{;a}` parses as a variable literally named ";a" and expands to the bare value,
dropping the required `;a=` — and the rewrite turned `{;a,b}` into a synthetic
that expanded to nothing. `;` is out of the rewrite's operator class, and a
template using it is now declined outright: `expandTemplate` returns null (so
the panel withholds the request rather than sending a knowingly invalid URI)
and the preview shows the template as declared.

**The collision scan skipped overlapping occurrences.** The token begins and
ends with `zz`, so it can overlap itself, and
`zzInspectorUnfilledzzInspectorUnfilledzzz0zz` holds a second occurrence whose
trailing run is the longer one. Advancing by the token's length missed it and
chose a colliding placeholder. The scan advances one character at a time, and
the logic is now one helper shared with the group-prefix padding so the two
cannot drift.

Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
Member Author

Round 5: four suppressed comments, all four in the machinery added last round, all four confirmed and fixed in 722f24d.

1. Explode modifiers broke group member lookup

Confirmed — variableNames strips a trailing * while my split kept it:

x://{/a*,b}   variableNames: ["a", "b"]     my group members: ["a*", "b"]

so the form stored a, applyGroups looked up a*, found nothing, and dropped a filled value. Member names are now normalized exactly the way the SDK does — strip a trailing *, keep a prefix modifier (a:3 stays a:3, matching the boundary pinned earlier in this PR). Mirroring rather than inventing a normalization is the point; diverging in either direction reintroduces the drop.

Test asserts both halves: templateVariableNames("x://e{/a*,b}")["a","b"], and the expansion → x://e/one/two.

2. Synthetic name could collide with a real variable

Confirmed. The prefix is now padded past any occurrence in the template, so x://{a,b}/{__inspectorGroup0__} expands to x://one,two/mine — the group and the user's own variable stay distinct. Regression test added.

3. The ; operator — I made it worse, not better

The sharpest of the four. Confirmed the premise:

x://e{;a}     variableNames: [";a"]        expand({a:"1"}) → "x://e1"    (no `;a=`)
x://e{;a,b}   variableNames: [";a","b"]

And the rewrite genuinely regressed it: {;a,b} became {;__inspectorGroup0__}, whose parsed name is ";__inspectorGroup0__", which applyGroups never sets — expanding to nothing.

Taking the "reject rather than send a known-invalid URI" option, since no arrangement of the SDK's branches produces the right output. ; is out of the rewrite's operator class, and a template using it is declined: expandTemplate returns null so the panel withholds the request, and the preview shows the template as the server declared it. Four tests: both forms return null, the preview form, and one asserting a literal ; outside an expression (x://e;q/{a}) is unaffected.

4. Overlapping occurrences skipped in the collision scan

Confirmed with the exact literal given — the token begins and ends with zz, so it overlaps itself:

zzInspectorUnfilledzzInspectorUnfilledzzz0zz
advance-by-length finds: [0]       advance-by-one finds: [0, 19]

and index 19 is the one with the longer trailing run, so the chosen placeholder collided. The scan now advances one character at a time. I also pulled the logic into one helper shared with the group-prefix padding from #2 above, so the two padding sites cannot drift — the same overlap bug would otherwise have been latent in the new one.

npm run ci passes (re-run after the v2/main merge that landed on the branch).

@cliffhall
cliffhall requested a balanced review from Copilot August 16, 2026 23:44
@cliffhall

Copy link
Copy Markdown
Member Author

Round 6: one suppressed comment, and it's the right architectural call — fixed in 00549c9.

The correction was web-only, which undercut the PR's own premise

Exactly right, and the citation was accurate: readResourceFromTemplate still called the SDK directly, so the same template resolved two different ways depending on which client drove it.

x://{a,b}  with { a: "foo/bar", b: "x y" }

  web panel (this PR's helper)         → x://foo%2Fbar,x%20y
  TUI/CLI via readResourceFromTemplate → x://foo/bar,x y        ← raw multi-name branch

The helper now lives in core/uri/uriTemplate.ts and all three consumers route through it:

  • the web ResourceTemplatePanel — discovery, expansion, preview;
  • InspectorClient.readResourceFromTemplate — the CLI/TUI submit path. The last direct new UriTemplate call in core/ is gone;
  • the TUI's uriTemplateToForm, which I took the opportunity to share discovery with as well. That was the other half of the divergence: the TUI built its fields from template.variableNames raw, so a repeated name produced two identical fields. Both clients now offer the same fields for a given template.

expandTemplate returning null becomes a thrown error on the client path, preserving the existing Failed to expand URI template contract that its callers and the backfill tests rely on.

Supporting changes required by the repo's own rules:

  • core/uri/** added to the web coverage include in clients/web/vite.config.ts, so it stays under the ≥90 gate (it is at 100%);
  • tests moved to clients/web/src/test/core/uri/uriTemplate.test.ts, per the placement rule that core/ tests mirror core's layout there;
  • structure trees and the coverage-include list updated in both AGENTS.md and the root README.md;
  • the TUI test that asserted the old local console.error now asserts the shared helper's warning, plus two new cases covering a query expression and a repeated name.

One new integration test asserts the two paths cannot drift again — it reads through readResourceFromTemplate and checks the expandedUri is both correctly encoded and identical to what the panel's expandTemplate produces:

expect(invocation.expandedUri).toBe("foobar://events/foo%2Fbar");
expect(invocation.expandedUri).toBe(
  expandTemplate("foobar://events/{topic}", { topic: "foo/bar" }),
);

npm run ci passes — all three durable guards green with the new folder (971 files format-gated, 936 typechecked), coverage 330/330, build-gate fired, all six smokes OK, Storybook 113/113. One useServers waitFor timeout appeared in the instrumented full-suite run and passed both in isolation and on a clean coverage re-run; it is untouched by this diff.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated no new comments.

Suppressed comments (2)

core/uri/uriTemplate.ts:186

  • projected starts as a copy of every caller-supplied parameter, so when none of the real group members is defined, an extra parameter whose key happens to equal the generated synthetic name remains in the object and expands the expression. The SDK previously ignored undeclared parameters, but expandTemplate("x://{a,b}", { __inspectorGroup0__: "injected" }) now emits x://injected instead of x://. Remove the synthetic key in the omitted-group branch.
    if (present.length > 0) projected[synthetic] = present;

core/uri/uriTemplate.ts:35

  • The SDK constructor does not reject every malformed expression. In @modelcontextprotocol/client@2.0.0, new UriTemplate("x://{}") succeeds with no variable names and expands to x://; consequently the panel's allFilled check is vacuously true and submits a URI different from the advertised template. This violates this helper's null-on-malformed contract. Validate the expression grammar (at minimum, require a non-empty variable list) before accepting the SDK parse.
  try {
    return new UriTemplate(uriTemplate);

…or a group (#1919)

Two follow-ups, both confirmed against the SDK.

**An empty expression was accepted.** `new UriTemplate("x://{}")` parses,
reports no variable names, and expands to `x://` — so the panel rendered no
inputs, its "every variable is filled" check was vacuously true, and Read
Resource would submit a URI that is not the template the server advertised.
`{ }`, `{,}`, and `{a,}` are the same defect with some members missing.

Validation now runs on the template *as the server declared it*, before any
rewriting: putting the check inside `parseTemplate` was not enough, because the
multi-name grouping folds `{a,}` into a synthetic single-name expression and
masks the empty member. The `;` check moves into the same place, so the two
"cannot handle this template" cases are one function with one reason string
rather than two guards at two layers.

**A caller value could stand in for a group.** `applyGroups` starts from a copy
of every supplied variable, so a caller passing a key equal to the generated
synthetic name had it expand the group's expression:
`expandTemplate("x://{a,b}", { __inspectorGroup0__: "injected" })` emitted
`x://injected` for an expression whose real members are all undefined, where the
SDK ignores undeclared variables entirely. The omitted-group branch now deletes
the key rather than leaving the caller's value under it.

Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
Member Author

Round 7: two suppressed comments, both confirmed and both fixed in 892914f.

1. An empty expression was accepted — the more serious of the two

Confirmed exactly:

new UriTemplate("x://{}")   parses. variableNames: []   expand({}) → "x://"
new UriTemplate("x://{ }")  parses. variableNames: []   expand({}) → "x://"
new UriTemplate("x://{,}")  parses. variableNames: []   expand({}) → "x://"

and the consequence is the one described: no variables discovered → no inputs rendered → allFilled vacuously true → Read Resource enabled → submits x://, which is not the template the server advertised. That is the worst possible way to violate the null-on-malformed contract, because it fails silently in the direction of sending a request.

Validation now runs on the template as declared, before any rewriting. Worth noting since it wasn't obvious: putting the check inside parseTemplate (the natural spot) is not sufficient — groupMultiNameExpressions runs first in the two entry points and folds {a,} into a synthetic single-name expression, so by the time the parse happens the empty member is gone. My first attempt did exactly that and {,} / {a,} still slipped through; the tests caught it.

The ; check moved into the same function while I was there, so the two "cannot handle this template" cases are one unsupportedReason() returning a reason string, rather than two guards at two different layers.

Five table-driven cases ({}, { }, {,}, {a,}, {?}) assert both halves of the contract — templateVariableNames[] and expandTemplatenull — plus a panel test asserting Read Resource stays disabled and the preview shows x://{} rather than the x:// the SDK would expand it to.

2. A caller value could stand in for a group

Correct. projected starts from a copy of every supplied variable, so a key equal to the generated synthetic name survived into the expansion:

expandTemplate("x://{a,b}", { __inspectorGroup0__: "injected" })
  → "x://injected"     where the SDK ignores undeclared variables → "x://"

The omitted-group branch now deletes the key rather than leaving the caller's value under it, so the projection is always assign-or-delete and never inherits. Regression test added alongside the existing one for the inverse case (a template that legitimately declares that name still gets the user's value).

npm run ci passes end to end with no flakes this run: guards green (971 format-gated, 936 typechecked, dep-lockstep OK), unit 270, coverage 330/330, build-gate fired, all six smokes OK, Storybook 113/113.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

core/uri/uriTemplate.ts:404

  • This restoration loop rescans the entire expanded URI once per variable, making preview generation O(variable-count × URI-length). The SDK accepts server-controlled templates with up to 10,000 expressions and 1 MB of text, so selecting a large template can trigger billions of character scans during React render and freeze the browser. Build a sentinel-to-placeholder map and replace all sentinel tokens in one regex/pass instead.
  let preview = expanded;
  names.forEach((name, index) => {
    if (filled[name] !== undefined) return;
    // The sentinel is unreserved, so it appears in the expansion unencoded.
    preview = preview.split(sentinelFor(base, index)).join(`{${name}}`);

Comment thread core/uri/uriTemplate.ts
values: Record<string, string | string[]>,
): string | null {
try {
return template.expand(values);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 8: one inline comment and one suppressed. Both confirmed, both fixed in 24345d6 — and the two turned out to have a single answer.

1. Delegating encoding to the SDK is not RFC-conformant

This is the most substantive finding in the review so far, and all three claims reproduce exactly:

x://{v}   "!"     → "x://!"           RFC 6570 §3.2.1: x://%21
x://{v}   "*'()"  → "x://*'()"                          x://%2A%27%28%29
x://{+v}  "[a]"   → "x://%5Ba%5D"                       x://[a]
x://{#v}  "[a]"   → "x://#%5Ba%5D"                      x://#[a]
x://{+v}  "%41"   → "x://%2541"                         x://%41

encodeURIComponent under-encodes the five sub-delims it exempts; encodeURI over-encodes the gen-delims [ and ] that reserved expansion exists to pass through, and re-encodes a well-formed pct-triplet. And the point that this now matters more because every client routes through here is the right framing.

Rather than patch around the SDK, I made the division of labour explicit. Each variable now reaches the SDK as a sentinel of unreserved characters and is replaced with its real rendering afterwards:

  • structure — operators, separators, group joins, which expressions appear at all — stays the SDK's job, where it is correct;
  • encoding becomes ours, against the explicit RFC 3986 unreserved (ALPHA / DIGIT / - . _ ~) and reserved (gen-delims + sub-delims) sets, with pct-triplet passthrough under +/# and whole-code-point encoding so an astral character yields its UTF-8 octets rather than lone surrogates.

Regressions added for all of it: each of !*'() individually in a simple expansion and together in a query one, gen-delims preserved under both + and #, the full reserved set round-tripping under +, triplet passthrough, a bare % and a malformed %zz both encoding to %25, the absence of triplet passthrough in a simple expansion (%41%2541, which is correct there), and 😀%F0%9F%98%80.

2. The restoration loop rescanned the URI once per variable

Also correct, and the restructure above resolves it as a side effect: substitution is now a single regex pass (base(\d+)zz → rendering) instead of one split/join per variable, so it is O(length) rather than O(variables × length) on a template the SDK will accept at 1 MB with 10,000 expressions. Test added with 200 variables, alternating filled and unfilled, which also exercises the multi-digit index boundary at scale.

Two things fell out of the restructure worth noting:

  • expandTemplate and previewTemplate are now one routine differing only in how an unset variable renders (omitted vs {name}), which removes the last place the two could drift.
  • The sentinel base now only has to avoid the template's own text — values never appear in the string being substituted, and a single pass does not rescan what it inserts, so a value equal to the token is inherently safe rather than defended against.

One consequence I had to handle rather than inherit: the SDK's per-value length ceiling no longer sees the real value (it sees a short sentinel), so the same 1,000,000-character bound is now enforced here. Without it the helper would emit a URI the SDK itself refuses to build, and the panel would quietly lose the "withhold rather than send something absurd" behavior added in round 4.

npm run ci passes: guards green, unit 270, coverage 330/330, build-gate fired, all six smokes OK, Storybook 113/113.

…1919)

The helper delegated value encoding to the SDK, which is not RFC 6570
conformant in three ways — all verified against the pinned version, all
reachable from a plain text input, and all now shared by every client:

    {v}  with "!"     → x://!        RFC: x://%21
    {+v} with "[a]"   → x://%5Ba%5D  RFC: x://[a]
    {+v} with "%41"   → x://%2541    RFC: x://%41

Simple and query expansions use `encodeURIComponent`, which leaves `!*'()`
bare; `+` and `#` use `encodeURI`, which escapes the gen-delims `[` and `]`
that reserved expansion exists to pass through, and re-encodes a well-formed
pct-triplet.

Rather than patch around it, the split is made explicit: each variable now
reaches the SDK as a sentinel of unreserved characters and is replaced by its
real rendering afterwards, so *structure* — operators, separators, which
expressions appear at all — stays the SDK's job while *encoding* becomes ours,
done against the explicit RFC 3986 unreserved and reserved sets.

That also answers the separate performance point: substitution is a single
regex pass over the expansion rather than one rescan per variable, which was
O(variables × length) during render on a template the SDK will accept at 1 MB
with 10,000 expressions. `expandTemplate` and `previewTemplate` collapse into
one routine differing only in how an unset variable renders, and the sentinel
base now only has to avoid the template's own text, since values never appear
in the string being substituted.

One consequence handled explicitly: the SDK's per-value length ceiling no
longer sees the real value, so the same bound is enforced here — otherwise the
helper would emit a URI the SDK itself refuses to build, and the panel would
lose its "withhold rather than send something absurd" behavior.

Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
Member Author

Round 8: one inline comment and one suppressed. Both confirmed, both fixed in 24345d6 — and the two turned out to have a single answer.

1. Delegating encoding to the SDK is not RFC-conformant

This is the most substantive finding in the review so far, and all three claims reproduce exactly:

x://{v}   "!"     → "x://!"           RFC 6570 §3.2.1: x://%21
x://{v}   "*'()"  → "x://*'()"                          x://%2A%27%28%29
x://{+v}  "[a]"   → "x://%5Ba%5D"                       x://[a]
x://{#v}  "[a]"   → "x://#%5Ba%5D"                      x://#[a]
x://{+v}  "%41"   → "x://%2541"                         x://%41

encodeURIComponent under-encodes the five sub-delims it exempts; encodeURI over-encodes the gen-delims [ and ] that reserved expansion exists to pass through, and re-encodes a well-formed pct-triplet. And the point that this now matters more because every client routes through here is the right framing.

Rather than patch around the SDK, I made the division of labour explicit. Each variable now reaches the SDK as a sentinel of unreserved characters and is replaced with its real rendering afterwards:

  • structure — operators, separators, group joins, which expressions appear at all — stays the SDK's job, where it is correct;
  • encoding becomes ours, against the explicit RFC 3986 unreserved (ALPHA / DIGIT / - . _ ~) and reserved (gen-delims + sub-delims) sets, with pct-triplet passthrough under +/# and whole-code-point encoding so an astral character yields its UTF-8 octets rather than lone surrogates.

Regressions added for all of it: each of !*'() individually in a simple expansion and together in a query one, gen-delims preserved under both + and #, the full reserved set round-tripping under +, triplet passthrough, a bare % and a malformed %zz both encoding to %25, the absence of triplet passthrough in a simple expansion (%41%2541, which is correct there), and 😀%F0%9F%98%80.

2. The restoration loop rescanned the URI once per variable

Also correct, and the restructure above resolves it as a side effect: substitution is now a single regex pass (base(\d+)zz → rendering) instead of one split/join per variable, so it is O(length) rather than O(variables × length) on a template the SDK will accept at 1 MB with 10,000 expressions. Test added with 200 variables, alternating filled and unfilled, which also exercises the multi-digit index boundary at scale.

Two things fell out of the restructure worth noting:

  • expandTemplate and previewTemplate are now one routine differing only in how an unset variable renders (omitted vs {name}), which removes the last place the two could drift.
  • The sentinel base now only has to avoid the template's own text — values never appear in the string being substituted, and a single pass does not rescan what it inserts, so a value equal to the token is inherently safe rather than defended against.

One consequence I had to handle rather than inherit: the SDK's per-value length ceiling no longer sees the real value (it sees a short sentinel), so the same 1,000,000-character bound is now enforced here. Without it the helper would emit a URI the SDK itself refuses to build, and the panel would quietly lose the "withhold rather than send something absurd" behavior added in round 4.

npm run ci passes: guards green, unit 270, coverage 330/330, build-gate fired, all six smokes OK, Storybook 113/113.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

core/uri/uriTemplate.ts:312

  • A repeated variable is encoded using only the operator from its first occurrence. RFC 6570 allows the same name in expressions with different operators, so x://{+a}/{a} with a = "/" must preserve / in the reserved expansion but encode it as %2F in the simple expansion. This map and the single per-name rendering apply the first encoding to both occurrences, producing the wrong URI. Track/render values per expression occurrence (or otherwise include the operator in the placeholder identity), and add a mixed-operator repeated-name regression.
      if (!operators.has(name)) operators.set(name, operator);

Comment thread core/uri/uriTemplate.ts Outdated
Comment on lines +284 to +285
const codePoint = String.fromCodePoint(value.codePointAt(at) as number);
out += encodeURIComponent(codePoint).replace(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 9: one inline, one suppressed. Both real, both consequences of last round's restructure, both fixed in 0a96cc1.

1. An unpaired surrogate throws out of render

Confirmed — encodeURIComponent("\uD800") raises URIError: URI malformed, and the point about where it throws is the important half: value encoding runs outside the SDK's try/catch, and previewTemplate runs during React render, so it would take the panel down rather than disable its submit.

A lone surrogate has no UTF-8 encoding under any operator, so there is no correct rendering to fall back to — it now fails the whole expansion rather than silently dropping one variable. Detected before anything is placed (codePointAt reports the unpaired surrogate value directly, so no exception is needed to find it): expandTemplatenull, preview → the raw template, panel → Read Resource disabled.

Tests for a lone high surrogate, a lone low surrogate, and one embedded in otherwise-valid text, each asserting both not.toThrow() and null, plus the preview fallback.

2. A repeated name with different operators got one encoding

Also correct, and it exposed a structural limit rather than a slip. x://{+a}-{a} with a = "/" must give x:///-%2F. The per-name operator map applied the first occurrence's operator to both — but the deeper reason a map cannot work here is that the SDK looks values up by name, so one key cannot carry two renderings no matter how the operator is tracked.

So the rewrite generalizes: instead of only multi-name expressions, every non-query expression now becomes its own synthetic variable, making an occurrence the unit of rendering — which is what RFC 6570 actually specifies.

Query expressions are deliberately the exception. ? and & emit the variable's name into the URI, so renaming {?topic} would produce ?__inspectorGroup0__=. They also don't need it: their branch already encodes correctly, and ?/& share one encoding, so every query occurrence of a name can share one value keyed by that name. A name used in both a query and a non-query expression is fine — the non-query occurrence no longer uses the bare name, so they cannot collide.

Four tests: the mixed +/simple pair, the repeated name still offered as one field, a +/query pair (x://{+a}{?a}x://a/b?a=a%2Fb), and a simple/fragment pair (x://{a}{#a}x://%5Bx%5D#[x]).

Two things fell out of this worth noting:

  • Name discovery now comes from the same scan the expander uses, rather than from UriTemplate.variableNames. The form's fields and the expander's lookups are one list by construction instead of two code paths that happened to agree — which is the class of bug this comment was.
  • My first expectation for the mixed-operator test was wrong (x:///%2F), because I miscounted the literal / between the two expressions. The implementation was right; the test now uses - as the separator so every slash in the output is one the expansion produced.

npm run ci passes: guards green, coverage 330/330, build-gate fired, all six smokes OK, Storybook 113/113.

Two more, both consequences of the previous round's restructure.

**An unpaired surrogate threw out of render.** `encodeURIComponent("\uD800")`
raises `URIError`, and value encoding runs outside the SDK's try/catch — so
both helpers could throw, and `previewTemplate` runs during React render, which
would take the panel down rather than disable its submit. A lone surrogate has
no UTF-8 encoding under any operator, so it is now detected up front and fails
the whole expansion: `expandTemplate` returns null, the preview falls back to
the template, and the panel withholds the request.

**A name repeated under different operators got one encoding.** RFC 6570 allows
`x://{+a}-{a}`, where `a = "/"` must stay `/` under the reserved operator and
become `%2F` in the simple expansion. The per-name operator map applied the
first occurrence's operator to both — and the underlying reason one map cannot
work is that the SDK looks values up *by name*, so a single key cannot carry
two renderings.

So the rewrite generalizes from "multi-name expressions" to **every non-query
expression**, each becoming its own synthetic variable. An occurrence is then
the unit of rendering, which is what RFC 6570 actually specifies. Query
expressions stay untouched — `?`/`&` emit the variable's *name* into the URI,
so renaming `{?topic}` would produce `?__inspectorGroup0__=` — and they need no
rewrite, since `?` and `&` share one encoding and their branch is already
correct.

Name discovery now comes from that same scan rather than from
`UriTemplate.variableNames`, so the form's fields and the expander's lookups are
one list by construction instead of two paths that happen to agree.

Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
Member Author

Round 9: one inline, one suppressed. Both real, both consequences of last round's restructure, both fixed in 0a96cc1.

1. An unpaired surrogate throws out of render

Confirmed — encodeURIComponent("\uD800") raises URIError: URI malformed, and the point about where it throws is the important half: value encoding runs outside the SDK's try/catch, and previewTemplate runs during React render, so it would take the panel down rather than disable its submit.

A lone surrogate has no UTF-8 encoding under any operator, so there is no correct rendering to fall back to — it now fails the whole expansion rather than silently dropping one variable. Detected before anything is placed (codePointAt reports the unpaired surrogate value directly, so no exception is needed to find it): expandTemplatenull, preview → the raw template, panel → Read Resource disabled.

Tests for a lone high surrogate, a lone low surrogate, and one embedded in otherwise-valid text, each asserting both not.toThrow() and null, plus the preview fallback.

2. A repeated name with different operators got one encoding

Also correct, and it exposed a structural limit rather than a slip. x://{+a}-{a} with a = "/" must give x:///-%2F. The per-name operator map applied the first occurrence's operator to both — but the deeper reason a map cannot work here is that the SDK looks values up by name, so one key cannot carry two renderings no matter how the operator is tracked.

So the rewrite generalizes: instead of only multi-name expressions, every non-query expression now becomes its own synthetic variable, making an occurrence the unit of rendering — which is what RFC 6570 actually specifies.

Query expressions are deliberately the exception. ? and & emit the variable's name into the URI, so renaming {?topic} would produce ?__inspectorGroup0__=. They also don't need it: their branch already encodes correctly, and ?/& share one encoding, so every query occurrence of a name can share one value keyed by that name. A name used in both a query and a non-query expression is fine — the non-query occurrence no longer uses the bare name, so they cannot collide.

Four tests: the mixed +/simple pair, the repeated name still offered as one field, a +/query pair (x://{+a}{?a}x://a/b?a=a%2Fb), and a simple/fragment pair (x://{a}{#a}x://%5Bx%5D#[x]).

Two things fell out of this worth noting:

  • Name discovery now comes from the same scan the expander uses, rather than from UriTemplate.variableNames. The form's fields and the expander's lookups are one list by construction instead of two code paths that happened to agree — which is the class of bug this comment was.
  • My first expectation for the mixed-operator test was wrong (x:///%2F), because I miscounted the literal / between the two expressions. The implementation was right; the test now uses - as the separator so every slash in the output is one the expansion produced.

npm run ci passes: guards green, coverage 330/330, build-gate fired, all six smokes OK, Storybook 113/113.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated no new comments.

Suppressed comments (3)

README.md:250

  • This says the SDK expander has only two RFC departures, but the new helper also replaces its non-conformant character encoding (including simple/query sub-delimiters, reserved [], and percent triplets). Update the summary so the documented rationale reflects what this PR now maintains.
All three clients now go through one shared helper, [`core/uri/uriTemplate.ts`](./core/uri/uriTemplate.ts) — the web panel, the TUI's form builder, and `InspectorClient.readResourceFromTemplate` — so a template cannot resolve differently depending on where it is driven from. It wraps the SDK's `UriTemplate` and corrects the two places that expander departs from RFC 6570: a multi-name expression (`{a,b}`) skips both encoding and its operator, and the `;` path-parameter operator is unimplemented (such a template is declined rather than expanded to a knowingly invalid URI).

core/uri/uriTemplate.ts:319

  • The expansion path validates only the rewritten text, not the server-supplied template. A template over the SDK's 1,000,000-character limit whose size is concentrated in a non-query variable name is compressed to a short synthetic slot, so expandTemplate accepts it (and can reduce it to a different URI such as x://) even though templateVariableNames rejected the original. Parse the original before rewriting so malformed/over-limit templates consistently return null, and add a boundary regression.
  const { text, slots, queryNames, order } = rewriteExpressions(uriTemplate);
  const template = parseTemplate(text);

core/uri/uriTemplate.ts:14

  • This header is stale after the encoding and occurrence-aware rewrites: it still says there are only two SDK gaps and points to the nonexistent groupMultiNameExpressions. Reference the current helpers instead so the module documentation matches the implementation.
 * These wrap the SDK's `UriTemplate`, correcting the two places its expander
 * departs from RFC 6570 (see `groupMultiNameExpressions` and
 * `unsupportedReason`). Living in `core/` is what makes the correction

…1919)

`expandTemplate` parsed only the *rewritten* text. The rewrite replaces
variable names with short synthetics, shrinking both the template and every
name — and the SDK enforces a length limit on each at construction — so a
template it would reject could slip through here while `templateVariableNames`,
which parses the original, rejected it: an empty form beside an expansion
happily producing some shorter URI. The original is now parsed first, so the
two agree. The second parse (of the rewritten text) can no longer fail, since
rewriting only ever shortens; that branch is marked unreachable rather than
left as dead-looking code.

The module header and the README also still described the state after the
*first* round of this work — "the two places the expander departs from RFC
6570", pointing at a `groupMultiNameExpressions` that no longer exists. Both
now describe what the helper actually maintains: value encoding against the RFC
3986 sets, the per-occurrence rewrite that fixes multi-name expressions and a
name repeated under different operators, and the shapes that are declined
rather than expanded into a knowingly invalid URI. The AGENTS.md entry is
updated in the same pass so a third copy isn't left stale.

Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
Member Author

Round 10: three suppressed comments — one real bug, two documentation-staleness items. All fixed in ae7b34c.

1. The expansion path validated only the rewritten text

Correct, and the mechanism is exactly as described. The SDK validates both the template and each variable name at construction:

UriTemplate.validateLength(template, MAX_TEMPLATE_LENGTH, "Template");
for (const name of names) UriTemplate.validateLength(name, MAX_VARIABLE_LENGTH, "Variable name");

and the rewrite replaces variable names with short synthetics, shrinking both. So an over-limit template could be compressed past the check and expand happily, while templateVariableNames — which parses the original — rejected it. The failure mode is the nasty combination: an empty form beside an expansion that still produces a URI.

expandWithPlaceholders now parses the original first, before rewriting, so discovery and expansion agree by construction. The second parse (of the rewritten text) can no longer fail — rewriting only ever shortens — so it is marked unreachable rather than left looking like a live branch.

Boundary regressions for both limits, each asserting discovery and expansion agree: an over-long variable name, and an over-long template.

2 & 3. The module header and README rationale were stale

Both fair, and my fault for letting prose lag three rounds of restructuring. The header still said "the two places its expander departs from RFC 6570" and pointed at groupMultiNameExpressions, which no longer exists — it became rewriteExpressions when the rewrite generalized from multi-name expressions to every non-query expression.

Both now describe what the helper actually maintains:

  • value encoding, done here against the explicit RFC 3986 sets, because the SDK's encodeURIComponent/encodeURI leave !*'() bare, escape the gen-delims [ ], and double-encode a percent triplet;
  • the per-occurrence rewrite, covering both multi-name expressions and a name repeated under different operators;
  • the declined shapes ({} and ;), which are refused rather than expanded into a knowingly invalid URI.

I also updated the core/uri entry in AGENTS.md in the same pass, since that was a third copy of the same rationale and would otherwise have been left stale by a fix to the other two.

npm run ci — the validate half is green (unit 270, cli 25, tui 26, launcher 1); the coverage/smoke/Storybook stages were still running when this was pushed at the maintainer's request, and I'll follow up here if any of them reports anything.

@cliffhall

Copy link
Copy Markdown
Member Author

Following up on the caveat in the previous comment: the rest of npm run ci has since finished clean on ae7b34cd — coverage 330/330 with no threshold errors, build-gate fired, all six smokes OK, Storybook 113/113, exit 0. Nothing outstanding.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

core/uri/uriTemplate.ts:87

  • The empty-member check runs before removing the explode modifier. As a result, SDK-accepted malformed expressions such as {*} or {a,*} pass this guard; rewriteExpressions then turns * into an empty name, so discovery returns "" and the web/TUI render a blank variable field instead of declining the template. Normalize members the same way as the rewrite before checking emptiness.
    const names = body.replace(/^[+#./?&]/, "").split(",");
    if (names.some((name) => name.trim().length === 0)) {

core/mcp/inspectorClient.ts:4981

  • This message is inaccurate for templates the helper deliberately does not implement. For example, {;a} is valid RFC 6570, but unsupportedReason rejects it, so saying the template “cannot be expanded per RFC 6570” incorrectly blames the input rather than Inspector's current limitation. State that Inspector could not expand it (or propagate the helper's concrete reason).
        `Failed to expand URI template "${uriTemplateString}": the template or one of its values cannot be expanded per RFC 6570.`,

Comment thread core/uri/uriTemplate.ts
if (run > longestRun) longestRun = run;
}
// -1 means the token is absent, so it needs no padding at all.
return token + pad.repeat(longestRun + 1);
@cliffhall

Copy link
Copy Markdown
Member Author

Closing as a duplicate of #2035, which fixes the same issue (#1919) and is the implementation we are taking forward.

Both PRs implement RFC 6570 expansion for the Resources form, but they take opposite architectures: this one delegates URI structure to the SDK's UriTemplate.expand (supplying its own encoding through a sentinel rewrite), while #2035 owns the expander outright. Three measured differences decided it:

Two things this PR got right that #2035 did not have been ported over to it: declining a template whose expression declares no variable ({}, {a,}, {?}) instead of silently expanding it away, and withholding the read when expansion fails rather than submitting the raw template. Thanks — those were the better call.

The showcase server and integration coverage land through #2035, so nothing here is lost.

@cliffhall cliffhall closed this Aug 17, 2026
@cliffhall
cliffhall deleted the v2/fix/1919-rfc6570-resource-templates branch August 17, 2026 02:29
cliffhall added a commit that referenced this pull request Aug 17, 2026
… one

Ports the two behaviors PR #2033 (the parallel attempt at #1919, now closed
as a duplicate) got right and this branch did not.

1. An expression declaring no variable is a malformed template, not one
   with a member to skip. RFC 6570 requires at least one varspec per
   expression and admits no empty member, so `{}`, `{,}`, `{a,}`, `{?}`
   and `{*}` (an explode modifier is not a name) now make the template
   invalid. Skipping them was the more dangerous reading: `x://{}`
   expanded to `x://` while the form rendered no inputs, so its
   "everything required is filled" check was vacuously true and it
   submitted a URI that is not the template the server published.

2. The read is withheld when expansion fails. `expandUriTemplate`'s
   raw-template fallback exists for the preview, which runs during render
   -- but the submit path used it too, so an invalid template was read
   with its braces intact and the server answered with a confusing "not
   found" for a defect that is not the user's. `tryExpandUriTemplate`
   returns the URI or the reason as a value the caller cannot mistake for
   one; the panel gates Read Resource on it and prints the reason.
   This also covers a value that cannot be encoded -- an unpaired
   surrogate has no UTF-8 encoding, so `encodeURIComponent` throws
   `URIError` on it, and a text input can hold one via paste.

`requiredGroups` now skips an expression naming no variable: an empty
group can never be satisfied, so it would gate the form a second time on
a condition nothing can meet, and the "any one of" hint built from it
would name no fields. The accurate reason is the malformed template,
which the expansion gate reports.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2 Issues and PRs for v2

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants