Compare commits

...
Author SHA1 Message Date
Kaoru0822 09980ead00 Merge remote-tracking branch 'origin/main' into fix/8304-deferred-output-schema-projection 2026-10-31 15:59:04 +00:00
Kaoru0822andClaude Opus 5.5 a7655ef29e test(mcp): validate guidance results against the live advertisement
Load tools/list before calling the guidance tools, so the SDK client checks
each result against the advertised, SDK-converted output schema.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:19:42 +00:00
Kaoru0822andClaude Opus 5.5 531f2bbfb0 fix(mcp): deliver guidance results under their advertised result object
`search_guidance` and `read_guidance_section` were the only tool contracts
with a non-object output root. The build records the stdio catalog through a
2025-era `tools/list`, which the SDK projects as a `{result}` wrapper. The
deferred stdio server then registered that object-rooted projection, so the
SDK neither re-wrapped it nor wrapped the payload. Every successful call
returned the bare union, and the official TypeScript client rejected it with
"data must have required property 'result'".

Both contracts now declare the `{result}` object that session tools already
use, and the adapter delivers the union under `result`. The CLI output is
unchanged. A catalog-wide test requires every tool's advertised output root
to be an object, so a future root union cannot reintroduce the mismatch. A
stdio boundary test drives `rea mcp` with the validating SDK client. The
in-memory test no longer accepts both shapes.

Fixes #8304

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 14:59:06 +00:00
7 changed files with 118 additions and 20 deletions

No files matched your search

