Wire this repo's CORE stdio MCP servers (board, calendar, guard, vault) into Claude Cowork Desktop and Claude Code. (The optional openwhispr voice add-on and the whatsapp add-on each have their OWN setup skill — /openwhispr-mcp-setup, /whatsapp-mcp-setup — so they're out of scope here.) Cowork uses direct stdio `command` entries in claude_desktop_config.json (validated — it does NOT accept HTTP `url` entries); Claude Code uses .mcp.json over the supergateway + launchd HTTP bridge. Use when se...
Install to Claude Code
npx -y skills add philipyaz/cos --skill mcp-bridge-setup --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Mcp Bridge Setup?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/philipyaz-mcp-bridge-setup)More formats (shields.io, HTML) on the badges page.
---
name: mcp-bridge-setup
description: Wire this repo's CORE stdio MCP servers (board, calendar, guard, vault) into Claude Cowork Desktop and Claude Code. (The optional openwhispr voice add-on and the whatsapp add-on each have their OWN setup skill — /openwhispr-mcp-setup, /whatsapp-mcp-setup — so they're out of scope here.) Cowork uses direct stdio `command` entries in claude_desktop_config.json (validated — it does NOT accept HTTP `url` entries); Claude Code uses .mcp.json over the supergateway + launchd HTTP bridge. Use when setting up on a new machine, when Cowork or Code can't see board/calendar/guard/vault, when a bridge is down, or when wiring a new MCP server. Covers the stdio config, supergateway + launchd, the search + guard sidecar LaunchAgents, verification, and the node/simdjson + pm2 gotchas.
---
# MCP bridge setup (stdio → Streamable HTTP for Cowork)
## Why this exists
This skill wires the **four core servers** (board, calendar, guard, vault) — all **stdio** MCP
servers. (The optional **openwhispr** voice add-on and **whatsapp** each live in their own setup
skill — `/openwhispr-mcp-setup`, `/whatsapp-mcp-setup` — because each integrates an *external app*;
the four here are self-contained in this repo.) Two Claude clients consume them, and they register
**differently** — getting this wrong is the usual failure:
- **Claude Cowork Desktop** launches them **directly as stdio `command` servers** in
`claude_desktop_config.json` (**validated 2026-06-01** — Cowork *can* spawn local stdio
servers; the earlier "it can't, so bridge over HTTP" assumption was wrong). It does **not**
accept HTTP `url` entries in that file. → **§5, the primary path for Cowork.**
- **Claude Code** connects over **HTTP** via `REPO/.mcp.json` (`"type":"http"` →
`localhost:8001`–`8005`). Each HTTP endpoint is a **supergateway** bridge, kept alive by
**launchd** — the "bridge" this skill is named for. (Claude Code could use stdio too, but a
long-running bridge gives one supervised, always-on instance independent of which app is open.)
```
Claude Code ──HTTP──> localhost:8001/mcp ──supergateway(launchd)──> node board-server (stdio)
localhost:8003/mcp ──supergateway(launchd)──> node calendar-server (stdio)
localhost:8004/mcp ──supergateway(launchd)──> node guard-server (stdio) ──HTTP──> guard sidecar :8009
localhost:8005/mcp ──supergateway(launchd)──> node vault-server (stdio) ──Agent SDK──> Anthropic API
Cowork Desktop ──spawns stdio directly──────────────────────────────> node board / calendar / guard / vault (§5)
```
This repo's servers (`mcp/`):
| server | stdio command | env | bridge port |
|--------|---------------|-----|------|
| board | `node mcp/board-server/server.mjs` | `CRM_BASE_URL=$BOARD_URL` | 8001 |
| calendar | `node mcp/calendar-server/server.mjs` | `CRM_BASE_URL=$BOARD_URL` | 8003 |
| guard | `node mcp/guard-server/server.mjs` | `COS_GUARD_URL=$GUARD_SIDECAR_URL` | 8004 |
| vault | `node mcp/vault-server/server.mjs` | `ANTHROPIC_API_KEY` (from `config/secrets.env` via `launch.sh`) + `COS_VAULT_DIR=$VAULT_DIR` | 8005 |
The core bridge ports above (8001 board, 8003 calendar, 8004 guard, 8005 vault — `:8002` is reserved
for the optional **openwhispr** add-on, `/openwhispr-mcp-setup`) and the sidecar ports (8008/8009)
are the defaults; all are configurable via `config/cos.env` (the loader resolves them into
`$*_BRIDGE_PORT` / `$*_SIDECAR_PORT`).
> The launchd bridges (§2–4) exist for **Claude Code's HTTP** path; **Cowork uses §5 direct
> stdio** and needs no bridge. Keep the bridges for Claude Code (and as the verification
> surface); use §5 to register Cowork.
> The **search sidecar** (`:8008`) is *not* a bridge here — it's an HTTP daemon the
> board calls, not a stdio MCP server, so it stays out of `.mcp.json` (like Gmail/Calendar).
> But launchd supervises it the same way and `ensure-bridges.sh` nudges it, so its
> LaunchAgent is documented in **["The search sidecar LaunchAgent"](#the-search-sidecar-launchagent-8008)** below.
> Machine config comes from the loader (run the preamble in §1 below): it exports `$REPO_ROOT`,
> `$BREW_PREFIX`, `$NODE_BIN`, `$UV_BIN`, `$SUPERGATEWAY_BIN`, `$LAUNCH_AGENTS_DIR`,
> `$COWORK_CONFIG`, `$VAULT_DIR`, and every bridge /
> sidecar port + URL — use those instead of hardcoding paths, the Homebrew prefix, or your
> username. (The plists are **generated** from the co-located `mcp/<name>-server/<name>.service.json`
> descriptors by `scripts/gen-launchd.mjs`, which resolves every `${VAR}` from the loader — see
> `mcp/CLAUDE.md`; the Anthropic key is never in a descriptor/plist — it stays in
> `config/secrets.env`, loaded by `launch.sh`.)
## Prerequisites
- Node + npm (Homebrew). Each server's deps installed: `(cd mcp/board-server && npm i)`,
`(cd mcp/calendar-server && npm i)`, `(cd mcp/guard-server && npm i)`, and
`(cd mcp/vault-server && npm i)` (vault pulls in the Agent SDK).
- `npm install -g supergateway`
- **`uv`** (Homebrew) for the two Python sidecars — the **search** sidecar (`:8008`) and the
**guard** sidecar (`:8009`); both self-provision their venv on first launch.
## Steps
### 1. Verify the stdio servers run
```sh
source "$(git rev-parse --show-toplevel)/config/load-config.sh"
# The loader exports $REPO_ROOT, $BREW_PREFIX, $NODE_BIN, $UV_BIN, $SUPERGATEWAY_BIN,
# $LAUNCH_AGENTS_DIR, $COWORK_CONFIG, $VAULT_DIR,
# and every bridge/sidecar port + URL — use those instead of hardcoding machine paths.
"$NODE_BIN" "$REPO_ROOT/mcp/board-server/server.mjs" # Ctrl-C; should print "...on stdio"
"$NODE_BIN" "$REPO_ROOT/mcp/calendar-server/server.mjs" # Ctrl-C; should print "...on stdio"
"$NODE_BIN" "$REPO_ROOT/mcp/guard-server/server.mjs" # Ctrl-C; prints "...ready ... fail-closed"; talks to the guard sidecar (:8009) via COS_GUARD_URL
ANTHROPIC_API_KEY=sk-ant-… COS_VAULT_DIR="$VAULT_DIR" "$NODE_BIN" "$REPO_ROOT/mcp/vault-server/server.mjs" # Ctrl-C; embeds the Agent SDK (outbound Anthropic API calls), session scoped to COS_VAULT_DIR
```
### 2. Render + install the LaunchAgent plists from the manifest
Each service is declared once in a co-located descriptor `mcp/<name>-server/<name>.service.json`
(+ `guard/guardsvc.service.json`, `search/search.service.json`), resolved by
`mcp/service-manifest.mjs` against `config/load-config.sh`. `scripts/gen-launchd.mjs` renders the
plist from that descriptor — expanding `$BREW_PREFIX` / `$REPO_ROOT` / `$BOARD_URL` / the port from
the loader (board → `8001` + `CRM_BASE_URL`; calendar → `8003` + `CRM_BASE_URL`; guard → `8004` +
`COS_GUARD_URL`; vault → `8005` + `ANTHROPIC_API_KEY` (via its secret-wrapper `launch.sh`) +
`COS_VAULT_DIR=$VAULT_DIR`) — and on macOS `--install` also does the `launchctl bootout → bootstrap
→ kickstart` reload in the **same step**. With no names it installs the **core** services
(board calendar guard vault search guardsvc):
```sh
source "$(git rev-parse --show-toplevel)/config/load-config.sh"
node "$REPO_ROOT/scripts/gen-launchd.mjs" --install # core: board calendar guard vault search guardsvc
```
> `--print <name>` renders one plist to stdout for review, `--out <dir>` writes copies without
> touching `~/Library/LaunchAgents`; only `--install` touches the real LaunchAgents dir. The
> installed plists stay gitignored + machine-specific (absolute paths) exactly as before — only
> their SOURCE moved from per-server `*.plist.template` files to the descriptors. See `mcp/CLAUDE.md`
> for the descriptor schema.
The generator handles the details the old hand-authored plist spelled out, so they still hold:
- `--outputTransport streamableHttp` + `--streamableHttpPath /mcp` → URL is `http://localhost:PORT/mcp`.
- `--cors` is **required** for the HTTP clients (Claude Code over the bridge, and the Desktop
Connector UI) — they connect from a different origin. (Cowork itself uses §5 stdio, no CORS.)
- `PATH` leads with `$BREW_PREFIX/bin`: launchd's default PATH lacks Homebrew, but
supergateway's `#!/usr/bin/env node` shebang and the `--stdio "node ..."` child both need `node`.
launchd cannot expand `$VARS` and cannot see an nvm/asdf shim, so the plist carries literal
`$BREW_PREFIX/bin/node` / `$BREW_PREFIX/bin/supergateway` paths — the resolver bakes these in.
- `--stdio "$BREW_PREFIX/bin/node <abs path>"` is **one** array element. Absolute paths throughout
(the servers `path.resolve()` them).
- `COS_MCP_IDLE_EXIT_MS=300000` is set on every node **bridge** (the descriptor's `idleExit:true`),
the bridge's **opt-in** to mcp-kit's idle-child reaper, which is **off by default** — see
["Why bridges set COS_MCP_IDLE_EXIT_MS"](#why-bridges-set-cos_mcp_idle_exit_ms). It is never set
on the sidecars or the Cowork direct-stdio entry.
The `-guard` bridge has a **second process** — its own `-guardsvc` **uv sidecar** on `:8009` (see
["The guard sidecar LaunchAgent"](#the-guard-sidecar-launchagent-8009) below), exactly as search
does; both are core, so the `--install` above renders them too.
#### Why bridges set `COS_MCP_IDLE_EXIT_MS`
`packages/mcp-kit/index.mjs` (the shared `start()` every node server uses) keeps the idle-exit
timer **OFF by default**, so a long-lived **direct** stdio client — Claude Cowork Desktop, `node
server.mjs` by hand, any future client — never has its server self-terminate while idle (that was
the *"Server transport closed unexpectedly … process exiting early" → MCP not responding* bug;
direct clients don't respawn). A real client disconnect/quit closes stdin, which already reaps the
child (backstop #1). The **supergateway bridge** is the one path that leaks: in stateless
StreamableHttp mode supergateway spawns a fresh child per request and reaps it only on
child-exit / protocol error, never on a normal POST completion, so idle children pile up until the
bridge dies. Each **bridge plist therefore opts in** with `COS_MCP_IDLE_EXIT_MS=300000` to reap
them (a request in flight disarms the timer; supergateway respawns on the next call). **Do NOT put
this var in the Cowork `claude_desktop_config.json`** — Cowork relies on the safe off-by-default.
Verify every installed node bridge carries the opt-in (the generator sets it from each bridge
descriptor's `idleExit:true`, so this is a quick post-install sanity check that the render landed):
```sh
source "$(git rev-parse --show-toplevel)/config/load-config.sh"
for s in board calendar guard vault openwhispr; do
printf '%s: ' "$s"
plutil -p "$LAUNCH_AGENTS_DIR/com.chiefofstaff.mcp-$s.plist" | grep -o 'COS_MCP_IDLE_EXIT_MS" => "[0-9]*"' || echo "MISSING — add it + reload, or this bridge leaks idle children"
done
```
### 3. Confirm they loaded
The `--install` in §2 already did the `bootout → bootstrap → kickstart` reload on macOS, so the
agents are loaded + running with `RunAtLoad`+`KeepAlive` (login boot + crash-restart). Confirm:
```sh
launchctl list | grep chiefofstaff # PID present + exit 0 = healthy
```
### 4. Verify each endpoint (real MCP handshake)
```sh
source "$(git rev-parse --show-toplevel)/config/load-config.sh"
curl -s -X POST "$BOARD_BRIDGE_URL/mcp" \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"c","version":"0"}}}'
# expect: ...{"serverInfo":{"name":"board",...}} (board answers even if the board port is down)
```
### 5. Register the endpoints (each client differs!)
The two clients register **differently** — getting this wrong is the usual failure.
#### Claude Cowork Desktop — direct stdio (✅ validated 2026-06-01)
`claude_desktop_config.json` accepts **stdio `command` servers only**. An HTTP `url` entry
(any `"type"`, incl. `streamable-http`) is rejected as *"not valid MCP server configurations …
skipped"* and ignored (older builds silently wiped the whole `mcpServers` block —
anthropics/claude-code#37286). So **do not put the bridge URLs here** — point Claude Desktop
straight at the servers; it spawns them itself (no bridge needed for Cowork). The file lives at
`$COWORK_CONFIG`. Add inside `mcpServers`, alongside the existing `preferences` (strict JSON — no
comments). The `$…` below are shown for clarity — **substitute the resolved values from
`config/cos.env` (the loader resolves them)** before writing them into this JSON, which can't
expand `$VARS`:
```json
{ "mcpServers": {
"board": {
"command": "$BREW_PREFIX/bin/node",
"args": ["$REPO_ROOT/mcp/board-server/server.mjs"],
"env": { "CRM_BASE_URL": "$BOARD_URL" }
},
"calendar": {
"command": "$BREW_PREFIX/bin/node",
"args": ["$REPO_ROOT/mcp/calendar-server/server.mjs"],
"env": { "CRM_BASE_URL": "$BOARD_URL" }
},
"guard": {
"command": "$BREW_PREFIX/bin/node",
"args": ["$REPO_ROOT/mcp/guard-server/server.mjs"],
"env": { "COS_GUARD_URL": "$GUARD_SIDECAR_URL" }
},
"vault": {
"command": "$BREW_PREFIX/bin/node",
"args": ["$REPO_ROOT/mcp/vault-server/server.mjs"],
"env": {
"ANTHROPIC_API_KEY": "sk-ant-…",
"COS_VAULT_DIR": "$VAULT_DIR"
}
}
}, "preferences": { /* leave intact */ } }
```
**Write it from config — don't hardcode (idempotent).** The JSON above is the *shape*; never
hand-type machine paths or the API key into it. `scripts/gen-cowork-config.mjs` builds each entry
from the same manifest the launchd/Windows supervisors read, **backs up first** (to `.bak`),
preserves `preferences`/other keys, and inlines the `vault` secret from `config/secrets.env`. Name
the four core servers (a named merge is additive and won't touch a third-party server you added by
hand); re-running it fixes drift (e.g. a missing `vault` — the most common gap, since it's the only
one needing the key + a vault dir):
```sh
source "$(git rev-parse --show-toplevel)/config/load-config.sh"
# Precondition: $COWORK_CONFIG must point at the REAL Cowork config (cos-setup detects + records it;
# default macOS path below, %APPDATA%/Claude on Windows). Confirm its dir exists before merging —
# the generator refuses a missing dir rather than writing an orphan config Cowork never reads.
[ -d "$(dirname "$COWORK_CONFIG")" ] || echo "FIX FIRST: set COWORK_CONFIG in config/cos.env to your real claude_desktop_config.json (dir '$(dirname "$COWORK_CONFIG")' missing — Cowork installed?)"
node "$REPO_ROOT/scripts/gen-cowork-config.mjs" board calendar guard vault
```
> Only `vault` carries `ANTHROPIC_API_KEY` (it makes outbound LLM calls — see
> ["The vault server makes outbound LLM calls"](#the-vault-server-makes-outbound-llm-calls)); the
> generator inlines it from `config/secrets.env` (Cowork can't run the macOS secret-wrapper) — the
> other servers are localhost-only and carry no secret. `--print` shows the generated block with the
> key value redacted. With **no** names it's a full resync of every cos bridge (and prunes cos-owned
> entries the manifest no longer defines); add-ons name just their own server from their own skill.
- **Absolute `node` path** (`$BREW_PREFIX/bin/node`, from the loader): Claude Desktop's spawn
env, like launchd's, lacks Homebrew on `PATH` (same gotcha as §2).
- **Absolute** server + env paths — Claude Desktop, like launchd, `path.resolve()`s nothing for you.
- **Quit + reopen Claude Desktop (⌘Q)** — it reads this file only at launch; then board,
calendar, guard + vault appear in Cowork's tools (**Settings → Connectors** lists the local
servers). On first use Cowork prompts to allow each server's tools — choose **"Always allow"** so
routine agent runs aren't interrupted by a per-call prompt (or approve per-tool if you prefer).
- board tool *calls* still need the Next app on `:3000` (`cd board && npm run dev`);
`initialize`/`tools/list` work without it. Semantic `search` flows on to the `:8008` sidecar.
- **Validate the exact command+env before trusting it** — spawn each with the SDK
`StdioClientTransport` and run `listTools()` + one `callTool` (how confirmed: board → 26 tools
+ semantic search; calendar → 6 tools;
guard → 4 tools (scan_email, classify_text, check_sender, block_sender) + non-error fail-closed verdicts (2026-06-03);
vault → 2 tools (ingest, query), `serverInfo.name=vault` (2026-06-08)).
#### Claude Code — HTTP via the bridge
`REPO/.mcp.json` *does* accept a `url` (`"type":"http"`, `"streamable-http"` as an alias),
pointing at the supergateway bridges from §2–3. It is **committed + CI-checked** and **generated**
from the manifest — don't hand-edit it; just regenerate (a no-op if already in sync):
```sh
source "$(git rev-parse --show-toplevel)/config/load-config.sh"
node "$REPO_ROOT/scripts/gen-mcp-json.mjs" # writes REPO/.mcp.json from the manifest (CI verifies it matches)
```
The generated entries look like (one per `claude-code` bridge, port-ascending):
```json
{ "mcpServers": {
"board": { "type": "http", "url": "http://localhost:8001/mcp" },
"calendar": { "type": "http", "url": "http://localhost:8003/mcp" },
"guard": { "type": "http", "url": "http://localhost:8004/mcp" },
"vault": { "type": "http", "url": "http://localhost:8005/mcp" }
}}
```
> The optional `openwhispr` (`:8002`) and `whatsapp` (`:8006`) entries land in `.mcp.json` once their
> own setup skills (`/openwhispr-mcp-setup`, `/whatsapp-mcp-setup`) install the descriptor and
> regenerate — the generator picks up every bridge in the manifest, so nothing is merged by hand.
#### Claude Desktop — HTTP alternatives (only if you prefer the bridge / can't use stdio)
- **Custom Connector UI** — Settings → Connectors → Add custom connector →
`$BOARD_BRIDGE_URL/mcp` (and the other bridge URLs). Uses the §2–4 bridges; no config-file entry, no
OAuth for a localhost no-auth endpoint. (The bridges serve plain streamable-HTTP — verify
with the SDK `StreamableHTTPClientTransport`, which connects + lists tools.)
- **`mcp-remote` stdio shim** — `{"command":"npx","args":["-y","mcp-remote","<url>"]}` — the
generic config-file workaround for HTTP servers, but it currently chokes on supergateway
with `Unexpected content type: null`. Prefer the direct stdio above (it's simpler anyway).
### 6. The app brings the bridges up (already wired — no edit)
`mcp/ensure-bridges.sh` + `mcp/ensure-bridges.mjs` are **committed**; you don't author them. And
`board/package.json` already chains them via `predev`/`prestart` so starting the app guarantees the
bridges are up first (cross-platform, no `sh` in the npm script):
```json
"predev": "node ../mcp/ensure-bridges.mjs",
"prestart": "node ../mcp/ensure-bridges.mjs"
```
`ensure-bridges.mjs` is the platform dispatcher: on Windows it runs `mcp/cos-services.mjs start`;
everywhere else it runs `mcp/ensure-bridges.sh`. That shell script is a **thin consumer of the
service manifest** — it iterates `node mcp/service-manifest.mjs --probe-list` (NOT a hardcoded
service list), and on macOS bootstraps + kickstarts each installed LaunchAgent then probes it.
**One-way on purpose** — it NEVER stops a bridge (Cowork needs them even when the dev app is down),
launchd stays the real supervisor (boot + crash-restart), and it always exits 0 so a bridge hiccup
can't block `next dev`. An optional add-on with no installed plist is skipped silently (no WARN
about `:8002`/`:8006`). If you start the app another way, run `sh mcp/ensure-bridges.sh` there too.
The same `ensure-bridges.sh` also nudges the two **uv sidecars** — **search** (`:8008`) and
**guard** (`:8009`): it bootstraps + kickstarts `com.chiefofstaff.mcp-search` and
`com.chiefofstaff.mcp-guardsvc` alongside the bridges, then probes each `/healthz` (not a bare
port-listen — a cold sidecar listens before its model is warm) and only **WARNs** if one isn't
up yet. The board's `POST /api/search` falls back to a keyword scan in that window, and the
guard MCP **fails CLOSED** (an unreachable scan returns `UNAVAILABLE → untrusted`), so a
missing/cold/absent sidecar never blocks `next dev` (see `docs/reference/search.md` / `docs/security/guard.md`).
> **The guard has TWO processes — bridge `:8004` *and* its own sidecar `:8009`.** The
> `guard` MCP bridge (`:8004`, in the table + `.mcp.json` above) is just a thin node
> stdio server; the real work lives in a **guard SIDECAR** (`guard/sidecar.py`, run by
> **`uv`**) on **`:8009`** — a uv FastAPI daemon **exactly like the search sidecar**:
> its own **`com.chiefofstaff.mcp-guardsvc`** LaunchAgent, supervised by launchd,
> **NOT** in `.mcp.json` (it's an HTTP backend, not an MCP server). `ensure-bridges.sh`
> nudges it alongside the search sidecar and probes its **`/healthz` leniently** (the
> classifier warms at startup, so a cold sidecar listens before it's ready — WARN, don't
> fail). Its **first real model** is the **gated** Meta `Llama-Prompt-Guard-2-86M`, which
> needs a HuggingFace login + license acceptance (`huggingface-cli login`); in **`auto`**
> mode (default) the sidecar **falls back to a deterministic heuristic** classifier when
> the model is unavailable, so the bridge works **offline** out of the box (degraded, and
> the classifier name in every response says so). See `docs/security/guard.md` for the full contract.
### The vault server makes outbound LLM calls
The `vault` server (bridge `:8005`) is unlike the other four bridges: it **embeds the Agent
SDK** and makes **outbound LLM calls** to the Anthropic API. So it needs an `ANTHROPIC_API_KEY`
(the other bridges talk only to localhost and never carry a key). It also passes
`COS_VAULT_DIR=$VAULT_DIR` (i.e. `$REPO_ROOT/vault/$VAULT_NAME`) so the inner session is scoped
to the vault tree.
**The key lives in `config/secrets.env`, not in the plist.** launchd never inherits your login
shell and never expands `$VARS` inside a plist, so rather than baking the literal secret into
the installed plist, the plist's `ProgramArguments` runs the launch wrapper
**`mcp/vault-server/launch.sh`**, which sources the gitignored **`config/secrets.env`** at
startup and exports the key before exec'ing supergateway. So the secret lives in exactly one
machine-local file — never in `~/Library/LaunchAgents`, never committed. One-time setup:
```bash
source "$(git rev-parse --show-toplevel)/config/load-config.sh"
cp "$REPO_ROOT/config/secrets.env.example" "$REPO_ROOT/config/secrets.env" # then edit in your sk-ant-… key
# render + install the plist (no secret in it). The vault plist is generated from
# mcp/vault-server/vault.service.json by scripts/gen-launchd.mjs — its ProgramArguments runs the
# secret-wrapper launch.sh (which sources config/secrets.env), and $REPO_ROOT / $VAULT_DIR resolve
# from the loader; the API key has no placeholder — it lives only in config/secrets.env. --install
# also reloads via launchctl. (This is included in the core `gen-launchd.mjs --install` in §2.)
node "$REPO_ROOT/scripts/gen-launchd.mjs" --install vault
```
**Rotate** the key by editing `config/secrets.env` — no plist edit. If the file is missing/empty the
bridge still boots; the vault tools just return a clean auth error per call (fail-soft).
!!! warning "Rotation is TWO steps — Cowork holds a *copy*, not a reference"
The clients bind the key at **different times**, so one command does not fix both:
| Client | Binding | Rotation step |
|---|---|---|
| Claude Code | `launch.sh` sources `secrets.env` on **every start** — late-bound | `launchctl kickstart -k gui/$(id -u)/com.chiefofstaff.mcp-vault` |
| Cowork | `gen-cowork-config.mjs` **inlined a copy** into `claude_desktop_config.json` — early-bound, never refreshed | `node scripts/gen-cowork-config.mjs vault`, then **⌘Q** + reopen |
Skip the second step and Cowork keeps serving the **old** key — surfacing as `401 Invalid API
key` in Cowork while Claude Code works perfectly, which sends you hunting in the wrong place.
The same asymmetry means a **placeholder** captured during first-run setup is permanent:
filling in `secrets.env` afterwards fixes Code and never fixes Cowork. `gen-cowork-config.mjs`
now refuses to snapshot a placeholder, but a config written before that guard can still carry one.
Verify the two agree at any time (prints fingerprints, never key material):
```sh
node "$REPO_ROOT/scripts/check-cowork-secrets.mjs"
```
Because it spawns its own inner Agent SDK session, that session is **isolated** so it can't
re-enter the repo's own tools/config: `strictMcpConfig: true`, `mcpServers: {}` (no nested
MCP servers), `settingSources: ['project']` (only project settings, not user/global), and a
**scoped `cwd`** (the vault dir, not the repo root). Those four options are load-bearing —
without them the inner session would inherit this repo's MCP servers and settings and could
loop back on itself.
## The search sidecar LaunchAgent (`:8008`)
The semantic-search **sidecar** (`search/sidecar.py`, run by **`uv`**) is an HTTP daemon — not
a stdio MCP server — so it does **not** go in `.mcp.json` (same as Gmail/Calendar). It's an
*optional ranking accelerator*: the board reads the same `cases.json` and falls back to a
keyword scan whenever the sidecar is down, so this LaunchAgent is **best-effort, never required**
(see `docs/reference/search.md` for the full contract). launchd supervises it the same way as the bridges.
The search plist is generated from `search/search.service.json` by `scripts/gen-launchd.mjs` (no
supergateway — `uv` self-provisions the venv on first launch and runs uvicorn directly): the
descriptor declares port `8008`, `--directory "$REPO_ROOT/search"`, and the env `COS_BOARD_DATA`
(absolute — the board writes `process.cwd()/data/cases.json` with cwd `board/`) + `HF_HUB_OFFLINE=1`
(pins offline once the model is cached, after the one-time prefetch below). `search` is core, so the
default `gen-launchd.mjs --install` in §2 already installs + reloads it; to (re)do just this one:
```sh
source "$(git rev-parse --show-toplevel)/config/load-config.sh"
node "$REPO_ROOT/scripts/gen-launchd.mjs" --install search
curl -s "$SEARCH_SIDECAR_URL/healthz" # {"ok":true,...} once the model is warm
```
**One-time model prefetch** (run once, while online, *before* setting `HF_HUB_OFFLINE=1`):
```sh
source "$(git rev-parse --show-toplevel)/config/load-config.sh"
"$UV_BIN" run --directory "$REPO_ROOT/search" python -c \
"from model2vec import StaticModel; StaticModel.from_pretrained('minishlab/potion-base-8M')"
```
This downloads the ~30MB `potion-base-8M` static embedding into `~/.cache/huggingface`; from
then on the sidecar starts fully offline. (Skip it — and drop `HF_HUB_OFFLINE` — if you run
the deterministic `COS_SEARCH_EMBEDDER=hash` embedder, which needs no model.)
**Fields that matter**
- `uv run --directory "$REPO_ROOT/search"` — `uv` reads `search/pyproject.toml`, builds the venv on
first launch (so there's no manual `pip install`), and runs `uvicorn sidecar:app`.
- `COS_BOARD_DATA` is **absolute** — see the comment above; a relative path is the classic
cwd-divergence bug (board cwd is `board/`, not the repo root).
- The embedder is **warmed at FastAPI startup**, so `/healthz` only returns `{"ok":true}` once
the model is loaded — which is exactly what `ensure-bridges.sh` probes (no false "up").
- `KeepAlive` + `RunAtLoad` mirror the bridges (the descriptor renders them): it starts at login
and restarts on crash.
> **Port `:8008`** sits clear of the bridges (`:8001`/`:8002`) and the board (`:3000`). If a
> *foreign* process already holds `:8008`, the board's `POST /api/search` gets a non-JSON /
> non-2xx reply → it falls back to keyword (degrades safely); the `/healthz` probe (not a bare
> `lsof`) is what stops a false "search up".
## The guard sidecar LaunchAgent (`:8009`)
The **guard MCP** (`:8004`, in `.mcp.json`) is a thin stdio→HTTP bridge; the real classification
work lives in the **guard sidecar** (`guard/sidecar.py`, run by **`uv`**) on **`:8009`** — a uv
FastAPI daemon **exactly like the search sidecar** (its own LaunchAgent, **not** in `.mcp.json` —
it's an HTTP backend the *bridge* calls, not an MCP server). Unlike search, the guard is a
**security control**, so the MCP **fails CLOSED** when the sidecar is down (an unreachable scan
returns an `UNAVAILABLE → untrusted` verdict, never a silent "clean").
The guardsvc plist is generated from `guard/guardsvc.service.json` by `scripts/gen-launchd.mjs` —
same shape as the search sidecar with three differences: port `8009`, `--directory "$REPO_ROOT/guard"`,
and env `COS_GUARD_TRUST_FILE` (absolute — the sidecar's only writable state, the sender-trust
whitelist) instead of `COS_BOARD_DATA` (and **no** `HF_HUB_OFFLINE` pin — see below). `guardsvc` is
core, so the default `gen-launchd.mjs --install` in §2 already installs + reloads it; to (re)do just
this one: `node "$REPO_ROOT/scripts/gen-launchd.mjs" --install guardsvc`.
**The classifier model is GATED.** With only the default deps (`fastapi`+`uvicorn`) the sidecar
runs in **`auto`** mode and falls back to a **deterministic heuristic** classifier — so it works
**offline out of the box** (degraded; `/healthz` reports `heuristic-fallback`, which is *why* the
plist does **not** pin `HF_HUB_OFFLINE`: there's no model cached to go offline against yet). To
switch on the real model (Meta `Llama-Prompt-Guard-2-86M`), one-time while online:
```sh
source "$(git rev-parse --show-toplevel)/config/load-config.sh"
# accept the Llama license at huggingface.co/meta-llama/Llama-Prompt-Guard-2-86M, then:
huggingface-cli login
(cd "$REPO_ROOT/guard" && "$UV_BIN" sync --extra model) # installs torch + transformers
node "$REPO_ROOT/scripts/gen-launchd.mjs" --install guardsvc # re-render + reload the sidecar
curl -s "$GUARD_SIDECAR_URL/healthz" # classifier flips to promptguard:meta-llama/...
```
`ensure-bridges.sh` nudges it alongside search and probes `/healthz` **leniently** (a cold
sidecar listens before it's ready → WARN, not fail; the fail-closed MCP keeps you safe meanwhile).
See `docs/security/guard.md` for the full contract and the board **`/security`** whitelist UI it backs.
> For the **end-to-end model setup** — picking a named model preset, accepting the gated Llama
> license + authenticating with the current `hf` CLI, prefetching the model, setting
> `COS_GUARD_MODEL`/`COS_GUARD_THRESHOLD` in `config/cos.env`, and verifying the real model loaded
> (not the heuristic) — follow the **guard-setup** skill.
## Manage
```sh
launchctl kickstart -k gui/$(id -u)/com.chiefofstaff.mcp-board # restart one
launchctl bootout gui/$(id -u)/com.chiefofstaff.mcp-board # stop one
tail -f mcp/logs/board.err.log # logs
```
Add a *bridge* server: the port in `config/load-config.sh`, one descriptor
`mcp/<name>-server/<name>.service.json`, then the generators — `gen-launchd.mjs --install <name>`,
`gen-mcp-json.mjs`, `gen-cowork-config.mjs <name>`. No second port map, no `ensure-bridges.sh` edit
(it reads the manifest). See the **"Add a new MCP — the checklist"** in `mcp/CLAUDE.md`. (The search
sidecar above is an HTTP daemon, not a bridge — its descriptor has `clients:[]`, so **no** `.mcp.json`
or Cowork entry.)
> **openwhispr add-on (`/openwhispr-mcp-setup`).** The optional voice server, `openwhispr` on
> **`:8002`**, surfaces the external OpenWhispr desktop app's local transcript store. It's wired
> exactly like the core four (a supergateway bridge in `.mcp.json` + a Cowork stdio entry) but
> lives in its **own** skill because of that external-app dependency — run
> **`/openwhispr-mcp-setup`** to add it (and `/voice-operations` owns the reads + watermark,
> `/second-brain-ingest` the board-side routing).
>
> **WhatsApp add-on (`/whatsapp-mcp-setup`).** Another optional server, `whatsapp`, lives in an
> **external** repo and is a worked example of wiring **both** a bridge AND a sidecar at once: the
> Python MCP as a supergateway bridge on **`:8006`** (in `.mcp.json` + Cowork stdio, exactly like
> the core four above) and the Go whatsmeow bridge as a launchd **sidecar** on **`:8010`** (like
> search/guardsvc — NOT in `.mcp.json`). Its plists are generated from the descriptors under
> `mcp/whatsapp/` by `scripts/gen-launchd.mjs` (see `mcp/CLAUDE.md`). It needs a one-time **QR
> pairing**; follow **`/whatsapp-mcp-setup`** for
> the full runbook. Heads-up: the upstream Python server reads `WHATSAPP_BRIDGE_PORT` only for the
> **Go** bridge, so cos names its supergateway port `WHATSAPP_MCP_BRIDGE_PORT` to keep the two
> distinct.
## Gotchas (learned the hard way)
- **`brew install <anything>` can break Node.** Installing another formula may bump a shared
lib and leave Node dangling: `dyld: Library not loaded: .../libsimdjson.NN.dylib, Referenced
from .../bin/node`. Every `node` call (and thus every bridge) then dies (exit 133/134).
**Fix:** `brew reinstall node` (relinks Node to the current lib). Verify: `node -v`.
- **pm2 is not used here.** pm2 6.x failed to fork on this machine (God daemon:
`Cannot find module ProcessContainerFork.js` / `Could not _load() the script`) under the
system Node. launchd is the supervisor. Don't reintroduce pm2.
- **supergateway exits if its stdin closes** in some shells, but launchd's `/dev/null` stdin
is fine — it stays up. If you ever run it by hand under a wrapper that closes stdin, feed it
`< <(tail -f /dev/null)`.
- **board needs the Next.js app on `:3000`** for tool *calls* (`npm run dev` in `board/`);
`initialize`/`tools/list` work without it. (The openwhispr real-store-vs-fixtures gotcha now
lives in `/openwhispr-mcp-setup`.)
- **Ports** 8001/8003/8004/8005 (core bridges) and 8008/8009 (search + guard sidecars) must be
free (`lsof -nP -iTCP:8001 -sTCP:LISTEN`). `:8002` (openwhispr) and `:8006` (whatsapp) belong to
the optional add-ons, wired by their own skills. The board itself runs on `:3000`.
- **search sidecar's first launch needs the network once** (to fetch the ~30MB model) and
takes ~30s to green `/healthz`. Run the one-time prefetch above, then `HF_HUB_OFFLINE=1`
makes every later start offline. A missing `uv` or a cold sidecar is harmless — the board
degrades to keyword search and `ensure-bridges.sh` only WARNs.
Scanned 9/2/2026
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!