Claude sessions could not work inside the user's own profile: the desktop app's browser lost synthetic pastes and typing in Medium's editor, and a browser-wide --remote-debugging-port would hand every session to any local process. The connector is a built-in extension, off until turned on in Settings > Extensions, that serves MCP on 127.0.0.1 behind a per-install token and drives one tab at a time through that tab's webContents.debugger. - Host: a "tab-control" capability (built-in add-ons only) for listing, opening, navigating, closing and focusing tabs, handing out web tabs' webContents (never Settings, extension or home tabs), and a toolbar chip plus tab outline while a client is connected; the chip's X stops it. Built-in add-ons can ship "defaultEnabled": false. - Tools: list_tabs, open_tab, navigate, activate_tab, close_tab, screenshot, read_page_text, snapshot (accessibility tree with refs), click, type (insertText + real Enter), press_key, paste_html / paste_text (system clipboard + webContents.paste, clipboard restored), upload_file (DOM.setFileInputFiles), wait_for, and evaluate behind its own switch. - Safety: first use of each site asks the user; password and one-time-code fields are refused; uploads only from folders the user added; requests from web pages or foreign Host headers are refused; every action is logged with a timestamp in the panel and actions.log. - Claude Desktop runs stdio-shim.cjs through the Theseus executable in Node mode. - Tests: unit (protocol, auth, policy, shim) and an end-to-end run on a throwaway profile that types, pastes HTML with a heading and a link, and uploads a PNG on a local page.
102 lines
4.6 KiB
JavaScript
102 lines
4.6 KiB
JavaScript
#!/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": ["<userData>\\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);
|
||
});
|