> ## Documentation Index
> Fetch the complete documentation index at: https://docs.firstflow.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Scopes & roles

> How the MCP server derives a caller's scopes from their organization role, gates each tool against those scopes, and what a 403 or a missing tool actually means.

Every MCP tool declares the scopes it needs, and the FirstFlow server decides at request time which scopes a caller actually holds. Those scopes come from the caller's **organization role** not from anything the coding agent sends which is why you cannot widen access from the client.

When a [coding agent](/mcp/overview) connects over OAuth, it approves a set of scopes on the dashboard consent screen, but approval never grants more than the role allows. The server is authoritative: it resolves scopes server-side, filters the visible tool set, and re-checks the scope on every call.

## 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.

```mermaid theme={null}
flowchart TD
    A["MCP request<br/>Authorization: Bearer …"] --> B{Credential type}
    B -->|Session JWT| C["Look up OrganizationMember.role<br/>→ ROLE_TO_SCOPES"]
    B -->|OAuth API key| D["apiKeyScopes<br/>→ expandLegacyScopes()"]
    C --> E["Resolved scopes<br/>on McpRequestContext"]
    D --> E
    E --> F["Org tool policy:<br/>disabled tools never register"]
    F --> G["tools/list returns<br/>only enabled tools"]
    G --> H["tools/call → assertScopes(ctx, required)"]
    H -->|has all scopes| I["Tool runs"]
    H -->|missing a scope| J["403 MCP_FORBIDDEN_SCOPE"]

    style A fill:#1e3a5f,stroke:#4a90d9,color:#fff
    style E fill:#2d4a22,stroke:#6ab04c,color:#fff
    style I fill:#2d4a22,stroke:#6ab04c,color:#fff
    style J fill:#5f1e1e,stroke:#d94a4a,color:#fff
```

**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 in `ROLE_TO_SCOPES`.

| Role (Clerk string)                   | Resolves to                                                                                             |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| **Admin** (`org:admin`, `admin`)      | Every scope in the catalog the full tool set.                                                           |
| **Member** (`basic_member`, `member`) | `agents:list`, `agents:read`, `experiences:list`, `experiences:read`, `audience:list`, `audience:read`. |

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.

| Scope                                                               | Grants                                                            |
| ------------------------------------------------------------------- | ----------------------------------------------------------------- |
| `agents:list`                                                       | List agents in the workspace.                                     |
| `agents:read`                                                       | Read an agent's details, config, and public SDK config.           |
| `agents:create`                                                     | Create a new agent.                                               |
| `agents:update`                                                     | Rename or re-describe an agent.                                   |
| `agents:delete`                                                     | Permanently delete an agent.                                      |
| `experiences:list`                                                  | List experiences for an agent.                                    |
| `experiences:read`                                                  | Read an experience, including its flow graph.                     |
| `experiences:create`                                                | Build tours, announcements, surveys, and guides.                  |
| `experiences:update`                                                | Edit steps, flow nodes, configuration, and triggers.              |
| `experiences:publish`                                               | Publish an experience (status → active).                          |
| `experiences:delete`                                                | Permanently delete an experience.                                 |
| `conversations:list` / `conversations:read` / `conversations:write` | Conversation access (reserved; not bound to catalog tools today). |
| `audience:list`                                                     | List segments and audience members.                               |
| `audience:read`                                                     | Read an end-user's membership and segments.                       |
| `traces:ingest`                                                     | Ingest analytics traces.                                          |
| `org:admin`                                                         | Read and write workspace settings.                                |

Each tool in the [tool catalog](/mcp/tools) 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 through `expandLegacyScopes()`, so older keys keep working without re-issuing them.

<AccordionGroup>
  <Accordion title="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`.
  </Accordion>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>

Expansion is additive and idempotent granular scopes that are already present pass through unchanged, and any string that is not a recognized MCP scope is dropped. If expansion leaves a key with zero valid MCP scopes, the resolver rejects the request with code `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** in `tools/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 your `OrganizationMember.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](/self-hosting/overview) and [self-hosting configuration](/self-hosting/configuration) for the full environment matrix.

## Next

Browse each tool's `requiredScopes` in the [tool catalog](/mcp/tools), or read how OAuth keys are issued and what scopes they carry in [API keys](/mcp/api-keys).
