@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.
- package/.claude/rules/package-management.md +69 -3
- package/.claude/rules/testing.md +3 -3
- package/FRAMEWORK-API.md +1 -1
- 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/migration-guides/11.42.3-to-11.42.4.md +17 -3
- package/migration-guides/11.42.4-to-11.42.5.md +104 -0
- package/package.json +1 -1
- package/src/test/README.md +79 -1
- package/src/test/test.helper.ts +303 -0
|
@@ -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
|
-
|
|
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.
|
|
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` —
|
|
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.
|
|
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",
|
package/src/test/README.md
CHANGED
|
@@ -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.
|
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
|