@lenne.tech/nest-server 11.42.15 → 11.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/.claude/rules/better-auth.md +22 -0
  2. package/.claude/rules/configurable-features.md +4 -3
  3. package/.claude/rules/testing.md +4 -4
  4. package/FRAMEWORK-API.md +2 -2
  5. package/dist/core/common/filters/mongoose-cast-error.filter.d.ts +17 -0
  6. package/dist/core/common/filters/mongoose-cast-error.filter.js +171 -0
  7. package/dist/core/common/filters/mongoose-cast-error.filter.js.map +1 -0
  8. package/dist/core/common/interfaces/server-options.interface.d.ts +4 -0
  9. package/dist/core/modules/ai/core-ai-mcp-oauth.initializer.d.ts +19 -0
  10. package/dist/core/modules/ai/core-ai-mcp-oauth.initializer.js +145 -0
  11. package/dist/core/modules/ai/core-ai-mcp-oauth.initializer.js.map +1 -0
  12. package/dist/core/modules/ai/core-ai-mcp.controller.js +6 -5
  13. package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
  14. package/dist/core/modules/ai/core-ai.module.d.ts +2 -0
  15. package/dist/core/modules/ai/core-ai.module.js +6 -1
  16. package/dist/core/modules/ai/core-ai.module.js.map +1 -1
  17. package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.d.ts +33 -0
  18. package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js +282 -0
  19. package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js.map +1 -0
  20. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.d.ts +15 -2
  21. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js +73 -19
  22. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js.map +1 -1
  23. package/dist/core/modules/ai/helpers/ai-mcp-oauth.registry.d.ts +5 -0
  24. package/dist/core/modules/ai/helpers/ai-mcp-oauth.registry.js.map +1 -1
  25. package/dist/core/modules/ai/index.d.ts +1 -0
  26. package/dist/core/modules/ai/index.js +1 -0
  27. package/dist/core/modules/ai/index.js.map +1 -1
  28. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.d.ts +30 -2
  29. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js +222 -15
  30. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js.map +1 -1
  31. package/dist/core/modules/better-auth/better-auth.config.d.ts +4 -0
  32. package/dist/core/modules/better-auth/better-auth.config.js +29 -2
  33. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  34. package/dist/core/modules/better-auth/core-better-auth.controller.d.ts +2 -0
  35. package/dist/core/modules/better-auth/core-better-auth.controller.js +36 -1
  36. package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
  37. package/dist/core.module.js +7 -0
  38. package/dist/core.module.js.map +1 -1
  39. package/dist/index.d.ts +1 -0
  40. package/dist/index.js +1 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/tsconfig.build.tsbuildinfo +1 -1
  43. package/docs/REQUEST-LIFECYCLE.md +30 -1
  44. package/docs/security-overrides.md +3 -2
  45. package/migration-guides/11.42.15-to-11.42.16.md +114 -0
  46. package/migration-guides/11.42.x-to-11.43.x.md +195 -0
  47. package/package.json +7 -7
  48. package/src/core/common/filters/mongoose-cast-error.filter.ts +310 -0
  49. package/src/core/common/helpers/swagger.helper.ts +4 -4
  50. package/src/core/common/interfaces/server-options.interface.ts +59 -9
  51. package/src/core/modules/ai/INTEGRATION-CHECKLIST.md +50 -24
  52. package/src/core/modules/ai/README.md +98 -13
  53. package/src/core/modules/ai/core-ai-mcp-oauth.initializer.ts +199 -0
  54. package/src/core/modules/ai/core-ai-mcp.controller.ts +13 -6
  55. package/src/core/modules/ai/core-ai.module.ts +14 -2
  56. package/src/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.ts +369 -0
  57. package/src/core/modules/ai/helpers/ai-mcp-oauth.helper.ts +149 -43
  58. package/src/core/modules/ai/helpers/ai-mcp-oauth.registry.ts +18 -5
  59. package/src/core/modules/ai/index.ts +1 -0
  60. package/src/core/modules/ai/services/core-ai-mcp-oauth.service.ts +385 -28
  61. package/src/core/modules/better-auth/better-auth.config.ts +49 -5
  62. package/src/core/modules/better-auth/core-better-auth.controller.ts +75 -1
  63. package/src/core.module.ts +13 -1
  64. package/src/index.ts +1 -0
