ClaudeCodeMod

All shelves / MCP servers

MCP Server NestJS Module Library

adrian-d-hidalgo/nestjs-mcp-server · 39 stars · TypeScript · MIT

MCP server

Install

The repo has no one-line install. Follow its README.

Open the repo

Files

README.md

MCP Server NestJS Module Library

NPM Version Semantic Release Downloads CI Pipeline codecov Known Vulnerabilities MIT License PRs Welcome Contributor Covenant


Overview

NestJS MCP Server is a modular library for building Model Context Protocol (MCP) servers using NestJS. It provides decorators, modules, and integration patterns to expose MCP resources, tools, and prompts in a scalable, maintainable way. This project is a wrapper for the official @modelcontextprotocol/sdk and is always kept compatible with its types and specification.


Table of Contents


Installation

npm install @nestjs-mcp/server @modelcontextprotocol/sdk zod
# or
yarn add @nestjs-mcp/server @modelcontextprotocol/sdk zod
# or
pnpm add @nestjs-mcp/server @modelcontextprotocol/sdk zod

Quickstart

Register the MCP module in your NestJS app and expose a simple tool:

import { Module } from '@nestjs/common';

import { CallToolResult } from '@modelcontextprotocol/sdk/types';

import { Resolver, Tool, McpModule } from '@nestjs-mcp/server';

@Resolver()
export class HealthResolver {
  /**
   * Simple health check tool
   */
  @Tool({ name: 'server_health_check' })
  healthCheck(): CallToolResult {
    return {
      content: [
        {
          type: 'text',
          text: 'Server is operational. All systems running normally.',
        },
      ],
    };
  }
}

@Module({
  imports: [
    McpModule.forRoot({
      name: 'My MCP Server',
      version: '1.0.0',
    }),
  ],
  providers: [HealthResolver],
})
export class AppModule {}

What is MCP?

The Model Context Protocol (MCP) is an open protocol for connecting LLMs to external data, tools, and prompts. MCP servers expose resources (data), tools (actions), and prompts (conversational flows) in a standardized way, enabling seamless integration with LLM-powered clients.


Core Concepts

Server

The MCP Server is the main entry point for exposing capabilities to LLMs. It manages the registration and discovery of resources, tools, and prompts.

Resource

A Resource represents structured data or documents that can be queried or retrieved by LLMs. Resources are typically read-only and are identified by a unique URI.

Tool

A Tool is an action or function that can be invoked by LLMs. Tools may have side effects and can accept parameters to perform computations or trigger operations.

Prompt

A Prompt defines a conversational flow, template, or interaction pattern for LLMs. Prompts help guide the model's behavior in specific scenarios.

See the Capabilities section for implementation details and code examples.


Module API

McpModule.forRoot

Registers the MCP Server globally in your NestJS application.

Parameters:

  • options: McpModuleOptions — Main server configuration object:
    • name: string: The name of your MCP server.
    • version: string: The version of your MCP server.
    • instructions?: string: Optional description of the MCP server for the client.
    • capabilities?: Record<string, unknown>: Optional additional capabilities metadata.
    • providers?: Provider[]: Optional array of NestJS providers to include in the module.
    • imports?: any[]: Optional array of NestJS modules to import.
    • logging?: McpLoggingOptions: Optional logging configuration:
      • enabled?: boolean (default: true): Enable/disable logging.
      • level?: 'error' | 'warn' | 'log' | 'debug' | 'verbose' (default: 'verbose'): Set the logging level.
    • transports?: McpModuleTransportOptions: Optional transport configuration (see Transport Options).
    • protocolOptions?: Record<string, unknown>: Optional parameters passed directly to the underlying @modelcontextprotocol/sdk server instance.

Returns:

  • A dynamic NestJS module with all MCP providers registered.

Example:

import { Module } from '@nestjs/common';
import { McpModule } from '@nestjs-mcp/server';

@Module({
  imports: [
    McpModule.forRoot({
      name: 'My Server',
      version: '1.0.0',
      instructions: 'A server providing utility tools and data.',
      logging: { level: 'log' },
      transport: { legacy: 'reject' }, // Modern-era clients only
      // ...other MCP options
    }),
  ],
})
export class AppModule {}

McpModule.forRootAsync

Registers the MCP Server globally using asynchronous options, useful for integrating with configuration modules like @nestjs/config.

Note:

  • The imports array should include any modules that provide dependencies required by your useFactory (e.g., ConfigModule if you inject ConfigService).
  • Use forRootAsync only once in your root module (AppModule).
  • See McpModuleAsyncOptions for all available options.

Parameters:

  • options: McpModuleAsyncOptions — Asynchronous configuration object:
    • imports?: any[]: Optional modules to import before the factory runs.
    • useFactory: (...args: any[]) => Promise<McpModuleOptions> | McpModuleOptions: A factory function that returns the McpModuleOptions.
    • inject?: any[]: Optional providers to inject into the useFactory.

Returns:

  • A dynamic NestJS module.

Example (with ConfigModule):

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { McpModule } from '@nestjs-mcp/server';

@Module({
  imports: [
    ConfigModule.forRoot(), // Make sure ConfigModule is imported
    McpModule.forRootAsync({
      imports: [ConfigModule], // Import ConfigModule here too
      useFactory: (configService: ConfigService) => ({
        name: configService.get<string>('MCP_SERVER_NAME', 'Default Server'),
        version: configService.get<string>('MCP_SERVER_VERSION', '1.0.0'),
        instructions: configService.get<string>('MCP_SERVER_DESC'),
        logging: {
          level: configService.get('MCP_LOG_LEVEL', 'verbose'),
        },
        // ... other options from configService
      }),
      inject: [ConfigService], // Inject ConfigService into the factory
    }),
  ],
})
export class AppModule {}

McpModule.forFeature

Registers additional MCP resources, tools, or prompts within a feature module. Use this to organize large servers into multiple modules. Resolvers containing MCP capabilities must be included in the providers array of the feature module.

Parameters:

  • options?: McpFeatureOptions (Currently unused, reserved for future enhancements).

Returns:

  • A dynamic module.

Example:

// src/status/status.resolver.ts
import { Resolver, Tool } from '@nestjs-mcp/server';
import { CallToolResult } from '@modelcontextprotocol/sdk/types';

@Resolver('status')
export class StatusResolver {
  @Tool({ name: 'health_check' })
  healthCheck(): CallToolResult {
    return { content: [{ type: 'text', text: 'OK' }] };
  }
}

// src/status/status.module.ts
import { Module } from '@nestjs/common';
import { McpModule } from '@nestjs-mcp/server';
import { StatusResolver } from './status.resolver';

@Module({
  imports: [McpModule.forFeature()], // Import forFeature here
  providers: [StatusResolver], // Register your resolver
})
export class StatusModule {}

Module Usage

This library provides two main ways to register MCP capabilities in your NestJS application:

1. Global Registration with McpModule.forRoot

Use McpModule.forRoot in your root application module to configure and register the MCP server globally. This is required for every MCP server application.

import { Module } from '@nestjs/common';
import { McpModule } from '@nestjs-mcp/server';
import { PromptsResolver } from './prompts.resolver';

@Module({
  imports: [
    McpModule.forRoot({
      name: 'My MCP Server',
      version: '1.0.0',
      // ...other MCP options
    }),
  ],
  providers: [PromptsResolver],
})
export class AppModule {}

2. Feature Module Registration with McpModule.forFeature

Use McpModule.forFeature in feature modules to register additional resolvers, tools, or resources. This is useful for organizing large servers into multiple modules.

import { Module } from '@nestjs/common';
import { McpModule } from '@nestjs-mcp/server';

import { ToolsResolver } from './tools.resolver';

@Module({
  imports: [McpModule.forFeature()],
  providers: [ToolsResolver],
})
export class ToolsModule {}
  • Use forRoot or forRootAsync only once in your root module (AppModule).
  • Use forFeature in any feature module where you define MCP capabilities (@Resolver classes).
  • Ensure all Resolvers are listed in the providers array of their respective modules.

