MCP for agents
A hosted MCP server over the same API key — six tools, one project-shaped context per call.
POST /api/v1/mcp — Streamable HTTP MCP, protocol version
2025-03-26. This is not IMAP and not a mail client:
an agent gets the project mailbox, domains, and bookings as tools, scoped by
the same API key as the REST API.
Auth
Authorization: Bearer gm_live_… — the exact
same key as send and list emails. There
is no separate MCP credential and no session-cookie path; see
Authentication for the two key formats.
Every tool takes an undeclared project_id
None of the six tools' advertised inputSchemas mention project_id —
you won't see it from a tools/list call. But every handler reads
args.project_id, and with an org/scoped key, five of the six tools
hard-fail with project_id is required for this key if you don't pass
it. A project key doesn't need it (its own project is implied), which is
probably why the schema omits it — but that leaves an org-key caller with
no way to discover the argument except this page. Pass project_id
explicitly whenever you're calling with an org/scoped key.
Tools
Read tools first, then write tools — calling a write tool spends the organisation's send quota, same as send:
list_emailsget_emaillist_domainslist_bookingssend_email(spends send quota)reply_email(spends send quota)
list_emails
Lists mail in scope, newest first (same defaults as the dashboard: live,
non-archived, both directions). Arguments: direction (inbound |
outbound, optional), archived (boolean, optional), limit (optional,
capped at 50), plus the undeclared project_id above.
Asymmetric with the other five tools
This is the one read tool that does not hard-fail an org key with no
project_id: if you omit it, an org/scoped key gets mail across every
project the key can see, resolved the same way GET /api/v1/projects
resolves scope. get_email, list_domains, list_bookings, send_email,
and reply_email all require you to name one project — this tool is the
only one that can span several in a single call.
get_email
Reads one message by id, including text and html body — the single-message
read that has no REST equivalent (see List emails). An
id from outside the key's project is reported as not found, not as a
permission error.
list_domains
Lists domains attached to a project and their verification status. No
arguments beyond project_id.
list_bookings
Lists bookings on a project, most recent start first. limit optional,
capped at 100. Never includes manage-token hashes.
send_email
Required: to, subject, text; optional html. Re-enters
POST /api/v1/send in-process — same validation, same quota,
same response shape, same "no from field" rule. There is no separate MCP
send path to drift from the REST one.
reply_email
Required: id (an inbound email to reply to), text; optional html and
confirm. Two-phase by design: call it once without confirm and you
get a preview (from, to, subject, the text you sent, and a hint) with
nothing sent. Call it again with confirm: true to actually send. This is
the only tool here that requires a second call to take effect — useful for
an agent that should show a human what it's about to send before it goes.
Rate limit
Per key, per server instance — not a global number
The server enforces roughly 60 requests per minute per key
(MCP_RATE_LIMIT_WINDOW_MS / MCP_RATE_LIMIT_MAX in apps/web/lib/mcp.ts),
but the counter is an in-memory Map local to one serverless instance,
not a shared store. On a platform that can route your calls to more than
one instance, the effective ceiling for a single key can be higher than
60/min in practice. Treat it as "at least 60/min per key," not as an exact
global cap — and don't build retry logic that assumes the 429 arrives at a
precise, repeatable request count.
Protocol surface
JSON-RPC 2.0 over a single HTTP endpoint. Batches (a JSON array of requests)
are supported. A request with no id is a notification: it's processed but
never answered, and a body that is only notifications gets 202 with no
JSON body at all.
| Method | Behaviour |
|---|---|
initialize | Returns protocolVersion, capabilities, and server info. |
ping | Empty result. |
tools/list | Returns the six tool definitions above. |
resources/list | Always { resources: [] } — no resources are exposed. |
prompts/list | Always { prompts: [] } — no prompts are exposed. |
tools/call | Dispatches to one of the six tools by name. |
Other HTTP behaviour: OPTIONS returns 204 (CORS is wide open,
Access-Control-Allow-Origin: *); GET returns 405 with an Allow
header pointing at POST; invalid JSON is 400.
JSON-RPC error codes: -32600 invalid request, -32601 method or tool not
found, -32602 invalid params (for example, tools/call with no name).
Tool failures are not HTTP errors
A tool that fails — bad arguments, "not found", a downstream error —
returns a successful JSON-RPC result with isError: true and the
problem in content/structuredContent, never a non-2xx HTTP status or a
JSON-RPC error object. Check isError in the result, not the HTTP
status code, to know whether a tools/call actually worked.
Connecting a client
Cursor, Claude Code, and other MCP clients that take a remote HTTP server URL:
{
"mcpServers": {
"kontacts": {
"url": "https://kontacts.dev/api/v1/mcp",
"headers": {
"Authorization": "Bearer gm_live_…"
}
}
}
}
Claude Code CLI:
claude mcp add --transport http kontacts https://kontacts.dev/api/v1/mcp \ --header "Authorization: Bearer gm_live_…"
Put the key in the header your client sends, never in a prompt — it's a live credential with the same reach as any other API key.
Keys are minted under Settings → API keys
(/dashboard/settings/api-keys), same page as REST keys.