@@ -685,32 +685,117 @@ Without Redis nothing changes: unknown ids answer `404` exactly as before.
685
685
  For generic MCP clients that auto-discover + register:
686
686
  `CoreAiMcpOAuthService` provides HMAC-signed access tokens (constant-time verify),
687
687
  PKCE S256, and MongoDB-backed client/code/refresh stores; the MCP controller then
688
- also accepts OAuth access tokens. Mount the discovery/token endpoints in `main.ts`:
688
+ also accepts OAuth access tokens.
689
689
 
690
690
  ```typescript
691
- // main.ts, after app.init()
692
- import { mountAiMcpOAuth } from '@lenne.tech/nest-server';
693
- await mountAiMcpOAuth(app);
691
+ // config.env.ts — that is all: endpoints, consent page and sign-in come with it
692
+ ai: { mcp: { oauth: true, oauthSecret: process.env.NSC__AI__ENCRYPTION_SECRET } }
694
693
  ```
695
694
 
695
+ **Mounted by `CoreModule` (11.43.0).** `CoreAiMcpOAuthInitializer` mounts the discovery and token
696
+ endpoints (`/.well-known/oauth-authorization-server`, `/.well-known/oauth-protected-resource<mcp path>`,
697
+ `/authorize`, `/token`, `/register`) during `app.init()` — no `main.ts` code, and the same under
698
+ `Test.createTestingModule()`. The position is the point: the endpoints are an Express router, and Nest
699
+ registers its not-found handler inside `app.init()`. The call this replaces — `mountAiMcpOAuth(app)` after
700
+ `app.init()`, as this README used to say — sat behind that handler, so every OAuth endpoint answered 404.
701
+ Mounted during init, the router sits behind every Nest middleware (security headers, request context,
702
+ CORS) and the Nest routes, and ahead of the not-found handler. Guards and interceptors do not run for it;
703
+ the SDK's handlers authenticate the client and rate-limit per IP themselves (so `trustProxy` matters
704
+ behind a proxy). A `mountAiMcpOAuth()` call left in `main.ts` is a no-op and logs that it can go; options
705
+ it passes that differ from the mount (`baseUrl`, `mcpPath`) are named in a warning.
706
+
707
+ The protected MCP resource is described at the path Nest registered the endpoint at — global prefix and
708
+ its exclusions, URI versioning and a `RouterModule` path included — and the endpoint's `401` names that
709
+ metadata document in `WWW-Authenticate: Bearer resource_metadata="…"` (RFC 9728 §5.1). Without OAuth the
710
+ challenge is a plain `Bearer`: there is no metadata to point at. CORS: a project's `enableCors()` runs
711
+ first, so a browser-based MCP client on an origin it does not allow is refused at the preflight; native
712
+ clients (Claude Code, Claude Desktop) send none.
713
+
696
714
  **Swagger (11.42.9):** `setupSwagger()` documents `/ai/mcp` (POST/GET/DELETE, with the bearer
697
715
  requirement the endpoint enforces itself) whenever the MCP server is registered, and — because the
698
- OAuth endpoints are an Express router, invisible to `@nestjs/swagger` — exactly the OAuth endpoints
699
- `mountAiMcpOAuth()` mounted (`/.well-known/oauth-authorization-server`,
700
- `/.well-known/oauth-protected-resource/ai/mcp`, `/authorize`, `/token`, `/register`; `/revoke` only for a
701
- provider with `revokeToken`). Without the call they are not documented, because they do not exist.
716
+ OAuth endpoints are an Express router, invisible to `@nestjs/swagger` — exactly the OAuth endpoints that
717
+ are mounted (`/revoke` only for a provider with `revokeToken`). Without `ai.mcp.oauth` they are not
718
+ documented, because they do not exist.
702
719
 
703
720
  The issuer — and with it every endpoint URL in the discovery metadata, which MCP clients follow —