Capabilities

This library provides a set of decorators to define MCP capabilities and apply cross-cutting concerns such as guards. Decorators can be used at both the Resolver (class) level and the method level.

Resolver Decorator

A Resolver is a class that groups related MCP capabilities. All MCP capability methods (@Prompt, @Resource, @Tool) must belong to a class decorated with @Resolver.

  • No @Injectable() Needed: Resolver classes are automatically treated as providers by the MCP module and do not require the @Injectable() decorator.
  • Dependency Injection: Standard NestJS dependency injection works within Resolver constructors.
  • Namespacing: You can optionally provide a string argument to @Resolver('my_namespace') to namespace the capabilities within that resolver.
  • Access defaults: @Resolver({ name, scopes, public, hideOutOfScope }) sets defaults for every capability of the class — see Resolver defaults.
  • Guards: Guards can be applied at the class level using @UseGuards().

Example:

import { Resolver, Prompt, Resource, Tool } from '@nestjs-mcp/server';
// Import any services you need to inject
import { SomeService } from '../some.service';

@Resolver('workspace') // No @Injectable()
export class MyResolver {
  // Inject dependencies as usual
  constructor(private readonly someService: SomeService) {}

  @Prompt({ name: 'greet_user' }) // Capabilities must be inside a Resolver
  greetPrompt(/*...args...*/) {
    const greeting = this.someService.getGreeting();
    /* ... */
  }

  @Resource({ name: 'user_profile', uri: 'user://{id}' })
  getUserResource(/*...args...*/) {
    /* ... */
  }

  @Tool({ name: 'calculate_sum' })
  sumTool(/*...args...*/) {
    /* ... */
  }
}

You can also apply guards at the resolver level:

import { UseGuards, Resolver } from '@nestjs-mcp/server';
import { MyGuard } from './guards/my.guard';

@UseGuards(MyGuard) // Applied to all capabilities in this Resolver
@Resolver('secure') // No @Injectable()
export class SecureResolver {
  // All capabilities in this resolver will use MyGuard
}

Prompt Decorator

Decorate methods within a Resolver class to expose them as MCP Prompts. Accepts options compatible with server.prompt() from @modelcontextprotocol/sdk. The name should use snake_case.

import { Prompt, Resolver } from '@nestjs-mcp/server';
import { McpContext } from '@nestjs-mcp/server'; // Handler context type
import { z } from 'zod'; // Example if using Zod schema

// Optional: Define schema if needed
// const SummaryArgs = z.object({ topic: z.string() });

@Resolver('prompts') // Must be in a Resolver class
export class MyPrompts {
  @Prompt({
    name: 'generate_summary',
    description: 'Generates a summary for the given text.',
    // argsSchema: SummaryArgs
  })
  generateSummaryPrompt(
    // params: z.infer<typeof SummaryArgs>, // Arguments based on argsSchema (if defined)
    ctx: McpContext, // The request this call arrived on
  ) {
    console.log(`Generating summary for ${ctx.mcpReq.method}`);
    /* ... return CallPromptResult ... */
    return { content: [{ type: 'text', text: 'Summary generated.' }] };
  }
}

Resource Decorator

Decorate methods within a Resolver class to expose them as MCP Resources. Accepts options compatible with server.resource() from @modelcontextprotocol/sdk. The name should use snake_case.

import { Resource, Resolver } from '@nestjs-mcp/server';
import { McpContext } from '@nestjs-mcp/server'; // Handler context type
import { URL } from 'url'; // Type for URI resource
import { z } from 'zod'; // Example if using Zod template

// Optional: Define template schema if needed
// const DocQueryTemplate = z.object({ query: z.string() });

@Resolver('data') // Must be in a Resolver class
export class MyResources {
  @Resource({
    name: 'user_profile',
    uri: 'user://profiles/{userId}',
    // metadata: { description: '...' } // Optional
  })
  getUserProfile(
    uri: URL, // First argument is the parsed URI
    // metadata: Record<string, any> // Second argument if is defined
    ctx: McpContext, // The request this call arrived on
  ) {
    const userId = uri.pathname.split('/').pop(); // Example: Extract ID from URI
    console.log(`Fetching profile for ${userId}`);
    /* ... return CallResourceResult ... */
    return { content: [{ type: 'text', text: `Profile data for ${userId}` }] };
  }

  @Resource({
    name: 'document_list',
    template: { type: 'string', description: 'Document content query' }, // Simple template example
    // metadata: { list: true } // Optional
  })
  findDocuments(
    uri: URL, // First arg based on simple template type
    variables: Record<string, string>, // Second arg is path params (if any)
    ctx: McpContext, // The request this call arrived on
  ) {
    console.log(`Finding documents matching '${query}'`);
    /* ... return CallResourceResult ... */
    return { content: [{ type: 'text', text: 'List of documents.' }] };
  }
}

Tool Decorator

Decorate methods within a Resolver class to expose them as MCP Tools. Accepts options compatible with server.tool() from @modelcontextprotocol/sdk. The name should use snake_case.

import { Tool, Resolver } from '@nestjs-mcp/server';
import { McpContext } from '@nestjs-mcp/server';
import { z } from 'zod';
import { CallToolResult } from '@modelcontextprotocol/sdk/types';

@Resolver('user_tools')
export class UserToolsResolver {
  @Tool({
    name: 'delete_user',
    description: 'Deletes a user by ID',
    paramsSchema: z.object({ userId: z.string() }),
    annotations: { destructiveHint: true, readOnlyHint: false },
  })
  deleteUser({ userId }: { userId: string }, ctx: McpContext): CallToolResult {
    // ...logic...
    return { content: [{ type: 'text', text: `User ${userId} deleted.` }] };
  }
}

Tool Annotations

The annotations field allows you to provide protocol-level hints about the tool's behavior, such as whether it is destructive, read-only, idempotent, or has other special properties. These hints can be used by clients, UIs, or the protocol itself to display warnings, optimize calls, or enforce policies.

Common annotation keys:

  • destructiveHint (boolean): Indicates the tool performs a destructive action (e.g., deletes data).
  • readOnlyHint (boolean): Indicates the tool does not modify any data.
  • idempotentHint (boolean): Indicates the tool can be safely called multiple times with the same effect.
  • openWorldHint (boolean): Indicates the tool may have side effects outside the current system.

Example:

@Tool({
  name: 'reset_password',
  paramsSchema: z.object({ userId: z.string() }),
  annotations: { destructiveHint: true, idempotentHint: false }
})
resetPassword({ userId }: { userId: string }): CallToolResult {
  // ...
}

ToolOptions Variants

Variant Required Fields
ToolBaseOptions name
ToolWithDescriptionOptions name, description
ToolWithParamOrAnnotationsOptions name, paramsSchemaOrAnnotations
ToolWithParamOrAnnotationsAndDescriptionOptions name, paramsSchemaOrAnnotations, description
ToolWithParamAndAnnotationsOptions name, paramsSchema, annotations
ToolWithParamAndAnnotationsAndDescriptionOptions name, paramsSchema, annotations, description
  • paramsSchema and paramsSchemaOrAnnotations can be a Zod schema for input validation.
  • annotations is an object with protocol-level hints as described above.

McpContext Argument

All MCP capability methods (@Prompt, @Resource, @Tool) receive an McpContext object as their last parameter. It extends the SDK's own ServerContext with the Express request the call arrived on.

Renamed in 2.0. This was RequestHandlerExtra, a type the MCP SDK removed in v2. See Migrating from 1.x to 2.x.

Properties from the SDK:

  • mcpReq.id — the JSON-RPC id of this request
  • mcpReq.method — the method being served, e.g. tools/call
  • mcpReq._meta / mcpReq.envelope — request metadata and the 2026-07-28 envelope
  • http.authInfo — validated access-token information, when auth middleware ran
  • sessionId — undefined on 2026-07-28 traffic. Protocol revision 2026-07-28 retired sessions. It may still be populated for 2025-era clients served through the legacy fallback. Never branch on it for authorization.

