pos-mcp-server
An MCP server that lets restaurants operate their point-of-sale through an LLM agent — edit menus, 86 items, check sales, restock inventory, monitor voids, and more via twenty tools over both stdio and Streamable HTTP transports with per-request tenancy from API keys.
README
pos-mcp-server
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.
The interesting parts
1. Tenancy comes from the key, and nothing else
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 with
A client cannot ask for another merchant's data, because there is no field in which to ask.
2. Pure-ASGI middleware for the auth boundary
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.
3. MCP's DNS-rebinding protection 421s behind a proxy
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.
4. Stateless JSON, deliberately
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.
5. Errors written for a model to act on
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.
Tools
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.
Running it
stdio (Claude Desktop, Claude Code, Copilot in VS Code)
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"
}
}
}
}
Streamable HTTP (cloud agents)
pip install -r requirements-remote.txt
ALAMOAI_POS_MCP_ALLOWED_HOSTS=your.domain \
ALAMOAI_POS_MCP_PATH=/pos-mcp \
python3 remote_server.py
Callers authenticate per request:
Authorization: Bearer alamopos_…
or X-Alamoai-Key: alamopos_…. Health check at /healthz. Missing key returns 401 before
anything else runs.
Environment
| 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 |
Design notes
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.
License
Apache-2.0. See LICENSE.
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
E2B
Using MCP to run code via e2b.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.