mirror of
https://github.com/morluto/rea
synced 2026-10-27 22:45:19 -04:00
fix(mcp): summarize copied browser observations in input declarations (#7469)
Declare copied complete browser observations as abbreviated object operands, keeping canonical validation and normalization controls unchanged. Preserve all other tool declarations and Evidence identities. Refs #4973 and #4081.
This commit is contained in:
+13
-2
@@ -1248,11 +1248,22 @@ failures for such entries name this cause and the operation to rerun with
|
||||
Advertised input and output schemas contain no reachable recursive references.
|
||||
Input schemas inline repeated definitions before applying the selected
|
||||
presentation profile. Both `full` and `compact` start from this self-contained
|
||||
projection; output schemas can share schema-local definitions. Full inputs
|
||||
retain nested fields, descriptions and valid literal examples. The compatibility
|
||||
projection; output schemas can share schema-local definitions. Analyst-authored
|
||||
full inputs retain nested fields, descriptions and valid literal examples. The compatibility
|
||||
projections below can relax advertised constraints; canonical runtime validation
|
||||
still enforces the complete input contract.
|
||||
|
||||
`compare_web_captures` declares its complete producer observations as object
|
||||
operands instead of repeating their full output schemas in the input catalog.
|
||||
Pass the original `inspect_web_page` normalized result in `before.inspection`
|
||||
and `after.inspection`, optional complete `discover_webmcp_tools` results in
|
||||
`webmcp`, or complete `capture_browser_scenario` results in the scenario fields.
|
||||
Do not construct partial observations from the abbreviated declaration: the
|
||||
server still validates every canonical field before comparison. Input groups
|
||||
and analyst-authored normalization rules remain explicit. This presentation
|
||||
changes no capture bytes, identity, completeness, or comparison semantics and
|
||||
does not reduce `inspect_analysis_view` selectors or other tools' declarations.
|
||||
|
||||
When a host forwards input schemas to the model, their examples consume context.
|
||||
REA's advertised examples therefore never copy complete producer Evidence.
|
||||
An embedded Evidence record is shown as its exact `{"kind":"retained-evidence","evidence_id":"ev_..."}`
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Ajv } from "ajv";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { presentInputJsonSchema } from "./inputSchemaPresentation.js";
|
||||
|
||||
it.each(["draft-2020-12", "draft-07"] as const)(
|
||||
"summarizes only declared observation schemas, preserving literal data in %s",
|
||||
(target) => {
|
||||
const literal = {
|
||||
type: "object",
|
||||
"x-rea-complete-observation": true,
|
||||
properties: { producer_data: { type: "string" } },
|
||||
};
|
||||
const controls = {
|
||||
type: "object",
|
||||
properties: { match: { type: "string", minLength: 1 } },
|
||||
required: ["match"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
const canonical = {
|
||||
$schema:
|
||||
target === "draft-07"
|
||||
? "http://json-schema.org/draft-07/schema#"
|
||||
: "https://json-schema.org/draft/2020-12/schema",
|
||||
type: "object",
|
||||
properties: {
|
||||
observation: {
|
||||
type: "object",
|
||||
description: "Complete result from the producer.",
|
||||
"x-rea-complete-observation": true,
|
||||
properties: { id: { type: "string" } },
|
||||
required: ["id"],
|
||||
},
|
||||
controls,
|
||||
literal: { const: literal },
|
||||
},
|
||||
required: ["observation", "controls", "literal"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
const presented = presentInputJsonSchema(canonical, () => undefined);
|
||||
expect(presented).toHaveProperty("properties.observation", {
|
||||
type: "object",
|
||||
description: expect.stringContaining("complete canonical observation"),
|
||||
});
|
||||
expect(presented).toHaveProperty("properties.controls", controls);
|
||||
expect(presented).toHaveProperty("properties.literal.const", literal);
|
||||
const ajv =
|
||||
target === "draft-07"
|
||||
? new Ajv({ strict: false })
|
||||
: new Ajv2020({ strict: false });
|
||||
const validate = ajv.compile(presented);
|
||||
expect(
|
||||
validate({ observation: {}, controls: { match: "x" }, literal }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
validate({ observation: "{}", controls: { match: "x" }, literal }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
validate({ observation: {}, controls: { match: "" }, literal }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
ajv.compile(canonical)({
|
||||
observation: {},
|
||||
controls: { match: "x" },
|
||||
literal,
|
||||
}),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
@@ -42,36 +42,47 @@ const describeSchema = (
|
||||
references: JsonSchemaReferences,
|
||||
inherited: JsonSchemaScope | undefined,
|
||||
describeProperty: (property: string) => string | undefined,
|
||||
propertyDescription?: string,
|
||||
): unknown => {
|
||||
if (!isObject(schema)) return schema;
|
||||
// These operands are complete producer results, not fields an agent builds.
|
||||
// Their owner keeps full canonical validation; only their wire declaration
|
||||
// omits the repeated result schema. Analyst-authored controls stay explicit.
|
||||
if (schema.type === "object" && schema["x-rea-complete-observation"] === true)
|
||||
return {
|
||||
type: "object",
|
||||
description: [
|
||||
propertyDescription ??
|
||||
(typeof schema.description === "string"
|
||||
? schema.description
|
||||
: undefined),
|
||||
"Pass the complete returned normalized_result unchanged. Its fields are omitted from this declaration; the server still validates the complete canonical observation.",
|
||||
]
|
||||
.filter((text) => text !== undefined)
|
||||
.join(" "),
|
||||
};
|
||||
const scope = references.enter(schema, inherited);
|
||||
return Object.fromEntries(
|
||||
const described = Object.fromEntries(
|
||||
Object.entries(schema).map(([key, value]) => {
|
||||
if (schemaMaps.has(key) && isObject(value))
|
||||
return [
|
||||
key,
|
||||
Object.fromEntries(
|
||||
Object.entries(value).map(([name, child]) => {
|
||||
const described = describeSchema(
|
||||
child,
|
||||
references,
|
||||
scope,
|
||||
describeProperty,
|
||||
);
|
||||
if (
|
||||
key !== "properties" ||
|
||||
!isObject(described) ||
|
||||
!isObject(child)
|
||||
)
|
||||
return [name, described];
|
||||
const description =
|
||||
referencedDescription(references, child, scope) ??
|
||||
describeProperty(name);
|
||||
key === "properties" && isObject(child)
|
||||
? (referencedDescription(references, child, scope) ??
|
||||
describeProperty(name))
|
||||
: undefined;
|
||||
return [
|
||||
name,
|
||||
description === undefined
|
||||
? described
|
||||
: { ...described, description },
|
||||
describeSchema(
|
||||
child,
|
||||
references,
|
||||
scope,
|
||||
describeProperty,
|
||||
description,
|
||||
),
|
||||
];
|
||||
}),
|
||||
),
|
||||
@@ -96,6 +107,9 @@ const describeSchema = (
|
||||
return [key, value];
|
||||
}),
|
||||
);
|
||||
return propertyDescription === undefined
|
||||
? described
|
||||
: { ...described, description: propertyDescription };
|
||||
};
|
||||
|
||||
const objectBranch = (
|
||||
|
||||
@@ -85,7 +85,7 @@ export const toolInputSchemaWithMetadata = <Contract extends ToolContract>(
|
||||
},
|
||||
);
|
||||
|
||||
/** Return the same complete input JSON Schema projection registered on MCP. */
|
||||
/** Return the full-profile advertised input JSON Schema registered on MCP. */
|
||||
export const advertisedInputJsonSchema = <Contract extends ToolContract>(
|
||||
contract: Contract,
|
||||
): Record<string, unknown> =>
|
||||
|
||||
@@ -74,12 +74,14 @@ const normalizationInputSchema = z
|
||||
|
||||
/** Input for step-aligned comparison of two browser scenario captures. */
|
||||
export const compareBrowserScenariosInputSchema = z.strictObject({
|
||||
before_scenario: retainedBrowserScenarioCaptureSchema.describe(
|
||||
"Earlier complete normalized_result from capture_browser_scenario.",
|
||||
),
|
||||
after_scenario: retainedBrowserScenarioCaptureSchema.describe(
|
||||
"Later complete normalized_result from capture_browser_scenario.",
|
||||
),
|
||||
before_scenario: retainedBrowserScenarioCaptureSchema
|
||||
.describe(
|
||||
"Earlier complete normalized_result from capture_browser_scenario.",
|
||||
)
|
||||
.meta({ "x-rea-complete-observation": true }),
|
||||
after_scenario: retainedBrowserScenarioCaptureSchema
|
||||
.describe("Later complete normalized_result from capture_browser_scenario.")
|
||||
.meta({ "x-rea-complete-observation": true }),
|
||||
normalization: normalizationInputSchema
|
||||
.default({ rules: [] })
|
||||
.describe("Exact-literal rules committed in the comparison result."),
|
||||
|
||||
@@ -6,10 +6,13 @@ import { webMcpDiscoverySchema } from "./webMcpDiscovery.js";
|
||||
|
||||
/** One normalized passive page snapshot accepted by capture comparison. */
|
||||
export const captureSnapshotSchema = z.object({
|
||||
inspection: webPageInspectionSchema.describe(
|
||||
"Complete normalized_result from inspect_web_page, including capture identity, observations, completeness, and limitations.",
|
||||
),
|
||||
inspection: webPageInspectionSchema
|
||||
.describe(
|
||||
"Complete normalized_result from inspect_web_page, including capture identity, observations, completeness, and limitations.",
|
||||
)
|
||||
.meta({ "x-rea-complete-observation": true }),
|
||||
webmcp: webMcpDiscoverySchema
|
||||
.meta({ "x-rea-complete-observation": true })
|
||||
.nullable()
|
||||
.default(null)
|
||||
.describe(
|
||||
|
||||
@@ -62,21 +62,23 @@ function invalidComparisonInputs(
|
||||
const scenarioCapture = browserScenarioCaptureSchema.parse(
|
||||
scenario.before_scenario,
|
||||
);
|
||||
return [
|
||||
const runtimeOnlyInvalid = [
|
||||
{ before: { inspection: {} }, after: passive.after },
|
||||
{ before: passive.before, after: { ...passive.after, webmcp: {} } },
|
||||
{ before_scenario: {}, after_scenario: {} },
|
||||
{ ...scenario, before_scenario: { ...scenarioCapture, steps: [{}] } },
|
||||
];
|
||||
const advertisedInvalid = [
|
||||
{},
|
||||
{ before: passive.before },
|
||||
{ before: { inspection: {} }, after: passive.after },
|
||||
{ before: { inspection: "{}" }, after: passive.after },
|
||||
{
|
||||
before: passive.before,
|
||||
after: { ...passive.after, webmcp: {} },
|
||||
after: { ...passive.after, webmcp: "{}" },
|
||||
},
|
||||
{ before_scenario: {}, after_scenario: {} },
|
||||
{ before_scenario: "{}", after_scenario: scenarioCapture },
|
||||
{ before_scenario: scenario.before_scenario },
|
||||
{ ...passive, unexpected: true },
|
||||
{
|
||||
...scenario,
|
||||
before_scenario: { ...scenarioCapture, steps: [{}] },
|
||||
},
|
||||
{
|
||||
...scenario,
|
||||
normalization: {
|
||||
@@ -86,6 +88,7 @@ function invalidComparisonInputs(
|
||||
},
|
||||
},
|
||||
];
|
||||
return { advertisedInvalid, runtimeOnlyInvalid };
|
||||
}
|
||||
|
||||
describe("browser comparison input boundary", () => {
|
||||
@@ -118,6 +121,10 @@ describe("browser comparison input boundary", () => {
|
||||
({ name }) => name === contract.name,
|
||||
);
|
||||
if (advertised === undefined) throw new Error("Missing comparison tool");
|
||||
// Claude Desktop rejects tools above its 64 KiB per-input-schema limit.
|
||||
expect(
|
||||
Buffer.byteLength(JSON.stringify(advertised.inputSchema)),
|
||||
).toBeLessThanOrEqual(65_536);
|
||||
expect(advertised.inputSchema.type).toBe("object");
|
||||
// Anthropic tool input schemas reject root combinators.
|
||||
expect(advertised.inputSchema).not.toHaveProperty("anyOf");
|
||||
@@ -146,6 +153,9 @@ describe("browser comparison input boundary", () => {
|
||||
"before + after; before_scenario + after_scenario + [normalization]",
|
||||
);
|
||||
const runtimeOnlyInvalid = [
|
||||
// Complete observations are copied from producer results, not authored
|
||||
// from their declaration. Malformed payloads still fail before effects.
|
||||
...invalid.runtimeOnlyInvalid,
|
||||
{ ...passive, ...scenario },
|
||||
{ ...passive, normalization: { rules: [] } },
|
||||
{
|
||||
@@ -165,7 +175,7 @@ describe("browser comparison input boundary", () => {
|
||||
runtimeValid: true,
|
||||
}),
|
||||
),
|
||||
...invalid.map((input) => ({
|
||||
...invalid.advertisedInvalid.map((input) => ({
|
||||
input,
|
||||
advertisedValid: false,
|
||||
runtimeValid: false,
|
||||
|
||||
@@ -582,34 +582,16 @@ describe("MCP JSON Schema validity", () => {
|
||||
expectKnownAuthorityHints(tools);
|
||||
const byName = new Map(tools.map((tool) => [tool.name, tool]));
|
||||
expectManagedLocalSignatureSchema(byName.get("inspect_managed_members"));
|
||||
const comparison = byName.get("compare_web_captures")?.inputSchema;
|
||||
for (const side of ["before", "after"])
|
||||
expect(byName.get("compare_web_captures")?.inputSchema).toHaveProperty(
|
||||
[
|
||||
"properties",
|
||||
side,
|
||||
"properties",
|
||||
"inspection",
|
||||
"properties",
|
||||
"scripts",
|
||||
"properties",
|
||||
"items",
|
||||
"items",
|
||||
"properties",
|
||||
"source",
|
||||
"anyOf",
|
||||
],
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
properties: expect.objectContaining({
|
||||
reason: { type: "string" },
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
properties: expect.objectContaining({
|
||||
artifact: expect.objectContaining({ type: "object" }),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
expect(comparison).toHaveProperty(
|
||||
["properties", side, "properties", "inspection"],
|
||||
{
|
||||
type: "object",
|
||||
description: expect.stringContaining(
|
||||
"complete canonical observation",
|
||||
),
|
||||
},
|
||||
);
|
||||
const catalogProjection = tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
|
||||
Reference in New Issue
Block a user