Expense Desk MCP
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@Expense Desk MCPshow me the pending expense claims waiting for approval"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Expense Desk MCP
An expense-approval MCP server secured with ThunderID.
Most MCP authorization writing stops at "check the scope". This repo goes further, because its tools have real consequences — one of them moves money — and that forces the question a scope cannot answer: is this particular call legitimate?
Three layers, each answering a different question:
Layer | Question | Where it lives | Example |
1. Token scopes | May this caller use this tool at all? | ThunderID issues, this server checks | andrea has no |
2. AuthZEN decision | Is that still true right now? | ThunderID's PDP, asked per call | mike loses the Approver role at 10:00; his 10:05 call is denied on a token that still says otherwise |
3. Application rules | Is this specific call legitimate? | authz/rules.py — always your own code | mike may approve, but not his own claim, and not over $5,000 |
Tools and scopes
Tool | What it does | Scope |
| List claims, optionally filtered |
|
| Fetch one claim in full |
|
| Create a claim |
|
| Approve a pending claim; releases payment |
|
| Move money back to a customer |
|
MCP resource servers use flat tool permissions: the handle is the scope, with
no prefix and no delimiter applied. That is why these read expenses-read and not
expenses:read — the colon form belongs to hierarchical API resource servers.
Related MCP server: Expense Tracker MCP Server
Who sees what
Principal | Role | Scopes | Tools visible |
| Expense Employee | read, submit | 3 |
| Expense Approver | read, submit, approve | 4 |
| Expense Triage Agent | read | 2 |
Nobody holds expenses-refund, so issue_refund is visible to no one. A permission
granted to no role is unreachable — a legitimate way to park a dangerous tool.
Run it
Needs Python 3.12+, uv, and a local ThunderID.
uv sync
cp .env.example .env # then fill in the values below
uv run python server.pyThe server listens on http://localhost:8000/mcp/.
ThunderID setup
Create a resource server: type MCP, identifier
http://localhost:8000/mcp, delimiter:.On Capabilities, add four tool permissions. Type only the Name and let ThunderID derive the handle:
Expenses Read→expenses-read, and likewise for Submit, Approve and Refund. Check each handle before saving — it is the exact scope string this server checks, and it is immutable afterwards.Create three roles granting those permissions (see the table above) and assign them to your users and agent.
Enable Dynamic Client Registration in ThunderID's
deployment.yamlso MCP clients can self-register:oauth: dcr: enabled: true insecure: true # local dev only: lets any client register unauthenticated
setup/thunderid-config.yaml declares the resource
server, roles, users and agent for ThunderID's declarative import. The four tool
permissions are not in it — the importer can only express actions nested under a
resource, which yields hierarchical expenses:read strings. Add those four through
the console or the REST API; the file documents the calls.
Connecting Claude Code
claude mcp add --transport http expense-desk http://localhost:8000/mcp/Then /mcp → authenticate. ThunderID's local certificate is self-signed, so point
Node at it first:
$env:NODE_EXTRA_CA_CERTS = "<thunderid>\config\certs\server.cert"Seeing the layers work
uv run python tools/audit_report.py # every decision, tagged with the layer that made it
uv run python tools/inspect_token.py --agent # what a token actually carriesSwitch layer 2 on and off with AUTHORIZATION_MODE=authzen|scopes, and turn
authorization off entirely with AUTH_ENABLED=false to see the unprotected server
the blog post opens with.
Troubleshooting
Every one of these cost us time while building it.
Symptom | Cause | Fix |
A tool never appears, no error anywhere | The scope string in code does not match the permission handle in ThunderID | Compare them character by character. A missing scope looks exactly like a tool that was never written |
Tool list is empty after connecting | The role is registered but not assigned, so the scope is silently dropped from the token | Assign the role, then reconnect |
Permissions look right but nothing matches | You typed | The name is decoration, the handle is the contract. Handles are immutable — delete and recreate |
| Node will not trust ThunderID's local certificate |
|
| A previous connection attempt registered the client and then failed | Delete that application in the console and reconnect |
| The authorization server is down, not the MCP server | Check ThunderID is running. The MCP server only points clients at it |
| Client id and secret sent in the POST body | Send them as HTTP Basic ( |
Rules never fire; |
| Read config lazily, and fail closed on an unrecognised caller |
Approvals silently revert | An older version kept state in memory and lost it on restart | Fixed: state persists to |
Layout
server.py FastMCP server, tools, ThunderID auth wiring
expense_store.py seed data, persisted to expenses.json
audit.py one JSON line per decision
authz/
scopes.py layer 1: the scope strings and the tool→scope map
middleware.py layer 1 enforcement + the layer 2 call
authzen.py layer 2: the PDP client
rules.py layer 3: business rules
principals.py maps a token subject to an application user
tools/
audit_report.py renders audit.jsonl as a table
inspect_token.py decodes a token's claims
setup/
thunderid-config.yaml declarative IAM setupLicense
MIT
This server cannot be deployed
Maintenance
Related MCP Connectors
Corporate travel booking and expense management for TripGain, exposed as an MCP server.
Manage expenses, corporate cards, accounts payable, and accounting integrations
Track expenses, budgets, balances, transfers, and multi-currency reports with OAuth-secured tools.
- ManiloOAuthapp.ledgy.api
Log, query, and edit expenses, budgets, and accounts in Manilo (formerly Ledgy) from any MCP-compatible AI assistant.
Related MCP Servers
- FlicenseBqualityDmaintenanceEnables users to add, list, and summarize expenses, and exposes expense categories as a resource.3-
- FlicenseNot gradedqualityCmaintenanceProvides remote expense management and analysis with CRUD operations and summary reports.-
- AlicenseNot gradedqualityBmaintenanceEnables MCP-compatible AI clients to manage expenses by creating, retrieving, listing, updating, and soft-deleting records with validation, idempotency, and duplicate detection.MIT
- FlicenseCqualityCmaintenanceEnables users to add expenses, retrieve expenses by date range, and get spending summaries by category through MCP tools.4-