@lenne.tech/nest-server 11.42.3 → 11.42.5
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/.claude/rules/configurable-features.md +1 -0
- package/.claude/rules/package-management.md +70 -4
- package/.claude/rules/testing.md +4 -4
- package/CLAUDE.md +2 -1
- package/FRAMEWORK-API.md +2 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +8 -0
- package/dist/core/common/services/core-body-parser.initializer.d.ts +26 -0
- package/dist/core/common/services/core-body-parser.initializer.js +136 -0
- package/dist/core/common/services/core-body-parser.initializer.js.map +1 -0
- package/dist/core/modules/better-auth/core-better-auth.module.js +7 -3
- package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
- package/dist/core/modules/error-code/error-codes.d.ts +9 -0
- package/dist/core/modules/error-code/error-codes.js +8 -0
- package/dist/core/modules/error-code/error-codes.js.map +1 -1
- package/dist/core.module.js +2 -0
- package/dist/core.module.js.map +1 -1
- package/dist/server/modules/error-code/error-codes.d.ts +1 -0
- package/dist/test/test.helper.d.ts +60 -0
- package/dist/test/test.helper.js +104 -0
- package/dist/test/test.helper.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +28 -0
- package/docs/error-codes.md +16 -0
- package/docs/security-overrides.md +18 -2
- package/migration-guides/11.42.3-to-11.42.4.md +199 -0
- package/migration-guides/11.42.4-to-11.42.5.md +104 -0
- package/package.json +3 -3
- package/src/core/common/interfaces/server-options.interface.ts +89 -0
- package/src/core/common/services/core-body-parser.initializer.ts +232 -0
- package/src/core/modules/better-auth/core-better-auth.module.ts +24 -4
- package/src/core/modules/error-code/error-codes.ts +9 -0
- package/src/core.module.ts +4 -0
- package/src/test/README.md +79 -1
- package/src/test/test.helper.ts +303 -0
package/src/test/test.helper.ts
CHANGED
|
@@ -197,6 +197,156 @@ export interface TestDownloadOptions {
|
|
|
197
197
|
token?: string;
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Options for MCP requests (`testHelper.mcp()` / `testHelper.mcpSession()`)
|
|
202
|
+
*/
|
|
203
|
+
export interface TestMcpOptions {
|
|
204
|
+
/**
|
|
205
|
+
* Cookie-based authentication, same three modes as {@link TestRestOptions.cookies}
|
|
206
|
+
*/
|
|
207
|
+
cookies?: Record<string, string> | string;
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Additional request headers, merged over the MCP defaults (`Accept`, `Content-Type`, `mcp-session-id`)
|
|
211
|
+
*/
|
|
212
|
+
headers?: Record<string, string>;
|
|
213
|
+
|
|
214
|
+
log?: boolean;
|
|
215
|
+
logError?: boolean;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* MCP endpoint path. Default: `/ai/mcp`, the built-in MCP server of the AI module
|
|
219
|
+
*/
|
|
220
|
+
path?: string;
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Value of the `mcp-session-id` header. Omit it for `initialize`, which opens a session.
|
|
224
|
+
*/
|
|
225
|
+
sessionId?: string;
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Expected HTTP status. Default: 200 for a request, 202 for a notification (a message without `id`)
|
|
229
|
+
*/
|
|
230
|
+
statusCode?: number;
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Bearer token: the user's normal session token or JWT, the same one `rest()` and `graphQl()` take
|
|
234
|
+
*/
|
|
235
|
+
token?: string;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Options for `testHelper.mcpSession()`
|
|
240
|
+
*/
|
|
241
|
+
export interface TestMcpSessionOptions extends Omit<TestMcpOptions, 'sessionId' | 'statusCode'> {
|
|
242
|
+
/**
|
|
243
|
+
* `clientInfo` sent with `initialize`. Default: `{ name: 'nest-server-test-helper', version: '1.0.0' }`
|
|
244
|
+
*/
|
|
245
|
+
clientInfo?: { name: string; version: string };
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Protocol version requested in `initialize`. Default: `2025-03-26`
|
|
249
|
+
*/
|
|
250
|
+
protocolVersion?: string;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* One JSON-RPC message as the MCP transport returns it. For an answer that is not JSON-RPC
|
|
255
|
+
* (e.g. the JSON body of a 404 for an unknown session), the parsed body.
|
|
256
|
+
*/
|
|
257
|
+
export interface TestMcpMessage {
|
|
258
|
+
[key: string]: any;
|
|
259
|
+
error?: any;
|
|
260
|
+
id?: null | number | string;
|
|
261
|
+
jsonrpc?: '2.0';
|
|
262
|
+
result?: any;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Response of `testHelper.mcp()`
|
|
267
|
+
*/
|
|
268
|
+
export interface TestMcpResponse {
|
|
269
|
+
/**
|
|
270
|
+
* The JSON-RPC message from the SSE `data:` line or the JSON body; `null` for a notification (202, no body)
|
|
271
|
+
*/
|
|
272
|
+
message: null | TestMcpMessage;
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Full supertest response: status, headers, raw text
|
|
276
|
+
*/
|
|
277
|
+
response: any;
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* `mcp-session-id` response header, set by `initialize`
|
|
281
|
+
*/
|
|
282
|
+
sessionId?: string;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Result of `session.callTool()`: the MCP `CallToolResult` plus its first text content parsed as JSON
|
|
287
|
+
*/
|
|
288
|
+
export interface TestMcpToolResult {
|
|
289
|
+
[key: string]: any;
|
|
290
|
+
content: { [key: string]: any; text?: string; type: string }[];
|
|
291
|
+
isError?: boolean;
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* `content[0].text` parsed as JSON, or `undefined` when it is no JSON. The built-in MCP server returns a
|
|
295
|
+
* tool's own result this way (`{ data, success }`), and an error result as plain text.
|
|
296
|
+
*/
|
|
297
|
+
json?: any;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* MCP tool descriptor from `tools/list`
|
|
302
|
+
*/
|
|
303
|
+
export interface TestMcpTool {
|
|
304
|
+
[key: string]: any;
|
|
305
|
+
description?: string;
|
|
306
|
+
inputSchema?: any;
|
|
307
|
+
name: string;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* An initialized MCP session, returned by `testHelper.mcpSession()`
|
|
312
|
+
*/
|
|
313
|
+
export interface TestMcpSession {
|
|
314
|
+
/**
|
|
315
|
+
* `tools/call`. Expects a JSON-RPC result (a refused or failing tool is a result with `isError: true`,
|
|
316
|
+
* not a protocol error) and returns it with `json` parsed from the first text content.
|
|
317
|
+
*/
|
|
318
|
+
callTool(name: string, args?: Record<string, unknown>): Promise<TestMcpToolResult>;
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Ends the session server-side (`DELETE` with the session id)
|
|
322
|
+
*/
|
|
323
|
+
close(): Promise<void>;
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Result of `initialize`: `serverInfo`, `capabilities`, `protocolVersion`
|
|
327
|
+
*/
|
|
328
|
+
initializeResult: any;
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* `tools/list`. Expects a JSON-RPC result and returns its `tools`.
|
|
332
|
+
*/
|
|
333
|
+
listTools(): Promise<TestMcpTool[]>;
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Any JSON-RPC request in this session. Returns the whole message, so a test can assert a protocol `error`.
|
|
337
|
+
*/
|
|
338
|
+
request(
|
|
339
|
+
method: string,
|
|
340
|
+
params?: Record<string, unknown>,
|
|
341
|
+
options?: Pick<TestMcpOptions, 'log' | 'logError' | 'statusCode'>,
|
|
342
|
+
): Promise<null | TestMcpMessage>;
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* `mcp-session-id` the server assigned
|
|
346
|
+
*/
|
|
347
|
+
sessionId: string;
|
|
348
|
+
}
|
|
349
|
+
|
|
200
350
|
/**
|
|
201
351
|
* Test helper
|
|
202
352
|
*/
|
|
@@ -476,6 +626,119 @@ export class TestHelper {
|
|
|
476
626
|
return undefined;
|
|
477
627
|
}
|
|
478
628
|
|
|
629
|
+
/**
|
|
630
|
+
* Send one JSON-RPC message to an MCP endpoint (Streamable HTTP transport, default `/ai/mcp`).
|
|
631
|
+
*
|
|
632
|
+
* The transport answers a request as an SSE stream (`event: message` + `data: {…}`) and a notification
|
|
633
|
+
* with 202 and no body. This method sets the `Accept` header both need, sends the session id when given,
|
|
634
|
+
* checks the HTTP status like `rest()` does, and parses the JSON-RPC message out of the stream, so a test
|
|
635
|
+
* reads `message.result` instead of splitting SSE lines itself.
|
|
636
|
+
*
|
|
637
|
+
* For a whole conversation (initialize, then tools), use {@link mcpSession}.
|
|
638
|
+
*
|
|
639
|
+
* @example
|
|
640
|
+
* // An MCP endpoint without a token answers 401
|
|
641
|
+
* await testHelper.mcp({ id: 1, method: 'initialize', params: {} }, { statusCode: 401 });
|
|
642
|
+
*/
|
|
643
|
+
async mcp(message: Record<string, unknown>, options: TestMcpOptions = {}): Promise<TestMcpResponse> {
|
|
644
|
+
const isNotification = message.id === undefined;
|
|
645
|
+
const response = await this.rest(options.path ?? '/ai/mcp', {
|
|
646
|
+
cookies: options.cookies,
|
|
647
|
+
headers: {
|
|
648
|
+
Accept: 'application/json, text/event-stream',
|
|
649
|
+
'Content-Type': 'application/json',
|
|
650
|
+
...(options.sessionId ? { 'mcp-session-id': options.sessionId } : {}),
|
|
651
|
+
...options.headers,
|
|
652
|
+
},
|
|
653
|
+
log: options.log,
|
|
654
|
+
logError: options.logError,
|
|
655
|
+
method: 'POST',
|
|
656
|
+
payload: { jsonrpc: '2.0', ...message },
|
|
657
|
+
returnResponse: true,
|
|
658
|
+
statusCode: options.statusCode ?? (isNotification ? 202 : 200),
|
|
659
|
+
token: options.token,
|
|
660
|
+
});
|
|
661
|
+
return {
|
|
662
|
+
message: TestHelper.parseMcpMessage(response),
|
|
663
|
+
response,
|
|
664
|
+
sessionId: response?.headers?.['mcp-session-id'],
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Open an MCP session the way every MCP client does (`initialize`, then `notifications/initialized`)
|
|
670
|
+
* and return helpers for the calls a test makes: `listTools()`, `callTool()`, `request()`, `close()`.
|
|
671
|
+
*
|
|
672
|
+
* The session belongs to the user of `token` (or `cookies`): the built-in server filters `tools/list`
|
|
673
|
+
* and `tools/call` by that user's roles, so open one session per user a test compares.
|
|
674
|
+
*
|
|
675
|
+
* @example
|
|
676
|
+
* const session = await testHelper.mcpSession({ token: userToken });
|
|
677
|
+
* const names = (await session.listTools()).map(tool => tool.name);
|
|
678
|
+
* expect(names).not.toContain('delete_user');
|
|
679
|
+
*
|
|
680
|
+
* const refused = await session.callTool('delete_user', { id });
|
|
681
|
+
* expect(refused.isError).toBe(true);
|
|
682
|
+
*/
|
|
683
|
+
async mcpSession(options: TestMcpSessionOptions = {}): Promise<TestMcpSession> {
|
|
684
|
+
const { clientInfo, protocolVersion, ...base } = options;
|
|
685
|
+
const path = base.path ?? '/ai/mcp';
|
|
686
|
+
|
|
687
|
+
const init = await this.mcp(
|
|
688
|
+
{
|
|
689
|
+
id: 1,
|
|
690
|
+
method: 'initialize',
|
|
691
|
+
params: {
|
|
692
|
+
capabilities: {},
|
|
693
|
+
clientInfo: clientInfo ?? { name: 'nest-server-test-helper', version: '1.0.0' },
|
|
694
|
+
protocolVersion: protocolVersion ?? '2025-03-26',
|
|
695
|
+
},
|
|
696
|
+
},
|
|
697
|
+
base,
|
|
698
|
+
);
|
|
699
|
+
expect(init.message?.error).toBeUndefined();
|
|
700
|
+
expect(init.sessionId).toBeTruthy();
|
|
701
|
+
const sessionId = init.sessionId;
|
|
702
|
+
await this.mcp({ method: 'notifications/initialized' }, { ...base, sessionId });
|
|
703
|
+
|
|
704
|
+
let nextId = 2;
|
|
705
|
+
const request: TestMcpSession['request'] = async (method, params, requestOptions = {}) => {
|
|
706
|
+
const reply = await this.mcp(
|
|
707
|
+
{ id: nextId++, method, ...(params ? { params } : {}) },
|
|
708
|
+
{ ...base, ...requestOptions, sessionId },
|
|
709
|
+
);
|
|
710
|
+
return reply.message;
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
return {
|
|
714
|
+
callTool: async (name, args = {}) => {
|
|
715
|
+
const reply = await request('tools/call', { arguments: args, name });
|
|
716
|
+
expect(reply?.error).toBeUndefined();
|
|
717
|
+
const result = reply?.result ?? { content: [] };
|
|
718
|
+
return { ...result, json: TestHelper.parseJsonOrUndefined(result.content?.[0]?.text) };
|
|
719
|
+
},
|
|
720
|
+
close: async () => {
|
|
721
|
+
await this.rest(path, {
|
|
722
|
+
cookies: base.cookies,
|
|
723
|
+
headers: { ...base.headers, 'mcp-session-id': sessionId },
|
|
724
|
+
log: base.log,
|
|
725
|
+
logError: base.logError,
|
|
726
|
+
method: 'DELETE',
|
|
727
|
+
returnResponse: true,
|
|
728
|
+
token: base.token,
|
|
729
|
+
});
|
|
730
|
+
},
|
|
731
|
+
initializeResult: init.message?.result,
|
|
732
|
+
listTools: async () => {
|
|
733
|
+
const reply = await request('tools/list');
|
|
734
|
+
expect(reply?.error).toBeUndefined();
|
|
735
|
+
return reply?.result?.tools ?? [];
|
|
736
|
+
},
|
|
737
|
+
request,
|
|
738
|
+
sessionId,
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
479
742
|
/**
|
|
480
743
|
* Send REST request
|
|
481
744
|
*/
|
|
@@ -929,6 +1192,46 @@ export class TestHelper {
|
|
|
929
1192
|
return value;
|
|
930
1193
|
}
|
|
931
1194
|
|
|
1195
|
+
/**
|
|
1196
|
+
* Parse the JSON-RPC message out of an MCP response: the first `data:` line that carries a `result` or
|
|
1197
|
+
* an `error` in an SSE stream (a server may send notifications before the response), or the JSON body
|
|
1198
|
+
* otherwise. Returns `null` for an empty body (a notification's 202).
|
|
1199
|
+
*/
|
|
1200
|
+
static parseMcpMessage(response: { headers?: Record<string, any>; text?: string }): null | TestMcpMessage {
|
|
1201
|
+
const text = response?.text ?? '';
|
|
1202
|
+
if (!text.trim()) {
|
|
1203
|
+
return null;
|
|
1204
|
+
}
|
|
1205
|
+
const contentType = String(response?.headers?.['content-type'] ?? '');
|
|
1206
|
+
if (contentType.includes('text/event-stream') || /^(event|data|id):/m.test(text)) {
|
|
1207
|
+
for (const line of text.split(/\r?\n/)) {
|
|
1208
|
+
if (!line.startsWith('data:')) {
|
|
1209
|
+
continue;
|
|
1210
|
+
}
|
|
1211
|
+
const parsed = TestHelper.parseJsonOrUndefined(line.slice(5).trim());
|
|
1212
|
+
if (parsed && (parsed.result !== undefined || parsed.error !== undefined)) {
|
|
1213
|
+
return parsed;
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
return null;
|
|
1217
|
+
}
|
|
1218
|
+
return TestHelper.parseJsonOrUndefined(text) ?? null;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* `JSON.parse`, or `undefined` for input that is no JSON
|
|
1223
|
+
*/
|
|
1224
|
+
static parseJsonOrUndefined(text: string | undefined): any {
|
|
1225
|
+
if (typeof text !== 'string') {
|
|
1226
|
+
return undefined;
|
|
1227
|
+
}
|
|
1228
|
+
try {
|
|
1229
|
+
return JSON.parse(text);
|
|
1230
|
+
} catch {
|
|
1231
|
+
return undefined;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
932
1235
|
/**
|
|
933
1236
|
* Convert JWT into to object
|
|
934
1237
|
* @param token
|