Skip to main content
Glama
Ryan-Solomon

Agentic Travel Recommendations MCP Server

by Ryan-Solomon

Agentic Travel Recommendations MCP Server

A proof-of-concept MCP (Model Context Protocol) server that lets an AI agent request personalized travel recommendations for a travel loyalty program member, while guaranteeing every result respects the calling partner's read-only business rules. Built as a design exercise in the problem every multi-tenant, white-label travel platform actually has: enforcing per-partner business rules — category exclusions, recommendation caps — without ever trusting an LLM's judgment to remember and apply them correctly.

Feature checklist

Feature

Where

At least one MCP server endpoint an AI agent can discover and invoke

get_travel_recommendations and get_partner_policy, registered in src/mcp/createServer.ts, served over MCP's Streamable HTTP transport (src/server.ts)

Partner-specific rule enforcement (caps, category exclusions)

src/domain/policyEngine.ts (applyExclusions, applyCap), unit-tested in tests/policyEngine.test.ts and tests/tenantIsolation.test.ts

Language/framework

TypeScript / Node.js

Backend API and a minimal frontend or CLI demonstrating the flow end-to-end

Backend: src/server.ts. Frontend: public/index.html, served by the same Express app at /, calling GET /api/recommendations (src/http/recommendationsRoute.ts). CLI: scripts/cli.ts (npm run cli), a standalone client against the separately running backend

Mocked upstream services (member data, partner config)

