@wyrd-company/async-codex-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # async-codex-mcp
2
+
3
+ Async Codex MCP server.
4
+
5
+ This package implements an MCP server that proxies a Codex MCP server and turns blocking `codex` calls into background sessions. Configured profile tools return immediately with an async session id; clients can poll `session-status`, receive `notifications/message` completion events, and resume completed Codex sessions with `continue-session`.
6
+
7
+ ## Why
8
+
9
+ The Codex CLI can run as an MCP server with `codex mcp-server`, exposing blocking `codex` and `codex-reply` tools. This server wraps those tools to:
10
+
11
+ - expose named, opinionated profile tools from YAML configuration;
12
+ - restrict caller-controlled inputs to `prompt`, `model`, and `cwd`;
13
+ - default Codex execution to `sandboxMode: danger-full-access` and `approvalPolicy: never` for devcontainer use;
14
+ - return immediately while Codex runs in the background;
15
+ - send MCP logging notifications when a background session completes or fails;
16
+ - expose `continue-session` as a generic wrapper around `codex-reply`.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ npm install --global @wyrd-company/async-codex-mcp
22
+ ```
23
+
24
+ ## Configuration
25
+
26
+ Pass a YAML file path as the first CLI argument, or set `ASYNC_CODEX_MCP_CONFIG`. If no config is provided, a single `codex` profile is created with `danger-full-access` sandboxing and `never` approval policy.
27
+
28
+ Example:
29
+
30
+ ```yaml
31
+ codex:
32
+ command: codex
33
+ args: [mcp-server]
34
+ env: {}
35
+
36
+ tools:
37
+ codex-write:
38
+ description: Run Codex asynchronously with full filesystem access.
39
+ sandboxMode: danger-full-access
40
+ approvalPolicy: never
41
+ codex-review:
42
+ description: Ask Codex to review code without making edits.
43
+ sandboxMode: read-only
44
+ approvalPolicy: never
45
+ ```
46
+
47
+ Tool `config` values are passed through to the underlying Codex MCP `codex` tool as Codex config overrides. For example, this exposes a separate tool that routes through an Azure/OpenAI-compatible provider:
48
+
49
+ ```yaml
50
+ tools:
51
+ codex-azure-review:
52
+ description: Ask Codex to review code using Azure OpenAI.
53
+ sandboxMode: read-only
54
+ approvalPolicy: never
55
+ model: gpt-5-codex
56
+ config:
57
+ model_provider: azure
58
+ model_providers:
59
+ azure:
60
+ name: Azure
61
+ base_url: https://YOUR_RESOURCE_NAME.openai.azure.com/openai
62
+ wire_api: responses
63
+ query_params:
64
+ api-version: 2025-04-01-preview
65
+ env_key: AZURE_OPENAI_API_KEY
66
+ ```
67
+
68
+ Keep API keys in environment variables, not YAML. In the example above, Codex reads the provider key from `AZURE_OPENAI_API_KEY`.
69
+
70
+ Callbacks are enabled by default. For each async session, this wrapper injects a session-scoped MCP server into Codex with two tools:
71
+
72
+ - `async_codex_ask_user`: blocking; Codex sends `message` plus optional `context` and waits until the async session is answered.
73
+ - `async_codex_notify_user`: non-blocking; Codex sends `message` plus optional `topic` and keeps working.
74
+
75
+ Use `answer-session` to respond when `session-status` reports `waiting_for_input`.
76
+
77
+ Disable callbacks globally:
78
+
79
+ ```yaml
80
+ callbacks:
81
+ enabled: false
82
+ ```
83
+
84
+ Or disable them for one configured tool:
85
+
86
+ ```yaml
87
+ tools:
88
+ codex-review:
89
+ description: Ask Codex to review code without making edits.
90
+ sandboxMode: read-only
91
+ approvalPolicy: never
92
+ callbacks:
93
+ enabled: false
94
+ ```
95
+
96
+ ## Run
97
+
98
+ ```bash
99
+ node dist/src/cli.js ./fixtures/async-codex-mcp.yaml
100
+ ```
101
+
102
+ Each configured profile becomes an MCP tool that accepts:
103
+
104
+ - `prompt` (required): prompt to send to Codex;
105
+ - `model` (optional): model override, for example `gpt-5.4-mini`;
106
+ - `cwd` (optional): working directory for the run.
107
+
108
+ The profile tool returns JSON with an async `session_id` and `running` status. Use `session-status` with that id to inspect completion state. When complete, use `continue-session` with the async session id and a new `prompt` to resume the underlying Codex session.
109
+
110
+ If a session is waiting for input, answer it with:
111
+
112
+ ```json
113
+ {
114
+ "session_id": "<async-session-id>",
115
+ "message": "Use staging."
116
+ }
117
+ ```
118
+
119
+ ## Claude Code plugin
120
+
121
+ This repo includes the `async-codex-mcp` Claude Code plugin under `plugins/async-codex-mcp`. The marketplace manifest lives in the dedicated Wyrd Company plugin marketplace repository.
122
+
123
+ ## Publishing
124
+
125
+ The package is published publicly to npm as `@wyrd-company/async-codex-mcp`. Publishing is handled by the `Publish Package` GitHub Actions workflow, which runs tests, builds the package, and publishes with the repository `NPM_TOKEN` secret.
126
+
127
+ Run it manually from GitHub Actions, or push a SemVer tag without a `v` prefix, for example `0.1.0`.
128
+
129
+ ## Development
130
+
131
+ ```bash
132
+ npm test
133
+ npm run build
134
+ ```
135
+
136
+ The test suite uses ThoughtSpot's `mcp-testing-kit` transport approach to exercise the MCP server directly and validates a `gpt-5.4-mini` model override without making network calls.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ const options = parseArgs(process.argv.slice(2));
6
+ const server = new McpServer({ name: "async-codex-mcp-callback", version: "0.1.0" }, {
7
+ instructions: "Use async_codex_ask_user only when you need a user answer before continuing. Use async_codex_notify_user for non-blocking progress updates or FYIs.",
8
+ });
9
+ server.tool("async_codex_ask_user", "Ask the user a blocking question. Codex waits until the user responds to the async session.", {
10
+ message: z.string().min(1).describe("The question or problem that needs a user response."),
11
+ context: z.string().optional().describe("Optional context explaining why the answer is needed."),
12
+ }, async ({ message, context }) => {
13
+ const result = await postCallback("/ask", { message, context });
14
+ return textResult(result.answer);
15
+ });
16
+ server.tool("async_codex_notify_user", "Send a non-blocking progress update or FYI to the user.", {
17
+ message: z.string().min(1).describe("The progress update or FYI to send."),
18
+ topic: z.string().optional().describe("Optional free-text topic for the notification."),
19
+ }, async ({ message, topic }) => {
20
+ await postCallback("/notify", { message, topic });
21
+ return textResult("Notification delivered.");
22
+ });
23
+ await server.connect(new StdioServerTransport());
24
+ function textResult(text) {
25
+ return { content: [{ type: "text", text }] };
26
+ }
27
+ async function postCallback(path, body) {
28
+ const response = await fetch(`${options.url}${path}`, {
29
+ method: "POST",
30
+ headers: {
31
+ authorization: `Bearer ${options.token}`,
32
+ "content-type": "application/json",
33
+ },
34
+ body: JSON.stringify({ ...body, session_id: options.sessionId }),
35
+ });
36
+ const json = (await response.json());
37
+ if (!response.ok) {
38
+ throw new Error(json.error ?? `Callback failed with HTTP ${response.status}.`);
39
+ }
40
+ return json;
41
+ }
42
+ function parseArgs(args) {
43
+ const values = new Map();
44
+ for (let index = 0; index < args.length; index += 2) {
45
+ const key = args[index];
46
+ const value = args[index + 1];
47
+ if (!key?.startsWith("--") || !value) {
48
+ throw new Error(`Invalid callback argument near ${key ?? "<end>"}.`);
49
+ }
50
+ values.set(key.slice(2), value);
51
+ }
52
+ const url = values.get("url");
53
+ const token = values.get("token");
54
+ const sessionId = values.get("session-id");
55
+ if (!url || !token || !sessionId) {
56
+ throw new Error("--url, --token, and --session-id are required.");
57
+ }
58
+ return { url, token, sessionId };
59
+ }
@@ -0,0 +1,28 @@
1
+ export type CallbackHubHandlers = {
2
+ ask(input: {
3
+ sessionId: string;
4
+ message: string;
5
+ context?: string;
6
+ }): Promise<string>;
7
+ notify(input: {
8
+ sessionId: string;
9
+ message: string;
10
+ topic?: string;
11
+ }): Promise<void>;
12
+ };
13
+ export type CallbackHubConnection = {
14
+ url: string;
15
+ token: string;
16
+ };
17
+ export declare class CallbackHub {
18
+ private readonly handlers;
19
+ private server?;
20
+ private connection?;
21
+ private readonly token;
22
+ private readonly sockets;
23
+ constructor(handlers: CallbackHubHandlers);
24
+ ensureStarted(): Promise<CallbackHubConnection>;
25
+ close(): Promise<void>;
26
+ private handle;
27
+ private writeJson;
28
+ }
@@ -0,0 +1,128 @@
1
+ import http from "node:http";
2
+ export class CallbackHub {
3
+ handlers;
4
+ server;
5
+ connection;
6
+ token = crypto.randomUUID();
7
+ sockets = new Set();
8
+ constructor(handlers) {
9
+ this.handlers = handlers;
10
+ }
11
+ async ensureStarted() {
12
+ if (this.connection)
13
+ return this.connection;
14
+ this.server = http.createServer((request, response) => {
15
+ void this.handle(request, response);
16
+ });
17
+ this.server.on("connection", (socket) => {
18
+ this.sockets.add(socket);
19
+ socket.on("close", () => this.sockets.delete(socket));
20
+ });
21
+ await new Promise((resolve, reject) => {
22
+ this.server?.once("error", reject);
23
+ this.server?.listen(0, "127.0.0.1", () => resolve());
24
+ });
25
+ const address = this.server.address();
26
+ if (!address || typeof address === "string") {
27
+ throw new Error("Callback hub did not bind to a TCP port.");
28
+ }
29
+ this.connection = {
30
+ url: `http://127.0.0.1:${address.port}`,
31
+ token: this.token,
32
+ };
33
+ return this.connection;
34
+ }
35
+ async close() {
36
+ if (!this.server)
37
+ return;
38
+ const server = this.server;
39
+ this.server = undefined;
40
+ this.connection = undefined;
41
+ for (const socket of this.sockets) {
42
+ socket.destroy();
43
+ }
44
+ this.sockets.clear();
45
+ await new Promise((resolve, reject) => {
46
+ server.close((error) => (error ? reject(error) : resolve()));
47
+ });
48
+ }
49
+ async handle(request, response) {
50
+ try {
51
+ if (request.method !== "POST") {
52
+ this.writeJson(response, 405, { error: "Only POST is supported." });
53
+ return;
54
+ }
55
+ if (request.headers.authorization !== `Bearer ${this.token}`) {
56
+ this.writeJson(response, 401, { error: "Invalid callback token." });
57
+ return;
58
+ }
59
+ const body = await readJson(request);
60
+ const parsed = parseCallbackRequest(body);
61
+ if (!parsed.ok) {
62
+ this.writeJson(response, 400, { error: parsed.error });
63
+ return;
64
+ }
65
+ if (request.url === "/ask") {
66
+ const answer = await this.handlers.ask({
67
+ sessionId: parsed.value.session_id,
68
+ message: parsed.value.message,
69
+ context: parsed.value.context,
70
+ });
71
+ this.writeJson(response, 200, { answer });
72
+ return;
73
+ }
74
+ if (request.url === "/notify") {
75
+ await this.handlers.notify({
76
+ sessionId: parsed.value.session_id,
77
+ message: parsed.value.message,
78
+ topic: parsed.value.topic,
79
+ });
80
+ this.writeJson(response, 200, { delivered: true });
81
+ return;
82
+ }
83
+ this.writeJson(response, 404, { error: "Unknown callback endpoint." });
84
+ }
85
+ catch (error) {
86
+ this.writeJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
87
+ }
88
+ }
89
+ writeJson(response, statusCode, body) {
90
+ response.writeHead(statusCode, { "content-type": "application/json" });
91
+ response.end(JSON.stringify(body));
92
+ }
93
+ }
94
+ async function readJson(request) {
95
+ const chunks = [];
96
+ for await (const chunk of request) {
97
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
98
+ }
99
+ const text = Buffer.concat(chunks).toString("utf8");
100
+ return text ? JSON.parse(text) : {};
101
+ }
102
+ function parseCallbackRequest(body) {
103
+ if (!body || typeof body !== "object") {
104
+ return { ok: false, error: "Request body must be an object." };
105
+ }
106
+ const value = body;
107
+ if (typeof value.session_id !== "string" || !value.session_id) {
108
+ return { ok: false, error: "session_id is required." };
109
+ }
110
+ if (typeof value.message !== "string" || !value.message) {
111
+ return { ok: false, error: "message is required." };
112
+ }
113
+ if (value.context !== undefined && typeof value.context !== "string") {
114
+ return { ok: false, error: "context must be a string." };
115
+ }
116
+ if (value.topic !== undefined && typeof value.topic !== "string") {
117
+ return { ok: false, error: "topic must be a string." };
118
+ }
119
+ return {
120
+ ok: true,
121
+ value: {
122
+ session_id: value.session_id,
123
+ message: value.message,
124
+ context: value.context,
125
+ topic: value.topic,
126
+ },
127
+ };
128
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { loadConfig } from "./config.js";
4
+ import { createServer } from "./server.js";
5
+ const configPath = process.argv[2];
6
+ const config = loadConfig(configPath);
7
+ const server = createServer(config);
8
+ await server.connect(new StdioServerTransport());
@@ -0,0 +1,22 @@
1
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
+ import type { AsyncCodexConfig, ToolProfile } from "./config.js";
3
+ export type CodexToolArguments = {
4
+ prompt: string;
5
+ model?: string;
6
+ cwd?: string;
7
+ };
8
+ export type CodexClientLike = {
9
+ callCodex(profile: ToolProfile, args: CodexToolArguments): Promise<CallToolResult>;
10
+ continueSession(sessionId: string, prompt: string, cwd?: string): Promise<CallToolResult>;
11
+ close(): Promise<void>;
12
+ };
13
+ export declare class CodexMcpClient implements CodexClientLike {
14
+ private readonly config;
15
+ private client?;
16
+ private transport?;
17
+ constructor(config: AsyncCodexConfig);
18
+ callCodex(profile: ToolProfile, args: CodexToolArguments): Promise<CallToolResult>;
19
+ continueSession(sessionId: string, prompt: string, cwd?: string): Promise<CallToolResult>;
20
+ close(): Promise<void>;
21
+ private getClient;
22
+ }
@@ -0,0 +1,59 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
+ export class CodexMcpClient {
4
+ config;
5
+ client;
6
+ transport;
7
+ constructor(config) {
8
+ this.config = config;
9
+ }
10
+ async callCodex(profile, args) {
11
+ const client = await this.getClient();
12
+ const codexArgs = {
13
+ prompt: args.prompt,
14
+ sandbox: profile.sandboxMode,
15
+ "approval-policy": profile.approvalPolicy,
16
+ };
17
+ const model = args.model ?? profile.model;
18
+ if (model)
19
+ codexArgs.model = model;
20
+ if (args.cwd)
21
+ codexArgs.cwd = args.cwd;
22
+ if (profile.baseInstructions)
23
+ codexArgs["base-instructions"] = profile.baseInstructions;
24
+ if (profile.compactPrompt)
25
+ codexArgs["compact-prompt"] = profile.compactPrompt;
26
+ if (profile.developerInstructions)
27
+ codexArgs["developer-instructions"] = profile.developerInstructions;
28
+ if (Object.keys(profile.config).length > 0)
29
+ codexArgs.config = profile.config;
30
+ return client.callTool({ name: "codex", arguments: codexArgs });
31
+ }
32
+ async continueSession(sessionId, prompt, cwd) {
33
+ const client = await this.getClient();
34
+ const args = { threadId: sessionId, prompt };
35
+ if (cwd)
36
+ args.cwd = cwd;
37
+ return client.callTool({ name: "codex-reply", arguments: args });
38
+ }
39
+ async close() {
40
+ await this.client?.close();
41
+ await this.transport?.close();
42
+ this.client = undefined;
43
+ this.transport = undefined;
44
+ }
45
+ async getClient() {
46
+ if (this.client)
47
+ return this.client;
48
+ this.client = new Client({ name: "async-codex-mcp-client", version: "0.1.0" });
49
+ this.transport = new StdioClientTransport({
50
+ command: this.config.codex.command,
51
+ args: this.config.codex.args,
52
+ env: { ...process.env, ...this.config.codex.env },
53
+ cwd: this.config.codex.cwd,
54
+ stderr: "inherit",
55
+ });
56
+ await this.client.connect(this.transport);
57
+ return this.client;
58
+ }
59
+ }
@@ -0,0 +1,42 @@
1
+ import { z } from "zod";
2
+ declare const profileSchema: z.ZodObject<{
3
+ description: z.ZodOptional<z.ZodString>;
4
+ model: z.ZodOptional<z.ZodString>;
5
+ approvalPolicy: z.ZodDefault<z.ZodString>;
6
+ sandboxMode: z.ZodDefault<z.ZodString>;
7
+ baseInstructions: z.ZodOptional<z.ZodString>;
8
+ compactPrompt: z.ZodOptional<z.ZodString>;
9
+ developerInstructions: z.ZodOptional<z.ZodString>;
10
+ config: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
11
+ callbacks: z.ZodOptional<z.ZodObject<{
12
+ enabled: z.ZodBoolean;
13
+ }, z.core.$strict>>;
14
+ }, z.core.$strict>;
15
+ declare const configSchema: z.ZodObject<{
16
+ codex: z.ZodDefault<z.ZodObject<{
17
+ command: z.ZodDefault<z.ZodString>;
18
+ args: z.ZodDefault<z.ZodArray<z.ZodString>>;
19
+ env: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
20
+ cwd: z.ZodOptional<z.ZodString>;
21
+ }, z.core.$strip>>;
22
+ callbacks: z.ZodDefault<z.ZodObject<{
23
+ enabled: z.ZodDefault<z.ZodBoolean>;
24
+ }, z.core.$strict>>;
25
+ tools: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
26
+ description: z.ZodOptional<z.ZodString>;
27
+ model: z.ZodOptional<z.ZodString>;
28
+ approvalPolicy: z.ZodDefault<z.ZodString>;
29
+ sandboxMode: z.ZodDefault<z.ZodString>;
30
+ baseInstructions: z.ZodOptional<z.ZodString>;
31
+ compactPrompt: z.ZodOptional<z.ZodString>;
32
+ developerInstructions: z.ZodOptional<z.ZodString>;
33
+ config: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
34
+ callbacks: z.ZodOptional<z.ZodObject<{
35
+ enabled: z.ZodBoolean;
36
+ }, z.core.$strict>>;
37
+ }, z.core.$strict>>>;
38
+ }, z.core.$strip>;
39
+ export type AsyncCodexConfig = z.infer<typeof configSchema>;
40
+ export type ToolProfile = z.infer<typeof profileSchema>;
41
+ export declare function loadConfig(configPath?: string): AsyncCodexConfig;
42
+ export {};
@@ -0,0 +1,54 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import yaml from "js-yaml";
4
+ import { z } from "zod";
5
+ const stringRecordSchema = z.record(z.string(), z.string());
6
+ const unknownRecordSchema = z.record(z.string(), z.unknown());
7
+ const codexServerSchema = z.object({
8
+ command: z.string().default("codex"),
9
+ args: z.array(z.string()).default(["mcp-server"]),
10
+ env: stringRecordSchema.default({}),
11
+ cwd: z.string().optional(),
12
+ });
13
+ const callbacksSchema = z.object({
14
+ enabled: z.boolean().default(true),
15
+ }).strict();
16
+ const profileCallbacksSchema = z.object({
17
+ enabled: z.boolean(),
18
+ }).strict();
19
+ const profileSchema = z.object({
20
+ description: z.string().optional(),
21
+ model: z.string().optional(),
22
+ approvalPolicy: z.string().default("never"),
23
+ sandboxMode: z.string().default("danger-full-access"),
24
+ baseInstructions: z.string().optional(),
25
+ compactPrompt: z.string().optional(),
26
+ developerInstructions: z.string().optional(),
27
+ config: unknownRecordSchema.default({}),
28
+ callbacks: profileCallbacksSchema.optional(),
29
+ }).strict();
30
+ const configSchema = z.object({
31
+ codex: codexServerSchema.default({ command: "codex", args: ["mcp-server"], env: {} }),
32
+ callbacks: callbacksSchema.default({ enabled: true }),
33
+ tools: z.record(z.string(), profileSchema).default({
34
+ codex: {
35
+ description: "Run Codex asynchronously with danger-full-access sandboxing.",
36
+ sandboxMode: "danger-full-access",
37
+ approvalPolicy: "never",
38
+ config: {},
39
+ },
40
+ }),
41
+ });
42
+ export function loadConfig(configPath) {
43
+ const resolvedPath = configPath ?? process.env.ASYNC_CODEX_MCP_CONFIG;
44
+ if (!resolvedPath) {
45
+ return configSchema.parse({});
46
+ }
47
+ const file = fs.readFileSync(resolvedPath, "utf8");
48
+ const loaded = yaml.load(file) ?? {};
49
+ const parsed = configSchema.parse(loaded);
50
+ if (parsed.codex.cwd && !path.isAbsolute(parsed.codex.cwd)) {
51
+ parsed.codex.cwd = path.resolve(path.dirname(resolvedPath), parsed.codex.cwd);
52
+ }
53
+ return parsed;
54
+ }
@@ -0,0 +1,4 @@
1
+ export { loadConfig, type AsyncCodexConfig, type ToolProfile } from "./config.js";
2
+ export { createServer, type CreateServerOptions } from "./server.js";
3
+ export { CodexMcpClient, type CodexClientLike } from "./codex-client.js";
4
+ export { SessionStore, type SessionRecord, type SessionStatus } from "./session-store.js";
@@ -0,0 +1,4 @@
1
+ export { loadConfig } from "./config.js";
2
+ export { createServer } from "./server.js";
3
+ export { CodexMcpClient } from "./codex-client.js";
4
+ export { SessionStore } from "./session-store.js";
@@ -0,0 +1,9 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { type CodexClientLike } from "./codex-client.js";
3
+ import type { AsyncCodexConfig } from "./config.js";
4
+ import { SessionStore } from "./session-store.js";
5
+ export type CreateServerOptions = {
6
+ client?: CodexClientLike;
7
+ store?: SessionStore;
8
+ };
9
+ export declare function createServer(config: AsyncCodexConfig, options?: CreateServerOptions): McpServer;
@@ -0,0 +1,231 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { z } from "zod";
5
+ import { CallbackHub } from "./callback-hub.js";
6
+ import { CodexMcpClient } from "./codex-client.js";
7
+ import { SessionStore } from "./session-store.js";
8
+ const runShape = {
9
+ prompt: z.string().min(1).describe("Prompt to send to Codex."),
10
+ model: z.string().optional().describe("Optional model override for this run."),
11
+ cwd: z.string().optional().describe("Optional working directory for Codex."),
12
+ };
13
+ const continueShape = {
14
+ session_id: z.string().min(1).describe("Async session id returned by a profile tool."),
15
+ prompt: z.string().min(1).describe("Prompt to continue the completed Codex session."),
16
+ cwd: z.string().optional().describe("Optional working directory for Codex."),
17
+ };
18
+ const answerShape = {
19
+ session_id: z.string().min(1).describe("Async session id waiting for user input."),
20
+ message: z.string().min(1).describe("User response to return to Codex."),
21
+ };
22
+ export function createServer(config, options = {}) {
23
+ const server = new McpServer({ name: "async-codex-mcp", version: "0.1.0" }, {
24
+ capabilities: { logging: {} },
25
+ instructions: "Starts Codex sub-agent sessions asynchronously. Profile tools return immediately with an async session id; use continue-session after completion to resume.",
26
+ });
27
+ const client = options.client ?? new CodexMcpClient(config);
28
+ const store = options.store ?? new SessionStore();
29
+ const callbackHub = new CallbackHub({
30
+ ask: async ({ sessionId, message, context }) => {
31
+ const ask = store.ask(sessionId, { message, context });
32
+ await sendCallbackNotification(server, sessionId, "ask", message, { context });
33
+ return ask.response;
34
+ },
35
+ notify: async ({ sessionId, message, topic }) => {
36
+ store.notify(sessionId, { message, topic });
37
+ await sendCallbackNotification(server, sessionId, "notify", message, { topic });
38
+ },
39
+ });
40
+ let closeServicesPromise;
41
+ const closeServices = () => {
42
+ closeServicesPromise ??= Promise.all([client.close(), callbackHub.close()]).then(() => undefined);
43
+ return closeServicesPromise;
44
+ };
45
+ const originalOnClose = server.server.onclose;
46
+ server.server.onclose = () => {
47
+ originalOnClose?.();
48
+ void closeServices().catch((error) => {
49
+ server.server.onerror?.(error instanceof Error ? error : new Error(String(error)));
50
+ });
51
+ };
52
+ const originalServerClose = server.server.close.bind(server.server);
53
+ server.server.close = async () => {
54
+ await originalServerClose();
55
+ await closeServices();
56
+ };
57
+ const originalClose = server.close.bind(server);
58
+ server.close = async () => {
59
+ await originalClose();
60
+ await closeServices();
61
+ };
62
+ for (const [name, profile] of Object.entries(config.tools)) {
63
+ server.tool(name, profile.description ?? `Start an asynchronous Codex session using the ${name} profile.`, runShape, async ({ prompt, model, cwd }) => {
64
+ const session = store.create({ toolName: name, prompt, model, cwd });
65
+ const effectiveProfile = await prepareProfile(config, profile, session.id, callbackHub);
66
+ void client
67
+ .callCodex(effectiveProfile, { prompt, model, cwd })
68
+ .then(async (result) => {
69
+ if (result.isError) {
70
+ const message = errorMessageFromResult(result);
71
+ store.update(session.id, { status: "failed", result, error: message });
72
+ await sendSessionNotification(server, session.id, "failed", undefined, message);
73
+ return;
74
+ }
75
+ const codexSessionId = extractCodexSessionId(result);
76
+ store.update(session.id, { status: "completed", result, codexSessionId });
77
+ await sendSessionNotification(server, session.id, "completed", codexSessionId);
78
+ })
79
+ .catch(async (error) => {
80
+ const message = error instanceof Error ? error.message : String(error);
81
+ store.update(session.id, { status: "failed", error: message });
82
+ await sendSessionNotification(server, session.id, "failed", undefined, message);
83
+ });
84
+ return textResult(JSON.stringify({
85
+ session_id: session.id,
86
+ status: session.status,
87
+ message: "Codex session started. Watch notifications/message for completion.",
88
+ }, null, 2));
89
+ });
90
+ }
91
+ server.tool("session-status", "Inspect an asynchronous Codex session by id.", { session_id: z.string().min(1).describe("Async session id returned by a profile tool.") }, async ({ session_id }) => {
92
+ const session = store.get(session_id);
93
+ if (!session) {
94
+ return textResult(`Unknown session: ${session_id}`, true);
95
+ }
96
+ return textResult(JSON.stringify({
97
+ id: session.id,
98
+ toolName: session.toolName,
99
+ status: session.status,
100
+ createdAt: session.createdAt,
101
+ updatedAt: session.updatedAt,
102
+ codexSessionId: session.codexSessionId,
103
+ error: session.error,
104
+ messages: session.messages,
105
+ pendingAskId: session.pendingAskId,
106
+ result: session.result,
107
+ }, null, 2));
108
+ });
109
+ server.tool("continue-session", "Resume a completed async Codex session.", continueShape, async ({ session_id, prompt, cwd }) => {
110
+ const session = store.get(session_id);
111
+ if (!session)
112
+ return textResult(`Unknown session: ${session_id}`, true);
113
+ if (session.status !== "completed")
114
+ return textResult(`Session ${session_id} is ${session.status}; only completed sessions can be continued.`, true);
115
+ if (!session.codexSessionId)
116
+ return textResult(`Session ${session_id} did not expose a Codex session id.`, true);
117
+ try {
118
+ const result = await client.continueSession(session.codexSessionId, prompt, cwd ?? session.cwd);
119
+ return result;
120
+ }
121
+ catch (error) {
122
+ const message = error instanceof Error ? error.message : String(error);
123
+ return textResult(message, true);
124
+ }
125
+ });
126
+ server.tool("answer-session", "Answer a Codex question for an async session waiting for input.", answerShape, async ({ session_id, message }) => {
127
+ const session = store.get(session_id);
128
+ if (!session)
129
+ return textResult(`Unknown session: ${session_id}`, true);
130
+ if (session.status !== "waiting_for_input")
131
+ return textResult(`Session ${session_id} is ${session.status}; only waiting_for_input sessions can be answered.`, true);
132
+ try {
133
+ const answered = store.answer(session_id, message);
134
+ return textResult(JSON.stringify({
135
+ session_id,
136
+ answered_message_id: answered.id,
137
+ status: store.get(session_id)?.status,
138
+ }, null, 2));
139
+ }
140
+ catch (error) {
141
+ const errorMessage = error instanceof Error ? error.message : String(error);
142
+ return textResult(errorMessage, true);
143
+ }
144
+ });
145
+ return server;
146
+ }
147
+ function textResult(text, isError = false) {
148
+ return { content: [{ type: "text", text }], isError };
149
+ }
150
+ function errorMessageFromResult(result) {
151
+ const text = result.content
152
+ .filter((item) => item.type === "text")
153
+ .map((item) => item.text)
154
+ .join("\n")
155
+ .trim();
156
+ return text || "Codex returned an error result.";
157
+ }
158
+ async function sendSessionNotification(server, sessionId, status, codexSessionId, error) {
159
+ await server.server.sendLoggingMessage({
160
+ level: status === "completed" ? "notice" : "error",
161
+ logger: "async-codex-mcp",
162
+ data: { session_id: sessionId, status, codex_session_id: codexSessionId, error },
163
+ });
164
+ }
165
+ async function sendCallbackNotification(server, sessionId, type, message, extra) {
166
+ await server.server.sendLoggingMessage({
167
+ level: type === "ask" ? "warning" : "info",
168
+ logger: "async-codex-mcp",
169
+ data: { session_id: sessionId, type, message, ...extra },
170
+ });
171
+ }
172
+ async function prepareProfile(config, profile, sessionId, callbackHub) {
173
+ if (!callbacksEnabled(config, profile)) {
174
+ return profile;
175
+ }
176
+ const connection = await callbackHub.ensureStarted();
177
+ return {
178
+ ...profile,
179
+ developerInstructions: appendCallbackInstructions(profile.developerInstructions),
180
+ config: {
181
+ ...profile.config,
182
+ mcp_servers: {
183
+ ...recordValue(profile.config.mcp_servers),
184
+ async_codex_mcp_callback: {
185
+ command: process.execPath,
186
+ args: [
187
+ callbackCliPath(),
188
+ "--url",
189
+ connection.url,
190
+ "--token",
191
+ connection.token,
192
+ "--session-id",
193
+ sessionId,
194
+ ],
195
+ },
196
+ },
197
+ },
198
+ };
199
+ }
200
+ function callbacksEnabled(config, profile) {
201
+ return profile.callbacks?.enabled ?? config.callbacks.enabled;
202
+ }
203
+ function appendCallbackInstructions(existing) {
204
+ const callbackInstructions = "You have two callback tools for communicating with the user during this async Codex session. " +
205
+ "Call async_codex_ask_user with message and optional context only when you need a user answer before continuing; the tool blocks until the user answers. " +
206
+ "Call async_codex_notify_user with message and optional topic for non-blocking progress updates, warnings, or FYIs.";
207
+ return existing ? `${existing}\n\n${callbackInstructions}` : callbackInstructions;
208
+ }
209
+ function callbackCliPath() {
210
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), "callback-cli.js");
211
+ }
212
+ function recordValue(value) {
213
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
214
+ }
215
+ function extractCodexSessionId(result) {
216
+ const meta = result._meta;
217
+ for (const key of ["threadId", "session_id", "sessionId", "codex_session_id", "codexSessionId"]) {
218
+ const value = meta?.[key];
219
+ if (typeof value === "string")
220
+ return value;
221
+ }
222
+ const structured = result.structuredContent;
223
+ if (typeof structured?.threadId === "string")
224
+ return structured.threadId;
225
+ const text = result.content
226
+ .filter((item) => item.type === "text")
227
+ .map((item) => item.text)
228
+ .join("\n");
229
+ const match = text.match(/(?:thread[_ -]?id|session[_ -]?id|codex[_ -]?session[_ -]?id)["'`:\s]+([a-zA-Z0-9_-]+)/i);
230
+ return match?.[1];
231
+ }
@@ -0,0 +1,47 @@
1
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
+ export type SessionStatus = "running" | "waiting_for_input" | "completed" | "failed";
3
+ export type SessionMessage = {
4
+ id: string;
5
+ type: "ask" | "notify";
6
+ message: string;
7
+ context?: string;
8
+ topic?: string;
9
+ createdAt: string;
10
+ answeredAt?: string;
11
+ response?: string;
12
+ };
13
+ export type SessionRecord = {
14
+ id: string;
15
+ toolName: string;
16
+ prompt: string;
17
+ model?: string;
18
+ cwd?: string;
19
+ status: SessionStatus;
20
+ createdAt: string;
21
+ updatedAt: string;
22
+ codexSessionId?: string;
23
+ result?: CallToolResult;
24
+ error?: string;
25
+ messages: SessionMessage[];
26
+ pendingAskId?: string;
27
+ };
28
+ export declare class SessionStore {
29
+ readonly sessions: Map<string, SessionRecord>;
30
+ private readonly pendingAskResolvers;
31
+ create(input: Pick<SessionRecord, "toolName" | "prompt" | "model" | "cwd">): SessionRecord;
32
+ get(id: string): SessionRecord | undefined;
33
+ update(id: string, patch: Partial<Omit<SessionRecord, "id" | "createdAt">>): SessionRecord;
34
+ notify(sessionId: string, input: {
35
+ message: string;
36
+ topic?: string;
37
+ }): SessionMessage;
38
+ ask(sessionId: string, input: {
39
+ message: string;
40
+ context?: string;
41
+ }): {
42
+ message: SessionMessage;
43
+ response: Promise<string>;
44
+ };
45
+ answer(sessionId: string, response: string): SessionMessage;
46
+ private require;
47
+ }
@@ -0,0 +1,91 @@
1
+ export class SessionStore {
2
+ sessions = new Map();
3
+ pendingAskResolvers = new Map();
4
+ create(input) {
5
+ const now = new Date().toISOString();
6
+ const session = {
7
+ ...input,
8
+ id: crypto.randomUUID(),
9
+ status: "running",
10
+ createdAt: now,
11
+ updatedAt: now,
12
+ messages: [],
13
+ };
14
+ this.sessions.set(session.id, session);
15
+ return session;
16
+ }
17
+ get(id) {
18
+ return this.sessions.get(id);
19
+ }
20
+ update(id, patch) {
21
+ const session = this.sessions.get(id);
22
+ if (!session) {
23
+ throw new Error(`Unknown session: ${id}`);
24
+ }
25
+ Object.assign(session, patch, { updatedAt: new Date().toISOString() });
26
+ return session;
27
+ }
28
+ notify(sessionId, input) {
29
+ const session = this.require(sessionId);
30
+ const now = new Date().toISOString();
31
+ const message = {
32
+ id: crypto.randomUUID(),
33
+ type: "notify",
34
+ message: input.message,
35
+ topic: input.topic,
36
+ createdAt: now,
37
+ };
38
+ session.messages.push(message);
39
+ session.updatedAt = now;
40
+ return message;
41
+ }
42
+ ask(sessionId, input) {
43
+ const session = this.require(sessionId);
44
+ if (session.pendingAskId) {
45
+ throw new Error(`Session ${sessionId} is already waiting for input.`);
46
+ }
47
+ const now = new Date().toISOString();
48
+ const message = {
49
+ id: crypto.randomUUID(),
50
+ type: "ask",
51
+ message: input.message,
52
+ context: input.context,
53
+ createdAt: now,
54
+ };
55
+ session.messages.push(message);
56
+ session.pendingAskId = message.id;
57
+ session.status = "waiting_for_input";
58
+ session.updatedAt = now;
59
+ const response = new Promise((resolve) => {
60
+ this.pendingAskResolvers.set(message.id, resolve);
61
+ });
62
+ return { message, response };
63
+ }
64
+ answer(sessionId, response) {
65
+ const session = this.require(sessionId);
66
+ if (!session.pendingAskId) {
67
+ throw new Error(`Session ${sessionId} is not waiting for input.`);
68
+ }
69
+ const message = session.messages.find((item) => item.id === session.pendingAskId);
70
+ if (!message) {
71
+ throw new Error(`Session ${sessionId} pending question was not found.`);
72
+ }
73
+ const now = new Date().toISOString();
74
+ message.response = response;
75
+ message.answeredAt = now;
76
+ session.pendingAskId = undefined;
77
+ session.status = "running";
78
+ session.updatedAt = now;
79
+ const resolve = this.pendingAskResolvers.get(message.id);
80
+ this.pendingAskResolvers.delete(message.id);
81
+ resolve?.(response);
82
+ return message;
83
+ }
84
+ require(id) {
85
+ const session = this.sessions.get(id);
86
+ if (!session) {
87
+ throw new Error(`Unknown session: ${id}`);
88
+ }
89
+ return session;
90
+ }
91
+ }
@@ -0,0 +1,12 @@
1
+ codex:
2
+ command: codex
3
+ args: [mcp-server]
4
+ tools:
5
+ codex-write:
6
+ description: Run Codex asynchronously with full filesystem access.
7
+ sandboxMode: danger-full-access
8
+ approvalPolicy: never
9
+ codex-review:
10
+ description: Ask Codex to review code without making edits.
11
+ sandboxMode: read-only
12
+ approvalPolicy: never
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@wyrd-company/async-codex-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Async MCP proxy for Codex MCP server profiles",
5
+ "type": "module",
6
+ "bin": {
7
+ "async-codex-mcp": "dist/src/cli.js"
8
+ },
9
+ "main": "dist/src/index.js",
10
+ "exports": {
11
+ ".": "./dist/src/index.js"
12
+ },
13
+ "scripts": {
14
+ "build": "tsc -p tsconfig.json",
15
+ "prepack": "npm run build",
16
+ "test": "vitest run",
17
+ "start": "node dist/src/cli.js"
18
+ },
19
+ "files": [
20
+ "dist/src",
21
+ "fixtures",
22
+ "plugins/async-codex-mcp"
23
+ ],
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/wyrd-company/async-codex-mcp.git"
27
+ },
28
+ "publishConfig": {
29
+ "registry": "https://registry.npmjs.org/",
30
+ "access": "public"
31
+ },
32
+ "dependencies": {
33
+ "@modelcontextprotocol/sdk": "^1.29.0",
34
+ "js-yaml": "^4.1.1",
35
+ "zod": "^4.4.3"
36
+ },
37
+ "devDependencies": {
38
+ "@openai/codex": "^0.135.0",
39
+ "@types/js-yaml": "^4.0.9",
40
+ "@types/node": "^22.15.3",
41
+ "mcp-testing-kit": "github:thoughtspot/mcp-testing-kit",
42
+ "typescript": "^5.8.3",
43
+ "vitest": "^3.1.2"
44
+ }
45
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "async-codex-mcp",
3
+ "displayName": "Async Codex MCP",
4
+ "version": "0.1.0",
5
+ "description": "Expose async Codex MCP profile tools to Claude Code.",
6
+ "author": {
7
+ "name": "Wyrd Company"
8
+ },
9
+ "repository": "https://github.com/wyrd-company/async-codex-mcp",
10
+ "keywords": ["codex", "mcp", "async"],
11
+ "mcpServers": "./.mcp.json"
12
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "mcpServers": {
3
+ "async-codex-mcp": {
4
+ "command": "npx",
5
+ "args": [
6
+ "--yes",
7
+ "@wyrd-company/async-codex-mcp"
8
+ ],
9
+ "env": {}
10
+ }
11
+ }
12
+ }