@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 +21 -0
- package/README.md +189 -0
- package/dist/concurrency/index.js +9 -0
- package/dist/config/constants.js +8 -0
- package/dist/config/runtime.js +9 -0
- package/dist/contracts/delegate.js +26 -0
- package/dist/errors.js +8 -0
- package/dist/git/snapshot.js +41 -0
- package/dist/index.js +57 -0
- package/dist/server.js +15 -0
- package/dist/startup.js +52 -0
- package/dist/tools/delegate.js +107 -0
- package/dist/workers/codex.js +125 -0
- package/dist/workers/process.js +18 -0
- package/dist/workers/types.js +1 -0
- package/package.json +36 -0
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
|
+
[](https://www.npmjs.com/package/@sghanavati/relay-mcp)
|
|
3
|
+
[](https://nodejs.org)
|
|
4
|
+
[](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
|
+
}
|
package/dist/startup.js
ADDED
|
@@ -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
|
+
}
|