Skip to main content
Glama
andypnguyen11-gif

Agentic Travel Recommendations MCP Server

Agentic Travel Recommendations Service

Internal service that lets an external AI agent (the "AI Concierge") fetch a member's profile and generate partner-rule-compliant travel recommendations on arrivia's multi-tenant, white-label platform. Deterministic backend; the LLM is the MCP client, never the rule enforcer.

📹 Video walkthrough: https://www.loom.com/share/540f530d27d541968ee61f78ada758bf

How it works (at a glance)

  • Deterministic generator turns member facts into candidate offers.

  • Deterministic rule engine applies partner category exclusions, then caps.

  • A tenant guard runs before any member data is returned.

  • REST, MCP (stdio), and a CLI all call the same service brain.

  • No LLM call inside the service. No API key required to run, test, or demo.

Related MCP server: autonomad-travel

Setup

python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt

Run the REST API

uvicorn app.main:app --reload
curl -s localhost:8000/health
curl -s -X POST localhost:8000/recommendations \
  -H "x-partner-id: partner_no_cruise" -H "x-agent-id: agent-demo" \
  -H "content-type: application/json" -d '{"member_id":"M-plat-nocruise"}'

Run the MCP server (agent-discoverable)

Caller identity is the trusted launch context; one process = one partner session.

PARTNER_ID=partner_no_cruise AGENT_ID=agent-demo python -m app.mcp.server

Discover + invoke with MCP Inspector: PARTNER_ID=partner_capped AGENT_ID=agent-demo
npx @modelcontextprotocol/inspector python -m app.mcp.server Then: list tools -> inspect get_recommendations schema -> invoke with {"member_id":"M-plat-capped"} (cap of 3) and, with a partner_no_cruise-scoped server, {"member_id":"M-plat-nocruise"} (cruise removed).

Run the CLI demo

python -m cli.demo --member-id M-plat-nocruise --partner-id partner_no_cruise --agent-id a

Tests and evals

pytest            # full suite
pytest tests/evals  # safety evals only

Section A — Architecture & Trade-offs

The service has one shared brain, RecommendationService, and three thin callers — REST (app/api/recommendations.py), an MCP stdio server (app/mcp/server.py), and a CLI (cli/demo.py) — that all build an AgentContext and delegate to it. Nothing about rule enforcement or data access lives in any of the three callers, so there is no code path where an agent talks directly to member or partner data. get_recommendations runs a fixed pipeline: fetch the member from MemberService, authorize the caller against that member's partner_id via TenantGuard.authorize (fail closed on mismatch), read that partner's config from the read-only PartnerConfigService, generate candidate offers from member facts alone (RecommendationGenerator), and run the ordered RuleEngine (CategoryExclusionRule then MaxRecommendationsRule) over those candidates. get_member_profile runs the same fetch-then-authorize guard before returning anything, so both tools share one authorization gate rather than each reimplementing it.

Trade-off 1 — deterministic rule engine vs. LLM enforcement. An LLM could read partner config and "decide" what to show, but that makes rule compliance probabilistic and unauditable — exactly what a travel partner relationship cannot tolerate. Keeping exclusion and cap logic in typed, unit-tested Rule classes means every response is reproducible from the same inputs, and rule_metadata gives on-call a literal trail (applied_rules, removed_recommendations) instead of an LLM's self-report. The cost is that the service can't yet handle a rule too nuanced to express as code — an acceptable trade for a four-week first step.

Trade-off 2 — env-scoped MCP identity vs. a per-call partner argument. MCP tools take only member_id; partner_id and agent_id come from the server's launch environment (PARTNER_ID/AGENT_ID), not from tool arguments. A per-call partner argument would let a compromised or careless agent claim any partner scope it wants. Binding identity to the process that launched the server means one MCP session is hard-scoped to one partner for its lifetime; production replaces this launch context with signed workload identity, but the one-session-one-partner shape stays.

Config and rule changes. PartnerConfigService is read at request time, so if a partner's cap or excluded-category list changes upstream, the very next get_recommendations call picks it up with zero code changes or redeploys — the service never caches or writes config. Adding an entirely new kind of rule (e.g. a minimum-tier gate) means writing one new Rule class and adding it to the ordered list in RuleEngine.__init__; the pipeline, metadata shape, and both callers are untouched.

Section B — Production Readiness & Incident Response

Incident Runbook: "AI Concierge shows cruises for a cruise-excluded partner"

  1. Get request_id/partner_id/member_id from the report or logs.

  2. Confirm partner config: does partner_config return Cruise in excluded_categories? If not, this is a config-source issue, not a service bug.

  3. Reproduce deterministically: call get_recommendations for that member; inspect rule_metadata.applied_rules and removed_recommendations — was category_exclusion applied? Was a cruise in removed_recommendations?

  4. If exclusion did not fire: check the rule order and the CategoryExclusionRule; check whether the client is calling our tool vs. rendering its own offers.

  5. Resolve: fix + regression eval (test_cruise_exclusion_eval); if config-source, escalate to the partner-config owner since that service is read-only to us.

B2 — Required Reasoning Question (answer WITHOUT AI assistance)

Prompt: Describe a scenario where an AI coding assistant gives a plausible-but-wrong answer for enforcing partner rules, how you'd catch it, and what you'd verify before acting.

A similar problem came up while I was building an AI clinical co-pilot on top of OpenEMR. An AI coding assistant could generate an API implementation that looks correct, passes basic tests, and returns valid data, but fails to enforce business rules at the correct boundary. For example, the assistant might trust a member or partner ID provided by the client instead of validating the member's actual partner association before retrieving data or applying recommendation rules. The API could appear to work correctly while allowing data or configuration to cross tenant boundaries.

