<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: FP Intern</title>
    <description>The latest articles on DEV Community by FP Intern (@fpintern).</description>
    <link>https://dev.to/fpintern</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4137386%2Fa0992098-6fd4-46f8-be0a-6db4e7a5a5b4.png</url>
      <title>DEV Community: FP Intern</title>
      <link>https://dev.to/fpintern</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/fpintern"/>
    <language>en</language>
    <item>
      <title>MCP for tour guides: building a remote MCP server with ~100 tools and OAuth</title>
      <dc:creator>FP Intern</dc:creator>
      <pubDate>Sat, 03 Oct 2026 10:30:05 +0000</pubDate>
      <link>https://dev.to/fpintern/mcp-for-tour-guides-building-a-remote-mcp-server-with-100-tools-and-oauth-5gob</link>
      <guid>https://dev.to/fpintern/mcp-for-tour-guides-building-a-remote-mcp-server-with-100-tools-and-oauth-5gob</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2qrwr8v7zy60s8yme2ao.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2qrwr8v7zy60s8yme2ao.png" alt=" " width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/IMAGE_URL" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/IMAGE_URL" alt="MCP for tour guides overview" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Endpoint: &lt;code&gt;https://api.yournext.tours/api/mcp/guide&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Transport: Streamable HTTP, stateless (no sessions, so it runs fine on a PM2 cluster)&lt;/li&gt;
&lt;li&gt;Auth: OAuth 2.1 + PKCE for claude.ai and ChatGPT, or a personal API key for clients that only send headers&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;Registry name: &lt;code&gt;tours.yournext/guide&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;About 100 tools, each behind a scope&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Answer GET with 405, not 404
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Stateless Streamable HTTP: no SSE stream and no sessions.&lt;/span&gt;
&lt;span class="c1"&gt;// The SDK client only treats 405 as "no stream, fine"; 404 surfaces as an error.&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DELETE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/mcp/guide&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nx"&gt;reply&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;405&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Allow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;METHOD_NOT_ALLOWED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Use POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. The 401 has to be a real 401
&lt;/h2&gt;

&lt;p&gt;Claude discovers your authorization server from the &lt;code&gt;WWW-Authenticate&lt;/code&gt; header on a 401. A few details mattered:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The status code must be 401. The same header on a 200 is ignored.&lt;/li&gt;
&lt;li&gt;Include &lt;code&gt;scope&lt;/code&gt; in the challenge. Without it the client requests everything in &lt;code&gt;scopes_supported&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;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".
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;unauthorizedChallenge&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s2"&gt;`resource_metadata="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;headerSafe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;resourceMetadataUrl&lt;/span&gt;&lt;span class="p"&gt;())}&lt;/span&gt;&lt;span class="s2"&gt;"`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`scope="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;headerSafe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;OFFERED_SCOPES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))}&lt;/span&gt;&lt;span class="s2"&gt;"`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One more: validate the token's &lt;code&gt;sub&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Some clients take scopes from the authorization server, not from you
&lt;/h2&gt;

&lt;p&gt;We serve RFC 9728 resource metadata with a deliberately narrow &lt;code&gt;scopes_supported&lt;/code&gt;: read/write for tours, content, trips and the website. Scopes that expose guest PII or send email to guests are left out on purpose.&lt;/p&gt;

&lt;p&gt;Gemini CLI ignored that document. It built its scope request from &lt;code&gt;scopes_supported&lt;/code&gt; in the authorization server's &lt;code&gt;openid-configuration&lt;/code&gt;. Hydra's default there is roughly &lt;code&gt;openid offline offline_access&lt;/code&gt;, so the consent screen came up with nothing to grant and no usable token was issued.&lt;/p&gt;

&lt;p&gt;The fix is configuration, not code: keep the AS's advertised scopes identical to your resource metadata (plus &lt;code&gt;offline_access&lt;/code&gt;). 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.&lt;/p&gt;

