Announcing CIMD support for MCP Client registration
Learn more
MCP authentication
Aug 24, 2026

Scoped permissions for an MCP server built on mcp-use

Saif Ali Shaik
Founding Developer Advocate

Your Model Context Protocol (MCP) tools touch real resources: notes, tickets, files. A customer who can only read those notes will still ask their agent to edit one. If the write tool runs, login did not protect the resource. The server has to check a permission and reject the call.

mcp-use is a TypeScript framework for MCP servers and clients. It ships first-class Open Authorization (OAuth) providers, including scalekit. scalekit is the authorization server: it signs the user in and puts their permissions on the token. mcp-use verifies that JSON Web Token (JWT). Your tool decides whether the call may proceed.

Start with the mcp-use TypeScript docs. Add login from the scalekit provider page. This page continues from that login and adds scoped permissions.

The worked example is a notes server. list_notes needs notes:read. add_note needs notes:write. Those strings are scalekit permissions, not OAuth scopes such as openid.

What you will have working

The next time a customer uses your MCP server from an agent, a tool they are not allowed to run is rejected. Notes are the worked example: list_notes succeeds for a read-only user, add_note fails, then succeeds after you grant notes:write. You prove that in mcp-use Inspector. The same check applies to any tool that mutates a resource.

Login names who is calling. Permissions decide if the tool runs.

A shared MCP URL is fine. A shared permission set is not.

The caller can be a person in Inspector or an agent acting for that person. Login names them. The tool still appears in the client. Permissions decide whether that call is allowed to run.

  • mcp-use Inspector calls a tool with a bearer token.
  • mcp-use verifies the token with scalekit. No scalekit client secret lives on the resource server.
  • The verified token names the user and lists their permissions.
  • The handler runs only if the required permission is on that list. Otherwise the server returns a missing-permission error.

Who this is for

You deploy one MCP server, at one address, for example https://notes.example.com/mcp. Each customer adds that same address to their agent or to Inspector. They do not each get a private server.

Login tells the server which person is calling. Permissions tell the server which tools that person may run. Without the permission check, everyone who can sign in can write.

This pattern is the difference between shipping a secure MCP server and shipping one that only looks secure at first glance.

Prerequisites

Need
Check
Node.js 22.22.2 or newer
node --version
A scalekit account with an MCP server resource
Dashboard → MCP servers
DCR and CIMD on for that resource
Inspector can register as a public client
Server URL http://localhost:3000/mcp with no trailing slash
Must match MCP_URL
Permissions notes:read and notes:write
Dashboard → Authorization → Permissions / Roles, not MCP server Scopes
A test user who has read and not write
So the deny path is real
The example repo
scalekit-developers/scalekit-mcpuse-example (ships whoami and greet; you add the notes tools below)

Connect an AI client once, the same way you add other MCP servers.

# scalekit's own MCP server (dashboard help while you work) claude mcp add --transport http scalekit https://mcp.scalekit.com/ # this tutorial's server, after npm run dev claude mcp add --transport http notes http://localhost:3000/mcp # or open Inspector without Claude npx @mcp-use/inspector

npx @mcp-use/inspector opens a local Inspector. npm run dev in the example repo also mounts Inspector at http://localhost:3000/mcp/inspector. Use either. The walk below uses the mounted Inspector.

Register the MCP server in scalekit before you clone the repo. More detail lives on the scalekit provider page.

  • Open scalekit Dashboard → MCP servers → Add MCP server. Give it a name. That name appears on the consent screen.
  • Turn on dynamic client registration (DCR) and Client ID Metadata Document (CIMD). Public clients such as Inspector need at least one of these. Keep both on.
  • Under advanced settings, set Server URL to http://localhost:3000/mcp with no trailing slash.
  • Save. Copy the Environment URL and the Resource ID (res_…). Dev environments look like https://<your-env>.scalekit.dev. Some workspaces show .scalekit.cloud. Use the exact value from the dashboard.

How to set up the server and sign in

Configure the environment

Clone the example, install, and copy .env.example.

git clone git@github.com:scalekit-developers/scalekit-mcpuse-example.git cd scalekit-mcpuse-example npm install cp .env.example .env

Set these three values. There is no SCALEKIT_CLIENT_ID and no SCALEKIT_CLIENT_SECRET. The resource server only verifies tokens scalekit already issued.

# Never hardcode secrets; use environment variables. # Names below match the example repo .env.example. SCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.dev SCALEKIT_RESOURCE_ID=res_xxxxxxxx MCP_URL=http://localhost:3000/mcp

MCP_URL must match the scalekit Server URL exactly. Trailing slash, port, and http vs https all count.

