#!/usr/bin/env node // stdio ⇄ Streamable HTTP relay for MCP clients that only start stdio // servers (Claude Desktop). Run by the Theseus executable itself in Node // mode, so nothing else needs installing: // // "command": "C:\\...\\Theseus Navigator.exe", // "args": ["\\extensions\\claude-connector\\stdio-shim.cjs"], // "env": { "ELECTRON_RUN_AS_NODE": "1", "THESEUS_MCP_URL": "http://127.0.0.1:47800/mcp", "THESEUS_MCP_TOKEN": "…" } // // THESEUS_MCP_URL may be left out: the shim then reads the address Theseus // wrote to extensions-data/claude-connector/endpoint.json (THESEUS_USER_DATA, // else %APPDATA%\Theseus). const fs = require("node:fs"); const path = require("node:path"); const os = require("node:os"); const readline = require("node:readline"); const TOKEN = process.env.THESEUS_MCP_TOKEN || ""; const err = (...a) => process.stderr.write(`[theseus-shim] ${a.join(" ")}\n`); function endpoint() { if (process.env.THESEUS_MCP_URL) return process.env.THESEUS_MCP_URL; const ud = process.env.THESEUS_USER_DATA || (process.platform === "win32" ? path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "Theseus") : process.platform === "darwin" ? path.join(os.homedir(), "Library", "Application Support", "Theseus") : path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "Theseus")); try { return JSON.parse(fs.readFileSync(path.join(ud, "extensions-data", "claude-connector", "endpoint.json"), "utf8")).url; } catch { return null; } } let sessionId = null; let stream = null; const out = (obj) => process.stdout.write(JSON.stringify(obj) + "\n"); function failFor(msg, text) { if (msg && msg.id !== undefined && msg.id !== null) out({ jsonrpc: "2.0", id: msg.id, error: { code: -32000, message: text } }); } async function post(msg) { const url = endpoint(); if (!url) return failFor(msg, "Theseus is not running, or the Claude connector is off (Settings › Extensions)"); const headers = { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${TOKEN}` }; if (sessionId) headers["mcp-session-id"] = sessionId; let res; try { res = await fetch(url, { method: "POST", headers, body: JSON.stringify(msg) }); } catch (e) { return failFor(msg, `Theseus did not answer at ${url} (${e.message})`); } const sid = res.headers.get("mcp-session-id"); if (sid && sid !== sessionId) { sessionId = sid; openStream(url); } if (res.status === 202) return; const body = await res.text(); if (!res.ok && !body.startsWith("{")) return failFor(msg, `Theseus refused the request (${res.status}: ${body.trim()})`); try { const data = JSON.parse(body); for (const m of Array.isArray(data) ? data : [data]) out(m); } catch { failFor(msg, `unreadable answer from Theseus (${res.status})`); } } // Server notifications (tools/list_changed) and the "still connected" signal. async function openStream(url) { if (stream) stream.abort(); const ctl = new AbortController(); stream = ctl; try { const res = await fetch(url, { headers: { accept: "text/event-stream", authorization: `Bearer ${TOKEN}`, "mcp-session-id": sessionId }, signal: ctl.signal }); if (!res.ok || !res.body) return; const reader = res.body.getReader(); const dec = new TextDecoder(); let buf = ""; for (;;) { const { value, done } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); let i; while ((i = buf.indexOf("\n\n")) >= 0) { const block = buf.slice(0, i); buf = buf.slice(i + 2); const data = block.split("\n").filter((l) => l.startsWith("data: ")).map((l) => l.slice(6)).join("\n"); if (data) { try { out(JSON.parse(data)); } catch {} } } } } catch (e) { if (!ctl.signal.aborted) err("event stream closed:", e.message); } } if (!TOKEN) err("THESEUS_MCP_TOKEN is not set; copy the command from the Claude connector panel in Theseus"); // Requests are relayed one at a time, in order. let chain = Promise.resolve(); const rl = readline.createInterface({ input: process.stdin }); rl.on("line", (line) => { if (!line.trim()) return; let msg; try { msg = JSON.parse(line); } catch { return err("ignored a line that is not JSON"); } chain = chain.then(() => post(msg)).catch((e) => err(e.message)); }); rl.on("close", async () => { await chain; if (stream) stream.abort(); const url = endpoint(); if (sessionId && url) { try { await fetch(url, { method: "DELETE", headers: { authorization: `Bearer ${TOKEN}`, "mcp-session-id": sessionId } }); } catch {} } process.exit(0); });