&lt;p&gt;Two related Hydra settings that cost us time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;With &lt;code&gt;strategies.scope: exact&lt;/code&gt;, a dynamically registered client that didn't pass scopes at registration gets locked to the default set, and later asks for &lt;code&gt;website:write&lt;/code&gt; fail with &lt;code&gt;invalid_scope&lt;/code&gt;. Set &lt;code&gt;oidc.dynamic_client_registration.default_scope&lt;/code&gt; to the full catalog.&lt;/li&gt;
&lt;li&gt;Hydra doesn't serve &lt;code&gt;/.well-known/oauth-authorization-server&lt;/code&gt; (RFC 8414). SDK clients fall back to OIDC discovery, but a strict RFC 8414 client won't. We proxy that path to &lt;code&gt;openid-configuration&lt;/code&gt; in nginx.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. Make write tools declare their annotations
&lt;/h2&gt;

&lt;p&gt;Claude's and ChatGPT's directory reviews check &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;readOnlyHint&lt;/code&gt; and &lt;code&gt;destructiveHint&lt;/code&gt; on every tool, and clients use them to decide when to ask for confirmation.&lt;/p&gt;

&lt;p&gt;The spec's default for a missing &lt;code&gt;destructiveHint&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt;. Our first version filled in &lt;code&gt;false&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;WriteAnnotations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Required&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;
  &lt;span class="nb"&gt;Pick&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ToolAnnotations&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;destructiveHint&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;openWorldHint&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;
  &lt;span class="nb"&gt;Pick&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ToolAnnotations&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;idempotentHint&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ReadTool&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;ToolBase&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;access&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;read&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;annotations&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;never&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// read tools are always readOnly, no overrides&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;WriteTool&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;ToolBase&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;access&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;write&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;annotations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WriteAnnotations&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// no annotations, no compile&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Tool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ReadTool&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;WriteTool&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;readOnlyHint&lt;/code&gt; is derived from &lt;code&gt;access&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;Our rule for &lt;code&gt;destructiveHint: true&lt;/code&gt;: 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Scopes per tool, including reads
&lt;/h2&gt;

&lt;p&gt;Every tool requires a scope, reads included. A key with only &lt;code&gt;tours:read&lt;/code&gt; doesn't just get denied on write tools, it doesn't see them in &lt;code&gt;tools/list&lt;/code&gt; at all. That cuts down on the model trying things it can't do.&lt;/p&gt;

&lt;p&gt;Two decisions we'd make again:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Edits never notify guests.&lt;/strong&gt; &lt;code&gt;trips:write&lt;/code&gt; 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 &lt;code&gt;trips:announce&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The guest list is masked by default.&lt;/strong&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;maskName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Ahmet Yilmaz&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;    &lt;span class="c1"&gt;// "A*** Y***"&lt;/span&gt;
&lt;span class="nf"&gt;maskEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ahmet@gmail.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "***@gmail.com"&lt;/span&gt;
&lt;span class="nf"&gt;maskPhone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;+905551234567&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// "***4567"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To be clear, this isn't access control. A client with the roster scope can pass &lt;code&gt;full: true&lt;/code&gt;. The point is that pulling full PII becomes an explicit choice that shows up in the audit log, not something that happens by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Tell the model who it's talking to
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;instructions&lt;/code&gt; field of the &lt;code&gt;initialize&lt;/code&gt; response, along with a rule not to invent URLs (published sites only live at &lt;code&gt;&amp;lt;subdomain&amp;gt;.yournext.tours&lt;/code&gt;). With that, the model can give the actual reason instead of guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Models fill empty fields with fiction
&lt;/h2&gt;

&lt;p&gt;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. &lt;code&gt;get_website_overview&lt;/code&gt; now returns an &lt;code&gt;assistantGuide&lt;/code&gt; with rules and a list of what's missing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;IntakeQuestion&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// the question for the user, rephrased in their language&lt;/span&gt;
  &lt;span class="nl"&gt;offer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// what to write, and where, once they answer&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Errors the model can act on
&lt;/h2&gt;

