# Transport

MachineMail runs a remote MCP server, so any MCP client can give its model email: create an inbox, wait for a message, read it safely, reply. It calls the same service layer as the REST API, so scopes, permissions and trust reports behave identically.

|  |  |
| --- | --- |
| Endpoint | `https://mcp.machinemail.dev/mcp` |
| Transport | Streamable HTTP, stateless |
| Auth | OAuth sign-in, or `Authorization: Bearer <API key>` |
| Server name | `machinemail` |
| Description document | `https://mcp.machinemail.dev/.well-known/mcp.json` |

[Client setup](/content/docs/agents/clients/index.html) has the exact configuration for Claude Code, Cursor, Codex, OpenCode, VS Code and others.

## Legacy endpoint

Earlier versions of these docs gave `https://api.machinemail.dev/mcp`; `https://machinemail.dev/mcp` also works. Both are legacy aliases of the same server and keep working, so existing configurations need no change. Use `https://mcp.machinemail.dev/mcp` for new setups. A client that signed in on `https://machinemail.dev/mcp` keeps its tokens; tokens for either URL work on both. On `https://api.machinemail.dev/mcp` API keys work, but the OAuth metadata there names `https://machinemail.dev/mcp`, which clients that check the resource against the URL they connected to refuse: if OAuth sign-in fails there, switch to `https://mcp.machinemail.dev/mcp`.

`mcp.machinemail.dev` serves only the MCP server: `/mcp`, its protected resource metadata and `/.well-known/mcp.json`. Opening `https://mcp.machinemail.dev/` in a browser brings you to this page. Sign-in, the dashboard and the REST API stay on `machinemail.dev`. Clients written to the 2025-03-26 MCP auth spec, which look for the authorization server on the MCP URL’s own host, reach it there: `/.well-known/oauth-authorization-server` and `/authorize` on `mcp.machinemail.dev` redirect (`307`) to `machinemail.dev`, and `/token` and `/register` answer in place as the authorization server’s own endpoints, so HTTP Basic client credentials are kept (a cross-origin redirect would drop them).

## Transport

