Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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

About

MCP server for restaurant POS operations — 20 tools over stdio + Streamable HTTP, multi-tenant by caller API key

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages