diff --git a/Dockerfile b/Dockerfile index bc95d696a..6ba6ef527 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,12 @@ FROM node:22-bookworm-slim@sha256:6c74791e557ce11fc957704f6d4fe134a7bc8d6f5ca4403205b2966bd488f6b3 AS package +RUN apt-get update \ + && apt-get install --no-install-recommends --yes \ + git \ + python3 \ + && rm -rf /var/lib/apt/lists/* + WORKDIR /build/sdk/typescript COPY sdk/typescript/package.json sdk/typescript/pnpm-lock.yaml ./ diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 05856eff3..70e747fa5 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -909,8 +909,8 @@ command manifest, `scan --schema --format json` for a command schema, and `skills add` syncs agent skills; `mcp add` registers the CLI as an MCP server. Start the server with `codex-security --mcp` (or `npx --yes @openai/codex-security --mcp`). It uses stdin/stdout and exposes -`info` for read-only metadata and `scan` for security scans. For example, an -MCP client can launch it with: +`info` for read-only metadata, `scan` for security scans, and the application +command tools listed below. For example, an MCP client can launch it with: ```json { @@ -933,8 +933,9 @@ working directory. First check local inputs without starting a model: ``` Then call `scan` with `dryRun` omitted or false to run the scan. Standard, -Deep, path, and Git diff scans are supported. MCP does not support `patch`, -`patchSeverity`, or `createPr`; patching and other commands remain CLI-only. +Deep, path, and Git diff scans are supported. The `scan` tool does not accept +`patch`, `patchSeverity`, or `createPr`; use the separate `patch` tool after +scanning. Scans run noninteractively with the same local credentials and `auth` selection as the CLI. Sign in with `codex-security login` before starting the @@ -956,6 +957,84 @@ partial artifacts remain available at the output directory. Canceled MCP requests do not receive a result. This server is separate from the bundled security plugin's MCP server used internally during scans. +The remaining application commands are also available as MCP tools: + +| CLI command | MCP tool | Behavior | +| ---------------------------------------- | ---------------------------------------- | --------------------------------------------------------------------------- | +| `scan-components` | `scan-components` | Plan or scan explicit project components. | +| `bulk-scan` | `bulk-scan` | Run a resumable batch from an explicit repository CSV. | +| `scans list`, `scans show`, `scans logs` | `scans_list`, `scans_show`, `scans_logs` | Read saved scans and activity. | +| `scans rerun` | `scans_rerun` | Run a saved scan again. | +| `scans match`, `scans compare` | `scans_match`, `scans_compare` | Match findings and compare scans; may call models and persist matches. | +| `findings list` | `findings_list` | Read saved findings. | +| `findings false-positive` | `findings_false-positive` | Update finding triage and future scan feedback. | +| `import github` | `import_github` | Read GitHub code scanning alerts. | +| `export` | `export` | Export CSV, JSON, or SARIF to a file or tool output. | +| `validate`, `verify-fix` | `validate`, `verify-fix` | Validate findings or verify existing fixes. | +| `patch` | `patch` | Patch findings; optionally assess risk or create a draft PR. | +| `publish check`, `publish scan` | `publish_check`, `publish_scan` | Check publication inputs or publish findings to the documented destination. | +| `install-hook` | `install-hook` | Install the pre-commit scan hook. | +| `login status`, `logout` | `login`, `logout` | Inspect or remove the server's stored sign-in. | + +These tools use `args` for positional arguments and `options` for named CLI +options, with camelCase names and unchanged CLI defaults. Variadic positional +arguments become arrays without the trailing `...`. For example: + +```json +{ "args": { "scanId": "scan_example_001" } } +``` + +calls `scans_show`, and: + +```json +{ + "args": { "scanDir": "/path/to/completed-scan" }, + "options": { "exportFormat": "csv", "output": "-" } +} +``` + +calls `export`. A `validate` call uses +`{ "args": { "findings": ["/path/to/finding.md"] } }`. Keeping `args` and +`options` separate preserves commands such as `publish scan`, whose positional +`scanDir` and repeated `--scan-dir` option have different meanings. + +Set the optional outer `workingDirectory` field when the command should run in +a different repository. It defaults to the server's working directory; +relative values resolve from that directory. For example, call `validate` with +`{ "workingDirectory": "/path/to/repository", "args": { "findings": ["/path/to/finding.md"] } }`. +This sets only that command process's directory and is not a new CLI flag. +Relative paths in `CODEX_SECURITY_STATE_DIR`, `CODEX_HOME`, `CODEX_CLI_PATH`, +and `PYTHON` resolve from the server's directory, keeping its state, credentials, +and configured executables shared across command working directories. +A bare `PYTHON` name such as `python3` still uses `PATH`. + +The new tools return `{ "exitCode": 0, "data": ... }` for structured CLI +results, including arrays. Commands that produce plain text or exported +content return `output` instead of `data`; successful commands with no stdout +return just their exit code. CLI diagnostics are included as `diagnostics` +and also sent to stderr. Nonzero exits set `isError`, include an `error`, and +retain any result or output. No output is silently truncated. + +Each command runs as a separate CLI process with the selected working directory +and the server's environment, credentials, and filesystem access. Request cancellation or a +disconnect requests termination of that command's process tree and waits for the +CLI's own subprocess cleanup to finish. On Windows, termination uses the existing +process-tree termination behavior. Completed artifacts are retained, but cancellation +does not roll back file changes, credential changes, or external publication. + +Supply explicit inputs instead of relying on terminal pickers: a repository CSV +for `bulk-scan`, scan identifiers/directories for publication, and component +selection for `scan-components`. As with the CLI, positional inputs beginning +with `-` cannot be passed as literal arguments; use a file for such finding text, +or prefix a relative file path with `./`. A later positional argument cannot be +supplied while an earlier one is omitted. + +For login status, call `login` with `{ "args": { "action": "status" } }`. +Browser/device sign-in and credential entry must be completed with the CLI +before using the server; MCP transport input is never used as credential stdin. +Shell completions and the `mcp`/`skills` installation helpers remain CLI-only. +They configure the local client rather than execute security workflows. + ## Containerized bulk scans Create `repositories.csv` as described under [Bulk scans](#bulk-scans). diff --git a/sdk/typescript/scripts/check-package.mjs b/sdk/typescript/scripts/check-package.mjs index 531f8146d..ea93c6062 100644 --- a/sdk/typescript/scripts/check-package.mjs +++ b/sdk/typescript/scripts/check-package.mjs @@ -165,6 +165,7 @@ const distFiles = new Set( "auth", "bulk-scan-discovery", "cli", + "cli-mcp-commands", "cloud-publish", "codex-prompt", "component-plan", diff --git a/sdk/typescript/scripts/smoke-package.mjs b/sdk/typescript/scripts/smoke-package.mjs index a497ab28f..0b1a5eec8 100644 --- a/sdk/typescript/scripts/smoke-package.mjs +++ b/sdk/typescript/scripts/smoke-package.mjs @@ -160,18 +160,35 @@ async function pluginFiles(directory) { return files.sort(); } -async function smokeCliMcp(launcher, consumer) { +async function smokeCliMcp(launcher, consumer, completedScan) { const repository = join(consumer, "mcp-repository"); await mkdir(repository); await writeFile( join(repository, "example.js"), "export const example = 1;\n", ); + run("git", ["-c", "init.templateDir=", "init", "--quiet", repository], { + cwd: consumer, + }); + run( + "git", + ["config", "--local", "core.hooksPath", join(repository, ".git", "hooks")], + { + cwd: repository, + }, + ); + const manifest = JSON.parse( + run(process.execPath, [launcher, "--llms", "--format", "json"], { + cwd: consumer, + capture: true, + }), + ); const child = spawn(process.execPath, [launcher, "--mcp"], { cwd: consumer, env: { ...process.env, CODEX_SECURITY_STATE_DIR: join(consumer, "mcp-state"), + CODEX_SECURITY_LINEAR_PROJECT: "", }, stdio: "pipe", timeout: PACKAGE_SMOKE_TIMEOUT_MS, @@ -203,10 +220,12 @@ async function smokeCliMcp(launcher, consumer) { }); send({ jsonrpc: "2.0", method: "notifications/initialized" }); const tools = await request(2, "tools/list", {}); - assert.deepEqual(tools.tools.map((tool) => tool.name).sort(), [ - "info", - "scan", - ]); + assert.deepEqual( + tools.tools.map((tool) => tool.name).sort(), + manifest.commands + .map((command) => command.name.replaceAll(" ", "_")) + .sort(), + ); const info = await request(3, "tools/call", { name: "info", arguments: {}, @@ -219,6 +238,58 @@ async function smokeCliMcp(launcher, consumer) { assert.notEqual(scan.isError, true, JSON.stringify(scan)); assert.equal(scan.structuredContent.exitCode, 0); assert.equal(scan.structuredContent.data.dryRun, true); + const call = async (id, name, args) => { + const result = await request(id, "tools/call", { name, arguments: args }); + assert.notEqual(result.isError, true, JSON.stringify(result)); + assert.equal( + result.structuredContent.exitCode, + 0, + JSON.stringify(result), + ); + return result.structuredContent; + }; + const history = await call(5, "scans_list", { args: { repository } }); + assert.deepEqual(history.data.scans, []); + const findings = await call(6, "findings_list", { args: { repository } }); + assert.deepEqual(findings.data.findings, []); + const exported = await call(7, "export", { + args: { scanDir: completedScan }, + options: { exportFormat: "csv", output: "-" }, + }); + assert.equal( + exported.output, + run( + process.execPath, + [ + launcher, + "export", + completedScan, + "--export-format", + "csv", + "--output", + "-", + ], + { + cwd: consumer, + capture: true, + }, + ), + ); + const publication = await call(8, "publish_scan", { + args: { scanDir: completedScan }, + options: { to: "linear", linearTeam: "team-example", dryRun: true }, + }); + assert.equal(publication.data.dryRun, true); + assert.equal(publication.data.scanId, "scan_example_001"); + assert.equal(publication.data.counts.created, 0); + const installedHook = await call(9, "install-hook", { + args: { repository }, + }); + assert.equal(installedHook.data.failOnSeverity, "high"); + assert.match( + await readFile(installedHook.data.hook, "utf8"), + /scan \. --working-tree/u, + ); child.stdin.end(); assert.equal((await closed)[0], 0, stderr); } finally { @@ -542,8 +613,6 @@ try { assert.match(help, /Usage: codex-security\b/u); assert.match(help, /\bpublish\b/u); - await smokeCliMcp(launcher, consumer); - const publicationScan = join(consumer, "publication-scan"); await cp( join(installedRoot, "_bundled_plugin", "examples", "completed-scan"), @@ -551,6 +620,7 @@ try { { recursive: true }, ); if (process.platform !== "win32") await chmod(publicationScan, 0o700); + await smokeCliMcp(launcher, consumer, publicationScan); const publication = JSON.parse( run( process.execPath, @@ -685,7 +755,7 @@ try { await smokeNestedDeepScanWorker(installedRoot, consumer); console.log( - `Validated installed ${packageManifest.name}@${packageManifest.version}: public import, NodeNext types, CLI, credential locking, ${expectedPluginFiles.length} bundled plugin files, MCP initialization, bundled Codex version, and a nested worker without global codex.`, + `Validated installed ${packageManifest.name}@${packageManifest.version}: public import, NodeNext types, CLI, credential locking, ${expectedPluginFiles.length} bundled plugin files, MCP scan/history/export/publication/hook calls, bundled Codex version, and a nested worker without global codex.`, ); } finally { await rm(consumer, { diff --git a/sdk/typescript/src/cli-mcp-commands.ts b/sdk/typescript/src/cli-mcp-commands.ts new file mode 100644 index 000000000..40221144b --- /dev/null +++ b/sdk/typescript/src/cli-mcp-commands.ts @@ -0,0 +1,357 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { win32 } from "node:path"; + +export interface CliMcpSchema { + [key: string]: unknown; + type?: string; + properties?: Record; + required?: string[]; + default?: unknown; +} + +export interface CliMcpManifest { + commands: { + name: string; + description?: string; + schema?: { + args?: CliMcpSchema; + options?: CliMcpSchema; + output?: CliMcpSchema; + }; + }[]; +} + +export interface CliMcpCommand { + name: string; + path: string[]; + description: string; + inputSchema: CliMcpSchema; + jsonOutput: boolean; +} + +export interface CliMcpInput { + workingDirectory?: string; + args?: Record; + options?: Record; +} + +export interface CliMcpResult { + exitCode: number; + data?: unknown; + output?: string; + error?: string; + diagnostics?: string; +} + +export interface CliMcpOutputOptions { + jsonOutput?: boolean; +} + +export interface CliMcpRunOptions extends CliMcpOutputOptions { + executable: string; + entrypoint: string; + cwd: string; + environment: NodeJS.ProcessEnv; + signal?: AbortSignal; + onStderr?: (chunk: string) => void; +} + +/** Adapt the public CLI manifest without maintaining a second command schema. */ +export function buildCliMcpCommands(manifest: CliMcpManifest): CliMcpCommand[] { + return manifest.commands + .filter(({ name }) => !["scan", "info"].includes(name)) + .map(({ name, description, schema }) => { + const properties: Record = { + workingDirectory: { + type: "string", + description: + "Working directory for this invocation (default: server working directory).", + }, + }; + const required: string[] = []; + for (const kind of ["args", "options"] as const) { + if (name === "login" && kind === "options") continue; + const fields = schema?.[kind]; + if (fields === undefined) continue; + properties[kind] = adaptFields(fields, kind === "args"); + if (name === "login" && kind === "args") { + properties[kind].required = ["action"]; + } + if (name === "publish scan" && kind === "options") { + // Only the documented destination and its options belong in MCP. + const options = properties[kind]; + const destination = options.properties?.["to"]; + if (destination !== undefined) destination["enum"] = ["linear"]; + delete options.properties?.["csv"]; + if (options.required !== undefined) { + options.required = options.required.filter( + (field) => field !== "csv", + ); + if (options.required.length === 0) delete options.required; + } + } + if ((properties[kind].required?.length ?? 0) > 0) required.push(kind); + } + return { + name: name.replaceAll(" ", "_"), + path: name.split(" "), + description: + name === "login" + ? "Report login status. Complete sign-in locally with the CLI before using authenticated tools." + : description ?? name, + inputSchema: { + type: "object", + properties, + ...(required.length > 0 ? { required } : {}), + additionalProperties: false, + }, + // Patch chooses between structured saved-finding output and direct + // workflow text. Its caller supplies that decision for each invocation. + jsonOutput: schema?.output !== undefined && name !== "patch", + }; + }); +} + +function adaptFields(schema: CliMcpSchema, positional: boolean): CliMcpSchema { + const properties: Record = {}; + const required: string[] = []; + for (const [name, field] of Object.entries(schema.properties ?? {})) { + const variadic = positional && name.endsWith("..."); + const inputName = variadic ? name.slice(0, -3) : name; + const isRequired = + schema.required?.includes(name) === true && !("default" in field); + const inputField = structuredClone(field); + if (positional) { + inputField["description"] = [ + field["description"], + "Positional values cannot start with '-'; prefix paths with './', or put finding text in a file.", + ] + .filter(Boolean) + .join(" "); + } + properties[inputName] = variadic + ? { + type: "array", + items: inputField, + description: inputField["description"], + ...(isRequired ? { minItems: 1 } : {}), + } + : inputField; + if (isRequired) required.push(inputName); + } + const result = { ...schema, properties }; + if (required.length > 0) result.required = required; + else delete result.required; + return result; +} + +export function buildCliMcpArguments( + command: CliMcpCommand, + input: CliMcpInput, + options: CliMcpOutputOptions = {}, +): string[] { + const argv = [...command.path]; + if (options.jsonOutput ?? command.jsonOutput) argv.push("--json"); + for (const name of Object.keys( + command.inputSchema.properties?.["options"]?.properties ?? {}, + )) { + const value = input.options?.[name]; + if (value === undefined) continue; + const flag = name.replace( + /[A-Z]/gu, + (letter) => `-${letter.toLowerCase()}`, + ); + if (typeof value === "boolean") { + argv.push(value ? `--${flag}` : `--no-${flag}`); + } else { + for (const item of Array.isArray(value) ? value : [value]) { + argv.push(`--${flag}=${String(item)}`); + } + } + } + let omittedArgument: string | undefined; + for (const name of Object.keys( + command.inputSchema.properties?.["args"]?.properties ?? {}, + )) { + const value = input.args?.[name]; + const values = + value === undefined + ? [] + : (Array.isArray(value) ? value : [value]).map(String); + if (values.length === 0) { + omittedArgument ??= name; + continue; + } + if (omittedArgument !== undefined) { + throw new Error( + `Positional argument '${name}' requires the preceding '${omittedArgument}' argument.`, + ); + } + if (values.some((item) => item.startsWith("-"))) { + throw new Error( + `Positional argument '${name}' cannot start with '-'; prefix paths with './', or put finding text in a file.`, + ); + } + argv.push(...values); + } + return argv; +} + +export function parseCliMcpResult( + exitCode: number, + stdout: string, + stderr: string, + options: CliMcpOutputOptions = {}, +): CliMcpResult { + const result: CliMcpResult = { exitCode }; + if (options.jsonOutput) { + try { + result.data = JSON.parse(stdout) as unknown; + } catch { + if (stdout.length > 0) result.output = stdout; + if (exitCode === 0) { + result.exitCode = 2; + result.error = "Command returned invalid JSON."; + } + } + } else if (stdout.length > 0) { + result.output = stdout; + } + if (exitCode !== 0) { + result.error = + stderr.trim() || + stdout.trim() || + `Command exited with status ${exitCode}.`; + } else if (stderr.length > 0) { + result.diagnostics = stderr; + } + return result; +} + +/** Run a CLI invocation with its own streams and wait for process cleanup. */ +export async function runCliMcpCommand( + command: CliMcpCommand, + input: CliMcpInput, + options: CliMcpRunOptions, +): Promise { + if (options.signal?.aborted) { + return { exitCode: 130, error: "Command cancelled." }; + } + const jsonOutput = options.jsonOutput ?? command.jsonOutput; + let argv: string[]; + try { + argv = buildCliMcpArguments(command, input, { jsonOutput }); + } catch (error) { + return { exitCode: 2, error: (error as Error).message }; + } + return new Promise((resolve) => { + const child = spawn(options.executable, [options.entrypoint, ...argv], { + cwd: options.cwd, + env: options.environment, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + detached: process.platform !== "win32" && options.signal !== undefined, + }); + let stdout = ""; + let stderr = ""; + let startError: Error | undefined; + let cancelled = false; + let termination: Promise | undefined; + const abort = (): void => { + if (cancelled) return; + cancelled = true; + termination = terminateProcess(child, options.signal); + // The CLI owns its subprocess cleanup, including detached workers and + // their termination grace periods. Wait for that cleanup before closing. + }; + options.signal?.addEventListener("abort", abort, { once: true }); + if (options.signal?.aborted) abort(); + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + try { + options.onStderr?.(chunk); + } catch { + // Progress observers must not interrupt the command. + } + }); + child.once("error", (error) => { + startError = error; + }); + child.once("close", (code, signal) => { + void (termination ?? Promise.resolve()).then(() => { + if (cancelled && process.platform !== "win32") { + terminateProcessGroup(child, "SIGKILL"); + } + options.signal?.removeEventListener("abort", abort); + const result = parseCliMcpResult( + cancelled ? 130 : signal !== null ? 1 : code ?? 2, + stdout, + stderr, + { jsonOutput }, + ); + if (startError !== undefined) { + result.exitCode = 2; + result.error = startError.message; + } else if (cancelled) result.error = "Command cancelled."; + resolve(result); + }); + }); + }); +} + +function terminateProcess( + child: ChildProcess, + signal?: AbortSignal, +): Promise { + if (process.platform !== "win32" || child.pid === undefined) { + terminateProcessGroup( + child, + signal?.reason === "SIGINT" ? "SIGINT" : "SIGTERM", + ); + return Promise.resolve(); + } + return new Promise((resolve) => { + const taskkill = spawn( + win32.join( + process.env["SystemRoot"] ?? "C:\\Windows", + "System32", + "taskkill.exe", + ), + ["/PID", String(child.pid), "/T", "/F"], + { stdio: "ignore", windowsHide: true }, + ); + taskkill.once("error", () => { + terminateProcessGroup(child, "SIGKILL"); + resolve(); + }); + taskkill.once("close", (code) => { + if (code !== 0) terminateProcessGroup(child, "SIGKILL"); + resolve(); + }); + }); +} + +function terminateProcessGroup( + child: ChildProcess, + signal: NodeJS.Signals, +): void { + if (child.pid === undefined) return; + if (process.platform !== "win32") { + try { + process.kill(-child.pid, signal); + return; + } catch { + // The group may have exited before the cancellation request arrived. + } + } + try { + child.kill(signal); + } catch { + // The direct child may have exited as well. + } +} diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 03f7fd4b0..1d98ec5a0 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -47,6 +47,11 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify, stripVTControlCharacters } from "node:util"; import { Cli, z } from "incur"; import type { Transport } from "@modelcontextprotocol/server"; +import type { + CliMcpInput, + CliMcpManifest, + runCliMcpCommand, +} from "./cli-mcp-commands.js"; import { parse as parseToml } from "smol-toml"; import { classifyConnectionFailure, @@ -1107,6 +1112,7 @@ interface PatchRiskAssessment extends PatchRiskReport { interface CliDependencies { mcpInput?: Readable; + runMcpCommand?: typeof runCliMcpCommand; createSecurity( config: CodexSecurityConfig, ): Pick; @@ -1551,6 +1557,37 @@ export function exportEnvironment( ); } +function mcpCommandMetadata(command: string) { + const readOnly = [ + "scans list", + "scans show", + "scans logs", + "findings list", + "import github", + "publish check", + "login", + ].includes(command); + const local = [ + "scans list", + "scans show", + "scans logs", + "findings list", + "findings false-positive", + "install-hook", + "export", + "login", + "logout", + ].includes(command); + return { + annotations: { + readOnlyHint: readOnly, + destructiveHint: !readOnly, + idempotentHint: readOnly, + openWorldHint: !local, + }, + }; +} + export async function main( argv: readonly string[] = process.argv.slice(2), output: Writable = process.stdout, @@ -1699,7 +1736,7 @@ export async function main( }).command("false-positive", { description: "Mark a finding as a false positive for future scans.", destructive: true, - mcp: false, + mcp: mcpCommandMetadata("findings false-positive"), args: z.object({ occurrenceId: z .string() @@ -1733,7 +1770,7 @@ export async function main( }); findingFeedback.command("list", { description: "List open findings for a repository across its scans.", - mcp: false, + mcp: mcpCommandMetadata("findings list"), args: z.object({ repository: z .string() @@ -1775,7 +1812,7 @@ export async function main( }) .command("list", { description: "List saved scans for a repository or scan root.", - mcp: false, + mcp: mcpCommandMetadata("scans list"), args: z.object({ repository: z .string() @@ -1820,7 +1857,7 @@ export async function main( }) .command("show", { description: "Show the results and saved configuration for a scan.", - mcp: false, + mcp: mcpCommandMetadata("scans show"), args: z.object({ scanId: z .string() @@ -1855,7 +1892,7 @@ export async function main( }) .command("logs", { description: "Show saved activity for a scan and its workers.", - mcp: false, + mcp: mcpCommandMetadata("scans logs"), args: z.object({ scanId: z .string() @@ -1905,7 +1942,7 @@ export async function main( .command("rerun", { description: "Rerun a saved scan with its original configuration.", destructive: true, - mcp: false, + mcp: mcpCommandMetadata("scans rerun"), args: z.object({ scanId: z .string() @@ -1966,7 +2003,7 @@ export async function main( .command("match", { description: "Match findings by root cause across saved scans.", destructive: true, - mcp: false, + mcp: mcpCommandMetadata("scans match"), args: z.object({ beforeId: z .string() @@ -2014,7 +2051,7 @@ export async function main( .command("compare", { description: "Match and compare findings and coverage between scans.", destructive: true, - mcp: false, + mcp: mcpCommandMetadata("scans compare"), args: z.object({ beforeId: z .string() @@ -2066,7 +2103,7 @@ export async function main( }).command("scan", { description: "Publish findings from a completed scan or CSV.", destructive: true, - mcp: false, + mcp: mcpCommandMetadata("publish scan"), args: z.object({ scanDir: z .string() @@ -2598,7 +2635,7 @@ export async function main( publication.command("check", { description: "Check saved scan history and Linear access without creating issues.", - mcp: false, + mcp: mcpCommandMetadata("publish check"), args: z.object({ scanDir: z.string().describe("Completed scan directory."), }), @@ -2633,7 +2670,7 @@ export async function main( }).command("github", { description: "Import GitHub code scanning alerts without changing GitHub.", destructive: false, - mcp: false, + mcp: mcpCommandMetadata("import github"), args: z.object({ repository: z .string() @@ -2936,7 +2973,7 @@ export async function main( nextStep: "codex-security scan . --dry-run", }); const mcpInstructions = - "Use info for SDK metadata and scan to run security scans. Scans use local credentials, can make billable model calls, and write artifacts. Only scan repositories the user has authorized. Patching and other commands remain CLI-only."; + "Use info for SDK metadata and scan to run security scans. Other command tools accept args and options matching the CLI. Commands use local credentials and can make billable model calls, modify files or scan history, and publish external issues or pull requests. Only perform operations the user has authorized. Supply explicit inputs for commands that otherwise use a terminal picker. Authentication setup and CLI integration installers remain local operator actions."; const scanMcpAnnotations = { readOnlyHint: false, destructiveHint: true, @@ -3012,7 +3049,7 @@ export async function main( .command("install-hook", { description: "Install a Git pre-commit security scan.", destructive: true, - mcp: false, + mcp: mcpCommandMetadata("install-hook"), args: z.object({ repository: z .string() @@ -3089,7 +3126,7 @@ export async function main( description: "Run standard scans for project components and combine the results.", destructive: true, - mcp: false, + mcp: mcpCommandMetadata("scan-components"), args: z.object({ repository: z .string() @@ -3336,7 +3373,7 @@ export async function main( description: "Discover repositories and run resumable bulk security scans.", destructive: true, - mcp: false, + mcp: mcpCommandMetadata("bulk-scan"), args: z.object({ input: z .string() @@ -3528,7 +3565,7 @@ export async function main( description: "Export findings from a completed scan as CSV, JSON, or SARIF.", destructive: true, - mcp: false, + mcp: mcpCommandMetadata("export"), args: z.object({ scanDir: z .string() @@ -3595,7 +3632,7 @@ export async function main( .command("validate", { description: "Validate one or more candidate security findings.", destructive: true, - mcp: false, + mcp: mcpCommandMetadata("validate"), args: z.object({ "findings...": z .string() @@ -3632,7 +3669,7 @@ export async function main( description: "Verify existing security fixes without changing the repository.", destructive: false, - mcp: false, + mcp: mcpCommandMetadata("verify-fix"), args: z.object({ "findings...": z .string() @@ -3848,7 +3885,7 @@ export async function main( .command("patch", { description: "Patch one or more security issues.", destructive: true, - mcp: false, + mcp: mcpCommandMetadata("patch"), args: z.object({ "issues...": z .string() @@ -4107,7 +4144,7 @@ export async function main( .command("login", { description: "Sign in with ChatGPT or store credentials.", destructive: true, - mcp: false, + mcp: mcpCommandMetadata("login"), args: z.object({ action: z.enum(["status"]).optional().describe("Show login status."), }), @@ -4202,7 +4239,7 @@ export async function main( .command("logout", { description: "Remove the stored sign-in.", destructive: true, - mcp: false, + mcp: mcpCommandMetadata("logout"), async run() { const credentialHome = dependencies.prepareAuthenticationHome !== undefined @@ -4245,18 +4282,26 @@ export async function main( if (argv.includes("--mcp")) { // incur's MCP adapter does not pass request cancellation to commands. - const [{ McpServer }, { StdioServerTransport }] = await Promise.all([ - import("@modelcontextprotocol/server"), - import("@modelcontextprotocol/server/stdio"), - ]); + const [{ McpServer, fromJsonSchema }, { StdioServerTransport }, commands] = + await Promise.all([ + import("@modelcontextprotocol/server"), + import("@modelcontextprotocol/server/stdio"), + import("./cli-mcp-commands.js"), + ]); const server = new McpServer( { name: "codex-security", version: VERSION }, { instructions: mcpInstructions }, ); - const pending = new Set>(); + const pending = new Set>(); // The SDK ignores cancellation for request IDs 0 and "". Track those // requests before async tool validation so immediate cancellation works too. - const scanCancellation = new Map(); + const requestCancellation = new Map(); + const requestSignal = (id: string | number, signal: AbortSignal) => { + const cancellation = requestCancellation.get(id); + return cancellation === undefined + ? signal + : AbortSignal.any([signal, cancellation.signal]); + }; server.registerTool( "info", { @@ -4309,12 +4354,7 @@ export async function main( errorOutput, dependencies, false, - scanCancellation.has(context.mcpReq.id) - ? AbortSignal.any([ - context.mcpReq.signal, - scanCancellation.get(context.mcpReq.id)!.signal, - ]) - : context.mcpReq.signal, + requestSignal(context.mcpReq.id, context.mcpReq.signal), ); pending.add(operation); try { @@ -4330,6 +4370,91 @@ export async function main( }; }, ); + let manifest = ""; + await cli.serve(["--llms-full", "--format", "json"], { + env: dependencies.environment, + stdout: (value) => { + manifest += value; + }, + exit: (code) => { + throw new Error(`CLI command discovery exited with status ${code}.`); + }, + }); + const commandOutput = z.object({ + exitCode: z.number(), + data: z.unknown().optional(), + output: z.string().optional(), + error: z.string().optional(), + diagnostics: z.string().optional(), + }); + const serverDirectory = dependencies.currentDirectory(); + const commandEnvironment = { ...dependencies.environment }; + for (const [name, value] of Object.entries(commandEnvironment)) { + const configured = value?.trim(); + if ( + configured && + (["CODEX_SECURITY_STATE_DIR", "CODEX_HOME", "CODEX_CLI_PATH"].includes( + name.toUpperCase(), + ) || + (name.toUpperCase() === "PYTHON" && + (configured.includes("/") || configured.includes("\\")))) + ) { + // A tool's repository must not change the server's state or runtime. + commandEnvironment[name] = resolve( + serverDirectory, + expandHome(configured, dependencies.environment), + ); + } + } + for (const command of commands.buildCliMcpCommands( + JSON.parse(manifest) as CliMcpManifest, + )) { + server.registerTool( + command.name, + { + description: command.description, + inputSchema: fromJsonSchema(command.inputSchema), + outputSchema: commandOutput, + ...mcpCommandMetadata(command.path.join(" ")), + }, + async (value, context) => { + const input = value as CliMcpInput; + const issues = input.args?.["issues"]; + const jsonOutput = + command.name === "patch" + ? input.options?.["resumePr"] !== undefined || + input.options?.["scan"] !== undefined || + (Array.isArray(issues) && + issues.length > 0 && + issues.every(isFindingIdentifier)) + : command.jsonOutput; + const operation = ( + dependencies.runMcpCommand ?? commands.runCliMcpCommand + )(command, input, { + executable: process.execPath, + entrypoint: fileURLToPath(import.meta.url), + cwd: resolveCliPath(serverDirectory, input.workingDirectory ?? "."), + environment: commandEnvironment, + signal: requestSignal(context.mcpReq.id, context.mcpReq.signal), + jsonOutput, + onStderr: (chunk) => { + errorOutput.write(chunk); + }, + }); + pending.add(operation); + try { + const outcome = await operation; + return { + content: [{ type: "text", text: JSON.stringify(outcome) }], + structuredContent: { ...outcome }, + ...(outcome.exitCode === 0 ? {} : { isError: true }), + }; + } finally { + pending.delete(operation); + } + }, + ); + } const input = dependencies.mcpInput ?? process.stdin; const protocolOutput = output instanceof NodeWritable @@ -4347,17 +4472,16 @@ export async function main( "id" in message && (message.id === 0 || message.id === "") && "method" in message && - message.method === "tools/call" && - message.params?.["name"] === "scan" + message.method === "tools/call" ) { - scanCancellation.set(message.id, new AbortController()); + requestCancellation.set(message.id, new AbortController()); } else if ( "method" in message && message.method === "notifications/cancelled" ) { const requestId = message.params?.["requestId"]; if (requestId === 0 || requestId === "") { - scanCancellation.get(requestId)?.abort(); + requestCancellation.get(requestId)?.abort(); } } transport.onmessage?.(message); @@ -4373,8 +4497,8 @@ export async function main( message.id !== undefined && message.id !== null ) { - const cancellation = scanCancellation.get(message.id); - scanCancellation.delete(message.id); + const cancellation = requestCancellation.get(message.id); + requestCancellation.delete(message.id); if (cancellation?.signal.aborted) return; } await stdio.send(message); @@ -4410,6 +4534,15 @@ export async function main( // Writes can fail after EOF and after main returns. Keep the error handler // for the output stream's lifetime, without waiting on a blocked writer. protocolOutput.on("error", stop); + if (errorOutput instanceof NodeWritable) { + // Diagnostics can fail after a command or the server has returned too. + // Keep these failures nonfatal until the diagnostic stream closes. + const ignoreDiagnosticError = (): void => {}; + errorOutput.on("error", ignoreDiagnosticError); + errorOutput.once("close", () => { + errorOutput.off("error", ignoreDiagnosticError); + }); + } dependencies.addSignalListener("SIGINT", onInterrupt); dependencies.addSignalListener("SIGTERM", onTerminate); try { diff --git a/sdk/typescript/tests-ts/cli-mcp-commands.test.ts b/sdk/typescript/tests-ts/cli-mcp-commands.test.ts new file mode 100644 index 000000000..89e5c761b --- /dev/null +++ b/sdk/typescript/tests-ts/cli-mcp-commands.test.ts @@ -0,0 +1,522 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "bun:test"; +import { + buildCliMcpArguments, + buildCliMcpCommands, + parseCliMcpResult, + runCliMcpCommand, + type CliMcpCommand, + type CliMcpManifest, +} from "../src/cli-mcp-commands.js"; + +const directories: string[] = []; +afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +function command( + name: string, + schema: NonNullable = {}, +): CliMcpCommand { + return buildCliMcpCommands({ commands: [{ name, schema }] })[0]!; +} + +async function script(source: string) { + const cwd = await mkdtemp(join(tmpdir(), "codex-security-mcp-command-")); + directories.push(cwd); + const entrypoint = join(cwd, "command.mjs"); + await writeFile(entrypoint, source); + return { + executable: process.execPath, + entrypoint, + cwd, + environment: { + ...(process.platform === "win32" + ? { SystemRoot: process.env["SystemRoot"] } + : {}), + MCP_TEST_VALUE: "synthetic value", + }, + }; +} + +describe("CLI MCP command schemas", () => { + test("uses named CLI leaves without duplicating existing tools", () => { + expect( + buildCliMcpCommands({ + commands: [ + { name: "scan" }, + { name: "info" }, + { name: "findings false-positive", description: "Save a decision." }, + { name: "scans list" }, + ], + }).map(({ name, path, description }) => ({ name, path, description })), + ).toEqual([ + { + name: "findings_false-positive", + path: ["findings", "false-positive"], + description: "Save a decision.", + }, + { + name: "scans_list", + path: ["scans", "list"], + description: "scans list", + }, + ]); + }); + + test("exposes logout and only explicit login status, without credential inputs", () => { + const login = command("login", { + args: { + type: "object", + properties: { action: { type: "string", enum: ["status"] } }, + additionalProperties: false, + }, + options: { + properties: { + deviceAuth: { type: "boolean" }, + withApiKey: { type: "boolean" }, + }, + }, + }); + expect(login.inputSchema).toMatchObject({ + required: ["args"], + properties: { + args: { + required: ["action"], + properties: { action: { enum: ["status"] } }, + }, + }, + }); + expect(login.inputSchema.properties).not.toHaveProperty("options"); + expect(login.description).toContain("Complete sign-in locally"); + expect(buildCliMcpArguments(login, { args: { action: "status" } })).toEqual( + ["login", "status"], + ); + expect(command("logout").inputSchema).toEqual({ + type: "object", + properties: { + workingDirectory: { + type: "string", + description: expect.stringContaining("Working directory"), + }, + }, + additionalProperties: false, + }); + }); + + test("keeps colliding positional and option names separate and defaults optional", () => { + const schema = { + args: { + type: "object", + properties: { scanDir: { type: "string" } }, + additionalProperties: false, + }, + options: { + type: "object", + properties: { + scanDir: { type: "array", items: { type: "string" }, default: [] }, + to: { type: "string", default: "linear" }, + csv: { type: "boolean", default: false }, + project: { type: "string", minLength: 1 }, + }, + required: ["scanDir", "to", "project"], + additionalProperties: false, + }, + }; + const tool = command("publish scan", schema); + expect(tool.inputSchema).toMatchObject({ + required: ["options"], + additionalProperties: false, + properties: { + args: { properties: { scanDir: { type: "string" } } }, + options: { + required: ["project"], + properties: { + scanDir: { type: "array", default: [] }, + to: { type: "string", default: "linear", enum: ["linear"] }, + project: { type: "string", minLength: 1 }, + }, + }, + }, + }); + expect(schema.options.required).toEqual(["scanDir", "to", "project"]); + expect(schema.options.properties.to).not.toHaveProperty("enum"); + expect( + tool.inputSchema.properties?.["options"]?.properties, + ).not.toHaveProperty("csv"); + }); + + test("converts required and optional variadic operands to arrays", () => { + for (const required of [true, false]) { + const tool = command("validate", { + args: { + type: "object", + properties: { + "findings...": { + type: "string", + minLength: 1, + description: "Finding text.", + }, + }, + ...(required ? { required: ["findings..."] } : {}), + additionalProperties: false, + }, + }); + expect(tool.inputSchema.properties?.["args"]?.properties).toEqual({ + findings: { + type: "array", + description: expect.stringContaining("Finding text."), + items: { + type: "string", + minLength: 1, + description: expect.stringContaining( + "Positional values cannot start", + ), + }, + ...(required ? { minItems: 1 } : {}), + }, + }); + expect(tool.inputSchema.required).toEqual( + required ? ["args"] : undefined, + ); + expect(tool.inputSchema.properties?.["args"]?.required).toEqual( + required ? ["findings"] : undefined, + ); + } + }); + + test("requests structured output only where the command supports it", () => { + expect( + command("import github", { output: { type: "array" } }).jsonOutput, + ).toBe(true); + expect(command("patch", { output: { type: "object" } }).jsonOutput).toBe( + false, + ); + expect(command("export").jsonOutput).toBe(false); + }); +}); + +describe("CLI MCP command arguments", () => { + test("advertises an optional working directory without serializing it as argv", () => { + const tool = command("scans list"); + const input = { workingDirectory: "relative project" }; + expect(tool.inputSchema.properties?.["workingDirectory"]).toMatchObject({ + type: "string", + }); + expect(tool.inputSchema.required).toBeUndefined(); + expect(buildCliMcpArguments(tool, input)).toEqual(["scans", "list"]); + expect(input.workingDirectory).toBe("relative project"); + }); + + test("preserves argument order, repeated values, booleans, and exact option strings", () => { + const tool = command("example run", { + args: { + properties: { + repository: { type: "string" }, + "findings...": { type: "string" }, + }, + }, + options: { + properties: { + outputDir: { type: "string" }, + workers: { type: "number" }, + codex: { type: "array", items: { type: "string" } }, + enabled: { type: "boolean", default: false }, + disabled: { type: "boolean", default: true }, + }, + }, + output: { type: "object" }, + }); + expect( + buildCliMcpArguments(tool, { + args: { repository: "project folder", findings: ["first", "second"] }, + options: { + outputDir: "--help", + workers: 3, + codex: ['model="example"', "a=b=c"], + enabled: true, + disabled: false, + }, + }), + ).toEqual([ + "example", + "run", + "--json", + "--output-dir=--help", + "--workers=3", + '--codex=model="example"', + "--codex=a=b=c", + "--enabled", + "--no-disabled", + "project folder", + "first", + "second", + ]); + }); + + test("lets the CLI apply omitted defaults and accepts a per-call output choice", () => { + const tool = command("patch", { output: { type: "object" } }); + expect(buildCliMcpArguments(tool, {})).toEqual(["patch"]); + expect(buildCliMcpArguments(tool, {}, { jsonOutput: true })).toEqual([ + "patch", + "--json", + ]); + }); + + test("does not shift a later positional into an omitted earlier argument", () => { + const tool = command("scans compare", { + args: { + properties: { + beforeId: { type: "string" }, + afterId: { type: "string" }, + }, + }, + }); + expect(() => + buildCliMcpArguments(tool, { args: { afterId: "scan-after" } }), + ).toThrow("'afterId' requires the preceding 'beforeId'"); + }); + + test("keeps whitespace, quotes, Unicode, and newlines in positional values", () => { + const tool = command("validate", { + args: { properties: { "findings...": { type: "string" } } }, + }); + const findings = ['space and "quotes"', "λ\nnext line", "./--help"]; + expect(buildCliMcpArguments(tool, { args: { findings } })).toEqual([ + "validate", + ...findings, + ]); + }); +}); + +describe("CLI MCP command results", () => { + test("retains structured arrays and successful stderr diagnostics", () => { + expect( + parseCliMcpResult(0, '[{"number":7}]\n', "completed\n", { + jsonOutput: true, + }), + ).toEqual({ + exitCode: 0, + data: [{ number: 7 }], + diagnostics: "completed\n", + }); + }); + + test("preserves raw exports and direct workflow text without parsing", () => { + expect(parseCliMcpResult(0, "id,title\n7,example\n", "")).toEqual({ + exitCode: 0, + output: "id,title\n7,example\n", + }); + expect(parseCliMcpResult(0, '{"raw":true}', "")).toEqual({ + exitCode: 0, + output: '{"raw":true}', + }); + expect(parseCliMcpResult(0, "", "Wrote findings.json\n")).toEqual({ + exitCode: 0, + diagnostics: "Wrote findings.json\n", + }); + }); + + test("reports failures while retaining any structured partial result", () => { + expect( + parseCliMcpResult(1, '{"completed":2}', "failed\n", { + jsonOutput: true, + }), + ).toEqual({ exitCode: 1, data: { completed: 2 }, error: "failed" }); + expect(parseCliMcpResult(2, "", "")).toEqual({ + exitCode: 2, + error: "Command exited with status 2.", + }); + }); + + test("does not report malformed structured output as a successful tool result", () => { + expect(parseCliMcpResult(0, "not json", "", { jsonOutput: true })).toEqual({ + exitCode: 2, + output: "not json", + error: "Command returned invalid JSON.", + }); + }); +}); + +describe("CLI MCP command processes", () => { + test("captures isolated streams, working directory, environment, and argv", async () => { + const options = await script(` + process.stdout.write(JSON.stringify({ + argv: process.argv.slice(2), cwd: process.cwd(), + value: process.env.MCP_TEST_VALUE, + })); + process.stderr.write("progress λ\\n"); + `); + const chunks: string[] = []; + const result = await runCliMcpCommand( + command("scans list", { output: { type: "object" } }), + {}, + { + ...options, + onStderr: (chunk) => { + chunks.push(chunk); + throw new Error("observer"); + }, + }, + ); + expect(result).toEqual({ + exitCode: 0, + data: { + argv: ["scans", "list", "--json"], + cwd: await realpath(options.cwd), + value: "synthetic value", + }, + diagnostics: "progress λ\n", + }); + expect(chunks.join("")).toBe("progress λ\n"); + }); + + test("reports launch failures and already-cancelled calls", async () => { + const options = await script("process.exit(0)"); + expect( + await runCliMcpCommand( + command("export"), + {}, + { + ...options, + executable: join(options.cwd, "missing-executable"), + }, + ), + ).toMatchObject({ exitCode: 2, error: expect.stringContaining("ENOENT") }); + expect( + await runCliMcpCommand( + command("export"), + {}, + { + ...options, + signal: AbortSignal.abort(), + }, + ), + ).toEqual({ exitCode: 130, error: "Command cancelled." }); + }); + + test.each(["--mcp", "--help", "--json", "-v"])( + "rejects positional %s before a child can reinterpret it as a flag", + async (finding) => { + const options = await script(` + import { writeFileSync } from "node:fs"; + writeFileSync("started", "unexpected invocation"); + `); + const result = await runCliMcpCommand( + command("validate", { + args: { properties: { "findings...": { type: "string" } } }, + }), + { args: { findings: [finding] } }, + options, + ); + expect(result).toMatchObject({ + exitCode: 2, + error: expect.stringContaining( + "cannot start with '-'; prefix paths with './'", + ), + }); + expect( + await readFile(join(options.cwd, "started")).catch(() => undefined), + ).toBeUndefined(); + }, + ); + + test.each([ + ["same-group", false, 0], + ["detached", true, 50], + ] as const)( + "cancellation waits for CLI cleanup of its %s descendant", + async (_name, detached, handlerDelay) => { + const options = await script(` + import { spawn } from "node:child_process"; + const child = spawn(process.execPath, ["-e", [ + 'process.on("SIGTERM", () => {});', + 'console.log("ready");', + 'setInterval(() => {}, 1000);', + ].join("")], { + stdio: ["ignore", "pipe", "ignore"], + detached: process.platform !== "win32" && ${detached}, + windowsHide: true, + }); + process.on("SIGTERM", () => setTimeout(() => { + child.kill("SIGTERM"); + setTimeout(() => child.kill("SIGKILL"), 1000); + }, ${handlerDelay})); + child.once("close", () => { + process.stdout.write("cleanup completed\\n"); + process.exit(0); + }); + child.stdout.once("data", () => process.stderr.write(JSON.stringify([process.pid, child.pid]) + "\\n")); + `); + // Exercise the installed CLI runtime, rather than Bun's signal behavior. + options.executable = execFileSync("node", ["-p", "process.execPath"], { + encoding: "utf8", + }).trim(); + const controller = new AbortController(); + let pids: number[] = []; + let progress = ""; + try { + const result = await runCliMcpCommand( + command("validate"), + {}, + { + ...options, + signal: controller.signal, + onStderr: (chunk) => { + progress += chunk; + if (progress.includes("\n")) { + pids = JSON.parse(progress) as number[]; + controller.abort(); + } + }, + }, + ); + expect(result).toMatchObject({ + exitCode: 130, + error: "Command cancelled.", + }); + expect(pids).toHaveLength(2); + if (process.platform !== "win32") { + expect(result.output).toBe("cleanup completed\n"); + } + for (const pid of pids) expect(await processHasExited(pid)).toBe(true); + } finally { + controller.abort(); + for (const pid of pids) { + try { + process.kill(pid, "SIGKILL"); + } catch { + /* Already exited. */ + } + } + } + }, + 10_000, + ); +}); + +async function processHasExited(pid: number): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + try { + process.kill(pid, 0); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return true; + throw error; + } + if (process.platform === "linux") { + const stat = await readFile(`/proc/${pid}/stat`, "utf8").catch( + () => undefined, + ); + if (stat === undefined || /\) Z /u.test(stat)) return true; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return false; +} diff --git a/sdk/typescript/tests-ts/cli-mcp.test.ts b/sdk/typescript/tests-ts/cli-mcp.test.ts index 4e46b4639..f9027496f 100644 --- a/sdk/typescript/tests-ts/cli-mcp.test.ts +++ b/sdk/typescript/tests-ts/cli-mcp.test.ts @@ -1,4 +1,5 @@ import { PassThrough, Writable } from "node:stream"; +import { join, resolve } from "node:path"; import { setImmediate } from "node:timers/promises"; import { describe, expect, test } from "bun:test"; import type { CallToolResult, Tool } from "@modelcontextprotocol/server"; @@ -14,11 +15,57 @@ import { } from "./cli-fixtures.js"; import { BUNDLED_PLUGIN_VERSION, VERSION } from "../src/version.js"; +const commandInputs: Record = { + "bulk-scan": { + args: { input: "repositories.csv" }, + options: { outputDir: "/synthetic/bulk" }, + }, + export: { + args: { scanDir: "/synthetic/scan" }, + options: { exportFormat: "csv", output: "-" }, + }, + "findings_false-positive": { + args: { occurrenceId: "occ_example" }, + options: { reason: "Reviewed synthetic fixture" }, + }, + findings_list: {}, + import_github: { args: { repository: "example/repository" } }, + "install-hook": { args: { repository: "/synthetic/repo" } }, + login: { args: { action: "status" } }, + logout: {}, + patch: { args: { issues: ["Review the synthetic issue."] } }, + publish_check: { + args: { scanDir: "/synthetic/scan" }, + options: { to: "linear" }, + }, + publish_scan: { + args: { scanDir: "/synthetic/scan" }, + options: { to: "linear", dryRun: true }, + }, + "scan-components": { + args: { repository: "/synthetic/repo" }, + options: { + component: ["src"], + outputDir: "/synthetic/components", + planOnly: true, + }, + }, + scans_compare: {}, + scans_list: {}, + scans_logs: { args: { scanId: "scan_example" } }, + scans_match: { options: { all: true } }, + scans_rerun: { args: { scanId: "scan_example" } }, + scans_show: { args: { scanId: "scan_example" } }, + validate: { args: { findings: ["Review the synthetic finding."] } }, + "verify-fix": { args: { findings: ["occ_example"] } }, +}; + async function connect( deps = dependencies(), finishWrite: (callback: (error?: Error | null) => void) => void = ( callback, ) => callback(), + diagnostics?: Writable, ) { const input = new PassThrough(); const stderr = capture(true); @@ -45,7 +92,7 @@ async function connect( finishWrite(callback); }, }); - const serving = main(["--mcp"], output, stderr.stream, { + const serving = main(["--mcp"], output, diagnostics ?? stderr.stream, { ...deps, mcpInput: input, }); @@ -96,12 +143,127 @@ async function connect( } describe("CLI MCP scans", () => { + test("anchors inherited runtime paths while preserving environment names and values", async () => { + const serverDirectory = resolve("synthetic server"); + const environment = { + CODEX_SECURITY_STATE_DIR: " state directory ", + CodeX_Home: "codex home", + CODEX_CLI_PATH: " ", + codex_cli_path: "bin/codex.exe", + PyThOn: "../runtime/python3", + PATH: "unchanged-relative-bin", + OPENAI_API_KEY: "synthetic-key", + }; + const originalEnvironment = { ...environment }; + const deps = dependencies({ + currentDirectory: serverDirectory, + environment, + }); + const calls: { cwd: string; environment: NodeJS.ProcessEnv }[] = []; + deps.runMcpCommand = async (_command, _input, options) => { + calls.push({ cwd: options.cwd, environment: options.environment }); + return { exitCode: 0 }; + }; + const session = await connect(deps); + try { + await session.call("scans_list", { workingDirectory: "first repository" }) + .result; + await session.call("findings_list", { + workingDirectory: "second repository", + }).result; + expect(calls.map(({ cwd }) => cwd)).toEqual([ + resolve(serverDirectory, "first repository"), + resolve(serverDirectory, "second repository"), + ]); + for (const call of calls) { + expect(call.environment).toEqual({ + ...originalEnvironment, + CODEX_SECURITY_STATE_DIR: resolve(serverDirectory, "state directory"), + CodeX_Home: resolve(serverDirectory, "codex home"), + codex_cli_path: resolve(serverDirectory, "bin/codex.exe"), + PyThOn: resolve(serverDirectory, "../runtime/python3"), + }); + } + expect(environment).toEqual(originalEnvironment); + } finally { + await session.close(); + } + }); + + test.each([ + ["HOME", "python3"], + ["USERPROFILE", ".python3"], + ])( + "expands inherited runtime paths using %s and preserves bare PYTHON %s", + async (homeVariable, python) => { + const home = resolve("synthetic home"); + const environment = { + [homeVariable]: home, + CODEX_SECURITY_STATE_DIR: "~/state directory", + CODEX_HOME: "~\\codex home", + CODEX_CLI_PATH: join(home, "bin", "codex.exe"), + PYTHON: python, + }; + const deps = dependencies({ environment }); + let received: NodeJS.ProcessEnv | undefined; + deps.runMcpCommand = async (_command, _input, options) => { + received = options.environment; + return { exitCode: 0 }; + }; + const session = await connect(deps); + try { + await session.call("scans_list", { + workingDirectory: "other repository", + }).result; + expect(received).toEqual({ + ...environment, + CODEX_SECURITY_STATE_DIR: join(home, "state directory"), + CODEX_HOME: join(home, "codex home"), + }); + } finally { + await session.close(); + } + }, + ); + + test("resolves each command working directory without changing server state", async () => { + const deps = dependencies(); + const serverDirectory = deps.currentDirectory(); + const processDirectory = process.cwd(); + const directories: string[] = []; + deps.runMcpCommand = async (_command, _input, options) => { + directories.push(options.cwd); + return { exitCode: 0, data: { directory: options.cwd } }; + }; + const session = await connect(deps); + try { + const first = session.call("scans_list", { + workingDirectory: "first repository", + }); + const second = session.call("findings_list", { + workingDirectory: "second repository", + }); + await Promise.all([first.result, second.result]); + await session.call("scans_list").result; + expect(directories).toEqual([ + resolve(serverDirectory, "first repository"), + resolve(serverDirectory, "second repository"), + resolve(serverDirectory), + ]); + expect(process.cwd()).toBe(processDirectory); + expect(deps.currentDirectory()).toBe(serverDirectory); + } finally { + await session.close(); + } + }); test("advertises scan-only inputs and read-only metadata", async () => { const session = await connect(); try { const { tools } = await session.request<{ tools: Tool[] }>("tools/list") .result; - expect(tools.map((tool) => tool.name).sort()).toEqual(["info", "scan"]); + expect(tools.map((tool) => tool.name).sort()).toEqual( + ["info", "scan", ...Object.keys(commandInputs)].sort(), + ); const scan = tools.find((tool) => tool.name === "scan")!; expect(scan.inputSchema.properties).toMatchObject({ repository: { type: "string" }, @@ -139,6 +301,164 @@ describe("CLI MCP scans", () => { } }); + test("dispatches every remaining command with typed inputs and independent results", async () => { + const calls: { + name: string; + input: unknown; + jsonOutput: boolean | undefined; + }[] = []; + const deps = dependencies(); + deps.runMcpCommand = async (command, input, options) => { + calls.push({ name: command.name, input, jsonOutput: options.jsonOutput }); + options.onStderr?.("Command progress.\n"); + return command.name === "import_github" + ? { exitCode: 0, data: [{ number: 1 }] } + : command.name === "export" + ? { exitCode: 0, output: "id,title\nexample,Synthetic finding\n" } + : { exitCode: 0, data: { command: command.name } }; + }; + const session = await connect(deps); + try { + for (const [name, input] of Object.entries(commandInputs)) { + const result = await session.call(name, input).result; + expect(result.isError).not.toBe(true); + expect(result.structuredContent).toMatchObject({ exitCode: 0 }); + expect( + JSON.parse((result.content[0] as { text: string }).text), + ).toEqual(result.structuredContent); + expect(calls.at(-1)).toMatchObject({ name, input }); + if (name === "import_github") + expect(result.structuredContent).toMatchObject({ + data: [{ number: 1 }], + }); + if (name === "export") + expect(result.structuredContent).toMatchObject({ + output: "id,title\nexample,Synthetic finding\n", + }); + } + expect(calls.find(({ name }) => name === "patch")?.jsonOutput).toBe( + false, + ); + await session.call("patch", { args: { issues: ["occ_example"] } }).result; + expect(calls.at(-1)?.jsonOutput).toBe(true); + await session.call("patch", { options: { resumePr: "patch_example" } }) + .result; + expect(calls.at(-1)?.jsonOutput).toBe(true); + expect(session.stderr.text()).toContain("Command progress."); + } finally { + await session.close(); + } + }); + + test("preserves command errors and validates nested schemas before starting a command", async () => { + const deps = dependencies(); + let started = 0; + deps.runMcpCommand = async () => { + started++; + return { + exitCode: 2, + data: { partial: true }, + error: "Synthetic command failure.", + }; + }; + const session = await connect(deps); + try { + for (const [name, input] of [ + ["validate", { args: { findings: [] } }], + ["validate", { args: { findings: "not an array" } }], + ["publish_scan", { options: { to: "unsupported" } }], + ["login", {}], + [ + "login", + { args: { action: "status" }, options: { withApiKey: true } }, + ], + ["scans_list", { unexpected: true }], + ] as const) { + const result = await session.call(name, input).result; + expect(result.isError).toBe(true); + } + expect(started).toBe(0); + const failed = await session.call("scans_list").result; + expect(failed.isError).toBe(true); + expect(failed.structuredContent).toEqual({ + exitCode: 2, + data: { partial: true }, + error: "Synthetic command failure.", + }); + expect(started).toBe(1); + expect((await session.call("info").result).isError).not.toBe(true); + } finally { + await session.close(); + } + }); + + test.each(["command-request", 0, ""])( + "cancels command request %s without cancelling another command", + async (requestId) => { + const deps = dependencies(); + let announceStarted!: () => void; + const started = new Promise((resolve) => { + announceStarted = resolve; + }); + let finishCleanup!: () => void; + const cleanup = new Promise((resolve) => { + finishCleanup = resolve; + }); + let aborted = false; + deps.runMcpCommand = async (_command, input, options) => { + if (input.args?.["scanId"] !== "cancel-me") + return { exitCode: 0, data: { independent: true } }; + const cancelled = new Promise((resolve) => { + options.signal!.addEventListener( + "abort", + () => { + aborted = true; + resolve(); + }, + { once: true }, + ); + }); + announceStarted(); + await cancelled; + await cleanup; + return { exitCode: 130, error: "Command cancelled." }; + }; + const session = await connect(deps); + try { + session.call( + "scans_rerun", + { args: { scanId: "cancel-me" } }, + requestId, + ); + await started; + session.cancel(requestId); + const other = await session.call( + "scans_show", + { args: { scanId: "other" } }, + "other-request", + ).result; + expect(other.structuredContent).toMatchObject({ + exitCode: 0, + data: { independent: true }, + }); + expect(aborted).toBe(true); + let exited = false; + session.serving.then(() => { + exited = true; + }); + session.input.end(); + await setImmediate(); + expect(exited).toBe(false); + finishCleanup(); + await session.close(); + expect(session.responses.has(requestId)).toBe(false); + } finally { + finishCleanup(); + await session.close(); + } + }, + ); + test("runs scans with shared options, noninteractive auth and protocol-safe progress", async () => { const calls: unknown[] = []; let closed = 0; @@ -249,7 +569,7 @@ describe("CLI MCP scans", () => { expect((await session.call("scan", input).result).isError).toBe(true); } expect(started).toBe(0); - expect(await session.call("patch").result).toMatchObject({ + expect(await session.call("unknown-command").result).toMatchObject({ code: -32602, }); } finally { @@ -404,6 +724,60 @@ describe("CLI MCP scans", () => { }, ); + test.each(["during command", "after shutdown"] as const)( + "keeps asynchronous diagnostic failures nonfatal %s", + async (phase) => { + const pendingWrite = + Promise.withResolvers<(error?: Error | null) => void>(); + const finishCommand = Promise.withResolvers(); + const stream = new Writable({ + write(chunk, _encoding, callback) { + if (chunk.length > 0) pendingWrite.resolve(callback); + else callback(); + }, + }); + const deps = dependencies(); + deps.runMcpCommand = async (_command, _input, options) => { + options.onStderr?.("Command progress.\n"); + if (phase === "during command") await finishCommand.promise; + return { exitCode: 0, data: { completed: true } }; + }; + const session = await connect(deps, undefined, stream); + let closed = false; + try { + const call = session.call("scans_list"); + const finishWrite = await pendingWrite.promise; + if (phase === "after shutdown") { + expect((await call.result).isError).not.toBe(true); + await session.close(); + closed = true; + } + const protection = new Promise((resolve) => { + stream.once("error", () => resolve(stream.listenerCount("error"))); + }); + finishWrite( + Object.assign(new Error("Synthetic broken diagnostic pipe."), { + code: "EPIPE", + }), + ); + expect(await protection).toBeGreaterThan(0); + finishCommand.resolve(); + if (phase === "during command") { + expect((await call.result).structuredContent).toMatchObject({ + exitCode: 0, + data: { completed: true }, + }); + expect((await session.call("info").result).isError).not.toBe(true); + } + } finally { + finishCommand.resolve(); + if (!closed) await session.close(); + } + await setImmediate(); + expect(stream.listenerCount("error")).toBe(0); + }, + ); + test("handles a buffered stdout failure after EOF while scan cleanup is pending", async () => { let bufferOutput = false; const pendingWrite =