transport & trust

One boundary, and everything behind it

Requests are authenticated at the gateway before any tool is reached. The interesting engineering is on the inside — a request model that keeps no state, a ledger that records every call without recording its arguments, and one subprocess wrapper that every backend goes through.

The HTTP surface is deliberately small: a liveness probe, a machine-readable registry card, an OAuth pair, and one authenticated POST. The MCP endpoint is stateless — a fresh server object per request, no session identifier, JSON responses enabled — so the other verbs on that path exist only to say so.

GET /health
A liveness probe. Returns { ok, name, version, ts }, the version read from package.json at boot with a "0.0.0" fallback if that read fails.
GET /.well-known/registry-card
A service description: schema version, name, version, owner, endpoint, transport, and an auth block whose instructions read “Request a bearer token from the operator.”
GET /.well-known/oauth-authorization-server
Standard metadata: authorization and token endpoints, code response type, authorization_code grant, three client auth methods, S256 and plain challenge methods, one scope.
POST /mcp
The authenticated MCP route. Body limit 10 MB. Constructs an McpServer, registers all thirty tools, attaches a streamable HTTP transport, handles the request, and tears both down when the response closes.
GET /mcp · DELETE /mcp
405 with an explanatory body — “Stateless mode: use POST /mcp”. No SSE stream to resume, no session to delete.
The trust boundary and what sits on either side of it Service-metadata routes — health, the registry card, OAuth metadata, authorize and token — sit outside the boundary. The POST /mcp route crosses it after bearer authentication. Inside, a per-request MCP server exposes thirty tools which reach the filesystem, git, subprocess CLIs, a loopback HTTP service, remote machines over SSH and a credential vault. Every call writes a row to a SQLite audit ledger recording the tool, a truncated hash of its arguments, status and latency, but never argument values. outside the boundary GET /health { ok, name, version, ts } liveness only, no side effects /.well-known/registry-card how to reach and authenticate names an introspection tool /oauth/authorize · /oauth/token completes the authorization-code grant to one known first-party client POST /mcp Authorization: Bearer <token> the authenticated MCP route authMiddleware — bearer verification verified once per request · before the McpServer object is constructed per-request McpServer new McpServer({ name, version }, { instructions }) registerAllTools(server) — all thirty, every time new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }) res.on("close") → transport.close(); mcpServer.close() nothing survives the response except audit rows and the federation registry, which is process-wide uncaught errors → 500 { error: "internal_error", message } the backend classes the tool layer reaches the wiki and files under $HOME CLIs on the gateway box remote machines over SSH file transfer between hosts GitHub + Workspace CLIs the credential vault, by alias every call lands in the ledger; the credential and execution tools also require a written “reason” audit.db — better-sqlite3, WAL mode, created on boot CREATE TABLE calls (id, ts DEFAULT datetime('now'), tool, args_hash, status, latency_ms, error) indices on ts and on tool args_hash = sha256(JSON.stringify(args)).hex.slice(0, 16) — correlatable, not readable
The boundary. Authentication happens once per request, before the per-request MCP server object exists. Nearly every handler then calls logCall on both its success and its failure path — the exceptions are guard clauses that decline a call before it does any work.

Authentication and its error surface

The middleware runs ahead of every other handler on POST /mcp and resolves to one of three outcomes, each with a distinct shape a client can act on. A missing or malformed header sets WWW-Authenticate: Bearer; a credential that does not verify sets WWW-Authenticate: Bearer error="invalid_token". Both return 401 with their own error_description, so a client can tell “you sent nothing” from “you sent the wrong thing”. Server-side misconfiguration returns 500 — the route fails closed rather than falling open.

The OAuth server exists for one client

It implements the authorization-code grant with PKCE so that a connector platform which requires an OAuth handshake can complete one. What the flow identifies is a single known first-party client rather than an end user, which is why the metadata document advertises exactly one scope and why the redirect allowlist has exactly one entry.

The authorization-code flow as implemented — src/oauth.ts
StageCheckFailure
GET /oauth/authorizeclient_id equals the configured id400 invalid_client
response_type is code400 unsupported_response_type
redirect_uri starts with the single allowed prefix400 invalid_request
On success: 32 random bytes, base64url, stored in a process-local map with a 5-minute expiry alongside the challenge, client id, redirect and scope. 302 back with code and, if supplied, state.
POST /oauth/tokenAn access token is configured at all500 server_error
grant_type is authorization_code400 unsupported_grant_type
client_id matches and the client secret verifies — Basic header or form field401 invalid_client
Code exists, has not expired, and its stored client id and redirect match the request400 invalid_grant
PKCE: S256 digest or plain comparison against the stored challenge400 invalid_grant
On success: { access_token, token_type: "Bearer", expires_in: 31536000, scope }. The code is deleted from the map at lookup time, before any check runs, so it is single-use even on a failed exchange.

