mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
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:
co-authored by
Saim Shafique
parent
cdd9fe7167
commit
a8a9c4d817
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user