mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 19:23:24 +00:00
refactor(mcp): derive every advertised tool through one owner (#11268)
* refactor(mcp): derive every advertised tool through one owner The server's registration and tools/list rewrite, catalog identity and the Copilot verifier each assembled the advertised description, input and output schema themselves. src/contracts/advertisedTool.ts now owns that composition; served environment catalogs (MCP listing and card) go through the same owner's dialect projection, keeping argument descriptions. * test(mcp): pin each remaining environment dialect violation to its query union
This commit is contained in:
1 parent
d1675822f6
commit
500c1f46b1
15 files changed
+351
-198
No files matched your search
@@ -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++;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown>,
|
||||
"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,
|
||||
|
||||
+48
-51
@@ -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<string, unknown>,
|
||||
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<typeof createToolCatalog>,
|
||||
presentation: CompactInputSchemaPresentation,
|
||||
presentation: AdvertisedToolPresentation,
|
||||
) =>
|
||||
catalog.map((tool) => ({
|
||||
...tool,
|
||||
...presentedInput(tool.description, tool.input_schema, presentation),
|
||||
}));
|
||||
|
||||
const presentOutputSchemas = (
|
||||
catalog: ReturnType<typeof presentToolCatalog>,
|
||||
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<typeof presentToolCatalog>,
|
||||
catalog: ReturnType<typeof createToolCatalog>,
|
||||
prompts: ReturnType<typeof createPromptCatalog>,
|
||||
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",
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown> | undefined;
|
||||
readonly outputSchema?: Record<string, unknown> | 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<typeof projectToAdvertisedDialect>
|
||||
>();
|
||||
|
||||
const presentInput = (
|
||||
schema: Record<string, unknown>,
|
||||
presentation: AdvertisedToolPresentation["input"],
|
||||
): {
|
||||
readonly schema: Record<string, unknown>;
|
||||
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<string, unknown>,
|
||||
presentation: AdvertisedToolPresentation["input"],
|
||||
): {
|
||||
readonly description: string | undefined;
|
||||
readonly inputSchema: Record<string, unknown>;
|
||||
} => {
|
||||
const input = presentInput(inputSchema, presentation);
|
||||
return {
|
||||
description: describe(description, input.guidance),
|
||||
inputSchema: input.schema,
|
||||
};
|
||||
};
|
||||
|
||||
const compactedOutput = new WeakMap<
|
||||
object,
|
||||
Map<number, Record<string, unknown>>
|
||||
>();
|
||||
|
||||
/** The advertised output schema, or undefined when the presentation omits it. */
|
||||
export const advertiseToolOutput = (
|
||||
outputSchema: Record<string, unknown>,
|
||||
presentation: AdvertisedToolPresentation["output"],
|
||||
): Record<string, unknown> | 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 }),
|
||||
};
|
||||
};
|
||||
@@ -109,7 +109,7 @@ export const compactAdvertisedInputSchema = (
|
||||
// read one projection of the same memoized canonical schema object.
|
||||
const compacted = new WeakMap<object, Map<number, CompactedInputSchema>>();
|
||||
|
||||
/** 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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
compactPresentation: CompactInputSchemaPresentation,
|
||||
): Record<string, unknown> =>
|
||||
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}`;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, unknown>, Record<string, unknown>>,
|
||||
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<string>();
|
||||
|
||||
@@ -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<typeof createCatalogIdentity>,
|
||||
@@ -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<string, unknown>) =>
|
||||
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<string, unknown>,
|
||||
Record<string, unknown>
|
||||
>();
|
||||
|
||||
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<string>,
|
||||
) {
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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<ReplayArguments>(definition.inputSchema),
|
||||
description: advertised.description,
|
||||
// Calls are validated against the recorded schema; clients list the advertised one.
|
||||
inputSchema: transformAdvertisedInputJsonSchema(
|
||||
fromJsonSchema<ReplayArguments>(definition.inputSchema),
|
||||
() => advertised.inputSchema,
|
||||
),
|
||||
annotations: definition.annotations,
|
||||
},
|
||||
(args): CallToolResult => {
|
||||
@@ -141,6 +148,7 @@ export const createEnvironmentMcpServer = (
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
return server;
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;',
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user