Skip to main content
Glama
rk9595

Voyager Travel MCP Server

by rk9595
README.md
# Voyager Travel MCP Server

A MakeMyTrip-style **travel-booking MCP server** (flights, hotels, trains,
bookings) exposed over the **Streamable HTTP** transport. Point any MCP client
(Claude Desktop, Cursor, Claude Code) at `https://<your-host>/mcp`.

It ships **12 tools grouped into four risk tiers**, so an external policy layer
can enforce role-based access and guardrails per tier. The tiers map to scopes,
and roles map to scopes: this is the seam where **Votal Shield** plugs in later
(see [Guarding with Votal Shield](#guarding-with-votal-shield-later)).

Modeled after the structure of [meridian-mcp](https://github.com/saivarmadpr/meridian-mcp)
(risk-tiered tools, Docker + Railway, `/healthz`), but for travel booking and
with an in-memory data store (no database), so it deploys as a single service.

## Tools

| # | Tool | Tier | Scope | What it does |
|---|------|------|-------|--------------|
| 1 | `search_flights` | read-only | `travel:read` | Search flights by origin/destination/date |
| 2 | `search_hotels` | read-only | `travel:read` | Search hotels by city |
| 3 | `search_trains` | read-only | `travel:read` | Search trains by route/date |
| 4 | `get_booking` | read-only | `travel:read` | Fetch one booking by id |
| 5 | `list_bookings` | read-only | `travel:read` | List a traveler's bookings (contains PII) |
| 6 | `create_booking` | reversible-write | `travel:write` | Create a pending booking + lead traveler |
| 7 | `add_traveler` | reversible-write | `travel:write` | Add a co-traveler |
| 8 | `apply_coupon` | reversible-write | `travel:write` | Apply a discount coupon |
| 9 | `pay_booking` | money-movement | `payments:write` | Pay for and confirm a booking |
| 10 | `request_refund` | money-movement | `payments:write` | Refund a confirmed booking |
| 11 | `cancel_booking` | destructive-admin | `admin:write` | Cancel a booking |
| 12 | `override_price` | destructive-admin | `admin:write` | Override a booking amount |

## Roles

The demo resolves the caller's role from an `X-User-Role` request header and
checks it against this matrix (each role is a superset of the one above):

| Role | `travel:read` | `travel:write` | `payments:write` | `admin:write` |
|------|:--:|:--:|:--:|:--:|
| `guest`    | ✅ | — | — | — |
| `traveler` | ✅ | ✅ | ✅ | — |
| `admin`    | ✅ | ✅ | ✅ | ✅ |

A request with no `X-User-Role` header is treated as `guest` (least privilege).
An under-privileged call returns an **MCP tool error** (`isError: true`) with an
`Access denied ...` message, not an HTTP 403.

> The plaintext `X-User-Role` header is trusted only because a gateway/IdP is
> assumed in front. Do not expose this server unauthenticated. In production,
> either put an auth gateway in front or set `AUTHZ_MODE=off` and let a policy
> layer (Votal Shield) own authorization.

## Run locally

```bash
npm install
npm run build
npm start                 # serves MCP at http://localhost:8080/mcp

# or, live-reload during development:
npm run dev
```

Smoke-test the whole thing (initialize, tools/list, good/bad calls per role):

```bash
npm run smoke                         # against http://localhost:8790 by default
node scripts/smoke.mjs http://localhost:8080
```

Raw `tools/list` with curl (send a role header):

```bash
curl -s http://localhost:8080/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'X-User-Role: admin' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

Call a tool as different roles to see allow vs. block:

```bash
# admin CAN cancel -> executes
curl -s http://localhost:8080/mcp -H 'X-User-Role: admin' \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"cancel_booking","arguments":{"bookingId":"BK-7002"}}}'

# guest CANNOT cancel -> "Access denied ..."
curl -s http://localhost:8080/mcp -H 'X-User-Role: guest' \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"cancel_booking","arguments":{"bookingId":"BK-7002"}}}'
```

See [TESTING.md](./TESTING.md) for the full good/bad matrix.

## Deploy to Railway

1. Push this repo to GitHub, then in Railway: **New Project -> Deploy from GitHub
   repo** and select it. Railway detects `railway.json` + `Dockerfile` and builds.
2. (Optional) Variables: `AUTHZ_MODE` (`header` default, or `off`), `DEFAULT_ROLE`.
   Do **not** set `PORT` — Railway injects it.
3. **Settings -> Networking -> Generate Domain.** Your endpoint is that domain + `/mcp`.
4. Health check is wired to `/healthz` in `railway.json`.

Any container host works the same way: build the `Dockerfile`, provide `$PORT`.

## Endpoints

| Method | Path | Purpose |
|---|---|---|
| POST | `/mcp` | MCP Streamable HTTP (JSON-RPC: initialize, tools/list, tools/call) |
| GET | `/healthz` | Health check (`{status:"ok"}`) |
| GET | `/` | Human-readable info: tools, roles, authz mode |

## Environment variables

| Var | Default | Notes |
|---|---|---|
| `PORT` | `8080` | Host injects this (Railway sets it automatically) |
| `AUTHZ_MODE` | `header` | `header` enforces role->scope; `off` delegates to an upstream layer |
| `DEFAULT_ROLE` | `guest` | Role assumed when no `X-User-Role` header is present |
| `DEFAULT_TENANT_ID` | `voyager-demo` | Tenant sent to Shield when the client does not send `X-Tenant-ID` |
| `SHIELD_MODE` | `off` | `off`, `monitor`, or `enforce` for in-process Shield calls |
| `SHIELD_BASE_URL` | unset | Shield API base URL, e.g. `https://api.guardrails.votal.ai` |
| `SHIELD_API_KEY` | unset | Tenant API key sent as `X-API-Key` |
| `SHIELD_AGENT_KEY` | `voyager-mcp` | Agent key/id sent to Shield tool guard endpoints |
| `SHIELD_TIMEOUT_MS` | `1000` | Per-check timeout |
| `SHIELD_PRE_CHECKS` | tool check | Comma-separated endpoints called before tool execution |
| `SHIELD_POST_CHECKS` | tool output | Comma-separated endpoints called after tool execution |

## Guarding with Votal Shield (later)

This server can be guarded by Votal Shield in two ways:

- **Front it with Shield** and set `AUTHZ_MODE=off` here, letting Shield own
  RBAC + input/output guardrails for every tool call.
- **Call Shield in-process** from each tool (RBAC check, input screening, output
  sanitization) before/after the tool body runs. This repo now supports this
  path through `src/shield.ts`.

The `authorize()` function in `src/authz.ts` is the single seam where the role
-> scope decision is made, and every tool already declares its tier and scope,
so wiring Shield in is a localized change. Guardrail decisions then surface in
the Shield admin portal's Guardrail Metrics dashboard.

### In-process Shield flow

When `SHIELD_MODE=monitor` or `SHIELD_MODE=enforce` and `SHIELD_BASE_URL` is
set, every `tools/call` runs this flow:

```text
MCP client / scanner
  -> POST /mcp tools/call
  -> Voyager local authz check
  -> POST {SHIELD_BASE_URL}/v1/shield/tool/check
  -> tool handler executes only if pre-checks allow
  -> POST {SHIELD_BASE_URL}/v1/shield/tool/output
  -> MCP response returned, blocked, or redacted
```

Voyager sends Shield a JSON payload containing:

```json
{
  "request_id": "uuid",
  "tenant_id": "eval-tenant",
  "user_id": "eval-user",
  "user_role": "traveler",
  "agent_key": "voyager-mcp",
  "stage": "pre_call",
  "tool_name": "create_booking",
  "tier": 2,
  "scope": "travel:write",
  "tool_input": {}
}
```

Shield can return common decision shapes such as:

```json
{ "allowed": false, "action": "block", "reason": "adversarial payload" }
```

or:

```json
{ "allowed": true, "action": "redact", "sanitized_output": "..." }
```

Run the local eval with a mock Shield server:

```bash
npm run shield:eval
```

For a real Shield server:

```bash
SHIELD_MODE=enforce \
SHIELD_BASE_URL=https://api.guardrails.votal.ai \
SHIELD_API_KEY=... \
SHIELD_AGENT_KEY=voyager-mcp \
npm start
```

## Data note

Data lives in memory (`src/data.ts`) and resets on restart. Two bookings are
pre-seeded (`BK-7001`, `BK-7002`) so `get_booking` / `list_bookings` work
immediately. Swap this module for a real database when you need persistence.