fix(mcp): name response-byte and startup ceilings in recovery (#6005)
* fix(mcp): name response-byte and startup ceilings in recovery Name REA_MCP_MAX_RESPONSE_BYTES, its effective value, the 10485760 byte floor, and process restart when the stdio response budget is hit, and state that the setting does not raise Node's string ceiling. Name fixed MCP startup phase budgets when those deadlines fire, and keep Codex's 30 second startup figure as client guidance. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(mcp): distinguish string ceilings and keep dossier recovery Name REA's reserved string limit separately from Node's ceiling, restore detail:summary discovery for an oversized complete evidence bundle, and apply the doctor deadline sentence when binary_session itself times out. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(mcp): keep retained SDK errors inside the text budget Quoted SDK validation diagnostics expand when recovery JSON is escaped into MCP text. Shorten only that preview so the retained-evidence reference still arrives as parseable error content. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(mcp): keep export advice when a summary exceeds the response budget An oversized get_evidence_bundle summary no longer repeats detail: summary. export_evidence_bundle still writes the retained bundle outside one MCP frame, including when a larger byte budget cannot raise the string ceiling. Co-authored-by: morluto <morluto@users.noreply.github.com> * style(mcp): format the SDK diagnostic preview return oxfmt rejected the fitted-preview ternary in EvidenceMcpServer.ts, which failed Static checks and the CI required gate. Co-authored-by: morluto <morluto@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: morluto <morluto@users.noreply.github.com>
This commit is contained in:
18 files changed
+815
-55
No files matched your search
@@ -234,11 +234,14 @@ The destination changes only when a complete export is atomically published.
|
||||
|
||||
Clients that explicitly configure a larger receive buffer can set the REA
|
||||
server's `REA_MCP_MAX_RESPONSE_BYTES` environment variable to the same byte
|
||||
count. This setting must be a safe decimal integer at least 10485760; REA
|
||||
reserves 1024 bytes for the envelope. Raising it restores complete inline
|
||||
delivery for responses that fit that buffer and Node's single-string limit.
|
||||
It does not change the client's buffer, analysis coverage, or retained content.
|
||||
Ordinary responses keep their existing complete result contract.
|
||||
count. This setting must be a safe decimal integer at least 10485760 (10 MiB);
|
||||
REA reserves 1024 bytes for the envelope. The value is read when the REA
|
||||
process starts, so a change requires a restart and a matching client receive
|
||||
buffer. Raising it restores complete inline delivery for responses that fit
|
||||
that buffer. It does not raise REA's reserved single-string limit, which is
|
||||
1024 UTF-16 code units below Node's single-string ceiling, and it does not
|
||||
raise that Node ceiling. It does not change analysis coverage or retained
|
||||
content. Ordinary responses keep their existing complete result contract.
|
||||
|
||||
### Error recovery and deadline ownership
|
||||
|
||||
|
||||
@@ -47,6 +47,13 @@ export function profileMcpModuleLoading(
|
||||
/** Parse Linux procfs VmRSS text into bytes. */
|
||||
export function parseLinuxVmRssBytes(status: string): number | null;
|
||||
|
||||
/** Name a fixed MCP startup phase budget. Mirrors `fixedMcpPhaseBudgetRecovery`. */
|
||||
export function fixedMcpStartupPhaseBudgetRecovery(
|
||||
phase: string,
|
||||
value: number,
|
||||
unit: "ms" | "bytes",
|
||||
): string;
|
||||
|
||||
/** Collect unique file-backed ESM module URLs from Node diagnostics. */
|
||||
export function createEsmModuleProfileCollector(packageName?: string): {
|
||||
append(chunk: string): void;
|
||||
|
||||
@@ -35,11 +35,22 @@ export async function measureMcpStartup({
|
||||
const startedAt = performance.now();
|
||||
rss.start();
|
||||
try {
|
||||
await client.connect(transport, { timeout: policy.initializeBudgetMs });
|
||||
await withFixedPhaseBudget(
|
||||
"MCP initialize phase budget",
|
||||
policy.initializeBudgetMs,
|
||||
"ms",
|
||||
() => client.connect(transport, { timeout: policy.initializeBudgetMs }),
|
||||
);
|
||||
const initializedAt = performance.now();
|
||||
const tools = await client.listTools(undefined, {
|
||||
timeout: policy.toolsListBudgetMs,
|
||||
});
|
||||
const tools = await withFixedPhaseBudget(
|
||||
"MCP tools/list phase budget",
|
||||
policy.toolsListBudgetMs,
|
||||
"ms",
|
||||
() =>
|
||||
client.listTools(undefined, {
|
||||
timeout: policy.toolsListBudgetMs,
|
||||
}),
|
||||
);
|
||||
const listedAt = performance.now();
|
||||
rss.stop();
|
||||
const measurement = {
|
||||
@@ -52,7 +63,11 @@ export async function measureMcpStartup({
|
||||
assertStartupBudgets(measurement, policy);
|
||||
return measurement;
|
||||
} catch (cause) {
|
||||
throw new Error(`packaged MCP startup probe failed: ${stderr}`, { cause });
|
||||
const detail = cause instanceof Error ? cause.message : String(cause);
|
||||
throw new Error(
|
||||
`packaged MCP startup probe failed: ${detail}${stderr.length === 0 ? "" : `\n${stderr}`}`,
|
||||
{ cause },
|
||||
);
|
||||
} finally {
|
||||
rss.stop();
|
||||
await Promise.allSettled([client.close(), transport.close()]);
|
||||
@@ -82,12 +97,24 @@ export async function profileMcpModuleLoading({
|
||||
version: "1.0.0",
|
||||
});
|
||||
try {
|
||||
await client.connect(transport, {
|
||||
timeout: policy.doctorDeadlineMs,
|
||||
});
|
||||
await client.listTools(undefined, {
|
||||
timeout: policy.toolsListBudgetMs,
|
||||
});
|
||||
await withFixedPhaseBudget(
|
||||
"MCP doctor startup deadline",
|
||||
policy.doctorDeadlineMs,
|
||||
"ms",
|
||||
() =>
|
||||
client.connect(transport, {
|
||||
timeout: policy.doctorDeadlineMs,
|
||||
}),
|
||||
);
|
||||
await withFixedPhaseBudget(
|
||||
"MCP tools/list phase budget",
|
||||
policy.toolsListBudgetMs,
|
||||
"ms",
|
||||
() =>
|
||||
client.listTools(undefined, {
|
||||
timeout: policy.toolsListBudgetMs,
|
||||
}),
|
||||
);
|
||||
return profile.result();
|
||||
} finally {
|
||||
await Promise.allSettled([client.close(), transport.close()]);
|
||||
@@ -197,16 +224,46 @@ const createLinuxRssSampler = (pid) => {
|
||||
};
|
||||
};
|
||||
|
||||
/** Same sentence as `fixedMcpPhaseBudgetRecovery`; this script cannot import that TypeScript module. */
|
||||
export const fixedMcpStartupPhaseBudgetRecovery = (phase, value, unit) =>
|
||||
`REA's fixed ${phase} is ${String(value)} ${unit} and this release has no runtime override.`;
|
||||
|
||||
const phaseTimeout = (cause) => {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
return (
|
||||
message.includes("Request timed out") ||
|
||||
message.includes("Maximum total timeout exceeded") ||
|
||||
message.includes("timed out")
|
||||
);
|
||||
};
|
||||
|
||||
const withFixedPhaseBudget = async (phase, value, unit, operation) => {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (cause) {
|
||||
if (!phaseTimeout(cause)) throw cause;
|
||||
const detail = cause instanceof Error ? cause.message : String(cause);
|
||||
throw new Error(
|
||||
`${detail} ${fixedMcpStartupPhaseBudgetRecovery(phase, value, unit)}`,
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const assertStartupBudgets = (measurement, policy) => {
|
||||
if (measurement.process_started_to_initialized_ms > policy.initializeBudgetMs)
|
||||
throw new Error("packaged MCP exceeded the initialize regression budget");
|
||||
throw new Error(
|
||||
`packaged MCP exceeded the initialize phase budget. ${fixedMcpStartupPhaseBudgetRecovery("MCP initialize phase budget", policy.initializeBudgetMs, "ms")}`,
|
||||
);
|
||||
if (measurement.initialized_to_tools_list_ms > policy.toolsListBudgetMs)
|
||||
throw new Error("packaged MCP exceeded the tools/list regression budget");
|
||||
throw new Error(
|
||||
`packaged MCP exceeded the tools/list phase budget. ${fixedMcpStartupPhaseBudgetRecovery("MCP tools/list phase budget", policy.toolsListBudgetMs, "ms")}`,
|
||||
);
|
||||
if (
|
||||
measurement.process_started_to_tools_list_ms > policy.firstCatalogBudgetMs
|
||||
)
|
||||
throw new Error(
|
||||
"packaged MCP exceeded the first-catalog regression budget",
|
||||
`packaged MCP exceeded the first-catalog phase budget. ${fixedMcpStartupPhaseBudgetRecovery("MCP first-catalog phase budget", policy.firstCatalogBudgetMs, "ms")}`,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,31 @@ import { err, ok, type Result } from "../domain/result.js";
|
||||
// This override supports clients with larger frames. Keep the configuration
|
||||
// parser independent of the SDK's eager server imports; conformance verifies
|
||||
// this floor against the pinned SDK's actual default receive-buffer size.
|
||||
const MINIMUM_RESPONSE_BYTES = 10 * 1024 * 1024;
|
||||
export const MINIMUM_RESPONSE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Facts for a completed or failed MCP result that exceeded the stdio receive
|
||||
* budget. The effective value is the configured receive buffer, including the
|
||||
* envelope reservation. REA's reserved string limit is separate from, and
|
||||
* lower than, Node's single-string ceiling. This setting raises neither.
|
||||
*/
|
||||
export const stdioResponseBudgetRecovery = (
|
||||
effectiveResponseBytes: number,
|
||||
reservedStringCodeUnits: number,
|
||||
nodeStringCeilingCodeUnits: number,
|
||||
): string =>
|
||||
`The effective REA_MCP_MAX_RESPONSE_BYTES value is ${String(effectiveResponseBytes)} bytes (floor ${String(MINIMUM_RESPONSE_BYTES)} bytes, 10 MiB). A change requires restarting the REA process and a matching client receive buffer. It does not raise REA's reserved single-string limit of ${String(reservedStringCodeUnits)} UTF-16 code units or Node's single-string ceiling of ${String(nodeStringCeilingCodeUnits)} UTF-16 code units. This release has no runtime override for either limit.`;
|
||||
|
||||
/**
|
||||
* Facts for a result that exceeded REA's reserved string limit.
|
||||
* `reservedStringCodeUnits` is that REA limit. `nodeStringCeilingCodeUnits`
|
||||
* is Node's own ceiling and is not the same number.
|
||||
*/
|
||||
export const nodeStringCeilingRecovery = (
|
||||
reservedStringCodeUnits: number,
|
||||
nodeStringCeilingCodeUnits: number,
|
||||
): string =>
|
||||
`REA's reserved single-string limit is ${String(reservedStringCodeUnits)} UTF-16 code units, below Node's single-string ceiling of ${String(nodeStringCeilingCodeUnits)} UTF-16 code units. This release has no runtime override for either limit, and REA_MCP_MAX_RESPONSE_BYTES does not raise them.`;
|
||||
|
||||
/** Optional stdio response budget, including room for the protocol envelope. */
|
||||
export const mcpResponseBudgetSchema = z
|
||||
|
||||
@@ -39,6 +39,13 @@ import {
|
||||
} from "./analysisErrorBase.js";
|
||||
import type { AnalysisErrorProjection } from "./analysisErrorProjection.js";
|
||||
|
||||
/** File export when one MCP frame cannot carry the retained session bundle. */
|
||||
export const MCP_TRANSPORT_EVIDENCE_EXPORT =
|
||||
"Export complete retained session evidence through export_evidence_bundle to a caller-selected path, or use complete CLI JSON output. The connection remains usable.";
|
||||
|
||||
/** Next step when one MCP frame cannot carry a complete retained session bundle. */
|
||||
export const MCP_TRANSPORT_EVIDENCE_DISCOVERY = `Discover retained records with get_evidence_bundle and detail: summary, then inspect compatible records through inspect_analysis_view using an exact Evidence reference. ${MCP_TRANSPORT_EVIDENCE_EXPORT}`;
|
||||
|
||||
export const analysisErrorRemediationAction = (
|
||||
error: AnalysisError,
|
||||
): string => {
|
||||
@@ -81,7 +88,7 @@ export const analysisErrorRemediationAction = (
|
||||
: error.resource === "capacity"
|
||||
? "Wait for an active analysis to complete, then retry within the reported admission limits."
|
||||
: error.resource === "transport"
|
||||
? "Discover retained records with get_evidence_bundle and detail: summary, then inspect compatible records through inspect_analysis_view using an exact Evidence reference. Export complete retained session evidence through export_evidence_bundle to a caller-selected path, or use complete CLI JSON output. The connection remains usable."
|
||||
? MCP_TRANSPORT_EVIDENCE_DISCOVERY
|
||||
: error.resource === "cpu"
|
||||
? "Review the reported worker CPU limits and observed signal. Retry with sufficient CPU time or a smaller artifact; REA retains tighter inherited limits."
|
||||
: error.resource === "file-size"
|
||||
|
||||
@@ -54,7 +54,7 @@ export function* jsonValueValidationSteps(
|
||||
if (item === undefined) break;
|
||||
if (item.depth > MAX_JSON_DEPTH)
|
||||
return finish(
|
||||
`JSON value exceeds maximum nesting depth of ${MAX_JSON_DEPTH}`,
|
||||
`JSON value exceeds maximum nesting depth of ${MAX_JSON_DEPTH}. This release has no runtime override.`,
|
||||
);
|
||||
const value = item.value;
|
||||
if (typeof value === "object" && value !== null && types.isProxy(value))
|
||||
|
||||
+32
-6
@@ -15,7 +15,10 @@ import { PROMPT_CONTRACTS } from "./contracts/promptContracts.js";
|
||||
import { TOOL_CONTRACTS } from "./contracts/toolContracts.js";
|
||||
import { err, ok } from "./domain/result.js";
|
||||
import { PRODUCT_IDENTITY } from "./identity.js";
|
||||
import { MCP_STARTUP_POLICY } from "./mcpStartupPolicy.js";
|
||||
import {
|
||||
MCP_STARTUP_POLICY,
|
||||
mcpDoctorDeadlineRecovery,
|
||||
} from "./mcpStartupPolicy.js";
|
||||
|
||||
const OUTPUT_FORMATS = ["toon", "json", "yaml", "md", "jsonl"] as const;
|
||||
type OutputFormat = (typeof OUTPUT_FORMATS)[number];
|
||||
@@ -81,9 +84,8 @@ interface McpDoctorCheck {
|
||||
/** Diagnose the actual production stdio adapter with one startup-bounded SDK session. */
|
||||
export const runProductionMcpDoctor = async (options: McpDoctorOptions) => {
|
||||
const catalog = catalogIdentityForEnvironment(options.environment);
|
||||
const deadline =
|
||||
performance.now() +
|
||||
(options.deadlineMs ?? MCP_STARTUP_POLICY.doctorDeadlineMs);
|
||||
const budgetMs = options.deadlineMs ?? MCP_STARTUP_POLICY.doctorDeadlineMs;
|
||||
const deadline = performance.now() + budgetMs;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(new Error("Production MCP doctor deadline expired")),
|
||||
@@ -111,6 +113,7 @@ export const runProductionMcpDoctor = async (options: McpDoctorOptions) => {
|
||||
deadline,
|
||||
controller.signal,
|
||||
catalog,
|
||||
budgetMs,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
result = {
|
||||
@@ -124,7 +127,7 @@ export const runProductionMcpDoctor = async (options: McpDoctorOptions) => {
|
||||
{
|
||||
name: "transport",
|
||||
ok: false,
|
||||
detail: errorMessage(cause),
|
||||
detail: doctorTransportDetail(cause, budgetMs),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -197,6 +200,7 @@ const inspectProductionMcpSession = async (
|
||||
deadline: number,
|
||||
signal: AbortSignal,
|
||||
catalog: ReturnType<typeof createCatalogIdentity>,
|
||||
budgetMs: number,
|
||||
) => {
|
||||
const request = requestOptions(deadline, signal);
|
||||
const [tools, prompts] = await Promise.all([
|
||||
@@ -213,7 +217,10 @@ const inspectProductionMcpSession = async (
|
||||
},
|
||||
)
|
||||
.then(ok, (cause: unknown) =>
|
||||
err({ kind: "request-flow" as const, detail: errorMessage(cause) }),
|
||||
err({
|
||||
kind: "request-flow" as const,
|
||||
detail: doctorTransportDetail(cause, budgetMs),
|
||||
}),
|
||||
);
|
||||
const inventory = {
|
||||
tools: compareInventory(
|
||||
@@ -541,3 +548,22 @@ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
||||
|
||||
const errorMessage = (cause: unknown): string =>
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
|
||||
const doctorDeadlineFired = (cause: unknown): boolean => {
|
||||
const message = errorMessage(cause);
|
||||
return (
|
||||
message.includes("Production MCP doctor deadline expired") ||
|
||||
message.includes("Request timed out") ||
|
||||
message.includes("Maximum total timeout exceeded") ||
|
||||
(cause instanceof Error &&
|
||||
(cause.name === "TimeoutError" || cause.name === "AbortError"))
|
||||
);
|
||||
};
|
||||
|
||||
/** Name the fixed doctor deadline when it fires, without turning Codex's 30s into a REA knob. */
|
||||
const doctorTransportDetail = (cause: unknown, budgetMs: number): string => {
|
||||
const message = errorMessage(cause);
|
||||
return doctorDeadlineFired(cause)
|
||||
? `${message} ${mcpDoctorDeadlineRecovery(budgetMs)}`
|
||||
: message;
|
||||
};
|
||||
@@ -18,6 +18,22 @@ export const MCP_STARTUP_POLICY = {
|
||||
doctorStderrBytes: 64 * 1024,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Recovery for a fixed MCP startup phase budget. Codex's startup figure is
|
||||
* client guidance and is not named here; doctor text adds that distinction
|
||||
* because its deadline uses the same 30 second magnitude.
|
||||
*/
|
||||
export const fixedMcpPhaseBudgetRecovery = (
|
||||
phase: string,
|
||||
value: number,
|
||||
unit: "ms" | "bytes",
|
||||
): string =>
|
||||
`REA's fixed ${phase} is ${String(value)} ${unit} and this release has no runtime override.`;
|
||||
|
||||
/** Doctor startup recovery. The Codex figure is client guidance REA does not enforce. */
|
||||
export const mcpDoctorDeadlineRecovery = (budgetMs: number): string =>
|
||||
`${fixedMcpPhaseBudgetRecovery("MCP doctor startup deadline", budgetMs, "ms")} Codex startup_timeout_sec of ${String(MCP_STARTUP_POLICY.codexStartupTimeoutSeconds)} seconds is client guidance; REA does not enforce that figure as this deadline.`;
|
||||
|
||||
/** Parsed startup and upstream request budgets affecting a client's tool deadline. */
|
||||
export interface McpToolTimeoutInputs {
|
||||
readonly analysisProvider: AnalysisProviderSelector;
|
||||
|
||||
@@ -38,8 +38,11 @@ import {
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
import { jsonObjectSchema } from "../domain/jsonValue.js";
|
||||
import type { ToolResultDelivery } from "./toolResult.js";
|
||||
import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import {
|
||||
mcpResponseOverflowProjection,
|
||||
type ToolResultDelivery,
|
||||
} from "./toolResult.js";
|
||||
import { toolReturnsTargetContent } from "../contracts/toolEffects.js";
|
||||
import {
|
||||
carriesTargetContentMarker,
|
||||
@@ -483,10 +486,20 @@ class ErrorEvidenceTransport implements Transport {
|
||||
);
|
||||
const recorded = this.recordEvidence?.(evidence);
|
||||
const retained = recorded !== undefined && recorded.ok;
|
||||
const failure = new AnalysisResourceConstraintError(
|
||||
const projected = mcpResponseOverflowProjection({
|
||||
completed: false,
|
||||
constraint: encoded.constraint,
|
||||
effectiveResponseBytes: this.delivery.maximumResponseBytes,
|
||||
nextStep: retained
|
||||
? "Export the retained original error through export_evidence_bundle to a caller-selected path. The connection remains usable."
|
||||
: encoded.constraint === "string-length"
|
||||
? "The original error could not be retained by this server. Use complete CLI JSON output when the analysis completed. Repeating the unchanged MCP call fails the same way."
|
||||
: "The original error could not be retained by this server. Restart REA with a larger REA_MCP_MAX_RESPONSE_BYTES and a matching client receive buffer, then retry.",
|
||||
});
|
||||
const replacement = this.transportConstraintResult(
|
||||
operation,
|
||||
"transport",
|
||||
"The tool failed, but its complete error response exceeds the MCP response budget.",
|
||||
projected.reason,
|
||||
projected.remediationAction,
|
||||
{
|
||||
boundary: "mcp-response",
|
||||
...(originalError?.success
|
||||
@@ -513,19 +526,72 @@ class ErrorEvidenceTransport implements Transport {
|
||||
? { retention_error: projectAnalysisError(recorded.error) }
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
remediationAction: retained
|
||||
? "Export the retained original error through export_evidence_bundle to a caller-selected path. The connection remains usable."
|
||||
: "Restart REA with a larger REA_MCP_MAX_RESPONSE_BYTES and matching client receive buffer, then retry. The original error could not be retained by this server.",
|
||||
},
|
||||
targetContent,
|
||||
);
|
||||
const replacement = this.delivery.toErrorToolResult(failure, targetContent);
|
||||
return {
|
||||
...message,
|
||||
result: replaceToolResultPayload(message.result, replacement),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a transport-constraint error inside the MCP text budget.
|
||||
* The complete SDK diagnostic stays in retained Evidence. Its character
|
||||
* preview can still expand past the budget when quotes and backslashes are
|
||||
* escaped into that text, so shorten only the preview and keep the reference.
|
||||
*/
|
||||
private transportConstraintResult(
|
||||
operation: string,
|
||||
reason: string,
|
||||
remediationAction: string,
|
||||
limits: Readonly<Record<string, JsonValue>>,
|
||||
targetContent: boolean,
|
||||
): CallToolResult {
|
||||
const deliver = (
|
||||
reportedLimits: Readonly<Record<string, JsonValue>>,
|
||||
): CallToolResult =>
|
||||
this.delivery.toErrorToolResult(
|
||||
new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
"transport",
|
||||
reason,
|
||||
reportedLimits,
|
||||
{ remediationAction },
|
||||
),
|
||||
targetContent,
|
||||
);
|
||||
const delivered = deliver(limits);
|
||||
const preview = limits.diagnostic_preview;
|
||||
if (delivered.content.length > 0 || typeof preview !== "string")
|
||||
return delivered;
|
||||
let low = 0;
|
||||
let high = preview.length - 1;
|
||||
let fitted: CallToolResult | undefined;
|
||||
let fittedLength = -1;
|
||||
while (low <= high) {
|
||||
const length = Math.floor((low + high) / 2);
|
||||
const candidate = deliver({
|
||||
...limits,
|
||||
diagnostic_preview: preview.slice(0, length),
|
||||
diagnostic_truncated: true,
|
||||
});
|
||||
if (candidate.content.length > 0) {
|
||||
fitted = candidate;
|
||||
fittedLength = length;
|
||||
low = length + 1;
|
||||
} else high = length - 1;
|
||||
}
|
||||
if (fitted !== undefined && fittedLength > 0) return fitted;
|
||||
const { diagnostic_preview: _preview, ...withoutPreview } = limits;
|
||||
const omitted = deliver({
|
||||
...withoutPreview,
|
||||
diagnostic_truncated: preview.length > 0,
|
||||
});
|
||||
return omitted.content.length > 0 || fitted === undefined
|
||||
? omitted
|
||||
: fitted;
|
||||
}
|
||||
|
||||
private normalizeSdkValidationError(
|
||||
message: JSONRPCMessage,
|
||||
tool: string | undefined,
|
||||
|
||||
+77
-10
@@ -14,12 +14,53 @@ import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
MCP_TRANSPORT_EVIDENCE_DISCOVERY,
|
||||
MCP_TRANSPORT_EVIDENCE_EXPORT,
|
||||
} from "../domain/analysisErrorPresentation.js";
|
||||
import {
|
||||
nodeStringCeilingRecovery,
|
||||
stdioResponseBudgetRecovery,
|
||||
} from "../config/mcpResponseBudget.js";
|
||||
import {
|
||||
encodeToolResult,
|
||||
MCP_RESULT_STRING_LIMIT,
|
||||
NODE_MAX_STRING_LENGTH,
|
||||
TARGET_CONTENT_RESULT_META,
|
||||
} from "./toolResultEncoding.js";
|
||||
|
||||
/** Project a response-budget refusal without labeling REA's reserved string limit as Node's ceiling. */
|
||||
export const mcpResponseOverflowProjection = (input: {
|
||||
readonly completed: boolean;
|
||||
readonly constraint: "receive-buffer" | "string-length";
|
||||
readonly effectiveResponseBytes: number;
|
||||
readonly nextStep: string;
|
||||
}): { readonly reason: string; readonly remediationAction: string } => {
|
||||
const facts =
|
||||
input.constraint === "string-length"
|
||||
? nodeStringCeilingRecovery(
|
||||
MCP_RESULT_STRING_LIMIT,
|
||||
NODE_MAX_STRING_LENGTH,
|
||||
)
|
||||
: stdioResponseBudgetRecovery(
|
||||
input.effectiveResponseBytes,
|
||||
MCP_RESULT_STRING_LIMIT,
|
||||
NODE_MAX_STRING_LENGTH,
|
||||
);
|
||||
const summary =
|
||||
input.constraint === "string-length"
|
||||
? input.completed
|
||||
? "The operation completed, but its complete MCP response exceeds REA's reserved single-string limit."
|
||||
: "The tool failed, but its complete error response exceeds REA's reserved single-string limit."
|
||||
: input.completed
|
||||
? "The operation completed, but its complete MCP response exceeds the stdio response budget. The analysis result was not truncated."
|
||||
: "The tool failed, but its complete error response exceeds the MCP response budget.";
|
||||
return {
|
||||
reason: `${summary} ${facts} ${input.nextStep}`,
|
||||
remediationAction: `${input.nextStep} ${facts}`,
|
||||
};
|
||||
};
|
||||
|
||||
/** Create one delivery policy from the already selected, parsed response budget. */
|
||||
export const createToolResultDelivery = (
|
||||
maximumResponseBytes: number | undefined,
|
||||
@@ -50,10 +91,32 @@ const retainedEvidenceContinuation = (
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* A summary projection has already used `detail: "summary"`.
|
||||
* Export still writes the retained bundle outside one MCP frame.
|
||||
*/
|
||||
const evidenceBundleSummary = (value: JsonValue): boolean => {
|
||||
const payload =
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
"result" in value
|
||||
? value.result
|
||||
: value;
|
||||
return (
|
||||
typeof payload === "object" &&
|
||||
payload !== null &&
|
||||
!Array.isArray(payload) &&
|
||||
payload.kind === "evidence-bundle-summary"
|
||||
);
|
||||
};
|
||||
|
||||
/** Immutable encoding and recovery policy shared by one MCP server. */
|
||||
export class ToolResultDelivery {
|
||||
readonly maximumResponseBytes: number;
|
||||
readonly resultBudgetBytes: number;
|
||||
constructor(maximumResponseBytes: number) {
|
||||
this.maximumResponseBytes = maximumResponseBytes;
|
||||
this.resultBudgetBytes = maximumResponseBytes - 1024;
|
||||
Object.freeze(this);
|
||||
}
|
||||
@@ -176,21 +239,27 @@ export class ToolResultDelivery {
|
||||
: {}),
|
||||
};
|
||||
const retainedEvidence = recovery.evidence_reference !== undefined;
|
||||
const recoveryAdvice =
|
||||
const nextStep =
|
||||
recovery.continuation !== undefined
|
||||
? "Call inspect_analysis_view with the supplied continuation arguments to read retained analysis. Follow coverage.next_offset for subsequent pages; reduce the page limit if needed, or export the complete retained Evidence."
|
||||
: retainedEvidence
|
||||
? "Export retained evidence to consume the complete analysis, or use complete CLI JSON output."
|
||||
: encoded.constraint === "string-length"
|
||||
? "Use complete CLI JSON output to consume the analysis."
|
||||
: "Use a larger MCP response budget with a matching client receive buffer, or use complete CLI JSON output to consume the analysis.";
|
||||
: contract.name === "get_evidence_bundle"
|
||||
? evidenceBundleSummary(value)
|
||||
? MCP_TRANSPORT_EVIDENCE_EXPORT
|
||||
: MCP_TRANSPORT_EVIDENCE_DISCOVERY
|
||||
: "Use complete CLI JSON output to consume the analysis.";
|
||||
const projected = mcpResponseOverflowProjection({
|
||||
completed: true,
|
||||
constraint: encoded.constraint,
|
||||
effectiveResponseBytes: this.maximumResponseBytes,
|
||||
nextStep,
|
||||
});
|
||||
return this.toErrorToolResult(
|
||||
new AnalysisResourceConstraintError(
|
||||
contract.name,
|
||||
"transport",
|
||||
encoded.constraint === "string-length"
|
||||
? `The operation completed, but its complete MCP response exceeds Node's single-string representation limit. ${recoveryAdvice}`
|
||||
: `The operation completed, but its complete MCP response exceeds the stdio response budget. The analysis result was not truncated. ${recoveryAdvice}`,
|
||||
projected.reason,
|
||||
{
|
||||
boundary: "mcp-response",
|
||||
default_receive_buffer_bytes: STDIO_DEFAULT_MAX_BUFFER_SIZE,
|
||||
@@ -201,9 +270,7 @@ export class ToolResultDelivery {
|
||||
constraint: encoded.constraint,
|
||||
...recovery,
|
||||
},
|
||||
recovery.continuation === undefined
|
||||
? undefined
|
||||
: { remediationAction: recoveryAdvice },
|
||||
{ remediationAction: projected.remediationAction },
|
||||
),
|
||||
contract.returnsTargetContent,
|
||||
);
|
||||
|
||||
@@ -3,7 +3,11 @@ import { constants as bufferConstants } from "node:buffer";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { bufferedJsonParts, jsonParts } from "../domain/jsonSerialization.js";
|
||||
|
||||
export const MCP_RESULT_STRING_LIMIT = bufferConstants.MAX_STRING_LENGTH - 1024;
|
||||
/** Node's actual single-string ceiling. REA refuses earlier, at MCP_RESULT_STRING_LIMIT. */
|
||||
export const NODE_MAX_STRING_LENGTH = bufferConstants.MAX_STRING_LENGTH;
|
||||
|
||||
/** REA's reserved serialization limit, 1024 code units under Node's ceiling. */
|
||||
export const MCP_RESULT_STRING_LIMIT = NODE_MAX_STRING_LENGTH - 1024;
|
||||
|
||||
const RESULT_ENVELOPE_BYTES =
|
||||
Buffer.byteLength(
|
||||
|
||||
@@ -321,6 +321,9 @@ it("discovers an oversized native dossier by exact metadata and reads its retain
|
||||
expect(broadError.remediation?.action).toContain(
|
||||
"get_evidence_bundle and detail: summary",
|
||||
);
|
||||
expect(broadError.remediation?.action).toContain(
|
||||
"REA_MCP_MAX_RESPONSE_BYTES",
|
||||
);
|
||||
const response = await client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: {
|
||||
|
||||
@@ -22,7 +22,15 @@ import {
|
||||
import { ok } from "../../../src/domain/result.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { registerOfficialTools } from "../../../src/server/registerOfficialTools.js";
|
||||
import { ToolResultDelivery } from "../../../src/server/toolResult.js";
|
||||
import {
|
||||
mcpResponseOverflowProjection,
|
||||
ToolResultDelivery,
|
||||
} from "../../../src/server/toolResult.js";
|
||||
import { MINIMUM_RESPONSE_BYTES } from "../../../src/config/mcpResponseBudget.js";
|
||||
import {
|
||||
MCP_RESULT_STRING_LIMIT,
|
||||
NODE_MAX_STRING_LENGTH,
|
||||
} from "../../../src/server/toolResultEncoding.js";
|
||||
import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js";
|
||||
import { encodeToolResult } from "../../../src/server/toolResultEncoding.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
@@ -96,6 +104,110 @@ const callPseudoCode = async (
|
||||
}
|
||||
};
|
||||
|
||||
describe("MCP response budget recovery", () => {
|
||||
it("names the stdio setting, floor, restart, and the separate string ceiling", () => {
|
||||
const projected = mcpResponseOverflowProjection({
|
||||
completed: true,
|
||||
constraint: "receive-buffer",
|
||||
effectiveResponseBytes: MINIMUM_RESPONSE_BYTES,
|
||||
nextStep: "Use complete CLI JSON output to consume the analysis.",
|
||||
});
|
||||
expect(projected.reason).toContain(
|
||||
`The effective REA_MCP_MAX_RESPONSE_BYTES value is ${String(MINIMUM_RESPONSE_BYTES)} bytes`,
|
||||
);
|
||||
expect(projected.reason).toContain("floor 10485760 bytes, 10 MiB");
|
||||
expect(projected.reason).toContain("restarting the REA process");
|
||||
expect(projected.reason).toContain("does not raise");
|
||||
expect(projected.reason).toContain("no runtime override");
|
||||
expect(NODE_MAX_STRING_LENGTH - MCP_RESULT_STRING_LIMIT).toBe(1024);
|
||||
expect(projected.reason).toContain(
|
||||
`REA's reserved single-string limit of ${String(MCP_RESULT_STRING_LIMIT)} UTF-16 code units`,
|
||||
);
|
||||
expect(projected.reason).toContain(
|
||||
`Node's single-string ceiling of ${String(NODE_MAX_STRING_LENGTH)} UTF-16 code units`,
|
||||
);
|
||||
expect(projected.reason).not.toContain(
|
||||
`Node's single-string ceiling of ${String(MCP_RESULT_STRING_LIMIT)}`,
|
||||
);
|
||||
expect(projected.reason).not.toContain(
|
||||
`Node's single-string ceiling is ${String(MCP_RESULT_STRING_LIMIT)}`,
|
||||
);
|
||||
expect(projected.remediationAction).toContain("REA_MCP_MAX_RESPONSE_BYTES");
|
||||
expect(projected.remediationAction).not.toContain(
|
||||
"set REA_MCP_MAX_RESPONSE_BYTES above Node",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not offer REA_MCP_MAX_RESPONSE_BYTES as a way to raise the string ceiling", () => {
|
||||
const projected = mcpResponseOverflowProjection({
|
||||
completed: false,
|
||||
constraint: "string-length",
|
||||
effectiveResponseBytes: MINIMUM_RESPONSE_BYTES * 2,
|
||||
nextStep: "Use complete CLI JSON output when the analysis completed.",
|
||||
});
|
||||
expect(projected.reason).toContain("REA's reserved single-string limit");
|
||||
expect(projected.reason).toContain(
|
||||
`REA's reserved single-string limit is ${String(MCP_RESULT_STRING_LIMIT)} UTF-16 code units`,
|
||||
);
|
||||
expect(projected.reason).toContain(
|
||||
`below Node's single-string ceiling of ${String(NODE_MAX_STRING_LENGTH)} UTF-16 code units`,
|
||||
);
|
||||
expect(projected.reason).not.toContain(
|
||||
`Node's single-string ceiling is ${String(MCP_RESULT_STRING_LIMIT)}`,
|
||||
);
|
||||
expect(projected.reason).not.toContain(
|
||||
`Node's single-string ceiling of ${String(MCP_RESULT_STRING_LIMIT)}`,
|
||||
);
|
||||
expect(projected.reason).toContain(
|
||||
"REA_MCP_MAX_RESPONSE_BYTES does not raise them",
|
||||
);
|
||||
expect(projected.reason).toContain("no runtime override");
|
||||
expect(projected.reason).not.toContain("floor 10485760");
|
||||
expect(projected.remediationAction).toContain(
|
||||
"REA_MCP_MAX_RESPONSE_BYTES does not raise them",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps summary discovery when a complete evidence bundle exceeds the budget", () => {
|
||||
const tight = new ToolResultDelivery(2048);
|
||||
const delivered = tight.toCallToolResult(
|
||||
ok({ records: "x".repeat(1500) }),
|
||||
toolContract("get_evidence_bundle"),
|
||||
);
|
||||
expect(delivered.isError).toBe(true);
|
||||
const action = z
|
||||
.object({
|
||||
error: z.object({ remediation: z.object({ action: z.string() }) }),
|
||||
})
|
||||
.parse(delivered.structuredContent).error.remediation.action;
|
||||
expect(action).toContain("get_evidence_bundle and detail: summary");
|
||||
expect(action).toContain("REA_MCP_MAX_RESPONSE_BYTES");
|
||||
expect(action).toContain(
|
||||
`Node's single-string ceiling of ${String(NODE_MAX_STRING_LENGTH)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps export advice when the evidence summary itself exceeds the budget", () => {
|
||||
const tight = new ToolResultDelivery(2048);
|
||||
const delivered = tight.toCallToolResult(
|
||||
ok({
|
||||
kind: "evidence-bundle-summary",
|
||||
records: "x".repeat(1500),
|
||||
}),
|
||||
toolContract("get_evidence_bundle"),
|
||||
);
|
||||
const action = z
|
||||
.object({
|
||||
error: z.object({ remediation: z.object({ action: z.string() }) }),
|
||||
})
|
||||
.parse(delivered.structuredContent).error.remediation.action;
|
||||
expect(action).not.toContain("get_evidence_bundle and detail: summary");
|
||||
expect(action).toContain("export_evidence_bundle");
|
||||
expect(action).toContain("caller-selected path");
|
||||
expect(action).toContain("or use complete CLI JSON output");
|
||||
});
|
||||
});
|
||||
|
||||
describe("large complete Evidence MCP delivery", () => {
|
||||
it("delivers a two MiB provider result with raw observations and one normalized payload", async () => {
|
||||
const payload = "x".repeat(2 * 1024 * 1024);
|
||||
@@ -226,14 +338,19 @@ describe("large complete Evidence MCP delivery", () => {
|
||||
.object({
|
||||
error: z.object({
|
||||
message: z.string(),
|
||||
remediation: z.object({ action: z.string() }),
|
||||
details: z.object({
|
||||
reported_limits: z.record(z.string(), z.unknown()),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.parse(detached.structuredContent).error;
|
||||
expect(limits.message).toContain("larger MCP response budget");
|
||||
expect(limits.message).toContain("REA_MCP_MAX_RESPONSE_BYTES");
|
||||
expect(limits.message).toContain("10485760");
|
||||
expect(limits.message).toContain("restarting the REA process");
|
||||
expect(limits.message).toContain("does not raise");
|
||||
expect(limits.message).not.toContain("Export retained evidence");
|
||||
expect(limits.remediation.action).toContain("REA_MCP_MAX_RESPONSE_BYTES");
|
||||
expect(limits.details.reported_limits).not.toHaveProperty(
|
||||
"evidence_reference",
|
||||
);
|
||||
|
||||
@@ -377,11 +377,77 @@ describe("production MCP doctor child lifecycle", () => {
|
||||
const result = await vi.waitFor(() => pending, { timeout: 10_000 });
|
||||
vi.useRealTimers();
|
||||
expect(result.healthy).toBe(false);
|
||||
expect(result.checks[0]?.detail).toMatch(/deadline|abort|timed/iu);
|
||||
expect(result.checks[0]?.detail).toContain("60000 ms");
|
||||
expect(result.checks[0]?.detail).toContain("no runtime override");
|
||||
expect(result.checks[0]?.detail).toContain(
|
||||
"Codex startup_timeout_sec of 30 seconds is client guidance",
|
||||
);
|
||||
expect(result.checks[0]?.detail).not.toContain("REA_MCP_STARTUP");
|
||||
await expect(waitForExit(pid)).resolves.toBeUndefined();
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
describe("production MCP doctor request-flow deadline", () => {
|
||||
it("names the applied deadline when binary_session exceeds it after the catalog", async () => {
|
||||
const root = await createTestTempDirectory("rea-mcp-doctor-session-");
|
||||
const readyPath = join(root, "ready");
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
const pending = runProductionMcpDoctor({
|
||||
command: process.execPath,
|
||||
args: [resolve("tests/fixtures/mcpStartupProbeChild.mjs")],
|
||||
cwd: process.cwd(),
|
||||
environment: {
|
||||
...process.env,
|
||||
REA_MCP_PROBE_MODE: "hang-on-call",
|
||||
REA_MCP_PROBE_READY_PATH: readyPath,
|
||||
},
|
||||
deadlineMs: 60_000,
|
||||
});
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
await readFile(readyPath, "utf8");
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
const result = await vi.waitFor(() => pending, { timeout: 10_000 });
|
||||
vi.useRealTimers();
|
||||
expect(result.server).not.toBeNull();
|
||||
const requestFlow = result.checks.find(
|
||||
(check) => check.name === "request-flow",
|
||||
);
|
||||
expect(requestFlow?.ok).toBe(false);
|
||||
expect(requestFlow?.detail).toContain("60000 ms");
|
||||
expect(requestFlow?.detail).toContain("no runtime override");
|
||||
expect(requestFlow?.detail).toContain(
|
||||
"Codex startup_timeout_sec of 30 seconds is client guidance",
|
||||
);
|
||||
expect(requestFlow?.detail).not.toContain("30000 ms");
|
||||
expect(requestFlow?.detail).not.toContain("REA_MCP_STARTUP");
|
||||
}, 15_000);
|
||||
|
||||
it("does not add deadline guidance to an unrelated binary_session failure", async () => {
|
||||
const result = await runProductionMcpDoctor({
|
||||
command: process.execPath,
|
||||
args: [resolve("tests/fixtures/mcpStartupProbeChild.mjs")],
|
||||
cwd: process.cwd(),
|
||||
environment: {
|
||||
...process.env,
|
||||
REA_MCP_PROBE_MODE: "refuse-call",
|
||||
},
|
||||
deadlineMs: 5_000,
|
||||
});
|
||||
expect(result.server).not.toBeNull();
|
||||
const requestFlow = result.checks.find(
|
||||
(check) => check.name === "request-flow",
|
||||
);
|
||||
expect(requestFlow?.ok).toBe(false);
|
||||
expect(requestFlow?.detail).toContain("unrelated fixture method refusal");
|
||||
expect(requestFlow?.detail).not.toContain("no runtime override");
|
||||
expect(requestFlow?.detail).not.toContain("MCP doctor startup deadline");
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
const runNoisyDoctorChild = async (body: string) => {
|
||||
const root = await createTestTempDirectory("rea-mcp-doctor-stderr-");
|
||||
const scriptPath = join(root, "noisy.mjs");
|
||||
|
||||
@@ -1,11 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import {
|
||||
createEsmModuleProfileCollector,
|
||||
fixedMcpStartupPhaseBudgetRecovery,
|
||||
measureMcpStartup,
|
||||
parseLinuxVmRssBytes,
|
||||
profileMcpModuleLoading,
|
||||
} from "../../../scripts/lib/mcp-startup-probe.mjs";
|
||||
import {
|
||||
fixedMcpPhaseBudgetRecovery,
|
||||
mcpDoctorDeadlineRecovery,
|
||||
MCP_STARTUP_POLICY,
|
||||
} from "../../../src/mcpStartupPolicy.js";
|
||||
|
||||
describe("MCP startup policy", () => {
|
||||
it("names fixed phase budgets without turning Codex's 30s into a REA knob", () => {
|
||||
const initialize = fixedMcpPhaseBudgetRecovery(
|
||||
"MCP initialize phase budget",
|
||||
MCP_STARTUP_POLICY.initializeBudgetMs,
|
||||
"ms",
|
||||
);
|
||||
expect(initialize).toBe(
|
||||
"REA's fixed MCP initialize phase budget is 15000 ms and this release has no runtime override.",
|
||||
);
|
||||
expect(initialize).toBe(
|
||||
fixedMcpStartupPhaseBudgetRecovery(
|
||||
"MCP initialize phase budget",
|
||||
MCP_STARTUP_POLICY.initializeBudgetMs,
|
||||
"ms",
|
||||
),
|
||||
);
|
||||
expect(
|
||||
fixedMcpStartupPhaseBudgetRecovery(
|
||||
"MCP tools/list phase budget",
|
||||
MCP_STARTUP_POLICY.toolsListBudgetMs,
|
||||
"ms",
|
||||
),
|
||||
).toContain("5000 ms");
|
||||
expect(
|
||||
fixedMcpStartupPhaseBudgetRecovery(
|
||||
"MCP first-catalog phase budget",
|
||||
MCP_STARTUP_POLICY.firstCatalogBudgetMs,
|
||||
"ms",
|
||||
),
|
||||
).toContain("20000 ms");
|
||||
const doctor = mcpDoctorDeadlineRecovery(
|
||||
MCP_STARTUP_POLICY.doctorDeadlineMs,
|
||||
);
|
||||
expect(doctor).toContain("30000 ms");
|
||||
expect(doctor).toContain("no runtime override");
|
||||
expect(doctor).toContain(
|
||||
"Codex startup_timeout_sec of 30 seconds is client guidance",
|
||||
);
|
||||
expect(doctor).toContain("does not enforce");
|
||||
expect(doctor).not.toContain("REA_MCP_STARTUP");
|
||||
});
|
||||
|
||||
it("parses Linux resident memory without accepting other fields", () => {
|
||||
expect(
|
||||
parseLinuxVmRssBytes("Name:\tnode\nVmRSS:\t 12345 kB\nThreads:\t7\n"),
|
||||
@@ -32,3 +84,140 @@ describe("MCP startup policy", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const probeChild = resolve("tests/fixtures/mcpStartupProbeChild.mjs");
|
||||
|
||||
const probePolicy = (
|
||||
overrides: Partial<{
|
||||
initializeBudgetMs: number;
|
||||
toolsListBudgetMs: number;
|
||||
firstCatalogBudgetMs: number;
|
||||
doctorDeadlineMs: number;
|
||||
}> = {},
|
||||
) => ({
|
||||
initializeBudgetMs: 30_000,
|
||||
toolsListBudgetMs: 30_000,
|
||||
firstCatalogBudgetMs: 30_000,
|
||||
doctorDeadlineMs: 30_000,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const errorMessage = (cause: unknown): string =>
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
|
||||
describe("packaged MCP startup probe failures", () => {
|
||||
it("names the initialize budget when that phase times out", async () => {
|
||||
const budgetMs = 200;
|
||||
await expect(
|
||||
measureMcpStartup({
|
||||
command: process.execPath,
|
||||
args: [probeChild],
|
||||
environment: { ...process.env, REA_MCP_PROBE_MODE: "hang" },
|
||||
policy: probePolicy({ initializeBudgetMs: budgetMs }),
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
`MCP initialize phase budget is ${String(budgetMs)} ms and this release has no runtime override.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("names the tools/list budget when that phase times out", async () => {
|
||||
const budgetMs = 200;
|
||||
await expect(
|
||||
measureMcpStartup({
|
||||
command: process.execPath,
|
||||
args: [probeChild],
|
||||
environment: {
|
||||
...process.env,
|
||||
REA_MCP_PROBE_MODE: "initialize-then-hang",
|
||||
},
|
||||
policy: probePolicy({ toolsListBudgetMs: budgetMs }),
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
`MCP tools/list phase budget is ${String(budgetMs)} ms and this release has no runtime override.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("names the first-catalog budget when startup exceeds it", async () => {
|
||||
const budgetMs = 1;
|
||||
await expect(
|
||||
measureMcpStartup({
|
||||
command: process.execPath,
|
||||
args: [probeChild],
|
||||
environment: { ...process.env, REA_MCP_PROBE_MODE: "serve" },
|
||||
policy: probePolicy({ firstCatalogBudgetMs: budgetMs }),
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
`MCP first-catalog phase budget is ${String(budgetMs)} ms and this release has no runtime override.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not add timeout guidance to an unrelated startup failure", async () => {
|
||||
try {
|
||||
await measureMcpStartup({
|
||||
command: process.execPath,
|
||||
args: [probeChild],
|
||||
environment: { ...process.env, REA_MCP_PROBE_MODE: "fail" },
|
||||
policy: probePolicy(),
|
||||
});
|
||||
throw new Error("expected the unrelated probe failure");
|
||||
} catch (cause) {
|
||||
const message = errorMessage(cause);
|
||||
expect(message).toContain("unrelated probe failure");
|
||||
expect(message).not.toContain("expected the unrelated probe failure");
|
||||
expect(message).not.toContain("no runtime override");
|
||||
expect(message).not.toContain("phase budget");
|
||||
}
|
||||
});
|
||||
|
||||
it("names the doctor deadline when module profiling cannot initialize", async () => {
|
||||
const budgetMs = 200;
|
||||
await expect(
|
||||
profileMcpModuleLoading({
|
||||
command: process.execPath,
|
||||
args: [probeChild],
|
||||
environment: { ...process.env, REA_MCP_PROBE_MODE: "hang" },
|
||||
policy: probePolicy({ doctorDeadlineMs: budgetMs }),
|
||||
packageName: "rea-agents",
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
`MCP doctor startup deadline is ${String(budgetMs)} ms and this release has no runtime override.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("names the tools/list budget when module profiling passes initialize", async () => {
|
||||
const budgetMs = 200;
|
||||
await expect(
|
||||
profileMcpModuleLoading({
|
||||
command: process.execPath,
|
||||
args: [probeChild],
|
||||
environment: {
|
||||
...process.env,
|
||||
REA_MCP_PROBE_MODE: "initialize-then-hang",
|
||||
},
|
||||
policy: probePolicy({ toolsListBudgetMs: budgetMs }),
|
||||
packageName: "rea-agents",
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
`MCP tools/list phase budget is ${String(budgetMs)} ms and this release has no runtime override.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not add timeout guidance to an unrelated module-profile failure", async () => {
|
||||
try {
|
||||
await profileMcpModuleLoading({
|
||||
command: process.execPath,
|
||||
args: [probeChild],
|
||||
environment: { ...process.env, REA_MCP_PROBE_MODE: "fail" },
|
||||
policy: probePolicy(),
|
||||
packageName: "rea-agents",
|
||||
});
|
||||
throw new Error("expected the unrelated probe failure");
|
||||
} catch (cause) {
|
||||
const message = errorMessage(cause);
|
||||
expect(message).not.toContain("expected the unrelated probe failure");
|
||||
expect(message).not.toContain("no runtime override");
|
||||
expect(message).not.toContain("phase budget");
|
||||
expect(message).not.toContain("MCP doctor startup deadline");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -592,7 +592,7 @@ describe("json depth bounds over MCP", () => {
|
||||
{
|
||||
path: [],
|
||||
reason: "invalid_value",
|
||||
message: `left.parameters: JSON value exceeds maximum nesting depth of ${MAX_JSON_DEPTH}`,
|
||||
message: `left.parameters: JSON value exceeds maximum nesting depth of ${MAX_JSON_DEPTH}. This release has no runtime override.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server";
|
||||
|
||||
import { parseMcpResponseBudget } from "../../../src/config/mcpResponseBudget.js";
|
||||
import {
|
||||
MINIMUM_RESPONSE_BYTES,
|
||||
nodeStringCeilingRecovery,
|
||||
parseMcpResponseBudget,
|
||||
stdioResponseBudgetRecovery,
|
||||
} from "../../../src/config/mcpResponseBudget.js";
|
||||
|
||||
describe("MCP response budget configuration", () => {
|
||||
it("requires the override to accommodate the pinned transport's default frames", () => {
|
||||
@@ -13,6 +18,37 @@ describe("MCP response budget configuration", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("names the effective stdio budget without claiming it raises the string ceiling", () => {
|
||||
expect(MINIMUM_RESPONSE_BYTES).toBe(10_485_760);
|
||||
const reserved = 536_869_864;
|
||||
const nodeCeiling = 536_870_888;
|
||||
const recovery = stdioResponseBudgetRecovery(
|
||||
20_971_520,
|
||||
reserved,
|
||||
nodeCeiling,
|
||||
);
|
||||
expect(recovery).toContain(
|
||||
"The effective REA_MCP_MAX_RESPONSE_BYTES value is 20971520 bytes",
|
||||
);
|
||||
expect(recovery).toContain("floor 10485760 bytes, 10 MiB");
|
||||
expect(recovery).toContain("restarting the REA process");
|
||||
expect(recovery).toContain(
|
||||
`REA's reserved single-string limit of ${String(reserved)} UTF-16 code units`,
|
||||
);
|
||||
expect(recovery).toContain(
|
||||
`Node's single-string ceiling of ${String(nodeCeiling)} UTF-16 code units`,
|
||||
);
|
||||
expect(recovery).not.toContain(
|
||||
`Node's single-string ceiling of ${String(reserved)}`,
|
||||
);
|
||||
expect(recovery).not.toContain(
|
||||
`Node's single-string ceiling is ${String(reserved)}`,
|
||||
);
|
||||
expect(nodeStringCeilingRecovery(reserved, nodeCeiling)).toBe(
|
||||
`REA's reserved single-string limit is ${String(reserved)} UTF-16 code units, below Node's single-string ceiling of ${String(nodeCeiling)} UTF-16 code units. This release has no runtime override for either limit, and REA_MCP_MAX_RESPONSE_BYTES does not raise them.`,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["", "1.5", "1e8", " 10485760", "9007199254740992"])(
|
||||
"rejects malformed or unusable budgets: %j",
|
||||
(value) => {
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import { createInterface } from "node:readline";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
|
||||
const mode = process.env.REA_MCP_PROBE_MODE ?? "serve";
|
||||
|
||||
if (mode === "fail") {
|
||||
console.error("unrelated probe failure");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const respond = (id, result) => {
|
||||
const response =
|
||||
result === undefined
|
||||
? {
|
||||
error: {
|
||||
code: -32601,
|
||||
message: "unrelated fixture method refusal",
|
||||
},
|
||||
}
|
||||
: { result };
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ jsonrpc: "2.0", id, ...response })}\n`,
|
||||
);
|
||||
};
|
||||
|
||||
const input = createInterface({ input: process.stdin });
|
||||
input.on("line", (line) => {
|
||||
let request;
|
||||
try {
|
||||
request = JSON.parse(line);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (request.id === undefined) return;
|
||||
if (mode === "hang") return;
|
||||
if (mode === "initialize-then-hang" && request.method !== "initialize")
|
||||
return;
|
||||
if (mode === "hang-on-call" && request.method === "tools/call") {
|
||||
const readyPath = process.env.REA_MCP_PROBE_READY_PATH;
|
||||
if (readyPath !== undefined) void writeFile(readyPath, "ready");
|
||||
return;
|
||||
}
|
||||
if (mode === "refuse-call" && request.method === "tools/call") {
|
||||
respond(request.id, undefined);
|
||||
return;
|
||||
}
|
||||
let result;
|
||||
switch (request.method) {
|
||||
case "initialize":
|
||||
result = {
|
||||
protocolVersion: request.params?.protocolVersion ?? "2025-11-25",
|
||||
capabilities: { tools: {}, prompts: {} },
|
||||
serverInfo: { name: "probe-fixture", version: "1" },
|
||||
};
|
||||
break;
|
||||
case "tools/list":
|
||||
result = { tools: [] };
|
||||
break;
|
||||
case "prompts/list":
|
||||
result = { prompts: [] };
|
||||
break;
|
||||
case "ping":
|
||||
result = {};
|
||||
break;
|
||||
default:
|
||||
result = undefined;
|
||||
}
|
||||
respond(request.id, result);
|
||||
});
|
||||
input.on("close", () => {
|
||||
process.exit(0);
|
||||
});
|
||||
Reference in new issue
Block a user