Skip to content
Episode 120 · Live for partners by Friday

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.

30 min advancedDesigning an MCP server: tools, resources and promptsMCP tools, resources, promptsMCP transports: stdio vs streamable HTTPAuthorizing remote MCP servers with OAuthTesting and reviewing an MCP serverMCP security & tool poisoning
  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

01

The problem

2 min
Tuesday, 9:00. The PMS server prototype is on screen.
  1. Karim · CTO
    Three partner hotels go live Friday: Lisbon, Amsterdam, Nice. The prototype works in the demo.
  2. Omar · On-call SRE
    46 tools, one per PMS endpoint, refunds included. One shared PMS admin key. Partner tokens forwarded straight to the PMS.
  3. Lina · Senior engineer, your mentor
    You lead this one. I'll check your answers.
02

The big idea

4 min
Designing a server you'd expose to strangers
The big idea

Build 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.

03

How it works

5 min
Building a server you can expose to partners
  1. 1
    Pick 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.

  2. 2
    Cut 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.

  3. 3
    Choose 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.

  4. 4
    Check 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 the hotel_ids claim on every call.

  5. 5
    Own 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.

  6. 6
    Test like an API

    Tests through an in-memory client, snapshots of descriptions, the , an audit log of every write. Annotations like readOnlyHint help host UIs but protect nothing.

04

Watch a decision being made

4 min
Worked example

You 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.

  1. 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.

  2. 2Authorize every call

    Middleware checks the token (including audience https://mcp.stayhub.example/mcp). Each handler checks scope and hotel.

    ts
    const 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.

  3. 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.

  4. 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.

The whole thing in one line: Few reviewed tools, audience-bound tokens, scope and hotel checks in every handler, the server's own credential, and tests through a real client.
05

In real life

3 min
Local vs remote, in the wild

The 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.

06

What goes wrong

3 min
Forwarding the caller's token to the PMS

Breaks audience checks, muddles the audit trail, forbidden by the spec.

Instead

Validate the token for your server; use your own scoped credential.

Relying on tool annotations for safety

readOnlyHint and destructiveHint are UI hints; hosts may ignore them.

Instead

Enforce permissions in the server.

Logging to stdout in a stdio server

stdout carries JSON-RPC; a stray log corrupts the stream.

Instead

Log to stderr or a file.

07

Trade-offs

3 min
ServerChoose whenYou pay
Local stdioSingle-user tools, local files, demosUser's machine privileges; no central audit
Remote streamable HTTPShared systems for many people or partnersOAuth done right; public attack surface
No MCP: tools in your agentExactly one agent will ever use itNot reusable
Wednesday, 17:30. Two days to the deadline.
  1. Karim · CTO
    Partner OAuth is new and Friday is close. Static API keys for the pilot, read-only. OAuth later.
  2. Omar · On-call SRE
    Static keys end up in config files and chat logs. "Later" for auth usually means after the first incident.
  3. Karim · CTO
    Three trusted partners, read-only, one month. The deadline is real.
08

Your job as tech lead

4 min

Claude 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.

Decisions you own
  • 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_ids claim 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.
Tell Claude Code
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.
Check before you accept
  • 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.
Red flags
  • One tool per PMS endpoint.
  • Only signature and expiry checked, not audience, scopes or hotel_ids.
  • destructiveHint as the only protection on write tools.
09

Check yourself

2 min
Q1/3Scenario
Your remote MCP server receives a valid OAuth token for the user. How should it call the PMS API?
How confident are you?

Next 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.

Finished?

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.
10

Technical deep dive

From 46 tools to a server you can defend
Worked example

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.

  1. 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.

  2. 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_id for 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.

  3. 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.

  4. 4Authorize every call

    Middleware checks signature, expiry, audience https://mcp.stayhub.example/mcp and scopes. Each handler calls requireScope(extra, scope, hotel_id) to check the hotel_ids claim.

    ts
    const 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.

  5. 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.

  6. 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.

The whole thing in one line: You cut 46 endpoints to 7 reviewed tools plus resources and a prompt, served them remotely behind audience-bound OAuth tokens, checked scopes and hotel claims in every handler, called the PMS with the server's own narrow key, and tested through a real client.
stdioStreamable HTTP
Runs whereOn the user's machine, launched by the hostOur infrastructure, one URL
Who can use itOne user, one hostMany users and hosts
AuthEnv vars, local credentialsOAuth bearer tokens on every request
Network surfaceNoneTLS, rate limits, Origin checks
UpdatesEach user updates their installDeploy once
11

Interactive simulation

12

Visual diagram

Click a component

One PMS MCP server, two kinds of hosts: a local desktop app over stdio and a remote agent over Streamable HTTP with OAuth.

13

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.

14

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.

15

Check your understanding

Q1/3Design
Match each PMS capability to the best MCP primitive:
Search free rooms for given dates
The hotel's cancellation policy document
“Draft a reply to this guest” command
Append a note to a reservation
How confident are you?
16

Practice ladder

Step 1 · Trace

Trace three requests

Scenario Warm-up

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

  1. Nice partner token: audience correct, scopes reservations:read, hotel_ids ["NCE-02"]. Calls get_arrivals({ hotel_id: "NCE-02", date: "2026-10-01" }).
  2. Same token. Calls add_reservation_note({ reservation_id: "NCE-20417", note: "late arrival" }).
  3. A token for https://api.stayhub.example/loyalty, scopes reservations:read, hotel_ids ["NCE-02"]. Calls get_arrivals({ hotel_id: "NCE-02", ... }).
Step 2 · Complete

Finish requireScope

Code Practice

Lina'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;
}
Step 3 · Solve

A housekeeping server for staff phones

Design Practice

Housekeeping 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.

Step 4 · Decide

A partner asks for run_report(sql)

Decision Stretch

The 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…".

17

Explain it yourself

Explain how you would design and secure a remote MCP server for your hotel PMS.

0 words