mcp-hub

Personal knowledge-base MCP server. Streamable HTTP, bearer tokens, one Markdown collection per tenant.

Endpoint
https://mcp.yusypenko.com/mcp
Health
https://mcp.yusypenko.com/healthz (no auth, no tenant info)
Transport
MCP Streamable HTTP over TLS only
Auth
Authorization: Bearer <token> on every request
This page never contains a token. Anything that looks like one below is a placeholder. The token is your identity to the hub: never paste it into a repo, an issue, a chat, or shell history.

1. Get the token

One token per tenant. Your personal one exists in exactly three places — pick any:

  1. Password manager — entry mcp-hub · personal token.
  2. A machine that already has it:
    claude mcp get personal-kb        # prints the Authorization header
  3. The server (only from a machine with the mcp-hub SSH alias):
    ssh mcp-hub 'sed -n "s/^MCP_HUB_TOKENS=personal=\([^:]*\).*/\1/p" /srv/mcp-hub/src/deploy/.env'

Put it in a shell variable without echo or history:

read -rsp 'personal-kb token: ' PERSONAL_KB_TOKEN; echo

2. Claude Code — user scope (all projects on this machine)

claude mcp add --transport http personal-kb https://mcp.yusypenko.com/mcp \
  --scope user \
  --header "Authorization: Bearer $PERSONAL_KB_TOKEN"

claude mcp get personal-kb          # expect:  Status: ✔ Connected
unset PERSONAL_KB_TOKEN

The header is stored in ~/.claude.json (private, per user). Then inside any session:

Why user scope: personal notes should follow you into every project. Client tenants are the opposite — see §6. Do not add this server to a committed .mcp.json.

3. Verify from a shell

# liveness — public, no tenant details
curl -s https://mcp.yusypenko.com/healthz; echo

# without a token: must be 401
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://mcp.yusypenko.com/mcp \
  -H 'Accept: application/json, text/event-stream' -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'

# with your token: 200 and an initialize result
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://mcp.yusypenko.com/mcp \
  -H "Authorization: Bearer $PERSONAL_KB_TOKEN" \
  -H 'Accept: application/json, text/event-stream' -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'

4. Other clients

Cursor / anything that takes url + headers

{
  "mcpServers": {
    "personal-kb": {
      "url": "https://mcp.yusypenko.com/mcp",
      "headers": { "Authorization": "Bearer PASTE_TOKEN_HERE" }
    }
  }
}

Codex CLI (~/.codex/config.toml)

[mcp_servers.personal-kb]
url = "https://mcp.yusypenko.com/mcp"
bearer_token_env_var = "PERSONAL_KB_TOKEN"   # export it in your shell profile

Clients that only speak stdio (Claude Desktop config, older tools)

{
  "mcpServers": {
    "personal-kb": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://mcp.yusypenko.com/mcp",
               "--header", "Authorization: Bearer ${PERSONAL_KB_TOKEN}"]
    }
  }
}

Prefer the env-var forms: the config file then holds no secret.

5. How auth works

  1. TLS only. Caddy terminates HTTPS with Let's Encrypt; plain HTTP is redirected (308). The MCP container has no published port — it is reachable only from Caddy on the internal Docker network.
  2. Static bearer tokens, no OAuth. The server holds a table in its environment:
    MCP_HUB_TOKENS="personal=<64 hex>:kb:read+kb:write,client-acme=<64 hex>"
    Tokens are openssl rand -hex 32. Minimum 16 chars; two tenants sharing a token is a startup error, not a warning.
  3. Every /mcp request must carry Authorization: Bearer <token>. The verifier compares it against every configured tenant with a constant-time compare (hmac.compare_digest, no early exit), so timing does not reveal which prefix matched.
  4. The token picks the tenant. A match yields exactly one tenant name; that name is the directory kb/<tenant>/ and every tool call is confined to it by path resolution. No tool accepts a tenant, client or collection argument — the model cannot choose or switch tenants. No match → 401; there is no default tenant (fail closed).
  5. Scopes live in the table, not the token. kb:read is required for any call; kb:write additionally unlocks write_note. Read-only is the default for new tenants.
  6. Revocation = rotation. There are no sessions to revoke: change the token in the table, restart the container, and the old token gets 401 on its next call.

Consequences: a token is worth exactly one tenant's notes plus (if write-scoped) the ability to add to them. A leaked token cannot reach another tenant. Rotate when a machine is lost or a contract ends.

6. Client tenants — project scope, placeholder in git

Only after that client's NDA allows notes on self-hosted infra. Register it inside the client's repo so it cannot follow you into another project:

cd ~/code/acme
claude mcp add --transport http kb https://mcp.yusypenko.com/mcp \
  --scope project \
  --header "Authorization: Bearer ${ACME_KB_TOKEN}"

# .mcp.json is committed with the ${ACME_KB_TOKEN} placeholder — never the value.
# Real value goes in .envrc (direnv) or your shell, never in git:
echo 'export ACME_KB_TOKEN=…' >> .envrc && direnv allow

When pairing or screen-sharing with a client, check /mcp: no personal or other-client server should be live in that session.

7. Tools

ToolScopeArgumentsWhat it does
whoamireadTenant, scopes, document count. Ask this first when unsure where you are.
search_kbreadquery, limit=5Lexical search ranked by distinct-term coverage; headings and filenames weigh more. Stems match, synonyms don't — retry with other words on 0 hits.
list_documentsreadprefix=""Browse the tree.
get_documentreadpathFull Markdown of one note.
write_notewritepath, content, append=falseSave a .md note inside the tenant; auto-committed to git and pushed to the backup repo within 15 min.

Write things that stay true — decisions and their reasoning, gotchas, runbooks, conventions. Not ticket or deploy status; it goes stale in place.

8. Rotate a token / lost machine

openssl rand -hex 32                                         # new token
ssh mcp-hub                                                  # on the box:
  $EDITOR /srv/mcp-hub/src/deploy/.env                       # replace the value in MCP_HUB_TOKENS, keep the name
  cd /srv/mcp-hub/src/deploy && docker compose up -d       # recreate; no rebuild
# confirm the old token is dead → must print 401
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://mcp.yusypenko.com/mcp \
  -H 'Authorization: Bearer OLD_TOKEN' -H 'Accept: application/json, text/event-stream' \
  -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
# then re-run the `claude mcp add` from §2 on every machine, and update the password manager

9. Troubleshooting

SymptomCause / fix
Every call returns 401Token mismatch — check for a trailing newline or quote when you pasted it; compare with claude mcp get personal-kb on a working machine.
claude mcp get says ✘ Failed to connectcurl https://mcp.yusypenko.com/healthz. If that fails too, the box or DNS is down: ssh mcp-hub 'cd /srv/mcp-hub/src/deploy && docker compose ps && docker compose logs --tail 50'.
search_kb always returns 0 hitsThose words are absent, not the topic. Try synonyms; whoami shows the document count.
write_note refusedToken is read-only (kb:write not in its scopes) — a deliberate default for new tenants.
Responses hangSomething between you and Caddy buffers SSE. Direct connection works; corporate proxies often don't.