One detail is worth pulling out of that table. The authorization code lives only in a process-local map with a five-minute expiry — nothing about a half-finished handshake is written to disk, and a restart invalidates every code still outstanding. The store is deliberately the most forgettable part of the flow.

The audit ledger

One table, two indices, WAL mode, opened once at boot before the token check. The logCall helper is a no-op if the database was never initialised, so a failure to open it degrades logging rather than the server.

src/audit.ts:32-44logCall()
export function logCall(
  tool: string,
  args: unknown,
  status: 'success' | 'error',
  latencyMs: number,
  error?: string,
): void {
  if (!db) return;
  const argsHash = createHash('sha256').update(JSON.stringify(args ?? {})).digest('hex').slice(0, 16);
  db.prepare(
    'INSERT INTO calls (tool, args_hash, status, latency_ms, error) VALUES (?, ?, ?, ?, ?)',
  ).run(tool, argsHash, status, latencyMs, error ?? null);
}

Callers choose what to hash, and they choose conservatively: files_search passes the query truncated to 100 characters, creds_token_get passes the alias and the reason but never the value, machines_command_exec passes the host, the first 200 characters of the command and the route it took. The hash makes repeated identical calls correlatable without making any of them readable. The data/ directory it lives in is excluded by .gitignore, with a comment noting it may contain captured tool arguments.

Everything eventually becomes a subprocess

Search, graph traversal, credential reads, remote execution, file copies and both CLIs run through the same eighty-line wrapper. It uses spawn with an argument array — never a shell string — copies the environment with overrides applied on top, and enforces two independent limits.

Limits

  • Timeout, default 60 s: sets a flag and sends SIGKILL. Not SIGTERM — there is no graceful window.
  • Output cap, default 5 MB: checked on every data event for stdout and stderr independently; exceeding it also sends SIGKILL.
  • Both buffers are sliced to the cap before being returned, so a burst that arrives in one chunk cannot exceed it either.

Result shape

  • { stdout, stderr, exitCode, timedOut }; a process killed before reporting yields exitCode: -1.
  • formatResult returns only stdout on a clean exit — no framing, no noise.
  • On any failure it returns Exit <code>, a (TIMEOUT) marker where relevant, then stderr, then stdout.

That asymmetry matters for tool output. On success the model sees exactly what the command printed and nothing else; on failure it sees a small structured report it can reason about.

Why nothing builds a shell string

The longest comment in the repository sits above files_read and explains why the path is handed to spawn as its own argument rather than interpolated into a command line. It is reproduced here because it is a better argument for argument arrays than any abstract one.

src/tools/files.ts:365-376why files_read does not touch a shell
// Pass path as separate argv to spawn (no shell parsing).
// Quoting a path into a command line is not a substitute for this: the
// rules for what a shell re-expands inside quotes are not the rules any
// string-escaping helper implements.
// head executes directly via execvp; path is a literal argv string, no shell sees it.
if (host === 'local') {
  r = await runShell('head', ['-c', String(max), input.path], { timeoutMs: 30_000, maxBytes: max + 1024 });
} else {
  r = await runShell('ssh', ['-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new',
    host, 'head', '-c', String(max), input.path], { timeoutMs: 30_000, maxBytes: max + 1024 });
}

The same discipline runs through the module. Paths, hosts, flags and their values each arrive at spawn as their own array element rather than being concatenated into a line — the local and remote branches above differ only in which binary they front. Applied uniformly, it is the reason the subprocess wrapper below has no escaping logic in it at all: there is never a string for it to escape.

Routing a command to a machine

machines_command_exec takes a host that may be a device id, an alias, a hostname, an IP or user@host, and resolves it against two inventories fetched in parallel. The routing ladder has five rungs and reports which one it used in the response — every result is prefixed [via <route>].

The five-step command routing ladder A host string is split into an optional user and a bare host. The literal values local or self run immediately in a local shell. Otherwise two device inventories are listed in parallel. A device-manager match runs locally if the device is this gateway, otherwise through the device manager's remote shell; if that reports that remote shell automation is unavailable and the same machine appears in the mesh inventory, it falls back to mesh SSH. A mesh match runs locally when the peer is this host, reports offline when it is not reachable, and otherwise resolves a default SSH user and connects by IP. If nothing matches, a plain SSH attempt is made with the supplied host. machines_command_exec({ host, command }) splitUserHost — everything before “@” is the user host is “local” or “self” ? bash -lc, via “local” Promise.all — list device-manager devices and mesh peers both via a CLI with a 5 s timeout; either may return an empty list 1 · matches a managed device identity compared with all punctuation stripped is this gateway ⇒ run locally instead of SSH to self else ⇒ device manager’s remote shell, bash -lc JSON exit_code / stdout / stderr envelope unwrapped 2 · that device has no remote shell four known error strings are matched, case-insensitively the same machine is looked up in the mesh inventory found ⇒ retry over mesh SSH route reported as “device(id)->mesh-ssh(target)” 3 · matches a mesh peer offline ⇒ exit 255 with an explanatory stderr, no attempt is this host ⇒ run locally else ⇒ ssh to the peer’s mesh IP SSH user from the request, else a per-host default table 4–5 · no match anywhere plain ssh with the host exactly as supplied every SSH attempt, same options BatchMode=yes · NumberOfPasswordPrompts=0 KbdInteractiveAuthentication=no · PasswordAuthentication=no PreferredAuthentications=publickey · ConnectTimeout=15
Five rungs, one report. Public-key authentication is mandatory on every SSH path — password and keyboard-interactive prompts are disabled outright, so a host that is not already trusted fails fast instead of hanging on a prompt no agent can answer.

