MCP server

What your client must do to connect to Pairly's CRM MCP server.

Pairly runs a Model Context Protocol server for each business's CRM. An agent or MCP client connected to it can create and update contacts and companies, record account events and send transactional email, with the same effects as the signed webhook.

Requirements

A client that meets all of these can connect.

  1. Endpoint and transport. Streamable HTTP at https://api.pairlyhq.com/v1/mcp/crm. The path works with or without a trailing slash. The server is stateless: every request stands alone, so do not depend on a session surviving between requests.
  2. Authentication. Send Authorization: Bearer <key secret> on every request. The secret is the same one used to sign webhooks (Keys). There is no OAuth flow. A 401 response names a resource_metadata URL in its WWW-Authenticate header, but nothing is served there, so ignore it. Every valid key carries the single scope crm:write.
  3. Business scoping. A key belongs to exactly one business, and the server derives the business from it. No tool takes a business id.
  4. Call from a server. Requests that carry an Origin header are accepted only from Pairly's own API origins. Any other origin, including app.pairlyhq.com, gets 403 Invalid Origin header. Browser-based clients are therefore not supported, and the secret must not reach a browser anyway. Server-side clients send no Origin and are unaffected.
  5. Handle a revoked key. Rotating or revoking the key takes effect immediately and without notice. A missing, wrong or revoked key gets 401 with WWW-Authenticate: Bearer error="invalid_token" and the JSON body {"error": "invalid_token", "error_description": "Authentication required"}. Treat it as "ask the business for a new key", not as something to retry.
  6. Treat tool errors as data. A tool that fails returns a normal result with isError: true and one text item reading Error executing tool <name>: <message>. Validation messages (for example email is not a valid email address) mean the arguments must change. Could not record the event; please retry is a generic server-side failure that any tool can return. Retrying is safe for the upserts, upsert_company and delete_person. record_user_created and record_email_confirmed add a lifecycle note on every call, so a retry may add a duplicate note, and a retry of record_event may log the event twice. For send_transactional_email, retry with the same idempotency_key. A successful result is one text item holding JSON such as {"status": "ok", "person_id": "…"}.

Behaviour

  • Contacts are matched by email. Upserts fill in empty fields and never overwrite a value someone already set.
  • Tags are merged onto the contact. Sending fewer tags never removes one.
  • On tools that take an email argument, an address that is invalid or longer than 320 characters is rejected before anything is written. send_transactional_email validates its to address separately.
  • delete_person archives the contact and is safe to repeat. A later upsert for the same email finds the archived contact and does not un-archive it.
  • record_event has no idempotency key, unlike the webhook's user.event. If you need exactly-once event logging with retries, use the webhook.
  • send_transactional_email is transactional only (receipts, password resets) and is sent once per idempotency_key. When the business has reached its sending limit the call fails with an error that says how long to wait; retry with the same idempotency_key.

Full argument lists: tool reference.

Connecting

claude mcp add --transport http pairly-crm https://api.pairlyhq.com/v1/mcp/crm \
  --header "Authorization: Bearer $PAIRLY_KEY_SECRET"

Conformance checklist

Run these with MCP Inspector (npx @modelcontextprotocol/inspector): transport "Streamable HTTP", your URL, and the Authorization header.

  • initialize succeeds.
  • tools/list returns exactly the seven tools in the reference.
  • upsert_person with a test email returns {"status": "ok", "person_id": "…"}, and the contact appears in the business's CRM.
  • The same call with a wrong or revoked key is refused with HTTP 401.

You can also run the Python or TypeScript snippet above to perform these checks.