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

# Integrating MCP clients

> Connect an MCP client to DMLY by signing in with OAuth or by pasting a scoped API key, and read the failure responses when it does not connect.

Every client connects to the same address (`POST https://dash.dmly.io/mcp`) and there are two
ways to authenticate to it: **sign in with OAuth**, or **paste a `dmly_` API key**. That choice
also decides what the connection is allowed to do, so read [MCP overview](/mcp/overview) first if
you have not.

## Which method

|                   | OAuth (sign in)                                                                            | API key                                                                                                                                           |
| ----------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| Setting it up     | The client sends you to DMLY, you sign in and pick a workspace                             | You mint a key in DMLY and paste it into the client                                                                                               |
| Best for          | Hosted and desktop clients with an "add a connector" flow                                  | Config-file clients, scripts, your own agent, anything headless                                                                                   |
| The credential is | One person's connection to one workspace                                                   | A workspace credential that records the member who generated it                                                                                   |
| What it may do    | The tools that person's team role allows in the dashboard                                  | What the key's scope allows (`mcp:read` or `mcp:write`), narrowed to what its creator's team role allows                                          |
| Cutting it off    | Remove that person from the workspace, or narrow their role. Both bite on the next request | Revoke the key. Removing its creator from the workspace, or narrowing their role, also bites on `/mcp`, but neither touches the key's REST access |

Neither is a fallback for the other. If your client offers a connector or sign-in flow, OAuth is
the shorter path: nothing to mint, and no key to paste into a file. If it wants a URL and a
header, use a key.

## Connect over OAuth

DMLY publishes everything a client needs to register itself: the discovery documents at
`/.well-known/oauth-authorization-server` and `/.well-known/oauth-protected-resource`, dynamic
client registration at `/oauth/register`, and the authorization-code flow with PKCE (`S256`) plus
refresh tokens. Nothing has to be created in DMLY first; you give the client the address and it
does the rest. There is one scope, `mcp:use`, and the client is issued it automatically; it is not
something you pick, and it decides nothing on its own.

<Note>
  **Callback addresses.** A client that registers an `https://` callback always works; that is
  what claude.ai and other hosted clients use. A native desktop client that registers a
  private-use scheme instead (RFC 8252) is accepted for `claude://`, `cursor://` and `vscode://`;
  any other scheme is refused at registration with `400 invalid_redirect_uri`, before you ever see
  a consent screen.
</Note>

<Note>
  **Registration is rate limited.** `/oauth/register` allows 10 registrations an hour from one
  address, inside a 200-an-hour ceiling across the platform that a caller rotating addresses cannot
  sidestep. Past either, it answers `429` with `{"error":"too_many_requests"}` and *Registration is
  rate limited.* A real client registers once per install, so this only bites a script in a loop:
  register once and keep the `client_id` you were issued rather than re-registering per connection.
</Note>

<Steps>
  <Step title="Give the client the server address">
    `https://dash.dmly.io/mcp` is the same address for every workspace, including one whose people
    reach DMLY on an agency's own domain. In a hosted client this is the "custom connector" or
    "remote MCP server" field; wording differs by client and changes with releases, so follow its
    own documentation for where that lives.
  </Step>

  <Step title="Sign in to DMLY">
    The client opens a DMLY page. If you are not signed in already you get the login screen first,
    then come back to the same place.
  </Step>

  <Step title="Choose the workspace">
    The consent screen names the client that is asking, says what it will be able to do, and gives
    you exactly one choice: which **Workspace** it may work in. Pick it and select **Connect**
    (or **Cancel** to refuse). It will only ever see that one workspace.

    Above the picker, whenever the authorization request carries a `redirect_uri`, the screen also
    shows the host that code would be sent to: **Access will be sent to** *host*, followed by *"If
    you don't recognise that address, close this page instead of connecting."* Read that line rather
    than the name at the top. Registration is open by design, so the client name is whatever the
    registering party typed and proves nothing; the callback host is the one element of the screen
    a phisher cannot fake, which is why it is there.
  </Step>

  <Step title="Ask it something read-only">
    Before you let it act: *How many conversations are open?* See [Verify it
    works](#verify-it-works).
  </Step>
</Steps>

### What an OAuth connection may do

There is no scope to pick on the consent screen, because the one scope a connection carries
(`mcp:use`) authorises nothing by itself. Your **team role** is the whole gate: the connection acts
as **you**, and gets the tools your role would let you use in the dashboard.

| Your role in the workspace | What the connection gets                                                                                                                                                                                                                               |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Owner** or **Admin**     | All 37 tools                                                                                                                                                                                                                                           |
| **Member**                 | 33 tools: the everyday operator set of reading, messaging, contacts, invoices and broadcasts, but not the team-diary tools (`list_appointments`, `book_appointment`, `reschedule_appointment`, `cancel_appointment`), which are admin-level by default |
| **Viewer**                 | 15 tools: the read set minus `list_appointments`, which is admin-level like the other team-diary tools. Nothing it does reaches a customer                                                                                                             |
| A **custom role**          | Exactly the tools whose permission is ticked for that role                                                                                                                                                                                             |

The per-tool permissions are listed in the [tools reference](/mcp/tools-reference#per-tool-permissions),
and they are the same keys you tick when building a role. See [Roles and
permissions](/account/roles-and-permissions). Per-member restrictions count too: a member whose
inbox access was switched off gets no inbox or messaging tools here either, and one whose
reporting access was switched off gets no `get_workspace_overview`, which is the first call the
server tells every client to make. A connection missing it answers *not found* on that call and
carries on with the rest.

<Note>
  A tool your role does not grant is **not registered**, so it never appears in `tools/list`, and
  calling it by name anyway answers *not found*, because the server resolves a call through the
  same eligibility check as the listing. Change someone's role and their connection changes with
  it; nothing has to be reconnected.
</Note>

<Note>
  **Contact details are masked here too.** If the connecting person's role withholds
  `contacts.view_phone` or `contacts.view_email`, phone numbers and email addresses come back
  masked over MCP exactly as they do in the dashboard, so a connection really is bounded by what
  its owner can see, PII included. The same holds on an API key, where the acting member is the one
  who generated it. Only the [REST API](/api-reference/introduction) is unmasked.
</Note>

<Tip>
  Want an AI connection that can read but never act, over OAuth? Connect it as someone whose role
  is **Viewer**. Otherwise mint an **AI: read** key, below.
</Tip>

The workspace you picked is remembered against that (person, client) pairing. Re-consenting with
a different workspace **moves** the connection rather than adding a second one, and two different
AI clients can hold two different workspaces, which is how you work across both. Membership is
re-checked on every request, so someone removed from a workspace stops reaching it immediately,
without anyone having to tidy up the connection.

**How long a connection lasts.** An access token is good for 15 days and the refresh token behind
it for 90. A client in normal use refreshes silently and you never notice. One left untouched for
more than 90 days has nothing left to refresh with, and reconnecting means going through sign-in
and consent again.

## Connect with an API key

### 1. Mint a key with MCP access

MCP access is **opt-in per key**. A key with no scopes reaches the REST API and nothing else,
which is where every key minted before the MCP server existed still sits.

<Steps>
  <Step title="Open the API keys page">
    Go to **Configurations → Integrations** and select the **REST API** tile, then **Generate key**.
  </Step>

  <Step title="Name it after the client">
    Use the name of the thing that will hold it: "Claude Desktop", "support agent", "Cursor".
    The name is how you will recognise it when you revoke it.
  </Step>

  <Step title="Choose what this key can reach">
    The picker is labelled **What this key can reach** and has exactly three options:

    | Option                                          | Shown in the list as | MCP scope            |
    | ----------------------------------------------- | -------------------- | -------------------- |
    | **REST API only**                               | **REST only**        | none (no MCP access) |
    | **REST API + let an AI assistant read**         | **AI: read**         | `mcp:read`           |
    | **REST API + let an AI assistant read and act** | **AI: read & act**   | `mcp:write`          |

    **REST API only** is the default and the right answer for Zapier, n8n and your own
    integrations. Pick one of the AI options only for a client that will speak MCP.
  </Step>

  <Step title="Copy the key now">
    It is shown once. DMLY stores a SHA-256 hash, so it cannot be retrieved or re-shown later.
    The plaintext starts with `dmly_`.
  </Step>

  <Step title="Copy the endpoint">
    Once the workspace has at least one AI-scoped key, the same page shows a **Connect an AI
    assistant** panel with the address ending in `/mcp`. That is what the client connects to.
  </Step>
</Steps>

### Read or read-and-act

This is the choice that matters on a key, and it is enforced by not existing rather than by
refusing.

* **`mcp:read`** registers up to 16 tools. The 21 write tools are not in `tools/list` at all, so the
  model cannot call one, cannot hallucinate having called one, and cannot be talked into it.
  Nothing it does reaches a customer.
* **`mcp:write`** registers up to all 37, and implies read. It can message customers, take and cancel
  bookings, raise and send invoices, record payments and switch automations on or off, inside
  the [safety model](/mcp/overview#the-safety-model).

The scope is a ceiling rather than the whole story. A key generated in DMLY records the member who
generated it, and over MCP it resolves that member's dashboard permissions as well, so it can never
reach a tool they could not use themselves. Only an owner, an admin, or a custom role holding
**Manage integrations & API keys** can generate one; owners and admins hold every permission, so for
their keys the scope really is the only limit, while a key generated by a custom role is bounded by
that role too.

Three things follow, and they are not equivalent.

* **Narrowing that member's role narrows the key**, on its next call. The role is read live on every
  request, so nothing has to be reissued.
* **Removing them from the workspace stops the key at `/mcp` entirely.** Every request answers `403`
  with a message saying the key's creator is no longer a member, and asking an admin for a new key.
* **Revoking the key is the only lever that stops it everywhere.** The first two leave the key's
  REST API access untouched: `/api/v1` neither resolves a member nor refuses a departed one, so the
  key keeps working there whatever happens to the person.

(A key with no member recorded against it, which the dashboard never generates, is governed by its
scope alone.)

<Tip>
  Start read-only. You can mint a read-and-act key later once you have watched the client work
  with your real data.
</Tip>

<Note>
  A key's access level cannot be edited after it is created. To change it, generate a
  replacement at the level you want, move the client onto it, then **Revoke** the old one.
  Revocation takes effect immediately.
</Note>

### 2. Give the client the address and the key

The server speaks **Streamable HTTP**: JSON-RPC 2.0 over `POST` to `/mcp`. The key goes in a
header. Either works, and they are the same two the [REST
API](/api-reference/authentication) accepts:

```
x-api-key: dmly_xxxxxxxxxxxx
```

```
Authorization: Bearer dmly_xxxxxxxxxxxx
```

If both are present, `x-api-key` wins.

Most desktop and editor clients are configured from a JSON file that maps a server name to a
URL and headers. The shape below is the common one for a remote MCP server:

```json theme={"dark"}
{
  "mcpServers": {
    "dmly": {
      "url": "https://dash.dmly.io/mcp",
      "headers": {
        "x-api-key": "dmly_xxxxxxxxxxxx"
      }
    }
  }
}
```

<Warning>
  Client config schemas differ, and they change. The key names above are the widely used shape,
  not a guarantee for any particular product. Check your client's own documentation for where
  the file lives and what it calls the URL and header fields, then fill it in with the endpoint
  and key from step 1. The file holds a plaintext credential to your workspace, so treat it like
  any other secrets file: never commit it.
</Warning>

Some clients ask for a remote MCP server URL in their own UI rather than a file. Same two
values: the `/mcp` address, and the key as a header.

## Verify it works

The cheapest check is that the endpoint is reachable and enforcing auth. With no credential it
must answer `401`:

```bash theme={"dark"}
curl -i -X POST https://dash.dmly.io/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

```
HTTP/1.1 401 Unauthorized
{"message":"Unauthenticated. Send a DMLY API key with the mcp: scope in the x-api-key header, or connect through OAuth."}
```

Add the credential and the same call returns the tool list. Count what comes back: on a key generated
by an owner or an admin it is **16 tools on `mcp:read`, 37 on `mcp:write`**, which is the fastest way
to confirm the key carries the scope you meant it to. A key generated by a custom role returns fewer,
because that role's permissions apply as well. Over OAuth the count is whatever your role grants: 37
for an admin, 33 for a plain member, 15 for a viewer. A real client will run an `initialize`
handshake before this; `curl` is only here to prove reachability and auth.

Then ask the client a read-only question before you let it do anything: *how many conversations
are open?* The server's own instructions tell it to call `get_workspace_overview` first, so a
correctly connected client answers with your business name and channel list.

<Tip>
  Have the client read the `dmly://guide/capabilities` resource. It is a per-workspace briefing
  (connected channels, what the plan includes, the tools this particular connection has, and
  what it cannot do), and it is generated from the live registration, so it never disagrees with
  `tools/list`.
</Tip>

## Troubleshooting

Everything below is a plain JSON body, not an MCP protocol error, on purpose: a client that
cannot get past these never completes a handshake, and a readable HTTP body is what surfaces in
its connection dialog. Most come from `/mcp` itself; the `400` and the first `429` happen earlier,
at `/oauth/register`, while the client is registering itself.

| Status | Body                                                                                                                                    | What it means                                                                                                                                                                                                                               |
| ------ | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | `{"error":"invalid_redirect_uri"}`                                                                                                      | From `/oauth/register`, before any DMLY screen appears: the client asked to be called back on a private-use scheme other than `claude://`, `cursor://` or `vscode://`. An `https://` callback is always accepted                            |
| `401`  | `Unauthenticated. Send a DMLY API key with the mcp: scope in the x-api-key header, or connect through OAuth.`                           | No credential, an unrecognised key, a key whose workspace is gone, or an OAuth token that has expired or been revoked. Reconnect the client                                                                                                 |
| `401`  | `That access token could not be verified. Reconnect the integration.`                                                                   | An OAuth token that failed verification                                                                                                                                                                                                     |
| `403`  | `This API key has no MCP access. Mint a key with the "mcp:read" or "mcp:write" scope…`                                                  | A REST-only key. Generate one at an AI access level                                                                                                                                                                                         |
| `403`  | `This API key was created by someone who is no longer a member of this workspace. Ask an admin to issue a new key.`                     | The member who generated the key has been removed from the workspace, so there is no role left to resolve it against. It fires on `/mcp` only: the same key still works on the REST API. Generate a replacement and move the client onto it |
| `403`  | `This connection is not linked to a workspace. Disconnect and reconnect the integration, and choose a workspace on the consent screen.` | An OAuth pairing with no recorded consent. It is refused, never defaulted to your first workspace                                                                                                                                           |
| `403`  | `You no longer have access to the workspace this connection was set up for.`                                                            | You were removed from that workspace since connecting                                                                                                                                                                                       |
| `403`  | `The workspace this connection was set up for no longer exists.`                                                                        | The workspace was deleted                                                                                                                                                                                                                   |
| `403`  | `Your plan does not include the MCP server. Upgrade to connect Claude or another AI client to this workspace.`                          | The plan gate. See [Choosing a plan](/billing/choosing-a-plan)                                                                                                                                                                              |
| `405`  | *(empty, with `Allow: POST`)*                                                                                                           | You sent `GET` or `DELETE`. The endpoint is `POST`-only                                                                                                                                                                                     |
| `429`  | `{"error":"too_many_requests","error_description":"Registration is rate limited."}`                                                     | From `/oauth/register`: 10 registrations an hour from one address, inside a 200-an-hour ceiling across the platform. A real client registers once per install, so keep the `client_id` you were issued instead of re-registering            |
| `429`  | `Too many requests. Retry after N seconds.`                                                                                             | From `/mcp`: 120 requests/minute, bucketed per API key or per OAuth connection. Only a request with neither (no credential, or one that resolves to nothing) falls back to a shared per-IP bucket at 60/minute                              |
| `503`  | `The MCP server is currently unavailable.`                                                                                              | MCP is switched off fleet-wide. Nothing you did; it clears on its own                                                                                                                                                                       |

<Accordion title="It connected, but some tools are missing">
  Two possible causes, depending on how it connected.

  On an **API key**: usually the key carries `mcp:read`, and write tools are never registered for a
  read-scoped key. Generate one at **REST API + let an AI assistant read and act** and move the
  client onto it. If it already has that level, the limit is the team role of the member who
  generated the key, whose permissions the key resolves: a key generated by a custom role gets only
  that role's tools, and it loses more if the role is narrowed later. Regenerate it as an admin, or
  widen that role.

  Over **OAuth**: your team role does not grant those tools. A Viewer gets reads only; a Member
  does not get the team-diary appointment tools by default. Ask an Admin to change your role or
  give you a custom role with the permissions you need. See [Roles and
  permissions](/account/roles-and-permissions).
</Accordion>

<Accordion title="It connected, but sees no contacts or conversations">
  It is on a different workspace than you think. The workspace comes from the credential: check
  which workspace the key was generated in, or (under OAuth) disconnect and reconnect, choosing
  the right workspace on the consent screen.
</Accordion>

<Accordion title="Sends are being refused with a message about a limit">
  The workspace hit its AI outbound budget: an hourly send cap, or a rolling 24-hour cap on how
  many distinct people an AI client may reach. The refusal names the actual number. It is
  counted at the send chokepoint, so messages fired by automations off the back of a write count
  too. Finish the send from the DMLY dashboard.
</Accordion>

<Accordion title="A message was refused because the window is closed">
  WhatsApp only allows a freeform message inside 24 hours of the customer's last message.
  Outside it, `send_template_message` with an approved template is the only way through. Read
  the `dmly://reference/templates` resource for the exact names and variable counts. See
  [Message templates](/broadcasts/message-templates).
</Accordion>

<Accordion title="A broadcast was 'submitted' but nobody received it">
  That is correct behaviour. `submit_broadcast` creates the broadcast **awaiting approval** and
  returns `sent: false`; someone has to approve it in **Broadcasts** before it goes out. The AI
  cannot bypass this. See [Create a campaign](/broadcasts/create-campaign).
</Accordion>

<Accordion title="Cutting off a client that has gone wrong">
  On a key: revoke it in **Configurations → Integrations → REST API**. Access stops on the next request,
  on `/mcp` and on the REST API, and it is the only lever with that reach. Two others bite on MCP
  alone, which is what you want when the key also drives REST integrations you need left running:
  **narrow the role of the member who generated it**, and the tools you are worried about stop being
  registered, or **remove them from the workspace**, and `/mcp` refuses the key outright. Both are
  re-checked on every request, and neither changes what the key can do on `/api/v1`.

  Over OAuth, the levers that bite immediately are on the DMLY side: **remove the person from the
  workspace**, or **narrow their role** so the tools you are worried about stop being registered.
  Both are re-checked on every request. Deleting the connector in the client is worth doing too,
  but it is not a revocation: DMLY has no page listing OAuth connections and no revocation
  endpoint, so an access token already issued stays valid until it expires, up to 15 days later.
</Accordion>

<Columns cols={2}>
  <Card title="MCP tools reference" icon="table-list" href="/mcp/tools-reference">
    All 37 tools and 4 resources, with arguments, key scope and team permission.
  </Card>

  <Card title="API authentication" icon="key" href="/api-reference/authentication">
    The same key, on the REST surface.
  </Card>
</Columns>
