> ## Documentation Index
> Fetch the complete documentation index at: https://docs.flozy.com/llms.txt
> Use this file to discover all available pages before exploring further.

# MCP Server

> Connect Claude, Cursor and other MCP clients to your Flozy CRM using an existing API key.

Flozy exposes its CRM to LLM clients as a **remote MCP server**. Once connected, an agent can search
your leads, read and write tasks and opportunities, and manage contacts on a lead — without you
leaving the chat.

It is the same product surface as the [External API](/introduction), reached over a different
protocol. There is no separate credential, no separate permission model and no separate tenancy: the
API key you already use for REST is the key you paste into your MCP client, and the scopes on that
key decide exactly what the agent can do.

## Endpoint

```
POST https://api.flozy.com/api/v1/mcp
```

Same host as the REST API. The transport is **Streamable HTTP, stateless** — one endpoint, no
session to open, nothing to keep alive. `GET` and `DELETE` on this URL return `405`.

## Authentication

Send your API key as **one** of these headers. Both are accepted, and both are standard header names
that MCP clients allow without special configuration:

```
X-API-Key: flz_live_your_key_here
```

```
Authorization: Bearer flz_live_your_key_here
```

Keys are created in **Settings → API Keys** (agency owners only), and are shown once at creation.
See [Authentication](/authentication) for the key format and the full scope list.

Two details that cause real support tickets:

* **`X-API-Key` wins.** If both headers are present, Flozy reads `X-API-Key` and never looks at
  `Authorization`. A stale `X-API-Key` left over from an earlier attempt will keep failing even after
  you add a correct `Authorization` header.
* **The `Authorization` form only works with a Flozy key.** The value must literally begin with
  `Bearer flz_`. Anything else is treated as *no key at all*, which produces the "API key required"
  401 rather than the "invalid key" 401.

## Before you paste a key: one key means one agency

<Warning>
  One key = one agency, with no per-user layer of any kind. Everyone sharing a connector acts as the
  same principal with the same scopes, and Flozy records no per-user audit trail. Understand this
  before putting a key with `delete:*` scopes into a shared workspace.
</Warning>

The Flozy External API has **no per-user concept**. An API key identifies an *agency*, and nothing
else. Everything below follows from that, and none of it is configurable:

* Every person using a connector configured with your key acts as **the same principal**, with the
  **same scopes**, over the **same agency's data**. There is no per-user permission layer to fall
  back on.
* There is **no per-user audit trail**. The only usage attribution Flozy records is `last_used_at` on
  the key itself, visible in Settings → API Keys. Ten people using the connector produce one
  timestamp on one key.
* If the key holds `delete:leads`, **anyone** who can talk to that connector can trash a lead — and
  trashing a lead cascades to its opportunities and tasks. Flozy cannot tell you who asked for it.

