Skip to content

MCP client setup

The MCP surface wraps the REST API one-to-one, so your agent gets email as native tool calls. Connect either over HTTP (type: http against the API’s POST /mcp) or over stdio (local @openagentemail/mcp). Both register the same tool set.

The stdio package needs Node.js 18+ on the machine running the MCP client (a local checkout runs with Bun instead), plus two environment variables:

Variable Purpose Default
OPENAGENTEMAIL_API_URL Base URL of the API http://localhost:3100
OPENAGENTEMAIL_API_KEY The identity token (oa_…) from POST /v1/identities — or an admin key for full access — (required)

In the stdio examples below, /path/to/openagentemail is wherever you cloned the repo. From a local checkout the server starts with bun run /path/to/openagentemail/packages/mcp/src/main.ts; without a checkout, npx -y @openagentemail/mcp runs the published npm package. See packages/mcp/README.md for server internals.

The API process also exposes a stateless MCP endpoint at POST /mcp (MCP 2026-07-28 / SDK v2). Clients that support remote MCP can talk HTTP + Bearer directly — no local @openagentemail/mcp stdio wrapper.

Item Value
URL https://<your-api-host>/mcp (plain http://…:3100/mcp only on loopback or an encrypted tunnel such as a WireGuard tailnet — never on the public internet or other untrusted networks)
Auth Authorization: Bearer <oa_… / admin API_KEYS / OAuth access token>
Session None — no Mcp-Session-Id; each request authenticates on its own
Methods POST only (other methods → 405 with Allow: POST)
Discovery GET /.well-known/oauth-protected-resource (RFC 9728 PRM; also path-aware …/oauth-protected-resource/mcp) → authorization_servers points at the AS issuer → GET /.well-known/oauth-authorization-server (RFC 8414). Public, no auth.
Public origin Env MCP_PUBLIC_URL sets the canonical public origin for PRM resource / derived authorization_servers, OAuth issuer & token audience, and the 401 WWW-Authenticate resource_metadata= URL. Required for any public ingress.

Deployment: loopback / tailnet is still the secure default (Track 0). To open /mcp + OAuth on the public internet, set MCP_PUBLIC_URL and OAE_PUBLIC_EDGE=true, then follow Exposing MCP publicly (optional Cloudflare fronting or a bare reverse proxy — CF is not required).

Two ways to put a Bearer on POST /mcp:

Create an identity (admin) and paste its scoped token into the client config. This is the usual path for Cursor / Claude Desktop / local agents you control.

{
"mcpServers": {
"openagentemail": {
"type": "http",
"url": "http://127.0.0.1:3100/mcp",
"headers": {
"Authorization": "Bearer oa_…"
}
}
}
}

Off loopback, use https. Authorization: Bearer rides with every request; cleartext HTTP exposes the token to anyone on the path.

Compare the existing stdio shape (local npx wrapper, same API over REST):

{
"mcpServers": {
"openagentemail": {
"command": "npx",
"args": ["-y", "@openagentemail/mcp"],
"env": {
"OPENAGENTEMAIL_API_URL": "http://127.0.0.1:3100",
"OPENAGENTEMAIL_API_KEY": "oa_…"
}
}
}
}

