Tutorial: Build a todo manager
This tutorial uses Logto as the example authorization server. If you're using a different provider, check out our Provider Guides for configuration steps.
MCP Auth is also available for Python! Check out the Python SDK repository for installation and usage.
In this tutorial, we will build a todo manager MCP server with user authentication and authorization. Following the latest MCP specification, our MCP server will act as an OAuth 2.0 Resource Server that validates access tokens and enforces scope-based permissions.
After completing this tutorial, you will have:
- ✅ A basic understanding of how to set up role-based access control (RBAC) in your MCP server.
- ✅ A MCP server that acts as a Resource Server, consuming access tokens issued by an Authorization Server.
- ✅ A working implementation of scope-based permission enforcement for todo operations.
Overview
The tutorial will involve the following components:
- MCP Client (VS Code): A code editor with built-in MCP support that acts as an OAuth 2.0/OIDC client. It initiates the authorization flow with the authorization server and obtains access tokens to authenticate requests to the MCP server.
- Authorization Server: An OAuth 2.1 or OpenID Connect provider that manages user identities, authenticates users, and issues access tokens with appropriate scopes to authorized clients.
- MCP Server (Resource Server): According to the latest MCP specification, the MCP server acts as a Resource Server in the OAuth 2.0 framework. It validates access tokens issued by the authorization server and enforces scope-based permissions for todo operations.
This architecture follows the standard OAuth 2.0 flow where:
- The VS Code requests protected resources on behalf of the user
- The Authorization Server authenticates the user and issues access tokens
- The MCP Server validates tokens and serves protected resources based on granted permissions
Here's a high-level diagram of the interaction between these components:
Understand your authorization server
Access tokens with scopes
To implement role-based access control (RBAC) in your MCP server, your authorization server needs to support issuing access tokens with scopes. Scopes represent the permissions that a user has been granted.
Logto provides RBAC support through its API resources (conforming RFC 8707: Resource Indicators for OAuth 2.0) and roles features. Here's a quick overview:
-
Sign in to Logto Console (or your self-hosted Logto Console)
-
Create API resource and scopes:
- Go to "API Resources"
- Create a new API resource named "Todo Manager"
- Add the following scopes:
create:todos: "Create new todo items"read:todos: "Read all todo items"delete:todos: "Delete any todo item"
-
Create roles (recommended for easier management):
- Go to "Roles"
- Create an "Admin" role and assign all scopes (
create:todos,read:todos,delete:todos) - Create a "User" role and assign only the
create:todosscope
-
Assign permissions:
- Go to "Users"
- Select a user
- You can either:
- Assign roles in the "Roles" tab (recommended)
- Or directly assign scopes in the "Permissions" tab
The scopes will be included in the JWT access token's scope claim as a space-separated string.
📖 See Logto Provider Guide for detailed setup instructions.
Validating tokens and checking permissions
According to the latest MCP specification, the MCP server acts as a Resource Server in the OAuth 2.0 framework. As a Resource Server, the MCP server has the following responsibilities:
- Token Validation: Verify the authenticity and integrity of access tokens received from MCP clients
- Scope Enforcement: Extract and validate the scopes from the access token to determine what operations the client is authorized to perform
- Resource Protection: Only serve protected resources (execute tools) when the client presents valid tokens with sufficient permissions
When your MCP server receives a request, it performs the following validation process:
- Extract the access token from the
Authorizationheader (Bearer token format) - Validate the access token's signature and expiration
- Extract the scopes and user information from the validated token
- Check if the token has the required scopes for the requested operation
For example, if a user wants to create a new todo item, their access token must include the create:todos scope. Here's how the Resource Server validation flow works:
Dynamic Client Registration
Dynamic Client Registration is not required for this tutorial, but it can be useful if you want to automate the MCP client registration process with your authorization server. Check Is Dynamic Client Registration required? for more details.
Understand RBAC in todo manager
For demonstration purposes, we'll implement a simple role-based access control (RBAC) system in our todo manager MCP server. This will show you the basic principles of RBAC while keeping the implementation straightforward.
While this tutorial demonstrates RBAC-based scope management, it's important to note that not all authentication providers implement scope management through roles. Some providers may have their own unique implementations and mechanisms for managing access control and permissions.
Tools and scopes
Our todo manager MCP server provides three main tools:
create-todo: Create a new todo itemget-todos: List all todosdelete-todo: Delete a todo by ID
To control access to these tools, we define the following scopes:
create:todos: Allows creating new todo itemsdelete:todos: Allows deleting existing todo itemsread:todos: Allows querying and retrieving the list of all todo items
Roles and permissions
We'll define two roles with different levels of access:
| Role | create:todos | read:todos | delete:todos |
|---|---|---|---|
| Admin | ✅ | ✅ | ✅ |
| User | ✅ |
- User: A regular user who can create todo items and view or delete only their own todos
- Admin: An administrator who can create, view, and delete all todo items, regardless of ownership
Resource ownership
While the permission table above shows the explicit scopes assigned to each role, there's an important principle of resource ownership to consider:
- Users don't have the
read:todosordelete:todosscopes, but they can still:- Read their own todo items
- Delete their own todo items
- Admins have full permissions (
read:todosanddelete:todos), allowing them to:- View all todo items in the system
- Delete any todo item, regardless of ownership
This demonstrates a common pattern in RBAC systems where resource ownership grants implicit permissions to users for their own resources, while administrative roles receive explicit permissions for all resources.
To dive deeper into RBAC concepts and best practices, check out Mastering RBAC: A Comprehensive Real-World Example.
Configure authorization in your provider
To implement the access control system we described earlier, you'll need to configure your authorization server to support the required scopes.
-
Sign in to Logto Console (or your self-hosted Logto Console)
-
Create API resource and scopes:
- Go to "API Resources"
- Create a new API resource named "Todo Manager" and using
http://localhost:3001/as the resource indicator.- Important: The resource indicator must match your MCP server's URL. For this tutorial, we use
http://localhost:3001/since our MCP server runs on port 3001. In production, use your actual MCP server URL (e.g.,https://your-mcp-server.example.com/).
- Important: The resource indicator must match your MCP server's URL. For this tutorial, we use
- Create the following scopes:
create:todos: "Create new todo items"read:todos: "Read all todo items"delete:todos: "Delete any todo item"
-
Create roles (recommended for easier management):
- Go to "Roles"
- Create an "Admin" role and assign all scopes (
create:todos,read:todos,delete:todos) - Create a "User" role and assign only the
create:todosscope - In the "User" role's details page, switch to the "General" tab, and set the "User" role as the "Default role".
-
Manage user roles and permissions:
- For new users:
- They will automatically get the "User" role since we set it as the default role
- For existing users:
- Go to "User management"
- Select a user
- Assign roles for the user in the "Roles" tab
- For new users:
You can also use Logto's Management API to programmatically manage user roles. This is particularly useful for automated user management or when building admin panels.
When requesting an access token, Logto will include scopes in the token's scope claim based on the user's role permissions.
Always include a trailing slash (/) in the resource indicator. Due to a current bug in the MCP official SDK, clients using the SDK will automatically append a trailing slash to resource identifiers when initiating auth requests. If your resource indicator doesn't include the trailing slash, resource validation will fail for those clients. (VS Code is not affected by this bug.)
After configuring your authorization server, users will receive access tokens containing their granted scopes. The MCP server will use these scopes to determine:
- Whether a user can create new todos (
create:todos) - Whether a user can view all todos (
read:todos) or only their own - Whether a user can delete any todo (
delete:todos) or only their own
Set up the MCP server
We will use the MCP official SDKs to create our todo manager MCP server.
Create a new project
Set up a new Node.js project:
mkdir mcp-server
cd mcp-server
npm init -y # Or use `pnpm init`
npm pkg set type="module"
npm pkg set main="todo-manager.ts"
npm pkg set scripts.start="node --experimental-strip-types todo-manager.ts"
We're using TypeScript in our examples as Node.js v22.6.0+ supports running TypeScript natively using the --experimental-strip-types flag. If you're using JavaScript, the code will be similar - just ensure you're using Node.js v22.6.0 or later. See Node.js docs for details.
Install the MCP SDK and dependencies
npm install @modelcontextprotocol/sdk express zod
Or any other package manager you prefer, such as pnpm or yarn.
Create the MCP server
Create a file named todo-manager.ts and add the following code:
// todo-manager.ts
import { z } from 'zod';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import express, { type Request, type Response } from 'express';
// Factory function to create an MCP server instance
// In stateless mode, each request needs its own server instance
const createMcpServer = () => {
const mcpServer = new McpServer({
name: 'Todo Manager',
version: '0.0.0',
});
mcpServer.registerTool(
'create-todo',
{
description: 'Create a new todo',
inputSchema: { content: z.string() },
},
async ({ content }) => {
return {
content: [{ type: 'text', text: JSON.stringify({ error: 'Not implemented' }) }],
};
}
);
mcpServer.registerTool(
'get-todos',
{
description: 'List all todos',
inputSchema: {},
},
async () => {
return {
content: [{ type: 'text', text: JSON.stringify({ error: 'Not implemented' }) }],
};
}
);
mcpServer.registerTool(
'delete-todo',
{
description: 'Delete a todo by id',
inputSchema: { id: z.string() },
},
async ({ id }) => {
return {
content: [{ type: 'text', text: JSON.stringify({ error: 'Not implemented' }) }],
};
}
);
return mcpServer;
};
// Below is the boilerplate code from MCP SDK documentation
const PORT = 3001;
const app = express();
app.post('/', async (request: Request, response: Response) => {
// In stateless mode, create a new instance of transport and server for each request
// to ensure complete isolation. A single instance would cause request ID collisions
// when multiple clients connect concurrently.
const mcpServer = createMcpServer();
try {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
await mcpServer.connect(transport);
await transport.handleRequest(request, response, request.body);
response.on('close', () => {
console.log('Request closed');
void transport.close();
void mcpServer.close();
});
} catch (error) {
console.error('Error handling MCP request:', error);
if (!response.headersSent) {
response.status(500).json({
jsonrpc: '2.0',
error: {
code: -32_603,
message: 'Internal server error',
},
id: null,
});
}
}
});
app.listen(PORT);
Run the server with:
npm start
Integrate with your authorization server
To complete this section, there are several considerations to take into account:
The issuer URL of your authorization server
This is usually the base URL of your authorization server, such as https://auth.example.com. Some providers may have a path like https://example.logto.app/oidc, so make sure to check your provider's documentation.
How to retrieve the authorization server metadata
- If your authorization server conforms to the OAuth 2.0 Authorization Server Metadata or OpenID Connect Discovery, you can use the MCP Auth built-in utilities to fetch the metadata automatically.
- If your authorization server does not conform to these standards, you will need to manually specify the metadata URL or endpoints in the MCP server configuration. Check your provider's documentation for the specific endpoints.
How to register the MCP client in your authorization server
- If your authorization server supports Dynamic Client Registration, you can skip this step as the MCP client will automatically register itself.
- If your authorization server does not support Dynamic Client Registration, you will need to manually register the MCP client in your authorization server.
Understand token request parameters
When requesting access tokens from different authorization servers, you'll encounter various approaches for specifying the target resource and permissions. Here are the main patterns:
-
Resource indicator based:
- Uses the
resourceparameter to specify the target API (see RFC 8707: Resource Indicators for OAuth 2.0) - Common in modern OAuth 2.0 implementations
- Example request:
{ "resource": "http://localhost:3001/", "scope": "create:todos read:todos" } - The server issues tokens specifically bound to the requested resource
- Uses the
-
Audience based:
- Uses the
audienceparameter to specify the intended token recipient - Similar to resource indicators but with different semantics
- Example request:
{ "audience": "todo-api", "scope": "create:todos read:todos" }
- Uses the
-
Pure scope based:
- Relies solely on scopes without resource/audience parameters
- Traditional OAuth 2.0 approach
- Example request:
{ "scope": "todo-api:create todo-api:read openid profile" } - Often uses prefixed scopes to namespace permissions
- Common in simpler OAuth 2.0 implementations
- Check your provider's documentation for supported parameters
- Some providers support multiple approaches simultaneously
- Resource indicators provide better security through audience restriction
- Consider using resource indicators when available for better access control
While each provider may have its own specific requirements, the following steps will guide you through the process of integrating VS Code and the MCP server.
Register MCP client as a third-party app
Since VS Code is not controlled by you (the MCP server operator), it should be registered as a third-party application. Users will see a consent screen when authorizing.
If you're building your own MCP client, check the Logto Provider Guide for first-party app registration.
Since Logto does not support Dynamic Client Registration yet, you will need to manually register your MCP client (VS Code) as a third-party app in your Logto tenant:
- Sign in to Logto Console (or your self-hosted Logto Console).
- Navigate to Applications > Third-party apps and click on "Create application".
- Select Native App as the application type.
- Fill in the application details:
- Application name: Enter a name for your application, e.g., "MCP Client".
- Description: Enter a description, e.g., "MCP client for VS Code".
- Set the following Redirect URIs for VS Code:
http://127.0.0.1 https://vscode.dev/redirect - Click "Save changes".
- Go to the app's Permissions tab, under User section, add the
create:todos,read:todos, anddelete:todospermissions from the Todo Manager API resource you created earlier. - In the top card, you will see the "App ID" value. Copy it for later use.
Set up MCP Auth
First, install the MCP Auth SDK in your MCP server project.
- pnpm
- npm
- yarn
pnpm add mcp-authnpm install mcp-authyarn add mcp-authNow we need to initialize MCP Auth in your MCP server. With the protected resource mode, you need to configure your resource metadata including the authorization servers.
There are two ways to configure authorization servers:
- Pre-fetched (Recommended): Use
fetchServerConfig()to fetch the metadata before initializing MCPAuth. This ensures the configuration is validated at startup. - On-demand discovery: Only provide
issuerandtype- metadata will be fetched on-demand when first needed. This is useful for edge runtimes (like Cloudflare Workers) where top-level async fetch is not allowed.
Configure protected resource metadata
First, get your authorization server's issuer URL. In Logto, you can find the issuer URL on your application details page within Logto Console, under the "Endpoints & Credentials / Issuer endpoint" section. It should look like https://my-project.logto.app/oidc.
Now, configure the Protected Resource Metadata when building the MCP Auth instance:
// todo-manager.ts
import { MCPAuth, fetchServerConfig } from 'mcp-auth';
const issuerUrl = '<issuer-url>'; // Replace with your authorization server's issuer URL
// Define the resource identifier for this MCP server
const resourceId = 'http://localhost:3001/';
// Pre-fetch authorization server configuration (recommended)
const authServerConfig = await fetchServerConfig(issuerUrl, { type: 'oidc' });
// Configure MCP Auth with protected resource metadata
const mcpAuth = new MCPAuth({
protectedResources: {
metadata: {
resource: resourceId,
authorizationServers: [authServerConfig],
// Scopes this MCP server understands
scopesSupported: ['create:todos', 'read:todos', 'delete:todos'],
},
},
});
Update MCP server
We are almost done! It's time to update the MCP server to apply the MCP Auth route and middleware function, then implement the permission-based access control for the todo manager tools based on the user's scopes.
Now, apply protected resource metadata routes so that MCP clients can retrieve expected resource metadata from the MCP server.
// todo-manager.ts
// Set up Protected Resource Metadata routes
// This exposes metadata about this resource server for OAuth clients
app.use(mcpAuth.protectedResourceMetadataRouter());
Next, we will apply the MCP Auth middleware to the MCP server. This middleware will handle authentication and authorization for incoming requests, ensuring that only authorized users can access the todo manager tools.
// todo-manager.ts
app.use(mcpAuth.protectedResourceMetadataRouter());
// Apply the MCP Auth middleware
app.use(
mcpAuth.bearerAuth('jwt', {
resource: resourceId,
audience: resourceId,
})
);
At this point, we can update the todo manager tools to leverage the MCP Auth middleware for authentication and authorization.
Let's update the implementation of the tools.
// todo-manager.ts
// other imports...
import assert from 'node:assert';
import { fetchServerConfig, MCPAuth, MCPAuthBearerAuthError } from 'mcp-auth';
import { type AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
// Will mention in the next section
import { TodoService } from './todo-service.js';
const assertUserId = (authInfo?: AuthInfo) => {
const { subject } = authInfo ?? {};
assert(subject, 'Invalid auth info');
return subject;
};
const hasRequiredScopes = (userScopes: string[], requiredScopes: string[]): boolean => {
return requiredScopes.every((scope) => userScopes.includes(scope));
};
// TodoService is a singleton since we need to share state across requests
const todoService = new TodoService();
// Factory function to create an MCP server instance
// In stateless mode, each request needs its own server instance
const createMcpServer = () => {
const mcpServer = new McpServer({
name: 'Todo Manager',
version: '0.0.0',
});
mcpServer.registerTool(
'create-todo',
{
description: 'Create a new todo',
inputSchema: { content: z.string() },
},
({ content }, { authInfo }) => {
const userId = assertUserId(authInfo);
/**
* Only users with 'create:todos' scope can create todos
*/
if (!hasRequiredScopes(authInfo?.scopes ?? [], ['create:todos'])) {
throw new MCPAuthBearerAuthError('missing_required_scopes');
}
const createdTodo = todoService.createTodo({ content, ownerId: userId });
return {
content: [{ type: 'text', text: JSON.stringify(createdTodo) }],
};
}
);
mcpServer.registerTool(
'get-todos',
{
description: 'List all todos',
inputSchema: {},
},
(_params, { authInfo }) => {
const userId = assertUserId(authInfo);
/**
* If user has 'read:todos' scope, they can access all todos (todoOwnerId = undefined)
* If user doesn't have 'read:todos' scope, they can only access their own todos (todoOwnerId = userId)
*/
const todoOwnerId = hasRequiredScopes(authInfo?.scopes ?? [], ['read:todos'])
? undefined
: userId;
const todos = todoService.getAllTodos(todoOwnerId);
return {
content: [{ type: 'text', text: JSON.stringify(todos) }],
};
}
);
mcpServer.registerTool(
'delete-todo',
{
description: 'Delete a todo by id',
inputSchema: { id: z.string() },
},
({ id }, { authInfo }) => {
const userId = assertUserId(authInfo);
const todo = todoService.getTodoById(id);
if (!todo) {
return {
content: [{ type: 'text', text: JSON.stringify({ error: 'Failed to delete todo' }) }],
};
}
/**
* Users can only delete their own todos
* Users with 'delete:todos' scope can delete any todo
*/
if (todo.ownerId !== userId && !hasRequiredScopes(authInfo?.scopes ?? [], ['delete:todos'])) {
return {
content: [
{
type: 'text',
text: JSON.stringify({ error: 'Failed to delete todo' }),
},
],
};
}
const deletedTodo = todoService.deleteTodo(id);
return {
content: [
{
type: 'text',
text: JSON.stringify({
message: `Todo ${id} deleted`,
details: deletedTodo,
}),
},
],
};
}
);
return mcpServer;
};
Now, create the "Todo service" used in the above code to implement the related functionality:
Create the todo-service.ts file for the Todo service:
// todo-service.ts
type Todo = {
id: string;
content: string;
ownerId: string;
createdAt: string;
};
/**
* A simple Todo service for demonstration purposes.
* Use an in-memory array to store todos
*/
export class TodoService {
private readonly todos: Todo[] = [];
getAllTodos(ownerId?: string): Todo[] {
if (ownerId) {
return this.todos.filter((todo) => todo.ownerId === ownerId);
}
return this.todos;
}
getTodoById(id: string): Todo | undefined {
return this.todos.find((todo) => todo.id === id);
}
createTodo({ content, ownerId }: { content: string; ownerId: string }): Todo {
const todo: Todo = {
id: this.genId(),
content,
ownerId,
createdAt: new Date().toISOString(),
};
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
this.todos.push(todo);
return todo;
}
deleteTodo(id: string): Todo | undefined {
const index = this.todos.findIndex((todo) => todo.id === id);
if (index === -1) {
return undefined;
}
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
const [deleted] = this.todos.splice(index, 1);
return deleted;
}
private genId(): string {
return Math.random().toString(36).slice(2, 10);
}
}
Congratulations! We've successfully implemented a complete MCP server with authentication and authorization!
Check out the MCP Auth Node.js SDK repository for the complete code of the MCP server (OIDC version).
Checkpoint: Run the todo-manager tools
Restart your MCP server and connect VS Code to it. Here's how to connect with authentication:
- In VS Code, press
Command + Shift + P(macOS) orCtrl + Shift + P(Windows/Linux) to open the Command Palette. - Type
MCP: Add Server...and select it. - Choose
HTTPas the server type. - Enter the MCP server URL:
http://localhost:3001 - After an OAuth request is initiated, VS Code will prompt you to enter the App ID. Enter the App ID you copied from your authorization server.
- Since we don't have an App Secret (it's a public client), just press Enter to skip.
- Complete the sign-in flow in your browser.
Once you sign in and return to VS Code, repeat the actions we did in the previous checkpoint to run todo manager tools. This time, you can use these tools with your authenticated user identity. The behavior of the tools will depend on the roles and permissions assigned to your user:
-
If you're logged in as a User (with only
create:todosscope):- You can create new todos using the
create-todotool - You can only view and delete your own todos
- You won't be able to see or delete other users' todos
- You can create new todos using the
-
If you're logged in as an Admin (with all scopes:
create:todos,read:todos,delete:todos):- You can create new todos
- You can view all todos in the system using the
get-todostool - You can delete any todo using the
delete-todotool, regardless of who created it
You can test these different permission levels by:
- Disconnecting from the MCP server (remove the server configuration in VS Code)
- Signing in with a different user account that has different roles/permissions
- Trying the same tools again to observe how the behavior changes based on the user's permissions
This demonstrates how role-based access control (RBAC) works in practice, where different users have different levels of access to the system's functionality.
Check out the MCP Auth Node.js SDK repository for the complete code of the MCP server (OIDC version).
Closing notes
Congratulations! You have successfully completed the tutorial. Let's recap what we've done:
- Setting up a basic MCP server with todo management tools (
create-todo,get-todos,delete-todo) - Implementing role-based access control (RBAC) with different permission levels for users and admins
- Integrating the MCP server with an authorization server using MCP Auth
- Configuring VS Code to authenticate users and use access tokens with scopes to call tools
Be sure to check out other tutorials and documentation to make the most of MCP Auth.