# MCP reference

Two servers, one product. They are not interchangeable, and the difference is the whole reason the
tool names are what they are.

| | Remote `https://app.solospace.dev/mcp` | Local stdio |
|---|---|---|
| Auth | OAuth 2.1, no API key | ed25519 agent key |
| Has a browser | **No** | Yes, its own Chromium |
| Can sign bytes | Yes | Yes, via the service |
| Can complete a sign-in alone | No — you drive the page | Yes, in one call |

## Remote tools

The signing tools are **ticket-based**: the remote server mints a single-use ceremony ticket and a
page script, the *page* fetches its own signature from the service, and the agent never carries a
cryptographic byte. `passkey_complete_signin` is the fallback for a page that cannot reach the
service itself (usually the site's own CSP). There is no tool called `passkey_assert` any more —
it was renamed and split; calling the old name returns a structured error naming its replacement.

### `passkey_howto`

The end-to-end procedure: the shim to inject, the object shape to hand back to the page, what
every refusal means, and your connectors' operating notes. Optional `domain` focuses the connector
section on one site.

Needs **no scope** — only a valid token. Gating the manual behind a scope produces exactly the
failure it exists to prevent: an agent refused the instructions for the thing it was just granted
permission to do.

### `passkey_check_access`

What this connection may do, before you do it: which scopes you hold, which tools are actually
usable, which sites are connected, and when your token expires. Needs **no scope**, signs nothing,
and wakes nobody — call it before the expensive call rather than discovering a refusal the costly
way.

### `passkey_list` — `connectors:read`

The connector catalogue, already narrowed to what this grant covers. Per connector:

| Field | Meaning |
|---|---|
| `notes` | Rendered prose about that site's flow — what a human wrote and what other agents have observed, inside a labelled fence. Returned first, because it is the field that decides whether the sign-in works. |
| `siteNotes` | What is generally true of this site, present when a human also wrote a connector note. Both are returned: a note saying where the button is does not retract one saying the browser gets blocked. |
| `notesSource` | `connector` (a human wrote it for this credential — trust it), `default` (a general note for the site — verify it), `none` (nobody has written one). |
| `rpId` | The relying party — the domain the passkey belongs to. |
| `credentialId` | base64url. The value `passkey_begin_signin` and `passkey_complete_signin` take. |
| `userName` / `account` | Which identity this passkey signs in as. Declare it with `expectAccount` on the call that matters when a grant holds more than one account. |
| `aliases` | Other names the site shows for the same account (a GitHub `@login` beside the email), recorded from the site at enrolment or by the owner. `expectAccount` accepts any of them exactly. |
| `signinUrl` | Where to start. |
| `origins` | The origins a human observed at enrolment — a floor, not the exact accepted set. |
| `originAdvice` | The registrable-domain rule the server actually applies, and whether `signinUrl` passes it today. A sibling host the site navigates to (`accounts.google.com` for a credential enrolled on `myaccount.google.com`) is accepted; the server decides, this is a hint. |
| `mode` | `approval` or `silent` (read it live — do not assume these are the only two). |
| `lastUsedAt` | The last signature the service allowed for this connector. |

A site that is missing was not granted. No rephrasing produces it; the person changes it on their
dashboard.

### `passkey_connector_status` — `connectors:read`

What the **service** has recorded about one connector: its mode, its notes, its last successful
signature, and whether a signature is waiting on a human *right now*. Use it before raising a
second approval.

It cannot tell you whether your browser has a live session. Nothing on the server can see your
browser.

### `passkey_note` — `connectors:read`

Write back what you learned about a site — where the passkey option hides, that a button fires no
request and succeeds anyway, how to tell which account a page is acting as. `kind: "login"` is how
to get in; `kind: "site"` is how to work the site once in. Prose, never selectors. Stored as
**provisional**, attributed to your connection, shown to the account owner, and returned to the
next agent inside a labelled fence marking it an unreviewed observation. It changes nothing about
what anyone may sign — nothing here reads a note to decide anything.

### `passkey_begin_signin` — `signin`

**Start here.** Takes `credentialId` (and optionally `pageUrl`, `expectAccount`) and mints a
single-use ceremony **ticket**, good for a few minutes. It returns `armUrl`: navigate a browser
with the Agent Passkey extension to it, then click the site's own passkey button — the extension
redeems the ticket from its background worker, so strict-CSP sites work and no page script is
involved. `next` names that step. `shim` is for a browser without the extension: where the
site's CSP lets the page reach this service, evaluate it in the page as-is and poll
`window.__apk.status` (`shimProcedure` says whether this site allows it). `ifNotArmed` says what to do if the arm page reports no extension —
including, where nothing automatic can work, the exact sentence to ask the person.
`passkey_signin_status({ ticket })` reports the server's view either way.

### `passkey_signin_status` — `signin`

The server's view of one ticket — `unused`, `in_flight`, `pending_approval`, `used`, `denied`
(with the code and reason), `expired`. Signs nothing, wakes nobody, callable as often as you like.

### `passkey_complete_signin` — `signin`

The **fallback**, for a page whose CSP blocks it from reaching the service itself. Takes
`credentialId`, `clientDataJSON` (base64url, byte-for-byte as the page produced it) and `origin`.
Returns one of:

| `state` | Meaning | What to do |
|---|---|---|
| `ok` | `authData`, `signature`, `userHandle` are in the result | Hand them to the page via `window.__apk.deliver`. Each assertion is good once. |
| `pending` | A human is being asked, on their phone, right now | Send `waitUpToMs` so the SERVER waits, rather than polling — then resend **byte-identical** arguments until signed, denied, or the approval expires. |
| `denied` | Terminal. The result carries a `code`: `approval_denied`, `approval_timeout`, `policy_denied`, `origin_rejected`, `argument_mismatch` | Stop. Report it. Do not retry. |

**Pass `clientDataJSON` through untouched.** The shim builds those bytes inside the page and the
page keeps them to hand to the site; the agent's job is to move the base64url string, not to
re-create it. One differing byte produces a signature that verifies against nothing, after every
other gate has passed.

**The `origin` is the page's, not the connector's.** A site may navigate to another host before
it asks for a passkey — start at `signinUrl`, but send whatever origin the ceremony is actually
running on. It must share a registrable domain with one of the connector's enrolled `origins`;
`originAdvice` says whether those two agree today. Scheme and host only: no path and no trailing
slash, even though `signinUrl` usually has one.

The `credentialId` parameter is validated against your **live** grant on every call, not against
a schema your client may have cached — a credential missing from a stale schema is not evidence it
is unconnected.

### `passkey_approvals` — `approvals:read`

The approvals this connection has raised that are still open, with the deadline on each. It
approves nothing and hurries nobody.

### Automations — `passkey_automation_propose`, `passkey_automation_list`, `passkey_run_begin`, `passkey_run_end`

Propose, activate and run unattended sign-ins. See [Automations](/docs/automations) for the full
shape — the same ticket-based signing tools are used inside a run, with its `runId` attached.
`passkey_run_status` and `passkey_automation_runs` read a run, or the recent runs of one
automation, back.

### Reading back — `passkey_approval_status`, `passkey_audit`

`passkey_approval_status({ approvalId, waitUpToMs })` (`approvals:read`) is one approval, held
open server-side until a human decides or the wait runs out — use it instead of re-sending the
sign-in call. `passkey_audit({ limit, before })` (`audit:read`) is this connection's **own**
audit rows, newest first, and nothing anyone else did.

### Enrolment — `passkey_begin_enrol`, `passkey_enrol_status`, `passkey_complete_enrol`, `passkey_enrol_direct`, `passkey_revoke`

The first four need the `enrol` scope ("Add passkeys"); `passkey_revoke` needs
`credentials:write` ("Revoke passkeys"). Both are off by default. They add a passkey
to a site the human has **already signed in to** in the browser you drive — the service cannot do
that step — and revoke one. The ticket flow mirrors sign-in: `begin` returns a ticket and a
page script that stands in for `navigator.credentials.create`, the page fetches its own
attestation, and `complete` records whether the site accepted it. See
[Adding passkeys via the API](/docs/enrol).

## Local tools

### `passkey_howto`
The short local procedure — three calls, in order, and how to read each status.

### `passkey_list`
The same catalogue, over the ed25519 agent key.

### `passkey_session_status(domain)`
Does **this agent browser** already have a live session? Opens the sign-in URL and reports where
it landed. The answer is *inferred*: landing away from a login page is good evidence of a session,
not proof of one.

### `passkey_signin(domain)`
The whole ceremony in one call. Statuses: `already_signed_in`, `signed_in`,
`waiting_for_approval`, `denied`, `no_assertion` (the page never asked for a passkey — read the
notes and click the right button first), `failed`.

## About the name `passkey_status`

It no longer exists on either server. It used to exist on **both**, answering different questions:
locally "is this browser signed in", remotely "what has the service recorded". An agent connected
to both saw one name whose meaning depended on which registration won — wrong silently, never with
an error. It is now `passkey_session_status` (local, a question about a browser) and
`passkey_connector_status` (remote, a question about the service's records). No alias is kept: an
alias would preserve the collision it exists to fix, and `unknown tool` is a loud failure a client
corrects in one turn.
