@lenne.tech/nest-server 11.42.4 → 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.
@@ -19,7 +19,8 @@ pnpm update --depth Infinity proxy-addr
19
19
  ```
20
20
 
21
21
  Then add the `@graphql-tools/utils` override from §3 to `pnpm-workspace.yaml`, run `pnpm install`,
22
- and check §3 for an older `@graphql-tools/utils` 11.x copy, which the override does not reach.
22
+ refresh `source-map-js` (§3), check §3 for an older `@graphql-tools/utils` 11.x copy, which the
23
+ override does not reach, and commit `pnpm-workspace.yaml` together with `pnpm-lock.yaml`.
23
24
  Vendor-mode projects pick up the core update and need the same two dependency steps. The body limit
24
25
  needs no configuration: unset, both parsers keep the 100 kB they had.
25
26
 
@@ -62,6 +63,12 @@ server.useBodyParser('json', { limit: '2mb' });
62
63
  bodyParser: { json: { limit: '2mb' } },
63
64
  ```
64
65
 
66
+ **Starter-based projects:** `config.env.ts` builds `local` / `e2e` / `ci` from `localConfig()` and the
67
+ deployed environments from `deployedConfig()`. Put the limit where BOTH see it — the starter ships a
68
+ shared `PROJECT_BODY_PARSER` constant for this since its 11.42.4 update — or set
69
+ `NSC__BODY_PARSER__JSON__LIMIT` in the environment. A value only in the deployed block leaves the e2e
70
+ suite and `lt dev up` at 100 kB.
71
+
65
72
  The config is applied during module init, so it holds in every test that builds the app with
66
73
  `Test.createTestingModule(...).createNestApplication()` — which never runs `main.ts`, so a
67
74
  `useBodyParser()` call there never reached the e2e suite. Once configured, the value also replaces a
@@ -101,7 +108,7 @@ refused request, with method, path (without query string) and the config key to
101
108
  The status code is unchanged. Only clients that compare the message text against
102
109
  `'request entity too large'` need adjusting — branch on the status or on `LTNS_0304` instead.
103
110
 
104
- ## 3. Two dependency advisories your project must fix itself
111
+ ## 3. Dependency advisories your project must fix itself
105
112
 
106
113
  `pnpm` overrides apply only to the project that runs the install, so the framework's own fixes do not
107
114
  reach your tree — see [docs/security-overrides.md](../docs/security-overrides.md).
