Skip to main content
Glama
markonu

last-mile-mcp

by markonu

last-mile-mcp

tests

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

Latest Blog Posts

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