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:
gensokyo-dev
2026-10-27 17:09:22 +08:00
committed by GitHub
parent b8cdbdf0ef
commit d6f88becd5
8 changed files with 158 additions and 66 deletions
+13 -2
View File
@@ -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);
},
);
+32 -18
View File
@@ -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 = (
+1 -1
View File
@@ -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> =>
+8 -6
View File
@@ -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 -3
View File
@@ -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,
+9 -27
View File
@@ -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,