The published scalekit provider reads MCP_USE_OAUTH_SCALEKIT_ENVIRONMENT_URL, MCP_USE_OAUTH_SCALEKIT_RESOURCE_ID, and MCP_URL when you call oauthScalekitProvider() with no arguments. This walk uses the example repo instead, which passes the values in code.

Attach the scalekit provider

oauthScalekitProvider is how mcp-use verifies tokens. The example repo imports a local adapter and passes the three env values. The process does not call scalekit with a client secret. It checks the JWT against scalekit JWKS.

import { MCPServer } from "mcp-use"; import { oauthScalekitProvider } from "./oauth/scalekit.js"; const server = new MCPServer({ name: "scalekit-mcpuse-example", version: "1.0.0", oauth: oauthScalekitProvider({ // Never hardcode secrets; use environment variables. environmentUrl: process.env.SCALEKIT_ENVIRONMENT_URL!, resourceId: process.env.SCALEKIT_RESOURCE_ID!, resource: process.env.MCP_URL!, }), }); export default server;

That block is already in the example index.ts. Do not replace the file. Add the notes tools next to whoami and greet.

Run the server

npm run dev
Endpoint
URL
MCP
http://localhost:3000/mcp
Inspector
http://localhost:3000/mcp/inspector

Open Inspector. Connect to http://localhost:3000/mcp. The first call returns 401. Complete scalekit login. Call whoami.

You should see a user id, subjectType: "user", and an aud value that includes your res_….

{ "user": { "id": "usr_123", "subjectType": "user" }, "scopes": ["openid", "profile"], "permissions": [], "iss": "https://your-env.scalekit.dev", "aud": ["http://localhost:3000/mcp", "res_xxxxxxxx"] }

scopes here are OAuth grants such as openid and profile. MCP server Scopes also land here. This walk does not use scopes as the tool gate. If login fails, use the troubleshooting table in the example README.

How to grant access to specific tools

1. Define the two permissions in scalekit

The MCP server page and the authorization page are different screens.

On Dashboard → MCP servers → your server, you already set Server URL, DCR, and CIMD. That page also has Scopes (openid, profile, or MCP tool scopes such as todo:read). Those strings land on ctx.auth.scopes. Do not use that list as the tool gate in this walk.

Create application permissions here:

  • Open Dashboard → Authorization → Permissions.
  • Create notes:read and notes:write (resource:action names).
  • Open Roles. Create notes_reader with only notes:read. Create notes_writer with notes:read and notes:write. The dashboard rejects a dot in the role name (notes.reader). It stores notes_reader.
  • Assign notes_reader to your test user (organization member).

mcp-use maps the token permissions claim to ctx.auth.permissions. Those strings must match the tool checks exactly.

If whoami later shows notes:read under scopes and permissions is still [], you added MCP Scopes, not Authorization permissions. Move the strings to Authorization and reconnect Inspector.

Understanding the distinction between scopes and permissions matters here. For a deeper look at how access control works across multi-tenant AI agents, the patterns translate directly to MCP tool gating.

2. Add the two tools

Register both tools on the same server you created with oauthScalekitProvider. After mcp-use verifies the token, each tool handler receives a context object. The signed-in user and their permissions are on that context.

Keep notes in memory, keyed by the signed-in user id. That is enough to prove isolation.

// index.ts: add next to the existing whoami tool import { z } from "zod"; type Note = { id: string; text: string; userId: string }; const notes: Note[] = []; function deny(message: string) { return { isError: true, content: [{ type: "text" as const, text: message }], }; } function requireUser(subjectType: string) { if (subjectType === "machine") { return deny("User session required"); } return null; } function requirePermission(permissions: string[], permission: string) { if (!permissions.includes(permission)) { return deny(`Missing permission: ${permission}`); } return null; }

requirePermission is the gate. It reads ctx.auth.permissions from the verified token. It does not read ctx.auth.scopes.

list_notes needs notes:read. It returns only notes for the signed-in user.

export const listNotes = server.tool( { name: "list_notes", title: "List notes", description: "List notes for the signed-in user", annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false, }, }, async (_args, ctx) => { const blocked = requireUser(ctx.auth.user.subjectType) ?? requirePermission(ctx.auth.permissions, "notes:read"); if (blocked) return blocked; const mine = notes.filter((note) => note.userId === ctx.auth.user.id); return { content: [{ type: "text", text: JSON.stringify(mine, null, 2) }], }; }, );

add_note needs notes:write. A read-only user hits deny here.

export const addNote = server.tool( { name: "add_note", title: "Add note", description: "Add a note for the signed-in user", inputSchema: z.object({ text: z.string().min(1), }), annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false, }, }, async ({ text }, ctx) => { const blocked = requireUser(ctx.auth.user.subjectType) ?? requirePermission(ctx.auth.permissions, "notes:write"); if (blocked) return blocked; const note = { id: String(notes.length + 1), text, userId: ctx.auth.user.id, }; notes.push(note); return { content: [{ type: "text", text: JSON.stringify(note, null, 2) }], }; }, );

Do not gate these tools on ctx.auth.scopes. scopes is the OAuth grant (openid, profile, or MCP server Scopes). permissions is what this person may do. The mcp-use user context page draws that same line.

This is the same enforcement pattern used when implementing OAuth for MCP servers more broadly — the token carries structured claims and the handler validates them.

3. Sign in as a read-only user

Confirm the test user has notes_reader only. Reconnect Inspector so scalekit mints a new token. Complete login. Call whoami again.

You should see permissions include notes:read and not include notes:write.

{ "user": { "id": "usr_123", "subjectType": "user" }, "scopes": ["openid", "profile"], "permissions": ["notes:read"] }

If permissions is still empty, the role is not on that user, you created MCP Scopes instead of Authorization permissions, or Inspector is holding the old token. Reconnect. Do not keep calling tools against a stale token.

How to test the flow in mcp-use Inspector

This section is the proof. Stay in http://localhost:3000/mcp/inspector. Do not switch to the official MCP Inspector for this walk.

Call the allowed tool

Run list_notes.

Expected result: an empty array. The user has notes:read. There are no notes yet.

[]

Call the denied tool

Run add_note with text set to first note.

Expected result: a missing-permission error. Treat this as success. A 401 here means authentication broke. A missing-permission payload means authorization worked.

{ "isError": true, "content": [{ "type": "text", "text": "Missing permission: notes:write" }] }

Grant write, then retry

Add notes:write to the same user, or move them to notes_writer. Reconnect Inspector so the next token includes the new permission. Call add_note again with text set to first note. Then call list_notes.

The same user, on the same server, now has one deny and one allow. The new note belongs to this usr_… only.

{ "id": "1", "text": "first note", "userId": "usr_123" } [ { "id": "1", "text": "first note", "userId": "usr_123" } ]

A second browser profile that signs in as another user sees an empty list. That check is optional. The required proof is the deny, then the allow, in Inspector.

What the code is doing

Check
Where
What it proves
JWT signature, iss, aud
oauthScalekitProvider
Authentication: this token is for this MCP server
ctx.auth.user.id
tool body
Data is per person
ctx.auth.permissions.includes("notes:write")
tool body
Authorization: this person may run this tool

mcp-use does not invent permissions. scalekit puts them on the token. Your tool enforces them.

OAuth grants and MCP server Scopes stay on ctx.auth.scopes. Use them to understand the login. Do not use them as the tool gate.

The JWT claims used here follow the structure defined in the JSON Web Token guide for developers — understanding iss, aud, and sub claims directly informs how token verification works in practice.

Common failure modes

Why did Inspector never open login?

DCR and CIMD are both off, or Inspector cached old authorization-server metadata. Turn at least one of DCR or CIMD on, save, and reconnect.

Why is every tool 401 after login?

MCP_URL does not match the scalekit Server URL. Compare trailing slash, port, and scheme.

Why does add_note succeed when the user has no write permission?

The tool is not checking ctx.auth.permissions, or it is checking ctx.auth.scopes instead. Fix the check. Then reconnect so you are not looking at a leftover success from an earlier token.

Why do I still miss notes:write after I added it in the dashboard?

You added it under MCP server Scopes, or the token is old. Create it under Authorization → Permissions, assign the role, then reconnect Inspector.

Why is whoami fine but list_notes empty after a successful write?

You are looking at a different usr_…. A second account, or a second Inspector session, has its own note list.

Tradeoffs

Approach
Use when
Shared API key, no permissions
Local spike. Not a customer-facing server.
Login only, every tool open
Demo. Breaks on the first write that user should not have.
scalekit permissions plus tool checks
An MCP server that other people call.

Notes live in memory. A process restart wipes them. Permissions still hold after the restart. Swap the array for your store when you need durability. Keep the same userId and permission checks.

When you move beyond demos and ship to real customers, audit trails for agent auth become the next concern — every scoped action should produce a verifiable record.

What's next

For a broader look at the tradeoffs in MCP authentication and authorization: build vs. buy, that guide covers when to extend this pattern and when to reach for a managed solution.

No items found.
Agent
Auth Quickstart
On this page
Share this article
Agent
Auth Quickstart

Acquire enterprise customers with
zero upfront cost.

Every feature unlocked. No hidden fees.