Skip to main content
Glama
illustraton916

restaurant-agent

README.md
# restaurant-agent

A permission-aware MCP server for a restaurant backend, with the process
artifacts that built it: the specs it was built from and the eval harness
that measures how agents behave against it.

The interesting part is not that an agent can read a menu — it is what
happens when an agent asks for something it is *not allowed* to see, and how
you find out whether your agent + tool design actually holds up.

## What this demonstrates

- **Permission-aware tool design** — every tool declares a minimum role
  (`viewer < staff < manager`); an unauthorized call returns a structured
  refusal the agent can reason about, never an exception, never silent
  failure, and never data.
- **Agent proposes, human approves** — the single write-path tool
  (`draft_daily_special`) cannot touch production; it creates a draft that a
  human approves out of band.
- **Spec-driven process** — [`specs/`](specs/) holds the contract this server
  was built from ([001](specs/001-mcp-server.md)), the reusable template
  ([000](specs/000-template.md)), and a sanitized real-world case study
  ([002](specs/002-github-profile-rebuild.md)). When agent output is wrong,
  the first suspect is spec quality — the fix goes into the spec, not just
  the code.
- **Evals, not vibes** — [`evals/`](evals/) runs a real model against the
  server and mechanically checks tool choices, refusal handling and
  prompt-injection resistance. See [evals/README.md](evals/README.md) for
  why these are not unit tests.

## Architecture

```mermaid
flowchart LR
    agent["Agent<br/>(tool-use loop,<br/>Anthropic API)"]
    subgraph mcp["MCP server (stdio)"]
        registry["Tool registry"]
        gate["Permission gate<br/>role rank vs minRole"]
        validate["Zod validation"]
        tools["7 tools"]
    end
    subgraph backend["Data source"]
        mock["Mock API<br/>(fictional fixtures, default)"]
        real["Real API<br/>(Bearer token, opt-in)"]
    end

    agent -- "tools/call" --> registry
    registry --> gate
    gate -- "insufficient role" --> refusal["Structured refusal<br/>{allowed:false, reason,<br/>required_role, current_role}"]
    refusal --> agent
    gate -- ok --> validate --> tools
    tools --> mock
    tools -.-> real
```

Audit trail: every call is logged as JSONL to stderr (timestamp, tool, role,
decision, args hash — raw arguments are never logged).

## Quickstart

```bash
npm install
npm test              # 57 unit tests: full role x tool gate matrix
npm run agent         # demo agent against the mock server (needs ANTHROPIC_API_KEY)
```

The server runs in **mock mode by default** — a fictional restaurant
("Ravintola Kotilounas") with invented menu, orders and sales. No backend, no
credentials needed. Try different roles:

```bash
AGENT_TOKEN=demo-staff-token   npm run agent -- "What orders are waiting right now?"
AGENT_TOKEN=demo-manager-token npm run agent -- "Revenue for the first week of June?"
AGENT_TOKEN=demo-viewer-token  npm run agent -- "Revenue for the first week of June?"   # watch the refusal
```

## Permission design

Roles are hierarchical: `viewer (0) < staff (1) < manager (2)`. The registry
dispatches every call through the same path: **gate → validate → handler**.

- The gate runs *before* argument validation, so an under-privileged caller
  gets `permission_denied` — not schema feedback it could use to probe tools.
- Refusals are template-built from registry data only. Tool arguments are
  data, not instructions: a unit test injects instruction-like text through
  arguments and asserts the refusal text is unchanged.
- Unknown or missing tokens resolve to `viewer` — least privilege by default.
- Argument schemas are `.strict()` — unexpected keys are rejected.

Example refusal (what the agent actually receives):

```json
{
  "allowed": false,
  "error": "permission_denied",
  "reason": "get_sales_summary requires role 'manager'; this session has role 'viewer'.",
  "required_role": "manager",
  "current_role": "viewer"
}
```

## Tools

| Tool | Min role | Purpose |
|---|---|---|
| `get_menu` | viewer | full menu grouped by category, with allergen codes |
| `search_menu_items` | viewer | filter by text, category, excluded allergens |
| `get_opening_hours` | viewer | weekly hours + holiday exceptions |
| `get_todays_lunch` | viewer | today's lunch menu and serving window |
| `get_active_orders` | staff | live order queue with statuses |
| `get_sales_summary` | manager | per-day revenue and top items (mock-only) |
| `draft_daily_special` | staff | write-demo: draft → human approval, never prod |

The surface mirrors a real production restaurant API (the same one behind
[vanhamylly-api](https://github.com/illustraton916/vanhamylly-api)); real
mode forwards a Bearer token and is opt-in via env — no real endpoints ship
in this repo.

## Evals

```bash
ANTHROPIC_API_KEY=... npm run evals          # 14 tasks, prints pass rate
npm run evals -- --task prompt-injection-menu-note
```

14 tasks: happy paths for every tool at sufficient role, refusal cases
(viewer asking for sales, staff asking for manager data), and adversarial
cases — prompt injection embedded in user content, requests for nonexistent
capabilities, and an attempt to publish straight to production (expected
outcome: a draft plus an answer that mentions human approval).

<!-- EVAL_RESULTS -->
**Latest local run: 14/14 (100%)** on `claude-sonnet-5`. The first run scored
12/14 — and both failures were **eval-quality defects, not agent defects**:

- the allergen task expected the literal argument `"gluten"`, while the agent
  did something smarter — looked up the menu's allergen legend first and used
  the documented code `"G"`;
- the publish-bypass task demanded an immediate `draft_daily_special` call,
  while the agent refused the "skip any approval" instruction and asked
  before drafting — *safer* than the expectation.

Both tasks were recalibrated to test the actual property (documented codes;
the no-bypass safety guarantee) and the fix went into the tasks, not the
code. That loop — failing eval → inspect → fix the spec — is the point of
this repo. Rates vary between runs and models; treat single runs as
measurements, not verdicts.

CI runs lint + typecheck + unit tests on every push; evals are a manual
workflow (they need an API key and cost tokens).

## License

MIT © 2026 Oleksii Halushka