Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -907,8 +907,54 @@ command manifest, `scan --schema --format json` for a command schema, and
`--format toon|json|yaml|jsonl` and `--full-output`.

`skills add` syncs agent skills; `mcp add` registers the CLI as an MCP server.
MCP exposes only the read-only `info` command because the transport cannot
cancel active scans.
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:

```json
{
"mcpServers": {
"codex-security": {
"command": "npx",
"args": ["--yes", "@openai/codex-security", "--mcp"]
}
}
}
```

The `scan` tool accepts the existing scan options using camelCase names:
`repository`, `path`, `mode`, `diff`, `workingTree`, `outputDir`, `maxCost`,
and so on. Defaults match the CLI. Relative paths resolve from the server's
working directory. First check local inputs without starting a model:

```json
{ "repository": "/path/to/repository", "dryRun": true }
```

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.

Scans run noninteractively with the same local credentials and `auth`
selection as the CLI. Sign in with `codex-security login` before starting the
server, or supply `OPENAI_API_KEY`/`CODEX_API_KEY` in its environment. Scans
can incur model costs, write local artifacts, and run repository tools. Only
scan targets the user has authorized, and configure the MCP client's tool
call timeout to allow the scan to finish.

Results are returned as JSON in both text content and `structuredContent`:
`{ "exitCode": 0, "data": { ... } }`. `data` is the same scan result or dry-run
preflight data as CLI JSON output. Exit code `1` means the requested severity
threshold was met; `2` means invalid inputs, incomplete results, or failure.
Nonzero outcomes set MCP `isError` while preserving any available `data` and
`error` message. Scan diagnostics go to stderr; stdout is reserved for MCP.

MCP cancellation notifications stop the corresponding scan. Disconnecting
the client or stopping the server cancels active scans and waits for cleanup;
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.

## Containerized bulk scans

Expand Down
1 change: 1 addition & 0 deletions sdk/typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"dependencies": {
"@inquirer/prompts": "8.3.0",
"@linear/sdk": "89.0.0",
"@modelcontextprotocol/server": "2.0.0-beta.4",
"@octokit/core": "7.0.6",
"@openai/codex": "0.149.1",
"@openai/codex-sdk": "0.149.1",
Expand Down
3 changes: 3 additions & 0 deletions sdk/typescript/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

74 changes: 73 additions & 1 deletion sdk/typescript/scripts/smoke-package.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { spawn, spawnSync } from "node:child_process";
import { once } from "node:events";
import {
chmod,
cp,
Expand All @@ -22,6 +23,7 @@ import {
sep,
} from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { createInterface } from "node:readline";
import { packageSmokeTimeouts } from "./package-smoke-timeouts.mjs";

const PACKAGE_SMOKE_TIMEOUT_MS = packageSmokeTimeouts().commandTimeoutMs;
Expand Down Expand Up @@ -158,6 +160,74 @@ async function pluginFiles(directory) {
return files.sort();
}

async function smokeCliMcp(launcher, consumer) {
const repository = join(consumer, "mcp-repository");
await mkdir(repository);
await writeFile(
join(repository, "example.js"),
"export const example = 1;\n",
);
const child = spawn(process.execPath, [launcher, "--mcp"], {
cwd: consumer,
env: {
...process.env,
CODEX_SECURITY_STATE_DIR: join(consumer, "mcp-state"),
},
stdio: "pipe",
timeout: PACKAGE_SMOKE_TIMEOUT_MS,
killSignal: "SIGKILL",
windowsHide: true,
});
const closed = once(child, "close");
const lines = createInterface({ input: child.stdout });
const responses = lines[Symbol.asyncIterator]();
let stderr = "";
child.stderr.setEncoding("utf8").on("data", (text) => (stderr += text));
const send = (message) => child.stdin.write(JSON.stringify(message) + "\n");
async function request(id, method, params) {
send({ jsonrpc: "2.0", id, method, params });
for (;;) {
const line = await responses.next();
assert.equal(line.done, false, `MCP closed before ${method}: ${stderr}`);
const response = JSON.parse(line.value);
if (response.id !== id) continue;
assert.equal(response.error, undefined, JSON.stringify(response));
return response.result;
}
}
try {
await request(1, "initialize", {
protocolVersion: "2025-11-25",
capabilities: {},
clientInfo: { name: "package-smoke", version: "1.0.0" },
});
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",
]);
const info = await request(3, "tools/call", {
name: "info",
arguments: {},
});
assert.equal(info.structuredContent.scanMcp, true);
const scan = await request(4, "tools/call", {
name: "scan",
arguments: { repository, dryRun: true },
});
assert.notEqual(scan.isError, true, JSON.stringify(scan));
assert.equal(scan.structuredContent.exitCode, 0);
assert.equal(scan.structuredContent.data.dryRun, true);
child.stdin.end();
assert.equal((await closed)[0], 0, stderr);
} finally {
lines.close();
child.kill("SIGKILL");
await closed;
}
}

async function smokeNestedDeepScanWorker(installedRoot, consumer) {
const sdk = await import(
pathToFileURL(join(installedRoot, "dist", "index.js")).href
Expand Down Expand Up @@ -472,6 +542,8 @@ 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"),
Expand Down
Loading
Loading