fix(mcp): express inspect_analysis_view selector constraints in the contract schema (#1705)

The item view advertised its four selector alternatives independently of the
selected collection, while collection-specific constraints lived only in a
superRefine that does not survive JSON Schema conversion. Clients validating
against tools/list could not discover that modules accept only path/node_id
and sections/symbols accept only index/name.

Replace the refined object with a discriminated union on collection so the
converted draft-2020-12 schema rejects the same combinations the MCP server
rejects. Runtime selection semantics are unchanged.

Fixes #1695

Co-authored-by: Saim Shafique <[email protected]>
This commit is contained in:
Saim Shafique
2026-10-11 19:55:16 +08:00
committed by GitHub
co-authored by Saim Shafique
parent cdd9fe7167
commit a8a9c4d817
2 changed files with 124 additions and 37 deletions
+35 -37
View File
@@ -30,44 +30,42 @@ const facetViewSchema = z.strictObject({
kind: z.literal("facet"),
facet: z.enum(["mitigations", "linkage"]),
});
const itemSelectorSchema = z.union([
z.strictObject({ index: z.number().int().nonnegative() }),
z.strictObject({ name: z.string() }),
z.strictObject({
path: z
.string()
.describe(
"Exact observed module path or original source-map reference from a module page; an empty reference is valid.",
),
}),
z.strictObject({ node_id: prefixedDigestSchema("jag_node") }),
const indexSelectorSchema = z.strictObject({
index: z.number().int().nonnegative(),
});
const nameSelectorSchema = z.strictObject({ name: z.string() });
const pathSelectorSchema = z.strictObject({
path: z
.string()
.describe(
"Exact observed module path or original source-map reference from a module page; an empty reference is valid.",
),
});
const nodeIdSelectorSchema = z.strictObject({
node_id: prefixedDigestSchema("jag_node"),
});
const moduleItemViewSchema = z.strictObject({
kind: z.literal("item"),
collection: z.literal("modules"),
selector: z.union([pathSelectorSchema, nodeIdSelectorSchema]),
});
const sectionOrSymbolItemViewSchema = z.strictObject({
kind: z.literal("item"),
collection: z.enum(["sections", "symbols"]),
selector: z.union([indexSelectorSchema, nameSelectorSchema]),
});
/**
* The collection-specific selector alternatives are expressed in the owning
* contract so the advertised JSON Schema preserves them: modules accept
* path/node_id, sections and symbols accept index/name. Refinement-only
* constraints do not survive JSON Schema conversion, so the advertised
* schema previously accepted combinations that runtime input validation
* rejects.
*/
const itemViewSchema = z.discriminatedUnion("collection", [
moduleItemViewSchema,
sectionOrSymbolItemViewSchema,
]);
const itemViewSchema = z
.strictObject({
kind: z.literal("item"),
collection: z.enum(["sections", "symbols", "modules"]),
selector: itemSelectorSchema,
})
.superRefine((value, context) => {
const selector = value.selector;
if (value.collection === "modules") {
if (!("path" in selector) && !("node_id" in selector))
context.addIssue({
code: "custom",
path: ["selector"],
message:
"Module items are selected by exact path or node_id, not index or name.",
});
return;
}
if (!("index" in selector) && !("name" in selector))
context.addIssue({
code: "custom",
path: ["selector"],
message:
"Section and symbol items are selected by index or exact name, not path or node_id.",
});
});
const pageViewSchema = z.strictObject({
kind: z.literal("page"),
collection: z.enum(["sections", "symbols", "modules"]),
@@ -460,6 +460,95 @@ it(
advertiseAndEnforceProcessEnvironmentKeyConstraint,
);
const advertiseAndEnforceItemSelectorCollectionConstraints =
async (): Promise<void> => {
const contract = TOOL_CONTRACTS.find(
({ name }) => name === "inspect_analysis_view",
);
if (contract === undefined)
throw new Error("inspect_analysis_view contract was not registered");
const server = new McpServer({
name: "item-selector-schema",
version: "0",
});
server.registerTool(
contract.name,
toolRegistrationOptions(contract),
async () => ({
content: [],
}),
);
const client = new Client({ name: "item-selector-schema", version: "0" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const advertised = (await client.listTools()).tools.find(
({ name }) => name === contract.name,
);
if (advertised === undefined)
throw new Error("inspect_analysis_view tool was not advertised");
const validate = new Ajv2020({
strict: false,
validateFormats: false,
}).compile(advertised.inputSchema);
const source = {
kind: "retained-evidence",
evidence_id: `ev_${"a".repeat(64)}`,
};
const itemInput = (collection: string, selector: unknown) => ({
source,
view: { kind: "item", collection, selector },
});
const nodeId = `jag_node_${"b".repeat(64)}`;
// Collection-specific selector constraints must survive JSON Schema
// conversion: the advertised schema rejects exactly what runtime
// input validation rejects.
const invalid: ReadonlyArray<readonly [string, unknown]> = [
["modules", { index: 0 }],
["modules", { name: "entry" }],
["sections", { path: "/src/entry.js" }],
["symbols", { node_id: nodeId }],
];
const valid: ReadonlyArray<readonly [string, unknown]> = [
["modules", { path: "/src/entry.js" }],
["modules", { node_id: nodeId }],
["sections", { index: 0 }],
["sections", { name: ".text" }],
["symbols", { index: 0 }],
["symbols", { name: "main" }],
];
for (const [collection, selector] of invalid) {
const input = itemInput(collection, selector);
const label = `${collection}/${Object.keys(selector as object)[0]}`;
expect(contract.inputSchema.safeParse(input).success, label).toBe(
false,
);
expect(validate(input), `advertised schema rejects ${label}`).toBe(
false,
);
}
for (const [collection, selector] of valid) {
const input = itemInput(collection, selector);
const label = `${collection}/${Object.keys(selector as object)[0]}`;
expect(contract.inputSchema.safeParse(input).success, label).toBe(true);
expect(validate(input), `advertised schema accepts ${label}`).toBe(
true,
);
}
} finally {
await Promise.allSettled([client.close(), server.close()]);
}
};
it(
"advertises and enforces the item selector collection constraints",
advertiseAndEnforceItemSelectorCollectionConstraints,
);
describe("MCP process input JSON Schema", () => {
it("advertises process strings without weakening terminal input", async () => {
const server = new McpServer({