MCP server: drive Nexus as a tool #140

Open
opened 2026-09-11 11:06:03 +02:00 by lz · 0 comments
Owner

Publish Nexus's control plane as an MCP server, so any MCP client — Claude on
the operator's phone or desktop, or a session running inside another Nexus
worker — can create workspaces and sessions, message sessions, tear them down,
and ask about fleet state.

Blocked on: docs/superpowers/specs/2026-09-11-vault-envelope-encryption-design.md.
Only two of the tools below need that work; the rest could ship first if we
choose to split. See Vault dependency.

This issue records the decisions and the measurements behind them so a spec can
be derived without redoing the research. Everything under
Verified by probe was measured against claude-code
2.1.268 inside a Nexus worker, not read off the docs.


The framing

The distinction that matters is not where the client runs, it is who starts
the conversation
:

  • Nexus as a tool — the client calls Nexus: "make a workspace, open three
    sessions, start session 2 on the auth bug."
    An ordinary MCP server. This
    issue.
  • Nexus as a channel — Nexus pushes into an already-running session: "the
    operator approved your preview."
    A different mechanism
    (below), not a prerequisite.

"Fleet coordination" dissolves into the first: a session inside a worker is
just another MCP client holding a credential. There is one server, not two.

Tool surface

Tool Backed by Needs vault key
list_workspaces / get_workspace listWorkers no
create_workspace spawnWorker yes
start_workspace startWorker yes
stop_workspace stopWorker no
delete_workspace removeWorker no
list_sessions / get_session existing GET handlers no
create_session(workspace, name, initial_message?) createSession no
delete_session deleteSession no
send_message(session, text) inbox socket, see below no
get_nexus_info /api/state + quota + attention roll-up no