Added by @nestjs-mcp/server:

  • request — the live Express request for this call
  • headers — shorthand for request.headers
  • reportProgress(progress, total?, message?) — sends a progress update to the caller; see Reporting progress

Usage Example:

import { McpContext, Resolver, Tool } from '@nestjs-mcp/server';
import { CallToolResult } from '@modelcontextprotocol/server';
import { z } from 'zod';

@Resolver('auth')
export class AuthResolver {
  @Tool({
    name: 'authenticate_user',
    description: 'Authenticates a user with credentials',
    paramsSchema: z.object({ username: z.string(), password: z.string() }),
  })
  authenticateUser(
    params: { username: string; password: string },
    ctx: McpContext, // Always the last parameter
  ): CallToolResult {
    // Headers of THIS call, not of the connection handshake
    const authHeader = ctx.headers.authorization;
    const userAgent = ctx.headers['user-agent'];
    console.log(`Serving ${ctx.mcpReq.method} for: ${String(userAgent)}`);

    // Anything Nest middleware attached upstream is reachable too
    console.log(ctx.request.ip);

    return {
      content: [{ type: 'text', text: 'Authentication successful' }],
    };
  }
}

Important Notes:

  • ctx is always the last parameter in any method decorated with @Resource, @Prompt, or @Tool
  • headers changed meaning in 2.0. Before 2.0 they were the headers of the request that opened the connection — the initialize POST — frozen for its lifetime. They are now this call's own headers, so credentials that expire or are revoked mid-conversation are seen correctly. Code that compiled against 1.x keeps compiling and starts receiving a different (correct) value.
  • ctx is not JSON-serializable: request is an Express object with circular references, so JSON.stringify(ctx) throws. Read the fields you need.

Reporting progress

A long-running tool, prompt or resource can tell the caller how far along it is:

@Tool({
  name: 'analyze_files',
  paramsSchema: z.object({ files: z.array(z.string()) }),
})
async analyzeFiles(
  params: { files: string[] },
  ctx: McpContext,
): Promise<CallToolResult> {
  const total = params.files.length;

  for (let i = 0; i < total; i++) {
    await this.analyzer.analyze(params.files[i]);
    await ctx.reportProgress(i + 1, total, `analyzed ${i + 1}/${total}`);
  }

  return { content: [{ type: 'text', text: `analyzed ${total} files` }] };
}

reportProgress sends a notifications/progress correlated with the progressToken the client put in the request's _meta:

  • No token, no-op. A client that did not ask for progress (most do not) gets nothing, and the call answers exactly as it would have without it.
  • Never rejects. An update that can no longer be delivered — the exchange already ended — is logged at debug and dropped. void ctx.reportProgress(…) is safe.
  • Safe to detach. It never reads this, so it can be passed to a service as a plain callback: await importer.run(rows, ctx.reportProgress).
  • Whether it is delivered depends on responseMode — see Progress and responseMode.
  • No validation or throttling: keep progress increasing, and keep the update rate sensible.

⚠️ message reaches the caller verbatim. Do not put secrets, credentials or other tenants' data in progress messages.


Dynamic Capabilities

@Tool, @Prompt and @Resource all accept an optional enabled option that decides, per request, whether that capability is advertised and invocable.

type McpCapabilityToggle = boolean | Type<McpCapabilityGate>;

interface McpCapabilityGate {
  isEnabled(context: McpRegistrationContext): boolean | Promise<boolean>;
}

interface McpRegistrationContext {
  /** The HTTP request whose capability set is being assembled. */
  request: Request; // express
  /** The effective AuthInfo produced by `auth.strategies` / `auth.authorizers`. */
  authInfo?: AuthInfo; // import type { AuthInfo } from '@nestjs-mcp/server'
  /** 'modern' (2026-07-28) or 'legacy' (2025-era clients). */
  era: ProtocolEra;
}

Two forms, one option key. Omitting enabled is the default and behaves exactly as it did before the option existed: always registered, always enabled, and it costs the request nothing.

Static toggle

For a capability that is simply off. It needs no class, no provider and no await.

@Tool({
  name: 'legacy_tool',
  description: 'Kept for compatibility, not offered to clients',
  enabled: false,
})
legacyTool(): CallToolResult {
  /* ... */
}

Capability gate

For a decision the server has to make. A gate is a class, resolved from the Nest container exactly as a guard is — so it can inject services — and its verdict is awaited, so it can do real asynchronous work.

import { Injectable } from '@nestjs/common';
import {
  McpCapabilityGate,
  McpRegistrationContext,
  Resolver,
  Tool,
} from '@nestjs-mcp/server';

@Injectable()
export class AdminGate implements McpCapabilityGate {
  constructor(private readonly permissions: PermissionsService) {}

  // `authInfo` may be undefined — always optional-chain it.
  async isEnabled(context: McpRegistrationContext): Promise<boolean> {
    if (context.request.headers['x-role'] === 'admin') return true;

    return this.permissions.isAdmin(context.authInfo?.clientId);
  }
}

@Resolver('admin')
export class AdminResolver {
  @Tool({
    name: 'rotate_keys',
    description: 'Only advertised to admin connections',
    enabled: AdminGate,
  })
  rotateKeys(): CallToolResult {
    /* ... */
  }
}

Register the gate as a provider in the module that owns the resolver:

@Module({
  imports: [McpModule.forRoot({ name: 'admin', version: '1.0.0' })],
  providers: [PermissionsService, AdminGate, AdminResolver],
})
export class AppModule {}

Pass the class, never an instance — an instance built at module scope cannot reach the container, which is the whole reason the gate is a class. A gate may also answer synchronously (isEnabled(): boolean) when the check is a cheap in-memory one; boolean | Promise<boolean> covers both.

Note that McpRegistrationContext carries no sessionId, deliberately. It exists under SSE at registration time but not under streamable HTTP, where the transport assigns it while handling the initialize POST — after registration has already run. Exposing it would make the same gate behave differently per transport.

What the client sees

A disabled capability is registered and then disabled, never skipped. It is therefore absent from tools/list / prompts/list / resources/list, and invoking it answers Tool <name> disabled rather than Tool <name> not found — the two are distinct in the SDK and the first is the correct answer for a capability that exists but is off for you.

Fail-closed, in five cases

A gate that cannot answer never leaves a capability exposed:

Case Result
isEnabled throws synchronously disabled, error logged
isEnabled returns a rejecting promise disabled, error logged
the gate class cannot be resolved from the container disabled, error logged — it is never silently built with new, because such a gate has undefined dependencies and could answer something accidentally truthy
a gate is declared but no registration context exists disabled, error logged
disable() itself throws the capability remains enabled, and the log says so — the one residual fail-open, because there is no other way to disable

A failing gate never affects the capabilities beside it, and never turns the request into a 500. Log lines name the capability and the failure only — never the request, its headers, or a token.

Rules you must know before using this

  • Evaluated once per request. A fresh MCP server is built for every HTTP request — that is what makes the server stateless — and enabled is resolved while it is being populated. Changing whatever the gate reads takes effect on the client's very next call; no reconnect is needed. (Before 2.0 the verdict was frozen for the life of a connection.)
  • No listChanged notification is emitted. Every toggle is applied before the server is connected to its transport, where the SDK gates notification dispatch. This library never sends notifications/tools/list_changed or its prompt/resource equivalents. Do not expect a connected client to be told that something changed — nothing changes mid-session.
  • Keep gates cheap — they are on the request path. Every tools/list and every tools/call settles every gate before the client is served, so a gate that queries a database is a per-request database query. This is the most important behavioural change in 2.0 for anyone already using the option. The library bounds what it can: registration stays synchronous, all gates settle in one concurrency wave (the added latency is the slowest single gate, not the sum), and each distinct gate class is both resolved from the container and asked once per request however many capabilities share it. What it cannot bound is the gate itself — do your caching in the injected service, at provider scope. There is no built-in timeout: a gate that never settles leaves the request unanswered.
  • A gate with no registration context fails closed. Both built-in transports always pass a context. If you call RegistryService.registerAll(server) yourself with a single argument, any capability declaring a gate is disabled and the reason is logged. Static true / false and capabilities with no enabled option are unaffected.
  • authInfo may be undefined. It is only populated when an auth strategy authenticated the caller (see Authentication & authorization), or when middleware upstream set req.auth. Write context.authInfo?.scopes — a gate that dereferences it unguarded throws, and then fails closed, which silently removes the capability.
  • Request-scoped gate providers are not supported. A gate is resolved with moduleRef.get(..., { strict: false }), falling back to moduleRef.create. Neither handles a request-scoped provider cleanly. Use a singleton gate that reads what it needs from the registration context.

