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:
parent
935113d1e0
commit
ebc18e1d56
2 changed files with 353 additions and 106 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue