Kontacts docs
MCP

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.

ToolRequires
list_projectsprojects ≥ read
key_infonothing — 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

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.

ToolRequires
list_emailsinbox ≥ read
get_emailinbox ≥ read
send_emailinbox ≥ write
reply_emailinbox ≥ 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.

ToolRequires
list_domainsdomains ≥ read
add_domaindomains ≥ admin
verify_domaindomains ≥ 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.

ToolRequires
list_bookingsbooking ≥ read
list_meeting_typesbooking ≥ read
create_meeting_typebooking ≥ write
set_meeting_type_activebooking ≥ admin
set_availabilitybooking ≥ 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.

ToolRequires
list_contactscontacts ≥ read
create_contactcontacts ≥ write
update_contactcontacts ≥ write
delete_contactcontacts ≥ 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.

ToolRequires
get_boardkanban ≥ read
list_cardskanban ≥ read
list_commentskanban ≥ read
publish_kanbankanban ≥ admin
set_kanban_public_editingkanban ≥ admin
update_boardkanban ≥ admin
add_columnkanban ≥ write
rename_columnkanban ≥ write
move_columnkanban ≥ write
delete_columnkanban ≥ write
create_cardkanban ≥ write
update_cardkanban ≥ write
move_cardkanban ≥ write
delete_cardkanban ≥ write
delete_commentkanban ≥ 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.

ToolRequires
list_audiencesnewsletters ≥ read
list_subscribersnewsletters ≥ read
list_issuesnewsletters ≥ read
get_issuenewsletters ≥ read
get_issue_statsnewsletters ≥ read
preview_issuenewsletters ≥ read
create_audiencenewsletters ≥ admin
set_audience_enablednewsletters ≥ admin
create_draftnewsletters ≥ write
update_draftnewsletters ≥ write
send_issuenewsletters ≥ 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.

ToolRequires
list_formsforms ≥ read
get_formforms ≥ read
list_submissionsforms ≥ read
get_submissionforms ≥ read
create_formforms ≥ write
update_formforms ≥ write
set_form_fieldsforms ≥ write
set_form_enabledforms ≥ admin
set_form_originsforms ≥ write
archive_formforms ≥ write
duplicate_formforms ≥ write
release_submissionforms ≥ 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.

MethodBehaviour
initializeReturns protocolVersion, capabilities, and server info.
pingEmpty result.
tools/listReturns the tool definitions above.
resources/listAlways { resources: [] } — no resources are exposed.
prompts/listAlways { prompts: [] } — no prompts are exposed.
tools/callDispatches 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.

On this page