Skip to main content

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:
ScopeGrants
actions:*Every non-destructive action on every connected integration. Also implies docs:read, setup:read, and data:read.
actions:<integration>:*Every non-destructive action on one integration (e.g. actions:hubspot:*).
actions:<integration>:<action>One specific (non-destructive) action (e.g. actions:hubspot:create_deal).
docs:readThe proxied search_docs and read_doc tools that hit the public Halo docs.
setup:readThe read-only setup helpers setup_get_install_snippet and setup_get_jwt_sample. Lets a connected client (Cursor, Claude Desktop) walk the customer through installing the widget.
data:readAll read-only workspace-data tools. Records: data_find_user, data_search_users, data_search_leads, data_find_company, data_search_companies, data_search_tickets, data_get_ticket, data_search_transcripts, data_get_transcript, data_list_negative_feedback. Product-insight tools: data_search_events (SDK behaviour search), data_top_pages_before_chat, data_top_unresolved_questions, data_user_journey, data_low_sentiment_conversations. Ask-AI-parity retrieval + analytics: data_search_knowledge (Voyage embed + Postgres match_knowledge hybrid + Voyage rerank, the same pipeline that powers Ask AI thinking mode), data_query_integration (recency cuts over synced integration data), data_query_metrics (KPI snapshots), data_query_activity_time_series (bucketed DAU / new users / conversations / tickets), data_get_hubspot_pipeline_stages, data_query_hubspot_deals, data_query_hubspot_engagements, data_query_stripe, data_lookup_stripe_customer, data_list_automations, data_get_automation_performance, data_list_automation_recipients, data_query_email_engagement_overview, data_web_search, data_web_fetch. The MCP client can answer “who is this customer”, “what tickets are open”, “what did we tell Acme about onboarding last quarter”, and “how has churn changed in the last 30 days” without touching the dashboard.
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.

Destructive actions are not exposed via MCP

Halo’s action registry flags destructive operations (refunds, deletes, irreversible writes) with destructive: true. Those tools are intentionally not surfaced through MCP, regardless of the token’s scopes. The gate (scopeAllowsDestructive) returns false unconditionally. This is a deliberate trade-off: an external MCP client (running on a teammate’s laptop, a CI box, an AI coding tool) sits at one extra layer of trust away from the operator who’s already in the dashboard. We’d rather close that path entirely than ask every operator to reason about whether this particular destructive call is safe at consent time. If you need destructive operations from an automation, drive them through a Halo agent invocation in the dashboard or a server-side workflow with its own guardrails. 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.

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).
This is defense in depth on purpose. Disabling the integration in Integrations kills MCP access for that integration even if older tokens were scoped to it. Disabling the action on Ask AI kills it even if the integration mode is on. Removing a scope from a token kills it even if both are on. Every refusal path writes an 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:
{
  "params": {
    "name": "linear.create_bug",
    "arguments": { "title": "Bug from Acme", "description": "..." },
    "_meta": {
      "bound_end_user_id": "u_abc123"
    }
  }
}
You can use 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 an action_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
You can filter the existing Action Executions view by actor kind to see only MCP traffic. Every refusal also writes a row so denied attempts are visible alongside successful runs.

Token lifecycle

StepDetail
IssueAt /api/mcp/oauth/token after PKCE-verified code exchange. Returns access_token (1 hour TTL) + refresh_token (long-lived, rotates on use).
RefreshStandard grant_type=refresh_token. Rotation: the old refresh row is revoked atomically as the new pair is issued. Refresh-token replay revokes the entire derived chain.
Downscoperefresh_token requests can include scope= to drop scopes. The new scopes must be a subset of the current ones; you can never upscope on refresh.
RevokePOST /api/mcp/oauth/revoke with the access or refresh token. Idempotent and always returns 200 (per RFC 7009 §2.2 to prevent token-shape probing).
Auto-cleanupInactive clients (never issued a token within 30 days of registration) are auto-pruned by a nightly cron. Active clients are kept indefinitely.
Pick the smallest set that covers your use case:
Use caseScopes
Help a customer install the widget from inside Cursor / Claude Desktopsetup:read docs:read
Read-only product-insight surface (search users, tickets, conversations, negative feedback)data:read docs:read
Look up customer + conversation data + take non-destructive actionsactions:* data:read
Documentation search onlydocs:read
Restricted to a single integrationactions:hubspot:*
Restricted to a single actionactions:hubspot:create_deal