Remote Transport: SSE & Streamable HTTP

August 9, 2026 · View on GitHub

Perseus Vault is an MCP stdio server by default. For remote access — Claude Desktop, the Anthropic MCP Connector API, or any HTTP MCP client — it also ships a full SSE and Streamable HTTP transport, with optional Bearer-token auth.

Transport modes

FlagEndpointsUse case
(default)stdioLocal clients (Hermes, Claude Code, Cursor)
--transport sseGET /sse + POST /messageClaude Desktop, MCP Connector API
--transport httpPOST /message onlyStateless Streamable HTTP

stdio process lifecycle & orphan detection

Each MCP client spawns one perseus-vault stdio process per connection and runs all its tool calls over that persistent pipe — it does not spawn a process per tool call. A well-behaved client closes stdin when the session ends, the server sees EOF, and it exits, freeing its DB handle.

The server exits when its host dies — never merely because the host went quiet. Abandonment is detected by parent death, not inactivity (#748):

  • Linux: the kernel delivers SIGTERM the instant the parent dies (PR_SET_PDEATHSIG), backed by a reparent-to-init poll.
  • macOS / other Unix: a background watcher polls getppid() every 5s and exits promptly when the process is reparented to PID 1 (launchd) — i.e. the spawning host is gone. This works with zero traffic, so an orphaned server blocked in recv() still notices.
  • Windows: the same watcher polls OpenProcess on the parent PID recorded at startup (via Toolhelp snapshot), comparing creation timestamps so a recycled PID can't masquerade as a live parent (#751).
  • A quiet-but-alive host (e.g. Claude Desktop, which can go many minutes between tool calls and does not respawn a server that exits) is left running indefinitely. Idleness is not abandonment.

PERSEUS_VAULT_IDLE_TIMEOUT_SECS=<seconds> remains as an opt-in flat idle watchdog (default: off since v2.21.0; previously 600s) for the one topology parent-death detection cannot see: a host that leaks the child's stdin write-end while staying alive (the Hermes-worker reconnect leak, NousResearch/hermes-agent#57228). Hosts with that lifecycle should set the env var when spawning the server. Unparseable values are ignored with a warning.

⚠️ Do not run an external process-count reaper (e.g. a cron that kills the oldest perseus-vault processes when more than N exist). These subprocesses are the normal stdio transport for live tool calls, so a count-based reap races with active operations and kills them mid-call — clients then see Unknown tool errors and silent dispatch failures (#450). The built-in parent-death watcher already reclaims true orphans. If you must add external cleanup, key it on age + orphaned parent (PPID reparented to init), never on raw process count.

Quick start

perseus-vault --db /path/to/perseus-vault.db \
  --transport sse \
  --web-bind 0.0.0.0 \
  --port 8765 \
  --mcp-token "$(openssl rand -hex 32)"

Output:

perseus-vault: MCP over sse transport on http://0.0.0.0:8765
perseus-vault: POST http://0.0.0.0:8765/message
perseus-vault: GET  http://0.0.0.0:8765/sse

⚠️ Binding to a non-loopback address (--web-bind 0.0.0.0) without an auth token is refused by default — the server exits with an error rather than come up wide open. Set --mcp-token (as above), keep the default 127.0.0.1 bind, or, only if the network is genuinely trusted (e.g. an auth-terminating reverse proxy), set PERSEUS_VAULT_ALLOW_INSECURE_BIND=1 to override.

Authentication (--mcp-token)

When --mcp-token is set, every transport route requires a matching Authorization: Bearer <token> header. Requests without it — or with the wrong token — get 401 Unauthorized with a WWW-Authenticate: Bearer header. Has no effect on stdio transport.

perseus-vault --db /path/to/perseus-vault.db \
  --transport http \
  --web-bind 0.0.0.0 \
  --port 8765 \
  --mcp-token "$(openssl rand -hex 32)"

Client request:

curl -s -X POST http://localhost:8765/message \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
Auth stateResponse
No Authorization header401 Unauthorized
Wrong token401 Unauthorized
Correct Bearer <token>200 OK (request processed)

When --mcp-token is not set, auth is skipped entirely (backward compatible) — appropriate only for loopback (127.0.0.1) deployments. Token comparison is constant-time, so a wrong token leaks no timing signal about the secret.

Hardening

The HTTP surfaces (MCP transport + web dashboard) apply these protections by default; all are env-tunable:

ControlDefaultEnv knob
Secure-bind guardrefuse non-loopback bind with no tokenPERSEUS_VAULT_ALLOW_INSECURE_BIND=1 to override
Request-body cap8 MiBPERSEUS_VAULT_MAX_HTTP_BODY_BYTES
Rate limit (global token bucket)50 req/s, burst 100 → 429PERSEUS_VAULT_HTTP_RATE_PER_SEC (0 disables), PERSEUS_VAULT_HTTP_RATE_BURST
CORStightened methods/headers; origin mirrors requestPERSEUS_VAULT_CORS_ALLOWED_ORIGINS (comma-separated allowlist)

The rate limit is global, not per-client — the vault is a single-tenant, local-first service, so per-IP fairness is a fronting reverse proxy's job. TLS is likewise expected to be terminated by a proxy for the HTTP transport; the gRPC server can terminate TLS/mTLS itself (see GRPC-SECURITY.md).

Verify the endpoint

import json, urllib.request

TOKEN = "YOUR_TOKEN"  # omit the header entirely if running without --mcp-token

def jsonrpc(method, params=None, id=1):
    body = {"jsonrpc": "2.0", "id": id, "method": method, "params": params or {}}
    req = urllib.request.Request(
        "http://localhost:8765/message",
        data=json.dumps(body).encode(),
        method="POST",
        headers={
            "Content-Type": "application/json",
            "Authorization": f"Bearer {TOKEN}",
        },
    )
    with urllib.request.urlopen(req, timeout=5) as resp:
        return json.loads(resp.read().decode())

init = jsonrpc("initialize", {
    "protocolVersion": "2024-11-05",
    "capabilities": {},
    "clientInfo": {"name": "test", "version": "1.0"},
})
print("Server:", init["result"]["serverInfo"])

tools = jsonrpc("tools/list", id=2)
print("Tools:", len(tools["result"]["tools"]))

Connecting from the Anthropic MCP Connector API

The SSE endpoint must be publicly reachable (e.g. behind a Cloudflare tunnel). Pass the Bearer token via the connector's authorization_token field:

client.beta.messages.create(
    model="claude-opus-4-8",
    mcp_servers=[{
        "type": "url",
        "url": "https://perseus-vault-mcp.example.com/sse",
        "name": "perseus-vault",
        "authorization_token": "YOUR_TOKEN",  # matches --mcp-token
    }],
    tools=[{"type": "mcp_toolset", "mcp_server_name": "perseus-vault"}],
    betas=["mcp-client-2025-11-20"],
)

Docker

docker run -p 8765:8765 \
  -v ~/.perseus-vault/data:/data \
  ghcr.io/perseus-computing-llc/perseus-vault:latest \
  --db /data/perseus-vault.db \
  --transport sse \
  --web-bind 0.0.0.0 \
  --port 8765 \
  --mcp-token YOUR_TOKEN