Authorize mcp-use tools with Scalekit scopes
Sign a user in with Scalekit, grant todo:read and todo:write, and prove allow and deny in the mcp-use Inspector.
An mcp-use server is one URL that many hosts share. Scalekit login tells you who called. It does not decide which tools that person may run.
Without a scope check, list_todos and add_todo are both open to every signed-in user. mcp-use still lists the write tool when the write scope is missing. The handler must refuse.
By the end of this recipe, you sign in with todo:read only, list todos, and see add_todo return Missing scope todo:write. That deny path is the test.
This is for an mcp-use v2 resource server over Streamable HTTP. If you use FastMCP instead, see the FastMCP quickstart. That guide uses the same todo:read / todo:write scopes.
Login is not authorization
Section titled “Login is not authorization”A valid token only proves Scalekit issued it for this MCP server. Every tool still runs unless you check a scope.
| Login proves | A scope check proves |
|---|---|
| The caller signed in with Scalekit | This caller may run this tool |
The token aud includes this res_… | The grant string matches the handler |
Four other facts make the deny path easy to miss:
- mcp-use still lists the tool. A missing scope does not hide
add_todoin Inspector. - Scopes live in two places. You create
todo:readandtodo:writeon the Scalekit MCP server. You check the same strings inctx.auth.scopes. A mismatch looks like a random 403. - Consent is sticky. If you add a scope after the first login, the existing token does not gain it. The user must sign in again.
resourceis notaud.resourceIdis the JWT audience (res_…).resource(MCP_URL) is the public MCP URL. mcp-use putsresourcein RFC 9728 protected-resource metadata.
Who this recipe is for
Section titled “Who this recipe is for”Use this recipe when:
- You run mcp-use over Streamable HTTP
- Users must sign in with Scalekit before they call tools
- Some tools are read-only and some tools write
Skip this recipe when you only need a shared machine credential and no user identity. For Scalekit M2M JWT verification on a regular API, see M2M JWT verification with JWKS and OAuth scopes.
Two tools, two scopes
Section titled “Two tools, two scopes”Scalekit issues an OAuth 2.1 access token that lists the scopes the user granted. mcp-use verifies that token with oauthScalekitProvider. Each tool then checks ctx.auth.scopes before it runs.
Use two tools and two scopes, the same pair as the FastMCP todo app:
| Tool | Required scope | What it does |
|---|---|---|
list_todos | todo:read | Lists todos owned by ctx.auth.user.id |
add_todo | todo:write | Creates a todo for that user |
mcp-use does not enforce those scopes for you. The check belongs next to the tool.
Four checks in Inspector
Section titled “Four checks in Inspector”The recipe succeeds when all of this is true:
- You sign in and grant
todo:readonly. whoamishows ausr_…id,subjectType: "user", andtodo:read.list_todossucceeds.add_todois visible and returnsMissing scope todo:write.
A green whoami is not enough.
Register the MCP server and add scopes
Section titled “Register the MCP server and add scopes”Scalekit issues the token. Your process only verifies it. There is no Scalekit client id or secret on the resource server.
Prerequisites: Node.js 22.22.2 or newer, a Scalekit account, and at least one auth method enabled.
-
Add the MCP server
Section titled “Add the MCP server”Open Scalekit Dashboard → MCP servers → Add MCP server. Give it a name. That name appears on the consent screen.
-
Enable DCR and CIMD
Section titled “Enable DCR and CIMD”Turn on dynamic client registration and Client ID Metadata Document (CIMD). Inspector needs at least one of these. Keep both on.
Reconnect the MCP client after you change DCR or CIMD. Inspector and other clients cache authorization-server metadata. This process does not.
-
Set the Server URL with no trailing slash
Section titled “Set the Server URL with no trailing slash”Under advanced settings, set Server URL to:
http://localhost:3000/mcpDo not add a trailing slash. mcp-use requires the resource path to equal the server base path. A deployed origin uses
https://mcp.example.com/mcp. -
Create the two scopes
Section titled “Create the two scopes”Create or link these scopes on the same MCP server, then save:
Scope Description todo:readList todos todo:writeCreate todos These strings must match the checks in your tools exactly.
-
Copy the three values
Section titled “Copy the three values”Value Where it appears Environment URL Dashboard → API credentials → https://<your-env>.scalekit.cloudResource ID This MCP server page → res_…Public MCP URL The same string as Server URL (no trailing slash)
resourceId is the audience check. A token minted for a different MCP server in the same environment must fail.
Authenticate the mcp-use server
Section titled “Authenticate the mcp-use server”# Never hard-code secrets. Use environment variables.npm install mcp-use zodSCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.cloudSCALEKIT_RESOURCE_ID=res_xxxxxxxxMCP_URL=http://localhost:3000/mcpPass those three values into the published factory. Advertise the same scopes Scalekit offers, so Inspector can request them:
import { MCPServer } from 'mcp-use'import { oauthScalekitProvider } from 'mcp-use/oauth/scalekit'import { z } from 'zod'
function requiredEnv(name: string): string { const value = process.env[name]?.trim() if (!value) { throw new TypeError( `${name} is missing. Copy .env.example to .env and fill in values from your Scalekit dashboard.`, ) } return value}
const server = new MCPServer({ name: 'todo-mcp', version: '1.0.0', oauth: oauthScalekitProvider({ environmentUrl: requiredEnv('SCALEKIT_ENVIRONMENT_URL'), resourceId: requiredEnv('SCALEKIT_RESOURCE_ID'), resource: requiredEnv('MCP_URL'), scopesSupported: ['todo:read', 'todo:write'], }), publicLandingPage: true,})resource must match the Scalekit Server URL exactly. It is not a second aud check.
publicLandingPage: true keeps the HTML page public. /mcp stays bearer-gated. The tool checks below still apply if a client requests fewer scopes than you advertise.
Guard each tool with a scope
Section titled “Guard each tool with a scope”Store todos per user. Check the scope first. A missing scope returns an error. It does not throw an HTTP 401. The user is already authenticated.
type Todo = { id: string; title: string; ownerId: string }
const todos = new Map<string, Todo[]>()
function requireScope(ctx: { auth: { scopes: string[] } }, scope: string) { if (!ctx.auth.scopes.includes(scope)) { return { isError: true, content: [{ type: 'text', text: `Missing scope ${scope}` }], } } return undefined}
server.tool( { name: 'list_todos', description: 'List todos for the signed-in user. Requires todo:read.', outputSchema: z.object({ todos: z.array( z.object({ id: z.string(), title: z.string(), }), ), }), }, async (_args, ctx) => { const denied = requireScope(ctx, 'todo:read') if (denied) return denied
const items = todos.get(ctx.auth.user.id) ?? [] return { content: [{ type: 'text', text: JSON.stringify(items) }], structuredContent: { todos: items.map(({ id, title }) => ({ id, title })), }, } },)
server.tool( { name: 'add_todo', description: 'Create a todo for the signed-in user. Requires todo:write.', inputSchema: z.object({ title: z.string().min(1), }), outputSchema: z.object({ id: z.string(), title: z.string(), }), }, async (args, ctx) => { const denied = requireScope(ctx, 'todo:write') if (denied) return denied
const item = { id: crypto.randomUUID(), title: args.title, ownerId: ctx.auth.user.id, } const items = todos.get(ctx.auth.user.id) ?? [] items.push(item) todos.set(ctx.auth.user.id, items) return { content: [{ type: 'text', text: JSON.stringify(item) }], structuredContent: { id: item.id, title: item.title }, } },)
export default serverctx.auth.user.id is the token sub. Use it as the owner key. Do not treat a subjectType: "machine" caller as a person if your product is user-only.
Add a whoami tool so Inspector can show the signed-in user and the granted scopes before you test writes:
server.tool( { name: 'whoami', description: 'Return the signed-in Scalekit user and granted scopes', }, async (_args, ctx) => { const data = { user: ctx.auth.user, scopes: ctx.auth.scopes, } return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], } },)Prove allow and deny in Inspector
Section titled “Prove allow and deny in Inspector”npm run dev| Endpoint | URL |
|---|---|
| MCP | http://localhost:3000/mcp |
| Inspector | http://localhost:3000/mcp/inspector |
Leave Inspector’s client-id fields empty. This flow uses DCR or CIMD.
-
Sign in with read only
Section titled “Sign in with read only”Open Inspector. Connect to
http://localhost:3000/mcp. The first call returns 401. Inspector starts Scalekit login. Complete consent. Granttodo:readonly on the first pass. -
Confirm identity
Section titled “Confirm identity”Call
whoami. You should see ausr_…id,subjectType: "user", andscopesthat includetodo:read. Do not paste live ids into docs or tickets. -
Prove the allow path
Section titled “Prove the allow path”Call
list_todos. The call succeeds. The list may be empty. -
Prove the deny path
Section titled “Prove the deny path”Call
add_todowith{ "title": "Ship scopes" }. The tool is visible. The handler returnsMissing scope todo:write. That is the test. -
Grant write and retry
Section titled “Grant write and retry”Sign out. Sign in again and grant
todo:writeas well. Calladd_todo. The todo appears. Calllist_todos. You see only your items.
A public URL change is two matching updates: Scalekit Server URL and MCP_URL. Restart the process. resourceId stays the audience check.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Fix |
|---|---|
| Inspector never starts login | If DCR and CIMD are both off, turn at least one on and save. If they are already on, reconnect Inspector. Clients cache authorization-server metadata. |
add_todo is visible but returns Missing scope todo:write | mcp-use does not hide tools by scope. This is the deny path. Grant todo:write and sign in again. |
| Scope is on the dashboard but not on the token | Consent is sticky. Disconnect Inspector and sign in again. |
Every call returns Missing scope todo:read | Dashboard string does not match the code. Use todo:read and todo:write in both places. |
| Login works, every tool is 401 | Scalekit Server URL does not match MCP_URL. Check trailing slash, port, and http vs https. |
Cannot find module 'mcp-use/oauth/scalekit' | The factory is not in the installed mcp-use release yet. Wait for the release that includes mcp-use#2272, or run the example. |
| Need claim details on a 401 | Set MCP_USE_OAUTH_DEBUG=1. Logs print iss, aud, and sub. Logs never print the raw token. |
See also MCP auth troubleshooting.
What to do next
Section titled “What to do next”- Clone scalekit-mcpuse-example if the published import is not on npm yet.
- Compare the same scopes on FastMCP in the FastMCP quickstart.
- Register more MCP servers in the MCP Auth quickstart.
- Replace the in-memory
Mapwith a database keyed byctx.auth.user.id. - Refuse
subjectType: "machine"on user-only tools.