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.
276 lines
12 KiB
JavaScript
276 lines
12 KiB
JavaScript
// MCP over Streamable HTTP, loopback only.
|
|
//
|
|
// POST /mcp JSON-RPC request(s) → application/json response
|
|
// GET /mcp SSE stream for server notifications (also the "a client is
|
|
// still here" signal behind the toolbar indicator)
|
|
// DELETE /mcp end the session
|
|
//
|
|
// Every request needs `Authorization: Bearer <token>`. Requests with an
|
|
// Origin header (a web page) or a Host other than this loopback address
|
|
// (DNS rebinding) are refused before the token is even looked at.
|
|
|
|
const http = require("node:http");
|
|
const crypto = require("node:crypto");
|
|
|
|
const PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
const MAX_BODY = 32 * 1024 * 1024;
|
|
// A session counts as connected while its SSE stream is open, or for this
|
|
// long after its last request.
|
|
const IDLE_MS = 2 * 60 * 1000;
|
|
const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
|
|
|
const sha = (s) => crypto.createHash("sha256").update(String(s)).digest();
|
|
function tokenMatches(given, expected) {
|
|
if (!given || !expected) return false;
|
|
return crypto.timingSafeEqual(sha(given), sha(expected));
|
|
}
|
|
|
|
class McpServer {
|
|
// tools: { list(): [toolDef], call(name, args, session) => Promise<result> }
|
|
constructor({ getToken, tools, serverInfo, instructions, onStatus, log }) {
|
|
this.getToken = getToken;
|
|
this.tools = tools;
|
|
this.serverInfo = serverInfo || { name: "theseus", version: "0.0.0" };
|
|
this.instructions = instructions || "";
|
|
this.onStatus = typeof onStatus === "function" ? onStatus : () => {};
|
|
this.log = typeof log === "function" ? log : () => {};
|
|
this.sessions = new Map(); // id -> { id, client, protocolVersion, created, lastSeen, streams:Set<res> }
|
|
this.server = null;
|
|
this.port = null;
|
|
this._lastStatus = "";
|
|
this._sweep = null;
|
|
}
|
|
|
|
get url() { return this.port ? `http://127.0.0.1:${this.port}/mcp` : null; }
|
|
|
|
listen(port) {
|
|
return new Promise((resolve, reject) => {
|
|
const srv = http.createServer((req, res) => this._handle(req, res).catch((e) => {
|
|
this.log("request failed:", e?.message || e);
|
|
if (!res.headersSent) this._json(res, 500, { jsonrpc: "2.0", id: null, error: { code: -32603, message: "internal error" } });
|
|
else try { res.end(); } catch {}
|
|
}));
|
|
srv.keepAliveTimeout = 5000;
|
|
srv.requestTimeout = 0; // tool calls may wait on the user (site approval)
|
|
srv.headersTimeout = 20000;
|
|
const fail = (e) => { srv.removeListener("listening", ok); reject(e); };
|
|
const ok = () => {
|
|
srv.removeListener("error", fail);
|
|
this.server = srv;
|
|
this.port = srv.address().port;
|
|
this._sweep = setInterval(() => this._expire(), 15000);
|
|
this._sweep.unref?.();
|
|
resolve(this.port);
|
|
};
|
|
srv.once("error", fail);
|
|
srv.once("listening", ok);
|
|
srv.listen(port, "127.0.0.1");
|
|
});
|
|
}
|
|
|
|
async close() {
|
|
clearInterval(this._sweep);
|
|
this._sweep = null;
|
|
this.dropAllSessions();
|
|
const srv = this.server;
|
|
this.server = null;
|
|
this.port = null;
|
|
if (srv) await new Promise((r) => { srv.close(() => r()); srv.closeAllConnections?.(); });
|
|
this._emitStatus();
|
|
}
|
|
|
|
dropAllSessions() {
|
|
for (const s of this.sessions.values()) for (const r of s.streams) { try { r.end(); } catch {} }
|
|
this.sessions.clear();
|
|
this._emitStatus();
|
|
}
|
|
|
|
// Tell every connected client that the tool list changed (evaluate on/off).
|
|
notifyToolsChanged() {
|
|
const msg = `event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", method: "notifications/tools/list_changed" })}\n\n`;
|
|
for (const s of this.sessions.values()) for (const r of s.streams) { try { r.write(msg); } catch {} }
|
|
}
|
|
|
|
status() {
|
|
const now = Date.now();
|
|
const live = [...this.sessions.values()].filter((s) => s.streams.size > 0 || now - s.lastSeen < IDLE_MS);
|
|
return {
|
|
listening: !!this.server,
|
|
port: this.port,
|
|
connected: live.length > 0,
|
|
clients: [...new Set(live.map((s) => s.client))],
|
|
sessions: live.map((s) => ({ client: s.client, since: new Date(s.created).toISOString(), lastSeen: new Date(s.lastSeen).toISOString(), streaming: s.streams.size > 0 })),
|
|
};
|
|
}
|
|
|
|
_emitStatus() {
|
|
const st = this.status();
|
|
const key = JSON.stringify([st.listening, st.port, st.connected, st.clients]);
|
|
if (key === this._lastStatus) return;
|
|
this._lastStatus = key;
|
|
this.onStatus(st);
|
|
}
|
|
|
|
_expire() {
|
|
const now = Date.now();
|
|
for (const [id, s] of this.sessions) {
|
|
if (s.streams.size === 0 && now - s.lastSeen > SESSION_TTL_MS) this.sessions.delete(id);
|
|
}
|
|
this._emitStatus();
|
|
}
|
|
|
|
_json(res, status, body, headers = {}) {
|
|
const data = body === undefined ? "" : JSON.stringify(body);
|
|
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", ...headers });
|
|
res.end(data);
|
|
}
|
|
|
|
_refuse(res, status, message) {
|
|
res.writeHead(status, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", ...(status === 401 ? { "www-authenticate": 'Bearer realm="theseus"' } : {}) });
|
|
res.end(message);
|
|
}
|
|
|
|
async _handle(req, res) {
|
|
const ra = req.socket.remoteAddress || "";
|
|
if (!/^(?:127\.|::1$|::ffff:127\.)/.test(ra)) return this._refuse(res, 403, "loopback only");
|
|
const host = String(req.headers.host || "").toLowerCase();
|
|
if (host !== `127.0.0.1:${this.port}` && host !== `localhost:${this.port}`) return this._refuse(res, 403, "bad host");
|
|
// Browsers always send Origin on cross-origin requests; MCP clients do
|
|
// not. A page in any browser on this computer gets nothing here.
|
|
if (req.headers.origin) return this._refuse(res, 403, "browser requests are not accepted");
|
|
const url = new URL(req.url, `http://${host}`);
|
|
if (url.pathname !== "/mcp") return this._refuse(res, 404, "not found");
|
|
const auth = String(req.headers.authorization || "");
|
|
const m = /^Bearer\s+(\S+)$/i.exec(auth);
|
|
if (!m || !tokenMatches(m[1], this.getToken())) return this._refuse(res, 401, "missing or wrong token");
|
|
|
|
const sid = req.headers["mcp-session-id"] ? String(req.headers["mcp-session-id"]) : null;
|
|
if (req.method === "DELETE") {
|
|
const s = sid && this.sessions.get(sid);
|
|
if (s) { for (const r of s.streams) { try { r.end(); } catch {} } this.sessions.delete(sid); this._emitStatus(); }
|
|
return this._json(res, 200, undefined);
|
|
}
|
|
if (req.method === "GET") {
|
|
if (!String(req.headers.accept || "").includes("text/event-stream")) return this._refuse(res, 406, "GET needs Accept: text/event-stream");
|
|
const s = sid && this.sessions.get(sid);
|
|
if (!s) return this._refuse(res, sid ? 404 : 400, sid ? "unknown session" : "missing Mcp-Session-Id");
|
|
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
|
|
res.write(": theseus\n\n");
|
|
s.streams.add(res);
|
|
s.lastSeen = Date.now();
|
|
const ping = setInterval(() => { try { res.write(": ping\n\n"); } catch {} }, 20000);
|
|
ping.unref?.();
|
|
const done = () => { clearInterval(ping); s.streams.delete(res); s.lastSeen = Date.now(); this._emitStatus(); };
|
|
req.on("close", done);
|
|
res.on("close", done);
|
|
this._emitStatus();
|
|
return;
|
|
}
|
|
if (req.method !== "POST") return this._refuse(res, 405, "method not allowed");
|
|
|
|
const body = await readBody(req);
|
|
let msg;
|
|
try { msg = JSON.parse(body); } catch { return this._json(res, 400, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } }); }
|
|
const batch = Array.isArray(msg);
|
|
const msgs = batch ? msg : [msg];
|
|
if (!msgs.length) return this._json(res, 400, { jsonrpc: "2.0", id: null, error: { code: -32600, message: "empty batch" } });
|
|
|
|
const init = msgs.find((x) => x && x.method === "initialize");
|
|
let session = sid ? this.sessions.get(sid) : null;
|
|
const headers = {};
|
|
if (init) {
|
|
const id = crypto.randomBytes(16).toString("hex");
|
|
const ci = (init.params && init.params.clientInfo) || {};
|
|
session = { id, client: String(ci.name || "MCP client").slice(0, 60), clientVersion: String(ci.version || ""), protocolVersion: null, created: Date.now(), lastSeen: Date.now(), streams: new Set() };
|
|
this.sessions.set(id, session);
|
|
headers["mcp-session-id"] = id;
|
|
this.log(`client connected: ${session.client} ${session.clientVersion}`.trim());
|
|
} else if (!session) {
|
|
// Unknown id: the client re-initializes (spec: 404 means "start over").
|
|
return this._json(res, sid ? 404 : 400, { jsonrpc: "2.0", id: null, error: { code: -32000, message: sid ? "unknown session; initialize again" : "missing Mcp-Session-Id; initialize first" } });
|
|
}
|
|
session.lastSeen = Date.now();
|
|
this._emitStatus();
|
|
|
|
const replies = [];
|
|
for (const m1 of msgs) {
|
|
if (!m1 || m1.jsonrpc !== "2.0" || typeof m1.method !== "string") {
|
|
if (m1 && m1.id !== undefined && (m1.result !== undefined || m1.error !== undefined)) continue; // a client's reply to us
|
|
replies.push({ jsonrpc: "2.0", id: m1 && m1.id !== undefined ? m1.id : null, error: { code: -32600, message: "invalid request" } });
|
|
continue;
|
|
}
|
|
const isNotification = m1.id === undefined || m1.id === null;
|
|
const r = await this._dispatch(m1, session);
|
|
if (!isNotification) replies.push(r);
|
|
}
|
|
session.lastSeen = Date.now();
|
|
if (!replies.length) { res.writeHead(202, headers); return res.end(); }
|
|
return this._json(res, 200, batch ? replies : replies[0], headers);
|
|
}
|
|
|
|
async _dispatch(m, session) {
|
|
const ok = (result) => ({ jsonrpc: "2.0", id: m.id, result });
|
|
const err = (code, message) => ({ jsonrpc: "2.0", id: m.id, error: { code, message } });
|
|
try {
|
|
switch (m.method) {
|
|
case "initialize": {
|
|
const asked = m.params && m.params.protocolVersion;
|
|
const v = PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0];
|
|
session.protocolVersion = v;
|
|
return ok({ protocolVersion: v, capabilities: { tools: { listChanged: true } }, serverInfo: this.serverInfo, instructions: this.instructions });
|
|
}
|
|
case "notifications/initialized":
|
|
case "notifications/cancelled":
|
|
case "notifications/roots/list_changed":
|
|
return null;
|
|
case "ping":
|
|
return ok({});
|
|
case "tools/list":
|
|
return ok({ tools: this.tools.list() });
|
|
case "tools/call": {
|
|
const name = m.params && m.params.name;
|
|
const args = (m.params && m.params.arguments) || {};
|
|
if (!this.tools.list().some((t) => t.name === name)) return err(-32602, `unknown tool: ${name}`);
|
|
try {
|
|
return ok(await this.tools.call(name, args, session));
|
|
} catch (e) {
|
|
return ok({ content: [{ type: "text", text: `Error: ${e?.message || e}` }], isError: true });
|
|
}
|
|
}
|
|
default:
|
|
if (m.method.startsWith("notifications/")) return null;
|
|
return err(-32601, `method not found: ${m.method}`);
|
|
}
|
|
} catch (e) {
|
|
return err(-32603, e?.message || String(e));
|
|
}
|
|
}
|
|
}
|
|
|
|
function readBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks = [];
|
|
let n = 0;
|
|
req.on("data", (c) => {
|
|
n += c.length;
|
|
if (n > MAX_BODY) { reject(new Error("request too large")); req.destroy(); return; }
|
|
chunks.push(c);
|
|
});
|
|
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
req.on("error", reject);
|
|
});
|
|
}
|
|
|
|
// First free port in the connector's range, else any free port.
|
|
async function pickPort(preferred) {
|
|
const tryPort = (p) => new Promise((resolve) => {
|
|
const s = http.createServer();
|
|
s.once("error", () => resolve(null));
|
|
s.listen(p, "127.0.0.1", () => { const got = s.address().port; s.close(() => resolve(got)); });
|
|
});
|
|
if (preferred) { const p = await tryPort(preferred); if (p) return p; }
|
|
for (let p = 47800; p < 47900; p++) { const got = await tryPort(p); if (got) return got; }
|
|
return tryPort(0);
|
|
}
|
|
|
|
module.exports = { McpServer, pickPort, tokenMatches, PROTOCOL_VERSIONS };
|