{"slug": "mcp-for-tour-guides-building-a-remote-mcp-server-with-100-tools-and-oauth", "title": "MCP for tour guides: building a remote MCP server with ~100 tools and OAuth", "summary": "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.", "body_md": "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.\n\nMost 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.\n\nThis 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.\n\n`https://api.yournext.tours/api/mcp/guide`\n`tours.yournext/guide`\nThe 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.\n\nWith 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.\n\nThe MCP SDK client treats 405 as \"this server has no stream, carry on\". So the fix is an explicit 405 for GET and DELETE:\n\n```\n// Stateless Streamable HTTP: no SSE stream and no sessions.\n// The SDK client only treats 405 as \"no stream, fine\"; 404 surfaces as an error.\nfor (const method of [\"GET\", \"DELETE\"] as const) {\n  app.route({\n    method,\n    url: \"/api/mcp/guide\",\n    handler: async (_request, reply) =>\n      reply\n        .code(405)\n        .header(\"Allow\", \"POST\")\n        .send({ error: { code: \"METHOD_NOT_ALLOWED\", message: \"Use POST\" } }),\n  });\n}\n```\n\nClaude discovers your authorization server from the `WWW-Authenticate` header on a 401. A few details mattered:\n\n`scope` in the challenge. Without it the client requests everything in `scopes_supported`.\n\n``` js\nexport function unauthorizedChallenge(): string {\n  const parts = [\n    `resource_metadata=\"${headerSafe(resourceMetadataUrl())}\"`,\n    `scope=\"${headerSafe(OFFERED_SCOPES.join(\" \"))}\"`,\n  ];\n  return `Bearer ${parts.join(\", \")}`;\n}\n```\n\nOne 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.\n\nWe 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.\n\nGemini 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.\n\nThe 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.\n\nTwo related Hydra settings that cost us time:\n\n`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.\nClaude'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.\n\nThe 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:\n\n```\ntype WriteAnnotations = Required<\n  Pick<ToolAnnotations, \"destructiveHint\" | \"openWorldHint\">\n> &\n  Pick<ToolAnnotations, \"idempotentHint\">;\n\ninterface ReadTool extends ToolBase {\n  access?: \"read\";\n  annotations?: never; // read tools are always readOnly, no overrides\n}\n\ninterface WriteTool extends ToolBase {\n  access: \"write\";\n  annotations: WriteAnnotations; // no annotations, no compile\n}\n\ntype Tool = ReadTool | WriteTool;\n```\n\n`readOnlyHint` is derived from `access`, so the two can't disagree. Because types can be bypassed with a cast, a boot-time assert checks the same thing at runtime, and a golden test pins every tool's annotations.\n\nOur rule for `destructiveHint: true`: anything that deletes, overwrites or clears existing data, changes something public (publishing the website), sends something to other people, or invalidates a link that was already shared. Adding, reordering and duplicating are not destructive.\n\nEvery tool requires a scope, reads included. A key with only `tours:read` doesn't just get denied on write tools, it doesn't see them in `tools/list` at all. That cuts down on the model trying things it can't do.\n\nTwo decisions we'd make again:\n\n**Edits never notify guests.** `trips:write` can change a trip, its stops and the guest list, and it never sends a notification. Delay and cancellation announcements, which email guests, need a separate `trips:announce` scope. A model fixing ten stops in a row can't send ten emails, and there's no way to cancel a trip silently: cancelling means announcing it.\n\n**The guest list is masked by default.** Reading a trip roster sends personal data to a third-party LLM provider. Most questions (\"how many people joined?\") don't need it, so the default response is masked:\n\n```\nmaskName(\"Ahmet Yilmaz\");    // \"A*** Y***\"\nmaskEmail(\"ahmet@gmail.com\"); // \"***@gmail.com\"\nmaskPhone(\"+905551234567\");   // \"***4567\"\n```\n\nTo be clear, this isn't access control. A client with the roster scope can pass `full: true`. The point is that pulling full PII becomes an explicit choice that shows up in the audit log, not something that happens by accident.\n\nTools are filtered by scope, organization role and plan. When an organization tool was missing from the list, the model made up a reason for it. Now the server puts the account, organization, role, plan and granted scopes into the `instructions` field of the `initialize` response, along with a rule not to invent URLs (published sites only live at `<subdomain>.yournext.tours`). With that, the model can give the actual reason instead of guessing.\n\nAsked to \"build my company website\", a model filled every section with made-up content without asking a single question. The fix was to make the data tell the model what to ask. `get_website_overview` now returns an `assistantGuide` with rules and a list of what's missing:\n\n```\nexport interface IntakeQuestion {\n  topic: string;\n  ask: string;   // the question for the user, rephrased in their language\n  offer: string; // what to write, and where, once they answer\n}\n```\n\nThe first rule tells the model to interview the user two or three questions at a time and not to fill those topics itself. Others forbid inventing prices, licence numbers, reviews or addresses. Those texts go to the model, so they're in English; the model asks in the user's language.\n\nOur tool errors used to be free text like \"Tool error: validation.error\", which told the model nothing. Now every tool error uses the same JSON envelope as our HTTP API: `code`, `messageKey`, `message`, `details` and a `hint` that says where in the panel the user can fix it. For example, `details` lists what's missing before a website can be published. With that, the model can fix the problem or explain it to the user.\n\nIf you run a tour business or just want to see how it behaves:\n\nSetup guide: [https://yournext.tours/ai-assistant-integration/](https://yournext.tours/ai-assistant-integration/)\n\nIf your client breaks against it, or you've solved one of these differently, I'd like to hear about it in the comments.", "url": "https://wpnews.pro/news/mcp-for-tour-guides-building-a-remote-mcp-server-with-100-tools-and-oauth", "canonical_source": "https://dev.to/fpintern/mcp-for-tour-guides-building-a-remote-mcp-server-with-100-tools-and-oauth-5gob", "published_at": "2026-10-03 10:30:05+00:00", "updated_at": "2026-10-03 10:37:59.918203+00:00", "lang": "en", "topics": ["ai-agents", "agent-protocols", "ai-tools", "developer-tools"], "entities": ["Your Next Tours", "Claude", "ChatGPT", "Cursor", "Gemini CLI", "Hydra", "Fastify", "Postgres"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/mcp-for-tour-guides-building-a-remote-mcp-server-with-100-tools-and-oauth", "markdown": "https://wpnews.pro/news/mcp-for-tour-guides-building-a-remote-mcp-server-with-100-tools-and-oauth.md", "text": "https://wpnews.pro/news/mcp-for-tour-guides-building-a-remote-mcp-server-with-100-tools-and-oauth.txt", "jsonld": "https://wpnews.pro/news/mcp-for-tour-guides-building-a-remote-mcp-server-with-100-tools-and-oauth.jsonld"}}