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 frompackage.jsonat 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,
coderesponse type,authorization_codegrant, three client auth methods,S256andplainchallenge 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.
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.
| Stage | Check | Failure |
|---|---|---|
GET /oauth/authorize | client_id equals the configured id | 400 invalid_client |
response_type is code | 400 unsupported_response_type | |
redirect_uri starts with the single allowed prefix | 400 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/token | An access token is configured at all | 500 server_error |
grant_type is authorization_code | 400 unsupported_grant_type | |
client_id matches and the client secret verifies — Basic header or form field | 401 invalid_client | |
| Code exists, has not expired, and its stored client id and redirect match the request | 400 invalid_grant | |
PKCE: S256 digest or plain comparison against the stored challenge | 400 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.
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. NotSIGTERM— there is no graceful window. - Output cap, default 5 MB: checked on every
dataevent for stdout and stderr independently; exceeding it also sendsSIGKILL. - 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 yieldsexitCode: -1.formatResultreturns 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.
// 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>].
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
$ 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.
| Variable | Effect | Default |
|---|---|---|
IAN_BRAIN_TOKEN | The bearer token. Absence is fatal at boot. | required |
IAN_BRAIN_CHATGPT_ACCESS_TOKEN | Credential for the connector integration path. | unset |
IAN_BRAIN_PORT · IAN_BRAIN_HOST | Listener address. | 5050 · 127.0.0.1 |
IAN_BRAIN_PUBLIC_URL | Base URL advertised in the registry card and OAuth metadata. | a configured default |
IAN_BRAIN_OAUTH_CLIENT_ID · _SECRET | Expected client identity for the authorization-code flow. | per deployment |
IAN_BRAIN_WIKI_DIR | Root of the wiki corpus for both the read tools and the write path. | a path under $HOME |
IAN_BRAIN_QMD_URL | Referenced only in the failure message of files_search; the search itself uses the CLI. | a loopback address |
IAN_BRAIN_GRAPHIFY_PYTHON · IAN_BRAIN_GRAPH_JSON | Interpreter and graph file used by the three graph tools. | paths under $HOME |
IAN_BRAIN_PERSONAL_HINDSIGHT_URL | Memory service base URL. | a loopback address |
IAN_BRAIN_DEFAULT_MEMORY_BANK | Bank used when a call omits one. | personal |
IAN_BRAIN_DOWNSTREAMS_CONFIG | Federation config path; a missing file disables federation. | config/downstreams.json |
IAN_BRAIN_REFRESH_INTERVAL_MS | Downstream catalogue refresh period; 0 disables the timer. | 600000 |
IAN_BRAIN_CREDS_ALIASES_PATH · OP_BIN | Credential alias map and vault binary. | config/creds-aliases.json · op |
CONNECTORCTL_BIN | Device-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.