704
721
  is the server's `baseUrl` (`NSC__BASE_URL` when deployed), resolved the same way BetterAuth and
705
722
  CORS resolve it. `local` / `ci` / `e2e` fall back to `http://localhost:3000`; any other environment
706
- without a `baseUrl` fails the call instead of guessing, because a deployed API that advertised
723
+ without a `baseUrl` fails the boot instead of guessing, because a deployed API that advertised
707
724
  `http://localhost:3000` sent Claude Code off to register the client on the user's own machine.
708
- Pass `{ baseUrl }` only when the issuer must differ from the server's `baseUrl`, and do not add a
709
- `localhost` fallback of your own. The resolved issuer is logged once at boot.
710
-
711
- Override `CoreAiMcpOAuthService.authorizeConsent()` to wire your login/consent UI.
725
+ Set `ai.mcp.oauthBaseUrl` only when MCP clients reach the server under another URL than `baseUrl` (its
726
+ origin is then trusted by Better-Auth automatically, because the consent page signs users in there),
727
+ and do not add a `localhost` fallback of your own. Issuer and resource are logged once at boot.
712
728
  Set `ai.mcp.oauthSecret` (or reuse `ai.encryptionSecret`) to a random 32+ char value.
713
729
 
730
+ **Consent page (11.43.0).** `/authorize` answers with a built-in, build-free page — the step where the
731
+ user decides whether an MCP client may act for them:
732
+
733
+ - **Nobody signed in** → the sign-in view: e-mail + password and the two-factor step (TOTP or backup
734
+ code) against Better-Auth's own endpoints on this origin (`<basePath>/sign-in/email`,
735
+ `/two-factor/verify-totp`, `/two-factor/verify-backup-code`), then a reload of the same `/authorize`
736
+ request. Passkey and social login go through the app's login page: `ai.mcp.oauthLoginUrl`, else
737
+ `<appUrl>/auth/login`, opened next to the consent page, which then offers "continue". A `{returnTo}`
738
+ in `oauthLoginUrl` is replaced by the pending request's URL, for a login page that sends the user
739
+ back by itself. A user already signed in to the API origin (session cookie) skips this view.
740
+ - **Signed in** → the consent view: the signed-in account first (with "use another account", which signs
741
+ out and shows the sign-in view), the client's name, the host the code goes to (a loopback host is
742
+ named "an application on this computer"), Allow / Deny as a form POST. Allow is rendered `disabled`;
743
+ a nonce'd script enables it, and a click counts only after intent on this page — visible, focused,
744
+ and a pointer move / key / touch at least 600 ms after it got there. That defeats
745
+ double-clickjacking (a hostile page sends this window to `/authorize` behind a popup and lets the
746
+ second click of a "double-click" land on Allow). Without JavaScript nothing can be approved, and the
747
+ sign-in forms are hidden and `method="post"`, so a password never lands in a URL.
748
+ - **Allow** issues the code only with the page's CSRF token, signed with the OAuth secret and bound to
749
+ the user, the client, the redirect URI and the PKCE challenge (10 minutes); a stale token shows the
750
+ form again, a forged or tampered one too, and nothing is issued. **Deny** sends the client back with
751
+ `access_denied`.
752
+
753
+ The page carries its own `Content-Security-Policy` (script and style by nonce, `frame-ancestors 'none'`,
754
+ no `form-action`, which browsers would apply to the redirect to the client), `X-Frame-Options: DENY`,
755
+ `Cache-Control: no-store` and `Referrer-Policy: no-referrer`, independent of `security.headers`. Every
756
+ value from outside is escaped — the client's name is chosen by whoever registered the client. German
757
+ for a German `Accept-Language`, English otherwise.
758
+
759
+ Why Allow is a deliberate step: dynamic registration is open by design, so anybody can register a
760
+ client with their own redirect URI and start the flow — PKCE is then theirs too. A GET to `/authorize`
761
+ is what any link produces, and it carries the session cookie, so a page that issued on GET would let any
762
+ link a signed-in user opens authorize an attacker's client. The framework refuses a code saved while
763
+ answering a GET to `/authorize` (logged; the client gets `error=server_error`) — for the built-in page
764
+ and for one of your own alike.
765
+
766
+ **Global prefix.** Nest registers the Better-Auth middleware below a global prefix while the OAuth router sits at the
767
+ root, so the page resolves the session itself when the middleware did not run, and signs in at the prefixed IAM path
768
+ (`/api/iam/sign-in/email`). Better-Auth's own routes are not forwarded below a global prefix (see `setupSwagger()`), so
769
+ the two-factor step does not work there; users with 2FA sign in through the app.
770
+
771
+ **Needs Better-Auth.** The page signs users in through it; without Better-Auth active the boot warns and
772
+ the page offers no sign-in. Its sign-in goes through the IAM sign-in controller, which (since 11.43.0)
773
+ hands Better-Auth the request so Better-Auth's own login-CSRF protection runs — otherwise a hostile page
774
+ could sign the visitor in to the attacker's account before they approve a client.
775
+
776
+ **Your own page.** Override `renderConsentPage(view)` in a subclass to restyle it (keep every value
777
+ escaped), or `authorizeConsent(client, params, res)` for a consent UI of your own — same shape: no session
778
+ → sign in, GET → show which client, POST with a CSRF token → `saveAuthorizationCode()` and redirect;
779
+ refuse with `AccessDeniedError` from `this.loadOAuthErrors()`. Register the subclass via
780
+ `CoreModule.forRoot(env, { ai: { mcpOAuthService } })`, so the MCP controller and the endpoints share it.
781
+ Two checks stay outside the overridable methods: an API token can never approve a consent, and the
782
+ production secret check runs again before the endpoints are mounted (without a signing secret, a
783
+ environment outside `local` / `ci` / `e2e` refuses to boot and to sign tokens at all — deployed `develop` and `test`
784
+ stages included).
785
+
786
+ Known property of the SDK: `/authorize` redirects its error answers to the client's registered
787
+ redirect URI, and any client may register any https URI — an open redirect in the sense of RFC 9700
788
+ §4.11.2, carrying only an OAuth error code.
789
+
790
+ **Refusals are OAuth errors (11.43.0).** The SDK's handlers answer an error with its OAuth code only
791
+ when it is one of the SDK's own `OAuthError` classes; anything else becomes `500 server_error`. The
792
+ built-in provider refuses an unknown, rotated or foreign refresh token or authorization code with
793
+ `400 invalid_grant` (until 11.43.0 these were plain `Error`s and answered 500). That matters beyond the
794
+ status: an MCP client discards its stored tokens only on `invalid_grant`, so on a 500 it kept a dead
795
+ token, retried, and never asked the user to sign in again. A refusal in your own `authorizeConsent()`
796
+ needs the same: take the class from `this.loadOAuthErrors()` (e.g. `AccessDeniedError` when the user
797
+ declines), never a plain `Error`.
798
+
714
799
  ## Tests
