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:
1 parent
b8cdbdf0ef
commit
d6f88becd5
8 files changed
+158
-66
No files matched your search
+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.
|
Advertised input and output schemas contain no reachable recursive references.
|
||||||
Input schemas inline repeated definitions before applying the selected
|
Input schemas inline repeated definitions before applying the selected
|
||||||
presentation profile. Both `full` and `compact` start from this self-contained
|
presentation profile. Both `full` and `compact` start from this self-contained
|
||||||
projection; output schemas can share schema-local definitions. Full inputs
|
projection; output schemas can share schema-local definitions. Analyst-authored
|
||||||
retain nested fields, descriptions and valid literal examples. The compatibility
|
full inputs retain nested fields, descriptions and valid literal examples. The compatibility
|
||||||
projections below can relax advertised constraints; canonical runtime validation
|
projections below can relax advertised constraints; canonical runtime validation
|
||||||
still enforces the complete input contract.
|
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.
|
When a host forwards input schemas to the model, their examples consume context.
|
||||||
REA's advertised examples therefore never copy complete producer Evidence.
|
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_..."}`
|
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,
|
references: JsonSchemaReferences,
|
||||||
inherited: JsonSchemaScope | undefined,
|
inherited: JsonSchemaScope | undefined,
|
||||||
describeProperty: (property: string) => string | undefined,
|
describeProperty: (property: string) => string | undefined,
|
||||||
|
propertyDescription?: string,
|
||||||
): unknown => {
|
): unknown => {
|
||||||
if (!isObject(schema)) return schema;
|
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);
|
const scope = references.enter(schema, inherited);
|
||||||
return Object.fromEntries(
|
const described = Object.fromEntries(
|
||||||
Object.entries(schema).map(([key, value]) => {
|
Object.entries(schema).map(([key, value]) => {
|
||||||
if (schemaMaps.has(key) && isObject(value))
|
if (schemaMaps.has(key) && isObject(value))
|
||||||
return [
|
return [
|
||||||
key,
|
key,
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
Object.entries(value).map(([name, child]) => {
|
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 =
|
const description =
|
||||||
referencedDescription(references, child, scope) ??
|
key === "properties" && isObject(child)
|
||||||
describeProperty(name);
|
? (referencedDescription(references, child, scope) ??
|
||||||
|
describeProperty(name))
|
||||||
|
: undefined;
|
||||||
return [
|
return [
|
||||||
name,
|
name,
|
||||||
description === undefined
|
describeSchema(
|
||||||
? described
|
child,
|
||||||
: { ...described, description },
|
references,
|
||||||
|
scope,
|
||||||
|
describeProperty,
|
||||||
|
description,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -96,6 +107,9 @@ const describeSchema = (
|
|||||||
return [key, value];
|
return [key, value];
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
return propertyDescription === undefined
|
||||||
|
? described
|
||||||
|
: { ...described, description: propertyDescription };
|
||||||
};
|
};
|
||||||
|
|
||||||
const objectBranch = (
|
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>(
|
export const advertisedInputJsonSchema = <Contract extends ToolContract>(
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
): Record<string, unknown> =>
|
): Record<string, unknown> =>
|
||||||
|
|||||||
@@ -74,12 +74,14 @@ const normalizationInputSchema = z
|
|||||||
|
|
||||||
/** Input for step-aligned comparison of two browser scenario captures. */
|
/** Input for step-aligned comparison of two browser scenario captures. */
|
||||||
export const compareBrowserScenariosInputSchema = z.strictObject({
|
export const compareBrowserScenariosInputSchema = z.strictObject({
|
||||||
before_scenario: retainedBrowserScenarioCaptureSchema.describe(
|
before_scenario: retainedBrowserScenarioCaptureSchema
|
||||||
"Earlier complete normalized_result from capture_browser_scenario.",
|
.describe(
|
||||||
),
|
"Earlier complete normalized_result from capture_browser_scenario.",
|
||||||
after_scenario: retainedBrowserScenarioCaptureSchema.describe(
|
)
|
||||||
"Later 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
|
normalization: normalizationInputSchema
|
||||||
.default({ rules: [] })
|
.default({ rules: [] })
|
||||||
.describe("Exact-literal rules committed in the comparison result."),
|
.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. */
|
/** One normalized passive page snapshot accepted by capture comparison. */
|
||||||
export const captureSnapshotSchema = z.object({
|
export const captureSnapshotSchema = z.object({
|
||||||
inspection: webPageInspectionSchema.describe(
|
inspection: webPageInspectionSchema
|
||||||
"Complete normalized_result from inspect_web_page, including capture identity, observations, completeness, and limitations.",
|
.describe(
|
||||||
),
|
"Complete normalized_result from inspect_web_page, including capture identity, observations, completeness, and limitations.",
|
||||||
|
)
|
||||||
|
.meta({ "x-rea-complete-observation": true }),
|
||||||
webmcp: webMcpDiscoverySchema
|
webmcp: webMcpDiscoverySchema
|
||||||
|
.meta({ "x-rea-complete-observation": true })
|
||||||
.nullable()
|
.nullable()
|
||||||
.default(null)
|
.default(null)
|
||||||
.describe(
|
.describe(
|
||||||
|
|||||||
@@ -62,21 +62,23 @@ function invalidComparisonInputs(
|
|||||||
const scenarioCapture = browserScenarioCaptureSchema.parse(
|
const scenarioCapture = browserScenarioCaptureSchema.parse(
|
||||||
scenario.before_scenario,
|
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: passive.before },
|
||||||
{ before: { inspection: {} }, after: passive.after },
|
{ before: { inspection: "{}" }, after: passive.after },
|
||||||
{
|
{
|
||||||
before: passive.before,
|
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 },
|
{ before_scenario: scenario.before_scenario },
|
||||||
{ ...passive, unexpected: true },
|
{ ...passive, unexpected: true },
|
||||||
{
|
|
||||||
...scenario,
|
|
||||||
before_scenario: { ...scenarioCapture, steps: [{}] },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
...scenario,
|
...scenario,
|
||||||
normalization: {
|
normalization: {
|
||||||
@@ -86,6 +88,7 @@ function invalidComparisonInputs(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
return { advertisedInvalid, runtimeOnlyInvalid };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("browser comparison input boundary", () => {
|
describe("browser comparison input boundary", () => {
|
||||||
@@ -118,6 +121,10 @@ describe("browser comparison input boundary", () => {
|
|||||||
({ name }) => name === contract.name,
|
({ name }) => name === contract.name,
|
||||||
);
|
);
|
||||||
if (advertised === undefined) throw new Error("Missing comparison tool");
|
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");
|
expect(advertised.inputSchema.type).toBe("object");
|
||||||
// Anthropic tool input schemas reject root combinators.
|
// Anthropic tool input schemas reject root combinators.
|
||||||
expect(advertised.inputSchema).not.toHaveProperty("anyOf");
|
expect(advertised.inputSchema).not.toHaveProperty("anyOf");
|
||||||
@@ -146,6 +153,9 @@ describe("browser comparison input boundary", () => {
|
|||||||
"before + after; before_scenario + after_scenario + [normalization]",
|
"before + after; before_scenario + after_scenario + [normalization]",
|
||||||
);
|
);
|
||||||
const runtimeOnlyInvalid = [
|
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, ...scenario },
|
||||||
{ ...passive, normalization: { rules: [] } },
|
{ ...passive, normalization: { rules: [] } },
|
||||||
{
|
{
|
||||||
@@ -165,7 +175,7 @@ describe("browser comparison input boundary", () => {
|
|||||||
runtimeValid: true,
|
runtimeValid: true,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
...invalid.map((input) => ({
|
...invalid.advertisedInvalid.map((input) => ({
|
||||||
input,
|
input,
|
||||||
advertisedValid: false,
|
advertisedValid: false,
|
||||||
runtimeValid: false,
|
runtimeValid: false,
|
||||||
|
|||||||
@@ -582,34 +582,16 @@ describe("MCP JSON Schema validity", () => {
|
|||||||
expectKnownAuthorityHints(tools);
|
expectKnownAuthorityHints(tools);
|
||||||
const byName = new Map(tools.map((tool) => [tool.name, tool]));
|
const byName = new Map(tools.map((tool) => [tool.name, tool]));
|
||||||
expectManagedLocalSignatureSchema(byName.get("inspect_managed_members"));
|
expectManagedLocalSignatureSchema(byName.get("inspect_managed_members"));
|
||||||
|
const comparison = byName.get("compare_web_captures")?.inputSchema;
|
||||||
for (const side of ["before", "after"])
|
for (const side of ["before", "after"])
|
||||||
expect(byName.get("compare_web_captures")?.inputSchema).toHaveProperty(
|
expect(comparison).toHaveProperty(
|
||||||
[
|
["properties", side, "properties", "inspection"],
|
||||||
"properties",
|
{
|
||||||
side,
|
type: "object",
|
||||||
"properties",
|
description: expect.stringContaining(
|
||||||
"inspection",
|
"complete canonical observation",
|
||||||
"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" }),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
);
|
);
|
||||||
const catalogProjection = tools.map((tool) => ({
|
const catalogProjection = tools.map((tool) => ({
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
|
|||||||
Reference in new issue
Block a user