An AI Surface Should Not Have Its Own Backend

02/09/2026

by Hai Bui

An AI Surface Should Not Have Its Own Backend

Our colleague created AI travel planner: Travolp, shipped 115 MCP tools inside Claude and ChatGPT with no new backend. The lessons: filter what the model sees, strip what a chat log should never hold, and make consent match capability

An AI Surface Should Not Have Its Own Backend

There is a pattern in teams putting their product inside Claude or ChatGPT: the integration grows a backend of its own. New endpoints, new permission checks, a second opinion about who is allowed to do what. It starts as glue and ends as a liability, because two authorization paths never stay in agreement for long.

Hai Bui, our colleague created the AI travel planner Travolp, shipped the counter-example this summer. Travolp now runs entirely from inside Claude and ChatGPT: plan a trip, split the costs with a group, pick a hotel, buy an eSIM, without opening the app. That is 115 tools on the Model Context Protocol, three interactive widgets rendering inside the chat, one registration serving both hosts. And no new backend behind any of it. What the build actually teaches is where the effort went, because almost none of it went where you would expect.

Every tool is a call into the API that already existed

Here is one complete tool, the one that fetches a trip. The snippets in this post are trimmed and lightly renamed from the production code, but every mechanism shown is the one that ships:

async (args, ctx) =>
  tripView(await apiFetch(ctx, { path: "/api/v1/trips/" + args.tripId }))

Each MCP tool calls a REST endpoint Travolp already had, as the calling user, with the caller's own token:

const url = new URL(call.path, ctx.origin);
return fetch(url, {
  method: call.method ?? "GET",
  headers: {
    Authorization: "Bearer " + ctx.bearer,
    "X-Created-Via": "mcp",
  },
});

Membership, tenancy, and rate limits apply unchanged, because nothing new sits beside them. The only backend change the whole project required was letting the auth layer accept an OAuth access token alongside the first-party session token. A permission rule changed once applies everywhere. The AI path cannot quietly disagree with the product, because there is no separate AI path.

The temptation this removes is real. Teams assume the AI surface will need something special, and they build for that assumption before it is tested. Across 115 tools, the special case never arrived.

mcp-architecture-diagram.png

Most of the engineering was subtraction

The tool list is filtered per caller. A travel agency admin sees tour authoring, CRM and storefront tools; a traveller sees none of that, and a model shown 48 irrelevant tools picks worse ones. The filter is two lines:

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

The interesting part is the failure mode, how currentAudience() ends:

} catch {
  // visibility only; real authorization happens downstream
  return "staff";
}

It fails open, to the full list, and that is correct, because it is presentation, not security. Every call still hits the real authorization stack downstream. A second flag in the same codebase, the one deciding whether responses get the PII scrub, fails closed, because that one is security. Knowing which of your mechanisms are load bearing for safety and which are user experience, and letting them fail in opposite directions, is half the design.

Responses get the same treatment. Anything a model sees can end up in a screenshotted group chat, so every response passes through an allow-list projection that strips what a chat log should never hold: emails hiding inside display-name fields, hotel booking references, credentials such as eSIM activation codes. The sharpest scrub is three lines, masking emails by value rather than by field name, because the leak was never a field called email; it was an email hiding inside a field called name:

function safeName(value) {
  if (!value) return null;
  return value.includes("@") ? null : value;
}

Two endpoints were removed from the AI surface entirely rather than scrubbed, one carrying supplier rates, one carrying buyer records. Removal beats redaction. A filter can regress in a refactor; an absent tool cannot.

The prompt is part of the API

The behaviour that matters most lives in the tool descriptions, each one written after watching a model get something wrong. Three of them, trimmed from the live definitions:

"Create a BRAND-NEW trip. Do NOT call this to change a trip that
 already exists; find it with list_trips and edit it in place.
 Calling this for an edit leaves a duplicate trip."

"EVERY call delivers another real email. Do not retry to make sure.
 429 means it was resent too often in the past hour: wait."

"This card stays live and updates itself after edits, so do not
 call this again to re-show a trip that is already on screen."

The important rules are stated twice, once in the server-wide instructions and again on the individual tool, because different hosts weight the two differently. Descriptions are not documentation. They are behaviour control, and they are the cheapest fix available: you do not get them right by thinking hard, you get them by watching the model and writing the sentence that stops it.

Consent has to match capability

The sharpest lesson came from security review, not feature work. The OAuth consent screen listed read permissions while write tools still ran, so what the user approved and what the connector could do were two different sentences. The fix makes writes require their own separately approved permission:

function writeScopeViolation(isWrite, grantedScopes, requiredScope) {
  if (!isWrite || !requiredScope) return null;
  if (grantedScopes.includes(requiredScope)) return null;
  return error(403, "Reconnect and approve the write permission");
}

What feeds isWrite is the detail worth copying: the read-only annotation every tool already declares, because chat hosts use it for their own confirmation prompts. One flag, two consumers, no second list to drift out of sync:

writeScopeViolation(tool.annotations.readOnlyHint !== true, scopes, WRITE_SCOPE)

And it ships behind a switch, because turning it on flips every existing connector to read-only until its owner reconnects: that is a migration, not a deploy. This is the standard we hold AI systems to in our own delivery work: what the user approved should be inspectable against what the system can actually do.

Try it

The result takes about thirty seconds to feel. In Claude or ChatGPT, add a custom connector with this URL, no account needed:

https://travolp.com/api/public/mcp

Ask what tours are running in Vietnam, or for a three day plan for Lisbon. The answers come from the real product, not the model's imagination.

Screenshot 2026-09-01 at 10.48.31.png
Screenshot 2026-09-01 at 10.48.43.png
Screenshot 2026-09-01 at 12.06.58.png

The connector is the demo. The product is the app: Travolp for iOS and Android builds the itinerary and then travels with you, with the live day plan, re-planning when you are running late, shared group expenses, and eSIM data packs in one place. Get it for iOS or Android, or start at on Web.

We work with Hai on projects like this. Contact us.