This is not a replacement for guards

enabled is discovery-level defence-in-depth, not the authorization mechanism. Guards are: they run on every capability invocation, against that invocation's own context.

The reason changed in 2.0, and the conclusion survives it. Before 2.0 the argument was mechanical: the registration context was built from the initialize POST and nothing else, so later calls were never re-examined. That is no longer true — a gate now sees each call's own request and req.auth. What remains is the difference in kind: a gate omits a capability from a listing, while a guard refuses an invocation. Omission is not refusal. A client that already knows a tool's name can call it without ever reading the list, and only a guard stops that.

This is the same line the MCP SDK draws for its own per-request factory: the HTTP layer verifies the bearer token, while per-tool checks live in the handler, because "the handler is the authoritative source for the executing tool."

Use enabled to decide what a caller should even see; use a guard to decide whether a call is allowed. Both now see the same request, so a predicate can be factored between them — but hiding a capability is not enforcement: a client that already knows the tool name can still call it, and only a guard stops that.

If you call RegistryService directly

RegistryService.registerAll is async and its second argument — the registration context — is required:

await registry.registerAll(server, { request, era: 'modern' });

Only code that drives the registry itself is affected; using McpModule normally is not. See Migrating from 1.x to 2.x for the full 2.0 change list.

A runnable example lives in examples/dynamic/ — EXAMPLE=dynamic pnpm start:example.


Guards

Apply one or more guards to a Resolver, to individual methods, or globally. Guards must implement the NestJS CanActivate interface.

Global-level guards

This approach uses the standard NestJS global guard system (APP_GUARD). A global guard will protect all NestJS routes, including the MCP endpoint (/mcp). Use this for broad authentication or checks that apply before any MCP-specific logic runs.

// src/guards/global-auth.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Request } from 'express';

@Injectable()
export class GlobalAuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest<Request>();
    const apiKey = request.headers['x-api-key'];
    // Example: Check for a valid API key
    return !!apiKey && apiKey === 'EXPECTED_KEY';
  }
}

Register the guard globally in your main module:

// src/app.module.ts
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { McpModule } from '@nestjs-mcp/server';
import { GlobalAuthGuard } from './guards/global-auth.guard';

@Module({
  imports: [McpModule.forRoot(/*...*/)],
  providers: [
    {
      provide: APP_GUARD,
      useClass: GlobalAuthGuard,
    },
  ],
})
export class AppModule {}

Guards attached with @UseGuards receive an McpExecutionContext, not Nest's ExecutionContext. Implement McpGuard (optionally McpGuard<YourExtra> for a typed identity) rather than CanActivate: CanActivate types its parameter as ExecutionContext, so implements CanActivate with an McpExecutionContext parameter does not compile under strict mode. @UseGuards accepts both. Global APP_GUARD guards are ordinary Nest guards and keep using CanActivate.

Resolver-level guards

This is a custom feature of this library. Resolver-level guards are applied using the @UseGuards() decorator (exported from @nestjs-mcp/server) on a Resolver class. All MCP methods (@Prompt, @Resource, @Tool) within that specific resolver will be protected by these guards. Use this to enforce logic (e.g., role checks) for a group of related capabilities.

import { UseGuards, Resolver, Prompt } from '@nestjs-mcp/server';
import { RoleGuard } from './guards/role.guard';

@UseGuards(RoleGuard)
@Resolver('admin')
export class AdminResolver {
  @Prompt({ name: 'admin_action' })
  adminAction(/*...*/) {
    /* ... */
  }
  // ... other admin capabilities
}

Method-level guards

This is a custom feature of this library. Method-level guards are applied using the @UseGuards() decorator directly on an MCP capability method (@Prompt, @Resource, @Tool). Only the decorated method will be protected by these guards. Use this for fine-grained access control on specific capabilities.

import { UseGuards, Resolver, Prompt, Tool } from '@nestjs-mcp/server';
import { SpecificCheckGuard } from './guards/specific-check.guard';

@Resolver('mixed')
export class MixedResolver {
  @Prompt({ name: 'public_prompt' })
  publicPrompt() {
    /* Publicly accessible */
  }

  @UseGuards(SpecificCheckGuard)
  @Tool({ name: 'protected_tool' })
  protectedTool(/*...*/) {
    /* Requires SpecificCheckGuard to pass */
  }
}

Important: Resolver and Method-level guards only run for MCP capability invocations, not for the initial connection establishment handled by global guards. They use the custom McpExecutionContext.

Guard Example

A guard for Resolver or Method-level protection:

// src/guards/my-mcp.guard.ts
import { Injectable } from '@nestjs/common';
import { McpExecutionContext, McpGuard } from '@nestjs-mcp/server';

@Injectable()
export class MyMcpGuard implements McpGuard {
  canActivate(context: McpExecutionContext): boolean {
    const request = context.getRequest();
    const userAgent = request.headers['user-agent'];

    const handlerArgs = context.getArgs();

    console.log(
      `Guard activated for ${context.getContext().mcpReq.method} from ${String(userAgent)}`,
    );
    console.log('Handler args:', handlerArgs);

    return true;
  }
}

MCP Execution Context

When implementing Resolver-level or Method-level guards using @UseGuards() from this library, your canActivate method receives an McpExecutionContext. It provides access to MCP-specific information:

import { Injectable } from '@nestjs/common';
import { McpExecutionContext, McpGuard } from '@nestjs-mcp/server';

@Injectable()
export class McpAuthGuard implements McpGuard {
  canActivate(context: McpExecutionContext): boolean {
    // The request this capability was INVOKED on — not the connection
    // handshake, as it was before 2.0.
    const request = context.getRequest();

    const authHeader = request.headers.authorization;
    if (!authHeader?.startsWith('Bearer ')) {
      console.log('Guard Denied: Missing or invalid Bearer token.');
      return false;
    }

    const token = authHeader.split(' ')[1];
    return token === 'VALID_TOKEN';
  }
}

Key points for McpExecutionContext:

  • getRequest() — the Express request this capability was invoked on. Since 2.0 it is the call's own request, so expiring or revoked credentials are seen correctly. In 1.x it was the connection handshake, frozen for the connection's lifetime.
  • getContext() — the full McpContext: mcpReq (id, method, _meta, envelope) and http.authInfo.
  • getArgs() — the arguments passed to the MCP handler being invoked. Their shape depends on the capability type; narrow on the type discriminator.
  • getClass() / getHandler() — the resolver class and method.
  • getAuthInfo?.() — the effective AuthInfo (after auth.authorizers), or undefined for an anonymous request. Optional on the interface so existing test doubles keep compiling.
  • getSessionId() was removed in 2.0. Protocol revision 2026-07-28 retired sessions, so there is no id to return and no store to look one up in.
  • This is not Nest's ExecutionContext: there is no switchToHttp(). Use getRequest() directly.

Guards with Dependency Injection

Guards can inject NestJS providers. Use @Injectable() and register the guard as a provider:

@Injectable()
export class AuthGuard implements McpGuard {
  constructor(private readonly tokens: TokenService) {}

  async canActivate(context: McpExecutionContext): Promise<boolean> {
    const header = context.getRequest().headers.authorization;
    if (!header) return false;

    // Revocation works now: this is the token sent with THIS call.
    return this.tokens.isValid(header.replace('Bearer ', ''));
  }
}

@Module({
  imports: [McpModule.forRoot({ name: 'my-server', version: '1.0.0' })],
  providers: [AuthGuard, TokenService, MyResolver],
})
export class AppModule {}

