fix: expand each expression independently, and reject invalid varspecs

Addresses Copilot's round-5 review on #2035 — eight suppressed comments,
all of them real.

The consequential one overturns behavior I copied from the SDK and
defended in two earlier rounds. RFC 6570 expands each expression
independently; it carries no cross-expression state. The SDK tracks
whether a query expression has already emitted and rewrites a later
`{?two}`'s `?` to `&`. That looks friendlier and is wrong, and its own
matcher says so: for `x{?one}{?two}` it expands to `x?one=1&two=2`, and
`match("x?one=1&two=2")` on that same template returns **null**, while
`match("x?one=1?two=2")` returns both variables. So the rewrite emitted a
URI the advertised template cannot match — #1919 one level up. A server
that wants a continuation advertises `{?one}{&two}`, which expands to `&`
and matches; producing that shape is the server's choice to declare, not
ours to infer.

Also:

- `{id:abc}` was silently treated as `{id}`, and a test codified it. RFC
  6570's max-length is `%x31-39 0*3DIGIT`, so `{id:}`, `{id:0}`,
  `{id:abc}` and `{id:10000}` are invalid templates rather than templates
  with an ignorable modifier — and the SDK's constructor accepts them all,
  so nothing else rejects them. Strict expansion now throws; lenient
  returns the raw template. Discovery stays lenient so the panel still
  renders rather than going blank.

- The TUI could submit `{a,b}` completely blank while the web panel
  blocked the same request: ink-form has no way to express "any one of
  these", so its members are left optional and the modal needs its own
  group check. Added, with tests — it was also below the coverage gate
  without them (functions 76.92%).

- Added the integration test the config never had: it resolves the
  checked-in config and drives all four reads over a real transport, so a
  misspelt preset fails there rather than only when someone runs the repro
  by hand.

- "All three clients" was false. The CLI has no template form and its
  `resources/read` passes the already-expanded `--uri` straight to
  `readResource`, so nothing here runs for it. Corrected in the README,
  AGENTS.md, and the module comment, and dropped a docblock still
  describing the SDK-delegation path removed last round.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-16 21:38:52 -04:00
commit ebc18e1d56
2 changed files with 353 additions and 106 deletions

View file

@ -77,6 +77,53 @@ const renderAndSubmit = async (
};
describe("ResourceTestModal", () => {
// RFC 6570 keeps a required expression satisfied by any ONE of its names, so
// `{a,b}` cannot be expressed with ink-form's per-field `required` flag and
// its members are left optional there. Without the modal's own group check
// the TUI would submit blank -- dropping the expression and reading a
// different resource -- while the web panel blocks the same request (#1919).
describe("required-group validation", () => {
it("refuses to submit when no name in a required group has a value", async () => {
const read = vi.fn();
const { onClose, unmount } = await renderAndSubmit(
fakeClient(read),
makeTemplate({ uriTemplate: "x://{a,b}" }),
{ a: "", b: "" },
);
expect(read).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
unmount();
});
it("submits once any one name in the group has a value", async () => {
const read = vi.fn().mockResolvedValue({
result: { contents: [] },
expandedUri: "x://only-a",
});
const { unmount } = await renderAndSubmit(
fakeClient(read),
makeTemplate({ uriTemplate: "x://{a,b}" }),
{ a: "only-a", b: "" },
);
expect(read).toHaveBeenCalledWith("x://{a,b}", { a: "only-a", b: "" });
unmount();
});
it("does not block a template whose only expression is omittable", async () => {
const read = vi.fn().mockResolvedValue({
result: { contents: [] },
expandedUri: "x://events",
});
const { unmount } = await renderAndSubmit(
fakeClient(read),
makeTemplate({ uriTemplate: "x://events{?topic}" }),
{ topic: "" },
);
expect(read).toHaveBeenCalled();
unmount();
});
});
it("renders the form initially without invoking the client", async () => {
const read = vi.fn();
const api = render(

View file

@ -5,6 +5,10 @@ import { InspectorClient } from "@inspector/core/mcp/index.js";
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
import type { ReadResourceResult } from "@modelcontextprotocol/client";
import { uriTemplateToForm } from "../utils/uriTemplateToForm.js";
import {
hasRequiredValues,
requiredGroups,
} from "@inspector/core/mcp/uriTemplate.js";
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
// Helper to extract error message from various error types
@ -133,6 +137,29 @@ export function ResourceTestModal({
const handleFormSubmit = async (values: Record<string, string>) => {
if (!inspectorClient || !template) return;
// RFC 6570 keeps a required expression satisfied by any ONE of its names,
// so `{a,b}` cannot be expressed with ink-form's per-field `required` flag
// and its members are left optional there. Without this check the TUI would
// submit `{a,b}` completely blank -- dropping the expression and reading a
// different resource -- while the web panel blocks the same request (#1919).
const groups = requiredGroups(template.uriTemplate);
if (!hasRequiredValues(groups, values)) {
const unmet = groups
.filter(
(names) => !names.some((name) => (values[name] ?? "").length > 0),
)
.map((names) => names.join(" or "));
setResult({
input: values,
output: null,
error: `Missing required template variable(s): ${unmet.join(", ")}`,
duration: 0,
uri: template.uriTemplate,
});
setState("results");
return;
}
setState("loading");
const startTime = Date.now();