surface

Thirty tools, and a hatch to everything else

Twenty-six tools do concrete work. The remaining four are a door: they list, search, describe and invoke the tools of other MCP servers, so the catalogue this gateway exposes can grow without its own surface growing at all.

Registration is unremarkable and that is the point. Eight modules, eight register*(server) functions, one barrel file that calls them in a fixed order. Every tool is a Zod input schema and an async handler that returns MCP content. There is no middleware chain and no capability negotiation — validation happens at the schema, and the work happens in the handler.

The tool surface, grouped by registrar A central MCP endpoint is surrounded by eight registrar clusters joined to it by hairlines. Clockwise: files and wiki with seven tools, wiki_append with one, graph with three, projects with four, memory with three, actions with three, machines with five, and creds with four. Each cluster lists its tool names and the backend it reaches. The projects cluster is marked as a gateway to federated servers rather than a fixed set. POST /mcp 30 tools registered per request files_* / wiki_* 7 files_search · wiki_index · files_read files_neighbors · wiki_read_all files_write · files_wiki_ingest → disk, plus a search CLI subprocess wiki_append 1 the only sanctioned wiki write → disk + git + a JSONL journal graph_* 3 graph_query · graph_path graph_explain → a Python module over a prebuilt graph.json, guarded if absent projects_* 4 list · search schema · call → any number of federated MCP servers memory_* 3 memory_recall · memory_retain memory_reflect → a loopback HTTP service actions_* 3 actions_github_run · actions_shell_run actions_workspace_run → CLIs on the gateway box machines_* 5 host_list · command_exec · log_tail file_copy · access_guide → ssh, scp, and two device inventories, merged creds_token_* 4 get · list store · rotate → a vault CLI; every read needs a written reason
Eight registrars. The clusters differ enormously in cost and in consequence — a wiki index is a directory walk, while the creds_* and actions_* clusters reach systems whose state a call can change. Every one of them is reached through the same transport; see the gateway.

The catalogue

Argument names and constraints below are taken from the Zod schemas. Optional arguments are marked with ?; where a default exists it is stated.

All thirty registered tools
Tool Arguments Reaches
wiki_index readnoneDisk — one line per page
files_search readquery, limit? 1–50 = 10, mode? deep / keyword / vector = vectorSearch CLI subprocess
files_neighbors readslug, max_bytes? 1K–500K = 30K, snippet_chars? 80–2000 = 280Disk — whole-corpus scan
wiki_read_all readmax_bytes? 1K–2M = 1MDisk — every page
files_read readpath, host? = local, max_bytes? 1–5M = 1Mhead -c, locally or over SSH
files_write writepath under $HOME, content, mode? overwrite / appendDisk, gateway box only
files_wiki_ingest writefilename, plus exactly one of content or urlDisk (raw/), optional HTTP fetch
wiki_append writeslug, content, links?, create_new? = falseDisk + git + journal
graph_query readquestion, dfs? = false, budget? 200–20,000 = 2,000Python module, 60 s cap
graph_path readfrom, toPython module, 30 s cap
graph_explain readnodePython module, 30 s cap
memory_recall readquery, limit? 1–50 = 10, bank? = personal, tags?Memory service over HTTP
memory_retain writetext ≥ 3 chars, tags?, bank?Memory service over HTTP
memory_reflect readquery, budget? low / medium / high = low, bank?Memory service over HTTP
machines_host_list readinclude_offline? = falseTwo device inventories, merged by IP then name
machines_access_guide readhost?Same inventories; returns advice, not access
machines_command_exec exechost, command ≤ 20,000 chars, timeout_seconds? 1–600 = 60Five-step routing to a shell
machines_file_copy execsrc_host, src_path, dst_host, dst_pathscp -3, 300 s cap
machines_log_tail readhost, path, lines? 1–10,000 = 50tail -n via the same routing
actions_github_run execargs — an array of strings, never a shell stringGitHub CLI as the gateway's logged-in user
actions_workspace_run execcommand, account? (two profiles), params?, json?, format? = jsonWorkspace CLI, config dir per account
actions_shell_run execcommand ≤ 20,000 chars, timeout_seconds? 1–600 = 60bash -lc on the gateway box
creds_token_list readnoneAlias names and references — never values
creds_token_get execname, reason ≥ 3 charsVault CLI read; returns the raw secret
creds_token_store writename or a full vault reference, value, reasonVault edit, falling back to create
creds_token_rotate writename, new_value, reasonVault edit, logged as a rotation
projects_list metanoneIn-memory downstream registry: status, tool count, last refresh
projects_search metaproject?, query?, limit? 1–200 = 25, refresh?Cached tool catalogues, substring match
projects_schema metaname — the prefixed tool nameThe downstream tool's full input schema
projects_call execname, args?Proxied call to the owning downstream server

Read the risk column, not the name

creds_token_get is tagged exec here rather than read deliberately: it returns a live secret into the model's context. The tool description is explicit about the contract — “The returned value is the raw secret — use it then drop it. Audit logs the alias + reason but never the value.” The reason argument is required, minimum three characters, and is written to the audit ledger alongside the alias.

The discovery ladder, again

The four projects_* tools repeat the pattern the retrieval router established: cheap discovery first, expensive action last. A model that wants a capability the gateway does not implement directly climbs four rungs — and only the last one does anything.

