MCP for tour guides: building a remote MCP server with ~100 tools and OAuth The team behind Your Next Tours, a live-audio app for tour guides, built a remote MCP server exposing roughly 100 tools with OAuth so guides can connect their own Claude, ChatGPT or Cursor accounts and have the model build tour programs from uploaded PDF itineraries. The writeup details fixes for stateless Streamable HTTP (returning 405 rather than 404 for GET/DELETE), OAuth discovery and scope configuration in Hydra, and tool annotations such as readOnlyHint and destructiveHint. The server deliberately narrows scopes_supported to exclude guest PII and email-sending capabilities, and shifts LLM inference cost onto the user's own subscription. I work on Your Next Tours, an app tour guides use to broadcast live audio to their group from their phone. Guests scan a QR code and listen in the browser. Before a tour, guides prepare a lot of material in our panel: day-by-day programs, stops, info cards, guest lists, sometimes a small company website. Most of that material already exists somewhere else, usually as a PDF itinerary. So we built an MCP server that lets a guide connect their own Claude, ChatGPT or Cursor to their account. The guide drops the PDF into the chat, and the model builds the program through tools instead of the guide retyping it. This post covers how the server is set up and the problems we ran into, with the code we ended up with. Most of it applies to any remote MCP server with real user data behind it. https://api.yournext.tours/api/mcp/guide tours.yournext/guide The LLM cost is on the user's own subscription, which is part of why this was worth doing for us: our in-app assistant runs on our bill, this one doesn't. With a stateless server there is no SSE stream, so we had no GET handler and Fastify returned 404. Cursor and Gemini CLI showed that as "Failed to open SSE stream" and treated the server as broken. The MCP SDK client treats 405 as "this server has no stream, carry on". So the fix is an explicit 405 for GET and DELETE: // Stateless Streamable HTTP: no SSE stream and no sessions. // The SDK client only treats 405 as "no stream, fine"; 404 surfaces as an error. for const method of "GET", "DELETE" as const { app.route { method, url: "/api/mcp/guide", handler: async request, reply = reply .code 405 .header "Allow", "POST" .send { error: { code: "METHOD NOT ALLOWED", message: "Use POST" } } , } ; } Claude discovers your authorization server from the WWW-Authenticate header on a 401. A few details mattered: scope in the challenge. Without it the client requests everything in scopes supported . js export function unauthorizedChallenge : string { const parts = resource metadata="${headerSafe resourceMetadataUrl }" , scope="${headerSafe OFFERED SCOPES.join " " }" , ; return Bearer ${parts.join ", " } ; } One more: validate the token's sub before it reaches the database. Hydra subjects are free text. A malformed one reached Postgres as a UUID cast error, came back as a 400, and since the client never saw a 401 it never started re-authorization. We serve RFC 9728 resource metadata with a deliberately narrow scopes supported : read/write for tours, content, trips and the website. Scopes that expose guest PII or send email to guests are left out on purpose. Gemini CLI ignored that document. It built its scope request from scopes supported in the authorization server's openid-configuration . Hydra's default there is roughly openid offline offline access , so the consent screen came up with nothing to grant and no usable token was issued. The fix is configuration, not code: keep the AS's advertised scopes identical to your resource metadata plus offline access . Don't advertise the full catalog either. Consent screens usually pre-check every requested scope, so the PII scopes would be one click away from going to the LLM. Two related Hydra settings that cost us time: strategies.scope: exact , a dynamically registered client that didn't pass scopes at registration gets locked to the default set, and later asks for website:write fail with invalid scope . Set oidc.dynamic client registration.default scope to the full catalog. /.well-known/oauth-authorization-server RFC 8414 . SDK clients fall back to OIDC discovery, but a strict RFC 8414 client won't. We proxy that path to openid-configuration in nginx. Claude's and ChatGPT's directory reviews check title , readOnlyHint and destructiveHint on every tool, and clients use them to decide when to ask for confirmation. The spec's default for a missing destructiveHint is true . Our first version filled in false when it was missing, which meant a forgotten annotation quietly marked a delete tool as harmless. We changed it so that forgetting one fails to compile: type WriteAnnotations = Required< Pick