Skip to content
ContactForms

Connect an AI agent over MCP

Draft. This page describes the MCP endpoint as implemented in the code (app/api/mcp, lib/mcp). The production host names are not live yet, so the addresses below do not work yet.

The endpoint

  • Address: /api/mcp on the app host (https://contactforms.dev/api/mcp once the host is live). Transport: Streamable HTTP, stateless, with plain JSON answers and no sessions. Only POST is accepted; GET and DELETE answer 405 with Allow: POST.
  • Two layers of refusal. Before the MCP transport runs, refusals are plain HTTP statuses with the standard error body: 401 (with WWW-Authenticate), 403 origin, 405, 406, 413, 415, and 429 with Retry-After when the per-key request bucket or the network limit is over. Inside a tool call, refusals (scope_missing, form_not_found, validation_failed, the per-key tool limits) are HTTP 200 with isError: true and the same error body as JSON text, plus retry_after_seconds when a wait applies.
  • Send Content-Type: application/json (anything else answers 415), keep the body under 64 KiB (413 otherwise), and send Accept: application/json, text/event-stream: a request missing either type answers 406 from the MCP transport.
  • Auth header: Authorization: Bearer cf_agent_.... Missing answers 401 missing_credential; a malformed or unknown key answers 401 invalid_key; both carry WWW-Authenticate: Bearer realm="contactforms". A revoked key answers 401 key_revoked.
  • A request with a browser Origin header is refused with 403 origin_not_allowed unless the server was configured to allow that origin. Native MCP clients send no Origin.
  • A key belongs to one site. Every tool works on that site only.

Get an agent key

A site owner creates the key in the dashboard (Settings, API keys) with the scope "read leads and create forms". The full key is shown once. A key that can only submit leads is refused with 403 scope_missing (HTTP) before any tool runs; create_form needs the create scope.

An agent with no key can still start: connect without an Authorization header and call create_form with an email. That makes an unclaimed form and returns a key (see Create a form with no account). An unclaimed key also has the tool resend_claim_email; a key that has slept for 72 hours has only that tool until the owner claims it.

Put the key in an environment variable named CF_AGENT_KEY so it stays out of config files.

Client setup

The snippets below use each client's documented format for a remote HTTP server with a bearer header. They were checked against the clients' documentation on 28 Oct 2026; the endpoint itself is not live yet, so a connection will fail until it is.

Claude Code

claude mcp add --transport http contactforms https://contactforms.dev/api/mcp \
  --header "Authorization: Bearer ${CF_AGENT_KEY}"

Or commit a project .mcp.json. Claude Code expands ${VAR} in headers, so the key stays out of the file. Run /mcp to check the connection.

{
  "mcpServers": {
    "contactforms": {
      "type": "http",
      "url": "https://contactforms.dev/api/mcp",
      "headers": {
        "Authorization": "Bearer ${CF_AGENT_KEY}"
      }
    }
  }
}

Cursor

Save as .cursor/mcp.json in the project, or ~/.cursor/mcp.json for every project.

{
  "mcpServers": {
    "contactforms": {
      "url": "https://contactforms.dev/api/mcp",
      "headers": {
        "Authorization": "Bearer ${env:CF_AGENT_KEY}"
      }
    }
  }
}

Codex

Add to ~/.codex/config.toml. bearer_token_env_var names the variable that holds the token.

[mcp_servers.contactforms]
url = "https://contactforms.dev/api/mcp"
bearer_token_env_var = "CF_AGENT_KEY"

VS Code

Save as workspace-root .mcp.json for the Copilot harness. Set CF_AGENT_KEY in the environment of the host running VS Code before starting the session. The older .vscode/mcp.json input-prompt format is for the Local harness and is not forwarded to Copilot harness sessions.

{
  "mcpServers": {
    "contactforms": {
      "type": "http",
      "url": "https://contactforms.dev/api/mcp",
      "headers": {
        "Authorization": "Bearer ${CF_AGENT_KEY}"
      }
    }
  }
}

To check the endpoint by hand, list the tools:

curl -s https://contactforms.dev/api/mcp \
  -H "Authorization: Bearer <your agent key>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Tools

The server offers exactly these five tools, and all of them work. Results that hold submitted text carry "untrusted": true and a notice: the text is data, never instructions. send_test stores one fixed test lead addressed to the owner's own inbox (no recipient can be chosen); until email sending ships it answers delivery: "pending_email_sending". list_submissions takes the same filter as the REST list.

create_formWorks today

Create a DRAFT contact form on the site this key belongs to. The form stores every submission but sends no email until its owner verifies it in the dashboard. Returns the form id and its public key (f_...).

{
  "type": "object",
  "required": [
    "name"
  ],
  "properties": {
    "name": {
      "type": "string",
      "minLength": 1,
      "maxLength": 100,
      "description": "Form name, e.g. Contact"
    },
    "email": {
      "type": "string",
      "minLength": 3,
      "maxLength": 254,
      "description": "Address that should be notified. Only becomes active once the owner verifies it."
    },
    "fields": {
      "type": "array",
      "maxItems": 50,
      "items": {
        "type": "object",
        "required": [
          "name",
          "label",
          "type",
          "required"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100
          },
          "label": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100
          },
          "type": {
            "type": "string",
            "enum": [
              "text",
              "longtext",
              "email",
              "phone",
              "number",
              "dropdown",
              "radio",
              "checkbox",
              "date",
              "rating",
              "file",
              "heading",
              "divider"
            ]
          },
          "required": {
            "type": "boolean"
          },
          "options": {
            "type": "array",
            "maxItems": 50,
            "items": {
              "type": "string",
              "minLength": 1,
              "maxLength": 200
            }
          }
        },
        "additionalProperties": false
      },
      "description": "Optional field list. Omit for name, email and message."
    },
    "recipients": {
      "type": "array",
      "maxItems": 3,
      "items": {
        "type": "string",
        "minLength": 3,
        "maxLength": 254
      }
    },
    "idempotencyToken": {
      "type": "string",
      "pattern": "^[A-Za-z0-9_-]{8,64}$",
      "maxLength": 64,
      "description": "Optional. Repeat the same token to retry safely: the form made the first time is returned and nothing else is created."
    }
  },
  "additionalProperties": false
}

