HoldDesk
by alanwen168
README.md
# HoldDesk
> **The assistant holds, a human confirms.**
**A voice front desk for a small professional-services office where the assistant can *ask* but only a human can *approve*.**
Self-hosted [Model Context Protocol](https://modelcontextprotocol.io) server over **Streamable HTTP (MCP spec 2025-11-25)**, built for the Alexa+ track of the Amazon *Build, Ship, Shape* Developer Hackathon. Everything in this repository is new, synthetic and self-contained (MIT).
(Prototyped under the working name *AgentDesk Concierge*.)
```
Caller --voice--> Alexa+ / any MCP client --Streamable HTTP--> HoldDesk --approval queue--> Human on /desk
```
## Product principle
Voice assistants are great at *taking* a request and terrible at being *accountable* for it. HoldDesk splits the two:
| The assistant can (MCP tools) | Only a signed-in person can (`/desk`) |
|---|---|
| check openings, hold a slot, check or cancel *its own caller's* appointment, answer office FAQs from a bundled knowledge file, give a clearly non-binding home-insurance range, escalate anything | see the approval queue, approve or reject each held slot / cancellation / escalation, with their name and staff account on the audit trail |
There is deliberately **no MCP tool that resolves an approval, or even lists the queue**. A hold is never a booking until a human says so, and the assistant is told exactly that in its tool descriptions and server instructions.
## Tools
The MCP surface contains exactly **seven tools**:
| Tool | Kind | Scope | What it does |
|---|---|---|---|
| `check_availability(date, service, staff_id?)` | read-only | public | Open start times for `consultation` (30 min), `policy_review` (60), `quote_walkthrough` (45); respects hours, holidays, blocked time, held/confirmed slots, and today's clock. |
| `book_appointment(client_name, contact, service, date, start_time, staff_id?, notes?)` | proposes | user | Places a **hold** (`pending_approval`) owned by the linked account (token subject) and opens an approval request. Refuses SSN/card numbers in the name and notes. |
| `get_appointment_status(appointment_id, contact)` | read-only | user | Status lookup; only the linked account that booked it, and the booking contact (or last-4 of the phone) must match. Unknown id, another account's booking and wrong contact all return the same `not_found`. |
| `cancel_appointment(appointment_id, contact, reason?)` | proposes | user | Same ownership check, with full booking contact required (last-four is lookup-only); marks `cancel_requested` and opens an approval request; slot is kept until a human decides. |
| `answer_office_faq(question)` | read-only | public | Deterministic keyword retrieval over `data/faq.json`; returns `grounded:false` instead of guessing. |
| `estimate_home_insurance_quote_range(state, dwelling_coverage, year_built, construction_type?, roof_age_years?, deductible?)` | read-only | public | Illustrative annual/monthly **range** from a *synthetic* rate table. Always `binding:false` with a disclaimer; old roofs are refused to underwriting review. |
| `request_human_approval(action, summary, details?, urgency?)` | proposes | user | Generic escalation into the human queue (callback, binding quote, speak to advisor...). SSN/card numbers anywhere in `action`, `summary` or the keys/values of `details` are refused, nothing is stored. |
The approval queue is staff-only: it is shown on the desk (and its JSON API) after staff sign-in, never over MCP.
Also exposed: resource `office://info` (JSON office profile) and prompt `front_desk_greeting`.
Every result carries a `spoken_summary` string written for voice, plus structured fields for screens (MCP `structuredContent`).
Domain refusals come back as `{"ok": false, "error": {"code": ..., "message": ...}, "spoken_summary": ...}` so the assistant can recover conversationally; schema violations surface as normal MCP tool errors.
## Quick start
```bash
git clone https://github.com/alanwen168/holddesk.git
cd holddesk
python3 -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
python -m pytest # every tool incl. refusal/validation paths, live HTTP, desk login/CSRF, OAuth AS + scopes, Alexa+ simulator
python -m holddesk # MCP: http://127.0.0.1:8641/mcp Human desk: http://127.0.0.1:8641/desk (staff sign-in)
```
Options: `--auth static|oauth` (see *Authentication for Alexa+*), `--port`, `--host`, `--stateful` (issue `MCP-Session-Id` instead of stateless mode), `--json-response` (plain JSON instead of SSE), `--allowed-host`/`--allowed-origin` (needed behind a tunnel, see below).
Environment: `HOLDDESK_PORT`, `HOLDDESK_HOST`, `HOLDDESK_BEARER_TOKEN`, `HOLDDESK_STATE_FILE` (persist the in-memory desk to JSON), `HOLDDESK_FIXED_NOW` (freeze the clock, e.g. `2026-09-14T10:00:00`, for reproducible demos), `HOLDDESK_ALLOWED_HOSTS`, `HOLDDESK_ALLOWED_ORIGINS` (comma-separated), `HOLDDESK_AUTH` (`static`/`oauth`), plus the `HOLDDESK_OAUTH_*` / `HOLDDESK_DEMO_*` / `HOLDDESK_DESK_*` variables below.
### Try it with the MCP Inspector
```bash
npx @modelcontextprotocol/inspector # web UI: choose "Streamable HTTP", URL http://127.0.0.1:8641/mcp
npx @modelcontextprotocol/inspector --cli http://127.0.0.1:8641/mcp --transport http --method tools/list
npx @modelcontextprotocol/inspector --cli http://127.0.0.1:8641/mcp --transport http --method tools/call \
--tool-name check_availability --tool-arg date=2026-09-16 --tool-arg service=consultation
```
### Try the whole journey with the official Python client
```bash
HOLDDESK_DESK_PASSWORD=<desk password> python scripts/smoke_client.py http://127.0.0.1:8641/mcp --date 2026-09-16
```
It calls every tool, signs in to the desk and approves the held slot through the desk JSON API, and prints the transcript. Open `http://127.0.0.1:8641/desk` in a browser and sign in to approve/reject by hand instead.
### The human desk (staff sign-in)
`/desk` is the most consequential surface, so it has its own staff login, separate from the callers' OAuth consent login:
* `HOLDDESK_DESK_USERNAME` (default `staff`) / `HOLDDESK_DESK_PASSWORD`. If the password is unset a random one is generated for the process and printed once to stderr (development only).
* Session cookie `holddesk_desk`: opaque random id (only its SHA-256 is kept, in memory), `HttpOnly`, `SameSite=Strict`, `Path=/desk`, 8 h, and `Secure` when the public URL / OAuth issuer is `https://`.
* CSRF: every state-changing request needs the session's CSRF token (hidden `csrf_token` field in the desk forms, `X-CSRF-Token` header on `POST /desk/api/approvals/{id}/decision`); the login form carries a double-submit token.
* Failed logins are throttled per client IP + username (5 failures -> 30 s lockout, doubling up to 15 min) and per IP across usernames (20); during a lockout the password is not even checked. The OAuth consent login uses the same throttle.
* Framing: `HOLDDESK_DESK_FRAME_ANCESTORS` (space/comma-separated origins) becomes `Content-Security-Policy: frame-ancestors <origins>`; unset means `frame-ancestors 'none'` + `X-Frame-Options: DENY`. The simulator launcher sets it to the simulator origin only.
* JSON API for scripts: `GET /desk/api/session` (returns the CSRF token), `GET /desk/api/approvals`, `POST /desk/api/approvals/{id}/decision`; all `401` without a session.
The throttle is in-memory and keyed on the client IP as seen by the server: behind a reverse proxy all clients share the proxy's IP, so a burst of failures can lock the (single) demo account out for everyone for a while. Put SSO or an allow-list in front of the desk for anything beyond a demo.
### Connect from any MCP client
`examples/mcp_client_config.example.json` shows the generic remote-server config (`type: http`, `url: .../mcp`). Claude Code: `claude mcp add --transport http holddesk http://127.0.0.1:8641/mcp`.
## Connect to Alexa+ (MCP Toolkit)
Alexa+ add-ons talk to a **remote** MCP server over **Streamable HTTP, spec 2025-11-25** ([overview](https://developer.amazon.com/docs/alexaplus/add-ons/mcp-toolkit-overview.html), [quickstart](https://developer.amazon.com/docs/alexaplus/add-ons/mcp-toolkit-quickstart.html)).
1. Expose the server with a tunnel during development:
```bash
python -m holddesk --allowed-host mcp.example.com --allowed-origin https://mcp.example.com
cloudflared tunnel --url http://127.0.0.1:8641 # or ngrok; use the https URL it prints
```
(`--allowed-host` is required: the server enforces the spec's DNS-rebinding protection and rejects unknown `Host`/`Origin` values with 403/421.)
2. Install the Alexa AI CLI and log in: `alexa-ai configure`.
3. Either let the add-on Agent Skill scaffold it (*"I have an MCP server at https://... for a small office front desk. Help me create an Alexa+ add-on."*) or do it manually:
```bash
alexa-ai new mcp --name "HoldDesk" --locale en-US --mcp-server-url "https://<your-host>/mcp"
# edit addon-package/addon.json (see examples/addon.json.example), then
alexa-ai deploy && alexa-ai submit # deploy = test in the web simulator / dev-stage device
```
4. Redeploy after changing tools: Alexa+ refreshes tool metadata only on `alexa-ai deploy`.
The Alexa+ Local Inspector (`mcp-toolkit-local-inspector`) can produce a certification readiness report against this server before you deploy.
Auth: see the next section. For Alexa+ run with `--auth oauth` and register the client id/secret, the authorization URL (`/oauth/authorize`) and the token URL (`/oauth/token`) with the add-on.
## Authentication for Alexa+
The Alexa+ MCP Toolkit [authentication guide](https://developer.amazon.com/docs/alexaplus/add-ons/mcp-toolkit-authentication.html) asks for a two-tier OAuth 2.0 setup. HoldDesk ships a small built-in authorization server (`holddesk/oauth.py`) that follows it:
| Alexa+ requirement | HoldDesk |
|---|---|
| Metadata document | `GET /.well-known/oauth-authorization-server`: `issuer`, `authorization_endpoint`, `token_endpoint`, `response_types_supported: ["code"]`, `grant_types_supported: ["client_credentials","authorization_code","refresh_token"]`, `code_challenge_methods_supported: ["S256"]`, `token_endpoint_auth_methods_supported: ["client_secret_basic","client_secret_post"]`, `scopes_supported: ["mcp:service","mcp:tools","mcp:resources"]`. Also `GET /.well-known/oauth-protected-resource` (RFC 9728) pointing at it. |
| Tier 1, service level | `grant_type=client_credentials` -> Bearer token with scope `mcp:service`, no refresh token. Enough for `initialize`, `tools/list`, resources/prompts and the **public** tools. |
| Tier 2, user level | `authorization_code` + PKCE: `/oauth/authorize` shows a consent + demo-login page; only `code_challenge_method=S256` is accepted (plain or missing -> `invalid_request`); `redirect_uri` must exactly equal a pre-registered one (otherwise a 400 page, never a redirect); codes are single-use (a replay also revokes the tokens minted from that code) and live 60 s. Tokens carry `mcp:tools mcp:resources`; a rotating refresh token is issued. |
| Token hygiene | Opaque random tokens (`secrets.token_urlsafe`), only their SHA-256 is kept in memory; access tokens live `HOLDDESK_OAUTH_TOKEN_TTL` seconds, capped at 3600. Refresh tokens rotate; presenting an already-rotated refresh token revokes the whole token family (every access/refresh token from that authorization). Client secret and demo password are compared in constant time; failed consent logins are throttled (see *The human desk*). `resource` (if sent) must equal the MCP URL. |
| Client authentication | HTTP Basic (`client_secret_basic`) or `client_id`/`client_secret` in the POST body (`client_secret_post`); using both is rejected. |
| Not supported, on purpose | Dynamic Client Registration, OpenID Connect, step-up auth, `WWW-Authenticate` on 401. Unauthenticated `/mcp` calls get `401 {"error":"unauthorized"}` with no `WWW-Authenticate` header. |
**Scope policy** (`PUBLIC_TOOLS` / `USER_TOOLS` in `holddesk/server.py`):
* **Public** (any valid token, including Tier 1 `mcp:service`): `check_availability`, `answer_office_faq`, `estimate_home_insurance_quote_range`. They read office-wide, non-personal data only. The `office://info` resource and greeting prompt are available to any authenticated MCP caller; `mcp:resources` is advertised for client compatibility but is not a separate resource-level authorization gate in this demo.
* **User level** (need `mcp:tools`, i.e. a Tier 2 token): `book_appointment`, `get_appointment_status`, `cancel_appointment`, `request_human_approval`. They create, read or change something tied to a caller, and are **bound to the token subject**: what a token creates is owned by its subject (`user:<login>`), and status/cancel only find appointments owned by the same subject.
* The approval queue (other callers' requests) is not an MCP tool at all; only signed-in staff see it on the desk.
A user-level tool called with a Tier 1 token does nothing and returns a normal tool result:
```json
{"ok": false,
"error": {"code": "insufficient_scope", "required_scope": "mcp:tools", "granted_scopes": ["mcp:service"],
"grant_type_hint": "authorization_code", "message": "This tool acts for a specific caller and needs a user-level token."},
"spoken_summary": "I need you to link your account before I can do that for you."}
```
The check fails closed: if no principal reached the tool, it is refused.
### Run in OAuth mode
```bash
export HOLDDESK_OAUTH_ISSUER=https://mcp.example.com # public base URL (default http://127.0.0.1:<port>)
export HOLDDESK_OAUTH_CLIENT_ID=holddesk-alexa # default holddesk-dev-client
export HOLDDESK_OAUTH_CLIENT_SECRET="$(python -c 'import secrets;print(secrets.token_urlsafe(32))')"
export HOLDDESK_OAUTH_REDIRECT_URIS=https://<redirect-uri-given-by-alexa> # comma-separated, exact match
export HOLDDESK_DEMO_USERNAME=demo # the one demo account for the consent page
export HOLDDESK_DEMO_PASSWORD="$(python -c 'import secrets;print(secrets.token_urlsafe(12))')"
# optional: HOLDDESK_OAUTH_TOKEN_TTL=3600 (capped at 3600), HOLDDESK_OAUTH_RESOURCE (default <issuer>/mcp)
python -m holddesk --auth oauth --allowed-host mcp.example.com --allowed-origin https://mcp.example.com
```
`--auth` defaults to `oauth` when `HOLDDESK_OAUTH_CLIENT_SECRET` is set, otherwise `static`. If you force `--auth oauth` without a secret or demo password, random values are generated for that process and printed once to stderr (development only). The default redirect URI is `<issuer>/oauth/debug-callback`, a local page that just shows the code, so the flow can be tried by hand:
```bash
B=http://127.0.0.1:8641
curl -s $B/.well-known/oauth-authorization-server
# Tier 1
curl -s -u "$HOLDDESK_OAUTH_CLIENT_ID:$HOLDDESK_OAUTH_CLIENT_SECRET" -d grant_type=client_credentials -d scope=mcp:service $B/oauth/token
# Tier 2: open $B/oauth/authorize?response_type=code&client_id=...&redirect_uri=$B/oauth/debug-callback&code_challenge=<S256>&code_challenge_method=S256&state=x
# log in, then exchange: -d grant_type=authorization_code -d code=... -d redirect_uri=... -d code_verifier=...
```
Tokens live in memory, so restarting the server logs every client out; that is intended for a single-instance demo.
### Static mode (local and tunnelled testing)
`--auth static` keeps the original behaviour: set `HOLDDESK_BEARER_TOKEN=<random>` and `/mcp` answers `401` (JSON, no `WWW-Authenticate`) unless `Authorization: Bearer <random>` is present; the holder of that token gets every scope. With no token set the endpoint is open (local development only); in open and static mode every caller shares one subject. In every mode `/healthz` and the OAuth endpoints are public, and `/desk` is behind its own staff sign-in (not the MCP gate).
## Alexa+ simulator (voice assistant simulator, Alexa+ style)
The hackathon rules allow "a simulated Alexa+ experience ... via a web app". `holddesk_sim/` is that web app: an Echo Show 15-style screen (1280x800) with a voice ring, driven by a small **orchestrator** that talks to the real HoldDesk server over MCP exactly the way the Alexa+ MCP Toolkit describes. It is a separate package in this repo; the MCP server does not depend on it.

```bash
pip install -e ".[sim]"
python -m holddesk_sim.demo # or scripts/run_demo.sh
# Simulator UI : http://127.0.0.1:8650/
# Human desk : http://127.0.0.1:8641/desk
# Caller login : demo / <random, printed once> (account-linking consent page)
# Desk login : staff / <random, printed once> (staff sign-in on the desk)
```
The launcher starts HoldDesk in `--auth oauth` mode on :8641 with generated dev secrets, the simulator's `/link/callback` registered as the only redirect URI and the simulator origin as the only allowed desk frame ancestor, then the simulator on :8650. Both logins are printed in the terminal only; the simulator API does not hand them out. Sign in to the desk inside its iframe once. Open the UI in Chrome/Edge, tap the ring (Web Speech API; the text box is the fallback) and speak.
**What happens on each utterance** (`holddesk_sim/orchestrator.py`)
1. At session start the orchestrator gets a **Tier 1** token (`client_credentials`, scope `mcp:service`), connects with the official `mcp` Python client over **Streamable HTTP** and calls `tools/list`; the MCP tool schemas become LLM function-calling schemas.
2. The utterance + tool schemas go to the LLM; every tool call it makes is executed on HoldDesk via MCP `tools/call`, results go back to the model, until it produces a short spoken reply (read aloud with `speechSynthesis`, en-US).
3. When a tool answers `error.code == "insufficient_scope"` (e.g. `book_appointment` on a Tier 1 token) the turn pauses and the UI asks the user to **link their account**. That opens HoldDesk's own consent page in a popup: a real `authorization_code` + **PKCE S256** flow (`/oauth/authorize` -> `/link/callback` -> code exchange at `/oauth/token`). The paused tool call is then retried with the **Tier 2** token (`mcp:tools mcp:resources`).
4. Structured results render as cards: availability slots, the booking hold with a **"Pending staff approval"** badge, FAQ answer with its source id, the insurance range with its non-binding disclaimer. A collapsible **developer trace** shows every MCP call (tool, arguments, latency, token tier), every LLM step and every OAuth event. The human desk (`/desk`) sits beside it in an iframe: approve the hold there and the next "is it confirmed?" comes back **Confirmed by staff**.
**LLM providers** (`holddesk_sim/llm.py`)
| Provider | Use |
|---|---|
| `OllamaProvider` (default) | Local `qwen2.5:7b-instruct-q4_K_M` via Ollama `/api/chat` with native `tools`. No cloud, no cost. |
| `ScriptedProvider` | Deterministic keyword/regex intent router emitting the same tool calls. Used by the tests and as the automatic **fallback** when Ollama is down (`FallbackProvider`, 30 s cooldown), for transport-failure recovery; it does not prove model reliability. `SIM_LLM=scripted` forces it. |
| `BedrockProvider` | Stub only (shape of an Amazon Bedrock Converse mapping); never called. |
**Guards for a small model** (all visible in the trace as `guard:*` and in the reply metadata):
* *No invented caller details*: `client_name` / `contact` must have been said by the caller; otherwise the call is rejected before it reaches HoldDesk and the assistant asks for them (qwen 7b tried to book as "User, 123-456-7890" in testing).
* *No unbacked claims*: a reply like "I've booked it" with no tool call gets one nudge, then is replaced.
* *Grounding*: a question answered from the model's own memory (e.g. a stale status, or "capital of France") is re-routed through the deterministic intent router to the right tool.
* *No false confirmations*: "confirmed" is only allowed if `get_appointment_status` returned `confirmed`; booking replies keep the pending notice, reference id and disclaimers.
* Arguments are coerced to the tool schema (unknown keys dropped, "$500,000" -> 500000).
Environment for the launcher: `HOLDDESK_PUBLIC_URL` / `SIM_PUBLIC_URL` (public HTTPS hostnames behind a tunnel or proxy; they become the OAuth issuer, redirect URI and `--allowed-host`/`--allowed-origin`), `HOLDDESK_PORT` / `SIM_PORT`, `SIM_BIND`, `HOLDDESK_OAUTH_CLIENT_SECRET` / `HOLDDESK_DEMO_PASSWORD` / `HOLDDESK_DESK_USERNAME` / `HOLDDESK_DESK_PASSWORD` (random if unset), `HOLDDESK_FIXED_NOW`, `SIM_LLM=ollama|scripted`, `SIM_OLLAMA_MODEL`, `OLLAMA_URL`, `SIM_SHOW_DEMO_CREDS=1` (local recording only: `/api/config` then returns the caller demo login and the linking dialog shows it; off by default).
`scripts/sim_e2e.py` drives the whole journey through the simulator's HTTP API (it plays the caller in the consent popup and the staff member signing in to the desk) and scores tool selection; it uses the `httpx` dependency from the `sim` extra. `scripts/sim_screenshot.py` does the same through the real UI in headless Chromium. These repository scripts run from a source checkout; for the screenshot script, install its optional dependency and browser once:
```bash
pip install -e ".[sim,screenshot]"
python -m playwright install chromium
python scripts/sim_screenshot.py docs/sim_screenshot.png
```
Release verification (2026-10-16): **108 automated tests passed**, including live HTTP negotiation of `2025-11-25`, two-tier OAuth, scope and subject checks, staff authentication, concurrent holds, and cancellation transitions. The recorded demo uses `SIM_LLM=scripted`, a deterministic intent router, with real MCP transport, OAuth consent and staff decisions. It demonstrates the server workflow rather than measuring LLM accuracy. The optional Ollama integration remains available; historical local-model measurements are not release guarantees.
Simulator limits: sessions and tokens live in memory; the simulator holds the Tier 2 token server-side for its browser session, so it is a single-user demo surface, not a multi-tenant app. It is not an Amazon product and uses no Amazon marks.
## Design notes for voice
* Tool descriptions state the approval rule so the orchestrator never promises a confirmed booking.
* `spoken_summary` is short, states the reference id, and never enumerates more than three slots.
* Sensitive numbers are refused at the boundary (`sensitive_data_refused`) rather than logged: in names, notes, cancel reasons, escalation summaries and every key/value of escalation `details`.
* Responses are deterministic for the same inputs and clock, which keeps demos reproducible.
## Repository layout
```
holddesk/
domain.py pure logic: scheduling, approvals, FAQ retrieval, synthetic rating
server.py FastMCP tools/resource/prompt, scope policy, /desk human UI + JSON API, auth gate, CLI
oauth.py minimal OAuth 2.0 AS: metadata, client_credentials, auth code + PKCE S256, refresh (family revocation)
desk_auth.py staff sign-in for /desk: sessions, cookie flags, CSRF, frame-ancestors
security.py in-memory login throttle (consent + desk)
data/ office.json, faq.json, rate_table.json (all synthetic)
holddesk_sim/ voice assistant simulator (Alexa+ style)
orchestrator.py utterance -> LLM -> MCP tool calls loop, two-tier token handling, account linking, guards, cards
mcp_link.py official mcp client over Streamable HTTP + OAuth token requests (both tiers, PKCE)
llm.py Ollama / scripted / fallback providers, Bedrock stub
app.py Starlette API + /link/callback; demo.py one-command launcher; static/ UI (no build step)
tests/ pytest: domain unit tests, live Streamable HTTP end-to-end tests, desk auth/CSRF/XSS, OAuth AS + scope/subject tests, simulator
scripts/smoke_client.py exercises every tool over HTTP
scripts/run_demo.sh, sim_e2e.py, sim_screenshot.py launch / score / screenshot the simulator
docs/ simulator screenshots and release verification notes
examples/ addon.json.example (Alexa+ manifest template with placeholders), mcp_client_config.example.json
DEMO_SCRIPT.md <3-minute video plan friction_log.md product feedback for the judges
```
## Status and honesty
* Synthetic office, staff, FAQ and rate table. No real customer, insurer or rate data anywhere.
* The quote tool is an illustration of *how* a voice assistant should hand off to a licensed human, not a rating engine.
* Not yet tested on a physical Alexa+ device (developer-preview access pending); tested with the MCP Inspector, the official Python client over Streamable HTTP, and the bundled Alexa+-style simulator (deterministic routing for the release recording; optional local model integration).
* The OAuth server is verified by its own tests and by hand with curl and the Python client; it has not yet been exercised by Alexa's real account-linking flow. It is a single-instance, in-memory reference (one client, one demo user, one staff account), not a production identity provider.
* `examples/addon.json.example` is a template: privacy/terms URLs, media and the MCP URL are placeholders to fill in before any deploy.
License: MIT.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues