Back to Browse

Nestjs MCP Server

Developer ToolsUse Caution3.5MCP RegistryLocal
Free

Server data from the Official MCP Registry

Runtime inspector embedded in a NestJS app: graph, routes, logs, request traces, jobs.

About

Runtime inspector embedded in a NestJS app: graph, routes, logs, request traces, jobs.

Security Report

3.5
Use Caution3.5High Risk

This is a runtime NestJS inspector that opens an HTTP MCP listener on 127.0.0.1:7777 by default and exposes module graph, routes, logs, request traces, and optionally local variable values at throw sites. No authentication is required by default, since the authorize hook is optional, and debug locals can expose secrets to any connected client, which lowers the score into the fair range. Nothing in the shown code indicates exfiltration, backdoors, or shell execution, and the loopback default limits exposure. Supply chain analysis found 8 known vulnerabilities in dependencies (0 critical, 3 high severity). Package verification found 1 issue.

10 files analyzed · 14 issues found

Security scores are indicators to help you make informed decisions, not guarantees. Always review permissions before connecting any MCP server.

Permissions Required

This plugin requests these system permissions. Most are normal for its category.

HTTP Network Access

Connects to external APIs or services over the internet.

env_vars

Check that this permission is expected for this type of plugin.

system_info

Check that this permission is expected for this type of plugin.

How to Install

Add this to your MCP configuration file:

{
  "mcpServers": {
    "io-github-jsirp-dev-nestjs-mcp": {
      "args": [
        "-y",
        "@jsirp/nestjs-mcp"
      ],
      "command": "npx"
    }
  }
}

Documentation

View on GitHub

From the project's GitHub README.

@jsirp/nestjs-mcp

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

Add one module and your running app opens an MCP endpoint, on a port of its own, 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 http://127.0.0.1:7777/.

Status: released (changelog). Every tool group in Tools works on Nest 10 and 11.

Contents

Install

npm install @jsirp/nestjs-mcp

Install it as a regular dependency, not a devDependency. Your AppModule imports it, so a production install that leaves devDependencies out (npm ci --omit=dev, yarn workspaces focus --production, the last stage of most Docker images) would make the app fail at startup with Cannot find module '@jsirp/nestjs-mcp', before any option is read. In production it costs nothing but disk: enabled defaults to false there, and then the module opens no listener, copies no logs and wraps nothing.

If you want it out of production builds altogether, keep it in devDependencies and import it only where it is installed, so the production bundle never resolves it:

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

const inspector: DynamicModule[] =
  process.env.NODE_ENV === 'production'
    ? []
    : // eslint-disable-next-line @typescript-eslint/no-require-imports
      [require('@jsirp/nestjs-mcp').NestMcpModule.forRoot()];

@Module({ imports: [...inspector] })
export class AppModule {}

In an ES module app, use await import('@jsirp/nestjs-mcp') behind the same condition.

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.

