Appearance
MCP tools
Krafter exposes 64 tools over the Model Context Protocol (MCP) — 50 reads, 12 writes and 2 session tools (teams_list, teams_select) across nine services — letting AI assistants query and manage data using the same API keys you already issue for REST.
Endpoint: https://mcp.krafter.dev/mcp
GET /mcp opens the Server-Sent Events stream. POST /mcp carries JSON-RPC messages.
Transport
Krafter implements JSON-RPC 2.0 over Streamable HTTP through phantom_mcp, which negotiates the protocol version with the client up to 2025-11-25. Sessions are real: initialize returns an Mcp-Session-Id header, and responses arrive as SSE frames on the request's own stream.
The server identifies itself as { "name": "krafter", "version": "1.0.0" } in the initialize response, which also carries instructions describing the team binding, the scope rules and the irreversible operations.
Supported methods
| Method | Description |
|---|---|
initialize | Negotiates the protocol version and returns server capabilities and instructions. |
notifications/initialized | Client acknowledgement after initialize. Server replies 202 Accepted. |
tools/list | Returns the tools the calling API key is authorised for, with annotations. |
tools/call | Invokes a tool. The result is a JSON-encoded text content block, plus structuredContent where the tool returns an object. |
ping | Liveness check. |
Any other method returns JSON-RPC error code -32601 ("Method not found"). Malformed messages return -32600 ("Invalid request").
Errors are tool results, not protocol errors
A tool that fails — a bad argument, a missing record, an insufficient scope, an unexpected exception — returns a normal tools/call result with isError: true and a message written for the model to correct itself against. JSON-RPC error codes are reserved for what is wrong with the message. This matters because most clients render a JSON-RPC error as a transport failure: the explanation never reaches the model, and the agent retries the same wrong call.
Annotations
Every tool carries MCP annotations, so a client can tell a listing from a deletion without parsing its description:
| Hint | Meaning |
|---|---|
readOnlyHint | The tool does not modify anything. True for all 37 read tools. |
destructiveHint | The change does not come back. True only for mail_delete_domain and mail_delete_suppression. |
idempotentHint | Calling it again with the same arguments changes nothing further. |
Output schemas
Every tool declares an outputSchema alongside its inputSchema, so a client can validate what comes back instead of parsing and hoping. The schema describes the envelope exactly — which key carries the list, that count and total are integers, that next_cursor is a string when present — and leaves item fields open. That restraint is deliberate: a schema that over-claims is worse than a narrow one, because a client acts on it.
next_cursor is never required. Its absence is how a caller knows a listing is finished.
Pagination
List tools are cursor-paginated. A response carries next_cursor when there is another page; pass it back as cursor. When there is no next page the key is absent, not null.
Cursors are opaque and keyset-based: they carry the position of the last row, not an offset, so rows arriving mid-listing do not shift the pages under you. A cursor is bound to the tool and the filters that produced it — handing one to a different tool, or reusing it after changing a filter, is refused rather than silently answered with the wrong rows.
Tool errors
| JSON-RPC code | Meaning |
|---|---|
-32001 | Tool not found |
-32002 | Permission denied (API key lacks the required scope) |
-32602 | Invalid or missing parameters |
-32603 | Internal error (uncaught exception during tool dispatch) |
Authentication
Two ways in. Both end at the same place — a credential with a team and a set of scopes — so every tool, limit and audit row below behaves identically whichever you use.
OAuth 2.1 — for MCP clients
Point your client at https://mcp.krafter.dev/mcp and let it do the rest. There is nothing to paste: the server answers an unauthenticated request with an RFC 9728 discovery pointer, the client registers itself (RFC 7591), sends you to a consent screen, and stores its own token.
WWW-Authenticate: Bearer error="invalid_token",
resource_metadata="https://mcp.krafter.dev/.well-known/oauth-protected-resource/mcp"On the consent screen you tick which teams the connection may act on and which scopes it gets. A connection covering one team acts on it straight away; one covering several must select the active team in-session with teams_select before any other tool works, and every action is recorded against the team it acted on. Revoking from one team's screen removes that team and leaves the connection working for the others.
What the server supports:
| Issuer | https://mcp.krafter.dev |
| Grants | authorization_code, refresh_token — no implicit, no ROPC, no client credentials |
| PKCE | required, S256 |
| Client authentication | none — public clients only |
| Access token lifetime | 24 hours, with rotating refresh |
| Resource (RFC 8707) | https://mcp.krafter.dev/mcp |
Discovery documents: /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource/mcp.
Connections are listed and revoked in the dashboard under API Keys → Connected applications, beside the team's API keys. Revoking takes effect on the client's next request, not at token expiry.
API key — for your own code
Send a key in the Authorization header, the same Bearer format the REST API uses:
Authorization: Bearer kr_live_...An API key is the right choice for a script or a service you run yourself, where there is no browser to approve a consent screen. Unlike an OAuth connection, a key can also be narrowed to named resources — one mail domain, one log project — and it opens the REST API and the SMTP relay as well, so treat it as the broader credential of the two.
Scopes
Both credential types use the same scope vocabulary, and the same table decides which tools a scope reaches. tools/list returns only the tools your scopes satisfy, and tools/call re-checks before dispatching — an absent tool and an unscoped tool look identical from the client.
| Scope | Reaches |
|---|---|
analytics:read | Analytics reads |
audit:read | AI Audit reads |
audit:write | Audit findings, tasks, and starting scans |
cron:read | Cron reads |
domains:manage | Creating, verifying and deleting mail domains |
flags:read | Flags reads |
forms:read | Forms reads |
logs:read | Logs reads |
mail:manage | Suppression list writes |
mail:read | Mail reads |
push:read | Push reads |
surveys:read | Surveys reads |
surveys:write | Editing surveys and questions |
webhooks:read | Webhooks reads |
webhooks:write | Replaying webhook events |
The wildcard * grants every tool. It is an API key scope only — an OAuth connection can never hold it, because that credential is granted by clicking a button rather than by deliberately minting a key.
A write scope does not imply read. Pair surveys:write with surveys:read to both list and edit. Krafter issues more scopes than the fifteen above, but the rest are REST-only and reach no MCP tool.
Origin validation
POST /mcp validates the Origin header against :krafter, :mcp_allowed_origins (default ["http://localhost:*"]). Mismatched origins return 403 Forbidden. Configure the list in runtime.exs to allow your assistant's host.
Tool inventory
Teams — any scope (2 tools)
Which team the connection acts on. Every other tool acts on the active team and takes no team argument; a connection granted for several teams selects one here first. Callable with any scopes — they expose nothing beyond the connection's own teams.
| Tool | Description |
|---|---|
teams_list | The teams this connection may act on, and which is active. |
teams_select | Make one of them active (write — changes what every later call acts on). Refuses a team the connection was not granted for. |
Analytics — analytics:read (7 tools)
| Tool | Description |
|---|---|
analytics_list_sites | All analytics sites for your team, with each site's event retention. |
analytics_get_stats | Pageviews, visitors, visits over a period with optional dimension breakdown. |
analytics_realtime | Active visitors in the last 30 minutes. |
analytics_top_pages | Most visited pages for a period. |
analytics_top_referrers | Top traffic sources for a period. |
analytics_events | Custom event counts and revenue. |
analytics_goal_conversions | Conversion stats for a specific goal. |
AI Audit — audit:read, plus audit:write for the writes (9 tools)
A finding carries recommended_fix as a structured map, a severity and a numeric priority_score — output built to be read by software, which until recently nothing could reach.
org_id is not an argument: it is the team that owns the API key, so the tools derive it.
| Tool | Description |
|---|---|
audit_command_center | Overview of one project: open findings by severity, recent scans, trend. The cheapest call that says whether anything needs attention. |
audit_list_findings | Findings, highest priority first. Filter by severity, status or domain. |
audit_get_finding | One finding in full, with its evidence items and complete recommended_fix. |
audit_update_finding | Change status, severity or owner — accept, dismiss or reprioritise. Requires audit:write. |
audit_list_tasks | Remediation tasks opened against this project's findings. |
audit_create_task | Open a remediation task against a finding. Requires audit:write. |
audit_update_task | Change a task — status, priority, owner, due date, PR link. Requires audit:write. |
audit_report_summary | Report-level totals, severity split and posture over time. |
audit_run_scan | Queue a new scan. Consumes AI quota and counts against the billed scan limit. Non-idempotent — do not retry blindly. Requires audit:write. |
Cron — cron:read (3 tools)
| Tool | Description |
|---|---|
cron_list_jobs | All cron jobs for your team with schedules and last-run info. |
cron_get_job | Details for a single cron job by ID. |
cron_list_executions | Recent executions for a specific job. |
Flags — flags:read (6 tools)
| Tool | Description |
|---|---|
flags_list_projects | All feature-flag projects for your team. |
flags_list_flags | Flags in a project, optionally filtered by status and tags. |
flags_get_flag | A single flag by ID, including its evaluation rules. |
flags_evaluate | What a flag would return for a given user or context. A dry run: it resolves exactly as the SDK would, but records no evaluation, enrols nobody in an experiment and is not billed. |
flags_list_audit_log | Audit log entries for your team's flags. |
flags_list_experiments | A/B test experiments for a specific flag. |
Forms — forms:read (3 tools)
| Tool | Description |
|---|---|
forms_list_forms | All forms for your team with submission and unread counts. |
forms_get_form | Details for a single form by ID. |
forms_list_submissions | Submissions for a form, with spam-status filter and full-text search. |
Logs — logs:read (5 tools)
| Tool | Description |
|---|---|
logs_list_projects | All log projects with retention and daily-limit settings. |
logs_search | Search entries by level, stream, full-text query, and time range (max 1000). |
logs_list_streams | All streams within a project. |
logs_list_alerts | Alerts configured for a project. |
logs_get_usage | Today's ingestion usage (entry count + bytes). |
Mail — mail:read, plus domains:manage / mail:manage for the writes (10 tools)
| Tool | Description |
|---|---|
mail_list_emails | Emails sent by your team, with status filter and search. |
mail_get_email | A single email record with its delivery events. |
mail_email_stats | Totals by status, with optional daily breakdown. |
mail_list_domains | Sending domains and their verification status. |
mail_list_suppressions | Suppressed addresses (bounces, complaints, manual). |
mail_create_domain | Register a sending domain and return the DNS records to publish — with the note that on Cloudflare every CNAME and MX must be DNS-only (not proxied). |
mail_verify_domain | Start a background verification check; the result lands on the domain's status, read it back with mail_list_domains. |
mail_delete_domain | Delete a sending domain. Irreversible; requires confirmation. |
mail_create_suppression | Suppress an address so no further mail is sent to it. |
mail_delete_suppression | Release a suppressed address. Irreversible; requires confirmation. |
Push — push:read (5 tools)
| Tool | Description |
|---|---|
push_list_apps | Push apps with subscriber counts. |
push_list_subscribers | Subscribers for an app, filtered by platform / status / tags. |
push_list_notifications | Notifications for an app, optionally filtered by status. |
push_get_notification | A single notification with targeting and delivery stats. |
push_daily_stats | Daily counters for an app (sent, delivered, clicked, failed, new_subscribers, unsubscribes). The delivered counter is currently always 0 — see Analytics → Response Fields for why. |
Surveys — surveys:read, plus surveys:write for the writes (8 tools)
| Tool | Description |
|---|---|
surveys_list | Surveys for your team, newest first. Cursor-paginated. |
surveys_get | One survey with its sections and questions — the structure a breakdown is keyed by. |
surveys_stats | Response counts and completion rate. |
surveys_question_breakdown | What people answered, aggregated per question. This is what turns a survey into findings. |
surveys_completion_funnel | Per-question reach, so the question that loses people is visible rather than inferred. |
surveys_list_responses | Individual responses, newest first. Cursor-paginated. |
surveys_update | Change name, status or the public-results flag. active opens a survey for responses. Requires surveys:write. |
surveys_update_question | Edit one question's text, help text or options. Requires surveys:write. |
Webhooks — webhooks:read (6 tools)
These cover the webhook relay service — webhooks Krafter receives and forwards. The outgoing webhooks that mail, forms, flags, push, signal, surveys and waitlists each send are configured per service and are not these; an empty source list does not mean your team has no webhooks.
| Tool | Description |
|---|---|
webhooks_list_sources | Webhook sources for your team. |
webhooks_get_source | Details for a source including destination count. |
webhooks_list_events | Received events for a source, with event_type filter. |
webhooks_get_event | One received event in full, with its delivery attempts — status, response code and error per destination. This is what answers "why did my webhook not arrive". |
webhooks_replay_event | Re-deliver a received event. Sends real traffic; name destination_ids to avoid duplicating a delivery that already succeeded. Requires webhooks:write. |
webhooks_list_destinations | Forwarding destinations for a source. |
Write tools
Twelve tools write. They call the same context functions the REST controllers call, so an MCP mutation gets the same validation, DNS record generation and side effects as one made through /api/v1 — there is no second path that can skip them. They use the same scopes as REST:
| Tool | Scope | Notes |
|---|---|---|
mail_create_domain | domains:manage | Returns the DNS records that must be published. |
mail_verify_domain | domains:manage | Sets status to verifying and enqueues the check. Safe to repeat. |
mail_delete_domain | domains:manage | Irreversible. Confirmation required. |
mail_create_suppression | mail:manage | |
mail_delete_suppression | mail:manage | Irreversible. Confirmation required. |
audit_update_finding | audit:write | Only the fields you pass change. |
audit_create_task | audit:write | |
audit_update_task | audit:write | |
audit_run_scan | audit:write | Costs money. Spends AI quota and a billed scan; non-idempotent. |
surveys_update | surveys:write | status: "active" makes a survey reachable by respondents. |
surveys_update_question | surveys:write | |
webhooks_replay_event | webhooks:write | Sends real HTTP. Non-idempotent — a retry delivers twice. |
Deleting a survey and designing one from nothing are deliberately absent: a delete takes its responses with it and a response cannot be recreated, and a half-built survey shared with respondents is worse than none.
Confirmation for irreversible operations
The two destructive tools cannot act in a single call. The first call performs no work: it measures what would be lost, returns those numbers, and hands back a short-lived signed confirmation string. Only a second call carrying that string in confirm executes.
The confirmation is bound to the API key, the tool, the subject and the measured impact, and expires after five minutes. If what the operation would affect has changed since the preview, the confirmation is refused and a fresh preview is required — so a person who agreed to one set of numbers cannot have a different one executed.
This is a brake on a single hallucinated call, not a human in the loop: the same agent may spend the confirmation it just received. It lives on the server precisely because a gate that exists only in one client's settings protects only that client.
Audit trail
Every tool call leaves a row in the team's audit log — reads included, which is the half that is usually missing. The row carries the tool, the credential it came through and which kind that was (api_key or oauth), the outcome (ok, gated, denied, invalid_params, exception) and the duration.
Arguments are hashed, never stored. A SHA-256 fingerprint of the sorted arguments is recorded instead of their values, so the trail does not become a second copy of the data it watches over — while still proving two calls were identical and making a loop that repeats one call a thousand times visible as a thousand rows sharing one hash.
A confirmation preview is recorded as gated rather than ok: a destructive call the server stopped is the most interesting event in the trail, and it must not read as an ordinary success.
Hosts
https://mcp.krafter.dev/mcp is the endpoint. https://app.krafter.dev/mcp serves the same server for API-key callers and keeps working, so existing configurations do not break — but the dedicated host is the one to use, and the one this page documents.
OAuth works only on mcp.krafter.dev. The authorisation server and its discovery documents answer on that host alone; the app host returns 404 for them. A client pointed at app.krafter.dev will not find an authorisation server to register with.
Rate limiting
MCP is throttled per credential, in its own bucket, separate from the REST API's per-team one. Each credential — an API key or an OAuth connection — gets 300 requests per minute at /mcp, and the two kinds never share a counter.
That separation is the point rather than a detail: sharing the team's single REST budget meant an agent that mis-paginated and looped could spend it and take the team's transactional mail down with it. One team holds several credentials — the agent's and production's — and they must not be able to exhaust each other.
Coverage gaps
Writes outside the twelve above are still REST-only: sending mail, toggling flags, scheduling notifications, creating cron jobs.
Long-running work — audit_run_scan, webhooks_replay_event, mail_verify_domain — returns as soon as the work is queued rather than when it finishes. The MCP specification has an extension for this (a durable task handle the client polls), which the transport library does not yet support. Until it does, read the outcome back: audit_command_center for a scan, webhooks_get_event for a replay, mail_list_domains for a verification.