MCP for agents
A hosted MCP server over the same API key — mail, domains, bookings, contacts, newsletters, forms, and the public kanban, 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, bookings, contact book,
newsletters, contact forms, and the project's public kanban as tools, scoped
by the same API key as the REST API — plus two discovery reads
(list_projects and key_info) so the agent
can see that scope before touching anything.
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 project scope and permissions.
Each tool checks a permission on the key before it runs — see the permission
table in each service section below, and note every tool's advertised
description ends by naming the grant it needs (for example
Requires booking:admin.), so a task can be planned against a key before the
first write. A key without the right grant does not
get an HTTP error back; the tools/call still succeeds at the transport level
and comes back as an isError: true result naming the missing permission (for
example this key lacks inbox:write). tools/list advertises every tool
regardless of what a given key is actually permitted to call — the
permission gate runs on tools/call, not on discovery. To see what a key
actually holds, call key_info: it needs no grant at all.
Tools
56 tools in all, grouped below by the service
they belong to: Projects & access, Mail,
Domains, Bookings,
Contacts, Kanban, Newsletters, and
Forms. Within each service, read tools come first and write tools
follow.
Five of the eight services can put something in front of a stranger: mail
spends the organisation's send quota on send_email and reply_email;
kanban can publish the project's board and open it to public editing;
bookings can open a public scheduling page; newsletters chains a verified
domain into a public subscribe page and, at the top of its blast radius,
send_issue; and forms can open a hosted page with set_form_enabled
(create_form only makes a closed draft) and publish a held stranger's
message to a public GitHub issue with release_submission. Domains only registers a domain and checks its DNS — the
precondition the mail and newsletter services build on — contacts never
leaves the project at all, and projects & access is pure read: discovery,
never action. Each service section opens with what its tools
are for and closes its intro with its own permission table.
project_id takes a UUID or a slug, and defaults only for single-project keys
Every project-scoped tool declares project_id in its advertised
inputSchema, and the value can be the project's UUID or its slug —
the same string a dashboard URL shows. The argument may be omitted only
when the key is scoped to exactly one project, in which case that project
is assumed; a multi-project or all-projects key must pass it, and a call
that omits it fails with an error pointing at
list_projects, the tool that answers with the ids and
slugs in scope.
The kanban content tools that take a board_id, card_id or
comment_id, the newsletter tools that take an audience_id or
issue_id, and the forms tools that take a form_id or submission_id,
are the exception in the other direction: the project is derived from
the row itself, so they never take project_id on any key — a row the
key cannot reach is simply reported as not found.
Projects & access
The discovery pair. Neither takes any argument, neither writes anything, and together they answer the two questions every session starts with: which projects can this key act on, and what is it allowed to do there.
| Tool | Requires |
|---|---|
list_projects | projects ≥ read |
key_info | nothing — any live key |
list_projects
Returns the projects in the key's scope — id, name, slug, and
created_at — whether the key is pinned to a list or reaches all projects in
the workspace. Either the id or the slug of a row works as project_id
on every project-scoped tool below. The REST equivalent is
GET /api/v1/projects, behind the
same projects ≥ read grant.
key_info
Describes the key making the call: its name and token_prefix as shown in
Settings → API keys, the organisation, whether it reaches all projects,
the projects in scope, and the full permission map (a surface absent from the
map is none).
Deliberately gated on nothing: a key may always describe itself, because the
caller already holds the credential — the tool only saves the
one-failed-call-per-grant probe. Two triage moves it enables: compare
token_prefix with the credential your client is actually sending to rule
out a stale config, and read the permission map instead of inferring grants
from denials. The token itself is never returned, only the display prefix.
Mail tools read the project's inbox and send or reply through
the same path as the REST API. send_email and reply_email are the
dangerous pair here: both spend the organisation's send quota, the same
meter send uses.
| Tool | Requires |
|---|---|
list_emails | inbox ≥ read |
get_email | inbox ≥ read |
send_email | inbox ≥ write |
reply_email | inbox ≥ write |
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 project_id (UUID or slug).
Asymmetric with every other tool
This is the one tool that does not hard-fail a multi-project or
all-projects key with no project_id: if you omit it, the call resolves
mail across every project the key can see, the same way
GET /api/v1/projects resolves scope. Every other tool requires you to name
one project unless the key is scoped to exactly that one — this tool is the
only one that can span several projects 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.
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 — useful
for an agent that should show a human what it's about to send before it goes.
publish_kanban below uses the same two-call shape for the same reason.
Domains
Domains tools register a custom domain and re-check its
DNS. A verified domain here is the precondition for the mailbox itself, and
— once verified — for a newsletter audience too: create_audience
refuses anything but a verified custom domain. Neither tool here is
destructive: there is no delete_domain, because removing a domain silently
detaches every inbound address, forward route and sending identity hanging
off it, with no undo — that stays a dashboard action.
| Tool | Requires |
|---|---|
list_domains | domains ≥ read |
add_domain | domains ≥ admin |
verify_domain | domains ≥ write |
list_domains
Lists domains attached to a project and their verification status. No
arguments beyond project_id.
add_domain
Required: domain (a bare domain name, e.g. acme.com); optional confirm,
plus project_id (UUID or slug). Registers a custom domain on the
project — the same path as the dashboard's Add domain button, so the DNS
records you get back are the ones the dashboard would show. Two-phase in
the reply_email shape: call once without confirm for a dry run (scoped and
validated, nothing created), call again with confirm: true to register.
Success is a row, not a working mailbox
add_domain returning ok means a domain row exists — nothing more.
Everything after it depends on DNS you have not published yet. The result
carries dns_records (SPF, DKIM, MX — each with a status); publish them
at the domain's DNS host, then poll verify_domain with
the returned domain_id until it reports verified. No mail can arrive or
send before that.
There is deliberately no delete_domain tool: removing a domain silently
detaches every inbound address, forward route and sending identity hanging off
it, with no undo. Removing a domain stays a dashboard action.
verify_domain
Required: domain_id (from add_domain or list_domains). Asks Resend —
the only DNS oracle — to re-check the domain's records, persists the result,
and returns per-record status plus the domain's overall status and
verified flag.
Deliberately not read-only: it writes the verification state back. It is
still safe to retry — re-verifying an already-verified domain reads once and
never perturbs it — which matters because the honest client loop is
add_domain, publish the records, poll verify_domain. A domain that is not
verified yet comes back as a normal result with a hint, not as an error: DNS
propagation takes time, and polling is the expected shape.
On the first transition into verified, the same first-run seeding as the
dashboard's Verify button runs: hello@ plus starter aliases land on the
domain's allow-list, and the project's org admins get the one-time "domain
verified" email.
Bookings
Bookings tools list what has already been scheduled and
manage meeting types — the draftable, publishable unit behind the project's
public booking page — including the weekly availability hours the page offers.
set_meeting_type_active is the one that puts a page on the public internet.
| Tool | Requires |
|---|---|
list_bookings | booking ≥ read |
list_meeting_types | booking ≥ read |
create_meeting_type | booking ≥ write |
set_meeting_type_active | booking ≥ admin |
set_availability | booking ≥ write |
Availability sits at write, with the draft, not at admin with the
publish: hours are the page's content, and opening the page to the internet
stays the separate, admin-gated act.
list_bookings
Lists bookings on a project, most recent start first. limit optional,
capped at 100. Never includes manage-token hashes.
list_meeting_types
Lists the project's meeting types — the same fields create_meeting_type
returns, plus each type's timezone and its current availability windows.
No arguments beyond project_id. Read it before
set_availability: that tool replaces the whole set, so
this is where you get the windows you want to keep.
create_meeting_type
Required: title; optional slug (derived from the title when omitted:
lowercase letters, digits and hyphens, at most 63 characters),
description (longer text shown under the title on the booking page),
duration_minutes (5–480, default 30), timezone (an IANA zone, e.g.
Europe/Paris — the column default is UTC, so pass the owner's real zone),
and availability (weekly windows in the set_availability
shape, so one call can create a bookable draft). Creates a meeting type as a
private draft: is_active keeps its FALSE database default — a booking
page never opens by omission — so the public
/book/{project}/{type} URL keeps returning 404 until
set_meeting_type_active publishes it. The response carries that future URL,
so you can report where the page will live before publishing it. The same create/publish split as the
kanban pair, for the same reason: creating and publishing must not be one
call with a flag.
Buffers, minimum notice, booking horizon and location keep their column defaults and are edited in the dashboard.
set_availability
Required: meeting_type_id, rules — the complete new set of weekly
windows, each { weekday, start_minute, end_minute } with weekday 0–6
(0 = Sunday), and minutes counted since local midnight (540 = 09:00, 1020 =
17:00). Optional: timezone, the IANA zone the windows are read in — omit it
to keep the type's current zone. An empty rules array clears all hours.
Replaces the entire set, the same whole-list contract as
set_form_fields: whatever you pass becomes the type's
availability, and every window you omit is deleted. The safe loop is read
with list_meeting_types, edit, send the whole list
back. Every window is validated before anything is written — a bad weekday, a
window that ends before it starts, or an unknown timezone refuses the whole
call and changes nothing.
The windows and the timezone travel together on purpose: the minutes are local to the type's zone, so an agent that sets hours without stating the zone it means can publish a page offering 3am slots. Pass both.
set_meeting_type_active
Required: meeting_type_id, is_active (boolean); optional confirm and
allow_empty. Publishing is the externally visible act, so it is two-phase
in the same shape as publish_kanban: call it with
is_active: true and no confirm
and you get a dry run — preview: true and would_publish_at, the URL that
would go live — with nothing changed. Call again with confirm: true to
publish. The success payload returns url, so you never build the public
link yourself.
A page with no hours is refused, not warned about
Publishing a meeting type with zero availability windows is refused —
preview included — with an error naming
set_availability as the fix. A booking page with no
bookable time resolves and looks live, and offers no slot in any month; an
earlier version only attached a warning to the successful publish, and
such pages shipped anyway. If a zero-slot page is really what you want
(hours coming later, page needed now), pass allow_empty: true — the
publish then succeeds and carries the warning.
Deactivating does not need confirm
is_active: false takes effect on the first call — it is the recovery
direction, and the only remedy there is: a meeting type has no delete
(bookings reference it with ON DELETE RESTRICT, so removing one would
take the appointment history with it). Deactivating closes the public page
and keeps the record, same posture as unpublishing the board.
Contacts
Contacts tools read and edit the project's private contact
book. None of the four has a public counterpart — there is no anonymous read
or write path into the book through this service. The book itself can still
fill from public surfaces elsewhere (form submissions and public
kanban card creation by a signed-in visitor); see Contacts
for the capture rules. delete_contact is the one destructive call.
| Tool | Requires |
|---|---|
list_contacts | contacts ≥ read |
create_contact | contacts ≥ write |
update_contact | contacts ≥ write |
delete_contact | contacts ≥ write |
Deleting a contact sits at contacts ≥ write, not admin: a contact row
is one person's email and name, not project configuration. The guard against
an accidental delete is the confirm gate on the tool itself, described
below.
list_contacts
Lists the project's contact book, newest first. limit optional, capped
at 500. Rows are auto-captured from form submissions and public kanban
cards (the exact capture rules are on Contacts), and
created explicitly with create_contact below.
Every row comes back in full — id, email, first_name, last_name,
gender, the open options object, and timestamps — which is why there is
no get_contact tool: the list already carries everything a per-contact
read would return.
create_contact
Required: email; optional first_name, last_name, gender, and
options (an open JSON object stored on the contact — the tool adds
"source": "mcp" provenance unless you set source yourself). The email is
lowercased and validated.
One contact per email per project: creating an address that is already in
the book is refused with a contact with this email already exists in this project, and the existing row — whose names an owner may have edited — is
never overwritten. gender is never inferred by anything in Kontacts; only
set it to something you were explicitly told.
update_contact
Required: id (from list_contacts); optional email, first_name,
last_name, gender, options. Fields you omit are left alone; pass an
empty string to clear first_name, last_name, or gender. email can be
changed but never cleared — it is the row's identity — and changing it to an
address another contact already holds is refused as a duplicate. options
keys are shallow-merged over what is stored, so an edit cannot silently drop
the capture provenance.
delete_contact
Required: id; optional confirm. Permanently removes one contact — there
is no undo, and auto-capture will not restore names an owner had edited. So
it is two-phase in the same shape as reply_email: call it without
confirm and you get a preview echoing the exact row that would be removed,
with nothing deleted; call again with confirm: true to delete. The success
payload echoes the removed row one last time.
Kanban
Kanban tools read and manage the project's single public
board: its settings, columns, cards and comments. publish_kanban and
set_kanban_public_editing are the dangerous pair — one decides whether a
stranger can see the board, the other whether they can write to it, and
they are deliberately two separate calls. A visitor who creates a card while
public editing is on can land in the project's contact book
if they are signed in; see set_kanban_public_editing
below.
| Tool | Requires |
|---|---|
get_board | kanban ≥ read |
list_cards | kanban ≥ read |
list_comments | kanban ≥ read |
publish_kanban | kanban ≥ admin |
set_kanban_public_editing | kanban ≥ admin |
update_board | kanban ≥ admin |
add_column | kanban ≥ write |
rename_column | kanban ≥ write |
move_column | kanban ≥ write |
delete_column | kanban ≥ write |
create_card | kanban ≥ write |
update_card | kanban ≥ write |
move_card | kanban ≥ write |
delete_card | kanban ≥ write |
delete_comment | kanban ≥ write |
The permission split follows one line: update_board joins the publish pair
at admin because it edits the board's configuration — what the public
page is — while columns, cards, and comment moderation are the board's
contents and sit at write.
get_board
No arguments beyond project_id (UUID or slug). Returns the project's whole
kanban graph in one read: the board's settings (title, published,
public_editing, home_url), its columns in order, its
cards with per-card comment counts, and url — the public
/p/{project}/kanban address when the board is published, null while it is
not.
Every project has exactly one board and it exists by default, which is why
there is no create_board: get_board always answers, and
publish_kanban is the only lifecycle action. Boards have
no delete — unpublishing takes the page off the internet. Cards are
removed with delete_card.
The board id in the response is the handle every content tool below takes; column and card ids come from the same read.
list_cards
Required: board_id (from get_board); optional column_id to narrow to
one column (an id from another board is refused). Cards come back in
position order with comment counts.
Card rows here — and on get_board — include creator_email, the address a
guest typed when creating the card publicly. It is collected so the owner
can follow up and this owner-authenticated surface is that reader; the
anonymous read API never returns it.
list_comments
Required: card_id. Returns the card's comments oldest first, each with an
author_label. Comment ids feed delete_comment.
publish_kanban
Required: published (boolean); optional confirm. Every project has exactly
one kanban board and it is private until you publish it — publishing is
what puts the roadmap on the public internet at /p/{project}/kanban, with no
login and no key in the URL.
Because that is externally visible, publishing is two-phase in the same shape
as reply_email: call it with published: true and no confirm and you get
a dry run — preview: true, the board's current state, and
would_publish_at, the URL that would go live — with nothing changed. Call
again with confirm: true to publish. The success payload returns url, so
you never have to build the public link yourself.
Unpublishing does not need confirm
published: false takes effect on the first call. The confirm gate exists
to stop an agent putting a private roadmap on the internet by accident;
taking the page back down is the recovery from that mistake and must not
cost a second round trip. Cards are never deleted either way — there is no
delete path for a board, so unpublishing is the whole undo.
set_kanban_public_editing
Required: public_editing (boolean). Lets visitors create cards in the
board's intake column (Idea); moving and
editing cards stay with project admins either way. On by default for a
new board — publishing is still the separate decision that puts the page
on the internet.
Public card creation is not anonymous in the "no identity" sense: a visitor who is not signed in must send a name and a valid email with the card — the card is refused otherwise. The name becomes the card's public creator label; the email is stored on the card but never leaves through the public read API. A signed-in visitor keeps their session identity, and guest-typed fields never override it.
Card creators can land in the contact book
A signed-in creator's address is auto-captured into the project's contact book. A guest-typed email stays on the card row only — capture reads the signed-in session's address, never the typed field.
Deliberately a separate tool from publish_kanban: "anyone can read my
roadmap" and "anyone on the internet can write to it" are different decisions,
and one call should never make both. No confirm — it cannot expose a board
that is not already published, since an unpublished board 404s for everyone.
Returns the new state plus the public url when the board is live.
update_board
Required: board_id; optional title (at most 80 characters) and home_url
(the public page's "Return to site" link — http(s) only, null clears
it). At least one field is required, and unlike the dashboard form the tool
refuses what it cannot store — an empty title or a javascript: URL is
an error, never silently repaired.
add_column / rename_column / move_column
All take board_id. add_column appends a named column at the end of the
board (names are capped at 40 characters; repeating the call adds a
duplicate). rename_column takes column_id and name; cards stay where
they are. move_column takes column_id and direction (up moves one
step towards the front of the board, down towards the back; moving past
either end changes nothing) and returns the full column order after the
move.
A column_id from another board is refused as column not found on this board on every column and card tool.
delete_column
Required: board_id, column_id. The one destructive column tool, and it
refuses twice: while the column still has cards (move them with
move_card or delete them with delete_card
first) and when it is the board's last column.
create_card
Required: board_id, title (at most 200 characters); optional
description (at most 10,000 characters), labels, and column_id — cards
land in the intake column (Idea) when
column_id is omitted. The creator recorded on the card is the key's owner,
the same identity the dashboard would record from their session.
Labels are a closed set
Bug, Feature, Design, Idea, Question — nothing else. The public
intake path drops an unknown label silently; these tools refuse it
with an error naming the legal set, so an agent attaching "P1" learns
the vocabulary instead of shipping an unlabelled card. Case is
canonicalized (bug → Bug) and duplicates collapse.
update_card
Required: card_id; optional title, description, labels. Omitted
fields are left alone; labels replaces the whole list and follows the same
closed-set refusal as create_card.
move_card
Required: card_id, column_id. Moving across columns appends the card at
the end of the target column; moving a card to the column it is already in
changes nothing.
delete_card
Required: card_id (from get_board or list_cards). Permanently deletes
one card and its comments — the moderation tool for a board with
public editing on. There is no undo and, unlike
delete_contact, no confirm gate: the blast radius is one
card on the caller's own board, and the tool ships destructiveHint: true so a
client knows to check first.
delete_comment
Required: comment_id (from list_comments). Permanently deletes one
comment — the moderation tool for a board with
public editing on. There is no undo and, unlike
delete_contact, no confirm gate: the blast radius is one comment on the
caller's own board, and the tool ships destructiveHint: true so a client
knows to check first.
Newsletters
Newsletters tools run an audience end to end: a public
subscribe page that collects subscribers, private drafting, and send_issue
— external fan-out on a separate monthly quota, and the most dangerous call
on this page. create_audience needs a verified custom domain:
shared kontacts.dev address space is refused, so a newsletter audience
cannot exist until its domain does.
| Tool | Requires |
|---|---|
list_audiences | newsletters ≥ read |
list_subscribers | newsletters ≥ read |
list_issues | newsletters ≥ read |
get_issue | newsletters ≥ read |
get_issue_stats | newsletters ≥ read |
preview_issue | newsletters ≥ read |
create_audience | newsletters ≥ admin |
set_audience_enabled | newsletters ≥ admin |
create_draft | newsletters ≥ write |
update_draft | newsletters ≥ write |
send_issue | newsletters ≥ admin |
The newsletter levels split by blast radius: reads — preview_issue
included, it renders and sends nothing — at read, private drafting at
write, and admin for everything that reaches strangers (minting or
opening the public subscribe page, and send_issue). A key built for a
drafting agent can hold newsletters: write and be structurally unable to
send.
list_audiences
Lists the project's newsletter audiences: name, the from_addr it sends
from, enabled state, the count of active subscribers, and the public
subscribe page (/s/{key}) each one answers on. No arguments beyond
project_id.
list_subscribers
Required: audience_id; optional status (active | unsubscribed |
bounced, omit for all). Newest first. The audience's project is derived
from audience_id — an audience the key cannot reach is reported as not
found, indistinguishable from a nonexistent id.
Unsubscribe tokens are never returned: a token is a live capability to unsubscribe that person, not data.
list_issues
Required: audience_id. Lists the audience's issues newest first — subject,
status (draft | sending | sent | failed), from_addr, sent_at.
Bodies are deliberately not in the list; get_issue carries them.
get_issue
Required: issue_id. One issue in full, including body_markdown. Scope is
derived issue → audience → project, same rule as list_subscribers.
get_issue_stats
Required: issue_id. The per-recipient delivery counts for one issue:
queued, sent, bounced, failed, plus the issue's overall status.
All zeroes for a draft that has never been sent. After a send_issue, this
is the tool that tracks delivery — sent grows as the queue flushes and
bounces land via the provider webhook.
preview_issue
Required: issue_id. Renders the issue through the same markdown pipeline
the send path uses and returns the full sendable HTML document, the plain
text companion, the from address, and recipient_count — the number of
active subscribers a send would reach right now. Sends nothing, spends
nothing, works on any status.
One honest caveat: unsubscribe links are minted per recipient at send time, so the preview substitutes a labelled placeholder rather than showing any real subscriber's token.
create_audience
Required: domain_id (a verified custom domain from list_domains),
name (unique per project, ≤ 60 chars), from_local_part (the part before
the @); optional confirm, plus project_id. Two-phase
in the reply_email shape: without confirm you get a real dry run — domain
resolved and checked, name normalised, and would_send_from, the exact
address the audience would send from — with nothing created. Call again with
confirm: true to create.
Creating an audience opens a public page
The subscribe page at /s/{key} is live — accepting strangers' signups —
from the moment the audience exists (enabled defaults to true). That is
why creation carries a confirm and sits at newsletters ≥ admin.
set_audience_enabled with enabled: false is the off switch.
Shared kontacts.dev address space is refused — newsletters send only from
a verified custom domain. Subscriber counts are also capped per plan
(enforced by the database on every signup), so a growing list can start
refusing new subscribers before anything here does.
There is deliberately no delete_audience tool: deleting an audience
cascades away every subscriber, issue and send record it owns, and a
subscriber list is unrecoverable. Deleting stays a dashboard action.
set_audience_enabled
Required: audience_id, enabled (boolean); optional confirm. Opens or
closes the audience's public subscribe surface (the hosted /s/{key} page
and its API). Enabling is the externally visible act, so it is two-phase like
publish_kanban: preview first, confirm: true to open. Disabling takes
effect on the first call — it is the recovery direction. Disabling only stops
new signups; existing subscribers, issues and stats are untouched, and
send_issue still works.
create_draft
Required: audience_id, subject (≤ 200 chars), body_markdown (≤ 100,000
chars — headings, lists, links, bold/italic; images are not rendered). Creates
a draft issue: a private row that nobody receives. Nothing about a draft
is public and nothing sends until send_issue is called with
confirm: true; preview_issue shows exactly what would go out.
update_draft
Required: issue_id; at least one of subject, body_markdown. Drafts
only — an issue that is sending, sent or failed past the draft stage
is refused with Only a draft can be edited.. body_markdown replaces the
entire stored body, not a patch — send the whole text you want.
send_issue
Required: issue_id; optional confirm. Sends the issue to every active
subscriber of its audience, from the audience's address.
This is the highest-blast-radius call on this server, and the one place the
two-call shape is load-bearing rather than polite. Without confirm you get
the numbers a human should see first: the subject, the from address, and
recipient_count — exactly how many people receive it. preview_issue
carries the rendered content. Call again with confirm: true to send.
There is no recall
Once the send is committed, per-recipient rows are queued and dispatched in
batches — and anything still queued goes out on the next cron flush even
if your call crashes or you never call again. A failed issue can be
sent again with a second confirmed call, and it will send again:
send_issue is deliberately not idempotent. There is no test-send and no
recipient cap; the only safe rehearsal is preview_issue.
Sending charges newsletter_sends_per_month — one unit per recipient, a
different meter from the transactional quota send_email and
reply_email spend. A refused send (over quota, empty list, domain no
longer verified) comes back as a tool error with nothing queued. The success
payload reports queued and how much of it flushed inline; get_issue_stats
tracks the rest.
Forms
Forms tools manage the project's hosted contact forms and
read what they have collected. Two calls are the outward-facing ones:
set_form_enabled opens (and closes) a form's hosted page, and
release_submission can publish an unauthenticated stranger's message to a
public GitHub issue. create_form is not one of them: it makes a closed
draft, and the page refuses submissions until set_form_enabled opens it. set_form_origins looks like it belongs in that group
but does not: it only restricts which third-party sites may embed a form
for submission — the hosted page itself keeps answering regardless of the
origin allow-list.
| Tool | Requires |
|---|---|
list_forms | forms ≥ read |
get_form | forms ≥ read |
list_submissions | forms ≥ read |
get_submission | forms ≥ read |
create_form | forms ≥ write |
update_form | forms ≥ write |
set_form_fields | forms ≥ write |
set_form_enabled | forms ≥ admin |
set_form_origins | forms ≥ write |
archive_form | forms ≥ write |
duplicate_form | forms ≥ write |
release_submission | forms ≥ admin |
The forms levels draw the same line. Editing what a form is — creating a
draft, its copy, fields, embed origins, archived state, a duplicate — sits
at write; a new form, a duplicate and an un-archived form all come back
disabled, so none of it opens anything. admin is for what reaches
strangers: set_form_enabled, the open/close switch on the hosted page;
and release_submission, which can publish a stranger's message to the
open internet.
list_forms
Lists the project's contact forms: name, URL slug, enabled and archived
state, a field count, and each form's public hosted page URL. No arguments
beyond project_id. Full definitions live behind
get_form.
get_form
Required: form_id (from list_forms). One form in full: the ordered field
definitions, the hosted page URL, the pretty URL, an <iframe> embed
snippet ready to paste, the allowed embed origins, and the notification
toggles (notify_owner, confirm_submitter). Scope is derived from the
form itself — an id from outside the key's projects is reported as not
found.
The public key inside the hosted URL and embed snippet is meant to live in page source; it is not a secret. There is deliberately no way to rotate it over MCP — rotation would instantly break every deployed form action, share link and iframe, with no way back — so treat the URL as stable.
list_submissions
Optional: form_id (narrow to one form), limit (capped at 500), plus
project_id. Submissions newest first — sender, subject, and the
hold state. The structured answers live behind
get_submission.
Every submission arrives held
A form sender is unauthenticated by construction — anyone can type any
email address into a form — so every submission lands with held: true
and no GitHub issue. It is still stored, still visible here and in the
dashboard inbox; the hold only stops publication.
release_submission is the override.
Archived and trashed mail is excluded, matching the dashboard's submissions view.
get_submission
Required: submission_id (from list_submissions). One submission in full:
form_data — the structured key/value answers the visitor typed — plus the
formatted message text, the hold state and its reason, and the GitHub issue
link when one was opened. Scope is derived submission → project, and an
ordinary email id is reported as not found: only form submissions answer
here.
create_form
Required: name (unique per project — it seeds the page title and URL
slug); optional project_id. Creates the form as a closed draft, the
same create/publish split as a kanban board or a meeting type: the hosted
page exists but says it is closed and refuses submissions, so nothing goes
public here and no confirm is needed. The natural sequence is create,
shape the fields, restrict the embed origins, then open the page with
set_form_enabled — the single, confirm-gated
go-public step.
The new form starts with a starter field set;
set_form_fields replaces it. The success payload
carries the full definition, the hosted URL the page will answer at, and
the embed snippet.
There is deliberately no delete_form tool: deleting is permanent, and
the messages a form collected outlive it either way.
archive_form is the reversible alternative and stays the
strongest removal this surface offers; hard deletion stays a dashboard
action.
update_form
Required: form_id; at least one of title, description, submit_label,
success_message, home_url, notify_owner, confirm_submitter. Omitted
fields are left alone; empty strings clear title, description and
home_url and restore the defaults for submit_label and
success_message. home_url is http(s) only, refused otherwise.
An empty patch — or any field this list does not name — is refused, not ignored: a typoed field name comes back as an error naming the legal keys, never as a success that saved nothing.
set_form_fields
Required: form_id, fields. Replaces the entire field list. There is
no add-one or remove-one operation: whatever you pass becomes the whole
form, and every existing field you omit is deleted. The safe loop is read
with get_form, edit, send the whole list back — keeping each
field's id to preserve its identity.
Each field is an object with type (short_text, long_text, email,
phone, single_choice, multiple_choice, number, date, hidden),
label, and optionally required, name (the form_data key — derived
from the label when omitted), placeholder, options (required for the
choice types), and defaultValue (hidden fields). At most 40 fields; the
validation is the same one the dashboard builder runs, and a bad entry is
refused with the field named.
set_form_enabled
Required: form_id, enabled (boolean); optional confirm. Opens or
closes the form for submissions. Enabling is the externally visible act, so
it is two-phase like publish_kanban: preview first, confirm: true to
open. Disabling takes effect on the first call — it is the recovery
direction. A disabled form's hosted page still renders, saying the form is
closed; it just refuses every submission.
An archived form is refused in both directions: un-archive first. And
note the interaction the other way — archiving forces enabled to false,
and un-archiving does not restore it, so this tool is always the last step
in bringing an archived form back online.
set_form_origins
Required: form_id, origins — the entire new allow-list, as domains
(acme.com) or full https:// origins. Governs which third-party sites may
submit the form from an embed; the hosted page itself always works.
An empty list means no restriction: every origin may submit, and so may
callers that send no Origin header at all. A non-empty list restricts
submissions to the named origins and their subdomains — and requests with
no Origin header (curl, server-side callers) are then refused, so an
owner-configured restriction cannot be bypassed from outside a browser.
Whole-array replace, same as set_form_fields: an origin you omit loses
access.
archive_form
Required: form_id, archived (boolean). Archiving hides the hosted page
(it answers not-found), refuses submissions, and forces enabled to
false. Fully reversible — this is the soft alternative to deletion — and
the submissions a form already collected stay in the inbox either way.
Un-archiving does not bring a form back online
Un-archiving restores the stored enabled value — which archiving just
set to false. The tool's answer says so explicitly: after
archived: false, call set_form_enabled with
enabled: true to accept submissions again.
duplicate_form
Required: form_id. Copies a form — fields, copy, origins and toggles —
under a fresh name, slug and public key. The copy starts disabled and
non-indexable, so nothing is live until set_form_enabled; existing embeds
keep pointing at the original, because a public key is never shared between
forms. Not idempotent: every call mints another copy.
release_submission
Required: submission_id; optional confirm. Clears the hold on a
submission — and, when the project has a linked GitHub repository, opens a
public GitHub issue containing the submitter's full message at that
moment.
This publishes a stranger's words
The message being released was written by whoever filled in the form, and
the issue that opens is on a public repository. There is no undo on that
disclosure — closing the issue later does not unpublish its history. The
preview names the destination repository (or says no repository is
linked, in which case releasing only clears the hold); nothing changes
until you call again with confirm: true.
The release is recorded as exactly that: the submission's status becomes
released, never clean, so it stays visible afterwards that a person (or
an agent) overrode the hold rather than the message having passed the gate.
Releasing an already-released submission returns the original issue link
without opening a second issue, and a double call cannot open two — the
same guarantee the dashboard's Release button has.
Rate limit
Per key, per server instance — not a global number
The server enforces roughly 60 requests per minute per key before answering 429, but
the cap is counted per server instance, not globally. When your calls are
routed to more than one instance, the effective ceiling for a single key
can be higher in practice. Treat 60 requests per minute per key as a floor, 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 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 tools above 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.