Building a production MCP server with OAuth 2.1: lessons from shipping one
What it took to let customers connect Claude and ChatGPT to their own data safely: OAuth 2.1 with PKCE, dynamic client registration, scoped tools, live permissions and rate limits per connection.

The Model Context Protocol (MCP) lets AI assistants like Claude and ChatGPT call tools in your product on a user's behalf. A local MCP server for your own laptop is a weekend project. A remote one that real customers connect to their business data is mostly an authentication and authorization project with a little JSON-RPC on top.
This is what I learned shipping Krofile's MCP server: 12 tools that let a customer ask their assistant things like "who are my newest leads?" or "mark this lead as contacted". You can see the product page at krofile.com/products/mcp.
Isolation by absence
The first design decision mattered most. The MCP router mounts the OAuth endpoints and one tool-dispatch endpoint, and nothing else. There's no route from it to card creation, billing or team management. They aren't blocked by a filter; they simply don't exist on that surface.
A filter is a list someone has to keep correct forever. An absent route can't be misconfigured. The tools call the same internal services the HTTP controllers use; they never proxy the HTTP API, which would quietly put the whole route table back within reach.
OAuth discovery: the step that fails silently
MCP clients discover how to authenticate from metadata documents (RFC 9728 for the protected resource, RFC 8414 for the authorization server). We initially served them at the wrong path:
# Wrong: path first
https://api.example.com/mcp/.well-known/oauth-protected-resource
# Right: well-known first, resource path appended
https://api.example.com/.well-known/oauth-protected-resource/mcpEverything else worked. Registration answered correctly when called directly. But a client that can't find the metadata never learns that registration exists, so it reports "couldn't register with the sign-in service". The error names the last step, not the missing one. If your connector fails at registration, check discovery first.
Dynamic client registration and PKCE
Assistants register themselves as OAuth clients at runtime (RFC 7591), so you can't pre-provision client IDs. Every flow uses PKCE with S256. Two details saved us real debugging time:
- `/token` must accept form encoding. OAuth clients post
application/x-www-form-urlencoded. Our JSON endpoints use a JSON parser, and one parser configured for both rejects one of them, so the MCP routes get their own parsers. - No redirects in front of discovery. A 3xx drops the
Authorizationheader on the hop, and the target then returns 401 with nothing to explain why.
Consent is a fresh sign-in, not a cookie
The authorize step deliberately doesn't accept an existing session as consent. It hands off to the main app, where the customer signs in again (so two-factor applies) and ticks the scopes they want to grant: leads:read, leads:write, leads:export, analytics:read. A connector can never exceed that grant.
Return a real 401, not an error message
This one surprises people. If your tool returns 200 with an error message like "please re-authenticate", the model reads the text and carries on. Only an HTTP 401 with a WWW-Authenticate header pointing at your resource metadata makes the client start the OAuth flow again.
res
.status(401)
.set(
"WWW-Authenticate",
`Bearer resource_metadata="${issuer}/.well-known/oauth-protected-resource/mcp"`
)
.end();Permissions resolved live, on every call
The access token says who is asking and which workspace. It doesn't decide what they may do. On every request we look up the member's current role, so a teammate who is demoted or removed loses access on their next call, not when the token expires. If they've been removed entirely, the connection is revoked on the spot so the stale credential stops existing.
Rate-limit per connection, not per IP
Assistant traffic comes from shared egress ranges. An IP-based limit lets one busy customer throttle everyone else and lets an abusive one hide in the crowd. The connection ID is the only key that identifies who's actually calling, so limits apply per connection, and every tool call is written to the customer's audit log in plain language ("an assistant listed your leads").
Production MCP server checklist
- Expose only the MCP surface. Absent routes beat filtered ones.
- Serve discovery documents well-known first, with no redirects.
- Support dynamic client registration and PKCE S256.
- Accept form-encoded bodies on
/token. - Make consent a fresh sign-in with explicit scopes.
- Return a real 401 with
WWW-Authenticateto trigger re-auth. - Resolve roles live per request, and revoke on removal.
- Rate-limit and audit per connection.
MCP was one part of the Krofile V2 rebuild; the case study covers the rest of the AI platform.