@sezzlee/sdk-nestjs 0.0.0-stage → 0.1.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 +291 -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/dist/catalog.js
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
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, Optional, RequestMethod, } from "@nestjs/common";
|
|
14
|
+
import { ApplicationConfig, DiscoveryService, HttpAdapterHost, ModulesContainer, } from "@nestjs/core";
|
|
15
|
+
import { assertCatalogValid, buildCatalog, combineMarkers, matchesRoute, SezzleeTemplateError, textMediaType, urlEncodedMediaType, } from "@sezzlee/core";
|
|
16
|
+
import { severityOf } from "./discovery/diagnostics.js";
|
|
17
|
+
import { createRoutePaths, discoverEndpoints, modulePathOf, normalizeRoute, } from "./discovery/endpoint-discovery.js";
|
|
18
|
+
import { loadFamilies, resolveFamily, } from "./families.js";
|
|
19
|
+
import { SEZZLEE_OPTIONS, } from "./options.js";
|
|
20
|
+
import { extensionTokens } from "./extension-points.js";
|
|
21
|
+
import { protectedResourceMetadataPath } from "./transport/protected-resource-metadata.js";
|
|
22
|
+
export { cleanTags } from "@sezzlee/core";
|
|
23
|
+
let SezzleeCatalog = class SezzleeCatalog {
|
|
24
|
+
discovery;
|
|
25
|
+
modules;
|
|
26
|
+
applicationConfig;
|
|
27
|
+
options;
|
|
28
|
+
adapterHost;
|
|
29
|
+
toolRanker;
|
|
30
|
+
snapshot;
|
|
31
|
+
currentGeneration = 0;
|
|
32
|
+
listeners = new Set();
|
|
33
|
+
loads;
|
|
34
|
+
pending = Promise.resolve();
|
|
35
|
+
constructor(discovery, modules, applicationConfig, options, adapterHost, toolRanker = null) {
|
|
36
|
+
this.discovery = discovery;
|
|
37
|
+
this.modules = modules;
|
|
38
|
+
this.applicationConfig = applicationConfig;
|
|
39
|
+
this.options = options;
|
|
40
|
+
this.adapterHost = adapterHost;
|
|
41
|
+
this.toolRanker = toolRanker;
|
|
42
|
+
}
|
|
43
|
+
get ranker() {
|
|
44
|
+
return this.toolRanker;
|
|
45
|
+
}
|
|
46
|
+
get generation() {
|
|
47
|
+
return this.currentGeneration;
|
|
48
|
+
}
|
|
49
|
+
get current() {
|
|
50
|
+
this.snapshot ??= this.build(this.loads);
|
|
51
|
+
return this.snapshot;
|
|
52
|
+
}
|
|
53
|
+
onChange(listener) {
|
|
54
|
+
this.listeners.add(listener);
|
|
55
|
+
return () => this.listeners.delete(listener);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Rebuilds the catalog, loading family members first when any source is registered.
|
|
59
|
+
*
|
|
60
|
+
* Without a source the rebuild happens before this returns, as it always has. With one, reloads
|
|
61
|
+
* run one at a time, and a rebuild that would turn a valid catalog fatal is refused and the
|
|
62
|
+
* current catalog kept ([tool-families.md] §Membership): member data added under a name another
|
|
63
|
+
* tool already has would otherwise take every tool offline.
|
|
64
|
+
*
|
|
65
|
+
* @throws SezzleeCatalogError through the returned promise when the rebuild is refused
|
|
66
|
+
*/
|
|
67
|
+
reload() {
|
|
68
|
+
if (this.options.families.sources.size === 0) {
|
|
69
|
+
this.commit(this.build(this.loads));
|
|
70
|
+
return Promise.resolve();
|
|
71
|
+
}
|
|
72
|
+
return this.enqueue(async () => {
|
|
73
|
+
const loads = await loadFamilies(this.options.families, this.loads);
|
|
74
|
+
const candidate = this.build(loads);
|
|
75
|
+
if (candidate.fatal.length > 0 && this.current.fatal.length === 0) {
|
|
76
|
+
assertCatalogValid(candidate.fatal);
|
|
77
|
+
}
|
|
78
|
+
this.loads = loads;
|
|
79
|
+
this.commit(candidate);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Loads family members once the application's own providers are ready, since a source usually
|
|
84
|
+
* reads from one of them. A catalog built earlier carries `family_not_loaded` and is rebuilt
|
|
85
|
+
* here, which signals `listChanged`.
|
|
86
|
+
*/
|
|
87
|
+
async onApplicationBootstrap() {
|
|
88
|
+
if (this.options.families.sources.size === 0) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
await this.enqueue(async () => {
|
|
92
|
+
this.loads = await loadFamilies(this.options.families, this.loads);
|
|
93
|
+
if (this.snapshot !== undefined) {
|
|
94
|
+
this.commit(this.build(this.loads));
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
enqueue(task) {
|
|
99
|
+
const next = this.pending.then(task);
|
|
100
|
+
this.pending = next.catch(() => undefined);
|
|
101
|
+
return next;
|
|
102
|
+
}
|
|
103
|
+
commit(snapshot) {
|
|
104
|
+
this.snapshot = snapshot;
|
|
105
|
+
this.currentGeneration += 1;
|
|
106
|
+
for (const listener of this.listeners) {
|
|
107
|
+
listener();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
get diagnostics() {
|
|
111
|
+
return this.current.diagnostics;
|
|
112
|
+
}
|
|
113
|
+
find(name) {
|
|
114
|
+
return this.current.byName.get(name);
|
|
115
|
+
}
|
|
116
|
+
ensureValid() {
|
|
117
|
+
assertCatalogValid(this.current.fatal);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Guard: Express 5 defaults `query parser` to `simple`, which does not parse
|
|
121
|
+
* brackets — `?filter[status]=x` then arrives as one literal key and the DTO
|
|
122
|
+
* binds nothing. A tool that composes a filter the backend silently ignores
|
|
123
|
+
* returns an unfiltered result set, which is the failure
|
|
124
|
+
* `unresolved_query_shape` exists to prevent, so this is fatal rather than a
|
|
125
|
+
* warning. Only Express is checked: `getInstance().get` is a route
|
|
126
|
+
* registrar on Fastify, not a settings reader.
|
|
127
|
+
*/
|
|
128
|
+
reportUnparsedBrackets(entries, report) {
|
|
129
|
+
const bracketed = entries.filter((entry) => (entry.descriptor.parameters ?? []).some((parameter) => parameter.style === "deepObject" &&
|
|
130
|
+
(parameter.objectNotation ?? "bracket") === "bracket"));
|
|
131
|
+
if (bracketed.length === 0) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const adapter = this.adapterHost?.httpAdapter;
|
|
135
|
+
if (adapter === undefined || adapter.getType() !== "express") {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const instance = adapter.getInstance();
|
|
139
|
+
const parser = instance.get("query parser");
|
|
140
|
+
if (parser === "extended" || typeof parser === "function") {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
report({
|
|
144
|
+
code: "query_parser_not_extended",
|
|
145
|
+
message: `Tool '${bracketed[0]?.tool.name}' composes a bracketed query object, but this application's Express 'query parser' is '${String(parser)}', which delivers one literal key instead of an object. Call app.set('query parser', 'extended'), or declare objectNotation on the parameter.`,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Guard: Express parses a body only for the media types a registered parser declares, and an
|
|
150
|
+
* unparsed body reaches the handler as `req.body === undefined` with no error (form-body-probe
|
|
151
|
+
* N6) — a tool the backend answers as if every field were absent. The default parsers are
|
|
152
|
+
* `jsonParser` and `urlencodedParser`, visible by name on the router stack (N7); a text body
|
|
153
|
+
* needs `app.useBodyParser('text')`. Only Express is checked, for `reportUnparsedBrackets`'s
|
|
154
|
+
* reason.
|
|
155
|
+
*/
|
|
156
|
+
lacksBodyParser(descriptor) {
|
|
157
|
+
const contentType = descriptor.requestBody?.contentType;
|
|
158
|
+
const needed = contentType === textMediaType
|
|
159
|
+
? "textParser"
|
|
160
|
+
: contentType === urlEncodedMediaType
|
|
161
|
+
? "urlencodedParser"
|
|
162
|
+
: undefined;
|
|
163
|
+
const adapter = this.adapterHost?.httpAdapter;
|
|
164
|
+
if (needed === undefined ||
|
|
165
|
+
adapter === undefined ||
|
|
166
|
+
adapter.getType() !== "express") {
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
const instance = adapter.getInstance();
|
|
170
|
+
const stack = instance.router?.stack ?? instance._router?.stack ?? [];
|
|
171
|
+
return stack.some((layer) => layer.name === needed) ? undefined : needed;
|
|
172
|
+
}
|
|
173
|
+
build(loads) {
|
|
174
|
+
const prior = [];
|
|
175
|
+
const report = (diagnostic) => {
|
|
176
|
+
prior.push(diagnostic);
|
|
177
|
+
};
|
|
178
|
+
const routePaths = createRoutePaths(this.applicationConfig);
|
|
179
|
+
const globalPrefix = this.applicationConfig.getGlobalPrefix();
|
|
180
|
+
const versioningOptions = this.applicationConfig.getVersioning();
|
|
181
|
+
const mcpPath = this.options.resourceServer?.mcpPath ?? "/mcp";
|
|
182
|
+
this.checkMetadataPath(globalPrefix, mcpPath, routePaths, report);
|
|
183
|
+
const refDescription = this.options.files.resolver?.refDescription;
|
|
184
|
+
const files = refDescription === undefined ? undefined : { refDescription };
|
|
185
|
+
const applicationId = this.modules.applicationId;
|
|
186
|
+
const controllers = this.discovery
|
|
187
|
+
.getControllers()
|
|
188
|
+
.filter((wrapper) => typeof wrapper.metatype === "function")
|
|
189
|
+
.map((wrapper) => {
|
|
190
|
+
const modulePath = modulePathOf(wrapper.host?.metatype, applicationId);
|
|
191
|
+
return {
|
|
192
|
+
metatype: wrapper.metatype,
|
|
193
|
+
...(modulePath === undefined ? {} : { modulePath }),
|
|
194
|
+
};
|
|
195
|
+
});
|
|
196
|
+
const discovered = discoverEndpoints(controllers, {
|
|
197
|
+
...(this.options.schema === undefined
|
|
198
|
+
? {}
|
|
199
|
+
: { schema: this.options.schema }),
|
|
200
|
+
...(versioningOptions === undefined ? {} : { versioningOptions }),
|
|
201
|
+
globalPrefix,
|
|
202
|
+
routePaths,
|
|
203
|
+
severity: (code) => severityOf(code, this.options.diagnostics),
|
|
204
|
+
queryGrouping: this.options.query.grouping,
|
|
205
|
+
report,
|
|
206
|
+
});
|
|
207
|
+
const reserved = routePaths
|
|
208
|
+
.create({ globalPrefix, methodPath: mcpPath }, RequestMethod.ALL)
|
|
209
|
+
.map(normalizeRoute);
|
|
210
|
+
const families = new Map();
|
|
211
|
+
const routed = discovered.filter((endpoint) => {
|
|
212
|
+
if (reserved.some((path) => endpoint.descriptor.route.startsWith(path))) {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
const family = endpoint.hints.family;
|
|
216
|
+
if (family === undefined) {
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
const resolution = resolveFamily(family, `${endpoint.controller.name}.${endpoint.handlerName}`, endpoint.hints.variants ?? [], this.options.families, loads);
|
|
220
|
+
if (resolution.kind === "refused") {
|
|
221
|
+
report(resolution.diagnostic);
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
if (resolution.kind === "members") {
|
|
225
|
+
resolution.diagnostics.forEach(report);
|
|
226
|
+
families.set(endpoint, resolution);
|
|
227
|
+
}
|
|
228
|
+
return true;
|
|
229
|
+
});
|
|
230
|
+
const built = buildCatalog(routed.map((endpoint) => {
|
|
231
|
+
const tags = endpoint.hints.tags ?? this.options.tags?.(endpoint.controller.name);
|
|
232
|
+
const searchTerms = endpoint.hints.searchTerms ??
|
|
233
|
+
this.options.searchTerms?.(endpoint.controller.name);
|
|
234
|
+
return {
|
|
235
|
+
source: {
|
|
236
|
+
controller: endpoint.controller,
|
|
237
|
+
handlerName: endpoint.handlerName,
|
|
238
|
+
},
|
|
239
|
+
owner: `${endpoint.controller.name}.${endpoint.handlerName}`,
|
|
240
|
+
descriptor: endpoint.descriptor,
|
|
241
|
+
...markersOf(endpoint),
|
|
242
|
+
...(tags === undefined ? {} : { tags }),
|
|
243
|
+
...(searchTerms === undefined ? {} : { searchTerms }),
|
|
244
|
+
declare: (cleaned) => declaredDescriptor(endpoint, this.curationFor(endpoint), cleaned, families.get(endpoint)),
|
|
245
|
+
};
|
|
246
|
+
}), {
|
|
247
|
+
selection: this.options.selection,
|
|
248
|
+
prefixMode: this.options.naming.prefixMode,
|
|
249
|
+
...(files === undefined ? {} : { files }),
|
|
250
|
+
providers: this.options.arguments.providers,
|
|
251
|
+
severity: (code) => severityOf(code, this.options.diagnostics),
|
|
252
|
+
failOn: this.options.diagnostics.failOn ?? "fatal",
|
|
253
|
+
prior,
|
|
254
|
+
admit: (descriptor, name) => {
|
|
255
|
+
const missingParser = this.lacksBodyParser(descriptor);
|
|
256
|
+
return missingParser === undefined
|
|
257
|
+
? undefined
|
|
258
|
+
: {
|
|
259
|
+
code: "body_parser_missing",
|
|
260
|
+
message: `Tool '${name}' sends a ${descriptor.requestBody?.contentType ?? ""} body, but this application registers no ${missingParser}; the handler would receive no body. Register the parser with app.useBodyParser(), or declare another media type.`,
|
|
261
|
+
};
|
|
262
|
+
},
|
|
263
|
+
inspect: (entries, inspectReport) => this.reportUnparsedBrackets(entries, inspectReport),
|
|
264
|
+
});
|
|
265
|
+
return { ...built, discovered: discovered.length };
|
|
266
|
+
}
|
|
267
|
+
checkMetadataPath(globalPrefix, mcpPath, routePaths, report) {
|
|
268
|
+
if (this.options.resourceServer === undefined || globalPrefix === "") {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const metadataPath = protectedResourceMetadataPath(mcpPath);
|
|
272
|
+
const [served] = routePaths.create({ globalPrefix, methodPath: metadataPath }, RequestMethod.GET);
|
|
273
|
+
if (served === metadataPath) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
report({
|
|
277
|
+
code: "prm_path_prefixed",
|
|
278
|
+
message: `The global prefix moves the protected-resource metadata to '${String(served)}', but RFC 9728 requires it at '${metadataPath}'; clients cannot discover the authorization server. Pass it to setGlobalPrefix's exclude list: setGlobalPrefix('${globalPrefix}', { exclude: ['${metadataPath}'] }).`,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Applies the declaration ladder, least specific first, with sealed fields last.
|
|
283
|
+
*
|
|
284
|
+
* Merging is per argument and per field, so a global rule that hides a tenant identifier and a
|
|
285
|
+
* method-level rule that renames a page number both survive. A decorator wins a tie against a
|
|
286
|
+
* central rule of the same specificity: it is nearer the code and that is the reading a
|
|
287
|
+
* maintainer expects.
|
|
288
|
+
*/
|
|
289
|
+
curationFor(endpoint) {
|
|
290
|
+
const central = this.options.arguments.rules
|
|
291
|
+
.filter((rule) => matchesTarget(rule, endpoint))
|
|
292
|
+
.sort((a, b) => a.specificity - b.specificity);
|
|
293
|
+
assertUnambiguous(central, endpoint);
|
|
294
|
+
const layers = [
|
|
295
|
+
...central.filter((rule) => !rule.sealed),
|
|
296
|
+
{ rules: endpoint.hints.arguments ?? {}, sealed: false },
|
|
297
|
+
...central.filter((rule) => rule.sealed),
|
|
298
|
+
];
|
|
299
|
+
/**
|
|
300
|
+
* Collected before the merge, not during it: sealed rules are applied last so that they win,
|
|
301
|
+
* which means a check that learns the sealed names as it goes can never see an override.
|
|
302
|
+
*/
|
|
303
|
+
const sealed = new Set(central
|
|
304
|
+
.filter((rule) => rule.sealed)
|
|
305
|
+
.flatMap((rule) => Object.keys(rule.rules)));
|
|
306
|
+
const merged = new Map();
|
|
307
|
+
for (const layer of layers) {
|
|
308
|
+
for (const [name, rule] of Object.entries(layer.rules)) {
|
|
309
|
+
if (sealed.has(name) && !layer.sealed) {
|
|
310
|
+
throw new SezzleeTemplateError("sealed_curation_overridden", `Argument '${name}' is sealed on ${endpoint.controller.name}.${endpoint.handlerName}; a sealed rule cannot be overridden.`);
|
|
311
|
+
}
|
|
312
|
+
merged.set(name, rule);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return toCuration(Object.fromEntries(merged));
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
SezzleeCatalog = __decorate([
|
|
319
|
+
Injectable(),
|
|
320
|
+
__param(3, Inject(SEZZLEE_OPTIONS)),
|
|
321
|
+
__param(4, Optional()),
|
|
322
|
+
__param(5, Optional()),
|
|
323
|
+
__param(5, Inject(extensionTokens.toolRanker)),
|
|
324
|
+
__metadata("design:paramtypes", [DiscoveryService,
|
|
325
|
+
ModulesContainer,
|
|
326
|
+
ApplicationConfig, Function, HttpAdapterHost, Object])
|
|
327
|
+
], SezzleeCatalog);
|
|
328
|
+
export { SezzleeCatalog };
|
|
329
|
+
function markersOf(endpoint) {
|
|
330
|
+
const container = combineMarkers(endpoint.containerMarkers.includes(true), endpoint.containerMarkers.includes(false));
|
|
331
|
+
const operation = combineMarkers(endpoint.operationMarkers.includes(true), endpoint.operationMarkers.includes(false));
|
|
332
|
+
return {
|
|
333
|
+
...(container === undefined ? {} : { container }),
|
|
334
|
+
...(operation === undefined ? {} : { operation }),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Rejects two central rules that set the same argument differently at the same specificity.
|
|
339
|
+
*
|
|
340
|
+
* Between levels the nearer rule simply wins, silently and by design. Within one level there is no
|
|
341
|
+
* nearer rule, so a merge would have to pick by registration order and the host would be reading a
|
|
342
|
+
* ladder that does not decide anything.
|
|
343
|
+
*/
|
|
344
|
+
function assertUnambiguous(rules, endpoint) {
|
|
345
|
+
const claimed = new Map();
|
|
346
|
+
for (const rule of rules) {
|
|
347
|
+
for (const [name, declaration] of Object.entries(rule.rules)) {
|
|
348
|
+
const key = `${rule.specificity}|${rule.sealed ? "sealed" : "open"}|${name}`;
|
|
349
|
+
const written = JSON.stringify(declaration);
|
|
350
|
+
const existing = claimed.get(key);
|
|
351
|
+
if (existing !== undefined && existing !== written) {
|
|
352
|
+
throw new SezzleeTemplateError("ambiguous_curation", `Two curation rules of equal specificity declare argument '${name}' differently on ${endpoint.controller.name}.${endpoint.handlerName}; narrow one of their targets.`);
|
|
353
|
+
}
|
|
354
|
+
claimed.set(key, written);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Folds the decorator's declarations into the descriptor.
|
|
360
|
+
*
|
|
361
|
+
* Discovery reports facts; declarations are reduced here, the same way `toolName` and
|
|
362
|
+
* `containerPrefix` are. The curation list arrives resolved because the ladder that produces it
|
|
363
|
+
* needs the host's central rules, which are not a property of the endpoint; a family's members
|
|
364
|
+
* arrive already lowered, because they were loaded from a source rather than declared.
|
|
365
|
+
*/
|
|
366
|
+
export function declaredDescriptor(endpoint, curation, tags, family) {
|
|
367
|
+
const hints = endpoint.hints;
|
|
368
|
+
const annotations = annotationsOf(hints);
|
|
369
|
+
const declaredFamily = family?.family ??
|
|
370
|
+
(hints.family === undefined
|
|
371
|
+
? undefined
|
|
372
|
+
: { parameter: hints.family.parameter });
|
|
373
|
+
const variants = [
|
|
374
|
+
...(hints.variants ?? []).map(declaredVariant),
|
|
375
|
+
...(family?.variants ?? []),
|
|
376
|
+
];
|
|
377
|
+
return {
|
|
378
|
+
...endpoint.descriptor,
|
|
379
|
+
...(hints.name === undefined ? {} : { toolName: hints.name }),
|
|
380
|
+
...(hints.prefix === undefined ? {} : { containerPrefix: hints.prefix }),
|
|
381
|
+
...(tags === undefined ? {} : { tags: [...tags] }),
|
|
382
|
+
...(curation.length === 0 ? {} : { arguments: [...curation] }),
|
|
383
|
+
...(variants.length === 0
|
|
384
|
+
? {}
|
|
385
|
+
: { variants: variants }),
|
|
386
|
+
...(declaredFamily === undefined ? {} : { family: declaredFamily }),
|
|
387
|
+
...(annotations === undefined ? {} : { annotations }),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
function declaredVariant(variant) {
|
|
391
|
+
const annotations = annotationsOf(variant);
|
|
392
|
+
return {
|
|
393
|
+
name: variant.name,
|
|
394
|
+
description: variant.description,
|
|
395
|
+
...(variant.arguments === undefined
|
|
396
|
+
? {}
|
|
397
|
+
: { arguments: toCuration(variant.arguments) }),
|
|
398
|
+
...(variant.body === undefined
|
|
399
|
+
? {}
|
|
400
|
+
: {
|
|
401
|
+
requestBody: {
|
|
402
|
+
schema: variant.body,
|
|
403
|
+
...(variant.bodyRequired === undefined
|
|
404
|
+
? {}
|
|
405
|
+
: { required: variant.bodyRequired }),
|
|
406
|
+
},
|
|
407
|
+
}),
|
|
408
|
+
...(annotations === undefined ? {} : { annotations }),
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
function annotationsOf(declared) {
|
|
412
|
+
const annotations = {
|
|
413
|
+
...(declared.readOnly === undefined
|
|
414
|
+
? {}
|
|
415
|
+
: { readOnlyHint: declared.readOnly }),
|
|
416
|
+
...(declared.destructive === undefined
|
|
417
|
+
? {}
|
|
418
|
+
: { destructiveHint: declared.destructive }),
|
|
419
|
+
...(declared.idempotent === undefined
|
|
420
|
+
? {}
|
|
421
|
+
: { idempotentHint: declared.idempotent }),
|
|
422
|
+
};
|
|
423
|
+
return Object.keys(annotations).length === 0 ? undefined : annotations;
|
|
424
|
+
}
|
|
425
|
+
export function toCuration(rules) {
|
|
426
|
+
return Object.entries(rules).map(([name, rule]) => ({
|
|
427
|
+
name,
|
|
428
|
+
...(rule.hide === undefined
|
|
429
|
+
? {
|
|
430
|
+
...(rule.as === undefined ? {} : { as: rule.as }),
|
|
431
|
+
...(rule.description === undefined
|
|
432
|
+
? {}
|
|
433
|
+
: { description: rule.description }),
|
|
434
|
+
}
|
|
435
|
+
: { hidden: rule.hide }),
|
|
436
|
+
}));
|
|
437
|
+
}
|
|
438
|
+
function matchesTarget(rule, endpoint) {
|
|
439
|
+
const target = rule.target;
|
|
440
|
+
if (target.controller !== undefined &&
|
|
441
|
+
target.controller !== endpoint.controller) {
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
if (target.handler !== undefined && target.handler !== endpoint.handlerName) {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
if (target.method !== undefined &&
|
|
448
|
+
target.method.toUpperCase() !== endpoint.descriptor.method) {
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
if (target.route !== undefined &&
|
|
452
|
+
!matchesRoute(target.route, endpoint.descriptor.route)) {
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
return true;
|
|
456
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { ArgumentFill, EndpointDescriptor, JsonSchemaObject } from "@sezzlee/core";
|
|
2
|
+
export type JsonValue = string | number | boolean | null | readonly JsonValue[] | {
|
|
3
|
+
readonly [key: string]: JsonValue;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* A curation rule for one argument.
|
|
7
|
+
*
|
|
8
|
+
* The union is what makes "hidden but also renamed" unrepresentable: a hidden argument has no
|
|
9
|
+
* agent-facing name or description to carry.
|
|
10
|
+
*/
|
|
11
|
+
export type ArgumentRule = {
|
|
12
|
+
readonly hide?: undefined;
|
|
13
|
+
readonly as?: string;
|
|
14
|
+
readonly description?: string;
|
|
15
|
+
} | {
|
|
16
|
+
readonly hide: ArgumentFill;
|
|
17
|
+
};
|
|
18
|
+
export declare const hidden: {
|
|
19
|
+
readonly value: (value: JsonValue) => ArgumentRule;
|
|
20
|
+
readonly from: (source: string) => ArgumentRule;
|
|
21
|
+
readonly omit: () => ArgumentRule;
|
|
22
|
+
};
|
|
23
|
+
export type ArgumentRules<T> = {
|
|
24
|
+
readonly [K in keyof T & string]?: ArgumentRule;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Key-checks a curation against a DTO's own keys.
|
|
28
|
+
*
|
|
29
|
+
* Optional: `arguments` accepts a plain record. Reaching for this moves the
|
|
30
|
+
* `curation_unresolved` diagnostic from startup to compile time, which is where a typo belongs.
|
|
31
|
+
*/
|
|
32
|
+
export declare function curate<T>(rules: ArgumentRules<T>): ArgumentRules<T>;
|
|
33
|
+
type DescriptorParameter = NonNullable<EndpointDescriptor["parameters"]>[number];
|
|
34
|
+
export interface McpParameterOptions {
|
|
35
|
+
readonly required?: boolean;
|
|
36
|
+
readonly style?: DescriptorParameter["style"];
|
|
37
|
+
readonly explode?: boolean;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* What one status code returns.
|
|
41
|
+
*
|
|
42
|
+
* A bare class is bound through the same TypeShape reader the request body uses; a one-element
|
|
43
|
+
* tuple declares a collection of it; `{ schema }` supplies a schema verbatim; `{}` declares a
|
|
44
|
+
* status that carries no body.
|
|
45
|
+
*/
|
|
46
|
+
export type McpResponseDeclaration = NewableFunction | readonly [NewableFunction] | {
|
|
47
|
+
readonly schema: JsonSchemaObject;
|
|
48
|
+
} | Record<string, never>;
|
|
49
|
+
export interface McpFileFieldOptions {
|
|
50
|
+
/** A `FilesInterceptor` field, sent as repeated parts. */
|
|
51
|
+
readonly multiple?: boolean;
|
|
52
|
+
readonly required?: boolean;
|
|
53
|
+
readonly description?: string;
|
|
54
|
+
/** The media type the backend expects; it becomes the part's default. */
|
|
55
|
+
readonly mediaType?: string;
|
|
56
|
+
}
|
|
57
|
+
export interface McpToolOptions {
|
|
58
|
+
readonly name?: string;
|
|
59
|
+
readonly prefix?: string;
|
|
60
|
+
readonly description?: string;
|
|
61
|
+
readonly body?: JsonSchemaObject;
|
|
62
|
+
/**
|
|
63
|
+
* What the endpoint returns, keyed by status code.
|
|
64
|
+
*
|
|
65
|
+
* A declaration is needed because a handler's return type is not readable at runtime: TypeScript
|
|
66
|
+
* emits `design:returntype` with the generic erased, so an `async` handler reports `Promise` and
|
|
67
|
+
* a collection reports `Array`. Discovery falls back to `@nestjs/swagger`'s response metadata and
|
|
68
|
+
* then to `design:returntype`, but only a declaration here is guaranteed to be exact.
|
|
69
|
+
*/
|
|
70
|
+
readonly responses?: Readonly<Record<string, McpResponseDeclaration>>;
|
|
71
|
+
/**
|
|
72
|
+
* Whether the backend requires a body at all, as distinct from requiring the
|
|
73
|
+
* fields inside it. `false` makes omitting the body expressible: the agent
|
|
74
|
+
* then sends no body rather than an empty object.
|
|
75
|
+
*/
|
|
76
|
+
readonly bodyRequired?: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Per-parameter declarations keyed by parameter name. Nest exposes no runtime
|
|
79
|
+
* signal that proves a named `@Query`/`@Headers` is required, nor how an
|
|
80
|
+
* array-valued one is serialised, so both are declarations rather than
|
|
81
|
+
* inferences.
|
|
82
|
+
*/
|
|
83
|
+
readonly parameters?: Readonly<Record<string, McpParameterOptions>>;
|
|
84
|
+
/**
|
|
85
|
+
* Curation declarations keyed by wire name: a parameter, a flattened body field, or the body
|
|
86
|
+
* root argument. Distinct from `parameters`, which corrects a discovered fact; these declare the
|
|
87
|
+
* agent-facing surface and span the body too.
|
|
88
|
+
*/
|
|
89
|
+
readonly arguments?: Readonly<Record<string, ArgumentRule>>;
|
|
90
|
+
/**
|
|
91
|
+
* The operation's grouping labels, replacing the one derived from the container rather than
|
|
92
|
+
* adding to it, so a host can remove a grouping it did not choose. They are `search_tools`
|
|
93
|
+
* filter keys and search index vocabulary at once ([search-semantics.md]).
|
|
94
|
+
*/
|
|
95
|
+
readonly tags?: readonly string[];
|
|
96
|
+
/**
|
|
97
|
+
* Search vocabulary the operation's own text does not contain. Indexed like the description,
|
|
98
|
+
* never a filter key and never shown to the agent ([search-semantics.md]).
|
|
99
|
+
*/
|
|
100
|
+
readonly searchTerms?: readonly string[];
|
|
101
|
+
/**
|
|
102
|
+
* The body's media type, replacing the one discovery chooses. It is the host's own claim that a
|
|
103
|
+
* parser for it is registered; when `@ApiConsumes` also lists types, it must be one of them.
|
|
104
|
+
*/
|
|
105
|
+
readonly consumes?: string;
|
|
106
|
+
/**
|
|
107
|
+
* The multipart file fields, keyed by the name the file interceptor reads. `FileInterceptor`
|
|
108
|
+
* keeps that name in a closure, so without a declaration here or an `@ApiBody` schema marking
|
|
109
|
+
* the field `format: "binary"` the endpoint is dropped rather than guessed at.
|
|
110
|
+
*/
|
|
111
|
+
readonly files?: Readonly<Record<string, McpFileFieldOptions>>;
|
|
112
|
+
readonly readOnly?: boolean;
|
|
113
|
+
readonly destructive?: boolean;
|
|
114
|
+
readonly idempotent?: boolean;
|
|
115
|
+
readonly variants?: readonly McpVariantOptions[];
|
|
116
|
+
readonly family?: McpToolFamilyOptions;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The operation dispatches on one parameter, and each of its variants is a member that writes its
|
|
120
|
+
* own key there ([tool-families.md]).
|
|
121
|
+
*
|
|
122
|
+
* @param parameter the dispatch parameter's wire name
|
|
123
|
+
* @param source the name a member source was registered under with `options.families.provide`;
|
|
124
|
+
* without one the members are the handler's `@McpVariant`s alone
|
|
125
|
+
*/
|
|
126
|
+
export interface McpToolFamilyOptions {
|
|
127
|
+
readonly parameter: string;
|
|
128
|
+
readonly source?: string;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Behaviour hints a member declares. The union keeps a read-only member from also claiming to be
|
|
132
|
+
* destructive, which MCP defines as meaningless.
|
|
133
|
+
*/
|
|
134
|
+
export type McpToolEffect = {
|
|
135
|
+
readonly readOnly: true;
|
|
136
|
+
readonly destructive?: never;
|
|
137
|
+
readonly idempotent?: boolean;
|
|
138
|
+
} | {
|
|
139
|
+
readonly readOnly?: false;
|
|
140
|
+
readonly destructive?: boolean;
|
|
141
|
+
readonly idempotent?: boolean;
|
|
142
|
+
};
|
|
143
|
+
/**
|
|
144
|
+
* One of several tools produced from a single operation.
|
|
145
|
+
*
|
|
146
|
+
* `name` and `description` are non-optional because one description cannot honestly describe two
|
|
147
|
+
* tools whose arguments are hidden differently; `tsc` therefore enforces the rule at the
|
|
148
|
+
* declaration site.
|
|
149
|
+
*/
|
|
150
|
+
export interface McpVariantOptions {
|
|
151
|
+
readonly name: string;
|
|
152
|
+
readonly description: string;
|
|
153
|
+
readonly arguments?: Readonly<Record<string, ArgumentRule>>;
|
|
154
|
+
/** A family member's own body; refused on a variant of an operation that is not a family. */
|
|
155
|
+
readonly body?: JsonSchemaObject;
|
|
156
|
+
readonly bodyRequired?: boolean;
|
|
157
|
+
readonly readOnly?: boolean;
|
|
158
|
+
readonly destructive?: boolean;
|
|
159
|
+
readonly idempotent?: boolean;
|
|
160
|
+
}
|
|
161
|
+
export interface McpSelectionMarker {
|
|
162
|
+
readonly include: boolean;
|
|
163
|
+
readonly options: McpToolOptions;
|
|
164
|
+
}
|
|
165
|
+
export declare const MCP_SELECTION = "sezzlee:selection";
|
|
166
|
+
export declare function McpVariant(options: McpVariantOptions): MethodDecorator;
|
|
167
|
+
export declare function McpToolFamily(options: McpToolFamilyOptions): MethodDecorator;
|
|
168
|
+
type Target = object | ((...args: never[]) => unknown);
|
|
169
|
+
export declare function McpTool(options?: McpToolOptions): ClassDecorator & MethodDecorator;
|
|
170
|
+
export declare function McpIgnore(): ClassDecorator & MethodDecorator;
|
|
171
|
+
export declare function markersOf(target: Target | undefined): McpSelectionMarker[];
|
|
172
|
+
export {};
|