So: a key in a personal Claude Code config is scoped to you. A key in a shared Claude.ai connector or
a committed `.mcp.json` is scoped to everyone who can reach it. Choose the scopes accordingly — see
[Choosing scopes](#choosing-scopes) — and prefer a separate, read-only key for anything shared.

You cannot change the scopes of an existing key. There is no edit operation: to change what a key can
do, create a new key with the scopes you want, re-point the connector at it, and revoke the old one.
(Rotating a key replaces the secret and keeps the scopes.)

## Setup

### Claude Code

```bash theme={null}
claude mcp add --transport http flozy https://api.flozy.com/api/v1/mcp \
  --header "X-API-Key: flz_live_your_key_here"
```

Add `--scope user` to make the connector available in every project instead of just the current one.

The equivalent config block, if you prefer to write it by hand — `.mcp.json` in the project root, or
the user-level settings file:

```json theme={null}
{
  "mcpServers": {
    "flozy": {
      "type": "http",
      "url": "https://api.flozy.com/api/v1/mcp",
      "headers": {
        "X-API-Key": "flz_live_your_key_here"
      }
    }
  }
}
```

A `.mcp.json` written with `--scope project` is committed with your repository. **Do not commit a key
this way** unless the repository is private and everyone with access is meant to have the key's
scopes — see the section above.

### Cursor

Cursor reads MCP servers from `~/.cursor/mcp.json` (all projects) or `.cursor/mcp.json` (one
project). The same shape works:

```json theme={null}
{
  "mcpServers": {
    "flozy": {
      "url": "https://api.flozy.com/api/v1/mcp",
      "headers": {
        "X-API-Key": "flz_live_your_key_here"
      }
    }
  }
}
```

No `type` key is needed — a server with a `url` and no `command` is treated as remote.

### Claude Desktop — same beta as Claude.ai

Claude Desktop is **not** like Claude Code and Cursor here, despite running on your own machine. Its
`claude_desktop_config.json` configures *local* (stdio) servers only and cannot point at this
endpoint. A remote server such as Flozy is added through **Settings → Connectors → Add custom
connector**, which is the same custom-connector mechanism claude.ai uses — including for the request
header that carries your key.

That header field is part of the same beta described below, so treat this path as **unverified in
exactly the same way**. If it is available to you, paste the endpoint URL and supply the key as an
`X-API-Key` or `Authorization` request header. If it is not, use Claude Code or Cursor on the same
machine — those work today and need no beta access.

### Claude.ai and mobile — not yet verified

On claude.ai, a remote MCP server is added as a **custom connector**, and a fixed credential is
attached to it with the **`static_headers`** feature — an org admin pastes the key once when adding
the connector, and it is sent as a request header on every call.

**We have not verified this path.** `static_headers` is a beta feature on Anthropic's side, and we
have not yet confirmed it is available on the plans our customers use. The Flozy side needs nothing
built for it — the server already accepts both `x-api-key` and `authorization`, which are the
standard header names this feature allows — but "the server is ready" is not the same as "it works
for you today", and we will not claim the second until we have tested it.

So, concretely: **it is expected to work via `static_headers`, and it is not yet verified on our
side.** If the option is not available when you try to add the connector — no place to set a header,
or the connector form only offers OAuth — then:

* Do not work around it by putting the key in a URL query string or in the connector's name. Flozy
  does not read a key from anywhere but the two headers above, and neither of those places is a safe
  place for a credential.
* Use **Claude Code or Cursor** instead. Those two read the key from a config file you control, work
  today, and need no beta access. Claude Desktop does not help here — it goes through the same
  custom-connector beta.
* Tell us. Which plan you are on and what the connector form offered is exactly the information we
  need, and it is the thing blocking us from documenting this path properly.

## Check the connection

You can call the endpoint directly. This lists the tools your key can use:

```bash theme={null}
curl -sS https://api.flozy.com/api/v1/mcp \
  -H "X-API-Key: flz_live_your_key_here" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

And this runs a read-only tool:

```bash theme={null}
curl -sS https://api.flozy.com/api/v1/mcp \
  -H "X-API-Key: flz_live_your_key_here" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"describe_workspace","arguments":{}}}'
