JOURNAL · SEPTEMBER 1, 2026

putting Travolp inside Claude and ChatGPT: how we built the MCP server

Two MCP servers, 115 tools, and widgets that render in the chat, built as a thin facade over the API we already trust. The decisions that made it tractable, and the security lesson we did not go looking for.

Published
September 1, 2026
Author
Priorli
Tags
mcp, ai-integration, architecture, security

Travolp is our AI travel planner: it builds an itinerary, then travels with you. This year we exposed the whole product over the Model Context Protocol, so a traveller can plan a trip, split expenses, book a hotel and buy an eSIM from inside Claude or ChatGPT, and an agency can run its storefront, CRM and tour catalogue from the same chat window.

The result, as of August 2026: two MCP servers (one OAuth gated, one public), 115 authenticated tools plus 12 public ones, three interactive widgets that render inside the chat, and about 10,500 lines of MCP-layer code. It works in Claude and ChatGPT from a single registration.

This post is about the decisions that made that tractable. The position we ended up with, and will defend here: your MCP server should be a thin facade over the API you already trust, and most of the real engineering is subtraction. What you strip from responses, what you hide from the tool list, and what you refuse to render matters more than what you add.

Architecture diagram: Claude and ChatGPT call a deliberately thin MCP layer, which calls the existing REST API over loopback with the caller's own token. No parallel authorization path.

the decision that made everything else cheap

