PluginWorld
Ne

nestjs-mcp

MCP

Embeds a runtime MCP inspector in a NestJS app so coding agents can inspect the module and provider graph, routes and their guards/interceptors/pipes/filters, logs, request traces, event listeners, and scheduled jobs through an HTTP endpoint.

@jsirp-dev · v0.0.0 · MIT License · updated 10d ago

SECURITY

B

SCORE

68

STARS

▲ 0

PLUG IN

git clone https://github.com/jsirp-dev/nestjs-mcp.git

See the README to configure this MCP server

README

@jsirp/nestjs-mcp

Runtime MCP inspector embedded in your NestJS app, for coding agents.

Add one module and your running app exposes an MCP endpoint that tells an agent (Claude Code, Cursor, any MCP client) what Nest actually built: the module and provider graph, every route with the guards, interceptors, pipes and filters it goes through, what the app's Logger is writing right now, which enhancers a given request passed through and how long each took, event listeners and scheduled jobs. Nothing to install on the agent side: it points at a URL of your app.

Status: 0.1.0 in development. Today the endpoint, app_info, the graph, routes and logs work; the other tool groups land one by one (see Tools).

Install

npm install --save-dev @jsirp/nestjs-mcp

Everything a Nest app already has is a peer dependency: @nestjs/common and @nestjs/core 10 or 11, reflect-metadata, rxjs, and zod (3.25+ or 4, the versions the MCP SDK accepts). @nestjs/event-emitter and @nestjs/schedule are optional peers: their tools appear only if your app uses them. The only runtime dependency is @modelcontextprotocol/sdk.

Quick start

import { Module } from '@nestjs/common';
import { NestMcpModule } from '@jsirp/nestjs-mcp';

@Module({
  imports: [NestMcpModule.forRoot()],
})
export class AppModule {}

Create the app with snapshot: true so Nest keeps the graph the graph_* and routes_* tools read:

const app = await NestFactory.create(AppModule, { snapshot: true });

At startup the module logs the endpoint, with context NestMcp:

[NestMcp] Inspector endpoint at http://localhost:3000/api/_mcp

One module on its own endpoint

forFeature mounts an instance whose scope is the module that imports it and everything that module imports. path is required. Any number of instances may coexist, each with its own path, guards and switches.

The scope is resolved from the serialized graph at the first tool call, so it needs snapshot: true too: app_info.scope then reads { "kind": "module", "module": "CatsModule" }. Without snapshot, it reads { "kind": "module", "module": null, "error": "scope_unresolved", "fallback": "app" }. The closure leaves out Nest's internal modules and the instance's own module.

@Module({
  imports: [NestMcpModule.forFeature({ path: '_mcp/cats' })],
})
export class CatsModule {}

Options from a configuration source

forRootAsync and forFeatureAsync follow Nest's configurable-module pattern, so any source works. With @nestjs/config, as an example:

NestMcpModule.forRootAsync({
  imports: [ConfigModule],
  inject: [ConfigService],
  useFactory: (config: ConfigService) => ({
    enabled: config.get('INSPECTOR_ENABLED') === 'true',
  }),
});

Nest reads the endpoint's route and guards before any factory runs, so path, guards and localOnly are given statically, next to useFactory, not returned by it. enabled may go either way: given statically and false, nothing is registered; returned by the factory as false, the endpoint answers 404, exactly as if it were not mounted, and nothing else is installed.

Point your agent at it

Claude Code:

claude mcp add --transport http my-nest-app http://localhost:3000/api/_mcp

Or any client that reads an mcpServers file:

{
  "mcpServers": {
    "my-nest-app": { "type": "http", "url": "http://localhost:3000/api/_mcp" }
  }
}

Options

All optional, except path in forFeature.

Option Default What it does
enabled process.env.NODE_ENV !== 'production' The switch. When false the instance registers nothing. It is a default, not a rule: set it to true wherever you want the endpoint and protect it with guards.
path '_mcp' for forRoot; required for forFeature Route of the endpoint, relative to the app's global prefix and version-neutral under any versioning. With prefix api: /api/_mcp.
guards [] Nest guards on the endpoint, exactly like @UseGuards, after LocalOnlyGuard.
localOnly true Reject requests that do not come from loopback (127.0.0.1, ::1).
tools all true One switch per tool group: { graph, routes, logs, requests, events, schedule }. A group switched off registers no tools and installs nothing.
logs.bufferSize 2000 Size of the ring buffer the Logger tee keeps.
requests.bufferSize 500 How many request traces are kept.
requests.idHeader 'x-nest-mcp-request-id' The correlation header an agent sets to find a request's trace. Read, never echoed back.
requests.headers 'names' Which request headers a trace keeps: 'none', 'names' or 'full'. Bodies never.
serverInfo your app's package.json { name, version } the server reports in the MCP initialize handshake.