&lt;p&gt;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: &lt;code&gt;code&lt;/code&gt;, &lt;code&gt;messageKey&lt;/code&gt;, &lt;code&gt;message&lt;/code&gt;, &lt;code&gt;details&lt;/code&gt; and a &lt;code&gt;hint&lt;/code&gt; that says where in the panel the user can fix it. For example, &lt;code&gt;details&lt;/code&gt; lists what's missing before a website can be published. With that, the model can fix the problem or explain it to the user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;If you run a tour business or just want to see how it behaves:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add a custom connector in Claude or ChatGPT (ChatGPT currently needs a paid plan with Developer mode on).&lt;/li&gt;
&lt;li&gt;Paste &lt;code&gt;https://api.yournext.tours/api/mcp/guide&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Sign in and choose the scopes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Setup guide: &lt;a href="https://yournext.tours/ai-assistant-integration/" rel="noopener noreferrer"&gt;https://yournext.tours/ai-assistant-integration/&lt;/a&gt;&lt;/p&gt;

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

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>oauth</category>
      <category>typescript</category>
    </item>
    <item>
      <title>How we built a verified dataset of 198 tour group headset rules</title>
      <dc:creator>FP Intern</dc:creator>
      <pubDate>Fri, 02 Oct 2026 12:38:39 +0000</pubDate>
      <link>https://dev.to/fpintern/how-we-built-a-verified-dataset-of-198-tour-group-headset-rules-5h42</link>
      <guid>https://dev.to/fpintern/how-we-built-a-verified-dataset-of-198-tour-group-headset-rules-5h42</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbl92uyi7ttgohibunvil.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbl92uyi7ttgohibunvil.png" alt=" " width="800" height="400"&gt;&lt;/a&gt;&lt;br&gt;
Museums and historic city centres keep adding rules about how a tour guide may talk to a group. Some require headsets above a certain group size, some ban loudspeakers, and some do not let an outside guide explain anything indoors. The rules live in PDFs, booking terms and city ordinances, in a dozen languages, and nobody had put them in one table.&lt;/p&gt;

&lt;p&gt;We work on a small app for tour guides (Your Next Tours), so we needed that table anyway. This post is about how we built it, what the schema looks like, and the mistakes we tried to avoid. The result is open under CC BY 4.0 on &lt;a href="https://huggingface.co/datasets/first-point/tour-group-headset-rules" rel="noopener noreferrer"&gt;Hugging Face&lt;/a&gt; and &lt;a href="https://www.kaggle.com/datasets/felixton/tour-group-headset-and-loudspeaker-rules" rel="noopener noreferrer"&gt;Kaggle&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Start wide, then throw things away
&lt;/h2&gt;

&lt;p&gt;The first pass was a survey of 228 candidate rules. That list was useful for finding places, but it was not trustworthy. Some entries came from travel blogs, some quoted an old version of a rule, a few were simply wrong.&lt;/p&gt;

&lt;p&gt;So the second pass had one job: for every candidate, open the official source again and find the quoted sentence in it. We split the list into batches and let LLM agents do the opening and searching, with one strict instruction: if the sentence is not in the official text, the record is marked &lt;code&gt;not-found&lt;/code&gt; and does not become a rule. No search engines, only the institution's own site, its PDFs, or an archived copy of that page.&lt;/p&gt;

&lt;p&gt;Each checked record came back as a small JSON object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fr-musee-du-louvre"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"class"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"A"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"threshold"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"thresholdCountsGuide"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"devicePolicy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"own-allowed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"in-force"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"quote"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"quoteLang"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fr"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sourceUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"verifiedAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-02"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"verdict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"confirmed"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A build script then keeps a record only if the verdict is &lt;code&gt;confirmed&lt;/code&gt; or &lt;code&gt;changed&lt;/code&gt;, the class is A, B or C, the rule is in force or formally proposed, the source is https and the quote is not empty. Everything else is dropped. In the end 197 records passed, plus one Turkish rule (the National Palaces in Istanbul) that we had already documented separately, so the dataset has 198 rows from 36 countries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three classes, because "headset rule" means three different things
&lt;/h2&gt;

