Skip to content

feat(mcp): embedded MCP endpoint for managing the running server - #419

Merged
MagicalTux merged 8 commits into
KarpelesLab:masterfrom
lexfrei:feat/embedded-mcp-server
Sep 20, 2026
Merged

MagicalTux merged 8 commits into
KarpelesLab:masterfrom
lexfrei:feat/embedded-mcp-server

Conversation

@lexfrei

@lexfrei lexfrei commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Opt-in MCP endpoint at POST /teamclaude/mcp, served by the proxy itself. Claude Code (or any other MCP client) can read the fleet state and change rotation settings from inside a session.

Controlled by proxy.mcp, off by default. "read" gives get_status, get_quota and get_settings. "full" adds the write tools: switch, enable/disable, priority, remove, threshold, distribute, probe, keep-warm, routes, blocked models, default client mode. That is what the CLI management commands can do. There is no login, import or api tool, so no credential goes through a tool call.

Design notes:

  • No new dependency, the protocol layer is plain Node. It answers the stateless 2026-07-28 revision and the older initialize handshake. Claude Code already uses the new one over HTTP.
  • Same gates as the other /teamclaude/ routes. With no key in the config at all the rebinding Host check is skipped for every caller, so this route checks Host itself.
  • Settings are written like the CLI does it: file, then reload. Account changes go like the TUI does it, in process and then save, because a reload never drops an account. For that the account half of the TUI save is split out, so it works without the TUI and writes nothing but accounts.
  • The setting rules moved from the CLI commands into config-ops.js. CLI output is the same except for one doubly-invalid threshold input, details in the commit.
  • get_status is a summary. The raw status payload names every session and client, and is too big for a model context.

One thing I'm not sure about: "full" lets every proxy key holder remove accounts. It is documented and it is why the default is off. If you prefer writes gated differently (loopback only, or the shared key only), I can change it.

Tested with unit tests, an e2e test against a headless server, and by hand with Claude Code 2.1.276 calling a read and a write tool. The manual run found a bug my tests missed: the 2026-07-28 revision requires caching hints on tools/list, and Claude Code drops the whole tool list without them.

The bounds on a probe or keep-warm interval, the threshold table, the
distribution modes and the route checks lived inside the CLI commands,
next to argv parsing and process.exit. Nothing else could apply the
same rules without copying them, and index.js cannot be imported: it
runs the command dispatch as it loads.

config-ops.js holds them as changes to a config object that throw a
ConfigOpError carrying the message to show. The commands keep their
own argv handling and output. One difference: a bucket assignment that
is wrong twice over (an unknown bucket and a value that is not a
percentage) now gets the usage text instead of the bucket message.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
The protocol layer for a management endpoint, kept apart from the
tools it will serve so it can be tested as a function from a request
to a reply, and written against Node alone: the package has no
runtime dependencies and the SDK would be the first.

Clients are split between two shapes of the protocol. The 2026-07-28
revision is stateless: every request carries its version and client
identity in the body, mirrors them into HTTP headers, and the server
must refuse a request whose headers and body disagree, since a gateway
may have routed on the headers. Claude Code speaks it to an HTTP
server, opening with server/discover. The revisions before it open
with an initialize handshake instead. One endpoint answers both: a
request carrying the body metadata is held to the stateless rules,
anything else is served as the handshake era. No session id is
issued, which the handshake revisions allow, so nothing is kept
between requests.

A list or discover result on the stateless revision carries the
caching hints that revision makes mandatory. They are not decoration:
Claude Code validates the result and drops the whole tool list when
they are missing, while still reporting the server as connected.

Two more things the stateless revision asks for. Every result names
the server in its _meta, since there is no session to have learned it
from. And a request whose _meta lacks the required client capabilities
is malformed: -32602, with a 400.

Every reply is a single JSON object. There is no GET stream and no
server-initiated traffic, because a tool call here is one short
operation with nothing to report until it is done.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
…ault

