@sghanavati/relay-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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ghanavati
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,189 @@
1
+ # relay-mcp
2
+ [![npm version](https://img.shields.io/npm/v/@sghanavati/relay-mcp.svg)](https://www.npmjs.com/package/@sghanavati/relay-mcp)
3
+ [![Node.js](https://img.shields.io/node/v/@sghanavati/relay-mcp.svg)](https://nodejs.org)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
5
+
6
+ MCP server that lets Claude Code delegate coding tasks to Codex.
7
+
8
+ > **Prerequisites:** Codex CLI >= 0.39.0 must be installed (`npm install -g @openai/codex`) and authenticated (`codex login`).
9
+
10
+ ## Quick Start
11
+ Add this to your Claude Code `.mcp.json`, then restart Claude Code:
12
+
13
+ ```json
14
+ {
15
+ "mcpServers": {
16
+ "relay-mcp": {
17
+ "type": "stdio",
18
+ "command": "npx",
19
+ "args": ["-y", "@sghanavati/relay-mcp"]
20
+ }
21
+ }
22
+ }
23
+ ```
24
+
25
+ ## Installation
26
+ `@sghanavati/relay-mcp` is a zero-install MCP server when launched with `npx`.
27
+
28
+ - No global install of `relay-mcp` is required.
29
+ - `npx -y @sghanavati/relay-mcp` downloads and runs the published package directly.
30
+ - You only need to keep Codex CLI installed and authenticated locally.
31
+
32
+ ## Claude Code Registration
33
+ ### npx (recommended)
34
+ Use this in your `.mcp.json`:
35
+
36
+ ```json
37
+ {
38
+ "mcpServers": {
39
+ "relay-mcp": {
40
+ "type": "stdio",
41
+ "command": "npx",
42
+ "args": ["-y", "@sghanavati/relay-mcp"]
43
+ }
44
+ }
45
+ }
46
+ ```
47
+
48
+ ### local dev variant (from repository root)
49
+ Use this when iterating on local source before publishing:
50
+
51
+ ```json
52
+ {
53
+ "mcpServers": {
54
+ "relay-mcp": {
55
+ "type": "stdio",
56
+ "command": "node",
57
+ "args": ["dist/index.js"]
58
+ }
59
+ }
60
+ }
61
+ ```
62
+
63
+ ### Timeout strategy (DOCS-03)
64
+ `relay-mcp` accepts `timeout_ms` per delegate call. Use this strategy:
65
+
66
+ - Keep server registration static in `.mcp.json`.
67
+ - Set timeouts per task based on expected scope.
68
+ - Start with `300000` (5 minutes) for medium tasks.
69
+ - Default timeout is `600000` (10 minutes) when omitted.
70
+
71
+ Example delegate call with explicit timeout:
72
+
73
+ ```javascript
74
+ delegate({
75
+ task: "Refactor auth middleware and update tests",
76
+ workdir: "/path/to/repo",
77
+ timeout_ms: 300000
78
+ });
79
+ ```
80
+
81
+ ## Claude Desktop Registration
82
+ On macOS, edit:
83
+ `~/Library/Application Support/Claude/claude_desktop_config.json`
84
+
85
+ ```json
86
+ {
87
+ "mcpServers": {
88
+ "relay-mcp": {
89
+ "command": "npx",
90
+ "args": ["-y", "@sghanavati/relay-mcp"],
91
+ "env": {
92
+ "RELAY_CODEX_PATH": "/usr/local/bin/codex"
93
+ }
94
+ }
95
+ }
96
+ }
97
+ ```
98
+
99
+ Find your Codex binary path with:
100
+
101
+ ```bash
102
+ which codex
103
+ ```
104
+
105
+ Claude Desktop does not inherit your shell `PATH` reliably, so `RELAY_CODEX_PATH` should be set explicitly.
106
+
107
+ ## Usage
108
+ In Claude Code, ask for a delegated action, for example:
109
+
110
+ ```text
111
+ Use the delegate tool to add a README to /path/to/my-project
112
+ ```
113
+
114
+ Claude Code will issue a tool call like:
115
+
116
+ ```javascript
117
+ delegate({
118
+ task: "Add a README.md with setup instructions",
119
+ workdir: "/path/to/my-project"
120
+ });
121
+ ```
122
+
123
+ Typical response payload:
124
+
125
+ ```json
126
+ {
127
+ "status": "success",
128
+ "output": "...",
129
+ "files_changed": ["README.md"],
130
+ "meta": {
131
+ "duration_ms": 45000,
132
+ "truncated": false,
133
+ "warnings": [],
134
+ "model": null,
135
+ "token_estimate": 1200,
136
+ "exit_code": 0
137
+ }
138
+ }
139
+ ```
140
+
141
+ ## Configuration
142
+ | Variable | Default | Description |
143
+ | --- | --- | --- |
144
+ | `RELAY_CODEX_PATH` | `codex` from `PATH` | Full path to Codex binary for PATH-limited environments (especially Claude Desktop). |
145
+ | `RELAY_LOG_LEVEL` | `info` | Startup log verbosity control. `error` suppresses the ready banner; other values show it. |
146
+
147
+ ## Troubleshooting
148
+ ### 1) PATH issues (Codex not found)
149
+ Symptom:
150
+ `Error: Codex binary not found ...`
151
+
152
+ Fix:
153
+
154
+ ```bash
155
+ which codex
156
+ ```
157
+
158
+ Then add that path to `RELAY_CODEX_PATH` in your MCP server config `env` block.
159
+
160
+ ### 2) Auth failures
161
+ Symptom:
162
+ `Error: Codex not authenticated`
163
+
164
+ Fix:
165
+
166
+ ```bash
167
+ codex login
168
+ ```
169
+
170
+ Then restart Claude Code or Claude Desktop.
171
+
172
+ ### 3) Network/DNS failures
173
+ Symptom:
174
+ `status: "error"` with `error.code: "CODEX_ERROR"` during delegation.
175
+
176
+ Fix:
177
+
178
+ - Retry the same delegate call.
179
+ - Confirm network/DNS connectivity and any proxy requirements.
180
+ - Re-run `codex login` if your session may have expired.
181
+
182
+ ## Security
183
+ - `relay-mcp` only delegates work inside the `workdir` you pass to `delegate`.
184
+ - `workdir` values are canonicalized with `path.resolve()` before subprocess execution (SAFE-01 / CVE-2025-59532 mitigation).
185
+ - Codex runs with `--full-auto` and can read/write files in that delegated directory.
186
+ - `relay-mcp` does not store credentials, API keys, or session tokens; authentication remains in Codex CLI.
187
+
188
+ ## License
189
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,9 @@
1
+ // Per-workdir mutex: prevent concurrent Codex runs in the same directory.
2
+ import { Mutex } from "async-mutex";
3
+ const workdirLocks = new Map();
4
+ export function getWorkdirMutex(workdir) {
5
+ if (!workdirLocks.has(workdir)) {
6
+ workdirLocks.set(workdir, new Mutex());
7
+ }
8
+ return workdirLocks.get(workdir);
9
+ }
@@ -0,0 +1,8 @@
1
+ /** Default Codex delegation timeout: 10 minutes */
2
+ export const DEFAULT_TIMEOUT_MS = 600_000;
3
+ /** Token count at which a warning is added to meta.warnings */
4
+ export const TOKEN_WARN_THRESHOLD = 10_000;
5
+ /** Hard cap: output truncated at this token count */
6
+ export const TOKEN_HARD_CAP = 25_000;
7
+ /** Minimum supported Codex CLI version */
8
+ export const MIN_CODEX_VERSION = [0, 39, 0];
@@ -0,0 +1,9 @@
1
+ const LOG_LEVELS = ["debug", "info", "warn", "error"];
2
+ export function getCodexBin() {
3
+ const configuredPath = process.env.RELAY_CODEX_PATH?.trim();
4
+ return configuredPath ? configuredPath : "codex";
5
+ }
6
+ export function getRelayLogLevel() {
7
+ const rawLevel = (process.env.RELAY_LOG_LEVEL ?? "info").trim().toLowerCase();
8
+ return LOG_LEVELS.includes(rawLevel) ? rawLevel : "info";
9
+ }
@@ -0,0 +1,26 @@
1
+ import { z } from "zod";
2
+ const delegateSchemaShape = {
3
+ task: z.string().min(1).describe("The coding task to delegate to the worker"),
4
+ workdir: z.string().describe("Absolute path to the working directory"),
5
+ provider: z
6
+ .enum(["codex"])
7
+ .optional()
8
+ .default("codex")
9
+ .describe("Worker provider (currently only codex)"),
10
+ context: z
11
+ .string()
12
+ .optional()
13
+ .describe("Additional context prepended to the task before delegation"),
14
+ timeout_ms: z
15
+ .number()
16
+ .int()
17
+ .positive()
18
+ .optional()
19
+ .describe("Wall-clock timeout in milliseconds (default: 600000)"),
20
+ model: z
21
+ .string()
22
+ .optional()
23
+ .describe("Model override for the provider (e.g., o4-mini for Codex)"),
24
+ };
25
+ const delegateArgsSchema = z.object(delegateSchemaShape);
26
+ export const delegateSchema = delegateArgsSchema.shape;
package/dist/errors.js ADDED
@@ -0,0 +1,8 @@
1
+ // All failure modes return RelayError - never plain strings (RELY-01)
2
+ /**
3
+ * Factory for structured errors. The `retryable` flag guides CC on whether
4
+ * to re-invoke the delegate tool automatically.
5
+ */
6
+ export function makeError(code, message, retryable) {
7
+ return { code, message, retryable };
8
+ }
@@ -0,0 +1,41 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFileAsync = promisify(execFile);
4
+ export async function takeSnapshot(workdir) {
5
+ try {
6
+ const { stdout } = await execFileAsync("git", ["status", "--porcelain=v1"], {
7
+ cwd: workdir,
8
+ });
9
+ const snapshot = new Map();
10
+ for (const line of stdout.split("\n")) {
11
+ if (!line.trim())
12
+ continue;
13
+ const statusCode = line.slice(0, 2);
14
+ const filepath = line.slice(3).trim();
15
+ snapshot.set(filepath, statusCode);
16
+ }
17
+ return snapshot;
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ export function diffSnapshots(pre, post) {
24
+ if (!post)
25
+ return [];
26
+ const changed = [];
27
+ for (const [filepath, postCode] of post) {
28
+ const preCode = pre?.get(filepath);
29
+ if (preCode !== postCode) {
30
+ changed.push(filepath);
31
+ }
32
+ }
33
+ if (pre) {
34
+ for (const [filepath] of pre) {
35
+ if (!post.has(filepath)) {
36
+ changed.push(filepath);
37
+ }
38
+ }
39
+ }
40
+ return [...new Set(changed)];
41
+ }
package/dist/index.js ADDED
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { startServer } from "./server.js";
6
+ import { cleanupAll } from "./workers/process.js";
7
+ const HELP_TEXT = `relay-mcp
8
+
9
+ MCP server that lets Claude Code delegate coding tasks to Codex.
10
+
11
+ Usage:
12
+ relay-mcp
13
+ relay-mcp --help
14
+ relay-mcp --version
15
+
16
+ Environment:
17
+ RELAY_CODEX_PATH Path to Codex CLI binary (default: codex from PATH)
18
+ RELAY_LOG_LEVEL Log verbosity: debug | info | warn | error (default: info)
19
+ `;
20
+ function getPackageVersion() {
21
+ try {
22
+ const currentFile = fileURLToPath(import.meta.url);
23
+ const currentDir = dirname(currentFile);
24
+ const packageJsonPath = resolve(currentDir, "../package.json");
25
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
26
+ if (typeof packageJson.version === "string" && packageJson.version.length > 0) {
27
+ return packageJson.version;
28
+ }
29
+ }
30
+ catch {
31
+ // Fall through to a safe fallback version string.
32
+ }
33
+ return "0.0.0";
34
+ }
35
+ function handleCliFlags(args) {
36
+ if (args.includes("--help") || args.includes("-h")) {
37
+ process.stdout.write(HELP_TEXT);
38
+ return true;
39
+ }
40
+ if (args.includes("--version") || args.includes("-v")) {
41
+ process.stdout.write(`${getPackageVersion()}\n`);
42
+ return true;
43
+ }
44
+ return false;
45
+ }
46
+ process.on("exit", () => {
47
+ cleanupAll();
48
+ });
49
+ process.on("SIGTERM", () => {
50
+ process.exit(0);
51
+ });
52
+ if (!handleCliFlags(process.argv.slice(2))) {
53
+ startServer().catch((err) => {
54
+ process.stderr.write(`relay-mcp fatal: ${err instanceof Error ? err.message : String(err)}\n`);
55
+ process.exit(1);
56
+ });
57
+ }
package/dist/server.js ADDED
@@ -0,0 +1,15 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { delegateSchema } from "./contracts/delegate.js";
4
+ import { validateStartup } from "./startup.js";
5
+ import { handleDelegate } from "./tools/delegate.js";
6
+ export async function startServer() {
7
+ await validateStartup();
8
+ const server = new McpServer({
9
+ name: "relay-mcp",
10
+ version: "1.0.0",
11
+ });
12
+ server.tool("delegate", delegateSchema, async (args) => handleDelegate(args));
13
+ const transport = new StdioServerTransport();
14
+ await server.connect(transport);
15
+ }
@@ -0,0 +1,52 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { MIN_CODEX_VERSION } from "./config/constants.js";
4
+ import { getCodexBin, getRelayLogLevel } from "./config/runtime.js";
5
+ const execFileAsync = promisify(execFile);
6
+ function shouldPrintReadyBanner() {
7
+ return getRelayLogLevel() !== "error";
8
+ }
9
+ function isVersionBelow(version, min) {
10
+ const match = version.match(/(\d+)\.(\d+)\.(\d+)/);
11
+ if (!match)
12
+ return true;
13
+ const [, maj, min_, pat] = match.map(Number);
14
+ if (maj !== min[0])
15
+ return maj < min[0];
16
+ if (min_ !== min[1])
17
+ return min_ < min[1];
18
+ return pat < min[2];
19
+ }
20
+ export async function validateStartup() {
21
+ const failures = [];
22
+ let codexVersion = "";
23
+ const codexBin = getCodexBin();
24
+ const configuredPath = process.env.RELAY_CODEX_PATH?.trim();
25
+ try {
26
+ const { stdout } = await execFileAsync(codexBin, ["--version"]);
27
+ codexVersion = stdout.trim();
28
+ }
29
+ catch {
30
+ const binaryHint = configuredPath
31
+ ? `RELAY_CODEX_PATH=${configuredPath}`
32
+ : "PATH (default `codex` binary)";
33
+ failures.push(`Error: Codex binary not found via ${binaryHint}.
34
+ Fix: run \`npm install -g @openai/codex\` or set \`RELAY_CODEX_PATH=/full/path/to/codex\` in your MCP config, then restart relay-mcp.`);
35
+ }
36
+ if (codexVersion && isVersionBelow(codexVersion, MIN_CODEX_VERSION)) {
37
+ failures.push(`Error: Codex version ${codexVersion} is below minimum 0.39.0.\nFix: run \`npm install -g @openai/codex@latest\` then restart relay-mcp.`);
38
+ }
39
+ try {
40
+ await execFileAsync(codexBin, ["login", "status"]);
41
+ }
42
+ catch {
43
+ failures.push("Error: Codex not authenticated.\nFix: run `codex login` then restart relay-mcp.");
44
+ }
45
+ if (failures.length > 0) {
46
+ process.stderr.write(failures.join("\n\n") + "\n");
47
+ process.exit(1);
48
+ }
49
+ if (shouldPrintReadyBanner()) {
50
+ process.stderr.write(`relay-mcp ready\n codex: ${codexVersion} ✓\n auth: authenticated ✓\n listening on stdio...\n`);
51
+ }
52
+ }
@@ -0,0 +1,107 @@
1
+ import * as path from "node:path";
2
+ import { DEFAULT_TIMEOUT_MS, TOKEN_HARD_CAP, TOKEN_WARN_THRESHOLD } from "../config/constants.js";
3
+ import { makeError } from "../errors.js";
4
+ import { getWorkdirMutex } from "../concurrency/index.js";
5
+ import { diffSnapshots, takeSnapshot } from "../git/snapshot.js";
6
+ import { runCodexWorker } from "../workers/codex.js";
7
+ /** Canonical workdir normalization - used by mutex key, snapshot, and worker. */
8
+ function normalizeWorkdir(rawWorkdir) {
9
+ return path.resolve(rawWorkdir);
10
+ }
11
+ function buildDelegateMeta(params) {
12
+ return {
13
+ duration_ms: params.duration_ms,
14
+ truncated: params.truncated,
15
+ warnings: params.warnings,
16
+ model: params.model ?? null,
17
+ token_estimate: params.token_estimate,
18
+ exit_code: params.exit_code,
19
+ };
20
+ }
21
+ function toMcpResult(response) {
22
+ return {
23
+ content: [{ type: "text", text: JSON.stringify(response) }],
24
+ isError: response.status !== "success",
25
+ };
26
+ }
27
+ export function applyTruncation(output, warnings) {
28
+ const tokenEstimate = Math.ceil(output.length / 4);
29
+ if (tokenEstimate >= TOKEN_WARN_THRESHOLD && tokenEstimate < TOKEN_HARD_CAP) {
30
+ warnings.push(`Output is ${tokenEstimate} tokens - approaching 25,000-token limit`);
31
+ }
32
+ if (tokenEstimate >= TOKEN_HARD_CAP) {
33
+ const charCap = TOKEN_HARD_CAP * 4;
34
+ return {
35
+ output: output.slice(0, charCap) + "\n\n[OUTPUT TRUNCATED]",
36
+ truncated: true,
37
+ tokenEstimate,
38
+ };
39
+ }
40
+ return {
41
+ output,
42
+ truncated: false,
43
+ tokenEstimate,
44
+ };
45
+ }
46
+ export async function handleDelegate(args) {
47
+ const { task, workdir: rawWorkdir, context, timeout_ms, model } = args;
48
+ const workdir = normalizeWorkdir(rawWorkdir);
49
+ const finalTask = context ? `${context}\n\n${task}` : task;
50
+ const preSnapshot = await takeSnapshot(workdir);
51
+ const release = await getWorkdirMutex(workdir).acquire();
52
+ let workerResult;
53
+ try {
54
+ const workerTask = {
55
+ task: finalTask,
56
+ workdir,
57
+ timeout_ms: timeout_ms ?? DEFAULT_TIMEOUT_MS,
58
+ model,
59
+ };
60
+ workerResult = await runCodexWorker(workerTask);
61
+ }
62
+ catch (err) {
63
+ const response = {
64
+ status: "error",
65
+ output: "",
66
+ files_changed: [],
67
+ meta: buildDelegateMeta({
68
+ duration_ms: 0,
69
+ truncated: false,
70
+ warnings: [],
71
+ model,
72
+ token_estimate: 0,
73
+ exit_code: null,
74
+ }),
75
+ error: makeError("UNKNOWN", `Unexpected worker error: ${String(err)}`, false),
76
+ };
77
+ return toMcpResult(response);
78
+ }
79
+ finally {
80
+ release();
81
+ }
82
+ const postSnapshot = await takeSnapshot(workdir);
83
+ const filesChanged = diffSnapshots(preSnapshot, postSnapshot);
84
+ const warnings = [];
85
+ if (preSnapshot === null) {
86
+ warnings.push("workdir is not a git repository - files_changed tracking unavailable");
87
+ }
88
+ else if (filesChanged.length === 0 && workerResult.status === "success") {
89
+ warnings.push("No files were modified by Codex");
90
+ }
91
+ const { output, truncated, tokenEstimate } = applyTruncation(workerResult.output, warnings);
92
+ const response = {
93
+ status: workerResult.status,
94
+ output,
95
+ files_changed: filesChanged,
96
+ meta: buildDelegateMeta({
97
+ duration_ms: workerResult.duration_ms,
98
+ truncated,
99
+ warnings,
100
+ model,
101
+ token_estimate: tokenEstimate,
102
+ exit_code: workerResult.exit_code,
103
+ }),
104
+ ...(workerResult.error ? { error: workerResult.error } : {}),
105
+ };
106
+ return toMcpResult(response);
107
+ }
@@ -0,0 +1,125 @@
1
+ import { spawn } from "node:child_process";
2
+ import { registerPid, unregisterPid } from "./process.js";
3
+ import { getCodexBin } from "../config/runtime.js";
4
+ import { makeError } from "../errors.js";
5
+ /** Pure function for JSONL parsing - exported for testing */
6
+ export function parseCodexLine(line) {
7
+ try {
8
+ const event = JSON.parse(line);
9
+ if (event["type"] === "item.completed" &&
10
+ typeof event["item"] === "object" &&
11
+ event["item"] !== null) {
12
+ const item = event["item"];
13
+ if (item["type"] === "agent_message" && typeof item["text"] === "string") {
14
+ return item["text"];
15
+ }
16
+ }
17
+ }
18
+ catch {
19
+ // Ignore malformed JSON lines.
20
+ }
21
+ return null;
22
+ }
23
+ export async function runCodexWorker(task) {
24
+ const startTime = Date.now();
25
+ const codexBin = getCodexBin();
26
+ const args = ["exec", "--cd", task.workdir, "--json", "--full-auto"];
27
+ if (task.model)
28
+ args.push("--model", task.model);
29
+ args.push(task.task);
30
+ const child = spawn(codexBin, args, {
31
+ stdio: ["pipe", "pipe", "pipe"],
32
+ env: { ...process.env },
33
+ });
34
+ child.stdin?.end();
35
+ if (child.pid !== undefined) {
36
+ registerPid(child.pid);
37
+ }
38
+ const agentMessages = [];
39
+ let stdoutBuf = "";
40
+ child.stdout?.on("data", (chunk) => {
41
+ stdoutBuf += chunk.toString("utf8");
42
+ const lines = stdoutBuf.split("\n");
43
+ stdoutBuf = lines.pop() ?? "";
44
+ for (const line of lines) {
45
+ if (!line.trim())
46
+ continue;
47
+ const message = parseCodexLine(line);
48
+ if (message) {
49
+ agentMessages.push(message);
50
+ }
51
+ }
52
+ });
53
+ child.stderr?.on("data", (chunk) => {
54
+ process.stderr.write(chunk);
55
+ });
56
+ let timedOut = false;
57
+ const timeoutHandle = setTimeout(() => {
58
+ timedOut = true;
59
+ child.kill("SIGTERM");
60
+ setTimeout(() => {
61
+ try {
62
+ child.kill("SIGKILL");
63
+ }
64
+ catch {
65
+ // Process already exited.
66
+ }
67
+ }, 5_000);
68
+ }, task.timeout_ms);
69
+ return new Promise((resolve) => {
70
+ child.on("close", (code) => {
71
+ clearTimeout(timeoutHandle);
72
+ if (child.pid !== undefined) {
73
+ unregisterPid(child.pid);
74
+ }
75
+ if (stdoutBuf.trim()) {
76
+ const message = parseCodexLine(stdoutBuf);
77
+ if (message) {
78
+ agentMessages.push(message);
79
+ }
80
+ }
81
+ const duration_ms = Date.now() - startTime;
82
+ const output = agentMessages.join("\n");
83
+ if (timedOut) {
84
+ resolve({
85
+ status: "timeout",
86
+ output,
87
+ duration_ms,
88
+ exit_code: code,
89
+ error: makeError("TIMEOUT", `Codex timed out after ${task.timeout_ms}ms`, true),
90
+ });
91
+ return;
92
+ }
93
+ if (code !== 0) {
94
+ resolve({
95
+ status: "error",
96
+ output,
97
+ duration_ms,
98
+ exit_code: code,
99
+ error: makeError("CODEX_ERROR", `Codex exited with code ${code}`, false),
100
+ });
101
+ return;
102
+ }
103
+ resolve({
104
+ status: "success",
105
+ output,
106
+ duration_ms,
107
+ exit_code: code,
108
+ });
109
+ });
110
+ child.on("error", (err) => {
111
+ clearTimeout(timeoutHandle);
112
+ if (child.pid !== undefined) {
113
+ unregisterPid(child.pid);
114
+ }
115
+ const duration_ms = Date.now() - startTime;
116
+ resolve({
117
+ status: "error",
118
+ output: "",
119
+ duration_ms,
120
+ exit_code: null,
121
+ error: makeError("BINARY_NOT_FOUND", `Failed to spawn codex binary (${codexBin}): ${err.message}. Set RELAY_CODEX_PATH=/full/path/to/codex if needed.`, false),
122
+ });
123
+ });
124
+ });
125
+ }
@@ -0,0 +1,18 @@
1
+ const pidRegistry = new Set();
2
+ export function registerPid(pid) {
3
+ pidRegistry.add(pid);
4
+ }
5
+ export function unregisterPid(pid) {
6
+ pidRegistry.delete(pid);
7
+ }
8
+ /** Kill all registered PIDs with SIGTERM. Called on process exit. */
9
+ export function cleanupAll() {
10
+ for (const pid of pidRegistry) {
11
+ try {
12
+ process.kill(pid, "SIGTERM");
13
+ }
14
+ catch {
15
+ // Already terminated.
16
+ }
17
+ }
18
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@sghanavati/relay-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server that lets Claude Code delegate coding tasks to Codex",
5
+ "type": "module",
6
+ "bin": {
7
+ "relay-mcp": "dist/index.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=18.19.0"
11
+ },
12
+ "files": [
13
+ "dist/",
14
+ "LICENSE",
15
+ "!dist/**/*.test.js"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsc",
19
+ "dev": "tsc --watch",
20
+ "prepublishOnly": "npm test",
21
+ "test": "npm run build && node --test dist/**/*.test.js",
22
+ "test:git": "npm run build && node dist/git/snapshot.test.js",
23
+ "test:codex": "npm run build && node dist/workers/codex.test.js",
24
+ "test:delegate": "npm run build && node dist/tools/delegate.test.js"
25
+ },
26
+ "dependencies": {
27
+ "@modelcontextprotocol/sdk": "^1.20.2",
28
+ "zod": "^3.25.0",
29
+ "execa": "^9.6.0",
30
+ "async-mutex": "^0.5.0"
31
+ },
32
+ "devDependencies": {
33
+ "typescript": "^5.0.0",
34
+ "@types/node": "^22.0.0"
35
+ }
36
+ }