A companion tool, machines_access_guide, exists purely to answer a question the model would otherwise get wrong: which access route fits a task. It returns no access at all — only a resolved view of the target and a set of recommendations, including the literal command a human should paste into their own terminal for an interactive session. Its caveats section is the honest part: “[Mesh] online, SSH ready, and remote desktop ready are separate states.”

Connecting to it

probing the endpoint
$ curl -s "$GATEWAY/health"
{"ok":true,"name":"ian-brain","version":"0.3.0","ts":"2026-07-28T00:00:00.000Z"}

$ curl -s -i -X POST "$GATEWAY/mcp" -d '{}'
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer
{"error":"invalid_token","error_description":"Missing Authorization header"}

$ curl -s -i -X POST "$GATEWAY/mcp" -H "Authorization: Bearer wrong" -d '{}'
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token"
{"error":"invalid_token","error_description":"Token mismatch"}

$ curl -s "$GATEWAY/mcp"
{"error":"method_not_allowed","message":"Stateless mode: use POST /mcp"}

# register it with a client — from the project README
$ claude mcp add --transport http ian-brain "$GATEWAY/mcp" \
    --header "Authorization: Bearer $TOKEN"

Status codes, headers and JSON bodies are exactly those constructed in src/auth.ts:13-25, src/server.ts:44-46 and :135-140. The gateway address and token are shell variables here; the real values do not appear on this site.

Configuration surface

Every operational decision is an environment variable with a default, which is what makes the repository readable without access to the machine it runs on.

Environment variables read at runtime
VariableEffectDefault
IAN_BRAIN_TOKENThe bearer token. Absence is fatal at boot.required
IAN_BRAIN_CHATGPT_ACCESS_TOKENCredential for the connector integration path.unset
IAN_BRAIN_PORT · IAN_BRAIN_HOSTListener address.5050 · 127.0.0.1
IAN_BRAIN_PUBLIC_URLBase URL advertised in the registry card and OAuth metadata.a configured default
IAN_BRAIN_OAUTH_CLIENT_ID · _SECRETExpected client identity for the authorization-code flow.per deployment
IAN_BRAIN_WIKI_DIRRoot of the wiki corpus for both the read tools and the write path.a path under $HOME
IAN_BRAIN_QMD_URLReferenced only in the failure message of files_search; the search itself uses the CLI.a loopback address
IAN_BRAIN_GRAPHIFY_PYTHON · IAN_BRAIN_GRAPH_JSONInterpreter and graph file used by the three graph tools.paths under $HOME
IAN_BRAIN_PERSONAL_HINDSIGHT_URLMemory service base URL.a loopback address
IAN_BRAIN_DEFAULT_MEMORY_BANKBank used when a call omits one.personal
IAN_BRAIN_DOWNSTREAMS_CONFIGFederation config path; a missing file disables federation.config/downstreams.json
IAN_BRAIN_REFRESH_INTERVAL_MSDownstream catalogue refresh period; 0 disables the timer.600000
IAN_BRAIN_CREDS_ALIASES_PATH · OP_BINCredential alias map and vault binary.config/creds-aliases.json · op
CONNECTORCTL_BINDevice-manager CLI used for the machine inventory and remote shell.a binary name on PATH

Secrets are referenced, never stored

The credential module holds a map of friendly aliases to vault references and shells out to the vault CLI to resolve them. creds_token_list returns the alias names and their references but never a value. .gitignore is written defensively around the same principle: patterns for *token*, *secret* and *credential* sit under a header reading “NEVER COMMIT”, and the audit database directory is excluded with a note that it may contain captured tool arguments.

Shutting down

SIGTERM and SIGINT both run the same handler: close every federated client, stop accepting connections, exit zero when the server closes. A five-second timer forces exit if that never happens — and it is unref'd, so a clean shutdown is not delayed by the timer it set to guard itself.

Treat it as the authorized access layer for machines, project systems, credentials, memory, wiki, and workspace tools.

src/server.ts:79 — the first line every client reads

Hostnames, domains, ports beyond documented defaults, credential aliases, vault names, account identities and token values are omitted throughout. $GATEWAY and $TOKEN are placeholders for values held by the operator.