The supported way to consume the package is the published npm package. Listing a checkout of this repository (a clone or a git submodule) as a workspace of a host monorepo is not supported: the repo keeps its own yarn.lock, which its CI and standalone development need, so Yarn treats that folder as a separate project and scripts run inside it fail until it gets its own install; and that install, or any workspace install, brings in the package's devDependencies, a second copy of Nest that the host's dependency injection does not recognise (Nest can't resolve dependencies of the NestMcpModule). The published tarball holds only dist/, this README, the changelog and the license, and npm never installs a package's devDependencies, so the app's own Nest is the only one. To try unreleased changes in an app, build here and install the result of npm pack in the app.

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 opens the inspector's own HTTP listener, next to your app's server and independent of it, and logs the endpoint, with context NestMcp:

[NestMcp] Inspector listening at http://127.0.0.1:7777/

Nothing of your app runs in front of it: no global prefix, no versioning, no middlewares, no global guards, interceptors, pipes or filters. Your auth guard never sees an inspector call, your request logger never logs one, your OpenAPI document does not list it, and the inspector's calls never show up in its own request traces. The listener closes with the app (app.close()).

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, authorize and switches, all on the same listener: forRoot answers at /, each forFeature at its path (http://127.0.0.1:7777/cats below).

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, and takes in every global module (@Global(), isGlobal: true) and what it imports: the module can inject what they export without declaring them.

@Module({
  imports: [NestMcpModule.forFeature({ path: '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',
  }),
});

path is given statically, next to useFactory, not returned by it, so a forFeatureAsync without it fails at once. Everything else, listen and authorize included, may come from the factory. enabled may go either way: given statically and false, nothing is registered; returned by the factory as false, the instance does not join the listener (with no other instance, no listener is opened) and nothing else is installed.

Point your agent at it

Claude Code:

claude mcp add --transport http my-nest-app http://127.0.0.1:7777/

Or any client that reads an mcpServers file:

{
  "mcpServers": {
    "my-nest-app": { "type": "http", "url": "http://127.0.0.1:7777/" }
  }
}

The URL does not depend on your app's port, prefix or versioning. Two apps on one machine need two ports: give each its own listen.port.

Options

All optional, except path in forFeature.

OptionDefaultWhat it does
enabledprocess.env.NODE_ENV !== 'production'The switch. When false the instance registers nothing and opens nothing. It is a default, not a rule: set it to true wherever you want the inspector and protect it with authorize.
listen{ host: '127.0.0.1', port: 7777 }The inspector's own HTTP listener, shared by every instance of the app: set it on one instance, or the same on all (two different ones fail at startup). port: 0 picks a free port; the startup log and app_info.mcpUrl say which. A port in use fails at startup with an error naming listen.port.
path'/' for forRoot; required for forFeatureRoute of the instance on the listener. No prefix or versioning applies. Two instances on one path fail at startup.
authorizenone(req) => boolean | Promise<boolean>, called with the raw Node IncomingMessage before each request to this instance is answered. false, or a throw (logged), answers 403.
toolsall trueOne switch per tool group: { graph, routes, logs, requests, events, schedule, runs }. A group switched off registers no tools and installs nothing.
logs.bufferSize2000Size of the ring buffer logs_tail reads.
logs.sinknoneWhat createLogSink() returned, added to your own logger's outputs: that logger's lines enter the buffer too. See Reading your own logger.
logs.capture'both'What fills the buffer: 'nest' (Nest's Logger, through the tee), 'sink' (only logs.sink; no tee is installed) or 'both'. 'sink' requires logs.sink.
logs.normalizenone(record) => entry | undefined: maps the sink's records of a format the built-in mapping does not read.
requests.bufferSize500How 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, except what a handler holds in a variable when it throws, with debug.
runs.bufferSize500How many job and listener runs are kept. One buffer per app: the first instance that installs it sets the size.
debugfalsetrue or { stackFrames?, locals? }: each error a trace step or a run records carries its stack, as frames mapped to your source, and the locals of the frames that threw. Off by default, whatever enabled is. Process-wide: the first instance decides, so give every instance the same value. See Debug: stacks of failing steps.
debug.stackFrames30Frames kept per error, from the throw outwards; at most 30.
debug.localstrue (with debug)The values of local variables at the throw, read by a worker thread's inspector session (ADR-0005). false keeps the stacks without them, and no worker starts. An object sets its bounds: { frames: 5, depth: 2, properties: 20, stringLength: 200, bytes: 16384, perMinute: 10 } (the defaults). See Locals at the throw.
serverInfoyour app's package.json{ name, version } the server reports in the MCP initialize handshake.

authorize is a plain function, not a Nest guard: there is no ExecutionContext. Close over what it needs, or build it in a forRootAsync factory from injected services:

NestMcpModule.forRootAsync({
  imports: [ConfigModule],
  inject: [ConfigService],
  useFactory: (config: ConfigService) => ({
    listen: { host: '0.0.0.0', port: 7777 },
    authorize: (req) => req.headers['x-inspector-key'] === config.get('INSPECTOR_KEY'),
  }),
});

Tools

GroupToolsStatus
appapp_info: Nest and Node versions, uptime, global prefix, versioning, endpoint path and URL (mcpPath, mcpUrl), instance scope, whether snapshot is on, which groups are live, debug (whether it is on, how stacks are mapped and the state of the locals worker), and the instance's tool switches, requests.headers policy and debug✅
graphgraph_modules, graph_module, graph_providers, graph_provider, graph_cycles✅
routesroutes_list, route_get✅
logslogs_tail, logs_levels✅
requestsrequests_list, request_trace✅
eventsevents_listeners, events_recent (only with @nestjs/event-emitter and its module)✅
scheduleschedule_jobs (only with @nestjs/schedule and its module)✅
runsruns_list, run_get (only with @nestjs/schedule or @nestjs/event-emitter 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.

ToolInputAnswers
graph_modulesincludeInternal?Each module: global, dynamic, provider and controller counts, imports (declared)
graph_modulename, includeInternal?imports (declared), globals (linked by Nest), exports, providers (token, scope, exported), controllers, importedBy (modules that declare it)
graph_providersfilter?, module?, scope?, includeInternal?Providers and injectables; enhancers carry subtype (guard, pipe, …) and, when registered globally, global: true
graph_providertoken, module?, includeInternal?Module, injection scope, dependsOn, dependents, initTimeMs
graph_cycles—cycles: module import cycles (declared imports only) 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.
  • imports is what a module declares. Nest also links every module to every global module, and those links go to globals (with InternalCoreModule under includeInternal: true); a global module the module declares stays in imports. graph_cycles leaves the global links out: they would make every global module a cycle with whatever imports it.
  • 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 is not one of them: it is not a controller. 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.

ToolInputAnswers
routes_listfilter?, method?, controller?, module?One entry per route: method, path, Controller.handler, and how many guards, interceptors, pipes and filters run on it
route_getmethod, pathThe 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:

    LevelComes from
    globalapp.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
    methodThe 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 middlewares 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).

  • A function middleware (consumer.apply((req, res, next) => next()), or nestjs-pino's two) reads <functional middleware from LoggerModule>, after the module whose configure applied it. Nest wraps the function in a class with a random name and keeps the function only in a closure, so its own name cannot be read. Each one is listed once.

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. If your services log through a logger of their own (PinoLogger, winston…), hand its output to the same buffer with a log sink. The tee and the buffer are installed once per process and shared by every instance: the first instance that installs them decides logs.bufferSize, and an instance with logs.capture other than 'sink' installs the tee, so give every instance with the logs group on the same logs. Switching the logs group off in every instance installs nothing.

ToolInputAnswers
logs_taillimit? (≤ 500, default 100), level?, minLevel?, context?, contains?, source? ('nest' or 'sink'), requestId?, runId?, sinceSeq?, scope? ('instance' or 'app')entries, oldest first, each { seq, source, timestamp, level, context, message, stack?, requestId?, runId? }; 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 Nest's logger has on (null if it has no isLevelEnabled, or with capture: 'sink'), capture, the buffer (size, length, lastSeq) and the sink (connected, received, errors, lastError?; null without one)
  • Levels are Nest's: verbose, debug, log, warn, error, fatal. minLevel keeps that level and the more severe ones.
  • source says where an entry came from: 'nest' for Nest's Logger, 'sink' for your logger through the sink.
  • 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.
  • requestId is the id of the request being traced when the entry was captured (the correlation header's value, or the generated id requests_list shows); logs_tail({ requestId }) reads one request's lines and request_trace lists them. It is read from the request's async context at the moment the tee or the sink receives the line, so an entry written outside a request has none, and so has a sink line delivered outside that context: one your logger hands over later (an asynchronous transport, a worker-thread destination such as pino.transport) or one the sink held before it was connected. A line logged by a middleware that runs after one that lost the async context (and before the first guard) has none either, nor has one written once the response is sent (a res.on('finish') listener, such as pino-http's request completed line) of a request whose context was lost: Node calls it where Nest's router ended the response. It needs the requests group on; without it no entry carries one.
  • runId is, likewise, the id of the job or listener run executing when the entry was captured; logs_tail({ runId }) and run_get read a run's lines. A line a listener writes while it runs inside a request carries both ids. It needs the runs group on.

Reading your own logger

The tee sees only what goes through Nest's Logger. A service that logs through a logger of its own writes past it: with nestjs-pino, this is what reaches logs_tail:

Written throughWithout a sinkWith a sink
Nest's Logger (new Logger(ctx), and the Logger nestjs-pino installs with app.useLogger)yes (source: 'nest')yes ('nest', 'sink' or both, per logs.capture)
PinoLogger, @InjectPinoLogger()noyes (source: 'sink')
The pino-http request linesnoyes (source: 'sink')

createLogSink() returns a log sink: a writable stream your logger writes to like any other output, plus a record(entry) function for loggers that hand over objects. Create it before your logger, add it to the logger's outputs and pass it as logs.sink (from forRoot, or returned by a forRootAsync factory). The sink only reads: your other outputs get exactly what they got before, and it never throws into your logger (a failure is swallowed and counted in logs_levels.sink).

pino, with nestjs-pino. Write to stdout and to the sink with pino.multistream:

import { Module } from '@nestjs/common';
import { createLogSink, NestMcpModule } from '@jsirp/nestjs-mcp';
import { LoggerModule } from 'nestjs-pino';
import pino from 'pino';

const logSink = createLogSink();

@Module({
  imports: [
    LoggerModule.forRoot({
      pinoHttp: [
        { level: 'debug' },
        pino.multistream([
          { stream: process.stdout, level: 'debug' },
          { stream: logSink, level: 'debug' },
        ]),
      ],
    }),
    // app.useLogger(app.get(Logger)) routes Nest's Logger through pino too: read it once, from the sink.
    NestMcpModule.forRoot({ logs: { sink: logSink, capture: 'sink' } }),
  ],
})
export class AppModule {}

The sink must run in your process: add it to multistream (or as a plain stream), not as a pino.transport target, which runs in a worker thread.

winston, with or without nest-winston. A transport that hands each record to record:

import { createLogSink, NestMcpModule } from '@jsirp/nestjs-mcp';
import * as winston from 'winston';
import Transport from 'winston-transport';

const logSink = createLogSink();

class InspectorTransport extends Transport {
  override log(info: unknown, next: () => void): void {
    logSink.record(info);
    next();
  }
}

export const logger = winston.createLogger({
  transports: [new winston.transports.Console(), new InspectorTransport()],
});

// In AppModule. With WinstonModule as the app logger, Nest's Logger goes through winston too:
NestMcpModule.forRoot({ logs: { sink: logSink, capture: 'sink' } });

What the sink reads out of the box:

  • pino and bunyan JSON lines: the numeric level (10 → verbose, 20 → debug, 30 → log, 40 → warn, 50 → error, 60 → fatal), msg, time, context (what nestjs-pino and nest-winston set) or else name, and the stack of err.
  • Objects passed to record, such as winston's: a level name (info → log, warning → warn, trace and silly → verbose…, colour codes ignored), message, timestamp, context.
  • Plain text: each line is a log entry with the line as its message and no context.

One write may carry several lines; each is one entry. Anything else maps with logs.normalize, which receives the parsed JSON line, the plain-text line as a string, or what you passed to record, and returns { level?, message?, context?, timestamp?, stack? }, or undefined to use the built-in mapping:

NestMcpModule.forRoot({
  logs: {
    sink: logSink,
    normalize: (record) =>
      typeof record === 'object' && record !== null && 'severity' in record
        ? { level: String(record.severity), message: (record as { text?: string }).text }
        : undefined,
  },
});

Which logs.capture to pick:

  • 'both' (the default): your services log through their own logger and Nest's Logger prints elsewhere (Nest's ConsoleLogger, for example). Nothing is duplicated.
  • 'sink': Nest's Logger also writes through the logger the sink is attached to (nestjs-pino's or nest-winston's logger set with app.useLogger). With 'both' each of those lines would be there twice, once per source. No tee is installed, so logs_levels.levels is null.
  • 'nest': the sink is ignored.

What arrives before the app starts is held by the sink (up to 1000 entries) and delivered when the inspector connects it; when the app closes, the sink is disconnected and holds again. With the inspector disabled the sink is never connected, so it only keeps those last 1000 lines; leave it out of your logger's outputs where you never enable the inspector.

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" }
ToolInputAnswers
requests_listlimit? (≤ 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_traceid, scope?The trace: method, url, route, statusCode, durationMs, controller, handler, module, headers, its steps in order and, with the logs group on, its logs; 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:

kindRecordedoutcome
guardWhen canActivate returns (or its Promise or Observable settles)allowed, denied (returned false, Nest answers 403) or threw
interceptorTwice: 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
pipePer argument, with param as route_get shows it ('id', or #n for an argument without a name; absent when two same-typed unnamed arguments cannot be told apart) and paramType (param, query, body, custom); never the valueok or threw
handlerNamed Controller.handler by the method's property key, as routes_list names it (also when a decorator replaced the method with a wrapper), placed where it ran: after the pipes, before the interceptors' exits. Absent when a pipe or an interceptor stopped the request firstok or threw
filterWhen an exception filter of yours handles an exception, with that exception in errorcaught, 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 calls are never traced: they arrive on its listener, not through Nest. 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.
  • logs (with the logs group on) are the log entries written during this request that the logs buffer still holds, oldest first, as logs_tail answers them: Nest's own exceptions handler logs an unhandled error inside the request, so a 500 lists that line too. At most 200, the newest, with logsTruncated: true when more matched; logsDropped: true when the buffer already discarded some of them, so the list is not taken for the whole. Lines of an earlier request that reused the same id are not listed. They are listed whatever the instance's scope, since the trace itself already is in scope. Lines are matched as requestId says, with its limits.

Debug: stacks of failing steps

With debug: true, every error in a trace (on the step that threw, and on the filter step with the exception it caught) and the error of a run carry stack: the exception's frames, from the throw outwards, at most debug.stackFrames (30).

"error": {
  "name": "NotFoundException",
  "message": "Cat 999 not found",
  "stack": [
    { "function": "CatsController.findOne", "file": "/app/src/cats/cats.controller.ts", "line": 31, "column": 21, "mapped": true },
    { "function": "<anonymous>", "file": "/app/node_modules/@nestjs/core/router/router-execution-context.js", "line": 38, "column": 29, "mapped": false, "external": true }
  ]
}
  • file, line and column are the original source's when a source map is found (mapped: true): Node's own, when the process runs with --enable-source-maps or process.setSourceMapsEnabled(true) (then the stack is already mapped and read as it is), or else the one the compiled file points to with its sourceMappingURL comment (a .js.map next to it, as tsc and the Nest CLI write with sourceMap: true, or an inline map). Without one, the frame keeps the compiled position with mapped: false. A position in a file that is not JavaScript (.ts) is mapped: true: something mapped it already, or the process runs it as it is. The inspector never turns source maps on in the process; app_info.debug.sourceMaps says whether Node applies them (process) and how many compiled files' own maps the inspector has read (files).
  • function is the name V8 writes (CatsController.find, new Thing, async Promise.all), <anonymous> without one; an anonymous async frame (at async /app/dist/main.js:3:7) is async <anonymous>, with the path alone in file, mapped like any other.
  • external: true flags frames in node_modules, node: internals and frames without a file (<anonymous>, native), so an agent can skip to your code. They are kept, unmapped.
  • stack holds positions only: function name, file, line, column. No source text (a map's sourcesContent is dropped when it is read). The values in scope are in locals, below.
  • Off, nothing changes: errors are { name, message } as before, no stack is read and no worker or inspector session exists.
  • The stack is read when the error is recorded, from error.stack, as any logger would read it; a compiled file's map is read once and kept. How many frames there are is V8's Error.stackTraceLimit (10 unless your process raises it); the inspector does not change it.
Locals at the throw

With debug on (and debug.locals not false), the error of the step that threw, and of a failing run, also carries locals: the variables in scope in the top frames at the moment of the throw, aligned with stack (locals[0] is the frame stack[0] names when the error was created where it was thrown, as usual).

"error": {
  "name": "Error",
  "message": "failed with 42",
  "stack": [{ "function": "OrdersController.total", "file": "/app/src/orders.controller.ts", "line": 18, "column": 11, "mapped": true }, "…"],
  "locals": [
    {
      "function": "OrdersController.total",
      "file": "/app/src/orders.controller.ts",
      "line": 18,
      "scopes": {
        "local": { "total": 42, "order": { "[[Class]]": "Order", "id": 7, "lines": "[Array(2)]" }, "note": "nnnn…[500 chars]" },
        "closure": { "TAX": 0.21 }
      }
    },
    { "function": "<anonymous>", "file": "/app/node_modules/@nestjs/core/router/router-execution-context.js", "line": 38, "external": true }
  ]
}
  • What is read: the local scopes (block and catch scopes merged in) and closure scopes of the top debug.locals.frames frames; never module, script or global scopes. Frames outside your code (node_modules, node:) are listed with external: true and not read.
  • How values look: strings, numbers, booleans and null as they are; plain objects and arrays depth levels deep, then [Object], [Array(3)] or the class name; class instances with "[[Class]]"; undefined, NaN, 42n, symbols as bracketed strings; functions by name ([Function: find]), never their source; a Map, Set, Date, Promise or proxy by its description, never entered. Only own enumerable data properties are read: a getter shows as [Getter] and is never called. Nothing in your app is evaluated.
  • Bounds: properties per object (and variables per scope, and items per array), with "…": "[N more]"; strings cut at stringLength with their length; at most bytes of JSON per error, after which reading stops and the error says localsTruncated: true; at most perMinute captures, after which the error says localsSkipped: 'rate' and nothing is read.
  • Which exceptions: those your own code throws while a traced request or a run is in flight. Exceptions thrown with nothing in flight (startup, a timer of yours) are never read, nor those thrown inside node_modules (a pipe's BadRequestException), nor your code rethrowing from a catch block the error it caught (the first throw keeps its locals). Exceptions your code throws and catches itself during a request are read too, and count against the rate.
  • One step, not all of them: in request_trace the locals go on the first step that recorded the exception, the one that threw; the interceptors it passed through and the filter that caught it carry its stack (and localsSkipped, when there is one) without them.
  • When none are given: two exceptions with the same stack text pending at once (the same code failing in two concurrent requests) are not told apart: the error says localsSkipped: 'ambiguous' rather than show another request's values. An exception whose stack your code replaced after the throw, or that is not an Error, gets none.
  • What it costs: the main thread stops at every exception while the worker decides; measured on an M-series Mac, Node 20 and 22, about 0.13 ms per exception it does not read and about 1.8 ms per exception it reads (the CHANGELOG has the numbers). It is a development tool; turn it off under load.
  • It can expose anything that was in scope: a password a handler compared, the body it parsed, a token in a closure. Keep debug to development, and protect the endpoint as the Security section says.
  • With your own debugger attached: the worker is one more debugger session. Pauses it did not cause (your breakpoints) are left alone, but pauses on exceptions are resumed by it, including ones you asked your debugger for ("pause on caught exceptions"). Set debug.locals: false while you debug by hand.

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.

ToolInputAnswers
events_listenersevent?, scope? ('instance' or 'app')listeners, each { event, className, methodName, module, options?, requestScoped?, lastRun? }; wildcard (whether the emitter resolves wildcards)
events_recentlimit? (≤ 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.

Documentation truncated — see the full README on GitHub.

Reviews

No reviews yet

Be the first to review this server!