Skip to content
Talk to an EngineerDashboard

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.

A valid token only proves Scalekit issued it for this MCP server. Every tool still runs unless you check a scope.

Login provesA scope check proves
The caller signed in with ScalekitThis 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_todo in Inspector.
  • Scopes live in two places. You create todo:read and todo:write on the Scalekit MCP server. You check the same strings in ctx.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.
  • resource is not aud. resourceId is the JWT audience (res_…). resource (MCP_URL) is the public MCP URL. mcp-use puts resource in RFC 9728 protected-resource metadata.

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.

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:

ToolRequired scopeWhat it does
list_todostodo:readLists todos owned by ctx.auth.user.id
add_todotodo:writeCreates a todo for that user

mcp-use does not enforce those scopes for you. The check belongs next to the tool.

The recipe succeeds when all of this is true:

  1. You sign in and grant todo:read only.
  2. whoami shows a usr_… id, subjectType: "user", and todo:read.
  3. list_todos succeeds.
  4. add_todo is visible and returns Missing scope todo:write.

A green whoami is not enough.

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.

  1. Open Scalekit DashboardMCP serversAdd MCP server. Give it a name. That name appears on the consent screen.

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

  3. Under advanced settings, set Server URL to:

    http://localhost:3000/mcp

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

  4. Create or link these scopes on the same MCP server, then save:

    ScopeDescription
    todo:readList todos
    todo:writeCreate todos

    These strings must match the checks in your tools exactly.

  5. ValueWhere it appears
    Environment URLDashboard → API credentials → https://<your-env>.scalekit.cloud
    Resource IDThis MCP server page → res_…
    Public MCP URLThe 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.

Terminal window
# Never hard-code secrets. Use environment variables.
npm install mcp-use zod
SCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.cloud
SCALEKIT_RESOURCE_ID=res_xxxxxxxx
MCP_URL=http://localhost:3000/mcp

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

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 server

ctx.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) }],
}
},
)
Terminal window
npm run dev
EndpointURL
MCPhttp://localhost:3000/mcp
Inspectorhttp://localhost:3000/mcp/inspector

Leave Inspector’s client-id fields empty. This flow uses DCR or CIMD.

  1. Open Inspector. Connect to http://localhost:3000/mcp. The first call returns 401. Inspector starts Scalekit login. Complete consent. Grant todo:read only on the first pass.

  2. Call whoami. You should see a usr_… id, subjectType: "user", and scopes that include todo:read. Do not paste live ids into docs or tickets.

  3. Call list_todos. The call succeeds. The list may be empty.

  4. Call add_todo with { "title": "Ship scopes" }. The tool is visible. The handler returns Missing scope todo:write. That is the test.

  5. Sign out. Sign in again and grant todo:write as well. Call add_todo. The todo appears. Call list_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.

SymptomFix
Inspector never starts loginIf 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:writemcp-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 tokenConsent is sticky. Disconnect Inspector and sign in again.
Every call returns Missing scope todo:readDashboard string does not match the code. Use todo:read and todo:write in both places.
Login works, every tool is 401Scalekit 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 401Set MCP_USE_OAUTH_DEBUG=1. Logs print iss, aud, and sub. Logs never print the raw token.

See also MCP auth troubleshooting.

  • 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 Map with a database keyed by ctx.auth.user.id.
  • Refuse subjectType: "machine" on user-only tools.