POST /teamclaude/mcp answers MCP clients such as Claude Code with the
proxy's own control plane as tools: the fleet at a glance, quota, and
the settings that steer rotation. It sits behind the same gates as the
other /teamclaude/ routes — proxy key or loopback, no cross-origin
POST, a Host header naming this machine — with one addition. A config
with no key at all admits every caller as authenticated, which is the
condition under which the rebinding check on key-less loopback
requests is skipped, so this route asks it again.

The endpoint is off until proxy.mcp says "read" or "full", read per
request so a reload can flip it, and any other value keeps it off. Off
means a 404 answered here: an unclaimed path falls through to the
upstream forwarder with a fleet credential attached, which is the
wrong place for a client probing the endpoint to land. For the same
reason the route also matches a trailing slash or a query string: the
URL is typed into a client by hand.

The read tools are built from the status payload rather than handing
it over: that payload names every session, client and usage dimension,
carries raw error text from upstream, and is far larger than a model
context should pay for. Settings are listed by name, because the same
object holds the proxy keys and every account credential.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
With proxy.mcp set to "full", the endpoint also changes things: it
switches, enables, disables, reprioritises and removes accounts, and
sets the threshold, distribution, probe interval, keep-warm, routes,
blocked models and default client mode — everything the CLI's
management commands can, through the same rules in config-ops.

Settings go the way the CLI's do: written to the file under the config
lock, then applied by a reload. Account changes go the way the TUI's
do, in process and then saved: a reload never drops an account, and it
applies a disk-side priority or disabled flag to the manager without
mirroring it onto the entry the next save is built from. The account
half of the TUI's save is split out and handed to the endpoint, since
a headless server otherwise has nothing to save with. The settings
half stays with the TUI, so an account change does not pin this
server's resolved defaults into a file that never spelled them out.

A name that fits several accounts is refused with the candidates
rather than resolved to the first match; for a removal, the first
match is the wrong one often enough. Write tools run one at a time:
a client may issue several calls at once, and a reload started by one
reads the disk before a removal made by another has been saved, then
re-adds the removed account from that stale read. Each turn of that
queue is bounded by a timeout: a reload can hang on an upstream that
accepts the connection and never answers, and one caller waiting on
that is one thing, every later write waiting behind it until a restart
is another. Every write logs one line naming the tool, the caller and
the arguments.

Arguments that do not fit a tool's schema come back as a tool error,
not a protocol one. The protocol keeps -32602 for a call that is
malformed as a call and files a refused value under input validation,
which is the kind of error a client passes on to the model so it can
correct itself. Nothing runs and nothing is logged for such a call.

proxy.mcp is copied on reload like the other proxy.* fields read per
request, so the endpoint can be opened, narrowed or closed without a
restart.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
Where it is documented follows where the reader looks: the usage page
next to the browser dashboard for what it does and how to connect
Claude Code to it, the configuration table for the setting, the
example config, the help text, and a line in the README's feature
list and documentation table.

Two consequences of the shared gates are spelled out rather than left
to be discovered: every proxy key holder can use the endpoint, so
"full" on a shared proxy means every client can remove accounts; and
a browser-based MCP client is refused as cross-origin.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
MagicalTux and others added 3 commits September 20, 2026 08:35
…d no open endpoint without a key

A request authenticated with a named proxy.clientKeys key is now served the
"read" tools even when proxy.mcp is "full": everywhere else a client key can
only switch, reload and probe, and the write tools reach as far as deleting an
account's credentials, so they stay with the shared proxy.apiKey and key-exempt
loopback callers. Route names, match globs, route accounts, buckets and
blocked-model patterns are refused when they carry a C0/C1 control character,
because the TUI draws them raw and an escape sequence would repaint the
operator's terminal. With no proxy key configured the key gate is open and a
non-browser caller on a non-loopback bind can write Host: localhost itself, so
the endpoint now also requires what the loopback exemption requires (loopback
peer, no forwarding header, trustLoopback not off) and answers 403 otherwise.
A reload now reads an absent proxy section or proxy.mcp key as off, instead of
keeping the last mode it saw in memory.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@MagicalTux
MagicalTux merged commit b079857 into KarpelesLab:master Sep 20, 2026
5 checks passed
MagicalTux added a commit that referenced this pull request Sep 20, 2026
Thirty-six commits since 1.1.20. Several change what a client sees on a
failure, so read the first section before upgrading a shared deployment.

