DEV Community

Tolga Cakular
Tolga Cakular

Posted on

Four things that bit us shipping a remote MCP server with OAuth

We recently shipped a remote MCP server so people can verify email addresses from inside Claude, ChatGPT and Cursor. (Disclosure: I work on ClearBounce, the service behind it.) The protocol itself is pleasant to implement. What cost us time was everything around it — the parts that are not in the spec, or are in the spec but that clients and directories disagree about.

Here are four of them, with what we actually observed.

1. Echo the client's protocol version. Do not force your own.

Our initialize handler proudly returned the newest protocol version we supported. Claude Code refused to connect:

Server's protocol version is not supported: 2026-07-28
Enter fullscreen mode Exit fullscreen mode

The client sends the version it wants in params.protocolVersion. If you answer with something it has never heard of, it bails — even when your version is newer. The fix is to accept any sane date-shaped version the client asks for and echo it back:

const SUPPORTED = ['2026-07-28', '2025-11-25', '2025-06-18', '2025-03-26'];

function negotiateProtocolVersion(requested) {
  if (typeof requested === 'string' && /^20\d{2}-\d{2}-\d{2}$/.test(requested)) {
    return requested;
  }
  return SUPPORTED[0];
}
Enter fullscreen mode Exit fullscreen mode

Think of it as content negotiation, not as advertising your capabilities.

2. Return 401, not 403 — and accept that one directory will call you unhealthy for it

For OAuth-protected servers, the discovery trigger is an unauthenticated request answered with 401 plus a WWW-Authenticate header pointing at your protected-resource metadata:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource/mcp"
Enter fullscreen mode Exit fullscreen mode

This is what makes claude.ai and ChatGPT offer to connect an account instead of just failing. Return 403 and the client has nothing to discover.

We learned this the hard way in the other direction. An earlier version exposed an anonymous tools/list as a friendly "here is what I can do" response. Because nothing ever returned 401, no client ever started the OAuth flow. Removing the anonymous showcase fixed it.

The irony: after doing the correct thing, one directory's health checker connects without credentials, receives our correct 401, and marks the server Unhealthy on its public listing. Meanwhile another directory's scanner documents the opposite requirement — its docs explicitly say servers should return 401 rather than 403 so OAuth can be discovered — and passed us cleanly.

Same behaviour, two verdicts. Do the spec-correct thing, and be ready to explain the red badge.

3. Instrument the endpoint, or you are flying blind

We added a small table that records one row per JSON-RPC request: method, tool name, client name, auth type, ok/error, duration. Fire-and-forget, so a failed insert can never break a tool call.

The first day of data was not what we expected. Roughly 200 unauthenticated initialize calls, from clients we had never heard of:

mcpbeat 0.1                  every ~15 minutes
smithery-probe
agent-tools.cloud 0.1
zdi-well-wirer 1.0
agentic-resource-search 0.4
trimtab-verifier 0.1
glama-mcp-inspector 1.0.0
Enter fullscreen mode Exit fullscreen mode

We did not submit to most of these. There is an active crawler ecosystem discovering MCP endpoints on its own, and it is far busier than the human traffic. Someone also called this/method/does/not/exist, presumably checking whether our error handling was sane.

One practical detail: MCP is stateless, so only initialize carries clientInfo. Every other request would be anonymous. We fall back to the first token of the User-Agent and keep it raw rather than mapping it to friendly product names — a guess would quietly produce wrong statistics. That is how we know a real call came from claude-code/2.1.231 rather than "probably Claude".

If you are adding OAuth, also put the client id into the access token as a claim. Then every later call tells you which client it came from without any session state.

4. Cursor's one-click install link is broken below 3.15.12

We added the usual install deeplink to our docs page:

cursor://anysphere.cursor-deeplink/mcp/install?name=NAME&config=BASE64_CONFIG
Enter fullscreen mode Exit fullscreen mode

where the base64 payload for a remote server is simply:

{ "url": "https://example.com/mcp" }
Enter fullscreen mode Exit fullscreen mode

On an older Cursor build, clicking it brings the app to the front, opens the settings pane, and shows no install card at all. Nothing is added; nothing errors. It looks like your link is malformed.

It is not. There is a bug report on Cursor's own forum describing exactly this, and a team member confirming it was fixed in 3.15.12+. If you ship one of these buttons, add a line telling people on older builds to add the server URL manually — otherwise the silent failure reads as your bug.

The small stuff that also helped

  • Add outputSchema and the readOnlyHint / destructiveHint / idempotentHint / openWorldHint annotations to every tool. Directory scanners read them, and reviewers notice when they are missing.
  • resources/list and prompts/list will be probed even if you never declared those capabilities. Returning -32601 is spec-legal, but it shows up as a warning in scanner logs.
  • If a tool costs the user money, say so in the tool description and tell the model to ask first. Ours returns a candidate list plus an explicit note about the per-check cost, and instructs the assistant to verify one at a time and stop at the first good result. Models follow that instruction well, and it is the difference between a useful tool and one that burns a stranger's credits.

None of this is hard once you know it. It just is not written down in one place, which is why I wrote it down.

Top comments (1)

Collapse
 
mads_hansen_27b33ebfee4c9 profile image
Mads Hansen

Very useful field notes. One important correction to the version example: the server should not echo every date-shaped value. It must echo the requested version only when it actually supports that version; otherwise it should return another version it does support, normally its latest. In the sample, SUPPORTED is declared but never consulted, so a future or fabricated date would make the server claim semantics it may not implement.

I would make negotiation table-driven and test three cases: supported legacy version is echoed, unsupported version returns the server's chosen supported fallback, and subsequent HTTP requests with an invalid or unsupported MCP-Protocol-Version are rejected. Also log requested and negotiated versions separately; that makes client compatibility failures much easier to diagnose.

The crawler observation is excellent too. It suggests treating initialize and OAuth discovery as an internet-facing attack surface: rate-limit by behavior, cap request bodies, and keep discovery telemetry separate from authenticated tool usage so scanner traffic does not distort product metrics.