I encountered a similar issue when my OpenEMR agent answered questions using information related to other patients. I addressed the problem by creating a tool registry factory that restricted which tools and records were available within the current patient context. I also added verification middleware that ran after the model generated a response but before anything reached the user. Every cited source had to match a record the agent actually retrieved, and specific values were checked against the underlying data.

I would apply the same approach to this travel recommendations API. I would keep partner-rule enforcement deterministic and outside the model, validate the member-to-partner relationship before reading partner configuration or generating recommendations, and enforce rules such as cruise exclusions and recommendation limits server-side.

I would catch an incorrect AI-generated implementation with negative tests that attempt cross-tenant access, modified member or partner IDs, excluded cruise recommendations, and requests that exceed partner-specific limits. Before accepting the implementation, I would trace the full request path through authentication, tenant validation, partner configuration lookup, recommendation generation, and final response filtering.

The main lesson from my OpenEMR project was that plausible code is not the same as correct code. Before acting on an AI-generated solution, I verify the implementation against the actual business requirements, test isolation boundaries and failure paths, and confirm that automated tests would catch future regressions.

Section C — AI Usage Log

This project was built with AI assistance throughout design and implementation, under a rule that the deterministic backend and its enforcement logic are human-reviewed line by line, and that Section B2 above is answered by a human with no AI involvement. Three representative interactions, recorded in questions/decision-log.md:

  1. Scope-down from a large multi-PR plan to a lean MVP. The starting plan sized this as a large system (agent orchestrator, citations + citation validation, response validation layer, full eval dashboards). Asked to scope it to a realistic four-week, single-engineer first step, the AI proposed cutting to: mock member + partner config services, a deterministic generator, an ordered rule engine (exclusions then caps), a tenant guard, REST + MCP + CLI over one shared service, and a focused test/eval set — with the rest explicitly deferred to a "ships later" list. Kept as proposed: the cut lines up with what the assignment actually rewards (correctness and judgment under scope discipline, not feature count), and it left the repo with one honest story instead of an aspirational one it couldn't finish. See decision-log D1.

  2. Removing LangSmith to honor the "existing infrastructure only" constraint. An early observability pass suggested LangSmith for LLM-specific tracing. Flagged in review as violating the assignment's explicit "no new third-party platforms" rule, the AI was asked to re-propose observability using only infrastructure arrivia already runs. The replacement — structured JSON logs with request_id/member_id/ partner_id/tool_name/rule_decisions/failure_reason, framed as CloudWatch/Application Insights for logs and metrics and X-Ray/ OpenTelemetry for traces — was kept, and any future LLM-observability idea is now phrased generically ("arrivia-approved observability tooling") so it can't reintroduce a named third-party tool by accident. See decision-log D2.

  3. get_member_profile tenant-guard gap caught in spec review. During human review of the design spec, the drafted MCP get_member_profile tool called MemberService directly rather than going through RecommendationService, which meant it would return a member's profile without ever checking that the caller's partner_id matched the member's partner_id — the exact cross-tenant leak this whole design exists to prevent. Rejected the direct-call version; the AI's fix, accepted, added a guarded RecommendationService.get_member_profile(ctx, member_id) that runs fetch → TenantGuard.authorize → return, mirroring get_recommendations, with a cross-partner-access test added to prove the 403/safe-MCP-error path. See decision-log D16.


What ships first vs. later

Ships now (four-week first step)

Deferred (later)

Deterministic generator + rule engine

LLM phrasing/ranking/orchestration

REST + MCP (2 tools) + CLI

Citations + citation validation

Tenant guard + multi-tenant evals

Real auth (signed identity / JWT)

Structured JSON logs (PII-safe)

CloudWatch/App Insights/X-Ray wiring

Mock member + partner config

Real DB persistence, partner admin UI

Four-week plan

This is the four-week first step; the table above bounds what ships now. Indicative week-by-week:

Week

Focus

1

Domain models + fixtures (Silver/Gold/Platinum, last-5 bookings), mock member and read-only partner-config services, deterministic candidate generator.

2

Rule engine with fixed order (category exclusions → caps), tenant guard, shared recommendation service brain; unit tests for cap enforcement, cruise exclusion, and isolation.

3

REST endpoint, MCP stdio server (two tools), CLI demo — all sharing the one service layer; PII-safe structured JSON logging with correlation IDs.

4

Deterministic safety evals, Docker + compose, MCP Inspector walkthrough, README + incident runbook; buffer for hardening (non-root image, healthcheck) noted as later work.

Notes

  • Logs never include full profiles, travel history, or recommendation reasons; production would apply arrivia-approved redaction/tokenization.

  • MCP caller identity is a trusted launch context in the mock; production replaces it with signed workload identity / JWT claims / gateway-injected tenant.

  • Partner IDs are human-readable (partner_capped, partner_no_cruise, partner_unlimited) as a deliberate demo-readability choice — they make the Inspector/CLI walkthrough self-explanatory about which rule is under test. The tenant guard treats partner_id as an opaque string (plain equality match), so the format carries no correctness or security meaning; production would use opaque, tenant-scoped identifiers (e.g. UUIDs) supplied by the verified identity layer, never a client-supplied header. Note that UUID-vs-string is cosmetic — the security property is that partner_id comes from signed identity, not that it looks random.

F
license - not found
-
quality - not tested
B
maintenance

Maintenance

–Maintainers
–Response time
–Release cycle
–Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

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

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/andypnguyen11-gif/agentic-travel-recommendations-api'

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