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

> Source: <https://dev.to/fpintern/mcp-for-tour-guides-building-a-remote-mcp-server-with-100-tools-and-oauth-5gob>
> Published: 2026-10-03 10:30:05+00:00

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<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;
```

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

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"
```

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.

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.

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
}
```

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.

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.

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

Setup guide: [https://yournext.tours/ai-assistant-integration/](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.