A guard class the container cannot resolve or create denies the call and logs an error (since 2.1). Register guards with dependencies as providers.


Authentication & authorization

Bring your own authentication. The library does not decide how callers authenticate, nor where the credential travels. Your application registers authentication strategies — an API key in the header, query parameter or cookie of your choice, Basic auth, a session cookie, a client certificate, a JWT, an opaque OAuth token: anything that can be read from the HTTP request. The library provides the infrastructure around them: it runs them before the MCP handler, writes the 401/403/429 answers with the challenges your strategies declare, carries the resulting identity to authorizers, gates, guards and handlers, and — only if you use OAuth — serves the discovery metadata. Authorization is a separate layer you own: request authorizers, per-capability scopes, gates and guards.

HTTP request ─► Layer 1  authentication (strategies, once per request)     ─► req.auth: AuthInfo | 401
             ─► Layer 2a request authorization (authorizers, once per request) ─► req.auth narrowed | 403
             ─► Layer 2b capability authorization (scopes, gates, guards)   ─► handler | 403 / hidden / denied

Evaluation order for one request: strategies → authorizers → gates (enabled, at registration) → the SDK's scopes challenge → guards (@UseGuards) → handler. Absent auth, the endpoint behaves exactly as before.

Configuring auth

import { Module } from '@nestjs/common';
import { McpModule } from '@nestjs-mcp/server';

@Module({
  imports: [
    McpModule.forRoot({
      name: 'notes',
      version: '1.0.0',
      auth: {
        strategies: [ApiKeyStrategy, JwtStrategy], // in order; first AuthInfo wins
        authorizers: [WorkspaceAuthorizer], // optional, see below
        optional: false, // default: unrecognized caller → 401
        hideOutOfScope: false, // default: see "Per-capability scopes"
        protectedResource: {
          // optional: OAuth discovery, see below
          resource: 'https://notes.example.com/mcp',
          authorizationServers: ['https://auth.example.com'],
          scopesSupported: ['notes:read', 'notes:write'],
          resourceName: 'Notes',
        },
      },
    }),
  ],
  // Strategies and authorizers are ordinary providers, so they can inject
  // services. They may be declared in any module.
  providers: [ApiKeyStrategy, JwtStrategy, WorkspaceAuthorizer, NotesResolver],
})
export class AppModule {}

Every strategy and authorizer is resolved from the container at boot. A class that cannot be resolved stops the application from starting — the library never falls back to new, which would bypass DI.

With forRootAsync, declare the classes in providers next to the module, and pass the static protectedResourceMetadata: true flag if the factory returns a protectedResource (Nest fixes controllers before the factory runs):

McpModule.forRootAsync({
  imports: [ConfigModule],
  inject: [ConfigService],
  providers: [JwtStrategy],
  protectedResourceMetadata: true,
  useFactory: (config: ConfigService) => ({
    name: 'notes',
    version: '1.0.0',
    auth: {
      strategies: [JwtStrategy],
      protectedResource: {
        resource: config.getOrThrow('MCP_RESOURCE'),
        authorizationServers: [config.getOrThrow('OAUTH_ISSUER')],
      },
    },
  }),
});

Writing a strategy

A strategy returns the SDK's AuthInfo (re-exported from this package), null ("no credentials for me, try the next one"), or throws:

Outcome HTTP answer
returns AuthInfo continues; later strategies are skipped
every strategy returns null 401 + WWW-Authenticate with every registered challenge (no error=)
throw new McpUnauthorizedError(description) 401 {"error":"unauthorized"} + the registered challenges; stops
throw new OAuthError(OAuthErrorCode.InvalidToken) 401 + Bearer error="invalid_token" challenge (OAuth strategies); stops
throw new McpHttpError(status, body, headers) written as-is (e.g. 429 with Retry-After)
anything else 500, logged without headers or token

AuthInfo is the MCP SDK's identity type (it is what handlers receive as ctx.http.authInfo), whatever the method: clientId is the id of the authenticated principal and token the credential that was presented. Both are required, and expiresAt is in seconds since the epoch. A returned AuthInfo without a string token, a string clientId and a scopes array of strings, or with an expiresAt that is clearly milliseconds (above 1e11), is a bug answered 500; one whose expiresAt has already passed is answered 401 invalid_token. Put application identity (user id, key id, workspace) in AuthInfo.extra. CORS preflight (OPTIONS) is never authenticated. Never put the credential in an error message: an unexpected error is logged by its class and message.

Challenges. A strategy may declare readonly challenge — the RFC 9110 challenge that tells clients how to present its credential, e.g. ApiKey header="x-api-key" or Basic realm="notes". Every 401 lists all declared challenges; when protectedResource is set, the Bearer challenge with resource_metadata comes first (MCP clients look for it). With no declared challenge and no protectedResource, the header is a bare Bearer. A challenge that is not one valid RFC 9110 challenge stops the application at boot, and so does a scopesSupported or decorator scopes entry that is not an OAuth scope-token (no spaces, quotes, backslashes or control characters).

API key from a header you choose (not OAuth; no OAuth vocabulary needed):

import { Injectable } from '@nestjs/common';
import {
  AuthenticatedRequest,
  AuthInfo,
  McpAuthStrategy,
  McpUnauthorizedError,
} from '@nestjs-mcp/server';

@Injectable()
export class ApiKeyStrategy implements McpAuthStrategy {
  readonly challenge = 'ApiKey header="x-api-key"';

  constructor(private readonly keys: ApiKeyService) {}

  async authenticate(req: AuthenticatedRequest): Promise<AuthInfo | null> {
    const key = req.headers['x-api-key'];
    if (typeof key !== 'string') return null; // not mine

    const record = await this.keys.find(key);
    if (!record) throw new McpUnauthorizedError('Unknown API key');

    return { token: key, clientId: record.id, scopes: record.scopes };
  }
}

The credential's location is entirely the strategy's decision: read req.query.api_key, req.cookies.session, req.socket.getPeerCertificate() or any header. Prefer headers: query strings end up in access logs and proxies, and the MCP authorization spec forbids them for OAuth access tokens.

