DEV Community

Volkan Şark
Volkan Şark

Posted on Edited on

Shipping a remote MCP server with its own OAuth 2.1 server: notes from HireGPS

I'm the founder of HireGPS, a tool that reads a CV against a target role the way a recruiter would and explains the risks behind the score. This autumn we shipped a remote MCP server so people can use it from Claude and other MCP clients. It lives at https://mcp.hiregps.app/mcp and is listed in the official MCP Registry as app.hiregps/hiregps.

Getting the tools to work took a few days. Getting authorization right took most of the project. These are the decisions that mattered, including one design we threw away.

The design we threw away: reusing the app's login

Our web app already signs users in through a hosted auth provider. The obvious plan was to make that provider the MCP authorization server and let MCP clients receive the same kind of token the web app uses.

We dropped it after testing what that token could do. A web session token is built for the user sitting in front of the app. In our setup it could do anything the user can do, including account-level actions such as changing the password; we confirmed that in a test environment before deciding. Handing that to an AI client, which runs tool calls on the user's behalf and may log or cache what it receives, gives the client far more power than any of our tools need.

So we built a small, dedicated authorization server. It issues tokens that only work against the MCP server and only for the tools we expose. If one leaks, the damage is limited to what those tools can do, and the user can revoke it without touching their account.

If you take one thing from this post: an MCP token should be narrower than a user session, not a copy of it.

Discovery: the 401 that starts everything

An MCP client doesn't know where your authorization server is. The MCP authorization spec solves this with OAuth 2.0 Protected Resource Metadata (RFC 9728). MCP servers MUST implement it, and clients MUST use it to find the authorization server.

In practice the flow starts with a failed request. The client calls the MCP endpoint without a token, and the server answers with a 401 that points to its metadata. This is ours, verbatim:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="https://mcp.hiregps.app/.well-known/oauth-protected-resource/mcp"
Enter fullscreen mode Exit fullscreen mode

The metadata document names the authorization server, and the client then reads the authorization server's own metadata (RFC 8414) to find the authorize, token and registration endpoints. Most of our early bugs lived here: a trailing slash in one URL and not another, or metadata served at the root while the client looked under the path. The spec lists both well-known locations and the order clients try them in, so it's worth testing both.

Client registration: DCR today, Client ID Metadata Documents next

MCP clients and servers usually have no prior relationship. Someone adds your server URL to their client and expects it to work. The current spec (2025-11-25) lists three ways for a client to get a client_id: pre-registration, Client ID Metadata Documents, and Dynamic Client Registration (RFC 7591).

The ranking changed in that version. Servers and clients SHOULD support Client ID Metadata Documents, where the client_id is an HTTPS URL pointing to a JSON document the client hosts. Dynamic Client Registration is now a MAY, kept for backwards compatibility.

We shipped with Dynamic Client Registration because that's what the clients we tested supported at the time. Adding Client ID Metadata Documents is on our list.

An open registration endpoint is public attack surface, so we hardened it:

  • The endpoint is rate limited per IP at the proxy, and oversized registrations are rejected.
  • Redirect URIs must be HTTPS or a loopback address (plus one pinned exception for a desktop client), and the authorization endpoint checks them against the registered values by exact match, as the spec requires.
  • A registration that never completes an authorization is deleted automatically after a while.
  • The consent screen shows the client name and the redirect host, so a user can spot a client pretending to be something else. Clients we have not verified get an explicit warning on that screen.

A security researcher also sent us a report about the registration endpoint shortly after launch. Registration stayed open, because the clients people actually use depend on it, but that report is why the hardening above shipped when it did.

Tokens: short, rotating and stored as hashes

The token rules we ended up with:

  • PKCE with S256 is required. The spec requires clients to use PKCE, and our authorization server metadata advertises code_challenge_methods_supported so clients can check before starting.
  • Access tokens last 15 minutes. The spec says authorization servers SHOULD issue short-lived access tokens. Fifteen minutes keeps a leaked token from staying useful for long.
  • Refresh tokens rotate on every use. For public clients the spec makes rotation a MUST. If an already-used refresh token comes back, we treat it as stolen or copied and revoke the whole connection.
  • We store hashes, not tokens. The database keeps a hash of each token. A database leak alone doesn't give anyone a usable token.
  • Tokens are bound to the MCP server. Our tokens are opaque random strings that only exist in the MCP server's own store, the authorization server rejects any resource value (RFC 8707) other than the MCP server's canonical URL, and every request re-validates the token and its resource. The spec requires servers to reject tokens issued for other resources and forbids passing a client's token through to other APIs.

A tool that costs real money per call

Five of our six tools only read: list_analyses, get_analysis, get_rewrites, get_interview_kit and get_career_guidance. The sixth, analyze_resume, runs a new analysis. It calls language models, so each call costs money, and it counts against the user's monthly allowance.

We considered leaving it out. But a read-only server that can't create anything isn't very useful; people want to say "analyze this CV for this role" and get an answer. So we kept it and put guardrails around it:

  • Daily caps, per user. 5 analyses and 15 content generations per day, with a separate per-minute cap on reads. The caps are keyed to the user, not the token, so connecting a second client doesn't double them.
  • Telling the model. Our server instructions say that analyze_resume saves a new analysis and counts toward the user's monthly allowance, so it should only be called when the user asks for a new analysis. A model that knows a tool has a cost is less likely to call it "just to check."
  • Clear errors. When a cap is hit, the tool returns a plain message the model can relay, rather than a generic failure.

Publishing to the MCP Registry

The official MCP Registry uses reverse-domain names, so our entry is app.hiregps/hiregps. To claim that namespace you prove you control the domain. We used the HTTP option: serve a file under /.well-known/ on the domain and sign the publish request with the matching private key.

One practical lesson: that private key is what lets you update the entry later. Keep it in a password manager, not only on the laptop you published from.

What I'd do differently

  • Write the spec's MUST lines as tests first. Most of our bugs were discovery and metadata details the spec already spells out.
  • Decide the token's reach before writing code. The question "what can this token do if it leaks?" killed our first design. It would have been cheaper to ask it on day one.
  • Plan for Client ID Metadata Documents from the start. The spec moved toward them while we were building.

If you want to see the result from the user's side, our connect page shows how to add HireGPS to an MCP client. I'm happy to answer questions about the authorization setup in the comments.

Top comments (0)