Agent tooling

Edit docs/guide/23-agent-tooling.md on GitHub

An agent working in a dunx application spends most of its budget rediscovering things the framework already knows: which routes exist, what validates a body, which module bound a provider, why boot fails. @dunx/mcp answers those over the Model Context Protocol so the agent can ask instead of grepping.

You can run it before you have an app. Three tools need no app: one serves this guide, one the smallest working app, and one the feature catalogue.

SH
bunx @dunx/mcp                        # the guide, the starter, the catalogue
bunx @dunx/mcp ./src/app.module.ts    # those, plus the readers for your app

Wiring it into a client

JSON
{
  "mcpServers": {
    "dunx": {
      "command": "bunx",
      "args": ["@dunx/mcp", "./src/app.module.ts"]
    }
  }
}

The entry is optional. Without one the server starts with the three tools that need no app, which is the state a project has before dunx is installed in it.

Once you have an app, point it at the file that declares your root module. The export needs no particular name: @Module marks the class, so a module exported only by name is still found. bunx @dunx/create-app scaffolds a module exported this way.

default and root win if present. --export=<name> settles a file that declares several. The path is resolved with Bun.resolveSync, so anything import accepts works: a relative path with or without ./, an absolute one, an extensionless specifier, or a package name.

The tools

Three need no app:

ToolAnswers
dunx_startThe runtime, the two ways to get an app, and the rules that are boot errors
dunx_guideThe written guide: the index, one chapter in full, or a search across every chapter
dunx_scaffoldEvery feature bunx @dunx/create-app generates, and the source of the smallest app

Six read the app, and are served once an entry is given:

ToolAnswers
dunx_overviewCounts, plus every constructor dependency whose type was erased. The cheapest first call
dunx_routesEvery route: controller, handler, module, guards, @Public()/@Roles(), status, and which inputs it validates
dunx_providersEvery registration - controllers, gateways, useClass, useValue, useFactory - with its dependencies
dunx_gatewaysEvery websocket gateway, its path, and the handler each @OnMessage event lands on
dunx_modulesThe module graph in traversal order, and what each module contributes
dunx_openapiThe OpenAPI 3.1 document, with the real request and response JSON Schemas

Every tool takes optional filters and nothing else, so a caller that knows nothing still gets a useful first answer:

JSON
{ "name": "dunx_routes", "arguments": { "method": "POST", "path": "/users" } }
{ "name": "dunx_providers", "arguments": { "unresolvedOnly": true } }

The filters exist because a large app's full route table is a lot of tokens to hand a model that asked about one path. Omitting them means everything.

Start with dunx_start, then the overview

dunx_start is the call worth making before writing any dunx. It costs about 4 KB and carries the runtime, the scaffold command, the two install commands and the bunfig.toml that add dunx to a project that already exists, an index of the guide, and the rules that fail at boot rather than at review:

JSON
{
  "rule": "Constructor injection needs the preload.",
  "detail": "Add `preload = [\"@dunx/transform/preload\"]` to bunfig.toml, and again under `[test]`."
}

dunx_guide then answers the chapter-level questions. With no arguments it returns the index; search returns matching lines with the chapter and line number of each, capped at five per chapter so a common word still reaches the chapter that answers it, and reports how many it left out; topic returns one chapter in full.

search matches literal text, so a question written as a sentence matches no line anywhere. Every result also carries suggested, the chapters the query's own words point at, scored highest on a title and lowest on a body mention:

JSON
{
  "query": "how do I validate a request body",
  "hits": [],
  "omitted": 0,
  "suggested": ["06-validation", "08-middleware-and-guards", "05-controllers"]
}

dunx_overview is the call worth making first once there is an app. It says how big the app is and whether it would boot, without returning the graph:

JSON
{
  "modules": 23,
  "controllers": 12,
  "gateways": 1,
  "providers": 53,
  "routes": 40,
  "publicRoutes": 8,
  "guardedRoutes": 7,
  "unresolvedDependencies": []
}

unresolvedDependencies lists every constructor parameter the container cannot inject. Each one would fail boot. A parameter lands here when its type is erased at runtime: an interface, a primitive, a union, or a type-only import. A type-only import also gets a typeOnly field, because the fix is one line:

JSON
{
  "provider": "ReportsService",
  "module": "ReportsModule",
  "unresolved": "private readonly config: AppConfig",
  "typeOnly": "AppConfig"
}

Drop the type from that import and it resolves.

Which inputs, or which schemas