API key via Bearer is the same strategy reading Authorization. Return null for a Bearer value that is not one of your keys (for example, one with a JWT's xxx.yyy.zzz shape) so a JWT strategy later in the list still gets it.

JWT with jose — checking the audience is the strategy's job (RFC 8707): a token minted for another resource must not be accepted.

import { Injectable } from '@nestjs/common';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import {
  AuthenticatedRequest,
  AuthInfo,
  McpAuthStrategy,
  OAuthError,
  OAuthErrorCode,
} from '@nestjs-mcp/server';

const JWKS = createRemoteJWKSet(
  new URL('https://auth.example.com/.well-known/jwks.json'),
);

@Injectable()
export class JwtStrategy implements McpAuthStrategy {
  async authenticate(req: AuthenticatedRequest): Promise<AuthInfo | null> {
    // The auth-scheme is case-insensitive (RFC 9110 §11.1).
    const match = /^Bearer +(\S+)$/i.exec(req.headers.authorization ?? '');
    if (!match) return null;
    const token = match[1];

    try {
      const { payload } = await jwtVerify(token, JWKS, {
        issuer: 'https://auth.example.com',
        audience: 'https://notes.example.com/mcp',
        requiredClaims: ['exp'], // a token without expiry never stops working
      });
      const clientId = [payload.client_id, payload.sub].find(
        (claim): claim is string => typeof claim === 'string',
      );
      if (!clientId) {
        throw new OAuthError(OAuthErrorCode.InvalidToken, 'No client id');
      }
      return {
        token,
        clientId,
        scopes: String(payload.scope ?? '')
          .split(' ')
          .filter(Boolean),
        expiresAt: payload.exp, // seconds
        extra: { sub: payload.sub },
      };
    } catch {
      throw new OAuthError(OAuthErrorCode.InvalidToken, 'Invalid access token');
    }
  }
}

OAuth discovery (protectedResource)

With protectedResource set, the library serves the RFC 9728 Protected Resource Metadata document at the path derived from resource (https://notes.example.com/mcp → /.well-known/oauth-protected-resource/mcp) and advertises it as resource_metadata on the challenges the library builds: the 401 for missing or invalid_token credentials and the 403 insufficient_scope step-up. An McpAccessDeniedError, an McpHttpError, or an McpUnauthorizedError carrying its own challenge is written as thrown. That is how OAuth-capable MCP clients (claude.ai, ChatGPT) find your authorization server. The library does not serve authorization-server metadata — that belongs to the AS.

Trying OAuth end to end

examples/oauth runs an MCP server protected by OAuth 2.1 next to a mock authorization server that behaves like a real provider, with no Docker and no provider account. How to run it, the demo users and clients, the failure scenarios and how to swap the mock for Auth0, Okta or Keycloak are in the example's README.

Request authorizers (Layer 2a)

An McpAuthorizer runs once per request after authentication, receives the AuthInfo so far and returns the one to continue with. Use it for anything that changes the caller's effective scopes — it must run before the SDK sees the request, because the SDK evaluates scopes before any guard:

@Injectable()
export class WorkspaceAuthorizer implements McpAuthorizer {
  constructor(private readonly members: MembershipService) {}

  async authorize(
    req: AuthenticatedRequest,
    auth: AuthInfo,
  ): Promise<AuthInfo> {
    const workspace = String(req.headers['x-workspace']);
    const role = await this.members.roleOf(auth.clientId, workspace);
    if (!role) throw new McpAccessDeniedError('Not a member of this workspace'); // 403 access_denied

    return {
      ...auth,
      scopes: auth.scopes.filter((scope) => role.allows(scope)), // key ∩ role
      extra: { ...auth.extra, workspace },
    };
  }
}

Throw McpAccessDeniedError for 403 { "error": "access_denied" } (nothing a re-consent could fix), or new OAuthError(OAuthErrorCode.InsufficientScope, …) for a 403 step-up challenge. The returned AuthInfo is what gates, scopes, guards (context.getAuthInfo()) and handlers (ctx.http.authInfo) see — both read it with getAuthInfo(ctx).

Per-capability scopes (Layer 2b)

@Tool, @Resource and @Prompt accept scopes:

@Tool({ name: 'add_note', paramsSchema: AddNote, scopes: ['notes:write'] })

The SDK checks them before dispatch against the effective AuthInfo. auth.scopeSatisfies(granted, required) overrides the default check (every required scope granted). What happens when they are not held depends on hideOutOfScope:

hideOutOfScope Listed? Calling it Use when
false (default) yes 403 insufficient_scope, scope="<required scopes>" the grant lacks a scope the user can consent to (OAuth step-up)
true no "disabled" the narrowing comes from something re-consent cannot fix, e.g. a workspace role

A request without AuthInfo is not challenged when auth is not configured (matching the SDK's requireScopes). When auth is configured with optional: true, an anonymous request finds scoped capabilities disabled.

With auth configured, tools/list, prompts/list, resources/list and resources/templates/list default to private, zero-TTL cache hints, since the lists are per caller. Explicit server.cacheHints win.

Resolver defaults

@Resolver also accepts an options object whose scopes, public and hideOutOfScope become the defaults for every capability of the class. The string form, @Resolver('notes'), is unchanged.

@Resolver({ name: 'notes', scopes: ['notes:read'] })
export class NotesResolver {
  @Tool({ name: 'list_notes' }) // requires notes:read (inherited)
  listNotes() {}

  @Tool({ name: 'add_note', scopes: ['notes:write'] }) // requires notes:write only
  addNote() {}

  @Tool({ name: 'about', public: true }) // no scopes: public drops them
  about() {}
}

Precedence is capability > resolver > module: a capability's scopes replace the resolver's (they are not merged), a capability's public overrides the resolver's, and hideOutOfScope on a capability or resolver overrides auth.hideOutOfScope. A capability declaring its own scopes inside a @Resolver({ public: true }) class is protected, not public. Scope tokens are validated when the class is defined, as on the method decorators.

Public capabilities

public: true on @Tool, @Resource or @Prompt (or on @Resolver) opens a capability to callers no strategy recognized, while auth stays mandatory for everything else:

@Tool({ name: 'search_docs', public: true })
searchDocs() {}

When auth is configured without optional: true and every strategy returns null, the request is normally answered 401. If the module declares at least one public capability, the already-parsed JSON-RPC body is inspected instead, and the request is served anonymously only if every message in it (one message or a batch) is:

  • a handshake or liveness message — initialize, server/discover, ping, any notifications/*;
  • a list — tools/list, prompts/list, resources/list, resources/templates/list — which then lists only public capabilities;
  • a tools/call, prompts/get or resources/read naming a public tool, prompt, resource URI, or a URI matching a public resource template.

Anything else — another method, a non-public target, a missing or malformed body, a GET — gets the same 401 with the same challenges as before. For an anonymous request every non-public capability is disabled (unlisted, and "disabled" if called directly), so nothing but public capabilities is reachable.

  • Credentials change nothing. A request with valid credentials sees and calls capabilities exactly as without public; public ones are available to everyone.
  • public and scopes are mutually exclusive. Declaring both on the same capability (or on the same @Resolver) throws when the class is defined: "anyone" and "only a grant holding X" contradict each other, and silently picking one would either expose or lock away something by accident.
  • No public capability, no change. Without one, the authentication path is exactly the same as before — the body is never inspected.
  • optional: true keeps its meaning. Every anonymous request is let through without body inspection, unscoped capabilities stay visible to it, and scoped ones are disabled.
  • The handshake is public too. With any public capability, initialize and server/discover — including the server's instructions — are readable anonymously, so keep nothing confidential there.
  • Both protocol eras. The check runs on the parsed body before the SDK sees the request, so 2025-era clients (e.g. @modelcontextprotocol/sdk v1) work anonymously too: initialize → tools/list → a public tools/call.

Typed identity (getAuthInfo)

getAuthInfo<TExtra>(ctx) returns the effective AuthInfo — what the strategies produced and the authorizers shaped — from a handler's McpContext or a guard's McpExecutionContext, with extra typed as TExtra (McpAuthInfo<TExtra>). It is undefined for an anonymous request.

type WorkspaceExtra = { workspace: string }; // a type alias, not an interface

@Tool({ name: 'list_notes', scopes: ['notes:read'] })
listNotes(ctx: McpContext) {
  const workspace = getAuthInfo<WorkspaceExtra>(ctx)?.extra?.workspace;
  return this.notes.listFor(workspace); // the service decides which rows
}

@Injectable()
export class OwnerGuard implements McpGuard<WorkspaceExtra> {
  canActivate(context: McpExecutionContext<WorkspaceExtra>) {
    return getAuthInfo<WorkspaceExtra>(context)?.extra?.workspace !== undefined;
  }
}

TExtra is an assertion about what your strategies and authorizers put in extra, not a runtime check. McpExecutionContext takes the same optional type parameter; without it, getAuthInfo() returns the SDK's AuthInfo as before. Use a type alias for TExtra: an interface would need an index signature, which lets any key through.

Which tool for which job

Mechanism Runs Answers Use for
strategy once per request 401 recognizing the caller
authorizer once per request, after authentication 403 / narrowed AuthInfo tenant/workspace membership, role ∩ key scopes
scopes per capability, before dispatch 403 insufficient_scope / hidden OAuth scopes a capability requires
gate (enabled) per capability, at registration hidden + "disabled" discovery-level visibility from arbitrary logic
guard (@UseGuards) per invocation, before the handler tool error per-call checks on arguments or resources being touched

Choosing where to authorize

Each layer answers a different question. The library never authorizes data: it decides whether a caller may reach an operation, never which records that operation may touch.

Where Answers Shape Failure the client sees
scopes what the credential/app was granted, per operation declarative, like a permission decorator on a controller method 403 insufficient_scope (step-up), or hidden
guards (@UseGuards) your application's rules for one operation (arguments, time, feature flags) code, per invocation a tool error
authorizers who the caller is for this request: tenant/workspace, role ∩ credential scopes code, once per request; shapes the AuthInfo everything else reads 403 access_denied / narrowed scopes
application services data-level authorization — "is this record in this workspace?" — the source of truth your domain code, called by the handler with getAuthInfo(ctx) whatever your handler returns
public which operations need no identity at all declarative, per capability or resolver 401 for everything else
resolver defaults the common scopes / public / hideOutOfScope of a group of operations declarative, on @Resolver({ … }) as scopes / public
typed identity how handlers and guards read the caller getAuthInfo<TExtra>(ctx) —

Why a scope failure is a 403 but a guard failure is a tool error. scopes are known before dispatch, so the SDK can refuse the HTTP request itself with a 403 and a WWW-Authenticate challenge an OAuth client knows how to act on (re-consent with more scopes). A guard runs inside the dispatched call: by then the MCP request has been accepted and the HTTP response is committed to a JSON-RPC result, so the only honest answer left is an error result for that tool call. Put anything a client could fix by re-authorizing in scopes (or an authorizer); keep guards for rules re-consent cannot change.

Things to know

  • Global APP_GUARDs also run on /mcp. A REST guard registered globally will see MCP traffic too; apply REST guards per controller instead. They run before the MCP controller authenticates the request, so req.auth is still undefined there: authorize MCP callers with auth.authorizers, @UseGuards on resolvers, or capability gates, which all see the AuthInfo.
  • The MCP endpoint and the metadata route are unversioned. /mcp and /.well-known/oauth-protected-resource… are served VERSION_NEUTRAL, so they stay at those paths even when the app enables URI versioning.
  • Global prefix. If you call app.setGlobalPrefix(), exclude the MCP routes: app.setGlobalPrefix('api', { exclude: ['mcp', '.well-known/{*path}'] }).
  • Unresolvable guards now deny (since 2.1). A @UseGuards class the container cannot resolve used to fall back to new Guard(); it is now a denial plus an error log, matching gates.

See examples/auth for a runnable server with an API-key strategy, a JWT strategy, a tenant authorizer and scoped tools.


Statelessness

Since 2.0 this library holds no state between requests. There is no session store, no session id, and no sticky-routing requirement.

The SDK's createMcpHandler builds a fresh McpServer for every HTTP request from a factory this library supplies. That factory runs RegistryService.registerAll against the request being served, so every capability handler and every guard closes over that request. Nothing survives the response.

What this buys you

  • Run any number of instances behind a plain round-robin load balancer. No sticky sessions, no shared session store, no session affinity at the gateway.
  • Instances can restart or autoscale mid-conversation without breaking clients.
  • Deploy to serverless and edge runtimes that cannot hold a connection open.

This is the resolution of #121, and it is verified end to end by test/stateless-load-balancing.e2e-spec.ts: two independently constructed Nest applications behind a strict round-robin proxy, driven by one MCP client, with an assertion that no Mcp-Session-Id ever crosses the wire.

Reaching request data

Everything the old SessionManager lookup existed to recover is now handed to you directly, and it describes the current call rather than the connection handshake:

// In a capability handler
handler(params: Params, ctx: McpContext) {
  ctx.headers.authorization; // this call's credential
  ctx.request.ip;            // the live Express request
}

// In a guard
canActivate(context: McpExecutionContext) {
  context.getRequest().headers.authorization;
}

Transport Options

The library exposes one MCP endpoint: ALL /mcp.

Changed in 2.0. 1.x served POST/GET/DELETE /mcp plus GET /sse and POST /messages, with per-transport enabled toggles. The HTTP+SSE transport was removed: it is structurally sticky-session — a long-lived GET /sse must be paired with POST /messages on the same instance — which is exactly the problem statelessness solves. GET and DELETE on /mcp were the 2025-era session operations and now answer 405.

Backward compatibility. 2025-era clients are still served, on the same endpoint, through the SDK's stateless legacy fallback. Every currently published MCP client SDK — including @modelcontextprotocol/sdk@1 and @modelcontextprotocol/client@2 — speaks that era, and both are covered by the e2e suite.

Options are passed straight through to the SDK's createMcpHandler:

McpModule.forRoot({
  name: 'my-server',
  version: '1.0.0',
  transport: {
    // 'stateless' (default) — serve 2025-era clients per request.
    // 'reject'              — modern-only; refuses 2025-era traffic.
    legacy: 'stateless',

    // 'auto' (default) — JSON, upgrading to SSE if the handler streams
    // 'json'           — never stream (drops mid-call notifications)
    // 'sse'            — always stream
    responseMode: 'auto',

    // SSE keepalive interval; 0 disables. Default 15000.
    keepAliveMs: 15000,
  },
});
Option Purpose
legacy How 2025-era traffic is handled. Default 'stateless'.
responseMode Response shaping for modern exchanges. Default 'auto'.
keepAliveMs SSE comment-frame keepalive. Default 15000.
maxSubscriptions Cap on open subscriptions/listen streams. Default 1024.
bus Change-event bus for subscriptions/listen. Swap for Redis to fan out notifications across instances.

Progress and responseMode

Whether ctx.reportProgress reaches the client depends on the protocol era of the request and on responseMode:

Request era 'auto' (default) 'sse' 'json'
Modern (2026-07-28) Delivered Delivered Dropped — one warning, see below
Legacy (2025, SDK fallback) Delivered over SSE Delivered over SSE Delivered over SSE
  • Modern + 'json'. The SDK drops mid-call notifications in this mode. The first time a handler calls reportProgress, one warn line is logged under @mcp:progress; the SDK also prints its own console.warn once at boot for any 'json' endpoint. The call itself still succeeds.
  • Legacy era. The SDK's legacy fallback always answers a call over SSE and does not apply responseMode, so progress is delivered in every mode.
  • The SSE upgrade on 'auto'. Without progress, a modern call answers a single application/json body. After the first progress frame the response is 200 text/event-stream — progress frames, then the result. From then on a later error rides inside the stream as a JSON-RPC error, not as an HTTP status: a client that keys on the status code sees 200.

Server-wide protocol options live under server, passed verbatim to the SDK:

server.* Purpose
requestState.verify Validates the state an MRTR retry echoes — see Resuming on a different instance
cacheHints ttlMs / cacheScope for the server's cacheable methods (tools/list, server/discover, …). Per-resource hints go on @Resource.
inputRequired maxRounds, roundTimeoutMs for MRTR
jsonSchemaValidator Swap the JSON Schema validation engine

⚠️ legacy: 'reject' refuses every client SDK published today, since none of them speak the 2026-07-28 era yet. Choose it only when you control every caller and they issue raw requests.


MCP 2026-07-28 features

Multi Round-Trip Requests (MRTR)

The 2026-07-28 spec replaced server-initiated elicitation/create and sampling/createMessage — which required a held-open bidirectional stream, and therefore a session — with a retry handshake. A handler that needs input returns inputRequired(...); the client re-sends the same call with the answers attached, and the handler completes.

import {
  acceptedContent,
  CallToolResult,
  inputRequired,
  InputRequiredResult,
  McpContext,
  Resolver,
  Tool,
} from '@nestjs-mcp/server';

@Resolver('ops')
export class OpsResolver {
  @Tool({ name: 'deploy', paramsSchema: z.object({ env: z.string() }) })
  deploy(
    params: { env: string },
    ctx: McpContext,
  ): CallToolResult | InputRequiredResult {
    const answer = acceptedContent<{ confirm: boolean }>(
      ctx.mcpReq.inputResponses,
      'confirm',
    );

    if (!answer) {
      return inputRequired({
        inputRequests: {
          confirm: inputRequired.elicit({
            message: `Deploy to ${params.env}?`,
            requestedSchema: {
              type: 'object',
              properties: { confirm: { type: 'boolean' } },
              required: ['confirm'],
            },
          }),
        },
      });
    }

    return { content: [{ type: 'text', text: `deployed to ${params.env}` }] };
  }
}

Resuming on a different instance

The handler above re-derives everything from its arguments, so any instance can serve the retry. When the round trip must carry state, seal it into requestState — it round-trips through the client, so it is attacker-controlled input and the SDK applies no integrity protection by default.

import { createRequestStateCodec } from '@nestjs-mcp/server';

// The HMAC key must be identical on every instance that might receive the
// echoed value. That shared key is what lets the retry land on another pod.
const codec = createRequestStateCodec<{ item: string }>({
  key: process.env.MCP_STATE_KEY!, // ≥ 32 bytes
});

McpModule.forRoot({
  name: 'shop',
  version: '1.0.0',
  server: { requestState: { verify: (...a) => codec.verify(...a) } },
});
// in the handler
return inputRequired({
  inputRequests: { confirm: inputRequired.elicit({/* … */}) },
  requestState: await codec.mint({ item: params.item }),
});

