# HTTP MCP API
Dialup has two equal surfaces: a WebSocket-first browser UI for humans and an auto-generated
HTTP MCP API for agents. This guide covers the agent API side. Dialup derives an MCP-compatible
HTTP JSON-RPC API from the same UI declarations that power the browser interface, so you declare
actions and regions once; agents receive `tools/list`, `tools/call`, and `read_scene` without a
second API layer.
## The core idea
```
<.dialup_action> ─┐
declare_action/1 ─┼─→ tools/list ──→ POST /mcp (Bearer token)
<.dialup_region> ─┘ │
declare_region/1 tools/call ──→ command / set / navigate / handle_event
```
Each declared action carries `_meta.mode`:
| Mode | Server path |
|------|-------------|
| `command` | Build Commanded command → `Context.dispatch/1` → remount page |
| `set` | Merge rendered `set` map into assigns |
| `navigate` | Navigate to declared path |
| `action` | Legacy `handle_event/3` |
Human operators use WebSocket (`/ws`). AI agents use HTTP request-response (`POST /mcp` with a bearer token).
Both paths serialize through the same `UserSessionProcess`, so state, versions, and audit logs stay
consistent.
## Minimal page
```elixir
defmodule Dialup.App.Invoice.Page do
use Dialup.Page
alias MyApp.Ordering
def mount(_params, assigns) do
{:ok, assigns |> set_default(%{items: [], order_id: "ord-1"})}
end
def agent_state(assigns), do: %{items: assigns.items}
def render(assigns) do
~H"""
<.dialup_region name={:items} role="list" desc="Invoice line items" data={@items}>
<ul><li :for={i <- @items}>{i.sku} × {i.qty}</li></ul>
</.dialup_region>
<.dialup_action
command={{Ordering, :add_item}}
desc="Add a line item"
params={%{sku: :string, qty: {:integer, default: 1}}}
bind={%{order_id: @order_id}}
errors={%{too_many: "Too many line items"}}
available={length(@items) < 20}
sku="SKU-1"
qty="1"
>
Add item
</.dialup_action>
"""
end
end
```
For UI-only toggles without Commanded, use `set` mode inline in HEEx (not via `declare_action/1`):
```elixir
<.dialup_action
name={:toggle_sidebar}
desc="Toggle the sidebar"
params={%{}}
set={%{sidebar_open: !@sidebar_open}}
>
Toggle
</.dialup_action>
```
Legacy actions that still use `handle_event/3` remain valid — see the `action` row in the mode table
above.
No separate REST controller. No OpenAPI hand-authoring. The catalog is derived from compile-time
declarations and runtime availability via `available={...}` / generated `__available__/2`.
### The declaration boundary
`<.dialup_action>` / `declare_action` is the **single boundary** of what an agent can do. Dialup
never auto-exposes raw `ws-event`, `ws-submit`, `ws-change`, or `ws-href` elements as tools. This is
deliberate: an agent is an untrusted external caller, so each tool needs a validated input schema, a
description, availability and confirmation rules — none of which can be inferred from an arbitrary
`handle_event/3` clause. It also keeps internal plumbing events out of the agent's surface.
The principle is *parity by declaration*, not parity by default: when you write a control with
`<.dialup_action>` it is human- and agent-operable at once, and that includes **navigation** — see
[Navigating between pages](#navigating-between-pages). If you want the agent to do something, declare
it; if a human-facing control should be agent-operable, express it with `<.dialup_action>`.
## Discovery
Every agent-enabled page advertises:
- HTTP `Link` header → `/.well-known/dialup-agent?path=/current/path`
- Embedded `<script id="dialup-agent-context" type="application/json">`
- `/llms.txt` — operator instructions for coding agents
The discovery document includes `agent_message/1`, static tool schemas, semantic regions, and
HTTP connection instructions. It does **not** include a live session token.
`GET /.well-known/dialup-agent?path=...` resolves page mounts the same way as HTTP GET:
auth cookies (`_dialup_user_token`) seed `current_user` into layout session before `mount/2`
runs, so protected pages that return `{:redirect, "/log_in"}` for guests still advertise the
correct catalog when the request is authenticated. Redirect loops and invalid mount returns
map to JSON errors instead of 500 responses.
## Authentication and MCP grants
When `auth_accounts` is configured on your Application module, unauthenticated live sessions
receive **minimal** MCP capabilities (`[:read_scene, :issue_browser_url]`) via
`Dialup.Auth.Grants.for_user/1`. Logged-in sessions expand to `:all` or your custom
`agent_grant/1`.
Important behaviors:
| Topic | Behavior |
|-------|----------|
| **Grant re-validation** | Every `tools/call` re-checks live auth, capability/projection subsets, and an issuance-time **auth fingerprint** |
| **Login upgrade** | Guest tokens issued before human login **do not** gain authenticated `read_scene` after the user signs in — re-issue handoff |
| **Logout / cookie loss** | HTTP logout revokes DB session tokens (including tokens held only on live processes) and downgrades MCP grants |
| **Browser join** | Joiner's auth cookie is applied before the first post-join render |
See [Authentication](./authentication.md) for `mix dialup.gen.auth`, CSRF, session rotation, and
security regression notes.
## Session tokens
To operate a user's **live** browser session, obtain a bearer token:
1. **Programmatic** — `Dialup.Session.grant(session_pid, opts)`
2. **From the open tab** — `POST /_dialup/agent-handoff?tab_id=...` (uses the tab's registry key)
3. **Agent-first** — `POST /_dialup/agent-session` with `{"path":"/page"}` (no browser tab required)
Then call the MCP endpoint. Prefer the canonical bearer form:
```
POST /mcp
Authorization: Bearer {token}
Content-Type: application/json
```
Session descriptors and discovery documents advertise `/mcp` as the endpoint. A legacy path-token form remains available:
```
POST /agent/{token}
Content-Type: application/json
```
`POST /mcp` is the canonical [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports)
endpoint for off-the-shelf MCP clients: configure the client with the `/mcp` URL and a bearer
token (the `Mcp-Session-Id` header is also accepted). Session handoff descriptors return `/mcp`
as `endpoint`. Legacy `POST /agent/{token}` uses the same handler with the token in the URL path;
prefer `/mcp` to avoid token leakage in access logs and Referer headers. On `initialize`, the server
returns the token as the `Mcp-Session-Id` response header, which a standard client echoes on
subsequent requests. A `GET` to either endpoint returns `405` because no server-initiated SSE stream
is offered — agents poll with `read_scene`.
## MCP lifecycle
Supported JSON-RPC methods:
| Method | Purpose |
|--------|---------|
| `initialize` | Protocol handshake (`2025-11-25`) |
| `notifications/initialized` | Client ready (no response body) |
| `ping` | Health check |
| `tools/list` | Generated tool catalog |
| `tools/call` | Invoke `read_scene`, `read_audit_log`, `lock_ui`, `unlock_ui`, `issue_browser_url`, or a declared action (including navigation actions) |
### Typical flow
```json
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"agent","version":"1"}}}
```
```json
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
```
```json
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"read_scene","arguments":{}}}
```
```json
{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"add_item","arguments":{"sku":"AI-1","qty":2,"_version":3}}}
```
Every mutating action should include the latest `_version` from `read_scene`. A stale `_version`
returns an **isError tool result** whose `structuredContent.currentVersion` is the latest version —
call `read_scene` again instead of blind retries.
### Error model
Following the MCP [tools spec](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#error-handling),
errors fall into two buckets:
- **Protocol errors** (JSON-RPC `error`) — unknown tool (`-32602`), session/grant problems
(`-32002`/`-32003`), parse/invalid request (`-32700`/`-32600`). The request itself is
unanswerable.
- **Tool execution errors** (a normal result with `"isError": true`) — stale `_version`,
invalid arguments, unavailable action, unknown semantic target, and `confirm: :human`. These carry
a human-readable `content` message plus a `structuredContent.reason` so the model can self-correct
and retry.
## Human-only actions
Actions marked `confirm: :human` are **not** executable over HTTP MCP. The call returns an
**isError tool result** (`structuredContent.confirm = "human"`). Use the human browser UI for those
operations.
## Navigating between pages
Navigation is **not** a free-form tool. It follows the same principle as every other capability:
an agent can reach exactly the links your UI declares — no more, no less. Declare a navigable link
with `navigate` on `<.dialup_action>`:
```elixir
<.dialup_action navigate="/docs/concepts">Concepts</.dialup_action>
```
This renders an ordinary `ws-href` link for humans and, from the same declaration, generates a
navigation tool for agents. The tool name is derived from the path (`/docs/concepts` →
`navigate_docs__concepts`; path segments join with `__` so `/foo-bar` and `/foo/bar` stay
distinct); pass an explicit `name={...}` to override it. The action takes no
arguments because the destination is fixed at the declaration site:
```json
{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"navigate_docs__concepts","arguments":{}}}
```
Calling it runs the same code path as a human clicking the link: the page re-mounts and the human's
browser follows in real time. Each page generates a different tool catalog, so call `tools/list`
(or `read_scene`) again afterwards. The destination of each navigation action is reported in
`tools/list` under `_meta.navigate` and in `read_scene` actions under `navigate`.
Navigation links declared on a **layout** (for shared chrome like a nav bar) are merged into the
tool catalog of every page under that layout, so an agent gets the same site-wide navigation a human
sees. Because navigation actions are ordinary actions, they are gated by capability under their
derived name (e.g. `:navigate_docs__concepts`); a `:all` grant includes them automatically.
Navigation actions respect the same `__available__/2` predicates and `confirm: :human` gates as
mutating actions.
## Locking the human UI
While an agent works, it can stop the human from operating the page to avoid conflicting edits.
Two built-in tools control this (gated by the `:lock_ui` capability):
```json
{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"lock_ui","arguments":{"reason":"AIが整理中です"}}}
```
```json
{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"unlock_ui","arguments":{}}}
```
While locked:
- The browser shows a blocking overlay with the optional `reason`.
- Human `ws-event`/`ws-submit`/`ws-change` interactions are ignored server-side (defense in depth).
- Agent tool calls still apply normally, and `read_scene` reports `"uiLocked": true`.
Grant the capability explicitly so agents can use it:
```elixir
def agent_grant(_assigns) do
%{capabilities: [:add_item, :lock_ui], projections: [:state, :regions, :actions]}
end
```
Always pair `lock_ui` with `unlock_ui` (e.g. in a `try/after`) so the human regains control even if
the agent errors out. A session timeout also releases the lock when the process is rebuilt.
## Inviting a human to join
When an agent starts or operates a session without a human tab open, it can mint a one-time browser
join URL through the `issue_browser_url` built-in (gated by the `:issue_browser_url` capability):
```json
{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"issue_browser_url","arguments":{}}}
```
The tool result includes `browserUrl` (for example `/invoices?_join=TOKEN`), `browserToken`, and
`expiresInMs`. Share `browserUrl` with the person who should join.
Handoff is attach → `POST /_dialup/finalize-join` (sets cookie, consumes token) → WebSocket
`__reconnect`. Opening the URL alone does not set a session cookie or complete the join.
Join tokens are single-use and expire quickly. Issue a fresh URL if one is consumed or expires.
Grant the capability explicitly when you scope agent authority:
```elixir
def agent_grant(_assigns) do
%{capabilities: [:add_item, :issue_browser_url], projections: [:state, :regions, :actions]}
end
```
See [Session tokens for HTTP MCP](./agent-handoff.md) for the full agent-first and browser-handoff
flow, including `POST /_dialup/agent-session`.
## Scoped grants
```elixir
{:ok, grant} =
Dialup.Session.grant(session_pid,
capabilities: [:add_item],
projections: [:state],
expires_in: :timer.minutes(5),
require_version: true
)
```
`capabilities` limits which tools appear in `tools/list`. `projections` limits what `read_scene`
returns (`:state`, `:regions`, `:actions`). Revoke with `Dialup.Session.revoke/2` or
`DELETE /agent/:token`.
## Action metadata
Tool entries include `_meta` for agent decision-making:
- `mode` — `command`, `set`, `navigate`, or legacy `action`
- `available` — live predicate derived from `available={...}` / generated `__available__/2`
- `confirm`, `risk`, `effects`, `reversible`, `idempotent`
- `examples`, `success`
Put metadata on `<.dialup_action>` for one-off controls, or hoist with `declare_action/1` for
repeated references. Use `agent_only: true` when an operation has no human button.
## What agents do not get
- **No agent WebSocket** — `/agent/:token/ws` is not supported. Use HTTP only.
- **No push notifications** — poll with `read_scene` or rely on request-response results.
- **No focus tool** — semantic regions and `read_scene` carry the structured context. To prevent
concurrent human edits, use `lock_ui` / `unlock_ui` (above) instead.
Agents *can* navigate (via declared navigation actions) and lock the UI (`lock_ui`), mirroring human
capabilities. What an agent cannot do is anything you leave undeclared and ungranted: raw `ws-event`
/ `ws-submit` / `ws-href` elements are **never** auto-exposed. A page event or a link becomes a tool
only through `<.dialup_action>` / `declare_action`, and built-ins like `lock_ui` require their
capability. The single declaration boundary keeps the agent's surface typed, documented, and
intentional rather than a mirror of every internal event.
## Demos and references
- [Building agent-native applications](./agent-native-app-development.md) — implementation workflow
- [Live demo](https://dialup-framework.org/agent_demo) — interactive playground on dialup-framework.org