mirror of
https://github.com/morluto/rea
synced 2026-10-26 22:28:27 -04:00
b6ad1f2c3d
* feat(mcp): add compact input schema profile for size-capped providers Moonshot's function-calling API rejects a request while any single tool parameters schema exceeds its literal serialized size cap, so clients such as kimi-cli fail every request while the default catalog is attached (#1484). Add an opt-in REA_MCP_INPUT_SCHEMA_PROFILE=compact server presentation. Each advertised input schema is rendered within a 13,312-byte budget, measured to sit below the observed provider cap: annotation prose (nested descriptions, examples, defaults, titles) is dropped, repeated annotation-free subschemas are shared through schema-local $defs/$ref references the provider validates without expansion, and the three structurally oversized schemas are advertised in a documented reduced form. The canonical catalog, tool set, output schemas, and server-side canonical validation are unchanged; the default full profile is byte-identical to today's advertisement. Boundary tests pin every compact schema to the budget, keep them valid JSON Schema that still accepts every canonical example, hold reduced presentations at three or fewer, and verify canonical rejection still guards a permissive advertisement. Verified against the real Moonshot API through kimi-cli: prompts succeed with the compact catalog attached where the default catalog fails every request with 400. * fix(mcp): preserve compact schemas through Kimi conversion --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
214 lines
6.9 KiB
TypeScript
214 lines
6.9 KiB
TypeScript
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
|
import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server";
|
|
import { afterEach, expect, it, vi } from "vitest";
|
|
import { z } from "zod";
|
|
|
|
import { createAnalysisExecution } from "../../src/application/AnalysisProvider.js";
|
|
import { AnalysisInputError } from "../../src/domain/analysisErrorCore.js";
|
|
import { jsonObjectSchema } from "../../src/domain/jsonValue.js";
|
|
import { ok } from "../../src/domain/result.js";
|
|
import { run } from "../../src/main.js";
|
|
import { createServer } from "../../src/server/createServer.js";
|
|
import type { EvidenceMcpServer } from "../../src/server/EvidenceMcpServer.js";
|
|
import { parseMcpToolError } from "../fixtures/mcpToolError.js";
|
|
|
|
const resources: Array<{ close(): Promise<void> }> = [];
|
|
afterEach(async () => {
|
|
await Promise.all(resources.splice(0).map((resource) => resource.close()));
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
const connect = async (server: EvidenceMcpServer) => {
|
|
const client = new Client({ name: "selected-delivery", version: "1" });
|
|
const [clientTransport, serverTransport] =
|
|
InMemoryTransport.createLinkedPair();
|
|
resources.push(client, server);
|
|
await server.connect(serverTransport);
|
|
await client.connect(clientTransport);
|
|
return client;
|
|
};
|
|
|
|
it("keeps independent server budgets for successful results and oversized errors after environment mutation", async () => {
|
|
const smallBudget = STDIO_DEFAULT_MAX_BUFFER_SIZE;
|
|
const largeBudget = 2 * smallBudget;
|
|
const smallEnvironment = { REA_MCP_MAX_RESPONSE_BYTES: String(smallBudget) };
|
|
const largeEnvironment = { REA_MCP_MAX_RESPONSE_BYTES: String(largeBudget) };
|
|
const payload = "x".repeat(3 * 1024 * 1024);
|
|
const analysis = {
|
|
execute: () =>
|
|
Promise.resolve(
|
|
ok(
|
|
createAnalysisExecution(
|
|
payload,
|
|
{
|
|
id: "fixture",
|
|
name: "Fixture",
|
|
version: "1",
|
|
},
|
|
{ rawResult: payload },
|
|
),
|
|
),
|
|
),
|
|
};
|
|
|
|
vi.stubEnv("REA_MCP_MAX_RESPONSE_BYTES", String(largeBudget));
|
|
const small = createServer(
|
|
{ kind: "fixed", analysis },
|
|
{
|
|
environment: smallEnvironment,
|
|
},
|
|
);
|
|
vi.stubEnv("REA_MCP_MAX_RESPONSE_BYTES", String(smallBudget));
|
|
const large = createServer(
|
|
{ kind: "fixed", analysis },
|
|
{
|
|
environment: largeEnvironment,
|
|
},
|
|
);
|
|
smallEnvironment.REA_MCP_MAX_RESPONSE_BYTES = "invalid-after-selection";
|
|
largeEnvironment.REA_MCP_MAX_RESPONSE_BYTES = String(smallBudget);
|
|
vi.stubEnv("REA_MCP_MAX_RESPONSE_BYTES", "invalid-ambient-after-selection");
|
|
|
|
// Error text alone exceeds the small budget and fits the large one.
|
|
const diagnostic = "observed failure ".repeat(
|
|
Math.ceil((smallBudget + 65536) / 17),
|
|
);
|
|
for (const server of [small, large])
|
|
server.registerTool(
|
|
"selected_failure",
|
|
{ inputSchema: z.object({}) },
|
|
async () =>
|
|
server.delivery.toErrorToolResult(
|
|
new AnalysisInputError("fixture", undefined, [
|
|
{
|
|
path: ["procedure"],
|
|
reason: "invalid_value",
|
|
message: diagnostic,
|
|
},
|
|
]),
|
|
),
|
|
);
|
|
|
|
const [smallClient, largeClient] = await Promise.all([
|
|
connect(small),
|
|
connect(large),
|
|
]);
|
|
const [smallResult, largeResult] = await Promise.all(
|
|
[smallClient, largeClient].map((client) =>
|
|
client.callTool({
|
|
name: "procedure_pseudo_code",
|
|
arguments: { procedure: "main" },
|
|
}),
|
|
),
|
|
);
|
|
if (smallResult === undefined || largeResult === undefined)
|
|
throw new Error("Missing result from selected-budget server");
|
|
expect(smallResult?.isError).toBe(true);
|
|
expect(parseMcpToolError(smallResult)).toMatchObject({
|
|
error: {
|
|
code: "resource_constraint",
|
|
details: {
|
|
reported_limits: {
|
|
result_budget_bytes: smallBudget - 1024,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(largeResult?.isError).not.toBe(true);
|
|
expect(
|
|
jsonObjectSchema.parse(largeResult?.structuredContent).normalized_result,
|
|
).toBe(payload);
|
|
|
|
const [smallFailure, largeFailure] = await Promise.all(
|
|
[smallClient, largeClient].map((client) =>
|
|
client.callTool({ name: "selected_failure", arguments: {} }),
|
|
),
|
|
);
|
|
if (smallFailure === undefined || largeFailure === undefined)
|
|
throw new Error("Missing failure from selected-budget server");
|
|
expect(parseMcpToolError(smallFailure)).toMatchObject({
|
|
error: {
|
|
code: "resource_constraint",
|
|
details: {
|
|
reported_limits: {
|
|
result_budget_bytes: smallBudget - 1024,
|
|
original_error_code: "invalid_request",
|
|
retention: "unavailable",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(parseMcpToolError(largeFailure)).toMatchObject({
|
|
error: {
|
|
code: "invalid_request",
|
|
details: { issues: [{ message: diagnostic }] },
|
|
},
|
|
});
|
|
expect(small.delivery.resultBudgetBytes).toBe(smallBudget - 1024);
|
|
expect(large.delivery.resultBudgetBytes).toBe(largeBudget - 1024);
|
|
});
|
|
|
|
it("snapshots the runtime-selected environment before optional loading and reuses it for replacement servers", async () => {
|
|
const selected = {
|
|
REA_MCP_MAX_RESPONSE_BYTES: String(2 * STDIO_DEFAULT_MAX_BUFFER_SIZE),
|
|
REA_BROWSER_EXECUTABLE: "/selected/browser",
|
|
REA_LOG_LEVEL: "silent",
|
|
};
|
|
const observed: EvidenceMcpServer[] = [];
|
|
let shutdown: (() => void) | undefined;
|
|
let finishClose: (() => void) | undefined;
|
|
const closed = new Promise<void>((resolve) => {
|
|
finishClose = resolve;
|
|
});
|
|
vi.stubEnv(
|
|
"REA_MCP_MAX_RESPONSE_BYTES",
|
|
String(STDIO_DEFAULT_MAX_BUFFER_SIZE),
|
|
);
|
|
const code = await run({
|
|
env: selected,
|
|
loadOptionalProviders: async () => {
|
|
selected.REA_MCP_MAX_RESPONSE_BYTES = "invalid-after-start";
|
|
selected.REA_BROWSER_EXECUTABLE = "/mutated/browser";
|
|
return {};
|
|
},
|
|
createServer: (source, options) => {
|
|
expect(options?.environment?.REA_BROWSER_EXECUTABLE).toBe(
|
|
"/selected/browser",
|
|
);
|
|
expect(Object.isFrozen(options?.environment)).toBe(true);
|
|
const server = createServer(source, options);
|
|
observed.push(server);
|
|
return server;
|
|
},
|
|
serve: (factory) => {
|
|
factory({ era: "modern" });
|
|
factory({ era: "legacy" });
|
|
return {
|
|
close: async () => {
|
|
await Promise.all(observed.map((server) => server.close()));
|
|
finishClose?.();
|
|
},
|
|
};
|
|
},
|
|
writeStderr: (message) => {
|
|
throw new Error(message);
|
|
},
|
|
setExitCode: () => undefined,
|
|
registerShutdown: (handler) => {
|
|
shutdown = handler;
|
|
return () => undefined;
|
|
},
|
|
});
|
|
expect(code).toBe(0);
|
|
expect(observed).toHaveLength(2);
|
|
expect(observed[0]?.delivery).toBe(observed[1]?.delivery);
|
|
expect(observed[0]?.delivery.resultBudgetBytes).toBe(
|
|
2 * STDIO_DEFAULT_MAX_BUFFER_SIZE - 1024,
|
|
);
|
|
if (shutdown === undefined)
|
|
throw new Error("Missing runtime shutdown handler");
|
|
shutdown();
|
|
await closed;
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
});
|