@aventara/nest 1.0.0-rc.0 → 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,169 @@
1
1
  # @aventara/nest
2
2
 
3
+ ## 1.0.0-rc.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 7e3d06f: Breaking: the configuration has a new shape. Every layer (the root, `application` and `client`) now holds the
8
+ same six keys: `exposed`, `transactionsStyle`, `limits`, `pipelines`, `resources` and `exemptions`. Everything about
9
+ one Resource lives under `resources.<Resource>`: its fields, and each field's `definition` and `pipelines`. The old
10
+ keys are refused when the framework starts, with a message that names where each one moved:
11
+
12
+ | Before (rc.0) | Now (rc.1) |
13
+ | ------------------------------------------------------- | ----------------------------------------------------------------------------------- |
14
+ | `fields: { Post: { url: { type, nullable, … } } }` | `resources: { Post: { fields: { url: { definition: { type, nullable, … } } } } }` |
15
+ | `behaviors: { Post: { fields: { slug: { create } } } }` | `resources: { Post: { fields: { slug: { pipelines: { compute: { create } } } } } }` |
16
+ | `transactions: "none"` | `transactionsStyle: "none"` |
17
+ | `restrictions` | `exposed` + `exemptions` (see below) |
18
+ | `@aventara/core/json` | `@aventara/core/avz` |
19
+
20
+ Before:
21
+
22
+ ```ts
23
+ createFramework({
24
+ api: "/api",
25
+ adapter,
26
+ transactions: "interactive",
27
+ fields: {
28
+ Post: {
29
+ url: {
30
+ type: { scalar: "string" },
31
+ nullable: false,
32
+ list: false,
33
+ lifecycle: ["VIRTUAL"],
34
+ },
35
+ },
36
+ },
37
+ behaviors: {
38
+ Post: {
39
+ fields: {
40
+ url: {
41
+ read: {
42
+ dependsOn: ["slug"],
43
+ compute: ({ record }) => `/p/${record.slug}`,
44
+ },
45
+ },
46
+ },
47
+ },
48
+ },
49
+ });
50
+ ```
51
+
52
+ After:
53
+
54
+ ```ts
55
+ createFramework({
56
+ api: "/api",
57
+ adapter,
58
+ transactionsStyle: "interactive",
59
+ resources: {
60
+ Post: {
61
+ fields: {
62
+ url: {
63
+ definition: {
64
+ type: { scalar: "string" },
65
+ nullable: false,
66
+ list: false,
67
+ lifecycle: ["VIRTUAL"],
68
+ format: "url",
69
+ },
70
+ pipelines: {
71
+ compute: {
72
+ read: {
73
+ dependsOn: ["slug"],
74
+ compute: ({ record }) => `/p/${record.slug}`,
75
+ },
76
+ },
77
+ },
78
+ },
79
+ },
80
+ },
81
+ },
82
+ });
83
+ ```
84
+
85
+ What else changed:
86
+
87
+ - A field is defined at the root only. A `definition` in `application` or `client` is refused
88
+ (`CONFIG_FORBIDDEN_IN_OVERLAY`). Define the field
89
+ at the root; `application` and `client` can still add compute stages to it.
90
+ - Computed values chain. When the root and an overlay both compute a field, the root's stage runs first and the
91
+ overlay's receives its result as `value`. A stage can no longer be turned off with `false`. Returning `undefined`
92
+ keeps the current value. An upsert computes its create branch with `compute.create` and its update branch with
93
+ `compute.update`.
94
+ - A discovered field keeps the adapter's facts. On a field the adapter discovered you may restate its own `type`,
95
+ `nullable` and `list`, and give a Json field its shape (`definition.json`). Setting a different type, any `format`
96
+ or any `capabilities` on it is refused. `format` is for fields you add, and it must be one of zod's named string
97
+ formats (`"email"`, `"url"`, `"uuid"`, …); any other name is a type error and refused at startup.
98
+ - A misplaced key is a type error. In a configuration written inline in `createFramework({ ... })` or
99
+ `AventaraModule.forRoot`, a key that has no place there (a typo, an old key, `compute` on a relation field, a field
100
+ name neither your schema nor the root has) is now a type error at that key, and still refused at startup.
101
+ - A Resource whose name starts with `$` is refused at startup (`$` names are the framework's).
102
+ - The Json shape builder moved from `@aventara/core/json` to `@aventara/core/avz`. Change the import; `AvZ` is
103
+ the same.
104
+
105
+ `exposed` and `exemptions`, which replace `restrictions`, have their own entry.
106
+
107
+ - 4907afc: New: `AventaraModule.forRoot` and `forRootAsync` take a `state` option that seeds each protocol request's
108
+ `ctx.$state` (see `@aventara/core`'s changelog). It is called once per request with the platform's own request, so
109
+ what your middleware set there reaches every guard, pipe, hook and interceptor of the request:
110
+
111
+ ```ts
112
+ AventaraModule.forRoot({
113
+ config,
114
+ state: (req: Request) => ({ user: req.user }), // Passport's req.user, on Express
115
+ });
116
+ ```
117
+
118
+ On Express the request is Express's `req`. On Fastify it is Fastify's `request`; middleware added with `app.use`
119
+ writes to its Node request, `request.raw`, so read `request.raw.user` there. Fastify answers a protocol route in its
120
+ `onRequest` hook, so only what runs before that (`app.use` middleware, an earlier `onRequest` hook) can be read. A
121
+ `state` function that throws an `AvError` answers that error (`AvError("A4001", …)` is a `403`); any other throw is
122
+ `500 A3000`. A request the protocol refuses before running (a stale client, an unknown route) never calls it. A
123
+ `state` that is not a function is refused while Nest builds the application.
124
+
125
+ - Breaking: the configuration's mount path is `api` (it was `entrypoint`), and the module reads `framework.api`.
126
+ `forRoot({ config: { api: "/api", adapter } })`. The Fastify refusal of a path with `*` now says "Change `api`, or
127
+ host on Express."
128
+
129
+ - New: with `mcp` in the configuration, the module mounts the MCP endpoint of `@aventara/mcp` at that path (and, with
130
+ `mcp.authorization`, its OAuth metadata at `/.well-known/oauth-protected-resource` plus the path), on Express and on
131
+ Fastify, as it mounts the protocol's routes: the body read raw, the request's URL and peer address handed over, the
132
+ answer written as it is. The `state` option seeds each tool call's `ctx.$state`. `@aventara/mcp` is an optional peer
133
+ dependency, loaded only when `mcp` is configured; with `mcp` set and the package missing, the application refuses to
134
+ start and names it.
135
+
136
+ - Fixed on Express: a request whose path matched a mounted route only because Express ignores case reached the route
137
+ without its body, and was answered as if it had sent none. Express matches routes case-insensitively by default,
138
+ while the body reader compared the path exactly. The MCP routes now answer their own path only, exactly as written,
139
+ so `/MCP` or `/mcp/` goes on to Nest's `404`, as on Fastify. The protocol's routes keep Express's matching (it also
140
+ answers the protocol under `/API` for `api: "/api"`), and the body reader now matches the `api` path the same way, so
141
+ `POST /API/_resources/…` gets its body.
142
+
143
+ - The host's log line for an internal failure names the MCP endpoint when the endpoint itself failed
144
+ (`A3000 on the MCP endpoint (agent origin, request …)`).
145
+
146
+ - Updated dependencies [800123b]
147
+ - Updated dependencies [8bb1d6a]
148
+ - Updated dependencies [68d4395]
149
+ - Updated dependencies [7e3d06f]
150
+ - Updated dependencies [1b5d3c0]
151
+ - Updated dependencies [6317811]
152
+ - Updated dependencies [6d0e5d6]
153
+ - Updated dependencies [7a0cab9]
154
+ - Updated dependencies [eb2a718]
155
+ - Updated dependencies [03312c5]
156
+ - Updated dependencies [123f6dc]
157
+ - Updated dependencies [b65df2e]
158
+ - Updated dependencies [c6e5010]
159
+ - Updated dependencies [c1f9389]
160
+ - Updated dependencies [9c52482]
161
+ - Updated dependencies [8396148]
162
+ - Updated dependencies [e8fbb02]
163
+ - Updated dependencies [916e5f6]
164
+ - Updated dependencies [6cbd158]
165
+ - @aventara/core@1.0.0-rc.1
166
+
3
167
  ## 1.0.0-rc.0
4
168
 
5
169
  ### Major Changes
package/README.md CHANGED
@@ -1,16 +1,16 @@
1
1
  # `@aventara/nest`
2
2
 
3
- Hosts an Aventara `Framework` in a NestJS application. It does one thing: it mounts the framework's HTTP protocol —
4
- `AvProtocol.bind(framework)` from `@aventara/core/protocol` — on Nest's HTTP platform, and puts the `Framework` into
3
+ Hosts an Aventara `AvFramework` in a NestJS application. It mounts the framework's HTTP protocol
4
+ (`AvProtocol.bind(framework)` from `@aventara/core/protocol`) on Nest's HTTP platform, and puts the `AvFramework` into
5
5
  Nest's dependency injection. Every rule of the protocol (routes, decoding, statuses, envelopes, headers, absence) is
6
6
  core's; this package only translates a Nest request into the protocol's request and writes the protocol's answer back.
7
7
 
8
- - **Nest 12** (`@nestjs/common` and `@nestjs/core` `^12.0.0`), on **Express** (Nest's default) or **Fastify**.
9
- - `@aventara/core` is its one dependency.
8
+ It runs on Nest 12 (`@nestjs/common` and `@nestjs/core` `^12.0.0`), on Express (Nest's default) or Fastify.
9
+ `@aventara/core` is its one dependency.
10
10
 
11
11
  ## Mounting it
12
12
 
13
- `AventaraModule` is a global module, imported once in your root module. It takes the `Framework` in one of four forms.
13
+ `AventaraModule` is a global module, imported once in your root module. It takes the `AvFramework` in one of four forms.
14
14
 
15
15
  ```ts
16
16
  // src/app.module.ts — the shape `aventara init` writes (`@aventara/cli`)
@@ -33,14 +33,27 @@ import { PrismaModule, PrismaService } from "./prisma.service.js";
33
33
  })
34
34
  export class AppModule {}
35
35
 
36
- // src/aventara.config.ts — the framework's configuration: entrypoint, restrictions, pipelines
36
+ // src/aventara.config.ts — the framework's configuration: api, exposure, pipelines, resources
37
+ import type { AvDiagnostic } from "@aventara/core";
37
38
  import { createPrismaAdapter } from "@aventara/prisma7-adapter";
39
+ import { Logger } from "@nestjs/common";
38
40
  import { discovery } from "./generated/aventara/discovery.artifact.js";
39
41
  import type { PrismaService } from "./prisma.service.js";
40
42
 
43
+ const logger = new Logger("Aventara");
44
+
41
45
  export async function aventaraConfig(prisma: PrismaService) {
42
46
  return {
43
- entrypoint: "/api",
47
+ api: "/api",
48
+ exposed: true,
49
+ // The raw error of a server failure, with its stack, in Nest's log.
50
+ diagnostics: (diagnostic: AvDiagnostic) => {
51
+ const { error } = diagnostic;
52
+ logger.error(
53
+ `${diagnostic.code} (request ${diagnostic.requestId}): raw error`,
54
+ error instanceof Error ? error.stack : String(error),
55
+ );
56
+ },
44
57
  adapter: await createPrismaAdapter({
45
58
  client: prisma,
46
59
  discovery,
@@ -53,15 +66,18 @@ export async function aventaraConfig(prisma: PrismaService) {
53
66
 
54
67
  | Form | When |
55
68
  |---|---|
56
- | `AventaraModule.forRoot({ framework })` | You called `createFramework(config)` yourself and want your own typed `Framework<M, Cfg>` in code. |
69
+ | `AventaraModule.forRoot({ framework })` | You called `createFramework(config)` yourself and want your own typed `AvFramework<M, Cfg>` in code. |
57
70
  | `AventaraModule.forRoot({ config })` | The module calls `createFramework(config)` for you. |
58
71
  | `AventaraModule.forRootAsync({ imports, inject, useFactory })` resolving `{ framework }` or `{ config }` | What builds the Framework (a Prisma client, a configuration service) is itself a provider. |
59
72
 
60
- The `config` form is typed exactly like `createFramework`'s parameter: an annotated `FrameworkConfig<M>`, an inline
73
+ Each form also takes `state`, which seeds every protocol request's `ctx.$state` from the platform request (see
74
+ *Request state*).
75
+
76
+ The `config` form is typed exactly like `createFramework`'s parameter: an annotated `AvConfig<M>`, an inline
61
77
  literal, an inferred constant and a `satisfies` check are all accepted, and checked against your Adapter's model.
62
78
 
63
- **Injecting it.** `@InjectFramework()` (or `@Inject(AVENTARA_FRAMEWORK)`) injects the `Framework`. DI carries no type
64
- argument, so type the parameter with your own alias:
79
+ `@InjectFramework()` (or `@Inject(AVENTARA_FRAMEWORK)`) injects the `AvFramework`. DI carries no type argument, so type
80
+ the parameter with your own alias:
65
81
 
66
82
  ```ts
67
83
  export type AppFramework = Awaited<ReturnType<typeof buildFramework>>;
@@ -72,23 +88,23 @@ export class ReportsService {
72
88
  }
73
89
  ```
74
90
 
75
- **Fails before listen.** These reject while Nest builds the application, before it listens: both or neither of
76
- `framework` and `config`; a `framework` value that is not a Framework; the module mounted twice in one application; a
77
- platform other than Express or Fastify (or none, as in `createApplicationContext`); a `FrameworkConstructionError`,
78
- yours or the module's own, which propagates unchanged. Nest's default `abortOnError: true` **exits the process** on a
79
- bootstrap error instead of rejecting; pass `abortOnError: false` to `NestFactory.create` to catch it.
91
+ These reject while Nest builds the application, before it listens: both or neither of `framework` and `config`; a
92
+ `framework` value that is not a Framework; a `state` option that is not a function; the module mounted twice in one application; a platform other than Express
93
+ or Fastify (or none, as in `createApplicationContext`); an `AvConstructionError`, yours or the module's own, which
94
+ propagates unchanged. Nest's default `abortOnError: true` exits the process on a bootstrap error instead of rejecting;
95
+ pass `abortOnError: false` to `NestFactory.create` to catch it.
80
96
 
81
- **Nest's testing module works.** `Test.createTestingModule({ imports: [AppModule] }).compile()` builds the providers
82
- before any HTTP platform exists; the module waits for the one `createNestApplication()` supplies, and still refuses an
83
- unsupported platform before the application listens. A testing module initialized with no
84
- application at all is refused at `init()`, as a platform of `none`.
97
+ Nest's testing module works. `Test.createTestingModule({ imports: [AppModule] }).compile()` builds the providers before
98
+ any HTTP platform exists; the module waits for the one `createNestApplication()` supplies, and still refuses an
99
+ unsupported platform before the application listens. A testing module initialized with no application at all is
100
+ refused at `init()`, as a platform of `none`.
85
101
 
86
- **Nothing to shut down.** The module owns no resource. Your Prisma provider closes its own client — in its
102
+ The module owns no resource, so there is nothing to shut down. Your Prisma provider closes its own client in its
87
103
  `onModuleDestroy`, with `app.enableShutdownHooks()` for signals.
88
104
 
89
105
  ## Calling it by hand
90
106
 
91
- Against the shape `aventara init` writes — the entrypoint `/api`, a Prisma `User` model:
107
+ Against the shape `aventara init` writes (the `api` mount path `/api`, a Prisma `User` model):
92
108
 
93
109
  <!-- pilot-gate:curl -->
94
110
  ```bash
@@ -108,18 +124,19 @@ curl -s -X POST http://localhost:3000/api/_resources/User/find/many \
108
124
  Leave out an identity header and the answer is `400 A2000`, naming the header that is missing. The hash changes
109
125
  whenever the schema or the configuration does: fetch it again, and regenerate the client.
110
126
 
111
- - **Resource keys are the model names, as written.** `model User` is `User` everywhere: on the wire
112
- (`/_resources/User/find/many`) and in the generated client (`avClient.User.find.many({})`). Nothing is renamed,
113
- lower-cased or pluralized.
114
- - **Results are read-only.** A list result is a `readonly` array: type it `readonly User[]`, not `User[]` —
115
- `const users: readonly User[] = await avClient.User.find.many({});`.
127
+ Resource keys are the model names, as written. `model User` is `User` everywhere: on the wire
128
+ (`/_resources/User/find/many`) and in the generated client (`avClient.User.find.many({})`). Nothing is renamed,
129
+ lower-cased or pluralized.
130
+
131
+ Results are read-only. A list result is a `readonly` array, so type it `readonly User[]`:
132
+ `const users: readonly User[] = await avClient.User.find.many({});`.
116
133
 
117
134
  ## What runs on a protocol route
118
135
 
119
- The protocol's routes are platform routes, registered from the module's `configure()`, one per
120
- `protocol.surface()` entry — every advertised operation, `GET <entrypoint>/_contract`, and `POST
121
- <entrypoint>/_transactions` only when the ClientContract advertises `interactive` — plus the absence handler for the
122
- rest of `<entrypoint>/_*`. Nest's `RouterExplorer` does not list platform routes, so the module logs one line of its
136
+ The protocol's routes are platform routes, registered from the module's `configure()`. There is one per
137
+ `protocol.surface()` entry (every advertised operation, `GET <api>/_contract`, and `POST
138
+ <api>/_transactions` only when the ClientContract advertises `interactive`), plus the absence handler for the
139
+ rest of `<api>/_*`. Nest's `RouterExplorer` does not list platform routes, so the module logs one line of its
123
140
  own when it mounts them, through Nest's `Logger` (context `AventaraModule`; `logger: false` silences it like any other):
124
141
 
125
142
  ```text
@@ -127,16 +144,19 @@ own when it mounts them, through Nest's `Logger` (context `AventaraModule`; `log
127
144
  ```
128
145
 
129
146
  An internal failure (`A3000`–`A3004`, answered as a 500) is logged as one error line through Nest's `Logger`
130
- (context `Aventara`): the code, the operation, the origin and the request id, and the thrown value's class name — never
131
- its message, which can hold SQL or a connection string. The raw error still goes to your `diagnostics` sink, when you
132
- configure one:
147
+ (context `Aventara`): the code, the operation, the origin and the request id, and the thrown value's class name. It
148
+ never logs the message, which can hold SQL or a connection string. The raw error still goes to your `diagnostics` sink, when you
149
+ configure one. The `aventara.config.ts` that `aventara new` and `aventara init` write has one: it logs the raw error with its
150
+ stack through the same `Logger`, at error level, right after this line.
133
151
 
134
152
  ```text
135
153
  [Nest] ERROR [Aventara] A3001 on Post.create.one (client origin, request 3f1c…): PrismaClientValidationError. The raw error goes to the framework's diagnostics sink.
154
+ [Nest] ERROR [Aventara] A3001 (request 3f1c…): raw error
155
+ PrismaClientValidationError: …
136
156
  ```
137
157
 
138
- For `A3004` — one of your pipes returned arguments the server contract refuses — the line also names the pipe, the
139
- operation, the offending field and its V-code; the caller only ever sees the generic message.
158
+ For `A3004` (one of your pipes returned arguments the server contract refuses), the line also names the pipe, the
159
+ operation, the offending field and its V-code. The caller only ever sees the generic message.
140
160
 
141
161
  The line is added when the module builds the framework from `config`; a framework you build yourself and pass as
142
162
  `framework` keeps exactly the `diagnostics` sink you gave it.
@@ -146,42 +166,97 @@ The framework's validation is outermost:
146
166
  | | Express | Fastify |
147
167
  |---|---|---|
148
168
  | Your `app.use(...)` middleware, `app.enableCors()`, helmet | runs | runs |
149
- | Nest guards, interceptors, pipes (global or not) | **never** | **never** |
150
- | `MiddlewareConsumer` middleware | **never** | **runs** — Fastify runs Nest middleware before routing; add `.exclude("api/_*path")` (your entrypoint) to keep it off |
151
- | Your controller at `<entrypoint>/_contract` (or any `_` path) | never reached | the application does not start |
152
- | Your controller at `<entrypoint>/health` (outside `_`) | reached | reached |
169
+ | Nest guards, interceptors, pipes (global or not) | never | never |
170
+ | `MiddlewareConsumer` middleware | never | runs, because Fastify runs Nest middleware before routing; add `.exclude("api/_*path")` (your `api` path) to keep it off |
171
+ | Your controller at `<api>/_contract` (or any `_` path) | never reached | the application does not start |
172
+ | Your controller at `<api>/health` (outside `_`) | reached | reached |
153
173
 
154
- Authentication and authorization therefore belong in Aventara **pipelines** (guards), which read
155
- `transport.headers` — lower-cased names, repeats joined. A Passport strategy run as Nest middleware sets `req.user`,
156
- which a pipeline cannot see: verify the bearer token or cookie in the Aventara guard instead.
174
+ Authorization therefore belongs in Aventara pipelines (guards). A guard reads the request's headers as
175
+ `ctx.$transport.headers` (lower-cased names, repeats joined), and whatever your middleware found as `ctx.$state`,
176
+ through the module's `state` option (next section).
157
177
 
158
- **Where it is mounted.** `FrameworkConfig.entrypoint` alone decides it. `app.setGlobalPrefix(...)` does not move the
159
- protocol; set the entrypoint to where you want it, and point the generated client at the same URL.
178
+ ## Request state
179
+
180
+ `state` seeds each protocol request's `ctx.$state`, the one object every stage of that request reads and writes. It is
181
+ called once per request, with the platform's own request, after the protocol has accepted it (a stale client or an
182
+ unknown route never calls it):
183
+
184
+ ```ts
185
+ declare module "@aventara/core" {
186
+ interface AvState {
187
+ user?: { id: string; roles: string[] };
188
+ }
189
+ }
190
+
191
+ AventaraModule.forRoot({
192
+ config,
193
+ state: (req: Request) => ({ user: req.user }), // what your auth middleware set
194
+ });
195
+
196
+ // In the configuration: a guard reads it.
197
+ const signedIn: AvGuard = (ctx) => ctx.$state.user !== undefined;
198
+ ```
199
+
200
+ - `forRootAsync`'s `useFactory` returns `state` beside `framework` or `config`.
201
+ - On Express, `req` is Express's request. On Fastify it is Fastify's `request`; `app.use` middleware writes to its
202
+ Node request, so read `request.raw.user`. Fastify answers a protocol route from its `onRequest` hook, so only what
203
+ runs before it (`app.use` middleware, an earlier `onRequest` hook) has run.
204
+ - Use `app.use(...)` for the middleware that finds the user (a Passport `authenticate` call, a session or JWT check).
205
+ Nest guards never run on protocol routes, and `MiddlewareConsumer` middleware does not on Express (the table above).
206
+ - Throw `new AvError("A4001", "…")` from `state` to refuse the request (`403`); any other throw is `500 A3000`, with the
207
+ error in your `diagnostics` sink.
208
+ - A plan sent to `/_transactions` gets one state for all its steps and transaction stages. A ctx call a stage makes
209
+ inherits it. Compute reads it but cannot write it.
210
+ - Server code that calls the framework in process seeds it per call:
211
+ `framework.application.Post.create.one(args, { state: { user } })`.
212
+
213
+ `AvConfig.api` alone decides where the protocol is mounted. `app.setGlobalPrefix(...)` does not move it; set
214
+ `api` to where you want it, and point the generated client at the same URL.
160
215
 
161
216
  ## Requests
162
217
 
163
- - **Bodies** on protocol routes are read raw — never through Nest's body parser — up to `limits.maxRequestBytes`; the
218
+ - Bodies on protocol routes are read raw, never through Nest's body parser, up to `limits.maxRequestBytes`. The
164
219
  protocol's decoder answers an oversized body (`413 A2010`), another content type (`415 A2011`) and malformed JSON
165
220
  (`400 A2000`). Your own routes keep Nest's parsed `req.body`. No `NestFactory.create` option is needed.
166
- - **`Content-Encoding`:** `gzip`, `deflate` and `br` are inflated, and the limit counts decoded bytes. Any other coding
221
+ - For `Content-Encoding`, `gzip`, `deflate` and `br` are inflated, and the limit counts decoded bytes. Any other coding
167
222
  answers `415 A2011`; a body that does not decode in the coding it names answers `400 A2000`. These two answers carry a
168
223
  newly minted `Aventara-Request-Id`, not the client's.
169
- - **The request id** is the client's `Aventara-Request-Id` as it arrived, or one core mints. A middleware that rewrites
170
- `req.headers` cannot change it, and `X-Request-Id` is never copied into it — set `Aventara-Request-Id` at your proxy.
171
- - **A query string** is not part of the protocol path; it is ignored.
224
+ - The request id is the client's `Aventara-Request-Id` as it arrived, or one core mints. A middleware that rewrites
225
+ `req.headers` cannot change it, and `X-Request-Id` is never copied into it, so set `Aventara-Request-Id` at your
226
+ proxy.
227
+ - A query string is not part of the protocol path; it is ignored.
228
+
229
+ ## The MCP endpoint
230
+
231
+ With `mcp` in the configuration, the module also mounts the MCP server of `@aventara/mcp` at that path, on Express and
232
+ Fastify alike, and logs `Aventara mounted the MCP endpoint at /mcp (…)`. Install it beside this package, at the same
233
+ version:
234
+
235
+ ```bash
236
+ npm install @aventara/mcp@rc
237
+ ```
238
+
239
+ It is an optional peer dependency: an application without `mcp` does not need it, and one with `mcp` and without the
240
+ package refuses to start, naming it. As for the protocol's routes, the module only mounts: the endpoint's body is read
241
+ raw, the request's URL comes from the platform's `protocol` and `host` (set `trust proxy` on Express or `trustProxy` on
242
+ Fastify behind a proxy), and every answer is `@aventara/mcp`'s. The `state` option seeds each tool call's `ctx.$state`.
243
+ See `@aventara/mcp`'s README for the tools, the contract resource and authorization.
172
244
 
173
245
  ## Platform differences
174
246
 
175
- - **Fastify cannot mount some Resource keys or entrypoints as literal routes.** A Resource key that needs `*` or a
176
- reserved character (`: + # $ & , / ; = ? @`) in its route path, or an entrypoint containing `*`, is refused at startup
247
+ - Fastify cannot mount some Resource keys or `api` paths as literal routes. A Resource key that needs `*` or a
248
+ reserved character (`: + # $ & , / ; = ? @`) in its route path, or an `api` path containing `*`, is refused at startup
177
249
  with a message naming it. Rename it, or host on Express, which mounts every key.
178
- - **Express routing is case-insensitive** by default, so it also answers the protocol under, say, `/API/_contract`;
179
- Fastify's is not. That is Express's own `case sensitive routing` setting, off by default, and yours to change.
180
- - **Express answers `500 A3000`** to a protocol route requested with a percent-encoding a generated client never sends
181
- (for example `%6Eotes` for `notes`); Fastify and the in-process protocol execute it. A known divergence.
250
+ - Express routing is case-insensitive by default, so it also answers the protocol under, say, `/API/_contract`;
251
+ Fastify's is not. That is Express's own `case sensitive routing` setting, off by default, and yours to change. The
252
+ MCP endpoint is the exception: on both platforms it answers its own path only, exactly as written (`/mcp`, not
253
+ `/MCP` or `/mcp/`).
254
+ - Express answers `500 A3000` to a protocol route requested with a percent-encoding a generated client never sends
255
+ (for example `%6Eotes` for `notes`); Fastify and the in-process protocol execute it. This is a known divergence.
182
256
  - On Express the response carries Express's `X-Powered-By` header; `app.disable("x-powered-by")` removes it.
183
257
 
184
258
  ## License
185
259
 
186
- PolyForm Shield 1.0.0 with an additional permission — free to use, including in commercial applications; you may not
187
- use it to build a product that competes with Aventara itself. See `LICENSE` and `LICENSE-ADDITIONAL-PERMISSION.md`.
260
+ PolyForm Shield 1.0.0 with an additional permission. It is free to use, including in commercial applications, but you
261
+ may not use it to build a product that competes with Aventara itself. See `LICENSE` and
262
+ `LICENSE-ADDITIONAL-PERMISSION.md`.
@@ -1,24 +1,35 @@
1
- import { type AdapterModel, type AnyFrameworkConfig, type Framework, type FrameworkConfig } from "@aventara/core";
1
+ import { type AvAdapterModel, type AvConfig, type AvConfigAny, type AvConfigExact, type AvFramework, type AvState } from "@aventara/core";
2
2
  import { type DynamicModule, type FactoryProvider, type ModuleMetadata, type NestModule, type OnModuleInit } from "@nestjs/common";
3
3
  import { type PendingProtocolHost } from "./protocol-host.factory.js";
4
4
  /**
5
5
  * How the application's Framework enters Nest: your own instance
6
6
  * (`{ framework }`, from `createFramework`) or a configuration the module
7
- * passes to `createFramework` itself (`{ config }`). Pass exactly one.
7
+ * passes to `createFramework` itself (`{ config }`). Pass exactly one. Either
8
+ * arm may add `state`, which seeds each protocol request's `ctx.$state`.
8
9
  */
9
- export type AventaraModuleOptions<M extends AdapterModel = AdapterModel, Cfg = AnyFrameworkConfig> = {
10
- readonly framework: Framework;
10
+ export type AventaraModuleOptions<M extends AvAdapterModel = AvAdapterModel, Cfg = AvConfigAny> = ({
11
+ readonly framework: AvFramework;
11
12
  readonly config?: never;
12
13
  } | {
13
- readonly config: Cfg & FrameworkConfig<M, Cfg>;
14
+ readonly config: Cfg & AvConfig<M, Cfg> & AvConfigExact<M, Cfg>;
14
15
  readonly framework?: never;
16
+ }) & {
17
+ /**
18
+ * Seeds a protocol request's `ctx.$state`, once per request, with the
19
+ * platform's own request: Express's `req`, or Fastify's `request` (its Node
20
+ * request is `request.raw`). What middleware already set there, such as
21
+ * Passport's `req.user`, flows into every stage of the request. A throw is
22
+ * answered like any developer failure: a thrown `AvError` as its own
23
+ * outcome, anything else `500 A3000`. Omitted, the state is `{}`.
24
+ */
25
+ state?(request: unknown): AvState | Promise<AvState>;
15
26
  };
16
27
  /**
17
28
  * `forRootAsync`'s options: the Nest idiom when what builds the Framework
18
29
  * (a Prisma client, a configuration service) is itself a provider. The
19
30
  * factory resolves to either arm of {@link AventaraModuleOptions}.
20
31
  */
21
- export type AventaraModuleAsyncOptions<M extends AdapterModel = AdapterModel, Cfg = AnyFrameworkConfig> = {
32
+ export type AventaraModuleAsyncOptions<M extends AvAdapterModel = AvAdapterModel, Cfg = AvConfigAny> = {
22
33
  /** Modules whose providers the factory injects. */
23
34
  readonly imports?: ModuleMetadata["imports"];
24
35
  /** The providers passed to `useFactory`, in order. */
@@ -43,11 +54,11 @@ export declare class AventaraModule implements NestModule, OnModuleInit {
43
54
  */
44
55
  onModuleInit(): void;
45
56
  /** Registers the module with a Framework instance or a configuration. */
46
- static forRoot<M extends AdapterModel, const Cfg = AnyFrameworkConfig>(options: AventaraModuleOptions<M, Cfg>): DynamicModule;
57
+ static forRoot<M extends AvAdapterModel, const Cfg = AvConfigAny>(options: AventaraModuleOptions<M, Cfg>): DynamicModule;
47
58
  /**
48
59
  * Registers the module with options built by a factory, for when the
49
60
  * Framework's inputs (a Prisma client, a config service) are providers.
50
61
  */
51
- static forRootAsync<M extends AdapterModel, const Cfg = AnyFrameworkConfig>(options: AventaraModuleAsyncOptions<M, Cfg>): DynamicModule;
62
+ static forRootAsync<M extends AvAdapterModel, const Cfg = AvConfigAny>(options: AventaraModuleAsyncOptions<M, Cfg>): DynamicModule;
52
63
  private static mount;
53
64
  }
@@ -4,12 +4,14 @@ import { HttpAdapterHost } from "@nestjs/core";
4
4
  import { AVENTARA_FRAMEWORK } from "./framework.token.js";
5
5
  import { checkedOptions, recordMount } from "./host-bootstrap.validator.js";
6
6
  import { withInternalFailureLog } from "./internal-failure.logger.js";
7
+ import { loadMcpBinding } from "./mcp-binding.loader.js";
7
8
  import { createPendingProtocolHost, } from "./protocol-host.factory.js";
8
9
  const MOUNT = Symbol("AVENTARA_MOUNT");
9
10
  let mounts = 0;
10
11
  const PROTOCOL_HOST = Symbol("AVENTARA_PROTOCOL_HOST");
11
- async function frameworkOf(options) {
12
- const checked = checkedOptions(options);
12
+ const OPTIONS = Symbol("AVENTARA_OPTIONS");
13
+ const MCP_BINDING = Symbol("AVENTARA_MCP_BINDING");
14
+ async function frameworkOf(checked) {
13
15
  if (checked.framework !== undefined) {
14
16
  return checked.framework;
15
17
  }
@@ -44,17 +46,27 @@ export class AventaraModule {
44
46
  providers: [
45
47
  { provide: MOUNT, useValue: mounts },
46
48
  {
47
- provide: AVENTARA_FRAMEWORK,
49
+ provide: OPTIONS,
48
50
  inject: [HttpAdapterHost, ...inject],
49
51
  useFactory: async (application, ...deps) => {
50
52
  recordMount(application);
51
- return frameworkOf(await resolve(...deps));
53
+ return checkedOptions(await resolve(...deps));
52
54
  },
53
55
  },
56
+ {
57
+ provide: AVENTARA_FRAMEWORK,
58
+ inject: [OPTIONS],
59
+ useFactory: (options) => frameworkOf(options),
60
+ },
61
+ {
62
+ provide: MCP_BINDING,
63
+ inject: [AVENTARA_FRAMEWORK],
64
+ useFactory: async (framework) => (await loadMcpBinding(framework)) ?? null,
65
+ },
54
66
  {
55
67
  provide: PROTOCOL_HOST,
56
- inject: [HttpAdapterHost, AVENTARA_FRAMEWORK],
57
- useFactory: (application, framework) => createPendingProtocolHost(application, framework),
68
+ inject: [HttpAdapterHost, AVENTARA_FRAMEWORK, OPTIONS, MCP_BINDING],
69
+ useFactory: (application, framework, options, mcp) => createPendingProtocolHost(application, framework, options.state?.bind(options), mcp ?? undefined),
58
70
  },
59
71
  ],
60
72
  exports: [AVENTARA_FRAMEWORK],
@@ -1,16 +1,25 @@
1
1
  import type { AvProtocolBinding } from "@aventara/core/protocol";
2
+ import type { McpBinding } from "./mcp-binding.loader.js";
2
3
  import { type NodeIncoming, type NodeOutgoing } from "./node-exchange.translator.js";
3
- import type { ProtocolHost } from "./protocol-host.factory.js";
4
+ import type { HostState, ProtocolHost } from "./protocol-host.factory.js";
4
5
  /**
5
6
  * The protocol on Nest's Express platform: one platform route per protocol
6
7
  * route under the entrypoint, translating the Express request into the
7
8
  * protocol's request and the protocol's response back onto the wire. A path the
8
9
  * protocol does not own goes on to Nest.
9
10
  */
10
- /** Express's request: Node's `IncomingMessage`, plus `originalUrl`. */
11
+ /**
12
+ * Express's request: Node's `IncomingMessage`, plus `originalUrl`, and the
13
+ * protocol and host Express reports (its `trust proxy` setting applied).
14
+ */
11
15
  type ExpressRequest = NodeIncoming & {
12
16
  readonly method: string;
13
17
  readonly originalUrl: string;
18
+ readonly protocol: string;
19
+ readonly host: string;
20
+ readonly socket: {
21
+ readonly remoteAddress?: string | undefined;
22
+ };
14
23
  };
15
24
  /** Express's response, written through Node's own `writeHead`. */
16
25
  type ExpressResponse = NodeOutgoing;
@@ -19,11 +28,12 @@ type ExpressHandler = (request: ExpressRequest, response: ExpressResponse, next?
19
28
  export type ExpressPlatform = {
20
29
  get(path: string, handler: ExpressHandler): unknown;
21
30
  post(path: string, handler: ExpressHandler): unknown;
31
+ delete(path: string, handler: ExpressHandler): unknown;
22
32
  use(handler: ExpressHandler): unknown;
23
33
  };
24
34
  /**
25
35
  * Installs the pre-parser on `platform` now, and returns the host whose
26
36
  * `mount` registers the routes and the absence fallback under `entrypoint`.
27
37
  */
28
- export declare function createExpressProtocolHost(platform: ExpressPlatform, binding: AvProtocolBinding, entrypoint: string, maxRequestBytes: number): ProtocolHost;
38
+ export declare function createExpressProtocolHost(platform: ExpressPlatform, binding: AvProtocolBinding, entrypoint: string, maxRequestBytes: number, state: HostState | undefined, mcp: McpBinding | undefined): ProtocolHost;
29
39
  export {};
@@ -1,25 +1,42 @@
1
- import { NO_BYTES, pathnameOf, protocolRequest, writeAnswer, } from "./node-exchange.translator.js";
2
- import { PROTOCOL_ROUTES } from "./protocol-route.table.js";
1
+ import { mcpRequest, NO_BYTES, pathnameOf, protocolRequest, writeAnswer, } from "./node-exchange.translator.js";
2
+ import { MCP_ROUTES, PROTOCOL_ROUTES, } from "./protocol-route.table.js";
3
3
  import { readRequestBody, unreadBodyAnswer, } from "./request-body.reader.js";
4
- const REGISTER = { GET: "get", POST: "post" };
4
+ const REGISTER = { GET: "get", POST: "post", DELETE: "delete" };
5
5
  const NOTHING_READ = { kind: "read", content: NO_BYTES };
6
- export function createExpressProtocolHost(platform, binding, entrypoint, maxRequestBytes) {
6
+ export function createExpressProtocolHost(platform, binding, entrypoint, maxRequestBytes, state, mcp) {
7
7
  const bodies = new WeakMap();
8
8
  const bodyOf = (request) => bodies.get(request) ?? NOTHING_READ;
9
+ const mcpEndpoint = mcp
10
+ ?.surface()
11
+ .find((route) => route.kind === "endpoint")?.path;
12
+ const namespace = asciiLowerCase(`${entrypoint}/_`);
9
13
  platform.use(async (request, _response, next) => {
10
- if (pathnameOf(request.originalUrl).startsWith(`${entrypoint}/_`)) {
14
+ const pathname = pathnameOf(request.originalUrl);
15
+ if (asciiLowerCase(pathname.slice(0, namespace.length)) === namespace) {
11
16
  bodies.set(request, await readRequestBody(request, request.headersDistinct["content-encoding"], maxRequestBytes));
12
17
  }
18
+ else if (mcp !== undefined && pathname === mcpEndpoint) {
19
+ bodies.set(request, await readRequestBody(request, undefined, mcp.maxRequestBytes));
20
+ }
13
21
  handOn(next);
14
22
  });
15
23
  return {
16
24
  mount: () => {
25
+ for (const route of mcp?.surface() ?? []) {
26
+ const answer = MCP_ROUTES[route.kind](mcp);
27
+ for (const method of route.methods) {
28
+ platform[REGISTER[method]](literalPath(route.path), exactly(route.path, async (request, response) => {
29
+ const body = bodyOf(request);
30
+ writeAnswer(response, await answer(mcpRequest(request.method, request, request.originalUrl, request, body.kind === "read" ? body.content : NO_BYTES, request.socket.remoteAddress), state === undefined ? undefined : () => state(request)));
31
+ }));
32
+ }
33
+ }
17
34
  for (const route of binding.surface()) {
18
35
  const answer = PROTOCOL_ROUTES[route.kind](binding);
19
36
  platform[REGISTER[route.method]](literalPath(`${entrypoint}${route.path}`), async (request, response) => {
20
37
  const body = bodyOf(request);
21
38
  writeAnswer(response, body.kind === "read"
22
- ? await answer(translated(request, entrypoint, body.content))
39
+ ? await answer(translated(request, entrypoint, body.content), state === undefined ? undefined : () => state(request))
23
40
  : unreadBodyAnswer(binding, body));
24
41
  });
25
42
  }
@@ -39,6 +56,18 @@ export function createExpressProtocolHost(platform, binding, entrypoint, maxRequ
39
56
  },
40
57
  };
41
58
  }
59
+ function exactly(path, handler) {
60
+ return (request, response, next) => {
61
+ if (pathnameOf(request.originalUrl) !== path) {
62
+ handOn(next);
63
+ return undefined;
64
+ }
65
+ return handler(request, response, next);
66
+ };
67
+ }
68
+ function asciiLowerCase(text) {
69
+ return text.replace(/[A-Z]/g, (letter) => letter.toLowerCase());
70
+ }
42
71
  function handOn(next) {
43
72
  if (typeof next === "function") {
44
73
  next();
@@ -1,6 +1,7 @@
1
1
  import type { AvProtocolBinding } from "@aventara/core/protocol";
2
+ import type { McpBinding } from "./mcp-binding.loader.js";
2
3
  import { type NodeIncoming, type NodeOutgoing, type PlatformHeaders } from "./node-exchange.translator.js";
3
- import type { ProtocolHost } from "./protocol-host.factory.js";
4
+ import type { HostState, ProtocolHost } from "./protocol-host.factory.js";
4
5
  /**
5
6
  * The protocol on Nest's Fastify platform: one Fastify route per protocol route
6
7
  * under the entrypoint, answered from its own `onRequest` hook before Fastify's
@@ -10,13 +11,21 @@ import type { ProtocolHost } from "./protocol-host.factory.js";
10
11
  * reserved escape) is refused while Nest builds the application, naming the
11
12
  * Resource keys.
12
13
  */
13
- /** Fastify's request: Node's own under `raw`, and whether a route matched. */
14
+ /**
15
+ * Fastify's request: Node's own under `raw`, whether a route matched, and the
16
+ * protocol and host Fastify reports (its `trustProxy` setting applied).
17
+ */
14
18
  type FastifyRequest = {
15
19
  readonly raw: NodeIncoming & {
16
20
  readonly method: string;
17
21
  readonly url: string;
22
+ readonly socket: {
23
+ readonly remoteAddress?: string | undefined;
24
+ };
18
25
  };
19
26
  readonly is404: boolean;
27
+ readonly protocol: string;
28
+ readonly host: string;
20
29
  };
21
30
  /** Fastify's reply: hijacked, then written through Node's response under `raw`. */
22
31
  type FastifyReply = {
@@ -27,7 +36,7 @@ type FastifyReply = {
27
36
  type OnRequestHook = (request: FastifyRequest, reply: FastifyReply) => Promise<FastifyReply | undefined>;
28
37
  export type FastifyPlatform = {
29
38
  route(options: {
30
- readonly method: string;
39
+ readonly method: string | readonly string[];
31
40
  readonly url: string;
32
41
  readonly exposeHeadRoute: false;
33
42
  readonly onRequest: OnRequestHook;
@@ -39,5 +48,5 @@ export type FastifyPlatform = {
39
48
  * The host whose `mount` registers the routes and the absence hook under
40
49
  * `entrypoint` — after refusing, now, a surface it cannot mount literally.
41
50
  */
42
- export declare function createFastifyProtocolHost(platform: FastifyPlatform, binding: AvProtocolBinding, entrypoint: string, maxRequestBytes: number): ProtocolHost;
51
+ export declare function createFastifyProtocolHost(platform: FastifyPlatform, binding: AvProtocolBinding, entrypoint: string, maxRequestBytes: number, state: HostState | undefined, mcp: McpBinding | undefined): ProtocolHost;
43
52
  export {};
@@ -1,10 +1,10 @@
1
- import { NO_BYTES, pathnameOf, protocolRequest, writeAnswer, } from "./node-exchange.translator.js";
2
- import { PROTOCOL_ROUTES } from "./protocol-route.table.js";
1
+ import { mcpRequest, NO_BYTES, pathnameOf, protocolRequest, writeAnswer, } from "./node-exchange.translator.js";
2
+ import { MCP_ROUTES, PROTOCOL_ROUTES } from "./protocol-route.table.js";
3
3
  import { readRequestBody, unreadBodyAnswer } from "./request-body.reader.js";
4
4
  const UNMATCHABLE = /\*|%(?:3A|2B|23|24|26|2C|2F|3B|3D|3F|40)/i;
5
5
  function checkLiteralRoutes(binding, entrypoint) {
6
6
  if (entrypoint.includes("*")) {
7
- throw new Error(`AventaraModule cannot mount the entrypoint \`${entrypoint}\` as a literal path on Nest's Fastify platform: it may not carry \`*\`. Change the entrypoint, or host on Express.`);
7
+ throw new Error(`AventaraModule cannot mount the api path \`${entrypoint}\` as a literal path on Nest's Fastify platform: it may not carry \`*\`. Change \`api\`, or host on Express.`);
8
8
  }
9
9
  const refused = binding
10
10
  .surface()
@@ -16,11 +16,35 @@ function checkLiteralRoutes(binding, entrypoint) {
16
16
  throw new Error(`AventaraModule cannot mount Resource ${refused.map((key) => `\`${key}\``).join(", ")} as literal routes on Nest's Fastify platform: a route path may not carry \`*\` or a reserved character (: + # $ & , / ; = ? @). Rename the Resource, or host on Express.`);
17
17
  }
18
18
  }
19
- export function createFastifyProtocolHost(platform, binding, entrypoint, maxRequestBytes) {
19
+ function checkLiteralMcpRoutes(mcp) {
20
+ for (const route of mcp?.surface() ?? []) {
21
+ if (UNMATCHABLE.test(route.path)) {
22
+ throw new Error(`AventaraModule cannot mount the mcp path \`${route.path}\` as a literal path on Nest's Fastify platform: it may not carry \`*\` or a reserved escape. Change \`mcp\`, or host on Express.`);
23
+ }
24
+ }
25
+ }
26
+ export function createFastifyProtocolHost(platform, binding, entrypoint, maxRequestBytes, state, mcp) {
20
27
  checkLiteralRoutes(binding, entrypoint);
28
+ checkLiteralMcpRoutes(mcp);
21
29
  const translated = (request, content) => protocolRequest(request.raw.method, request.raw.url, request.raw, entrypoint, content);
22
30
  return {
23
31
  mount: () => {
32
+ for (const route of mcp?.surface() ?? []) {
33
+ const answer = MCP_ROUTES[route.kind](mcp);
34
+ platform.route({
35
+ method: [...route.methods],
36
+ url: literalPath(route.path),
37
+ exposeHeadRoute: false,
38
+ onRequest: async (request, reply) => {
39
+ const body = await readRequestBody(request.raw, undefined, mcp.maxRequestBytes);
40
+ write(reply, await answer(mcpRequest(request.raw.method, request, request.raw.url, request.raw, body.kind === "read" ? body.content : NO_BYTES, request.raw.socket.remoteAddress), state === undefined ? undefined : () => state(request)));
41
+ return reply;
42
+ },
43
+ handler: () => {
44
+ throw new Error("AventaraModule: an MCP route's onRequest hook answers every request; its handler is unreachable.");
45
+ },
46
+ });
47
+ }
24
48
  for (const route of binding.surface()) {
25
49
  const answer = PROTOCOL_ROUTES[route.kind](binding);
26
50
  platform.route({
@@ -30,7 +54,7 @@ export function createFastifyProtocolHost(platform, binding, entrypoint, maxRequ
30
54
  onRequest: async (request, reply) => {
31
55
  const body = await readRequestBody(request.raw, request.raw.headersDistinct["content-encoding"], maxRequestBytes);
32
56
  write(reply, body.kind === "read"
33
- ? await answer(translated(request, body.content))
57
+ ? await answer(translated(request, body.content), state === undefined ? undefined : () => state(request))
34
58
  : unreadBodyAnswer(binding, body));
35
59
  return reply;
36
60
  },
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The injection token under which `AventaraModule` provides the application's
3
- * `Framework`. Type the injected value with your own `Framework<M, Cfg>` alias.
3
+ * `AvFramework`. Type the injected value with your own `AvFramework<M, Cfg>` alias.
4
4
  */
5
5
  export declare const AVENTARA_FRAMEWORK: unique symbol;
6
6
  /** `@InjectFramework()`: `@Inject(AVENTARA_FRAMEWORK)` in one call. */
@@ -1,6 +1,7 @@
1
1
  const NEITHER_ARM = "AventaraModule: the options carry neither `framework` nor `config`; pass exactly one.";
2
2
  const BOTH_ARMS = "AventaraModule: the options carry both `framework` and `config`; pass exactly one.";
3
- const NOT_A_FRAMEWORK = "AventaraModule: `framework` is not an Aventara Framework (it has no string `entrypoint` and no `contracts.client`); pass what `createFramework` resolved to.";
3
+ const NOT_A_FRAMEWORK = "AventaraModule: `framework` is not an Aventara Framework (it has no string `api` and no `contracts.client`); pass what `createFramework` resolved to.";
4
+ const STATE_NOT_A_FUNCTION = "AventaraModule: `state` is not a function; pass `(request) => ({ … })`, called once per protocol request.";
4
5
  const MOUNTED_TWICE = "AventaraModule is mounted twice in one application; import AventaraModule.forRoot or forRootAsync once.";
5
6
  export function checkedOptions(options) {
6
7
  const record = typeof options === "object" && options !== null
@@ -17,6 +18,10 @@ export function checkedOptions(options) {
17
18
  if (hasFramework && !isFramework(record.framework)) {
18
19
  throw new Error(NOT_A_FRAMEWORK);
19
20
  }
21
+ const state = record.state;
22
+ if (state !== undefined && typeof state !== "function") {
23
+ throw new Error(STATE_NOT_A_FUNCTION);
24
+ }
20
25
  return record;
21
26
  }
22
27
  function isFramework(value) {
@@ -24,7 +29,7 @@ function isFramework(value) {
24
29
  return false;
25
30
  }
26
31
  const candidate = value;
27
- return (typeof candidate.entrypoint === "string" &&
32
+ return (typeof candidate.api === "string" &&
28
33
  typeof candidate.contracts?.client === "object" &&
29
34
  candidate.contracts.client !== null);
30
35
  }
@@ -1,8 +1,8 @@
1
- import type { FrameworkDiagnostic, FrameworkDiagnosticsSink } from "@aventara/core";
1
+ import type { AvDiagnostic, AvDiagnosticsSink } from "@aventara/core";
2
2
  /** Wraps the application's sink (if any): log an internal failure, then forward. */
3
- export declare function withInternalFailureLog(sink: FrameworkDiagnosticsSink | undefined): FrameworkDiagnosticsSink;
3
+ export declare function withInternalFailureLog(sink: AvDiagnosticsSink | undefined): AvDiagnosticsSink;
4
4
  /**
5
5
  * e.g. `A3001 on Post.create.one (client origin, request 7f3c…): Error. The raw
6
6
  * error goes to the framework's diagnostics sink.`
7
7
  */
8
- export declare function internalFailureLine(diagnostic: FrameworkDiagnostic): string;
8
+ export declare function internalFailureLine(diagnostic: AvDiagnostic): string;
@@ -15,7 +15,9 @@ export function withInternalFailureLog(sink) {
15
15
  export function internalFailureLine(diagnostic) {
16
16
  const subject = diagnostic.kind === "operation"
17
17
  ? `${diagnostic.resource}.${diagnostic.family}.${diagnostic.variant}`
18
- : "a transaction";
18
+ : diagnostic.kind === "transaction"
19
+ ? "a transaction"
20
+ : "the MCP endpoint";
19
21
  const step = diagnostic.operation === undefined
20
22
  ? ""
21
23
  : `, plan operation ${diagnostic.operation}`;
@@ -24,7 +26,7 @@ export function internalFailureLine(diagnostic) {
24
26
  function frameworkDetail(error) {
25
27
  try {
26
28
  if (error instanceof Error &&
27
- error.name === "PipeOutputError" &&
29
+ error.name === "AvPipeOutputError" &&
28
30
  typeof error.message === "string") {
29
31
  return ` — ${error.message}`;
30
32
  }
@@ -0,0 +1,30 @@
1
+ import type { AvFramework, AvState } from "@aventara/core";
2
+ import type { AvProtocolResponse } from "@aventara/core/protocol";
3
+ /** One request to the MCP endpoint, as `@aventara/mcp` reads it. */
4
+ export type McpRequest = {
5
+ readonly method: string;
6
+ readonly url: string;
7
+ readonly headers: Readonly<Record<string, readonly string[]>>;
8
+ readonly body: Uint8Array;
9
+ readonly remoteAddress: string | undefined;
10
+ };
11
+ /** One route the MCP binding lists. */
12
+ export type McpRoute = {
13
+ readonly kind: "endpoint" | "metadata";
14
+ readonly path: string;
15
+ readonly methods: readonly ("GET" | "POST" | "DELETE")[];
16
+ };
17
+ export type McpBinding = {
18
+ readonly surface: () => readonly McpRoute[];
19
+ readonly maxRequestBytes: number;
20
+ readonly handleEndpoint: (request: McpRequest, options?: {
21
+ readonly state?: () => AvState | Promise<AvState>;
22
+ }) => Promise<AvProtocolResponse>;
23
+ readonly encodeMetadata: (request: McpRequest) => AvProtocolResponse;
24
+ };
25
+ /**
26
+ * The MCP binding for `framework`, or `undefined` when its configuration has no
27
+ * `mcp`. Called while Nest instantiates providers, so a missing package rejects
28
+ * `NestFactory.create` before the application listens, naming the package.
29
+ */
30
+ export declare function loadMcpBinding(framework: AvFramework, specifier?: string): Promise<McpBinding | undefined>;
@@ -0,0 +1,25 @@
1
+ const MCP_PACKAGE = "@aventara/mcp";
2
+ const missing = (specifier) => `AventaraModule: the configuration has \`mcp\`, and the package that serves it, ${specifier}, is not installed. Install it beside @aventara/nest, at the same version (npm install ${specifier}), or remove \`mcp\` from the configuration.`;
3
+ function isMissing(error, specifier) {
4
+ const { code, message } = (error ?? {});
5
+ return ((code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") &&
6
+ typeof message === "string" &&
7
+ message.includes(`'${specifier}'`));
8
+ }
9
+ export async function loadMcpBinding(framework, specifier = MCP_PACKAGE) {
10
+ if (framework.mcp === undefined) {
11
+ return undefined;
12
+ }
13
+ let loaded;
14
+ try {
15
+ loaded = await import(specifier);
16
+ }
17
+ catch (error) {
18
+ if (isMissing(error, specifier)) {
19
+ throw new Error(missing(specifier));
20
+ }
21
+ throw error;
22
+ }
23
+ const { AvMcp } = loaded;
24
+ return AvMcp.bind(framework);
25
+ }
@@ -1,4 +1,5 @@
1
1
  import type { AvProtocolRequest, AvProtocolResponse } from "@aventara/core/protocol";
2
+ import type { McpRequest } from "./mcp-binding.loader.js";
2
3
  export type NodeIncoming = AsyncIterable<Uint8Array> & {
3
4
  readonly headersDistinct: Readonly<Record<string, readonly string[]>>;
4
5
  };
@@ -14,6 +15,11 @@ export declare const NO_BYTES: Uint8Array<ArrayBuffer>;
14
15
  export declare function pathnameOf(url: string): string;
15
16
  /** The request as the protocol reads it: path relative to the entrypoint, distinct headers, raw body. */
16
17
  export declare function protocolRequest(method: string, url: string, incoming: NodeIncoming, entrypoint: string, content: Uint8Array): AvProtocolRequest;
18
+ /** The request as `@aventara/mcp` reads it: absolute URL, distinct headers, raw body, peer address. */
19
+ export declare function mcpRequest(method: string, origin: {
20
+ readonly protocol: string;
21
+ readonly host: string;
22
+ }, url: string, incoming: NodeIncoming, content: Uint8Array, remoteAddress: string | undefined): McpRequest;
17
23
  /**
18
24
  * Writes the protocol's answer as it is. Headers the platform's hooks already
19
25
  * set (CORS, say) go beneath it; a protocol header of the same name, in any
@@ -11,6 +11,15 @@ export function protocolRequest(method, url, incoming, entrypoint, content) {
11
11
  body: { kind: "raw", content },
12
12
  };
13
13
  }
14
+ export function mcpRequest(method, origin, url, incoming, content, remoteAddress) {
15
+ return {
16
+ method,
17
+ url: `${origin.protocol}://${origin.host}${url}`,
18
+ headers: incoming.headersDistinct,
19
+ body: content,
20
+ remoteAddress,
21
+ };
22
+ }
14
23
  export function writeAnswer(response, answer, beneath = {}) {
15
24
  const own = new Set(Object.keys(answer.headers).map((name) => name.toLowerCase()));
16
25
  const headers = {};
@@ -1,6 +1,9 @@
1
- import type { Framework } from "@aventara/core";
1
+ import type { AvFramework, AvState } from "@aventara/core";
2
2
  import type { AbstractHttpAdapter } from "@nestjs/core";
3
+ import type { McpBinding, McpRoute } from "./mcp-binding.loader.js";
3
4
  import type { SurfaceRoute } from "./protocol-route.table.js";
5
+ /** The developer's `state` option: seeds a request's `ctx.$state` from its platform request. */
6
+ export type HostState = (request: unknown) => AvState | Promise<AvState>;
4
7
  /** The protocol, bound once at startup, and the one call that mounts it. */
5
8
  export type ProtocolHost = {
6
9
  readonly mount: () => void;
@@ -11,7 +14,14 @@ export type ProtocolHost = {
11
14
  * rejects before the application listens. The routes are mounted later, from
12
15
  * the module's `configure()`.
13
16
  */
14
- export declare function createProtocolHost(adapter: AbstractHttpAdapter | undefined, framework: Framework): ProtocolHost;
17
+ export declare function createProtocolHost(adapter: AbstractHttpAdapter | undefined, framework: AvFramework, state?: HostState, mcp?: McpBinding): ProtocolHost;
18
+ /**
19
+ * What the MCP mount registered: the endpoint, and its OAuth metadata route
20
+ * or, without one, the rule an open endpoint answers by — e.g. `Aventara
21
+ * mounted the MCP endpoint at /mcp (+ GET
22
+ * /.well-known/oauth-protected-resource/mcp)`.
23
+ */
24
+ export declare function mcpMountedLine(routes: readonly McpRoute[]): string;
15
25
  /**
16
26
  * What one mount registered, read off `protocol.surface()`: the operation
17
27
  * count under the entrypoint, then each framework route by method and path —
@@ -35,4 +45,4 @@ export type AdapterHostView = {
35
45
  * A host for an application whose HTTP adapter may arrive after the providers
36
46
  * are instantiated (Nest's testing module).
37
47
  */
38
- export declare function createPendingProtocolHost(application: AdapterHostView, framework: Framework): PendingProtocolHost;
48
+ export declare function createPendingProtocolHost(application: AdapterHostView, framework: AvFramework, state?: HostState, mcp?: McpBinding): PendingProtocolHost;
@@ -4,24 +4,35 @@ import { createExpressProtocolHost } from "./express-protocol.mounter.js";
4
4
  import { createFastifyProtocolHost, } from "./fastify-protocol.mounter.js";
5
5
  import { checkedPlatform, } from "./host-bootstrap.validator.js";
6
6
  const PLATFORM_MOUNTERS = {
7
- express: (adapter, binding, entrypoint, maxRequestBytes) => createExpressProtocolHost({
7
+ express: (adapter, binding, entrypoint, maxRequestBytes, state, mcp) => createExpressProtocolHost({
8
8
  get: (path, handler) => adapter.get(path, handler),
9
9
  post: (path, handler) => adapter.post(path, handler),
10
+ delete: (path, handler) => adapter.delete(path, handler),
10
11
  use: (handler) => adapter.use(handler),
11
- }, binding, entrypoint, maxRequestBytes),
12
- fastify: (adapter, binding, entrypoint, maxRequestBytes) => createFastifyProtocolHost(adapter.getInstance(), binding, entrypoint, maxRequestBytes),
12
+ }, binding, entrypoint, maxRequestBytes, state, mcp),
13
+ fastify: (adapter, binding, entrypoint, maxRequestBytes, state, mcp) => createFastifyProtocolHost(adapter.getInstance(), binding, entrypoint, maxRequestBytes, state, mcp),
13
14
  };
14
- export function createProtocolHost(adapter, framework) {
15
+ export function createProtocolHost(adapter, framework, state, mcp) {
15
16
  const platform = checkedPlatform(adapter?.getType());
16
17
  const binding = AvProtocol.bind(framework);
17
- const host = PLATFORM_MOUNTERS[platform](adapter, binding, framework.entrypoint, framework.contracts.client.limits.maxRequestBytes);
18
+ const host = PLATFORM_MOUNTERS[platform](adapter, binding, framework.api, framework.contracts.client.limits.maxRequestBytes, state, mcp);
18
19
  return {
19
20
  mount: () => {
20
21
  host.mount();
21
- MOUNT_LOGGER.log(mountedLine(binding.surface(), framework.entrypoint));
22
+ MOUNT_LOGGER.log(mountedLine(binding.surface(), framework.api));
23
+ if (mcp !== undefined) {
24
+ MOUNT_LOGGER.log(mcpMountedLine(mcp.surface()));
25
+ }
22
26
  },
23
27
  };
24
28
  }
29
+ export function mcpMountedLine(routes) {
30
+ const endpoint = routes.find((route) => route.kind === "endpoint");
31
+ const metadata = routes.find((route) => route.kind === "metadata");
32
+ return `Aventara mounted the MCP endpoint at ${endpoint?.path ?? "/"} (${metadata === undefined
33
+ ? "no authorization: it answers callers on this machine only"
34
+ : `+ GET ${metadata.path}`})`;
35
+ }
25
36
  const MOUNT_LOGGER = new Logger("AventaraModule");
26
37
  export function mountedLine(routes, entrypoint) {
27
38
  const operations = routes.filter((route) => route.kind === "operation");
@@ -30,16 +41,16 @@ export function mountedLine(routes, entrypoint) {
30
41
  .map((route) => `${route.method} ${entrypoint}${route.path}`);
31
42
  return `Aventara mounted ${operations.length} ${operations.length === 1 ? "operation" : "operations"} at ${entrypoint === "" ? "/" : entrypoint} (+ ${framework.join(", ")})`;
32
43
  }
33
- export function createPendingProtocolHost(application, framework) {
44
+ export function createPendingProtocolHost(application, framework, state, mcp) {
34
45
  if (application.httpAdapter !== undefined) {
35
- const host = createProtocolHost(application.httpAdapter, framework);
46
+ const host = createProtocolHost(application.httpAdapter, framework, state, mcp);
36
47
  return { mount: host.mount, assertReady: () => undefined };
37
48
  }
38
49
  let host;
39
50
  let refusal;
40
51
  application.init$.subscribe(() => {
41
52
  try {
42
- host = createProtocolHost(application.httpAdapter, framework);
53
+ host = createProtocolHost(application.httpAdapter, framework, state, mcp);
43
54
  }
44
55
  catch (error) {
45
56
  refusal = error;
@@ -49,7 +60,7 @@ export function createPendingProtocolHost(application, framework) {
49
60
  if (refusal !== undefined) {
50
61
  throw refusal;
51
62
  }
52
- return host ?? createProtocolHost(undefined, framework);
63
+ return host ?? createProtocolHost(undefined, framework, state, mcp);
53
64
  };
54
65
  return {
55
66
  mount: () => ready().mount(),
@@ -1,10 +1,20 @@
1
+ import type { AvState } from "@aventara/core";
1
2
  import type { AvProtocolBinding, AvProtocolRequest, AvProtocolResponse } from "@aventara/core/protocol";
3
+ import type { McpBinding, McpRequest, McpRoute } from "./mcp-binding.loader.js";
2
4
  /** One route `protocol.surface()` lists. */
3
5
  export type SurfaceRoute = ReturnType<AvProtocolBinding["surface"]>[number];
6
+ /** The developer's `state` option over one platform request: seeds its `ctx.$state`. */
7
+ export type StateSeed = () => AvState | Promise<AvState>;
4
8
  /** What a mounted route answers its request with: a bound protocol member. */
5
- export type RouteAnswer = (request: AvProtocolRequest) => AvProtocolResponse | Promise<AvProtocolResponse>;
9
+ export type RouteAnswer = (request: AvProtocolRequest, state: StateSeed | undefined) => AvProtocolResponse | Promise<AvProtocolResponse>;
6
10
  /** The bound member each kind of surface route is answered by. */
7
11
  export type ProtocolRouteTable = {
8
12
  readonly [K in SurfaceRoute["kind"]]: (binding: AvProtocolBinding) => RouteAnswer;
9
13
  };
10
14
  export declare const PROTOCOL_ROUTES: ProtocolRouteTable;
15
+ /** What a mounted MCP route answers its request with: a bound `@aventara/mcp` member. */
16
+ export type McpRouteAnswer = (request: McpRequest, state: StateSeed | undefined) => AvProtocolResponse | Promise<AvProtocolResponse>;
17
+ /** The bound `@aventara/mcp` member each kind of MCP route is answered by. */
18
+ export declare const MCP_ROUTES: {
19
+ readonly [K in McpRoute["kind"]]: (binding: McpBinding) => McpRouteAnswer;
20
+ };
@@ -1,5 +1,9 @@
1
1
  export const PROTOCOL_ROUTES = {
2
- operation: (binding) => (request) => binding.handleOperation(request),
2
+ operation: (binding) => (request, state) => binding.handleOperation(request, state === undefined ? {} : { state }),
3
3
  contract: (binding) => (request) => binding.encodeContract(request),
4
- transactions: (binding) => (request) => binding.handleTransaction(request),
4
+ transactions: (binding) => (request, state) => binding.handleTransaction(request, state === undefined ? {} : { state }),
5
+ };
6
+ export const MCP_ROUTES = {
7
+ endpoint: (binding) => (request, state) => binding.handleEndpoint(request, state === undefined ? {} : { state }),
8
+ metadata: (binding) => (request) => binding.encodeMetadata(request),
5
9
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aventara/nest",
3
- "version": "1.0.0-rc.0",
3
+ "version": "1.0.0-rc.1",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "NestJS host integration for Aventara.",
6
6
  "type": "module",
@@ -22,24 +22,33 @@
22
22
  "CHANGELOG.md"
23
23
  ],
24
24
  "dependencies": {
25
- "@aventara/core": "1.0.0-rc.0"
25
+ "@aventara/core": "1.0.0-rc.1"
26
26
  },
27
27
  "peerDependencies": {
28
+ "@aventara/mcp": "1.0.0-rc.1",
28
29
  "@nestjs/common": "^12.0.0",
29
30
  "@nestjs/core": "^12.0.0"
30
31
  },
32
+ "peerDependenciesMeta": {
33
+ "@aventara/mcp": {
34
+ "optional": true
35
+ }
36
+ },
31
37
  "publishConfig": {
32
38
  "access": "public"
33
39
  },
34
40
  "devDependencies": {
35
- "@aventara/prisma7-adapter": "1.0.0-rc.0",
36
- "@aventara/testing": "1.0.0-rc.0",
41
+ "@aventara/mcp": "1.0.0-rc.1",
42
+ "@aventara/prisma7-adapter": "1.0.0-rc.1",
43
+ "@aventara/testing": "1.0.0-rc.1",
44
+ "@modelcontextprotocol/sdk": "1.30.1",
37
45
  "@nestjs/common": "^12.1.2",
38
46
  "@nestjs/core": "^12.1.2",
39
47
  "@nestjs/platform-express": "^12.1.2",
40
48
  "@nestjs/platform-fastify": "^12.1.2",
41
49
  "@nestjs/testing": "^12.1.2",
42
50
  "@prisma/adapter-better-sqlite3": "7.10.0",
51
+ "@prisma/adapter-pg": "7.10.0",
43
52
  "reflect-metadata": "^0.2.2",
44
53
  "rxjs": "^7.8.2"
45
54
  },