The scope grammar
Every Halo MCP token carries a list of scopes the user approved at consent time. Scopes are space-separated strings with these shapes:
A client that requests scopes Halo doesn’t recognise has them silently dropped at consent time, so a malicious client can’t trick the user into approving a meaningless string and then claiming that meant something.
The Sales Suite, CS Suite, and Workflow tool groups carry an extra org-level gate on top of scopes: the org must have the matching feature flag (
sales_suite / cs_suite) enabled. If the suite is off, the tools never appear in tools/list and a direct tools/call is refused with an integration_disabled audit row — even if the token carries the scope. Deals are shared objects: sales_* tools only touch sales-pipeline deals and cs_* tools only touch cs_onboarding deals, mirroring the dashboard’s per-pipeline suite ownership.
Destructive and write actions
Halo’s action registry flags destructive operations (refunds, deletes, irreversible writes) withdestructive: true. Those tools are gated behind a dedicated actions:destructive scope. actions:* does NOT imply actions:destructive, so a token that was granted the broad action surface does not silently gain destructive power; the destructive scope has to be granted on its own.
The consent UI groups permissions with all Read scopes first and all Write scopes below, each row tagged with a Read / Write badge. Every scope is checked by default; the operator unticks anything they don’t want to grant. On the server, every write tool additionally runs a grantor-permission check (see below) so an MCP token can never exceed the dashboard rights of the operator who granted it.
If the policy ever changes, it flips in lib/integrations/mcp-oauth/scopes.ts — there’s a single chokepoint, not a scattered set of checks.
Grantor-permission gate
Write tools (inbox:write, automations:write, data:write, sales:write, cs:write, workflows:write) run an extra server-side check: the OAuth token’s grantor (the operator who approved the consent) must hold the matching dashboard permission. The suite read tools check the corresponding :view permission too:
A refusal here writes an
action_executions row with reason: scope_denied and the missing permission as detail, so you can see “the token had the scope but the operator didn’t have the dashboard right” in the audit log.
Destructive integration actions (actions:destructive) rely on the same gating every integration action has always had: the integration’s Actions mode, the per-agent action toggle, and the action scope itself, plus the dedicated actions:destructive grant at consent.
Campaign-launch safeguards
automations:write can activate real email sends, so automation_activate is double-gated beyond scopes and permissions:
1
Validation
The campaign must pass the same publish validation the dashboard enforces: every email step needs a subject and content, segment audiences need at least one filter, event-triggered series need an event name, enabled goals need goal filters.
automation_readiness runs the same checks as a read-only preflight so the client can fix gaps before launching.2
Audience confirmation handshake
For sends that resolve an audience at activation (broadcasts, manual and segment-match series), the client must first call
automation_preview_audience and then pass the returned users_count as expected_recipient_count to automation_activate. The server re-resolves the audience through the same pipeline that enqueues recipients and refuses on any mismatch, so a campaign can never launch against an unconfirmed or silently-changed audience. Widgets and forward-trigger series (user_created / event) don’t resolve an audience at activation and skip the handshake.automation_set_audience are validated against a known operator allowlist; unknown operators are refused. Prefer saved segments (data_list_segments) or subscription lists (data_list_email_subscriptions) so the audience is one a human already vetted.
The three-layer gate
A non-destructive integration tool call has to clear three independent checks before it runs. Each layer is enforced server-side every call (no caching, no client-supplied claims):1
Workspace integration mode
The integration’s Actions mode must be on (toggled at the top of the integration’s detail page). If you flip it off, every tool from that integration disappears from MCP
tools/list immediately.2
Per-agent action toggle
The action must be enabled on the org’s Ask AI agent (the same agent the dashboard’s Ask AI surface uses). Same setting that gates the action when called from a chat conversation.
3
Token scope
The token’s scopes must include the matching
actions:<integration>:<action> (or a wildcard).action_executions row with the rejection reason (scope_denied, destructive_blocked, integration_disabled, agent_disabled, etc.) so you can answer “why didn’t this MCP client manage to do X” from the audit log instead of from server logs.
Binding a customer to a session
Tokens are org-scoped. To act on behalf of a specific customer (look up their data, file a ticket on their conversation, etc.), the client passes the binding inline on each call via the JSON-RPC_meta envelope:
bound_email instead of bound_end_user_id if your client only knows the email. Halo re-resolves the binding on every call against your org’s customer table; the binding is never trusted across calls and never trusted across orgs.
Audit trail
Every MCP call writes anaction_executions row with:
- The acting actor (
mcp_client) and the user who granted the token (grantedByUserId) - The token id (so you can correlate every call from one token without dereferencing the token itself)
- The bound customer (when present), re-resolved server-side
- The full input args
- The outcome (success, validation failure, scope denial, rate limit, integration error)
- A timestamp + duration