// on the retry, decoded and verified by whichever instance served it
const state = ctx.mcpReq.requestState<{ item: string }>();

Without the verify hook the echoed state is accepted unverified. With it, a tampered value is rejected — both cases are covered in test/mcp-features.e2e-spec.ts.

The client must declare elicitation: { form: {} } in its capabilities on every request, or the server refuses to ask (-32021).

ctx.mcpReq.elicitInput and ctx.mcpReq.requestSampling still exist but are deprecated and throw on a 2026-07-28 request. MRTR is the only path.

Structured output and display metadata

@Tool({
  name: 'measure',
  title: 'Measure Something',              // display name
  paramsSchema: z.object({ subject: z.string() }),
  outputSchema: z.object({ subject: z.string(), value: z.number() }),
  icons: [{ src: 'https://example.com/icon.png', mimeType: 'image/png' }],
  _meta: { 'com.example/category': 'diagnostics' },
})
measure(params: { subject: string }): CallToolResult {
  return {
    content: [{ type: 'text', text: `measured ${params.subject}` }],
    structuredContent: { subject: params.subject, value: 42 },
  };
}

title, icons and _meta are also available on @Prompt.

Resource cache hints

@Resource({
  name: 'catalog',
  uri: 'catalog://items',
  cacheHint: { ttlMs: 60_000, cacheScope: 'public' },
})

