mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(mcp): preserve tuple constraints in advertised schemas
Declare fixed tuple tails and lengths at the canonical boundary so client array validation accepts every tool. Preserve nonempty candidate constraints in managed and application comparisons. Add catalog-wide client checks and parser/schema parity coverage for both dialects and projection directions. Fixes #3576
This commit is contained in:
@@ -87,6 +87,12 @@ malformed, unavailable, cancellation, and partial outcomes through affected
|
||||
consumers. Real-engine claims require the matching provider lane; see
|
||||
[testing.md](testing.md).
|
||||
|
||||
For fixed tuples in public contracts, use `src/domain/fixedTupleSchema.ts`:
|
||||
Zod's tuple projection omits implicit length bounds and Draft 2020-12 `items`,
|
||||
which some clients require even alongside `prefixItems`. Tuples with a rest
|
||||
schema also need explicit minimum lengths. Check parser/schema parity for empty,
|
||||
short, overlong, and malformed values in both input and output projections.
|
||||
|
||||
When discoverability changes, evaluate representative broad, direct, and negative
|
||||
tasks on the intended host. Record host/model/catalog, selected tools, arguments,
|
||||
errors, retries, and task outcomes. Schema validity and keyword heuristics do not
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { Ajv } from "ajv";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { packetFieldsSchema } from "../domain/captures/packetCapture.js";
|
||||
import { swiftStringLiteralSchema } from "../domain/hopperValues.js";
|
||||
import { applicationVersionComparisonResultSchema } from "../domain/javascript/javascriptApplicationVersionComparisonSchemas.js";
|
||||
import { pinvokeVerificationSchema } from "../domain/managed/managedNativeVerificationSchemas.js";
|
||||
import { relationshipCoverageSchema } from "../domain/relationshipCoverage.js";
|
||||
|
||||
const directions = [
|
||||
[],
|
||||
["callers"],
|
||||
["callees"],
|
||||
["callers", "callees"],
|
||||
["callees", "callers"],
|
||||
["callers", "callers"],
|
||||
["callees", "callees"],
|
||||
["callers", "callees", "callers"],
|
||||
["incoming"],
|
||||
[null],
|
||||
null,
|
||||
];
|
||||
const literal = {
|
||||
representation: "small",
|
||||
source_address: "0x1000",
|
||||
address: null,
|
||||
value: "hello",
|
||||
registers: ["x0", "x1"],
|
||||
words: ["0x6f6c6c6568", "0xe500000000000000"],
|
||||
};
|
||||
const ambiguousItem = {
|
||||
item_id: `javc_item_${"a".repeat(64)}`,
|
||||
node_kind: "javascript-module",
|
||||
status: "unknown",
|
||||
left_node_id: `jag_node_${"b".repeat(64)}`,
|
||||
right_node_id: null,
|
||||
match: {
|
||||
status: "ambiguous",
|
||||
basis: "none",
|
||||
confidence: "unknown",
|
||||
candidate_left_node_ids: [],
|
||||
candidate_right_node_ids: [`jag_node_${"c".repeat(64)}`],
|
||||
},
|
||||
dimensions: [],
|
||||
evidence_links: [`ev_${"d".repeat(64)}`, `ev_${"e".repeat(64)}`],
|
||||
limitations: [],
|
||||
};
|
||||
const verifiedCandidates = pinvokeVerificationSchema.options.find(
|
||||
(option) => option.shape.status.value === "verified",
|
||||
)?.shape.candidates;
|
||||
if (verifiedCandidates === undefined)
|
||||
throw new Error("Missing verified native candidate contract");
|
||||
const nativeSymbol = {
|
||||
evidence_id: `ev_${"f".repeat(64)}`,
|
||||
operation: "analyze_function",
|
||||
name: "NativeEntry",
|
||||
address: "0x1000",
|
||||
module_name: null,
|
||||
module_path: null,
|
||||
source: "function-dossier",
|
||||
};
|
||||
|
||||
const cases: readonly {
|
||||
readonly name: string;
|
||||
readonly schema: z.ZodType;
|
||||
readonly samples: readonly {
|
||||
readonly value: unknown;
|
||||
readonly valid: boolean;
|
||||
}[];
|
||||
}[] = [
|
||||
...(["partial", "unknown"] as const).map((status) => ({
|
||||
name: `${status} relationship directions`,
|
||||
schema: relationshipCoverageSchema,
|
||||
samples: directions.map((limited_directions) => ({
|
||||
value: {
|
||||
status,
|
||||
...(status === "partial"
|
||||
? { limitations: ["Read limit reached."] }
|
||||
: { reason: "Coverage unavailable." }),
|
||||
limited_directions,
|
||||
},
|
||||
valid:
|
||||
Array.isArray(limited_directions) &&
|
||||
['["callers"]', '["callees"]', '["callers","callees"]'].includes(
|
||||
JSON.stringify(limited_directions),
|
||||
),
|
||||
})),
|
||||
})),
|
||||
{
|
||||
name: "nonempty managed native candidates",
|
||||
schema: verifiedCandidates,
|
||||
samples: [
|
||||
{ value: [nativeSymbol], valid: true },
|
||||
{ value: [nativeSymbol, nativeSymbol], valid: true },
|
||||
{ value: [], valid: false },
|
||||
{ value: [null], valid: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Swift register and word pairs",
|
||||
schema: swiftStringLiteralSchema,
|
||||
samples: [
|
||||
{ value: literal, valid: true },
|
||||
...["registers", "words"].flatMap((field) =>
|
||||
[[], ["x0"], ["x0", "x1", "x2"], ["x0", null], null].map((value) => ({
|
||||
value: { ...literal, [field]: value },
|
||||
valid: false,
|
||||
})),
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "packet frame numbers",
|
||||
schema: packetFieldsSchema,
|
||||
samples: [
|
||||
{ value: { "frame.number": ["1"], "ip.addr": ["a", "b"] }, valid: true },
|
||||
...[[], ["1", "2"], ["0"], [1], null].map((value) => ({
|
||||
value: { "frame.number": value },
|
||||
valid: false,
|
||||
})),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "nonempty ambiguous application candidates",
|
||||
schema: applicationVersionComparisonResultSchema.shape.items,
|
||||
samples: [
|
||||
{ value: [ambiguousItem], valid: true },
|
||||
{
|
||||
value: [
|
||||
{
|
||||
...ambiguousItem,
|
||||
match: { ...ambiguousItem.match, candidate_right_node_ids: [] },
|
||||
},
|
||||
],
|
||||
valid: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe.each(["draft-2020-12", "draft-07"] as const)(
|
||||
"tuple JSON Schema parity in %s",
|
||||
(target) => {
|
||||
describe.each(["input", "output"] as const)("%s projection", (io) => {
|
||||
it.each(cases)("preserves $name", ({ schema, samples }) => {
|
||||
const projected = z.toJSONSchema(schema, { io, target });
|
||||
const ajv =
|
||||
target === "draft-2020-12"
|
||||
? new Ajv2020({ strict: false, validateFormats: false })
|
||||
: new Ajv({ strict: false, validateFormats: false });
|
||||
expect(ajv.validateSchema(projected)).toBe(true);
|
||||
const validate = ajv.compile(projected);
|
||||
for (const { value, valid } of samples) {
|
||||
expect(schema.safeParse(value).success, JSON.stringify(value)).toBe(
|
||||
valid,
|
||||
);
|
||||
expect(validate(value), JSON.stringify(value)).toBe(valid);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { fixedTupleSchema } from "../fixedTupleSchema.js";
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
|
||||
const argument = z
|
||||
@@ -95,7 +96,7 @@ export type InspectPacketCaptureInput = z.infer<
|
||||
/** Values remain strings; repeated occurrences remain ordered arrays. */
|
||||
export const packetFieldsSchema = z
|
||||
.object({
|
||||
"frame.number": z.tuple([z.string().regex(/^[1-9][0-9]*$/)]),
|
||||
"frame.number": fixedTupleSchema([z.string().regex(/^[1-9][0-9]*$/)]),
|
||||
})
|
||||
.catchall(z.array(z.string()));
|
||||
export const packetCaptureSchema = z
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Fixed tuples with explicit item and length constraints in JSON Schema. */
|
||||
export const fixedTupleSchema = <
|
||||
const Items extends readonly [z.ZodType, ...z.ZodType[]],
|
||||
>(
|
||||
items: Items,
|
||||
): z.ZodTuple<Items, z.ZodNever> =>
|
||||
// Zod's tuple projection omits implicit arity and, without a rest schema,
|
||||
// `items` in Draft 2020-12. Spell out both at the canonical boundary so
|
||||
// consumers agree with the parser and clients accept the declaration.
|
||||
z
|
||||
.tuple(items, z.never())
|
||||
.check(z.minLength(items.length), z.maxLength(items.length));
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { fixedTupleSchema } from "./fixedTupleSchema.js";
|
||||
import { addressCoordinate } from "./native/addressCoordinate.js";
|
||||
|
||||
import type { JsonValue } from "./jsonValue.js";
|
||||
@@ -286,10 +287,10 @@ const swiftStringLiteralFields = {
|
||||
value: z
|
||||
.string()
|
||||
.describe("Decoded UTF-8 text, including the canonical empty string."),
|
||||
registers: z.tuple([z.string(), z.string()]),
|
||||
words: z
|
||||
.tuple([z.string(), z.string()])
|
||||
.describe("Observed constant register values, in register order."),
|
||||
registers: fixedTupleSchema([z.string(), z.string()]),
|
||||
words: fixedTupleSchema([z.string(), z.string()]).describe(
|
||||
"Observed constant register values, in register order.",
|
||||
),
|
||||
};
|
||||
|
||||
/** A Swift String literal decoded from the two words of its ABI value, not a data reference. */
|
||||
|
||||
@@ -70,7 +70,9 @@ export const compareApplicationVersionsInputSchema = z
|
||||
})
|
||||
.superRefine(validateApplicationVersionIdentities);
|
||||
|
||||
const candidateNodesSchema = z.tuple([nodeIdSchema]).rest(nodeIdSchema);
|
||||
const candidateNodesSchema = z
|
||||
.tuple([nodeIdSchema], nodeIdSchema)
|
||||
.check(z.minLength(1));
|
||||
const exactMatchSchema = z.strictObject({
|
||||
status: z.literal("matched"),
|
||||
basis: z.enum([
|
||||
|
||||
@@ -48,8 +48,8 @@ const pinvokeVerificationContextShape = {
|
||||
};
|
||||
|
||||
const observedCandidatesSchema = z
|
||||
.tuple([nativeSymbolSchema])
|
||||
.rest(nativeSymbolSchema);
|
||||
.tuple([nativeSymbolSchema], nativeSymbolSchema)
|
||||
.check(z.minLength(1));
|
||||
|
||||
export const pinvokeVerificationSchema = z.union([
|
||||
z.strictObject({
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { fixedTupleSchema } from "./fixedTupleSchema.js";
|
||||
|
||||
const diagnostics = z.array(z.string().min(1)).min(1).exactOptional();
|
||||
const completeCoverage = z.strictObject({
|
||||
status: z.literal("complete"),
|
||||
diagnostics,
|
||||
});
|
||||
const limitedDirections = z.union([
|
||||
z.tuple([z.literal("callers")]).check(z.minLength(1), z.maxLength(1)),
|
||||
z.tuple([z.literal("callees")]).check(z.minLength(1), z.maxLength(1)),
|
||||
z
|
||||
.tuple([z.literal("callers"), z.literal("callees")])
|
||||
.check(z.minLength(2), z.maxLength(2)),
|
||||
fixedTupleSchema([z.literal("callers")]),
|
||||
fixedTupleSchema([z.literal("callees")]),
|
||||
fixedTupleSchema([z.literal("callers"), z.literal("callees")]),
|
||||
]);
|
||||
const partialCoverage = z.strictObject({
|
||||
status: z.literal("partial"),
|
||||
|
||||
@@ -5,6 +5,11 @@ import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { advertisedInputExamples } from "../../../src/contracts/advertisedInputExamples.js";
|
||||
import {
|
||||
schemaArrays,
|
||||
schemaChildren,
|
||||
schemaMaps,
|
||||
} from "../../../src/contracts/inputSchemaPresentation.js";
|
||||
import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js";
|
||||
import { emptyArraySchema } from "../../../src/domain/emptyArraySchema.js";
|
||||
import { processScenarioSchema } from "../../../src/domain/process/processScenario.js";
|
||||
@@ -18,6 +23,37 @@ interface ToolSchemas {
|
||||
readonly annotations?: Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
function arrayItemSchemaError(
|
||||
schema: unknown,
|
||||
path: string,
|
||||
): string | undefined {
|
||||
if (!isRecord(schema)) return undefined;
|
||||
// VS Code Copilot's toolSchemaNormalizer requires truthy `items`, even
|
||||
// for Draft 2020-12 tuples with prefixItems (morluto/rea#3576).
|
||||
if (schema.type === "array" && !schema.items)
|
||||
return `${path}: tool parameters array type must have items`;
|
||||
for (const [key, value] of Object.entries(schema)) {
|
||||
const children =
|
||||
schemaMaps.has(key) && isRecord(value)
|
||||
? Object.entries(value).map(
|
||||
([name, child]) => [`${key}.${name}`, child] as const,
|
||||
)
|
||||
: (schemaArrays.has(key) || schemaChildren.has(key)) &&
|
||||
Array.isArray(value)
|
||||
? value.map(
|
||||
(child: unknown, index) => [`${key}.${index}`, child] as const,
|
||||
)
|
||||
: schemaChildren.has(key)
|
||||
? [[key, value] as const]
|
||||
: [];
|
||||
for (const [suffix, child] of children) {
|
||||
const error = arrayItemSchemaError(child, `${path}.${suffix}`);
|
||||
if (error !== undefined) return error;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function schemaErrors(tools: readonly ToolSchemas[]): string[] {
|
||||
// Reuse Ajv's meta-schema validators, then clear each root's reference
|
||||
// namespace, including nested IDs, before validating the next schema.
|
||||
@@ -35,7 +71,11 @@ function schemaErrors(tools: readonly ToolSchemas[]): string[] {
|
||||
if (!ajv.validateSchema(schema))
|
||||
return [`${tool.name}.${kind}: ${ajv.errorsText(ajv.errors)}`];
|
||||
ajv.compile(schema);
|
||||
return [];
|
||||
const clientError = arrayItemSchemaError(
|
||||
schema,
|
||||
`${tool.name}.${kind}`,
|
||||
);
|
||||
return clientError === undefined ? [] : [clientError];
|
||||
} catch (cause: unknown) {
|
||||
return [
|
||||
`${tool.name}.${kind}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
@@ -766,6 +806,26 @@ it("validates each tool independently when output schema IDs repeat", () => {
|
||||
expect(errors[0]).toContain("missing");
|
||||
});
|
||||
|
||||
it("checks client array constraints in schema positions while preserving literal metadata", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: { examples: { type: "array", items: { type: "string" } } },
|
||||
examples: [{ type: "array" }],
|
||||
default: { type: "array" },
|
||||
const: { type: "array" },
|
||||
enum: [{ type: "array" }],
|
||||
};
|
||||
expect(arrayItemSchemaError(schema, "literal")).toBeUndefined();
|
||||
expect(
|
||||
arrayItemSchemaError(
|
||||
{ ...schema, properties: { examples: { type: "array" } } },
|
||||
"broken",
|
||||
),
|
||||
).toBe(
|
||||
"broken.properties.examples: tool parameters array type must have items",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"rejects references to another tool's nested IDs (earlier compilation fails: %s)",
|
||||
(invalidPrevious) => {
|
||||
|
||||
Reference in New Issue
Block a user