Building an MCP server for a hotel PMS
Tuesday, 9:00: Karim wants the PMS server live for three partner hotels by Friday. The prototype exposes 46 tools behind one shared admin key. Lina leans back: "You lead this one."
Now you build the other side: one small program that lets any AI app safely talk to a hotel's booking system.
- Problem
Your hotel group wants its PMS usable from several AI apps (a staff desktop assistant, a guest chatbot, an internal agent) without building a custom integration for each.
- Why the simple system fails
Copying the same tool code into each app drifts out of sync, every app needs PMS credentials, and nobody owns the security of the integration.
- Engineering concept
Wrap the PMS once as an MCP server: a small, well-designed set of tools, resources and prompts, served over the right transport, authorized per user, tested like an API and reviewed like an attack surface.
- Technology
MCP TypeScript SDK, JSON Schema/Zod, stdio for local, Streamable HTTP for remote, OAuth 2.1 with audience-bound tokens, MCP Inspector and in-memory client tests.
- Trade-offs
Local stdio servers are simple but run with the user's machine privileges and don't share; remote HTTP servers are shareable and centrally governed but need auth, hosting and multi-tenant isolation.
The problem
2 min- Karim · CTOThree partner hotels go live Friday: Lisbon, Amsterdam, Nice. The prototype works in the demo.
- Omar · On-call SRE46 tools, one per PMS endpoint, refunds included. One shared PMS admin key. Partner tokens forwarded straight to the PMS.
- Lina · Senior engineer, your mentorYou lead this one. I'll check your answers.
The big idea
4 minBuild the server to be safe even if every call is adversarial: few tools, tokens meant for this server, permissions checked on every call.
A prompt saying "only use read tools" would not help: prompts guide, servers enforce. Start from four questions: which primitive, which transport, who is calling for which hotel, and what's the worst single call.
How it works
5 min- 1Pick primitives
Who decides to use it? The model → tool. The app → (policies, room types). The user → ("draft a guest reply"). Result: 7 tools, 2 resources, 1 prompt.
- 2Cut the surface
4 reads and 3 narrow writes (add a note, set an arrival time, change dates). No refunds, no rates. Tight schemas from the tool-design episode.
- 3Choose the transport
Partners are remote and many: behind our gateway, with TLS and per-partner rate limits. stdio is for one user on one machine.
- 4Check every token
Our server is a resource server: it checks signature, expiry, audience (the server a token was issued for) and like
reservations:read. Then thehotel_idsclaim on every call. - 5Own your credential
Call the PMS with the server's own narrow key. Never forward the partner's token: token passthrough () is forbidden by the spec.
- 6Test like an API
Tests through an in-memory client, snapshots of descriptions, the , an audit log of every write. Annotations like
readOnlyHinthelp host UIs but protect nothing.
Watch a decision being made
4 minYou build the partner PMS server; Lina checks
Turn the 46-tool prototype into a server three partner hotels can use from their own assistants by Friday.
- 1Cut 46 endpoints to 7 tools
Reads:
search_availability,get_reservation,get_arrivals,quote_date_change. Writes:add_reservation_note,set_arrival_time,change_reservation_dates.Why: 7 reviewed tools are a surface you can defend; 46 unreviewed ones are not.
- 2Authorize every call
Middleware checks the token (including audience
https://mcp.stayhub.example/mcp). Each handler checks scope and hotel.tsconst user = requireScope(extra, "reservations:write", args.hotel_id); await assertOwnsHotel(user, args.reservation_id); audit.log({ user: user.id, tool: "add_reservation_note", args });Why: A valid token says who's calling, not what they may touch. Permissions come from the token, never from arguments the model filled in.
- 3Call the PMS as the server
Its own key, limited to the 7 operations. The partner's token never leaves the server.
Why: The PMS only ever sees our server, and the audit log shows which partner asked for what.
- 4Test at the protocol level
Snapshot every description; prove a Lisbon token can't read Amsterdam. Lina: "One thing: rate limits per partner. Otherwise, ship it."
Why: Handlers can pass unit tests while a real client sees broken schemas or leaky errors.
In real life
3 minThe MCP project's reference filesystem server: local
- An open-source example a host launches on your machine (stdio)
- Reads and writes files in folders you allow
- No network surface; runs with your permissions
Perfect for one person and local data.
GitHub's hosted MCP server: remote
- Publicly announced as a remote server users sign in to
- One deployment for many users and hosts
- Permissions from the user's sign-in, managed centrally
Built for a shared system; needs real auth and operations.
What goes wrong
3 minBreaks audience checks, muddles the audit trail, forbidden by the spec.
Validate the token for your server; use your own scoped credential.
readOnlyHint and destructiveHint are UI hints; hosts may ignore them.
Enforce permissions in the server.
stdout carries JSON-RPC; a stray log corrupts the stream.
Log to stderr or a file.
Trade-offs
3 min| Server | Choose when | You pay |
|---|---|---|
| Local stdio | Single-user tools, local files, demos | User's machine privileges; no central audit |
| Remote streamable HTTP | Shared systems for many people or partners | OAuth done right; public attack surface |
| No MCP: tools in your agent | Exactly one agent will ever use it | Not reusable |
- Karim · CTOPartner OAuth is new and Friday is close. Static API keys for the pilot, read-only. OAuth later.
- Omar · On-call SREStatic keys end up in config files and chat logs. "Later" for auth usually means after the first incident.
- Karim · CTOThree trusted partners, read-only, one month. The deadline is real.
Your job as tech lead
4 minClaude Code can write the code for this. You still own the decisions, and you have to know what good looks like to accept or reject its work.
- What the server offers: 7 intent-level tools, 2 resources, 1 prompt, not 46 mirrored endpoints.
- Transport and sign-in: streamable HTTP, OAuth tokens with our audience and two scopes, and the
hotel_idsclaim checked on every call. - How the server reaches the PMS: its own least-privilege credential, never the partner's token or a shared admin key.
Build the StayHub PMS MCP server for the partner pilot (live Friday). Expose only what `docs/mcp-pms.md` lists: 7 tools, 2 resources, 1 prompt, over streamable HTTP. On every request validate the token's signature, expiry, audience (`https://mcp.stayhub.example/mcp`) and scopes, and filter every result by `hotel_ids`. Call the PMS with the server's own scoped credential; never forward the partner's token. Writes need an idempotency key and an audit entry. Log to stderr. Plan first, then add in-memory client tests, including Lisbon asking for Amsterdam.
- The cross-hotel test: a Lisbon token asking for Amsterdam gets an error, never the 212 arrivals.
- A valid token issued for another service gets 401.
- No code sends the partner's token to the PMS.
- One tool per PMS endpoint.
- Only signature and expiry checked, not audience, scopes or
hotel_ids. destructiveHintas the only protection on write tools.
Check yourself
2 minNext episode: a guest told the concierge in March that she's allergic to feathers. In June it booked her a room with feather pillows, and asked her again.
Mark it complete to update your skill map.
Go deeper (optional)You don't need this to understand the lesson. Open it when a project needs the details: Technical deep dive, Interactive simulation, Visual diagram, Analogy, Why it matters, Check your understanding, Practice ladder, Explain it yourself.
Technical deep dive
You build the partner PMS server (Lina checks)
Turn the 46-tool prototype into a server three partner hotels can use from their own AI assistants by Friday.
- 1Choose the primitives
Tools:
search_availability,get_reservation,get_arrivals,quote_date_change(reads; the quote changes nothing);add_reservation_note,set_arrival_time,change_reservation_dates(writes). Resources:pms://hotels/{id}/policies,.../room-types. Prompt:draft_guest_reply.Why: Who decides when it's used picks the primitive. Stable reference text is a resource; it doesn't need a model decision or a tool slot.
- 2Cut the surface
Drop refunds, rate changes and raw updates. Each tool uses the tight schemas from the tool-design episode: enums, dates, patterns, a
quote_idfor anything with a price.Why: The server's reach is the attacker's reach. 7 reviewed tools are a surface you can defend; 46 unreviewed ones are not.
- 3Pick the transport
Serve
buildServer()over streamable HTTP at/mcp, stateless, behind our gateway with TLS and per-partner rate limits.Why: Partners are many and remote, and we need central audit. Stateless mode scales horizontally like any API.
- 4Authorize every call
Middleware checks signature, expiry, audience
https://mcp.stayhub.example/mcpand scopes. Each handler callsrequireScope(extra, scope, hotel_id)to check thehotel_idsclaim.tsconst user = requireScope(extra, "reservations:write", args.hotel_id); await assertOwnsHotel(user, args.reservation_id); audit.log({ user: user.id, client: extra.clientId, tool: "add_reservation_note", args });Why: A valid token says who's calling, not what they may touch. Permissions must come from the token, never from tool arguments the model filled in.
- 5Use the server's own credential
The server calls the PMS with its own key, limited to the 7 operations. The partner's token never leaves the server.
Why: No token passthrough: the PMS only ever sees our server, and the audit log shows exactly which partner asked for what.
- 6Test at the protocol level
In-memory client tests: the tool list and descriptions match a snapshot; a Lisbon token can't read Amsterdam; write errors read well. Then explore by hand in the MCP Inspector.
Why: Handlers can pass unit tests while the real client sees broken schemas or leaky errors. Description snapshots make prompt changes reviewable.
| stdio | Streamable HTTP | |
|---|---|---|
| Runs where | On the user's machine, launched by the host | Our infrastructure, one URL |
| Who can use it | One user, one host | Many users and hosts |
| Auth | Env vars, local credentials | OAuth bearer tokens on every request |
| Network surface | None | TLS, rate limits, Origin checks |
| Updates | Each user updates their install | Deploy once |
Interactive simulation
Visual diagram
One PMS MCP server, two kinds of hosts: a local desktop app over stdio and a remote agent over Streamable HTTP with OAuth.
Analogy
- A hotel's agency deskA menu of requests the desk acceptsAn MCP serverTools
- A hotel's agency deskA binder of public documentsAn MCP serverResources
- A hotel's agency deskTemplate letters to start fromAn MCP serverPrompts
- A hotel's agency deskInside phone vs public phone lineAn MCP serverstdio vs streamable HTTP
- A hotel's agency deskChecking a badge issued for this deskAn MCP serverValidating the sign-in token
- A hotel's agency deskNever handing out the master keyAn MCP serverNever passing tokens through
Where the analogy breaks: a desk clerk can tell a guest's note from a manager's order. A model can't always, so your server must treat every guest-written note as plain data.
Why it matters
Without a server, each AI app builds its own booking-system connection. Three apps and two booking vendors means six connections. Six sets of bugs, six sets of passwords, six ideas of what "cancel" means.
One server gives you one owner. One place to fix a bug or change a password. One set of rules for who can see which bookings, no matter which app asks. And any MCP app can use it without new code.
The flip side: a badly built server is a shared weakness. Every connected app inherits its bugs and its too-broad permissions.
Check your understanding
Practice ladder
Trace three requests
Scenario Warm-upYour server's middleware checks signature, expiry, audience https://mcp.stayhub.example/mcp and scopes; each handler checks hotel_ids. Trace what happens to each request:
- Nice partner token: audience correct, scopes
reservations:read, hotel_ids["NCE-02"]. Callsget_arrivals({ hotel_id: "NCE-02", date: "2026-10-01" }). - Same token. Calls
add_reservation_note({ reservation_id: "NCE-20417", note: "late arrival" }). - A token for
https://api.stayhub.example/loyalty, scopesreservations:read, hotel_ids["NCE-02"]. Callsget_arrivals({ hotel_id: "NCE-02", ... }).
Finish requireScope
Code PracticeLina's requireScope has two gaps. Fill them in so a call fails when the scope is missing, and when the hotel isn't the caller's, without revealing whether that hotel exists.
function requireScope(extra: RequestExtra, scope: string, hotelId?: string) {
const user = extra.authInfo; // set by the token middleware
if (!user) throw unauthorized();
// gap 1: ___
// gap 2: ___
return user;
}A housekeeping server for staff phones
Design PracticeHousekeeping staff in 40 StayHub-managed hotels want their phone assistant to show their room list and mark rooms clean. Design the MCP server: primitives (tools, resources, prompts) with read/write, transport, token checks, the downstream credential, and two protocol-level tests you'd write.
A partner asks for run_report(sql)
Decision StretchThe Nice partner's data team asks for a tool run_report(sql: string) "so our assistant can answer any question about our bookings". They promise to use it only for their hotel. Karim says it would save us building many small reporting tools.
Write a short decision: "We chose X over Y because…, accepting the risk of…".
Explain it yourself
Explain how you would design and secure a remote MCP server for your hotel PMS.