Skip to main content
Glama
rk9595

Voyager Travel MCP Server

by rk9595

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

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

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):

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):

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

  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:

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:

{
  "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:eval

For a real Shield server:

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.