A Model Context Protocol server that lets a restaurant operate its point-of-sale by talking to an LLM agent — edit the menu, 86 an item, check sales, restock, watch for voids.
Twenty tools, two transports, one codebase:
- stdio — for local clients (Claude Desktop, Claude Code, Copilot in VS Code). One process, one merchant, credentials from the environment.
- Streamable HTTP — for cloud agents that can only reach a public endpoint. One endpoint serves every merchant, each request scoped by the caller's own API key.
This is extracted from a multi-tenant POS running in production. It's published as a reference for three things that were not obvious when I built it: making one tool implementation serve both transports, doing per-request tenancy in ASGI without leaking between concurrent requests, and the DNS-rebinding default that breaks MCP behind any reverse proxy.
On the remote transport there is no tenant identifier on the wire. The caller sends their API key; the key alone determines which merchant's data they get, resolved server-side.
def _get(path, params=None):
slug = _current_slug()
merged = {**(params or {})}
if slug: # stdio mode cross-checks the slug…
merged["slug"] = slug
... # …remote mode sends none, so there is nothing to tamper withA client cannot ask for another merchant's data, because there is no field in which to ask.
The per-request key is bound to a contextvars.ContextVar so the tool coroutine can read it
without threading an argument through every call site.
A correction worth recording. I originally wrote this as pure ASGI because I believed
Starlette's BaseHTTPMiddleware dropped contextvars set before call_next — it runs the
downstream app in a separate task, and that was a long-standing complaint. On Starlette 1.0 that
does not reproduce: I tested plain handlers, streaming response bodies, and background tasks,
and the contextvar propagates correctly in all three. If you're carrying that belief around from
older Starlette, re-test it.
The reasons to still prefer pure ASGI for an auth boundary are narrower and worth stating honestly:
- Reject before the app runs. A missing key returns 401 without the request ever reaching routing, session handling, or the MCP app. Nothing downstream allocates.
- No extra task hop wrapping a streaming response.
BaseHTTPMiddlewareinterposes itself between the app and the transport; for a streaming JSON transport there's no reason to pay for that. - The binding and the handler stay in one task, which makes request-scoped state easy to reason about rather than dependent on framework internals that have changed before and may change again.
class ApiKeyAuth:
async def __call__(self, scope, receive, send):
...
token = server._REQ_KEY.set(key)
try:
await self.app(scope, receive, send)
finally:
server._REQ_KEY.reset(token)Verified against Starlette 1.0 with a reproduction script before writing this — see the correction above.
The SDK enables DNS-rebinding protection with a localhost-only host allowlist by default. Put the server behind any reverse proxy and every request returns:
421 Invalid Host header
with nothing indicating a host allowlist is the cause. The fix is to configure the allowlist explicitly rather than to disable the protection:
server.mcp.settings.transport_security = TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=_ALLOWED,
allowed_origins=[...],
)Set ALAMOAI_POS_MCP_ALLOWED_HOSTS to your public hostname(s). The endpoint stays bearer-gated
either way.
stateless_http = True and json_response = True. Every POST is self-contained, which keeps the
auth contextvar bound to exactly one request's task — and it matches what cloud MCP clients
expect, since SSE support was dropped from that ecosystem in late 2025.
Tool errors are phrased as instructions, not status codes, because the consumer is a language model deciding what to do next:
if r.status_code == 404:
raise PosError("Not found. Check the id or name — list the menu/inventory "
"first to see valid values.")"404" makes a model guess. "List the menu first" makes it recover.
Menu list_menu · add_menu_category · add_menu_item · update_menu_item ·
set_item_availability · set_item_online · publish_category_online · set_store_type
Inventory inventory_status · lookup_product · receive_stock · set_stock ·
restock_ingredient · record_waste
Operations sales_today · cost_report · open_orders · void_report
Payments payment_status · create_onboarding_link
Tools resolve items and ingredients by name, not by id, so the model doesn't have to carry identifiers across turns.
pip install -r requirements.txt{
"mcpServers": {
"pos": {
"command": "python3",
"args": ["/path/to/pos-mcp-server/server.py"],
"env": {
"ALAMOAI_POS_API_KEY": "alamopos_yourkeyhere",
"ALAMOAI_POS_SLUG": "your-restaurant"
}
}
}
}pip install -r requirements-remote.txt
ALAMOAI_POS_MCP_ALLOWED_HOSTS=your.domain \
ALAMOAI_POS_MCP_PATH=/pos-mcp \
python3 remote_server.pyCallers authenticate per request:
Authorization: Bearer alamopos_…
or X-Alamoai-Key: alamopos_…. Health check at /healthz. Missing key returns 401 before
anything else runs.
| Variable | Default | Notes |
|---|---|---|
ALAMOAI_POS_API_KEY |
— | Required in stdio mode |
ALAMOAI_POS_SLUG |
— | Required in stdio mode; unused in remote mode |
ALAMOAI_POS_API_BASE |
https://alamoai.org/api |
Backing REST API |
ALAMOAI_POS_MCP_HOST |
127.0.0.1 |
Remote mode bind address |
ALAMOAI_POS_MCP_PORT |
8791 |
Remote mode port |
ALAMOAI_POS_MCP_PATH |
/mcp |
Public path for the endpoint |
ALAMOAI_POS_MCP_ALLOWED_HOSTS |
localhost,127.0.0.1:8000 |
Set to your public hostname |
Thin by intent. Every tool is one REST call against an API that already enforces tenancy and role permissions. This process holds no secrets beyond the one key and can grant nothing the key doesn't already carry. If it were compromised it would be a worse client, not a wider door.
API keys authenticate through the same path as interactive sessions. Because a key resolves through the identical session-resolution code as a human bearer token, every tenant-scoped, role-gated endpoint in the backing API accepted key auth with zero per-endpoint changes.
Build once, expose twice. The write functions behind these tools also back a web catalog editor. The MCP server was not a side integration — it was a second front end onto the same core.
Apache-2.0. See LICENSE.