import { DynamicModule, InjectionToken, Module, Provider, Type, } from '@nestjs/common'; import { DiscoveryModule } from '@nestjs/core'; import { McpTransportType } from './interfaces'; import type { McpOptions, McpModuleAsyncOptions, McpOptionsFactory, McpAsyncOptions, } from './interfaces'; import { McpExecutorService } from './services/mcp-executor.service'; import { McpRegistryDiscoveryService } from './services/mcp-registry-discovery.service'; import { McpSseService } from './services/mcp-sse.service'; import { McpStreamableHttpService } from './services/mcp-streamable-http.service'; import { SsePingService } from './services/sse-ping.service'; import { ToolAuthorizationService } from './services/tool-authorization.service'; import { McpRegistryService } from './services/mcp-dynamic-registry.service'; import { createSseController } from './transport/sse.controller.factory'; import { StdioService } from './transport/stdio.service'; import { createStreamableHttpController } from './transport/streamable-http.controller.factory'; import { normalizeEndpoint } from './utils/normalize-endpoint'; import { MCP_FEATURE_REGISTRATION, McpFeatureRegistration, } from './constants/feature-registration.constants'; let instanceIdCounter = 0; let featureIdCounter = 0; /** * Lightweight module class used by McpModule.forFeature(). * This is separate from McpModule to avoid circular dependency issues. */ @Module({}) export class McpFeatureModule {} @Module({ imports: [DiscoveryModule], providers: [ McpRegistryDiscoveryService, McpExecutorService, ToolAuthorizationService, ], }) export class McpModule { /** * To avoid import circular dependency issues, we use a marker property. */ readonly __isMcpModule = true; /** * Registers tool providers to be associated with a specific MCP server. * * Use this method to organize tools into separate feature modules while * associating them with a specific MCP server created by forRoot(). * * @param providers - Array of provider classes that contain @Tool, @Resource, @ResourceTemplate, or @Prompt decorated methods * @param serverName - The name of the MCP server (as specified in forRoot options) to register these tools with * @returns A DynamicModule that can be imported into any module * * @example * ```typescript * // In feature.module.ts * @Module({ * imports: [McpModule.forFeature([UserTools, OrderTools], 'my-server')], * providers: [UserTools, OrderTools, UserService, OrderService], * exports: [UserTools, OrderTools], * }) * export class FeatureModule {} * * // In app.module.ts * @Module({ * imports: [ * McpModule.forRoot({ name: 'my-server', version: '1.0.0' }), * FeatureModule, * ], * }) * export class AppModule {} * ``` */ static forFeature( providers: InjectionToken[], serverName: string, ): DynamicModule { const registration: McpFeatureRegistration = { serverName, providerTokens: providers, }; // Use a unique token for each forFeature call to allow multiple registrations const registrationToken = `${MCP_FEATURE_REGISTRATION}_${featureIdCounter++}`; return { module: McpFeatureModule, providers: [ { provide: registrationToken, useValue: registration, }, ], exports: [registrationToken], global: true, // Make it global so the registration can be discovered from any module }; } static forRoot(options: McpOptions): DynamicModule { const defaultOptions: Partial = { transport: [ McpTransportType.SSE, McpTransportType.STREAMABLE_HTTP, McpTransportType.STDIO, ], sseEndpoint: 'sse', messagesEndpoint: 'messages', mcpEndpoint: 'mcp', guards: [], decorators: [], streamableHttp: { enableJsonResponse: true, sessionIdGenerator: undefined, statelessMode: true, }, sse: { pingEnabled: true, pingIntervalMs: 30000, }, }; const mergedOptions = { ...defaultOptions, ...options } as McpOptions; mergedOptions.sseEndpoint = normalizeEndpoint(mergedOptions.sseEndpoint); mergedOptions.messagesEndpoint = normalizeEndpoint( mergedOptions.messagesEndpoint, ); mergedOptions.mcpEndpoint = normalizeEndpoint(mergedOptions.mcpEndpoint); const moduleId = `mcp-module-${instanceIdCounter++}`; const providers = this.createProvidersFromOptions(mergedOptions, moduleId); const controllers = this.createControllersFromOptions(mergedOptions); return { module: McpModule, controllers, providers, exports: [ McpRegistryDiscoveryService, McpSseService, McpStreamableHttpService, McpRegistryService, ], }; } /** * Asynchronous variant of forRoot. Controllers are NOT auto-registered here because * they must be declared synchronously at module definition time. This keeps the * API explicit: when using forRootAsync, you are responsible for creating and * registering any transport controllers (e.g. via createSseController / createStreamableHttpController). * * The exposed async options intentionally omit the `transport` property. Transport * selection only influences automatic controller creation (which does not occur here) * and STDIO auto-start. If you need STDIO with forRootAsync, manually instantiate * and bootstrap it (e.g. by importing a module that injects StdioService) or add * an explicit provider that sets options.transport before use. */ static forRootAsync(options: McpModuleAsyncOptions): DynamicModule { const moduleId = `mcp-module-${instanceIdCounter++}`; const asyncProviders = this.createAsyncProviders(options); const baseProviders: Provider[] = [ { provide: 'MCP_MODULE_ID', useValue: moduleId, }, McpRegistryDiscoveryService, McpExecutorService, ToolAuthorizationService, SsePingService, McpSseService, McpStreamableHttpService, McpRegistryService, StdioService, ]; return { module: McpModule, imports: options.imports ?? [], // No automatic controllers in async mode controllers: [], providers: [ ...asyncProviders, ...baseProviders, ...(options.extraProviders ?? []), ], exports: [ McpRegistryDiscoveryService, McpSseService, McpStreamableHttpService, McpRegistryService, ], }; } private static createAsyncProviders( options: McpModuleAsyncOptions, ): Provider[] { if (options.useFactory) { return [ { provide: 'MCP_OPTIONS', useFactory: async (...args: unknown[]) => { const resolved: McpAsyncOptions = await options.useFactory!( ...args, ); return this.mergeAndNormalizeAsyncOptions(resolved); }, inject: options.inject ?? [], }, ]; } // useClass / useExisting path const inject: any[] = []; let optionsFactoryProvider: Provider | undefined; if (options.useExisting || options.useClass) { const useExisting = options.useExisting || options.useClass!; inject.push(useExisting); if (options.useClass) { optionsFactoryProvider = { provide: options.useClass, useClass: options.useClass, } as Provider; } return [ ...(optionsFactoryProvider ? [optionsFactoryProvider] : []), { provide: 'MCP_OPTIONS', useFactory: async (factory: McpOptionsFactory) => { const resolved = await factory.createMcpOptions(); return this.mergeAndNormalizeAsyncOptions(resolved); }, inject, }, ]; } throw new Error('Invalid McpModuleAsyncOptions configuration.'); } private static mergeAndNormalizeAsyncOptions( resolved: McpAsyncOptions, ): McpOptions { const defaultOptions: Partial = { sseEndpoint: 'sse', messagesEndpoint: 'messages', mcpEndpoint: 'mcp', guards: [], decorators: [], streamableHttp: { enableJsonResponse: true, sessionIdGenerator: undefined, statelessMode: true, }, sse: { pingEnabled: true, pingIntervalMs: 30000, }, }; // Note: transport intentionally omitted const merged = { ...defaultOptions, ...resolved } as McpOptions; merged.sseEndpoint = normalizeEndpoint(merged.sseEndpoint); merged.messagesEndpoint = normalizeEndpoint(merged.messagesEndpoint); merged.mcpEndpoint = normalizeEndpoint(merged.mcpEndpoint); return merged; } private static createControllersFromOptions( options: McpOptions, ): Type[] { const sseEndpoint = options.sseEndpoint ?? 'sse'; const messagesEndpoint = options.messagesEndpoint ?? 'messages'; const mcpEndpoint = options.mcpEndpoint ?? 'mcp'; const guards = options.guards ?? []; const transports = Array.isArray(options.transport) ? options.transport : [options.transport ?? McpTransportType.SSE]; const controllers: Type[] = []; const decorators = options.decorators ?? []; const apiPrefix = options.apiPrefix ?? ''; if (transports.includes(McpTransportType.SSE)) { const sseController = createSseController( sseEndpoint, messagesEndpoint, apiPrefix, guards, decorators, options, ); controllers.push(sseController); } if (transports.includes(McpTransportType.STREAMABLE_HTTP)) { const streamableHttpController = createStreamableHttpController( mcpEndpoint, apiPrefix, guards, decorators, options, ); controllers.push(streamableHttpController); } if (transports.includes(McpTransportType.STDIO)) { // STDIO transport is handled by injectable StdioService, no controller } return controllers; } private static createProvidersFromOptions( options: McpOptions, moduleId: string, ): Provider[] { const providers: Provider[] = [ { provide: 'MCP_OPTIONS', useValue: options, }, { provide: 'MCP_MODULE_ID', useValue: moduleId, }, McpRegistryDiscoveryService, McpExecutorService, ToolAuthorizationService, SsePingService, McpSseService, McpStreamableHttpService, McpRegistryService, StdioService, ]; return providers; } }