Skip to main content
Glama
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.

![Node](https://img.shields.io/badge/node-20+-green)
![TypeScript](https://img.shields.io/badge/typescript-5.6-blue)
![Tests](https://img.shields.io/badge/tests-13%20passing-brightgreen)
![License](https://img.shields.io/badge/license-MIT-blue)

## 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).