Session tokens for HTTP MCP

Copy Markdown View Source

This guide covers how an external agent reaches a live Dialup browser session over HTTP MCP.

For the full API reference — discovery, tools/list, tools/call, grants, and metadata — read HTTP MCP API first.

Ordinary URL vs session token

URLPurpose
https://app.example.com/invoicesDescribes the app and static tool catalog via discovery
POST /agent/{token}Operates one specific live session (bearer credential)

Opening the ordinary URL in an agent's own browser creates that browser's session. It does not attach to work already open in the user's tab.

Obtaining a token

From server code

{:ok, descriptor} =
  Dialup.Session.grant(session_pid,
    capabilities: :all,
    projections: [:state, :regions, :actions],
    expires_in: :timer.minutes(15),
    require_version: true
  )

token = descriptor["token"]
endpoint = descriptor["endpoint"]  # "/agent/{token}"

From the user's open tab

When the user already has the app open, issue a token tied to that tab's registry key:

curl -X POST 'https://app.example.com/_dialup/agent-handoff?tab_id=TAB_ID' \
  -H 'Cookie: dialup_session=SESSION_ID'

The response includes token, endpoint, and grant metadata. Pass endpoint to your MCP client.

In browser JavaScript (same origin), use Dialup.tabId from dialup.js:

const res = await fetch(`/_dialup/agent-handoff?tab_id=${encodeURIComponent(Dialup.tabId)}`, {
  method: "POST",
  credentials: "same-origin",
});
const { token, endpoint } = await res.json();

Calling the API

curl -X POST "https://app.example.com/agent/TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"read_scene","arguments":{}}}'

See HTTP MCP API for the complete method list, versioning rules, and error codes.

Revocation and expiry

  • DELETE /agent/:token — revoke the grant
  • Dialup.Session.revoke(session_pid, token) — same from Elixir
  • Expired tokens return JSON-RPC error -32002

Ask the user to issue a fresh token from their still-open tab if a grant expires mid-task.

Login upgrade and guest tokens

When an agent starts before the human logs in, the initial handoff grant is bound to the guest auth fingerprint. After the user signs in (same tab via /log_in, or browser join with the human's cookies), that old token is rejected for operations that no longer match the current auth context — even though guest capabilities are a subset of :all.

Re-issue handoff after login when the agent needs elevated capabilities:

curl -X POST 'https://app.example.com/_dialup/agent-handoff?tab_id=TAB_ID' \
  -H 'Cookie: dialup_session=SESSION_ID; _dialup_user_token=USER_TOKEN'

From Elixir after observing an auth upgrade on the session process, call Dialup.Session.grant/2 again with the desired capabilities.

See Authentication for the full login → MCP upgrade flow.

Agent-first session (no browser tab yet)

Sometimes the agent starts work before a human opens the app. The endpoint is disabled by default; enable it explicitly with use Dialup, agent_session: true and protect it with auth plugs before exposing a public deployment. Start a headless session from the page URL:

curl -X POST 'https://app.example.com/_dialup/agent-session' \
  -H 'Content-Type: application/json' \
  -d '{"path":"/invoices"}'

The response includes token, endpoint, grant, path, and sessionId. Use the token with POST /mcp or POST /agent/{token} exactly like a handoff token.

From Elixir:

{:ok, descriptor} = Dialup.Session.start(MyApp, "/invoices")
token = descriptor["token"]
endpoint = descriptor["endpoint"]

Headless sessions stay alive for up to 15 minutes while waiting for a browser to join. Each MCP call resets that idle timer. After a human connects and finalize-join completes, the normal WebSocket disconnect timeout applies.

Inviting a human to join (browser handoff)

When an agent has already started or taken over a session, it can mint a one-time browser URL for a human to open:

{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"issue_browser_url","arguments":{}}}

The tool result includes:

  • browserUrl — relative URL such as /invoices?_join=TOKEN
  • browserToken — the raw join token
  • expiresInMs — time until the token expires (default five minutes)

Share browserUrl with the person who should join. The handoff completes in three server phases:

PhaseWhat happens
AttachBrowser opens the URL (cookie not set yet). dialup.js connects to /ws?tab_id=…&join_token=…. The server reserves the token, attaches the tab to the agent's UserSessionProcess, and sends the live HTML plus a join_finalize_nonce.
CompleteThe client POSTs /_dialup/finalize-join?tab_id=…&nonce=… with credentials: "same-origin". The server sets the dialup_session cookie, consumes the join token (single-use), and clears pending handoff state.
SyncThe client sends __reconnect on the WebSocket (or reconnects with the cookie only if the socket dropped after finalize).

Requirements and client behavior:

  • tab_id is required on the WebSocket upgrade for join links. dialup.js exposes a stable Dialup.tabId (stored in sessionStorage) for this.
  • _join stays in the URL until finalize succeeds so a reload can retry the handoff.
  • If finalize does not complete within about 30 seconds, the server rolls back the attach and releases the token for a fresh attempt.

Issue a fresh URL if a token is consumed or expires.

Grant the capability explicitly when you scope agent authority:

def agent_grant(_assigns) do
  %{
    capabilities: [:add_item, :issue_browser_url],
    projections: [:state, :regions, :actions]
  }
end

From Elixir on the server:

{:ok, %{"browserUrl" => url}} = Dialup.Session.browser_url(session_pid)

POST /_dialup/finalize-join

Called by dialup.js during browser handoff (not by agents directly). Query parameters:

  • tab_id — same value as the WebSocket upgrade
  • nonce — value from the join_finalize_nonce WebSocket field

On success, responds with 200 and sets the dialup_session cookie. This is the only completion point for a join token.

Security notes

  • Treat browserUrl like a short-lived login link. Do not log it or paste it into public channels.
  • A consumed or expired join token cannot be reused.
  • The human regains full UI control unless the agent has called lock_ui.

Live example

See the agent handoff demo on dialup-framework.org.

The demo page mints a token via Dialup.Session.grant/2. Production apps typically combine programmatic grants with the handoff endpoint for user-initiated access.