@@ -110,6 +117,7 @@ reach your tree — see [docs/security-overrides.md](../docs/security-overrides.
110
117
  | ------- | -------- | ----------------------- |
111
118
  | `proxy-addr` | [GHSA-jqcg-44mw-7w3h](https://github.com/advisories/GHSA-jqcg-44mw-7w3h) — **critical**: IP spoofing through an IPv4-mapped IPv6 address in a trusted subnet. Patched `>=2.0.8` | A lockfile refresh. `express` requests `^2.0.7`, so `2.0.8` is in range, but an existing lockfile stays on `2.0.7` |
112
119
  | `@graphql-tools/utils` | [GHSA-7mx3-vvmw-hjmv](https://github.com/advisories/GHSA-7mx3-vvmw-hjmv) — high: prototype pollution in `mergeDeep` (schema assembly). Covers `<=12.0.0` — **11.x included, with no 11.x fix**. Patched `>=12.0.1` | An override for the copy `@nestjs/graphql` 13.4.5 exact-pins (`12.0.0`), and — in an older lockfile — a refresh of `@graphql-tools/schema` / `merge`, which can still pull in 11.x (see below) |
120
+ | `source-map-js` | [GHSA-68fv-2mgg-jv7q](https://github.com/advisories/GHSA-68fv-2mgg-jv7q) — high: event-loop DoS through indexed source-map section offsets. Patched `>=1.2.2`. **Dev tooling only** (vitest > vite > postcss), but `pnpm audit` — and therefore `check` — reports it | A lockfile refresh: `pnpm update --depth Infinity source-map-js`. If `postcss` keeps a `1.2.1` copy, re-resolve it the same way rather than with `pnpm dedupe`, which moves unrelated peers too |
113
121
 
114
122
  `proxy-addr` is what Express uses to compute `request.ip` from `X-Forwarded-For` as far as
115
123
  `trustProxy` allows. With `trustProxy` set, every IP-keyed rate limit — the framework's own and any of
@@ -146,7 +154,13 @@ The current releases (schema 10.1.3, merge 9.2.6) request utils `^12.0.3`, which
146
154
  (22.22) and Node 24 satisfy that; if your `engines.node` still declares a lower floor, raise it — the
147
155
  override above cannot hold this copy back, because `^12.0.3` does not intersect its key.
148
156
 
149
- Then `pnpm install` and `pnpm audit` — both advisories must be gone.
157
+ Then `pnpm install` and `pnpm audit` — the advisories must be gone.
158
+
159
+ **Commit `pnpm-workspace.yaml` and `pnpm-lock.yaml` together.** Every step above leaves a consistent
160
+ lockfile once it has run to the end, but the override only reaches the lockfile through the
161
+ `pnpm install` that follows it. A `pnpm-workspace.yaml` committed with the override and without the
162
+ re-installed lockfile fails every `pnpm install --frozen-lockfile` — typically the Docker image build.
163
+ Check with `pnpm install --frozen-lockfile` locally before you push.
150
164
 
151
165
  ## 4. Native Better-Auth endpoints accept the session when the secret is auto-generated
152
166
 
@@ -0,0 +1,104 @@
1
+ # Migration Guide: 11.42.4 → 11.42.5
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ | -------------------- | ----------------------------------------------------------------------------------------------------------- |
7
+ | **New feature** | `TestHelper.mcpSession()` / `TestHelper.mcp()` — MCP tests without hand-written SSE parsing (§1) |
8
+ | **Tooling** | `check:overrides` accepts a declared "fix exists, but this consumer cannot use it" suppression that expires by itself (§2) |
9
+ | **Docs** | The 11.42.3 → 11.42.4 guide gained three steps; check them if you already applied it (§3) |
10
+ | **Breaking Changes** | None |
11
+ | **Migration Effort** | None required — `pnpm update`. Adopting the helpers is optional |
12
+
13
+ ## Quick Migration
14
+
15
+ ```bash
16
+ pnpm update @lenne.tech/nest-server
17
+ ```
18
+
19
+ Vendor-mode projects pick up the core update; the new helpers arrive with `src/test/`. No configuration
20
+ change is required.
21
+
22
+ ## 1. TestHelper: MCP tests without SSE parsing
23
+
24
+ Projects that enable the AI module's MCP server (`ai.mcp` in `config.env.ts`) should test their tools over
25
+ MCP as well as over REST/GraphQL: MCP has its own role filter and sessions, and a refused tool comes back as
26
+ a result with `isError: true`, not as an HTTP error. Until now such a test had to set the right `Accept`
27
+ header, carry the `mcp-session-id` header, and parse the SSE stream by hand. `TestHelper` now does that:
28
+
29
+ ```typescript
30
+ const session = await testHelper.mcpSession({ token: userToken });
31
+
32
+ expect((await session.listTools()).map((tool) => tool.name)).not.toContain('delete_user');
33
+
34
+ const refused = await session.callTool('delete_user', { id });
35
+ expect(refused.isError).toBe(true);
36
+
37
+ const result = await session.callTool('find_users', { query: '@test.com' });
38
+ expect(result.json.data.length).toBeGreaterThan(0);
39
+
40
+ await session.close();
41
+ ```
42
+
43
+ `testHelper.mcp(message, { statusCode, sessionId, token })` sends a single JSON-RPC message for the
44
+ HTTP-level cases (401 without a token, 404 for another user's session). Full reference: `src/test/README.md`
45
+ → "MCP Testing".
46
+
47
+ The helpers are additive and build on `testHelper.rest()`, so `token` and `cookies` behave as there. They add
48
+ no dependency, so vendor-mode projects receive them with `src/test/`. A project with its own MCP module (not
49
+ the AI module's `/ai/mcp`) passes its endpoint as `path`.
50
+
51
+ ## 2. `check:overrides`: a suppression for a fix the consumer cannot use
52
+
53
+ `auditConfig.ignoreGhsas` used to be usable only for advisories with no fix at all — the moment any fix
54
+ appeared, `check:overrides` failed with `FIX AVAILABLE`. That is right in the common case and a dead end in
55
+ one: the fix exists, but the package that pulls in the vulnerable version cannot use it (for example, a
56
+ patched major removed an export the consumer imports).
57
+
58
+ That case can now be declared next to the suppression:
59
+
60
+ ```yaml
61
+ auditConfig:
62
+ ignoreGhsas:
63
+ - GHSA-x6jw-m9v5-85vh
64
+ unusableFixConsumers:
65
+ GHSA-x6jw-m9v5-85vh: '@nuxt/devtools@3.4.1 cannot use 4.0.1'
66
+ ```
67
+
68
+ The declaration expires by itself: `check:overrides` fails with `RE-TEST` as soon as the consumer leaves the
69
+ tree or changes version, the advisory offers a patched version nobody assessed (a backport), or anything other
70
+ than exactly that consumer version pulls the vulnerable package — including a direct dependency of your own
71
+ project. An undeclared suppression of a fixed advisory still fails on sight.
72
+
73
+ This is a change to the guard script, not to the published runtime: projects created from
74
+ nest-server-starter or lt-monorepo receive it with their template update. Exhaust the remedies first (raise
75
+ the vulnerable package, raise or patch the consumer); the rules and the reasoning are in
76
+ `.claude/rules/package-management.md` → "When the fix exists and the consumer cannot use it".
77
+
78
+ ## 3. Three steps added to the 11.42.3 → 11.42.4 guide
79
+
80
+ If you already applied [11.42.3 → 11.42.4](./11.42.3-to-11.42.4.md), check these — they were added after its
81
+ release:
82
+
83
+ 1. **`source-map-js` < 1.2.2** ([GHSA-68fv-2mgg-jv7q](https://github.com/advisories/GHSA-68fv-2mgg-jv7q),
84
+ high, dev tooling only) is also reported by `pnpm audit`: `pnpm update --depth Infinity source-map-js`.
85
+ 2. **Commit `pnpm-workspace.yaml` and `pnpm-lock.yaml` together.** An override only reaches the lockfile through
86
+ the `pnpm install` that follows it; a workspace file committed without the re-installed lockfile fails every
87
+ `pnpm install --frozen-lockfile`, typically the Docker image build.
88
+ 3. **Starter-based projects:** set `bodyParser` where both `localConfig()` and `deployedConfig()` see it (the
89
+ starter ships a shared `PROJECT_BODY_PARSER` constant), or via `NSC__BODY_PARSER__JSON__LIMIT`. A value only
90
+ in the deployed block leaves the e2e suite and `lt dev up` at 100 kB.
91
+
92
+ ## Troubleshooting
93
+
94
+ | Symptom | Cause | Fix |
95
+ | ------- | ----- | --- |
96
+ | `check:overrides` fails with `RE-TEST` on a declared suppression | The consumer moved, a new patched version appeared, or another package (or your project directly) now pulls the vulnerable package | Re-assess: take the fix if it is now usable, otherwise update the declaration with what you re-checked (§2) |
97
+ | `check:overrides` fails with `FIX AVAILABLE` | A suppressed advisory gained a fix, and no declaration explains why it cannot be used | Take the fix; declare only after the remedies are exhausted (§2) |
98
+ | `session.callTool()` throws instead of returning `isError` | The JSON-RPC call itself failed (protocol error), not the tool | Assert on `session.request(...)` for protocol-level errors (§1) |
99
+
100
+ ## Module Documentation
101
+
102
+ - [TestHelper README — MCP Testing](../src/test/README.md)
103
+ - [AI module README](../src/core/modules/ai/README.md)
104
+ - `.claude/rules/package-management.md` — suppressions, overrides and the `check:overrides` guard
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.42.4",
3
+ "version": "11.42.5",
4
4
  "description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
5
5
  "keywords": [
6
6
  "node",
@@ -243,4 +243,82 @@ const result = await testHelper.graphQl(
243
243
  );
244
244
  ```
245
245
 
246
- See `TestGraphQLConfig` and `TestGraphQLOptions` interfaces in `test.helper.ts` for full configuration options.
246
+ See `TestGraphQLConfig` and `TestGraphQLOptions` interfaces in `test.helper.ts` for full configuration options.
247
+
248
+ ## MCP Testing (`testHelper.mcp()` / `testHelper.mcpSession()`)
249
+
250
+ An MCP client reaches the same services as REST and GraphQL through its own door: the MCP server of the AI module
251
+ (`/ai/mcp`, enabled with `ai.mcp` in `config.env.ts`) has its own role filter, its own sessions, and reports a refused
252
+ or failing tool as a result with `isError: true` instead of an HTTP error. A project that enables MCP therefore tests
253
+ its tools over MCP too, next to its API tests.
254
+
255
+ The transport answers requests as an SSE stream. These helpers handle the stream, the session header and the
256
+ handshake, so a test reads results directly.
257
+
258
+ ### `mcpSession(options?)`
259
+
260
+ Opens a session the way every MCP client does (`initialize`, then `notifications/initialized`) for the user of `token`
261
+ or `cookies`:
262
+
263
+ ```typescript
264
+ const session = await testHelper.mcpSession({ token: userToken });
265
+
266
+ // tools/list — filtered by the user's roles
267
+ const names = (await session.listTools()).map((tool) => tool.name);
268
+ expect(names).toContain('find_users');
269
+
270
+ // tools/call — `json` is the first text content parsed as JSON (the tool's own result)
271
+ const result = await session.callTool('find_users', { query: '@test.com' });
272
+ expect(result.isError).toBeFalsy();
273
+ expect(result.json.data.length).toBeGreaterThan(0);
274
+
275
+ // A tool outside the user's roles is refused, not run
276
+ const refused = await session.callTool('delete_user', { id });
277
+ expect(refused.isError).toBe(true);
278
+
279
+ // Any other JSON-RPC request; returns the whole message, protocol errors included
280
+ const reply = await session.request('prompts/list');
281
+
282
+ // End the session server-side
283
+ await session.close();
284
+ ```
285
+
286
+ | Member | Description |
287
+ | ------------------------------------ | ------------------------------------------------------------------------ |
288
+ | `sessionId` | `mcp-session-id` the server assigned |
289
+ | `initializeResult` | Result of `initialize` (`serverInfo`, `capabilities`, `protocolVersion`) |
290
+ | `listTools()` | `tools/list`, returns the tools array |
291
+ | `callTool(name, args?)` | `tools/call`, returns the `CallToolResult` plus `json` |
292
+ | `request(method, params?, options?)` | Any request in the session, returns the JSON-RPC message |
293
+ | `close()` | `DELETE` with the session id |
294
+
295
+ ### `mcp(message, options?)`
296
+
297
+ Sends a single JSON-RPC message and returns `{ message, response, sessionId }`. Use it for the HTTP-level cases a
298
+ session hides:
299
+
300
+ ```typescript
301
+ // No token → 401
302
+ await testHelper.mcp({ id: 1, method: 'initialize', params: {} }, { statusCode: 401 });
303
+
304
+ // Another user's session id → 404 (not 403: confirming the id exists would be an oracle)
305
+ await testHelper.mcp(
306
+ { id: 2, method: 'tools/list' },
307
+ { sessionId: adminSession.sessionId, statusCode: 404, token: userToken },
308
+ );
309
+ ```
310
+
311
+ ### TestMcpOptions
312
+
313
+ | Option | Type | Default | Description |
314
+ | ------------------ | ---------------------------------- | ------------------------------- | ----------------------------------------------- |
315
+ | `token` | `string` | - | Bearer token, the same one `rest()` takes |
316
+ | `cookies` | `string \| Record<string, string>` | - | Cookie authentication, same modes as `rest()` |
317
+ | `sessionId` | `string` | - | `mcp-session-id` header (`mcp()` only) |
318
+ | `statusCode` | `number` | `200`, `202` for a notification | Expected HTTP status (`mcp()` only) |
319
+ | `path` | `string` | `'/ai/mcp'` | Endpoint, for a project with its own MCP module |
320
+ | `headers` | `Record<string, string>` | - | Additional headers |
321
+ | `log` / `logError` | `boolean` | `false` | Same as `rest()` |
322
+
323
+ `mcpSession()` additionally takes `clientInfo` and `protocolVersion` for the `initialize` request.
324
+ `TestHelper.parseMcpMessage(response)` is the parser behind both, for tests that send requests themselves.
@@ -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