User-scoped MCP access
HipTrip's MCP endpoint (/api/mcp) accepts two user-scoped credential shapes, either as Authorization: Bearer <token> or the legacy x-api-key header:
- A static per-user API key, created under Profile → AI assistant access. Only a SHA-256 digest is stored; the plaintext key appears once in the creation response.
- An OAuth 2.1 access token, obtained via the Dynamic Client Registration + authorization-code flow described in
mcp-oauth.md. Recommended for any harness that can complete a browser redirect — no key to copy, and the connection is revocable from Profile → Connected apps.
Both shapes carry the same scopes and go through the same tool-discovery and ownership checks below; OAuth is additive, not a replacement for the static-key flow.
Default user scopes
trips:read,packing:read: list/read owned and accepted shared resources. Also coversget_itinerary_hipscores(see below) — it's a read view keyed off the same itinerary access check, not a separate grant. Also coverssearch_flights(read-only Duffel fare search, rate-limited to 30/hour per user).trips:write,packing:write: propose changes and apply them after confirmation. Shared resources requireeditpermission.propose_itinerary_flight(trips:write) stages adding a flight to an itinerary from asearch_flightsoffer_id. Fare and schedule are re-fetched from Duffel when proposed — never taken from the caller — and saved as oneflightactivity per leg (outbound first on its day, return last; a flight for the same leg and route is replaced). Duffel offers expire within hours, so propose and apply promptly. Flight activities can never be created or altered throughpropose_itinerary_day_update: a replaced day keeps or removes existing flight items but forged ones are dropped on apply.
- Editorial scopes are never granted by the user UI or by OAuth consent:
catalog:read,editorial:read,places:search,editorial:write,editorial:delete. An OAuth-issued token can never carry these regardless of what a client requests.
Tool discovery is scope-filtered. Every handler also filters by the key's userId; IDs supplied by a client never bypass ownership/share checks.
HipScores
get_itinerary_hipscores(itinerary_id) lists HipTrip's curated HipScores (0–100, editorial + AI quality signal) for the destination of an itinerary the caller owns or has been shared. It reuses the same curated-HipPlace matching (loadCuratedHipPlaces) as itinerary generation, so results match what the app would recommend for that destination.
It is gated on the itinerary being unlocked (isUnlocked: true) — a user can only pull HipScores for locations tied to a trip they've actually unlocked, not any arbitrary destination. This is separate from the editorial search_hip_places/get_hip_place tools (which query any destination but require an editorial:read-scoped key never issued to end users). Results are sorted by HipScore descending (unscored places last) and each place is tagged good (at/above the quality bar), flagged (AI-assessed below it), or unscored.
Creating a new itinerary
create_itinerary runs the same generation pipeline as the app's own "Generate" flow (scaffold → per-day agentic place resolution → geocode → persist) end-to-end in one call, given destination, trip_type, duration, difficulty, and optional trip_focus/interests/start_date/end_date. Unlike the mutation tools below, there's no pending-change/diff step — a brand-new itinerary has nothing to diff against, same as clicking Generate in the UI.
It's synchronous and can take a minute or more for a longer trip; app/api/mcp/route.ts sets maxDuration = 300 to give it room. It shares the app's own per-user cap (generate:<userId>, 5/hour) so an external agent can't exceed the UI's generation budget.
Building a trip yourself, instead of generating it
create_manual_itinerary is the "build it yourself" counterpart: it creates an empty itinerary shell (title/destination/dates, one blank day per date) with no AI-selected activities, unlocked immediately — the same in-app path a human uses when they choose "start from scratch" instead of "Generate." It shares create_itinerary's generate:<userId> rate limit (the cover-image step has real per-call cost).
This exists so an external agent can construct an itinerary with its own reasoning rather than only triggering HipTrip's internal generator. The intended flow:
create_manual_itinerary(destination, start_date, end_date)→ get anitineraryId, already unlocked.get_itinerary_hipscores(itinerary_id)→ see HipTrip's curated candidate places for that destination (only available because the itinerary is unlocked — see below).propose_itinerary_day_update+apply_pending_change, once per day, with activities as free text (place names, times, details). Resolution against curated HipPlaces happens server-side, the same as the built-in day editor — the agent never receives the raw scored catalog, only the resolved result of what it proposed.propose_itinerary_update+apply_pending_changefor trip-level fields (title, overview, packing list, tips) once days are filled in.
Deliberately not provided: a catalog-search tool (e.g. search_hip_places for an arbitrary destination). The curated HipPlace database is HipTrip's stated competitive moat, and catalog:read/editorial:read/places:search are never granted to end-user credentials (see "Default user scopes" above). get_itinerary_hipscores is the one sanctioned window into curated data for end users, and it's deliberately gated to destinations tied to a trip the caller has actually unlocked — not any destination on demand. See ADR-002 (Compass, Architecture) for the full reasoning.
Mutations
propose_itinerary_day_update, propose_itinerary_update, propose_itinerary_archive, and propose_packing_list_update store a 15-minute pending change and return a readable before/after diff. The client must show that diff and obtain explicit user confirmation before calling apply_pending_change(change_id, confirmed: true). Archiving is owner-only; an editor on a shared itinerary cannot archive the owner's trip.
Apply is one-time and checks the resource's updatedAt captured when proposed. Expired, replayed, cross-key, revoked-access, and stale proposals fail. Successful reads, proposals, and applies create attributed audit events.
MCP day application attaches Google placeId + coords deterministically via attachPlaceIds (no LLM: the place name the agent chose is never validated or rewritten; an unmatched name is saved with a null placeId) and computes transfer routes with resolveTransferActivity. Agent-supplied placeIds are verified when the change is proposed (not at apply): each distinct ID is checked against curated HipPlaces, then the Google Places cache, then one live Places lookup, and IDs that are malformed or unknown are rejected with an error naming the activity, so nothing is staged. For verified IDs the activity's coords are replaced with the verified location (agent-supplied coords are never trusted), and the diff shows the verified values. Because the stored pending payload is immutable, apply skips resolution for activities that carry both placeId and coords. Not yet verified: that a verified ID actually matches the activity's placeName or lies near the trip's destination, and placeIds on transfer endpoints. Its orchestration remains separate because MCP must resolve external place/route calls outside its short optimistic transaction, while the server action is session-bound.
Operations
Apply prisma/migrations/026_user_scoped_mcp/migration.sql and prisma/migrations/027_oauth_dcr/migration.sql manually to Aurora DSQL, one transaction at a time. The legacy HIPTRIP_MCP_KEY remains supported for editorial automation only and carries no user identity, so it cannot access user trip tools.