Guards are instantiated inside the inspector's module, like any enhancer of its controller: their dependencies must come from a global module or, in the async variants, from imports.

Tools

Group Tools Status
app app_info: Nest and Node versions, uptime, global prefix, versioning, endpoint path, instance scope, whether snapshot is on, which groups are live, and the instance's tool switches and requests.headers policy ✅
graph graph_modules, graph_module, graph_providers, graph_provider, graph_cycles ✅
routes routes_list, route_get ✅
logs logs_tail, logs_levels ✅
requests requests_list, request_trace ✅
events events_listeners, events_recent (only with @nestjs/event-emitter and its module) ✅
schedule schedule_jobs (only with @nestjs/schedule and its module) ✅

Every tool returns JSON as a single text block. Errors come back as { "error": "<code>", "hint": "…" } with isError: true, plus what helps to retry (candidates, for example).

Graph

Read from the serialized graph Nest keeps with snapshot: true, never from source. Every answer, errors included, carries scope, the boundary the instance observes: a forFeature instance lists only the modules in its closure, and the providers, dependencies and cycles among them.

Tool Input Answers
graph_modules includeInternal? Each module: global, dynamic, provider and controller counts, imports
graph_module name, includeInternal? Imports, exports, providers (token, scope, exported), controllers, importedBy
graph_providers filter?, module?, scope?, includeInternal? Providers and injectables; enhancers carry subtype (guard, pipe, …) and, when registered globally, global: true
graph_provider token, module?, includeInternal? Module, injection scope, dependsOn, dependents, initTimeMs
graph_cycles — cycles: module import cycles and class injection cycles; [] when there are none
  • Tokens are text: a class by its name, a string or symbol token by its description. Instances are never returned.
  • Nest's own modules and providers (InternalCoreModule, ModuleRef, ApplicationConfig, …) are hidden unless includeInternal: true.
  • Modules that share a class name (two dynamic instances of one module, for example) are listed as Name#1, Name#2 in the order Nest registered them; use those names as input.
  • dependsOn and dependents count constructor and property injection. Enhancers attached with @UseGuards and friends are not dependencies; the routes tools report them.
  • An unknown name answers not_found with up to five similar candidates; a token declared in several modules answers ambiguous with each { token, module }, and module picks one.

Routes and enhancers

Every HTTP route Nest registered whose controller is in the instance's scope, the inspector's own endpoint included (a forFeature instance does not list it). Routes that an RPC layer implements as controller handlers are ordinary routes; one served by a middleware of its own is not a route here.

