# 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
