Dev.to AI 🤖 Ai 👁 0 📖 6 min read

Shipping a remote MCP server with OAuth: what the clients actually check

Two weeks ago we put a remote MCP server in front of our reviews API. It lets Claude, Cursor, VS Code and other MCP clients read reviews from the 84 platforms the API covers, and publish a reply, with the user's approval

Two weeks ago we put a remote MCP server in front of our reviews API. It lets Claude, Cursor, VS Code and other MCP clients read reviews from the 84 platforms the API covers, and publish a reply, with the user's approval, on the ones whose API allows replies. (How few of them do is the subject of an earlier post.) The tools were the easy part, because each one forwards to a REST route we already had. Most of the work went into getting clients to connect without a pasted API key.

These are the things that mattered, in the order we ran into them.

1. The 401 is the start of the flow, not an error

A connector UI that supports OAuth doesn't know your login page. It finds it by calling the server with no token and reading the 401 response:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://api.prismareviews.com/.well-known/oauth-protected-resource/mcp", scope="reviews"
Access-Control-Expose-Headers: WWW-Authenticate

The resource_metadata URL (RFC 9728) points to a small JSON document that names the authorization server. From there the client reads /.well-known/oauth-authorization-server and gets the authorize, token, registration and revocation endpoints. Ours advertises only authorization_code with PKCE S256 and public clients (token_endpoint_auth_methods_supported: ["none"]), because that's all an MCP client needs.

Two things bit us:

  • The header has to be exposed to browsers. Web clients call the server from a browser, so without Access-Control-Expose-Headers the client never sees WWW-Authenticate and gives up.
  • Don't open the endpoint "to be helpful". We were asked to answer initialize and tools/list without auth, so that directories could list our tools. We didn't. A 200 on the first call is how a client decides that a server needs no login. If we had returned one, every connector would have skipped OAuth and then failed on the first real call. Instead we publish the tool list, with the input schemas, as a static server card at /.well-known/mcp/server-card.json. Directories read that; the MCP endpoint itself still answers 401.

2. Dynamic client registration, without a clients table

MCP clients usually register themselves (RFC 7591) before they start the OAuth flow, and there are many of them. We didn't want a table that grows by one row each time someone opens a new Cursor window. So the flow keeps no state on our side:

  • The client id is the registration itself. It holds the client's name and redirect URIs, signed with HMAC. When the id comes back, we check the signature and read the redirect URIs from it.
  • The authorization code is an encrypted grant. It lasts five minutes, needs PKCE S256 and works only once.
  • The access token is an ordinary API key named after the client. It appears in the user's key list as "Cursor (MCP)" or "Claude (MCP)", and revoking it there disconnects that client. The tools keep calling /v1 with a normal key, so the MCP layer doesn't need a second auth path.

Validating redirect URIs took more care than we expected. Desktop and CLI clients come back to http://localhost or 127.0.0.1 on a random port, and editors use their own schemes, such as cursor:// and vscode://. We allow https, loopback http and custom schemes. We reject plain http on the internet, URLs with a fragment, and javascript:, data:, file: and similar schemes.

3. Let the user choose test or live on the consent screen

The consent screen asks whether to connect Live (your real accounts) or Test (sample data from every platform). People who are only trying the connector don't need to connect a platform account first. It also gave the directory review a test account with data to look at, instead of an empty one.

4. Keep a header fallback for clients without OAuth

Claude Code, Cursor and VS Code configs take a header, and some people prefer an API key to OAuth. So the same URL also accepts Authorization: Bearer <key>. Some gateways keep Authorization for themselves, so the server also accepts X-API-Key. Claude Desktop needs mcp-remote to pass a header to a remote server, so the docs give that config in full.

5. Stateless JSON over Streamable HTTP

Our tools are request and response: list reviews, get stats, reply. So the server answers each POST with application/json, with no SSE stream and no session id. That runs well on serverless functions, and no client has needed more. We accept the 2025-06-18, 2025-03-26 and 2024-11-05 protocol versions, because clients upgrade at different speeds.

6. Tool annotations are part of the contract

We have ten tools. Seven only read, and they're marked readOnlyHint: true. reply_to_review is the one that matters:

annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true }

The reply is public and can replace an earlier one, so we mark it destructiveHint. Clients use that to ask the user before running the tool. openWorldHint is there because the text leaves our system and appears on the review platform.

Retries were the other risk. A reader pointed it out in the comments of an earlier post: on platforms where a reply can only be created, never edited, a retry after a dropped connection could publish the same text twice. The REST route under the tool accepts an Idempotency-Key header, so a repeated request with the same key gets the stored response instead of a second reply.

We also kept the tool descriptions plain: what the tool does and what it returns. They don't tell the model what to do next. It's easier to keep descriptions honest from the start than to rewrite them for a directory review.

7. A tool for the agent that created the account

This one is new since the first version of the server. Some of our tools can't finish a job without the account owner, such as create_connect_link, which returns a link the owner opens to connect a platform that needs a browser sign-in. But an agent that has created or opened an account for someone can still do the paperwork.

Every new account answers three sign-up questions: where the owner heard of us, which review platforms they need, and how many locations, apps or listings they have. complete_onboarding takes those answers over MCP and writes them through PUT /v1/onboarding, the same route the dashboard form uses:

annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }
  • platforms and locations_count are required. origin is optional. If the agent doesn't know where the owner heard of us, it leaves origin out and we record the source as an AI agent, or keep the answer the owner already gave. We'd rather store "unknown, came through an agent" than a guess.
  • It is idempotent. Calling it again corrects the answers, so a retry can't make a duplicate.
  • When the answer is "other", the schema asks for the name. origin_detail and platforms_other are required in that case, with a length limit.

It's a small tool, and it is the only one that changes the account rather than reading from a platform or publishing to one. That's why it has its own annotations instead of reusing the read-only set.

8. Listings

  • The official MCP Registry verifies a namespace through a key served from your domain. Ours is com.prismareviews/reviews, and we publish a new server.json version whenever the tools or the platform list change. It's on 1.0.5 now, and the description names the platform count ("... Viator and 79 more"). The older versions stay in the registry, flagged as not the latest.
  • Claude's connector directory lists the server. The review wanted a test account with real-looking data, which test mode covered. Any later change to the listing goes back to review, so we finished the listing text before submitting it.
  • Glama lets you claim a server through a file at /.well-known/glama.json, and shows whether the server responds.

What I'd do again

Start with the 401 and the metadata documents, and test them with a real connector UI before writing any tools. If discovery doesn't work, the tools never get called. Of the whole setup, only the redirect URI rules needed real security thought.

The server is at https://api.prismareviews.com/mcp. Setup for each client, the tool list and the API route behind each tool are in the docs: https://prismareviews.com/docs/mcp (there's also a shorter overview at https://prismareviews.com/reviews-mcp). If you run a remote MCP server and found that clients behave differently from this, I'd like to hear about it in the comments.

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.