last-mile-mcp
last-mile-mcp
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:
@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
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 toolsPoint 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 |
|
|
|
|
|
|
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.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/markonu/last-mile-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server