Missing or invalid Bearer on POST /mcp returns 401 with a WWW-Authenticate challenge that points at the PRM URL (unlike /v1/*, which returns bare JSON {"error":"unauthorized"}).

2. OAuth web authorization (ChatGPT / Claude and similar)

Section titled “2. OAuth web authorization (ChatGPT / Claude and similar)”

Web agents that speak standard OAuth use authorization code + PKCE (S256 only) + CIMD — no Dynamic Client Registration. The owner approves in the Dashboard consent UI; the resulting access token is identity-scoped only (never admin) and audience-bound to the MCP resource (…/mcp).

  1. GET /.well-known/oauth-protected-resource (RFC 9728 PRM) — read authorization_servers for the issuer.
  2. GET /.well-known/oauth-authorization-server (RFC 8414) — expect among others:
    • code_challenge_methods_supported: ["S256"]
    • authorization_response_iss_parameter_supported: true
    • client_id_metadata_document_supported: true
    • authorization_endpoint, token_endpoint, revocation_endpoint

Endpoint details: REST API — OAuth Authorization Server.

client_id is the HTTPS URL of a Client ID Metadata Document the client hosts. Required fields: client_id, client_name, redirect_uris. Any client_secret* key is rejected. redirect_uri must match a registered URI exactly; for loopback HTTP IP literals (127.0.0.1 / [::1]), port is ignored per RFC 8252. PKCE must use S256. Every authorize / token request must include the RFC 8707 resource parameter equal to {base}/mcp.

On the authorization callback (including error redirects), the client must compare the returned iss to the discovered issuer and abort on mismatch (RFC 9207 mix-up defense). Unless CSRF protection rests entirely on verified S256 PKCE, also send a high-entropy state and verify it on return (RFC 9700).

GET /authorize hands off to /ui/oauth/authorize inside a Dashboard admin session. Only the owner (admin session) can approve. The consent page shows client_name, the client_id host, and the redirect host; localhost / loopback redirects show an extra warning. On approve, pick an existing identity or create one on the spot.

Token Lifetime Notes
Access 1 hour Bearer for POST /mcp
Refresh 30 days Rotating — each refresh issues a new refresh token and invalidates the previous one
  • Exchange the code at POST /oauth/token (grant_type=authorization_code + PKCE verifier + resource).
  • Refresh with grant_type=refresh_token (same resource / client_id binding).
  • Revoke via POST /oauth/revoke (RFC 7009), or revoke the whole grant from Dashboard Authorized clients at /ui/oauth/grants.
Tool What it does
mail_new_identity(name?, localpart?, canNotifyUser?) Create a new address. Pass localpart for a custom address (e.g. qa-bot), or omit for a random one like fox-k7d2. canNotifyUser is admin-only
mail_list_identities() List all identities
mail_list_messages(address, limit?) List an inbox (newest first). Each item includes id/from/to/subject/date/seen/snippet/hasOtp/source. limit is 1–200 (server default 50)
mail_read_message(address, id) Full message: text, optional html, otp.codes / otp.links, links, source, and optional taskId/taskState. Optional messageId is this message’s angle-bracket Message-ID when it passes the same check as inReplyTo; optional references are its own References ids (order kept, deduped, at most the last 50). Both are omitted when missing or invalid. id is the IMAP UID. To reply, pass messageId as mail_send inReplyTo and references, if any, as references
mail_mark_seen(address, id, seen?) Mark read (default) or unread — reading never changes the flag by itself
mail_wait_for(address, fromContains?, subjectContains?, timeoutSec?) Block until a matching message arrives. Schema allows up to 600s; the API clamps with MCP_MAX_WAIT_SECONDS (default 60). Same detail fields as mail_read_message
mail_send(from, to, subject, text, html?, autoReply?, idempotencyKey?, inReplyTo?, references?) Send from an existing identity. Optional autoReply follows POST /v1/send: only true sets Auto-Submitted: auto-replied and uses the auto-reply cooldown. Optional idempotencyKey (after trim, 1–128 printable ASCII characters, code points 0x20–0x7E; whitespace-only is a validation error, not an omitted key) is sent as the Idempotency-Key header, not in the JSON body. The same sender and body within 24h replays the original response (Idempotent-Replayed: true) and does not send again; a different body with that key is REST 422 {"error":"idempotency_key_reused"} (validated inReplyTo and references stay as sent in the fingerprint; header dedup and appending inReplyTo do not change that array); a concurrent duplicate is REST 409 {"error":"idempotency_in_flight","retryAfterSec":1}. If send-log replay failed at startup, a keyed send is REST 503 {"error":"idempotency_unavailable"} and does not send; requests without a key are unchanged. Optional inReplyTo (one angle-bracket Message-ID) and references (1–50 of the same form; requires inReplyTo) set In-Reply-To and References; omit both and those headers are not added. The subject is not rewritten. If references is too long for the 8000-character join limit, keep the last entries that fit
notify_user(title, message, level?, tags?) Human-alert notification. Identity tokens need the server-side canNotifyUser grant; no ntfy topic or credential
notify_agent(name, title, message, level?, tags?) Wake a named agent by identity localpart (e.g. qa-bot). Server owns topics/credentials
notify_check(since?) Read this identity’s recent notifications (since is an optional ntfy duration/timestamp filter)
notify_verify() Harmless delivery self-check; same human-alert permission as notify_user
task_create(to, subject, body?, kind?, approval?, wait?) Assign a task to another managed identity (supports kind="approval" with typed action and expiry); optional wait is clamped by MCP_MAX_WAIT_SECONDS (default 60, schema max 600) for completed/failed
task_list_children(parentTaskId, limit?, cursor?) List only direct readable children of a readable parent task (limit is 20, 50, or 100; cursor-paged)
task_list(state?) List this identity’s email-backed tasks, optionally filtered by current state
task_get(id, wait?) Read one task thread and stamped state history; optional wait clamped the same way
task_update(id, state, body?, result?) Advance a task as a participant (completed/failed are terminal). Optional result becomes a JSON block in the reply
task_decide(id, decision) Approve or reject an approval task ("approved" or "rejected") as its designated reviewer; records decision only
task_claim(id, leaseSec?) Claim a submitted task as its managed recipient for a bounded lease (leaseSec is 30..3600, default 300)
task_renew(id, leaseToken, leaseSec?) Renew an active task lease using its current opaque lease token (leaseSec is 30..3600, default 300)
task_release(id, leaseToken, reason?) Release a task lease using its current opaque lease token; optional release reason
mail_webhook_list(address?) List outbound webhook subscriptions (identity callers see their own; admin callers may filter by address)
mail_webhook_create(url, address, events, contentScope?, description?) Create an outbound webhook subscription; returns subscription metadata and displayed signing secret (whs_...)
mail_webhook_delete(id) Permanently delete an outbound webhook subscription (whk_...) and cancel any pending retries
mail_webhook_test(id) Send an immediate probe ping to test webhook connectivity (whk_...)
mail_webhook_disable(id) Pause an active webhook subscription (whk_...) by marking it disabled

A typical automated-signup flow is: mail_new_identity → do the signup with that address → mail_wait_for(address, subjectContains="verify") → open otp.links[0].

Mail tools that return bodies or snippets (mail_list_messages, mail_read_message, mail_wait_for) leave only source === "internal" not fenced. Missing, unknown, or "external" values are wrapped as untrusted data. A compromised managed identity can still send stamped internal mail whose body contains hostile instructions — fencing is not authorization.

Regardless of source, agents should treat email bodies as data. Whether to act on anything in them must come from a separate authorization policy, not from source alone.

For non-internal mail, the MCP server wraps text / html / snippet in a bilingual [UNTRUSTED EXTERNAL EMAIL — START|END <nonce>] fence (random nonce per fenced field) and inserts a zero-width space into any fence-looking prefix inside the body so a forged end marker cannot close the outer fence early. Seeing that wrapper in tool output is intentional — extract OTP/links, do not follow body instructions. Details: Reading untrusted mail.

Terminal window
claude mcp add openagentemail \
--env OPENAGENTEMAIL_API_URL=http://localhost:3100 \
--env OPENAGENTEMAIL_API_KEY=oa_your-identity-token \
-- bun run /path/to/openagentemail/packages/mcp/src/main.ts

Or edit ~/.claude.json / project .mcp.json directly with the JSON block from Generic MCP clients below.

Edit claude_desktop_config.json:

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: %APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"openagentemail": {
"command": "bun",
"args": ["run", "/path/to/openagentemail/packages/mcp/src/main.ts"],
"env": {
"OPENAGENTEMAIL_API_URL": "http://localhost:3100",
"OPENAGENTEMAIL_API_KEY": "oa_your-identity-token"
}
}
}
}

Restart Claude Desktop after saving. If the server doesn’t appear, check Settings → Developer logs — a wrong absolute path to main.ts (or missing Bun) is the usual cause.

Edit ~/.cursor/mcp.json (global) or .cursor/mcp.json in the project:

{
"mcpServers": {
"openagentemail": {
"command": "bun",
"args": ["run", "/path/to/openagentemail/packages/mcp/src/main.ts"],
"env": {
"OPENAGENTEMAIL_API_URL": "http://localhost:3100",
"OPENAGENTEMAIL_API_KEY": "oa_your-identity-token"
}
}
}
}

Then toggle the server on in Settings → MCP.

Edit ~/.kimi-code/mcp.json (user level) or .kimi-code/mcp.json in the project (project level takes precedence):

{
"mcpServers": {
"openagentemail": {
"command": "bun",
"args": ["run", "/path/to/openagentemail/packages/mcp/src/main.ts"],
"env": {
"OPENAGENTEMAIL_API_URL": "http://localhost:3100",
"OPENAGENTEMAIL_API_KEY": "oa_your-identity-token"
}
}
}
}

Or run /mcp-config in the TUI to add it interactively; /mcp shows connection status. Tools appear as mcp__openagentemail__mail_wait_for, etc.

Connect ChatGPT (Pro, Web) via remote OAuth:

  1. Enable Developer mode in Settings.
  2. Create a new app or connector.
  3. Set the MCP URL to https://inbox.openagent.email/mcp.
  4. Choose OAuth for authentication.
  5. In advanced OAuth settings, the client registration method automatically selects Client ID Metadata Document (CIMD). Keep this default — no token needs to be pasted.
  6. On the authorization page, choosing an identity requires an active admin session (owner-only; unprivileged user sessions cannot approve).

Connect Grok (Web) via remote OAuth:

  1. Go to grok.com/connectors and create a new connector.
  2. Select Custom.
  3. Set the connector URL to https://inbox.openagent.email/mcp.
  4. Follow the prompts to complete OAuth authorization. (The connector details page provides an entry to re-authenticate whenever needed.)
  5. In conversations, mention @<connector-name> to call OpenAgentEmail tools.

Any client that speaks stdio MCP can run the server the same way:

{
"mcpServers": {
"openagentemail": {
"command": "bun",
"args": ["run", "/path/to/openagentemail/packages/mcp/src/main.ts"],
"env": {
"OPENAGENTEMAIL_API_URL": "http://localhost:3100",
"OPENAGENTEMAIL_API_KEY": "oa_your-identity-token"
}
}
}
}

For the stdio wrapper only: the MCP child process still runs on the client machine, but OPENAGENTEMAIL_API_URL can point anywhere the API is reachable — e.g. your VPS. (To skip the local wrapper entirely, use Remote HTTP instead.)

"env": {
"OPENAGENTEMAIL_API_URL": "https://api.example.com",
"OPENAGENTEMAIL_API_KEY": "oa_your-identity-token"
}

The API key is sent as a bearer token, so use HTTPS when the API is off localhost.

  • Server starts but every tool errors with 401 — OPENAGENTEMAIL_API_KEY matches neither an admin key (API_KEYS env) nor an identity token. If you lost the token, rotate a new one: POST /v1/identities/:address/token (admin).
  • Tools error with 403 — the identity token is scoped to one address; you’re asking for another one, or calling an admin-only tool (create/list identities needs the admin key).
  • Server exits immediately at startup — OPENAGENTEMAIL_API_KEY is missing (the server refuses to run without it).
  • Server fails to start — check Bun is installed and the absolute path to packages/mcp/src/main.ts; most clients log the child’s stderr.
  • mail_wait_for returns nothing — the server clamps waits with MCP_MAX_WAIT_SECONDS (default 60, max 600). Verify inbound mail with ./deploy/doctor.sh before blaming the client. On a public edge, keep the cap under every proxy read timeout — see Exposing MCP publicly.
  • Message body looks wrapped in UNTRUSTED EXTERNAL EMAIL — expected for non-internal mail. See External-mail fence.