@sezzlee/sdk-nestjs 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +360 -2
- package/dist/cache.d.ts +18 -0
- package/dist/cache.js +55 -0
- package/dist/catalog.d.ts +100 -0
- package/dist/catalog.js +456 -0
- package/dist/decorators.d.ts +172 -0
- package/dist/decorators.js +79 -0
- package/dist/discovery/body-encoding.d.ts +24 -0
- package/dist/discovery/body-encoding.js +211 -0
- package/dist/discovery/diagnostics.d.ts +4 -0
- package/dist/discovery/diagnostics.js +49 -0
- package/dist/discovery/endpoint-discovery.d.ts +52 -0
- package/dist/discovery/endpoint-discovery.js +722 -0
- package/dist/discovery/type-shape.d.ts +21 -0
- package/dist/discovery/type-shape.js +297 -0
- package/dist/dispatcher.d.ts +29 -0
- package/dist/dispatcher.js +182 -0
- package/dist/extension-points.d.ts +34 -0
- package/dist/extension-points.js +35 -0
- package/dist/families.d.ts +77 -0
- package/dist/families.js +157 -0
- package/dist/files.d.ts +43 -0
- package/dist/files.js +13 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +26 -0
- package/dist/invoke-result-mapper.d.ts +16 -0
- package/dist/invoke-result-mapper.js +44 -0
- package/dist/markers.d.ts +5 -0
- package/dist/markers.js +27 -0
- package/dist/meta-tools.d.ts +18 -0
- package/dist/meta-tools.js +306 -0
- package/dist/options-validation.d.ts +17 -0
- package/dist/options-validation.js +79 -0
- package/dist/options.d.ts +201 -0
- package/dist/options.js +151 -0
- package/dist/outer-connection.d.ts +18 -0
- package/dist/outer-connection.js +23 -0
- package/dist/sezzlee.module.d.ts +16 -0
- package/dist/sezzlee.module.js +180 -0
- package/dist/synthetic-context.d.ts +18 -0
- package/dist/synthetic-context.js +131 -0
- package/dist/transport/audience.d.ts +2 -0
- package/dist/transport/audience.js +21 -0
- package/dist/transport/protected-resource-metadata.d.ts +6 -0
- package/dist/transport/protected-resource-metadata.js +23 -0
- package/dist/transport/streamable-http.d.ts +25 -0
- package/dist/transport/streamable-http.js +52 -0
- package/dist/visibility/evaluator.d.ts +8 -0
- package/dist/visibility/evaluator.js +5 -0
- package/dist/visibility/probe.d.ts +25 -0
- package/dist/visibility/probe.js +104 -0
- package/dist/visibility/provider.d.ts +23 -0
- package/dist/visibility/provider.js +118 -0
- package/package.json +99 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kaan Akın
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,361 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @sezzlee/sdk-nestjs
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An MCP layer that embeds into your existing NestJS backend. It exposes your endpoints to agents as
|
|
4
|
+
a search-first tool catalog and replays each call through **your own** pipeline, so your guards,
|
|
5
|
+
pipes and interceptors keep running unchanged. Not a gateway, not duplicated business logic.
|
|
6
|
+
|
|
7
|
+
> Status: `0.2.0`, alpha; breaking changes are possible. [samples/demo-api](samples/demo-api) is
|
|
8
|
+
> the reference wiring.
|
|
9
|
+
|
|
10
|
+
## Requirements
|
|
11
|
+
|
|
12
|
+
- NestJS 10 or later (`@nestjs/common`, `@nestjs/core`), dev-tested against 11.x
|
|
13
|
+
- `@modelcontextprotocol/server`, `@modelcontextprotocol/node` and `@modelcontextprotocol/express`
|
|
14
|
+
2.0 or later, `rxjs` 7 and `zod` 4
|
|
15
|
+
- `class-validator` and `class-transformer` are optional; when installed, their decorators shape
|
|
16
|
+
the argument schemas
|
|
17
|
+
- Node 22 or later, Express as the HTTP adapter
|
|
18
|
+
|
|
19
|
+
Peers are peers on purpose: sezzlee uses your Nest and your MCP SDK, not its own copies.
|
|
20
|
+
|
|
21
|
+
## 1. Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pnpm add @sezzlee/sdk-nestjs
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## 2. Wiring — three pieces
|
|
28
|
+
|
|
29
|
+
The .NET SDK is three method calls. This one is three _pieces_, because a Nest controller is your
|
|
30
|
+
code and a module cannot add a route to it.
|
|
31
|
+
|
|
32
|
+
**The module.** `forRoot` takes a callback that **mutates** an options instance — not an options
|
|
33
|
+
literal. Passing `{ visibility: { tier: "probe" } }` configures nothing.
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
@Module({
|
|
37
|
+
imports: [
|
|
38
|
+
SezzleeModule.forRoot((options) => {
|
|
39
|
+
options.visibility.tier = "probe";
|
|
40
|
+
}),
|
|
41
|
+
],
|
|
42
|
+
controllers: [OrdersController, McpController],
|
|
43
|
+
})
|
|
44
|
+
export class AppModule {}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`forRootAsync({ imports, inject, useFactory })` is the async form. The module is `@Global()`, so
|
|
48
|
+
its providers are available without importing it again.
|
|
49
|
+
|
|
50
|
+
**The MCP controller.** Copy this; the seven injections are not guessable.
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
@Controller()
|
|
54
|
+
export class McpController {
|
|
55
|
+
constructor(
|
|
56
|
+
private readonly streamableHttp: SezzleeStreamableHttp,
|
|
57
|
+
private readonly catalog: SezzleeCatalog,
|
|
58
|
+
private readonly dispatcher: SezzleeDispatcher,
|
|
59
|
+
private readonly visibility: CallerVisibilityProvider,
|
|
60
|
+
@Inject(extensionTokens.invokeResultMapper)
|
|
61
|
+
private readonly mapper: InvokeResultMapper,
|
|
62
|
+
@Inject(extensionTokens.callerScopeResolver)
|
|
63
|
+
private readonly scopes: CallerScopeResolver,
|
|
64
|
+
@Inject(SEZZLEE_OPTIONS) private readonly options: SezzleeOptions,
|
|
65
|
+
) {}
|
|
66
|
+
|
|
67
|
+
@All("mcp")
|
|
68
|
+
async handle(@Req() req: Request, @Res() res: Response): Promise<void> {
|
|
69
|
+
await this.streamableHttp.handle(req, res, () => {
|
|
70
|
+
const server = new McpServer({ name: "your-api", version: "0.0.0" });
|
|
71
|
+
registerSezzleeTools(server, {
|
|
72
|
+
catalog: this.catalog,
|
|
73
|
+
dispatcher: this.dispatcher,
|
|
74
|
+
mapper: this.mapper,
|
|
75
|
+
visibility: this.visibility,
|
|
76
|
+
scopes: this.scopes,
|
|
77
|
+
options: this.options,
|
|
78
|
+
});
|
|
79
|
+
return server;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`@All` rather than `@Post`: the default session mode is `stateless`, and the transport wants to
|
|
86
|
+
answer `GET`/`DELETE` with `405` itself instead of letting Nest return `404`.
|
|
87
|
+
|
|
88
|
+
**The bootstrap.**
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
import "reflect-metadata"; // must be the first import in the process
|
|
92
|
+
|
|
93
|
+
const app = await NestFactory.create(AppModule);
|
|
94
|
+
app.useGlobalPipes(new ValidationPipe({ transform: true }));
|
|
95
|
+
|
|
96
|
+
await app.init(); // before any app.get(...) / app.use(...)
|
|
97
|
+
await app.listen(3000);
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
> **Order is critical.** `reflect-metadata` must be imported first, or the decorators in §3 store
|
|
101
|
+
> nothing and your catalog comes up empty. And `await app.init()` must precede `app.use(...)` that
|
|
102
|
+
> resolves a provider — `app.get(...)` throws before the container is initialized.
|
|
103
|
+
|
|
104
|
+
## 3. Which endpoints become tools
|
|
105
|
+
|
|
106
|
+
The default is **opt-in**: nothing is exposed until you mark it.
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
@Controller()
|
|
110
|
+
@McpTool()
|
|
111
|
+
export class OrdersController {
|
|
112
|
+
@Get("orders/:id")
|
|
113
|
+
@McpTool({ description: "Fetches one order by id." })
|
|
114
|
+
getOrder(@Param("id", ParseIntPipe) id: number): Order { ... }
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
NestJS has no equivalent of ASP.NET's description metadata, so `@McpTool({ description })` is how
|
|
119
|
+
an agent gets something to search against. Input schemas come from your `class-validator` DTOs and
|
|
120
|
+
your pipes: `ParseIntPipe` yields an integer, `@Min`/`@Max` become `minimum`/`maximum`.
|
|
121
|
+
|
|
122
|
+
A whole-object query binding is expanded into one argument per member, so the agent sees the
|
|
123
|
+
filters:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
class ListOrdersQuery {
|
|
127
|
+
@IsString() customerId!: string;
|
|
128
|
+
@IsString() @IsOptional() status?: string;
|
|
129
|
+
@IsInt() @Min(1) @IsOptional() page?: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
@Get("orders")
|
|
133
|
+
list(@Query() query: ListOrdersQuery) { ... }
|
|
134
|
+
// arguments: customerId (required), status, page
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The expansion needs a readable type. A member that cannot be expressed as a query parameter — a
|
|
138
|
+
nested object, a dictionary — is omitted with an `unbound_query_object` warning. If **no** member
|
|
139
|
+
can be read (`@Query() q: Record<string, unknown>`, or a DTO with no `class-validator` decorators)
|
|
140
|
+
the endpoint is dropped with `unresolved_query_shape` rather than published as a tool that claims
|
|
141
|
+
to take no filters. Decorate the type, declare `options.schema.typeShape`, or add the code to
|
|
142
|
+
`options.diagnostics.downgrade` to publish it filterless anyway.
|
|
143
|
+
|
|
144
|
+
A member whose name is already claimed by a path parameter or a named `@Query('x')`/`@Headers('x')`
|
|
145
|
+
binding is omitted too, with a `query_member_shadowed` warning: the flat argument object has one
|
|
146
|
+
slot per name and the explicit binding owns it. Rename the member, or bind it by name.
|
|
147
|
+
|
|
148
|
+
### Routes
|
|
149
|
+
|
|
150
|
+
Routes are composed by Nest's own route factory, so the descriptor's route is the path Nest
|
|
151
|
+
actually serves: `setGlobalPrefix` (with its `exclude` list), `RouterModule.register({ path })`,
|
|
152
|
+
URI versioning, controller path and method path. Two consequences worth knowing:
|
|
153
|
+
|
|
154
|
+
- **A global prefix moves the protected-resource metadata.** Nest applies the prefix to middleware
|
|
155
|
+
paths too, so `/.well-known/oauth-protected-resource/mcp` becomes `/api/.well-known/...` and RFC
|
|
156
|
+
9728 discovery breaks. When `resourceServer` is set, sezzlee raises a fatal `prm_path_prefixed`
|
|
157
|
+
diagnostic naming the fix: `setGlobalPrefix("api", { exclude: ["/.well-known/oauth-protected-resource/mcp"] })`.
|
|
158
|
+
- **Non-URI versioning is not carried into invocation.** With `HEADER`, `MEDIA_TYPE` or `CUSTOM`
|
|
159
|
+
versioning the version never enters the path, and the synthetic request sezzlee replays carries no
|
|
160
|
+
version header, so dispatch lands on the default version. URI versioning has no such gap.
|
|
161
|
+
|
|
162
|
+
One operation bound to several routes — `@Controller(["orders", "purchases"])`, a legacy path kept
|
|
163
|
+
alongside a new one, or one handler under two URI versions — stays **one tool**: the same code at
|
|
164
|
+
two URLs is one operation, and two identical tools only dilute search. The invoked route is chosen
|
|
165
|
+
deterministically (shortest, then ordinal), the folded routes are reported as `route_folded`, and
|
|
166
|
+
they stay searchable so a query naming the compatibility path still finds the tool.
|
|
167
|
+
|
|
168
|
+
At scale, invert it and exclude the exceptions:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
SezzleeModule.forRoot((options) => {
|
|
172
|
+
options.selection.default = "include";
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Then `@McpIgnore()` on the endpoints or controllers you withhold.
|
|
177
|
+
|
|
178
|
+
For a subtree that is categorically off limits — or one you cannot decorate, such as a generated
|
|
179
|
+
or third-party controller — put the decision in configuration instead:
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
SezzleeModule.forRoot((options) => {
|
|
183
|
+
options.selection.default = "include";
|
|
184
|
+
options.selection.rules = [
|
|
185
|
+
{ route: "/admin/**", decision: "exclude" },
|
|
186
|
+
{ method: "POST", decision: "exclude" },
|
|
187
|
+
];
|
|
188
|
+
});
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`*` stays inside one path segment and `**` crosses them, so `**` is the catch-all and `*` is not.
|
|
192
|
+
Route matching is case-sensitive and a path parameter is matched as the literal `{id}` the template
|
|
193
|
+
carries; `method` ignores case. Rules rank by how many fields they name, never by declaration
|
|
194
|
+
order, and two equally specific rules that disagree fail the catalog with `ambiguous_selection`
|
|
195
|
+
rather than one quietly winning. An attribute always outranks a rule, so a carve-out inside an
|
|
196
|
+
excluded subtree goes on the endpoint.
|
|
197
|
+
|
|
198
|
+
Visibility is **not** a security mechanism. A tool hidden from the catalog still runs only if your
|
|
199
|
+
backend permits it; enforcement is always in your pipeline at invoke time.
|
|
200
|
+
|
|
201
|
+
## 3b. Curating what the agent sees
|
|
202
|
+
|
|
203
|
+
Your DTO was written for HTTP clients. `arguments` declares a different agent-facing surface over
|
|
204
|
+
the same endpoint, without touching the DTO.
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
@Get("orders")
|
|
208
|
+
@McpTool({
|
|
209
|
+
description: "Search your orders by keyword.",
|
|
210
|
+
arguments: curate<ListOrdersQuery>({
|
|
211
|
+
customerId: hidden.from("tenant"),
|
|
212
|
+
status: { description: "active | closed" },
|
|
213
|
+
page: { as: "page_number" },
|
|
214
|
+
}),
|
|
215
|
+
})
|
|
216
|
+
list(@Query() query: ListOrdersQuery) { ... }
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
The agent sends `page_number`; the request still goes out as `?page=`. It never sees `customerId`,
|
|
220
|
+
and sending it is an `unknown_argument` error. The value comes from a provider you register once:
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
options.arguments.provide("tenant", (caller) => caller.claim("tid"));
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`hidden.value(x)` writes a constant instead, and `hidden.omit()` sends nothing so your backend's own
|
|
227
|
+
default stands. Rules can also live centrally — `options.arguments.curate(target, rules)` for
|
|
228
|
+
controllers you cannot decorate, `seal` for a rule no decorator may override.
|
|
229
|
+
|
|
230
|
+
**Curation is not enforcement.** Filling `customerId` from a token does not isolate tenants; your
|
|
231
|
+
pipeline still decides. It changes what the agent has to think about, not what it may do.
|
|
232
|
+
|
|
233
|
+
`@McpVariant({ name, description, arguments })` produces several tools from one handler. Both fields
|
|
234
|
+
are required, because one description cannot honestly describe two differently curated tools.
|
|
235
|
+
|
|
236
|
+
`@McpToolFamily({ parameter: "methodId", source: "..." })` publishes a handler that dispatches on a
|
|
237
|
+
parameter as one tool per method. `options.families.provide(name, source)` supplies the members —
|
|
238
|
+
key, name, description, body schema — loaded in `onApplicationBootstrap` and on every `reload()`,
|
|
239
|
+
which now returns a promise; the key is written into the route and never reaches the agent. Full
|
|
240
|
+
guide: the docs site's _How to expose a dispatching endpoint as one tool per method_.
|
|
241
|
+
|
|
242
|
+
Full guide: the docs site's _How to curate the arguments an agent sees_.
|
|
243
|
+
|
|
244
|
+
## 3c. Telling the agent what a tool returns
|
|
245
|
+
|
|
246
|
+
`load_tool` publishes an `outputSchema` alongside `inputSchema`, so an agent can plan a chain of
|
|
247
|
+
calls without making the first one. Nest has to be told what a handler returns: TypeScript erases
|
|
248
|
+
generics, so an `async` handler reports `Promise` at runtime and a collection reports `Array`.
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
@Get("orders/:id")
|
|
252
|
+
@McpTool({
|
|
253
|
+
description: "Fetches one order by id.",
|
|
254
|
+
responses: { 200: OrderResponse, 404: {} },
|
|
255
|
+
})
|
|
256
|
+
getOrder(@Param("id", ParseIntPipe) id: number): OrderResponse { ... }
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Per status code: a DTO class, `[OrderResponse]` for a collection of it, `{ schema }` for a shape no
|
|
260
|
+
class can express, or `{}` for a status with no body. A DTO needs class-validator decorators, the
|
|
261
|
+
same as a request DTO.
|
|
262
|
+
|
|
263
|
+
If you already write `@ApiOkResponse({ type: OrderResponse })`, it is read as a fallback, and a sync
|
|
264
|
+
handler's plain class return type is read as a last resort. The declaration wins over both.
|
|
265
|
+
|
|
266
|
+
Only one status becomes the schema — `200`, `201`, `202`, `204` in that order, then the lowest
|
|
267
|
+
remaining `2xx`. A non-object root is wrapped under `result`, because MCP requires an object. Unlike
|
|
268
|
+
the input side, read-only members are kept: they are what a response is made of.
|
|
269
|
+
|
|
270
|
+
Full guide: the docs site's _How to tell the agent what a tool returns_.
|
|
271
|
+
|
|
272
|
+
## 4. Visibility and guards
|
|
273
|
+
|
|
274
|
+
This is the SDK's defining behaviour, and the thing most likely to surprise you.
|
|
275
|
+
|
|
276
|
+
sezzlee asks every guard on an endpoint — global, controller and method — for a declaration, by
|
|
277
|
+
looking for a `describeVisibility()` method. **If any guard lacks it, the endpoint is marked
|
|
278
|
+
imperative and its visibility is `unknown` forever.**
|
|
279
|
+
|
|
280
|
+
```ts
|
|
281
|
+
@Injectable()
|
|
282
|
+
export class OrdersReadGuard implements CanActivate {
|
|
283
|
+
canActivate(): boolean {
|
|
284
|
+
/* your real check, unchanged */ return true;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
describeVisibility(): { anonymous: "no"; policies: string[] } {
|
|
288
|
+
return { anonymous: "no", policies: ["OrdersRead"] };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
`unknown` is not a denial: with the default `visibility.onUnknown: "show"` the tool still appears,
|
|
294
|
+
flagged `authUncertain`. When the guards cannot declare themselves, `visibility.tier = "probe"`
|
|
295
|
+
resolves them by really running them — at a cost. The full recipe, including
|
|
296
|
+
`visibility.probeValues`, is in the docs site's
|
|
297
|
+
_How to declare visibility for a NestJS guard_.
|
|
298
|
+
|
|
299
|
+
## 5. Connecting an MCP client
|
|
300
|
+
|
|
301
|
+
The endpoint speaks Streamable HTTP. `tools/list` returns only three meta-tools:
|
|
302
|
+
|
|
303
|
+
| Tool | Job |
|
|
304
|
+
| -------------- | ------------------------------------------ |
|
|
305
|
+
| `search_tools` | Finds operations by keyword, returns cards |
|
|
306
|
+
| `load_tool` | Returns one operation's full input schema |
|
|
307
|
+
| `invoke_tool` | Calls it |
|
|
308
|
+
|
|
309
|
+
The catalog is not dumped into `tools/list`: on a 700-endpoint backend that drowns the agent's
|
|
310
|
+
context. The agent searches, then loads, then calls.
|
|
311
|
+
|
|
312
|
+
`search_tools` takes `query`, `limit`, `detail` and `tags`. `tags` narrows the answer to operations
|
|
313
|
+
carrying every tag listed, matched whole and insensitive to case and accents; every answer carries
|
|
314
|
+
the vocabulary the caller may see, so the agent reads a tag rather than guessing one. Each operation
|
|
315
|
+
is tagged with its controller name unless you say otherwise:
|
|
316
|
+
|
|
317
|
+
```ts
|
|
318
|
+
@McpTool({ name: "find_orders", tags: ["billing", "read"] })
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
A declaration replaces the controller-derived tag rather than adding to it, and `options.tags` sets
|
|
322
|
+
the same thing centrally for controllers you cannot decorate. Tags are search text too, so changing
|
|
323
|
+
them changes ranking — see the how-to on grouping operations.
|
|
324
|
+
|
|
325
|
+
Words an agent may type that your operation's text does not contain go in `searchTerms`
|
|
326
|
+
(`@McpTool({ searchTerms: ["sipariş"] })`, or `options.searchTerms` centrally): indexed like the
|
|
327
|
+
description, never a filter key, never shown to the agent. To rank with your own retriever instead
|
|
328
|
+
of BM25, bind a `ToolRanker` through `forRoot`'s overrides (`{ toolRanker: { useClass: MyRanker } }`);
|
|
329
|
+
`options.search.rankerTimeoutMs` (10000 by default) and `options.search.onRankerFailure`
|
|
330
|
+
(`"fallback"` or `"error"`) govern it. Visibility, the tag filter and the payload budget still apply
|
|
331
|
+
to its answer — see the how-to on plugging in your own search.
|
|
332
|
+
|
|
333
|
+
This repository ships a client:
|
|
334
|
+
|
|
335
|
+
```bash
|
|
336
|
+
SEZZLEE_BASE_URL=http://127.0.0.1:3000 SEZZLEE_AUTH=token SEZZLEE_USER=alice \
|
|
337
|
+
node sdks/nestjs/samples/agent-client/dist/main.js --scenario smoke --query "create order"
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
To protect `/mcp`, set `options.resourceServer` and the module installs bearer verification plus
|
|
341
|
+
the RFC 9728 metadata handler for you.
|
|
342
|
+
|
|
343
|
+
## 6. Troubleshooting
|
|
344
|
+
|
|
345
|
+
| Symptom | Cause | Fix |
|
|
346
|
+
| ----------------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
|
347
|
+
| `search_tools` always empty | `selection.default` is `exclude` and nothing is decorated | Add `@McpTool()`, or set `selection.default = "include"` |
|
|
348
|
+
| Everything comes back `authUncertain` | A guard without `describeVisibility()`, or `tier` still declarative | Declare the guards, or set `visibility.tier = "probe"` |
|
|
349
|
+
| Catalog empty, decorators appear to do nothing | `reflect-metadata` not imported first | Make it the first import in `main.ts` |
|
|
350
|
+
| Startup throws resolving a provider | `app.use(...)` before `app.init()` | `await app.init()` first |
|
|
351
|
+
| No PRM document, 401 carries no pointer | `resourceServer` unset | Set `options.resourceServer`; check `/.well-known/oauth-protected-resource/mcp` |
|
|
352
|
+
| `GET /mcp` returns 405 | `transport.sessionMode` is `stateless` (the default) | Expected. Use `POST`, or switch to `stateful` |
|
|
353
|
+
| Host will not boot, fatal diagnostic | `name_collision` / `ambiguous_selection` / `invalid_name` | Read the codes in the log, then `options.diagnostics.downgrade` |
|
|
354
|
+
| A tool is missing and `unresolved_query_shape` was reported | Its `@Query()` type carries no readable members | Decorate the DTO, declare `options.schema.typeShape`, or downgrade the code |
|
|
355
|
+
| PRM 404s under a global prefix | `prm_path_prefixed` — Nest prefixed the `.well-known` path | Add the metadata path to `setGlobalPrefix`'s `exclude` list |
|
|
356
|
+
|
|
357
|
+
## 7. What's next
|
|
358
|
+
|
|
359
|
+
- The docs site: `pnpm --filter @sezzlee/docs dev` → `http://localhost:5180`
|
|
360
|
+
- [samples/demo-api](samples/demo-api) — the same endpoint matrix as the .NET sample: anonymous,
|
|
361
|
+
identity-only, policy, role, imperative ownership, and a POST with path, query and body
|
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type CacheTag, type CallerScope, type SezzleeCache } from "@sezzlee/core";
|
|
2
|
+
import { SezzleeOptions, type OuterRequest } from "./options.js";
|
|
3
|
+
export interface CallerScopeResolver {
|
|
4
|
+
resolve(outer: OuterRequest | undefined): CallerScope;
|
|
5
|
+
}
|
|
6
|
+
export declare class CarrierHashCallerScopeResolver implements CallerScopeResolver {
|
|
7
|
+
private readonly options;
|
|
8
|
+
constructor(options: SezzleeOptions);
|
|
9
|
+
resolve(outer: OuterRequest | undefined): CallerScope;
|
|
10
|
+
}
|
|
11
|
+
export declare const SEZZLEE_CACHE_INVALIDATOR: unique symbol;
|
|
12
|
+
export declare class SezzleeCacheInvalidator {
|
|
13
|
+
private readonly cache;
|
|
14
|
+
constructor(cache: SezzleeCache);
|
|
15
|
+
invalidateCaller(scope: CallerScope): Promise<void>;
|
|
16
|
+
invalidateTag(tag: CacheTag): Promise<void>;
|
|
17
|
+
invalidateAll(): Promise<void>;
|
|
18
|
+
}
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
8
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
|
+
};
|
|
10
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
11
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
12
|
+
};
|
|
13
|
+
import { Inject, Injectable } from "@nestjs/common";
|
|
14
|
+
import { createCallerScope, deriveCallerScopeKey, digestInput, } from "@sezzlee/core";
|
|
15
|
+
import { extensionTokens } from "./extension-points.js";
|
|
16
|
+
import { SEZZLEE_OPTIONS, SezzleeOptions, } from "./options.js";
|
|
17
|
+
let CarrierHashCallerScopeResolver = class CarrierHashCallerScopeResolver {
|
|
18
|
+
options;
|
|
19
|
+
constructor(options) {
|
|
20
|
+
this.options = options;
|
|
21
|
+
}
|
|
22
|
+
resolve(outer) {
|
|
23
|
+
const carriers = [...this.options.identity.carriers];
|
|
24
|
+
const digest = digestInput(carriers, (name) => outer?.headers[name]);
|
|
25
|
+
return createCallerScope(deriveCallerScopeKey(digest));
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
CarrierHashCallerScopeResolver = __decorate([
|
|
29
|
+
Injectable(),
|
|
30
|
+
__param(0, Inject(SEZZLEE_OPTIONS)),
|
|
31
|
+
__metadata("design:paramtypes", [SezzleeOptions])
|
|
32
|
+
], CarrierHashCallerScopeResolver);
|
|
33
|
+
export { CarrierHashCallerScopeResolver };
|
|
34
|
+
export const SEZZLEE_CACHE_INVALIDATOR = Symbol("SEZZLEE_CACHE_INVALIDATOR");
|
|
35
|
+
let SezzleeCacheInvalidator = class SezzleeCacheInvalidator {
|
|
36
|
+
cache;
|
|
37
|
+
constructor(cache) {
|
|
38
|
+
this.cache = cache;
|
|
39
|
+
}
|
|
40
|
+
invalidateCaller(scope) {
|
|
41
|
+
return this.cache.removeScope(scope.key);
|
|
42
|
+
}
|
|
43
|
+
invalidateTag(tag) {
|
|
44
|
+
return this.cache.removeTag(tag);
|
|
45
|
+
}
|
|
46
|
+
invalidateAll() {
|
|
47
|
+
return this.cache.clear();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
SezzleeCacheInvalidator = __decorate([
|
|
51
|
+
Injectable(),
|
|
52
|
+
__param(0, Inject(extensionTokens.cache)),
|
|
53
|
+
__metadata("design:paramtypes", [Object])
|
|
54
|
+
], SezzleeCacheInvalidator);
|
|
55
|
+
export { SezzleeCacheInvalidator };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { type OnApplicationBootstrap } from "@nestjs/common";
|
|
2
|
+
import { ApplicationConfig, DiscoveryService, HttpAdapterHost, ModulesContainer } from "@nestjs/core";
|
|
3
|
+
import { type ArgumentCuration, type CatalogBuild, type CatalogEntry as CoreCatalogEntry, type EndpointDescriptor, type ToolRanker } from "@sezzlee/core";
|
|
4
|
+
import { type CatalogDiagnostic } from "./discovery/diagnostics.js";
|
|
5
|
+
import { type DiscoveredEndpoint } from "./discovery/endpoint-discovery.js";
|
|
6
|
+
import type { ArgumentRule } from "./decorators.js";
|
|
7
|
+
import { type FamilyResolution } from "./families.js";
|
|
8
|
+
import { type SezzleeOptions } from "./options.js";
|
|
9
|
+
export { cleanTags } from "@sezzlee/core";
|
|
10
|
+
export interface NestSource {
|
|
11
|
+
readonly controller: NewableFunction;
|
|
12
|
+
readonly handlerName: string;
|
|
13
|
+
}
|
|
14
|
+
export type CatalogEntry = CoreCatalogEntry<NestSource>;
|
|
15
|
+
export interface CatalogSnapshot extends CatalogBuild<NestSource> {
|
|
16
|
+
readonly discovered: number;
|
|
17
|
+
}
|
|
18
|
+
type LoweredFamily = Extract<FamilyResolution, {
|
|
19
|
+
kind: "members";
|
|
20
|
+
}>;
|
|
21
|
+
export declare class SezzleeCatalog implements OnApplicationBootstrap {
|
|
22
|
+
private readonly discovery;
|
|
23
|
+
private readonly modules;
|
|
24
|
+
private readonly applicationConfig;
|
|
25
|
+
private readonly options;
|
|
26
|
+
private readonly adapterHost?;
|
|
27
|
+
private readonly toolRanker;
|
|
28
|
+
private snapshot;
|
|
29
|
+
private currentGeneration;
|
|
30
|
+
private readonly listeners;
|
|
31
|
+
private loads;
|
|
32
|
+
private pending;
|
|
33
|
+
constructor(discovery: DiscoveryService, modules: ModulesContainer, applicationConfig: ApplicationConfig, options: SezzleeOptions, adapterHost?: HttpAdapterHost | undefined, toolRanker?: ToolRanker | null);
|
|
34
|
+
get ranker(): ToolRanker | null;
|
|
35
|
+
get generation(): number;
|
|
36
|
+
get current(): CatalogSnapshot;
|
|
37
|
+
onChange(listener: () => void): () => void;
|
|
38
|
+
/**
|
|
39
|
+
* Rebuilds the catalog, loading family members first when any source is registered.
|
|
40
|
+
*
|
|
41
|
+
* Without a source the rebuild happens before this returns, as it always has. With one, reloads
|
|
42
|
+
* run one at a time, and a rebuild that would turn a valid catalog fatal is refused and the
|
|
43
|
+
* current catalog kept ([tool-families.md] §Membership): member data added under a name another
|
|
44
|
+
* tool already has would otherwise take every tool offline.
|
|
45
|
+
*
|
|
46
|
+
* @throws SezzleeCatalogError through the returned promise when the rebuild is refused
|
|
47
|
+
*/
|
|
48
|
+
reload(): Promise<void>;
|
|
49
|
+
/**
|
|
50
|
+
* Loads family members once the application's own providers are ready, since a source usually
|
|
51
|
+
* reads from one of them. A catalog built earlier carries `family_not_loaded` and is rebuilt
|
|
52
|
+
* here, which signals `listChanged`.
|
|
53
|
+
*/
|
|
54
|
+
onApplicationBootstrap(): Promise<void>;
|
|
55
|
+
private enqueue;
|
|
56
|
+
private commit;
|
|
57
|
+
get diagnostics(): readonly CatalogDiagnostic[];
|
|
58
|
+
find(name: string): CatalogEntry | undefined;
|
|
59
|
+
ensureValid(): void;
|
|
60
|
+
/**
|
|
61
|
+
* Guard: Express 5 defaults `query parser` to `simple`, which does not parse
|
|
62
|
+
* brackets — `?filter[status]=x` then arrives as one literal key and the DTO
|
|
63
|
+
* binds nothing. A tool that composes a filter the backend silently ignores
|
|
64
|
+
* returns an unfiltered result set, which is the failure
|
|
65
|
+
* `unresolved_query_shape` exists to prevent, so this is fatal rather than a
|
|
66
|
+
* warning. Only Express is checked: `getInstance().get` is a route
|
|
67
|
+
* registrar on Fastify, not a settings reader.
|
|
68
|
+
*/
|
|
69
|
+
private reportUnparsedBrackets;
|
|
70
|
+
/**
|
|
71
|
+
* Guard: Express parses a body only for the media types a registered parser declares, and an
|
|
72
|
+
* unparsed body reaches the handler as `req.body === undefined` with no error (form-body-probe
|
|
73
|
+
* N6) — a tool the backend answers as if every field were absent. The default parsers are
|
|
74
|
+
* `jsonParser` and `urlencodedParser`, visible by name on the router stack (N7); a text body
|
|
75
|
+
* needs `app.useBodyParser('text')`. Only Express is checked, for `reportUnparsedBrackets`'s
|
|
76
|
+
* reason.
|
|
77
|
+
*/
|
|
78
|
+
private lacksBodyParser;
|
|
79
|
+
private build;
|
|
80
|
+
private checkMetadataPath;
|
|
81
|
+
/**
|
|
82
|
+
* Applies the declaration ladder, least specific first, with sealed fields last.
|
|
83
|
+
*
|
|
84
|
+
* Merging is per argument and per field, so a global rule that hides a tenant identifier and a
|
|
85
|
+
* method-level rule that renames a page number both survive. A decorator wins a tie against a
|
|
86
|
+
* central rule of the same specificity: it is nearer the code and that is the reading a
|
|
87
|
+
* maintainer expects.
|
|
88
|
+
*/
|
|
89
|
+
private curationFor;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Folds the decorator's declarations into the descriptor.
|
|
93
|
+
*
|
|
94
|
+
* Discovery reports facts; declarations are reduced here, the same way `toolName` and
|
|
95
|
+
* `containerPrefix` are. The curation list arrives resolved because the ladder that produces it
|
|
96
|
+
* needs the host's central rules, which are not a property of the endpoint; a family's members
|
|
97
|
+
* arrive already lowered, because they were loaded from a source rather than declared.
|
|
98
|
+
*/
|
|
99
|
+
export declare function declaredDescriptor(endpoint: DiscoveredEndpoint, curation: readonly ArgumentCuration[], tags?: readonly string[], family?: LoweredFamily): EndpointDescriptor;
|
|
100
|
+
export declare function toCuration(rules: Readonly<Record<string, ArgumentRule>>): ArgumentCuration[];
|