src/services/mock*.ts, backed by mocks/data/*.json, behind swappable interfaces (src/services/types.ts)

README Sections A, B, C

Below. The Part B2 answer was written independently, without AI assistance, and inserted verbatim

Related MCP server: Agentic Travel Recommendations API

Quick start

npm install
npm test              # 36 tests: policy engine (incl. an "unlimited" cap), recommendation engine (incl. failure
                       # injection across all three upstream dependencies), multi-tenant isolation, the REST route
                       # (incl. error handling), trip-context parsing, and a real HTTP integration test via the MCP client SDK
npm run dev:http       # backend API: Streamable HTTP server on http://127.0.0.1:3000/mcp (health: /healthz)

# in a second terminal, once the backend above is running:
open http://127.0.0.1:3000/                                            # minimal frontend, same server/port
npm run cli -- --member-id member-001 --partner-id partner-sunwest   # minimal CLI client -> backend, end to end
npm run cli -- --help                                                  # all options (trip context, --url, ...)

npm run demo           # self-contained: starts its own server in-process, calls it for three partners,
                        # then forces a partner-config outage to show the fail-closed path live
npm run dev:stdio      # stdio entrypoint for the MCP Inspector or Claude Desktop

To try it in the MCP Inspector (note: the Inspector itself requires Node ≥22, independent of this project's own Node 20 support):

npx @modelcontextprotocol/inspector npx tsx src/stdio.ts

Section A — Architecture & Trade-offs

Architecture Overview

The MCP server (src/server.ts) exposes two tools over Streamable HTTP: get_travel_recommendations and get_partner_policy. Calling get_travel_recommendations(memberId, partnerId, tripContext?) invokes recommendationEngine.ts, which fetches the member profile and partner configuration in parallel (each under a 2s timeout), searches the mocked itinerary catalog, scores and ranks candidates against the member's stated preferences, then hands the ranked list to policyEngine.ts, which mechanically applies whatever the partner config returned — category exclusions, then the recommendation cap — before the result goes back to the agent. A REST endpoint (GET /api/recommendations) and a static frontend (public/index.html) call the exact same recommendationEngine, so there is one implementation of the flow, reachable three ways (MCP tool, REST, CLI). Member data, partner config, and itinerary search are all interfaces (src/services/types.ts) with in-memory mock implementations — a real deployment swaps in HTTP clients against the same interfaces with no change to the domain logic.

Design Trade-offs

  1. No LLM call inside the server. It's a deterministic, unit-testable tool provider; the calling agent handles natural-language wrapping. This keeps policy enforcement free of LLM non-determinism, at the cost of a simple, explainable heuristic ranking rather than ML-driven personalization — the right trade for a 4-week v1, since a wrong ranking is a product-quality bug but a wrong policy decision is a compliance incident.

  2. Fail closed on policy, fail open on personalization. A partner-config failure returns zero recommendations rather than ever guessing at rules; a member-lookup failure still returns fully compliant, just non-personalized, results. Treating both failures identically would have been simpler to write, but wrong — losing policy compliance is a partner-contract risk, losing personalization is only a UX regression.

Handling Partner Configuration Changes

The service reads partner config fresh on every request — there is no cache and no "notify on change" hook, so a partner's cap or exclusion list takes effect on the very next call, with no deploy, restart, or code change required. The trade-off runs the other way from most systems: it's simple today, but it puts the config service's latency on the critical path of every single recommendation. The highest-leverage backlog item for exactly this reason is a short-TTL cache in front of PartnerConfigService — if that ships, a partner's rule change would then take up to that TTL to propagate, which is a real, documented behavior change from today's "always live" guarantee, not a transparent optimization.

(Full architecture diagram and a third trade-off are in Additional architecture detail below, kept separate so this section stays within the requested word count.)

Four-week delivery plan

This code mocks all three upstream services end to end — the point is to demonstrate service design and policy-enforcement correctness, not integration plumbing against real third-party APIs. This plan is a separate thing: what an actual four-week production rollout would look like, assuming the member data service and partner configuration service already exist as documented internal APIs — not hypothetical ones.

Ships in week 1-4:

Week

Deliverable

1

PartnerConfigService/MemberService/ItineraryService interfaces + mocks; policyEngine.ts (exclusions, cap) with full unit test coverage — the compliance-critical logic lands and is proven correct first, against controllable test doubles, before anything talks to a real dependency

2

recommendationEngine.ts (scoring, ranking, dedupe-by-destination, category diversification), timeout + fail-closed/fail-open handling, tenant-isolation tests

3

MCP server (Streamable HTTP + tools), CLI client, minimal demo frontend + REST adapter, /healthz, structured JSON logging, Dockerfileand real HTTP clients for MemberService and PartnerConfigService, swapped in behind the same interfaces the mocks satisfy. This is realistic within week 3, not backlog: both are simple, already-documented, already-existing internal REST APIs, and the interfaces were built in week 1 specifically so this swap touches no other code. A service that's never exercised a real dependency isn't meaningfully on-call-ready, so this has to land before staging, not after. Deployed to staging on existing ECS/Container Apps infra.

4

Load + failure testing against staging — now against the real member/partner-config services' actual latency and failure behavior, not simulated chaos, which is a meaningfully stronger signal than week 1-3's mocked failure-mode tests. Alarms wired into existing CloudWatch/Azure Monitor (see runbook below), incident runbook, staged production rollout behind a partner allowlist (start with 1 partner, not all 4+).

Explicitly deferred past week 4 (backlog, not v1):

  • Real integration with the itinerary/inventory search system (700 airlines, 1M+ hotels, 30,000 rental car locations) — a fundamentally larger integration surface than a member or partner-config lookup, and the one piece of "real HTTP integration" that genuinely doesn't fit a first four weeks. ItineraryService stays mocked through week 4.

  • Caching layer for partner config (read-heavy, changes rarely — see "Handling Partner Configuration Changes" above for the propagation-delay trade-off it introduces)

  • Auth/authz on the MCP endpoint (v1 assumes it sits behind an internal network boundary / service mesh, not exposed to the public internet)

  • Per-partner rate limiting and request quotas

  • Richer, model-based ranking (v1's heuristic is intentionally simple and explainable)

  • Multi-region deployment / active-active failover


Section B — Production Readiness & Incident Response

Incident Runbook Entry: member sees cruise recommendations despite a partner exclusion

Symptom: a member under a partner whose config excludes the cruise category receives a cruise recommendation from the AI Concierge.

Diagnose — form the hypothesis space before touching anything. There are three plausible causes, in likelihood order: (1) a category-string mismatch between what the partner config service returns and our internal Category enum — applyExclusions (src/domain/policyEngine.ts) does an exact-string Set.has() check with no normalization, so "Cruise", "Cruises", or a differently-versioned taxonomy string would silently fail to exclude anything, with no thrown error and no log line to alert on; (2) the request reached us with the wrong partnerId entirely — a cross-tenant/session bug upstream of our service, not in it; (3) a regression in policyEngine.ts itself. Start by pulling the structured recommendation_request log for the member's exact request — the partnerId and policyOutcome it recorded tell you which of the three you're even looking at before you check anything else.

Confirm — verify which hypothesis is actually true, don't assume the likeliest one:

  1. Call get_partner_policy (or GET /api/recommendations) directly for the exact partnerId from the log and print excludedCategories verbatim. Compare it character-for-character against the internal Category values in src/types.ts ("cruise", lowercase, singular). A mismatch confirms hypothesis 1 immediately — and confirms it's a data-contract gap upstream, not a bug in the filtering logic itself.

  2. If excludedCategories is correct, confirm hypothesis 2: check whether the partnerId from the log actually matches the partner the member believes they belong to. If it doesn't, this is a cross-tenant identity bug upstream of us, not ours to fix directly.

  3. If both check out, confirm hypothesis 3: check recent deploys and git history for policyEngine.ts changes around the incident window, and re-run the exact (memberId, partnerId, candidate set) through applyExclusions in isolation to see whether it reproduces on the current code.

Resolve:

  • Category-string mismatch: this is a data-contract gap with a read-only upstream we cannot change — the constraint says we respect whatever partner config returns, but nothing says we can't normalize how we compare it. Fix on our side: case-insensitive comparison or an explicit mapping table from the partner config service's category vocabulary to our internal Category enum, plus a startup/ingestion check that flags any category string we don't recognize so this fails loudly next time instead of silently.

  • Wrong partnerId reaching us: our service behaved correctly given its input — escalate to whichever integration passed the wrong partner context; add a defense-in-depth warning log here regardless.

  • Regression: roll back to the previous container image via the existing CI/CD pipeline (stateless service, so rollback carries no data-migration risk); root-cause via git bisect on policyEngine.ts.

Part B2 — Required Reasoning Question (answer without AI assistance)

Describe a scenario where an AI coding assistant would give you a plausible but incorrect answer for this type of problem — building an API that enforces partner-specific business rules. Explain specifically how you would catch the error and what you would check before acting on it.

I've experienced this first-hand. At my current company we built a multi-tenant white-labelled platform where each partner had their own configuration. When I built one of the endpoints, it worked perfectly when all of a partner's configuration variables existed. Tests passed, manual testing confirmed it, no issues.

But the endpoint was too permissive. It assumed the happy path. It didn't account for a partner missing some of their configuration, and when that happened it fell through to allowing the record rather than denying it. Nothing errored. The response was a 200 with too much in it.

That's exactly the answer an AI assistant gives you here, and for the same reason I wrote it myself: the natural way to code it is "look up the restriction, apply it if it's there." The missing case doesn't look wrong, it just doesn't get thought about. And it passes every test, because the tests cover the partners that exist.

How I'd catch it: The problem is that the right and wrong versions read the same, so reading the diff doesn't help. What works is writing the negative test first: a partner with no config, and a partner with an empty config. If either returns data, the default is wrong.

I'd also check where the rule is actually enforced. If it's filtering the response instead of the query, the restricted rows are still being pulled and then dropped, which means counts and exports are still leaking them.

What I'd check before acting on it: Whether the rule holds everywhere it needs to. If the endpoint filters correctly but the count, the export, and any aggregate over the same data don't, I've got a leak in three other places. I'd trace the same restriction through every read path that touches that table.

I'd also check whether the config being absent is even a real state. Sometimes the right fix isn't handling the missing case, it's making it impossible by requiring the config at partner creation instead of defending against its absence at every call site.

That's the general lesson for me with AI-generated code. It fails quietly. A person who doesn't understand the permission model writes something obviously broken. The model writes something that looks right and breaks on the path nobody tested.

Additional runbook entry (bonus): Partner Configuration Service degraded or unreachable

Kept as a second, distinct scenario since it exercises a different part of the system (an availability failure, not a correctness bug) and is directly tied to the fail-closed design decision in Section A.

Trigger: alarm on the policy_unavailable rate (from the structured recommendation_request log events in src/mcp/deps.ts) crossing a threshold, e.g. >5% of requests over 5 minutes — a CloudWatch/Azure Monitor metric filter over the JSON logs, not a bespoke exporter.

Impact: affected partners receive zero recommendations (by design, per the fail-closed trade-off) rather than degraded ones. Blast radius is visible per-partner in the logs (partnerId is on every log line) — check whether one partner or all partners are affected before assuming a full outage.

Diagnosis: grep for "policyOutcome":"policy_unavailable" and inspect upstreamErrors.partnerConfig (hard error vs. timeout); call get_partner_policy directly for an affected partner; check the config service's own health dashboard (owned by another team); check whether UPSTREAM_TIMEOUT_MS (src/domain/recommendationEngine.ts) is still appropriate against current upstream p99 latency — a merely-slow-not-down upstream slower than the timeout shows identically to a hard outage.

Mitigation: confirmed-down upstream has no safe workaround that returns recommendations without confirmed policy — page per standard severity, do not raise the timeout blind. Confirmed-slow-but-alive: raising UPSTREAM_TIMEOUT_MS (make it env-configurable in a production version of this code) is the correct short-term fix. Repeat incidents: add the short-TTL cache from the backlog, as a product decision with whoever owns partner policy, not a unilateral code change.

Rollback: stateless service — a bad deploy is fixed by redeploying the previous container image, no data-migration risk.


Section C — AI Usage Log (Mandatory)

1. Designing the response to a degraded dependency, not just wrapping it in a try/catch

  • Asked: to scope and plan the MCP server implementation against the four stated constraints, including on-call ownership, before writing any code.

  • Got: a plan that listed failure handling as a bullet point but didn't specify what the server should actually do when a dependency degrades — implicitly treating "partner config is down" and "member service is down" as the same category of problem.

  • Kept / changed: pushed back on treating the constraints as background instead of design inputs. The result treats those two failures asymmetrically on purpose: an unreadable partner config fails the whole request closed (zero recommendations — we can't confirm what rules to apply), while an unreadable member profile fails open on personalization only (still returns compliant, just generic, recommendations). That's a real trade-off with a real cost either way, designed into recommendationEngine.ts itself, not bolted on as a try/catch after the fact.

2. A failing test that turned out to be a ranking design flaw, not a bad assertion

  • Asked: to build and test the candidate-ranking logic per the plan.

  • Got: a first fix that added a hash-based tiebreaker to the sort when a multi-tenant test kept failing — the test still failed.

  • Kept / changed: rejected "make the test pass" as the goal and traced the actual mechanism instead: with many candidates tied at the same score, a stable sort let ties fall back to catalog insertion order, which systematically favored whichever category happened to have the most inventory rows — an artifact of fixture ordering, not a ranking signal. The real fix was a design change (round-robin diversification across categories within a score tier), not a bigger hash. This is a realistic failure mode for any naive recommendation ranker: it can quietly always show the same handful of categories to every user.

3. Deciding what "the recommendation" is when three supplier records describe the same trip

  • Asked: to run the demo script and actually look at the output, not just trust green tests.

  • Got: a first implementation that deduplicated repeated destination records (one per airline/hotel/car supplier) by keeping whichever one the mock catalog happened to list first — arbitrary, and it silently discarded the other two along with their pricing.

  • Kept / changed: rejected "deduplicated" as sufficient on its own; the real question was a product one — what should "the recommendation" represent when multiple bookable options exist for the same destination? Landed on: surface the cheapest, and expose the count of alternatives (alternativeOptionsCount) instead of hiding them, so an agent can say "3 booking options available" rather than implying there's only one.

4. A code review surfaced an asymmetric design gap between three supposedly-parallel dependencies

  • Asked: an independent code-review pass over the diff, run separately from the main build.

  • Got: a finding that recommendationEngine treated its three upstream calls inconsistently — the member and partner-config lookups were both timeout-guarded with explicit typed failure handling, but the itinerary search was awaited directly, so a slow or failing inventory search could hang or throw uncaught, unlike its two siblings.

  • Kept / changed: fixed it at the same level as the other two — wrapped in the same timeout helper, with the same "degrade to an empty-but-still-valid result" behavior — rather than only patching the symptom (an unhandled rejection reaching the REST endpoint) with a try/catch. The try/catch got added too, as defense in depth, but the actual fix was making all three dependencies follow the failure-handling pattern the design was supposed to have from the start.

5. Pushing back on "real integration" being backlog instead of week 3 of the four-week plan

  • Asked: whether deferring all real HTTP integration to backlog was actually right, given the scenario states the member data and partner config services already exist as documented APIs.

  • Got (before the pushback): a plan that filed "real HTTP integrations replacing the three mocked services" as one undifferentiated backlog item — implicitly treating a member/partner-config lookup and a 700-airline/1M-hotel inventory search as the same size of problem.

  • Kept / changed: split it. Integration with the two already-existing, comparatively simple services moved into week 3 — the interfaces were built in week 1 specifically to make that swap low-risk, and a service that has never touched a real dependency isn't meaningfully on-call-ready. The itinerary/inventory search integration stayed in backlog, since it's a genuinely larger integration surface and doesn't fit a first four weeks the same way the other two do.

A few interactions that mattered for getting the deliverable actually complete, but were process/completeness catches rather than design decisions, so they're summarized rather than given full entries: requiring the Docker build be actually run rather than reasoned about from source (surfaced two real Dockerfile bugs); catching that the initial CLI-shaped demo script spun up its own throwaway server instead of demonstrating a real backend-plus-client split; and catching that a CLI alone doesn't satisfy a "frontend or CLI" requirement once a literal frontend is what's being asked for. All three are in the git history with their fixes.


Additional architecture detail

AI agent                    npm run cli            public/index.html
   │  tools/call                │  MCP client SDK        │  fetch()
   ▼                            ▼                        ▼
        POST /mcp (MCP JSON-RPC)          GET /api/recommendations (REST)
                    │                                │
                    ▼                                ▼
         src/mcp/createServer.ts          src/http/recommendationsRoute.ts
         (tool defs, zod input)            (thin adapter, same call below)
                    └────────────────┬───────────────┘
                                      ▼
                src/domain/recommendationEngine.ts  (orchestration + failure-mode handling)
                    │            │                  │
                    ▼            ▼                  ▼
             MemberService  PartnerConfigService  ItineraryService   ← mocked, interface-bound (src/services/)
                    │            │                  │
                    └──────┬─────┴──────────────────┘
                            ▼
        src/domain/policyEngine.ts   (pure: applyExclusions, applyCap — the actual "enforce partner rules" logic)

All served from one stateless Streamable HTTP process (src/server.ts): POST /mcp is the real agent-facing API this challenge is about, GET / and GET /api/recommendations exist only to power the demo frontend, and both paths converge on the same recommendationEngine/policyEngine — there is exactly one implementation of the recommendation flow, not two.

Deployment shape: the server uses MCP's Streamable HTTP transport, not stdio — stdio (src/stdio.ts) is kept only as a local dev/Inspector convenience. The Dockerfile builds a standard Node container meant to drop behind a typical existing ingress: ALB/API Gateway → ECS Fargate (AWS) or Container Apps (Azure). No new infrastructure layer, no new platform, no database beyond the in-memory mocks. Because the server is stateless (sessionIdGenerator: undefined), it's trivially horizontally scalable — no shared session store to run or fail over.

A third design trade-off (beyond the two in Section A): in-memory mocks behind interfaces, not real HTTP clients. MemberService, PartnerConfigService, and ItineraryService are interfaces with in-memory mock implementations (src/services/mock*.ts) backed by small JSON fixtures (mocks/data/). A real deployment swaps in HTTP-backed implementations of the same interfaces — recommendationEngine and the MCP tools never change. The cost: failure modes (timeouts, malformed responses, rate limits) are simulated via a ChaosController rather than observed from a real dependency, so the timeout/circuit-breaker tuning in this repo is a starting point, not a validated production value.

What's mocked vs. what's real here

  • mocks/data/{members,partners,itineraries}.json stand in for a real member data service, partner configuration service, and inventory search service. Swapping in real HTTP clients means implementing MemberService/PartnerConfigService/ItineraryService (src/services/types.ts) against real endpoints — no other code changes.

  • The ChaosController (src/lib/chaos.ts) exists purely to make the fail-closed/fail-open code paths exercisable in tests and the demo script; it has no production equivalent.

  • The Dockerfile build and run were verified locally (docker build, docker run, /healthz, the frontend, the REST route, and a real MCP tool call through the mapped port all succeeded, with the container's own HEALTHCHECK reporting healthy). Actual deployment onto ECS/Container Apps, CloudWatch/Azure Monitor alarms, and CI/CD wiring are described above but not stood up — out of scope for a local POC, in scope for week 3-4 of the delivery plan.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections. Dates show when Glama detected each change.

No tool schema history has been recorded yet.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    A full-stack travel booking MCP server that enables AI clients to search flights, make reservations, cancel bookings, and manage persistent state across sessions.
    15
    5
    MIT
  • F
    license
    A
    quality
    C
    maintenance
    MCP server that provides AI agents with personalized travel recommendations for members, enforcing partner-specific rules such as category exclusions, loyalty tier eligibility, and recommendation caps.
    3
    -
  • F
    license
    Not graded
    quality
    C
    maintenance
    MCP server for agentic travel recommendations, exposing tools to retrieve member profiles and personalized travel recommendations with partner-specific rules and policies.
    -

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Ryan-Solomon/agentic-travel-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server