fix: encode literals per RFC 6570 3.1, and label the varname tolerance

Addresses Copilot's round-10 review on #2035. One inline comment, one
suppressed; both real.

1. Literals were copied through verbatim, but RFC 6570 3.1 requires them
   pct-encoded on expansion -- the conformance case `café/{var}` expands to
   `caf%C3%A9/value`. Measured on ef3f5e74 it produced `café/value`, raw
   UTF-8 in the path; since this module replaced the SDK's expander for both
   clients, nothing else was going to encode it. (The pinned SDK does not
   either -- it returns `café/value` too -- so this is a gap we inherited
   rather than introduced.)

   `encodeLiteral` keeps reserved delimiters (a literal's `/`, `?` and `#`
   are structure) and existing pct-triplets, and encodes the rest. A BRACE
   passes through: it can only reach a literal from a malformed template,
   where an unclosed `{` becomes trailing text, and `%7B` would leave a
   string resembling neither the template nor anything the server can match.

   This is also what removed the preview's placeholder machinery. No token
   can survive a round trip now that literals are encoded, so the preview
   assembles part by part instead -- `encodeLiteral` for literal runs,
   `expandTemplateExpression` for filled expressions, `part.source` for the
   rest. Both halves still come from the shared expander, so the preview
   cannot promise a URI that submitting would not send, and the question of
   what token could never collide with a server-supplied template is gone.

2. On the varname set I took the structural point rather than the
   conclusion. `VARCHAR` is RFC-exact again; `-` and `~` moved into a
   separately named `TOLERATED_VARCHAR` tier, and a name needing it parses
   with `conforming: false`, surfaced on `TemplateVariable`.

   So the grammar in the code says what the RFC says and the tolerance is
   explicit and separately tested -- but the Inspector still expands
   `{default-graph-uri}`, because real servers publish hyphenated names and
   the SDK's matcher round-trips them. Refusing to read a resource that
   demonstrably works is a worse failure for a debugging tool than expanding
   a name the conformance suite would reject. A caller wanting RFC-exact
   behavior refuses on the flag rather than re-deriving the grammar.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-16 23:44:45 -04:00
commit 2e06b369f4
2 changed files with 225 additions and 98 deletions

View file

@ -79,3 +79,23 @@ describe("preview with Object.prototype-colliding names", () => {
);
});
});
describe("previewUriTemplate - literals", () => {
// The preview promises the URI that submitting would send, so it applies the
// same RFC 6570 3.1 literal encoding the wire does.
it("percent-encodes a non-ASCII literal", () => {
expect(previewUriTemplate("café/{var}", { var: "value" })).toBe(
"caf%C3%A9/value",
);
});
it("encodes the literal even while an expression is still unfilled", () => {
expect(previewUriTemplate("café/{var}", {})).toBe("caf%C3%A9/{var}");
});
it("keeps a malformed template legible rather than encoding its brace", () => {
expect(previewUriTemplate("x://café/{oops", {})).toBe(
"x://caf%C3%A9/{oops",
);
});
});

View file

@ -12,12 +12,14 @@
import {
definedValues,
expandUriTemplate,
encodeLiteral,
expandTemplateExpression,
parseUriTemplate,
} from "@inspector/core/mcp/uriTemplate.js";
export {
definedValues,
encodeLiteral,
expandUriTemplate,
hasRequiredValues,
parseUriTemplate,
@ -33,56 +35,37 @@ export type {
} from "@inspector/core/mcp/uriTemplate.js";
/**
* A placeholder standing in for an expression the user has not filled in yet.
* A partially-expanded template for display: an expression with at least one
* value is expanded exactly as the wire would render it, and the rest are left
* standing as written so the user can see what is still needed.
*
* `U+0000` cannot appear in a URI template, so a token built from it can never
* collide with real template text; and because it is emitted as *literal* text
* rather than as a variable value, expansion passes it through unencoded.
*/
const deferredToken = (index: number) => `\u0000${index}\u0000`;
/**
* A partially-expanded template for display: expressions with at least one
* value are expanded exactly as `expandUriTemplate` would, and the rest are
* left standing as written so the user can see what is still needed.
* Assembled part by part rather than by rewriting the template and re-parsing
* it. The rewrite approach needed a placeholder to stand in for each unexpanded
* expression, and a placeholder is exactly what cannot survive the round trip
* now that literals are pct-encoded on expansion (RFC 6570 §3.1) -- whatever
* token stood in for `{?topic}` would come back encoded and no longer match.
* Going part by part removes the token, and with it the question of what text
* could never collide with a server-supplied template.
*
* Unfilled expressions are swapped for an inert token and restored afterwards,
* and the rewritten template is expanded by `expandUriTemplate` itself. Routing
* it back through the real expander is what keeps the preview honest: it cannot
* promise a URI that submitting would not send. (Expansion carries no
* cross-expression state — see `expandParts` — so this is a per-expression
* substitution, not a whole-template rewrite that some later pass depends on.)
* Each half still comes from the shared expander -- `encodeLiteral` for literal
* runs, `expandTemplateExpression` for filled ones -- so the preview cannot
* promise a URI that submitting would not send.
*/
export function previewUriTemplate(
uriTemplate: string,
values: Record<string, string>,
): string {
const parts = parseUriTemplate(uriTemplate);
const deferred: string[] = [];
const defined = definedValues(values);
const rewritten = parts
return parseUriTemplate(uriTemplate)
.map((part) => {
if (part.kind === "literal") return part.text;
if (part.kind === "literal") return encodeLiteral(part.text);
// `Object.hasOwn`, not a bare lookup: `toString` and `constructor` are
// valid RFC 6570 variable names, and a plain lookup would find
// `Object.prototype`'s member and treat a blank field as filled.
if (part.names.some((name) => Object.hasOwn(defined, name))) {
return part.source;
}
deferred.push(part.source);
return deferredToken(deferred.length - 1);
return part.names.some((name) => Object.hasOwn(defined, name))
? expandTemplateExpression(part, defined)
: part.source;
})
.join("");
const expanded = expandUriTemplate(rewritten, defined);
// Restored by exact-string replacement rather than by a pattern: a regex
// matching the token would have to embed U+0000 literally, which `eslint`
// rejects (`no-control-regex`) -- and each token's text is already known
// here, so there is nothing to match on.
return deferred.reduce(
(uri, source, index) => uri.replaceAll(deferredToken(index), source),
expanded,
);
}