pos-mcp-server
by ryanDwright
README.md
# 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.
```python
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.** `BaseHTTPMiddleware` interposes 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.
```python
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:
```python
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:
```python
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)
```bash
pip install -r requirements.txt
```
```json
{
"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)
```bash
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](LICENSE).
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues