@sghanavati/relay-mcp 0.1.3 → 0.1.5
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 +4 -0
- package/dist/config/providers.js +9 -0
- package/dist/contracts/delegate.js +4 -3
- package/dist/server.js +1 -1
- package/dist/startup.js +8 -44
- package/dist/telemetry/run-event.js +10 -0
- package/dist/tools/check_workers.js +8 -52
- package/dist/tools/delegate.js +166 -24
- package/dist/utils/version.js +18 -0
- package/dist/workers/codex.js +12 -1
- package/dist/workers/diagnostics.js +56 -0
- package/dist/workers/lmstudio.js +84 -0
- package/dist/workers/openrouter.js +88 -0
- package/dist/workers/registry.js +15 -0
- package/dist/workers/runner.js +1 -0
- package/package.json +7 -6
package/README.md
CHANGED
|
@@ -239,6 +239,10 @@ tmux window
|
|
|
239
239
|
| `RELAY_CODEX_PATH` | `codex` from `PATH` | Full path to Codex binary for PATH-limited environments (especially Claude Desktop). |
|
|
240
240
|
| `RELAY_LOG_LEVEL` | `info` | Startup log verbosity control. `error` suppresses the ready banner; other values show it. |
|
|
241
241
|
|
|
242
|
+
### Provider model requirement
|
|
243
|
+
- `provider: "codex"`: `model` is optional.
|
|
244
|
+
- `provider: "openrouter"` or `provider: "lmstudio"`: `model` is required.
|
|
245
|
+
|
|
242
246
|
## Troubleshooting
|
|
243
247
|
### 1) PATH issues (Codex not found)
|
|
244
248
|
Symptom:
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function getOpenRouterApiKey() {
|
|
2
|
+
return process.env["OPENROUTER_API_KEY"]?.trim() || null;
|
|
3
|
+
}
|
|
4
|
+
export function getLmStudioEndpoint() {
|
|
5
|
+
return process.env["LMSTUDIO_ENDPOINT"]?.trim() || "http://localhost:1234";
|
|
6
|
+
}
|
|
7
|
+
export function getLmStudioApiKey() {
|
|
8
|
+
return process.env["LMSTUDIO_API_KEY"]?.trim() || null;
|
|
9
|
+
}
|
|
@@ -3,10 +3,11 @@ const delegateSchemaShape = {
|
|
|
3
3
|
task: z.string().min(1).describe("The coding task to delegate to the worker"),
|
|
4
4
|
workdir: z.string().describe("Absolute path to the working directory"),
|
|
5
5
|
provider: z
|
|
6
|
-
.enum(["codex"])
|
|
6
|
+
.enum(["codex", "openrouter", "lmstudio"])
|
|
7
7
|
.optional()
|
|
8
8
|
.default("codex")
|
|
9
|
-
.describe("Worker provider (
|
|
9
|
+
.describe("Worker provider. 'codex' = agentic execution (modifies files). " +
|
|
10
|
+
"'openrouter' and 'lmstudio' = generation-only (text response, no file changes)."),
|
|
10
11
|
context: z
|
|
11
12
|
.string()
|
|
12
13
|
.optional()
|
|
@@ -20,7 +21,7 @@ const delegateSchemaShape = {
|
|
|
20
21
|
model: z
|
|
21
22
|
.string()
|
|
22
23
|
.optional()
|
|
23
|
-
.describe("Model override for
|
|
24
|
+
.describe("Model override. Optional for 'codex'. Required for 'openrouter' and 'lmstudio' (pass an explicit provider model ID)."),
|
|
24
25
|
};
|
|
25
26
|
const delegateArgsSchema = z.object(delegateSchemaShape);
|
|
26
27
|
export const delegateSchema = delegateArgsSchema.shape;
|
package/dist/server.js
CHANGED
|
@@ -5,7 +5,6 @@ import { validateStartup } from "./startup.js";
|
|
|
5
5
|
import { handleDelegate } from "./tools/delegate.js";
|
|
6
6
|
import { handleCheckWorkers } from "./tools/check_workers.js";
|
|
7
7
|
export async function startServer() {
|
|
8
|
-
await validateStartup();
|
|
9
8
|
const server = new McpServer({
|
|
10
9
|
name: "relay-mcp",
|
|
11
10
|
version: "1.0.0",
|
|
@@ -14,4 +13,5 @@ export async function startServer() {
|
|
|
14
13
|
server.tool("check_workers", {}, async () => handleCheckWorkers());
|
|
15
14
|
const transport = new StdioServerTransport();
|
|
16
15
|
await server.connect(transport);
|
|
16
|
+
await validateStartup();
|
|
17
17
|
}
|
package/dist/startup.js
CHANGED
|
@@ -1,52 +1,16 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { MIN_CODEX_VERSION } from "./config/constants.js";
|
|
4
|
-
import { getCodexBin, getRelayLogLevel } from "./config/runtime.js";
|
|
5
|
-
const execFileAsync = promisify(execFile);
|
|
1
|
+
import { getRelayLogLevel } from "./config/runtime.js";
|
|
2
|
+
import { diagnoseCodexWorker } from "./workers/diagnostics.js";
|
|
6
3
|
function shouldPrintReadyBanner() {
|
|
7
4
|
return getRelayLogLevel() !== "error";
|
|
8
5
|
}
|
|
9
|
-
export 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
6
|
export async function validateStartup() {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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);
|
|
7
|
+
const codexDiagnostic = await diagnoseCodexWorker();
|
|
8
|
+
if (!codexDiagnostic.available) {
|
|
9
|
+
process.stderr.write(`Error: ${codexDiagnostic.reason}\nFix: ${codexDiagnostic.remediation}\n` +
|
|
10
|
+
"Tip: run the `check_workers` MCP tool for a structured readiness report.\n");
|
|
11
|
+
return;
|
|
48
12
|
}
|
|
49
13
|
if (shouldPrintReadyBanner()) {
|
|
50
|
-
process.stderr.write(`relay-mcp ready\n codex: ${
|
|
14
|
+
process.stderr.write(`relay-mcp ready\n codex: ${codexDiagnostic.version} ✓\n auth: authenticated ✓\n listening on stdio...\n`);
|
|
51
15
|
}
|
|
52
16
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
2
|
+
export const RELAY_EVENTS_LOG = "/tmp/relay-mcp-events.jsonl";
|
|
3
|
+
export function writeRunEvent(event, logPath = RELAY_EVENTS_LOG) {
|
|
4
|
+
try {
|
|
5
|
+
appendFileSync(logPath, JSON.stringify(event) + "\n");
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
// Best-effort — never crash the server over a log write.
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -1,56 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { promisify } from "node:util";
|
|
3
|
-
import { getCodexBin } from "../config/runtime.js";
|
|
4
|
-
import { MIN_CODEX_VERSION } from "../config/constants.js";
|
|
5
|
-
import { isVersionBelow } from "../startup.js";
|
|
6
|
-
const execFileAsync = promisify(execFile);
|
|
7
|
-
async function checkCodexWorker() {
|
|
8
|
-
const codexBin = getCodexBin();
|
|
9
|
-
const configuredPath = process.env.RELAY_CODEX_PATH?.trim();
|
|
10
|
-
const binaryHint = configuredPath
|
|
11
|
-
? `RELAY_CODEX_PATH=${configuredPath}`
|
|
12
|
-
: "PATH (default `codex` binary)";
|
|
13
|
-
let version = "";
|
|
14
|
-
try {
|
|
15
|
-
const { stdout } = await execFileAsync(codexBin, ["--version"]);
|
|
16
|
-
version = stdout.trim();
|
|
17
|
-
}
|
|
18
|
-
catch {
|
|
19
|
-
return {
|
|
20
|
-
worker: "codex",
|
|
21
|
-
available: false,
|
|
22
|
-
reason: `Codex binary not found via ${binaryHint}`,
|
|
23
|
-
remediation: "run: npm install -g @openai/codex OR set RELAY_CODEX_PATH=/full/path/to/codex in your MCP config",
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
if (isVersionBelow(version, MIN_CODEX_VERSION)) {
|
|
27
|
-
return {
|
|
28
|
-
worker: "codex",
|
|
29
|
-
available: false,
|
|
30
|
-
reason: `Codex ${version} is below minimum 0.39.0`,
|
|
31
|
-
remediation: "run: npm install -g @openai/codex@latest then restart relay-mcp",
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
try {
|
|
35
|
-
await execFileAsync(codexBin, ["login", "status"]);
|
|
36
|
-
}
|
|
37
|
-
catch {
|
|
38
|
-
return {
|
|
39
|
-
worker: "codex",
|
|
40
|
-
available: false,
|
|
41
|
-
reason: `Codex ${version} found but not authenticated`,
|
|
42
|
-
remediation: "run: codex login then restart relay-mcp",
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
return {
|
|
46
|
-
worker: "codex",
|
|
47
|
-
available: true,
|
|
48
|
-
reason: `${version} authenticated`,
|
|
49
|
-
remediation: "",
|
|
50
|
-
};
|
|
51
|
-
}
|
|
1
|
+
import { diagnoseCodexWorker } from "../workers/diagnostics.js";
|
|
52
2
|
export async function handleCheckWorkers() {
|
|
53
|
-
const
|
|
3
|
+
const codexDiagRaw = await diagnoseCodexWorker();
|
|
4
|
+
const codexDiag = {
|
|
5
|
+
worker: codexDiagRaw.worker,
|
|
6
|
+
available: codexDiagRaw.available,
|
|
7
|
+
reason: codexDiagRaw.reason,
|
|
8
|
+
remediation: codexDiagRaw.remediation,
|
|
9
|
+
};
|
|
54
10
|
const result = {
|
|
55
11
|
workers: [codexDiag],
|
|
56
12
|
all_available: codexDiag.available,
|
package/dist/tools/delegate.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import { createWriteStream, readFileSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { DEFAULT_TIMEOUT_MS, TOKEN_HARD_CAP, TOKEN_WARN_THRESHOLD } from "../config/constants.js";
|
|
6
6
|
import { makeError } from "../errors.js";
|
|
7
7
|
import { getWorkdirMutex } from "../concurrency/index.js";
|
|
8
8
|
import { diffSnapshots, takeSnapshot } from "../git/snapshot.js";
|
|
9
|
-
import {
|
|
9
|
+
import { getRunner } from "../workers/registry.js";
|
|
10
|
+
import { writeRunEvent } from "../telemetry/run-event.js";
|
|
10
11
|
/** Canonical workdir normalization - used by mutex key, snapshot, and worker. */
|
|
11
12
|
function normalizeWorkdir(rawWorkdir) {
|
|
12
13
|
return path.resolve(rawWorkdir);
|
|
@@ -15,13 +16,25 @@ function computeLogPath(workdir) {
|
|
|
15
16
|
const hash = createHash("sha1").update(workdir).digest("hex").slice(0, 8);
|
|
16
17
|
return `/tmp/relay-mcp-${hash}.log`;
|
|
17
18
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Resolve AGENTS.md from workdir upward to filesystem root.
|
|
21
|
+
* Nearest non-empty file wins.
|
|
22
|
+
*/
|
|
23
|
+
export function readNearestAgentsMd(workdir) {
|
|
24
|
+
let current = path.resolve(workdir);
|
|
25
|
+
while (true) {
|
|
26
|
+
try {
|
|
27
|
+
const content = readFileSync(join(current, "AGENTS.md"), "utf8").trim();
|
|
28
|
+
if (content.length > 0)
|
|
29
|
+
return content;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Continue traversing to parent.
|
|
33
|
+
}
|
|
34
|
+
const parent = path.dirname(current);
|
|
35
|
+
if (parent === current)
|
|
36
|
+
return null;
|
|
37
|
+
current = parent;
|
|
25
38
|
}
|
|
26
39
|
}
|
|
27
40
|
function buildDelegateMeta(params) {
|
|
@@ -33,6 +46,10 @@ function buildDelegateMeta(params) {
|
|
|
33
46
|
token_estimate: params.token_estimate,
|
|
34
47
|
exit_code: params.exit_code,
|
|
35
48
|
log_file: params.log_file,
|
|
49
|
+
run_id: params.run_id,
|
|
50
|
+
provider: params.provider,
|
|
51
|
+
spawn_time_ms: params.spawn_time_ms,
|
|
52
|
+
token_usage: params.token_usage,
|
|
36
53
|
};
|
|
37
54
|
}
|
|
38
55
|
function toMcpResult(response) {
|
|
@@ -60,10 +77,19 @@ export function applyTruncation(output, warnings) {
|
|
|
60
77
|
tokenEstimate,
|
|
61
78
|
};
|
|
62
79
|
}
|
|
80
|
+
function normalizeThrownError(err) {
|
|
81
|
+
if (err instanceof Error && err.message.startsWith("PROVIDER_NOT_CONFIGURED:")) {
|
|
82
|
+
return makeError("PROVIDER_NOT_CONFIGURED", err.message, false);
|
|
83
|
+
}
|
|
84
|
+
return makeError("UNKNOWN", `Unexpected worker error: ${String(err)}`, false);
|
|
85
|
+
}
|
|
63
86
|
export async function handleDelegate(args) {
|
|
64
|
-
const { task, workdir: rawWorkdir, context, timeout_ms, model } = args;
|
|
87
|
+
const { task, workdir: rawWorkdir, context, timeout_ms, model, provider = "codex" } = args;
|
|
88
|
+
const modelOverride = model?.trim() ? model.trim() : undefined;
|
|
89
|
+
const run_id = randomUUID();
|
|
90
|
+
const queued_at = Date.now();
|
|
65
91
|
const workdir = normalizeWorkdir(rawWorkdir);
|
|
66
|
-
const agentsContext =
|
|
92
|
+
const agentsContext = readNearestAgentsMd(workdir);
|
|
67
93
|
const parts = [];
|
|
68
94
|
if (agentsContext)
|
|
69
95
|
parts.push(agentsContext);
|
|
@@ -71,54 +97,166 @@ export async function handleDelegate(args) {
|
|
|
71
97
|
parts.push(context);
|
|
72
98
|
parts.push(task);
|
|
73
99
|
const finalTask = parts.join("\n\n");
|
|
74
|
-
const
|
|
75
|
-
const
|
|
100
|
+
const knownProviders = new Set(["codex", "openrouter", "lmstudio"]);
|
|
101
|
+
const isKnownProvider = knownProviders.has(provider);
|
|
102
|
+
const isAgenticProvider = provider === "codex";
|
|
76
103
|
const logPath = computeLogPath(workdir);
|
|
104
|
+
if (!isKnownProvider) {
|
|
105
|
+
const response = {
|
|
106
|
+
status: "error",
|
|
107
|
+
output: "",
|
|
108
|
+
files_changed: [],
|
|
109
|
+
meta: buildDelegateMeta({
|
|
110
|
+
duration_ms: 0,
|
|
111
|
+
truncated: false,
|
|
112
|
+
warnings: [],
|
|
113
|
+
model: modelOverride,
|
|
114
|
+
token_estimate: 0,
|
|
115
|
+
exit_code: null,
|
|
116
|
+
log_file: logPath,
|
|
117
|
+
run_id,
|
|
118
|
+
provider,
|
|
119
|
+
spawn_time_ms: 0,
|
|
120
|
+
token_usage: null,
|
|
121
|
+
}),
|
|
122
|
+
error: makeError("PROVIDER_NOT_CONFIGURED", `PROVIDER_NOT_CONFIGURED: unknown provider "${provider}"`, false),
|
|
123
|
+
};
|
|
124
|
+
return toMcpResult(response);
|
|
125
|
+
}
|
|
126
|
+
if (!isAgenticProvider && !modelOverride) {
|
|
127
|
+
const response = {
|
|
128
|
+
status: "error",
|
|
129
|
+
output: "",
|
|
130
|
+
files_changed: [],
|
|
131
|
+
meta: buildDelegateMeta({
|
|
132
|
+
duration_ms: 0,
|
|
133
|
+
truncated: false,
|
|
134
|
+
warnings: [],
|
|
135
|
+
model: modelOverride,
|
|
136
|
+
token_estimate: 0,
|
|
137
|
+
exit_code: null,
|
|
138
|
+
log_file: logPath,
|
|
139
|
+
run_id,
|
|
140
|
+
provider,
|
|
141
|
+
spawn_time_ms: 0,
|
|
142
|
+
token_usage: null,
|
|
143
|
+
}),
|
|
144
|
+
error: makeError("INVALID_ARGS", `model is required when provider is "${provider}". Pass an explicit provider model ID.`, false),
|
|
145
|
+
};
|
|
146
|
+
return toMcpResult(response);
|
|
147
|
+
}
|
|
148
|
+
writeRunEvent({ run_id, provider, model: modelOverride ?? null, workdir, status: "queued", queued_at });
|
|
149
|
+
let release = null;
|
|
150
|
+
let preSnapshot = null;
|
|
151
|
+
if (isAgenticProvider) {
|
|
152
|
+
release = await getWorkdirMutex(workdir).acquire();
|
|
153
|
+
preSnapshot = await takeSnapshot(workdir);
|
|
154
|
+
}
|
|
77
155
|
const logStream = createWriteStream(logPath, { flags: "a" });
|
|
156
|
+
logStream.on("error", () => {
|
|
157
|
+
/* swallow write errors - log is best-effort */
|
|
158
|
+
});
|
|
78
159
|
let workerResult;
|
|
160
|
+
let postSnapshot = null;
|
|
161
|
+
const started_at = Date.now();
|
|
162
|
+
const spawn_time_ms = started_at - queued_at;
|
|
163
|
+
writeRunEvent({
|
|
164
|
+
run_id,
|
|
165
|
+
provider,
|
|
166
|
+
model: modelOverride ?? null,
|
|
167
|
+
workdir,
|
|
168
|
+
status: "running",
|
|
169
|
+
queued_at,
|
|
170
|
+
started_at,
|
|
171
|
+
spawn_time_ms,
|
|
172
|
+
});
|
|
79
173
|
try {
|
|
174
|
+
const runner = getRunner(provider);
|
|
80
175
|
const workerTask = {
|
|
81
176
|
task: finalTask,
|
|
82
177
|
workdir,
|
|
83
178
|
timeout_ms: timeout_ms ?? DEFAULT_TIMEOUT_MS,
|
|
84
|
-
model,
|
|
179
|
+
model: modelOverride,
|
|
85
180
|
logStream,
|
|
181
|
+
onStderr: (text) => {
|
|
182
|
+
process.stderr.write(text);
|
|
183
|
+
logStream.write(text);
|
|
184
|
+
},
|
|
185
|
+
run_id,
|
|
186
|
+
provider,
|
|
86
187
|
};
|
|
87
|
-
workerResult = await
|
|
188
|
+
workerResult = await runner.run(workerTask);
|
|
189
|
+
if (isAgenticProvider) {
|
|
190
|
+
postSnapshot = await takeSnapshot(workdir);
|
|
191
|
+
}
|
|
88
192
|
}
|
|
89
193
|
catch (err) {
|
|
90
194
|
logStream.end();
|
|
195
|
+
const finished_at = Date.now();
|
|
196
|
+
const normalizedError = normalizeThrownError(err);
|
|
197
|
+
writeRunEvent({
|
|
198
|
+
run_id,
|
|
199
|
+
provider,
|
|
200
|
+
model: modelOverride ?? null,
|
|
201
|
+
workdir,
|
|
202
|
+
status: "error",
|
|
203
|
+
queued_at,
|
|
204
|
+
started_at,
|
|
205
|
+
finished_at,
|
|
206
|
+
duration_ms: finished_at - started_at,
|
|
207
|
+
spawn_time_ms,
|
|
208
|
+
error_code: normalizedError.code,
|
|
209
|
+
});
|
|
91
210
|
const response = {
|
|
92
211
|
status: "error",
|
|
93
212
|
output: "",
|
|
94
213
|
files_changed: [],
|
|
95
214
|
meta: buildDelegateMeta({
|
|
96
|
-
duration_ms:
|
|
215
|
+
duration_ms: finished_at - started_at,
|
|
97
216
|
truncated: false,
|
|
98
217
|
warnings: [],
|
|
99
|
-
model,
|
|
218
|
+
model: modelOverride,
|
|
100
219
|
token_estimate: 0,
|
|
101
220
|
exit_code: null,
|
|
102
221
|
log_file: logPath,
|
|
222
|
+
run_id,
|
|
223
|
+
provider,
|
|
224
|
+
spawn_time_ms,
|
|
225
|
+
token_usage: null,
|
|
103
226
|
}),
|
|
104
|
-
error:
|
|
227
|
+
error: normalizedError,
|
|
105
228
|
};
|
|
106
229
|
return toMcpResult(response);
|
|
107
230
|
}
|
|
108
231
|
finally {
|
|
109
|
-
release();
|
|
232
|
+
release?.();
|
|
110
233
|
}
|
|
111
234
|
logStream.end();
|
|
112
|
-
const
|
|
113
|
-
const
|
|
235
|
+
const finished_at = Date.now();
|
|
236
|
+
const duration_ms = finished_at - started_at;
|
|
237
|
+
const filesChanged = isAgenticProvider ? diffSnapshots(preSnapshot, postSnapshot) : [];
|
|
114
238
|
const warnings = [];
|
|
115
|
-
if (preSnapshot === null) {
|
|
239
|
+
if (isAgenticProvider && preSnapshot === null) {
|
|
116
240
|
warnings.push("workdir is not a git repository - files_changed tracking unavailable");
|
|
117
241
|
}
|
|
118
|
-
else if (filesChanged.length === 0 && workerResult.status === "success") {
|
|
242
|
+
else if (isAgenticProvider && filesChanged.length === 0 && workerResult.status === "success") {
|
|
119
243
|
warnings.push("No files were modified by Codex");
|
|
120
244
|
}
|
|
121
245
|
const { output, truncated, tokenEstimate } = applyTruncation(workerResult.output, warnings);
|
|
246
|
+
const eventStatus = workerResult.status === "success"
|
|
247
|
+
? "success"
|
|
248
|
+
: workerResult.status === "timeout"
|
|
249
|
+
? "timeout"
|
|
250
|
+
: "error";
|
|
251
|
+
writeRunEvent({
|
|
252
|
+
run_id, provider, model: modelOverride ?? null, workdir, status: eventStatus,
|
|
253
|
+
queued_at, started_at, finished_at, duration_ms,
|
|
254
|
+
spawn_time_ms,
|
|
255
|
+
output_size_chars: output.length,
|
|
256
|
+
exit_code: workerResult.exit_code,
|
|
257
|
+
token_usage: workerResult.token_usage ?? null,
|
|
258
|
+
...(workerResult.error ? { error_code: workerResult.error.code } : {}),
|
|
259
|
+
});
|
|
122
260
|
const response = {
|
|
123
261
|
status: workerResult.status,
|
|
124
262
|
output,
|
|
@@ -127,10 +265,14 @@ export async function handleDelegate(args) {
|
|
|
127
265
|
duration_ms: workerResult.duration_ms,
|
|
128
266
|
truncated,
|
|
129
267
|
warnings,
|
|
130
|
-
model,
|
|
268
|
+
model: modelOverride,
|
|
131
269
|
token_estimate: tokenEstimate,
|
|
132
270
|
exit_code: workerResult.exit_code,
|
|
133
271
|
log_file: logPath,
|
|
272
|
+
run_id,
|
|
273
|
+
provider,
|
|
274
|
+
spawn_time_ms,
|
|
275
|
+
token_usage: workerResult.token_usage ?? null,
|
|
134
276
|
}),
|
|
135
277
|
...(workerResult.error ? { error: workerResult.error } : {}),
|
|
136
278
|
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns true when `version` is lower than `min`.
|
|
3
|
+
* Accepted version format: MAJOR.MINOR.PATCH (optionally prefixed/suffixed).
|
|
4
|
+
*/
|
|
5
|
+
export function isVersionBelow(version, min) {
|
|
6
|
+
const match = version.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
7
|
+
if (!match)
|
|
8
|
+
return true;
|
|
9
|
+
const [, maj, min_, pat] = match.map(Number);
|
|
10
|
+
if (maj !== min[0])
|
|
11
|
+
return maj < min[0];
|
|
12
|
+
if (min_ !== min[1])
|
|
13
|
+
return min_ < min[1];
|
|
14
|
+
return pat < min[2];
|
|
15
|
+
}
|
|
16
|
+
export function formatVersionTriplet(version) {
|
|
17
|
+
return version.join(".");
|
|
18
|
+
}
|
package/dist/workers/codex.js
CHANGED
|
@@ -52,7 +52,12 @@ export async function runCodexWorker(task) {
|
|
|
52
52
|
}
|
|
53
53
|
});
|
|
54
54
|
child.stderr?.on("data", (chunk) => {
|
|
55
|
-
|
|
55
|
+
const text = chunk.toString("utf8");
|
|
56
|
+
if (task.onStderr) {
|
|
57
|
+
task.onStderr(text);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
process.stderr.write(text);
|
|
56
61
|
});
|
|
57
62
|
let timedOut = false;
|
|
58
63
|
const timeoutHandle = setTimeout(() => {
|
|
@@ -77,6 +82,7 @@ export async function runCodexWorker(task) {
|
|
|
77
82
|
const message = parseCodexLine(stdoutBuf);
|
|
78
83
|
if (message) {
|
|
79
84
|
agentMessages.push(message);
|
|
85
|
+
task.logStream?.write(message + "\n");
|
|
80
86
|
}
|
|
81
87
|
}
|
|
82
88
|
const duration_ms = Date.now() - startTime;
|
|
@@ -124,3 +130,8 @@ export async function runCodexWorker(task) {
|
|
|
124
130
|
});
|
|
125
131
|
});
|
|
126
132
|
}
|
|
133
|
+
export class CodexRunner {
|
|
134
|
+
run(task) {
|
|
135
|
+
return runCodexWorker(task);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
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 } from "../config/runtime.js";
|
|
5
|
+
import { formatVersionTriplet, isVersionBelow } from "../utils/version.js";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
export async function diagnoseCodexWorker() {
|
|
8
|
+
const codexBin = getCodexBin();
|
|
9
|
+
const configuredPath = process.env.RELAY_CODEX_PATH?.trim();
|
|
10
|
+
const binaryHint = configuredPath
|
|
11
|
+
? `RELAY_CODEX_PATH=${configuredPath}`
|
|
12
|
+
: "PATH (default `codex` binary)";
|
|
13
|
+
const minVersion = formatVersionTriplet(MIN_CODEX_VERSION);
|
|
14
|
+
let version = "";
|
|
15
|
+
try {
|
|
16
|
+
const { stdout } = await execFileAsync(codexBin, ["--version"]);
|
|
17
|
+
version = stdout.trim();
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return {
|
|
21
|
+
worker: "codex",
|
|
22
|
+
available: false,
|
|
23
|
+
version: null,
|
|
24
|
+
reason: `Codex binary not found via ${binaryHint}`,
|
|
25
|
+
remediation: "run: npm install -g @openai/codex OR set RELAY_CODEX_PATH=/full/path/to/codex in your MCP config",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
if (isVersionBelow(version, MIN_CODEX_VERSION)) {
|
|
29
|
+
return {
|
|
30
|
+
worker: "codex",
|
|
31
|
+
available: false,
|
|
32
|
+
version,
|
|
33
|
+
reason: `Codex ${version} is below minimum ${minVersion}`,
|
|
34
|
+
remediation: "run: npm install -g @openai/codex@latest then restart relay-mcp",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
await execFileAsync(codexBin, ["login", "status"]);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return {
|
|
42
|
+
worker: "codex",
|
|
43
|
+
available: false,
|
|
44
|
+
version,
|
|
45
|
+
reason: `Codex ${version} found but not authenticated`,
|
|
46
|
+
remediation: "run: codex login then restart relay-mcp",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
worker: "codex",
|
|
51
|
+
available: true,
|
|
52
|
+
version,
|
|
53
|
+
reason: `${version} authenticated`,
|
|
54
|
+
remediation: "",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { makeError } from "../errors.js";
|
|
2
|
+
import { getLmStudioEndpoint, getLmStudioApiKey } from "../config/providers.js";
|
|
3
|
+
export function parseLmStudioResponse(body) {
|
|
4
|
+
const choices = body["choices"] ?? [];
|
|
5
|
+
const output = choices[0]?.message?.content ?? "";
|
|
6
|
+
const usage = body["usage"];
|
|
7
|
+
return { output, token_usage: usage?.["completion_tokens"] ?? null };
|
|
8
|
+
}
|
|
9
|
+
export class LmStudioRunner {
|
|
10
|
+
async run(task) {
|
|
11
|
+
const model = task.model?.trim();
|
|
12
|
+
if (!model) {
|
|
13
|
+
return {
|
|
14
|
+
status: "error",
|
|
15
|
+
output: "",
|
|
16
|
+
duration_ms: 0,
|
|
17
|
+
exit_code: null,
|
|
18
|
+
error: makeError("INVALID_ARGS", "model is required when provider is \"lmstudio\".", false),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const endpoint = getLmStudioEndpoint();
|
|
22
|
+
const apiKey = getLmStudioApiKey();
|
|
23
|
+
const startTime = Date.now();
|
|
24
|
+
const controller = new AbortController();
|
|
25
|
+
const timeout = setTimeout(() => controller.abort(), task.timeout_ms);
|
|
26
|
+
const headers = {
|
|
27
|
+
"Content-Type": "application/json",
|
|
28
|
+
};
|
|
29
|
+
if (apiKey) {
|
|
30
|
+
headers["Authorization"] = `Bearer ${apiKey}`;
|
|
31
|
+
}
|
|
32
|
+
const requestBody = {
|
|
33
|
+
model,
|
|
34
|
+
messages: [{ role: "user", content: task.task }],
|
|
35
|
+
};
|
|
36
|
+
let res;
|
|
37
|
+
try {
|
|
38
|
+
res = await fetch(`${endpoint}/v1/chat/completions`, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers,
|
|
41
|
+
body: JSON.stringify(requestBody),
|
|
42
|
+
signal: controller.signal,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
clearTimeout(timeout);
|
|
47
|
+
const duration_ms = Date.now() - startTime;
|
|
48
|
+
if (err.name === "AbortError") {
|
|
49
|
+
return {
|
|
50
|
+
status: "timeout",
|
|
51
|
+
output: "",
|
|
52
|
+
duration_ms,
|
|
53
|
+
exit_code: null,
|
|
54
|
+
error: makeError("TIMEOUT", `LM Studio timed out after ${task.timeout_ms}ms`, true),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
status: "error",
|
|
59
|
+
output: "",
|
|
60
|
+
duration_ms,
|
|
61
|
+
exit_code: null,
|
|
62
|
+
error: makeError("PROVIDER_ERROR", `LM Studio fetch failed: ${String(err)}. Is it running at ${endpoint}?`, true),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
clearTimeout(timeout);
|
|
67
|
+
}
|
|
68
|
+
const duration_ms = Date.now() - startTime;
|
|
69
|
+
if (!res.ok) {
|
|
70
|
+
const body = await res.text();
|
|
71
|
+
return {
|
|
72
|
+
status: "error",
|
|
73
|
+
output: "",
|
|
74
|
+
duration_ms,
|
|
75
|
+
exit_code: res.status,
|
|
76
|
+
error: makeError("PROVIDER_ERROR", `LM Studio returned ${res.status}: ${body}`, res.status >= 500),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const body = (await res.json());
|
|
80
|
+
const { output, token_usage } = parseLmStudioResponse(body);
|
|
81
|
+
task.logStream?.write(output + "\n");
|
|
82
|
+
return { status: "success", output, duration_ms, exit_code: 0, token_usage };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { makeError } from "../errors.js";
|
|
2
|
+
import { getOpenRouterApiKey } from "../config/providers.js";
|
|
3
|
+
export function parseOpenRouterResponse(body) {
|
|
4
|
+
const choices = body["choices"] ?? [];
|
|
5
|
+
const output = choices[0]?.message?.content ?? "";
|
|
6
|
+
const usage = body["usage"];
|
|
7
|
+
return { output, token_usage: usage?.["completion_tokens"] ?? null };
|
|
8
|
+
}
|
|
9
|
+
export class OpenRouterRunner {
|
|
10
|
+
async run(task) {
|
|
11
|
+
const model = task.model?.trim();
|
|
12
|
+
if (!model) {
|
|
13
|
+
return {
|
|
14
|
+
status: "error",
|
|
15
|
+
output: "",
|
|
16
|
+
duration_ms: 0,
|
|
17
|
+
exit_code: null,
|
|
18
|
+
error: makeError("INVALID_ARGS", "model is required when provider is \"openrouter\".", false),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const apiKey = getOpenRouterApiKey();
|
|
22
|
+
if (!apiKey) {
|
|
23
|
+
return {
|
|
24
|
+
status: "error",
|
|
25
|
+
output: "",
|
|
26
|
+
duration_ms: 0,
|
|
27
|
+
exit_code: null,
|
|
28
|
+
error: makeError("PROVIDER_NOT_CONFIGURED", "OPENROUTER_API_KEY is not set. Add it to your MCP config env.", false),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const startTime = Date.now();
|
|
32
|
+
const controller = new AbortController();
|
|
33
|
+
const timeout = setTimeout(() => controller.abort(), task.timeout_ms);
|
|
34
|
+
let res;
|
|
35
|
+
try {
|
|
36
|
+
res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: {
|
|
39
|
+
Authorization: `Bearer ${apiKey}`,
|
|
40
|
+
"Content-Type": "application/json",
|
|
41
|
+
},
|
|
42
|
+
body: JSON.stringify({
|
|
43
|
+
model,
|
|
44
|
+
messages: [{ role: "user", content: task.task }],
|
|
45
|
+
}),
|
|
46
|
+
signal: controller.signal,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
clearTimeout(timeout);
|
|
51
|
+
const duration_ms = Date.now() - startTime;
|
|
52
|
+
if (err.name === "AbortError") {
|
|
53
|
+
return {
|
|
54
|
+
status: "timeout",
|
|
55
|
+
output: "",
|
|
56
|
+
duration_ms,
|
|
57
|
+
exit_code: null,
|
|
58
|
+
error: makeError("TIMEOUT", `OpenRouter timed out after ${task.timeout_ms}ms`, true),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
status: "error",
|
|
63
|
+
output: "",
|
|
64
|
+
duration_ms,
|
|
65
|
+
exit_code: null,
|
|
66
|
+
error: makeError("PROVIDER_ERROR", `OpenRouter fetch failed: ${String(err)}`, true),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
clearTimeout(timeout);
|
|
71
|
+
}
|
|
72
|
+
const duration_ms = Date.now() - startTime;
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
const body = await res.text();
|
|
75
|
+
return {
|
|
76
|
+
status: "error",
|
|
77
|
+
output: "",
|
|
78
|
+
duration_ms,
|
|
79
|
+
exit_code: res.status,
|
|
80
|
+
error: makeError("PROVIDER_ERROR", `OpenRouter returned ${res.status}: ${body}`, res.status >= 500),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const body = (await res.json());
|
|
84
|
+
const { output, token_usage } = parseOpenRouterResponse(body);
|
|
85
|
+
task.logStream?.write(output + "\n");
|
|
86
|
+
return { status: "success", output, duration_ms, exit_code: 0, token_usage };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { CodexRunner } from "./codex.js";
|
|
2
|
+
import { OpenRouterRunner } from "./openrouter.js";
|
|
3
|
+
import { LmStudioRunner } from "./lmstudio.js";
|
|
4
|
+
const runners = {
|
|
5
|
+
codex: new CodexRunner(),
|
|
6
|
+
openrouter: new OpenRouterRunner(),
|
|
7
|
+
lmstudio: new LmStudioRunner(),
|
|
8
|
+
};
|
|
9
|
+
export function getRunner(provider) {
|
|
10
|
+
const runner = runners[provider];
|
|
11
|
+
if (!runner) {
|
|
12
|
+
throw new Error(`PROVIDER_NOT_CONFIGURED: unknown provider "${provider}"`);
|
|
13
|
+
}
|
|
14
|
+
return runner;
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sghanavati/relay-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "MCP server that lets Claude Code delegate coding tasks to Codex",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -25,12 +25,13 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@modelcontextprotocol/sdk": "^1.20.2",
|
|
28
|
-
"
|
|
28
|
+
"async-mutex": "^0.5.0",
|
|
29
29
|
"execa": "^9.6.0",
|
|
30
|
-
"
|
|
30
|
+
"zod": "^3.25.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
}
|
|
33
|
+
"@types/node": "^22.0.0",
|
|
34
|
+
"typescript": "^5.0.0"
|
|
35
|
+
},
|
|
36
|
+
"license": "MIT"
|
|
36
37
|
}
|