Skip to main content

Tutorial: Build a todo manager

Using a different authorization server?

This tutorial uses Logto as the example authorization server. If you're using a different provider, check out our Provider Guides for configuration steps.

Python SDK available

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:

  1. Sign in to Logto Console (or your self-hosted Logto Console)

  2. 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"
  3. 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:todos scope
  4. 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:

  1. Token Validation: Verify the authenticity and integrity of access tokens received from MCP clients
  2. Scope Enforcement: Extract and validate the scopes from the access token to determine what operations the client is authorized to perform
  3. 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:

  1. Extract the access token from the Authorization header (Bearer token format)
  2. Validate the access token's signature and expiration
  3. Extract the scopes and user information from the validated token
  4. 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.

note

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 item
  • get-todos: List all todos
  • delete-todo: Delete a todo by ID

To control access to these tools, we define the following scopes:

  • create:todos: Allows creating new todo items
  • delete:todos: Allows deleting existing todo items
  • read:todos: Allows querying and retrieving the list of all todo items

Roles and permissions

We'll define two roles with different levels of access:

Rolecreate:todosread:todosdelete: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:todos or delete:todos scopes, but they can still:
    • Read their own todo items
    • Delete their own todo items
  • Admins have full permissions (read:todos and delete: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.

Learn More

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.

  1. Sign in to Logto Console (or your self-hosted Logto Console)

  2. 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/).
    • Create the following scopes:
      • create:todos: "Create new todo items"
      • read:todos: "Read all todo items"
      • delete:todos: "Delete any todo item"
  3. 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:todos scope
    • In the "User" role's details page, switch to the "General" tab, and set the "User" role as the "Default role".
  4. 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
Programmatic Role Management

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.

Trailing slash in resource indicator

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"
note

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 resource parameter 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
  • Audience based:

    • Uses the audience parameter to specify the intended token recipient
    • Similar to resource indicators but with different semantics
    • Example request:
      {
        "audience": "todo-api",
        "scope": "create:todos read:todos"
      }
  • 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
Best Practices
  • 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

Why 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:

  1. Sign in to Logto Console (or your self-hosted Logto Console).
  2. Navigate to Applications > Third-party apps and click on "Create application".
  3. Select Native App as the application type.
  4. 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".
  5. Set the following Redirect URIs for VS Code:
    http://127.0.0.1
    https://vscode.dev/redirect
    
  6. Click "Save changes".
  7. Go to the app's Permissions tab, under User section, add the create:todos, read:todos, and delete:todos permissions from the Todo Manager API resource you created earlier.
  8. 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 add mcp-auth

Now 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 issuer and type - 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!

info

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:

  1. In VS Code, press Command + Shift + P (macOS) or Ctrl + Shift + P (Windows/Linux) to open the Command Palette.
  2. Type MCP: Add Server... and select it.
  3. Choose HTTP as the server type.
  4. Enter the MCP server URL: http://localhost:3001
  5. 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.
  6. Since we don't have an App Secret (it's a public client), just press Enter to skip.
  7. 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:todos scope):

    • You can create new todos using the create-todo tool
    • You can only view and delete your own todos
    • You won't be able to see or delete other users' todos
  • 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-todos tool
    • You can delete any todo using the delete-todo tool, regardless of who created it

You can test these different permission levels by:

  1. Disconnecting from the MCP server (remove the server configuration in VS Code)
  2. Signing in with a different user account that has different roles/permissions
  3. 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.

info

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.