Projects › MCP Tools + RBAC
Watch the demo (AI-narrated, 1:11)
Narrated with a generated voice; captions included.
The server inside the official MCP Inspector, connected as a guest: four tools, a policy search with its sources, another guest's booking reported as not found, and the guest's own booking with guest fields only. Recorded against a local copy with synthetic data.
Read the narration
This is the hotel group's tool server, inside the official MCP Inspector,
a standard client anyone can use.
I'm connecting with a guest's key, and all the data is made up.
A guest sees four tools.
Front-desk staff would see five, and a manager six.
The role decides which tools even exist.
I'll ask the policy search whether I can bring my dog to the beach resort.
It returns the passages with their source,
and a note that the text is reference data, never instructions.
Now I'll try someone else's booking.
The server says it doesn't exist,
so a guest can't even find out that it's there.
My own booking works,
and it shows only what a guest is allowed to see.
No name, no email, no phone number.
Every call is checked against the role and written to an audit log.
In the eval, ninety-nine out of ninety-nine checks pass, across three runs.
And the server calls no model, so a call costs nothing.
The code is on GitHub, and the link's below.
Free hosting: the first call after a quiet spell may need a moment to wake.
Hotel Tools MCP Server
The hotel group's tools for any MCP client, such as Claude Desktop or Claude Code: the caller's role decides which tools even exist and which fields come back, another hotel group's data looks like it isn't there, and every call lands in a tamper-evident audit log.
Independent project: a simulated client engagement for the same fictional hotel group as the other stages, with synthetic data. Every number below comes from a saved report or test run in the repository. A second synthetic hotel group exists only to prove tenant isolation.
Live: a public, read-only copy at https://hotel-tools-mcp.vercel.app/mcp, connected as one made-up guest with no key and 10 calls a minute. On 2026-10-28 it passed 8 of 8 checks with the official MCP Python SDK client. How to connect →
Problem
Every new AI client (the stage 2 agent, Claude Desktop for managers, a future guest assistant) would re-implement the same booking lookup, the same $50 front-desk credit limit, the same “never read another guest's booking” rule and the same defence against instructions planted in documents. Copying those rules into every client is how one of them ends up without them. IT's biggest worry in discovery was someone tricking the AI into doing something.
Approach
- One server, seven typed tools: policy search (stage 1's search, copied at v1.6.0 and checked in CI), booking lookup, activities, booking requests, credit requests, credit approval, audit log.
- Roles by API key: guest, front desk, manager, auditor. One server process serves one key and registers only that role's tools, and every call is checked again.
- Field views and limits: a guest sees six fields of their own bookings only; front desk issues credits up to $50, above that waits for a manager; the auditor reads the audit log and nothing else.
- Tool results are data: sentences that read like instructions to an AI are removed from passages, booking notes and activity descriptions before they leave the server.
- Audit: one record per call (allowed or not) with role, hashed subject, tool, a hash of the arguments, status and the previous record's hash, so editing a line breaks the chain.
Architecture
- An MCP clientClaude Desktop or Code, the MCP Inspector, or any client.
- hotel-tools serverStarts with a key; the key's hash maps to a role, a hotel group and a subject. Only that role's tools are listed.
tools.call()on every callRate limit per key, then the role check.- Tenant-scoped storeAnother hotel group's booking looks like it isn't there.
- Audit logRole, hashed subject, tool, a hash of the arguments, status and redacted detail; each entry chained to the previous one's hash.
- The public demo endpoint is the same server over Streamable HTTP, pinned to one synthetic guest with no write tools (decision 15).
docs/ARCHITECTURE.md in the repository (its request sequence diagram).A real output
“Booking BR-1042: the room was noisy last night. Can we get a $75 credit and a late check-out?”
guest: lookup_booking found, 6 fields · issue_credit_request refused: the guest role may not use issue_credit_request · create_booking_request PENDING
front_desk: lookup_booking found, 9 fields · issue_credit_request pending_manager_approval · create_booking_request PENDING
manager: lookup_booking found, 11 fields (incl. phone, rate, notes) · issue_credit_request approved · create_booking_request PENDING
auditor: get_audit_log 9 records, chain intact: True
The output of python scripts/demo_roles.py (the real tool code, no model, no keys), re-run on main (3e3e700) on 2026-10-28 with the same results as the README; request IDs left out. The role decides the outcome: a $75 credit is over the front desk's $50 limit, so it waits for a manager.
Results
| Check | Result |
|---|---|
| Dev cases: roles allowed and denied, field views, tenant isolation, credit thresholds, injection in results, rate limit, input validation (33) | 99/99 |
| Held-out cases, written after the code (6) | 18/18 |
Stage 1's labelled retrieval questions through search_policy | 45/45 |
| Cost per 1,000 tool calls | $0.00 |
What the eval taught me
The first run scored 97%. The three failures were one case run three times, and the server was right: an auditor of the second hotel group had no audit records yet, so my check compared an empty list with the expected group. An empty result can pass or fail a check for the wrong reason, so the check now fails on an empty list and the case first makes a call in each group.
Trade-offs
| Choice | Why | What it costs |
|---|---|---|
| One process per key (stdio) | The role is fixed; the client never sees other tools | Doesn't scale to thousands of guests; HTTP with OAuth is the path |
| Another group's record is “not found” | Confirms nothing about IDs that exist | Support can't tell a typo from the wrong group without the audit log |
| Pattern-based injection removal | Deterministic and tested | A new phrasing can get through; clients are also told results are data |
| Local files, not a database | Runs anywhere in two minutes | Postgres with row-level security is the scale path |
Engineering
- Tests: 81, 90% coverage, including every role × every tool and a stdio round trip with the server as a separate process, the way Claude Desktop starts it.
- CI: ruff, pytest, the eval gate at 100%, a check that the copy of stage 1's search matches its pinned commit, pip-audit, a link check and gitleaks.
- SDK: the official MCP Python SDK's
MCPServer, the class formerly called FastMCP.
Business case (projection)
A projection, not a result: each new AI client connects to tools that already enforce the hotel's limits instead of re-building them, and IT gets one place to grant, limit and audit AI access. A pilot would measure time to connect a new client and the denied calls and removed instructions per week in the audit log.
Limitations
- Demo authentication: API keys in a local file. docs/AUTH.md shows how OAuth / SSO replaces it.
- JSON files and one process per key: fine for a pilot, not for scale (see Design at scale).
- The injection guard is pattern-based: it catches the phrasings in the eval, not every possible one. Clients are also told, in the server instructions and in every result, that results are data.
- No PMS integration: requests and credits are recorded, never posted.
- The public endpoint's rate limit and audit log live in each running instance, so they reset on a cold start (decision 15).
What's next
- OAuth tokens from the hotel's identity provider instead of API keys (the public endpoint already speaks Streamable HTTP)
- Postgres with row-level security as a second wall between hotel groups