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:
parent
1916d6adfe
commit
f3a8d883e2
4 changed files with 69 additions and 2 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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() };
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue