Request more scopes with step-up authorization
Before you start
When a client connects to your MCP server, the user approves a set of scopes. With step-up authorization, that first grant stays small, and the server asks for more scopes only when the agent calls a tool that needs them:
- The agent calls a tool, such as
whoami, with an access token that lacks the tool's scope. - Before the tool runs, your server answers
403 Forbiddenwith a challenge: aWWW-Authenticate: Bearer error="insufficient_scope", scope="..."header that names the scopes to request. - The MCP client sends the user through Clerk's OAuth consent screen again, this time for the scopes in the challenge.
- The client retries the tool call with the new access token.
The MCP TypeScript SDK (@modelcontextprotocol/server) sends this challenge for you when you set the scopeChallenge option of registerTool(). The requireAdditionalScopes() helper from @clerk/mcp-tools builds that option from the scopes a tool needs.
This guide assumes that you've completed Build an MCP server. It changes that server so that clients connect with only the profile scope, the baseline every request needs. Its get_clerk_user_data tool stops returning the email address, and a new whoami tool returns the user's ID and primary email. email becomes the step-up scope: the server asks clients for it the first time they call whoami.
Clients handle the challenge differently, so check your client's documentation. For example, Claude Code reports the missing scopes and asks the user to run /mcp, which opens the consent screen for those scopes. Clients without step-up support report a failed request and ask for the same baseline scopes when they reconnect, so their users can't use the tool unless the client requests the step-up scope when it connects.
Install the required versions
The scopeChallenge option requires @modelcontextprotocol/server 2.1 or later, and requireAdditionalScopes() requires @clerk/mcp-tools 0.9.0 or later. SDK 1.x (@modelcontextprotocol/sdk) and 2.0 don't have the option: TypeScript rejects it, and plain JavaScript ignores it.
Install or update the packages.
npm install @clerk/mcp-tools@latest @modelcontextprotocol/server@latest mcp-handler@latestpnpm add @clerk/mcp-tools@latest @modelcontextprotocol/server@latest mcp-handler@latestyarn add @clerk/mcp-tools@latest @modelcontextprotocol/server@latest mcp-handler@latestbun add @clerk/mcp-tools@latest @modelcontextprotocol/server@latest mcp-handler@latestmcp-handler 2.x runs on @modelcontextprotocol/server. mcp-handler 1.x runs on SDK 1.x, so it can't send the challenge.
Allow clients to request the step-up scope
A client can receive only the scopes your Clerk instance lets it request. If it asks for any other scope, Clerk rejects the authorization request with invalid_scope, and step-up fails. email is a built-in scope. For a scope of your own, such as projects:write, create a custom scope. Assign it to the pre-registered OAuth applications that can request it, and add it to the allowed scopes below for dynamic clients.
MCP clients usually connect as dynamic clients, either through or Dynamic Client Registration (DCR). By default, each dynamic client can request only the scopes it registered or first connected with. Clients that send no scope get Clerk's default scopes, profile and email, which would grant email up front. To let dynamic clients step up instead:
- In the Clerk Dashboard, navigate to the OAuth applications page and select the Settings tab.
- Under Client onboarding, set Default scopes for dynamic clients to
profileonly. - In Allowed scopes for dynamic clients, add
profile,email, and any custom scopes your tools need, and save your changes. The allowed scopes must include every default scope.
These settings don't change the scopes you set on a pre-registered CIMD client. In the Clerk Dashboard, on the OAuth applications page, select the Applications tab, select the client, and edit its scopes to include the step-up scope.
Reduce the required scopes and add the tool's data
In lib/mcp-auth.ts, reduce REQUIRED_SCOPES to the baseline. Remove the email address from getUserProfile(), because tokens with only profile can call it, and add getWhoami().
// ...
export const REQUIRED_SCOPES = ['profile', 'email']
export const REQUIRED_SCOPES = ['profile']
// ...
export async function getUserProfile(userId: string) {
const user = await clerkClient.users.getUser(userId)
return {
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
imageUrl: user.imageUrl,
email:
user.emailAddresses.find((address) => address.id === user.primaryEmailAddressId)
?.emailAddress ?? null,
}
}
export async function getWhoami(userId: string) {
const user = await clerkClient.users.getUser(userId)
return {
id: user.id,
email:
user.emailAddresses.find((address) => address.id === user.primaryEmailAddressId)
?.emailAddress ?? null,
}
}verifyMcpToken() must still return resourceMetadataUrl, as in the build guide. The SDK puts it in the challenge's resource_metadata parameter, which tells the client where to find your authorization server.
Register the tool with a scope challenge
Pass requireAdditionalScopes() the scopes the tool needs, as separate arguments, and use the result as the tool's scopeChallenge option.
In app/[transport]/route.ts, add the tool. Keep requiredScopes in withMcpAuth() set to the baseline REQUIRED_SCOPES.
import { requireAdditionalScopes } from '@clerk/mcp-tools/next'
import { createMcpHandler, withMcpAuth } from 'mcp-handler'
import {
getUserProfile,
getWhoami,
MCP_RESOURCE_URL,
REQUIRED_SCOPES,
verifyMcpToken,
} from '../../lib/mcp-auth'
const handler = createMcpHandler((server) => {
server.registerTool(
'get_clerk_user_data',
{ description: 'Gets the authorized user’s profile and primary email' },
{ description: 'Gets the authorized user’s profile' },
async (ctx) => {
// ...
},
)
server.registerTool(
'whoami',
{
description: 'Gets the authorized user’s ID and primary email. Needs the email scope.',
scopeChallenge: requireAdditionalScopes('email'),
},
async (ctx) => {
const userId = ctx.http?.authInfo?.extra?.userId
if (typeof userId !== 'string') throw new Error('Missing authenticated user')
return {
content: [{ type: 'text', text: JSON.stringify(await getWhoami(userId)) }],
}
},
)
})
const authHandler = withMcpAuth(handler, (request) => verifyMcpToken(request), {
required: true,
requiredScopes: REQUIRED_SCOPES,
// mcp-handler appends resourceMetadataPath to this origin for the WWW-Authenticate challenge
resourceUrl: new URL(MCP_RESOURCE_URL).origin,
resourceMetadataPath: '/.well-known/oauth-protected-resource/mcp',
})
export { authHandler as GET, authHandler as POST }Test step-up
Restart your server, clear your MCP client's stored token for this server, and then reconnect so it gets a new token. A token left over from the build guide already has email, so whoami would run without step-up. If your client requests the baseline scopes, the consent screen lists your basic profile information, but not your email address. Ask the agent "Who am I?" so it calls whoami. If your client supports step-up, the consent screen opens again for email, and the call succeeds. In Claude Code, run /mcp, re-authenticate the server, and then ask again.
If the consent screen doesn't open again, check the 403 response's WWW-Authenticate header. Confirm that its scope lists the token's granted scopes followed by email, such as profile offline_access email, and that its resource_metadata is your protected resource metadata URL. If Clerk then answers the authorization request with invalid_scope, allow clients to request the step-up scope.
Best practices
Follow these rules for every tool that uses scopeChallenge.
Challenge for the full scope set
The scope in the challenge is the complete set the client should request, including the scopes the token already has. Some clients request exactly the challenged scopes, and the new token replaces the old one. A challenge for only email produces a token without profile, and every other tool then fails or challenges again.
requireAdditionalScopes() requires the tool's scopes in addition to the ones the token already has, so it challenges for the granted scopes plus the missing ones. The SDK's own requireScopes() helper challenges for only the scopes you list, so if you use it, list every scope the token should keep.
Keep step-up scopes out of the baseline
Add the step-up scope only to the tool's scopeChallenge. Anywhere else, step-up doesn't work as intended:
- Route checks, such as the
requiredScopesofwithMcpAuth()or the check in the Express route, run on every request, includinginitializeandtools/list. A step-up scope there rejects every token without it. scopes_supportedin your protected resource metadata guides what MCP clients request when they first connect, unless the401challenge names scopes of its own. Keep it to the baseline, as in the build guide. It doesn't limit what a token can get, so a client can still request step-up scopes up front.
Limitations
scopeChallenge applies only to certain requests, so check these limits before you rely on it.
When the SDK sends the challenge
The SDK sends the challenge only when:
- The server runs
@modelcontextprotocol/server2.1 or later. Tools behind thestreamableHttpHandler()adapters in@clerk/mcp-tools/expressand@clerk/mcp-tools/honorun SDK 1.x and must check their scopes themselves. - The request comes over HTTP, through
createMcpHandler()or the Streamable HTTP transport. Over stdio (local servers), the tool runs without the challenge. - The request carries auth info.
requireAdditionalScopes()andrequireScopes()leave unauthenticated requests to your authentication check.
How the challenge behaves
- The SDK checks
tools/call,resources/read, andprompts/getrequests for enabled tools, resources, and prompts.registerResource()andregisterPrompt()accept the samescopeChallengeoption. - The challenge runs before the SDK validates the tool's arguments.
tools/liststill includes the tool for tokens that lack its scope. Mention the step-up scope in the tool's description so agents know to expect another consent screen.- If a
scopeChallengefunction throws, the SDK answers500 Internal Server Error.requireAdditionalScopes()validates its scopes when you call it. It throws aClerk error:at startup if you pass no scopes, an empty string, or a scope with a space, quote, backslash, control character, or non-ASCII character.
Feedback
Last updated on