How it works
The technical details for developers and MCP client builders, covering transport, OAuth, conventions, results, refusal codes and rate limits.
This page is for developers building or debugging an MCP client against Signal. If you only want to connect Claude or ChatGPT, Connecting is all you need.
Transport#
| Endpoint | POST https://crm.empiraa.app/mcp |
| Transport | MCP streamable HTTP |
| Sessions | Stateless: no answer carries Mcp-Session-Id |
GET /mcp, DELETE /mcp |
405 Method Not Allowed |
| Authentication | Authorization: Bearer <access token> (header only) |
| Caching | Every /mcp and /oauth/* answer is private or no-store |
A call with no token, or an expired one, answers 401 with a WWW-Authenticate: Bearer challenge that carries resource_metadata (pointing at the protected-resource document below) and scope (all three scopes).
OAuth 2.1#
Signal is both the protected resource and its own authorization server. A client discovers everything from the connector URL.
Discovery#
| Document | Paths |
|---|---|
| Protected resource metadata (RFC 9728) | /.well-known/oauth-protected-resource/mcp and /.well-known/oauth-protected-resource |
| Authorization server metadata (RFC 8414) | /.well-known/oauth-authorization-server and /.well-known/oauth-authorization-server/mcp |
Both are public and cached for five minutes.
Protected resource metadata:
{
"resource": "https://crm.empiraa.app/mcp",
"authorization_servers": ["https://crm.empiraa.app"],
"scopes_supported": ["signal:read", "signal:write", "offline_access"],
"bearer_methods_supported": ["header"],
"resource_name": "Empiraa Signal"
}
Authorization server metadata:
{
"issuer": "https://crm.empiraa.app",
"authorization_endpoint": "https://crm.empiraa.app/oauth/authorize",
"token_endpoint": "https://crm.empiraa.app/oauth/token",
"registration_endpoint": "https://crm.empiraa.app/oauth/register",
"revocation_endpoint": "https://crm.empiraa.app/oauth/revoke",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"token_endpoint_auth_methods_supported": ["none"],
"code_challenge_methods_supported": ["S256"],
"scopes_supported": ["signal:read", "signal:write", "offline_access"],
"client_id_metadata_document_supported": true,
"authorization_response_iss_parameter_supported": true,
"revocation_endpoint_auth_methods_supported": ["none"]
}
Scopes#
| Scope | Grants |
|---|---|
signal:read |
The read tools. Every connection has it. |
signal:write |
The four change tools (list_change_actions, preview_change, apply_change, undo_change). Granted only when the person chooses View and make changes. |
offline_access |
A refresh token, so the connection keeps working after the access token expires. |
Scopes are the ceiling. The person's choices on Signal's approval screen (access and areas), their role in Signal, the workspace's plan and modules, and the workspace admin's switch all narrow what a token can do on each call.
Client registration#
Signal accepts public clients only: no client secret, PKCE required. A client can identify itself in either of two ways.
- Dynamic client registration (RFC 7591) at
/oauth/register, withtoken_endpoint_auth_method: "none", up to 10 redirect URIs, and aclient_nameof up to 100 characters. A registration that's never used to connect is removed after 30 days. The client is told it isn't registered, and registers again. - Client ID metadata document: the
client_idis an https URL that serves the client's metadata. Signal fetches it with a 3-second timeout and caches it for between 5 minutes and 24 hours, following itsCache-Control(1 hour by default).
Redirect URIs must be written in canonical form, with no fragment and no username or password. One of these kinds is accepted:
httpson a public hosthttpon a loopback address (localhost,127.0.0.1or[::1]), with any port or none, for apps on the person's own computer- an app scheme in reverse-DNS form, such as
com.example.app:/callback
Authorization code flow#
- The client sends the person to
/oauth/authorizewithresponse_type=code,code_challengeandcode_challenge_method=S256(plainis refused),redirect_uri,stateand the scopes it wants. - The person signs in to Signal (SSO included), chooses a default workspace, the access and the areas, and approves.
- Signal redirects back with
code,stateandiss. Check thatissishttps://crm.empiraa.app. - The client exchanges the code at
/oauth/tokenwith itscode_verifier. - The client refreshes at
/oauth/tokenwithgrant_type=refresh_token, and revokes at/oauth/revoke.
Lifetimes#
| Item | Lifetime |
|---|---|
| Approval screen | 10 minutes to sign in and approve |
| Authorization code | 60 seconds, single use |
| Access token | 1 hour |
| Refresh token | 30 days of inactivity; rotated on every use, so a connection in use never lapses |
Refresh tokens rotate. A retry with the just-rotated refresh token from the same client within 30 seconds is treated as a lost response, not a replay, and gets a fresh pair. Replaying a code that was already used revokes every token issued from it.
A connection ends when the person disconnects it, an admin disconnects it, the person changes or resets their Signal password, or the person leaves the workspace.
Conventions every tool shares#
- Ids are
<kind>:<id>, for exampledeal:…,account:…,prospect:…,pipeline:…. Pass them on exactly as given. - Workspace: each call acts in one workspace, the
workspace_idinput if given and otherwise the connection's default.list_workspacesnames them and says whether this connection can use each. - Pagination: list tools take
limit(25 by default, at most 100) and answernext_cursor. Pass it back ascursor, with the same filters, for the next page. response_format:concise(the default) ordetailed(longer text and custom fields).list_recordsandlist_dealsalso takecsv.- Untrusted text: anything people wrote, such as names, notes and email subjects, arrives fenced as
{"untrusted": "…"}. It's data. Clients and models should never follow instructions inside it. - Links: a
urlin an answer opens Signal at that record or screen. - Ambiguous names: a name that matches more than one record is refused with
ambiguous_referenceand up to 5candidates. Ask the person which one they mean, then pass its id.
Results#
Every result carries the same JSON twice: as structuredContent and as text in content. A part of the answer that only a widget draws is sent in the result's _meta, which models don't read.
A refusal or failure is a normal tool result with isError: true, not a protocol error, so the model can read it and recover:
{
"code": "permission_denied",
"message": "What happened, in plain words.",
"hint": "What to do next."
}
ambiguous_reference adds candidates (up to 5). change_refused adds reason, the change's own words, fenced as untrusted text.
Refusal codes#
| Code | Meaning |
|---|---|
workspace_required |
The call needs a workspace and none was given or set as the default. |
workspace_not_found |
No workspace with that id that the person belongs to. |
workspace_locked |
The workspace is locked and can't be used. |
connector_not_included |
The workspace doesn't have the AI assistant connector. |
workspace_assistants_off |
A workspace admin has switched assistants off. |
workspace_read_only |
The workspace allows assistants to view only. |
client_not_allowed |
The workspace doesn't allow this app. |
insufficient_scope |
The token lacks the scope this tool needs, for example signal:write. |
toolset_not_granted |
The tool is in an area the person didn't tick when approving. |
permission_denied |
The person's role in Signal doesn't allow this. |
plan_feature |
The workspace's plan doesn't include this. |
module_disabled |
The module this needs is turned off in the workspace. |
subscription_inactive |
The workspace's subscription isn't active. |
plan_limit |
The change would pass a limit of the workspace's plan. |
not_found |
No such record, or one the person can't see. The two read the same. |
ambiguous_reference |
The name matches more than one record. See candidates. |
invalid_input |
An argument is malformed or out of range. |
too_large |
The answer is too long for an assistant to read in one result. Narrow the request. |
rate_limited |
A rate limit was reached. The hint says how many seconds to wait. |
connection_paused |
Signal paused this connection after an unusual number of changes or errors. The person can resume it in Signal. |
stale_preview |
The record changed after the preview, so nothing was changed. Preview again. |
change_refused |
Signal refused the change. reason says why. |
already_invoiced |
(Property module) The call-off already has an invoice number. |
ga4_not_connected, ga4_reconnect_needed, ga4_quota |
Google Analytics isn't connected, needs reconnecting, or the property's quota is spent. |
unavailable |
Signal couldn't complete a check it needs, or the connector was switched off mid-request. Try again later. |
tool_failed |
Something went wrong in Signal. Try again. |
Refusal codes are part of the contract: they're never renamed or reused.
Making changes#
Every change goes through the same four tools, whatever it changes.
list_change_actionslists the changes this connection may preview now, each with its required fields. Ask for up to 5 by name to get their fields in full, as JSON Schema.preview_changetakesactionandfields, and changes nothing. It answers a token (valid for 15 minutes, single use) and a card that says what will change, from what to what. A field the action doesn't take is refused, and the refusal names the fields it does take.apply_changetakes thetoken, plus optionaleditsto fields the card showed. The assistant app asks the person first, unless they've set it to always allow Signal's changes. It answers a receipt with achange_id. Repeating the same apply answers with the first receipt.undo_changetakes thechange_idand undoes the change once, within 7 days: field edits, stage moves, Assigned To, and created records nobody has changed since. Repeating it answers with the first receipt.
Long changes, such as large imports, Prospect Spark searches and sales reports, run in the background. Open the change with fetch and its id to check its status and read its results.
Every change is recorded in Settings › AI activity, labeled with the app that made it. The full list is in Change actions.
Tool annotations#
Each tool in tools/list carries readOnlyHint, destructiveHint, idempotentHint (on the write tools) and openWorldHint. Hosts use them to decide when to ask the person first. preview_change is read-only, because it changes nothing. apply_change and undo_change are marked destructive and idempotent.
Widgets (MCP Apps)#
Hosts that support MCP Apps draw some answers as interactive views, served as ui://signal/<id>.html resources (MCP Apps protocol version 2026-01-26). A widget is the same page for everyone and reaches no network: what it draws arrives in its tool's answer. Its buttons go through the same preview_change and apply_change as any other change. Each widget's payload is versioned (signal.<id>/1). See Prompts, resources and widgets.
Rate limits#
Limits are per minute or per hour, and depend on the workspace's plan.
| Limit | Launch | Drive | Command |
|---|---|---|---|
| Calls per connection, per minute | 60 | 120 | 240 |
| Calls per person (all their connections in one workspace), per minute | 120 | 240 | 480 |
| Changes per connection, per minute | 15 | 30 | 60 |
| Changes per person, per hour | 150 | 300 | 600 |
| Changes per workspace, per hour | 300 | 600 | 1,200 |
| Undos per person, per minute | 15 | 30 | 60 |
There's also a ceiling of 6,000 requests a minute from any one network address. A limited call answers rate_limited, with a hint that says how many seconds to wait.
Testing your client#
Point the MCP Inspector at https://crm.empiraa.app/mcp with the streamable HTTP transport. It runs the OAuth flow, lists the tools your connection is shown, and lets you call them. Use a test workspace, and View only, until you're sure what your client sends.

