Authorization, Scope Consent, and Incremental Permissions

Authentication tells you who the client is. Authorization tells you what they can do. In MCP, the distinction matters because a tool like delete_file should not be callable by the same client that can only call read_file. This lesson covers scope-based tool filtering, incremental permission consent (asking for more access only when needed), and per-user resource isolation patterns that prevent privilege escalation in multi-tenant MCP deployments.

Authorization scope diagram showing read-only scope versus admin scope mapping to different MCP tools dark
OAuth scopes map directly to MCP tool availability: the token’s scopes determine which tools a client sees.

Designing MCP Scopes

Scope design follows least-privilege: start narrow and expand on explicit consent. For an MCP server managing a product database:

// Scope hierarchy for a product management MCP server
const SCOPE_TOOLS = {
  'products:read': ['search_products', 'get_product', 'list_categories'],
  'products:write': ['create_product', 'update_product'],
  'products:admin': ['delete_product', 'bulk_import', 'manage_categories'],
  'inventory:read': ['get_inventory', 'check_availability'],
  'inventory:write': ['update_stock', 'create_transfer'],
  'reports:read': ['get_sales_report', 'get_inventory_report'],
};

// Build allowed tools list from token scopes
export function getAllowedTools(tokenScopes, allTools) {
  const scopeArray = tokenScopes.split(' ');
  const allowedNames = new Set(
    scopeArray.flatMap(scope => SCOPE_TOOLS[scope] ?? [])
  );
  return allTools.filter(tool => allowedNames.has(tool.name));
}

Scope-Filtered MCP Server

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { getAllowedTools } from './scopes.js';

export function buildMcpServer(authClaims) {
  const server = new McpServer({ name: 'product-server', version: '1.0.0' });
  const scope = authClaims?.scope ?? '';

  // Define all tools, but only register those allowed by scope
  const allToolDefs = [
    {
      name: 'search_products',
      schema: { query: z.string(), limit: z.number().optional().default(10) },
      handler: async ({ query, limit }) => { /* ... */ },
    },
    {
      name: 'delete_product',
      schema: { id: z.string() },
      handler: async ({ id }) => {
        // Double-check scope at handler level (defense in depth)
        if (!scope.includes('products:admin')) {
          return { content: [{ type: 'text', text: 'Forbidden: requires products:admin scope' }], isError: true };
        }
        // ... perform deletion
      },
    },
    // ... more tools
  ];

  const allowedTools = getAllowedTools(scope, allToolDefs);
  for (const tool of allowedTools) {
    server.tool(tool.name, tool.schema, tool.handler);
  }

  return server;
}
Tool filtering diagram showing OAuth scopes being used to filter MCP tool list before returning to client dark
Scope filtering happens at tool registration: unauthorized clients never see tools they cannot call.

Incremental Consent with MCP Elicitation

Incremental consent means requesting additional permissions only when the user explicitly needs them. Combined with MCP’s elicitation feature, this creates a smooth user experience where access expands progressively:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';

// Tool that detects insufficient scope and requests consent via elicitation
server.tool('delete_product', { id: z.string() }, async ({ id }, context) => {
  const scope = context.clientCapabilities?.auth?.scope ?? '';

  if (!scope.includes('products:admin')) {
    // Use elicitation to ask the user to authorize the additional scope
    const result = await context.elicit(
      'Deleting a product requires the "products:admin" permission. Grant this permission?',
      {
        type: 'object',
        properties: {
          confirm: { type: 'boolean', description: 'Confirm granting admin permission' },
        },
      }
    );

    if (!result.content?.confirm) {
      return { content: [{ type: 'text', text: 'Operation cancelled.' }] };
    }

    // In production, redirect to OAuth consent screen here via a redirect URI
    return { content: [{ type: 'text', text: 'Please re-authorize at: ' + buildConsentUrl('products:admin') }] };
  }

  // Proceed with deletion...
});

Resource-Level Authorization: Per-User Isolation

// Ensure users can only access their own data
server.tool('get_order', { orderId: z.string() }, async ({ orderId }, context) => {
  const userId = context.auth?.sub;  // Subject from JWT
  if (!userId) return { content: [{ type: 'text', text: 'Not authenticated' }], isError: true };

  const order = await db.orders.findById(orderId);
  if (!order) return { content: [{ type: 'text', text: 'Order not found' }] };

  // Resource ownership check - prevents horizontal privilege escalation
  if (order.userId !== userId) {
    return { content: [{ type: 'text', text: 'Forbidden: this order does not belong to you' }], isError: true };
  }

  return { content: [{ type: 'text', text: JSON.stringify(order) }] };
});

Role-Based Access Control (RBAC) with MCP

// Token claims can carry roles for coarse-grained access control
const ROLE_SCOPES = {
  viewer: 'products:read inventory:read reports:read',
  manager: 'products:read products:write inventory:read inventory:write reports:read',
  admin: 'products:read products:write products:admin inventory:read inventory:write reports:read',
};

function getRolesFromToken(claims) {
  // Roles can come from a custom claim in the JWT
  return claims['https://yourapp.com/roles'] ?? [];
}

function getScopeFromRoles(roles) {
  return [...new Set(roles.flatMap(r => (ROLE_SCOPES[r] ?? '').split(' ')))].join(' ');
}

// In your auth middleware
async function requireAuth(req, res, next) {
  const claims = await validateToken(token);
  const roles = getRolesFromToken(claims);
  req.auth = {
    ...claims,
    scope: getScopeFromRoles(roles),
  };
  next();
}

Testing Your Authorization Logic

// node:test - test scope filtering
import { test, describe } from 'node:test';
import assert from 'node:assert';
import { getAllowedTools } from './scopes.js';

const ALL_TOOLS = [
  { name: 'search_products' }, { name: 'delete_product' }, { name: 'get_inventory' },
];

describe('getAllowedTools', () => {
  test('read scope returns only read tools', () => {
    const tools = getAllowedTools('products:read', ALL_TOOLS);
    assert.ok(tools.some(t => t.name === 'search_products'));
    assert.ok(!tools.some(t => t.name === 'delete_product'));
  });

  test('admin scope includes delete', () => {
    const tools = getAllowedTools('products:read products:admin', ALL_TOOLS);
    assert.ok(tools.some(t => t.name === 'delete_product'));
  });

  test('empty scope returns no tools', () => {
    assert.strictEqual(getAllowedTools('', ALL_TOOLS).length, 0);
  });
});

Common Authorization Failures

  • Relying solely on tool list filtering: Always add a scope check inside the handler as well (defense in depth). Tool list filtering prevents the model from calling a tool, but a malicious client could still craft a direct JSON-RPC request.
  • Using wide scopes by default: Start with the narrowest scope and expand on request. Clients should not get admin access just because it is easier to configure.
  • Forgetting resource ownership checks: Scope says “can call this tool type”, resource ownership says “can call it on this specific resource”. Both checks are required.
  • Not auditing scope grants: Log every scope elevation request. If a client is frequently requesting elevated scopes, investigate why.

What to Build Next

  • Define scopes for your MCP server and implement getAllowedTools(). Verify that a token with only products:read cannot see or call write tools.
  • Add resource ownership checks to at least one tool handler. Write a test that verifies a user cannot access another user’s data.

nJoy 😉

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.