fix(web): expand resource templates per RFC 6570 - #2035
Conversation
The Resources screen discovered and substituted template variables with a
bare `/\{(\w+)\}/g` regex, which is wrong two ways: it cannot see an
expression carrying an operator, so `foobar://events{?topic}` rendered no
input at all; and it splices the raw value in, so a `/`, `?`, `#`, `%`,
space or non-ASCII character in a simple `{topic}` landed unencoded and
changed the URI's structure -- `foo/bar` produced an extra path segment
that a conforming matcher rejects with `-32602 Resource not found`.
Delegate expansion to the SDK's `UriTemplate`, the same implementation
`InspectorClient.readResourceFromTemplate` (and so the TUI) already
expands through, so the two clients cannot disagree about what a template
means. The new `utils/uriTemplate` supplies only what that class does
not: which variables to render an input for, which of them a read cannot
proceed without, and a partially-expanded preview.
Required-ness follows the operator. Under `?`, `&`, `.` or `/` the whole
expression is omitted when the variable is undefined, so those fields are
marked Optional and reading with them blank is a legitimate request for
the unfiltered resource; under `""`, `+` or `#` the variable sits mid-URI,
so it stays required. Blank fields are dropped before expanding so an
untouched optional field reads as undefined rather than as the empty
string, which would expand to a valueless `?topic=`.
Adds the `rfc6570_templates` preset and a `rfc6570-templates-http.json`
showcase server serving the two templates from the issue.
Closes #1919
Signed-off-by: cliffhall <cliff@futurescale.com>
There was a problem hiding this comment.
Pull request overview
This PR aims to make web resource-template expansion RFC 6570-aware by using the SDK’s UriTemplate, updating the form behavior, and adding a showcase server.
Changes:
- Adds URI-template parsing, expansion, preview, and variable classification utilities.
- Updates the Resources UI and tests for encoded and optional variables.
- Adds an RFC 6570 test-server preset and documentation.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.
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 preset. |
test-servers/configs/rfc6570-templates-http.json |
Configures the showcase server. |
README.md |
Documents the showcase workflow. |
clients/web/src/utils/uriTemplate.ts |
Implements template utilities. |
clients/web/src/utils/uriTemplate.test.ts |
Tests parsing and expansion. |
ResourceTemplatePanel.tsx |
Integrates RFC-aware form behavior. |
ResourceTemplatePanel.test.tsx |
Tests the updated UI behavior. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
…ame branch Addresses Copilot's review on #2035. All three findings reproduced against the pinned SDK before acting. 1. `#` was misclassified as required. Measured: `x://a{#frag}` with no `frag` expands to exactly `x://a`, a well-formed URI naming a real resource -- unlike `{+path}` (`x://a/`) or a simple `{userId}` (`file:///users//profile`), which leave an empty path segment. Moved `#` into the omittable set; the required cases now assert what the URI *becomes* when blank, so the rule is checked rather than asserted. 2. Delegating to the SDK did not actually give RFC 6570 expansion. `UriTemplate.expandPart` takes an early `names.length > 1` branch that raw-joins values, skipping both `encodeValue` and the operator prefix: `x://{a,b}` with `a = "foo/bar"` expands to `x://foo/bar,q` -- the very unencoded-slash defect this PR is about -- and `x://a{/p,q}` to `x://ax y,z`. Only `?`/`&` are correct, being dispatched earlier. Fixing that in the web client alone would have left the TUI and CLI wrong, since `readResourceFromTemplate` expands through the same class. So parse/classify/expand now live in `core/mcp/uriTemplate.ts` and both call sites use it. The correction is surgical: a multi-name non-query expression is expanded here and spliced in as literal text before the SDK sees the template (safe -- both encoders escape `{`/`}`), while every single-name and query expression still goes through the SDK untouched. The preview applies the same correction, so it cannot promise a URI that submitting would not send. 3. The showcase promised a blank `{?topic}` read that did not work. `UriTemplate.match()` compiles `{?topic}` to a *required* `\?topic=([^&]+)`, so `match("foobar://events")` returns null and the read 404s. A real server exposes the unfiltered collection as its own resource; the showcase now registers `foobar://events` so the documented step resolves. Verified end to end through the CLI. Signed-off-by: cliffhall <cliff@futurescale.com>
|
ping @copilot — review round 1 addressed in 409dce3. All three findings were correct; each was reproduced against the pinned SDK before acting, and each has an inline reply with the measurements. 1. 2. Delegating to the SDK did not actually give RFC 6570 expansion. Fixing that in the web client alone would have left the TUI and CLI wrong, since 3. The showcase promised a blank Verified end to end through the CLI: Note for round 2: the SDK's matcher has the mirrored multi-name gap (
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (3)
core/mcp/uriTemplate.ts:123
- Requiredness cannot be assigned independently to every name in a multi-name expression. For
{a,b}, this marks both fields required and the panel disables submission when onlyais filled, even though RFC 6570 omits undefined names and this module can validly expand that input to justa's value. Model the non-omittable constraint per expression (at least one value for this group) instead of requiring every member.
for (const part of parseUriTemplate(uriTemplate)) {
if (part.kind !== "expression") continue;
const required = !OMITTABLE_OPERATORS.has(part.operator);
for (const name of part.names) {
core/mcp/uriTemplate.ts:98
- RFC 6570 prefix modifiers are currently folded into the variable name. For
{id:3}, this creates anid:3form field instead ofid, and enteringabcdefcannot produce the requiredabcexpansion. Parse the:lengthmodifier separately and apply it before encoding rather than treating the whole varspec as the lookup key.
const names = body
.slice(operator.length)
.split(",")
.map((name) => name.replace("*", "").trim())
.filter((name) => name.length > 0);
core/mcp/uriTemplate.ts:15
- The RFC 6570 path-parameter operator
;is missing from this operator list. As a result,{;id}is parsed as a simple variable named;id, so the form renders the wrong field and expansion cannot produce;id=value(and incorrectly treats it as required). Add;parsing and its named expansion semantics, including multi-name and empty-value handling.
This issue also appears in the following locations of the same file:
- line 94
- line 120
const OPERATORS = ["+", "#", ".", "/", "?", "&"] as const;
…ssion Addresses Copilot's round-2 review on #2035. It reported "no new comments" but carried three *suppressed* ones; all three reproduced against the pinned SDK. 1. The `;` path-parameter operator is absent from the SDK's operator list, so `{;id}` parsed as a variable literally named ";id" and expanded to nothing. Added the operator and its named expansion (`;a=1;b=2`). 2. An RFC 6570 prefix modifier was folded into the variable name: `{id:3}` yielded a variable called "id:3" and expanded to nothing. Varspecs are now parsed properly and the value truncated before encoding. Truncation is by code point, since `String.prototype.slice` counts UTF-16 units and would split an astral character into a lone surrogate. For both of these the wrong URI is the lesser problem: a form has to *name* the variables it asks the user to fill, so the panel was rendering fields labelled `;id` and `id:3` that nobody could use. 3. Requiredness was applied per variable, but it is a property of the *expression*: RFC 6570 drops undefined names from a multi-name expression, so `{a,b}` with only `a` filled expands to `a`'s value -- the SDK does this too. The panel was refusing input the expander would have accepted. `hasRequiredValues` now encodes "any one name in a required group suffices", and such a field reads "Any one of: a, b" rather than falsely claiming each is mandatory. Two structural consequences, each pinned by a test: - Takeover is now per TEMPLATE rather than per expression. Splicing corrected fragments into a template the SDK re-expands would leave its cross-expression `?`-to-`&` rewrite blind to the fragments already resolved. - Expansion is split into a strict variant that throws and a lenient one that returns the raw template. `readResourceFromTemplate` wraps the thrown error with the template name -- three pre-existing integration tests assert that -- while the form must not throw on a server-supplied template, since that would take out the panel on render. The strict variant constructs the SDK template unconditionally, because that construction is what validates syntax: otherwise `x://{;a}{b,c` would take the own-expansion path and its unclosed tail would pass as literal text with nothing objecting. Signed-off-by: cliffhall <cliff@futurescale.com>
|
ping @copilot — round 2 addressed in 25c3ad2. The review reported "no new comments" but carried three suppressed comments. All three were real, and each was reproduced against the pinned SDK before acting: 1. The 2. A prefix modifier was folded into the variable name — For both of these the wrong URI was the lesser problem. A form has to name the variables it asks the user to fill, so the panel was rendering fields labelled 3. Requiredness is a property of the expression, not the variable. RFC 6570 drops undefined names from a multi-name expression, so Two structural consequences fell out, each pinned by a test:
The preview for a partially-filled
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (2)
core/mcp/uriTemplate.ts:199
- Deduplication keeps only the first occurrence's
groupNames, even when a later occurrence makes the variable required. For{?a}{?b}{a,b}, both variables retain singleton groups, sohasRequiredValuesincorrectly requires both fields although RFC expansion accepts either one; the UI also omits the “Any one of” hint. Preserve and evaluate every required expression group separately, while deduplicating names only for rendering.
existing.required = existing.required || required;
core/mcp/uriTemplate.ts:249
encodeURIis not an RFC 6570 allow-reserved encoder: it percent-encodes reserved[/]characters and double-encodes existing percent triplets, both of which must remain unchanged under+and#. For example, the own-expansion path for{+v,w}turnsv = "[::1]"into%5B::1%5Dand%2Finto%252F. The ordinary single-name path delegates to the SDK behavior this function mirrors, so use an RFC 6570 reserved-value encoder for both paths (or fix/upgrade the SDK) and cover these cases.
? encodeURI(value)
…C 6570 Addresses Copilot's round-3 review on #2035. It again reported "no new comments" while carrying two suppressed ones; both reproduced. 1. Requiredness could not live on a variable at all. Deduplication kept the first occurrence's group, so in `x{?a}{?b}{a,b}` both names ended up required with singleton groups and the form demanded both -- while the SDK expands that template with only `a` to "x?a=11". Widening the stored group would not have been enough either: in `{a,b}{a,c}`, filling `b` and `c` satisfies both expressions, which no per-variable flag can express. So requiredness is now returned per expression by `requiredGroups`, and `hasRequiredValues` asks that each group be satisfied by any one of its names. `TemplateVariable.groupNames` is gone rather than left as a field that quietly means something narrower than it reads; `required` remains, documented as driving the "Optional" marker and nothing else. 2. `encodeURI` is not the allow-reserved encoder `+` and `#` call for. It escapes `[` and `]`, which are reserved and must survive, and it escapes `%`, so an already-encoded value is double-encoded. Measured: "[::1]" -> "%5B::1%5D" and "%2F" -> "%252F". Both corrupt the URI rather than merely over-escaping it -- an IPv6 literal or a pre-encoded path reaches the server altered, which is the same class of defect #1919 is about. Added an RFC 6570 3.2.1 encoder that preserves reserved characters and existing pct-triplets, splitting on `%XX` so a lone `%` is still encoded to `%25`, and matching with the `u` flag so an astral character is encoded whole. `+` and `#` expressions are now taken over even for a single name, so both expansion paths agree on what those operators mean. Signed-off-by: cliffhall <cliff@futurescale.com>
…-expansion' into v2/fix/1919-rfc6570-uri-template-expansion
|
ping @copilot — round 3 addressed in e7d1246 (plus a merge of Round 3 also reported "no new comments" while carrying two suppressed ones. Both were real, and both reproduced against the pinned SDK: 1. Requiredness could not live on a variable at all. Deduplication kept the first occurrence's group, so So requiredness is now returned per expression by 2. Added an RFC 6570 §3.2.1 encoder that preserves reserved characters and existing pct-triplets: it splits on Also added a screenshot of the
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (3)
core/mcp/uriTemplate.ts:294
encodeURIComponentdoes not implement RFC 3986's unreserved set: it leaves!,',(,), and*unchanged. RFC 6570 requires those characters to be percent-encoded for simple, path, matrix, and query expansions (only+/#allow reserved characters), so{v}witha!bstill produces a non-conforming expansion. Use strict RFC 3986 encoding and ensure ordinary SDK-delegated expressions also take the corrected path for these values; add cases for this character set.
/** Encodes one value for its operator: reserved characters survive `+` and `#`. */
function encodeValue(value: string, operator: string): string {
return operator === "+" || operator === "#"
? encodeAllowReserved(value)
: encodeURIComponent(value);
core/mcp/uriTemplate.ts:433
- The normalized names used here no longer match the TUI form's submitted keys.
clients/tui/src/utils/uriTemplateToForm.ts:18-28still uses the SDK'svariableNames, so{;id}submits{ ";id": "7" }and{id:3}submits{ "id:3": "abc" }; this parser looks upid, finds no value, and drops the expression. Update the TUI form to derive fields from this module'stemplateVariablesand cover both shapes so the shared helper actually works for every client.
const sdkTemplate = new UriTemplate(uriTemplate);
const parts = parseUriTemplate(uriTemplate);
return parts.some(needsOwnExpansion)
? expandParts(parts, defined)
: sdkTemplate.expand(defined);
clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx:263
- A variable can belong to both a singleton required expression and a shared group. For
x://{a}/{a,b}, this labelsaas “Any one of: a, b”, but the['a']group still requiresa; entering onlybtherefore leaves Read Resource disabled despite the guidance. Suppress the shared-group message when a singleton group independently requires this field.
const sharedGroup = groups.find(
(names) => names.length > 1 && names.includes(varName),
);
const description = !required
? "Optional"
Addresses Copilot's round-4 review on #2035 — again reported as "no new comments" while carrying three suppressed ones. All three reproduced. 1. The TUI form still named its fields from the SDK. `uriTemplateToForm` read `UriTemplate.variableNames`, which mangles a name: `{;id}` yields ";id" and `{id:3}` yields "id:3". The form therefore submitted `{ ";id": "7" }` while the shared expander looked up `id`, found nothing, and dropped the expression — the value vanished silently. This is the finding that mattered most, because it falsified the claim this change is built on. Moving the expander into core only makes the clients agree if each client's FORM derives its names from the same parser: a form submits under the names it rendered. The TUI now reads `templateVariables`, and the SDK template is constructed only to validate, preserving the existing malformed-template diagnostic. Its `required` flag comes from `requiredGroups`, and only a variable that is the sole member of a non-omittable expression is marked: ink- form cannot express "any one of these", so marking every member of `{a,b}` required would refuse input the expander accepts. 2. `encodeURIComponent` is not RFC 3986's unreserved set — it leaves the sub-delims !'()* bare, which RFC 6570 requires encoded for every operator except + and #. Fixing that settled a design question left open in the previous round. With two encoders, the SAME value encoded differently depending on whether its expression happened to carry a modifier, since only the modifier pushed it onto our path. So delegation is gone entirely: one expander, one set of rules. The SDK's `UriTemplate` is still constructed, but only to validate a template. 3. The "Any one of: a, b" hint could contradict the disabled submit button. A name can sit in a singleton required group AND a shared one (`x://{a}/{a,b}`), where the singleton demands that exact field. The hint is suppressed in that case. Signed-off-by: cliffhall <cliff@futurescale.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.
Suppressed comments (2)
core/mcp/uriTemplate.ts:320
- Repeated names make one logical requirement look like a shared group. For the valid template
{a,a}, this returns['a', 'a'], so the TUI'snames.length === 1check marksaoptional while its submit guard still rejects a blank value, and the web UI displays “Any one of: a, a”. Deduplicate names within each expression before storing the group.
if (part.names.length === 0) continue;
groups.push(part.names);
clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx:305
- Using
findhides later overlapping requirements. For{a,b}{b,c}{a,c}, the displayed hints only mention the first two groups; after fillingb, every visible hint appears satisfied but Read Resource remains disabled because the hidden{a,c}group is unmet. Render every shared group containing this variable (or provide a form-level unmet-groups message).
const sharedGroup = individuallyRequired
? undefined
: groups.find(
(names) => names.length > 1 && names.includes(varName),
);
Addresses Copilot's round-13 review on #2035. Two suppressed comments, both real, both in the required-group work from round 7 rather than in the recent preview rework. 1. `{a,a}` is ONE requirement named twice, but `requiredGroups` stored it as a two-name group, so everything downstream read it as "either of these will do". The TUI was left with a form it could not submit: its `length === 1` test marked `a` optional while ResourceTestModal's guard still refused a blank. The web panel offered the useless hint "Any one of: a, a". Names are now deduplicated within the expression -- expansion still emits both occurrences (`x://{a,a}` -> `x://1,1`), which RFC 6570 requires. 2. The per-field hint used `find`, so a variable in several shared groups advertised only the first. With `{a,b}{b,c}{a,c}`, filling `b` satisfies the first two and every visible hint then looks met while Read Resource stays disabled on the unmet `{a,c}` -- a requirement with nothing on screen pointing at it. Fields now hint every shared group they sit in. That alone is not enough, because no per-field hint can say WHICH group is still outstanding, so the form states it directly: "Still needed: a or c", built from the same `unmetRequiredGroups` the submit gate uses, so the message and the button cannot disagree. `hasRequiredValues` is no longer called here -- the panel derives both from the one list. npm run ci passes. Signed-off-by: cliffhall <cliff@futurescale.com>
|
ping @copilot — round 13 addressed in 1. 2. The per-field hint used Fields now hint every shared group they sit in ( It is built from the same Tests: the dedupe and its expansion in core, the TUI form field being
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.
Suppressed comments (3)
core/mcp/uriTemplate.ts:276
- This makes variable discovery quadratic for a single multi-name expression: for every name, it scans all
varspecsagain. Since templates are server-controlled and the SDK permits templates up to 1 MB, an expression containing many short comma-separated names can freeze the Resources screen during render. Iteratepart.varspecsdirectly so each spec supplies both its name and conformance in one pass.
for (const name of part.names) {
const conforming = !part.varspecs.some(
(spec) => spec.name === name && spec.conforming === false,
);
core/mcp/uriTemplate.ts:552
- Replacing
UriTemplate.expand()drops its 1,000,000-character guard on every variable value: constructingUriTemplatevalidates only the template and names, while values now reach these allocation-heavy encoding paths without a bound. This is a reliability regression forreadResourceFromTemplateand both forms when a very large value is pasted or supplied programmatically. Preserve the pinned SDK's value limit before truncating/encoding.
function renderValue(value: string, spec: VarSpec, operator: string): string {
const truncated =
spec.maxLength === undefined
? value
: prefixUnits(value).slice(0, spec.maxLength).join("");
return encodeValue(truncated, operator);
core/mcp/uriTemplate.ts:671
new UriTemplate()does not fully validate brace syntax: the pinned SDK rejects an unclosed{but treats an unmatched}as literal text. The local parser does the same, andencodeLiteraldeliberately preserves braces, soexpandUriTemplateStrict("x://a}", {})returns the malformed URI and enables the read instead of withholding it. Add explicit validation for braces in literal parts (and a regression test for an unmatched closing brace) before expansion.
new UriTemplate(uriTemplate);
const parts = parseUriTemplate(uriTemplate);
const bad = parts.find((part) => part.kind === "expression" && part.invalid);
Addresses Copilot's round-14 review on #2035. Three suppressed comments, all real; each measured on 1c595e9 before acting. 1. A brace surviving in a literal is an unmatched one, and not a legal literal character. The SDK's constructor rejects an unclosed `{` but reads a stray `}` as text, and this parser did the same: expandUriTemplateStrict("x://a}", {}) => "x://a}" -- a "URI" carrying a brace, with the panel enabling the read on it. Both halves are now refused the same way, so the withhold behavior covers the whole class rather than the half the SDK happened to catch. 2. Replacing `UriTemplate.expand` also removed its 1,000,000-character per-value ceiling. Nothing else bounded what reached the allocation-heavy encoders, so a pasted or programmatically supplied megabyte-plus value went straight through. Restored at the SDK's own limit -- the guard it was, not a new policy -- and the error names the offending variable. 3. `templateVariables` re-scanned an expression's whole varspec list for every one of its names to compute `conforming`, which is quadratic in a multi-name expression. Templates are server-controlled and the SDK admits one up to 1 MB, so a single expression can carry thousands of short comma-separated names, and this runs on the render thread. Iterating the varspecs supplies the name and its conformance in one pass. npm run ci passes. Signed-off-by: cliffhall <cliff@futurescale.com>
|
ping @copilot — round 14 addressed in 1. A stray Any brace surviving in a literal is now refused, so the withhold behavior covers the whole class rather than the half the SDK happened to catch. 2. The per-value ceiling went missing with the SDK's expander. A 1,000,001-character value expanded fine here where 3. Discovery was quadratic per multi-name expression.
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.
Suppressed comments (2)
core/mcp/uriTemplate.ts:2
- This says the helper is shared by every client, but lines 10–13 explicitly state that the CLI is not a consumer. Describe it as shared by the web and TUI clients so the module summary matches its actual scope.
* RFC 6570 URI Template parsing and expansion, shared by every client (#1919).
core/mcp/inspectorClient.ts:4999
- The new InspectorClient wiring is not covered by a regression that distinguishes it from the old SDK expander. The existing
readResourceFromTemplatetest usesreport.txt, while the new transport test expands the URI separately and callsreadResource, so both still pass if this line is reverted. Add an InspectorClient test using a value such asfoo/bar(and ideally a query expression) and assertexpandedUri/the actual read URI is RFC 6570-encoded.
expandedUri = expandUriTemplateStrict(uriTemplateString, params);
… the SDK Addresses Copilot's round-15 review on #2035. 1. `readResourceFromTemplate` had no regression distinguishing this module from the SDK expander it replaced: the existing test uses `report.txt` and the transport tests expand separately and call `readResource`, so all of them pass either way. Writing that test corrected something I had asserted twice in this PR. The first version used `foo/bar` -- and verified by reverting the wiring, it still passed. `encodeURIComponent` DOES encode `/`, so the SDK expands `foobar://events/{topic}` to `foobar://events/foo%2Fbar` exactly as this module does. #1919 was the WEB PANEL's own `String.replace`, not the SDK expander, which the TUI path was using correctly all along. The test now uses `a!b`. `!` is a sub-delim: RFC 6570 requires it encoded, `encodeURIComponent` leaves it bare, so the SDK returns `foobar://events/a!b` and the assertion fails. Re-verified by reverting the wiring -- 2 of the 10 tests fail (this one and the malformed-template refusal), where before the change none did. 2. The module summary said "shared by every client" a dozen lines above the paragraph explaining the CLI is not a consumer. Now says web and TUI. Also records, on MAX_VALUE_LENGTH, the audit prompted by round 14: the per-value ceiling was the ONLY guard lost with `UriTemplate.expand`. The template-length (1e6), expression-count (1e4) and variable-name-length (1e6) limits all live in the SDK's `parse()`, which runs from the constructor -- and strict expansion still constructs a `UriTemplate`, so they remain enforced. The restored limit is the SDK's `MAX_VARIABLE_LENGTH`, read from the pinned dist rather than assumed. npm run ci passes. Signed-off-by: cliffhall <cliff@futurescale.com>
|
ping @copilot — round 15 addressed in 1. The Writing that test corrected something I had asserted twice, including in a commit message. My first version used So #1919 was the web panel's own 2. Module summary fixed — it said "shared by every client" a dozen lines above the paragraph explaining the CLI is not a consumer. And the audit your round-14 finding prompted, now recorded on
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.
Suppressed comments (2)
core/mcp/uriTemplate.ts:540
prefixUnitskeeps each%XXbyte as one unit, but RFC 6570 requires prefix lengths to count Unicode characters without splitting the octets of a multi-octet encoded character. For example,{+v:1}withv = "%C3%A9x"currently truncates to%C3, splitting the encodedé; it should retain the complete%C3%A9. Tokenize valid percent-encoded UTF-8 sequences as code points (while defining safe behavior for invalid sequences) and add a multibyte pct-encoded prefix case.
function prefixUnits(value: string): string[] {
return value.match(/%[0-9A-Fa-f]{2}|[\s\S]/gu) ?? [];
clients/web/src/utils/uriTemplate.ts:84
- This preview path bypasses the new per-value ceiling:
tryExpandUriTemplaterejects values over 1,000,000 characters before the allocation-heavy encoders run, but the panel then callspreviewUriTemplate, which reachesexpandTemplateExpressionhere with the same oversized value and encodes it during render. An arbitrarily large paste can therefore still block the UI despite the guard. Share the core length validation with the partial-preview path and fall back before encoding.
return part.names.some((name) => Object.hasOwn(defined, name))
? expandTemplateExpression(part, defined)
: part.source;
Addresses Copilot's round-16 review on #2035. Two suppressed comments, both real, and both refinements of fixes from the previous two rounds rather than defects in the original code. 1. Round 11 settled on "a pct-encoded triplet counts as one character", but a multi-octet character spans several triplets -- `%C3%A9` is one `é`. Measured on a3dd856: x{+v:1} v = "%C3%A9x" => "x%C3" a lone lead byte, decoding to nothing `prefixUnits` now recognizes a well-formed pct-encoded UTF-8 sequence from its lead byte (RFC 3629: C2-DF two octets, E0-EF three, F0-F4 four; continuations 80-BF), longest alternative first so a four-octet sequence is not read as a two-octet one followed by loose triplets. A malformed sequence -- a lead byte with too few continuations, or a stray continuation -- matches no sequence alternative and falls back to per-triplet counting. That is the conservative direction: it counts as MORE units, so truncation keeps less rather than emitting octets that were never in the value. 2. Round 14's per-value ceiling only covered the read. The panel calls `previewUriTemplate` on every keystroke, and that path expands expression by expression, so it reached the allocation-heavy encoders with the same oversized value -- submission refused while the UI froze on the paste anyway. Both paths now share one exported `valueLengthError`, checked before any encoding happens. npm run ci passes. Signed-off-by: cliffhall <cliff@futurescale.com>
|
ping @copilot — round 16 addressed in 1. A multi-octet pct-encoded character was being split. Round 11 settled on "a triplet counts as one character", which is right for
On your "define safe behavior for invalid sequences": a lead byte with too few continuations ( 2. The value ceiling only covered the read. Correct, and it made round 14 half a guard: the panel calls
|
| description={description} | ||
| placeholder={`Enter ${varName}`} | ||
| value={fieldValue} | ||
| data={completions[varName] ?? []} |
Addresses Copilot's round-17 review on #2035. A real crash, and a class the earlier prototype work missed rather than a narrower version of it. `toString`, `constructor` and `__proto__` are valid RFC 6570 variable names, and `completions` starts as `{}`. So for such a name: completions["toString"] ?? [] => the prototype's FUNCTION `??` catches only null and undefined, so that function went to Mantine's Autocomplete as its `data`, and the field crashed on first render with `TypeError: data.map is not a function` -- reproduced by reverting the fix under the new test. The earlier rounds fixed this for the *values* map, which is seeded with every declared variable and so always has the key as an own property. The completions map is the one name-keyed map this component reads WITHOUT having seeded it, which is what made it the surviving instance. Both reads now go through `Object.hasOwn` -- the render, and the stale-dropdown check that clears a variable's options on the next keystroke. Only the autocomplete branch could reach it, so the regression drives that branch: completions supported, a `{toString}` template, type and submit. npm run ci passes. Signed-off-by: cliffhall <cliff@futurescale.com>
|
ping @copilot — round 17 addressed in
What makes this one interesting is why it survived the earlier prototype rounds. Those fixed the values map — which the panel seeds with every declared variable, so the key is always an own property and a bare lookup happens to be safe. Only the autocomplete branch can reach it, so the regression drives that branch specifically —
|
Fixed in |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.
Suppressed comments (2)
core/mcp/uriTemplate.ts:534
- The UTF-8 grouping accepts malformed sequences that the comment says should fall back to single-triplet counting. For example,
%C0%80matches the two-byte branch even though RFC 3629 permits onlyC2–DF; theE0,ED,F0, andF4branches likewise need restricted second-byte ranges. Consequently{+v:1}can retain an entire invalid sequence instead of one%XXunit. Constrain these branches to valid UTF-8 scalar encodings (or validate/decode candidates) so malformed input follows the documented fallback.
const PREFIX_UNIT =
/%F[0-4](?:%[89AB][0-9A-F]){3}|%E[0-9A-F](?:%[89AB][0-9A-F]){2}|%[CD][0-9A-F]%[89AB][0-9A-F]|%[0-9A-F]{2}|[\s\S]/giu;
clients/web/src/utils/uriTemplate.ts:86
- This preview path bypasses the strict validator before expanding parts. A template such as
x://{a}}/{b}is refused bytryExpandUriTemplatefor its unmatched}, but with both fields filled this function previewsx://1}/2, a URI the disabled form can never send. SDK limit failures (for example, too many expressions) have the same mismatch. Run the shared template validation before partial expansion, returning the raw template on failure, so the preview preserves its “never over-promise” contract.
try {
return parseUriTemplate(uriTemplate)
.map((part) => {
if (part.kind === "literal") return encodeLiteral(part.text);
if (part.invalid) return part.source;
…viewing Addresses Copilot's round-18 review on #2035. Two suppressed comments, both holes in my own recent fixes rather than in the original code. 1. Round 16's grouping keyed off the length-announcing lead byte alone, so it accepted sequences RFC 3629 forbids -- and the comment claiming they fell back to per-triplet counting was simply wrong. Measured on 05bbfe5: x{+v:1} "%C0%80x" => "x%C0%80" overlong "%E0%80%80x" => "x%E0%80%80" overlong "%ED%A0%80x" => "x%ED%A0%80" UTF-16 surrogate "%F4%90%80%80x" => "x%F4%90%80%80" past U+10FFFF The lead-byte ranges are now the well-formed ones -- C2-DF, E0 only with A0-BF, ED only with 80-9F, F0 only with 90-BF, F4 only with 80-8F -- so each of the above falls back to one triplet, which is what the fallback was always documented to do. The legal boundary cases (%E0%A0%80, %ED%9F%BF, %F4%8F%BF%BF) are tested alongside, so tightening the ranges cannot quietly start splitting valid characters. 2. The preview never ran the read's template validation. It expands expression by expression, so `x://{a}}/{b}` with both fields filled previewed `x://1}/2` -- a URI whose stray `}` makes every read refuse. Validation moved into an exported `templateError` that both callers consult, so they cannot drift. It subsumes the narrower rule round 12 added here ("leave the invalid part standing"), which still let the REST of such a template expand: the refusal is per template, not per expression, so the preview now shows the template verbatim -- literals unencoded included, since half-normalizing an unreadable template invents a string the server never published. Two existing tests asserted the weaker behavior and now assert this one. npm run ci passes. Signed-off-by: cliffhall <cliff@futurescale.com>
|
ping @copilot — round 18 addressed in 1. The UTF-8 grouping keyed off the lead byte alone. You are right, and my round-16 comment claiming malformed sequences fell back was simply false. Measured on The ranges are now RFC 3629's well-formed ones — 2. The preview skipped the read's template validation. Confirmed exactly as described — The fix is the one you suggest, and it turns out to subsume what I added in round 12. That round made the preview leave an invalid part standing, which still let the rest of the template expand; the refusal is per template, so Two existing preview tests asserted the weaker behavior and now assert this one;
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.
Suppressed comments (1)
core/mcp/uriTemplate.ts:718
- The length guard currently examines every entry in the caller’s map, including variables the template never references. URI-template expansion ignores extra variables, and the previous SDK path only validated values after looking up a declared name, so an unrelated stale/auxiliary value over 1,000,000 characters now makes
x://{id}fail even whenidis valid. Filter the guard to the names in the parsed template and reuse those parsed parts for expansion.
const tooLong = valueLengthError(values);
if (tooLong) throw new Error(tooLong);
Addresses Copilot's round-19 review on #2035 -- a divergence introduced by my own round-14 guard rather than by the original code. The SDK validated a value inside `encodeValue`, i.e. only after looking one up by a DECLARED name. The restored guard checked the caller's whole map, so an unrelated entry failed a template that never mentions it. Measured on 9caac43: tryExpandUriTemplate("x://{id}", { id: "7", stale: "a".repeat(1_000_001) }) => error: The value for "stale" exceeds the 1000000-character limit RFC 6570 ignores an extra variable, so this refused a perfectly valid read. `valueLengthError` now takes the names to consider and both callers pass the template's declared set. Copilot's second half is worth taking on its own: strict expansion parsed the template THREE times -- inside `templateError`, again for the name list, again to expand -- and the template is server-controlled and may be 1 MB. It is now parsed once and reused, with validation split into `sdkTemplateError` (the constructor's unclosed-brace and length/count limits) and `partsError` (this module's own grammar), so `templateError` and strict expansion share both halves rather than duplicating either. npm run ci passes. Signed-off-by: cliffhall <cliff@futurescale.com>
|
ping @copilot — round 19 addressed in Your reading of the SDK is the decisive detail: it validated inside The second half was worth taking on its own. Strict expansion was parsing the template three times — inside
|
|
Review cycle complete — round 20 returned clean (no comments, no suppressed comments) on Fourteen rounds, every finding taken. The shape of them is worth recording, because it says something about where the risk in this PR actually was:
Two of them were things the SDK had been doing for us that replacing its expander quietly removed — the per-value ceiling and half the brace validation — which prompted an audit of the rest: the template-length, expression-count and variable-name limits all live in the constructor, which strict expansion still calls, so nothing else went missing. That is recorded on One correction that matters for the record: #1919 was the web panel's own One decision is deliberately left open for a human, and is a one-line change if you disagree: non-conforming variable names (
|
Closes #1919
The Resources screen discovered and substituted template variables with a bare
/\{(\w+)\}/gregex, which is wrong in two independent ways:foobar://events{?topic}rendered notopicinput at all — the template was un-fillable./,?,#,%, space, or non-ASCII character in a simple{topic}landed unencoded, sofoo/barproducedfoobar://events/foo/bar— an extra path segment, which a conforming resource-template matcher rejects with-32602 Resource not found.The fix
Parsing, variable classification, and expansion moved to
core/mcp/uriTemplate.ts, shared by the web Resources form and the TUI — both expand through it and derive their form fields from it, which is the half that makes the sharing real: a form submits values under the names it rendered, so a parser that mangles a name silently drops the value at expansion. (The CLI is not a consumer — it has no template form and passes an already-expanded--uristraight toreadResource.)The SDK's
UriTemplateis used only to validate a template. Its expander is not, because it is incomplete in five ways a form makes visible — each measured against the pinned SDK, not inferred:{a,b}foo%2Fbar,q{;id};missing from its operator list, so the variable parses as;id;id=7{id:3}id:3abc{+v}/{#v}encodeURImangles reserved[::1]→%5B::1%5Dand double-encodes%2F→%252F{v}encodeURIComponentleaves the sub-delims!'()*bare%21%27%28%29%2ATwo behaviors are deliberately not copied from the SDK:
?-to-&rewrite. RFC 6570 expands each expression independently. The SDK rewrites a second{?two}'s?to&, and its own matcher then rejects the result: forx{?one}{?two},match("x?one=1&two=2")isnullwhilematch("x?one=1?two=2")returns both variables. A server wanting a continuation advertises{?one}{&two}.max-lengthis%x31-39 0*3DIGIT, so{id:},{id:0},{id:abc}and{id:10000}are invalid templates. The SDK's constructor accepts them all. Strict expansion throws; the lenient variant returns the raw template so the panel does not blow up on render.Requiredness is a property of the expression, not the variable. RFC 6570 drops undefined names from a multi-name expression, so
{a,b}with onlyafilled is expandable and a form must not block it.requiredGroupsreturns one entry per non-omittable expression andhasRequiredValuesasks that each be satisfied by any one of its names — which no per-variable flag can express once a name recurs across expressions ({a,b}{a,c}is satisfied by fillingbandc). The TUI enforces the same rule in its submit handler, since ink-form has no way to express "any one of these".Lookups are own-property only.
toString,constructor,valueOfand__proto__are all valid RFC 6570 variable names, and a barevalues[name]findsObject.prototype's member for each — a blank{?toString}would have expanded a function body into the URI.A template that cannot expand withholds the read. An out-of-grammar modifier (
{id:abc}) or an expression declaring no variable ({},{,},{a,},{?},{*}) makes the template invalid, andtryExpandUriTemplatereturns the reason as a value rather than a URI: the panel disables Read Resource and prints it. The lenientexpandUriTemplate— which answers with the raw template — is for display only (the preview runs during render, where a throw takes the panel down). Submitting that fallback would read the template itself, braces intact, and draw a confusing "not found" for a defect that is not the user's. Skipping an empty varspec instead of rejecting it is worse still:x://{}would expand tox://with no inputs rendered, so the "everything required is filled" check passes vacuously and the read goes out for a URI that is not the template the server published. The same gate covers a value that cannot be encoded — an unpaired surrogate has no UTF-8 encoding, soencodeURIComponentthrowsURIErroron it, and a text input can hold one via paste. (Both behaviors are ported from #2033, the parallel attempt at this issue, now closed as a duplicate.)Screenshots
Captured against the new
rfc6570-templates-http.jsonshowcase server (below).Simple expression
foobar://events/{topic}withfoo/barentered — the preview beside the title is the URI that will be sent.foobar://events/foo/bar— unencoded, server answersResource not foundfoobar://events/foo%2Fbar— encoded, resolvesQuery expression
foobar://events{?topic}— before, there is no input to type into at all.topicfield renderedtopicfield, marked OptionalThe 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.And the same
topicfield with a value entered — the preview beside the title is the URI the read will send,foobar://events?topic=foo%2Fbar, with the/percent-encoded. There is no "before" counterpart because on the old build the field does not exist.A malformed template, refused.
events_malformed(foobar://events/{topic:abc}) on the showcase server below: Read Resource is disabled, the reason is printed under the form, and the preview shows the template as the server declared it. There is no "before" pair — on the old build the modifier was folded into the variable's name, so the form asked for a field labelledtopic:abcand the read went out against a URI the server never advertised.Test server
test-servers/configs/rfc6570-templates-http.json(presetrfc6570_templates) serves the two templates straight out of the issue —events_by_topic(foobar://events/{topic}) andevents_by_query(foobar://events{?topic}) — each echoing the URI it was matched against. Verified end-to-end through the CLI against the real SDK matcher:Documented in the root README's showcase table and a new RFC 6570 resource templates section.
Tests
clients/web/src/test/core/mcp/uriTemplate.test.ts— the expander: parsing, operator classification, required groups, encoding under every operator (/,?,#,%, spaces, Unicode,!'()*,[::1], pct-triplets), the;and:Nshapes, varspec-grammar rejection, expression independence, strict-vs-lenient, and theObject.prototypename collisions.clients/web/src/test/integration/mcp/rfc6570-templates.test.ts— resolves the checked-in config and drives four reads over a real transport: the base URI, the encoded simple value, the encoded query value, and the unencoded URI that must still be refused. A misspelt preset fails here rather than only when someone runs the repro by hand.ResourceTemplatePanel.test.tsx— the rendered input for{?topic}, the encoded URIs handed toonReadResource, the Optional marker and its effect on the submit gate, and the preview.clients/tui/__tests__/—uriTemplateToFormfield naming for{;id}/{id:3}, shared-group optionality, andResourceTestModal's group-aware submit guard.npm run cipasses.