+4
View File
@@ -29,6 +29,10 @@ are bound to the installed bundle and report stale or unavailable guidance witho
returning cached section content. A no-match result says nothing about analysis
capability availability.
Over MCP, both tools deliver their structured result under `result`, matching
the object root of their advertised output schemas, as the session tools do.
The CLI prints the result itself.
Search returns at most 20 matches per page with 500-code-unit context excerpts.
Read retrieves the complete selected section. Admission permits 1 MiB per file,
4 MiB per bundle, 4,096 sections, and 25,165,824 expanded UTF-16 code units before
+4 -4
View File
@@ -29,9 +29,9 @@ import type { ToolContract } from "./toolContractTypes.js";
import { toolContractMetadata } from "./toolEffects.js";
import {
readGuidanceSectionInputSchema,
readGuidanceSectionOutputSchema,
readGuidanceSectionToolOutputSchema,
searchGuidanceInputSchema,
searchGuidanceOutputSchema,
searchGuidanceToolOutputSchema,
} from "./guidanceContracts.js";
import {
guidedWorkflowsInputSchema,
@@ -86,7 +86,7 @@ export const APPLICATION_TOOL_CONTRACTS = [
"Search only the installed REA workflow guidance. Every non-generic query word must occur in a section. Returns ranked sections, bounded pages, and locators for read_guidance_section. No target or provider is accessed.",
kind: "application",
inputSchema: searchGuidanceInputSchema,
outputSchema: searchGuidanceOutputSchema,
outputSchema: searchGuidanceToolOutputSchema,
examples: [
{
title: "Find exact JavaScript module guidance",
@@ -124,7 +124,7 @@ export const APPLICATION_TOOL_CONTRACTS = [
"Retrieve a section selected by search_guidance from the installed REA guidance bundle. Reports stale locators and unavailable bundles explicitly. Returns documentation, not target observations or Evidence.",
kind: "application",
inputSchema: readGuidanceSectionInputSchema,
outputSchema: readGuidanceSectionOutputSchema,
outputSchema: readGuidanceSectionToolOutputSchema,
examples: [],
},
{
+14
View File
@@ -89,3 +89,17 @@ export type SearchGuidanceOutput = z.output<typeof searchGuidanceOutputSchema>;
export type ReadGuidanceSectionOutput = z.output<
typeof readGuidanceSectionOutputSchema
>;
/**
* MCP 2025 wire output roots are objects, so the adapters deliver each result
* union under `result`, as session tools do. A root union would be wrapped in
* the advertisement alone by the recorded catalog and rejected by validating
* clients.
*/
export const searchGuidanceToolOutputSchema = z.strictObject({
result: searchGuidanceOutputSchema,
});
export const readGuidanceSectionToolOutputSchema = z.strictObject({
result: readGuidanceSectionOutputSchema,
});
+20
View File
@@ -0,0 +1,20 @@
import { expect, it } from "vitest";
import { TOOL_CONTRACTS } from "./toolContracts.js";
import { toolOutputSchemaWithMetadata } from "./toolSchemaMetadata.js";
// MCP 2025 output roots are objects. The stdio server registers recorded
// advertisements, so a non-object root would be wrapped there alone and its
// structured results would fail validating clients (#8304).
it.each(["draft-2020-12", "draft-07"] as const)(
"advertises an object output root for every tool in %s",
(target) => {
const nonObject = TOOL_CONTRACTS.filter(
(contract) =>
toolOutputSchemaWithMetadata(contract)["~standard"].jsonSchema.output({
target,
}).type !== "object",
).map(({ name }) => name);
expect(nonObject).toEqual([]);
},
);
+2 -2
View File
@@ -21,7 +21,7 @@ export const registerGuidanceTools = (
const result = await searchGuidance(input, resources);
return server.delivery.toCallToolResult(
result.ok
? { ok: true, value: jsonValueSchema.parse(result.value) }
? { ok: true, value: jsonValueSchema.parse({ result: result.value }) }
: result,
search,
);
@@ -36,7 +36,7 @@ export const registerGuidanceTools = (
const result = await readGuidanceSection(input, resources);
return server.delivery.toCallToolResult(
result.ok
? { ok: true, value: jsonValueSchema.parse(result.value) }
? { ok: true, value: jsonValueSchema.parse({ result: result.value }) }
: result,
read,
);
+62
View File
@@ -0,0 +1,62 @@
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { resolve } from "node:path";
import { expect, it } from "vitest";
import {
readGuidanceSectionToolOutputSchema,
searchGuidanceToolOutputSchema,
} from "../../../src/contracts/guidanceContracts.js";
it(
"delivers guidance results the validating SDK client accepts over the stdio server",
{ timeout: 60_000 },
async () => {
const client = new Client({ name: "guidance-stdio-test", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
args: [resolve("scripts/rea.mjs"), "mcp"],
stderr: "pipe",
});
await client.connect(transport);
try {
// Loading the live definitions makes the SDK validate every result
// against the advertised, SDK-converted output schema.
const advertised = (await client.listTools()).tools.filter(({ name }) =>
["search_guidance", "read_guidance_section"].includes(name),
);
expect(advertised.map(({ outputSchema }) => outputSchema?.type)).toEqual([
"object",
"object",
]);
const found = await client.callTool({
name: "search_guidance",
arguments: { query: "exact JavaScript module", limit: 1 },
});
expect(found.isError).not.toBe(true);
const search = searchGuidanceToolOutputSchema.parse(
found.structuredContent,
).result;
if (search.status !== "matches")
throw new Error("Expected guidance matches");
const locator = search.matches[0]?.locator;
const read = await client.callTool({
name: "read_guidance_section",
arguments: { locator },
});
expect(
readGuidanceSectionToolOutputSchema.parse(read.structuredContent)
.result,
).toMatchObject({ status: "section", locator });
const none = await client.callTool({
name: "search_guidance",
arguments: { query: "qzxv-unlikely-guidance-term" },
});
expect(
searchGuidanceToolOutputSchema.parse(none.structuredContent).result,
).toMatchObject({ status: "no_matches", total_matches: 0 });
} finally {
await client.close();
}
},
);
@@ -2,9 +2,9 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { afterEach, expect, it } from "vitest";
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
import {
readGuidanceSectionOutputSchema,
readGuidanceSectionToolOutputSchema,
searchGuidanceInputSchema,
searchGuidanceOutputSchema,
searchGuidanceToolOutputSchema,
} from "../../../src/contracts/guidanceContracts.js";
import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js";
import { registerGuidanceTools } from "../../../src/server/registerGuidanceTools.js";
@@ -15,16 +15,11 @@ const open: {
server: EvidenceMcpServer;
resources: ArtifactResourceScope;
}[] = [];
// Every successful guidance result is the advertised object with `result`.
const guidanceOutput = <T>(
schema: { parse(value: unknown): T },
schema: { parse(value: unknown): { readonly result: T } },
value: unknown,
): T => {
// MCP's legacy output-schema projection wraps root-level union schemas.
// Newer transports pass the object unchanged.
if (typeof value === "object" && value !== null && "result" in value)
return schema.parse(value.result);
return schema.parse(value);
};
): T => schema.parse(value).result;
afterEach(async () => {
await Promise.all(
open
@@ -79,7 +74,7 @@ it("advertises strict provider-free contracts and returns packaged guide results
});
expect(response.isError).not.toBe(true);
const parsed = guidanceOutput(
searchGuidanceOutputSchema,
searchGuidanceToolOutputSchema,
response.structuredContent,
);
expect(parsed).toMatchObject({
@@ -97,7 +92,10 @@ it("advertises strict provider-free contracts and returns packaged guide results
});
expect(section.isError).not.toBe(true);
expect(
guidanceOutput(readGuidanceSectionOutputSchema, section.structuredContent),
guidanceOutput(
readGuidanceSectionToolOutputSchema,
section.structuredContent,
),
).toMatchObject({
status: "section",
heading: selected?.heading,
@@ -108,7 +106,7 @@ it("advertises strict provider-free contracts and returns packaged guide results
arguments: { query: "module", limit: 1 },
});
const firstResult = guidanceOutput(
searchGuidanceOutputSchema,
searchGuidanceToolOutputSchema,
firstPage.structuredContent,
);
if (firstResult.status !== "matches" || firstResult.next_cursor === undefined)
@@ -122,6 +120,6 @@ it("advertises strict provider-free contracts and returns packaged guide results
},
});
expect(
guidanceOutput(searchGuidanceOutputSchema, nextPage.structuredContent),
guidanceOutput(searchGuidanceToolOutputSchema, nextPage.structuredContent),
).toMatchObject({ status: "matches", offset: 1 });
});