The server speaks [Streamable HTTP](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports#streamable-http) in stateless mode. It serves MCP `2026-07-28` (clients discover it with `server/discover`) and the earlier revisions `2025-11-25`, `2025-06-18`, `2025-03-26` and `2024-11-05` (clients that start with `initialize`), so current clients keep working unchanged:

- Every JSON-RPC request is a `POST` to `/mcp`; send `Accept: application/json, text/event-stream`. On the 2025 revisions a response comes back as a single server-sent event (`Content-Type: text/event-stream`); on `2026-07-28` it is plain JSON.
- There is no session. The server does not issue an `Mcp-Session-Id`, and each request is handled on its own, so any request can go to any instance.
- A `GET` with `Accept: text/event-stream` and a `DELETE` return `405 Method Not Allowed`: there is no standalone server-to-client stream and no session to end.
- A plain `GET` (from a browser, say) returns a JSON description of the endpoint instead of speaking MCP.
- A request is cancelled when the client disconnects, which also ends a pending `wait_for_message`.

## Call a tool with curl

```
curl https://mcp.machinemail.dev/mcp \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_inboxes","arguments":{}}}'
```

## Response

```
event: message
data: {"result":{"content":[{"type":"text","text":"- agent-f5pvo2cei042@machinemail.dev (Docs example agent) ephemeral, expires 2026-10-10T09:06:15.234Z"}],"structuredContent":{"count":1,"inboxes":[{"inbox_id":"agent-f5pvo2cei042@machinemail.dev","email":"agent-f5pvo2cei042@machinemail.dev","ephemeral":true,"expires_at":"2026-10-10T09:06:15.234Z","...":"..."}]}},"jsonrpc":"2.0","id":1}
```

MCP clients do all of this for you. The curl form is useful for checking a key.

## Authentication

Every request carries a bearer credential. Three kinds work:

- **OAuth sign-in.** Add the server URL to your client with no credentials. The client receives a 401, finds MachineMail’s authorization server, and opens your browser. You sign in to the dashboard, choose what the client can reach, and approve. The client then holds a short-lived access token and a refresh token, and renews them on its own. This is the option to use for Claude, Cursor, VS Code, OpenCode and other interactive clients.
- **An API key** (`mm_live_...`) from the dashboard’s API keys page or `POST /v0/api-keys`, sent as a header. Use it where no browser is available: CI, servers, headless agents. The key’s scope and permissions apply unchanged. A key scoped to one inbox sees only that inbox through MCP. Resources outside the scope return not-found errors, exactly as on `/v0`.
- **An access token from auth.md.** An agent that [registered itself](/content/docs/agents/auth-md/index.html) uses its access token here too. Before it is claimed, the token reaches only its sandbox inbox.

## OAuth sign-in

MachineMail follows the [MCP authorization specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization) for revisions `2026-07-28`, `2025-11-25` and `2025-06-18`. MCP clients handle these steps for you; they are listed for client authors and for debugging.

1. **Discovery.** A request without a valid token gets a 401 that points to the protected resource metadata of `/mcp`:

```
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.machinemail.dev/.well-known/oauth-protected-resource/mcp", scope="inboxes.read inboxes.write messages.read ..."

{"error":{"type":"unauthorized","message":"Missing or invalid API key or access token"}}
```

If the request did send a token and it was rejected, the header adds `error="invalid_token"`. A `HEAD` request gets the same 401, for clients that probe before connecting. The metadata document names the resource, `https://mcp.machinemail.dev/mcp`, and the authorization server, `https://machinemail.dev`. The authorization server’s own metadata is at `https://machinemail.dev/.well-known/oauth-authorization-server` (RFC 8414). It lists the authorization, token, registration and revocation endpoints, `code_challenge_methods_supported: ["S256"]`, `token_endpoint_auth_methods_supported: ["none", "client_secret_post", "client_secret_basic"]`, `client_id_metadata_document_supported: true` and `authorization_response_iss_parameter_supported: true`.

2. **Client identity.** A client either uses the https URL of its [Client ID Metadata Document](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/) as its `client_id`, or registers at `/oauth2/register` (Dynamic Client Registration, RFC 7591). ChatGPT, Claude, Claude Code, VS Code, Codex, Zed and Goose use a metadata document; other clients register.

- A metadata document client is public: it authenticates with PKCE alone. A document may prefer another method (ChatGPT’s prefers `private_key_jwt`) as long as `token_endpoint_auth_methods_supported` includes `none`.
   - A registration is public by default (`token_endpoint_auth_method: "none"`). A client that registers with `client_secret_post` or `client_secret_basic` gets a `client_secret` in the response, shown once and never expiring (`client_secret_expires_at: 0`). It then authenticates at the token and revocation endpoints with HTTP Basic or `client_secret` in the body, either way. Other methods are registered as `none`. Grant types other than `authorization_code` and `refresh_token` are dropped from the registration.
   - Every authorization uses PKCE, public client or not.

Redirect URIs must use `https`, or `http` to a loopback address (`127.0.0.1`, `[::1]` or `localhost`, on any port, and any of the three matches the others), or a native app’s own scheme such as `cursor://`.

3. **Authorization.** The client opens `https://machinemail.dev/oauth2/authorize` with `response_type=code`, a PKCE `code_challenge` (`S256`), its `redirect_uri`, `state`, and `resource=https://mcp.machinemail.dev/mcp` (RFC 8707). Without `resource` (clients on the 2025-03-26 spec), the token is for the MCP server. Without `scope`, or with only `openid`, `profile`, `email` or `offline_access`, the client asks for every scope it may use. Any other scope MachineMail doesn’t define is ignored next to ones it does, and on its own is an `invalid_scope` error (a misspelled scope never widens to every scope). Your browser lands on the dashboard’s **Connect an app** page; sign in first if needed. [Connect an agent without code](/content/docs/guides/connect-an-agent#approve-an-mcp-app/index.html) shows this page step by step. The page shows:

- the client’s name, marked as unverified unless it came from a metadata document, in which case it shows the document’s host;
   - where your browser will be sent back to, with a warning when that is a program on your computer;
   - the scopes the client asked for.

Choose the whole organization, one pod or one inbox, then approve (`⌘↵`) or deny. The browser returns to the client with a code, or with `error=access_denied`. Both responses carry `state` and `iss`.

4. **Tokens.** The client exchanges the code at `/oauth2/token` with its `code_verifier`. A code works once, for 2 minutes. The response holds:

- an access token: a JWT that lasts one hour, bound to `https://mcp.machinemail.dev/mcp`;
   - a refresh token.

Every refresh returns a new refresh token, and the old one stops working. Presenting a replaced refresh token within 60 seconds of its replacement returns the current refresh token, so processes refreshing at once, or a late retry after a lost response, are safe, even when another refresh happened in between. After that, presenting an old refresh token revokes the whole grant, because one of the two copies has leaked. A refresh may ask for fewer scopes; scopes outside the grant are dropped. A refresh token expires after 30 days without use. Redeeming a code twice also revokes what was issued for it.

What a token can do is the intersection of three things:

- the scopes you approved;
- the organization, pod or inbox you picked;
- the [permissions](/content/docs/agents/mcp#permissions/index.html) those scopes map to.

The sandbox rules and trust handling are the same as for API keys.

Tokens are bound to their resource. A token issued for `https://mcp.machinemail.dev/mcp` works only on the MCP server, at `https://mcp.machinemail.dev/mcp` and at the legacy `https://machinemail.dev/mcp` (the same server); a token issued for the legacy `resource=https://machinemail.dev/mcp` works on both as well. Neither works on `/v0`. To call the REST API with OAuth, request `resource=https://machinemail.dev/` instead; that token works on both `/v0` and the MCP server. The MCP host’s origin alone (`resource=https://mcp.machinemail.dev`) also means the MCP server. Any other `resource` is refused with `invalid_target`.

### Disconnecting an app

Connected clients are listed under [Settings → Connected apps](/content/app/settings#connected-apps/index.html) in the dashboard, and at `GET /v0/oauth-grants`. Each entry shows the app, what it can reach, who approved it and when it was last used. **Disconnect** (or `DELETE /v0/oauth-grants/{grant_id}`) revokes the grant at once: its access and refresh tokens get 401 on the next request.

A client can also sign itself out with `POST /oauth2/revoke` and its refresh token (RFC 7009). A confidential client must authenticate there with its secret, as at the token endpoint; without it, revocation, a replayed code or an old refresh token is answered `401 invalid_client` and the grant stays. The [API reference](/content/docs/reference/api/agent-auth/index.html) documents every endpoint.

## Permissions

Each tool requires the same permission as the REST endpoint it mirrors. A key without it gets a tool error, not an HTTP error:

```json
{ "isError": true, "content": [{ "type": "text", "text": "forbidden: API key lacks the message_send permission" }] }
```

Every tool error has this shape: `isError: true`, a text block of `<type>: <message>` using the [error types](/content/docs/reference/errors/index.html) of the REST API, and the same error object in `structuredContent`.

## Tools

Seventeen tools. Each returns a readable text block for the model and `structuredContent` with the snake_case data. Inbox ids are email addresses. Full parameters and output shapes are in the [MCP tools reference](/content/docs/reference/mcp-tools/index.html).

### Inboxes

| Tool | What it does |
| --- | --- |
| `list_inboxes` | Lists the inboxes the credential can access. `limit`, `page_token`. Points clients with [MCP Events](/content/docs/agents/mcp#events-chatgpt/index.html) to `message.received` for new mail. |
| `create_inbox` | Creates an inbox and returns its address. Optional `username`, `domain`, `display_name`, `ttl_seconds` (makes it ephemeral) and `client_id` (idempotency key). Points clients with MCP Events to `message.received` for new mail. |

### Reading mail

| Tool | What it does |
| --- | --- |
| `list_threads` | Threads in an inbox, newest first: subjects, senders, counts, no bodies. Filter by `labels`. |
| `get_thread` | A thread with every message oldest first, each in agent-safe form with a trust summary. |
| `list_messages` | Messages in an inbox, newest first, with a 200-character agent-safe preview and `trust_level`. Spam, blocked, unauthenticated and quarantined mail is hidden; `include_quarantine: true` lists quarantined mail with content still withheld. Not for polling on a schedule: watch an inbox with `message.received`. |
| `get_message` | One message: agent-safe `text`, `text_source`, a `trust` summary, attachments metadata. Raw bodies only with `include_raw: true`. |
| `search_messages` | Full-text search over subject, sender and body, with agent-safe previews. `q` is required. |
| `get_trust_report` | The full [trust report](/content/docs/trust/trust-report/index.html) for an inbound message, with hidden text redacted. |
| `get_attachment` | One attachment of a message: small text files as text, small images as an image, anything else as a download link that works for 10 minutes. Withheld for quarantined, spam, blocked and unauthenticated mail and for high-risk files. See [get_attachment](/content/docs/agents/mcp#get_attachment/index.html). |
| `wait_for_message` | Blocks until a new message arrives, up to 60 seconds (default 30), then returns it like `get_message`. Returns `{ "timed_out": true }` otherwise. A short wait, not ongoing monitoring. |

### Writing mail

| Tool | What it does |
| --- | --- |
| `send_message` | Sends a new email from an inbox: `to`, `subject`, `text` and/or `html`, `cc`, `bcc`, `labels`, `attachments`. |
| `reply_to_message` | Replies in the original thread. `reply_all: true` copies the other recipients; `to` overrides them. |
| `forward_message` | Forwards a message to new recipients, with an optional note and the original’s attachments (`include_attachments`, default `true`). |
| `create_draft` | Saves a draft instead of sending. It is listed in the dashboard under Drafts, where a person can send or delete it; `send_at` schedules it. |
| `send_draft` | Sends a draft now. |
| `update_labels` | Adds or removes labels on a message, for example `remove_labels: ["UNREAD"]` to mark it read. |

### Feedback

| Tool | What it does |
| --- | --- |
| `send_feedback` | Tells the MachineMail team how the tools worked: `rating`, `category`, a `message` of up to 2,000 characters and optionally the `tool_name` it is about. Any credential can use it, within the [feedback rate limit](/content/docs/reference/rate-limits#mcp-feedback/index.html). See [send_feedback](/content/docs/reference/mcp-tools#send_feedback/index.html). |

The send, reply, forward and draft tools take `attachments`: small files as base64 `content` (up to 1.4 MB per call, since an MCP request is at most 2 MiB), a public `url` MachineMail downloads, or `message_id` and `attachment_id` to send on a file already in MachineMail without passing its bytes through the model. Files `get_attachment` withholds cannot be sent on. See [Attachments](/content/docs/reference/mcp-tools#attachments/index.html).

## How tools present untrusted mail

Every read tool shows the model the agent-safe view described in [Using trust in agents](/content/docs/trust/using-trust-in-agents#reading-trust-through-mcp/index.html):

- `text` is `trust.content.text`, never the raw body, and `text_source` says so.
- The text block puts the trust level, score and reasons first, then a notice that the content is untrusted, then the body inside `<untrusted_email_content>` tags.
- Quarantined messages are withheld: no text, `text_source: "withheld"`, and a `withheld_reason`.
- `get_attachment` withholds attachments of quarantined, spam, blocked and unauthenticated mail and high-risk files, and returns text attachments inside the same `<untrusted_email_content>` tags.

## Events (ChatGPT)

The server implements [MCP Events](https://developers.openai.com/plugins/build/mcp-events), so a client can ask to be woken when mail arrives instead of polling. ChatGPT uses it to run a task whenever a matching event comes in, and ChatGPT dots use it to watch an inbox. The server advertises `events` in its capabilities and answers `events/list`, `events/subscribe` and `events/unsubscribe` on `/mcp`, with the same credential as the tools.

| Event | Filter | What arrives in `data` |
| --- | --- | --- |
| `message.received` | `inbox_id?` | `message_id`, `thread_id`, `inbox_id`, `from`, `to`, `subject`, `received_at`, `trust_level`, `labels`, `attachment_count`, and a `snippet` of up to 1,000 characters |
| `message.delivered` | `inbox_id?` | `message_id`, `thread_id`, `inbox_id`, `recipients`, `delivered_at` |
| `message.bounced` | `inbox_id?` | `message_id`, `thread_id`, `inbox_id`, `recipients`, `bounce_type`, `bounce_sub_type`, `bounced_at` |

Leave out `inbox_id` to watch every inbox the connection can access. Give it to watch one inbox. `message.received` is the event for monitoring an inbox; `message.delivered` and `message.bounced` fire only for mail sent from your inboxes.
