@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
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built-in MCP OAuth consent page: one build-free HTML document with two views.
|
|
3
|
+
*
|
|
4
|
+
* - **consent** (a user is signed in): which client asks, where the code goes, Allow / Deny. A plain
|
|
5
|
+
* form POST to `/authorize` — works without JavaScript.
|
|
6
|
+
* - **sign-in** (nobody is signed in): e-mail + password and the two-factor step, against
|
|
7
|
+
* Better-Auth's own endpoints on the same origin, then a reload of the very same `/authorize`
|
|
8
|
+
* request. Other methods (passkey, social login) through the app's login page.
|
|
9
|
+
*
|
|
10
|
+
* Everything that reaches the page from outside is escaped: the client's name comes from dynamic
|
|
11
|
+
* registration, i.e. from whoever registered the client. Nothing here decides whether a code is
|
|
12
|
+
* issued — that is `CoreAiMcpOAuthService` (CSRF token, user, client, PKCE challenge).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** What the page shows. Built by `CoreAiMcpOAuthService`, rendered by {@link renderMcpOAuthConsentPage}. */
|
|
16
|
+
export interface IMcpOAuthConsentView {
|
|
17
|
+
/** Host the user knows the application by (the app's host, else the API's) */
|
|
18
|
+
appHost: string;
|
|
19
|
+
/** Better-Auth base path for the sign-in form; absent when Better-Auth is not active */
|
|
20
|
+
authBasePath?: string;
|
|
21
|
+
client: { id: string; name?: string };
|
|
22
|
+
/** CSRF token of the consent form (consent view only) */
|
|
23
|
+
csrfToken?: string;
|
|
24
|
+
/** Why the consent form is shown again */
|
|
25
|
+
notice?: 'expired';
|
|
26
|
+
locale: McpOAuthConsentLocale;
|
|
27
|
+
/** The app's login page, for passkey / social login; `returnsHere` when it sends the user back by itself */
|
|
28
|
+
loginUrl?: { href: string; returnsHere: boolean };
|
|
29
|
+
/** CSP nonce of this response */
|
|
30
|
+
nonce: string;
|
|
31
|
+
/** The OAuth parameters the consent form re-submits to `/authorize` */
|
|
32
|
+
oauthParams: Record<string, string>;
|
|
33
|
+
redirect: { host: string; loopback: boolean };
|
|
34
|
+
/** The signed-in user; absent → sign-in view */
|
|
35
|
+
user?: { email?: string; name?: string };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type McpOAuthConsentLocale = 'de' | 'en';
|
|
39
|
+
|
|
40
|
+
/** Form field carrying the decision (`approve` / `deny`). */
|
|
41
|
+
export const MCP_OAUTH_CONSENT_DECISION_FIELD = 'lt_mcp_consent';
|
|
42
|
+
/** Form field carrying the CSRF token. */
|
|
43
|
+
export const MCP_OAUTH_CONSENT_TOKEN_FIELD = 'lt_mcp_csrf';
|
|
44
|
+
|
|
45
|
+
const TEXTS = {
|
|
46
|
+
de: {
|
|
47
|
+
allow: 'Zulassen',
|
|
48
|
+
approveNoscript: 'Zum Bestätigen wird JavaScript benötigt.',
|
|
49
|
+
backupCode: 'Backup-Code verwenden',
|
|
50
|
+
code: 'Bestätigungscode',
|
|
51
|
+
codeFailed: 'Der Code ist ungültig.',
|
|
52
|
+
codeHint: 'Gib den Code aus deiner Authenticator-App ein.',
|
|
53
|
+
confirm: 'Bestätigen',
|
|
54
|
+
continue: 'Ich habe mich angemeldet – weiter',
|
|
55
|
+
deny: 'Ablehnen',
|
|
56
|
+
email: 'E-Mail',
|
|
57
|
+
expired: 'Die Seite war zu lange geöffnet. Bitte bestätige noch einmal.',
|
|
58
|
+
intro: '{client} möchte über MCP in deinem Namen auf {app} zugreifen – mit deinen Rechten.',
|
|
59
|
+
loopback: 'eine Anwendung auf diesem Computer',
|
|
60
|
+
noscript: 'Für die Anmeldung wird JavaScript benötigt.',
|
|
61
|
+
noSignIn: 'Auf diesem Server ist keine Anmeldung für MCP-Clients eingerichtet.',
|
|
62
|
+
otherMethod: 'Mit Passkey oder einem anderen Verfahren anmelden',
|
|
63
|
+
password: 'Passwort',
|
|
64
|
+
signedInAs: 'Angemeldet als {user}',
|
|
65
|
+
switchAccount: 'Anderes Konto',
|
|
66
|
+
signIn: 'Anmelden',
|
|
67
|
+
signInFailed: 'Anmeldung fehlgeschlagen. Bitte prüfe E-Mail und Passwort.',
|
|
68
|
+
signInIntro: 'Melde dich bei {app} an, um {client} den Zugriff zu erlauben.',
|
|
69
|
+
target: 'Die Freigabe geht an:',
|
|
70
|
+
title: 'Zugriff erlauben',
|
|
71
|
+
unnamedClient: 'Eine unbenannte Anwendung',
|
|
72
|
+
warning: 'Erlaube den Zugriff nur, wenn du die Verbindung gerade selbst gestartet hast.',
|
|
73
|
+
},
|
|
74
|
+
en: {
|
|
75
|
+
allow: 'Allow',
|
|
76
|
+
approveNoscript: 'Confirming requires JavaScript.',
|
|
77
|
+
backupCode: 'Use a backup code',
|
|
78
|
+
code: 'Verification code',
|
|
79
|
+
codeFailed: 'The code is invalid.',
|
|
80
|
+
codeHint: 'Enter the code from your authenticator app.',
|
|
81
|
+
confirm: 'Confirm',
|
|
82
|
+
continue: 'I have signed in – continue',
|
|
83
|
+
deny: 'Deny',
|
|
84
|
+
email: 'Email',
|
|
85
|
+
expired: 'This page was open for too long. Please confirm again.',
|
|
86
|
+
intro: '{client} wants to access {app} on your behalf through MCP – with your permissions.',
|
|
87
|
+
loopback: 'an application on this computer',
|
|
88
|
+
noscript: 'Signing in requires JavaScript.',
|
|
89
|
+
noSignIn: 'Sign-in for MCP clients is not set up on this server.',
|
|
90
|
+
otherMethod: 'Sign in with a passkey or another method',
|
|
91
|
+
password: 'Password',
|
|
92
|
+
signedInAs: 'Signed in as {user}',
|
|
93
|
+
switchAccount: 'Use another account',
|
|
94
|
+
signIn: 'Sign in',
|
|
95
|
+
signInFailed: 'Sign-in failed. Please check your email and password.',
|
|
96
|
+
signInIntro: 'Sign in to {app} to allow {client} access.',
|
|
97
|
+
target: 'Access is granted to:',
|
|
98
|
+
title: 'Allow access',
|
|
99
|
+
unnamedClient: 'An unnamed application',
|
|
100
|
+
warning: 'Only allow access if you just started this connection yourself.',
|
|
101
|
+
},
|
|
102
|
+
} satisfies Record<McpOAuthConsentLocale, Record<string, string>>;
|
|
103
|
+
|
|
104
|
+
/** The locale for an `Accept-Language` header: German when the first preference is German, else English. */
|
|
105
|
+
export function mcpOAuthConsentLocale(acceptLanguage: unknown): McpOAuthConsentLocale {
|
|
106
|
+
const first = typeof acceptLanguage === 'string' ? acceptLanguage.split(',')[0].trim().toLowerCase() : '';
|
|
107
|
+
return first.startsWith('de') ? 'de' : 'en';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Security headers of the page. `frame-ancestors 'none'` + `X-Frame-Options` stop clickjacking of the
|
|
112
|
+
* Allow button. No `form-action`: browsers apply it to the redirect after the POST, and that
|
|
113
|
+
* redirect goes to the client's callback on another origin.
|
|
114
|
+
*/
|
|
115
|
+
export function mcpOAuthConsentHeaders(nonce: string): Record<string, string> {
|
|
116
|
+
return {
|
|
117
|
+
'Cache-Control': 'no-store',
|
|
118
|
+
'Content-Security-Policy':
|
|
119
|
+
`default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; ` +
|
|
120
|
+
"img-src 'self' data:; base-uri 'none'; frame-ancestors 'none'",
|
|
121
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
122
|
+
'Referrer-Policy': 'no-referrer',
|
|
123
|
+
'X-Content-Type-Options': 'nosniff',
|
|
124
|
+
'X-Frame-Options': 'DENY',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Renders the page. Every dynamic value goes through {@link escapeHtml}. */
|
|
129
|
+
export function renderMcpOAuthConsentPage(view: IMcpOAuthConsentView): string {
|
|
130
|
+
const t = TEXTS[view.locale];
|
|
131
|
+
const clientName = view.client.name?.trim() || t.unnamedClient;
|
|
132
|
+
// One pass: a client name is chosen by whoever registered the client, so a name containing
|
|
133
|
+
// `{app}` must not be expanded by a second replace.
|
|
134
|
+
const fill = (text: string) =>
|
|
135
|
+
escapeHtml(text).replace(
|
|
136
|
+
/\{(client|app)\}/g,
|
|
137
|
+
(_, key: string) => `<strong>${escapeHtml(key === 'client' ? clientName : view.appHost)}</strong>`,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const body = view.user ? consentView(view, t, fill) : signInView(view, t, fill);
|
|
141
|
+
return `<!doctype html>
|
|
142
|
+
<html lang="${view.locale}">
|
|
143
|
+
<head>
|
|
144
|
+
<meta charset="utf-8">
|
|
145
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
146
|
+
<meta name="robots" content="noindex">
|
|
147
|
+
<link rel="icon" href="data:,">
|
|
148
|
+
<title>${escapeHtml(t.title)} – ${escapeHtml(view.appHost)}</title>
|
|
149
|
+
<style nonce="${escapeHtml(view.nonce)}">${STYLES}</style>
|
|
150
|
+
</head>
|
|
151
|
+
<body>
|
|
152
|
+
<main class="card">
|
|
153
|
+
${body}
|
|
154
|
+
</main>
|
|
155
|
+
${pageScript(view)}
|
|
156
|
+
</body>
|
|
157
|
+
</html>
|
|
158
|
+
`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
type Texts = (typeof TEXTS)[McpOAuthConsentLocale];
|
|
162
|
+
|
|
163
|
+
function consentView(view: IMcpOAuthConsentView, t: Texts, fill: (text: string) => string): string {
|
|
164
|
+
const target = view.redirect.loopback
|
|
165
|
+
? `${escapeHtml(t.loopback)} (${escapeHtml(view.redirect.host)})`
|
|
166
|
+
: escapeHtml(view.redirect.host);
|
|
167
|
+
const who = view.user?.email || view.user?.name;
|
|
168
|
+
const hidden = Object.entries(view.oauthParams)
|
|
169
|
+
.map(([name, value]) => `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">`)
|
|
170
|
+
.join('\n');
|
|
171
|
+
// The account comes first and is not muted: a session planted by login CSRF must be noticed
|
|
172
|
+
// before Allow, not after.
|
|
173
|
+
const account = who
|
|
174
|
+
? `<p class="account">${escapeHtml(t.signedInAs).replace('{user}', () => `<strong>${escapeHtml(who)}</strong>`)}${
|
|
175
|
+
view.authBasePath
|
|
176
|
+
? `<a href="" id="lt-mcp-switch" class="switch" data-sign-out="${escapeHtml(`${view.authBasePath}/sign-out`)}">${escapeHtml(t.switchAccount)}</a>`
|
|
177
|
+
: ''
|
|
178
|
+
}</p>`
|
|
179
|
+
: '';
|
|
180
|
+
// Allow starts disabled: only the guard script enables it, so a page without the script can never
|
|
181
|
+
// be clicked into an approval (see MCP_OAUTH_CONSENT_GUARD_SCRIPT).
|
|
182
|
+
return `<h1>${escapeHtml(t.title)}</h1>
|
|
183
|
+
${account}
|
|
184
|
+
${view.notice === 'expired' ? `<p class="notice" role="alert">${escapeHtml(t.expired)}</p>` : ''}
|
|
185
|
+
<p>${fill(t.intro)}</p>
|
|
186
|
+
<p class="target">${escapeHtml(t.target)} <strong>${target}</strong></p>
|
|
187
|
+
<p class="warning">${escapeHtml(t.warning)}</p>
|
|
188
|
+
<noscript><p class="notice">${escapeHtml(t.approveNoscript)}</p></noscript>
|
|
189
|
+
<form method="post" action="authorize" class="actions">
|
|
190
|
+
${hidden}
|
|
191
|
+
<input type="hidden" name="${MCP_OAUTH_CONSENT_TOKEN_FIELD}" value="${escapeHtml(view.csrfToken ?? '')}">
|
|
192
|
+
<button type="submit" name="${MCP_OAUTH_CONSENT_DECISION_FIELD}" value="deny" class="secondary">${escapeHtml(t.deny)}</button>
|
|
193
|
+
<button type="submit" name="${MCP_OAUTH_CONSENT_DECISION_FIELD}" value="approve" class="primary" id="lt-mcp-allow" disabled>${escapeHtml(t.allow)}</button>
|
|
194
|
+
</form>`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The script of the view: the consent guard, or the sign-in script; nothing when there is no sign-in. */
|
|
198
|
+
function pageScript(view: IMcpOAuthConsentView): string {
|
|
199
|
+
const script = view.user ? MCP_OAUTH_CONSENT_GUARD_SCRIPT : view.authBasePath ? MCP_OAUTH_CONSENT_SCRIPT : '';
|
|
200
|
+
return script ? `<script nonce="${escapeHtml(view.nonce)}">${script}</script>` : '';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function signInView(view: IMcpOAuthConsentView, t: Texts, fill: (text: string) => string): string {
|
|
204
|
+
const other = view.loginUrl
|
|
205
|
+
? `<div class="other">
|
|
206
|
+
<a href="${escapeHtml(view.loginUrl.href)}"${view.loginUrl.returnsHere ? '' : ' target="_blank" rel="noopener noreferrer"'}>${escapeHtml(t.otherMethod)}</a>
|
|
207
|
+
${view.loginUrl.returnsHere ? '' : `<a href="" class="continue">${escapeHtml(t.continue)}</a>`}
|
|
208
|
+
</div>`
|
|
209
|
+
: '';
|
|
210
|
+
if (!view.authBasePath) {
|
|
211
|
+
return `<h1>${escapeHtml(t.signIn)}</h1>
|
|
212
|
+
<p>${escapeHtml(t.noSignIn)}</p>
|
|
213
|
+
${other}`;
|
|
214
|
+
}
|
|
215
|
+
return `<h1>${escapeHtml(t.signIn)}</h1>
|
|
216
|
+
<p>${fill(t.signInIntro)}</p>
|
|
217
|
+
<noscript><p class="notice">${escapeHtml(t.noscript)}</p></noscript>
|
|
218
|
+
<div id="lt-mcp-signin" data-auth-base="${escapeHtml(view.authBasePath)}" data-sign-in-failed="${escapeHtml(t.signInFailed)}" data-code-failed="${escapeHtml(t.codeFailed)}">
|
|
219
|
+
<form id="lt-mcp-password" method="post" hidden>
|
|
220
|
+
<label>${escapeHtml(t.email)}<input name="email" type="email" autocomplete="username" required></label>
|
|
221
|
+
<label>${escapeHtml(t.password)}<input name="password" type="password" autocomplete="current-password" required></label>
|
|
222
|
+
<button type="submit" class="primary">${escapeHtml(t.signIn)}</button>
|
|
223
|
+
</form>
|
|
224
|
+
<form id="lt-mcp-second-factor" method="post" hidden>
|
|
225
|
+
<p class="muted">${escapeHtml(t.codeHint)}</p>
|
|
226
|
+
<label>${escapeHtml(t.code)}<input name="code" autocomplete="one-time-code" inputmode="numeric" required></label>
|
|
227
|
+
<label class="inline"><input name="backup" type="checkbox"> ${escapeHtml(t.backupCode)}</label>
|
|
228
|
+
<button type="submit" class="primary">${escapeHtml(t.confirm)}</button>
|
|
229
|
+
</form>
|
|
230
|
+
<p id="lt-mcp-error" class="notice" role="alert" hidden></p>
|
|
231
|
+
</div>
|
|
232
|
+
${other}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Client script of the consent view: arms Allow, and offers "use another account".
|
|
237
|
+
*
|
|
238
|
+
* Allow is rendered `disabled`; this script enables it, but a click counts only when the user has
|
|
239
|
+
* shown intent on THIS page: it is visible and focused, and at least 600 ms after it got there a
|
|
240
|
+
* pointer moved, a key was pressed or a touch began. That defeats double-clickjacking — a hostile
|
|
241
|
+
* page opens a popup, sends this window to `/authorize` behind it and lets the second click of a
|
|
242
|
+
* "double-click" land on Allow: that click arrives within milliseconds of the window regaining
|
|
243
|
+
* focus, and with the mouse resting where it was. Blur and hiding reset it. A click that does not
|
|
244
|
+
* count is simply not submitted; the user clicks again.
|
|
245
|
+
*/
|
|
246
|
+
export const MCP_OAUTH_CONSENT_GUARD_SCRIPT = `(function () {
|
|
247
|
+
var allow = document.getElementById('lt-mcp-allow');
|
|
248
|
+
if (allow) {
|
|
249
|
+
var since = 0;
|
|
250
|
+
var armed = false;
|
|
251
|
+
var reset = function () { since = 0; armed = false; };
|
|
252
|
+
var watch = function () {
|
|
253
|
+
if (document.visibilityState === 'visible' && document.hasFocus()) { if (!since) { since = Date.now(); } } else { reset(); }
|
|
254
|
+
};
|
|
255
|
+
var intent = function (event) {
|
|
256
|
+
watch();
|
|
257
|
+
var stationaryClick = event.type === 'pointerdown' && event.pointerType === 'mouse';
|
|
258
|
+
if (since && !stationaryClick && Date.now() - since >= 600) { armed = true; }
|
|
259
|
+
};
|
|
260
|
+
window.addEventListener('blur', reset);
|
|
261
|
+
window.addEventListener('focus', watch);
|
|
262
|
+
document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible') { watch(); } else { reset(); } });
|
|
263
|
+
document.addEventListener('pointermove', intent);
|
|
264
|
+
document.addEventListener('pointerdown', intent);
|
|
265
|
+
document.addEventListener('keydown', intent);
|
|
266
|
+
allow.addEventListener('click', function (event) { if (!armed) { event.preventDefault(); } });
|
|
267
|
+
allow.disabled = false;
|
|
268
|
+
watch();
|
|
269
|
+
}
|
|
270
|
+
var other = document.getElementById('lt-mcp-switch');
|
|
271
|
+
if (other) {
|
|
272
|
+
other.addEventListener('click', function (event) {
|
|
273
|
+
event.preventDefault();
|
|
274
|
+
var done = function () { location.reload(); };
|
|
275
|
+
fetch(other.getAttribute('data-sign-out'), { body: '{}', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, method: 'POST' }).then(done, done);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
})();`;
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Client script of the sign-in view. Plain ES5, no dependencies, no build: it signs in through
|
|
282
|
+
* Better-Auth's endpoints on this origin — the same ones the app's login uses — and reloads, so the
|
|
283
|
+
* unchanged `/authorize` request is answered again, now with a session. It never touches the consent
|
|
284
|
+
* decision.
|
|
285
|
+
*/
|
|
286
|
+
export const MCP_OAUTH_CONSENT_SCRIPT = `(function () {
|
|
287
|
+
var root = document.getElementById('lt-mcp-signin');
|
|
288
|
+
if (!root) { return; }
|
|
289
|
+
var base = root.getAttribute('data-auth-base');
|
|
290
|
+
var passwordForm = document.getElementById('lt-mcp-password');
|
|
291
|
+
var factorForm = document.getElementById('lt-mcp-second-factor');
|
|
292
|
+
var error = document.getElementById('lt-mcp-error');
|
|
293
|
+
// Both forms are rendered hidden and with method="post": without this script nothing can be
|
|
294
|
+
// submitted, and nothing would ever put the password into a URL.
|
|
295
|
+
passwordForm.hidden = false;
|
|
296
|
+
function fail(message) { error.textContent = message; error.hidden = false; }
|
|
297
|
+
function post(path, body) {
|
|
298
|
+
return fetch(base + path, {
|
|
299
|
+
body: JSON.stringify(body),
|
|
300
|
+
credentials: 'same-origin',
|
|
301
|
+
headers: { 'content-type': 'application/json' },
|
|
302
|
+
method: 'POST'
|
|
303
|
+
}).then(function (response) {
|
|
304
|
+
return response.json().then(function (json) { return json; }, function () { return {}; }).then(function (json) {
|
|
305
|
+
return { body: json || {}, ok: response.ok };
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
passwordForm.addEventListener('submit', function (event) {
|
|
310
|
+
event.preventDefault();
|
|
311
|
+
error.hidden = true;
|
|
312
|
+
post('/sign-in/email', { email: passwordForm.elements.email.value, password: passwordForm.elements.password.value })
|
|
313
|
+
.then(function (result) {
|
|
314
|
+
if (result.body.requiresTwoFactor || result.body.twoFactorRedirect) {
|
|
315
|
+
passwordForm.hidden = true;
|
|
316
|
+
factorForm.hidden = false;
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (!result.ok) { fail(root.getAttribute('data-sign-in-failed')); return; }
|
|
320
|
+
location.reload();
|
|
321
|
+
}, function () { fail(root.getAttribute('data-sign-in-failed')); });
|
|
322
|
+
});
|
|
323
|
+
factorForm.addEventListener('submit', function (event) {
|
|
324
|
+
event.preventDefault();
|
|
325
|
+
error.hidden = true;
|
|
326
|
+
var path = factorForm.elements.backup.checked ? '/two-factor/verify-backup-code' : '/two-factor/verify-totp';
|
|
327
|
+
post(path, { code: factorForm.elements.code.value })
|
|
328
|
+
.then(function (result) {
|
|
329
|
+
if (!result.ok) { fail(root.getAttribute('data-code-failed')); return; }
|
|
330
|
+
location.reload();
|
|
331
|
+
}, function () { fail(root.getAttribute('data-code-failed')); });
|
|
332
|
+
});
|
|
333
|
+
})();`;
|
|
334
|
+
|
|
335
|
+
/** Escape the five HTML-significant characters. */
|
|
336
|
+
function escapeHtml(value: string): string {
|
|
337
|
+
return String(value ?? '')
|
|
338
|
+
.replace(/&/g, '&')
|
|
339
|
+
.replace(/</g, '<')
|
|
340
|
+
.replace(/>/g, '>')
|
|
341
|
+
.replace(/"/g, '"')
|
|
342
|
+
.replace(/'/g, ''');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const STYLES = `
|
|
346
|
+
:root{color-scheme:light dark;--bg:#f4f5f7;--card:#fff;--text:#1d2129;--muted:#5b6270;--border:#d9dde3;--primary:#1f6feb;--primary-text:#fff;--notice:#b42318}
|
|
347
|
+
@media (prefers-color-scheme:dark){:root{--bg:#0f1115;--card:#181b21;--text:#e6e8eb;--muted:#9aa1ad;--border:#2c313a;--primary:#4c8dff;--notice:#ff8a80}}
|
|
348
|
+
*{box-sizing:border-box}
|
|
349
|
+
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:16px;background:var(--bg);color:var(--text);font:15px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
350
|
+
.card{width:100%;max-width:440px;background:var(--card);border:1px solid var(--border);border-radius:12px;padding:28px}
|
|
351
|
+
h1{font-size:20px;margin:0 0 16px}
|
|
352
|
+
p{margin:0 0 12px}
|
|
353
|
+
.muted{color:var(--muted)}
|
|
354
|
+
.account{padding:8px 10px;border:1px solid var(--border);border-radius:8px;font-size:14px}
|
|
355
|
+
.account .switch{display:block;margin-top:4px}
|
|
356
|
+
button:disabled{opacity:.6;cursor:default}
|
|
357
|
+
.target strong{overflow-wrap:anywhere}
|
|
358
|
+
.warning{color:var(--muted);font-size:13px;border-left:3px solid var(--border);padding-left:10px}
|
|
359
|
+
.notice{color:var(--notice)}
|
|
360
|
+
label{display:block;margin:0 0 12px;font-size:13px;color:var(--muted)}
|
|
361
|
+
label.inline{display:flex;gap:8px;align-items:center}
|
|
362
|
+
input:not([type=checkbox]){display:block;width:100%;margin-top:4px;padding:9px 10px;border:1px solid var(--border);border-radius:8px;background:transparent;color:var(--text);font:inherit}
|
|
363
|
+
.actions{display:flex;gap:10px;justify-content:flex-end;margin-top:20px}
|
|
364
|
+
button{padding:9px 16px;border-radius:8px;border:1px solid var(--border);background:transparent;color:var(--text);font:inherit;cursor:pointer}
|
|
365
|
+
button.primary{background:var(--primary);border-color:var(--primary);color:var(--primary-text)}
|
|
366
|
+
form#lt-mcp-password button,form#lt-mcp-second-factor button{width:100%}
|
|
367
|
+
.other{margin-top:20px;padding-top:16px;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:8px;font-size:13px}
|
|
368
|
+
a{color:var(--primary)}
|
|
369
|
+
`;
|
|
@@ -3,59 +3,129 @@ import { Logger } from '@nestjs/common';
|
|
|
3
3
|
import { resolveServerUrls } from '../../../common/helpers/cookies.helper';
|
|
4
4
|
import { ConfigService } from '../../../common/services/config.service';
|
|
5
5
|
import { CoreAiMcpOAuthService } from '../services/core-ai-mcp-oauth.service';
|
|
6
|
-
import { setMountedMcpOAuth } from './ai-mcp-oauth.registry';
|
|
6
|
+
import { IMountedMcpOAuth, setMountedMcpOAuth } from './ai-mcp-oauth.registry';
|
|
7
|
+
|
|
8
|
+
const logger = new Logger('mountAiMcpOAuth');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What is mounted where, keyed on the Express instance (or, for an app without an HTTP adapter, on
|
|
12
|
+
* the object handed in). Shared by `CoreAiMcpOAuthInitializer` and {@link mountAiMcpOAuth}, so the
|
|
13
|
+
* router is mounted once per app no matter which of them gets there first. Holds the PENDING mount:
|
|
14
|
+
* building the router awaits a dynamic import, and a second caller arriving meanwhile must wait for
|
|
15
|
+
* the first instead of passing the check too.
|
|
16
|
+
*/
|
|
17
|
+
const mountedTargets = new WeakMap<object, Promise<IMountedMcpOAuth>>();
|
|
18
|
+
|
|
19
|
+
/** The app shape {@link mountAiMcpOAuth} accepts: a Nest application or anything with `get` + `use`. */
|
|
20
|
+
export interface IAiMcpOAuthMountTarget {
|
|
21
|
+
get: (token: any) => any;
|
|
22
|
+
getHttpAdapter?: () => { getInstance?: () => unknown } | undefined;
|
|
23
|
+
use: (...args: any[]) => any;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Where the OAuth router goes and what it advertises. */
|
|
27
|
+
export interface IAiMcpOAuthRouterOptions {
|
|
28
|
+
/** Path the IAM endpoints answer under, for the consent page's sign-in (see `IMountedMcpOAuth`). */
|
|
29
|
+
authBasePath?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Public base URL of this server, when it differs from `baseUrl` — the issuer and the host of
|
|
32
|
+
* the MCP resource. Blank, or anything but a string (config arrives as JSON), counts as absent.
|
|
33
|
+
*/
|
|
34
|
+
baseUrl?: unknown;
|
|
35
|
+
/** Where `baseUrl` came from, for error messages and the boot log. */
|
|
36
|
+
baseUrlSource?: string;
|
|
37
|
+
/** Path of the MCP endpoint, as Nest registered it. */
|
|
38
|
+
mcpPath: string;
|
|
39
|
+
}
|
|
7
40
|
|
|
8
41
|
/**
|
|
9
42
|
* Mount the MCP OAuth 2.1 router (`mcpAuthRouter`) on a NestJS application.
|
|
10
43
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
44
|
+
* @deprecated Since 11.43.0 `CoreModule` mounts the OAuth endpoints itself when `ai.mcp.oauth` is
|
|
45
|
+
* enabled (`CoreAiMcpOAuthInitializer`): during `app.init()`, behind the Nest middleware and ahead of
|
|
46
|
+
* Nest's not-found handler. Called on an app where that already happened, this is a no-op. The call
|
|
47
|
+
* this replaces — after `app.init()`, as the documentation used to say — never worked in a Nest
|
|
48
|
+
* application: Nest registers its not-found handler inside `init()`, so a router added afterwards
|
|
49
|
+
* was never reached and every OAuth endpoint answered 404.
|
|
17
50
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
51
|
+
* Still mounts when nothing mounted the router yet, e.g. on an app built without `CoreModule`'s AI
|
|
52
|
+
* module, or when called BEFORE `app.init()` (the router then sits in front of every Nest
|
|
53
|
+
* middleware). Options the automatic mount derives — `baseUrl` from `ai.mcp.oauthBaseUrl`, the MCP
|
|
54
|
+
* path from the controller's route — are ignored with a warning when the router is already mounted.
|
|
20
55
|
*
|
|
21
|
-
* The base URL must be the server's public URL: it becomes the OAuth issuer and
|
|
22
|
-
*
|
|
23
|
-
* Resolution order:
|
|
56
|
+
* The base URL must be the server's public URL: it becomes the OAuth issuer and every endpoint in
|
|
57
|
+
* the discovery metadata, and MCP clients follow those URLs. Resolution order:
|
|
24
58
|
*
|
|
25
59
|
* 1. `options.baseUrl`, when non-blank — for an issuer that differs from `baseUrl`
|
|
26
60
|
* 2. `baseUrl` from the server config (`NSC__BASE_URL` in deployed environments)
|
|
27
61
|
* 3. `http://localhost:3000` in `local` / `ci` / `e2e` only
|
|
28
62
|
* 4. otherwise the call throws — it never guesses
|
|
29
63
|
*
|
|
30
|
-
* Steps 2 and 3 are `resolveServerUrls()`, the resolver BetterAuth and CORS use, so
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* @example
|
|
36
|
-
* ```typescript
|
|
37
|
-
* // main.ts, after app.init()
|
|
38
|
-
* await mountAiMcpOAuth(app);
|
|
39
|
-
* ```
|
|
64
|
+
* Steps 2 and 3 are `resolveServerUrls()`, the resolver BetterAuth and CORS use, so the issuer
|
|
65
|
+
* agrees with the URL the rest of the server believes it has. Step 4 is the point: a deployed API
|
|
66
|
+
* that advertised `http://localhost:3000` made Claude Code try to register the client on the
|
|
67
|
+
* user's own machine (`ECONNREFUSED`).
|
|
40
68
|
*/
|
|
41
69
|
export async function mountAiMcpOAuth(
|
|
42
|
-
app:
|
|
70
|
+
app: IAiMcpOAuthMountTarget,
|
|
43
71
|
options: { baseUrl?: string; mcpPath?: string } = {},
|
|
44
72
|
): Promise<void> {
|
|
45
|
-
const
|
|
73
|
+
const target = mountTargetOf(app);
|
|
74
|
+
const existing = mountedTargets.get(target);
|
|
75
|
+
if (existing) {
|
|
76
|
+
reportRedundantMount(await existing, options);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
await mountAiMcpOAuthRouter(target, (router) => app.use(router), app.get(CoreAiMcpOAuthService), {
|
|
80
|
+
baseUrl: options.baseUrl,
|
|
81
|
+
baseUrlSource: 'options.baseUrl',
|
|
82
|
+
mcpPath: options.mcpPath ?? '/ai/mcp',
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Builds the OAuth router for `oauthService` and mounts it with `use` — once per `target`.
|
|
88
|
+
*
|
|
89
|
+
* Returns what is mounted on `target`: the new mount, or the one already there (then `use` is not
|
|
90
|
+
* called). The mount is recorded for `setupSwagger()` and for the MCP controller's 401 answer.
|
|
91
|
+
*/
|
|
92
|
+
export async function mountAiMcpOAuthRouter(
|
|
93
|
+
target: object,
|
|
94
|
+
use: (router: unknown) => void,
|
|
95
|
+
oauthService: CoreAiMcpOAuthService,
|
|
96
|
+
options: IAiMcpOAuthRouterOptions,
|
|
97
|
+
): Promise<IMountedMcpOAuth> {
|
|
98
|
+
const existing = mountedTargets.get(target);
|
|
99
|
+
if (existing) {
|
|
100
|
+
return existing;
|
|
101
|
+
}
|
|
102
|
+
const pending = mountRouter(use, oauthService, options);
|
|
103
|
+
mountedTargets.set(target, pending);
|
|
104
|
+
try {
|
|
105
|
+
return await pending;
|
|
106
|
+
} catch (error) {
|
|
107
|
+
// A failed mount mounted nothing; a later attempt must not inherit the failure.
|
|
108
|
+
mountedTargets.delete(target);
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Resolves the issuer, builds the router and mounts it — the part {@link mountAiMcpOAuthRouter} runs once. */
|
|
114
|
+
async function mountRouter(
|
|
115
|
+
use: (router: unknown) => void,
|
|
116
|
+
oauthService: CoreAiMcpOAuthService,
|
|
117
|
+
options: IAiMcpOAuthRouterOptions,
|
|
118
|
+
): Promise<IMountedMcpOAuth> {
|
|
119
|
+
const { baseUrl, source } = resolveMcpOAuthBaseUrl(options.baseUrl, options.baseUrlSource ?? 'options.baseUrl');
|
|
46
120
|
const issuerUrl = parseAbsoluteUrl(baseUrl, source);
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
|
|
121
|
+
const resourceServerUrl = new URL(`${baseUrl.replace(/\/$/, '')}${options.mcpPath}`);
|
|
122
|
+
const { getOAuthProtectedResourceMetadataUrl, mcpAuthRouter } =
|
|
123
|
+
await import('@modelcontextprotocol/sdk/server/auth/router.js');
|
|
124
|
+
const provider = oauthService.buildOAuthProvider() as any;
|
|
50
125
|
|
|
51
126
|
let router: unknown;
|
|
52
|
-
const provider = oauthService.buildOAuthProvider() as any;
|
|
53
127
|
try {
|
|
54
|
-
router = mcpAuthRouter({
|
|
55
|
-
issuerUrl,
|
|
56
|
-
provider,
|
|
57
|
-
resourceServerUrl: new URL(`${baseUrl.replace(/\/$/, '')}${mcpPath}`),
|
|
58
|
-
});
|
|
128
|
+
router = mcpAuthRouter({ issuerUrl, provider, resourceServerUrl });
|
|
59
129
|
} catch (error) {
|
|
60
130
|
// The SDK's refusals ("Issuer URL must be HTTPS") do not say which URL they refused. Now
|
|
61
131
|
// that the URL can come from config rather than from the call site, that is the one thing
|
|
@@ -66,24 +136,60 @@ export async function mountAiMcpOAuth(
|
|
|
66
136
|
});
|
|
67
137
|
}
|
|
68
138
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
139
|
+
use(router);
|
|
140
|
+
const mounted: IMountedMcpOAuth = {
|
|
141
|
+
authBasePath: options.authBasePath,
|
|
142
|
+
baseUrl: baseUrl.replace(/\/$/, ''),
|
|
143
|
+
issuerUrl: issuerUrl.href,
|
|
144
|
+
mcpPath: options.mcpPath,
|
|
145
|
+
// The same conditions mcpAuthRouter() uses to decide whether /register and /revoke exist.
|
|
73
146
|
registration: !!provider?.clientsStore?.registerClient,
|
|
147
|
+
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(resourceServerUrl),
|
|
148
|
+
resourceUrl: resourceServerUrl.href,
|
|
74
149
|
revocation: !!provider?.revokeToken,
|
|
75
|
-
}
|
|
76
|
-
|
|
150
|
+
};
|
|
151
|
+
setMountedMcpOAuth(mounted);
|
|
152
|
+
logger.log(`MCP OAuth issuer: ${issuerUrl.href} (from ${source}), resource: ${resourceServerUrl.href}`);
|
|
153
|
+
return mounted;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The Express instance behind a Nest application, or the object itself for any other shape. */
|
|
157
|
+
function mountTargetOf(app: IAiMcpOAuthMountTarget): object {
|
|
158
|
+
const instance = app.getHttpAdapter?.()?.getInstance?.();
|
|
159
|
+
return (typeof instance === 'object' && instance !== null) || typeof instance === 'function'
|
|
160
|
+
? (instance as object)
|
|
161
|
+
: app;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A second mount on the same app changes nothing — say so, and name what was ignored. */
|
|
165
|
+
function reportRedundantMount(mounted: IMountedMcpOAuth, options: { baseUrl?: string; mcpPath?: string }): void {
|
|
166
|
+
const ignored: string[] = [];
|
|
167
|
+
const baseUrl = options.baseUrl?.trim().replace(/\/$/, '');
|
|
168
|
+
if (baseUrl && baseUrl !== mounted.baseUrl) {
|
|
169
|
+
ignored.push(`baseUrl "${options.baseUrl}" (mounted: "${mounted.baseUrl}"; set ai.mcp.oauthBaseUrl instead)`);
|
|
170
|
+
}
|
|
171
|
+
if (options.mcpPath && options.mcpPath !== mounted.mcpPath) {
|
|
172
|
+
ignored.push(
|
|
173
|
+
`mcpPath "${options.mcpPath}" (mounted: "${mounted.mcpPath}", derived from the MCP controller's route)`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (ignored.length) {
|
|
177
|
+
logger.warn(`The MCP OAuth router is already mounted; this call changed nothing and ignored ${ignored.join(', ')}`);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
logger.log(
|
|
181
|
+
'The MCP OAuth router is already mounted (CoreModule mounts it when ai.mcp.oauth is enabled); this call can be removed',
|
|
182
|
+
);
|
|
77
183
|
}
|
|
78
184
|
|
|
79
185
|
/**
|
|
80
186
|
* Resolves the issuer base URL. A blank `explicit` value counts as absent, because
|
|
81
187
|
* `BASE_URL=` in an env file yields `''`, not `undefined`.
|
|
82
188
|
*/
|
|
83
|
-
function resolveMcpOAuthBaseUrl(explicit:
|
|
84
|
-
const given = explicit
|
|
189
|
+
function resolveMcpOAuthBaseUrl(explicit: unknown, explicitSource: string): { baseUrl: string; source: string } {
|
|
190
|
+
const given = typeof explicit === 'string' ? explicit.trim() : undefined;
|
|
85
191
|
if (given) {
|
|
86
|
-
return { baseUrl: given, source:
|
|
192
|
+
return { baseUrl: given, source: explicitSource };
|
|
87
193
|
}
|
|
88
194
|
|
|
89
195
|
const config = ConfigService.configFastButReadOnly;
|
|
@@ -98,7 +204,7 @@ function resolveMcpOAuthBaseUrl(explicit: string | undefined): { baseUrl: string
|
|
|
98
204
|
|
|
99
205
|
throw new Error(
|
|
100
206
|
`mountAiMcpOAuth: no public server URL for the OAuth issuer (env: ${config?.env ?? 'unset'}). ` +
|
|
101
|
-
'Set `baseUrl` in the server config (NSC__BASE_URL) or
|
|
207
|
+
'Set `baseUrl` in the server config (NSC__BASE_URL) or `ai.mcp.oauthBaseUrl`. ' +
|
|
102
208
|
'A guessed localhost URL would send MCP clients to their own machine.',
|
|
103
209
|
);
|
|
104
210
|
}
|
|
@@ -1,29 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* What
|
|
2
|
+
* What is mounted of the MCP OAuth endpoints, for `setupSwagger()` and the MCP controller.
|
|
3
3
|
*
|
|
4
4
|
* The MCP OAuth endpoints are an Express router mounted with `app.use()`, not Nest controllers, so
|
|
5
5
|
* `@nestjs/swagger` cannot see them. The mount records here what it put in place — only then are the
|
|
6
|
-
* endpoints documented,
|
|
6
|
+
* endpoints documented, and only then does the MCP endpoint's 401 point clients at the metadata.
|
|
7
7
|
*
|
|
8
8
|
* Imports nothing on purpose: read by the common Swagger helper without pulling in the AI module.
|
|
9
9
|
*/
|
|
10
10
|
export interface IMountedMcpOAuth {
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Path the IAM endpoints answer under (`/iam`, or below a global prefix) — where the consent page
|
|
13
|
+
* signs users in. Unset when the mount could not tell (a manual `mountAiMcpOAuth()`).
|
|
14
|
+
*/
|
|
15
|
+
authBasePath?: string;
|
|
16
|
+
/** Public base URL of the server the router describes, without a trailing slash */
|
|
17
|
+
baseUrl: string;
|
|
18
|
+
/** OAuth issuer, as the discovery metadata states it */
|
|
19
|
+
issuerUrl: string;
|
|
20
|
+
/** Path of the protected MCP resource (`/ai/mcp`, or below a global prefix) */
|
|
12
21
|
mcpPath: string;
|
|
13
22
|
/** Whether `/register` (dynamic client registration) is mounted */
|
|
14
23
|
registration: boolean;
|
|
24
|
+
/** URL of the protected resource metadata (RFC 9728) — what a 401 names in `resource_metadata` */
|
|
25
|
+
resourceMetadataUrl: string;
|
|
26
|
+
/** URL of the protected MCP resource */
|
|
27
|
+
resourceUrl: string;
|
|
15
28
|
/** Whether `/revoke` is mounted */
|
|
16
29
|
revocation: boolean;
|
|
17
30
|
}
|
|
18
31
|
|
|
19
32
|
let mounted: IMountedMcpOAuth | undefined;
|
|
20
33
|
|
|
21
|
-
/** Records the mounted MCP OAuth endpoints (called by
|
|
34
|
+
/** Records the mounted MCP OAuth endpoints (called by the mount). */
|
|
22
35
|
export function setMountedMcpOAuth(value: IMountedMcpOAuth | undefined): void {
|
|
23
36
|
mounted = value;
|
|
24
37
|
}
|
|
25
38
|
|
|
26
|
-
/** The mounted MCP OAuth endpoints, or `undefined` when
|
|
39
|
+
/** The mounted MCP OAuth endpoints, or `undefined` when nothing mounted them. */
|
|
27
40
|
export function getMountedMcpOAuth(): IMountedMcpOAuth | undefined {
|
|
28
41
|
return mounted;
|
|
29
42
|
}
|