@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
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { Injectable, Logger, OnModuleInit, Optional, PayloadTooLargeException } from '@nestjs/common';
|
|
2
|
+
import { HttpAdapterHost, ModuleRef } from '@nestjs/core';
|
|
3
|
+
import { json, urlencoded } from 'express';
|
|
4
|
+
|
|
5
|
+
import { ErrorCode } from '../../modules/error-code/error-codes';
|
|
6
|
+
import { IBodyParserConfig } from '../interfaces/server-options.interface';
|
|
7
|
+
import { ConfigService } from './config.service';
|
|
8
|
+
|
|
9
|
+
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
|
10
|
+
|
|
11
|
+
/** The two parsers NestJS registers on every Express application */
|
|
12
|
+
export type CoreBodyParserType = 'json' | 'urlencoded';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The function names body-parser gives its middleware. NestJS identifies its own parser layers by
|
|
16
|
+
* exactly these names (`ExpressAdapter.isMiddlewareApplied()`), which is why a wrapper must keep them.
|
|
17
|
+
*/
|
|
18
|
+
const PARSER_NAMES: Record<CoreBodyParserType, string> = { json: 'jsonParser', urlencoded: 'urlencodedParser' };
|
|
19
|
+
|
|
20
|
+
/** 1024-based, like the `bytes` package body-parser itself parses limits with */
|
|
21
|
+
const UNIT_FACTORS: Record<string, number> = { b: 1, gb: 1024 ** 3, kb: 1024, mb: 1024 ** 2 };
|
|
22
|
+
|
|
23
|
+
/** The router layer fields this initializer reads and writes (Express 5 / `router` 2.x) */
|
|
24
|
+
interface ExpressLayer {
|
|
25
|
+
handle: RequestHandler;
|
|
26
|
+
slash?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Convert a configured body limit into bytes.
|
|
31
|
+
*
|
|
32
|
+
* Returns `undefined` for anything that is not a positive size, so the caller can refuse it. The
|
|
33
|
+
* value is turned into a number HERE rather than handed to body-parser as a string, so the limit that
|
|
34
|
+
* is logged and the limit that is enforced cannot differ.
|
|
35
|
+
*
|
|
36
|
+
* @example parseBodyLimit('2mb') // 2097152
|
|
37
|
+
* @example parseBodyLimit(500000) // 500000
|
|
38
|
+
* @example parseBodyLimit('lots') // undefined
|
|
39
|
+
*/
|
|
40
|
+
export function parseBodyLimit(value: unknown): number | undefined {
|
|
41
|
+
if (typeof value === 'number') {
|
|
42
|
+
return Number.isFinite(value) && value >= 1 ? Math.floor(value) : undefined;
|
|
43
|
+
}
|
|
44
|
+
if (typeof value !== 'string') {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
const match = /^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/i.exec(value.trim());
|
|
48
|
+
if (!match) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const bytes = Math.floor(Number(match[1]) * UNIT_FACTORS[(match[2] || 'b').toLowerCase()]);
|
|
52
|
+
return bytes >= 1 ? bytes : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Applies `ServerOptions.bodyParser` to the body parsers NestJS registered, and turns their 413 into
|
|
57
|
+
* an answer a caller can act on.
|
|
58
|
+
*
|
|
59
|
+
* WHY it swaps layers instead of calling `useBodyParser()`: NestJS registers its parsers inside
|
|
60
|
+
* `app.init()`, BEFORE any `onModuleInit` runs, and a parser appended afterwards would sit behind
|
|
61
|
+
* the routes. So the layers NestJS created are replaced in place — same position in the middleware
|
|
62
|
+
* chain, same options otherwise. Doing it during module init (rather than in `main.ts`) is what
|
|
63
|
+
* makes the limit hold under `Test.createTestingModule()`, which never runs `main.ts`.
|
|
64
|
+
*
|
|
65
|
+
* Unconfigured, the parsers are not replaced, only wrapped: their own limit and options stay, and a
|
|
66
|
+
* 413 they raise is answered with `ErrorCode.REQUEST_BODY_TOO_LARGE` plus size and limit instead of
|
|
67
|
+
* a bare "request entity too large", and logged as a warning that names the route and the config
|
|
68
|
+
* key. Without that line the first symptom of a too-small limit is a user whose save keeps failing.
|
|
69
|
+
*
|
|
70
|
+
* Registered as a CoreModule provider; consumers never interact with it.
|
|
71
|
+
*/
|
|
72
|
+
@Injectable()
|
|
73
|
+
export class CoreBodyParserInitializer implements OnModuleInit {
|
|
74
|
+
protected readonly logger = new Logger(CoreBodyParserInitializer.name);
|
|
75
|
+
|
|
76
|
+
constructor(
|
|
77
|
+
@Optional() protected readonly httpAdapterHost?: HttpAdapterHost,
|
|
78
|
+
@Optional() protected readonly moduleRef?: ModuleRef,
|
|
79
|
+
) {}
|
|
80
|
+
|
|
81
|
+
onModuleInit(): void {
|
|
82
|
+
const stack = this.getRouterStack();
|
|
83
|
+
if (!stack) {
|
|
84
|
+
// Fastify and non-HTTP contexts have no body-parser layers; there is nothing to adjust.
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const config = this.getConfig();
|
|
89
|
+
for (const type of ['json', 'urlencoded'] as const) {
|
|
90
|
+
const limit = this.resolveLimit(type, config?.[type]?.limit);
|
|
91
|
+
const layers = stack.filter((layer) => this.isGlobalParserLayer(layer, type));
|
|
92
|
+
|
|
93
|
+
if (limit !== undefined && !layers.length) {
|
|
94
|
+
this.logger.warn(
|
|
95
|
+
`bodyParser.${type}.limit is configured, but no global ${type} body parser is registered ` +
|
|
96
|
+
'(was the app created with `bodyParser: false`?) — the limit has no effect.',
|
|
97
|
+
);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const layer of layers) {
|
|
102
|
+
const parser = limit === undefined ? layer.handle : this.createParser(type, limit);
|
|
103
|
+
layer.handle = this.wrapParser(type, parser);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (limit !== undefined) {
|
|
107
|
+
this.logger.log(`Body parser: ${type} limit set to ${limit} bytes`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A parser equivalent to the one NestJS registers, with the configured limit.
|
|
114
|
+
*
|
|
115
|
+
* `extended: true` mirrors NestJS's URL-encoded default (nested objects via `qs`). `req.rawBody`
|
|
116
|
+
* is kept when the app was created with `rawBody: true`, because webhook signature checks read it
|
|
117
|
+
* and would otherwise fail on every request once a limit is configured.
|
|
118
|
+
*/
|
|
119
|
+
protected createParser(type: CoreBodyParserType, limit: number): RequestHandler {
|
|
120
|
+
const verify = this.isRawBodyEnabled() ? captureRawBody : undefined;
|
|
121
|
+
return type === 'json' ? json({ limit, verify }) : urlencoded({ extended: true, limit, verify });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The configured `bodyParser` block, or `undefined` when the project did not set one */
|
|
125
|
+
protected getConfig(): IBodyParserConfig | undefined {
|
|
126
|
+
return ConfigService.getFastButReadOnly<IBodyParserConfig>('bodyParser');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The Express router stack, or `undefined` for a non-Express adapter */
|
|
130
|
+
protected getRouterStack(): ExpressLayer[] | undefined {
|
|
131
|
+
const app = this.httpAdapterHost?.httpAdapter?.getInstance?.();
|
|
132
|
+
if (typeof app?.use !== 'function' || typeof app?.set !== 'function') {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
const stack = app.router?.stack;
|
|
136
|
+
return Array.isArray(stack) ? stack : undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Whether a layer is a GLOBAL parser of this type.
|
|
141
|
+
*
|
|
142
|
+
* `slash` is how the Express 5 router marks `app.use(fn)` — mounted on `/` without an end. A
|
|
143
|
+
* path-scoped `app.use('/upload', json({ limit }))` is a project's deliberate exception for one
|
|
144
|
+
* path and is left alone, including its 413.
|
|
145
|
+
*/
|
|
146
|
+
protected isGlobalParserLayer(layer: ExpressLayer | undefined, type: CoreBodyParserType): boolean {
|
|
147
|
+
return layer?.slash === true && typeof layer.handle === 'function' && layer.handle.name === PARSER_NAMES[type];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Whether the application was created with `rawBody: true`.
|
|
152
|
+
*
|
|
153
|
+
* `NestFactory.create()` hands its options to the container and nothing public exposes them, so
|
|
154
|
+
* they are read from the container `ModuleRef` carries. Under `Test.createTestingModule()` the
|
|
155
|
+
* options passed to `createNestApplication()` never reach the container — that is the caveat
|
|
156
|
+
* documented on `IServerOptions.bodyParser`.
|
|
157
|
+
*/
|
|
158
|
+
protected isRawBodyEnabled(): boolean {
|
|
159
|
+
const container = (this.moduleRef as unknown as { container?: { contextOptions?: { rawBody?: boolean } } })
|
|
160
|
+
?.container;
|
|
161
|
+
return container?.contextOptions?.rawBody === true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The configured limit in bytes, or `undefined` when unset or invalid.
|
|
166
|
+
*
|
|
167
|
+
* An invalid value is refused rather than passed on: body-parser would throw at boot for some
|
|
168
|
+
* inputs and apply nonsense (a negative limit rejects every body) for others. The parser keeps the
|
|
169
|
+
* limit it has — never "no limit", since it runs before authentication.
|
|
170
|
+
*/
|
|
171
|
+
protected resolveLimit(type: CoreBodyParserType, value: unknown): number | undefined {
|
|
172
|
+
if (value === undefined || value === null) {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
const limit = parseBodyLimit(value);
|
|
176
|
+
if (limit === undefined) {
|
|
177
|
+
this.logger.error(
|
|
178
|
+
`bodyParser.${type}.limit ${JSON.stringify(value)} is not a positive size (bytes, or e.g. '500kb' / '2mb') ` +
|
|
179
|
+
`— ignored, the ${type} body parser keeps the limit it has (100 kB unless set elsewhere).`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return limit;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Answer a body-parser 413 with an error code, the size and the limit, and log it.
|
|
187
|
+
*
|
|
188
|
+
* Every other error (malformed JSON, unsupported charset, too many parameters) passes through
|
|
189
|
+
* unchanged, so NestJS answers it exactly as before.
|
|
190
|
+
*/
|
|
191
|
+
protected translateError(type: CoreBodyParserType, error: unknown, req: Request): unknown {
|
|
192
|
+
const details = error as undefined | { length?: unknown; limit?: unknown; type?: unknown };
|
|
193
|
+
if (details?.type !== 'entity.too.large') {
|
|
194
|
+
return error;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// `length` is the declared Content-Length; a chunked body has none, and the byte count at which
|
|
198
|
+
// body-parser stopped reading says nothing about how large the body really was.
|
|
199
|
+
const length = typeof details.length === 'number' ? details.length : undefined;
|
|
200
|
+
const sizeText = length === undefined ? '' : `${length} bytes, `;
|
|
201
|
+
const path = String(req.originalUrl ?? req.url ?? '').split('?')[0];
|
|
202
|
+
|
|
203
|
+
this.logger.warn(
|
|
204
|
+
`413 ${req.method} ${path}: ${type} body ${length === undefined ? '' : `of ${length} bytes `}` +
|
|
205
|
+
`exceeds the limit of ${details.limit} bytes. If payloads of this size are legitimate here, ` +
|
|
206
|
+
`raise \`bodyParser.${type}.limit\` in the server options.`,
|
|
207
|
+
);
|
|
208
|
+
return new PayloadTooLargeException(
|
|
209
|
+
`${ErrorCode.REQUEST_BODY_TOO_LARGE} [${sizeText}limit ${details.limit} bytes]`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Wrap a parser so its 413 is translated, keeping the name NestJS identifies the layer by.
|
|
215
|
+
*
|
|
216
|
+
* Wrapping an already wrapped parser is harmless: the outer wrapper receives the translated
|
|
217
|
+
* `PayloadTooLargeException`, which carries no body-parser `type`, and passes it on unchanged.
|
|
218
|
+
*/
|
|
219
|
+
protected wrapParser(type: CoreBodyParserType, parser: RequestHandler): RequestHandler {
|
|
220
|
+
const wrapped = (req: Request, res: Response, next: NextFunction) =>
|
|
221
|
+
parser(req, res, (error?: unknown) => (error ? next(this.translateError(type, error, req)) : next()));
|
|
222
|
+
Object.defineProperty(wrapped, 'name', { value: PARSER_NAMES[type] });
|
|
223
|
+
return wrapped;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Same behaviour as the `verify` NestJS installs for `rawBody: true` */
|
|
228
|
+
function captureRawBody(req: Request & { rawBody?: Buffer }, _res: Response, buffer: Buffer): void {
|
|
229
|
+
if (Buffer.isBuffer(buffer)) {
|
|
230
|
+
req.rawBody = buffer;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -226,6 +226,27 @@ function normalizeBetterAuthConfig(config: boolean | IBetterAuth | undefined): I
|
|
|
226
226
|
return config;
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
/**
|
|
230
|
+
* The secret Better-Auth actually signs with, for the NestJS layer (controller cookie helper,
|
|
231
|
+
* API middleware, CoreBetterAuthService) to sign its cookies with the same value.
|
|
232
|
+
*
|
|
233
|
+
* `createBetterAuthInstance` resolves it as `betterAuth.secret` → first fallback of at least 32
|
|
234
|
+
* characters → auto-generated. Only the instance knows the generated one; without it the cookie
|
|
235
|
+
* went out unsigned and every native Better-Auth endpoint (`/iam/token`, MCP authorize, passkey,
|
|
236
|
+
* 2FA) rejected the session with 401.
|
|
237
|
+
*/
|
|
238
|
+
function resolveSigningSecret(
|
|
239
|
+
authInstance: BetterAuthInstance | null,
|
|
240
|
+
config: IBetterAuth,
|
|
241
|
+
fallbackSecrets?: (string | undefined)[],
|
|
242
|
+
): string | undefined {
|
|
243
|
+
return (
|
|
244
|
+
(authInstance as null | { options?: { secret?: string } })?.options?.secret ||
|
|
245
|
+
config.secret ||
|
|
246
|
+
fallbackSecrets?.find((s) => s && s.length >= 32)
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
229
250
|
/**
|
|
230
251
|
* CoreBetterAuthModule provides integration with the better-auth authentication framework.
|
|
231
252
|
*
|
|
@@ -698,8 +719,8 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
|
|
|
698
719
|
// Store a config copy with the resolved secret so that consumers
|
|
699
720
|
// (CoreBetterAuthService, CoreBetterAuthController) can sign cookies.
|
|
700
721
|
// The original config object may be frozen (from ConfigService), so we
|
|
701
|
-
// create a shallow copy with the resolved fallback secret applied.
|
|
702
|
-
const resolvedSecret =
|
|
722
|
+
// create a shallow copy with the resolved (fallback or generated) secret applied.
|
|
723
|
+
const resolvedSecret = resolveSigningSecret(this.authInstance, config, fallbackSecrets);
|
|
703
724
|
this.currentConfig =
|
|
704
725
|
resolvedSecret && resolvedSecret !== config.secret ? { ...config, secret: resolvedSecret } : config;
|
|
705
726
|
|
|
@@ -1068,8 +1089,7 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
|
|
|
1068
1089
|
this.resolvedCookieDomain = result?.cookieDomain;
|
|
1069
1090
|
|
|
1070
1091
|
// Store a config copy with the resolved secret (same as first forRoot variant)
|
|
1071
|
-
const
|
|
1072
|
-
const resolvedSecret2 = config.secret || fallbacks?.find((s) => s && s.length >= 32);
|
|
1092
|
+
const resolvedSecret2 = resolveSigningSecret(this.authInstance, config, options?.fallbackSecrets);
|
|
1073
1093
|
this.currentConfig =
|
|
1074
1094
|
resolvedSecret2 && resolvedSecret2 !== config.secret ? { ...config, secret: resolvedSecret2 } : config;
|
|
1075
1095
|
|
|
@@ -435,6 +435,15 @@ export const LtnsErrors = {
|
|
|
435
435
|
},
|
|
436
436
|
},
|
|
437
437
|
|
|
438
|
+
REQUEST_BODY_TOO_LARGE: {
|
|
439
|
+
code: 'LTNS_0304',
|
|
440
|
+
message: 'Request body too large',
|
|
441
|
+
translations: {
|
|
442
|
+
de: 'Die gesendeten Daten sind zu groß und wurden nicht verarbeitet.',
|
|
443
|
+
en: 'The submitted data is too large and was not processed.',
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
|
|
438
447
|
// =====================================================
|
|
439
448
|
// Resource Errors (LTNS_0400-LTNS_0499)
|
|
440
449
|
// =====================================================
|
package/src/core.module.ts
CHANGED
|
@@ -48,6 +48,7 @@ import { mongooseRoleGuardPlugin } from './core/common/plugins/mongoose-role-gua
|
|
|
48
48
|
import { mongooseSystemRolePlugin } from './core/common/plugins/mongoose-system-role.plugin';
|
|
49
49
|
import { mongooseTenantPlugin } from './core/common/plugins/mongoose-tenant.plugin';
|
|
50
50
|
import { ConfigService } from './core/common/services/config.service';
|
|
51
|
+
import { CoreBodyParserInitializer } from './core/common/services/core-body-parser.initializer';
|
|
51
52
|
import { CoreCronJobsInitializer } from './core/common/services/core-cron-jobs.initializer';
|
|
52
53
|
import { CoreRedisService } from './core/common/services/core-redis.service';
|
|
53
54
|
import { CoreS3Service } from './core/common/services/core-s3.service';
|
|
@@ -324,6 +325,9 @@ export class CoreModule implements NestModule {
|
|
|
324
325
|
},
|
|
325
326
|
|
|
326
327
|
// Core Services
|
|
328
|
+
// Applies `bodyParser` limits and answers a body-parser 413 with an error code. Swaps the
|
|
329
|
+
// parser layers NestJS registered in place, during module init — see the initializer.
|
|
330
|
+
CoreBodyParserInitializer,
|
|
327
331
|
CoreCronJobsInitializer,
|
|
328
332
|
// Audits the file endpoints the project actually REGISTERED. applyFileRoles() above can only
|
|
329
333
|
// reach the base-class members; a subclass that re-declares one keeps its own @Roles() and the
|
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.
|