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: Partner Booking MCP Server

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
    A
    quality
    D
    maintenance
    Enables interaction with IntelliMatch recruiting and iSuite insurance systems through spec-driven MCP tools, allowing natural language queries for candidate search, policy management, claims, and workflows.
    28
    -
  • 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