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>
This commit is contained in:
parent
1d29e4e71c
commit
935113d1e0
4 changed files with 190 additions and 102 deletions
|
|
@ -11,7 +11,38 @@ describe("uriTemplateToForm", () => {
|
|||
expect(form.title).toBe("Read Resource: file");
|
||||
const fields = form.sections[0]!.fields;
|
||||
expect(fields.map((f) => f.name)).toEqual(["path", "name"]);
|
||||
expect(fields[0]).toMatchObject({ type: "string", required: false });
|
||||
// Simple `{path}` sits mid-URI, so omitting it would leave an empty path
|
||||
// segment rather than a shorter URI -- it is mandatory, matching the web
|
||||
// panel. Both clients read this from core's `requiredGroups`.
|
||||
expect(fields[0]).toMatchObject({ type: "string", required: true });
|
||||
});
|
||||
|
||||
it("names fields as the expander looks them up, not as the SDK parses them", () => {
|
||||
// The SDK reports these as ";id" and "id:3"; submitting under those keys
|
||||
// would make the expander find nothing and drop the expression (#1919).
|
||||
expect(
|
||||
uriTemplateToForm("x://a{;id}", "matrix").sections[0]!.fields.map(
|
||||
(f) => f.name,
|
||||
),
|
||||
).toEqual(["id"]);
|
||||
expect(
|
||||
uriTemplateToForm("x://a/{id:3}", "prefix").sections[0]!.fields.map(
|
||||
(f) => f.name,
|
||||
),
|
||||
).toEqual(["id"]);
|
||||
});
|
||||
|
||||
it("leaves a shared required group optional rather than demanding every name", () => {
|
||||
// `{a,b}` is satisfied by either name, and ink-form cannot say "any one
|
||||
// of"; marking both required would refuse input the expander accepts.
|
||||
const fields = uriTemplateToForm("x://{a,b}", "pair").sections[0]!.fields;
|
||||
expect(fields.map((f) => f.name)).toEqual(["a", "b"]);
|
||||
expect(fields.every((f) => f.required === false)).toBe(true);
|
||||
});
|
||||
|
||||
it("marks an omittable variable optional", () => {
|
||||
const fields = uriTemplateToForm("x://a{?topic}", "q").sections[0]!.fields;
|
||||
expect(fields[0]).toMatchObject({ name: "topic", required: false });
|
||||
});
|
||||
|
||||
it("returns an empty Template Variables section for a static URI", () => {
|
||||
|
|
|
|||
|
|
@ -4,32 +4,50 @@
|
|||
|
||||
import type { FormStructure, FormSection, FormField } from "ink-form";
|
||||
import { UriTemplate } from "@modelcontextprotocol/client";
|
||||
import {
|
||||
requiredGroups,
|
||||
templateVariables,
|
||||
} from "@inspector/core/mcp/uriTemplate.js";
|
||||
|
||||
/**
|
||||
* Converts a URI Template to ink-form structure
|
||||
* Converts a URI Template to ink-form structure.
|
||||
*
|
||||
* Fields come from core's `templateVariables`, the same parser
|
||||
* `InspectorClient.readResourceFromTemplate` expands through, so the key this
|
||||
* form submits is the key the expander looks up. Using the SDK's
|
||||
* `variableNames` here instead is not merely untidy -- it mangles the name:
|
||||
* `{;id}` yields `";id"` and `{id:3}` yields `"id:3"`, so the form would submit
|
||||
* `{ ";id": "7" }` while the expander looks for `id`, silently dropping the
|
||||
* value and the whole expression with it (#1919).
|
||||
*
|
||||
* The SDK template is still constructed, but only to validate: core's parser is
|
||||
* deliberately lenient (an unclosed `{` becomes literal text), so this is what
|
||||
* still surfaces a malformed template as an empty form plus a logged error.
|
||||
*/
|
||||
export function uriTemplateToForm(
|
||||
uriTemplate: string,
|
||||
templateName: string,
|
||||
): FormStructure {
|
||||
const fields: FormField[] = [];
|
||||
let fields: FormField[] = [];
|
||||
|
||||
try {
|
||||
const template = new UriTemplate(uriTemplate);
|
||||
/* v8 ignore next -- UriTemplate.variableNames is a getter that always
|
||||
returns a string[]; the `|| []` fallback is an unreachable guard. */
|
||||
const variableNames = template.variableNames || [];
|
||||
|
||||
for (const variableName of variableNames) {
|
||||
const field: FormField = {
|
||||
name: variableName,
|
||||
label: variableName,
|
||||
type: "string",
|
||||
required: false, // URI template variables are typically optional
|
||||
};
|
||||
|
||||
fields.push(field);
|
||||
}
|
||||
new UriTemplate(uriTemplate);
|
||||
// Only a variable that is the *sole* member of a non-omittable expression
|
||||
// is genuinely mandatory. RFC 6570 drops undefined names from a multi-name
|
||||
// expression, so `{a,b}` needs only one of the two -- ink-form cannot
|
||||
// express "any one of these", and marking both required would refuse input
|
||||
// the expander accepts.
|
||||
const mandatory = new Set(
|
||||
requiredGroups(uriTemplate)
|
||||
.filter((names) => names.length === 1)
|
||||
.map(([name]) => name),
|
||||
);
|
||||
fields = templateVariables(uriTemplate).map(({ name }) => ({
|
||||
name,
|
||||
label: name,
|
||||
type: "string",
|
||||
required: mandatory.has(name),
|
||||
}));
|
||||
} catch (error) {
|
||||
// If parsing fails, return empty form
|
||||
console.error("Failed to parse URI template:", error);
|
||||
|
|
|
|||
|
|
@ -253,9 +253,18 @@ export function ResourceTemplatePanel({
|
|||
// required multi-name expression no single field is mandatory either
|
||||
// -- any one of them satisfies it -- so say which, rather than
|
||||
// marking each one required and blocking valid input.
|
||||
const sharedGroup = groups.find(
|
||||
(names) => names.length > 1 && names.includes(varName),
|
||||
// A name can sit in a singleton required group *and* a shared one
|
||||
// (`x://{a}/{a,b}`). The singleton demands this exact field, so the
|
||||
// "any one of" hint would contradict the disabled submit button --
|
||||
// suppress it and let the field read as plainly required.
|
||||
const individuallyRequired = groups.some(
|
||||
(names) => names.length === 1 && names[0] === varName,
|
||||
);
|
||||
const sharedGroup = individuallyRequired
|
||||
? undefined
|
||||
: groups.find(
|
||||
(names) => names.length > 1 && names.includes(varName),
|
||||
);
|
||||
const description = !required
|
||||
? "Optional"
|
||||
: sharedGroup
|
||||
|
|
|
|||
|
|
@ -432,3 +432,36 @@ describe("allow-reserved encoding under + and #", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unreserved encoding under the non-reserved operators", () => {
|
||||
// `encodeURIComponent` leaves the sub-delims !'()* bare, but RFC 6570 only
|
||||
// allows *unreserved* characters through for these operators.
|
||||
it.each([
|
||||
["", "x://"],
|
||||
[".", "x://a."],
|
||||
["/", "x://a/"],
|
||||
])("encodes !'()* under the %s operator", (operator, prefix) => {
|
||||
const base = operator === "" ? "x://" : "x://a";
|
||||
expect(
|
||||
expandUriTemplate(`${base}{${operator}v}`, { v: "a!b'c(d)e*f" }),
|
||||
).toBe(`${prefix}a%21b%27c%28d%29e%2Af`);
|
||||
});
|
||||
|
||||
it("encodes them in a named (query) expression too", () => {
|
||||
expect(expandUriTemplate("x://a{?v}", { v: "a!b" })).toBe("x://a?v=a%21b");
|
||||
});
|
||||
|
||||
it("encodes them in a matrix expression too", () => {
|
||||
expect(expandUriTemplate("x://a{;v}", { v: "a!b" })).toBe("x://a;v=a%21b");
|
||||
});
|
||||
|
||||
it("leaves them alone under + and #, where reserved characters are allowed", () => {
|
||||
expect(expandUriTemplate("x://{+v}", { v: "a!b'c(d)e*f" })).toBe(
|
||||
"x://a!b'c(d)e*f",
|
||||
);
|
||||
});
|
||||
|
||||
it("still leaves the unreserved set itself untouched", () => {
|
||||
expect(expandUriTemplate("x://{v}", { v: "aZ0-._~" })).toBe("x://aZ0-._~");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue