inspector/core/mcp/uriTemplate.ts
cliffhall 935113d1e0 fix: encode per RFC 3986, and derive the TUI form from the shared parser
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>
2026-08-16 21:04:20 -04:00

455 lines
17 KiB
TypeScript

/**
* RFC 6570 URI Template parsing and expansion, shared by every client (#1919).
*
* This lives in `core/` rather than in a client so the web Resources form, the
* TUI, and the CLI cannot disagree about what a template means. Every one of
* them derives its form fields from {@link templateVariables} and expands
* through {@link expandUriTemplate} — the web panel directly, the TUI and CLI
* via `InspectorClient.readResourceFromTemplate`.
*
* ## Why this is not simply `new UriTemplate(t).expand(v)`
*
* The SDK's `UriTemplate` is still used, but only to *validate* a template —
* constructing it is what rejects an unclosed expression. Its expander is not,
* because it is incomplete in ways a form makes visible: a form has to *name*
* the variables it asks the user to fill in, so a parser that mangles a name
* produces a field nobody can use. Each of these was measured against the
* pinned SDK, not inferred:
*
* | Shape | SDK behavior |
* | ---------------- | --------------------------------------------------------- |
* | `{a,b}` | raw-joins the values — no encoding, operator prefix dropped |
* | `{;id}` | `;` is not in its operator list, so the variable is `;id` |
* | `{id:3}` | the prefix modifier is folded into the name, giving `id:3` |
* | `{+v}` / `{#v}` | `encodeURI` mangles reserved `[`/`]` and double-encodes `%` |
* | `{v}` | `encodeURIComponent` leaves the sub-delims `!'()*` bare |
*
* An earlier revision delegated the shapes the SDK got right and took over only
* the rest. That split is gone: once the encoders themselves differed, the two
* paths would have encoded the *same value* differently depending on whether
* the expression happened to carry a modifier. One expander, one set of rules.
*/
import { UriTemplate } from "@modelcontextprotocol/client";
/**
* The RFC 6570 operators.
*
* `;` is here but **not** in the SDK's own list, which is why `{;id}` parses
* there as a variable literally named `;id`. Order matters only in that each is
* a distinct single character; the first match wins.
*/
const OPERATORS = ["+", "#", ".", "/", ";", "?", "&"] as const;
/**
* Operators whose expansion omits cleanly when the variables in it are
* undefined — the whole expression, separator and all, simply disappears and
* what is left is still a well-formed URI naming a real (broader) resource.
*
* The remaining operators — `""` (simple) and `+` (reserved) — interpolate the
* value into the *middle* of the URI, so omitting one leaves an empty path
* segment rather than a shorter URI: measured against the pinned SDK,
* `file:///users/{userId}/profile` expands to `file:///users//profile` and
* `x://a/{+path}` to `x://a/`, both of which name a different resource. Those
* are the ones a form must require before allowing a read.
*
* `#` is in the omittable set on the same measurement: `x://a{#frag}` with no
* `frag` expands to exactly `x://a`. A fragment is optional by construction.
*/
const OMITTABLE_OPERATORS = new Set(["#", ".", "/", ";", "?", "&"]);
/** Operators that expand to `name=value` pairs rather than bare values. */
const NAMED_OPERATORS = new Set([";", "?", "&"]);
/** A single variable reference inside an expression, e.g. `id` or `id:3`. */
export interface VarSpec {
name: string;
/**
* The RFC 6570 prefix modifier (`{id:3}`), a maximum length in *characters*.
* Applied to the value before percent-encoding, per §3.2.1.
*/
maxLength?: number;
}
interface TemplateLiteral {
kind: "literal";
text: string;
}
interface TemplateExpression {
kind: "expression";
/** The expression including its braces, e.g. `{?topic}`. */
source: string;
/** The RFC 6570 operator, or `""` for a simple expression. */
operator: string;
/** The variable references, in order. */
varspecs: VarSpec[];
/** Bare variable names, `*` and any `:length` modifier stripped. */
names: string[];
}
export type TemplatePart = TemplateLiteral | TemplateExpression;
export interface TemplateVariable {
name: string;
/** The operator of the expression the variable was first seen in. */
operator: string;
/**
* True when this variable appears in at least one expression that cannot be
* omitted without changing the URI's structure — see
* {@link OMITTABLE_OPERATORS}. Drives the form's "Optional" marker.
*
* It does **not** mean "the user must fill this field in". Requiredness 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. Gate submission on {@link hasRequiredValues} over
* {@link requiredGroups}, never by testing this flag per variable.
*/
required: boolean;
}
/** Parses one varspec (`id`, `id*`, `id:3`) into a name and optional prefix. */
function parseVarSpec(raw: string): VarSpec | null {
// The explode modifier is stripped rather than honored: it only changes how
// a list or map value is joined, and every value reaching this module is a
// single string.
const spec = raw.replace("*", "").trim();
if (spec.length === 0) return null;
const colon = spec.indexOf(":");
if (colon === -1) return { name: spec };
const name = spec.slice(0, colon);
const length = Number(spec.slice(colon + 1));
// A malformed modifier (`{id:}`, `{id:abc}`) is not a valid varspec; keep the
// name and ignore the modifier rather than inventing a truncation.
if (name.length === 0) return null;
return Number.isInteger(length) && length > 0
? { name, maxLength: length }
: { name };
}
/**
* Splits a template into literal runs and expressions.
*
* An unclosed `{` yields a trailing literal, which is what makes the callers
* below degrade to "render no inputs, show the template verbatim" rather than
* throw at the user.
*/
export function parseUriTemplate(uriTemplate: string): TemplatePart[] {
const parts: TemplatePart[] = [];
let literal = "";
let i = 0;
while (i < uriTemplate.length) {
if (uriTemplate[i] !== "{") {
literal += uriTemplate[i];
i += 1;
continue;
}
const end = uriTemplate.indexOf("}", i);
if (end === -1) {
// Unclosed expression — the rest is not a template, treat it as text.
literal += uriTemplate.slice(i);
break;
}
if (literal) {
parts.push({ kind: "literal", text: literal });
literal = "";
}
const body = uriTemplate.slice(i + 1, end);
const operator = OPERATORS.find((op) => body.startsWith(op)) ?? "";
const varspecs = body
.slice(operator.length)
.split(",")
.map(parseVarSpec)
.filter((spec): spec is VarSpec => spec !== null);
parts.push({
kind: "expression",
source: uriTemplate.slice(i, end + 1),
operator,
varspecs,
names: varspecs.map((spec) => spec.name),
});
i = end + 1;
}
if (literal) parts.push({ kind: "literal", text: literal });
return parts;
}
/**
* The variables a form should render an input for, in template order and
* deduplicated by name. A name appearing under more than one operator is
* required if *any* of its occurrences is.
*/
export function templateVariables(uriTemplate: string): TemplateVariable[] {
const byName = new Map<string, TemplateVariable>();
for (const part of parseUriTemplate(uriTemplate)) {
if (part.kind !== "expression") continue;
const required = !OMITTABLE_OPERATORS.has(part.operator);
for (const name of part.names) {
const existing = byName.get(name);
if (existing) {
existing.required = existing.required || required;
} else {
byName.set(name, { name, operator: part.operator, required });
}
}
}
return [...byName.values()];
}
/**
* The variable names of each expression that cannot be omitted, in template
* order — one entry per expression, not per variable.
*
* This is deliberately *not* folded onto {@link TemplateVariable}. A name can
* appear in several expressions with different operators, and each required
* expression has to be satisfied on its own: in `x{?a}{?b}{a,b}` the only
* required expression is `{a,b}`, which either `a` or `b` satisfies (the SDK
* expands that template with just `a` to `x?a=11`), while a per-variable model
* that kept only the first occurrence's group would mark both names required
* with singleton groups and refuse it. And in `{a,b}{a,c}` — two required
* expressions sharing `a` — filling `b` and `c` satisfies both, which no
* per-variable flag can express at all.
*/
export function requiredGroups(uriTemplate: string): string[][] {
const groups: string[][] = [];
for (const part of parseUriTemplate(uriTemplate)) {
if (part.kind !== "expression") continue;
if (OMITTABLE_OPERATORS.has(part.operator)) continue;
groups.push(part.names);
}
return groups;
}
/**
* Whether `values` supplies everything expansion structurally needs: every
* required expression has at least one of its names filled in.
*/
export function hasRequiredValues(
groups: string[][],
values: Record<string, string>,
): boolean {
return groups.every((names) =>
names.some((name) => (values[name] ?? "").length > 0),
);
}
/**
* Drops empty entries so an untouched optional field reads as *undefined*
* (the expression disappears) rather than as the empty string (which would
* expand to a valueless `?topic=`).
*/
export function definedValues(
values: Record<string, string>,
): Record<string, string> {
return Object.fromEntries(
Object.entries(values).filter(([, value]) => value.length > 0),
);
}
/**
* The characters RFC 6570 leaves alone under the `+` and `#` operators:
* RFC 3986 *unreserved* plus *reserved* (gen-delims and sub-delims).
*/
const ALLOW_RESERVED = /[^A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=]/gu;
/**
* The `allow-reserved` value encoding of RFC 6570 §3.2.1, used by `+` and `#`.
*
* The SDK reaches for `encodeURI` here, which is close but wrong twice over,
* and both cases corrupt the URI rather than merely over-escaping it:
*
* - It escapes `[` and `]`, which are *reserved* and must survive — so an IPv6
* literal `[::1]` becomes `%5B::1%5D`.
* - It escapes `%`, so an already-encoded value is double-encoded: `%2F`
* becomes `%252F`, and the server sees a literal "%2F" rather than a slash.
*
* The spec instead keeps existing pct-triplets intact, which is what the split
* below does: odd chunks are whole `%XX` triplets and pass through untouched,
* even chunks are scanned for anything outside the allowed set. A lone `%` is
* not a triplet, so it lands in an even chunk and is correctly encoded to
* `%25`. The `u` flag makes the class match by code point, so an astral
* character is handed to `encodeURIComponent` whole rather than as surrogates.
*/
function encodeAllowReserved(value: string): string {
return value
.split(/(%[0-9A-Fa-f]{2})/g)
.map((chunk, index) =>
index % 2 === 1
? chunk
: chunk.replace(ALLOW_RESERVED, (char) => encodeURIComponent(char)),
)
.join("");
}
/**
* Percent-encodes everything outside RFC 3986's *unreserved* set, which is what
* every operator except `+` and `#` calls for.
*
* `encodeURIComponent` alone is not that set: it leaves `!`, `'`, `(`, `)` and
* `*` unescaped. Those are sub-delims, not unreserved, so RFC 6570 requires
* them encoded for simple, label, path, matrix and query expansion. They are
* substituted afterwards rather than hand-rolled, so `encodeURIComponent` still
* does the UTF-8 work for everything else.
*/
const FORCE_ENCODED: Record<string, string> = {
"!": "%21",
"'": "%27",
"(": "%28",
")": "%29",
"*": "%2A",
};
function encodeUnreserved(value: string): string {
return encodeURIComponent(value).replace(
/[!'()*]/g,
(char) => FORCE_ENCODED[char],
);
}
/** Encodes one value for its operator: reserved characters survive `+` and `#`. */
function encodeValue(value: string, operator: string): string {
return operator === "+" || operator === "#"
? encodeAllowReserved(value)
: encodeUnreserved(value);
}
/**
* Applies a prefix modifier, then encodes.
*
* Truncation is by *code point* (`Array.from`), not by `slice`: RFC 6570 counts
* the prefix in characters, and `String.prototype.slice` counts UTF-16 code
* units, so it can cut an astral character in half and yield a lone surrogate.
*/
function renderValue(value: string, spec: VarSpec, operator: string): string {
const truncated =
spec.maxLength === undefined
? value
: Array.from(value).slice(0, spec.maxLength).join("");
return encodeValue(truncated, operator);
}
/** Expands one expression per RFC 6570. Returns "" if no name has a value. */
function expandExpression(
part: TemplateExpression,
values: Record<string, string>,
): string {
const present = part.varspecs.filter(
(spec) => values[spec.name] !== undefined,
);
if (present.length === 0) return "";
const { operator } = part;
if (NAMED_OPERATORS.has(operator)) {
const pairs = present.map(
(spec) =>
`${spec.name}=${renderValue(values[spec.name], spec, operator)}`,
);
// `;` repeats its separator per pair; `?`/`&` join with `&`.
return operator === ";"
? `;${pairs.join(";")}`
: `${operator}${pairs.join("&")}`;
}
const rendered = present.map((spec) =>
renderValue(values[spec.name], spec, operator),
);
switch (operator) {
case "#":
return `#${rendered.join(",")}`;
case ".":
return `.${rendered.join(".")}`;
case "/":
return `/${rendered.join("/")}`;
// "" and "+" — a bare comma-joined list, no prefix.
default:
return rendered.join(",");
}
}
/**
* Expands a whole parsed template, mirroring the SDK's `expand` — including its
* rule that a second query expression switches its leading `?` to `&`.
*/
export function expandParts(
parts: TemplatePart[],
values: Record<string, string>,
): string {
let result = "";
let hasQueryParam = false;
for (const part of parts) {
if (part.kind === "literal") {
result += part.text;
continue;
}
const expanded = expandExpression(part, values);
if (!expanded) continue;
const isQuery = part.operator === "?" || part.operator === "&";
result += isQuery && hasQueryParam ? `&${expanded.slice(1)}` : expanded;
if (isQuery) hasQueryParam = true;
}
return result;
}
/**
* Expands a template against the entered values per RFC 6570 — percent-encoding
* each value according to its operator, and omitting expressions whose
* variables were left blank. **Throws** on a template that is not valid.
*
* Templates the SDK handles correctly still go through the SDK, so the two
* cannot drift on the ordinary path; only a template containing at least one
* expression from the table above is expanded here instead. That is a
* whole-template switch rather than a per-expression splice so the `?`-to-`&`
* rewrite always sees every expression that actually produced output.
*
* The SDK template is constructed *before* choosing a path, and unconditionally:
* that construction is what validates the syntax and throws `Unclosed template
* expression`, and callers such as `readResourceFromTemplate` wrap that error.
* Skipping it on the own-expansion path would silently accept a template like
* `{;a}{b,c` — this module's parser treats the unclosed tail as literal text,
* so nothing else would object.
*/
export function expandUriTemplateStrict(
uriTemplate: string,
values: Record<string, string>,
): string {
const defined = definedValues(values);
// Constructed purely to validate: this is what throws `Unclosed template
// expression`, and callers such as `readResourceFromTemplate` wrap that
// error. Its `expand` is deliberately not used — see the module comment.
new UriTemplate(uriTemplate);
return expandParts(parseUriTemplate(uriTemplate), defined);
}
/**
* {@link expandUriTemplateStrict}, but falling back to the raw template string
* instead of throwing.
*
* This is the form's variant: the template comes from the connected server, not
* from the user, so an invalid one is not something the user can fix from the
* panel — and what they already see in the URI preview is the raw template.
* Letting it throw would take out the whole panel on render; returning it
* unchanged sends the server a URI it rejects with a legible error instead.
* Call sites that need the failure (`readResourceFromTemplate`, which wraps it
* with the template name) use the strict variant.
*/
export function expandUriTemplate(
uriTemplate: string,
values: Record<string, string>,
): string {
try {
return expandUriTemplateStrict(uriTemplate, values);
} catch {
return uriTemplate;
}
}