diff --git a/bundled-addons/claude-connector/index.js b/bundled-addons/claude-connector/index.js index 3ef60615..4f7a316e 100644 --- a/bundled-addons/claude-connector/index.js +++ b/bundled-addons/claude-connector/index.js @@ -49,6 +49,10 @@ module.exports = { get allowEvaluate() { return api.storage.get("allowEvaluate", false) === true; }, get allowedOrigins() { const v = api.storage.get("allowedOrigins", []); return Array.isArray(v) ? v.map(String) : []; }, get uploadDirs() { const v = api.storage.get("uploadDirs", []); return Array.isArray(v) ? v.map(String) : []; }, + // Simultaneous-session cap. 0 = unlimited (default). Values are clamped + // between 1 and 100 everywhere a UI writes them, so a saved 1 or 5 means + // the user picked that limit on purpose. + get sessionLimit() { const v = Number(api.storage.get("sessionLimit", 0)); return Number.isFinite(v) && v > 0 ? Math.floor(v) : 0; }, }; if (!S.token) api.storage.set("token", newToken()); @@ -120,6 +124,7 @@ module.exports = { allowEvaluate: S.allowEvaluate, allowedOrigins: S.allowedOrigins, uploadDirs: S.uploadDirs, + sessionLimit: S.sessionLimit, token: S.token, commands: url ? commandsFor(url, S.token) : null, apps: appStatus, @@ -193,6 +198,7 @@ module.exports = { if (!port) throw new Error("no free local port"); const s = new McpServer({ getToken: () => S.token, + getSessionLimit: () => S.sessionLimit, tools: { list: () => controller.definitions(), call: (name, args, session) => controller.call(name, args, session) }, serverInfo: { name: "theseus", title: "Theseus Navigator", version: readVersion(api.folder) }, instructions: INSTRUCTIONS, @@ -252,6 +258,19 @@ module.exports = { if (on) await start(); else await stop("turned off in the panel"); return publicState(); }); + // Simultaneous-session cap. 0 = unlimited; any positive value is a hard + // ceiling checked at initialize time. Lowering below the current live + // count does NOT retroactively kick anyone — the next connect will be + // refused until an existing session goes away. + api.onMessage("set-session-limit", async ({ limit } = {}) => { + let n = Number(limit); + if (!Number.isFinite(n) || n < 0) n = 0; + if (n > 100) n = 100; + api.storage.set("sessionLimit", Math.floor(n)); + log.add({ tool: "connector", args: n > 0 ? `session cap set to ${Math.floor(n)}` : "session cap cleared (no limit)", ok: true }); + pushStatus(); + return publicState(); + }); api.onMessage("disconnect", async () => { if (server) server.dropAllSessions(); await controller.releaseAll(); diff --git a/bundled-addons/claude-connector/lib/mcp-server.cjs b/bundled-addons/claude-connector/lib/mcp-server.cjs index 85b7a419..80ee73a3 100644 --- a/bundled-addons/claude-connector/lib/mcp-server.cjs +++ b/bundled-addons/claude-connector/lib/mcp-server.cjs @@ -27,8 +27,12 @@ function tokenMatches(given, expected) { class McpServer { // tools: { list(): [toolDef], call(name, args, session) => Promise } - constructor({ getToken, tools, serverInfo, instructions, onStatus, log }) { + constructor({ getToken, getSessionLimit, tools, serverInfo, instructions, onStatus, log }) { this.getToken = getToken; + // 0 = no cap. Any positive value is a hard ceiling on live sessions — + // an initialize past the ceiling is refused with 429 so the client can + // tell the user something concrete. + this.getSessionLimit = typeof getSessionLimit === "function" ? getSessionLimit : () => 0; this.tools = tools; this.serverInfo = serverInfo || { name: "theseus", version: "0.0.0" }; this.instructions = instructions || ""; @@ -179,6 +183,15 @@ class McpServer { let session = sid ? this.sessions.get(sid) : null; const headers = {}; if (init) { + // Enforce the user's simultaneous-session cap. Only live sessions + // count: a GC pass over expired ones runs first so an abandoned + // tab doesn't permanently hold a slot. + this._expire(); + const limit = Math.max(0, Number(this.getSessionLimit()) || 0); + if (limit > 0 && this.sessions.size >= limit) { + this.log(`refused connect: at session cap (${limit})`); + return this._json(res, 429, { jsonrpc: "2.0", id: init.id ?? null, error: { code: -32020, message: `Theseus is at its simultaneous-session cap (${limit}). Disconnect a client, or raise the limit in the Claude connector panel.` } }, { "retry-after": "30" }); + } 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() }; diff --git a/bundled-addons/claude-connector/panel.html b/bundled-addons/claude-connector/panel.html index c2908106..a92d082a 100644 --- a/bundled-addons/claude-connector/panel.html +++ b/bundled-addons/claude-connector/panel.html @@ -76,6 +76,14 @@
Accept connections
Off
+
Simultaneous sessions
A new client's initialize past this limit is refused with 429. Live sessions only — abandoned ones time out.
+
+ + + + +
+
@@ -156,6 +164,14 @@ : '
  • None. Uploads are refused.
  • '; $("evaluate").checked = !!st.allowEvaluate; $("evalWarn").hidden = !st.allowEvaluate; + // Session-cap radios: 1-only / Multi / No-limit. Multi reveals a number + // input for the exact cap. 0 (unlimited) shows "No limit" selected. + const lim = Number(st.sessionLimit) || 0; + const smode = lim === 1 ? "1" : lim === 0 ? "0" : "multi"; + for (const r of document.querySelectorAll('input[name="smode"]')) r.checked = (r.value === smode); + const num = $("sNum"); + num.hidden = (smode !== "multi"); + num.value = smode === "multi" ? String(lim) : (smode === "1" ? "" : ""); } function entryHtml(e) { @@ -169,6 +185,25 @@ } $("accept").onchange = async (e) => render(await S.invoke("set-accept", { on: e.target.checked })); + // Session cap: the three radios + the number input share one setter. + for (const r of document.querySelectorAll('input[name="smode"]')) { + r.addEventListener("change", async () => { + const v = r.value; + $("sNum").hidden = (v !== "multi"); + if (v === "0") render(await S.invoke("set-session-limit", { limit: 0 })); + else if (v === "1") render(await S.invoke("set-session-limit", { limit: 1 })); + else { // multi + const n = Math.max(2, Math.min(100, Number($("sNum").value) || 5)); + $("sNum").value = String(n); + render(await S.invoke("set-session-limit", { limit: n })); + } + }); + } + $("sNum").addEventListener("change", async () => { + const n = Math.max(2, Math.min(100, Number($("sNum").value) || 5)); + $("sNum").value = String(n); + render(await S.invoke("set-session-limit", { limit: n })); + }); $("evaluate").onchange = async (e) => render(await S.invoke("set-evaluate", { on: e.target.checked })); $("disconnect").onclick = async () => render(await S.invoke("disconnect")); // Two clicks: every client has to be set up again afterwards. diff --git a/package.json b/package.json index 45f2b706..c99be1ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "theseus-navigator", - "version": "0.3.124", + "version": "0.3.125", "license": "MPL-2.0", "description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.", "author": "Silent Mode",