Skip to main content
Glama

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

Related MCP server: lig-room

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

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

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

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, quickstart).

  1. Expose the server with a tunnel during development:

    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:

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

{"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

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:

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.

Simulator: spoken request, MCP trace, pending hold, human approval on the desk, confirmed

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:

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.

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    D
    maintenance
    Simulates a third-party appointment booking agent, enabling your AI platform to check availability and book appointments via MCP interoperability.
    -
  • F
    license
    Not graded
    quality
    B
    maintenance
    Enables AI agents to search for available desk space, submit room reservations, and let admins approve or reject booking requests through a scoped MCP API.
    -
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables voice-driven appointment booking for small businesses, letting customers check availability, book, reschedule, and cancel appointments through any MCP client.
    1 npm
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables voice assistants to find nearby service providers, connect users to live video representatives for natural conversation, and handle appointment booking. It also returns confirmed bookings so the assistant can add them to its calendar.
    MIT