# Remote MCP for Claude Desktop (MSIX builds) — full build recipe

Built 2026-08-06. The path when Claude Desktop is the **MSIX/Store package** and its "Add custom connector" dialog is **remote-only** (URL + OAuth, no command/args field — see `windows-environment-quirks` Rule 5).

## Final architecture

```
Claude Desktop (connector: Name=hermes-vps, URL=https://robblake.cloud/mcp, OAuth fields EMPTY)
  → nginx 443 (robblake.cloud)
    → /mcp                      → 127.0.0.1:9123  hermes-vault-mcp.service (vault_mcp.py — direct MCP server)
    → /.well-known/oauth-*      → 127.0.0.1:9122  hermes-oauth.service (oauth_server.py — OAuth 2.1+PKCE shim)
    → /oauth/{authorize,token,register} → 127.0.0.1:9122 (same)
```

The OAuth endpoints and `/mcp` must be inside the EXISTING 443 server block in `/etc/nginx/sites-enabled/robblake.cloud`. Bearer token lives at `/root/.hermes/mcp-bridge/token.env` (chmod 600, `openssl rand -hex 24`).

## Why `hermes mcp serve` was abandoned

- `mcp_serve.py` imports `from mcp.server.fastmcp import FastMCP` (line 52).
- mcp **2.0.0**: `mcp/server/` has no `fastmcp` module (removed).
- mcp **1.0.0**: `mcp/server/` = `{__init__, __main__, models, session, sse, stdio, websocket}.py` — also no `fastmcp`.
- supergateway (`--stdio "hermes mcp serve" --outputTransport streamableHttp`) spawns the child; child exits 1 with `Error: MCP server requires the 'mcp' package`; the HTTP side returns 200 + `text/event-stream` headers with an **empty body**. Symptom is silent emptiness, not an error message — check `systemctl status hermes-mcp-bridge -l` for the child stderr.
- `mcp-remote` is the WRONG DIRECTION (stdio client → remote HTTP server). supergateway is the right-direction tool; it just has nothing viable to wrap.
- Retest `hermes mcp serve` after the 0.20.x native-install migration. `hermes-mcp-bridge.service` (supergateway on 9121) was left installed but `/mcp` no longer points at it.

## The working direct MCP server (vault_mcp.py)

~120 lines, stdlib only. Key details that make Claude's connector accept it:

- POST `/mcp` only; GET → the connector doesn't use it.
- Dispatch on `method`: `initialize` (return `protocolVersion: "2024-11-05"`, `capabilities: {tools:{}}`, `serverInfo`), `tools/list`, `tools/call` (params.name + params.arguments).
- **Response shape: ONE SSE frame** — `data: {json}\n\n`, `Content-Type: text/event-stream`, explicit `Content-Length`, `Cache-Control: no-cache`, then `close_connection = True`. Do NOT hold the stream open: `BaseHTTPRequestHandler` is single-threaded and an open stream hangs every later request (the "empty response" rabbit hole).
- Tools implemented for the vault bridge: `list_files(path)`, `read_file(path)`, `write_file(path, content)` — rooted at `/root/.hermes/vault`, `os.makedirs(dirname, exist_ok=True)` on write. Results wrapped as `{'content': [{'type': 'text', 'text': json.dumps(...)}]}`.

systemd: `hermes-vault-mcp.service`, `ExecStart=/usr/bin/python3 /root/.hermes/mcp-bridge/vault_mcp.py`, port 9123, `Restart=always`.

## The OAuth shim (oauth_server.py)

Claude's remote connector runs OAuth 2.1 discovery when it sees the endpoint; a static-bearer-only endpoint fails with **"Couldn't register with X's sign-in service"** (that's the MISSING `/oauth/register` — registration, not auth). The shim needs all four:

