last-mile-mcp
# last-mile-mcp
[](https://github.com/markonu/last-mile-mcp/actions/workflows/tests.yml)
A reference MCP server for the part of an AI deployment that actually decides
whether it survives: **who is calling, what they may touch, and what must never
happen without a human.**
It is deliberately small and deliberately opinionated. The interesting content
is not the tools — it is the four constraints they are built under, each of
which was paid for in production.
```
1. An identified actor per call actor.py
2. A named grant per tool grants.py
3. A hard gate on irreversible actions gate.py
4. Connectors separated from business connectors/ + domain/
```
## Why this exists
Most AI pilots do not fail on the model. They fail on the last mile: the model
has nothing real to act on, or it is given everything at once and the security
review stops it. This repository is the smallest honest example of the middle
ground — a model reaching live business objects, under constraints a reviewer
can read in one sitting.
## The four constraints
### 1. An identified actor per call
A shared MCP server has no single user. Every call runs on behalf of someone,
and the identity lives in a `ContextVar`, not a module global — the server handles
concurrent requests on one event loop, and a global leaks one caller's identity
into another's tool call under load.
There is **no anonymous fallback**. `current_actor()` raises rather than
returning a default. A server that answers without knowing who asked is a server
whose audit trail is fiction.
In production, the actor is resolved from a signed assertion issued by the
identity-aware proxy in front of the server, revalidated here rather than
trusted — so a misconfigured route cannot silently become an open one. The
bundled `StaticHeaderResolver` is for development only and fails closed on an
unknown principal exactly like the real one.
### 2. A named grant per tool
One grant, declared at the tool, checked on every call:
```python
@mcp.tool()
def customer_invoices(customer_id: str) -> list[dict[str, Any]]:
require("billing.read")
...
```
Two rules make it work.
**Fail closed.** A missing grant is a refusal, never a degraded answer.
**Refuse loudly.** This is the one most implementations get wrong. A tool that
quietly returns `[]` when the caller lacks permission teaches everyone
downstream that the data does not exist — and a model reading that empty list
will confidently tell a user their customer has no invoices. The refusal has to
be legible:
> `Support desk service token does not hold the grant 'billing.read'. This is a
> permission refusal, not an empty result: the data may well exist.`
A grant name that is not in the catalogue raises immediately, so a typo fails on
the first call rather than silently authorising in production six weeks later.
### 3. A hard gate on irreversible actions
**An agent may draft. Only a human sends.**
Every action whose effect leaves the building stops at `gate.py` and becomes a
draft in the requester's own queue. The tool result says so in words, so the
model reports *"I prepared it for you"* rather than *"I sent it"*.
This is a product decision, not a safety disclaimer. It is the reason
non-technical staff trust a system like this enough to use it daily: nothing it
does can embarrass them in front of a customer without their signature.
The line is not "writes are dangerous". `open_ticket` writes and executes
directly, because a ticket nobody has sent anywhere can be closed again.
`prepare_invoice` drafts, because an issued invoice reaches the customer and
cannot be recalled. Draw that line explicitly, per action, and write down why.
### 4. Connectors separated from business logic
`connectors/` knows how each external system authenticates, paginates and fails.
Nothing above it does. When a vendor changes an API, the change lands in one
file instead of in every tool that touched that system.
Connectors return domain objects, never raw vendor payloads. A vendor field name
that leaks upward becomes a field name in a tool schema, then in the model's
vocabulary, and then it is load bearing.
`domain/service.py` holds the joins and the rules. Tools stay thin: a rule that
lives in a tool body is a rule the next tool will not know about.
## Running it
```bash
python -m venv .venv && .venv/bin/pip install -e ".[dev]"
.venv/bin/python -m pytest # 14 tests: the constraints, as tests
.venv/bin/lastmile-mcp # stdio MCP server, 8 tools
```
Point any MCP client at it. `whoami` is the first tool to call — when someone
reports that a tool "does not work", the first question is always which token
they are on.
Three demo identities are wired in `server.py`, with deliberately different
grant sets so refusals are easy to observe:
| Principal | Grants |
|---|---|
| `svc-support` | `crm.read`, `ticketing.read`, `ticketing.write` |
| `svc-account-manager` | `crm.read`, `ticketing.read`, `billing.read`, `billing.draft` |
| `svc-readonly` | `crm.read` |
## One gotcha, written down so you don't rediscover it
`server.py` has **no** `from __future__ import annotations`. The server builds
each tool's schema by introspecting real annotation objects; under PEP 563 they
arrive as strings and the introspection fails on `issubclass`. It surfaces as an
unrelated-looking type error at import time.
A second one, if you are porting code: this targets the MCP Python SDK **2.x**,
where `FastMCP` was renamed to `MCPServer` and `Tool.inputSchema` became
`Tool.input_schema`. 1.x code imports `mcp.server.fastmcp` and fails here.
## Scope
Fixtures instead of real systems, an in-memory draft queue, and a development
actor resolver. Everything that would differ in a real deployment is behind an
interface, and the constraints above are the part meant to be copied.
MIT. Written by [Cédric Laurent](https://laurent.cloud).
TDQS
Scored across 8 tools
Each tool serves a distinct purpose: customer lookup, ticket listing, invoice listing, combined health view, ticket creation, invoice draft creation, draft listing, and identity/grants. No two tools overlap in intent, and the descriptions clarify any potential ambiguity (e.g., account_health explicitly combines data from other tools).
Naming mixes verb-first (find_customer, open_ticket, prepare_invoice) with noun-first (customer_tickets, customer_invoices, account_health) and possessive (my_drafts, whoami). The pattern is readable but not consistent, lacking a uniform verb_noun or resource_noun structure.
8 tools is well-scoped for a customer service and billing domain. Each tool covers a core operation without unnecessary bloat, and the count feels neither thin nor overwhelming.
The surface covers customer lookup, ticket listing/creation, invoice listing/drafting, health summaries, and identity. Missing update/close actions for tickets or invoices, but these are intentionally excluded (e.g., invoice issuance is human-only), so the gaps are not fatal and agents can work within the design.