Theseus 0.3.125: Claude connector gets a simultaneous-session cap

New row in the connector panel: Simultaneous sessions. Three radios —
1 only / Multi / No limit — and a number input next to Multi for the
exact cap (clamped 1-100). Default stays at no limit, so existing users
see no behaviour change until they pick a value.

On the server side, initialize counts live sessions first (a GC pass over
expired ones runs so an abandoned tab doesn't permanently hold a slot)
and refuses any that would push past the cap with 429 and a message the
client surfaces to the user ("Theseus is at its simultaneous-session
cap (<N>). Disconnect a client, or raise the limit..."). Lowering the
cap below the live count is non-retroactive: no one gets kicked, but the
next connect fails until one goes away.

Settings surface widened: publicState() carries sessionLimit, and a new
set-session-limit message writes it (persisted in the add-on's storage
alongside token / port / allowEvaluate).
This commit is contained in:
Local Dev 2026-10-27 22:30:23 +02:00
parent 1916d6adfe
commit f3a8d883e2
4 changed files with 69 additions and 2 deletions

View file

@ -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();

View file

@ -27,8 +27,12 @@ function tokenMatches(given, expected) {
class McpServer {
// tools: { list(): [toolDef], call(name, args, session) => Promise<result> }
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() };

View file

@ -76,6 +76,14 @@
<section>
<div class="row"><div class="grow"><b>Accept connections</b><div class="note" id="where">Off</div></div><label class="sw"><input type="checkbox" id="accept"><span></span></label></div>
<div class="row" style="margin-top:8px;align-items:flex-start"><div class="grow"><b>Simultaneous sessions</b><div class="note">A new client's <code>initialize</code> past this limit is refused with 429. Live sessions only — abandoned ones time out.</div></div>
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
<label><input type="radio" name="smode" value="1"> 1 only</label>
<label><input type="radio" name="smode" value="multi"> Multi</label>
<label><input type="radio" name="smode" value="0"> No limit</label>
<input type="number" id="sNum" min="1" max="100" step="1" style="width:60px" title="Multi: enter the cap (1-100)" hidden>
</div>
</div>
<div id="clients" class="note"></div>
<div class="row" style="margin-top:8px"><button class="b" id="disconnect">Disconnect clients</button><button class="b warn" id="revoke" title="Issue a new token. Every client must be set up again with the new command.">Revoke token</button></div>
</section>
@ -156,6 +164,14 @@
: '<li class="empty">None. Uploads are refused.</li>';
$("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.

View file

@ -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",