Skip to content

Commit f32fa44

Browse files
hyperpolymathclaude
andcommitted
feat(mcp-bridge): migrate Node.js stdio bridge to Deno
Replaces all Node.js-specific APIs with native Deno equivalents: - process.env.* → Deno.env.get() - process.stdin event loop → for-await async iteration - process.stdout.write → Deno.stdout.writeSync (encoder) - process.stderr.write → Deno.stderr.writeSync (encoder) - process.exit → Deno.exit - process.pid → Deno.pid - Node shebang → Deno shebang on main.js + generate-offline-menu.js - Adds deno.json with tasks and nodeModulesDir: none - Nickel temp file moved to /tmp with absolute import path - probeNickel() now catches PermissionDenied gracefully node:child_process, node:fs, node:path, node:url imports retained (Deno's Node compat layer handles them without change). deno check: zero warnings. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a6c8456 commit f32fa44

7 files changed

Lines changed: 66 additions & 46 deletions

File tree

mcp-bridge/deno.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
//
4+
// Deno configuration for the BoJ MCP stdio bridge.
5+
{
6+
"tasks": {
7+
"start": "deno run --allow-net --allow-env --allow-read main.js",
8+
"gen-menu": "deno run --allow-read lib/generate-offline-menu.js"
9+
},
10+
"nodeModulesDir": "none"
11+
}

mcp-bridge/lib/api-clients.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ import { isValidCartridgeName } from "./security.js";
1010
import { warn } from "./logger.js";
1111
import { SERVER_VERSION } from "./version.js";
1212

13-
const BOJ_BASE = process.env.BOJ_URL || "http://localhost:7700";
14-
const GITHUB_TOKEN = process.env.GITHUB_TOKEN || "";
15-
const GITLAB_TOKEN = process.env.GITLAB_TOKEN || "";
13+
const BOJ_BASE = Deno.env.get("BOJ_URL") ?? "http://localhost:7700";
14+
const GITHUB_TOKEN = Deno.env.get("GITHUB_TOKEN") ?? "";
15+
const GITLAB_TOKEN = Deno.env.get("GITLAB_TOKEN") ?? "";
1616

1717
// ===================================================================
1818
// BoJ REST API wrappers
@@ -253,7 +253,7 @@ async function gitlabApiCall(method, path, body) {
253253
if (!GITLAB_TOKEN) {
254254
return { error: "GITLAB_TOKEN not set." };
255255
}
256-
const baseUrl = process.env.GITLAB_URL || "https://gitlab.com";
256+
const baseUrl = Deno.env.get("GITLAB_URL") ?? "https://gitlab.com";
257257
try {
258258
const url = `${baseUrl}/api/v4${path}`;
259259
const opts = {

mcp-bridge/lib/generate-offline-menu.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#!/usr/bin/env node
1+
#!/usr/bin/env -S deno run --allow-read
22
// SPDX-License-Identifier: MPL-2.0
33
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
44
//
@@ -30,5 +30,5 @@ try {
3030
console.log("\nUpdate mcp-bridge/lib/offline-menu.js with any new cartridges.");
3131
} catch (err) {
3232
console.error(`Error scanning cartridges directory: ${err.message}`);
33-
process.exit(1);
33+
Deno.exit(1);
3434
}

mcp-bridge/lib/logger.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
// MCP JSON-RPC messages). Log level controlled via BOJ_LOG_LEVEL env.
88

99
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
10-
let currentLevel = LOG_LEVELS[process.env.BOJ_LOG_LEVEL || "info"] ?? LOG_LEVELS.info;
10+
const _enc = new TextEncoder();
11+
let currentLevel = LOG_LEVELS[Deno.env.get("BOJ_LOG_LEVEL") ?? "info"] ?? LOG_LEVELS.info;
1112

1213
/**
1314
* Update the minimum log level at runtime. Used by the MCP
@@ -38,7 +39,7 @@ function log(level, message, fields = {}) {
3839
msg: message,
3940
...fields,
4041
};
41-
process.stderr.write(JSON.stringify(entry) + "\n");
42+
Deno.stderr.writeSync(_enc.encode(JSON.stringify(entry) + "\n"));
4243
}
4344

4445
/** @param {string} msg @param {Record<string, unknown>} [fields] */

mcp-bridge/lib/nickel-validator.js

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,15 @@ export function contractsPath() {
5959
/** Probe whether `nickel` is on PATH. Cached. */
6060
function probeNickel() {
6161
if (nickelAvailable !== null) return nickelAvailable;
62-
const r = spawnSync("nickel", ["--version"], { stdio: "ignore" });
63-
nickelAvailable = r.status === 0;
62+
try {
63+
const r = spawnSync("nickel", ["--version"], { stdio: "ignore" });
64+
nickelAvailable = r.status === 0;
65+
} catch {
66+
// PermissionDenied (no --allow-run) or binary not found — treat as absent.
67+
nickelAvailable = false;
68+
}
6469
if (!nickelAvailable) {
65-
const strict = process.env.COORD_REQUIRE_NICKEL === "1";
70+
const strict = Deno.env.get("COORD_REQUIRE_NICKEL") === "1";
6671
if (strict) {
6772
throw new Error("COORD_REQUIRE_NICKEL=1 but `nickel` not on PATH");
6873
}
@@ -124,17 +129,17 @@ export function validateEnvelope(envelope, senderRole) {
124129
}
125130

126131
// Write the call-site script to a temp file in the same dir as the
127-
// contracts so its relative `import` resolves.
128-
const tmp = resolve(dirname(path), `._validate_${process.pid}_${Date.now()}.ncl`);
132+
// contracts so its relative `import` resolves. /tmp is writable and
133+
// isolated enough; we use an absolute import path so location doesn't matter.
134+
const tmp = resolve("/tmp", `._boj_validate_${Deno.pid}_${Date.now()}.ncl`);
129135
const script =
130-
`let c = import "${path.split("/").pop()}" in\n` +
136+
`let c = import "${path}" in\n` +
131137
`let e = ${toNickel(withMeta)} in\n` +
132138
`c.validate e\n`;
133139

134140
try {
135141
writeFileSync(tmp, script);
136142
const r = spawnSync("nickel", ["eval", tmp], {
137-
cwd: dirname(path),
138143
encoding: "utf8",
139144
});
140145
if (r.status === 0) return { ok: true };

mcp-bridge/lib/security.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ function scanObjectForInjection(obj, maxDepth = 10) {
133133
// Rate limiter (token bucket)
134134
// ===================================================================
135135

136-
const RATE_LIMIT = parseInt(process.env.BOJ_RATE_LIMIT, 10) || 60;
136+
const RATE_LIMIT = parseInt(Deno.env.get("BOJ_RATE_LIMIT") ?? "60", 10) || 60;
137137
const RATE_WINDOW_MS = 60_000;
138138

139139
const rateBucket = {

mcp-bridge/main.js

Lines changed: 33 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#!/usr/bin/env node
1+
#!/usr/bin/env -S deno run --allow-net --allow-env --allow-read
22
// SPDX-License-Identifier: MPL-2.0
33
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
44
//
@@ -8,8 +8,7 @@
88
// stdio protocol so that Claude Code, Glama, and other MCP clients
99
// can discover and invoke BoJ cartridge tools.
1010
//
11-
// Usage: deno run --allow-net main.js
12-
// or: node main.js
11+
// Usage: deno run --allow-net --allow-env --allow-read main.js
1312

1413
import {
1514
RATE_LIMIT,
@@ -37,45 +36,24 @@ import {
3736
} from "./lib/nickel-validator.js";
3837
import { info, warn, error as logError, setLevel as setLogLevel } from "./lib/logger.js";
3938

40-
const BOJ_BASE = process.env.BOJ_URL || "http://localhost:7700";
39+
const BOJ_BASE = Deno.env.get("BOJ_URL") ?? "http://localhost:7700";
4140
const SERVER_NAME = "boj-server";
4241
const SERVER_VERSION = "0.4.0";
4342

4443
// ===================================================================
4544
// JSON-RPC stdio transport
4645
// ===================================================================
4746

47+
const encoder = new TextEncoder();
48+
const decoder = new TextDecoder();
49+
4850
let buffer = "";
4951
const MAX_BUFFER_BYTES = 2 * 1_048_576; // 2 MB
5052

51-
process.stdin.setEncoding("utf8");
5253
const pendingMessages = [];
5354

54-
process.stdin.on("data", (chunk) => {
55-
buffer += chunk;
56-
if (buffer.length > MAX_BUFFER_BYTES) {
57-
sendError(null, -32600, "Message too large");
58-
buffer = "";
59-
return;
60-
}
61-
let boundary;
62-
while ((boundary = buffer.indexOf("\n")) !== -1) {
63-
const line = buffer.slice(0, boundary).trim();
64-
buffer = buffer.slice(boundary + 1);
65-
if (line.length > 0) {
66-
const p = handleMessage(line).catch(() => {});
67-
pendingMessages.push(p);
68-
}
69-
}
70-
});
71-
72-
process.stdin.on("end", async () => {
73-
await Promise.allSettled(pendingMessages);
74-
process.exit(0);
75-
});
76-
7755
function send(obj) {
78-
process.stdout.write(JSON.stringify(obj) + "\n");
56+
Deno.stdout.writeSync(encoder.encode(JSON.stringify(obj) + "\n"));
7957
}
8058

8159
function sendResult(id, result) {
@@ -268,7 +246,7 @@ async function dispatchTool(toolName, args) {
268246
// local-coord-mcp direct dispatch (loopback only, port 7745)
269247
// ===================================================================
270248

271-
const LOCAL_COORD_URL = process.env.COORD_BACKEND_URL || "http://127.0.0.1:7745";
249+
const LOCAL_COORD_URL = Deno.env.get("COORD_BACKEND_URL") ?? "http://127.0.0.1:7745";
272250

273251
// Nickel contracts run on coord_send / coord_send_gated only — those
274252
// are the two tools whose `message` argument carries an A2ML envelope.
@@ -477,3 +455,28 @@ async function handleMessage(line) {
477455
}
478456
}
479457
}
458+
459+
// ===================================================================
460+
// Main I/O loop — async-iterable stdin (Deno)
461+
// ===================================================================
462+
463+
for await (const chunk of Deno.stdin.readable) {
464+
buffer += decoder.decode(chunk);
465+
if (buffer.length > MAX_BUFFER_BYTES) {
466+
sendError(null, -32600, "Message too large");
467+
buffer = "";
468+
continue;
469+
}
470+
let boundary;
471+
while ((boundary = buffer.indexOf("\n")) !== -1) {
472+
const line = buffer.slice(0, boundary).trim();
473+
buffer = buffer.slice(boundary + 1);
474+
if (line.length > 0) {
475+
const p = handleMessage(line).catch(() => {});
476+
pendingMessages.push(p);
477+
}
478+
}
479+
}
480+
481+
await Promise.allSettled(pendingMessages);
482+
Deno.exit(0);

0 commit comments

Comments
 (0)