# AgentBack — full documentation corpus # Source: https://github.com/ninemindai/agentback · Site: https://agentback.dev # AgentBack documentation A TypeScript dependency-injection framework with HTTP (REST) and MCP servers that plug into the **same container** and validate against the **same Zod schemas**. This is the place to learn the ideas and build with them. > New here? Read the [root README](../README.md) for the one-page tour and the > 30-second code feel, then come back for the depth. ## How the framework thinks Three ideas carry the whole framework. Everything in these docs is an elaboration of one of them: 1. **Everything is a binding in a context.** The `Application` _is_ a DI `Context`. Controllers, MCP tool classes, services, config, and even the servers themselves are just bindings the framework discovers by tag. New capability = new binding; you never edit a central registry. 2. **Schemas live once, on the decorator.** A route's or tool's Zod schema is simultaneously the runtime validator, the `z.infer` TypeScript type, the OpenAPI/MCP contract, and the rendered docs. One artifact, many views. 3. **Servers are components.** REST and MCP are interchangeable, composable plug-ins over the same container — run either, or both from one process. If you internalize those three, the API surface mostly writes itself. ## Learning path Read top-to-bottom the first time; jump around afterwards. ### Blog — _design notes and release stories_ | Entry | What you'll find | | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [Blog home](blog/index.html) | Short-form posts: boundary coherence, hybrid REST + MCP apps, schema-shared clients, agent-actionable errors, tool-surface budgets, and self-describing APIs. | | [Architecture map](blog/diagrams/system-boundary.html) | A standalone dark HTML/SVG diagram of the runtime boundary model, with copy/PNG/PDF export controls. | ### Concepts — _understand the machine_ | Doc | What you'll learn | | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | [Dependency injection](concepts/dependency-injection.md) | `Context`, `Binding`, scopes, `@inject`, providers, tag-based discovery — the foundation everything sits on. | | [Schema-first decorators](concepts/schema-first-decorators.md) | How one Zod schema on a decorator becomes validator + type + OpenAPI + MCP contract; the slot-0 input bundle; runtime + compile-time guarantees. | | [Components, servers & lifecycle](concepts/components-servers-lifecycle.md) | How a `Component` packages bindings, how a `Server` is discovered and started, and the start/stop lifecycle. | ### Guides — _build something_ | Guide | Outcome | | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | [Build a REST API](guides/build-a-rest-api.md) | A Zod-validated REST service with auto-emitted OpenAPI 3.1 and Swagger UI. | | [Build an MCP server](guides/build-an-mcp-server.md) | Tools, resources, and prompts an MCP client (or Claude) can call, with an inspector UI. | | [Build a hybrid app](guides/build-a-hybrid-app.md) | REST + MCP from a single process and a single set of schemas, plus a type-safe HTTP client. | | [Render a widget with MCP Apps](guides/mcp-apps-widgets.md) | An interactive `ui://` widget a host (Claude Desktop) renders inline for a tool's result (SEP-1865). | | [Composition & extensibility](guides/composition-and-extensibility.md) | The modular toolkit: components, middleware, interceptors, extension points, and subclassing the dispatcher. | | [Testing](guides/testing.md) | `createTestApp` and the four client surfaces: typed calls, supertest, in-memory MCP, DI assertions. | | [Secure MCP over HTTP](guides/secure-mcp-over-http.md) | Auth modes (strategies vs OAuth 2.1 resource server), scope-gated tools, DNS-rebinding, rate limits. | | [Deploy to production](guides/deploy-to-production.md) | Containers, validated config, K8s probes, metrics/tracing, graceful shutdown, multi-instance checklist. | ### Reference & design | Doc | Purpose | | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | | [Architecture overview](architecture/overview.md) | The big picture: how a request flows, how servers discover bindings, full package layering. Diagrams included. | | [Metering & payments](architecture/metering-and-payments.md) | Counting every REST/MCP call (`metering`) and gating or billing the paid ones — x402 / MPP / Stripe (`payments`). Diagrams. | | [Boundary coherence (design thesis)](agent-ergonomics.md) | _Why_ the framework is shaped this way — the "one artifact, viewed differently" bet and what it buys AI-led teams. | | [Database story](db-story.md) | The framework's stance on persistence (Drizzle recipe), and why there's no built-in ORM. | Every package under [`packages/`](../packages/) carries its own `README.md` with its exports, a usage snippet, and where it sits in the layering. **Server extensions** (`install*` onto a running REST server): [health/readiness probes](../packages/extension-health/README.md) · [Prometheus metrics](../packages/extension-metrics/README.md) · [rate limiting](../packages/extension-rate-limit/README.md) (in-memory or Redis, `429` + `RateLimit-*` headers). **Metering & payments** (subclass the dispatcher / mount a rail): [usage metering](../packages/metering/README.md) (per-principal `UsageEvent`s → audit-log sinks + quota) · [payments](../packages/payments/README.md) (x402 / MPP / Stripe) — see the [architecture doc](architecture/metering-and-payments.md). ## The shortest possible example ```ts import {z} from 'zod'; import {api, get} from '@agentback/openapi'; import {RestApplication} from '@agentback/rest'; @api({basePath: '/greet'}) class GreetingController { @get('/hello/{name}', { path: z.object({name: z.string().min(1)}), response: z.object({greeting: z.string()}), }) async hello(input: {path: {name: string}}) { return {greeting: `Hello, ${input.path.name}!`}; } } const app = new RestApplication(); app.restController(GreetingController); await app.start(); // GET /greet/hello/world -> {"greeting":"Hello, world!"} // GET /openapi.json -> OpenAPI 3.1.1 derived from the Zod schemas ``` Change `GreetingController` to an `@mcpServer()` with `@tool(...)` methods and the same schemas become an MCP tool surface instead — see [Build an MCP server](guides/build-an-mcp-server.md). ## Runnable examples Each guide maps to a working example you can run: ```bash pnpm install && pnpm build # build first — tests/examples run against dist/ pnpm -F hello-rest start # REST + Swagger UI + Context Explorer pnpm -F hello-mcp test # MCP over stdio, driven by a test client pnpm -F hello-hybrid start # REST + MCP from one process, both UIs pnpm -F hello-client start # the typed client calling hello-rest's schemas ``` | Example | Demonstrates | Guide | | ----------------------- | ------------------------------------------ | ------------------------------------------------------------------------- | | `examples/hello-rest` | REST + auth + health + metrics + explorers | [REST](guides/build-a-rest-api.md) | | `examples/hello-mcp` | MCP tools over stdio | [MCP](guides/build-an-mcp-server.md) | | `examples/hello-hybrid` | REST + MCP in one process | [Hybrid](guides/build-a-hybrid-app.md) | | `examples/hello-client` | Schema-shared typed client | [Hybrid](guides/build-a-hybrid-app.md#a-type-safe-client-with-no-codegen) | | `examples/hello-mcp-apps` | MCP Apps `ui://` widget rendered by a host | [MCP Apps](guides/mcp-apps-widgets.md) | ## Conventions in these docs - Code blocks are real, compiling TypeScript drawn from the packages and examples — not pseudocode. - ESM only: relative imports carry `.js` extensions in actual source; doc snippets omit them for readability where the import is from a package. - "Slot 0 / slot 1+" refers to a handler method's parameter positions — see [Schema-first decorators](concepts/schema-first-decorators.md#the-handler-signature). # Concept: Dependency Injection The DI container is the foundation the entire framework stands on. REST and MCP are just two ways to expose bindings that live in it. Understand this layer and the rest of the framework becomes "bind a class, tag it, a server finds it." > Packages: [`@agentback/context`](../../packages/context) (the container) > and [`@agentback/core`](../../packages/core) (`Application`, which _is_ a > context). The DI semantics match `@loopback/context` / `@loopback/core` > exactly — if you know LoopBack 4 DI, you already know this. ## The three nouns ```mermaid graph TD App["Application (a Context)"] subgraph Registry["binding registry"] B1["'clock' → Clock"] B2["services.Greeter → Greeter"] B3["controllers.Foo → Foo [tag: controller]"] B4["servers.RestServer → RestServer [tag: server]"] end App --- Registry Resolve["app.get('services.Greeter')"] -->|"resolves + injects deps"| B2 B2 -.->|"@inject('clock')"| B1 ``` - **`Context`** — a registry of bindings plus a resolver. Contexts form a hierarchy (an app context, a per-server child, a per-request child); a lookup walks up the chain. - **`Binding`** — maps a **key** (e.g. `services.Greeter`) to a **value source** (a constant, a class, a provider, an alias) with a **scope** and a set of **tags**. - **`@inject`** — declares, on a constructor parameter or property, _which binding_ supplies a value. The container resolves and injects it. The key mental model: **the `Application` is itself a `Context`.** `app.bind(...)`, `app.get(...)`, `app.find(...)` are `Context` methods. Servers, components, your controllers — all bindings in that one container. ## Binding a value ```ts import {Application} from '@agentback/core'; import {BindingScope} from '@agentback/context'; const app = new Application(); // a constant app.bind('config.apiBase').to('https://api.example.com'); // a class (constructed lazily, deps injected) app.bind('services.Clock').toClass(Clock); // a provider (a class with a value() method, for async/computed values) app.bind('services.Token').toProvider(TokenProvider); // an alias (another binding's value, by key) app.bind('services.Now').toAlias('services.Clock'); // a dynamic value (recomputed each resolution unless scoped) app.bind('now').toDynamicValue(() => Date.now()); ``` Resolve with `await app.get(key)` (async — providers may be async) or `app.getSync(key)` when you know the value is synchronous. ## Scopes — how often a value is created Set with `.inScope(...)` or the `@injectable({scope})` decorator. The ones you will actually use: | Scope | Meaning | | --------------------- | ----------------------------------------------------------------------------------------------- | | `TRANSIENT` (default) | A fresh value on every resolution. | | `SINGLETON` | One value for the whole application, cached on the owning context. | | `CONTEXT` | One value per context that resolves it (e.g. per request when resolved from a request context). | ```ts import {injectable, BindingScope} from '@agentback/context'; @injectable({scope: BindingScope.SINGLETON}) class Clock { now() { return new Date().toISOString(); } } app.bind('services.Clock').toClass(Clock); // honors the @injectable scope ``` Other scopes (`APPLICATION`, `SERVER`, `REQUEST`) exist for advanced hierarchical setups; default to `TRANSIENT` for stateless logic and `SINGLETON` for shared state or expensive construction. ## `@inject` — declaring dependencies `@inject` works on constructor parameters and properties. The value is resolved when the owning binding is constructed. ```ts import {inject} from '@agentback/context'; class Greeter { // constructor injection (preferred) constructor(@inject('services.Clock') private clock: Clock) {} greet(name: string) { return `Hello ${name} at ${this.clock.now()}`; } } ``` ### Typed binding keys A plain string key is untyped. `BindingKey.create()` ties a key to a type so `@inject` and `app.get` are type-checked and discoverable: ```ts import {BindingKey} from '@agentback/context'; export const CLOCK = BindingKey.create('services.Clock'); app.bind(CLOCK).toClass(Clock); class Greeter { constructor(@inject(CLOCK) private clock: Clock) {} // Clock inferred } ``` This is the recommended pattern for anything you expose for others to inject — it turns "what's behind this string?" into a go-to-definition. ### `@inject` variants | Variant | Injects | | -------------------------------- | ------------------------------------------------------------------- | | `@inject(key)` | The resolved value. | | `@inject(key, {optional: true})` | The value, or `undefined` if unbound (no throw). | | `@inject.getter(key)` | A `() => Promise` you call later (defers resolution). | | `@inject.setter(key)` | A `(value) => void` to rebind at runtime. | | `@inject.tag(tag)` | An **array** of all values whose binding carries `tag`. | | `@inject.view(filter)` | A live `ContextView` that updates as matching bindings come and go. | | `@inject.context()` | The owning `Context` itself (use sparingly). | | `@config()` | The configuration bound for the current binding (see below). | `@inject.tag` is the workhorse of extensibility — see [Composition & extensibility](../guides/composition-and-extensibility.md). ## Tags and discovery A binding can carry **tags** (name/value pairs). The framework finds bindings by tag instead of by hard-coded references — this is how servers locate your code without you registering it in a router. ```ts app.bind('controllers.Foo').toClass(Foo).tag('controller'); // find everything tagged — what RestServer does at startup const controllers = app.findByTag('controller'); // Binding[] ``` You rarely call `.tag()` by hand. The class decorators put the tag in metadata, and the registration helpers apply it for you: | Helper | Tag applied | Found by | | ----------------------- | -------------------------------------------------------------- | -------------------------- | | `app.controller(C)` | `controller` (+ `extensionFor: MCP_SERVERS` if `@mcpServer()`) | `RestServer` | | `app.restController(C)` | `controller` — a thin alias for `app.controller(C)` | `RestServer` | | `app.service(C)` | `service` (+ `extensionFor: MCP_SERVERS` if `@mcpServer()`) | DI / MCP server | | `app.component(C)` | — (registers the component's bindings) | — | | `app.server(C)` | `server` | `Application` lifecycle | `@mcpServer()` is built on `@injectable`: it tags the class `extensionFor: MCP_SERVERS` (and defaults it to singleton scope); when you `app.service(WeatherTools)`, the framework reads that metadata and tags the binding automatically — see the [MCP guide](../guides/build-an-mcp-server.md#how-discovery-works). **Register tool classes with `app.service(C)`** — a tool is a service. The MCP server discovers it as an `MCP_SERVERS` extension and resolves the instance through its binding, so constructor `@inject` is honored regardless of namespace (`service`, `controller`, or a manual `bind().apply(extensionFor(MCP_SERVERS))`). A dual REST + MCP class (`@api` + `@mcpServer`) needs only **one** registration: `app.restController(C)` (or `app.controller(C)`) tags it `controller` so `RestServer` mounts its routes, and — because the helper honors the class's `@mcpServer` metadata — keeps its `extensionFor: MCP_SERVERS` membership so the MCP server discovers its tools. Don't *also* `app.service(C)` the same class: explicit calls keep separate bindings, which would register the tool twice. ## Configuration Any binding can have a sidecar configuration binding. Inject it with `@config()`: ```ts import {config} from '@agentback/context'; class MailService { constructor(@config() private cfg: {from: string}) {} } app.bind('services.Mail').toClass(MailService); app.configure('services.Mail').to({from: 'noreply@example.com'}); ``` Servers use exactly this mechanism: `app.configure('servers.RestServer').to({port: 3000})`. ## Why this matters for composition Because every capability is a binding discovered by tag: - **Adding a feature never edits central code.** A new controller, tool, auth strategy, or health check is a new binding. Nothing downstream changes. - **Swapping for tests is one line.** `app.bind('services.Clock').to(fakeClock)` overrides the real one; no module-mock machinery. - **Reasoning stays local.** A class declares its dependencies in its constructor. There's no hidden global to trace. These properties are what the [composition guide](../guides/composition-and-extensibility.md) builds on, and why the framework scales to multi-team / plugin / AI-tool surfaces. ## Standalone use You don't need HTTP or MCP to use the container. It's a fine general-purpose DI system for any Node app: ```ts const app = new Application(); app.bind('services.Clock').toClass(Clock); app.service(Greeter); const greeter = await app.get('services.Greeter'); console.log(greeter.greet('world')); ``` ## Next - [Schema-first decorators](schema-first-decorators.md) — how routes and tools are declared on top of this container. - [Components, servers & lifecycle](components-servers-lifecycle.md) — how the servers themselves are bindings. # Concept: Schema-first decorators This is the idea that makes REST and MCP feel like the same framework: you attach a **Zod schema to a decorator**, and that one schema becomes the runtime validator, the TypeScript type of your handler's input, the OpenAPI/MCP contract, and the rendered docs. > Packages: [`@agentback/openapi`](../../packages/openapi) (REST verb > decorators + OpenAPI emission) and [`@agentback/mcp`](../../packages/mcp) > (`@tool`/`@resource`/`@prompt`). Both follow the same shape. ## One artifact, many views ```mermaid graph LR Z["z.object({ name: z.string().min(1) })"] Z --> V["runtime validator
(rejects bad input)"] Z --> T["TS type
z.infer<typeof Schema>"] Z --> O["OpenAPI 3.1 schema
z.toJSONSchema()"] Z --> M["MCP inputSchema /
outputSchema"] Z --> D["Swagger UI / MCP Inspector
rendered docs"] ``` There is no second source of truth. Change the schema and the handler's parameter type changes (TS error if your code disagrees), the OpenAPI document changes, the MCP tool definition changes, and the validation changes — together, in one edit. This is the framework's core bet; the [design thesis](../agent-ergonomics.md) explains why it matters so much for AI-led and large teams. ## REST: schemas on the verb decorator ```ts import {z} from 'zod'; import {api, get, post} from '@agentback/openapi'; const HelloPath = z.object({name: z.string().min(1).max(64)}); const Greeting = z.object({greeting: z.string()}); const EchoIn = z.object({text: z.string().min(1).max(280)}); const EchoOut = z.object({echoed: z.string(), at: z.string()}); @api({basePath: '/greet'}) class GreetingController { @get('/hello/{name}', {path: HelloPath, response: Greeting}) async hello(input: {path: z.infer}) { return {greeting: `Hello, ${input.path.name}!`}; } @post('/echo', {body: EchoIn, response: EchoOut}) async echo(input: {body: z.infer}) { return {echoed: input.body.text, at: new Date().toISOString()}; } } ``` The verb decorators (`@get`, `@post`, `@put`, `@patch`, `@del`) take a path and an options object. The schema slots are: | Option | Validates | Appears in OpenAPI as | | ---------- | ---------------------------------------- | ------------------------------------------------ | | `path` | URL path parameters | path parameters | | `query` | query string | query parameters | | `headers` | request headers (use **lowercase** keys) | header parameters | | `body` | request body | request body | | `response` | the handler's return value | the `200` (or `status`) response | | `status` | — (a number) | overrides the default `200`; `204` sends no body | ## MCP: schemas on `@tool` ```ts import {z} from 'zod'; import {mcpServer, tool} from '@agentback/mcp'; const ForecastIn = z.object({ city: z.string(), days: z.number().int().min(1).max(7), }); const ForecastOut = z.object({city: z.string(), forecast: z.string()}); @mcpServer() class WeatherTools { @tool('get_forecast', { description: 'Forecast for a city', input: ForecastIn, output: ForecastOut, }) async getForecast(input: z.infer) { return {city: input.city, forecast: 'sunny'}; } } ``` `@tool`'s `input`/`output` play the same role as REST's `body`/`response`: the input is validated and typed via `z.infer`; if `output` is declared, the return type is constrained at compile time, validated at runtime, **and** handed to the MCP SDK so structured-content clients consume it directly. ## The handler signature This is the one rule worth memorizing, and it's identical in spirit for REST and MCP. ### Slot 0 = the validated input bundle (when any schema is declared) For **REST**, slot 0 is an object with only the keys you declared: ```ts @post('/things/{id}', {path: IdPath, body: ThingBody, query: ThingQuery}) async create(input: { path: z.infer; body: z.infer; query: z.infer; }) { … } ``` For **MCP**, slot 0 is the inferred input directly: ```ts @tool('add', {input: AddIn}) async add(input: z.infer) { … } ``` The decorator's typing enforces this at compile time. If your parameter type disagrees with the declared schemas, you get a TypeScript error **at the decorator line**, with the mismatch surfaced precisely. ### Slot 0 is yours when no schemas are declared ```ts @get('/whoami') async whoami(@inject(SecurityBindings.USER) user: UserProfile) { … } // valid @tool('ping') async ping() { … } // valid ``` ### `@inject` lives at slot 1+ When you declare schemas, dependencies inject at the **second** parameter onward — slot 0 is reserved for the input bundle: ```ts @post('/echo', {body: EchoIn, response: EchoOut}) async echo( input: {body: z.infer}, // slot 0 @inject('services.Clock') clock: Clock, // slot 1+ ) { return {echoed: input.body.text, at: clock.now()}; } ``` Putting `@inject` at slot 0 _alongside_ a schema throws at decoration time with the offending class+method named. ## Validation, in and out - **Input** is parsed with the schema before your handler runs. On failure the request never reaches your code: - REST returns **422** (bad body) / **400** (bad params) with the structured `ZodError.issues` in the response. - MCP throws, and the call surfaces the issues to the client. - **Output** is validated against `response`/`output` after your handler returns. On mismatch: REST logs it (and still responds), MCP throws — the asymmetry is intentional (a REST API shouldn't 500 on a doc drift, an MCP tool contract should be strict). ## Startup checks Some coherence is verified when `app.start()` runs, not at request time: - **URL placeholders must match the `path` schema keys.** `@get('/hello/{name}', {path: z.object({name: …})})` is fine; a `{id}` in the URL with no `id` in the schema throws at startup, naming the controller + method. This turns a class of "wrong at runtime, in production" bugs into "wrong at boot, in the first test." ## Where it's wired (for the curious) - REST verb decorators store route options on `RestEndpoint` metadata plus a per-route Zod bundle in `zod-bridge.ts`'s `routeRegistry`. `RestServer` reads the registry, validates, and weaves `@inject` arguments via `resolveInjectedArguments`. - `@tool` stores `input`/`output` on `ToolMetadata`. `MCPServer.dispatchTool` parses input, weaves injects, applies the method, validates output. You don't need to touch those to use the framework, but it's a short read if you want to extend the dispatcher — see [Composition & extensibility](../guides/composition-and-extensibility.md#subclassing-the-dispatcher). ## Next - [Components, servers & lifecycle](components-servers-lifecycle.md) - [Build a REST API](../guides/build-a-rest-api.md) / [Build an MCP server](../guides/build-an-mcp-server.md) # Concept: Components, Servers & Lifecycle [Dependency injection](dependency-injection.md) gives you a container of bindings. This doc covers the three pieces that turn that container into a runnable application: **components** package bindings, **servers** expose them over a transport, and the **lifecycle** starts and stops everything in order. > Package: [`@agentback/core`](../../packages/core). ## Component — a bundle of bindings A `Component` is the unit of reuse. It's a class that declares bindings to add to the application: controllers, providers, classes, servers, and lifecycle observers. Registering it with `app.component(C)` merges all of that into the container in one call. ```ts import {Component, Application} from '@agentback/core'; class GreetingComponent implements Component { controllers = [GreetingController]; classes = {'services.Clock': Clock}; providers = {'services.Token': TokenProvider}; // servers = {MCPServer}; // contribute a server // lifeCycleObservers = [MetricsFlush]; // run code on start/stop } const app = new Application(); app.component(GreetingComponent); ``` This is how the framework ships capabilities. `MCPComponent`, `JWTAuthenticationComponent`, the health and metrics extensions — each is a `Component` you drop in with one line. Your own cross-cutting features (a tenant resolver, an audit log, a set of admin tools) become components too, which is what keeps large apps composable: a feature is a component, not a diff against `main()`. ```mermaid graph TD C["app.component(MyComponent)"] C --> ctl["controllers[] → tagged controller"] C --> cls["classes{} → bound by key"] C --> prov["providers{} → bound by key"] C --> srv["servers{} → bound under servers.*"] C --> obs["lifeCycleObservers[] → start/stop hooks"] ctl & cls & prov & srv & obs --> App["the one Application context"] ``` ## Server — exposes bindings over a transport A `Server` is a `LifeCycleObserver` with a `listening` flag. The framework ships two: - **`RestServer`** ([`@agentback/rest`](../../packages/rest)) — discovers bindings tagged `controller`, builds routes from their decorator metadata, validates with Zod, serves `/openapi.json`. - **`MCPServer`** ([`@agentback/mcp`](../../packages/mcp)) — discovers bindings tagged `mcpServer`, registers their `@tool`/`@resource`/`@prompt` methods with the MCP SDK, runs a transport (stdio by default). Both are just bindings under `servers.*`. You don't instantiate them directly — you register them (often via a component or a `RestApplication`) and the application's lifecycle starts them. ```ts import {RestApplication} from '@agentback/rest'; import {MCPComponent} from '@agentback/mcp'; const app = new RestApplication(); // binds servers.RestServer for you app.component(MCPComponent); // binds servers.MCPServer app.configure('servers.RestServer').to({port: 3000}); await app.start(); // starts BOTH servers ``` A server discovers controllers/tools at **start time** by querying the container for the relevant tag. That's why adding a controller is just `app.restController(C)` — no router edit, no manual registration. The [architecture overview](../architecture/overview.md) traces this discovery step by step. ### Writing your own server Implement `Server` (extend the lifecycle), bind it under `servers.*`, and the app will start/stop it with everything else: ```ts import {Server, Application} from '@agentback/core'; class MetricsServer implements Server { private _listening = false; get listening() { return this._listening; } async start() { /* open a port, begin scraping… */ this._listening = true; } async stop() { this._listening = false; } } app.server(MetricsServer); // bound under servers.MetricsServer, tagged `server` ``` ## Lifecycle — start and stop, in order `Application` extends a `LifeCycleObserver` registry. `await app.start()` and `await app.stop()` drive every registered observer (servers included) through three optional hooks: | Hook | When | Typical use | | --------- | ------------------------------- | ------------------------------- | | `init()` | once, before first start | one-time setup | | `start()` | on `app.start()` | open ports, connect transports | | `stop()` | on `app.stop()` (reverse order) | graceful shutdown, flush, close | ```ts import {lifeCycleObserver, LifeCycleObserver} from '@agentback/core'; @lifeCycleObserver('metrics') class MetricsFlusher implements LifeCycleObserver { async start() { /* begin periodic flush */ } async stop() { /* flush remaining + clear timer */ } } app.lifeCycleObserver(MetricsFlusher); ``` Observers run in groups, and **stop runs in reverse start order** so dependencies shut down after their dependents. `RestServer`/`MCPServer` use these same hooks — they have no privileged lifecycle, they're observers like any other. The HTTP server stops gracefully (drains in-flight requests) on `stop()`. ## Putting it together A typical app is: construct an application, add components, register your controllers/tools/services, configure servers, start. ```ts const app = new RestApplication(); app.component(JWTAuthenticationComponent); // a feature bundle app.component(MCPComponent); // adds servers.MCPServer app.restController(GreetingController); // REST surface app.service(WeatherTools); // MCP surface (an MCP_SERVERS extension) app.configure('servers.RestServer').to({port: 3000}); await app.start(); // both servers up, controllers + tools discovered // … later await app.stop(); // graceful shutdown of everything, reverse order ``` ## Next - [Build a REST API](../guides/build-a-rest-api.md) — the REST server end to end. - [Build an MCP server](../guides/build-an-mcp-server.md) — the MCP server end to end. - [Composition & extensibility](../guides/composition-and-extensibility.md) — components, middleware, interceptors, and extension points in depth. # Guide: Build a REST API Build a Zod-validated REST service that auto-emits OpenAPI 3.1 and serves Swagger UI. By the end you'll have controllers, validation, dependency injection, auth, and a browsable spec. > Prerequisites: skim [Dependency injection](../concepts/dependency-injection.md) > and [Schema-first decorators](../concepts/schema-first-decorators.md). > Working example: [`examples/hello-rest`](../../examples/hello-rest) — > `pnpm -F hello-rest start`. ## 1. Schemas first Define your shapes as Zod schemas in one place and share them. (Keeping schemas in their own module lets a [typed client](build-a-hybrid-app.md#a-type-safe-client-with-no-codegen) import the exact same objects.) ```ts // schemas.ts import {z} from 'zod'; export const HelloPath = z.object({name: z.string().min(1).max(64)}); export const Greeting = z.object({greeting: z.string()}); export const EchoIn = z.object({text: z.string().min(1).max(280)}); export const EchoOut = z.object({echoed: z.string(), at: z.string()}); ``` ## 2. A controller A controller is a plain class. `@api({basePath})` sets a shared prefix; each method gets a verb decorator carrying its schemas. The handler receives the validated [input bundle in slot 0](../concepts/schema-first-decorators.md#the-handler-signature). ```ts import {z} from 'zod'; import {api, get, post} from '@agentback/openapi'; import {EchoIn, EchoOut, Greeting, HelloPath} from './schemas.js'; @api({basePath: '/greet'}) export class GreetingController { @get('/hello/{name}', {path: HelloPath, response: Greeting}) async hello(input: { path: z.infer; }): Promise> { return {greeting: `Hello, ${input.path.name}!`}; } @post('/echo', {body: EchoIn, response: EchoOut, description: 'Echoed input'}) async echo(input: { body: z.infer; }): Promise> { return {echoed: input.body.text, at: new Date().toISOString()}; } } ``` What you get for free: - `GET /greet/hello/world` → `{"greeting":"Hello, world!"}`. - `POST /greet/echo` with `{"text":""}` → **422** with the Zod issues. - `GET /openapi.json` → an OpenAPI 3.1.1 document derived from these schemas. ## 3. Start the server `RestApplication` binds `servers.RestServer` for you. Register the controller, configure a port, start. ```ts import {RestApplication} from '@agentback/rest'; import {GreetingController} from './greeting.controller.js'; const app = new RestApplication(); app.configure('servers.RestServer').to({port: 3000, host: '127.0.0.1'}); app.restController(GreetingController); await app.start(); const server = await app.restServer; console.log(`listening at ${server.url}`); ``` `port: 0` picks a free port (handy for tests — read it back from `server.url`). ## 4. Inject dependencies into handlers Declare a dependency at slot 1+ (slot 0 is the input bundle). The container resolves it per the binding's scope. ```ts import {inject} from '@agentback/core'; @post('/echo', {body: EchoIn, response: EchoOut}) async echo( input: {body: z.infer}, @inject('services.Clock') clock: {now(): string}, ) { return {echoed: input.body.text, at: clock.now()}; } ``` Bind it once: `app.bind('services.Clock').to({now: () => new Date().toISOString()})`. For tests, rebind to a fake — no mocking framework needed. ## 5. Add authentication & authorization The auth stack is components. Add JWT auth, then gate methods with `@authenticate` and `@authorize`. ```ts import {authenticate} from '@agentback/authentication'; import {authorize} from '@agentback/authorization'; import { JWTAuthenticationComponent, JWTBindings, } from '@agentback/authentication-jwt'; import { SecurityBindings, UserProfile, securityId, } from '@agentback/security'; // wiring (before start) app.bind(JWTBindings.SECRET).to(process.env.JWT_SECRET ?? 'dev-secret'); app.bind(JWTBindings.EXPIRES_IN).to('1h'); app.component(JWTAuthenticationComponent); @api({basePath: '/auth'}) class AuthController { // any authenticated user @authenticate('jwt') @get('/me', {response: Me}) async me( @inject(SecurityBindings.USER) user: UserProfile, ): Promise> { return { id: user[securityId], name: user.name ?? '', roles: (user as any).roles ?? [], }; } // authenticated AND has the 'admin' role @authenticate('jwt') @authorize({allowedRoles: ['admin']}) @get('/secret', {response: Secret}) async secret(): Promise> { return {secret: '🐇 deeper.'}; } } ``` Note `@get('/me')` declares no input schema, so slot 0 is free for `@inject` — see [the handler signature rules](../concepts/schema-first-decorators.md#the-handler-signature). ## 6. Browse it: Swagger UI ```ts import {installExplorer} from '@agentback/rest-explorer'; await installExplorer(app, {title: 'My API'}); // before app.start() // -> Swagger UI at /explorer, pointed at /openapi.json ``` Want to see the DI container itself? Mount the [Context Explorer](composition-and-extensibility.md#inspect-the-container): `installContextExplorer(app)` → `/context-explorer/`. ## 7. Observability (optional) Health checks and Prometheus metrics are one-liners: ```ts import { installHealth, registerHealthCheck, } from '@agentback/extension-health'; import {installMetrics} from '@agentback/extension-metrics'; registerHealthCheck(app, 'health.checks.db', { name: 'db', type: 'readiness', async check() { /* throw to report unhealthy */ }, }); await installHealth(app); // GET /health, /ready await installMetrics(app); // GET /metrics ``` ## Customizing requests You don't reach for sequences/actions (the framework deliberately dropped them). Instead: - **Per-route** behavior → it's on the decorator (schemas, status, description). - **Cross-cutting** behavior (CORS, rate limit, auth probes, tracing) → [middleware or interceptors](composition-and-extensibility.md). - **Envelope/error-shape changes** → subclass `RestServer` and override `dispatch` / `sendResult` / `sendError` ([how](composition-and-extensibility.md#subclassing-the-dispatcher)). CORS is built in — configure it on the REST server: `app.configure('servers.RestServer').to({cors: true})` (or pass a `CorsOptions` object from the `cors` package for fine control). ## Recap ```mermaid graph LR S["Zod schemas"] --> C["@api controller
@get/@post + schemas"] C --> R["app.restController(C)"] R --> A["RestApplication.start()"] A --> RS["RestServer: routes + validation"] A --> O["/openapi.json"] O --> E["/explorer (Swagger UI)"] ``` ## Next - [Build an MCP server](build-an-mcp-server.md) — the same shape, for tools. - [Build a hybrid app](build-a-hybrid-app.md) — REST + MCP together, one schema set. - [Composition & extensibility](composition-and-extensibility.md) — middleware, interceptors, custom dispatch. # Guide: Build an MCP server Expose tools, resources, and prompts that an MCP client — Claude Desktop, the Vercel AI SDK, your own agent — can discover and call. It's the same decorator-on-a-class, schema-on-the-decorator shape as REST, so if you've read the [REST guide](build-a-rest-api.md) this will feel familiar. > Package: [`@agentback/mcp`](../../packages/mcp). Built on the official > [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol). > Working examples: [`examples/hello-mcp`](../../examples/hello-mcp) (stdio) and > [`examples/hello-hybrid`](../../examples/hello-hybrid) (REST + MCP). ## What MCP exposes | Primitive | Decorator | An LLM uses it to… | | ------------ | ------------------------------ | -------------------------------------------------- | | **Tool** | `@tool(name, {input, output})` | _do_ something (call a function with typed args). | | **Resource** | `@resource(uri, {...})` | _read_ context (a document/blob addressed by URI). | | **Prompt** | `@prompt(name, {...})` | fetch a reusable prompt template. | A class tagged `@mcpServer()` groups these; the `MCPServer` discovers them and registers them with the SDK. ## 1. A tool class ```ts import {z} from 'zod'; import {mcpServer, tool} from '@agentback/mcp'; const EchoIn = z.object({text: z.string().min(1)}); const AddIn = z.object({a: z.number().int(), b: z.number().int()}); const AddOut = z.object({sum: z.number().int()}); @mcpServer() export class MathTools { @tool('echo', {description: 'Echo back the text', input: EchoIn}) echo(input: z.infer) { return {echoed: input.text}; } @tool('add', {description: 'Add two integers', input: AddIn, output: AddOut}) add(input: z.infer): z.infer { return {sum: input.a + input.b}; } } ``` `input` is validated and typed via `z.infer`. Declaring `output` constrains the return type at compile time, validates it at runtime, and hands the schema to the MCP SDK so structured-content clients consume it directly. Omit `output` to leave the return type free. ## 2. Resources and prompts In this framework, resources and prompts are **parameterless** methods — the handler takes no input and returns a value the framework wraps into the MCP wire shape. ```ts import {resource, prompt} from '@agentback/mcp'; @mcpServer() export class Docs { @resource('docs://motd', { name: 'motd', description: 'Message of the day', mimeType: 'text/plain', }) motd() { return 'Welcome to the service.'; // → {contents: [{uri, mimeType, text}]} } @prompt('triage', {description: 'Triage a bug report'}) triage() { return 'You are a triage assistant. Classify the report by severity…'; // → {messages: [{role: 'user', content: {type: 'text', text}}]} } } ``` A `string` return is sent as text; a non-string is JSON-stringified. ## 3. Run the server Add `MCPComponent` (it binds `servers.MCPServer`), register your tool classes with `app.service(...)`, configure, start. The stdio transport is active by default — exactly what Claude Desktop and most local MCP clients speak. > A tool class **is** a DI service. `@mcpServer()` (built on `@injectable`) > makes it an _extension_ of the `MCP_SERVERS` extension point and defaults it > to singleton scope. The server discovers it by that extension and resolves it > through its binding, so constructor `@inject` is honored no matter how it was > registered — see [§4](#4-dependency-injection-in-tools). ```ts import {RestApplication} from '@agentback/rest'; import {MCPComponent} from '@agentback/mcp'; import {MathTools, Docs} from './tools.js'; const app = new RestApplication(); app.component(MCPComponent); app.configure('servers.MCPServer').to({ name: 'my-mcp', version: '1.0.0', // transports: {stdio: true}, // default; set false in tests/hybrid HTTP apps }); app.service(MathTools); app.service(Docs); await app.start(); // MCP server up, tools/resources/prompts registered ``` > You can use a plain `Application` instead of `RestApplication` if you only > want MCP and no HTTP. `RestApplication` is convenient when you also want the > [inspector UI](#5-inspect-it-the-mcp-inspector) (which is served over HTTP). ## Expose it over Streamable HTTP stdio is great for a local child process, but to let **remote** MCP clients (Claude, Cursor, agents) reach the same tools, expose the in-process server over the MCP **Streamable HTTP** transport with [`@agentback/mcp-http`](../../packages/mcp-http): ```ts import {installMcpHttp} from '@agentback/mcp-http'; await installMcpHttp(app); // before app.start() — mounts POST/GET/DELETE /mcp ``` Each client session gets its own isolated server instance (keyed by the `Mcp-Session-Id` header). A client connects with the SDK's `StreamableHTTPClientTransport`: ```ts import {Client} from '@modelcontextprotocol/sdk/client/index.js'; import {StreamableHTTPClientTransport} from '@modelcontextprotocol/sdk/client/streamableHttp.js'; const client = new Client({name: 'my-client', version: '1.0.0'}); await client.connect( new StreamableHTTPClientTransport(new URL('http://host:port/mcp')), ); await client.callTool({name: 'add', arguments: {a: 2, b: 40}}); ``` This works alongside REST in one process (`examples/hello-hybrid` mounts `/mcp`, `/explorer`, and `/mcp-inspector` together). To protect `/mcp` as an OAuth 2.1 resource server, pass `installMcpHttp(app, {auth: {...}})` — see the [`@agentback/mcp-http` README](../../packages/mcp-http/README.md#oauth-resource-server). Tag a tool with `@tool('x', {scope: 'admin'})` to gate it by the caller's token scopes. ## How discovery works `@mcpServer()` is built on `@injectable`: it tags the class as an _extension_ of the `MCP_SERVERS` extension point (`extensionFor: MCP_SERVERS`) and defaults it to singleton scope. When you call `app.service(MathTools)`, the framework reads that metadata and tags the binding — **you never call `.tag()`**. At `app.start()`, `MCPServer` discovers the `MCP_SERVERS` extensions, reflects over their `@tool`/`@resource`/`@prompt` methods, and registers each with the SDK — resolving the instance through its own binding, so constructor `@inject` works. ```mermaid graph LR D["@mcpServer() class"] -->|"app.service()"| B["binding [extensionFor: MCP_SERVERS]"] B -->|"start(): extensionFilter"| M["MCPServer"] M -->|reflect @tool/@resource/@prompt| SDK["@modelcontextprotocol/sdk"] ``` Consequence: adding a tool is adding a method (or a class + `app.service`). Nothing central changes — the same composability as REST controllers. ## 4. Dependency injection in tools Tools are DI-resolved classes, so inject services like anywhere else. With an `input` schema, injects go at slot 1+ (slot 0 is the validated input); with no input, slot 0 is free. ```ts @mcpServer() class WeatherTools { constructor(@inject('services.WeatherApi') private api: WeatherApi) {} @tool('forecast', {input: z.object({city: z.string()})}) async forecast(input: {city: string}) { return this.api.lookup(input.city); } } ``` Register it with **`app.service(WeatherTools)`** — a tool is a service. The dispatcher discovers it as an `MCP_SERVERS` extension and resolves the instance through that binding, so `this.api` is injected. This holds for any registration that carries the extension tag — `app.service`, `app.controller`, or a manual `bind().apply(extensionFor(MCP_SERVERS))`. (A dual REST + MCP class — `@api` _and_ `@mcpServer` — still needs **both** `restController` for the routes and `service` for the MCP extension, since `restController` tags it for REST only.) ## 5. Inspect it: the MCP Inspector A browser UI to list and _exercise_ tools/resources/prompts without wiring up an MCP client: ```ts import {installInspector} from '@agentback/mcp-inspector'; await installInspector(app); // before app.start(); needs servers.MCPServer bound // -> UI at /mcp-inspector/, JSON API at /mcp-inspector/api/* ``` The inspector fills tool argument forms from each tool's Zod-derived JSON Schema, shows validation errors inline, renders results, and logs a call history. (It's itself a worked example of a dogfooded REST controller — see its [README](../../packages/mcp-inspector/README.md).) ## Calling tools programmatically `MCPServer` exposes the same dispatch path the SDK uses, handy for tests or in-process use: ```ts const mcp = await app.get('servers.MCPServer'); await mcp.callTool('add', {a: 2, b: 40}); // → {sum: 42} await mcp.readResource('motd'); // → {contents: [...]} await mcp.getPrompt('triage'); // → {messages: [...]} ``` Invalid input throws with the structured Zod issues attached. ## Testing an MCP server [`examples/hello-mcp`](../../examples/hello-mcp) drives the server with a real MCP client over stdio (`transports: {stdio: true}` and an in-memory client pair). For unit-style checks, prefer `callTool`/`readResource`/`getPrompt` against an app with `transports: {stdio: false}` so the server doesn't take over the process's stdin. ## Next - [Build a hybrid app](build-a-hybrid-app.md) — serve REST and MCP from one process, sharing the same schemas. - [Render a widget with MCP Apps](mcp-apps-widgets.md) — give a tool an interactive `ui://` widget a host renders inline for its result (SEP-1865). - [Composition & extensibility](composition-and-extensibility.md) — package your tools as a reusable component. # Guide: Build a hybrid app (REST + MCP) The payoff of "servers are components over one container": run a **REST API and an MCP server in the same process**, often backed by the **same Zod schemas and the same services**. One business rule, two surfaces — an HTTP endpoint for apps and an MCP tool for agents — with no duplicated logic. > Working examples: [`examples/hello-hybrid`](../../examples/hello-hybrid) > (`pnpm -F hello-hybrid start`) and [`examples/hello-client`](../../examples/hello-client). ## One process, two servers ```ts import {RestApplication} from '@agentback/rest'; import {MCPComponent} from '@agentback/mcp'; const app = new RestApplication(); // binds servers.RestServer app.component(MCPComponent); // binds servers.MCPServer app.restController(GreetingController); // REST surface app.service(EchoTools); // MCP surface (an MCP_SERVERS extension) app.configure('servers.RestServer').to({port: 3000}); app.configure('servers.MCPServer').to({name: 'hello-hybrid', version: '1.0.0'}); await app.start(); // BOTH servers start; each discovers its own bindings by tag ``` `app.start()` walks the lifecycle and starts every `servers.*` binding. The REST server finds `controller`-tagged bindings; the MCP server finds `mcpServer`-tagged ones. They share the container, so they also share any service you bind. ```mermaid graph TD subgraph App["one RestApplication (Context)"] Svc["services.* (shared business logic)"] Ctl["controllers.* [controller]"] Tool["tools [mcpServer]"] end Ctl --> RS["RestServer → HTTP + /openapi.json + /explorer"] Tool --> MS["MCPServer → stdio + /mcp-inspector"] Ctl -.@inject.-> Svc Tool -.@inject.-> Svc ``` ## Share logic, not just process Put the real work in a service and inject it into both a controller and a tool. The HTTP route and the MCP tool become thin adapters over one implementation: ```ts @injectable({scope: BindingScope.SINGLETON}) class Forecaster { forecast(city: string) { return {city, forecast: 'sunny'}; } } @api({basePath: '/weather'}) class WeatherController { constructor(@inject('services.Forecaster') private fc: Forecaster) {} @get('/{city}', {path: CityPath, response: Forecast}) async get(input: {path: {city: string}}) { return this.fc.forecast(input.path.city); } } @mcpServer() class WeatherTools { constructor(@inject('services.Forecaster') private fc: Forecaster) {} @tool('forecast', {input: CityIn, output: Forecast}) forecast(input: {city: string}) { return this.fc.forecast(input.city); } } ``` Both can even reuse the **same response schema** (`Forecast`) — REST validates it as an HTTP response, MCP validates it as tool output. One artifact, two boundaries; see [Schema-first decorators](../concepts/schema-first-decorators.md). ## Mount both UIs ```ts import {installExplorer} from '@agentback/rest-explorer'; import {installInspector} from '@agentback/mcp-inspector'; await installExplorer(app, {title: 'Hybrid REST'}); // /explorer await installInspector(app, {title: 'Hybrid MCP'}); // /mcp-inspector await app.start(); ``` Now `/explorer` browses the REST API and `/mcp-inspector` exercises the tools — both served by the same process. ## A type-safe client with no codegen Because the contract _is_ the Zod schema, a TypeScript consumer can import the exact same schemas and get a fully typed client with **no generated SDK and no spec round-trip** — [`@agentback/client`](../../packages/client). ### Share the schemas Keep schemas in a module the server and client both import. In a monorepo, export them from the server package (e.g. `hello-rest/schemas`) — but **never from the server's main entry**, or importing the schemas would also boot the server. ```ts // schemas.ts — imported by server controllers AND client route handles export const HelloPath = z.object({name: z.string().min(1)}); export const Greeting = z.object({greeting: z.string()}); ``` ### Define route handles ```ts import {createClient, routeGroup} from '@agentback/client'; import {Greeting, HelloPath, EchoIn, EchoOut} from 'hello-rest/schemas'; const greet = routeGroup('/greet'); const hello = greet.get('/hello/{name}', {path: HelloPath, response: Greeting}); const echo = greet.post('/echo', {body: EchoIn, response: EchoOut}); const client = createClient({baseURL: 'http://localhost:3000'}); const out = await hello.call(client, {path: {name: 'Alice'}}); // ^^^ inferred {greeting: string}, validated against Greeting at runtime ``` - `route.call(client, input)` — throws on non-2xx; returns the validated body. - `route.safeCall(client, input)` — returns a discriminated `{success, ...}` result (mirrors Zod's `safeParse`) for when a non-2xx is an expected path. - `route.url(client, input)` — composes the URL without firing a request. ### Authenticated calls The `headers` option is a (possibly async) function, so token refresh is natural: ```ts const {token} = await login.call(anon, { body: {username: 'alice', roles: ['admin']}, }); const authed = createClient({ baseURL, headers: () => ({authorization: `Bearer ${token}`}), }); await me.call(authed); // sends the bearer token ``` Drift is caught at the **call site**: change a shared schema and TypeScript flags every client call that no longer matches — no regeneration step. (Non-TypeScript consumers still get `/openapi.json` for standard codegen.) ## When to go hybrid Reach for one process serving both when: - The same domain logic should be reachable by both apps (HTTP) and agents (MCP). - You want a single deploy unit, config, and auth story. Keep them separate when the surfaces have independent scaling, security boundaries, or release cadences — the framework supports both; it's a binding/ component decision, not a rewrite. ## Next - [Composition & extensibility](composition-and-extensibility.md) — bundle these surfaces into reusable components and add cross-cutting behavior. - [Architecture overview](../architecture/overview.md) — how dispatch and discovery actually work under the hood. # Guide: Composition & extensibility This is the guide that makes the "modular, extensible, composable" promise concrete. The framework gives you five tools, ordered here from "reach for first" to "deepest hook." Pick the lightest one that does the job. | Tool | Scope | Use for | | ---------------------------------------------------------- | ------------------------- | ----------------------------------------- | | [Components](#1-components--package-a-feature) | a bundle of bindings | shipping/reusing a whole feature | | [Interceptors](#2-interceptors--wrap-method-calls) | around method invocations | logging, caching, timing, tx, retries | | [Middleware](#3-middleware--around-http-requests) | around HTTP requests | CORS, rate limit, probes, request tracing | | [Extension points](#4-extension-points--open-a-plugin-slot) | a registry others fill | "any number of X" plugin surfaces | | [Subclassing dispatch](#subclassing-the-dispatcher) | the REST pipeline itself | response envelopes, custom error shapes | Underneath all of them is the same principle: **add a binding, don't edit the core.** ([Why](../concepts/dependency-injection.md#why-this-matters-for-composition).) ## 1. Components — package a feature A [`Component`](../concepts/components-servers-lifecycle.md#component--a-bundle-of-bindings) is the unit of reuse: a class that contributes controllers, services, providers, servers, and lifecycle observers in one registration. Turn any feature into a component and "adding it" becomes one line. ```ts import {Component} from '@agentback/core'; export class AuditComponent implements Component { controllers = [AuditController]; classes = {'services.AuditLog': AuditLog}; lifeCycleObservers = [AuditFlusher]; } app.component(AuditComponent); // everything above is now in the container ``` The framework's own auth, health, metrics, and MCP support all ship this way — your features should too. A new feature is a new component, never a diff through `main()`. ## 2. Interceptors — wrap method calls An interceptor runs around a method invocation (proceed → or short-circuit). Use it for behavior that's about _the call_, not _the HTTP request_: timing, caching, transactions, retries, structured logging. ```ts import {intercept, Interceptor} from '@agentback/context'; const timed: Interceptor = async (ctx, next) => { const start = Date.now(); try { return await next(); // proceed to the method (or the next interceptor) } finally { console.log(`${ctx.targetName} took ${Date.now() - start}ms`); } }; class ReportController { @intercept(timed) @get('/report', {response: Report}) async report() { /* … */ } } ``` `@globalInterceptor('group')` registers one that applies to **every** invocation through the container — apply cross-cutting concerns without touching each method. Interceptors compose in a defined order and work for any DI-invoked method, not just REST. ## 3. Middleware — around HTTP requests Middleware sits in front of route handlers, with the real Express `request`/`response`. Use it for things that are genuinely about the HTTP request: CORS, rate limiting, health probes, request-id/tracing, body size limits. `RestApplication` mixes in the middleware machinery. ```ts // framework-style middleware (gets a MiddlewareContext) app.middleware(async (ctx, next) => { ctx.response.setHeader('x-request-id', crypto.randomUUID()); return next(); }); // or mount any Express middleware factory app.expressMiddleware(rateLimit, {windowMs: 60_000, max: 100}); ``` The chain runs before route handlers, so middleware can short-circuit (return a response) for preflights, throttling, or liveness checks. **CORS** is built in — you don't need middleware for it: ```ts app.configure('servers.RestServer').to({cors: true}); // sensible defaults // or {cors: {origin: ['https://app.example.com'], credentials: true}} // — any CorsOptions from the `cors` package ``` ### Interceptor vs middleware — which? - Touching `req`/`res`, status, headers, or short-circuiting an HTTP request → **middleware**. - Wrapping a method's _invocation_ regardless of transport (also runs for MCP tools, internal calls) → **interceptor**. ## 4. Extension points — open a plugin slot When you want "any number of X, contributed by anyone," define an **extension point** and let extensions register by tag. This is how the auth stack collects strategies and how health collects checks. ```ts import {extensionPoint, extensions} from '@agentback/core'; import {Getter} from '@agentback/context'; @extensionPoint('greeters') // an extension point named "greeters" class GreetingService { constructor( @extensions() private getGreeters: Getter, // all registered greeters ) {} async greet(lang: string, name: string) { const greeters = await this.getGreeters(); return greeters.find(g => g.language === lang)?.greet(name); } } // elsewhere — register an extension for the point: import {extensionFor} from '@agentback/core'; app.bind('greeters.fr').toClass(FrenchGreeter).apply(extensionFor('greeters')); ``` The service never imports the extensions; it discovers them through the container. New languages are new bindings — the registry grows without editing `GreetingService`. (`@inject.tag(tag)` is the lower-level form: inject an array of everything carrying a tag.) ## Inspect the container Two UIs help you _see_ the composition you've built: - **Context Explorer** — browse every binding (key, scope, type, tags, injections) and a dependency graph of what injects what. Mount it: ```ts import {installContextExplorer} from '@agentback/context-explorer'; await installContextExplorer(app); // -> /context-explorer/ ``` Useful when "who provides this?" or "what depends on `services.Clock`?" needs an answer. See its [README](../../packages/context-explorer/README.md). - **MCP Inspector** (`/mcp-inspector`) and **Swagger UI** (`/explorer`) show the tool and HTTP surfaces your bindings produce. ## Subclassing the dispatcher The REST request pipeline is a single, fixed method — there are no LB4 sequences/actions to assemble. For envelope wrappers, custom error shapes, or request-scoped tracing, subclass `RestServer` and override the `protected` seams, then bind your subclass. ```ts import {RestServer} from '@agentback/rest'; class EnvelopingRestServer extends RestServer { // wrap every successful result in {data, meta} protected sendResult(req, res, result, status) { super.sendResult(req, res, {data: result, meta: {at: Date.now()}}, status); } // shape errors your way protected sendError(req, res, err) { res.status(err.statusCode ?? 500).json({error: {message: err.message}}); } } app.server(EnvelopingRestServer); // bind your subclass under servers.* ``` The overridable seams are `makeHandler`, `dispatch`, `sendResult`, and `sendError`. This keeps the common path simple while leaving a real escape hatch for the rare app that needs to reshape it — without forking the framework. ## A composition checklist When adding a capability, ask in order: 1. Is it a whole feature others might reuse? → **Component**. 2. Is it behavior around a method call (any transport)? → **Interceptor**. 3. Is it about the HTTP request/response? → **Middleware** (or built-in CORS). 4. Is it "many plugins of a kind"? → **Extension point**. 5. Does it reshape the REST pipeline itself? → **Subclass `RestServer`**. If none fit, it's probably just a new binding — which is the whole point. ## Next - [Architecture overview](../architecture/overview.md) — see where each of these hooks sits in the request flow. - [Boundary coherence](../agent-ergonomics.md) — the design philosophy behind "add a binding, don't edit the core." # Testing How to test an AgentBack application: the harness, the four client surfaces it hands you, and the conventions the workspace itself follows. ## The one rule: tests run against `dist/` `vitest.config.ts` globs `packages/*/dist/__tests__/**/*.{test,spec,unit,integration,acceptance}.js`. Edit a `.ts` file → `pnpm build` (or keep `pnpm build:watch` running) → `pnpm test`. If a change "isn't being picked up," this is why. Naming conventions: `*.unit.ts` for tests of one module in isolation, `*.integration.ts` for tests that boot servers, under `src/__tests__/unit/` and `src/__tests__/integration/`. ## `createTestApp` — boot once, get every surface `@agentback/testing` boots your real application class with test-safe overrides: an ephemeral REST port, MCP stdio disabled, and your bindings swapped where you need fakes. ```ts import {createTestApp} from '@agentback/testing'; import {getOrder} from 'my-service/routes'; // a defineRoute/routeGroup handle it('serves an order end to end', async () => { await using t = await createTestApp(MyApplication, { overrides: {[DB_KEY]: fakeDb}, // rebinding by key wins }); const order = await t.call(getOrder, {path: {id: '42'}}); expect(order.status).toBe('shipped'); // typed: z.infer of the response schema }); ``` `await using` (explicit resource management) stops the app when the block exits — no `afterEach` bookkeeping. On runtimes without `await using`, call `t.stop()` in a `finally`. The returned `TestApp` carries four surfaces; pick the lowest one that can express the assertion: | Surface | What it is | Use for | | ---------- | --------------------------------------------------- | ------------------------------------------------------ | | `t.call` | typed route-handle execution (schema-shared client) | most behavior tests — input and output are `z.infer`ed | | `t.client` | a `@agentback/client` Client at the test URL | `safeCall`, custom handles, error-result shapes | | `t.http` | raw supertest | status codes, headers, malformed-input cases | | `t.mcp` | in-memory MCP SDK client | tool/resource/prompt behavior, visibility, envelopes | | `t.app` | the application (a `Context`) | DI assertions: `t.app.getSync(KEY)` | Examples of the non-typed surfaces: ```ts // Wire-level: assert the agent error envelope on a validation failure. const r = await t.http.post('/orders').send({}).expect(422); expect(r.body.error.code).toBe('invalid_body'); // MCP: same process, no transport, real dispatch pipeline. const result = await t.mcp.callTool({name: 'get_order', arguments: {id: '42'}}); expect(result.isError).toBeFalsy(); ``` ## Testing the policy layer `mcpScopes` builds the in-memory MCP session exactly like an authenticated HTTP session, so scope-gated visibility is testable without standing up OAuth: ```ts await using t = await createTestApp(MyApp, {mcpScopes: ['orders:read']}); const {tools} = await t.mcp.listTools(); expect(tools.map(x => x.name)).not.toContain('refund_order'); // needs orders:write ``` For REST auth, drive the real strategies through `t.http` with real headers — the test app runs the same authenticate → authorize → validate pipeline as production. ## Overriding configuration `configurations` merges over whatever the app configured per binding key: ```ts await using t = await createTestApp(MyApplication, { configurations: { 'servers.RestServer': {basePath: '/api'}, 'servers.MCPServer': {name: 'test-server'}, }, }); ``` (The harness always forces `port: 0` and `transports: {stdio: false}` on top — tests must not grab fixed ports or hijack stdio.) ## What to test at which level - **Unit**: pure logic, decorators' metadata, a hook's behavior with a fake `info` object. No app boot. Fast enough to run on every save. - **Integration** (`createTestApp`): the contract — routes validate and serialize as declared, tools appear/disappear by scope, error envelopes carry the right `code`. This is where boundary coherence pays off: shape mistakes are already impossible by compile time or startup, so these tests assert _behavior_, not bookkeeping. - **Don't test the framework**: re-asserting that Zod validates or that OpenAPI emits is the workspace's job (2,000+ tests here). Your tests own your handlers' behavior. One startup behavior worth relying on instead of testing: URL placeholders are cross-checked against `path:` schemas at `app.start()` — so a single "the app boots" integration test catches every route/schema mismatch in the codebase at once. ## Testing time, randomness, and queues - Stores and meters take injectable clocks/id generators (`MeterOptions.now/genId`) — bind deterministic ones rather than sleeping. - The in-memory messaging adapter (`@agentback/messaging`) runs jobs and events in-process; integration tests can await a job's completion without Redis. The BullMQ adapter has its own conformance suite that runs only when `REDIS_URL` is present — follow that pattern for tests needing external services: skip, don't mock the world. # Secure MCP over HTTP `installMcpHttp(app)` exposes your `@tool`/`@resource`/`@prompt` surface to remote MCP clients (Claude, Cursor, agents) at `/mcp`. That is a remotely callable RPC endpoint into your process — this guide is the production checklist for it. The threat model in one paragraph: an unauthenticated `/mcp` lets anyone who can reach the port enumerate and call your tools; a browser-reachable one is additionally exposed to DNS-rebinding (a malicious web page POSTing JSON-RPC to `http://localhost`). The defenses below layer: transport auth decides _who_ is calling, `@authorize`/scopes decide _what they see and may call_, and the hardening options bound the blast radius. ## Step 1 — pick an authentication mode ### Option A: framework strategies (`strategyAuth`) Reuse the same `@agentback/authentication` strategies that protect your REST routes — JWT, API key, client-credentials — so both surfaces share one identity system: ```ts import {installMcpHttp} from '@agentback/mcp-http'; await installMcpHttp(app, { strategyAuth: { strategy: ['api-key', 'jwt'], // first that authenticates wins required: true, // 401 when none does (the default) }, }); ``` The authenticated principal's `scopes` (or a client application's `allowedScopes`) become the session's MCP scopes; override the mapping with `strategyAuth.scopes: auth => string[]`. This is the right mode when your callers already hold credentials you issued. ### Option B: OAuth 2.1 resource server (`auth`) For third-party MCP clients that discover authorization dynamically (the MCP auth spec flow), make `/mcp` a protected resource. The framework is the **resource server only** — bring your own authorization server (Auth0, Clerk, WorkOS, Keycloak, your own) and a token verifier: ```ts import {createRemoteJWKSet, jwtVerify} from 'jose'; const jwks = createRemoteJWKSet( new URL('https://auth.example.com/.well-known/jwks.json'), ); await installMcpHttp(app, { auth: { resource: 'https://api.example.com/mcp', authorizationServers: ['https://auth.example.com'], requiredScopes: ['mcp:use'], verifier: { verifyAccessToken: async token => { const {payload} = await jwtVerify(token, jwks, { audience: 'https://api.example.com/mcp', }); return { token, clientId: String(payload.client_id ?? payload.azp ?? ''), scopes: String(payload.scope ?? '') .split(' ') .filter(Boolean), expiresAt: payload.exp, }; }, }, }, }); ``` With `auth:` set, every request must carry `Authorization: Bearer `; the endpoint serves `/.well-known/oauth-protected-resource` (RFC 9728) and challenges unauthenticated requests so compliant clients discover your AS and start the OAuth flow on their own. Verify the **audience**: a token minted for another resource must not open yours. The two modes compose — `auth` for external OAuth clients alongside `strategyAuth` for first-party API keys. ## Step 2 — scope the tool surface per caller Authentication answers "who"; the policy layer answers "what". One `@authorize` declaration governs both REST and MCP: ```ts @authorize({scopes: ['orders:write']}) @tool('refund_order', {input: RefundIn, output: RefundOut}) async refund(input: z.infer) { … } ``` On an authenticated transport, scope-gated tools are **invisible** in `tools/list` to sessions lacking the scope (gated at session construction, not just at call time), and denied on `tools/call` regardless. The same applies to `@resource` and `@prompt` members. Roles/voter-gated members stay listed and are denied at call time — voters need a live request to vote. Inside a tool, the verified identity is injectable: ```ts @tool('whoami') async whoami(@inject(MCPBindings.REQUEST_AUTH, {optional: true}) auth?: AuthInfo) { return {clientId: auth?.clientId, scopes: auth?.scopes}; } ``` One subtlety worth knowing: `MCPServerConfig.localPrincipal` is the ambient identity for **unauthenticated transports** (stdio, the inspector). It is a development convenience — do not configure a privileged `localPrincipal` on an app that also exposes `/mcp` without auth, or every remote caller inherits it. ## Step 3 — harden the endpoint ```ts await installMcpHttp(app, { strategyAuth: {strategy: 'jwt'}, // DNS-rebinding defense: reject requests whose Host/Origin aren't yours. allowedHosts: ['mcp.example.com'], allowedOrigins: ['https://app.example.com'], // Per-tool, per-caller rate limits for tools/call. rateLimit: { points: 60, durationSecs: 60, perTool: {expensive_search: {points: 5, durationSecs: 60}}, }, }); ``` - **DNS rebinding**: setting `allowedHosts`/`allowedOrigins` enables the protection automatically. The permissive default exists only so local dev works out of the box — production deployments should always set the allowlists. - **Rate limiting** is per tool and per caller, so one chatty agent can't starve the rest. (The in-memory limiter is per-process; see the multi-instance checklist in [Deploy to production](deploy-to-production.md).) - **Resumable sessions**: pass an `eventStore` only if you need SSE-replay across reconnects; a shared (Redis) store is required for it to work behind a load balancer. ## Step 4 — verify what a session actually sees The cheapest audit is the framework's own test harness: boot the app with a given scope set and assert the visible tool list. ```ts await using t = await createTestApp(MyApp, {mcpScopes: ['orders:read']}); const {tools} = await t.mcp.listTools(); expect(tools.map(x => x.name)).not.toContain('refund_order'); ``` This exercises the same session-construction path as an authenticated HTTP caller — if the test can't see a tool, neither can a token with those scopes. ## Checklist - [ ] `strategyAuth` or `auth` configured; anonymous `/mcp` is a deliberate decision, not a default you forgot. - [ ] OAuth `verifier` checks signature, expiry, **and audience**. - [ ] Dangerous tools carry `@authorize({scopes})` (invisible without the scope) and, where appropriate, `confirm: true`. - [ ] `allowedHosts`/`allowedOrigins` set. - [ ] Per-tool rate limits for expensive tools. - [ ] No privileged `localPrincipal` on an HTTP-exposed app. - [ ] A scope-visibility test per sensitive tool. # Deploy to production This guide takes an AgentBack service from `pnpm start` on a laptop to a container behind a load balancer: configuration, probes, metrics, tracing, graceful shutdown, and the multi-instance gotchas. ## Build and run A service is plain ESM Node — `pnpm build` emits `dist/`, and production runs `node dist/main.js`. Nothing in the framework needs a bundler, a custom runtime, or a build plugin. A multi-stage Dockerfile (pnpm workspace layout): ```dockerfile FROM node:22-slim AS build RUN corepack enable WORKDIR /app COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ COPY packages ./packages COPY apps/my-service ./apps/my-service RUN pnpm install --frozen-lockfile RUN pnpm build RUN pnpm --filter my-service deploy --prod /out FROM node:22-slim ENV NODE_ENV=production WORKDIR /app COPY --from=build /out . USER node EXPOSE 3000 CMD ["node", "dist/main.js"] ``` For a standalone (non-workspace) service, replace the `pnpm deploy` step with `pnpm prune --prod` in place. ## Configuration Bind the listen address from the environment; everything else through `@agentback/config` so it is **validated at startup** instead of failing at first use: ```ts import {loadConfigFile} from '@agentback/config'; const AppConfig = z.object({ database: z.object({url: z.string().url()}), auth: z.object({jwksUri: z.string().url()}), }); const config = loadConfigFile('config.jsonc', AppConfig); // throws on invalid const app = new RestApplication(); app.configure('servers.RestServer').to({ port: Number(process.env.PORT ?? 3000), host: '0.0.0.0', // containers: bind all interfaces, not 127.0.0.1 }); ``` The loader reads `config/config.jsonc`, deep-merges `config/config..jsonc` on top, and resolves `${VAR}` / `${VAR:-default}` interpolations from the environment — so secrets stay in env vars while structure stays in files. A missing variable without a default throws at startup. Behind a path-prefixing proxy, set `basePath` in the same config object — `/openapi.json`, `/llms.txt`, and the explorer all mount under it. ## Probes (Kubernetes-shaped) ```ts import { installHealth, registerHealthCheck, } from '@agentback/extension-health'; await installHealth(app); // GET /health (liveness), GET /ready (readiness) registerHealthCheck(app, { name: 'db', type: 'readiness', check: async () => void (await db.execute(sql`select 1`)), }); ``` `/health` runs liveness checks and answers `200 {status: 'UP'}` / `503`; `/ready` runs readiness checks. Wire them directly: ```yaml livenessProbe: httpGet: {path: /health, port: 3000} readinessProbe: httpGet: {path: /ready, port: 3000} ``` ## Metrics and tracing ```ts import {installMetrics} from '@agentback/extension-metrics'; import {installOtel} from '@agentback/extension-otel'; await installMetrics(app); // Prometheus text at /metrics: // process metrics + request-duration histogram await installOtel(app); // spans for every REST dispatch and MCP tool call ``` `extension-otel` depends only on `@opentelemetry/api` — **you bring the SDK** and exporter in your entrypoint, before the app starts: ```ts import {NodeSDK} from '@opentelemetry/sdk-node'; import {OTLPTraceExporter} from '@opentelemetry/exporter-trace-otlp-http'; const sdk = new NodeSDK({ serviceName: 'my-service', traceExporter: new OTLPTraceExporter(), // honors OTEL_EXPORTER_OTLP_ENDPOINT }); sdk.start(); ``` Point `OTEL_EXPORTER_OTLP_ENDPOINT` at your collector (Jaeger, Tempo, Datadog agent — anything OTLP). When metering is installed, `installOtel` also stamps the active trace id onto every usage event, so billing records and traces share a join key. ## Graceful shutdown The HTTP server already closes gracefully (in-flight requests drain, new connections are refused). Hook the signals: ```ts for (const signal of ['SIGTERM', 'SIGINT'] as const) { process.on(signal, () => { app.stop().then( () => process.exit(0), err => { console.error(err); process.exit(1); }, ); }); } ``` `app.stop()` stops every bound server (REST, MCP transports) and runs lifecycle observers' `stop()` — close DB pools and queue connections there. ## Multi-instance checklist Several conveniences default to **per-process in-memory state**. Fine on one instance; on two or more, bind shared implementations: | Feature | Default | Multi-instance binding | | ---------------------- | ----------------- | ------------------------------------------------------------------------- | | Rate limiting | in-memory buckets | `installRateLimit(app, {redis: …})` (Redis-backed) | | `confirm:` tokens | in-memory store | bind `RestBindings.CONFIRMATION_STORE` / `MCPBindings.CONFIRMATION_STORE` | | `idempotency:` replay | in-memory store | bind `RestBindings.IDEMPOTENCY_STORE` | | Metering sink | in-memory log | bind `MeteringBindings.SINK` (Redis/JSONL/composite ship in-box) | | MCP resumable sessions | none | pass a shared `EventStore` to `installMcpHttp` | | Job queue / event bus | in-memory adapter | `@agentback/messaging-bullmq` (BullMQ + Redis Streams) | Also remember that MCP-over-HTTP sessions are sticky to an instance unless you enable session resumability — terminate MCP at one instance or use a session-affinity LB policy. ## Exposure checklist - `cors:` — off by default; enable deliberately (`true` or `CorsOptions`). - `/mcp` — if exposed, read [Secure MCP over HTTP](secure-mcp-over-http.md) first: auth, DNS-rebinding allowlists, per-tool rate limits. - `/openapi.json`, `/llms.txt`, `/explorer`, `/mcp-inspector` — public by default. The spec and AX artifacts are usually fine to leave public (they are the product); gate or disable the explorer/inspector UIs in production if your API is not. - Set `DEBUG=` (empty) in production; enable namespaces selectively when debugging (`DEBUG=agentback:rest:*`). # Architecture overview How the pieces fit, how a request flows, and how the packages layer. If you've read the [concepts](../concepts/dependency-injection.md), this ties them together. > A polished, standalone version of the system diagram below lives at > [`diagrams/system-architecture.html`](diagrams/system-architecture.html) — > open it in a browser. ## The system at a glance Everything is a binding in one `Context`. The servers are bindings too; they discover your code by tag at startup and expose it over their transport. The same Zod schemas feed validation, OpenAPI, and MCP contracts. ```mermaid graph TD subgraph Container["Application — one DI Context"] direction TB Schemas["Zod schemas (shared)"] Ctrls["controllers.* [controller]"] Tools["tool classes [mcpServer]"] Svcs["services.* / providers / config"] Ctrls -. @inject .-> Svcs Tools -. @inject .-> Svcs Ctrls -. carry .-> Schemas Tools -. carry .-> Schemas end Container --> RS["RestServer
(servers.RestServer)"] Container --> MS["MCPServer
(servers.MCPServer)"] RS -->|findByTag controller| Ctrls MS -->|findByTag mcpServer| Tools RS --> HTTP["HTTP + /openapi.json"] HTTP --> SUI["/explorer (Swagger UI)"] MS --> STDIO["stdio transport"] MS --> INS["/mcp-inspector"] Schemas --> OAS["OpenAPI 3.1 (z.toJSONSchema)"] Schemas --> MCPC["MCP input/output schema"] OAS --> HTTP MCPC --> MS ``` The shape to remember: **one container in the middle; servers on the edges discovering bindings by tag; schemas radiating out to every contract.** ## How a REST request flows > Two standalone diagrams cover the middleware layer: the **structure** — the > group-sorted cascade (`cors → parseBody → middleware`) that fronts every route > — at [`diagrams/middleware-chain.html`](diagrams/middleware-chain.html), and a > live **request trace** through the resolved order (POST `/mcp` ①→⑦, plus the > OPTIONS-preflight short-circuit) at > [`diagrams/mcp-request-lifecycle.html`](diagrams/mcp-request-lifecycle.html). > Open them in a browser. ```mermaid sequenceDiagram participant C as Client participant MW as Middleware chain participant RS as RestServer.dispatch participant Z as Zod (route bundle) participant DI as resolveInjectedArguments participant H as Your handler C->>MW: HTTP request MW->>RS: (after CORS / rate-limit / tracing) RS->>Z: validate path/query/headers/body alt invalid Z-->>C: 422 / 400 + ZodError.issues else valid RS->>DI: resolve @inject args (slot 1+) DI->>H: input bundle (slot 0) + deps H-->>RS: return value RS->>Z: validate against `response` (log on mismatch) RS-->>C: status + JSON (sendResult) end ``` - **Middleware** runs first and can short-circuit (preflights, throttling, probes). - **Validation** happens before your code — a bad request never reaches the handler. - **Injection** weaves dependencies at slot 1+; the validated input bundle is slot 0. - **`dispatch` / `sendResult` / `sendError`** are the `protected` seams you [subclass](../guides/composition-and-extensibility.md#subclassing-the-dispatcher) to reshape the pipeline. An MCP tool call follows the analogous path inside `MCPServer.dispatchTool`: parse input → weave injects → apply method → validate output. ## How discovery works No router file, no central switch. Servers find your code by querying the container at start time. ```mermaid graph LR Dec["@api + @mcpServer class"] -->|"app.restController (one call)"| Bind["one binding + tags
(controller · extensionFor MCP_SERVERS)"] Start["app.start()"] --> SrvR["RestServer"] Start --> SrvM["MCPServer"] SrvR -->|"findByTag('controller')"| Bind SrvM -->|"findByTag('mcpServer')"| Bind SrvR -->|read decorator metadata| Routes["routes + Zod bundles"] SrvM -->|reflect @tool/@resource/@prompt| Reg["SDK registrations"] ``` This is why "add a feature" = "add a binding": the discovery step picks it up with zero wiring. ## Package layering The DI foundation is the base; servers, integrations, and the agent runtime build up from it. Every package has its own `README.md` with exports and a usage snippet. An arrow means "depends on." ```mermaid graph BT subgraph foundation["DI foundation (ports of @loopback/*)"] metadata["metadata"]; context["context"]; core["core"] end subgraph crosscut["cross-cutting"] config["config"]; security["security"]; testlab["testlab"] end subgraph http["HTTP / REST / OpenAPI"] httpserver["http-server"]; express["express"]; openapi["openapi"] rest["rest"]; restexp["rest-explorer"]; client["client (standalone)"] end subgraph auth["auth stack"] authn["authentication"]; authjwt["authentication-jwt"] authoauth2["authentication-oauth2"]; authz["authorization"] end subgraph pay["metering + payments"] metering["metering"]; payments["payments"] end subgraph mcpfam["MCP"] mcp["mcp"]; mcphttp["mcp-http"] mcpclient["mcp-client"]; mcphost["mcp-host"]; mcpconnect["mcp-connect"] end subgraph obs["observability + edge"] health["extension-health"]; metrics["extension-metrics"] ratelimit["extension-rate-limit"] end subgraph ui["console / explorers"] theme["console-theme"]; ctxexp["context-explorer"] schemaexp["schema-explorer"]; mcpins["mcp-inspector"]; console["console"] end context --> metadata core --> context config --> core security --> core httpserver --> core express --> httpserver openapi --> core rest --> express rest --> openapi rest --> authn rest --> authz authn --> security authjwt --> authn authoauth2 --> authn authz --> security metering --> rest metering --> mcp payments --> metering payments --> mcp mcp --> core mcphttp --> mcp mcphost --> mcpclient mcpconnect --> mcpclient restexp --> rest health --> rest metrics --> rest ratelimit --> rest ctxexp --> rest schemaexp --> rest schemaexp --> mcp mcpins --> mcp mcpins --> mcpconnect console --> mcpins console --> ctxexp console --> schemaexp ``` Notes: - **`metadata → context → core`** is the DI foundation, a faithful ESM port of `@loopback/{metadata,context,core}`. Know LB4 DI and you know this layer. `core` re-exports `context`, which re-exports `metadata` — most consumers only import `@agentback/core`. - **`openapi`, `rest`, `mcp`** are rewrites (see the [design pivots](../../README.md#design-pivots-from-the-upstream-loopback-4)). - **`config`, `security`** are cross-cutting; the **auth stack** (`authentication`, `authentication-jwt`, `authentication-oauth2`, `authorization`) builds on `security` and is woven into `rest`'s dispatch pipeline. - **`metering`, `payments`** subclass the dispatchers to count every call (`metered?`) and gate/bill the paid ones (`paid?` — x402 / MPP / Stripe). They hang off the principal the auth stack produces — see [metering & payments](metering-and-payments.md). - **`client`** depends on **none** of the above — it only needs `zod`, so it's browser-safe and shares schemas with the server without importing the server. - The **MCP client family** (`mcp-client`, `mcp-host`, `mcp-connect`) is a standalone path for _consuming_ upstream MCP servers, distinct from `mcp` (which _serves_ tools). - The UI packages (`rest-explorer`, `mcp-inspector`, `context-explorer`, `schema-explorer`, `console`) mount on a running server; `console-theme` is shared styling. `schema-explorer` reads **both** `rest` and `mcp` — it indexes the app by Zod schema and joins each entity to the routes and tools that use it (the inverse of the per-protocol explorers). ## Agent-facing runtime pieces The packages in this repo stop at framework and substrate boundaries. They give agent applications the core primitives for tools, durable work, gatewaying, and plugins, but they do not currently ship a higher-level turn loop or orchestration runtime. ```mermaid graph BT subgraph contracts["tool and API contracts"] openapi["openapi"]; mcp["mcp"]; rest["rest"] end subgraph runtime["agent-facing substrates"] messaging["messaging"]; bullmq["messaging-bullmq"] mcphost["mcp-host"]; mcpclient["mcp-client"]; plugin["plugin"] files["files"]; filess3["files-s3"] end subgraph platform["platform concerns"] auth["authentication / authorization"] metering["metering / payments"] otel["extension-otel"] end core(["core / context"]) openapi --> core rest --> openapi mcp --> core messaging --> core bullmq --> messaging files --> core filess3 --> files rest --> files mcphost --> mcpclient plugin --> core auth --> core metering --> rest metering --> mcp otel --> rest otel --> mcp otel --> messaging ``` - **Tool serving** — [`mcp`](../../packages/mcp/README.md) exposes decorated tool classes over MCP, with the same Zod schemas used for runtime validation and tool contracts. - **Tool consumption** — [`mcp-client`](../../packages/mcp-client/README.md) and [`mcp-host`](../../packages/mcp-host/README.md) connect to and aggregate upstream MCP servers. - **Durable work** — [`messaging`](../../packages/messaging/README.md) defines Zod-typed `JobQueue`, `EventBus`, `Scheduler`, and `QueueAdmin` ports; [`messaging-bullmq`](../../packages/messaging-bullmq/README.md) implements those ports over BullMQ and Redis Streams. - **Runtime extension** — [`plugin`](../../packages/plugin/README.md) discovers and gates components; `metering`, `payments`, and `extension-otel` compose via dispatcher hooks and server seams. ## Where the boundaries are the same artifact The thing that distinguishes this framework: a single Zod schema is the validator, the `z.infer` type, the OpenAPI parameter/response, the MCP input/output, and the rendered docs — simultaneously. Changing it changes all of them in one edit, and disagreements surface as a **TypeScript error at the decorator**, a **startup throw**, or a **failing test** — three localized signals instead of one vague one. That property, not any single feature, is the framework's bet. The full argument is in the [boundary-coherence design thesis](../agent-ergonomics.md). ## Next - Back to the [concepts](../concepts/dependency-injection.md) or [guides](../guides/build-a-rest-api.md). - The [design thesis](../agent-ergonomics.md) for the "why." # Metering & payments How every REST and MCP call gets **counted** (and attributed to a billable identity), and how the calls that must be paid for get **gated** or **billed**. Two small packages sit on top of the dispatch seams: - **`@agentback/metering`** — the _metered?_ answer: a `Meter` emits a `UsageEvent` per call into pluggable sinks (the durable one is your audit log), plus a per-principal `QuotaService`. - **`@agentback/payments`** — the _paid?_ answer: a `PaymentRail` seam with **x402** (per-call HTTP 402), **MPP** (pre-authorized sessions), and **Stripe** (usage-log metered billing). > A polished, standalone version of the diagram below lives at > [`diagrams/metering-and-payments.html`](diagrams/metering-and-payments.html) — > open it in a browser. This is the framework realization of three questions an enforcement point asks per call — `may-call?` (policy, the [auth stack](overview.md#package-layering)), `paid?` (rail), `metered?` (billing). Auth answers the first; these two packages answer the other two, and both hang off the **principal the auth layer already produces**. ## Metering data flow Every call crosses one of two `protected` dispatch seams. A metered subclass wraps it, times the call, and records a `UsageEvent` attributed to the request's principal. ```mermaid graph LR Call["REST / MCP call
+ bearer token"] subgraph metering["@agentback/metering — metered?"] direction LR P["principal {user}/{client}
(authentication-oauth2)"] RS["metering REST dispatch hook"] MS["metering MCP dispatch hook"] M["Meter.observe()"] C["CompositeUsageSink"] Q["QuotaService"] InMem["InMemory"]; JSONL["JSONL (audit)"]; Redis["Redis"] end Call --> P P -. attribute .-> RS P -. attribute .-> MS RS -->|UsageEvent| M MS -->|UsageEvent| M M --> C M --> Q C --> InMem C --> JSONL C --> Redis ``` **The shape to remember:** the auth principal is the billable identity; the metered server is a thin subclass over the existing dispatch; one `UsageEvent` fans out to as many sinks as you bind. ### The seams `RestServer.dispatch` / `sendResult` / `sendError` and `MCPServer.dispatchTool` are `protected` (see [composition & extensibility](../guides/composition-and-extensibility.md#subclassing-the-dispatcher)). The metered servers subclass them and emit around the base call: ```ts import {MeteredRestServer, MeteringComponent} from '@agentback/metering'; const app = new RestApplication({}); app.server(MeteredRestServer, 'RestServer'); // replaces the default at the same key app.component(MeteringComponent); // in-memory sink + quota + Meter ``` `MeteredMCPServer` does the same for tools. With no `Meter` bound, both behave exactly like their base server — safe to install unconditionally. The principal is read from the auth result (REST) or the request context (MCP), so a metered call carries `{user}`/`{client}` even though authentication runs _inside_ the wrapped dispatch. ### The event ```ts interface UsageEvent { id: string; // ulid; also the idempotency key for sinks at: string; // ISO timestamp principal: {kind: 'user' | 'client' | 'anonymous'; id: string}; surface: 'rest' | 'mcp'; operation: string; // 'Controller.method' or tool name status: 'ok' | 'error' | 'denied' | 'rate_limited' | 'payment_required'; latencyMs: number; units: number; // billable units (default 1) cost?: {amount: string; currency: string}; // priced downstream, not here } ``` `status` is what makes the log an audit trail rather than a counter: a refused call (`denied`/`rate_limited`/`payment_required`) is **recorded with why, but not billed**. `units` is the seam between "a call happened" and "a call cost N"; the price lives downstream. ### Sinks A `UsageSink` is just `record(event)`. Pick one, or fan out with `CompositeUsageSink`: | Sink | Durability | Use | | -------------------- | ---------------------------- | --------------------------------------------- | | `InMemoryUsageSink` | process-local, queryable | dev, tests, the buffer behind a flushing sink | | `JsonlUsageSink` | append-only file, replayable | the durable **audit log** (rung-1 asset) | | `RedisUsageSink` | shared across processes | multi-instance deployments (SADD-deduped) | | `CompositeUsageSink` | fan-out | record to audit **and** bill from one event | All sinks are idempotent on `UsageEvent.id`, so replaying a log is safe. ### Quota `QuotaService.check(principalId)` / `consume(...)` is the _metered?_ enforcement arm — per-principal limits, independent of any rail. The default `InMemoryQuotaService` is a cumulative counter vs a limit map; a windowed or prepaid-credit policy is a downstream implementation of the same interface. > See `examples/hello-oauth2`: it swaps in `MeteredRestServer`, and > `GET /admin/usage` prints the recorded events — `user:user-alice ok`, > `client:svc-importer ok`, `user:user-bob denied` (403), `anonymous denied` > (401). The auth principal flows all the way to attributed usage. ## Payment rails Two _kinds_ of rail, deliberately different shapes: ```mermaid graph TD subgraph gate["Gating rails — PaymentRail.authorize (real time)"] PM["paymentMiddleware (REST)"] --> X["x402 → HTTP 402"] PM --> MPP["MPP → sessions"] PMCP["PaidMCPServer (MCP)"] --> TE["tool error
_meta: challenge → pay + retry"] end subgraph report["Reporting rail — a UsageSink (post-hoc)"] SMS["StripeMeterSink"] --> SB["Stripe metered billing → invoice"] end X -. settle .-> F["facilitator / processor (external)"] MPP -. settle .-> F ``` - **Gating rails** answer `paid?` at call time. `PaymentRail.authorize(ctx)` returns either a `paid` receipt or a `payment_required` challenge. - **The reporting rail** (Stripe) does _not_ gate — it forwards billable events to Stripe, which invoices on its own cycle. It is a `UsageSink`, not a `PaymentRail`. **We orchestrate, we don't settle:** every rail delegates the on-chain transfer or card movement to an external facilitator/processor, injected as an interface (and faked in tests). ### x402 (per-call HTTP 402) ```ts import {X402Rail, paymentMiddleware} from '@agentback/payments'; const rail = new X402Rail({ facilitator, requirements: ctx => [ /* PaymentRequirements */ ], }); restServer.expressApp.post('/premium', paymentMiddleware(rail), handler); ``` No `X-PAYMENT` header → `402` + the `accepts` requirements. A presented payment is `verify`-ed and `settle`-d through the facilitator; success sets `X-PAYMENT-RESPONSE` and runs the handler. Best for the long tail of cheap calls. See `examples/hello-x402` for the full 402 → pay → 200 flow against an in-process fake facilitator. ### MPP (pre-authorized sessions) ```ts import {MppRail, InMemoryMppSessionStore} from '@agentback/payments'; const store = new InMemoryMppSessionStore(); store.open({id: 'sess_1', limit: 1000, spent: 0}); // processor opens this out of band const rail = new MppRail({store, cost: () => 1}); // callers send X-MPP-SESSION ``` Instead of settling per call, MPP streams against a pre-authorized budget — an MPP session _is_ the per-principal budget the `QuotaService` models. Missing / expired / exhausted → `402` with an MPP challenge. The natural MCP rail (no per-call round-trip). ### Stripe (usage-log metered billing) The enterprise path — fiat, no crypto, no gating. `StripeMeterSink` is a `UsageSink` that forwards billable events to Stripe. Compose it with the audit sink so one event both records and bills: ```ts app .bind(MeteringBindings.SINK) .to( new CompositeUsageSink([ new JsonlUsageSink('usage.jsonl'), new StripeMeterSink(reporter), ]), ); ``` Only `status: 'ok'` bills by default; the event id is the Stripe idempotency `identifier`, so re-reporting a log is safe. ## Paying for MCP tools MCP is JSON-RPC — there is no HTTP `402`. So a paid tool called without proof returns a **tool error carrying the challenge in `_meta`**; the agent reads it, pays, and retries. `PaidMCPServer` wires it, and over MCP-over-HTTP it is **end-to-end with no per-app glue** — the proof travels in request headers (`X-PAYMENT` / `X-MPP-SESSION`), exposed to tools via `MCPBindings.REQUEST_INFO`: ```ts import {PaidMCPServer, PaymentMcpBindings} from '@agentback/payments'; app.server(PaidMCPServer, 'MCPServer'); app.bind(PaymentMcpBindings.OPTIONS).to({ railFor: tool => (tool === 'premium_search' ? rail : undefined), }); ``` A free tool passes straight through. A paid tool with no proof returns `{isError: true, content: [...], _meta: {'payments/challenge': }}`. This reuses the framework's existing escape hatch — a `dispatchTool` result with a `content` field passes through to the MCP SDK verbatim — rather than throwing, so the structured challenge survives to the client. ## How it composes - **Metering and payments are complementary.** Metering records _every_ call (the audit log); a gating rail blocks the unpaid ones; the Stripe sink bills the recorded ones. A gated paid call still emits a `UsageEvent`; a `402` emits one with `status: 'payment_required'`. - **Identity is shared.** The `{user}`/`{client}` principal from [`authentication-oauth2`](../../packages/authentication-oauth2/README.md) is the billable account for both — "who do I charge?" is answered per request by the auth layer. - **Everything is a binding.** Sinks, quota, the meter, the rails, and the paid servers are bound in the same `Context` and discovered the same way as any other capability — swap the in-memory sink for Redis, or the fake facilitator for a real one, by rebinding. ## Non-goals No custody, no on-chain broadcasting in-process, no card vault, no IdP. The substrate records `units`; an external billing system prices and bills; an external facilitator/processor settles. The rails and metering carry the seam so a route or tool node can declare its rail later without a code change. # Design: Boundary Coherence for AI-Agent-Led Development **Status:** Draft, captures findings from the May 2026 redesign of `@agentback/{rest, mcp, openapi}`. **Audience:** Future contributors (human or AI) deciding what to add or change in this framework, and downstream users evaluating whether to adopt it for AI-led codebases. **Last revised:** 2026-05-21. ## TL;DR The framework's defensible value for large-scale AI-led TypeScript development is not any single feature (DI, Zod, OpenAPI emission, MCP support). It is the property that **every boundary in the stack is the same artifact, viewed differently**. A single Zod schema simultaneously is: a runtime validator, an `z.infer`-derived TS type, an OpenAPI parameter/body/response, an MCP tool input/output, and a Swagger/Inspector-rendered doc. That artifact-coherence property is what gives AI agents — whose dominant failure mode is producing plausibly-typed code that diverges from the runtime contract — three orthogonal, localized failure signals (TS error at decoration, startup throw, behavioral test fail) instead of one ambiguous one (something is wrong, somewhere). The framework's design decisions are best evaluated by whether they preserve this property. ## The thesis Modern Node/TS API frameworks fall on a spectrum: | | Code↔runtime | Type↔runtime | Service↔service | Tool↔LLM | Human↔docs | | ------------------------- | ------------------- | ------------------------- | -------------------------------------- | ---------------------------------------------- | -------------------------- | | **Express, raw** | `req.body` is `any` | Hand-maintained types | Hand-written OpenAPI | Hand-written tool defs | Hand-written README | | **tRPC** | Zod validation | `z.infer` | TS-only (no language-agnostic export) | Custom adapter | Hand-written | | **NestJS + DI** | class-validator | Decorators on classes | OpenAPI emission via `@nestjs/swagger` | Custom adapter | Swagger UI | | **FastAPI (Python)** | Pydantic | Type hints _are_ Pydantic | OpenAPI auto-emitted | Custom adapter | Swagger UI | | **AgentBack (this)** | Zod | `z.infer` derived | OpenAPI 3.1 auto-emitted from same Zod | MCP `inputSchema`/`outputSchema` from same Zod | Swagger UI + MCP Inspector | The further down the table, the fewer distinct artifacts a contributor (or an AI agent) has to keep coherent. FastAPI's adoption among AI engineers is not coincidental: a Pydantic model is the validator, the type, and the OpenAPI parameter all at once. TypeScript has historically not had a clean version of this story; the framework's bet is that decorators carrying Zod schemas can supply it. ## Three pillars and what each contributes ### 1. DI + extension points (`@agentback/{context, core}`) What it gives an agent: - **Named slots to fill.** Adding an auth strategy, a config provider, a health check, an MCP tool, or a REST controller is a recipe: implement a known shape, bind it under a known key, tag it. The agent has no architectural choices to invent. - **Local reasoning.** Each DI-bound class is self-contained — declared dependencies in the constructor or as properties, no implicit imports from a hidden global registry. - **Composability without modifying core code.** New capability = new binding. The framework discovers it by tag. The agent does not have to find and edit a router config or a switch statement. - **Testability without mocking ceremony.** `app.bind('clock').to(stub)` swaps a dependency for tests; no Jest module-mock incantation needed. What it costs an agent: - **Indirection.** `@inject('clock')` does not click through to the binding. Agents whose primary navigation is grep have to follow the binding key by hand. Typed binding keys (`BindingKey.create('clock')`) help but don't eliminate this. - **Decorator metaprogramming has known TS weak spots.** `TypedPropertyDescriptor` invariance, the legacy-vs-stage-3 decorator transition, `emitDecoratorMetadata`'s crude type information — all surfaces where agent-generated code goes subtly wrong. - **Boilerplate amortizes only at scale.** For a 5-route service the framework is overhead the agent has to maintain. The crossover is somewhere around "multiple teams, plugin surface, AI tool exposure, per-tenant config." ### 2. Zod (`@agentback/openapi`) What it gives an agent: - **One artifact, four uses.** Schema → validator, type (`z.infer`), JSON Schema (`z.toJSONSchema`), OpenAPI/MCP contract — all derived from the same Zod expression. Agents are dramatically better at maintaining one source than at synchronizing several. - **Structured, machine-readable errors.** `ZodError.issues[]` with `path`, `code`, `message` is exactly what agent-written error handlers want to consume. - **Alignment with the AI ecosystem's center of gravity.** OpenAI structured outputs, Anthropic tool_use, Vercel AI SDK's `tool()`, and MCP all consume JSON Schema. Zod produces JSON Schema. The loop closes. - **Composability.** `Base.extend({...})`, `.pick()`, `.omit()`, `.merge()` map to how agents think about iterative refinement. What it costs an agent: - **Inference is opaque under transforms.** `z.preprocess(s => s.trim(), z.string())` produces an `infer` that agents sometimes can't predict, leading to spurious `as` casts. - **Library churn.** Zod 3 → 4 introduced real breaking changes that agents trained on v3 idioms regenerate. We hit this directly in this codebase (`z.string().uuid()` deprecation, `ZodObject` shape changes). ### 3. OpenAPI 3.1 emission (`@agentback/openapi`, mounted by `@agentback/rest`) What it gives an agent: - **Self-describing surfaces.** A service that publishes `/openapi.json` lets downstream agents introspect what routes exist, what they expect, what they return. This is the same loop MCP closes for tools (`tools/list` → `inputSchema`); OpenAPI closes it for HTTP. - **Drift elimination by construction.** Hand-maintained spec docs lag behind code. Auto-emission from decorator metadata cannot drift; the spec changes when the code changes. - **Language-agnostic boundary.** tRPC, ts-rest, Effect.Schema are TS-native. OpenAPI lets a Python service, a Rust service, or a different agent runtime consume your API without speaking TS. - **Documentation and machine contract are the same JSON.** No "is the docs accurate?" question. What it costs an agent: - **Large specs blow context windows.** A 200-route service produces a JSON document that no single LLM call holds. Mitigations (Swagger UI per-tag rendering, `?path=` filters) are partial. We do not currently address this. - **Zod → OpenAPI is good but not perfect.** Complex discriminated unions, recursive types, and refinements sometimes emit JSON Schema that's technically correct but ugly to read — and ugly schemas confuse agents back when they consume them. ## The "boundary coherence" insight The genuinely novel value of the stack is not in any pillar individually; it is in the fact that **the same Zod schema, declared once on the verb decorator (or `@tool`), services every boundary the agent might cross**: | Boundary | What the same schema becomes | | -------------------------- | ------------------------------------------------ | | Code → runtime | Validator (`safeParse`) | | Method signature → handler | TS type (`z.infer`) | | Service → service | OpenAPI `parameter` / `requestBody` / `response` | | Tool → LLM | MCP `inputSchema` / `outputSchema` | | Human → docs | Swagger UI / MCP Inspector rendering | An agent reading or writing a controller has _one place to look_ to understand all five. Compared to a typical Node/TS stack — TS types in `types.ts`, validators in `validators.ts`, OpenAPI in `swagger.yaml`, AI tools in `tool-defs.ts`, docs in `README.md` — that's a substantial reduction in surface area the agent has to keep coherent. This is exactly the Python-FastAPI promise transplanted to TypeScript by way of decorators that carry Zod schemas. The framework's design decisions (the rewrites described below) preserve this property at compile time, so agents catch boundary mismatches at the decoration line instead of at the second integration test. ## Why this is more aligned with spec/type-driven than test-driven development The decorator _is_ the spec: ```ts @post('/items', { body: CreateItem, response: Item, responses: {404: {schema: NotFound}, 422: {schema: ValidationFailure}}, status: 201, }) async create(input: {body: z.infer}) { throw new Error('TODO'); } ``` By the time this compiles, you have committed to: - the input contract (Zod schema → runtime validation + TS type), - the URL contract (`/items`, status 201), - the success-shape contract (`response: Item`), - the documented error contracts (`404`, `422`), - the OpenAPI document at `/openapi.json`, - the MCP-style introspectable signature (for inspector tools). The implementation is the only thing missing. That is spec-first in the literal sense: the contract lands before the behavior, the contract is enforced by the compiler, and the spec emission happens for free. The implementation cannot drift from the spec: - **TS** enforces that `input.body` matches `z.infer`. A wrong parameter shape errors at the `@post` line. - **Runtime** validates the return against `response:` (logged on mismatch); the URL-placeholder guard at `app.start()` catches path-shape drift. - **The OpenAPI doc** is emitted, not maintained — so it cannot drift from the schema. This is closer to **type-driven development** (Idris-style: the type carries enough information that the implementation is constrained) than to either pure spec-driven or pure TDD. The framework does not replace TDD; tests verify _behavior_ (does the route persist the item, does auth reject wrong users, does pagination round-trip), while the contract layer absorbs the "is the shape right?" class of bugs. The full cycle that emerges: 1. **Spec layer**: write Zod schemas + decorator. One artifact. 2. **Type check**: TS verifies the implementation matches the contract. 3. **Behavior tests**: assert valid inputs produce correct outputs. 4. **Refactor**: change the schema; type errors guide the edits; tests verify nothing semantic regressed. Stages 1 and 2 are spec-driven; stage 3 is TDD; stage 4 leverages the type system to localize the work. ## The agent ergonomics angle Agents iterate best when failure signals are **precise** and **localized**. A spec-first framework gives the agent three distinct failure classes: | Signal | What it points at | Where it fires | | ------------- | -------------------------------------------------------------------------------- | --------------- | | TS error | Implementation type drifted from schema | Decoration line | | Startup throw | URL ↔ path-schema mismatch, slot-0 `@inject` collision, missing required binding | `app.start()` | | Test failure | Behavioral bug | Test runner | The agent gets three orthogonal red signals, each pointing at a different category of mistake. The schema-as-spec layer absorbs the "shape mistake" class entirely, so when a test fails the agent knows the bug is behavioral, not structural. That's a meaningful reduction in the search space the agent has to navigate. This also means agent-led development can be **schema-first, test-second**: 1. Agent writes Zod schemas + decorators (the spec). 2. Agent runs `pnpm build`; the type system confirms the spec compiles. 3. Agent writes tests against the declared contract. 4. Agent writes the implementation. TS errors guide the structural work; tests guide the behavioral work. Compared to an Express + raw-Zod stack where the agent has to keep the route handler, the validator, the type, and the OpenAPI doc in sync by hand: fewer sources of truth, less drift, fewer cycles. ## Implementation evidence: what the framework does to preserve the property The recent rewrites of `@agentback/{rest, mcp, openapi}` are all in service of preserving boundary coherence at compile time. ### Slot-0 input bundle on verb and tool decorators ```ts @get('/hello/{name}', {path: HelloPath, response: Greeting}) async hello(input: {path: z.infer}) { ... } @tool('forecast', {input: ForecastIn, output: ForecastOut}) async forecast(input: z.infer) { ... } ``` A `TypedPropertyDescriptor` constraint on slot 0 forces the method's first parameter to satisfy `z.infer` of the declared schemas. A mismatch errors at the decoration line with the property difference surfaced precisely. Without this, the implementation can silently drift from the spec and the agent only finds out at request time. Replaced: per-parameter `@param.path` / `@requestBody` / `@response` / `@arg` decorators that required the agent to keep three declarations in sync (decorator, parameter type, schema). ### Compile-time output enforcement on `@tool` ```ts @tool('forecast', {input: ForecastIn, output: ForecastOut}) async forecast(input: z.infer) { return {wrong: 'shape'}; // ^^^^^^^^^^^^^^^ TS error at @tool line: not assignable to z.infer } ``` The decorator's generic R constrains the method's return type. Runtime validation belt-and-suspenders the same check at invocation. The MCP SDK is given `outputSchema` so structured-content clients consume the typed payload directly. ### URL placeholder ↔ path schema check at `app.start()` ```ts @get('/users/{id}', {path: z.object({userId: z.string()}), response: User}) // ^^ ^^^^^^ ``` Throws at startup with `Bad.getOne @get('/users/{id}'): path placeholders don't match the path schema — URL has {id} but schema doesn't; schema has [userId] but URL doesn't.` Without this, the route silently 422s on every request — exactly the class of mistake agents make often and notice late. ### Centralized dispatch with subclassable hooks `RestServer.dispatch` / `sendResult` / `sendError` are protected and overridable. The standard LB4 sequence pattern (`findRoute → parseParams → invoke → send → reject` as a five-action DI pipeline) is gone, but the most common use cases (response envelopes, custom error contracts, request-scoped tracing) are covered by single-method overrides: ```ts class EnvelopeRestServer extends RestServer { protected override sendResult(res, result, status) { res.status(status).json({ok: true, data: result}); } } ``` This is the explicit escape hatch for the "I want to wrap responses uniformly" class of need that previously required reaching for the full sequence pattern. ### Middleware chain wired into RestServer `RestApplication` extends `MiddlewareMixin(Application)`. `RestServer.start()` mounts `toExpressMiddleware(this.context)` before route handlers. Users register cross-cutting concerns via `app.middleware(fn)` / `app.expressMiddleware(factory)`; middleware runs through the framework's chain and can short-circuit (CORS preflights, rate limit rejections, probes) before reaching the dispatcher. ### CORS as a typed config knob ```ts app.configure('servers.RestServer').to({ cors: {origin: 'https://example.com', credentials: true}, }); ``` One configuration field, a thin wrapper around the `cors` package, mounted globally in the server constructor. The previous "CORS is a non-goal" framing was misleading — it was unwritten sugar, not an architectural omission. ## Honest limits The thesis is not a free lunch. Where the framework's bet is weaker: ### Decorator typing is the binding constraint Every advance in the framework's agent-friendliness has been a fight against TS's classic decorator type system. The `TypedPropertyDescriptor` invariance issue alone forced two different workarounds (constrained generic R for input, conditional `RouteDescriptor` for slot-0). The framework cannot get materially more typesafe than the underlying TS decorator implementation allows. Migration to stage-3 decorators may eventually loosen this; we do not currently plan it. ### OpenAPI from Zod is correct but not always pretty `z.toJSONSchema` produces draft-2020-12 output that satisfies OpenAPI 3.1's dialect. For simple object schemas the output is clean. For Zod features like discriminated unions with branded predicates, recursive types, and complex refinements, the emitted JSON Schema is technically valid but unwieldy. Downstream agents consuming these specs can struggle. We do not currently post-process for readability. ### Boundary coherence requires _adoption_ of the pattern A user who escapes via `restServer.expressApp.use(...)` for routing, or writes a custom validator in the route handler body, exits the boundary-coherence property for that route. The framework cannot enforce its own use; it can only make the in-pattern path the easy one. Documented escape hatches (CORS option, subclassable dispatch) are the explicit support for "I need to step outside the pattern, here is the supported way." Ad-hoc bypass via the express app is supported but unblessed. ### The crossover threshold matters For genuinely small services (a handful of routes, no plugin surface, no multi-team consumption, no AI tool exposure), the framework is overhead the agent has to maintain. The break-even point is "your app is big enough that boundary coherence costs more to maintain by hand than it costs in framework conventions." Below that line, `Hono + zValidator` is simpler and the agent does fine. Above that line, the framework's bet pays back. ### Not OpenAPI-first in the canonical sense A pure spec-first workflow writes `openapi.yaml` standalone, then generates code stubs from it. We do the inverse: write decorators, emit OpenAPI. This means: - If you have an existing OpenAPI 3.1 contract you want to honor, the framework does not scaffold controllers for you. You translate by hand. - Schemas live in TypeScript. Non-TS teams can read the emitted OpenAPI but cannot author against the source-of-truth Zod schemas directly. The canonical cross-team artifact is the OpenAPI export, not the Zod schema. For most codebases this is the right trade. For codebases where the API spec is the source of truth and many teams build against it, the inversion would matter. ## Design decisions this thesis informs Whenever the framework gains a feature or absorbs an external pattern, the question to ask is: **does this preserve or degrade boundary coherence?** Examples of decisions the thesis has informed (or would inform): | Decision | Preserves coherence? | | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | | Replacing `@param`/`@requestBody`/`@response` with verb-decorator options | Yes (one source of truth) | | Adding `@arg` per-parameter decoration to MCP `@tool` | No (two declarations) — _reverted_ | | Adding `output:` to `@tool` with typed return constraint | Yes (return type derives from schema) | | Adding LB4-style sequences/actions with DI-bound pipeline steps | No (introduces new abstraction layer that doesn't share artifacts) — _kept out_ | | Adding CORS via `RestServerConfig.cors` | Neutral (orthogonal to the schema artifact) — _adopted_ | | Adding subclassable `dispatch` / `sendResult` / `sendError` | Yes (preserves spec, lets users override behavior) — _adopted_ | | Adding a separate `openapi.yaml` spec authoring step | No (introduces a second source of truth) — _kept out_ | The thesis is not "every new feature must touch the Zod schema." It is "no new feature should create a second source of truth that the agent has to keep in sync with the Zod schema." ## Open questions 1. **Tool/route filtering for large specs.** A 200-tool MCP server or 200-route REST service produces a manifest no single LLM call can hold. Should the inspector/openapi expose `?include=...` or `?tag=...` filters so agents can fetch scoped subsets? 2. **JSON Schema readability.** Zod's emitted schemas are sometimes ugly. Worth post-processing for cleaner downstream consumption? 3. **OpenAPI-first inversion.** If demand emerges from cross-team consumers, would we add `lb4-from-openapi` codegen? At what point does maintaining two source-of-truth artifacts become acceptable? 4. **MCP HTTP/SSE transport.** Stdio works today. HTTP is the obvious next addition once the SDK transport is wired in. Should the same `RouteOptions` shape extend to MCP HTTP routes for symmetry? 5. **Per-tenant or per-version OpenAPI emission.** Multi-tenant SaaS deployments may want versioned or tenant-scoped specs. Does the framework grow that, or do users build it on top via the existing enhancer extension point? ## References within this codebase - Schema-on-decorator pattern: `packages/openapi/src/decorators/operation.decorator.ts`, `packages/mcp/src/decorators/tool.decorator.ts` - Per-route schema registry: `packages/openapi/src/zod-bridge.ts` (`registerRouteSchemas` / `lookupRouteSchemas`) - OpenAPI assembly from `RouteOptions`: `packages/openapi/src/controller-spec.ts` - Subclassable REST dispatch: `packages/rest/src/rest.server.ts` (`makeHandler` / `dispatch` / `sendResult` / `sendError`) - MCP tool dispatch with `@inject` weaving: `packages/mcp/src/mcp.server.ts` (`dispatchTool`) - Middleware chain wiring: `packages/rest/src/rest.application.ts` (`MiddlewareMixin(Application)`), `rest.server.ts` (`toExpressMiddleware(this.context)`) - Type-enforcement tests (deliberate mismatches): see commit messages on `feat(mcp)!: object-style tool input`, `feat(rest, mcp)!: method-level Zod schemas`. ## Glossary - **Boundary coherence**: the property that every API boundary (runtime, type, doc, AI tool) is derivable from the same source artifact (a Zod schema), so the artifact cannot disagree with itself. - **Slot-0 rule**: the method's first parameter (slot 0) is reserved for the validated input bundle when the verb/tool decorator declares any input schemas. `@inject` parameters live at slot 1+. When no schemas are declared, slot 0 is unconstrained and can carry `@inject` directly. - **Spec-first / type-driven development**: a workflow where the contract (Zod schema + decorator options) is declared before the implementation, and the type system enforces the implementation conforms. - **Localized failure signal**: an error message that names the file, line, and conceptual category of the mistake (e.g., "TS error at the `@post` line because `input.body.title` is missing"). Agents iterate dramatically faster on localized signals than on diffuse ones ("a test is failing somewhere"). # Proposal: Drizzle as the Blessed Database Recipe **Status:** Drizzle recipe implemented; MCP Toolbox remains a follow-up proposal. **Audience:** Framework contributors evaluating the DB story, and downstream users picking an ORM. **Last revised:** 2026-06-10. **Related:** [agent-ergonomics.md](agent-ergonomics.md) — the boundary-coherence thesis this builds on. ## TL;DR The framework ships **`@agentback/drizzle`** — a thin DI integration around [Drizzle ORM](https://orm.drizzle.team) — as the blessed recipe for app-owned databases. The integration is small: a binding key, a lifecycle observer for pool shutdown, and a `/zod` subpath with `drizzle-zod` re-exports so route schemas derive from table schemas. A secondary recipe is proposed for federated / enterprise databases via Google's [MCP Toolbox](https://github.com/googleapis/mcp-toolbox) — a separate service that exposes database operations as MCP tools. A LoopBack Agent wrapper for MCP Toolbox is not currently part of this repo. We will **not** ship an in-house ORM, will **not** port `@loopback/repository`, will **not** introduce a `Filter` / `Where` query DSL, and will **not** generate clients from a non-TS schema language. The choice of Drizzle is driven by **boundary coherence**: a Drizzle table declaration extends the framework's "one artifact, all boundaries" property one layer down to the database. The same TypeScript that defines the table generates the runtime row type, the Zod insert/select/update schemas, the `z.infer` parameter types on routes, the OpenAPI emission, and (when exposed via `@tool`) the MCP input/output. No codegen step, no DSL, no third-party Zod generator plugin. ## The decision **Primary recipe: Drizzle ORM, integrated via `@agentback/drizzle`.** ```ts // db/schema.ts — single source of truth import {pgTable, serial, text, timestamp} from 'drizzle-orm/pg-core'; import { createInsertSchema, createSelectSchema, } from '@agentback/drizzle/zod'; export const users = pgTable('users', { id: serial('id').primaryKey(), email: text('email').notNull().unique(), name: text('name').notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), }); export const NewUser = createInsertSchema(users, { email: schema => schema.email.email(), }); export const User = createSelectSchema(users); ``` ```ts // controllers/users.controller.ts import {z} from 'zod'; import {eq} from 'drizzle-orm'; import {api, get, post} from '@agentback/openapi'; import {inject} from '@agentback/context'; import {DrizzleBindings} from '@agentback/drizzle'; import type {NodePgDatabase} from 'drizzle-orm/node-postgres'; import {NewUser, User, users} from '../db/schema.js'; import * as schema from '../db/schema.js'; @api({basePath: '/users'}) export class UsersController { constructor( @inject(DrizzleBindings.CLIENT) private db: NodePgDatabase, ) {} @get('/', {response: z.array(User)}) async list(): Promise[]> { return this.db.select().from(users); } @get('/{id}', { path: z.object({id: z.coerce.number().int()}), response: User, responses: {404: {description: 'Not found'}}, }) async getOne(input: {path: {id: number}}): Promise> { const [row] = await this.db .select() .from(users) .where(eq(users.id, input.path.id)); if (!row) throw createError(404, `User ${input.path.id} not found`); return row; } @post('/', {body: NewUser, response: User, status: 201}) async create(input: { body: z.infer; }): Promise> { const [row] = await this.db.insert(users).values(input.body).returning(); return row; } } ``` **Secondary recipe: MCP Toolbox** — proposed for "I want to call a query that lives on another team's Toolbox server." ## What we ship ### `@agentback/drizzle` (primary, ~100 LoC) A thin DI integration. Drizzle does the ORM work; this package does framework-shaped wiring. **Exports:** - `DrizzleBindings.CLIENT` — binding key for the Drizzle instance. Apps keep exact driver/schema types with a local type alias. - `registerDrizzle(app, client, opts?)` — binds the client and, when `onStop` is supplied, registers a graceful-shutdown lifecycle observer. - `@agentback/drizzle/zod` — optional subpath that re-exports `createInsertSchema`, `createSelectSchema`, and `createUpdateSchema` from `drizzle-zod`. **Component contract:** ```ts import {registerDrizzle} from '@agentback/drizzle'; import {drizzle} from 'drizzle-orm/node-postgres'; import {Pool} from 'pg'; import * as schema from './db/schema.js'; const pool = new Pool({connectionString: process.env.DATABASE_URL}); const db = drizzle(pool, {schema}); registerDrizzle(app, db, {onStop: () => pool.end()}); ``` The factory is generic over the Drizzle dialect (`NodePgDatabase`, `BetterSQLite3Database`, `MySql2Database`, …) so users keep precise types in their controllers. The package itself doesn't pick a database driver. **What the component does internally:** 1. Binds the client value under `DrizzleBindings.CLIENT` or a caller-supplied key. 2. Registers a `LifeCycleObserver` whose `stop()` calls the user-supplied `onStop()`. This ensures pools close on `app.stop()` rather than leaking on test shutdown or graceful-restart. That's the whole abstraction. ### Proposed MCP Toolbox integration (secondary, ~150 LoC) A thin wrapper around `@toolbox-sdk/core` for the federated-DB-as-MCP-tools case. **Exports:** - `ToolboxBindings.CLIENT` — the configured `ToolboxClient`. - `ToolboxBindings.TOOLS` — a `Map` of loaded tools, populated at start. - `toolboxComponent(opts)` — factory that binds the client and registers a lifecycle observer that connects + loads the requested toolset on `app.start()`. - Optional: re-expose loaded Toolbox tools as MCP tools on the framework's own `MCPServer` (so an MCP client connecting to your app sees both your business tools and your DB-via-Toolbox tools). **Component contract:** ```ts // Pseudocode: this wrapper is proposed, not shipped today. app.component( toolboxComponent({ url: process.env.TOOLBOX_URL ?? 'http://localhost:5000', toolset: 'production-queries', // Optional: re-expose loaded tools through our own MCPServer exposeAsMcp: true, }), ); ``` Controllers `@inject(ToolboxBindings.TOOLS)` and call individual tools by name. ### Examples - **`examples/hello-drizzle`** — full Drizzle setup with SQLite in-memory database, table schema, `createInsertSchema`/`createSelectSchema`, three CRUD routes, a tiny migration runner, and tests that demonstrate binding swap for repository-style isolation. - **`examples/hello-toolbox`** — proposed follow-up example for a controller calling a Toolbox-exposed query. ## What we don't ship These are explicit non-goals; the design rejects them. - **No in-house ORM.** Drizzle does ORMs better than we would. - **No port of `@loopback/repository` or `juggler`.** Both are large surfaces with weak TS stories that mismatch the framework's small-surface, Zod-first thesis. Reintroducing them would be the same kind of decision we already rejected with sequences/actions. - **No `Filter` / `Where` query language.** Drizzle's typed query builder is what users get. Stringly-typed query languages are the opposite of boundary coherence. - **No `@model` / `@property` decorators with `@AgentBack`-namespaced metadata.** Drizzle's `pgTable(...)` syntax is the schema; we don't reinvent it. - **No in-house migration tool.** `drizzle-kit` exists, is well-maintained, and produces TS migration files. The framework's CLI (if and when it appears) might wrap it, but won't replace it. - **No support for the Prisma client out of the box.** Users who prefer Prisma can wire it themselves under the same DI patterns (bind `PrismaClient` to a context key, register a `LifeCycleObserver` for `$disconnect`) — but we don't ship a Component for it. A short "alternative ORMs" section in the README documents the pattern. ## How this fits the boundary-coherence thesis The framework's distinctive value is that every API boundary (runtime, type, OpenAPI, MCP, docs) is derivable from the same Zod schema declared on a verb/tool decorator. Drizzle extends this property one layer further: ``` pgTable('users', {...}) ← one table definition ├─ drizzle row type ← TS type for DB operations ├─ createInsertSchema(users) ← Zod schema for inserts ├─ createSelectSchema(users) ← Zod schema for selects ├─ createUpdateSchema(users) ← Zod schema for updates └─ used as @post({body: ...}) ← route contract, automatically: ├─ runtime validator (Zod safeParse) ├─ TS parameter type (z.infer) ├─ OpenAPI requestBody / response └─ MCP inputSchema / outputSchema (if exposed via @tool) ``` A user reading `users.controller.ts` sees `User` and `NewUser` and knows they come from `users` in `db/schema.ts` — one click away, no codegen folder to navigate. A user **changing** the table — adding a column, renaming a field, tightening a constraint — does it once in `db/schema.ts`. TypeScript flags every dependent file. Tests fail loudly. The OpenAPI doc updates on the next request to `/openapi.json`. No drift between "what the DB has," "what the validator accepts," "what the type says," and "what the docs claim." Compare to a Prisma stack: ``` schema.prisma ← .prisma DSL (non-TS) ↓ prisma generate ← codegen step #1 @prisma/client types ↓ prisma-zod-generator ← codegen step #2 (third-party plugin) generated Zod schemas └─ used as @post({body: ...}) ``` Three artifacts, two codegen steps, drift opportunities at each transition. Forget to run `prisma generate` after a `schema.prisma` edit and TS won't catch it. The chain works, but it works _despite_ the codegen step, not because of it. For an agent-led codebase, the "forgot to regenerate" failure mode is recurrent. Drizzle eliminates the failure mode by not having the step. ## Migration story Migrations live under `db/migrations/` as TS files generated by [`drizzle-kit`](https://orm.drizzle.team/kit-docs/overview). The recipe is standard Drizzle: ```bash # Initial schema → migration files pnpm drizzle-kit generate # Apply pending migrations pnpm drizzle-kit migrate # For development: push schema directly without migrations pnpm drizzle-kit push ``` We do **not** ship migration tooling of our own. We do **not** auto-run migrations on `app.start()` (that's a deployment-policy decision, not a framework one). We do provide a documented helper for "run pending migrations from a lifecycle observer" if a user wants that behavior — but the default is "you run migrations as a deployment step." Project layout convention (documented, not enforced): ``` src/ db/ schema.ts ← source of truth (Drizzle tables + drizzle-zod schemas) migrations/ ← drizzle-kit output (committed to git) 0000_init.sql 0001_add_users_table.sql meta/ _journal.json client.ts ← creates the pool + drizzle instance controllers/ users.controller.ts drizzle.config.ts ← drizzle-kit config (schema path, out dir, dialect) ``` ## Testing story Three patterns ship as documented recipes; the first is the recommended default. ### 1. In-memory SQLite with the real schema (recommended) ```ts import {drizzle} from 'drizzle-orm/better-sqlite3'; import Database from 'better-sqlite3'; import {migrate} from 'drizzle-orm/better-sqlite3/migrator'; import * as schema from '../db/schema.js'; beforeEach(async () => { const sqlite = new Database(':memory:'); const db = drizzle(sqlite, {schema}); migrate(db, {migrationsFolder: './src/db/migrations'}); app = new RestApplication(); app.component(drizzleComponent({client: db, shutdown: () => sqlite.close()})); // … }); ``` Real schema, real queries, deterministic. Fast enough for unit-test-like cycles. Works if you keep the schema dialect-agnostic (which Drizzle makes easy if you stick to portable column types). ### 2. Docker-compose Postgres for integration tests Standard pattern; nothing framework-specific. Document the `docker-compose.test.yml` shape. ### 3. Bind a fake `db` for pure controller unit tests ```ts app.bind(DrizzleBindings.CLIENT).to(stubDb); ``` For tests that don't care about query correctness, just controller logic. Works because the DI binding is swappable. We won't ship a "test DB harness" — these patterns are 10 lines each and standard Drizzle usage. ## Multi-database support Drizzle ships first-party drivers for Postgres (node-postgres, postgres-js, neon), MySQL (mysql2, planetscale), SQLite (better-sqlite3, libsql), and AWS Data API. Our package is generic over the driver — the user picks at app-init time. We don't add per-database support packages. What we **do** document, for each blessed database: - The driver to install. - The `drizzle(...)` invocation shape. - The pool / connection-management considerations (serverless vs long-running). - The corresponding `drizzle-kit` config dialect. That's a doc page (~50 lines per database), not code. ## Lifecycle and graceful shutdown `registerDrizzle` registers a `LifeCycleObserver` whose `stop()` calls the user-supplied `onStop` callback. This means: - `await app.stop()` closes the pool. - Tests that `afterEach(() => app.stop())` don't leak connections. - Production processes shutting down on `SIGTERM` close cleanly. If the user omits `onStop`, no observer is registered (some drivers like `better-sqlite3` are synchronous and don't need it; others do). ## Alternatives considered (and why we rejected them) ### Port `@loopback/repository` What it would give us: comprehensiveness, familiarity for LB4 users. Why we rejected it: - The surface is enormous — `juggler`, connectors, `Filter`/`Where`, `DefaultCrudRepository` plus N specialization types. The framework's small-surface thesis would be broken. - Type safety is weak — string-keyed filters, runtime-only operator validation. - Doesn't compose with our Zod-first thesis. Reintroducing it would create a parallel schema source of truth (model decorators vs Zod schemas). - We already declared it out-of-scope in the README. That was the right call; this proposal continues it. ### Prisma What it would give us: best-in-class generated types, mature migration tooling, much larger AI training corpus. Why we rejected it as primary: - Schema lives in a non-TS DSL (`schema.prisma`). Breaks the "everything in TS" property at the database layer. - Two codegen steps to reach Zod (`prisma generate` + `prisma-zod-generator`). Three artifacts to keep coherent. - The codegen step is a recurrent agent footgun ("forgot to regenerate after schema change"). - Heavier runtime than Drizzle (Rust query engine or driver adapter, plus generated client). Prisma remains a fully supported alternative — we document the recipe in the README and ship no Component, just the pattern. Teams migrating from NestJS+Prisma can keep using Prisma; the framework's DI patterns handle it identically. ### TypeORM What it would give us: largest legacy install base in the Nest world. Why we rejected it: - TS types are historically weak (relations, partial updates, `Partial` patterns). - Active-record style fights the framework's "controllers are bindings, services are bindings" composition. - The community is migrating away from TypeORM toward Prisma and Drizzle; betting on it would be backward-looking. ### Sequelize / MikroORM Considered briefly. Neither materially better than the alternatives above for our specific thesis. Neither shipped as a recipe; users wiring them in get the same DI patterns. ### MCP Toolbox as the _only_ DB story What it would give us: zero ORM code to maintain, multi-database support for free, federated queries, no in-app schema. Why we rejected it as primary (but kept as secondary): - Latency: every query is a network hop through the Toolbox server. - Queries are predefined in `tools.yaml`. No ad-hoc parameterized queries from app code. - Loses end-to-end TS type safety — the type contract is Toolbox's tool schema, not your table schema. - Wrong fit for "I want a local Postgres for my app's primary data." It's the right tool for cross-team, cross-database, cross-language federated access — which is why it ships as a separate recipe. ### Kysely A pure query builder, no schema-as-TS, no migration tooling. Drizzle is a superset for our purposes (it does query-building too) plus brings migrations and drizzle-zod. No reason to pick Kysely over Drizzle for this specific thesis. ## Implementation plan ### Phase 1 — `@agentback/drizzle` (implemented) - New workspace package `packages/drizzle/`. - `DrizzleBindings.CLIENT` binding key. - `registerDrizzle(app, client, {key?, onStop?})`. - Re-export `drizzle-zod` helpers from the `/zod` subpath. - Unit tests: client binding, multiple keys, lifecycle observer calls `onStop` once. - README + JSDoc. ### Phase 2 — `examples/hello-drizzle` (follow-up) A working end-to-end example: - SQLite in-memory (no external DB needed for `pnpm test`). - `db/schema.ts` with one table. - `drizzle.config.ts` (so users can copy the pattern). - A CRUD controller using `createInsertSchema` / `createSelectSchema`. - Tests demonstrating the route + DB integration. ### Phase 3 — MCP Toolbox integration (follow-up) - New workspace package `packages/mcp-toolbox/`. - `ToolboxBindings.CLIENT` / `ToolboxBindings.TOOLS`. - `toolboxComponent({url, toolset, exposeAsMcp?})`. - Lifecycle observer connects + loads toolset on start, disconnects on stop. - Optional MCP re-exposure path: register each Toolbox tool as an `MCPServer` tool with the same input/output schemas. - Tests: mock Toolbox server with `@modelcontextprotocol/sdk` and verify tools are loaded + invoked. ### Phase 4 — `examples/hello-toolbox` (week 2, parallel) A working federated-DB example. Likely against a public Toolbox demo deployment or a docker-compose Toolbox + Postgres. ### Phase 5 — Documentation (week 2 cleanup) - Add a "Database" section to README, pointing at both recipes. - Update CLAUDE.md's "available now" list. - Extend `docs/agent-ergonomics.md` with a short "Database boundary" subsection that points at `db-story.md` and reaffirms how Drizzle extends the boundary-coherence property. - Remove `@loopback/repository` references from non-goals (they're already absent, but worth a doc sweep). ### Out of scope for this proposal - Auto-emitting routes from a Drizzle schema (`createCrudController(users, {basePath: '/users'})` style sugar). Possible later; not load-bearing for the recipe. - Drizzle relations API integration with OpenAPI `$ref` emission. The current `drizzle-zod` story handles flat row schemas cleanly; relations produce nested objects that emit fine but might benefit from `$ref` reuse in the OpenAPI doc. - Multi-tenant query helpers. Standard Drizzle patterns work today. - Connection-string-from-config conveniences. Trivial for users; not worth a framework abstraction. ## Open questions 1. **Should the component accept a factory instead of a pre-built client?** Currently the user builds `drizzle(pool, {schema})` and passes it in. An alternative is `drizzleComponent({factory: (ctx) => drizzle(pool)})` — factory has access to the application context, which might be useful for config-driven pool setup. **Recommendation:** ship both. The plain `client:` form is simpler; the `factory:` form is for users who need context access. 2. **Should we re-expose `drizzle-kit` migrations through a framework command?** E.g., a built-in `app.runMigrations()` or `pnpm -F hello-drizzle migrate`. **Recommendation:** no for v0. Migrations are a deployment-policy concern, and the framework has historically avoided becoming a CLI host. 3. **What about Drizzle's "active query" / live updates story?** Drizzle is exploring reactive query subscriptions. **Recommendation:** track upstream; nothing in our framework precludes their integration when it lands. 4. **Should the MCP Toolbox integration be in the framework repo or a separate package?** The same question applies to all third-party-service integrations. **Recommendation:** in-repo for v0 so it benefits from the workspace's build/lint/test infrastructure. If/when integrations proliferate, factor them out. 5. **How do we handle the `drizzle-zod` version drift question?** `drizzle-zod` is a small package but versions can lag behind Drizzle. **Recommendation:** pin both deps as peer-deps of `@agentback/drizzle` and document compatible version ranges in the package README. Same approach as the `@modelcontextprotocol/sdk` peer-dep on `@agentback/mcp`. 6. **Does Drizzle's experimental "RQB" (Relational Queries Builder) interact with the Zod story?** RQB returns nested relation objects; `drizzle-zod` doesn't currently auto-derive Zod schemas for nested relation results. **Recommendation:** initial recipe sticks to `db.select()` / `db.insert()` / `db.update()` flat-row patterns. RQB integration is a follow-up if user demand emerges. ## Decision criteria checklist A new database integration is considered a candidate for first-class support if and only if: 1. **Schema lives in TypeScript.** No external DSL files for the schema. 2. **Type derivation is runtime or zero-step.** Generated clients with required codegen steps fail this check. 3. **Native Zod schema derivation.** Either built-in (Drizzle's `drizzle-zod`) or trivially derivable (`z.object` from the type). Third-party generators with significant version drift fail. 4. **Compatible with our DI patterns.** Binds cleanly to a context, supports lifecycle observers, doesn't require app subclassing. 5. **Maintains the framework's small surface.** A new integration that requires 10+ packages or a separate CLI fails. Drizzle passes all five. Prisma fails (1) and (2). TypeORM fails (3) and (4). `@loopback/repository` fails (1), (3), and (5). MCP Toolbox passes (4) and (5) but doesn't meet (1)–(3) because it lives out-of-process — which is why it ships as a secondary recipe, not as the primary database story. ## References - Drizzle ORM: https://orm.drizzle.team - `drizzle-zod`: https://orm.drizzle.team/docs/zod - `drizzle-kit`: https://orm.drizzle.team/kit-docs/overview - MCP Toolbox: https://github.com/googleapis/mcp-toolbox - `@toolbox-sdk/core`: https://github.com/googleapis/mcp-toolbox-sdk-js/tree/main/packages/toolbox-core - The boundary-coherence thesis this builds on: [agent-ergonomics.md](agent-ergonomics.md)