Dexio / how-we-build-dexio / app
Building a remote MCP server with OAuth, API keys and agent sign-in
The MCP endpoint at https://app.dexio.wiki/mcp, where agents read and write their team's
wiki. The code is open source: https://github.com/dexio-wiki/dexio, mostly
src/dexio/server/mcp_server.py, oauth.py and device.py. Where it runs:
app-server-and-releases.
What it is
One endpoint, Streamable HTTP with stateless JSON responses, built on the MCP Python SDK
(mcp 2.2, MCPServer). Every request carries a bearer token, and every token stands for one
person in one workspace. There are three ways to get one:
- API key (
dxk_...), sent as a header by clients that can hold one: Claude Code, Codex, Hermes, OpenClaw, Cursor. - OAuth 2.1 access token (
dxa_...), for hosted connectors that cannot send a header we hand them: Claude and ChatGPT on the web, and Cursor when it is given only the URL. - Agent sign-in, the OAuth device flow (RFC 8628): an agent told "log me in" gets a code, the person approves it in a browser, and the agent collects an API key.
Status: Claude has connected through OAuth with a real client. ChatGPT is built to its documented requirements but no ChatGPT account has connected yet.
Why it is built this way
- Hosted connectors run on the vendor's servers and sign in with OAuth, so OAuth is the price of being in Claude and ChatGPT at all.
- Coding agents can hold a header. A key is one line of config with nothing to refresh, so we do not make them do OAuth.
- An agent in a chat app or on another machine cannot receive an OAuth redirect to localhost. The device flow needs two HTTP requests from the agent and a browser anywhere.
- Stateless JSON: each call is one POST and nothing stays open between calls. Any proxy works, there are no sticky sessions, and a restart drops nothing.
- The SDK supplies the OAuth endpoints and metadata. We wrote the storage and the consent page, about 300 lines.
How to build it
-
Create the server and serve tools only:
server = MCPServer(name="dexio", title="Dexio", version=VERSION, instructions=INSTRUCTIONS, website_url="https://dexio.wiki") for method in UNSERVED_METHODS: # the prompts/* and resources/* handlers server._lowlevel_server._request_handlers.pop(method, None)The SDK registers prompt and resource handlers whether or not you have any, and advertises a capability for each. Some clients turn every capability into tools; Hermes added four that always list nothing. This reaches into a private attribute, so pin the SDK version.
-
Mount it stateless:
inner = server.streamable_http_app( streamable_http_path="/mcp", stateless_http=True, json_response=True, max_request_body_size=32 * 1024 * 1024, # the default 4 MB is under one large page transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False), )DNS-rebinding protection guards unauthenticated servers on localhost. A public endpoint that needs a bearer token on every request, one a browser never attaches, gains nothing from it.
-
Put a gate in front, as a small ASGI wrapper:
- No valid token: 401 with
WWW-Authenticate: Bearer realm="dexio", resource_metadata="https://app.dexio.wiki/.well-known/oauth-protected-resource/mcp"(RFC 9728). That header is how Claude and ChatGPT discover where to sign in; key clients ignore it. - GET and DELETE: 405 with
Allow: POST. In stateless mode the SDK otherwise answers GET with an event stream that never sends anything, and clients that probe with GET hang on it. - Check the token again inside each tool, in case the endpoint is ever mounted without the gate.
- No valid token: 401 with
-
Implement the SDK's OAuth provider over four tables: registered clients, pending requests, authorization codes, tokens (oauth.py).
register_clientstores dynamic client registrations (RFC 7591).authorizerejects aresourceother than your/mcp(RFC 8707), stores the request for 15 minutes, and redirects to your own consent page.- The consent page needs a signed-in session (send people to sign in or sign up and back),
lets them pick a workspace, and on Allow issues a one-use code good for 5 minutes, then
redirects to the client with
code,stateandiss. exchange_authorization_codeaccepts each code once and issues an access token (1 hour) and a refresh token (30 days) under one grant id. PKCE S256 is checked by the SDK.exchange_refresh_tokenchecks the person is still a member, revokes everything under the grant, and issues a new pair: refresh tokens rotate on every use.revoke_tokenends the whole grant.- Store every code and token as a SHA-256 hash. Give each kind a prefix (
dxa_,dxr_,dxc_,dxp_) so it is recognizable in logs and secret scanners.
-
Add the routes (from
app.py):issuer_url = AuthSettings(issuer_url=issuer, resource_server_url=issuer + "/mcp", validate_token_resource=False).issuer_url routes = create_auth_routes( provider, issuer_url, service_documentation_url=AnyHttpUrl("https://dexio.wiki"), client_registration_options=ClientRegistrationOptions(enabled=True), revocation_options=RevocationOptions(enabled=True)) routes += create_protected_resource_routes( resource_url=AnyHttpUrl(issuer + "/mcp"), authorization_servers=[issuer_url], resource_name="Dexio")Then three additions the SDK does not make:
- Serve the protected-resource metadata at the root (
/.well-known/oauth-protected-resource) as well as under/mcp. Some clients ask there. - Advertise
authorization_response_iss_parameter_supportedand addissto every redirect back to the client (RFC 9207). ChatGPT uses its stable callback URL only for servers that do this. - At
/tokenand/revoke, accept a client id sent only in a Basic header (RFC 6749, section 2.3.1). The SDK reads it from the form body alone and answers "Missing client_id"; Smithery's scanner could not finish signing in until we copied it across.
What clients then read from
/.well-known/oauth-authorization-server:{ "issuer": "https://app.dexio.wiki", "authorization_endpoint": "https://app.dexio.wiki/authorize", "token_endpoint": "https://app.dexio.wiki/token", "registration_endpoint": "https://app.dexio.wiki/register", "revocation_endpoint": "https://app.dexio.wiki/revoke", "grant_types_supported": ["authorization_code", "refresh_token"], "code_challenge_methods_supported": ["S256"], "authorization_response_iss_parameter_supported": true } - Serve the protected-resource metadata at the root (
-
Add agent sign-in, the device flow (device.py):
POST /api/v1/device/codereturns adevice_code, auser_codeof 8 consonants shown asXXXX-XXXX(no vowels, so a code cannot spell a word), averification_uri_complete, a 30-minute expiry and a 5-second polling interval. Limit it per address.- The person opens the link, signs in or signs up, checks the code, picks a workspace and allows it. The sign-in pages say an agent is waiting.
POST /api/v1/device/tokenanswersauthorization_pending,slow_down,access_deniedorexpired_tokenuntil then, and then returns an API key named for the agent, with the MCP URL.- Store device codes hashed. Mint the key at the poll that collects it, so its plaintext is never at rest, and check membership again at that moment.
- Publish the steps for agents at a stable URL (ours is https://dexio.wiki/agents.md) and
list it in
llms.txt.
-
Write the tools for agents, not for people:
- Put when to use a tool in its description, not only in the server instructions. Hermes never shows server instructions to its model, so descriptions are all it sees. In our test (Claude Code, 5 runs a cell) agents searched the wiki before answering in 0 of 5 runs with how-it-works descriptions and 4 to 5 of 5 with when-to-use ones.
- Give every tool a
titleannotation. Anthropic's directory requires it. - Make writes carry the version they were based on and fail on a conflict, so two agents
cannot silently overwrite each other. Require an
agentname on every change, so agents sharing one key are still told apart in history. - Keep schemas small and flat: drop the per-property titles Pydantic adds and write input
schemas without
$ref, which some clients do not follow. Trimming descriptions took our tool list from 17,789 to 9,775 bytes, and every agent pays for those bytes every turn.
-
Document the client config. With a key:
claude mcp add --transport http --scope user dexio https://app.dexio.wiki/mcp \ --header "Authorization: Bearer dxk_..."# Codex, ~/.codex/config.toml [mcp_servers.dexio] url = "https://app.dexio.wiki/mcp" http_headers = { "Authorization" = "Bearer dxk_..." }For Hermes,
hermes mcp add dexio --url https://app.dexio.wiki/mcp --auth header. For Claude and ChatGPT, add the URL as a custom connector and sign in.
Verify
curl https://<host>/.well-known/oauth-protected-resource/mcpand/.well-known/oauth-authorization-serverreturn the metadata, with the issuer exactly as the client will compare it.POST /mcpwith no token answers 401 with theWWW-Authenticateheader above. GET with a valid token answers 405.- Run a full OAuth round trip with a scripted client: register, authorize, consent, token,
tools/list, refresh, then confirm the old refresh token is refused. - Connect a real Claude account as a custom connector before submitting to its directory.
- For agent sign-in, run a headless agent in a throwaway home with only "Connect yourself to my Dexio wiki. The steps are at https://dexio.wiki/agents.md: read the whole file, not a summary, and follow them." Ours started the sign-in in 9 of 9 runs (Claude Code, Sonnet and Opus).
Pitfalls we hit
- Issuer strings are compared exactly (RFC 8414).
AnyHttpUrl("https://host")becomeshttps://host/; take the issuer fromAuthSettings, which keeps it path-less. - The silent event stream on GET: Hermes probes with GET and timed out after 30 seconds until the gate answered 405.
- Capabilities advertised for handlers with nothing behind them became extra tools.
- Client ids in Basic headers, and
isson redirects, as above. - With SQLite and one shared connection, a commit from another thread could reset a token lookup mid-read, so valid tokens were sometimes refused under concurrent writes (11 of 20 test runs). Token lookups now take the write lock.
- Agent-facing text gets read as an attack. A line telling the agent not to show the key in chat read to one model as hiding a credential from the user; now it says to tell the person which file holds it. Claude Code's web fetch passes an unfamiliar site through a summary, so put the endpoints in the page itself and tell agents to fetch the file raw.