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.
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues