DexioView only
Sign inMake a copy

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:

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

How to build it

  1. 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.

  2. 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.

  3. 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.
  4. Implement the SDK's OAuth provider over four tables: registered clients, pending requests, authorization codes, tokens (oauth.py).

    • register_client stores dynamic client registrations (RFC 7591).
    • authorize rejects a resource other 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, state and iss.
    • exchange_authorization_code accepts 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_token checks the person is still a member, revokes everything under the grant, and issues a new pair: refresh tokens rotate on every use.
    • revoke_token ends 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.
  5. 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_supported and add iss to every redirect back to the client (RFC 9207). ChatGPT uses its stable callback URL only for servers that do this.
    • At /token and /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
    }
    
  6. Add agent sign-in, the device flow (device.py):

    • POST /api/v1/device/code returns a device_code, a user_code of 8 consonants shown as XXXX-XXXX (no vowels, so a code cannot spell a word), a verification_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/token answers authorization_pending, slow_down, access_denied or expired_token until 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.
  7. 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 title annotation. 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 agent name 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.
  8. 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

Pitfalls we hit