| Endpoint | Behavior |
|---|---|
| `GET /.well-known/oauth-authorization-server` | JSON: issuer, authorization/token/registration endpoints, `code_challenge_methods_supported: ["S256"]` |
| `POST /oauth/register` | Return `client_id`/`client_secret` (any values), echo the request's `redirect_uris` |
| `GET /oauth/authorize` | Generate `code`, store `{client_id, redirect_uri, code_challenge}`, 302 to `redirect_uri?code=...&state=...` |
| `POST /oauth/token` | Verify `grant_type=authorization_code`, look up `code`, **verify PKCE**: `base64url(sha256(code_verifier)).rstrip('=') == stored code_challenge`, delete used code, return the static bearer token as `access_token` |

Also add CORS headers (`Access-Control-Allow-Origin: *`, handle OPTIONS). systemd: `hermes-oauth.service`, port 9122.

nginx block (inside the 443 server):

```nginx
location ~ ^/(\.well-known/oauth-authorization-server|oauth/(authorize|token|register))$ {
    proxy_pass http://127.0.0.1:9122;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto https;
}
location /mcp {
    proxy_pass http://127.0.0.1:9123;
    proxy_http_version 1.1;
    proxy_buffering off;
    proxy_cache off;
    proxy_set_header Connection "";
    proxy_read_timeout 86400;
}
```

## Error-message → layer map (the debug path that worked)

| Claude/Desktop symptom | Broken layer |
|---|---|
| "Couldn't register with X's sign-in service" | `/oauth/register` missing or not reachable |
| OAuth succeeds, "X returned an error when connecting" | `/mcp` upstream dead or SSE shape wrong |
| Connector added but "No connector named X in this session" | Per-session toggle — enable in that chat's connector menu, or start a fresh session (existing sessions snapshot the connector set) |
| curl to /mcp "hangs, shows nothing" | NORMAL for SSE without `--max-time` — retest with `timeout 3 curl -sN ... \| head -c 200` |

## nginx surgery pitfalls (hit twice in one night)

- Appending `location` blocks with `cat >>` puts them OUTSIDE the `server {}` → `emerg: "location" directive is not allowed here`.
- `sed '40,66c\...'` line-range replaces eat neighboring braces when the file has two server blocks — the 443 block lost its `}`, the port-80 block lost its `server {`.
- Rule: **one edit per `nginx -t`**, and after ~3 rounds of structural errors, stop sed-surgery and rewrite the file from a known-good copy.
- The `patch` tool refuses `/etc/nginx/**` (sensitive path) — all nginx edits are terminal heredoc/sed, which surfaces the security-scan approval. Fine, but slower; batch carefully.

## PowerShell-side (Connie) notes

- `curl` = `Invoke-WebRequest` alias: `-H "Authorization: ..."` throws `Cannot bind parameter 'Headers'`. Correct shape:
  ```powershell
  $h = @{ "Authorization" = "Bearer <tok>"; "Content-Type" = "application/json" }
  Invoke-RestMethod -Uri "https://robblake.cloud/mcp" -Method Post -Headers $h -Body $body
  ```
- Every `$variable` the user fails to carry between paste blocks (`$headers` defined in one block, used in another) fails silently into a 401 — put the full sequence in ONE block.

## Verification suite (all should pass before handing to the user)

```bash
# OAuth discovery
curl -s https://robblake.cloud/.well-known/oauth-authorization-server | head -c 100
# Registration
curl -s -X POST https://robblake.cloud/oauth/register | head -c 100
# MCP initialize (expect one SSE data: frame with protocolVersion)
timeout 3 curl -sN -X POST -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "Authorization: Bearer $(grep -oP 'MCP_BEARER_TOKEN=\K.*' /root/.hermes/mcp-bridge/token.env)" \
  -d '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}' \
  https://robblake.cloud/mcp | head -c 200
```

End-to-end proof on the Claude side: new chat → enable `hermes-vps` → "Using the hermes-vps connector, list files in Hermes Activity/" → should return `_README.md` + dated digests.
