mcp-hub
Personal knowledge-base MCP server. Streamable HTTP, bearer tokens, one Markdown collection per tenant.
https://mcp.yusypenko.com/mcphttps://mcp.yusypenko.com/healthz (no auth, no tenant info)Authorization: Bearer <token> on every request- 1. Get the token
- 2. Claude Code
- 3. Verify
- 4. Other clients
- 5. How auth works
- 6. Client tenants
- 7. Tools
- 8. Rotate / lost machine
- 9. Troubleshooting
1. Get the token
One token per tenant. Your personal one exists in exactly three places — pick any:
- Password manager — entry mcp-hub · personal token.
- A machine that already has it:
claude mcp get personal-kb # prints the Authorization header - The server (only from a machine with the
mcp-hubSSH 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:
/mcp→personal-kbconnected- ask: “call whoami on personal-kb” →
{"tenant": "personal", "scopes": ["kb:read","kb:write"], …}
.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
- 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.
- Static bearer tokens, no OAuth. The server holds a table in its environment:
Tokens areMCP_HUB_TOKENS="personal=<64 hex>:kb:read+kb:write,client-acme=<64 hex>"openssl rand -hex 32. Minimum 16 chars; two tenants sharing a token is a startup error, not a warning. - Every
/mcprequest must carryAuthorization: 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. - 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). - Scopes live in the table, not the token.
kb:readis required for any call;kb:writeadditionally unlockswrite_note. Read-only is the default for new tenants. - Revocation = rotation. There are no sessions to revoke: change the token in the table, restart the container, and the old token gets
401on 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
| Tool | Scope | Arguments | What it does |
|---|---|---|---|
whoami | read | — | Tenant, scopes, document count. Ask this first when unsure where you are. |
search_kb | read | query, limit=5 | Lexical search ranked by distinct-term coverage; headings and filenames weigh more. Stems match, synonyms don't — retry with other words on 0 hits. |
list_documents | read | prefix="" | Browse the tree. |
get_document | read | path | Full Markdown of one note. |
write_note | write | path, content, append=false | Save 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
| Symptom | Cause / fix |
|---|---|
Every call returns 401 | Token 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 connect | curl 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 hits | Those words are absent, not the topic. Try synonyms; whoami shows the document count. |
write_note refused | Token is read-only (kb:write not in its scopes) — a deliberate default for new tenants. |
| Responses hang | Something between you and Caddy buffers SSE. Direct connection works; corporate proxies often don't. |