Skip to main content

Scope enforcement

Every tool call is checked against the caller’s token once, before the tool body runs, using the same scope groups as the REST API (see Scopes):
  • READ-class tools require a CRM read (or manage) scope.
  • WRITE and COST_BEARING-class tools require a CRM manage scope.
This mirrors the REST routers exactly — a viewer-role credential that only carries read scopes gets rejected on any write tool, with no silent downgrade. Because the check is derived from the tool’s declared class rather than a second, independently-set flag, it can’t drift out of sync with what the tool actually does.

Org isolation is structural, not per-tool code

A tool’s arguments never include an organization id, and a client-supplied organization_id-shaped field in the call arguments is simply ignored if you try to pass one — the caller’s organization comes entirely from the credential (fixed on an ak_ key, or supplied via the X-Anyreach-Org header for a pat_ token and re-verified against Logto on every connection). Every downstream data access is additionally scoped by Postgres row-level security keyed off that same organization claim. There is no tool argument that can widen a call’s reach to another organization’s data.

Name and ID resolution

Most tools that take a record reference (a deal, account, or contact) accept either its UUID or a plain name, and resolve it server-side:
  • An exact, case-insensitive name match resolves immediately.
  • Multiple plausible matches return a structured ambiguous error with up to 8 candidates, rather than guessing which one you meant.
This is deliberate: guessing here would risk writing to the wrong customer’s record. If you get an ambiguous error, re-issue the call with the UUID from the candidate list.

Idempotency

Exactly five tools accept an optional idempotency_key, and they are the ones that create a record or start a paid job: create_account, create_contact, create_deal, log_activity, and cx_start_run. Retrying the same call with the same key returns the original result instead of repeating the effect; if you omit the key, one is derived by hashing the normalized call arguments, so an exact retry is still caught.
This is a best-effort, in-process cache (entries expire after 24 hours), not a distributed guarantee. It exists to stop the realistic failure mode — an agent retrying a call within one conversation — not to guarantee exactly-once semantics across a restart or a genuinely concurrent duplicate request. For a Customer Intelligence run, a retry that evades this cache means spending provider money twice.
The (idempotent) marker in Available Tools means something else entirely: those tools are naturally idempotent (repeating one converges on the same state), which is only a hint passed to your MCP client. None of them take an idempotency_key — an idempotency_key sent to a tool outside the five above is silently discarded as an unknown argument, and the write, along with its stage history, notifications and follow-up workflows, runs again.

Superadmin-gated tools

A handful of Customer Intelligence tools and fields are restricted to AnyCRM’s own platform operators, independent of any scope a customer’s credential can carry:
  • cx_list_runs, cx_get_run, cx_start_run, and get_job redact cost/spend figures for anyone who isn’t a superadmin, rather than refusing the call outright.
  • cx_get_result_metrics refuses non-superadmins entirely.
  • cx_set_manual_demo and cx_restore_cx_report are superadmin-only.
  • cx_edit_cx_report / cx_preview_cx_report gate a few specific fields (competitive analysis, brand data) to superadmins while leaving the rest of the report editable by anyone with CRM manage scope.
These checks are keyed on the caller’s email domain matching AnyCRM’s own operator domain — no organization API key or personal access token issued to a customer can satisfy them.

Limits

Error shape

Every tool failure — a scope check, a validation error, a timeout, an oversized result, an ambiguous reference, anything — comes back as a structured object rather than a transport-level failure, so a model can read error.code and decide what to do next:
not_found is also what you get for a record that exists but belongs to a different organization — row-level security makes the two cases indistinguishable on purpose, the same way the REST API never confirms a cross-org record exists via a 403 (see Errors & Pagination).