DEV Community

FP Intern
FP Intern

Posted on

MCP for tour guides: building a remote MCP server with ~100 tools and OAuth

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.

MCP for tour guides overview

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.

The setup

  • Endpoint: https://api.yournext.tours/api/mcp/guide
  • Transport: Streamable HTTP, stateless (no sessions, so it runs fine on a PM2 cluster)
  • Auth: OAuth 2.1 + PKCE for claude.ai and ChatGPT, or a personal API key for clients that only send headers
  • Authorization server: self-hosted Ory Hydra. Hydra delegates login and consent back to our existing account system, so passwords, Google/Apple sign-in and 2FA stay where they were. We decided early not to write our own OAuth server.
  • Registry name: tours.yournext/guide
  • About 100 tools, each behind a scope

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.

1. Answer GET with 405, not 404

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" } }),
  });
}
Enter fullscreen mode Exit fullscreen mode

2. The 401 has to be a real 401

Claude discovers your authorization server from the WWW-Authenticate header on a 401. A few details mattered:

  • The status code must be 401. The same header on a 200 is ignored.
  • Include scope in the challenge. Without it the client requests everything in scopes_supported.
  • Only send the challenge when OAuth is actually configured. Pointing a client at a metadata document you don't serve makes it fail with "server unreachable".
export function unauthorizedChallenge(): string {
  const parts = [
    `resource_metadata="${headerSafe(resourceMetadataUrl())}"`,
    `scope="${headerSafe(OFFERED_SCOPES.join(" "))}"`,
  ];
  return `Bearer ${parts.join(", ")}`;
}
Enter fullscreen mode Exit fullscreen mode

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.

3. Some clients take scopes from the authorization server, not from you

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:

  • With 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.
  • Hydra doesn't serve /.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.

4. Make write tools declare their annotations

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<ToolAnnotations, "destructiveHint" | "openWorldHint">
> &
  Pick<ToolAnnotations, "idempotentHint">;

interface ReadTool extends ToolBase {
  access?: "read";
  annotations?: never; // read tools are always readOnly, no overrides
}

interface WriteTool extends ToolBase {
  access: "write";
  annotations: WriteAnnotations; // no annotations, no compile
}

type Tool = ReadTool | WriteTool;
Enter fullscreen mode Exit fullscreen mode

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.

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

5. Scopes per tool, including reads

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

Two decisions we'd make again:

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.

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:

maskName("Ahmet Yilmaz");    // "A*** Y***"
maskEmail("ahmet@gmail.com"); // "***@gmail.com"
maskPhone("+905551234567");   // "***4567"
Enter fullscreen mode Exit fullscreen mode

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

6. Tell the model who it's talking to

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

7. Models fill empty fields with fiction

Asked 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:

export interface IntakeQuestion {
  topic: string;
  ask: string;   // the question for the user, rephrased in their language
  offer: string; // what to write, and where, once they answer
}
Enter fullscreen mode Exit fullscreen mode

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

8. Errors the model can act on

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

Try it

If you run a tour business or just want to see how it behaves:

  1. Add a custom connector in Claude or ChatGPT (ChatGPT currently needs a paid plan with Developer mode on).
  2. Paste https://api.yournext.tours/api/mcp/guide.
  3. Sign in and choose the scopes.

Setup guide: https://yournext.tours/ai-assistant-integration/

If your client breaks against it, or you've solved one of these differently, I'd like to hear about it in the comments.

Top comments (0)