projects_list
Which downstream servers exist, whether each is connected, how many tools it published, when the catalogue was last refreshed, and the last error if any.
projects_search
Substring match over cached name + description across every downstream, optionally scoped to one namespace. Returns names and 200-character description excerpts — not schemas.
projects_schema
The full input JSON schema for one prefixed tool name, fetched from the cache.
projects_call
Strips the prefix, resolves the owning client, reconnects it if the connection dropped, and forwards the call. The downstream's content and isError flag are returned unmodified.

This is the same economics as wiki_index before wiki_read_all. Loading every downstream schema into a tool listing would cost thousands of tokens per session for capabilities most sessions never use. Instead the gateway publishes four tools and lets the model pay for detail only when it needs detail.

Federation: mounting downstream MCP servers behind a prefix At startup a JSON config file is read and disabled entries are filtered out. For each remaining entry a streamable HTTP client connects, optionally with a bearer token resolved from a named environment variable, and its tool list is cached with each name rewritten to carry a namespace prefix. A timer refreshes every catalogue every ten minutes using allSettled so one failure cannot break the others. A call arrives as a prefixed name, is resolved against the registry, triggers a lazy reconnect if the client is not connected, and is forwarded under the tool's original upstream name. startup registry call time config/downstreams.json id · namespace · url · prefix? · description? bearer_env? — the NAME of an env var, not a value enabled? — false disables without deleting missing file ⇒ federation simply off connectAll() — allSettled StreamableHTTPClientTransport per entry Authorization header attached only if resolved refreshTools(runtime) listTools() → rewrite every name: prefixed = (prefix ?? namespace + "__") + upstream description and inputSchema cached verbatim setInterval — every catalogue re-fetched on a timer default 600,000 ms; 0 disables refreshing entirely allSettled again — one dead server breaks nothing Map<id, DownstreamRuntime> config client — or null status: connected | disconnected | error tools: CachedTool[] refreshed_at — epoch ms last_error — cleared on success CachedTool upstream_name · prefixed_name description · input_schema · server_id projects_call({ name, args }) name = "<ns>__<tool>" findTool — linear scan across every runtime not found ⇒ isError, with a hint to discover status !== "connected" ? lazy reconnect — connectDownstream(config) still no client ⇒ return the last error verbatim client.callTool({ name: upstream_name, args }) prefix stripped — downstreams never see it content and isError forwarded unchanged on shutdown SIGTERM / SIGINT → shutdownAll() every client closed, registry cleared then a 5 s hard-exit timer, unref'd
Federation. Credentials are referenced by environment-variable name in the config file, never by value — the type comment at src/federation/client.ts:15-18 spells out that a literal bearer field exists but should be avoided in anything checked in.

Disabled is a first-class state

Entries in the downstream config carry an enabled flag, filtered at load time by d.enabled !== false. The committed configuration uses it: several servers are present but switched off, each with a sibling field recording the date and the reason it was disabled — a port that is not listening, a redundancy after a tool was reimplemented natively, a transport that returns HTML instead of completing a handshake.

That last one is why files_search shells out to a CLI instead of federating to the search server that wraps the same index. The configuration file doubles as a decision log, and the workaround in files.ts is the visible consequence of an entry in it.

A disabled entry with a written reason is worth more than a deleted one. It is the difference between a system that forgot and a system that decided.

on config/downstreams.json

What a federated call looks like on the wire

JSON-RPC over POST /mcp — the discovery ladder
# 1 · which namespaces exist, and are they up?
$ curl -s -X POST "$GATEWAY/mcp" -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
         "params":{"name":"projects_list","arguments":{}}}'
{"projects": [{ "namespace": "<ns>", "prefix": "<ns>__", "status": "connected",
                "tool_count": 12, "last_refreshed": "…" }, … ]}

# 2 · find a capability by keyword, without loading any schemas
$  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
         "params":{"name":"projects_search","arguments":{"query":"email","limit":5}}}'
{"total_matched": 3, "returned": 3, "tools": [{ "name": "<ns>__<tool>", … }]}

# 3 · now pay for one schema
$  -d '{"…":"…","params":{"name":"projects_schema",
         "arguments":{"name":"<ns>__<tool>"}}}'

# 4 · and invoke it — the prefix is stripped before it leaves the gateway
$  -d '{"…":"…","params":{"name":"projects_call",
         "arguments":{"name":"<ns>__<tool>","args":{…}}}}'

Response shapes are the exact objects serialised at src/tools/projects.ts:29-39 and :82-90. Namespaces, tool names and the gateway address are placeholders.

One surface, many consequences

The catalogue spans a wide range: a directory listing at one end, tools that change the state of another system at the other. What distinguishes them at runtime is not the transport but the obligations attached to each call — every one lands in the audit ledger, the tools with lasting effects demand a written reason before they will run, and the instruction block asks the model to inspect before it writes.

Prefer discovery/search tools before broad calls. For risky or write actions, inspect first, keep the user informed, and avoid exposing secret values in normal replies.

src/server.ts:109 — the last line of the client instructions

Downstream identifiers, namespaces, URLs, credential aliases, vault names and account identities from the committed configuration are omitted. <ns>, <tool> and $GATEWAY are placeholders.