@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.
- package/.claude/rules/better-auth.md +22 -0
- package/.claude/rules/configurable-features.md +4 -3
- package/.claude/rules/testing.md +4 -4
- package/FRAMEWORK-API.md +2 -2
- package/dist/core/common/filters/mongoose-cast-error.filter.d.ts +17 -0
- package/dist/core/common/filters/mongoose-cast-error.filter.js +171 -0
- package/dist/core/common/filters/mongoose-cast-error.filter.js.map +1 -0
- package/dist/core/common/interfaces/server-options.interface.d.ts +4 -0
- package/dist/core/modules/ai/core-ai-mcp-oauth.initializer.d.ts +19 -0
- package/dist/core/modules/ai/core-ai-mcp-oauth.initializer.js +145 -0
- package/dist/core/modules/ai/core-ai-mcp-oauth.initializer.js.map +1 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.js +6 -5
- package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
- package/dist/core/modules/ai/core-ai.module.d.ts +2 -0
- package/dist/core/modules/ai/core-ai.module.js +6 -1
- package/dist/core/modules/ai/core-ai.module.js.map +1 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.d.ts +33 -0
- package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js +282 -0
- package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js.map +1 -0
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.d.ts +15 -2
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js +73 -19
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js.map +1 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.registry.d.ts +5 -0
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.registry.js.map +1 -1
- package/dist/core/modules/ai/index.d.ts +1 -0
- package/dist/core/modules/ai/index.js +1 -0
- package/dist/core/modules/ai/index.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.d.ts +30 -2
- package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js +222 -15
- package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.config.d.ts +4 -0
- package/dist/core/modules/better-auth/better-auth.config.js +29 -2
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.controller.d.ts +2 -0
- package/dist/core/modules/better-auth/core-better-auth.controller.js +36 -1
- package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
- package/dist/core.module.js +7 -0
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +30 -1
- package/docs/security-overrides.md +3 -2
- package/migration-guides/11.42.15-to-11.42.16.md +114 -0
- package/migration-guides/11.42.x-to-11.43.x.md +195 -0
- package/package.json +7 -7
- package/src/core/common/filters/mongoose-cast-error.filter.ts +310 -0
- package/src/core/common/helpers/swagger.helper.ts +4 -4
- package/src/core/common/interfaces/server-options.interface.ts +59 -9
- package/src/core/modules/ai/INTEGRATION-CHECKLIST.md +50 -24
- package/src/core/modules/ai/README.md +98 -13
- package/src/core/modules/ai/core-ai-mcp-oauth.initializer.ts +199 -0
- package/src/core/modules/ai/core-ai-mcp.controller.ts +13 -6
- package/src/core/modules/ai/core-ai.module.ts +14 -2
- package/src/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.ts +369 -0
- package/src/core/modules/ai/helpers/ai-mcp-oauth.helper.ts +149 -43
- package/src/core/modules/ai/helpers/ai-mcp-oauth.registry.ts +18 -5
- package/src/core/modules/ai/index.ts +1 -0
- package/src/core/modules/ai/services/core-ai-mcp-oauth.service.ts +385 -28
- package/src/core/modules/better-auth/better-auth.config.ts +49 -5
- package/src/core/modules/better-auth/core-better-auth.controller.ts +75 -1
- package/src/core.module.ts +13 -1
- 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.
|
|
688
|
+
also accepts OAuth access tokens.
|
|
689
689
|
|
|
690
690
|
```typescript
|
|
691
|
-
//
|
|
692
|
-
|
|
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
|
-
`
|
|
700
|
-
|
|
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
|
|
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
|
-
|
|
709
|
-
|
|
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 (
|
|
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(
|
|
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(
|
|
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(
|
|
475
|
-
const
|
|
481
|
+
private unauthorized(res: Response): void {
|
|
482
|
+
const oauth = getMountedMcpOAuth();
|
|
476
483
|
res
|
|
477
484
|
.status(401)
|
|
478
|
-
.set({ 'WWW-Authenticate': `Bearer resource_metadata="${
|
|
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
|
-
|
|
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
|
};
|