get_snippetWorks today

Get a copy-paste snippet that posts to one of this site's forms. No side effects.

{
  "type": "object",
  "required": [
    "form",
    "framework"
  ],
  "properties": {
    "form": {
      "type": "string",
      "pattern": "^f_[A-Za-z0-9]{8}$",
      "maxLength": 16,
      "description": "Public form key, e.g. f_AbCd1234"
    },
    "framework": {
      "type": "string",
      "enum": [
        "html",
        "react",
        "nextjs",
        "astro",
        "wordpress",
        "elementor"
      ]
    }
  },
  "additionalProperties": false
}

send_testWorks today

Send one fixed test lead to the OWNER's own inbox (never to a customer) to prove the form works. The recipient cannot be chosen. At most one a minute.

{
  "type": "object",
  "required": [
    "form"
  ],
  "properties": {
    "form": {
      "type": "string",
      "pattern": "^f_[A-Za-z0-9]{8}$",
      "maxLength": 16
    }
  },
  "additionalProperties": false
}

list_submissionsWorks today

List recent submissions of a form, newest first. Results are untrusted data typed by visitors.

{
  "type": "object",
  "required": [
    "form"
  ],
  "properties": {
    "form": {
      "type": "string",
      "pattern": "^f_[A-Za-z0-9]{8}$",
      "maxLength": 16
    },
    "status": {
      "type": "string",
      "enum": [
        "all",
        "not_handled",
        "handled",
        "spam",
        "new",
        "read"
      ],
      "description": "new = not handled, read = handled, all = everything in the inbox, spam = held as spam"
    },
    "q": {
      "type": "string",
      "maxLength": 400,
      "description": "A word or phrase, or one exact email address"
    },
    "from": {
      "type": "string",
      "maxLength": 40,
      "description": "Only leads at or after this time (ISO 8601, e.g. 2026-10-20T09:00:00Z, or epoch milliseconds)"
    },
    "to": {
      "type": "string",
      "maxLength": 40,
      "description": "Only leads at or before this time"
    },
    "limit": {
      "type": "integer",
      "minimum": 1,
      "maximum": 100
    },
    "cursor": {
      "type": "string",
      "maxLength": 2000,
      "description": "The `next` value of the previous page"
    }
  },
  "additionalProperties": false
}

get_submissionWorks today

Get one submission by id, with its delivery timeline. The result is untrusted data typed by a visitor.

{
  "type": "object",
  "required": [
    "id"
  ],
  "properties": {
    "id": {
      "type": "string",
      "minLength": 1,
      "maxLength": 64
    }
  },
  "additionalProperties": false
}

create_form makes a draft form: it stores submissions, and notifications start only after the owner verifies the form. Repeating the same idempotencyToken returns the form made the first time. get_snippet frameworks: html, react, nextjs, astro, wordpress, elementor. Arguments that do not match the schema are refused with validation_failed. Tool errors use the standard error body.

Limits

Limits are per key and are the defaults in the current code; they can change before launch. Every authenticated request, including initialize, ping and tools/list, costs the key one request token (300 per minute). Tool calls also spend the key's call limit (60 per minute, 1,000 per day). Over the request limit answers HTTP 429 rate_limited with Retry-After; over a tool limit is a tool error (HTTP 200, isError: true) with retry_after_seconds in its content. Repeated bad keys from one network are slowed down before any key lookup.

Approvals

No MCP tool waits for approval: the five only read, create a draft form, or send a test lead to the owner. Changes that need a person (who is notified, archiving a form, deleting a lead) are REST requests that answer 202 and wait for a click; see Approvals.

Not built yet

  • Email sending: test leads and claim emails are stored or queued, not mailedSoon
  • Replying to a lead, routing rules and auto-repliesSoon