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

> Source: <https://dev.to/fransopa/shipping-a-remote-mcp-server-with-oauth-what-the-clients-actually-check-314n>
> Published: 2026-11-12 11:40:44+00:00

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](https://dev.to/fransopa/state-of-review-platform-apis-in-2026-only-8-of-32-let-you-reply-16-have-no-api-at-all-4dnb).) 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.

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:

`Access-Control-Expose-Headers` the client never sees `WWW-Authenticate` and gives up.`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.
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:

`/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.

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.

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.

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.

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.

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

`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.`/.well-known/glama.json`, and shows whether the server responds.
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](https://prismareviews.com/docs/mcp) (there's also a shorter overview at [https://prismareviews.com/reviews-mcp](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.
