agentic-travel-recs
by aryanyeole
README.md
# Agentic Travel Recommendations Service
A multi-tenant travel recommendations service that exposes both a **REST API** and an **MCP server**, with partner-specific rule enforcement (recommendation caps, category exclusions). Built as an exploration of MCP server design and multi-tenant rule enforcement patterns for AI-agent-facing internal services.




## What it does
An AI agent (e.g. a travel concierge embedded in a partner-branded portal) needs two things from this service:
1. A member's profile — loyalty tier, past bookings, partner affiliation.
2. Personalized travel recommendations that respect the partner's business rules.
Different partners have different rules. Some cap recommendations at 3 per session; others exclude entire categories like cruises. The service enforces those rules in a single code path so they can't drift between the REST and MCP interfaces.
## Quick start
```bash
npm install
npm test # 13 tests: rules engine + integration
npm run demo # spawns the MCP server and walks 4 partner scenarios
npm run dev:api # REST API on :3000
npm run dev:mcp # MCP server on stdio
```
The demo is an actual MCP client that spawns the server as a subprocess and calls its tools the way an AI agent would.
## Seeded scenarios
The mocks include four partners exercising every combination of rule shape:
| Member | Partner | Loyalty | Partner rules |
|---------|----------------------|----------|-------------------------------------|
| M-1001 | Bank A | Gold | No cap, no exclusions |
| M-1002 | Credit Union B | Platinum | Excludes cruises |
| M-1003 | Membership Org C | Silver | Cap of 3 recommendations |
| M-1004 | Airline D | Gold | Cap of 5, excludes cars and cruises |
## Project structure
```
src/
mcp/server.ts # MCP server, stdio transport, two tools
api/server.ts # REST API (Express)
api/routes/recommendations.ts
services/
recommendationsService.ts # orchestrator — shared by REST & MCP
memberDataService.ts # client for the (mocked) member data upstream
partnerConfigService.ts # client for the (mocked) partner config upstream, 60s TTL cache
rules/partnerRulesEngine.ts # single source of truth for partner-rule enforcement
mocks/ # in-memory mocks of the two upstreams
types/index.ts # shared domain types
utils/logger.ts # pino JSON logger
cli/demo.ts # MCP client that spawns the server and walks the scenarios
tests/ # vitest — unit tests for the rules engine + integration
docker/Dockerfile # multi-stage image; default CMD runs the REST API
```
## Architecture
Four moving parts:
1. **AI agent** speaks MCP.
2. **This service** exposes two MCP tools (`get_member_profile`, `get_recommendations`) and an equivalent REST API for non-MCP callers and for debugging.
3. **Member data service** (mocked) — returns member ID, loyalty tier, partner ID, and last five bookings.
4. **Partner configuration service** (mocked, read-only) — returns per-partner rules.
Two design properties matter most:
- **REST and MCP share the same service layer.** `recommendationsService.ts` is called from both `api/routes/recommendations.ts` and `mcp/server.ts`. Partner rules run in exactly one code path — it's structurally impossible to ship an MCP path that bypasses them.
- **Rules enforcement is a pure function.** `enforcePartnerRules(candidates, partnerConfig, requestedMax?)` in `src/rules/partnerRulesEngine.ts` has no I/O and no side effects. Roughly 40 lines with a dedicated unit-test file, so partner-rule regressions can be caught in seconds.
Every response includes an `appliedRules` block reporting which categories were filtered and how many results were capped — so a downstream agent can explain to a user why cruises aren't showing up, and an on-call engineer can diagnose "empty result" cases without reading logs.
## Design trade-offs
**stdio MCP transport rather than Streamable HTTP.**
Stdio has no public network surface — no auth, no rate limiting, no session management to design. The MCP server co-locates as a sidecar with the AI agent that consumes it. Streamable HTTP would be the upgrade path for remotely-hosted use; the tool contracts wouldn't change.
**Rule-based candidate scoring, not ML.**
The recommender uses a legible scoring function: baseline + historical-type affinity + tier boost − recent-destination penalty. Swapping in an ML model later would leave the rules engine, MCP tools, and REST contracts untouched. A broken model would silently degrade recommendations; a broken rules engine would violate a partner contract. Ship the trustworthy piece first.
**60-second partner config cache.**
Short enough that a partner rule change propagates within about a minute, long enough to reduce upstream load by roughly 99% on the hot path. A cache-bust endpoint could be added later for partners that need instant propagation.
## Handling partner configuration changes
Partner config is read-only from this service's perspective, so a partner changing their cap or adding an exclusion happens entirely upstream. Within the 60-second TTL, the change is picked up on the next call. The rules engine reads it fresh, and the `appliedRules` block in the response reflects the new state.
The only case that requires a code change here is if a partner adds a *new kind of rule* (e.g. a time-of-day restriction). That would need a new field in the `PartnerConfig` type and a new step in `enforcePartnerRules`. A new instance of an *existing* rule shape needs no code change — that's what the four seeded partners demonstrate.
## Production considerations
Things designed for the person on-call at 2 a.m.:
- Every recommendation call logs one INFO line with `{ memberId, partnerId, returned, filteredCount, cappedCount }`. One grep answers "did the rules run?"
- The `appliedRules` block on every response means the same question can be answered by a curl, without logs.
- The rules engine has 7 unit tests including a specific "order matters" test that fails if filter and cap are reversed.
- Known error types map to stable HTTP status codes and structured MCP `isError: true` responses.
Things I'd add before running this in production:
- Timeouts and one retry with backoff on upstream calls, plus a circuit breaker with a documented fallback behavior.
- Per-partner dashboards for error rate and p99 latency; an alert if `filteredCount` suddenly spikes for a partner (usually the signal that someone shipped a bad config).
- Load testing with realistic traffic mix to validate the cache TTL choice.
- Streamable HTTP transport for MCP if remote hosting becomes a requirement.
## About the build process
The initial scaffold — types, mocks, service layer skeleton, and the first pass at the MCP server — was AI-assisted. Everything after that (the Partner D onboarding to prove multi-tenant behavior, the integration test for it, the real readiness probe replacing the stub, and a cross-platform fix for a Windows path bug in the MCP entrypoint) was written by me. The Windows bug is the one I'd point out: the AI's original entrypoint check used `import.meta.url === \`file://${process.argv[1]}\``, which silently fails on Windows because the two sides of the comparison use different path formats. Green unit tests didn't catch it because they never spawn the MCP server as a subprocess. I caught it by running the demo end-to-end, and fixed it by normalizing both sides through `pathToFileURL`.
## License
MIT — see [LICENSE](./LICENSE).This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues