# MCP server

CommSync speaks the [Model Context Protocol](https://modelcontextprotocol.io).
Point an MCP-capable app or agent at the endpoint. It can then read threads,
send messages, manage contacts and labels, and more.

There are two ways to authenticate, and both reach the same tools:

- **OAuth 2.1** is the primary way. The app signs you in with no shared
  secret. See [Connect AI apps](/docs/connect-ai-apps).
- **An API key** is the alternative for scripts and servers. Send it as a
  Bearer token. See [API keys](/docs/api-keys).

OAuth is how Claude, ChatGPT, Codex, Cursor, and VS Code connect.

The signed-in user and their org role scope every operation.

  Agents can fetch <a href="/docs/mcp.txt">/docs/mcp.txt</a> for the same catalog
  as plain text with full parameter details — one request, no scraping.

## Connect

The server is a single stateless HTTP endpoint. To connect an AI app, paste
the endpoint URL into the app and sign in. The
[Connect AI apps](/docs/connect-ai-apps) page has a guide for each app.

To call the endpoint from your own code with an API key:

  **Mint an API key**

    In CommSync, open **Settings → API Keys** and create a key. CommSync shows
    it once — store it securely. See <a href="/docs/api-keys">API keys</a>.
  
  **Call the endpoint**

    POST JSON-RPC to the <code>/api/mcp</code> route on your CommSync API origin.

    ```bash
    curl -X POST "$COMMSYNC_API/api/mcp" \
      -H "Authorization: Bearer csk_your_api_key" \
      -H "Content-Type: application/json" \
      -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
    ```
  
  **Call a tool**

    Use <code>tools/call</code> with the tool name and its arguments.

    ```json
    {
      "jsonrpc": "2.0",
      "id": 2,
      "method": "tools/call",
      "params": { "name": "list_threads", "arguments": { "limit": 20 } }
    }
    ```
  

| | |
| --- | --- |
| **Endpoint** | `POST /api/mcp` (on your CommSync API origin) |
| **Transport** | HTTP, stateless — one request per call |
| **Auth** | OAuth 2.1 access token, or `Authorization: Bearer csk_…` |

## Authentication

### OAuth 2.1

CommSync is its own authorization server. A client needs only the endpoint
URL, because it discovers everything else:

1. A request with no token gets `401` and a `WWW-Authenticate` header. The
   header names the protected-resource metadata
   (`/.well-known/oauth-protected-resource/api/mcp`, RFC 9728).
2. That document names the authorization server. Its metadata is at
   `/.well-known/oauth-authorization-server` (RFC 8414).
3. The client registers, sends you to the consent screen, and exchanges the
   code for tokens.

| | |
| --- | --- |
| **Grant** | Authorization code with PKCE (`S256` only) |
| **Client registration** | Dynamic registration at `POST /oauth/register` (RFC 7591), or a Client ID Metadata Document: an `https` URL as the `client_id` |
| **Client authentication** | `none` (public clients), `client_secret_post`, `client_secret_basic` |
| **Scope** | `mcp` (the default when you omit `scope`) |
| **Resource** | The endpoint URL (RFC 8707). CommSync binds each token to it |
| **Access token** | `csat_…`, valid for one hour |
| **Refresh token** | `csrt_…`, valid for 90 days. It rotates on every use. For 30 seconds after a use, the same token gets the same new pair again, for a client that refreshes twice at once. After that, a used token revokes its token family |
| **Revocation** | `POST /oauth/revoke` (RFC 7009) |

The authorization response carries `iss` (RFC 9207). A loopback redirect
(`http://127.0.0.1`, `http://localhost`, `http://[::1]`) can use any port.

A code is single-use. A second exchange that passes the PKCE check revokes
the tokens that the first exchange issued. CommSync refuses a second
exchange without the right verifier, and that exchange changes nothing.

### API key

Send `Authorization: Bearer csk_…`. A key does not expire, and you revoke it
under **Settings**, in **API keys**. A key suits a script or a server-side
agent.

## Authorization model

Every request resolves your credential (an OAuth token or an API key) to
`(userId, orgId, role, accessibleChannels)`.
Each tool sits behind one of four gates. The server rejects a call that
exceeds your access before anything happens.

any member any user in the org &nbsp;
channel access needs access to the relevant channel &nbsp;
owner / admin &nbsp;
owner only

- **any member** — any user with org membership.
- **channel access** — you must have access to the channel involved.
- **owner / admin** — reserved for org owners and admins.
- **owner only** — the single workspace owner (for example, billing).

Owners and admins have channel access implicitly; members get it per
channel — see [Roles & permissions](/docs/roles-and-permissions).

CommSync also tags tools by effect: `read` (no change), `write` (mutates), or
`destructive` (removes data — use with care).

### Channel-scoped keys and apps

An API key or a connected app can be fine-tuned to **specific lines** instead
of your full channel access. For a key, choose *Specific lines* when you
create or edit it under **Settings**, in **API keys**. For an app, choose it
on the consent screen. You can change it later under **Settings**, in
**Connect AI apps**. Pick the email addresses and phone numbers it can touch.

The server then intersects your live channel access with the allowlist on
every request. A scoped key or app can only **read, send, and act on the
chosen lines**. Threads on other channels are invisible to it, and
CommSync refuses sends from other channels. Channel listings only show what
is in scope.

- The default is **All channels**: the key follows your live access.
- This includes lines you connect later; CommSync migrated pre-existing
  keys this way.
- A scoped key never out-privileges you.
- If you lose access, or someone deletes the line, it drops from the
  key's scope too.
- Per-user surfaces (contacts, labels, AI, webhooks, profile) do not belong
  to a channel, so scope does not affect them.

## Tool catalog

### Threads

Org-scoped reads (filtered by channel access); mutations write only your own
per-user view state.

| Tool | Access | Kind | Description |
| --- | --- | --- | --- |
| `list_threads` | channel access | read | List threads visible to you |
| `get_thread_summary` | channel access | read | Single-thread summary in list-row shape |
| `get_thread_messages` | channel access | read | Paginated messages for a thread |
| `mark_thread_read` | channel access | write | Clear your unread count |
| `mark_thread_unread` | channel access | write | Force a thread unread for you |
| `archive_thread` | channel access | write | Archive (per-user) |
| `unarchive_thread` | channel access | write | Unarchive (per-user) |
| `mark_thread_spam` | channel access | write | Move to Spam (per-user) |
| `mark_thread_promotions` | channel access | write | Move to Promotions (per-user) |
| `mark_thread_automated` | channel access | write | Move to Automated Messages — non-human mail (per-user) |
| `move_thread_to_inbox` | channel access | write | Clear bucket flags (per-user) |
| `snooze_thread` | channel access | write | Snooze until a timestamp (per-user) |
| `unsnooze_thread` | channel access | write | Clear a snooze (per-user) |
| `delete_thread` | channel access | destructive | Hide the thread from your view; the org keeps its copy |
| `restore_thread` | channel access | write | Restore a soft-deleted thread |
| `delete_message` | channel access | destructive | Hide a message from you |
| `hard_delete_thread` | owner / admin | destructive | Permanently delete the shared thread and messages |

### Outbound sends

CommSync enforces channel access before any send. Compose and forward also
need at least one accessible channel of the right kind.

| Tool | Access | Kind | Description |
| --- | --- | --- | --- |
| `send_sms` | channel access | write | Reply on a current SMS thread |
| `send_email` | channel access | write | Reply on a current email thread |
| `compose_sms` | channel access | write | Start a new SMS conversation |
| `compose_email` | channel access | write | Start a new email conversation |
| `forward_message` | channel access | write | Forward a message to a new recipient |
| `resend_failed_message` | channel access | write | Retry a failed outbound message |
| `get_send_capacity` | channel access | read | Sustainable send rate per line, plus any active rate-limit pause |

#### Email bodies

`send_email` and `compose_email` take two body fields: `bodyText` and
`bodyHtml`. Give one of them or both. A call with neither fails before
CommSync sends anything.

Plain text alone is fine. CommSync builds the HTML part from `bodyText`.
It escapes the text, keeps the line breaks, and turns each `http://` or
`https://` link into a clickable link. When you give `bodyHtml`, CommSync
sends your HTML unchanged. When you give only `bodyHtml`, CommSync makes
the plain-text part from it. The account signature and the "Sent from
CommSync" footer, when they are on, go after your text in both parts.

#### Rate limits never reach you

CommSync accepts every authorised send and owns delivery from that point
on — **this covers carrier and mail-host rate limits too**. Send tools do
not return `429` and never ask you to retry. When a provider throttles us
the message stays queued, backs off, and goes out on its own.

Every send tool returns a receipt that describes where the message
actually is:

```json
{
  "accepted": true,
  "messageId": "cm9x…",
  "threadId": "cm7a…",
  "state": "waiting_on_line",
  "estimatedSendAt": "2026-07-29T18:41:12.000Z",
  "pacing": {
    "provider": "<platform name>",
    "sustainedPerMinute": 54,
    "rateLimited": true,
    "reason": "<platform name> rate limit — waiting 45s"
  }
}
```

`state` is one of `dispatching`, `waiting_on_line`, `scheduled`, `sent`, or
`failed`. **Never call a send tool twice for the same message** — CommSync
has already queued a `waiting_on_line` message and will deliver it.

Before a bulk run, call `get_send_capacity` for the sustainable rate on each
line you can send from. If you exceed it, that is safe — messages queue
rather than fail; they simply take longer to go out.

### Channel management

| Tool | Access | Kind | Description |
| --- | --- | --- | --- |
| `list_email_accounts` | channel access | read | List email-account channels you can see |
| `add_email_account` | any member | write | Link an `IMAP`/`SMTP` mailbox to the org |
| `delete_email_account` | owner / admin | destructive | Remove an email account |
| `test_email_account` | channel access | read | Test stored `IMAP`/`SMTP` credentials |
| `list_phone_numbers` | channel access | read | List phone-number channels you can see |
| `add_phone_number` | any member | write | Link a phone number to the org |
| `delete_phone_number` | owner / admin | destructive | Remove a phone number |

### Billing

| Tool | Access | Kind | Description |
| --- | --- | --- | --- |
| `get_billing_state` | any member | read | Tier, status, seats, caps, usage |
| `change_tier` | owner only | write | Swap subscription tier (prorates) |
| `set_seats` | owner only | write | Set the paid seat quantity (prorates) |
| `seats_preview` | owner only | read | Dry-run a prorated seat change |

### Contacts

The contact graph is private per user — these never leak across users.

| Tool | Access | Kind | Description |
| --- | --- | --- | --- |
| `list_contacts` | any member | read | List your contacts |
| `get_contact` | any member | read | A contact, with identities and labels |
| `create_contact` | any member | write | Create a new contact |
| `update_contact` | any member | write | Update the display name or notes |
| `delete_contact` | any member | destructive | Delete a contact; identities orphan |
| `merge_contacts` | any member | destructive | Merge whole contacts into one survivor; CommSync deletes the sources |
| `merge_identities` | any member | write | Merge two identities under one contact |
| `split_identity` | any member | write | Detach an identity into an orphan |
| `attach_identity_to_contact` | any member | write | Attach an orphan to a contact |
| `promote_identity_to_contact` | any member | write | Promote an orphan into a new contact |

### Identities

| Tool | Access | Kind | Description |
| --- | --- | --- | --- |
| `get_identity` | any member | read | An identity and its contact |
| `update_identity_notes` | any member | write | Edit per-channel notes |
| `list_orphaned_identities` | any member | read | Identities not yet attached to a contact |
| `list_all_identities` | any member | read | Every identity you own |

### Labels

| Tool | Access | Kind | Description |
| --- | --- | --- | --- |
| `list_labels` | any member | read | List labels with usage counts |
| `create_label` | any member | write | Create a label (name and hex color) |
| `update_label` | any member | write | Update the name, color, or AI prompt |
| `delete_label` | any member | destructive | Delete a label everywhere |
| `assign_label` | any member | write | Apply a label to a contact or identity |
| `unassign_label` | any member | write | Remove a label |

### AI

| Tool | Access | Kind | Description |
| --- | --- | --- | --- |
| `get_ai_settings` | any member | read | Read [AI](/docs/ai) configuration |
| `update_ai_settings` | any member | write | Update AI configuration |
| `get_todays_digest` | any member | read | Today's daily digest (or a date) |
| `list_digests` | any member | read | Recent daily digests |
| `dismiss_digest` | any member | write | Mark a digest dismissed |
| `trigger_digest_run` | any member | write | Run a digest now |
| `list_ai_runs` | any member | read | Recent AI activity entries |
| `test_ai_connectivity` | any member | read | Check that CommSync has AI configured |
| `trigger_inbox_backfill` | any member | write | Classify historical senders into Promotions or Spam |

### Search, account & webhooks

| Tool | Access | Kind | Description |
| --- | --- | --- | --- |
| `search` | any member | read | Search contacts, identities, messages |
| `search_threads` | any member | read | Full thread-centric search: every conversation that matches a query, ranked and paginated, with match snippets |
| `get_profile` | any member | read | Your profile (id, email, name) |
| `update_profile` | any member | write | Update your display name |
| `list_webhooks` | any member | read | List your [webhook](/docs/webhooks) endpoints |
| `get_webhook` | any member | read | A single endpoint |
| `register_webhook` | any member | write | Create an endpoint (returns secret once) |
| `update_webhook` | any member | write | Patch url / events / mode / status |
| `rotate_webhook_secret` | any member | write | Rotate the signing secret (old valid 24h) |
| `delete_webhook` | any member | destructive | Delete an endpoint + its history |
| `list_webhook_deliveries` | any member | read | Paginated delivery log |
| `resend_webhook_delivery` | any member | write | Retry a delivery |

  CommSync Agents are the AI teammates that answer inbound texts and
  email. You configure and manage them through the app or the REST admin
  API, not through this MCP tool catalog. See <a href="/docs/agents">Agents</a>
  for details.

  There is deliberately no MCP tool that lets an external agent create,
  reconfigure, or approve turns for a CommSync Agent. Use
  <code>send_sms</code> or <code>send_email</code> above to have your own
  integration reply directly instead.

## Resources

Beyond tools, the server exposes MCP resources for direct reads:

```text
commsync://threads/{threadId}/messages   — messages in a thread
commsync://contacts/{personId}            — a contact's detail
commsync://digests/{localDate}            — the AI digest for a date
```

## Common workflows

  **Triage the inbox**

    `list_threads` → `get_thread_messages(threadId)` to read the latest.
  
  **Reply**

    `get_thread_messages(threadId)` to find the identity a message came in on →
    <code>send_sms</code> or <code>send_email</code> with that `identityId`.
  
  **Merge a duplicate contact**

    `list_orphaned_identities` (or `search`) to find the stray identity →
    `merge_identities(identityAId, identityBId)` or
    `attach_identity_to_contact(personId, identityId)`. For two whole
    contact records of the same human, confirm both with `get_contact` and
    call `merge_contacts(survivorPersonId, sourcePersonIds)` instead.
  
  **React in real time**

    You do not have to call `list_threads` again and again. Register a
    <a href="/docs/webhooks">webhook</a> and call back into MCP only when an
    event fires.
  

  Connect an app in <a href="/docs/connect-ai-apps">Connect AI apps</a>, or
  create a key in <a href="/docs/api-keys">API keys</a>. Then wire up
  <a href="/docs/webhooks">webhooks</a>, so your agent reacts to messages
  and does not have to ask again and again.
  Full parameter reference: <a href="/docs/mcp.txt">/docs/mcp.txt</a>.