&lt;p&gt;Reading the texts side by side, they fell into three groups:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A, headsets required.&lt;/strong&gt; A written rule says guided groups must use headsets or a whisper system (66 rules).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;B, loudspeakers banned.&lt;/strong&gt; No megaphones or amplification. Headsets are not mandatory, but with a big group they are the practical way to be heard (75 rules).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;C, no outside guiding inside.&lt;/strong&gt; Only the site's own guides or audio guides may narrate indoors (57 rules).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keeping B separate from A mattered. Many city ordinances only ban amplification, and lumping them in with headset requirements would have inflated the headline number.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fields that took the longest to decide
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;threshold&lt;/code&gt;.&lt;/strong&gt; Texts say "more than 10", "10 or more", "groups of 5 to 30". We store the smallest group size the rule applies to, so "more than 8" becomes 9. An empty value means every group. Where a number exists, it ranges from 3 to 26, and the most common values are 7 and 11.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;threshold_counts_guide&lt;/code&gt;.&lt;/strong&gt; Some texts count the guide, most say nothing. We store &lt;code&gt;true&lt;/code&gt;, &lt;code&gt;false&lt;/code&gt; or empty, and empty means "the text does not say". We did not guess.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;device_policy&lt;/code&gt;.&lt;/strong&gt; This is the field people care about most, and the one easiest to get wrong. The values are &lt;code&gt;own-allowed&lt;/code&gt;, &lt;code&gt;unspecified&lt;/code&gt;, &lt;code&gt;institution-only&lt;/code&gt;, &lt;code&gt;app-banned&lt;/code&gt;, &lt;code&gt;phone-banned&lt;/code&gt; and &lt;code&gt;headphones-banned&lt;/code&gt;. 132 of the 198 rules are &lt;code&gt;unspecified&lt;/code&gt;: the text asks for headsets but does not say whose. That does not mean any device is accepted, and the dataset card says so in plain words. Only 39 rules explicitly allow a group's own system.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;quote&lt;/code&gt; and &lt;code&gt;source_url&lt;/code&gt;.&lt;/strong&gt; Every row carries the verbatim sentence in its original language and the official link, so anyone can check our reading. Rights to the quoted text stay with the issuing body.&lt;/p&gt;

&lt;h2&gt;
  
  
  Loading it
&lt;/h2&gt;

&lt;p&gt;The CSV is a single file, so plain pandas is enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;

&lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://huggingface.co/datasets/first-point/tour-group-headset-rules&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
       &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/resolve/main/data/tour-group-headset-rules-198-20261002.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                       &lt;span class="c1"&gt;# (198, 19)
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rule_class&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;value_counts&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="c1"&gt;# Rules with a group-size threshold, smallest first
&lt;/span&gt;&lt;span class="n"&gt;cut&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dropna&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;threshold&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;sort_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;threshold&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cut&lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;place&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;country&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;threshold&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;device_policy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]].&lt;/span&gt;&lt;span class="nf"&gt;head&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is also a JSONL file with the same rows and typed values, which is easier to drop into a RAG index.&lt;/p&gt;

&lt;h2&gt;
  
  
  One source, three outputs
&lt;/h2&gt;

&lt;p&gt;The same JSON fact packs feed three things: the human-readable list at &lt;a href="https://yournext.tours/tour-group-headset-rules/" rel="noopener noreferrer"&gt;yournext.tours/tour-group-headset-rules&lt;/a&gt;, the Hugging Face dataset and the Kaggle dataset. Each row's &lt;code&gt;page_url&lt;/code&gt; points to its anchor on that page, so a row like &lt;code&gt;fr-musee-du-louvre&lt;/code&gt; links straight to the Louvre entry. Generating all three from one place is the only way we found to keep the numbers from drifting apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we would do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Write the dropping rules before collecting anything. We decided late that a quote must be found word for word, and some early candidates that "felt right" did not survive that test.&lt;/li&gt;
&lt;li&gt;Store &lt;code&gt;effective_from&lt;/code&gt; as a separate field from the start. 38 of the rules took effect in 2024 or later, and one more (canal boats in Bruges) starts in 2029. That trend was the most interesting finding, and it was almost invisible in the first draft.&lt;/li&gt;
&lt;li&gt;Plan for updates. Rules change; we will re-check the sources every quarter and publish a new snapshot.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you know a rule we missed or read wrong, the comments here are a good place for it. We would rather fix a row than defend it.&lt;/p&gt;

</description>
      <category>opendata</category>
      <category>datascience</category>
      <category>python</category>
      <category>travel</category>
    </item>
  </channel>
</rss>