Behaviour changes
  #439 a 401 from upstream is no longer relayed to the client: the request
       fails over like a 403, and an API-key account or an OAuth account with
       no refresh token leaves rotation (it used to be picked again on every
       request). With nothing left the client gets the proxy's own error
  #439 a path under /teamclaude/ that no control route claims — a typo, or the
       wrong verb — answers 404 locally instead of being forwarded upstream
       under a fleet credential
  #429 the synthetic 429's retry-after is the real reset of the windows
       blocking the request's candidate accounts, not a flat 60s, and the
       message counts only those candidates; #408 names accounts that need a
       re-login instead of calling them "at quota"
  #438 session pins are per conversation (session id plus a digest of the
       first message), so a session's subagents spread across accounts.
       `sessions.items[].id` in status is the composite key, load is counted
       per conversation, and a persisted concurrency cap re-learns
  #378 a `thread: continue` bound for a per-account third-party upstream is
       refused with the 400 Anthropic gives, so the client resends the whole
       conversation; `messageThreads: true` opts a relay out
  #434 a 429 whose x-codex-* headers show a spent account-wide window holds
       the account like an Anthropic rejection; a spent model-scoped bucket
       only moves the request
  #437 a Codex response head is awaited for five minutes (Anthropic unchanged)
  #411 idle keep-alive connections are held 120s on both listeners
  #389 with session distribution on, requests carrying no session id rotate on
       a cursor of their own instead of all resting on the current account
  #405 `defaultClientMode` ("mitm" | "base-url") sets what `run` and `env` do
       without a flag; `--mitm` / `--no-mitm` decide per launch, and in
       base-URL mode `env` unsets a stale proxy export naming this proxy
  #439 route `--bucket` is validated; an array `switchThreshold` reads as the
       default with one line saying so

Features
  #419 an MCP management endpoint at POST /teamclaude/mcp, off unless
       `proxy.mcp` is "read" or "full"; a named client key is read-only even
       in full mode, and with no proxy key it serves only this machine
  #428 per-account `switchThreshold`, a number or a per-bucket table
  #406 a Claude+Codex pool is drawn as two panes on a wide terminal, each with
       its own current marker; #392 names the provider in a mixed list; #418
       lets the operator arrange the list (`displayOrder`); #376 draws
       loopback-served accounts last; #435 shows the percentage beside a bar's
       countdown; #394 shows the running version in the header
  #430 free Codex rate-limit reset credits in status, the TUI and the dashboard
  #385 `proxy.terminalOnly` tunnels chatgpt.com so ChatGPT Desktop stays out

Fixes
  #404 a TUI paint can no longer block the proxy (stdout non-blocking, frames
       dropped while the terminal is behind); #410 a dead terminal no longer
       takes the proxy with it, and SIGHUP shuts down cleanly
  #433 token usage is booked from Codex Responses streams
  #386 #387 #388 the Codex five-hour window is read from the model-scoped
       family and the usage probe, and extra limits are named from their entries
  #431 a headerless 429 that follows the request is retried once
  #432 #439 startup and collaborator log lines reach the TUI's activity pane
       and log file instead of the covered terminal
  #415 #403 #439 reload mirrors `priority`, `disabled`, `stripRequestFields`
       onto the config entry, and a reload during a removal does not re-add it
  #439 the Host check uses the address actually bound; sx.org calls time out
  #381 the dashboard polls status before asking for a key
  #403 session outcome accounting classifies the decoded path; account names in
       daemon log lines are sanitised

Tooling
  #371 #372 #373 `npm run typecheck` (tsc over the JS sources) in CI, with a
       strict-mode ratchet: per-file strict diagnostics may not grow past the
       pre-merge commit (2006 at introduction, 1735 now)
  #401 docker workflow actions bumped

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants