Files
reverse-engineering-anything/src/mcpDoctor.ts
T
2SinceJuly d427fc05c3 fix(mcp-doctor): stop the owned process family on every exit path
runProductionMcpDoctor wrapped the SDK StdioClientTransport and only joined its
close calls. That transport clears its direct-child reference, ends stdin and
signals only that child, so a server whose launcher exited after forking an
inherited-stdio descendant left the descendant running while the doctor
reported nothing but the transport deadline.

Run the doctor on OwnedMcpStdioTransport, which spawns the child in a dedicated
POSIX process group or Windows job and verifies group and token-owned
descendant cleanup before close returns. Feed stderr through a sink on
acquisition so diagnostics survive an early child exit and still honor the
64 KiB retained prefix and full byte count.

Covered by a fixture that forks a descendant, exits, and is timed out: the
regression asserts the descendant is gone after the doctor returns.
2026-11-14 05:36:23 +01:00

698 lines
23 KiB
TypeScript

import { Client, SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
import { Help } from "incur";
import { performance } from "node:perf_hooks";
import { resolve } from "node:path";
import { StringDecoder } from "node:string_decoder";
import { createCatalogIdentity } from "./catalogIdentity.js";
import { jsonLinesErrorRecord } from "./cli/jsonLines.js";
import {
CLI_DOCUMENT_OUTPUT_FORMATS,
missingCliOutputValueMessage,
renderCliDocument,
} from "./cliOutput.js";
import {
compactPresentationForMcpInputSchemaProfile,
parseMcpInputSchemaProfile,
} from "./config/mcpInputSchemaProfile.js";
import { parseMcpOutputSchemaProfile } from "./config/mcpOutputSchemaProfile.js";
import { parseMcpToolFamilies } from "./config/mcpToolFamilies.js";
import { parseMcpToolCatalogProfile } from "./config/mcpToolCatalogProfile.js";
import {
DISCOVER_TOOLS,
HOT_TOOL_NAMES,
progressiveCatalogIdentity,
} from "./catalogDiscovery.js";
import { contractsForToolFamilies } from "./contracts/toolFamilies.js";
import { promptsForToolFamilies } from "./contracts/promptToolFamilyScope.js";
import { err, ok } from "./domain/result.js";
import { PRODUCT_IDENTITY } from "./identity.js";
import {
parseMcpStartupBudgets,
type McpPhaseBudget,
} from "./config/mcpStartupBudgets.js";
import {
MCP_STARTUP_POLICY,
mcpDoctorDeadlineRecovery,
} from "./mcpStartupPolicy.js";
import { MCP_DOCTOR_ALIAS, MCP_DOCTOR_OPTIONS } from "./mcpDoctorOptions.js";
import { OwnedMcpStdioTransport } from "./process/OwnedMcpStdioTransport.js";
const OUTPUT_FORMATS = CLI_DOCUMENT_OUTPUT_FORMATS;
type OutputFormat = (typeof OUTPUT_FORMATS)[number];
const MCP_DOCTOR_COMMAND = "rea mcp doctor";
const MCP_DOCTOR_DESCRIPTION = "Validate MCP adapter startup and tool listing";
const MCP_DOCTOR_SCOPE =
"Checks this REA adapter using the current runtime; does not verify a client's registered command or launch environment.";
/** Help flags derived from `MCP_DOCTOR_OPTIONS` plus its alias. */
const HELP_FLAGS: ReadonlySet<string> = new Set(["--help", "-h"]);
/**
* Answer an advertised help request without starting a diagnostic session.
* Global options are hidden because this dispatcher entry accepts only the
* options above; the hint says so explicitly instead of implying full
* Incur parity.
*/
const renderMcpDoctorHelp = (): string =>
Help.formatCommand(MCP_DOCTOR_COMMAND, {
description: MCP_DOCTOR_DESCRIPTION,
hideGlobalOptions: true,
options: MCP_DOCTOR_OPTIONS,
alias: MCP_DOCTOR_ALIAS,
hint: `${MCP_DOCTOR_SCOPE} This entry accepts only the options above.`,
});
interface McpDoctorOptions {
readonly command: string;
readonly args: readonly string[];
readonly cwd: string;
readonly environment: Readonly<Record<string, string | undefined>>;
}
interface InventoryComparison {
readonly expected: number;
readonly observed: number;
readonly missing: readonly string[];
readonly unexpected: readonly string[];
readonly duplicates: readonly string[];
}
interface McpDoctorCheck {
readonly name: string;
readonly ok: boolean;
readonly detail: string;
}
/** Diagnose the actual production stdio adapter with one startup-bounded SDK session. */
export const runProductionMcpDoctor = async (options: McpDoctorOptions) => {
const parsedBudgets = parseMcpStartupBudgets(options.environment);
if (!parsedBudgets.ok)
return {
healthy: false as const,
adapter: "production-stdio" as const,
server: null,
inventory: null,
identity: null,
request_flow: {
tool: "binary_session" as const,
ok: false as const,
},
checks: parsedBudgets.error.settings.map((issue) => ({
name: issue.setting,
ok: false as const,
detail: issue.constraint,
})),
diagnostics: {
stderr: "",
stderr_bytes: 0,
stderr_truncated: false,
},
};
const expectation = doctorExpectationForEnvironment(options.environment);
const budget = parsedBudgets.value.doctor;
const budgetMs = budget.milliseconds;
const deadline = performance.now() + budgetMs;
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(new Error("Production MCP doctor deadline expired")),
Math.max(1, Math.ceil(deadline - performance.now())),
);
const stderr = captureStderr();
const transport = new DoctorStdioTransport(
{
command: options.command,
arguments: [...options.args],
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
env: targetFreeEnvironment(options.environment),
},
stderr.append,
);
const client = new Client({ name: "rea-mcp-doctor", version: "1" });
let result: ProductionMcpDoctorResult;
let cleanupFailure: { readonly cause: unknown } | undefined;
try {
await client.connect(
transport,
requestOptions(deadline, controller.signal),
);
result = await inspectProductionMcpSession(
client,
deadline,
controller.signal,
expectation,
budget,
);
} catch (cause: unknown) {
result = {
healthy: false,
adapter: "production-stdio" as const,
server: null,
inventory: null,
identity: null,
request_flow: { tool: "binary_session", ok: false },
checks: [
{
name: "transport",
ok: false,
detail: doctorTransportDetail(cause, budget, controller.signal),
},
],
};
} finally {
clearTimeout(timer);
try {
await client.close();
} catch (cause: unknown) {
cleanupFailure = { cause };
}
try {
// An interrupted connect can clear the client's transport reference
// before its startup child is closed. The transport retains that child.
await transport.close();
} catch (cause: unknown) {
cleanupFailure = {
cause:
cleanupFailure === undefined || cleanupFailure.cause === cause
? cause
: new AggregateError(
[cleanupFailure.cause, cause],
`MCP client and transport cleanup failed: ${errorMessage(cleanupFailure.cause)}; ${errorMessage(cause)}`,
),
};
}
}
if (cleanupFailure !== undefined)
result = {
...result,
healthy: false,
checks: [
...result.checks,
{
name: "cleanup",
ok: false,
detail: `MCP stdio cleanup failed: ${errorMessage(cleanupFailure.cause)}`,
},
],
};
// The owned transport bounds what it retains; this reporter keeps the exact
// prefix semantics the report promises, including a withheld partial character.
return { ...result, diagnostics: stderr.value() };
};
type ProductionMcpDoctorResult =
| Awaited<ReturnType<typeof inspectProductionMcpSession>>
| {
readonly healthy: false;
readonly adapter: "production-stdio";
readonly server: null;
readonly inventory: null;
readonly identity: null;
readonly request_flow: {
readonly tool: "binary_session";
readonly ok: false;
};
readonly checks: readonly McpDoctorCheck[];
};
/** The doctor's bounded stdio session, owning the process family it starts. */
class DoctorStdioTransport extends OwnedMcpStdioTransport {
constructor(
options: ConstructorParameters<typeof OwnedMcpStdioTransport>[0],
onStderr: (chunk: unknown) => void,
) {
super(options, {
provider: "mcp-doctor",
label: "MCP doctor",
maxFrameBytes: 8 * 1024 * 1024,
maxOperationBytes: 32 * 1024 * 1024,
// Retain only stderr, with the byte budget the report promises.
maxDiagnosticBytes: {
stdout: 0,
stderr: MCP_STARTUP_POLICY.doctorStderrBytes,
},
onStderr,
});
}
}
/** A valid scope can intentionally omit the target-free session request. */
const MCP_DOCTOR_SESSION_SCOPE_DETAIL =
"binary_session is outside the selected tool families; include the session family for request-flow and identity checks";
/** Doctor expectations derived from the same environment the child inherits. */
interface DoctorCatalogExpectation {
readonly catalog: ReturnType<typeof createCatalogIdentity>;
readonly expectedToolNames: readonly string[];
readonly expectedPromptNames: readonly string[];
}
const listPromptsWhenAdvertised = (
client: Client,
request: ReturnType<typeof requestOptions>,
) =>
client.getServerCapabilities()?.prompts
? client.listPrompts(undefined, request)
: Promise.resolve({ prompts: [] });
const inspectProductionMcpSession = async (
client: Client,
deadline: number,
signal: AbortSignal,
expectation: DoctorCatalogExpectation,
budget: McpPhaseBudget,
) => {
const request = requestOptions(deadline, signal);
// The SDK logs to stdout when listPrompts is called without the advertised
// capability, which corrupts the doctor's structured output. An absent
// capability is an empty prompt inventory for this diagnostic.
const promptsRequest = listPromptsWhenAdvertised(client, request);
const [tools, prompts] = await Promise.all([
client.listTools(undefined, request),
promptsRequest,
]);
const sessionTool = tools.tools.find(({ name }) => name === "binary_session");
// A scope without the session family cannot serve request flow at all;
// every other case still probes the call so the report names the real cause.
const requestFlow = expectation.expectedToolNames.includes("binary_session")
? await client
.callTool(
{ name: "binary_session", arguments: {} },
{
...requestOptions(deadline, signal),
...(sessionTool === undefined
? {}
: { toolDefinition: sessionTool }),
},
)
.then(ok, (cause: unknown) =>
err({
kind: "request-flow" as const,
detail: doctorTransportDetail(cause, budget, signal),
}),
)
: err({
kind: "request-flow" as const,
detail: MCP_DOCTOR_SESSION_SCOPE_DETAIL,
});
const inventory = {
tools: compareInventory(
expectation.expectedToolNames,
tools.tools.map(({ name }) => name),
),
prompts: compareInventory(
expectation.expectedPromptNames,
prompts.prompts.map(({ name }) => name),
),
};
const observedIdentity = parseIdentityToolResult(
requestFlow.ok ? requestFlow.value.structuredContent : undefined,
);
const { catalog } = expectation;
const serverVersion = client.getServerVersion();
const protocolVersion = client.getNegotiatedProtocolVersion();
const checks: McpDoctorCheck[] = [
{
name: "initialize",
ok:
serverVersion?.name === PRODUCT_IDENTITY.mcpServerKey &&
serverVersion.version === PRODUCT_IDENTITY.packageVersion &&
protocolVersion !== undefined,
detail: `${serverVersion?.name ?? "unknown"}@${serverVersion?.version ?? "unknown"}; protocol=${protocolVersion ?? "unknown"}`,
},
...Object.entries(inventory).map(([name, comparison]) =>
inventoryCheck(name, comparison),
),
{
name: "server-identity",
ok:
observedIdentity.packageVersion === PRODUCT_IDENTITY.packageVersion &&
observedIdentity.catalogDigest === catalog.digests.combined_sha256,
detail: `package=${observedIdentity.packageVersion ?? "missing"}; catalog=${observedIdentity.catalogDigest ?? "missing"}`,
},
{
name: "request-flow",
ok: requestFlow.ok && requestFlow.value.isError !== true,
detail: requestFlow.ok
? "binary_session target-free request"
: requestFlow.error.detail,
},
];
return {
healthy: checks.every(({ ok }) => ok),
adapter: "production-stdio" as const,
server: {
name: serverVersion?.name ?? null,
version: serverVersion?.version ?? null,
protocol_version: protocolVersion ?? null,
},
inventory,
identity: {
expected_package_version: PRODUCT_IDENTITY.packageVersion,
observed_package_version: observedIdentity.packageVersion,
expected_catalog_digest: catalog.digests.combined_sha256,
observed_catalog_digest: observedIdentity.catalogDigest,
},
request_flow: {
tool: "binary_session",
ok: requestFlow.ok && requestFlow.value.isError !== true,
},
checks,
};
};
/**
* Dispatcher entry for `rea mcp doctor`, kept separate from Incur MCP setup.
* @public
*/
export const runProductionMcpDoctorCli = async (
arguments_: readonly string[],
input: {
readonly dispatcherPath: string;
readonly packageRoot: string;
readonly stdoutIsTerminal: boolean;
},
): Promise<{ readonly output: string; readonly exitCode: number }> => {
const started = performance.now();
const parsed = parseOutputArguments(arguments_);
if (!parsed.ok) {
if (parsed.help)
return { output: `${renderMcpDoctorHelp()}\n`, exitCode: 0 };
return {
output: renderCliDocument(
parsed.format === "jsonl"
? jsonLinesErrorRecord({ code: parsed.code, message: parsed.message })
: { code: parsed.code, message: parsed.message },
parsed.format,
input.stdoutIsTerminal,
),
exitCode: 1,
};
}
const report = await runProductionMcpDoctor({
command: process.execPath,
args: [resolve(input.dispatcherPath), "mcp"],
cwd: input.packageRoot,
environment: process.env,
});
const result = { ...report, scope: MCP_DOCTOR_SCOPE };
const outputValue = parsed.fullOutput
? {
ok: result.healthy,
...(result.healthy ? { data: result } : { error: result }),
meta: {
command: MCP_DOCTOR_COMMAND,
duration: `${Math.round(performance.now() - started)}ms`,
},
}
: result;
return {
output: renderCliDocument(
parsed.format === "jsonl" && !result.healthy
? jsonLinesErrorRecord(outputValue)
: outputValue,
parsed.format,
input.stdoutIsTerminal,
),
exitCode: result.healthy ? 0 : 1,
};
};
/** Compare exact names while retaining actionable mismatch locations. */
const compareInventory = (
expectedNames: readonly string[],
observedNames: readonly string[],
): InventoryComparison => {
const expected = new Set(expectedNames);
const observed = new Set(observedNames);
return {
expected: expectedNames.length,
observed: observedNames.length,
missing: [...expected].filter((name) => !observed.has(name)).sort(),
unexpected: [...observed].filter((name) => !expected.has(name)).sort(),
duplicates: [...observed]
.filter(
(name) =>
observedNames.filter((candidate) => candidate === name).length > 1,
)
.sort(),
};
};
const inventoryCheck = (
name: string,
comparison: InventoryComparison,
): McpDoctorCheck => ({
name: `inventory:${name}`,
ok:
comparison.expected === comparison.observed &&
comparison.missing.length === 0 &&
comparison.unexpected.length === 0 &&
comparison.duplicates.length === 0,
detail: `expected=${String(comparison.expected)} observed=${String(comparison.observed)} missing=${comparison.missing.join(",") || "none"} unexpected=${comparison.unexpected.join(",") || "none"} duplicates=${comparison.duplicates.join(",") || "none"}`,
});
const parseIdentityToolResult = (
input: unknown,
): {
readonly packageVersion: string | null;
readonly catalogDigest: string | null;
} => {
if (!isRecord(input) || !isRecord(input.result))
return { packageVersion: null, catalogDigest: null };
const identity = input.result.server_identity;
if (!isRecord(identity)) return { packageVersion: null, catalogDigest: null };
const packageIdentity = identity.package;
const catalog = identity.catalog;
const digests = isRecord(catalog) ? catalog.digests : undefined;
return {
packageVersion:
isRecord(packageIdentity) && typeof packageIdentity.version === "string"
? packageIdentity.version
: null,
catalogDigest:
isRecord(digests) && typeof digests.combined_sha256 === "string"
? digests.combined_sha256
: null,
};
};
/**
* Parsed `rea mcp doctor` arguments, including a help request. The `help`
* variant is the only success-independent early return; every other accepted
* flag is an output selector declared in `MCP_DOCTOR_OPTIONS`.
*/
type McpDoctorArguments =
| {
readonly ok: true;
readonly format: OutputFormat;
readonly fullOutput: boolean;
}
| { readonly ok: false; readonly help: true }
| {
readonly ok: false;
readonly help: false;
readonly format: OutputFormat;
readonly code: "MISSING_OUTPUT_VALUE" | "VALIDATION_ERROR";
readonly message: string;
};
const parseOutputArguments = (
arguments_: readonly string[],
): McpDoctorArguments => {
// Help wins over output selection and validation errors, but only when it
// appears in option position. A token consumed as `--format`'s value is
// not a flag, so `--format --help` reports an invalid format instead of
// masking the typo with usage.
if (hasHelpInOptionPosition(arguments_)) return { ok: false, help: true };
let format: OutputFormat = "toon";
let fullOutput = false;
for (let index = 0; index < arguments_.length; index += 1) {
const argument = arguments_[index];
if (argument === "--json") {
format = "json";
continue;
}
const inline = argument?.startsWith("--format=")
? argument.slice("--format=".length)
: undefined;
if (argument === "--format" || inline !== undefined) {
const candidate = inline ?? arguments_[index + 1];
if (inline === undefined) index += 1;
if (candidate === undefined || candidate === "")
return {
ok: false,
help: false,
format,
code: "MISSING_OUTPUT_VALUE",
message: missingCliOutputValueMessage("--format"),
};
if (!isOutputFormat(candidate))
return {
ok: false,
help: false,
format,
code: "VALIDATION_ERROR",
message: `Invalid output format: ${candidate}`,
};
format = candidate;
continue;
}
if (argument === "--full-output") {
fullOutput = true;
continue;
}
if (HELP_FLAGS.has(argument ?? "")) return { ok: false, help: true };
return {
ok: false,
help: false,
format,
code: "VALIDATION_ERROR",
message: `Unknown mcp doctor option: ${argument ?? "missing"}`,
};
}
return { ok: true, format, fullOutput };
};
/**
* Report whether `--help`/`-h` appears in option position. Tokens consumed
* as `--format`'s value are skipped so `--format --help` stays an invalid
* format error instead of silently becoming a help request.
*/
const hasHelpInOptionPosition = (arguments_: readonly string[]): boolean => {
for (let index = 0; index < arguments_.length; index += 1) {
const argument = arguments_[index];
if (argument === "--format") {
index += 1;
continue;
}
if (HELP_FLAGS.has(argument ?? "")) return true;
}
return false;
};
const isOutputFormat = (value: string | undefined): value is OutputFormat =>
OUTPUT_FORMATS.some((candidate) => candidate === value);
const requestOptions = (deadline: number, signal: AbortSignal) => ({
signal,
timeout: Math.max(1, Math.ceil(deadline - performance.now())),
});
const targetFreeEnvironment = (
environment: Readonly<Record<string, string | undefined>>,
): Record<string, string> => {
const result = Object.fromEntries(
Object.entries(environment).filter(
(entry): entry is [string, string] => entry[1] !== undefined,
),
);
for (const name of [
"HOPPER_TARGET_PATH",
"HOPPER_TARGET_KIND",
"HOPPER_LOADER_ARGS_JSON",
])
delete result[name];
return result;
};
const doctorExpectationForEnvironment = (
environment: Readonly<Record<string, string | undefined>>,
): DoctorCatalogExpectation => {
const inputProfile = parseMcpInputSchemaProfile(
environment.REA_MCP_INPUT_SCHEMA_PROFILE,
);
const outputProfile = parseMcpOutputSchemaProfile(
environment.REA_MCP_OUTPUT_SCHEMA_PROFILE,
);
// An invalid selection fails child startup, which the transport check
// reports with the server's diagnostics; expect the complete catalog so a
// failed start is never mistaken for a scope mismatch.
const families = parseMcpToolFamilies(environment.REA_MCP_TOOL_FAMILIES);
const selection =
families.ok && families.value !== undefined
? new Set(families.value)
: undefined;
const contracts = contractsForToolFamilies(selection);
const promptContracts = promptsForToolFamilies(selection);
const catalogProfile = parseMcpToolCatalogProfile(
environment.REA_MCP_TOOL_CATALOG_PROFILE,
);
const progressive = catalogProfile.ok && catalogProfile.value === "hot-cold";
const catalog = createCatalogIdentity({
compactPresentation: compactPresentationForMcpInputSchemaProfile(
inputProfile.ok ? inputProfile.value : undefined,
),
contracts,
outputSchemaProfile: outputProfile.ok
? (outputProfile.value ?? "full")
: "full",
promptContracts,
});
return {
catalog: progressive ? progressiveCatalogIdentity(catalog) : catalog,
expectedToolNames: progressive
? [
...contracts
.map(({ name }) => name)
.filter((name) => HOT_TOOL_NAMES.has(name)),
DISCOVER_TOOLS.name,
]
: contracts.map(({ name }) => name),
expectedPromptNames: promptContracts.map(({ name }) => name),
};
};
const captureStderr = () => {
const retained = Buffer.allocUnsafe(MCP_STARTUP_POLICY.doctorStderrBytes);
let retainedBytes = 0;
let bytes = 0;
let truncated = false;
return {
append(chunk: unknown) {
const buffer = Buffer.isBuffer(chunk)
? chunk
: Buffer.from(String(chunk), "utf8");
bytes += buffer.byteLength;
const remaining = retained.byteLength - retainedBytes;
const copied = Math.min(remaining, buffer.byteLength);
if (copied > 0) {
buffer.copy(retained, retainedBytes, 0, copied);
retainedBytes += copied;
}
if (copied < buffer.byteLength) truncated = true;
},
value: () => ({
stderr: truncated
? new StringDecoder("utf8").write(retained.subarray(0, retainedBytes))
: retained.toString("utf8", 0, retainedBytes),
stderr_bytes: bytes,
stderr_truncated: truncated,
}),
};
};
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const errorMessage = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause);
/**
* Name the applied doctor deadline when an owned abort or SDK request timeout
* fires. Remote failure text is not a deadline. Codex's 30 second startup
* figure stays client guidance and is not this deadline.
*/
const doctorTransportDetail = (
cause: unknown,
budget: McpPhaseBudget,
signal: AbortSignal,
): string => {
const message = errorMessage(cause);
const timedOut =
(signal.aborted && cause === signal.reason) ||
(cause instanceof SdkError && cause.code === SdkErrorCode.RequestTimeout);
return timedOut ? `${message} ${mcpDoctorDeadlineRecovery(budget)}` : message;
};