How it works
Access is decided in two independent passes, both server-side. The first happens when the per-request MCP server is built; the second happens when a tool actually runs. Scope resolution. On each request to/mcp, the server reads the Authorization: Bearer token and produces an McpRequestContext carrying a flat list of scope strings. The path depends on the credential. A session JWT (the dashboard or internal tooling) is resolved by looking up the caller’s OrganizationMember.role and mapping it through ROLE_TO_SCOPES; if no membership row exists, the caller falls back to the member scope set. An OAuth-issued key carries its own apiKeyScopes, which are run through expandLegacyScopes() and filtered to valid MCP scopes. Either way, the result is a fixed scope list the client cannot influence.
Tool-policy filtering. Before any tools register, the server fetches the organization’s enabled-tool set and wraps registerTool so that disabled tools become no-ops. A disabled tool is never registered, so it does not appear in tools/list, and calling it returns the MCP SDK’s native “method not found”. This is an organization-level switch independent of scopes an admin can turn a whole tool off for everyone even though their role would otherwise allow it.
Per-call scope enforcement. Even for a registered, enabled tool, the handler calls scopeResolver.assertScopes(ctx, required) as its first step. If the resolved context is missing any required scope, the server throws ForbiddenException with code MCP_FORBIDDEN_SCOPE surfaced as a 403. So a tool can be visible (passed the policy filter) yet still refuse to run for a caller whose role lacks the scope.
Roles and their scopes
Two roles map to scope sets. Admins get the entire catalog; members get read and list access only. The mapping is exact these are the literal entries inROLE_TO_SCOPES.
A caller with no
OrganizationMember row, or an unrecognized role string, is treated as a member. The role string is lowercased before lookup, so Org:Admin and org:admin resolve identically.
The scope catalog
Scopes are fine-grained: each verb on each resource is its own scope, so you grant exactly the action a tool performs rather than a broad read/write bucket.
Each tool in the tool catalog declares its
requiredScopes. For example, create_survey requires experiences:create, publish_experience requires experiences:publish, and delete_agent requires agents:delete. The Discovery group is special: list_integrations_catalog requires no scope at all, so every connected caller can bootstrap context, while get_organization_settings in the same group still requires org:admin.
Legacy scope expansion
API keys created before the granular system carried coarse scopes. The resolver expands those into their modern equivalents throughexpandLegacyScopes(), so older keys keep working without re-issuing them.
Coarse → granular write expansion
Coarse → granular write expansion
agents:write expands to agents:create + agents:update. experiences:write expands to experiences:create + experiences:update + experiences:publish. analytics:read expands to traces:ingest.Read implies list
Read implies list
A bare
agents:read adds agents:list; experiences:read adds experiences:list; audience:read adds audience:list. This preserves the historical behavior where reading a resource implied being able to enumerate it.MCP_KEY_NO_SCOPES.
What a 403 or a missing tool means
These two symptoms come from the two different enforcement passes, and they are not interchangeable. A tool that never appears intools/list was filtered out before registration. Either the organization has disabled that tool in its policy, or for tools that the server gates harder the caller’s role keeps it out of the set. Calling such a tool by name returns “method not found” from the MCP SDK, because the server never registered it.
A 403 with MCP_FORBIDDEN_SCOPE means the tool is registered and visible, but the caller’s resolved scopes are missing one the tool requires. This is the common case for members attempting a write: list_experiences works, but create_survey returns a 403 because the member scope set has no experiences:create.
In both cases the cause is access, not a broken or absent feature. To fix it, raise the caller’s organization role to admin, issue an OAuth key with the needed scopes, or have an admin enable the tool in the organization’s tool policy.
Why approval cannot widen access
The OAuth consent screen lets you approve the scopes a client requests, but consent is bounded by your role. The server resolves scopes from yourOrganizationMember.role (or your key’s apiKeyScopes) every request it never reads a requested-scope list back from the client as ground truth. A member who approves “all scopes” on the consent screen still resolves to the six read/list scopes, so assertScopes rejects every write. This is the same reason eligibility and flow navigation are decided server-side elsewhere in FirstFlow: the client proposes, the server decides.
Self-hosting
The/mcp endpoint is rate-limited (McpThrottlerGuard) and metered per organization (McpQuotaService); in the community edition quotas are unlimited. Set BACKEND_PUBLIC_URL to the backend’s public origin and FRONTEND_URL to the dashboard’s so the OAuth issuer and consent redirect resolve. See Self-hosting overview and self-hosting configuration for the full environment matrix.
Next
Browse each tool’srequiredScopes in the tool catalog, or read how OAuth keys are issued and what scopes they carry in API keys.