mcp-oauth-hosting
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@mcp-oauth-hostingsearch my knowledge base for the OAuth setup guide"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
mcp-oauth-hosting
English · 中文
Connect your personal or team knowledge base to any endpoint over MCP — from any client, through a single HTTPS URL. Read-only by default; optionally let clients submit notes into one inbox folder. No custom header field, no pasted secret, no OAuth provider to sign up for.
Point it at a folder of markdown — an Obsidian vault, a wiki export, a docs/ directory — and it
serves that knowledge base as a remote MCP server with two tools, search_kb and get_doc. The
knowledge stays on your own machine or VPS; a client only ever needs one URL to reach it.
Because it publishes as one HTTPS URL and nothing else, it works from any client on any
endpoint — desktop apps, phone and mobile agents, chat assistants such as the Grok app — including
the ones that offer no "add header" box, no bearer-key field and no way to read a local file path.
A static Authorization: Bearer key cannot be typed anywhere in those clients; they just say
"couldn't connect to the server".
What those clients do support is the standard OAuth 2.1 discovery flow:
POST /mcp -> 401 WWW-Authenticate: Bearer resource_metadata="..."
GET /.well-known/oauth-protected-resource -> which authorization server?
GET /.well-known/oauth-authorization-server -> which endpoints?
POST /register -> dynamic client registration (RFC 7591)
GET /authorize -> browser opens, the human approves
POST /token -> code + PKCE -> bearer token
POST /mcp Authorization: Bearer <token> -> tools/list, tools/callSo this repo ships a ~380-line drop-in shim that implements exactly those endpoints on top of your existing static key — plus a small markdown MCP server to demonstrate it, a systemd unit, and scripts that publish it through a Cloudflare Tunnel and optionally put Cloudflare Access in front of the consent page so the human authorizes with an SSO login instead of a shared secret.
For a team, that same Access layer puts the consent step behind SSO, so every teammate authorizes with their own identity instead of passing a shared key around.
client (Grok / any MCP client) your VPS
┌───────────────────────────────────┐ ┌────────────────────────────┐
│ https://mcp.example.com/mcp │ │ 127.0.0.1:8081 │
│ ├─ no key → 401 + resource_meta │ │ mcp_server.py │
│ ├─ discovers OAuth metadata │───────▶│ ├─ /health │
│ ├─ registers itself │ │ ├─ /mcp (bearer) │
│ └─ opens /authorize in browser │ │ └─ oauth_shim.py │
└───────────────────────────────────┘ │ /.well-known/* │
▲ 302 with ?code │ /register /authorize │
│ │ /token /revoke │
Cloudflare Tunnel (proxied CNAME) └────────────────────────────┘
optional: Access app on /authorize* only ──▶ "log in with Cloudflare" consentQuickstart (5 minutes, local)
git clone https://github.com/ierhgfiuoergerg/mcp-oauth-hosting.git && cd mcp-oauth-hosting
python3 -m venv .venv && ./.venv/bin/pip install -r requirements.txt
# Then run it. Note: give the env vars to the *server* process — a `VAR=x cmd1 && cmd2`
# prefix would only reach cmd1, and the server would exit with "MCP_TOKEN is required".
export MCP_TOKEN=$(openssl rand -hex 32)
export KB_DIR=./examples/kb
export MCP_PUBLIC_BASE=http://127.0.0.1:8081
export HOST=127.0.0.1 PORT=8081
./.venv/bin/python mcp_server.pycurl -s localhost:8081/health # {"ok":true,"docs":2,...}
curl -si -X POST localhost:8081/mcp | head -3 # 401 + resource_metadata
bash tests/run_all.sh # 61 assertions, all offlineThen put it on the internet (TLS is mandatory for every real client):
sudo bash deploy/install.sh # /opt/mcp-server + systemd (127.0.0.1 only)
$EDITOR /etc/mcp-server.env # MCP_PUBLIC_BASE, MCP_ALLOWED_HOSTS
cloudflared tunnel create mcp # or ngrok / nginx + certbot
ZONE=example.com SUB=mcp TUNNEL_ID=<uuid> bash scripts/add_cname.sh
python3 scripts/selftest_oauth.py https://mcp.example.comPaste https://mcp.example.com/mcp into the client. It discovers, registers, opens the consent
page, gets its token and calls your tools.
Related MCP server: mcp-server-llm-wiki
Keyless consent with Cloudflare Access (optional)
Typing a secret into a consent page is still "a secret in a browser". Instead, scope an Access
application to /authorize* only:
ACC=<account_id> HOST=mcp.example.com bash scripts/provision_cf_access_app.sh
# prints MCP_CF_ACCESS_TEAM / MCP_CF_ACCESS_AUD -> put them in /etc/mcp-server.env, restart/authorize is then behind Cloudflare's own SSO (identity verified server-side with the
Cf-Access-Jwt-Assertion RS256 signature), so the consent page shows "Authenticated via
Cloudflare Access: you@example.com → Authorize" with no key field at all. Everything else —
/mcp, /token, /.well-known/* — is deliberately outside Access: non-browser clients cannot
complete an interactive login page, and wrapping them is what breaks the connection.
The provisioning script probes for exactly that mistake and rolls itself back if /mcp stops
answering with your own 401.
Optional: let clients write — into one folder, and only that folder
Set KB_WRITE_MODE=inbox and the server additionally exposes submit_doc(title, content, tags, filename).
Useful when a phone-side agent should be able to drop a note into your vault, but you do not want an
LLM-writable filesystem.
Three independent defences, any one of which alone would stop traversal:
No directory parameter exists. The tool's signature has no path argument at all; the destination is a server-side constant (
KB_DIR+KB_INBOX_DIR). There is nothing for a client to point elsewhere.The filename is sanitised.
/and\become-; control and shell-ish characters are collapsed;.., leading/trailing dots and length are handled; empty input becomesuntitled. Invariant, for *any* input: no/, no\, no.., not./.., non-empty, ≤ 80 chars.The resolved path is asserted to sit directly inside the inbox (
target.parent != INBOX_DIR⇒ refuse).
Plus: existing files are never overwritten — a name collision appends a timestamp, so the original is kept. Writes invalidate the index cache, so submitted content is searchable immediately.
KB_WRITE_MODE=inbox KB_INBOX_DIR=inbox ./... mcp_server.pyWhat a client sends vs where it lands (from scripts/selftest_inbox.py):
| file actually written |
|
|
|
|
|
|
|
|
Frontmatter is configurable, because a submitted note has to land in your vault, and
vaults disagree about metadata. The defaults are generic (source: mcp, no type); map them onto
whatever your own linter validates:
KB_INBOX_TYPE=inbox KB_INBOX_SOURCE=ai KB_INBOX_STATUS=raw # e.g. a wiki requiring these valuesDefault is off — a server that cannot write cannot be talked into writing.
What you get
file | what it is |
| the OAuth 2.1 shim: discovery, DCR, consent page, PKCE token exchange, refresh, revoke. Optional Cloudflare Access identity verification. |
| minimal Streamable-HTTP MCP server: |
| hardened systemd install (DynamicUser, |
| creates the path-scoped Access app via API + safety probe + rollback. |
| proxied CNAME → |
| walks the whole flow a URL-only client walks; 14 assertions. |
| proves signature verification with a locally self-signed JWT (9 assertions, no Cloudflare needed). |
| self-contained (builds a throwaway KB, starts the server on a spare port, tears it down): 20 assertions covering write scoping, four traversal-shaped filenames, the sibling-prefix containment trap, credential exclusion, cache invalidation, configurable frontmatter. |
| unit test for the filename sanitiser, extracted from the real source via AST — 18 cases plus a hard invariant that must hold for every input. |
| all of the above, offline, one command. |
| the specs, the gotchas, and the security reasoning. |
| the same procedure packaged as an agent skill. |
Environment
See env.example for the annotated list. The important ones:
variable | meaning |
| the access key (64 hex). Required. |
| public https origin; enables the OAuth shim. |
| folder of markdown to expose. |
| host allow-list ( |
| separate throwaway key for |
| where clients/refresh tokens are stored (chmod 600). |
| enable keyless Cloudflare-Access consent. |
|
|
| the one folder |
| keep credential-shaped documents out of the index even though they live in |
| index size cap (2 MiB), write size cap (512 KiB), cache lifetime (3 s). |
| frontmatter written into submissions (defaults |
Things that will bite you
Containment checks: use
Path.is_relative_to(), neverstr.startswith()."/kb-evil/x.md".startswith("/kb")isTrue— a sibling directory whose name merely extendsKB_DIRwould be readable.scripts/selftest_inbox.pyasserts this case explicitly.Wrap
/authorize, never/mcp. A non-browser client hitting a Cloudflare login page dies.HTTP 421 "Misdirected Request"on a tunnel: the origin received the wrongHost/:authority(h2 → HTTP/1.1 rewrite). Fix the tunneloriginRequest(http2Origin,originServerName) — don't "fix" it by disabling TLS verification.Python's default UA gets blocked by Cloudflare on some zones; always test with a browser UA.
Follow-zero redirects (or you lose the 302 +
Location); response header keys are lowercase.JSON-only responses keep clients happy: return
application/jsonforPOST /mcpinstead of opening a server-sent-event stream that never ends.A key-IP allow-list token + IPv6 egress = Cloudflare error
9109. Force IPv4 for API calls.Rotate after exposure: if the key ever travelled through a URL, a ticket or a chat log, treat it as burned — edit the env file, restart, clients re-authorize once.
What this is not
Not an identity provider. The token the client receives is your static key, so there is no
per-user authorization, no scopes enforcement beyond what you build, and no audit trail beyond your
logs. If you need real multi-user access control, put a real IdP (or Cloudflare Access on every
path with service tokens) in front of it. See references/security.md.
MIT licensed.
This server cannot be deployed
Maintenance
Related MCP Connectors
Serve a folder of Markdown notes as an MCP server: hybrid search, reading, and sourced answers.
OAuth-protected, read-only-by-default MCP server for provenance-labeled QuillCaddie project memory.
Read-only MCP server for the OrchestKit docs: full-text search + Markdown fetch. No auth.
Streamable HTTP MCP server for Google Calendar and Sheets with OAuth login.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceTurns a git-backed markdown directory into an MCP-compatible knowledge server, enabling any MCP client to read, search, ingest, and maintain a persistent wiki that compounds across sessions.MIT
- AlicenseNot gradedqualityAmaintenanceA generic MCP server that turns a directory of Markdown wiki pages into a network-reachable knowledge base, queryable and updatable by any MCP-compatible client.MIT
- AlicenseAqualityAmaintenanceLocal MCP server for querying and maintaining a Markdown vault. Provides full-text search, backlinks, note retrieval, and optional confined write tools, without sending the whole vault to the client context.221,202 PyPIApache 2.0
- AlicenseNot gradedqualityCmaintenanceTurns any folder of Markdown agent skills into a local MCP server, exposing them as tools, prompts, and resources for MCP-compatible clients.MIT