fix: expand resource templates per RFC 6570 in the web client (#1919) - #2033
fix: expand resource templates per RFC 6570 in the web client (#1919)#2033cliffhall wants to merge 13 commits into
Conversation
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>
Signed-off-by: cliffhall <cliff@futurescale.com>
There was a problem hiding this comment.
Pull request overview
Adds RFC 6570-compliant resource-template discovery, expansion, encoding, and previews to the web client.
Changes:
- Introduces shared
UriTemplatehelpers 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.
Signed-off-by: cliffhall <cliff@futurescale.com>
|
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 So 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 What I did take from this:
Pushed in 1487404. |
There was a problem hiding this comment.
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 intox://{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>
|
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 (
|
There was a problem hiding this comment.
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
withoutEmptyValueschanges 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. Passvariablesthrough 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>
|
Round 3 again reported "no new comments" with one suppressed. It's correct — fixed in bc1237c.
|
There was a problem hiding this comment.
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/client2.0.0 does not apply RFC 6570 encoding or operators to non-query expressions containing multiple names: itspart.names.length > 1branch joins the raw values before the encoding/operator switch. Consequently,expandTemplate("x://{a,b}", { a: "foo/bar", b: "x y" })producesx://foo/bar,x yrather thanx://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.expandcan throw even after parsing succeeds—for example, the pinned SDK rejects a variable value over 1,000,000 characters. This call runs whileResourceTemplatePanelrenders 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
zzInspectorUnfilledzzfollowed by a long run ofzcharacters 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>
|
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 operatorConfirmed, and the root cause is exactly where it was pointed. 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
}And the point about this PR exposing it is the right one: the old regex never matched 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. Seven tests added covering the simple, One thing worth flagging as out of reach here: 2.
|
There was a problem hiding this comment.
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.variableNamesstrips*, so the form storesawhileapplyGroupslooks upa*. For example,{/a*,b}silently dropsaeven 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__, andapplyGroupsoverwrites 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/client2.0.0'sUriTemplatedoes not recognize it. As a result,{;a}is treated as a variable literally named;aand 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 aszzInspectorUnfilledzzInspectorUnfilledzzz0zzcontains 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>
|
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 lookupConfirmed — so the form stored Test asserts both halves: 2. Synthetic name could collide with a real variableConfirmed. The prefix is now padded past any occurrence in the template, so 3. The
|
|
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 premiseExactly right, and the citation was accurate: The helper now lives in
Supporting changes required by the repo's own rules:
One new integration test asserts the two paths cannot drift again — it reads through expect(invocation.expandedUri).toBe("foobar://events/foo%2Fbar");
expect(invocation.expandedUri).toBe(
expandTemplate("foobar://events/{topic}", { topic: "foo/bar" }),
);
|
There was a problem hiding this comment.
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
projectedstarts 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, butexpandTemplate("x://{a,b}", { __inspectorGroup0__: "injected" })now emitsx://injectedinstead ofx://. 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 tox://; consequently the panel'sallFilledcheck is vacuously true and submits a URI different from the advertised template. This violates this helper'snull-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>
|
Round 7: two suppressed comments, both confirmed and both fixed in 892914f. 1. An empty expression was accepted — the more serious of the twoConfirmed exactly: and the consequence is the one described: no variables discovered → no inputs rendered → Validation now runs on the template as declared, before any rewriting. Worth noting since it wasn't obvious: putting the check inside The Five table-driven cases ( 2. A caller value could stand in for a groupCorrect. 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).
|
There was a problem hiding this comment.
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}}`);
| values: Record<string, string | string[]>, | ||
| ): string | null { | ||
| try { | ||
| return template.expand(values); |
There was a problem hiding this comment.
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:
expandTemplateandpreviewTemplateare 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>
|
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-conformantThis is the most substantive finding in the review so far, and all three claims reproduce exactly:
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:
Regressions added for all of it: each of 2. The restoration loop rescanned the URI once per variableAlso correct, and the restructure above resolves it as a side effect: substitution is now a single regex pass ( Two things fell out of the restructure worth noting:
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.
|
There was a problem hiding this comment.
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}witha = "/"must preserve/in the reserved expansion but encode it as%2Fin 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);
| const codePoint = String.fromCodePoint(value.codePointAt(at) as number); | ||
| out += encodeURIComponent(codePoint).replace( |
There was a problem hiding this comment.
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): expandTemplate → null, 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>
|
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 renderConfirmed — 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 ( Tests for a lone high surrogate, a lone low surrogate, and one embedded in otherwise-valid text, each asserting both 2. A repeated name with different operators got one encodingAlso correct, and it exposed a structural limit rather than a slip. 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. Four tests: the mixed Two things fell out of this worth noting:
|
There was a problem hiding this comment.
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
expandTemplateaccepts it (and can reduce it to a different URI such asx://) even thoughtemplateVariableNamesrejected the original. Parse the original before rewriting so malformed/over-limit templates consistently returnnull, 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>
|
Round 10: three suppressed comments — one real bug, two documentation-staleness items. All fixed in ae7b34c. 1. The expansion path validated only the rewritten textCorrect, 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
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 staleBoth 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 Both now describe what the helper actually maintains:
I also updated the
|
|
Following up on the caveat in the previous comment: the rest of |
There was a problem hiding this comment.
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;rewriteExpressionsthen 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, butunsupportedReasonrejects 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.`,
| if (run > longestRun) longestRun = run; | ||
| } | ||
| // -1 means the token is absent, so it needs no padding at all. | ||
| return token + pad.repeat(longestRun + 1); |
|
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
Two things this PR got right that #2035 did not have been ported over to it: declining a template whose expression declares no variable ( The showcase server and integration coverage land through #2035, so nothing here is lost. |
… 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>
Closes #1919
Problem
The Resources tab discovered a template's variables with
/\{(\w+)\}/gand substituted them with a plainString.replace. Two consequences, both reported in #1919:{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.topicoffoo/barproducedfoobar://events/foo/bar— a second path segment — instead offoobar://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 andInspectorClient.readResourceFromTemplatealready 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(presetrfc6570_templates) serves both templates from the issue —foobar://events/{topic}andfoobar://events{?topic}— each echoing back thetopicit 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
--webbuild.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.The resulting read. Before:
Read Error — Resource not found: foobar://events/foo/bar. After: the resource loads, and the server confirms it matchedfoobar://events/foo%2Fbar.Query expression. Before: no
topicinput at all, and Read Resource enabled anyway. After: an input, and a preview showing where the value lands.Query expression. After: data input, and displayed in the preview.
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.QueryExpressionStorybook story.npm run cipasses locally.