@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 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 (currently only codex)"),
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 the Codex provider. Valid values: 'gpt-5.3-codex' (default, highest quality), 'gpt-5-codex-mini' (faster, budget). Do NOT use OpenAI model names (o4-mini, gpt-4o, etc) — they are not valid for Codex."),
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 { 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);
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 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);
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: ${codexVersion} ✓\n auth: authenticated ✓\n listening on stdio...\n`);
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 { execFile } from "node:child_process";
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 codexDiag = await checkCodexWorker();
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,
@@ -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 { runCodexWorker } from "../workers/codex.js";
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
- function readAgentsMd(workdir) {
19
- try {
20
- const content = readFileSync(join(workdir, "AGENTS.md"), "utf8").trim();
21
- return content.length > 0 ? content : null;
22
- }
23
- catch {
24
- return null;
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 = readAgentsMd(workdir);
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 preSnapshot = await takeSnapshot(workdir);
75
- const release = await getWorkdirMutex(workdir).acquire();
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 runCodexWorker(workerTask);
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: 0,
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: makeError("UNKNOWN", `Unexpected worker error: ${String(err)}`, false),
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 postSnapshot = await takeSnapshot(workdir);
113
- const filesChanged = diffSnapshots(preSnapshot, postSnapshot);
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
+ }
@@ -52,7 +52,12 @@ export async function runCodexWorker(task) {
52
52
  }
53
53
  });
54
54
  child.stderr?.on("data", (chunk) => {
55
- process.stderr.write(chunk);
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",
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
- "zod": "^3.25.0",
28
+ "async-mutex": "^0.5.0",
29
29
  "execa": "^9.6.0",
30
- "async-mutex": "^0.5.0"
30
+ "zod": "^3.25.0"
31
31
  },
32
32
  "devDependencies": {
33
- "typescript": "^5.0.0",
34
- "@types/node": "^22.0.0"
35
- }
33
+ "@types/node": "^22.0.0",
34
+ "typescript": "^5.0.0"
35
+ },
36
+ "license": "MIT"
36
37
  }