ttlMs and cacheScope ride on the resources/read result so clients can cache instead of re-fetching. Resource-only by design: tools/list and prompts/list return one result for the whole server, so a per-capability hint would have nowhere to go — use server.cacheHints for those.

Emitting list_changed

Inject McpHttpService and publish onto its notifier. This is the first release in which the library can emit change notifications at all.

@Injectable()
export class PermissionsService {
  constructor(private readonly mcp: McpHttpService) {}

  onRoleChanged(): void {
    this.mcp.notify.toolsChanged();
  }
}

Notifications reach clients holding a subscriptions/listen stream. In a multi-instance deployment, supply transport.bus backed by Redis pub/sub so a change on one instance fans out to subscribers on the others.


Migrating from 1.x to 2.x

2.0 adopts MCP specification 2026-07-28, which retires sessions, and moves to the MCP SDK's v2 package family. Existing MCP clients keep working — they are served through the SDK's stateless legacy fallback — but the server-side API changed.

Install

# 1.x
pnpm add @nestjs-mcp/server @modelcontextprotocol/sdk

# 2.x — the SDK split into scoped packages; the server package is what you import types from
pnpm add @nestjs-mcp/server @modelcontextprotocol/server

The breaking changes

1.x 2.x Why
RequestHandlerExtra McpContext The SDK removed the type. ctx.request is now the live Express request.
extra.sessionId (gone) 2026-07-28 retired sessions. ctx.sessionId exists but is undefined on modern traffic.
extra.headers ctx.headers Meaning changed — see below.
extra.body (undocumented) ctx.request.body Now declared, and from the invoking request.
SessionManager (gone) No session store. Use ctx.request or context.getRequest().
McpExecutionContext.getSessionId() (removed) Nothing to return. Use getRequest() or getContext().
paramsSchema: { a: z.string() } paramsSchema: z.object({ a: z.string() }) v2 takes Standard Schema; raw Zod shapes are no longer expressible.
argsSchema: { … } argsSchema: z.object({ … }) Same.
transports: { sse, streamable } transport: { legacy, responseMode, … } One endpoint; options pass through to createMcpHandler.
session: { sessionTimeoutMs, … } (gone) No sessions to time out.
GET /sse + POST /messages (removed) HTTP+SSE is structurally sticky-session.
GET / DELETE /mcp 405 These were session operations.
McpModule.forFeature() still present Unchanged (still a no-op).

The silent one — read this

extra.headers compiled in 1.x and compiles in 2.x, but returns something different.

In 1.x it was the headers of the request that opened the connection — the initialize POST, or the GET /sse handshake — recovered from the session store and frozen for the connection's lifetime. In 2.x it is the headers of the call being served.

This is a bug fix, and it is the point of the release: an Authorization header that expires or is revoked mid-conversation is now seen. But if you relied on connection-scoped values, they no longer persist across calls. Mint an explicit handle from a tool and have the model pass it back as an argument instead.

Handler signature

- import { RequestHandlerExtra, Resolver, Tool } from '@nestjs-mcp/server';
- import { CallToolResult } from '@modelcontextprotocol/sdk/types';
+ import { McpContext, Resolver, Tool } from '@nestjs-mcp/server';
+ import { CallToolResult } from '@modelcontextprotocol/server';

  @Resolver('example')
  export class ExampleResolver {
    @Tool({
      name: 'my_tool',
-     paramsSchema: { id: z.string() },
+     paramsSchema: z.object({ id: z.string() }),
    })
-   myTool(params: { id: string }, extra: RequestHandlerExtra): CallToolResult {
-     const auth = extra.headers.authorization;
+   myTool(params: { id: string }, ctx: McpContext): CallToolResult {
+     const auth = ctx.headers.authorization;
      return { content: [{ type: 'text', text: params.id }] };
    }
  }

Guards

  @Injectable()
  export class AuthGuard implements McpGuard {
-   constructor(private readonly sessionManager: SessionManager) {}
-
    canActivate(context: McpExecutionContext): boolean {
-     const session = this.sessionManager.getSession(context.getSessionId());
-     return !!session?.request.headers.authorization;
+     return !!context.getRequest().headers.authorization;
    }
  }

Client compatibility

No client change is required. legacy: 'stateless' is the default and serves 2025-era clients on the same /mcp endpoint. Set transport.legacy = 'reject' only if you control every caller — no published client SDK speaks the 2026-07-28 era yet.


Inspector Playground

Facts

Kind
MCP server
Repo
adrian-d-hidalgo/nestjs-mcp-server
Group
Uncategorized
Stars
39
License
MIT
Language
TypeScript
Last push
2026-10-01
Forks
3

More on this shelf

  1. 1REAio.github.morluto/reaReverse engineer anything from your terminal or agent with one CLI and MCP server.125.3k
  2. 2com.browser-use/browser-usecom.browser-use/browser-useControl a real Chrome browser to complete any task: fill forms, extract data, book flights.117.7k
  3. 3World Monitorapp.worldmonitor/mcpLive markets, conflicts, country risk, chokepoints, energy, and China decision signals. 93 tools.88.3k
  4. 4Scrapling MCP Serverd4vinci/scrapling🕷️ An adaptive Web Scraping framework that handles everything from a single request to a full-scale crawl! Don't be shy, join here: https://discord.gg/EMgGbDceNQ and follow here for daily tips and tricks: https://x.com/Scraplingdev86.9k
  5. 5Everythingmodelcontextprotocol/serversThis MCP server attempts to exercise all the features of the MCP protocol. It is not intended to be a useful server, but rather a test server for builders of MCP clients. It implements prompts, tools, resources, sampling, and more to showcase MCP capabilities.85.8k
  6. 6Fetchmodelcontextprotocol/serversA Model Context Protocol server that provides web content fetching capabilities. This server enables LLMs to retrieve and process content from web pages, converting HTML to markdown for easier consumption.85.8k