Voyager Travel MCP Server
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).
Modeled after the structure of 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 |
| read-only |
| Search flights by origin/destination/date |
2 |
| read-only |
| Search hotels by city |
3 |
| read-only |
| Search trains by route/date |
4 |
| read-only |
| Fetch one booking by id |
5 |
| read-only |
| List a traveler's bookings (contains PII) |
6 |
| reversible-write |
| Create a pending booking + lead traveler |
7 |
| reversible-write |
| Add a co-traveler |
8 |
| reversible-write |
| Apply a discount coupon |
9 |
| money-movement |
| Pay for and confirm a booking |
10 |
| money-movement |
| Refund a confirmed booking |
11 |
| destructive-admin |
| Cancel a booking |
12 |
| destructive-admin |
| 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 |
|
|
|
|
| ✅ | — | — | — |
| ✅ | ✅ | ✅ | — |
| ✅ | ✅ | ✅ | ✅ |
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-Roleheader 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 setAUTHZ_MODE=offand let a policy layer (Votal Shield) own authorization.
Run locally
npm install
npm run build
npm start # serves MCP at http://localhost:8080/mcp
# or, live-reload during development:
npm run devSmoke-test the whole thing (initialize, tools/list, good/bad calls per role):
npm run smoke # against http://localhost:8790 by default
node scripts/smoke.mjs http://localhost:8080Raw tools/list with curl (send a role header):
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:
# 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 for the full good/bad matrix.
Deploy to Railway
Push this repo to GitHub, then in Railway: New Project -> Deploy from GitHub repo and select it. Railway detects
railway.json+Dockerfileand builds.(Optional) Variables:
AUTHZ_MODE(headerdefault, oroff),DEFAULT_ROLE. Do not setPORT— Railway injects it.Settings -> Networking -> Generate Domain. Your endpoint is that domain +
/mcp.Health check is wired to
/healthzinrailway.json.
Any container host works the same way: build the Dockerfile, provide $PORT.
Endpoints
Method | Path | Purpose |
POST |
| MCP Streamable HTTP (JSON-RPC: initialize, tools/list, tools/call) |
GET |
| Health check ( |
GET |
| Human-readable info: tools, roles, authz mode |
Environment variables
Var | Default | Notes |
|
| Host injects this (Railway sets it automatically) |
|
|
|
|
| Role assumed when no |
|
| Tenant sent to Shield when the client does not send |
|
|
|
| unset | Shield API base URL, e.g. |
| unset | Tenant API key sent as |
|
| Agent key/id sent to Shield tool guard endpoints |
|
| Per-check timeout |
| tool check | Comma-separated endpoints called before tool execution |
| 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=offhere, 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:
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 redactedVoyager sends Shield a JSON payload containing:
{
"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:
{ "allowed": false, "action": "block", "reason": "adversarial payload" }or:
{ "allowed": true, "action": "redact", "sanitized_output": "..." }Run the local eval with a mock Shield server:
npm run shield:evalFor a real Shield server:
SHIELD_MODE=enforce \
SHIELD_BASE_URL=https://api.guardrails.votal.ai \
SHIELD_API_KEY=... \
SHIELD_AGENT_KEY=voyager-mcp \
npm startData 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.