Tool Input Answers
routes_list filter?, method?, controller?, module? One entry per route: method, path, Controller.handler, and how many guards, interceptors, pipes and filters run on it
route_get method, path The route: path, rawPath, version, controller, handler, module, guards, interceptors, pipes, filters, middlewares
  • path is what the app serves, as Nest computed it: global prefix (unless the route is in its exclude list) and the URI version. rawPath is the controller and method paths alone, the one to use with header or media-type versioning. route_get takes either; a raw path shared by several versions answers ambiguous with the full paths.

  • version is the handler's version, or the controller's, or defaultVersion; VERSION_NEUTRAL is 'neutral'. Without versioning there is none.

  • Each enhancer is { name, level }, a class name and never an instance. Each list is in the order Nest runs it:

    Level Comes from
    global app.useGlobalGuards(...) and friends, and APP_GUARD / APP_INTERCEPTOR / APP_PIPE / APP_FILTER providers, in the order Nest registered them
    controller @UseGuards, @UseInterceptors, @UsePipes, @UseFilters on the controller class
    method The same decorators on the handler; then parameter pipes, which carry param ('id' for @Param('id', ParseIntPipe), #n for an argument without a name)
  • Filters are listed in the same order but resolved the other way round: Nest tries the method's filters first, then the controller's, then the globals, and the first whose @Catch matches handles the exception.

  • middlewares are the classes whose forRoutes path and method match the route's raw path. Plain paths match exactly; wildcards match by the prefix before them, and exclude(...) is not read, so the list is approximate (route_get says so in middlewaresMatch).

Logs

At startup the module wraps the logger your app set (with app.useLogger, the logger option of NestFactory.create, or Nest's default ConsoleLogger) in a transparent tee: every call to Nest's Logger reaches your logger exactly as it was made, and a copy goes to an in-memory ring buffer of logs.bufferSize entries. Nothing is masked, filtered or altered, and nothing is persisted. The tee is installed once per process and shared by every instance, so the first instance that installs it decides logs.bufferSize; switching the logs group off in every instance installs nothing.

Tool Input Answers
logs_tail limit? (≤ 500, default 100), level?, minLevel?, context?, contains?, sinceSeq?, scope? ('instance' or 'app') entries, oldest first, each { seq, timestamp, level, context, message, stack? }; lastSeq, to pass as sinceSeq next time; more when a page after sinceSeq was cut at limit; dropped when entries after sinceSeq already left the buffer
logs_levels — levels your logger has on (null if it has no isLevelEnabled) and the buffer: size, length, lastSeq
  • Levels are Nest's: verbose, debug, log, warn, error, fatal. minLevel keeps that level and the more severe ones.
  • context is the trailing string argument, as ConsoleLogger reads it: the class name for a new Logger(MyService.name). In error, the multi-line string before it is the stack. Non-string messages are stored as JSON, capped at 2000 characters.
  • seq grows by one per entry and never repeats. Without sinceSeq the answer is the newest limit entries; with it, the oldest limit after it, so paging never skips or repeats one.
  • A forFeature instance keeps only entries whose context is the name of a class in its scope (controllers, providers, injectables) and says so in scopeFilter. It is approximate: a context that is not a class name, or a class name shared with another module, is judged by name alone. scope: 'app' lifts the filter.
  • A level your logger reports as disabled (isLevelEnabled) is forwarded as usual but not copied, so the buffer holds what your logger keeps. With logger: false nothing is printed, as before, and the buffer still fills.
  • The inspector's own lines (context NestMcp) are in the buffer too.

Request traces

Fire a request at the app, then ask where it went. Set the correlation header (x-nest-mcp-request-id, or your requests.idHeader) to an id of your choosing and read the trace by that id; without the header the request gets a generated id, and requests_list finds it.

curl -H 'x-nest-mcp-request-id: try-1' http://localhost:3000/api/cats/abc
# then, from the agent: request_trace { "id": "try-1" }
Tool Input Answers
requests_list limit? (≤ 200, default 50), method?, path? (substring of the URL), status?, minStatus?, scope? ('instance' or 'app') requests, oldest first, each { id, method, url, startedAt, statusCode, durationMs, handler, module, steps } (inFlight: true while it runs); matched
request_trace id, scope? The trace: method, url, route, statusCode, durationMs, controller, handler, module, headers and its steps in order; with several traces sharing an id, the latest

Each step is { kind, name, level?, outcome, error?, startedAtMs, durationMs }, timed from the start of the request:

kind Recorded outcome
guard When canActivate returns (or its Promise or Observable settles) allowed, denied (returned false, Nest answers 403) or threw
interceptor Twice: phase: 'enter' when intercept is called, phase: 'exit' when the Observable it returned answers; shortCircuited: true on the exit when it answered without calling next.handle() ok or threw
pipe Per argument, with param ('id') and paramType (param, query, body, custom); never the value ok or threw
handler Named Controller.handler, placed where it ran: after the pipes, before the interceptors' exits. Absent when a pipe or an interceptor stopped the request first ok or threw
filter When an exception filter of yours handles an exception, with that exception in error caught, or threw if the filter itself threw

So GET /api/cats/abc on a route with an API-key guard, a timing interceptor and ParseIntPipe reads ApiKeyGuard allowed → TimingInterceptor enter → ParseIntPipe(id) threw BadRequestException → TimingInterceptor exit threw, status 400.

  • What a trace stores: method, the URL with its query string, route pattern, status, timings, controller, handler and steps; request headers per requests.headers: 'none', 'names' (the default, names only) or 'full' (names and values, credentials included: choose it knowingly). Bodies are never stored, neither of requests nor of responses.
  • The correlation header is read, never echoed back: the response carries nothing it did not carry before. To reuse an id your app already propagates (x-request-id from a proxy or request logger), set requests.idHeader: 'x-request-id'.
  • level (global, controller, method) comes from the route's enhancer chain, so it needs snapshot: true, like module. Without it the steps are there and those fields are not.
  • A forFeature instance lists only traces whose handler is in its scope; a request that matched no route has no handler and is seen from app scope only. scope: 'app' lifts the filter.
  • The inspector's own endpoints are never traced. One ring buffer of requests.bufferSize traces per app, shared by its instances; the first instance with the requests group on decides requests.*.
  • Enhancers in REQUEST or TRANSIENT scope have no long-lived instance to observe; a trace lists the ones on its route in notTraced.

Events

With @nestjs/event-emitter installed and EventEmitterModule imported, the inspector lists who listens to what and records what was emitted. Without the package, or without its module, there are no events_* tools and app_info.tools.events is false.

Tool Input Answers
events_listeners event?, scope? ('instance' or 'app') listeners, each { event, className, methodName, module, options?, requestScoped? }; wildcard (whether the emitter resolves wildcards)
events_recent limit? (≤ 500, default 50), event? entries, oldest first, each { seq, event, method, timestamp, listeners }; emissions: 'app-wide'
  • Listeners come from the @OnEvent metadata of every provider and controller, request-scoped ones included. With event, a listener matches when its pattern contains the text or when the emitter itself would call it for that event: with wildcard: true, events_listeners { "event": "cat.adopted" } returns the cat.* listeners too.
  • Emissions are recorded by wrapping emit and emitAsync on the app's EventEmitter2: the event name, the method, the time and listenerCount(event) at that moment. Payloads are never kept. The wrapper returns exactly what the original returned (the boolean, the same promise) and throws what it threw. event filters by substring or, as a pattern, through the emitter (cat.*).
  • A forFeature instance lists only listeners declared in its scope. Emissions have no module, so every instance sees all of them; one buffer of the latest 500 per emitter, shared by the instances.

Scheduled jobs

With @nestjs/schedule installed and ScheduleModule imported, the inspector lists what the app's SchedulerRegistry holds. Without the package, or without its module, there is no schedule_jobs and app_info.tools.schedule is false.

Tool Input Answers
schedule_jobs kind? ('cron', 'interval' or 'timeout'), scope? ('instance' or 'app') jobs, each { kind, name, className?, methodName?, module?, expression?, timeZone?, everyMs?, delayMs?, running?, lastRun?, nextRun? }
// schedule_jobs { "kind": "cron" }, for @Cron('0 3 * * *', { name: 'cleanup' }) on CatsService
{ "scope": { "kind": "app" }, "jobs": [{ "kind": "cron", "name": "cleanup", "className": "CatsService",
  "methodName": "cleanup", "module": "CatsModule", "expression": "0 3 * * *", "running": true,
  "nextRun": "2026-10-26T03:00:00.000+01:00" }] }
  • The registry is read on every call: cron jobs (expression, running, lastRun, and nextRun while running), intervals (everyMs) and timeouts (delayMs; running: false once fired or cleared), the ones the app added with addCronJob/addInterval/addTimeout included. timeZone appears when it differs from the process's own.
  • Class, method and module come from the @Cron/@Interval/@Timeout metadata, matched to the registry by name; a decorator without a name, which the registry knows by a generated id, is matched by its expression or period when exactly one job has it.
  • A forFeature instance lists only jobs declared by classes in its scope. A job the app added to the registry by hand has no class, so only scope: 'app' (or a forRoot instance) lists it.

How it works

  • Stateless Streamable HTTP. Each POST gets its own MCP server and transport, answered and closed. GET and DELETE return 405 with Allow: POST: there are no sessions to stream to or tear down.
  • A Nest controller. The endpoint is a regular controller mounted by the module, one class per instance, so your global prefix applies and the guards you configure get a real ExecutionContext.
  • Traces by wrapping enhancer instances. With the requests group on, the module wraps, in place, canActivate, intercept, transform and catch on the guard, interceptor, pipe and filter instances Nest built (global, per controller and per route) before Nest builds the routes. Each wrapper records a step into the current request's trace, held in an AsyncLocalStorage that a middleware opens, and returns exactly what the original returned, with the same this and the same exceptions. It also adds one global interceptor of its own, which names the handler. With the group off, nothing is wrapped and neither is installed.
  • Emissions by wrapping the emitter. With the events group on and the app's EventEmitter2 in the container, emit and emitAsync are replaced on that instance, once, by functions that record the event name and listener count and then call the original. Nothing else of the emitter changes.
  • Scheduled jobs by reading the registry. With the schedule group on, the module finds the app's SchedulerRegistry and reads it per call. Nothing is wrapped, started or stopped.
  • Nothing leaves the process. The server runs inside your app; the only network is the agent calling your app.

Security

The endpoint shows the inside of your app. By default it is off in production and only accepts loopback. If you turn it on elsewhere, put a guard in front of it:

NestMcpModule.forRoot({ enabled: true, localOnly: false, guards: [MyAuthGuard] });

Opening it to the network without guards works, and logs a warning at every startup.

Limitations

  • Verified with Express. Fastify is not supported in v1.
  • graph_* and routes_* need snapshot: true in NestFactory.create; without it the module warns at startup and those tools return snapshot_disabled with the exact option to set.
  • The log capture sees what goes through Nest's Logger. A logger that writes on its own is not captured: with nestjs-pino, what your code logs through Logger is in logs_tail, and the request lines pino-http writes are not.
  • The tee is installed during app.init() (in onModuleInit). Lines written before that, including the ones bufferLogs: true holds until useLogger flushes them, and the onModuleInit hooks of modules Nest initialises before the inspector's, are not in the buffer. A useLogger after the app started replaces the tee, and capture stops.
  • With Nest's default logger, every Logger prints through one shared ConsoleLogger; a per-instance new Logger(context, { timestamp: true }) loses its elapsed-time suffix.
  • Request tracing needs the app's routes to be built after the module is configured, which is the normal NestFactory.create → listen (or init) bootstrap. Global enhancers added after app.init() are not traced (Nest does not run them on existing routes either). Enhancer instances that are frozen are left unwrapped. Nest's built-in exception handler is not a step: an exception no filter of yours caught shows in the step that threw and in the status.
  • Requests that do not go through a Nest middleware (outside the global prefix, or served before Nest's router) are not traced.
  • Emissions are recorded from the moment the module is configured (before any onModuleInit); an emitter the app creates on its own, outside EventEmitterModule, is not observed. Listeners added by hand with emitter.on(...) count in listeners but are not in events_listeners, which reads @OnEvent metadata.
  • schedule_jobs lists what SchedulerRegistry holds: a decorated job ScheduleModule did not register (a request-scoped provider, a kind switched off in forRoot) is not listed. Periods and pending state of intervals and timeouts are read from Node's timer objects.
  • Clients that only speak stdio cannot connect: there is no stdio bridge in v1.
  • app_info reports the Nest version the package resolves and the app name and version from the package.json of the working directory; set serverInfo if the app starts from elsewhere.

Contributing

The package is its own Yarn 4 project (packageManager pins the version; corepack enable once).

yarn install --immutable
yarn format-check && yarn lint && yarn type-check
yarn build && yarn test   # the suite imports dist/index.cjs: build first

The devDependencies run the suite on Nest 11. To run it on Nest 10, install the four Nest packages at once (so the tree holds one major), run the suite, then throw the change away:

yarn add -D @nestjs/common@^10 @nestjs/core@^10 @nestjs/platform-express@^10 @nestjs/testing@^10
yarn build && yarn test
git checkout package.json yarn.lock && yarn install

CI (.github/workflows/ci.yml) runs format, lint, type-check, build and the suite on every push to main and every pull request, on Nest 10 and 11, each on Node 20 and 22. Branch protection on main requires the four ci checks to pass; a maintainer sets it in the repository settings, it is not part of this repo.

Releases are cut by a maintainer, never by the workflow: add a ## x.y.z section at the top of CHANGELOG.md, run npm version patch|minor|major (commit and vx.y.z tag), then git push --follow-tags. The tag starts .github/workflows/release.yml: the full CI matrix, then npm publish --provenance --access public (with the NPM_TOKEN repository secret) and a GitHub release whose notes are that CHANGELOG.md section. A manual run of the workflow, or a tag ending in -test, does all of it with npm publish --dry-run and creates no release.

License

MIT

SIMILAR PLUGINS