715
800
 
716
801
  - `tests/unit/ai.spec.ts` — crypto, registry role filtering, orchestrator loop,
@@ -0,0 +1,199 @@
1
+ import {
2
+ Injectable,
3
+ Logger,
4
+ OnApplicationBootstrap,
5
+ OnApplicationShutdown,
6
+ OnModuleInit,
7
+ Optional,
8
+ RequestMethod,
9
+ } from '@nestjs/common';
10
+ import { MODULE_PATH, PATH_METADATA, VERSION_METADATA } from '@nestjs/common/constants';
11
+ import { addLeadingSlash } from '@nestjs/common/utils/shared.utils';
12
+ import { ApplicationConfig, HttpAdapterHost, ModuleRef, ModulesContainer } from '@nestjs/core';
13
+ import { RoutePathFactory } from '@nestjs/core/router/route-path-factory';
14
+
15
+ import { ConfigService } from '../../common/services/config.service';
16
+ import { getBetterAuthTokenService } from '../better-auth/core-better-auth.registry';
17
+ import { CoreAiMcpController } from './core-ai-mcp.controller';
18
+ import { mountAiMcpOAuthRouter } from './helpers/ai-mcp-oauth.helper';
19
+ import { getMountedMcpOAuth, IMountedMcpOAuth, setMountedMcpOAuth } from './helpers/ai-mcp-oauth.registry';
20
+ import { CoreAiMcpOAuthService } from './services/core-ai-mcp-oauth.service';
21
+
22
+ /**
23
+ * Mounts the MCP OAuth 2.1 endpoints (`/.well-known/oauth-*`, `/authorize`, `/token`, `/register`)
24
+ * when `ai.mcp.oauth` is enabled — no `main.ts` code, and the same under `Test.createTestingModule()`.
25
+ *
26
+ * WHERE in the lifecycle is the whole point. The endpoints are an Express router, not Nest routes,
27
+ * and Nest registers its not-found handler inside `app.init()`, after `onModuleInit`. A router added
28
+ * after `app.init()` — what the documentation used to prescribe — sits behind that handler and is
29
+ * never reached: every OAuth endpoint answered 404. Mounted here, it sits behind every Nest
30
+ * middleware (security headers, request context, CORS) and the Nest routes, and ahead of the
31
+ * not-found handler.
32
+ *
33
+ * The MCP resource path is computed the way Nest computes the controller's route (global prefix and
34
+ * its exclusions, URI versioning, a `RouterModule` path), so the protected resource metadata names
35
+ * the URL the MCP endpoint actually answers on. The issuer is `ai.mcp.oauthBaseUrl`, else the
36
+ * server's `baseUrl`; a deployed environment without either fails the boot rather than advertising
37
+ * a guessed URL.
38
+ *
39
+ * Registered by `CoreAiModule` whenever the MCP endpoint is; consumers never interact with it.
40
+ */
41
+ @Injectable()
42
+ export class CoreAiMcpOAuthInitializer implements OnApplicationBootstrap, OnApplicationShutdown, OnModuleInit {
43
+ protected readonly logger = new Logger(CoreAiMcpOAuthInitializer.name);
44
+
45
+ /** What this instance mounted, so shutdown clears only its own record. */
46
+ private mounted?: IMountedMcpOAuth;
47
+
48
+ constructor(
49
+ protected readonly oauthService: CoreAiMcpOAuthService,
50
+ @Optional() protected readonly httpAdapterHost?: HttpAdapterHost,
51
+ @Optional() protected readonly moduleRef?: ModuleRef,
52
+ @Optional() protected readonly modulesContainer?: ModulesContainer,
53
+ ) {}
54
+
55
+ async onModuleInit(): Promise<void> {
56
+ if (!this.oauthService.isOAuthEnabled()) {
57
+ return;
58
+ }
59
+ const adapter = this.httpAdapterHost?.httpAdapter;
60
+ if (!adapter) {
61
+ // An application context without an HTTP server (CLI, migration script): nothing to serve.
62
+ return;
63
+ }
64
+ // Repeated here, in framework code, because a subclass may override onModuleInit() without super.
65
+ this.oauthService.assertProductionSafe();
66
+ if (adapter.getType() !== 'express') {
67
+ throw new Error(
68
+ `ai.mcp.oauth requires the Express adapter, but the application runs on "${adapter.getType()}": ` +
69
+ 'the MCP OAuth endpoints are an Express router.',
70
+ );
71
+ }
72
+
73
+ this.mounted = await mountAiMcpOAuthRouter(
74
+ adapter.getInstance(),
75
+ (router) => adapter.use(router),
76
+ this.oauthService,
77
+ {
78
+ baseUrl: ConfigService.get<unknown>('ai.mcp.oauthBaseUrl'),
79
+ authBasePath: this.resolveAuthBasePath(),
80
+ baseUrlSource: 'ai.mcp.oauthBaseUrl',
81
+ mcpPath: this.resolveMcpPath(),
82
+ },
83
+ );
84
+ }
85
+
86
+ /**
87
+ * The built-in consent page signs users in through Better-Auth. Checked at bootstrap, when every
88
+ * module — Better-Auth's included — has initialized.
89
+ */
90
+ onApplicationBootstrap(): void {
91
+ if (!this.mounted || !this.oauthService.usesBuiltInConsent() || getBetterAuthTokenService()) {
92
+ return;
93
+ }
94
+ this.logger.warn(
95
+ 'ai.mcp.oauth is enabled, but Better-Auth is not active: the built-in consent page has no way to sign a user ' +
96
+ 'in, so no MCP client can be authorized. Enable betterAuth, or register a CoreAiMcpOAuthService subclass ' +
97
+ 'with an authorizeConsent() of your own via CoreModule.forRoot(env, { ai: { mcpOAuthService } }).',
98
+ );
99
+ }
100
+
101
+ onApplicationShutdown(): void {
102
+ if (this.mounted && getMountedMcpOAuth() === this.mounted) {
103
+ setMountedMcpOAuth(undefined);
104
+ }
105
+ }
106
+
107
+ /**
108
+ * The path the MCP endpoint (`POST` on `CoreAiMcpController`) is registered at, computed with
109
+ * Nest's own `RoutePathFactory` from the inputs Nest passes it (`NestApplication.registerRouter`,
110
+ * `RoutesResolver`). Re-implementing the rules instead would drift silently the first time Nest
111
+ * changes one; this breaks loudly, and `tests/unit/ai-mcp-oauth-lifecycle.spec.ts` pins the result
112
+ * against the route the controller actually answers on.
113
+ */
114
+ protected resolveMcpPath(): string {
115
+ const container = (this.moduleRef as unknown as { container?: { applicationConfig?: ApplicationConfig } })
116
+ ?.container;
117
+ const applicationConfig = container?.applicationConfig;
118
+ const ctrlPath = this.controllerPath();
119
+ if (!applicationConfig) {
120
+ return ctrlPath;
121
+ }
122
+
123
+ const versioningOptions = applicationConfig.getVersioning();
124
+ const controllerVersion = versioningOptions
125
+ ? (Reflect.getMetadata(VERSION_METADATA, CoreAiMcpController) ?? versioningOptions.defaultVersion)
126
+ : undefined;
127
+ const paths = new RoutePathFactory(applicationConfig).create(
128
+ {
129
+ controllerVersion,
130
+ ctrlPath,
131
+ globalPrefix: addLeadingSlash(applicationConfig.getGlobalPrefix()),
132
+ methodPath: '/',
133
+ modulePath: this.modulePath(),
134
+ versioningOptions,
135
+ },
136
+ RequestMethod.POST,
137
+ );
138
+
139
+ if (paths.length > 1) {
140
+ this.logger.warn(
141
+ `The MCP endpoint is registered at ${paths.join(', ')}; the OAuth metadata describes the first one only.`,
142
+ );
143
+ }
144
+ return paths[0];
145
+ }
146
+
147
+ /**
148
+ * Where the IAM endpoints the consent page signs in through answer: Better-Auth's `basePath` below
149
+ * the global prefix (and URI version) Nest registers the IAM controller under. The OAuth router
150
+ * itself sits at the root, so a page served there must not assume `/iam`.
151
+ */
152
+ protected resolveAuthBasePath(): string {
153
+ const basePath = `/${String(ConfigService.get<string>('betterAuth.basePath') || 'iam').replace(/^\//, '')}`;
154
+ const applicationConfig = (this.moduleRef as unknown as { container?: { applicationConfig?: ApplicationConfig } })
155
+ ?.container?.applicationConfig;
156
+ if (!applicationConfig) {
157
+ return basePath;
158
+ }
159
+ const versioningOptions = applicationConfig.getVersioning();
160
+ const [signIn] = new RoutePathFactory(applicationConfig).create(
161
+ {
162
+ controllerVersion: versioningOptions?.defaultVersion,
163
+ ctrlPath: basePath,
164
+ globalPrefix: addLeadingSlash(applicationConfig.getGlobalPrefix()),
165
+ methodPath: '/sign-in/email',
166
+ versioningOptions,
167
+ },
168
+ RequestMethod.POST,
169
+ );
170
+ return signIn.replace(/\/sign-in\/email$/, '');
171
+ }
172
+
173
+ /** The controller's declared route, with a leading slash (`/ai/mcp`). */
174
+ private controllerPath(): string {
175
+ const declared = Reflect.getMetadata(PATH_METADATA, CoreAiMcpController);
176
+ const path = Array.isArray(declared) ? declared[0] : declared;
177
+ return `/${String(path ?? 'ai/mcp').replace(/^\//, '')}`;
178
+ }
179
+
180
+ /**
181
+ * A `RouterModule` path in front of the module that registered the MCP controller, read the way
182
+ * `RoutesResolver` reads it. The module is looked up rather than imported: importing `CoreAiModule`
183
+ * here would put this file and the module on an import cycle.
184
+ */
185
+ private modulePath(): string | undefined {
186
+ if (!this.modulesContainer) {
187
+ return undefined;
188
+ }
189
+ for (const module of this.modulesContainer.values()) {
190
+ if (module.controllers.has(CoreAiMcpController)) {
191
+ return (
192
+ Reflect.getMetadata(MODULE_PATH + this.modulesContainer.applicationId, module.metatype) ??
193
+ Reflect.getMetadata(MODULE_PATH, module.metatype)
194
+ );
195
+ }
196
+ }
197
+ return undefined;
198
+ }
199
+ }
@@ -9,6 +9,7 @@ import { ConfigService } from '../../common/services/config.service';
9
9
  import { CoreRedisService } from '../../common/services/core-redis.service';