```

No `initialize` handshake is needed first — the endpoint is stateless.

### The `Accept` header is not optional

<Warning>
  Your request must accept **both** `application/json` **and** `text/event-stream`. `curl`'s default
  `Accept: */*` does **not** satisfy this and returns `406`.
</Warning>

If the `Accept` header does not list both, the transport rejects the request with `406` before it
reaches any Flozy code:

```json theme={null}
{"jsonrpc":"2.0","error":{"code":-32000,
 "message":"Not Acceptable: Client must accept both application/json and text/event-stream"},"id":null}
```

The check is a plain substring match on the header, which is why a wildcard does not satisfy it.
Real MCP clients set the header correctly; it is almost exclusively something you hit
while testing by hand. Copy the `-H "Accept: ..."` line above verbatim.

## Tools

Nineteen tools. The **Scope** column is what your API key must hold for the tool to be available at
all — if the scope is missing, the tool is not advertised to the agent and cannot be called. See
[Troubleshooting](#a-tool-my-agent-says-does-not-exist) for what that looks like from the chat.

| Tool | What it does | Scope required |
| - | - | - |
| `describe_workspace` | Returns the workspace's pipelines and their stages, lead statuses, and custom field definitions — the only source of the opaque ids the write tools need. | **any one of** `read:pipelines`, `read:lead_statuses`, `read:custom_fields` — sections the key cannot read are omitted rather than failing the call |
| `search_leads` | Finds leads, filtered by title text, status, pipeline and creation date. | `read:leads` |
| `get_lead` | Reads one lead by id, with the contacts on it inlined. | `read:leads` — contacts are included only if the key also holds `read:contacts` |
| `create_lead` | Creates a lead. | `write:leads` — plus `write:custom_fields` to send `custom_fields` |
| `update_lead` | Updates a lead's title, status, about text or custom field values. | `write:leads` — plus `write:custom_fields` to send `custom_fields` |
| `delete_lead` | Moves a lead to the trash. Cascades: its opportunities and tasks go with it. | `delete:leads` |
| `search_tasks` | Finds tasks, filtered by title/description text, status, priority, lead and due date. | `read:tasks` |
| `get_task` | Reads one task by id. | `read:tasks` |
| `create_task` | Creates a task, optionally attached to a lead. | `write:tasks` |
| `update_task` | Updates a task — status, priority, dates, the lead it hangs off. | `write:tasks` |
| `delete_task` | Moves a task to the trash. | `delete:tasks` |
| `search_opportunities` | Finds opportunities (deals), filtered by text, stage, lead and expected close date. | `read:opportunities` |
| `get_opportunity` | Reads one opportunity by id. | `read:opportunities` |
| `create_opportunity` | Creates an opportunity on a lead, in a specific pipeline stage. | `write:opportunities` |
| `update_opportunity` | Updates an opportunity — stage, value, confidence, expected close date, note. | `write:opportunities` |
| `delete_opportunity` | Moves an opportunity to the trash. | `delete:opportunities` |
| `create_lead_contact` | Adds a person to a lead. | `write:contacts` |
| `update_lead_contact` | Updates a contact on a lead. Omitted fields keep their values. | `write:contacts` |
| `delete_lead_contact` | Removes a contact from a lead. Not recoverable. | `delete:contacts` |

Three tools need a *second* scope for part of what they do, and in each case the extra scope is
checked inside Flozy rather than by the connector:

* `get_lead` silently omits the `contacts` key without `read:contacts`. The call still succeeds.
* `create_lead` and `update_lead` **fail entirely** if `custom_fields` is sent without
  `write:custom_fields` — the lead is not created, and the other fields of an update are not applied.
  This is the one that surprises people; see [Choosing scopes](#choosing-scopes).

## Choosing scopes

Scopes are the *only* access control on this connector. There is nothing else to configure and no
per-user layer underneath, so the scope list on the key is the whole of your security posture.

A workable default:

1. **Start read-only.** `read:leads`, `read:tasks`, `read:opportunities`, `read:contacts`,
   `read:pipelines`, `read:lead_statuses`, `read:custom_fields`. An agent with these can answer any
   question about the CRM and change nothing. This is the right key for a shared workspace, and the
   right key to start with while you learn how the agent behaves.
2. **Add `write:*` one resource at a time**, as you find yourself asking the agent to do something it
   cannot. `write:tasks` is usually the first and the safest — a wrong task is noise. `write:leads`
   and `write:opportunities` change records your team reports on.
3. **Withhold `delete:*` by default.** Deletes are the scopes to leave off unless you have a specific
   reason. `delete:leads` in particular cascades to the lead's opportunities and tasks, and on a
   shared key there is no record of who asked for it.

Two specifics worth knowing before you pick:

* **`write:custom_fields` is not optional if your workspace uses custom fields.** The lead write
  routes only require `write:leads`, but sending a `custom_fields` object additionally requires
  `write:custom_fields`, and without it **the whole write is rejected** — not just the custom field
  part. A `create_lead` that would have worked fine fails and creates nothing. If your leads carry
  custom fields, grant `write:custom_fields` alongside `write:leads`; if you deliberately do not
  want the agent touching them, expect the agent to hit this and be ready to tell it to retry without
  custom fields.
* **`read:custom_fields`, `read:pipelines` and `read:lead_statuses` are cheap and high value.** They
  are what `describe_workspace` reads, and `describe_workspace` is where the agent gets the pipeline
  stage ids, status names and custom field keys that every write depends on. Without them the agent
  is guessing, and a guessed lead status **silently creates a new status** in your workspace rather
  than failing.

## Troubleshooting

### A tool my agent says does not exist

**Start here. A tool the agent cannot find is almost always a missing scope on your key, not a bug.**

Flozy only advertises the tools your key is scoped for. A key without `delete:leads` is never told
`delete_lead` exists, and if something calls it by name anyway the server answers:

```
Tool delete_lead not found
```

(JSON-RPC error `-32602`; most clients show it to the model as a "tool not found" error.) That
message is honest about the outcome and misleading about the cause: the tool exists, your key just
cannot use it. The refusal itself is correct and nothing was executed — but you will not be told
which scope was missing, which is why this section exists.

**How to fix it:**

1. Find the tool in the [table above](#tools) and read its **Scope** column.
2. Check what your key actually holds, in **Settings → API Keys** — the Scopes column, or *View
   details*.
3. If the scope is missing: **create a new key** with the scopes you want. There is no way to edit
   the scopes of an existing key.
4. Update the header in your MCP client to the new key, restart or reconnect the client, and revoke
   the old key.

To see the whole list your key can reach, run the `tools/list` curl from
[Check the connection](#check-the-connection). What comes back is exactly what the agent sees.

Two related cases:

* **`tools/list` comes back empty** (`{"tools":[]}`). The key holds none of the scopes any tool
  needs. In this one case a `tools/call` *does* name the missing scope in its error, because there is
  no filtered tool list to hide behind.
* **The agent can see a tool but a specific field fails.** That is a different problem — see
  `write:custom_fields` under [Choosing scopes](#choosing-scopes), and the validation errors below.

### 401 — invalid or revoked key

An HTTP `401` with Flozy's standard envelope, before any MCP processing:

```json theme={null}
{ "http_code": 401, "status": "failed", "message": "Invalid or revoked API key." }
```

or, when no key was found on the request at all:

```json theme={null}
{ "http_code": 401, "status": "failed", "message": "API key required. Provide X-API-Key header." }
```

Causes, in the order worth checking:

* The header name is wrong, or the client is not sending it. Confirm with the `tools/list` curl — if
  curl works and the client does not, the problem is the client config.
* The `Authorization` value does not start with `Bearer flz_`. See
  [Authentication](#authentication); this produces the *second* message, not the first.
* The key was revoked or rotated. Rotating issues a new secret; the old one stops working.
* The key **expired**. Keys can be created with an expiry date; an expired key behaves exactly like a
  revoked one. Check *Status* and the expiry in Settings → API Keys.
* You just revoked or rotated and it still works. Authentication results are cached for **60
  seconds** — the old key can keep authenticating for up to a minute. Wait it out before concluding
  the revoke failed.

Retrying does not help with any of these. Note that a `401` fails the whole HTTP request, before any
MCP processing — so the client reports a broken connector rather than a tool error, and no tool runs
at all. If your agent reports that Flozy is unreachable rather than that a tool failed, check here
first.

### 403 — the input was rejected

A validation failure. Because your key's scopes are checked *before* Flozy is called, a `403` is
never a plain "missing scope on this tool" here — that case is the [missing tool
above](#a-tool-my-agent-says-does-not-exist). A `403` means the input itself was rejected, and it is
the one error class where **retrying with corrected input actually works**.

You will not see an HTTP `403` at the MCP endpoint. It surfaces inside the tool result, with Flozy's
validation message passed through verbatim and marked retryable, e.g.:

```json theme={null}
{"error":"Too small: expected string to have >=1 characters","retryable":true}
```

Be aware that these messages usually **do not name the offending field** — only the first problem is
reported, and without its field path. The example above is a lead write with an empty `title`, but
nothing in the text says `title`. An agent will normally guess correctly on a small input and can
guess wrong on a large one, so if you see it retrying and failing the same way, tell it which field
you think is at fault.

Agents generally handle these on their own — read the message, fix the field, call again. You need to
step in when the message points at something the agent cannot know: a pipeline stage that does not
exist in your workspace, a lead id from somewhere else, a custom field key you renamed. Telling the
agent to call `describe_workspace` first resolves most of them.

**One case looks like a validation error but is really a scope problem:** sending `custom_fields`
without `write:custom_fields`. It comes back on the same `403` path, but marked **not** retryable, so
the agent is told to stop rather than loop:

```json theme={null}
{"error":"API key does not have required scope: write:custom_fields. This is a permanent limit on the API key...","retryable":false}
```

The fix is the operator's, not the agent's: either grant `write:custom_fields` on a new key, or tell
the agent to re-send the write without the fields that need it.

### 404 — not found

Also a tool result rather than an HTTP status. The record does not exist, or does not belong to your
agency. Marked non-retryable, because the same id will not start existing. If the agent invented the
id, point it at `search_leads` / `search_tasks` / `search_opportunities` to look the record up
properly.

### 405 — method not allowed

You sent a `GET` or `DELETE`. The endpoint is stateless and accepts `POST` only — there is no SSE
stream to open and no session to delete. If an MCP client is doing this, it is trying to negotiate a
stateful session; check that it is configured for Streamable HTTP rather than the older SSE
transport.

### 406 — the `Accept` header

Covered above: the request must accept **both** `application/json` and `text/event-stream`. If you
are seeing this from a real MCP client rather than from `curl`, the client is sending a non-standard
`Accept` header and that is worth reporting to us.

### 429 — rate limited

An HTTP `429` with the standard envelope:

```json theme={null}
{
  "http_code": 429,
  "status": "failed",
  "error": { "code": "RATE_LIMIT_EXCEEDED", "message": "Rate limit exceeded. Try again after the reset time." }
}
```

See [Rate limits](#rate-limits) below for the ceiling and what to do.

## Rate limits

The MCP endpoint has its **own** budget, separate from the REST API's:

| Limit | Applies to |
| - | - |
| **300 requests / 10 s** | Per API key, rolling 10-second window |

Every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (a Unix
timestamp). On a `429`, wait until `X-RateLimit-Reset` and retry.

The ceiling is per *key*, not per person — so on a shared connector, every user in the workspace
draws from the same budget. It is set for agent traffic, which arrives in bursts: a single agent turn
commonly fans out 8–25 tool calls at once and then goes quiet for tens of seconds, and some tools
(`describe_workspace`, `get_lead`) read several records per call. If you are hitting it in normal
use, tell us — that is a signal the ceiling is wrong, not that you are doing something unusual.

### Context cost

The other budget worth knowing about is your agent's context window, and it binds long before the
rate limit does:

* Simply having the connector enabled costs **north of 10,000 tokens** per conversation — the tool
  list itself, sent before the agent does anything. (The tool descriptions alone are about 5,000;
  the rest is the input schema for every tool, and it grew when the task and deal filters landed.)
* A single full page of leads (`search_leads` at `limit: 100`) is roughly **25,000 tokens**.

So an agent that asks for 100 leads twice has spent most of a small context window on CRM rows. If
answers start getting vague or the agent forgets earlier turns, that is usually the cause. Ask for
narrower results — all three search tools filter server-side (`search_leads` by status, pipeline and
creation date; `search_tasks` by status, priority, lead and due date; `search_opportunities` by
stage, lead and close date) — and `pagination.total` answers "how many?" without returning a single
row.

## Known limitations

Honest list. These are things the tools genuinely cannot do, so that you recognise them when an
agent works around them badly.

**Lead search is narrower than it looks.** `search_leads` filters by title substring, status,
pipeline and creation date, combined with AND. It **cannot** filter by:

* custom field values,
* owner or assignee,
* the lead's `about` text,
* "leads with no status".

An agent asked for any of those has to page through results and filter them itself, which is slow and
goes wrong quietly on large workspaces. Ask for it by title, status or date range where you can.

**`pipeline_id` means less than it sounds like.** A lead is not attached to a pipeline in Flozy's data
model. Filtering `search_leads` by `pipeline_id` actually means *"has at least one opportunity in this
pipeline"*. A lead that is plainly in a sales motion but has no opportunity record **never matches**,
no matter how obviously it belongs there. Making this mean something else would require a real
lead-to-pipeline relationship in the product, not a query change.

**Task search cannot filter by assignee, and takes one status at a time.** `search_tasks` filters by
text (title and description), `status`, `priority`, `lead_id` and a `due_after` / `due_before` date
range, combined with AND. It **cannot** filter by assignee or owner, by start date, or by parent task
and subtask relationships, and it sorts by creation date only — there is no sort by due date.

`status` and `priority` each take a **single** value. "Everything still open" is not one call: it is
one call per status you consider open, or one unfiltered call that you narrow afterwards. Expect an
agent asked for "all open work" either to make three calls or to quietly pick one status.

**A task with no due date matches neither `due_after` nor `due_before`.** The filter is a plain date
comparison, and a `NULL` due date fails both ends of it. So a due-date range **silently excludes
every undated task** — which reads like missing data rather than a filter doing its job. If the
answer looks short, ask for the same query without the date bounds.

**Opportunity search filters by stage, not by pipeline.** `search_opportunities` filters by text (the
parent lead's title and the deal note), `stage_id`, `lead_id`, and an `expected_close_after` /
`expected_close_before` range. There is deliberately **no `pipeline_id` filter** — to ask "what is in
this pipeline?" the agent filters one stage at a time, using the stage ids `describe_workspace` lists
for that pipeline. This is the reverse of `search_leads`, which *does* take `pipeline_id`; the
asymmetry is real and intentional, and follows from the same data model described above. It cannot
filter by value, confidence, owner or creation date, and sorts by creation date only.

**Date filters want `YYYY-MM-DD` exactly.** All of `created_after` / `created_before`, `due_after` /
`due_before`, `expected_close_after` / `expected_close_before` take a plain calendar date. A date-time
is **rejected** rather than truncated, with a message saying so. Both bounds are **inclusive** — a
range of `2026-01-01` to `2026-01-31` includes both days.

**Opportunity responses are ids, not names.** `search_opportunities` and `get_opportunity` return
`stage_id` and `lead_id` and no names. The agent has to resolve them through `describe_workspace` and
`search_leads` before it can say anything readable.

**Updating a lead's `about` text replaces it wholesale**, rather than merging. Sending new `about`
content through `update_lead` discards what was there.

**Not exposed at all:** sequences, campaigns, outreach and the inbox. This is deliberate — giving an
agent the ability to send email on your behalf is a separate decision with compliance weight, and it
is not part of this connector. Do not expect an agent to be able to email a lead through Flozy.

Lead, task and opportunity deletes are **soft** — the record goes to the workspace trash and a Flozy
admin can restore it through the UI. There is no restore *tool*, so the agent cannot undo its own
delete. Contact deletes are not recoverable.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.