Case study Personal project
Passports MCP
A real OAuth-authenticated MCP connector for Passports, so Claude.ai can read my trips and journal entries and propose edits — through the app's own auth and row-level security, not a shortcut. Ten tools and counting.
01 — The Problem
An AI that could talk about my trips, but not touch them
Passports is a trip-planning and travel-diary app I built solo — bases, day-by-day items, journal entries, the works, all live at passports.chrisaug.com. I'd gotten comfortable pasting screenshots of my itinerary into Claude and asking it for ideas. That worked, but every suggestion meant me manually typing it back into the app. The AI could think about my trips. It couldn't act on them.
What I actually wanted was for Claude.ai itself to read my real trip data — including past trips and journal entries, to learn what I actually like versus what I said I'd like — and then suggest or make changes directly in the app, the same way I would by hand.
That's a different problem than "call an API." Passports is a real multi-user app with sign-up, shared trips, and row-level security in Supabase. Whatever connected Claude to it had to act as the actual signed-in user, not as some superuser credential filtering by an owner id after the fact — because a shared secret would mean any connected user could see or edit anyone else's trips.
02 — My Approach
Real OAuth, not a shortcut, and a propose-then-confirm pattern for writes
MCP (Model Context Protocol) is the piece that makes this possible: it lets an AI assistant call named, schema-defined tools against an external system — nothing more than what a tool explicitly allows, no general database access. Claude.ai supports "custom connectors" that talk to a remote MCP server over HTTP. I built that server as an isolated mcp-server/ folder inside the Passports repo, deployed as a Netlify Function, with its own package.json — the one deliberate exception to the rest of the app staying dependency-free, hand-rolled HTML/CSS/JS with no build step.
The harder decision was authentication. Claude.ai's custom connectors only support real OAuth 2.0 or a static, org-wide shared token — there's no way for an individual user to just paste in a personal API key. So I built the real thing: Dynamic Client Registration, a genuine authorize/consent screen reusing the app's existing Supabase login, and a token exchange that captures the user's actual Supabase refresh token, encrypts it at rest (real AES-GCM, not base64), and rotates it correctly on every use. Every MCP request ends up resolving to a real Supabase session for a real user — the same row-level security policies that already protect the app in the browser are what protect it here too. No tool's code has to get authorization right; the database refuses the wrong rows regardless.
Writes got their own layer of caution on top of that. Claude.ai can call a tool without checking with me first, so I didn't want any tool that commits a change immediately. Instead, edits use a propose-then-confirm pattern: one tool validates a batch of changes and returns a plain-language summary — "Day 3: move dinner to 7pm, mark the museum visit confirmed" — plus a short-lived proposal id. Nothing touches the database until a second tool confirms that exact id, which gives Claude a natural moment to show me the diff and get a real yes. Proposals expire after 30 minutes and cap at 10 items, and every write — proposed or additive — is rate-limited per connection and logged to an audit table with a before/after snapshot, so a bad edit is trivially reversible by hand.
I shipped this in phases, the same way I built the Habits app: read-only tools first, then additive-only writes, then full edits, each one live and in real use before moving to the next.
03 — What I Shipped
Ten tools, two content domains, one real connector
The connector is live and I use it for real trip planning. It currently exposes ten tools across two areas of the app:
Day-by-day trip items — list_trips (every trip I own or belong to, with a derived planning/traveling/past status), get_trip (the full trip: bases, days, every item at any status), get_trip_journal (day- and item-level notes and photo URLs, so Claude can learn from what I actually wrote about past trips, not just what I booked), create_trip_item (adds a new idea to the master list — always as a draft idea, never anything already confirmed or reserved), and the propose_update_trip_item / confirm_update_trip_item pair for editing times, days, status, cost, or confirmation numbers on existing items.
Place-focused overview content — the history, culture, language, food-and-drink, and logistics notes that live on a trip or one of its bases, separate from the day-by-day plan. list_overview_blocks reads them; create_overview_block adds new ones (always as an unpublished draft unless I've explicitly said to publish); propose_update_overview_block / confirm_update_overview_block handle edits with the same summarize-first pattern as trip items.
All of it sits behind the same OAuth handshake, the same per-connection rate limit, and the same audit log — so from Claude.ai's side, planning a trip and asking "what did I write in my journal about Lisbon last time?" or "draft a food-and-drink blurb for our Kyoto base" are just tool calls, but on my side every one of them is a real, attributable, reversible action against my own data.
04 — Outcomes & Learnings
What changed — in the product and in me
I now genuinely plan trips this way: I'll ask Claude.ai to look at how I actually spent my time and what I journaled about on a past trip, and suggest ideas for an upcoming one, and it does — reading real data, not a description I typed in. Suggestions land as proposals I approve, not silent edits I have to audit later.
Building the auth layer was the real education here. I'd used OAuth as a user a thousand times without ever having to reason about DCR, PKCE, refresh-token rotation races, or why an authorization-server metadata document and a protected-resource metadata document are not interchangeable even though they look almost identical. Getting the .well-known discovery documents exactly right — separate uncached functions, correct path rules, real 404s instead of the app's SPA fallback — was the kind of detail that's invisible until it's wrong, and then it's the only thing that matters.
The propose-then-confirm pattern was the other big lesson, and it's one I now think about at work too: the risk in giving an AI a "write" tool usually isn't the database operation, it's whether the human actually saw the change before it happened. Building that confirmation step myself, for my own data, made the tradeoff concrete in a way that reading about "agentic AI safety" never did.
05 — The AI/Vibe Coding Angle
What this project proved about PM + AI
This is the project I'm most proud of out of everything I've vibe-coded, because it isn't a personal utility with an AI feature bolted on — it's infrastructure that lets an AI act as me, safely, inside something I built and actually use every day.
It's also the clearest example I have of what I think the PM role is becoming. Understanding MCP well enough to design the auth model, not just call an existing integration, is exactly the kind of technical range I think PMs need now. I didn't need to be the one implementing AES-GCM encryption or PKCE validation by hand — Claude Code did the actual coding — but I needed to understand what those things were protecting against well enough to insist on them, phase the rollout sensibly, and know a propose-then-confirm pattern was worth the extra complexity before a single line of it was written.
That's the muscle I keep coming back to across these personal projects: not becoming an engineer, but closing the gap between having the judgment to know what a system should do and being able to get it built.