We did not write a second backend. Every MCP tool calls an existing /api/v1/* REST endpoint over loopback HTTP, forwarding the caller’s OAuth bearer token:

const headers: Record<string, string> = {
  Authorization: `Bearer ${ctx.bearer}`,
  Accept: "application/json",
  // Creation-provenance marker: routes that record Trip.createdVia read
  // this to distinguish connector-created rows from manual creates.
  "X-Travolp-Created-Via": "mcp",
};

A complete tool is therefore one line, the endpoint call plus a response projection (covered below). This is get_trip, verbatim:

async (args, ctx) =>
  tripView(await apiFetch(ctx, { path: `/api/v1/trips/${enc(args.tripId)}` }), ctx.origin),

That one choice means there is no parallel authorization path. Membership checks, role checks, tenancy scoping and AI metering all live in the REST routes, and they apply to MCP calls unchanged. The only backend change the whole project required was making the auth helper accept an OAuth access token alongside a first-party session token. Everything below it stayed identical.

The loopback call costs a few milliseconds per tool invocation. We consider that a bargain for never having to answer “did the MCP path check the same things the API does?” The answer is structural: it is the API.

transport in 61 lines

The authenticated entry point is a single Next.js route file. We use mcp-handler for streamable HTTP and turned SSE off:

// We run a long-lived Node server, so streamable HTTP works
// statelessly without a Redis-backed session store.
const mcpHandler = createMcpHandler(
  (server) => { registerTravolpTools(server); },
  { serverInfo: { name: "travolp", version: "1.0.0" },
    instructions: TRAVOLP_MCP_INSTRUCTIONS },
  { basePath: "/api", disableSse: true, maxDuration: 300 },
);

Statelessness turned out to be load bearing, not a shortcut. The handler re-runs the entire tool registration on every request. That sounds wasteful until you realize it is what makes per-request tool filtering possible at all: the tool list a caller sees can depend on who they are, computed fresh each time, with no session store to invalidate.

OAuth without writing an OAuth server

We used Clerk as the OAuth 2.1 authorization server (PKCE, dynamic client registration) and kept our side to two jobs: publish discovery metadata and verify tokens. Two production realities cost us real time and are worth writing down.

First, if you run behind a platform proxy, the resource value you advertise in the protected-resource metadata gets derived from the request URL, and the request URL inside the proxy is your internal bind address. Strict clients validate token audience against that value (RFC 9728 and RFC 8707), so you must rebuild the request from the forwarded public host before answering.

Second, clients do not discover metadata the same way. Claude follows the WWW-Authenticate: resource_metadata pointer your 401 returns. ChatGPT instead derives the path-inserted form from the server URL itself, inserting the well-known prefix before the path (RFC 9728 section 3.1). If you only serve the root document, ChatGPT fails with a generic “problem connecting” and nothing in your logs tells you why. Serve both shapes.

115 tools is a haystack, not a menu

A travel agency admin needs tour authoring, CRM, bulk import and storefront tools. A traveller needs none of that, and a model shown 48 irrelevant tools picks worse ones. So the advertised tool list is role aware: one indexed membership read per request, cached 60 seconds, keyed by user id rather than by token because OAuth tokens rotate. The result travels to the registration code through AsyncLocalStorage, and enforcement is two lines at the end of the tool wrapper:

if (config.audience === "staff" && currentToolAudience() === "consumer") {
  tool.disable();
}

The part worth copying is the failure mode. The resolver fails open, to the full list. That reads backwards for anything security shaped, and it is correct here precisely because this mechanism is not security: it is presentation. Every call still hits the REST authorization stack downstream, so a wrong audience can never grant access. It can only show a tourist some tools that would 403. Getting crisp about which mechanisms are load bearing for security (fail closed) and which are UX (fail open) is half the design.

one tool layer, two front doors

The same 115 tools also power the in-app agency assistant. Rather than registering them twice, the assistant hands the registration function a Proxy standing in for an MCP server, and records what gets registered instead of serving it:

const stub = () => undefined;
const server = new Proxy({} as McpServer, {
  get: (_target, prop) => (prop === "registerTool" ? registerTool : stub),
});

One tool layer, two front doors, and they cannot drift apart. Each call carries a surface marker, connector or dashboard, and unlike the audience above this one fails closed to the scrubbed connector surface. Only the in-process collector can stamp dashboard. We deliberately never key that decision off the OAuth client id, because dynamic client registration lets any client name itself anything.

serializers, not rows

Every authenticated tool returns the caller’s own authorized data. That is not the end of the analysis, because the data is handed to a third-party model and can end up in a screenshotted, shared chat. So the MCP layer never returns an API payload as is. Each tool goes through an allow-list projection that keeps what the model functionally needs and drops the rest: participant emails (including the display-name field that falls back to an email, masked by value, not by key), airline booking references, encoded route polylines, internal user and tenant ids, cost and margin fields.

Two staff endpoints did not survive the audit at all. Supplier records (contacts plus confidential net rates) and the agency-wide booking list (buyer identity plus payment data) were removed from the MCP surface entirely rather than scrubbed. Removal beats redaction: a serializer can regress, an absent tool cannot.

the prompt is part of the API

The least glamorous file in the module might be the most important one: about a hundred lines of server instructions the host feeds to the model at initialize, plus tool descriptions that carry behaviour, not just parameters. Three examples that each trace back to a real incident:

  • create_trip says, in its own description, that it is for brand-new trips only, and that editing an existing trip means list_trips then edit in place. Before that sentence, models asked to change a trip would sometimes create a duplicate.
  • Invite tools warn that every call sends a real email, so a model must never retry one on a timeout.
  • get_trip tells the model its card stays live and updates itself, so do not call it again to re-show a trip. Before that, hosts stacked one persistent card per call and conversations filled with them.

If you think of tool descriptions as documentation, you will write them for humans and debug model behaviour forever. They are behaviour control, and they are the cheapest fix you have.

tools that return UI

For results that deserve better than JSON, we ship three widgets on the MCP Apps standard (io.modelcontextprotocol/ui): the trip itinerary with inline editing and a live generating state, hotel picks per city with a choose-then-book flow, and our eSIM catalogue with a data meter. The tool metadata carries both the standard keys and the openai/* aliases, so one registration renders in both hosts.

The build is deliberately primitive. esbuild bundles each widget (React included) to a single string, inlines the CSS, and emits one self-contained HTML document per widget, because the host sandbox blocks external scripts entirely. The resource URI embeds a content hash, ui://travolp/trip.<hash>.html, because hosts cache widget templates by URI and a stable URI serves a stale bundle after a deploy. Image hosts must be listed in the widget CSP, and an unlisted host does not error; it renders a silently blank card. And anything minted per use, like a single-use booking link or a payment URL, goes straight to the host’s link opener and never into widget state.

The judgment call that matters more than the mechanics: most tools should not render. A skeleton card after create_trip is immediately buried by the next call. We render three read surfaces and keep every action tool as plain data.

The sharpest lesson came from security review rather than feature work. With open dynamic client registration, any client can register and request the basic identity scopes. Our API accepted a valid token without inspecting scopes, so the granted scope did not bound capability: a connector a user approved believing it read their profile could, in principle, drive every write tool as that user. The consent screen was truthful about identity and silent about capability.

The fix is a gate in the tool wrapper that requires a separately consented write scope for any tool not marked read only:

if (!isWrite || !requiredScope) return null;
if (authInfo?.extra?.surface === "dashboard") return null;
if ((authInfo?.scopes ?? []).includes(requiredScope)) return null;
return new McpApiError("WRITE_SCOPE_REQUIRED", ..., 403);

Two details generalize. The read/write split reuses each tool’s existing readOnlyHint annotation, the same one hosts read to decide on confirmation prompts, so there is no second list to fall out of sync. And the rejection is written for the end user, telling them to reconnect the connector and approve the write permission, because the model will paraphrase whatever you return.

The gate ships behind a config flag rather than enforcing on deploy. Turning it on flips every already-connected client to read-only until its owner reconnects and approves the new scope, so enforcement is a scheduled migration with comms, not a side effect of a release.

what we would do differently

We would loosen output schemas from day one. Declaring result envelopes helps client models navigate structured output, but a strict inner type is a liability: one field declared as a list of objects while the endpoint returned a list of strings failed validation server side and took the tool down for every caller. Total outage, not degradation. Envelopes strict, bodies loose, every key optional.

We would also serve the path-inserted discovery routes before the first connect attempt, and write the behavioural tool descriptions before watching a model misuse the tool. Both are cheap in advance and expensive to learn live.


Priorli designs and builds production AI systems, including MCP servers like this one. If you want your product usable from Claude and ChatGPT, we can help: hello@priorli.com.