Scope design for MCP tools
This is the hardest part of putting OAuth on an MCP server, and the part nobody writes down. Scope IDs end up in user-facing consent screens, customer OAuth client configs, audit logs, and every token issued for the lifetime of your server. Getting them roughly right at the start saves you a migration later.
The three granularity choices
There are basically three places to land on:
| Granularity | Example | Pros | Cons |
|---|---|---|---|
| Catch-all | mcp:full | Trivial to use. One scope grants everything. | No way to restrict clients. A leaked token is total access. |
| Read/write split | mcp:read, mcp:write | Two scopes cover 80% of access decisions. Easy for users to consent to. | Doesn't differentiate "read repos" from "read users." |
| Per-resource read/write | mcp:repos:read, mcp:repos:write, mcp:users:read, ... | Maps naturally to the tool surface. Users grant only what's needed. | More scopes for users to consent to. Schema grows with the API. |
A fourth option exists conceptually — per-tool scopes (mcp:get_user, mcp:create_pr) — but it's almost always wrong. Tools change names and proliferate; scope IDs need to be stable. Per-resource granularity gives you ~80% of per-tool's safety without the maintenance burden.
The matrix
Here's the table to copy. Tool taxonomy on the left, recommended scope strategy on the right.
| Your MCP server's surface | Recommended scopes | Why |
|---|---|---|
| Single-purpose, all tools read-only | <rs>:read | One scope. Done. |
| Single-purpose, read + write | <rs>:read, <rs>:write | Two-way split is the cheapest meaningful boundary. |
| Multiple resources, all read-only | <rs>:<resource>:read per resource | Lets clients ask for narrow grants ("just users, not repos"). |
| Multiple resources, mixed CRUD | <rs>:<resource>:read, <rs>:<resource>:write per resource | The standard pattern. AuthSec's default scope template. |
| One tool is uniquely destructive | Add <rs>:admin for it | A separate scope for "do irreversible things" lets you grant write without granting destructive. |
| Background-job tools (delete, mass-update) | <rs>:admin | Same as above. Don't bundle these into normal write. |
The seeded set on registration — <rs>:tools:read, <rs>:tools:write, <rs>:admin — is the safe starting point. Add per-resource scopes if your tool count is >20 or your tools span genuinely different domains.
The naming convention that wears well
Use <rs-prefix>:<resource>:<verb>:
rs-prefixis namespaced to your specific resource server (e.g.rs-525da3b4). Tokens with this scope are bound to your server; other servers reject them. AuthSec auto-prefixes this.resourceis the kind of thing being accessed (repos,issues,users,actions). Plural nouns. Avoiddataorthings.verbis the action level.readandwritecover most things;adminfor destructive; avoidmanage(it conflates with admin).
Reserved patterns to know:
<rs>:admin— administrative actions. Grant carefully.<rs>:offline_access— refresh-token issuance. Standard OIDC; let your auth server handle it.openid,profile,email— OIDC standard scopes. Don't repurpose them.
The decision tree
Start at the top, follow yes/no:
- Do you have more than five tools? No → catch-all (
<rs>:full). Done. - Do all your tools touch the same kind of thing? Yes → read/write split (
<rs>:read,<rs>:write). - Do some tools touch sensitive data others don't? Yes → per-resource (
<rs>:repos:readetc.). - Are there one or two tools that are uniquely destructive? Yes → add
<rs>:adminfor them. - Are you sure? Pause for a week. Scope renames are expensive; the default seed (
<rs>:tools:read/write/admin) is fine for the first month while you find the patterns.
What public tools look like
Some tools genuinely don't need scopes: health, ping, capability discovery. Mark these Public on the Tools tab. The SDK allows them for any caller (authenticated or not). Be deliberate — public is the only state without an audit trail at the token level, so a leak is invisible.
Cases where public is wrong:
- Anything that returns user-specific data.
- Anything that modifies state.
- Anything that wraps a paid upstream API where the caller's identity matters for cost attribution.
Scope step-up
Most clients don't request all scopes up front; they ask for the minimum. If a user tries a tool requiring <rs>:tools:write with a token that only has <rs>:tools:read, the SDK returns 403 insufficient_scope. The MCP client should catch this, redirect the user back through the OAuth flow with the new scope requested, and retry.
Today this works fine for one-shot HTTP. For SSE (streaming) responses, the in-stream step-up flow is less well-defined — see the wedge content on this for the state of the spec.
Common mistakes
Naming scopes after tool names. "I'll call it mcp:get_pr." Now you rename the tool to mcp:fetch_pr and your scope is misleading. Use resource nouns, not tool verbs.
Putting environment in the scope. <rs>:repos:read:prod is fragile — promotion to prod becomes a scope change. Use the same scope across environments; let the resource server's aud claim distinguish.
Granting write without read. Some MCP servers have asymmetric tools where write doesn't imply read. If your write tool needs to read first to do its job, document this — and consider mapping the tool to require both scopes (the SDK supports this).
Bundling everything into one scope. "We just have one mcp:all scope." This is functionally identical to no auth — a leaked token does everything. Even two scopes is meaningfully better than one.
Related
- Define scopes — the UI mechanics
- Map tools to scopes — how scopes attach to tools
- How to add OAuth to an MCP server — the surrounding context
- Preventing confused-deputy attacks — why
audbinding matters