dunx_routes reports that a route validates its body, and by which Standard Schema vendor. It does not report the schema:

JSON
{
  "method": "POST",
  "path": "/api/users",
  "controller": "UsersController",
  "handler": "create",
  "module": "UsersModule",
  "public": false,
  "roles": ["admin"],
  "guards": ["SessionGuard"],
  "hidden": false,
  "validates": { "body": "zod" },
  "status": 201,
  "responses": [201, 422]
}

Turning a schema into JSON Schema is zod-specific work; @dunx/openapi already does it properly, so dunx_openapi is where it lives. That split keeps the other eight tools working in an app with no OpenAPI setup at all. @dunx/openapi is an optional peer, loaded only when dunx_openapi is called.

A wrong argument is an error, not a different answer

Every tool that takes arguments declares additionalProperties: false, and the server holds callers to it. A key the tool never declared, or a declared key carrying the wrong type, comes back as a tool error naming what was sent and what the tool accepts:

Unknown argument: chapter. This tool takes topic, search. Call it again with one of those.

dunx_guide takes topic, but the guide calls what it returns a chapter, so agents often send { chapter: '06-validation' }.

If the server ignored that key, the tool would see no arguments and return the whole index, seventeen kilobytes, with no sign that anything went wrong.

A tool that takes no arguments ignores unknown keys, since they cannot change its answer.

Results are structured, which is what makes them cheap

Every tool returns an object, and the server sends it in two forms: a text block, and structuredContent (added to MCP in 2025-06-18). A client that supports structuredContent reads the object directly.

The structured form costs the model fewer bytes. One dunx_gateways call against examples/full, measured through Claude Code:

ResultBytes the model reads
structuredContent and text598
Text alone1,354

The text block is indented. When both are present, the client uses structuredContent and sends it to the model without indentation.

Resources

The guide chapters are served as MCP resources as well, at dunx://guide/<slug>, for a client that attaches documents rather than calling tools. A chapter link inside one points at the same scheme; a link to anything else in the repository becomes absolute.

Starting a project with an agent

With the server wired up, an agent already has all of this: dunx_start for the rules, dunx_scaffold for the features and starter files, and dunx_guide for the written guide. For an agent without it, two files are served over HTTP:

URLHolds
https://dunx.win/setup.mdInstall, wire and verify an app, plus the rules dunx fails at boot over
https://dunx.win/llms.txtEvery dunx document, linked as raw markdown

Hand the first one to an agent as an instruction: "set up my project using https://dunx.win/setup.md". It is the same content the repository's guards check, copied to that URL by each build, so it states the version being served.

bunx @dunx/create-app my-api --yes writes an AGENTS.md and a CLAUDE.md into the app: its layout, its commands, the features it carries and the services those want running.

An agent driving a terminal should pass --yes, since a bare run opens a feature list and waits for an arrow key. To pick features from a script, call scaffold({ target, features }) from @dunx/create-app. With no terminal at all it writes the minimal template rather than blocking.

It reads the app. It never boots it.

AppFactory.create() instantiates every declared binding and awaits every async factory before it returns. Booting an app to answer "what routes exist" would open database connections, start queue workers, bind sockets and run every onInit. An agent asking a question about the code would end up running the code against whatever environment happened to be configured.

Reading the code has none of those effects. discoverRoutes and discoverGateway walk a prototype chain, and Object.create(Controller.prototype) gives that chain without running a constructor: instance.constructor still resolves to the class and every method is reachable.

The container graph comes from the same functions the container reads it with: collectModules, readControllers, readDeps and describeToken.

What that rules out is runtime state. The value of a config field, or whether the database is reachable, is not answerable here. If one of those is ever genuinely needed it belongs in a separately named tool whose description says it boots the app, so the cost is visible at the call site rather than hidden inside every answer.

What to ask it

The questions it answers better than a search:

  • "What is the unauthenticated surface?" dunx_routes with publicOnly. It reads @Public() from both class-level and method-level metadata, which a grep cannot combine.
  • "Why does boot fail?" dunx_providers with unresolvedOnly, giving the registrations that would throw, with the parameter named.
  • "Who binds this token?" dunx_providers with token, which reports the module and the binding kind - a useFactory and a bare class look nothing alike in source and identical here.
  • "Which event does this websocket message land on?" dunx_gateways, since @OnMessage('say') is a marker on a method and nothing in the path tells you.

And the one it cannot answer: "is Redis up?" That needs the app running, and this never runs it.