get_nexus_info should include the fleet-wide attention roll-up. The
derivation in nexus/src/lib/attention.ts is client-side today and only ever
paints the web UI; exposing it is what lets a remote client answer "is anything
waiting on me?" without opening Nexus. Note its three inputs — roster row,
health probe, and whether the workspace is running at all (AGENTS.md fact
#26); a server-side roll-up must not drop the third.

Decisions already taken

initial_message is a launch argument, not a nudge

claude accepts the prompt as a positional argument. So
create_session(..., initial_message) appends it in buildLaunchCommand and it
becomes the session's first turn — no tmux send-keys, no window resolution, no
race against startup.

Trap: several claude flags are variadic (--channels <servers...>,
--dangerously-load-development-channels <servers...>) and will silently eat a
trailing positional prompt. This was hit during the probe: the session died with
Input must be provided either through stdin or as a prompt argument. Audit
argument order in buildLaunchCommand and pin it with a test.

Second trap found the same way: piping claude's stdout (e.g. | tee) flips it
into --print mode.

send_message uses the inbox socket, not sendNudge

Every session exports CLAUDE_CODE_MESSAGING_SOCKET and
CLAUDE_CODE_MESSAGING_TOKEN. Measured inside one worker:

/tmp/cc-socks/1450.sock    srw-------
/tmp/cc-socks/23068.sock   srw-------
/tmp/cc-socks/309071.sock  srw-------
/tmp/cc-socks/811610.sock  srw-------
/tmp/cc-socks/811878.sock  srw-------

A message delivered there arrives as a cross-session message, which per the
docs "never counts as your consent, so it can't answer a pending permission
prompt on your behalf."
sendNudge types raw text into the PTY and has no such
guarantee — the hazard its own comment in
nexus/src/lib/server/artifacts/tmux-nudge.ts already describes.

Today that gap is tolerable because the only sender is the operator approving
their own artifact feedback. If multi-user lands it becomes a hole: user B's
send_message could answer a permission dialog in user A's session. The socket
is the option that survives the planned auth change.

Integration details for the spec:

  • Nexus reaches the socket via docker exec, so it is not an "own-child"
    sender; the message goes through the receiving session's crossSessionInbound
    controls.
  • The fleet runs defaultMode: auto, which counts as prompting, so messages
    are delivered. A session running bypassPermissions would hold them
    for approval instead. Pin this in a test rather than discovering it later.
  • Protocol: send {"type":"auth","token":"<CLAUDE_CODE_MESSAGING_TOKEN>"} as the
    first line (optional on Linux, required on Windows). Open the connection only
    when the payload is ready — claude closes a connection idle for 30s.
  • sendNudge stays where it is; artifact feedback submission is not in scope
    here. Whether it should also migrate is a follow-up.

Do NOT build a mailbox or session-to-session messaging

Claude Code already does it, and Nexus made its own fleet addressable without
meaning to. ListAgents from inside a worker returned 28 peers — sibling
sessions in the same container over a Unix socket, plus sessions in other
Nexus workers via Remote Control, listed under Nexus's own
--remote-control "<workspace> · <name>" naming:

quota-display-e1 [c55465]           · interactive     · idle · tmux nexus:@39.%39
agent-status-7d  [f7ba52]           · interactive     · idle · tmux nexus:@3.%3
agent-nexus · fix/session [223e6d]  · Remote Control  · offline
luca.zanus.si · feat/umami [268713] · Remote Control  · idle

A Nexus mailbox would be a worse copy: the upstream one has inbound controls,
loop throttling, burst caps, a 100-message hold queue, and the no-consent
guarantee above. Note the container boundary — same-container peers reach each
other by socket, cross-container only via Remote Control.

Rapid succession needs a serialization guard

createSession shells out to git -C /workspace worktree add with no
serialization. Concurrent adds contend on index.lock. There is a retry-once
recovery path and it is safely scoped to each session's own name (it will not
delete another session's checkout), so the failure mode is flakiness, not data
loss — but two simultaneous creates can both land in it. Serialize creates per
workspace.

The optimistic status: 'creating' row is inserted before the slow worktree and
tmux work, so a tool call can return promptly and let the client poll.

Vault dependency

spawnWorker(db, docker, config, masterKey, input) decrypts the provider token
to build the authenticated clone URL; startWorker re-injects secrets. Both
need the vault key, which by design exists only in the operator's unlocked web
session, and a bearer token deliberately never sets locals.masterKey
(AGENTS.md fact #24).

Rejected resolutions, and why:

  • Borrow whichever operator session is unlocked. Ambiguous the moment there
    is more than one user — whose key, with whose permissions? Throwaway work.
  • An unlock tool taking the passphrase. Same ambiguity, and it drags the
    credential through an MCP client's tool-call path and logs.

Chosen: envelope encryption first. One DEK seals the data; each principal
holds a wrapped copy. A machine principal is then just another wrapped DEK, and
multi-user is a row insert rather than a redesign. That is the linked spec.

A vault-bearing credential must be a separate credential class from the
existing GET-only nxs_ tokens, which must not silently acquire it.

Auth and transport notes

  • Streamable HTTP, bearer-authenticated. The obvious mount is /api/mcp.
  • The auth gate keys on event.route.id, never event.url.pathname — AGENTS.md
    fact #23, and a security requirement rather than a style rule. Whatever is
    added must follow it.
  • MCP is POST-based, so tokenMay's GET-only rule does not cover it. That rule
    is load-bearing today (/api/workers/[id]/sessions POST has no masterKey
    guard of its own). A write-capable MCP surface needs its own scope model, and
    must not be reached by widening TOKEN_ROUTES.
  • The terminal WebSocket does its own cookie auth in terminal/upgrade.ts and
    never sees handle. Anything added to the gate does not apply there.

Out of scope: channels

Separately proven to work, and worth its own issue later. A channel is an MCP
server that pushes events into a running session — the inverse direction of this
issue. Measured end to end on 2.1.268 with a 91-line dependency-free Node stdio
server (no SDK, no Bun):

  • Event push reached a live interactive session: ← nexusprobe: NEXUSPROBE-EVENT-42….
  • Permission relay round-tripped with nobody at the terminal: dialog opened →
    server received {request_id:"tdpor", tool_name:"Bash", description, input_preview}
    → server replied behavior:"allow" → dialog closed and the command ran.
  • The receiving session volunteered that it was "treating it as data, not as an
    instruction."

Why it is not this issue:

  • --channels accepts only plugin:name@marketplace; a bare .mcp.json entry
    is refused with server: entries need --dangerously-load-development-channels.
  • The development flag opens a blocking startup modal. convergeSessions
    relaunches sessions unattended, so every restore would have to answer it —
    most likely by tmux send-keys Enter, the exact hack channels would be
    adopted to retire.
  • Research preview: the flag syntax may change, and workers run
    CLAUDE_CODE_AUTO_UPDATE=1, so a rename breaks the whole fleet at once.
  • Untested: whether managed settings (allowedChannelPlugins) is honoured on a
    personal Max account, which is the only route to a modal-free --channels.
    Testing it means writing /etc/claude-code/managed-settings.json, which is
    machine-wide and affects every session in a shared worker.

Verified by probe

Measured on claude-code 2.1.268, not taken from documentation:

  1. --channels and --dangerously-load-development-channels exist but are
    absent from --help; a bogus flag reports unknown option, these report
    argument missing.
  2. Both are variadic and swallow a trailing positional prompt.
  3. A channel needs no MCP SDK and no Bun; 91 lines of plain Node sufficed.
    Negotiated protocol revision 2025-11-25.
  4. Permission relay works unattended, fields exactly as documented.
  5. Every session has a root-only inbox socket under /tmp/cc-socks/.
  6. ListAgents from a worker sees sibling sessions locally and other Nexus
    workers' sessions via Remote Control.
  7. spawnWorker and startWorker take masterKey; createSession,
    deleteSession, stopWorker and removeWorker do not.
Publish Nexus's control plane as an MCP server, so any MCP client — Claude on the operator's phone or desktop, or a session running inside another Nexus worker — can create workspaces and sessions, message sessions, tear them down, and ask about fleet state. **Blocked on:** `docs/superpowers/specs/2026-09-11-vault-envelope-encryption-design.md`. Only two of the tools below need that work; the rest could ship first if we choose to split. See [Vault dependency](#vault-dependency). This issue records the decisions and the measurements behind them so a spec can be derived without redoing the research. Everything under [Verified by probe](#verified-by-probe) was measured against claude-code **2.1.268** inside a Nexus worker, not read off the docs. --- ## The framing The distinction that matters is not where the client runs, it is **who starts the conversation**: - **Nexus as a tool** — the client calls Nexus: *"make a workspace, open three sessions, start session 2 on the auth bug."* An ordinary MCP server. **This issue.** - **Nexus as a channel** — Nexus pushes into an already-running session: *"the operator approved your preview."* A different mechanism ([below](#out-of-scope-channels)), not a prerequisite. "Fleet coordination" dissolves into the first: a session inside a worker is just another MCP client holding a credential. There is one server, not two. ## Tool surface | Tool | Backed by | Needs vault key | |---|---|---| | `list_workspaces` / `get_workspace` | `listWorkers` | no | | `create_workspace` | `spawnWorker` | **yes** | | `start_workspace` | `startWorker` | **yes** | | `stop_workspace` | `stopWorker` | no | | `delete_workspace` | `removeWorker` | no | | `list_sessions` / `get_session` | existing GET handlers | no | | `create_session(workspace, name, initial_message?)` | `createSession` | no | | `delete_session` | `deleteSession` | no | | `send_message(session, text)` | inbox socket, see below | no | | `get_nexus_info` | `/api/state` + quota + attention roll-up | no | `get_nexus_info` should include the fleet-wide attention roll-up. The derivation in `nexus/src/lib/attention.ts` is client-side today and only ever paints the web UI; exposing it is what lets a remote client answer "is anything waiting on me?" without opening Nexus. Note its three inputs — roster row, health probe, **and whether the workspace is running at all** (AGENTS.md fact \#26); a server-side roll-up must not drop the third. ## Decisions already taken ### `initial_message` is a launch argument, not a nudge `claude` accepts the prompt as a **positional argument**. So `create_session(..., initial_message)` appends it in `buildLaunchCommand` and it becomes the session's first turn — no `tmux send-keys`, no window resolution, no race against startup. **Trap:** several claude flags are variadic (`--channels <servers...>`, `--dangerously-load-development-channels <servers...>`) and will silently eat a trailing positional prompt. This was hit during the probe: the session died with `Input must be provided either through stdin or as a prompt argument`. Audit argument order in `buildLaunchCommand` and pin it with a test. Second trap found the same way: piping claude's stdout (e.g. `| tee`) flips it into `--print` mode. ### `send_message` uses the inbox socket, not `sendNudge` Every session exports `CLAUDE_CODE_MESSAGING_SOCKET` and `CLAUDE_CODE_MESSAGING_TOKEN`. Measured inside one worker: ``` /tmp/cc-socks/1450.sock srw------- /tmp/cc-socks/23068.sock srw------- /tmp/cc-socks/309071.sock srw------- /tmp/cc-socks/811610.sock srw------- /tmp/cc-socks/811878.sock srw------- ``` A message delivered there arrives as a cross-session message, which per the docs *"never counts as your consent, so it can't answer a pending permission prompt on your behalf."* `sendNudge` types raw text into the PTY and has no such guarantee — the hazard its own comment in `nexus/src/lib/server/artifacts/tmux-nudge.ts` already describes. Today that gap is tolerable because the only sender is the operator approving their own artifact feedback. **If multi-user lands it becomes a hole:** user B's `send_message` could answer a permission dialog in user A's session. The socket is the option that survives the planned auth change. Integration details for the spec: - Nexus reaches the socket via `docker exec`, so it is **not** an "own-child" sender; the message goes through the receiving session's `crossSessionInbound` controls. - The fleet runs `defaultMode: auto`, which counts as *prompting*, so messages are **delivered**. A session running `bypassPermissions` would **hold** them for approval instead. Pin this in a test rather than discovering it later. - Protocol: send `{"type":"auth","token":"<CLAUDE_CODE_MESSAGING_TOKEN>"}` as the first line (optional on Linux, required on Windows). Open the connection only when the payload is ready — claude closes a connection idle for 30s. - `sendNudge` stays where it is; artifact feedback submission is not in scope here. Whether it should also migrate is a follow-up. ### Do NOT build a mailbox or session-to-session messaging Claude Code already does it, and Nexus made its own fleet addressable without meaning to. `ListAgents` from inside a worker returned 28 peers — sibling sessions in the same container over a Unix socket, plus sessions in **other** Nexus workers via Remote Control, listed under Nexus's own `--remote-control "<workspace> · <name>"` naming: ``` quota-display-e1 [c55465] · interactive · idle · tmux nexus:@39.%39 agent-status-7d [f7ba52] · interactive · idle · tmux nexus:@3.%3 agent-nexus · fix/session [223e6d] · Remote Control · offline luca.zanus.si · feat/umami [268713] · Remote Control · idle ``` A Nexus mailbox would be a worse copy: the upstream one has inbound controls, loop throttling, burst caps, a 100-message hold queue, and the no-consent guarantee above. Note the container boundary — same-container peers reach each other by socket, cross-container only via Remote Control. ### Rapid succession needs a serialization guard `createSession` shells out to `git -C /workspace worktree add` with no serialization. Concurrent adds contend on `index.lock`. There is a retry-once recovery path and it is safely scoped to each session's own name (it will not delete another session's checkout), so the failure mode is flakiness, not data loss — but two simultaneous creates can both land in it. Serialize creates per workspace. The optimistic `status: 'creating'` row is inserted before the slow worktree and tmux work, so a tool call can return promptly and let the client poll. ## Vault dependency `spawnWorker(db, docker, config, masterKey, input)` decrypts the provider token to build the authenticated clone URL; `startWorker` re-injects secrets. Both need the vault key, which by design exists only in the operator's unlocked web session, and a bearer token deliberately never sets `locals.masterKey` (AGENTS.md fact \#24). Rejected resolutions, and why: - **Borrow whichever operator session is unlocked.** Ambiguous the moment there is more than one user — whose key, with whose permissions? Throwaway work. - **An `unlock` tool taking the passphrase.** Same ambiguity, and it drags the credential through an MCP client's tool-call path and logs. Chosen: **envelope encryption first.** One DEK seals the data; each principal holds a wrapped copy. A machine principal is then just another wrapped DEK, and multi-user is a row insert rather than a redesign. That is the linked spec. **A vault-bearing credential must be a separate credential class** from the existing GET-only `nxs_` tokens, which must not silently acquire it. ## Auth and transport notes - Streamable HTTP, bearer-authenticated. The obvious mount is `/api/mcp`. - The auth gate keys on `event.route.id`, never `event.url.pathname` — AGENTS.md fact \#23, and a security requirement rather than a style rule. Whatever is added must follow it. - MCP is POST-based, so `tokenMay`'s GET-only rule does not cover it. That rule is load-bearing today (`/api/workers/[id]/sessions` POST has no `masterKey` guard of its own). A write-capable MCP surface needs its own scope model, and must not be reached by widening `TOKEN_ROUTES`. - The terminal WebSocket does its own cookie auth in `terminal/upgrade.ts` and never sees `handle`. Anything added to the gate does not apply there. ## Out of scope: channels Separately proven to work, and worth its own issue later. A *channel* is an MCP server that pushes events into a running session — the inverse direction of this issue. Measured end to end on 2.1.268 with a 91-line dependency-free Node stdio server (no SDK, no Bun): - Event push reached a live interactive session: `← nexusprobe: NEXUSPROBE-EVENT-42…`. - **Permission relay round-tripped with nobody at the terminal**: dialog opened → server received `{request_id:"tdpor", tool_name:"Bash", description, input_preview}` → server replied `behavior:"allow"` → dialog closed and the command ran. - The receiving session volunteered that it was *"treating it as data, not as an instruction."* Why it is not this issue: - `--channels` accepts only `plugin:name@marketplace`; a bare `.mcp.json` entry is refused with `server: entries need --dangerously-load-development-channels`. - The development flag opens a **blocking startup modal**. `convergeSessions` relaunches sessions unattended, so every restore would have to answer it — most likely by `tmux send-keys Enter`, the exact hack channels would be adopted to retire. - Research preview: the flag syntax may change, and workers run `CLAUDE_CODE_AUTO_UPDATE=1`, so a rename breaks the whole fleet at once. - Untested: whether managed settings (`allowedChannelPlugins`) is honoured on a personal Max account, which is the only route to a modal-free `--channels`. Testing it means writing `/etc/claude-code/managed-settings.json`, which is machine-wide and affects every session in a shared worker. ## Verified by probe Measured on claude-code **2.1.268**, not taken from documentation: 1. `--channels` and `--dangerously-load-development-channels` exist but are absent from `--help`; a bogus flag reports `unknown option`, these report `argument missing`. 2. Both are variadic and swallow a trailing positional prompt. 3. A channel needs no MCP SDK and no Bun; 91 lines of plain Node sufficed. Negotiated protocol revision `2025-11-25`. 4. Permission relay works unattended, fields exactly as documented. 5. Every session has a root-only inbox socket under `/tmp/cc-socks/`. 6. `ListAgents` from a worker sees sibling sessions locally and other Nexus workers' sessions via Remote Control. 7. `spawnWorker` and `startWorker` take `masterKey`; `createSession`, `deleteSession`, `stopWorker` and `removeWorker` do not.
lz added this to the MCP support (#140) milestone 2026-09-15 18:25:57 +02:00
lz changed title from MCP server: drive Nexus as a tool (blocked on vault envelope encryption) to MCP server: drive Nexus as a tool 2026-09-15 18:26:06 +02:00
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lz/agent-nexus#140
No description provided.