@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 unlessincludeInternal: true. - Modules that share a class name (two dynamic instances of one module, for example) are listed as
Name#1,Name#2in the order Nest registered them; use those names as input. dependsOnanddependentscount constructor and property injection. Enhancers attached with@UseGuardsand friends are not dependencies; theroutestools report them.- An unknown name answers
not_foundwith up to five similarcandidates; a token declared in several modules answersambiguouswith each{ token, module }, andmodulepicks 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 |
pathis what the app serves, as Nest computed it: global prefix (unless the route is in itsexcludelist) and the URI version.rawPathis the controller and method paths alone, the one to use with header or media-type versioning.route_gettakes either; a raw path shared by several versions answersambiguouswith the full paths.versionis the handler's version, or the controller's, ordefaultVersion;VERSION_NEUTRALis'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 globalapp.useGlobalGuards(...)and friends, andAPP_GUARD/APP_INTERCEPTOR/APP_PIPE/APP_FILTERproviders, in the order Nest registered themcontroller@UseGuards,@UseInterceptors,@UsePipes,@UseFilterson the controller classmethodThe same decorators on the handler; then parameter pipes, which carry param('id'for@Param('id', ParseIntPipe),#nfor 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
@Catchmatches handles the exception.middlewaresare the classes whoseforRoutespath and method match the route's raw path. Plain paths match exactly; wildcards match by the prefix before them, andexclude(...)is not read, so the list is approximate (route_getsays so inmiddlewaresMatch).
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.minLevelkeeps that level and the more severe ones. contextis the trailing string argument, asConsoleLoggerreads it: the class name for anew Logger(MyService.name). Inerror, the multi-line string before it is thestack. Non-string messages are stored as JSON, capped at 2000 characters.seqgrows by one per entry and never repeats. WithoutsinceSeqthe answer is the newestlimitentries; with it, the oldestlimitafter it, so paging never skips or repeats one.- A
forFeatureinstance keeps only entries whose context is the name of a class in its scope (controllers, providers, injectables) and says so inscopeFilter. 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. Withlogger: falsenothing 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-idfrom a proxy or request logger), setrequests.idHeader: 'x-request-id'. level(global,controller,method) comes from the route's enhancer chain, so it needssnapshot: true, likemodule. Without it the steps are there and those fields are not.- A
forFeatureinstance 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.bufferSizetraces per app, shared by its instances; the first instance with therequestsgroup on decidesrequests.*. - Enhancers in
REQUESTorTRANSIENTscope have no long-lived instance to observe; a trace lists the ones on its route innotTraced.
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
@OnEventmetadata of every provider and controller, request-scoped ones included. Withevent, a listener matches when its pattern contains the text or when the emitter itself would call it for that event: withwildcard: true,events_listeners { "event": "cat.adopted" }returns thecat.*listeners too. - Emissions are recorded by wrapping
emitandemitAsyncon the app'sEventEmitter2: the event name, the method, the time andlistenerCount(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.eventfilters by substring or, as a pattern, through the emitter (cat.*). - A
forFeatureinstance 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, andnextRunwhile running), intervals (everyMs) and timeouts (delayMs;running: falseonce fired or cleared), the ones the app added withaddCronJob/addInterval/addTimeoutincluded.timeZoneappears when it differs from the process's own. - Class, method and module come from the
@Cron/@Interval/@Timeoutmetadata, 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
forFeatureinstance lists only jobs declared by classes in its scope. A job the app added to the registry by hand has no class, so onlyscope: 'app'(or aforRootinstance) lists it.
How it works
- Stateless Streamable HTTP. Each
POSTgets its own MCP server and transport, answered and closed.GETandDELETEreturn 405 withAllow: 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
guardsyou configure get a realExecutionContext. - Traces by wrapping enhancer instances. With the
requestsgroup on, the module wraps, in place,canActivate,intercept,transformandcatchon 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 anAsyncLocalStoragethat a middleware opens, and returns exactly what the original returned, with the samethisand 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
eventsgroup on and the app'sEventEmitter2in the container,emitandemitAsyncare 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
schedulegroup on, the module finds the app'sSchedulerRegistryand 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_*androutes_*needsnapshot: trueinNestFactory.create; without it the module warns at startup and those tools returnsnapshot_disabledwith 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: withnestjs-pino, what your code logs throughLoggeris inlogs_tail, and the request linespino-httpwrites are not. - The tee is installed during
app.init()(inonModuleInit). Lines written before that, including the onesbufferLogs: trueholds untiluseLoggerflushes them, and theonModuleInithooks of modules Nest initialises before the inspector's, are not in the buffer. AuseLoggerafter the app started replaces the tee, and capture stops. - With Nest's default logger, every
Loggerprints through one sharedConsoleLogger; a per-instancenew 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(orinit) bootstrap. Global enhancers added afterapp.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, outsideEventEmitterModule, is not observed. Listeners added by hand withemitter.on(...)count inlistenersbut are not inevents_listeners, which reads@OnEventmetadata. schedule_jobslists whatSchedulerRegistryholds: a decorated jobScheduleModuledid not register (a request-scoped provider, a kind switched off inforRoot) 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_inforeports the Nest version the package resolves and the app name and version from thepackage.jsonof the working directory; setserverInfoif 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