theseus/bundled-addons/claude-connector/stdio-shim.cjs
Dev c06557e957 Theseus: Claude connector, an opt-in MCP server for the signed-in profile
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.
2026-10-23 17:45:02 +03:00

102 lines
4.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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);
});