10
10
  import { CoreBetterAuthModule } from '../better-auth/core-better-auth.module';
11
11
  import { ErrorCode } from '../error-code/error-codes';
12
+ import { getMountedMcpOAuth } from './helpers/ai-mcp-oauth.registry';
12
13
  import { CoreAiMcpOAuthService } from './services/core-ai-mcp-oauth.service';
13
14
  import { CoreAiMcpService } from './services/core-ai-mcp.service';
14
15
  import { AiToolUser } from './tools/ai-tool.registry';
@@ -34,7 +35,8 @@ import { AiToolUser } from './tools/ai-tool.registry';
34
35
  * `WWW-Authenticate` header as the protocol expects.
35
36
  *
36
37
  * When `ai.mcp.oauth` is enabled, the handler additionally accepts OAuth 2.1 access
37
- * tokens (see `mountAiMcpOAuth`); otherwise it authenticates via Bearer/session token.
38
+ * tokens (endpoints mounted by `CoreAiMcpOAuthInitializer`); otherwise it authenticates via
39
+ * Bearer/session token.
38
40
  *
39
41
  * ## Multi-replica
40
42
  *
@@ -121,7 +123,7 @@ export class CoreAiMcpController implements OnModuleDestroy {
121
123
  async handlePost(@Req() req: Request, @Res() res: Response): Promise<void> {
122
124
  const user = await this.resolveUser(req);
123
125
  if (!user?.id) {
124
- this.unauthorized(req, res);
126
+ this.unauthorized(res);
125
127
  return;
126
128
  }
127
129
 
@@ -298,7 +300,7 @@ export class CoreAiMcpController implements OnModuleDestroy {
298
300
  private async handleSessionRequest(req: Request, res: Response): Promise<void> {
299
301
  const user = await this.resolveUser(req);
300
302
  if (!user?.id) {
301
- this.unauthorized(req, res);
303
+ this.unauthorized(res);
302
304
  return;
303
305
  }
304
306
  const sessionId = req.headers['mcp-session-id'] as string | undefined;
@@ -470,12 +472,17 @@ export class CoreAiMcpController implements OnModuleDestroy {
470
472
 
471
473
  /**
472
474
  * MCP-style 401 with a `WWW-Authenticate` header for OAuth discovery.
475
+ *
476
+ * `resource_metadata` is the URL of the protected resource METADATA (RFC 9728 §5.1), exactly as
477
+ * the mounted OAuth router serves it — not the endpoint itself, and not a URL built from the
478
+ * request's `Host` header, which the caller controls. Without mounted OAuth endpoints there is
479
+ * no metadata to point at, so the challenge is a plain `Bearer`.
473
480
  */
474
- private unauthorized(req: Request, res: Response): void {
475
- const baseUrl = `${req.protocol}://${req.get('host')}`;
481
+ private unauthorized(res: Response): void {
482
+ const oauth = getMountedMcpOAuth();
476
483
  res
477
484
  .status(401)
478
- .set({ 'WWW-Authenticate': `Bearer resource_metadata="${baseUrl}/ai/mcp"` })
485
+ .set({ 'WWW-Authenticate': oauth ? `Bearer resource_metadata="${oauth.resourceMetadataUrl}"` : 'Bearer' })
479
486
  .json({ error: 'Unauthorized: a valid Bearer token is required' });
480
487
  }
481
488
 
@@ -1,6 +1,7 @@
1
1
  import { DynamicModule, Global, Module, Type } from '@nestjs/common';
2
2
  import { MongooseModule } from '@nestjs/mongoose';
3
3
 
4
+ import { CoreAiMcpOAuthInitializer } from './core-ai-mcp-oauth.initializer';
4
5
  import { CoreAiMcpController } from './core-ai-mcp.controller';
5
6
  import { CoreAiController } from './core-ai.controller';
6
7
  import { CoreAiResolver } from './core-ai.resolver';
@@ -95,6 +96,12 @@ export interface CoreAiModuleOptions {
95
96
  /** Whether to register the MCP server controller at /ai/mcp. */
96
97
  mcpEnabled?: boolean;
97
98
 
99
+ /**
100
+ * Custom MCP OAuth service (extends CoreAiMcpOAuthService) — where `authorizeConsent()` wires the
101
+ * login/consent UI. Bound to the base token, so the MCP controller and the OAuth endpoints use it.
102
+ */
103
+ mcpOAuthService?: Type<CoreAiMcpOAuthService>;
104
+
98
105
  /** Custom connection-preference service (extends CoreAiConnectionPreferenceService). */
99
106
  preferenceService?: Type<CoreAiConnectionPreferenceService>;
100
107
 
@@ -174,11 +181,15 @@ export class CoreAiModule {
174
181
  const ToolPolicyServiceClass = options.toolPolicyService || CoreAiToolPolicyService;
175
182
  const ModeServiceClass = options.modeService || CoreAiModeService;
176
183
  const McpClientServiceClass = options.mcpClientService || CoreAiMcpClientService;
184
+ const McpOAuthServiceClass = options.mcpOAuthService || CoreAiMcpOAuthService;
177
185
 
178
- // Register the MCP controller only when enabled (it lazy-loads the MCP SDK).
186
+ // Register the MCP controller only when enabled (it lazy-loads the MCP SDK), together with the
187
+ // initializer that mounts its OAuth endpoints when `ai.mcp.oauth` is enabled.
179
188
  const controllers: Type<any>[] = [ControllerClass];
189
+ const mcpProviders: Type<any>[] = [];
180
190
  if (options.mcpEnabled) {
181
191
  controllers.push(CoreAiMcpController);
192
+ mcpProviders.push(CoreAiMcpOAuthInitializer);
182
193
  }
183
194
 
184
195
  return {
@@ -246,7 +257,7 @@ export class CoreAiModule {
246
257
  { provide: CoreAiConversationService, useClass: ConversationServiceClass },
247
258
  { provide: CoreAiInteractionService, useClass: InteractionServiceClass },
248
259
  CoreAiMcpService,
249
- CoreAiMcpOAuthService,
260
+ ...mcpProviders,
250
261
  // Bind base-class tokens to the (possibly overridden) implementations so
251
262
  // injections by base type resolve to the project's subclass.
252
263
  { provide: CoreAiConnectionPreferenceService, useClass: PreferenceServiceClass },
@@ -262,6 +273,7 @@ export class CoreAiModule {
262
273
  { provide: CoreAiToolPolicyService, useClass: ToolPolicyServiceClass },
263
274
  { provide: CoreAiModeService, useClass: ModeServiceClass },
264
275
  { provide: CoreAiMcpClientService, useClass: McpClientServiceClass },
276
+ { provide: CoreAiMcpOAuthService, useClass: McpOAuthServiceClass },
265
277
  ResolverClass,
266
278
  ],
267
279
  };