diff --git a/scripts/verify-copilot-client.mjs b/scripts/verify-copilot-client.mjs index 8c8992924f..faa43cb7ad 100644 --- a/scripts/verify-copilot-client.mjs +++ b/scripts/verify-copilot-client.mjs @@ -9,8 +9,8 @@ import { toolContract, } from "../dist/contracts/toolContracts.js"; import { advertisedInputJsonSchema } from "../dist/contracts/toolSchemaMetadata.js"; -import { presentAdvertisedInputSchema } from "../dist/contracts/compactInputSchema.js"; -import { COMPACT_INPUT_SCHEMA_PRESENTATION } from "../dist/config/mcpInputSchemaProfile.js"; +import { advertiseTool } from "../dist/contracts/advertisedTool.js"; +import { advertisedToolPresentation } from "../dist/config/mcpOutputSchemaProfile.js"; import { isPathWithinRoot } from "../dist/domain/localPath.js"; import { createVerifierCommands } from "./lib/verify-commands.mjs"; import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs"; @@ -166,12 +166,14 @@ await withVerifierCleanup(async () => { ); assert.deepEqual( tool.parameters, - presentAdvertisedInputSchema( - advertisedInputJsonSchema( - toolContract(tool.name.slice(prefix.length)), - ), - COMPACT_INPUT_SCHEMA_PRESENTATION, - ), + advertiseTool( + { + inputSchema: advertisedInputJsonSchema( + toolContract(tool.name.slice(prefix.length)), + ), + }, + advertisedToolPresentation("off"), + ).inputSchema, `${tool.name}: Copilot must forward the compact input schema`, ); schemasChecked++; diff --git a/src/application/environment/environmentTools.test.ts b/src/application/environment/environmentTools.test.ts index 344dd80fc5..2aabf07961 100644 --- a/src/application/environment/environmentTools.test.ts +++ b/src/application/environment/environmentTools.test.ts @@ -6,6 +6,7 @@ import type { RecordedExchange, } from "./environmentSource.js"; import { + advertisedEnvironmentTool, environmentCard, environmentToolArguments, environmentToolDefinitions as prepareDefinitions, @@ -594,11 +595,9 @@ describe("environmentCard", () => { "description", "inputSchema", ]); - expect(entry).toEqual({ - name: definitions[index]?.name, - description: definitions[index]?.description, - inputSchema: definitions[index]?.inputSchema, - }); + const definition = definitions[index]; + if (definition === undefined) throw new Error(`Missing tool ${index}`); + expect(entry).toEqual(advertisedEnvironmentTool(definition)); } expect(environmentCard("renamed", definitions).name).toBe("renamed"); }); diff --git a/src/application/environment/environmentTools.ts b/src/application/environment/environmentTools.ts index 41f61f1841..e2739ca21b 100644 --- a/src/application/environment/environmentTools.ts +++ b/src/application/environment/environmentTools.ts @@ -1,3 +1,4 @@ +import { advertiseToolInput } from "../../contracts/advertisedTool.js"; import { jsonPointerTokens } from "../../domain/jsonPointer.js"; import { ownMember, @@ -65,6 +66,35 @@ export interface EnvironmentToolDefinition { readonly tool: EnvironmentManifestTool; } +/** What MCP clients and the card receive for one served tool. */ +export interface AdvertisedEnvironmentTool { + readonly name: string; + readonly description: string; + readonly inputSchema: Record; +} + +/** + * The served tool in the portable advertised dialect. A served catalog bounds + * its own size, so argument descriptions stay; calls are still checked + * against the recorded `inputSchema`. + */ +export const advertisedEnvironmentTool = ({ + name, + description, + inputSchema, +}: EnvironmentToolDefinition): AdvertisedEnvironmentTool => { + const advertised = advertiseToolInput( + description, + inputSchema as unknown as Record, + "annotated", + ); + return { + name, + description: advertised.description ?? description, + inputSchema: advertised.inputSchema, + }; +}; + const environmentToolAnnotations = ({ method, path_template: path, @@ -560,10 +590,7 @@ export interface EnvironmentCard { readonly capabilities: { /** Null for a replay environment; the intake declaration when served from a state. */ readonly extensions: null | readonly [typeof STATE_INTAKE_EXTENSION]; - readonly tools: readonly Pick< - EnvironmentToolDefinition, - "name" | "description" | "inputSchema" - >[]; + readonly tools: readonly AdvertisedEnvironmentTool[]; readonly operations: readonly [] | typeof STATE_OPERATIONS; }; readonly children_environments: null; @@ -582,11 +609,7 @@ export const environmentCard = ( additionalInterfaces: [{ url: "/mcp", transport: "mcp" }], capabilities: { extensions: dataPlane === "state" ? [STATE_INTAKE_EXTENSION] : null, - tools: tools.map(({ name: toolName, description, inputSchema }) => ({ - name: toolName, - description, - inputSchema, - })), + tools: tools.map(advertisedEnvironmentTool), operations: dataPlane === "state" ? STATE_OPERATIONS : [], }, children_environments: null, diff --git a/src/catalogIdentity.ts b/src/catalogIdentity.ts index 2cab268a88..c00a4c6d42 100644 --- a/src/catalogIdentity.ts +++ b/src/catalogIdentity.ts @@ -14,14 +14,13 @@ import { advertisedInputJsonSchema, advertisedOutputJsonSchema, } from "./contracts/toolSchemaMetadata.js"; +import type { CompactInputSchemaPresentation } from "./contracts/compactInputSchema.js"; import { - describeAdvertisedTool, - presentAdvertisedInput, - type CompactInputSchemaPresentation, -} from "./contracts/compactInputSchema.js"; -import { presentAdvertisedOutputSchema } from "./contracts/compactOutputSchema.js"; + advertiseTool, + type AdvertisedToolPresentation, +} from "./contracts/advertisedTool.js"; import { - COMPACT_OUTPUT_SCHEMA_PRESENTATION, + advertisedToolPresentation, resolveMcpOutputSchemaProfile, type McpOutputSchemaProfile, } from "./config/mcpOutputSchemaProfile.js"; @@ -47,42 +46,31 @@ const createToolCatalog = (contracts: readonly ToolContract[]) => output_schema: advertisedOutputJsonSchema(contract), })); -/** The compact input schema and the tool description carrying its guidance. */ -const presentedInput = ( - description: string, - schema: Record, - presentation: CompactInputSchemaPresentation, -) => { - const input = presentAdvertisedInput(schema, presentation); - return { - description: describeAdvertisedTool(description, input.guidance), - input_schema: input.schema, - }; -}; - -/** Advertised input for every tool; output stays canonical unless bounded. */ +/** + * The identity's view of each tool: what the selected presentation + * advertises. An omitted output advertisement still digests the canonical + * reply contract the server validates. + */ const presentToolCatalog = ( catalog: ReturnType, - presentation: CompactInputSchemaPresentation, + presentation: AdvertisedToolPresentation, ) => - catalog.map((tool) => ({ - ...tool, - ...presentedInput(tool.description, tool.input_schema, presentation), - })); - -const presentOutputSchemas = ( - catalog: ReturnType, - outputSchemaProfile: McpOutputSchemaProfile, -) => - outputSchemaProfile === "compact" - ? catalog.map((tool) => ({ - ...tool, - output_schema: presentAdvertisedOutputSchema( - tool.output_schema, - COMPACT_OUTPUT_SCHEMA_PRESENTATION, - ), - })) - : catalog; + catalog.map((tool) => { + const advertised = advertiseTool( + { + description: tool.description, + inputSchema: tool.input_schema, + outputSchema: tool.output_schema, + }, + presentation, + ); + return { + ...tool, + description: advertised.description ?? tool.description, + input_schema: advertised.inputSchema ?? tool.input_schema, + output_schema: advertised.outputSchema ?? tool.output_schema, + }; + }); const createPromptCatalog = (contracts: readonly PromptContract[]) => contracts @@ -111,13 +99,17 @@ const digest = (value: unknown): string => { }; const digestCatalogs = ( - catalog: ReturnType, + catalog: ReturnType, prompts: ReturnType, + compactPresentation: CompactInputSchemaPresentation, outputSchemaProfile: McpOutputSchemaProfile, ) => { const tools = { output_schema_profile: outputSchemaProfile, - catalog: presentOutputSchemas(catalog, outputSchemaProfile), + catalog: presentToolCatalog( + catalog, + advertisedToolPresentation(outputSchemaProfile, compactPresentation), + ), }; return { tools_sha256: digest(tools), @@ -144,11 +136,9 @@ export const createCatalogDigests = ( ), ) => digestCatalogs( - presentToolCatalog( - createToolCatalog(sortByName(contracts)), - compactPresentation, - ), + createToolCatalog(sortByName(contracts)), createPromptCatalog(promptContracts), + compactPresentation, outputSchemaProfile, ); @@ -162,14 +152,21 @@ export const createCatalogDigestProfiles = ( contracts: readonly ToolContract[] = TOOL_CONTRACTS, promptContracts: readonly PromptContract[] = PROMPT_CONTRACTS, ): CatalogDigestProfiles => { - const catalog = presentToolCatalog( - createToolCatalog(sortByName(contracts)), - COMPACT_INPUT_SCHEMA_PRESENTATION, - ); + const catalog = createToolCatalog(sortByName(contracts)); const prompts = createPromptCatalog(promptContracts); return { - off: digestCatalogs(catalog, prompts, "off"), - compact: digestCatalogs(catalog, prompts, "compact"), + off: digestCatalogs( + catalog, + prompts, + COMPACT_INPUT_SCHEMA_PRESENTATION, + "off", + ), + compact: digestCatalogs( + catalog, + prompts, + COMPACT_INPUT_SCHEMA_PRESENTATION, + "compact", + ), }; }; diff --git a/src/config/mcpOutputSchemaProfile.ts b/src/config/mcpOutputSchemaProfile.ts index dd76479084..38dc35d86b 100644 --- a/src/config/mcpOutputSchemaProfile.ts +++ b/src/config/mcpOutputSchemaProfile.ts @@ -1,7 +1,10 @@ import { z } from "zod"; +import type { AdvertisedToolPresentation } from "../contracts/advertisedTool.js"; +import type { CompactInputSchemaPresentation } from "../contracts/compactInputSchema.js"; import { ConfigurationError } from "../domain/configurationErrors.js"; import { err, ok, type Result } from "../domain/result.js"; +import { COMPACT_INPUT_SCHEMA_PRESENTATION } from "./mcpInputSchemaProfile.js"; /** * Per-schema budget for compact output advertisements. Output contracts are @@ -42,6 +45,18 @@ export const COMPACT_OUTPUT_SCHEMA_PRESENTATION: { readonly budgetBytes: number; } = Object.freeze({ budgetBytes: COMPACT_OUTPUT_SCHEMA_BUDGET_BYTES }); +/** How REA's MCP catalog advertises its tools for a selected output profile. */ +export const advertisedToolPresentation = ( + outputSchemaProfile: McpOutputSchemaProfile, + input: CompactInputSchemaPresentation = COMPACT_INPUT_SCHEMA_PRESENTATION, +): AdvertisedToolPresentation => ({ + input: { budgetBytes: input.budgetBytes }, + output: + outputSchemaProfile === "compact" + ? COMPACT_OUTPUT_SCHEMA_PRESENTATION + : undefined, +}); + /** Parse the output schema advertisement selection at server composition. */ export const parseMcpOutputSchemaProfile = ( value: string | undefined, diff --git a/src/contracts/advertisedTool.ts b/src/contracts/advertisedTool.ts new file mode 100644 index 0000000000..b8f7237875 --- /dev/null +++ b/src/contracts/advertisedTool.ts @@ -0,0 +1,136 @@ +import { projectToAdvertisedDialect } from "./advertisedDialectProjection.js"; +import { + presentAdvertisedInput, + type CompactInputSchemaPresentation, +} from "./compactInputSchema.js"; +import { + presentAdvertisedOutputSchema, + type CompactOutputSchemaPresentation, +} from "./compactOutputSchema.js"; + +// The one owner of what a client receives for a tool: its description, input +// schema and output schema. The server's registration and `tools/list` +// listing, catalog identities, served environment catalogs and client +// verifiers all derive the advertised tool here from the canonical JSON +// Schemas; canonical server-side validation is unchanged. + +/** How the canonical schemas of one catalog are advertised. */ +export interface AdvertisedToolPresentation { + /** + * The per-tool input budget of the compact presentation, or `annotated`: + * project into the portable dialect and keep nested annotations, for a + * catalog that bounds its own size. + */ + readonly input: CompactInputSchemaPresentation | "annotated"; + /** Output schemas are bounded by this presentation, or omitted without one. */ + readonly output: CompactOutputSchemaPresentation | undefined; +} + +/** The client-facing fields of one tool, canonical or advertised. */ +export interface AdvertisedToolSchemas { + readonly description?: string | undefined; + readonly inputSchema?: Record | undefined; + readonly outputSchema?: Record | undefined; +} + +/** The tool description followed by the input guidance its schema omits. */ +const describe = ( + description: string | undefined, + guidance: readonly string[], +): string | undefined => { + if (guidance.length === 0) return description; + const input = guidance.join(" "); + return description === undefined || description === "" + ? input + : `${description}\n\n${input}`; +}; + +// The SDK and the contract metadata memoize each canonical schema object, so +// repeated listings and both output profiles reuse one projection. +const annotated = new WeakMap< + object, + ReturnType +>(); + +const presentInput = ( + schema: Record, + presentation: AdvertisedToolPresentation["input"], +): { + readonly schema: Record; + readonly guidance: readonly string[]; +} => { + if (presentation !== "annotated") + return presentAdvertisedInput(schema, presentation); + let projected = annotated.get(schema); + if (projected === undefined) + annotated.set(schema, (projected = projectToAdvertisedDialect(schema))); + return projected; +}; + +/** + * The advertised description and input schema. Reading the description + * projects the schema; a tool without an input schema keeps its description. + */ +export const advertiseToolInput = ( + description: string | undefined, + inputSchema: Record, + presentation: AdvertisedToolPresentation["input"], +): { + readonly description: string | undefined; + readonly inputSchema: Record; +} => { + const input = presentInput(inputSchema, presentation); + return { + description: describe(description, input.guidance), + inputSchema: input.schema, + }; +}; + +const compactedOutput = new WeakMap< + object, + Map> +>(); + +/** The advertised output schema, or undefined when the presentation omits it. */ +export const advertiseToolOutput = ( + outputSchema: Record, + presentation: AdvertisedToolPresentation["output"], +): Record | undefined => { + if (presentation === undefined) return undefined; + let byBudget = compactedOutput.get(outputSchema); + if (byBudget === undefined) + compactedOutput.set(outputSchema, (byBudget = new Map())); + let advertised = byBudget.get(presentation.budgetBytes); + if (advertised === undefined) + byBudget.set( + presentation.budgetBytes, + (advertised = presentAdvertisedOutputSchema(outputSchema, presentation)), + ); + return advertised; +}; + +/** Everything a client receives for one tool, from its canonical schemas. */ +export const advertiseTool = ( + canonical: AdvertisedToolSchemas, + presentation: AdvertisedToolPresentation, +): AdvertisedToolSchemas => { + const input = + canonical.inputSchema === undefined + ? { description: canonical.description } + : advertiseToolInput( + canonical.description, + canonical.inputSchema, + presentation.input, + ); + const outputSchema = + canonical.outputSchema === undefined + ? undefined + : advertiseToolOutput(canonical.outputSchema, presentation.output); + return { + ...(input.description === undefined + ? {} + : { description: input.description }), + ...("inputSchema" in input ? { inputSchema: input.inputSchema } : {}), + ...(outputSchema === undefined ? {} : { outputSchema }), + }; +}; diff --git a/src/contracts/compactInputSchema.ts b/src/contracts/compactInputSchema.ts index 70b978c290..cbf4b004ac 100644 --- a/src/contracts/compactInputSchema.ts +++ b/src/contracts/compactInputSchema.ts @@ -109,7 +109,7 @@ export const compactAdvertisedInputSchema = ( // read one projection of the same memoized canonical schema object. const compacted = new WeakMap>(); -/** The advertised input for the selected presentation, with its guidance. */ +/** The compact advertised input with its guidance; `advertisedTool.ts` composes the tool. */ export const presentAdvertisedInput = ( schema: Record, compactPresentation: CompactInputSchemaPresentation, @@ -125,30 +125,3 @@ export const presentAdvertisedInput = ( ); return presented; }; - -/** Return the JSON schema clients receive for the selected input presentation. */ -export const presentAdvertisedInputSchema = ( - schema: Record, - compactPresentation: CompactInputSchemaPresentation, -): Record => - presentAdvertisedInput(schema, compactPresentation).schema; - -/** The tool description followed by the input guidance its schema omits. */ -export function describeAdvertisedTool( - description: string, - guidance: readonly string[], -): string; -export function describeAdvertisedTool( - description: string | undefined, - guidance: readonly string[], -): string | undefined; -export function describeAdvertisedTool( - description: string | undefined, - guidance: readonly string[], -): string | undefined { - if (guidance.length === 0) return description; - const input = guidance.join(" "); - return description === undefined || description === "" - ? input - : `${description}\n\n${input}`; -} diff --git a/src/server/DeferredEvidenceMcpServer.ts b/src/server/DeferredEvidenceMcpServer.ts index ebe28fafb5..104088f465 100644 --- a/src/server/DeferredEvidenceMcpServer.ts +++ b/src/server/DeferredEvidenceMcpServer.ts @@ -226,13 +226,12 @@ export class DeferredEvidenceMcpServer extends EvidenceMcpServerCore { */ protected override describeTool( name: string, - description: string | undefined, - guidance: () => readonly string[], + advertised: () => string | undefined, ): { description?: string } { const recorded = this.tools.get(name)?.registered.description; if (this.materializing && recorded !== undefined) return { description: recorded }; - return super.describeTool(name, description, guidance); + return super.describeTool(name, advertised); } protected override bindTool( diff --git a/src/server/EvidenceMcpServerCore.ts b/src/server/EvidenceMcpServerCore.ts index f851bcd3a6..a37bc1af0a 100644 --- a/src/server/EvidenceMcpServerCore.ts +++ b/src/server/EvidenceMcpServerCore.ts @@ -29,14 +29,14 @@ import { import { analysisErrorProjectionSchema } from "../contracts/errorSchemas.js"; import { transportClosedByOwner } from "./transportOwnerClose.js"; +import type { CompactInputSchemaPresentation } from "../contracts/compactInputSchema.js"; import { - describeAdvertisedTool, - presentAdvertisedInput, - type CompactInputSchemaPresentation, -} from "../contracts/compactInputSchema.js"; -import { presentAdvertisedOutputSchema } from "../contracts/compactOutputSchema.js"; + advertiseToolInput, + advertiseToolOutput, + type AdvertisedToolPresentation, +} from "../contracts/advertisedTool.js"; import { - COMPACT_OUTPUT_SCHEMA_PRESENTATION, + advertisedToolPresentation, resolveMcpOutputSchemaProfile, type McpOutputSchemaProfile, } from "../config/mcpOutputSchemaProfile.js"; @@ -235,15 +235,12 @@ const replaceToolResultPayload = ( /** * Rewrite only client-facing output schemas; SDK registrations stay canonical. - * `compact` bounds each advertised schema, while `off` omits it entirely. - * The SDK memoises each tool's converted schema, so the caller passes a cache - * keyed by that object; repeated listings reuse the same projection. + * The presentation bounds each advertised schema or omits it entirely. */ const presentOutputSchemasForToolsList = ( message: JSONRPCMessage, requestId: RequestId, - profile: McpOutputSchemaProfile, - compactedSchemas: WeakMap, Record>, + presentation: AdvertisedToolPresentation["output"], ): JSONRPCMessage => { if ( !isJSONRPCResultResponse(message) || @@ -257,21 +254,12 @@ const presentOutputSchemasForToolsList = ( result: { ...message.result, tools: message.result.tools.map((tool: unknown) => { - if (!isRecord(tool)) return tool; - if (profile === "off") { - const { outputSchema: _outputSchema, ...advertisedTool } = tool; - return advertisedTool; - } - if (!isRecord(tool.outputSchema)) return tool; - let outputSchema = compactedSchemas.get(tool.outputSchema); - if (outputSchema === undefined) { - outputSchema = presentAdvertisedOutputSchema( - tool.outputSchema, - COMPACT_OUTPUT_SCHEMA_PRESENTATION, - ); - compactedSchemas.set(tool.outputSchema, outputSchema); - } - return { ...tool, outputSchema }; + if (!isRecord(tool) || !isRecord(tool.outputSchema)) return tool; + const { outputSchema: canonical, ...advertisedTool } = tool; + const outputSchema = advertiseToolOutput(canonical, presentation); + return outputSchema === undefined + ? advertisedTool + : { ...advertisedTool, outputSchema }; }), }, } as JSONRPCMessage; @@ -394,7 +382,7 @@ export class EvidenceMcpServerCore extends RequestTrackingMcpServer { ): RegisteredPrompt { return super.registerPrompt(name, config, callback); } - private readonly compactPresentation: CompactInputSchemaPresentation; + private readonly presentation: AdvertisedToolPresentation; private readonly maximumInputElements: number; private readonly evidenceReferenceAlternatives = new Set(); @@ -408,7 +396,7 @@ export class EvidenceMcpServerCore extends RequestTrackingMcpServer { private recordEvidence: EvidenceWriter["recordEvidence"] | undefined, public delivery: ToolResultDelivery, compactPresentation: CompactInputSchemaPresentation = COMPACT_INPUT_SCHEMA_PRESENTATION, - private readonly outputSchemaProfile: McpOutputSchemaProfile = resolveMcpOutputSchemaProfile( + outputSchemaProfile: McpOutputSchemaProfile = resolveMcpOutputSchemaProfile( undefined, ), identity?: ReturnType, @@ -420,9 +408,10 @@ export class EvidenceMcpServerCore extends RequestTrackingMcpServer { options.maxToolInputElements ?? DEFAULT_MCP_MAX_INPUT_ELEMENTS; super(info, { ...options, maxToolInputElements: maximumInputElements }); this.maximumInputElements = maximumInputElements; - this.compactPresentation = { - budgetBytes: compactPresentation.budgetBytes, - }; + this.presentation = advertisedToolPresentation( + outputSchemaProfile, + compactPresentation, + ); this.identity = identity; } @@ -521,35 +510,35 @@ export class EvidenceMcpServerCore extends RequestTrackingMcpServer { config: { readonly description?: string; readonly inputSchema?: Schema }, ): { description?: string; inputSchema?: Schema } { const { description, inputSchema } = config; - const presentation = this.compactPresentation; + const { input } = this.presentation; if (inputSchema === undefined || !hasAdvertisedInputJsonSchema(inputSchema)) return config; - const present = (projected: Record) => - presentAdvertisedInput(projected, presentation); return { - ...this.describeTool(name, description, () => { + ...this.describeTool(name, () => { const projected = (inputSchema as StandardSchemaWithJSON)[ "~standard" ].jsonSchema.input({ target: "draft-2020-12" }); - return isRecord(projected) ? present(projected).guidance : []; + return isRecord(projected) + ? advertiseToolInput(description, projected, input).description + : description; }), inputSchema: transformAdvertisedInputJsonSchema( inputSchema, - (projected) => present(projected).schema, + (projected) => + advertiseToolInput(description, projected, input).inputSchema, ), }; } /** * The listed description: guidance the compact input schema omits follows - * the registered description. Reading the guidance projects the schema. + * the registered description. Reading it projects the schema. */ protected describeTool( _name: string, - description: string | undefined, - guidance: () => readonly string[], + advertised: () => string | undefined, ): { description?: string } { - const described = describeAdvertisedTool(description, guidance()); + const described = advertised(); return described === undefined ? {} : { description: described }; } @@ -591,7 +580,7 @@ export class EvidenceMcpServerCore extends RequestTrackingMcpServer { () => this.recordEvidence, () => this.delivery, () => this.withAdmittedAnalysis, - this.outputSchemaProfile, + this.presentation.output, this.maximumInputElements, this.evidenceReferenceAlternatives, ), @@ -618,12 +607,6 @@ class ErrorEvidenceTransport implements Transport { return this.currentDelivery(); } - /** Bounded projections already computed for this server's budget. */ - private readonly compactedOutputSchemas = new WeakMap< - Record, - Record - >(); - constructor( private readonly transport: Transport, private readonly currentEvidenceWriter: () => @@ -631,7 +614,7 @@ class ErrorEvidenceTransport implements Transport { | undefined, private readonly currentDelivery: () => ToolResultDelivery, private readonly currentAdmission: () => WithAdmittedAnalysis | undefined, - private readonly outputSchemaProfile: McpOutputSchemaProfile, + private readonly outputPresentation: AdvertisedToolPresentation["output"], private readonly maximumInputElements: number, private readonly evidenceReferenceAlternatives: ReadonlySet, ) { @@ -788,8 +771,7 @@ class ErrorEvidenceTransport implements Transport { ? presentOutputSchemasForToolsList( recovered, id, - this.outputSchemaProfile, - this.compactedOutputSchemas, + this.outputPresentation, ) : recovered; await this.publish(advertised, tool, options, request); diff --git a/src/server/environment/serveEnvironment.test.ts b/src/server/environment/serveEnvironment.test.ts index 5b2c68bd4b..d77b00157a 100644 --- a/src/server/environment/serveEnvironment.test.ts +++ b/src/server/environment/serveEnvironment.test.ts @@ -9,6 +9,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { environmentServeSource } from "../../../tests/fixtures/environmentServe.js"; import { + advertisedEnvironmentTool, environmentCard, environmentToolDefinitions as prepareDefinitions, REPLAY_CATALOG_INSTRUCTIONS, @@ -147,11 +148,7 @@ describe("environment routes", () => { JSON.stringify( echoCardShape( "grafana-example", - definitions.map(({ name, description, inputSchema }) => ({ - name, - description, - inputSchema, - })), + definitions.map(advertisedEnvironmentTool), ), ), ); diff --git a/src/server/environment/serveEnvironment.ts b/src/server/environment/serveEnvironment.ts index dcaf960948..56586dd673 100644 --- a/src/server/environment/serveEnvironment.ts +++ b/src/server/environment/serveEnvironment.ts @@ -22,7 +22,9 @@ import { } from "../../application/environment/environmentToolAnswer.js"; import { EnvironmentStateStore } from "../../application/environment/environmentStateStore.js"; import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import { transformAdvertisedInputJsonSchema } from "../../contracts/toolSchemaMetadata.js"; import { + advertisedEnvironmentTool, environmentCard, REPLAY_CATALOG_INSTRUCTIONS, type EnvironmentCard, @@ -114,12 +116,17 @@ export const createEnvironmentMcpServer = ( instructions: `${UNTRUSTED_CONTENT_INSTRUCTIONS} ${REPLAY_CATALOG_INSTRUCTIONS}`, }, ); - for (const definition of tools) + for (const definition of tools) { + const advertised = advertisedEnvironmentTool(definition); server.registerTool( definition.name, { - description: definition.description, - inputSchema: fromJsonSchema(definition.inputSchema), + description: advertised.description, + // Calls are validated against the recorded schema; clients list the advertised one. + inputSchema: transformAdvertisedInputJsonSchema( + fromJsonSchema(definition.inputSchema), + () => advertised.inputSchema, + ), annotations: definition.annotations, }, (args): CallToolResult => { @@ -141,6 +148,7 @@ export const createEnvironmentMcpServer = ( }; }, ); + } return server; }; diff --git a/tests/boundary/mcp/compactInputSchemaMcp.test.ts b/tests/boundary/mcp/compactInputSchemaMcp.test.ts index c1178005fb..a2b9ed5ce7 100644 --- a/tests/boundary/mcp/compactInputSchemaMcp.test.ts +++ b/tests/boundary/mcp/compactInputSchemaMcp.test.ts @@ -9,11 +9,8 @@ import { } from "../../../src/contracts/toolContracts.js"; import { PROMPT_CONTRACTS } from "../../../src/contracts/promptContracts.js"; import { advertisedInputJsonSchema } from "../../../src/contracts/toolSchemaMetadata.js"; -import { - describeAdvertisedTool, - presentAdvertisedInput, - presentAdvertisedInputSchema, -} from "../../../src/contracts/compactInputSchema.js"; +import { advertiseTool } from "../../../src/contracts/advertisedTool.js"; +import { advertisedToolPresentation } from "../../../src/config/mcpOutputSchemaProfile.js"; import { createCatalogDigests } from "../../../src/catalogIdentity.js"; import { COMPACT_INPUT_SCHEMA_BUDGET_BYTES, @@ -90,10 +87,10 @@ describe("MCP compact input schema profile", () => { ); for (const contract of TOOL_CONTRACTS) expect(compactTools.get(contract.name)?.inputSchema).toEqual( - presentAdvertisedInputSchema( - advertisedInputJsonSchema(contract), - compactPresentation, - ), + advertiseTool( + { inputSchema: advertisedInputJsonSchema(contract) }, + advertisedToolPresentation("off", compactPresentation), + ).inputSchema, ); const compactIdentity = createCatalogDigests(); @@ -124,12 +121,13 @@ it("extends tool descriptions only with input guidance", async () => { const contract = TOOL_CONTRACTS.find(({ name }) => name === tool.name); if (contract === undefined) throw new Error(`Missing ${tool.name}`); expect(tool.description, tool.name).toBe( - describeAdvertisedTool( - toolRegistrationOptions(contract).description, - presentAdvertisedInput(advertisedInputJsonSchema(contract), { - budgetBytes: COMPACT_INPUT_SCHEMA_BUDGET_BYTES, - }).guidance, - ), + advertiseTool( + { + description: toolRegistrationOptions(contract).description, + inputSchema: advertisedInputJsonSchema(contract), + }, + advertisedToolPresentation("off"), + ).description, ); expect(tool.inputSchema.description, tool.name).toBeUndefined(); } diff --git a/tests/boundary/mcp/contractPresentation.test.ts b/tests/boundary/mcp/contractPresentation.test.ts index 11e6e36be3..152c764f81 100644 --- a/tests/boundary/mcp/contractPresentation.test.ts +++ b/tests/boundary/mcp/contractPresentation.test.ts @@ -1,5 +1,5 @@ -import { presentAdvertisedInputSchema } from "../../../src/contracts/compactInputSchema.js"; -import { COMPACT_INPUT_SCHEMA_PRESENTATION } from "../../../src/config/mcpInputSchemaProfile.js"; +import { advertiseTool } from "../../../src/contracts/advertisedTool.js"; +import { advertisedToolPresentation } from "../../../src/config/mcpOutputSchemaProfile.js"; import { randomUUID } from "node:crypto"; import { fileURLToPath } from "node:url"; import { isCallToolResult } from "@modelcontextprotocol/server"; @@ -99,10 +99,10 @@ it("preserves advertised schemas and handler meaning when presentation order cha ); if (canonical === undefined) throw new Error(`Missing ${advertised.name}`); expect(advertised.inputSchema).toEqual( - presentAdvertisedInputSchema( - canonical.inputSchema, - COMPACT_INPUT_SCHEMA_PRESENTATION, - ), + advertiseTool( + { inputSchema: canonical.inputSchema }, + advertisedToolPresentation("off"), + ).inputSchema, ); // The default output profile omits output schemas. expect(advertised.outputSchema).toBeUndefined(); diff --git a/tests/boundary/mcp/environmentServeMcp.test.ts b/tests/boundary/mcp/environmentServeMcp.test.ts index 4ea03ae2fa..236da92164 100644 --- a/tests/boundary/mcp/environmentServeMcp.test.ts +++ b/tests/boundary/mcp/environmentServeMcp.test.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { timedCaptureEvidence } from "../../fixtures/analysisView.js"; import { INITIAL_TRASH_ROWS } from "../../fixtures/environmentStateLaterReads.js"; import { jsonObjectSchema } from "../../../src/domain/jsonValue.js"; +import { advertisedInputDialectViolations } from "../../../src/contracts/advertisedDialect.js"; import { Client, @@ -23,9 +24,9 @@ import { } from "../../support/process/processFixture.js"; const CARD_PATH = "/.well-known/agent-env.json"; -/** SHA-256 of the 14,519-byte card served for the fixture named "renamed", its descriptions naming each tool's recorded responses; pure replay must serve these bytes. */ +/** SHA-256 of the card served for the fixture named "renamed", its descriptions naming each tool's recorded responses; pure replay must serve these bytes. */ const REPLAY_CARD_SHA256 = - "995c280f0798f77e76f206c068eb6c2c1f8c4210a03ac1c01fba8cc07b5d7f09"; + "825703b02f48d614b3c4690445b0b6b00333d975406dc7e8dd335836bceb7d59"; /** Serve the fixture pair as pure replay through the built CLI. */ const serveReplayOnly = async ( @@ -175,7 +176,9 @@ processTest( tools.find(({ name }) => name === tool.name)?.inputSchema.properties ?.tag, ).toMatchObject({ - oneOf: [ + // The recorded `oneOf` is advertised as the portable `anyOf`; the + // branches are disjoint, so both accept the same values. + anyOf: [ { type: "string" }, { type: "number" }, { type: "boolean" }, @@ -212,6 +215,29 @@ describe("rea environment-serve --replay-only", () => { expect(tools.map(({ name }) => name).sort()).toEqual( source.manifest.tools.map(({ name }) => name).sort(), ); + // A served catalog reaches models through the same clients as REA's. + // The dialect has no portable form for a query parameter's + // string, number, boolean or string-list union yet, so only that + // union's untyped node remains outside it. + for (const violation of tools.flatMap( + advertisedInputDialectViolations, + )) { + expect(violation).toMatchObject({ rule: "type", detail: "untyped" }); + const property = /^\$\.properties\.([^.]+)$/u.exec(violation.path); + const name = property?.[1] ?? ""; + const tool = tools.find( + ({ name: listed }) => listed === violation.tool, + ); + expect(tool?.inputSchema.properties?.[name], violation.path).toEqual({ + description: expect.stringMatching(/^Query parameter /u), + anyOf: [ + { type: "string" }, + { type: "number" }, + { type: "boolean" }, + { type: "array", items: { type: "string" } }, + ], + }); + } const users = tools.find(({ name }) => name === "get_users_by_id"); expect(users?.description).toContain( '; returns a JSON object with fields "id", "uid", "login", "email"; recorded statuses 200;', diff --git a/tests/boundary/mcp/serverWorkflowMcp.test.ts b/tests/boundary/mcp/serverWorkflowMcp.test.ts index 95858eb6cc..ac7931a604 100644 --- a/tests/boundary/mcp/serverWorkflowMcp.test.ts +++ b/tests/boundary/mcp/serverWorkflowMcp.test.ts @@ -15,11 +15,8 @@ import type { } from "../../../src/application/AnalysisProvider.js"; import { observed as ok } from "../../fixtures/analysisExecution.js"; import { createServer } from "../../../src/server/createServer.js"; -import { COMPACT_INPUT_SCHEMA_BUDGET_BYTES } from "../../../src/config/mcpInputSchemaProfile.js"; -import { - describeAdvertisedTool, - presentAdvertisedInput, -} from "../../../src/contracts/compactInputSchema.js"; +import { advertisedToolPresentation } from "../../../src/config/mcpOutputSchemaProfile.js"; +import { advertiseTool } from "../../../src/contracts/advertisedTool.js"; import { advertisedInputJsonSchema } from "../../../src/contracts/toolSchemaMetadata.js"; import { TOOL_CONTRACTS, @@ -177,12 +174,13 @@ it("advertises the complete currently available inventory with a session", async // to the end of the contract description. if (contract === undefined) continue; expect(tool.description, tool.name).toBe( - describeAdvertisedTool( - contract.description, - presentAdvertisedInput(advertisedInputJsonSchema(contract), { - budgetBytes: COMPACT_INPUT_SCHEMA_BUDGET_BYTES, - }).guidance, - ), + advertiseTool( + { + description: contract.description, + inputSchema: advertisedInputJsonSchema(contract), + }, + advertisedToolPresentation("off"), + ).description, ); expect(tool.annotations, tool.name).toEqual(contract?.annotations); expect(tool.inputSchema, tool.name).toBeDefined();