@vantageos/mcp-boilerplate 0.2.1 → 0.2.3
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-plugin/plugin.json +1 -1
- package/CHANGELOG.md +90 -0
- package/README.fr.md +70 -2
- package/README.md +64 -2
- package/dist/envKnobs.d.ts +14 -0
- package/dist/envKnobs.js +54 -0
- package/dist/http.js +4 -20
- package/dist/httpApp.js +17 -0
- package/dist/locales/en.json +5 -1
- package/dist/locales/fr.json +5 -1
- package/dist/nodeAdapter.d.ts +20 -0
- package/dist/nodeAdapter.js +71 -0
- package/dist/oauth/authorizationServer.d.ts +21 -1
- package/dist/oauth/authorizationServer.js +130 -12
- package/dist/oauth/clerk.d.ts +5 -1
- package/dist/oauth/clerk.js +29 -11
- package/dist/oauth/clientAssertion.d.ts +28 -0
- package/dist/oauth/clientAssertion.js +106 -0
- package/dist/oauth/clientRegistry.d.ts +28 -1
- package/dist/oauth/clientRegistry.js +139 -43
- package/dist/oauth/codeStore.d.ts +19 -1
- package/dist/oauth/codeStore.js +28 -3
- package/dist/oauth/picker.d.ts +6 -1
- package/dist/oauth/picker.js +8 -2
- package/dist/oauth/settings.d.ts +15 -0
- package/dist/oauth/settings.js +50 -3
- package/dist/ui/uiResource.d.ts +1 -1
- package/dist/ui/uiResource.js +1 -1
- package/dist/ui-bundle/widget.html +9 -49
- package/docs/oauth-stores.md +12 -1
- package/examples/oauth.env.example +10 -0
- package/package.json +5 -6
- package/plugin.json +1 -1
|
@@ -12,15 +12,38 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { lookup } from "node:dns/promises";
|
|
14
14
|
import { BlockList, isIP } from "node:net";
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Bounded in-memory registry. DCR is unauthenticated, so it can never grow without limit and
|
|
17
|
+
* never stops accepting registrations either: expired clients are swept on every registration,
|
|
18
|
+
* and at the cap the oldest client is evicted (an evicted client re-registers).
|
|
19
|
+
*/
|
|
20
|
+
export function createInMemoryClientRegistry(options = {}) {
|
|
21
|
+
const maxClients = options.maxClients ?? 10_000;
|
|
22
|
+
const ttlMs = (options.ttlSeconds ?? 30 * 24 * 3600) * 1000;
|
|
23
|
+
const now = options.now ?? (() => Date.now());
|
|
16
24
|
const clients = new Map();
|
|
25
|
+
const expired = (client) => client.issuedAt * 1000 + ttlMs <= now();
|
|
17
26
|
return {
|
|
18
27
|
async get(clientId) {
|
|
19
|
-
|
|
28
|
+
const client = clients.get(clientId);
|
|
29
|
+
if (!client)
|
|
30
|
+
return null;
|
|
31
|
+
if (expired(client)) {
|
|
32
|
+
clients.delete(clientId);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return client;
|
|
20
36
|
},
|
|
21
37
|
async register(client) {
|
|
22
|
-
|
|
23
|
-
|
|
38
|
+
for (const [id, row] of clients)
|
|
39
|
+
if (expired(row))
|
|
40
|
+
clients.delete(id);
|
|
41
|
+
clients.delete(client.clientId); // re-registering moves it to the newest position
|
|
42
|
+
while (clients.size >= maxClients) {
|
|
43
|
+
const oldest = clients.keys().next();
|
|
44
|
+
if (oldest.done)
|
|
45
|
+
break;
|
|
46
|
+
clients.delete(oldest.value);
|
|
24
47
|
}
|
|
25
48
|
clients.set(client.clientId, client);
|
|
26
49
|
},
|
|
@@ -73,6 +96,7 @@ const BLOCKED_V6 = [
|
|
|
73
96
|
["fe80::", 10], // link-local
|
|
74
97
|
["ff00::", 8], // multicast
|
|
75
98
|
["64:ff9b:1::", 48], // local-use NAT64 (RFC 8215)
|
|
99
|
+
["2001::", 32], // Teredo (RFC 4380): embeds a server and an obfuscated client IPv4
|
|
76
100
|
];
|
|
77
101
|
/** The v4 prefix `addr/len` written as a prefix under an IPv6 embedding of 96 leading bits. */
|
|
78
102
|
function embedV4(v4, len, prefix6) {
|
|
@@ -81,18 +105,58 @@ function embedV4(v4, len, prefix6) {
|
|
|
81
105
|
const lo = ((c << 8) | d).toString(16);
|
|
82
106
|
return [`${prefix6}${hi}:${lo}`, 96 + len];
|
|
83
107
|
}
|
|
108
|
+
/** 6to4 (RFC 3056): the v4 prefix `addr/len` sits in bits 16-47, under 2002::/16. */
|
|
109
|
+
function embedSixToFour(v4, len) {
|
|
110
|
+
const [a, b, c, d] = v4.split(".").map(Number);
|
|
111
|
+
const hi = ((a << 8) | b).toString(16);
|
|
112
|
+
const lo = ((c << 8) | d).toString(16);
|
|
113
|
+
return [`2002:${hi}:${lo}::`, 16 + len];
|
|
114
|
+
}
|
|
84
115
|
const blockList = new BlockList();
|
|
85
116
|
for (const [addr, len] of BLOCKED_V4) {
|
|
86
117
|
blockList.addSubnet(addr, len, "ipv4");
|
|
87
|
-
// IPv4-mapped (::ffff:a.b.c.d, any textual form)
|
|
88
|
-
// v4 address is held to the same
|
|
89
|
-
|
|
118
|
+
// IPv4-mapped (::ffff:a.b.c.d, any textual form), NAT64 (64:ff9b::a.b.c.d) and
|
|
119
|
+
// IPv4-translated (::ffff:0:a.b.c.d, RFC 2765): the embedded v4 address is held to the same
|
|
120
|
+
// list, in the last 32 bits of a /96.
|
|
121
|
+
for (const prefix6 of ["::ffff:", "64:ff9b::", "::ffff:0:"]) {
|
|
90
122
|
const [sub, bits] = embedV4(addr, len, prefix6);
|
|
91
123
|
blockList.addSubnet(sub, bits, "ipv6");
|
|
92
124
|
}
|
|
125
|
+
// 6to4 (2002::/16): the v4 address is in bits 16-47.
|
|
126
|
+
const [sixToFour, sixToFourBits] = embedSixToFour(addr, len);
|
|
127
|
+
blockList.addSubnet(sixToFour, sixToFourBits, "ipv6");
|
|
93
128
|
}
|
|
94
129
|
for (const [addr, len] of BLOCKED_V6)
|
|
95
130
|
blockList.addSubnet(addr, len, "ipv6");
|
|
131
|
+
/**
|
|
132
|
+
* The eight 16-bit groups of a SYNTACTICALLY VALID IPv6 literal (`isIP` has already accepted
|
|
133
|
+
* it). Only used to read bits that no BlockList prefix can express.
|
|
134
|
+
*/
|
|
135
|
+
function ipv6Groups(ip) {
|
|
136
|
+
let text = ip.toLowerCase();
|
|
137
|
+
const dotted = /(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(text);
|
|
138
|
+
if (dotted) {
|
|
139
|
+
const [, a, b, c, d] = dotted.map(Number);
|
|
140
|
+
text = `${text.slice(0, dotted.index)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
|
|
141
|
+
}
|
|
142
|
+
const [head = "", tail] = text.split("::");
|
|
143
|
+
const left = head ? head.split(":") : [];
|
|
144
|
+
const right = tail === undefined ? [] : tail ? tail.split(":") : [];
|
|
145
|
+
const fill = tail === undefined ? [] : Array(8 - left.length - right.length).fill("0");
|
|
146
|
+
return [...left, ...fill, ...right].map((g) => Number.parseInt(g, 16));
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* ISATAP (RFC 5214): bits 64-95 are 0000:5efe (or 0200:5efe, universal/local variant) under ANY
|
|
150
|
+
* prefix, and the low 32 bits are an embedded IPv4. No prefix expresses that, so it is read
|
|
151
|
+
* from the groups; the embedded v4 is held to the same blocked list.
|
|
152
|
+
*/
|
|
153
|
+
function isatapEmbeddedV4(ip) {
|
|
154
|
+
const g = ipv6Groups(ip);
|
|
155
|
+
if (g.length !== 8 || (g[4] !== 0x0000 && g[4] !== 0x0200) || g[5] !== 0x5efe)
|
|
156
|
+
return null;
|
|
157
|
+
const [hi, lo] = [g[6], g[7]];
|
|
158
|
+
return `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;
|
|
159
|
+
}
|
|
96
160
|
/** True only for a globally routable address. Fail-closed: anything unrecognised is refused. */
|
|
97
161
|
export function isPublicAddress(ip) {
|
|
98
162
|
if (typeof ip !== "string" || ip.includes("%"))
|
|
@@ -101,6 +165,11 @@ export function isPublicAddress(ip) {
|
|
|
101
165
|
if (version !== 4 && version !== 6)
|
|
102
166
|
return false;
|
|
103
167
|
try {
|
|
168
|
+
if (version === 6) {
|
|
169
|
+
const isatap = isatapEmbeddedV4(ip);
|
|
170
|
+
if (isatap !== null && blockList.check(isatap, "ipv4"))
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
104
173
|
return !blockList.check(ip, version === 4 ? "ipv4" : "ipv6");
|
|
105
174
|
}
|
|
106
175
|
catch {
|
|
@@ -139,6 +208,38 @@ async function readCappedText(res, maxBytes) {
|
|
|
139
208
|
export function isUrlShapedClientId(clientId) {
|
|
140
209
|
return /^https?:\/\//i.test(clientId);
|
|
141
210
|
}
|
|
211
|
+
/**
|
|
212
|
+
* The one SSRF-guarded fetch shared by the CIMD document and the client's JWKS: https only,
|
|
213
|
+
* public addresses only, no redirects, size and time capped. Returns the parsed JSON, or null
|
|
214
|
+
* for ANY failure.
|
|
215
|
+
*/
|
|
216
|
+
export async function fetchGuardedJson(url, options = {}) {
|
|
217
|
+
const { fetchImpl = fetch, resolveHost = defaultResolveHost } = options;
|
|
218
|
+
const maxBytes = options.maxBytes ?? 16 * 1024;
|
|
219
|
+
const timeoutMs = options.timeoutMs ?? 5000;
|
|
220
|
+
try {
|
|
221
|
+
if (url.protocol !== "https:" || url.username || url.password || url.hash)
|
|
222
|
+
return null;
|
|
223
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
224
|
+
const addresses = isIP(host) ? [host] : await resolveHost(host);
|
|
225
|
+
if (addresses.length === 0 || !addresses.every(isPublicAddress))
|
|
226
|
+
return null;
|
|
227
|
+
const res = await fetchImpl(url, {
|
|
228
|
+
redirect: "error",
|
|
229
|
+
headers: { Accept: "application/json" },
|
|
230
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
231
|
+
});
|
|
232
|
+
if (!res.ok)
|
|
233
|
+
return null;
|
|
234
|
+
const text = await readCappedText(res, maxBytes);
|
|
235
|
+
if (text === null)
|
|
236
|
+
return null;
|
|
237
|
+
return JSON.parse(text);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
142
243
|
/**
|
|
143
244
|
* Fetches and validates a Client ID Metadata Document. Returns null for ANY failure — an
|
|
144
245
|
* unreachable, oversized, malformed, private-addressed or self-contradicting document is an
|
|
@@ -148,10 +249,7 @@ export function isUrlShapedClientId(clientId) {
|
|
|
148
249
|
* the fetch resolves it again, so a DNS-rebinding window exists. A hostile-network deployment
|
|
149
250
|
* injects a `fetchImpl` that pins the validated address (for example an undici dispatcher).
|
|
150
251
|
*/
|
|
151
|
-
export async function
|
|
152
|
-
const { fetchImpl = fetch, resolveHost = defaultResolveHost } = options;
|
|
153
|
-
const maxBytes = options.maxBytes ?? 16 * 1024;
|
|
154
|
-
const timeoutMs = options.timeoutMs ?? 5000;
|
|
252
|
+
export async function resolveCimdDocument(clientId, options = {}) {
|
|
155
253
|
let url;
|
|
156
254
|
try {
|
|
157
255
|
url = new URL(clientId);
|
|
@@ -165,39 +263,37 @@ export async function resolveCimdClient(clientId, options = {}) {
|
|
|
165
263
|
return null; // the spec requires a path
|
|
166
264
|
if (url.href !== clientId)
|
|
167
265
|
return null; // client_id is compared as a string; no normalisation
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const uris = doc.redirect_uris;
|
|
190
|
-
if (!Array.isArray(uris) || uris.length === 0 || uris.length > 20)
|
|
191
|
-
return null;
|
|
192
|
-
if (!uris.every(isAllowedRedirectUri))
|
|
193
|
-
return null;
|
|
194
|
-
return {
|
|
266
|
+
const parsed = await fetchGuardedJson(url, options);
|
|
267
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
268
|
+
return null;
|
|
269
|
+
const doc = parsed;
|
|
270
|
+
if (doc.client_id !== clientId)
|
|
271
|
+
return null;
|
|
272
|
+
// The document must offer "none", either as its method or among the methods it supports (a
|
|
273
|
+
// client such as ChatGPT declares private_key_jwt and lists "none"). A client that sends no
|
|
274
|
+
// assertion is a public client and PKCE carries the proof; one that does is verified at /token.
|
|
275
|
+
const method = doc.token_endpoint_auth_method;
|
|
276
|
+
const supported = doc.token_endpoint_auth_methods_supported;
|
|
277
|
+
const acceptsNone = Array.isArray(supported) && supported.includes("none");
|
|
278
|
+
if (method !== undefined && method !== "none" && !acceptsNone)
|
|
279
|
+
return null;
|
|
280
|
+
const uris = doc.redirect_uris;
|
|
281
|
+
if (!Array.isArray(uris) || uris.length === 0 || uris.length > 20)
|
|
282
|
+
return null;
|
|
283
|
+
if (!uris.every(isAllowedRedirectUri))
|
|
284
|
+
return null;
|
|
285
|
+
return {
|
|
286
|
+
client: {
|
|
195
287
|
clientId,
|
|
196
288
|
redirectUris: uris,
|
|
197
289
|
...(typeof doc.client_name === "string" ? { clientName: doc.client_name.slice(0, 200) } : {}),
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
290
|
+
},
|
|
291
|
+
...(typeof doc.jwks_uri === "string" ? { jwksUri: doc.jwks_uri } : {}),
|
|
292
|
+
...(typeof doc.token_endpoint_auth_signing_alg === "string"
|
|
293
|
+
? { signingAlg: doc.token_endpoint_auth_signing_alg }
|
|
294
|
+
: {}),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
export async function resolveCimdClient(clientId, options = {}) {
|
|
298
|
+
return (await resolveCimdDocument(clientId, options))?.client ?? null;
|
|
203
299
|
}
|
|
@@ -8,4 +8,22 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import type { AuthorizationCodeRecord, AuthorizationCodeStore, ConsumeCodeResult } from "@vantageos/cloud-identity";
|
|
10
10
|
export type { AuthorizationCodeRecord, AuthorizationCodeStore, ConsumeCodeResult };
|
|
11
|
-
export
|
|
11
|
+
export interface InMemoryCodeStoreOptions {
|
|
12
|
+
now?: () => number;
|
|
13
|
+
/** Cap on live codes; past it the OLDEST live code is dropped. Default 10 000. */
|
|
14
|
+
maxEntries?: number;
|
|
15
|
+
}
|
|
16
|
+
export type InMemoryCodeStore = AuthorizationCodeStore & {
|
|
17
|
+
/** Sizes of the two internal sets, for tests and metrics. */
|
|
18
|
+
stats(): {
|
|
19
|
+
live: number;
|
|
20
|
+
used: number;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Bounded in-memory adapter. A code that was never redeemed is dropped once past its
|
|
25
|
+
* `expiresAt`; a redeemed code's "used" marker is kept only until that same instant, after
|
|
26
|
+
* which cloud-identity refuses the code as expired anyway. Both sweeps run on `put`, so memory
|
|
27
|
+
* stays proportional to the codes issued within one code lifetime (60 s by default).
|
|
28
|
+
*/
|
|
29
|
+
export declare function createInMemoryCodeStore(options?: InMemoryCodeStoreOptions): InMemoryCodeStore;
|
package/dist/oauth/codeStore.js
CHANGED
|
@@ -6,11 +6,35 @@
|
|
|
6
6
|
* The in-memory adapter is NOT for a multi-instance deployment: codes live in one process,
|
|
7
7
|
* so a second instance cannot redeem a code issued by the first.
|
|
8
8
|
*/
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Bounded in-memory adapter. A code that was never redeemed is dropped once past its
|
|
11
|
+
* `expiresAt`; a redeemed code's "used" marker is kept only until that same instant, after
|
|
12
|
+
* which cloud-identity refuses the code as expired anyway. Both sweeps run on `put`, so memory
|
|
13
|
+
* stays proportional to the codes issued within one code lifetime (60 s by default).
|
|
14
|
+
*/
|
|
15
|
+
export function createInMemoryCodeStore(options = {}) {
|
|
16
|
+
const now = options.now ?? (() => Date.now());
|
|
17
|
+
const maxEntries = options.maxEntries ?? 10_000;
|
|
10
18
|
const live = new Map();
|
|
11
|
-
const used = new
|
|
19
|
+
const used = new Map(); // codeHash -> expiresAt of the redeemed code
|
|
20
|
+
const sweep = () => {
|
|
21
|
+
const t = now();
|
|
22
|
+
for (const [hash, record] of live)
|
|
23
|
+
if (record.expiresAt <= t)
|
|
24
|
+
live.delete(hash);
|
|
25
|
+
for (const [hash, expiresAt] of used)
|
|
26
|
+
if (expiresAt <= t)
|
|
27
|
+
used.delete(hash);
|
|
28
|
+
};
|
|
12
29
|
return {
|
|
13
30
|
async put(record) {
|
|
31
|
+
sweep();
|
|
32
|
+
while (live.size >= maxEntries) {
|
|
33
|
+
const oldest = live.keys().next();
|
|
34
|
+
if (oldest.done)
|
|
35
|
+
break;
|
|
36
|
+
live.delete(oldest.value);
|
|
37
|
+
}
|
|
14
38
|
live.set(record.codeHash, record);
|
|
15
39
|
},
|
|
16
40
|
// Atomic by construction: this body has no await between the read and the delete, and
|
|
@@ -19,10 +43,11 @@ export function createInMemoryCodeStore() {
|
|
|
19
43
|
const record = live.get(codeHash);
|
|
20
44
|
if (record) {
|
|
21
45
|
live.delete(codeHash);
|
|
22
|
-
used.
|
|
46
|
+
used.set(codeHash, record.expiresAt);
|
|
23
47
|
return { status: "ok", record };
|
|
24
48
|
}
|
|
25
49
|
return used.has(codeHash) ? { status: "already-used" } : { status: "unknown" };
|
|
26
50
|
},
|
|
51
|
+
stats: () => ({ live: live.size, used: used.size }),
|
|
27
52
|
};
|
|
28
53
|
}
|
package/dist/oauth/picker.d.ts
CHANGED
|
@@ -8,4 +8,9 @@ import type { OrgPickerModel } from "@vantageos/cloud-identity";
|
|
|
8
8
|
import { type McpLocale } from "../i18n.js";
|
|
9
9
|
/** CSP for the picker page: no scripts, no external assets, one hashed inline style. */
|
|
10
10
|
export declare function pickerContentSecurityPolicy(): Promise<string>;
|
|
11
|
-
|
|
11
|
+
/** What the consent page must tell the person beyond the model: whether the client is trusted. */
|
|
12
|
+
export interface ConsentContext {
|
|
13
|
+
/** True for a Dynamic Client Registration client: it vouched for itself, nobody else did. */
|
|
14
|
+
unverified: boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare function renderOrgPicker(model: OrgPickerModel, locale: McpLocale, consent: ConsentContext): string;
|
package/dist/oauth/picker.js
CHANGED
|
@@ -27,7 +27,11 @@ export async function pickerContentSecurityPolicy() {
|
|
|
27
27
|
"frame-ancestors 'none'",
|
|
28
28
|
].join("; ");
|
|
29
29
|
}
|
|
30
|
-
export function renderOrgPicker(model, locale) {
|
|
30
|
+
export function renderOrgPicker(model, locale, consent) {
|
|
31
|
+
const destination = escapeHtml(model.redirectOrigin);
|
|
32
|
+
const unverified = consent.unverified
|
|
33
|
+
? `<p role="alert"><strong>${escapeHtml(t(locale, "oauth.pickerUnverified"))}</strong></p>\n`
|
|
34
|
+
: "";
|
|
31
35
|
const clientName = model.client.clientName ?? model.client.clientId;
|
|
32
36
|
const only = model.organizations.length === 1;
|
|
33
37
|
const orgs = model.organizations
|
|
@@ -48,7 +52,9 @@ export function renderOrgPicker(model, locale) {
|
|
|
48
52
|
<main>
|
|
49
53
|
<h1>${title}</h1>
|
|
50
54
|
<p>${intro}</p>
|
|
51
|
-
<
|
|
55
|
+
<p>${escapeHtml(t(locale, "oauth.pickerDestination"))} <strong>${destination}</strong><br>
|
|
56
|
+
${escapeHtml(t(locale, "oauth.pickerScope"))} <code>${escapeHtml(model.scope)}</code></p>
|
|
57
|
+
${unverified}<form method="post" action="/authorize/org">
|
|
52
58
|
<input type="hidden" name="state" value="${escapeHtml(model.state)}">
|
|
53
59
|
<input type="hidden" name="consentToken" value="${escapeHtml(model.consentToken ?? "")}">
|
|
54
60
|
<fieldset>
|
package/dist/oauth/settings.d.ts
CHANGED
|
@@ -14,8 +14,15 @@ export declare const OAUTH_ENV: {
|
|
|
14
14
|
readonly clerkSecretKey: "CLERK_SECRET_KEY";
|
|
15
15
|
readonly extraAuthorizedParties: "AUTHORIZE_AUTHORIZED_PARTIES";
|
|
16
16
|
readonly signingJwk: "MCP_OAUTH_SIGNING_JWK";
|
|
17
|
+
/** Number of trusted reverse proxies in front of this server (default 0). */
|
|
18
|
+
readonly trustedProxyCount: "MCP_TRUSTED_PROXY_COUNT";
|
|
19
|
+
/** Comma-separated browser origins allowed to call /register cross-origin (default: none). */
|
|
20
|
+
readonly registerCorsOrigins: "MCP_REGISTER_CORS_ORIGINS";
|
|
21
|
+
/** Exactly "1" lets NODE_ENV=production run on the in-memory stores (single instance only). */
|
|
22
|
+
readonly allowInMemoryStores: "MCP_ALLOW_IN_MEMORY_STORES";
|
|
17
23
|
};
|
|
18
24
|
/** Scope granted when a client names none: MCP access plus the OIDC subject. */
|
|
25
|
+
export declare const MCP_SCOPE = "mcp:full";
|
|
19
26
|
export declare const DEFAULT_SCOPE = "mcp:full openid";
|
|
20
27
|
export declare const SUPPORTED_SCOPES: readonly ["mcp:full", "openid", "email"];
|
|
21
28
|
export interface AuthorizeSettings {
|
|
@@ -46,3 +53,11 @@ export type AuthorizeSettingsResult = {
|
|
|
46
53
|
* secret key is then not required.
|
|
47
54
|
*/
|
|
48
55
|
export declare function readAuthorizeSettings(env: NodeJS.ProcessEnv, clerkCollaboratorsInjected?: boolean): Promise<AuthorizeSettingsResult>;
|
|
56
|
+
/**
|
|
57
|
+
* True when the configured Clerk instance is a DEVELOPMENT one (an `sk_test_` secret key, or a
|
|
58
|
+
* Frontend API domain under `accounts.dev`) while PUBLIC_BASE_URL is not loopback. A development
|
|
59
|
+
* instance's hosted sign-in redirects only to localhost or to the instance Home URL, so a remote
|
|
60
|
+
* server needs that Home URL pointed at it. Absent or malformed values answer `false`: this feeds
|
|
61
|
+
* a warning, and the readiness of the settings is decided by `readAuthorizeSettings`, not here.
|
|
62
|
+
*/
|
|
63
|
+
export declare function isDevClerkInstanceOnRemoteServer(env: NodeJS.ProcessEnv): boolean;
|
package/dist/oauth/settings.js
CHANGED
|
@@ -14,13 +14,31 @@ export const OAUTH_ENV = {
|
|
|
14
14
|
clerkSecretKey: "CLERK_SECRET_KEY",
|
|
15
15
|
extraAuthorizedParties: "AUTHORIZE_AUTHORIZED_PARTIES",
|
|
16
16
|
signingJwk: "MCP_OAUTH_SIGNING_JWK",
|
|
17
|
+
/** Number of trusted reverse proxies in front of this server (default 0). */
|
|
18
|
+
trustedProxyCount: "MCP_TRUSTED_PROXY_COUNT",
|
|
19
|
+
/** Comma-separated browser origins allowed to call /register cross-origin (default: none). */
|
|
20
|
+
registerCorsOrigins: "MCP_REGISTER_CORS_ORIGINS",
|
|
21
|
+
/** Exactly "1" lets NODE_ENV=production run on the in-memory stores (single instance only). */
|
|
22
|
+
allowInMemoryStores: "MCP_ALLOW_IN_MEMORY_STORES",
|
|
17
23
|
};
|
|
18
24
|
/** Scope granted when a client names none: MCP access plus the OIDC subject. */
|
|
19
|
-
export const
|
|
20
|
-
export const
|
|
25
|
+
export const MCP_SCOPE = "mcp:full";
|
|
26
|
+
export const DEFAULT_SCOPE = `${MCP_SCOPE} openid`;
|
|
27
|
+
export const SUPPORTED_SCOPES = [MCP_SCOPE, "openid", "email"];
|
|
28
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
29
|
+
/**
|
|
30
|
+
* The origin of a configured URL, or null when it is unusable: unparsable, not https, or http
|
|
31
|
+
* on a host that is not loopback. A cleartext issuer, sign-in or callback URL would put the
|
|
32
|
+
* session token, the authorization code or the signing-key set on the wire in the clear.
|
|
33
|
+
*/
|
|
21
34
|
function originOf(url) {
|
|
22
35
|
try {
|
|
23
|
-
|
|
36
|
+
const parsed = new URL(url);
|
|
37
|
+
if (parsed.protocol === "https:")
|
|
38
|
+
return parsed.origin;
|
|
39
|
+
if (parsed.protocol === "http:" && LOOPBACK_HOSTS.has(parsed.hostname))
|
|
40
|
+
return parsed.origin;
|
|
41
|
+
return null;
|
|
24
42
|
}
|
|
25
43
|
catch {
|
|
26
44
|
return null;
|
|
@@ -98,3 +116,32 @@ export async function readAuthorizeSettings(env, clerkCollaboratorsInjected = fa
|
|
|
98
116
|
},
|
|
99
117
|
};
|
|
100
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* True when the configured Clerk instance is a DEVELOPMENT one (an `sk_test_` secret key, or a
|
|
121
|
+
* Frontend API domain under `accounts.dev`) while PUBLIC_BASE_URL is not loopback. A development
|
|
122
|
+
* instance's hosted sign-in redirects only to localhost or to the instance Home URL, so a remote
|
|
123
|
+
* server needs that Home URL pointed at it. Absent or malformed values answer `false`: this feeds
|
|
124
|
+
* a warning, and the readiness of the settings is decided by `readAuthorizeSettings`, not here.
|
|
125
|
+
*/
|
|
126
|
+
export function isDevClerkInstanceOnRemoteServer(env) {
|
|
127
|
+
let baseHost;
|
|
128
|
+
try {
|
|
129
|
+
baseHost = new URL(env[OAUTH_ENV.publicBaseUrl] ?? "").hostname;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
if (baseHost === "" || LOOPBACK_HOSTS.has(baseHost) || LOOPBACK_HOSTS.has(`[${baseHost}]`)) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
const devKey = (env[OAUTH_ENV.clerkSecretKey] ?? "").startsWith("sk_test_");
|
|
138
|
+
let devDomain = false;
|
|
139
|
+
try {
|
|
140
|
+
const host = new URL(env[OAUTH_ENV.clerkIssuer] ?? "").hostname;
|
|
141
|
+
devDomain = host === "accounts.dev" || host.endsWith(".accounts.dev");
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
devDomain = false;
|
|
145
|
+
}
|
|
146
|
+
return devKey || devDomain;
|
|
147
|
+
}
|
package/dist/ui/uiResource.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { ExampleItem } from "../exampleItems.js";
|
|
|
3
3
|
export declare const ITEMS_UI_RESOURCE_URI = "ui://mcp-boilerplate/items-widget.html";
|
|
4
4
|
/**
|
|
5
5
|
* Reads the single-file bundle produced by `npm run build:ui`
|
|
6
|
-
* (
|
|
6
|
+
* (in-repo inlining plugin). Never silently swallowed: a missing bundle is a build defect, not
|
|
7
7
|
* a runtime fallback case. The error that reaches the MCP client names no filesystem path;
|
|
8
8
|
* the detail goes to stderr (server side) only.
|
|
9
9
|
*/
|
package/dist/ui/uiResource.js
CHANGED
|
@@ -33,7 +33,7 @@ function renderMarkdownFallback(items, locale) {
|
|
|
33
33
|
}
|
|
34
34
|
/**
|
|
35
35
|
* Reads the single-file bundle produced by `npm run build:ui`
|
|
36
|
-
* (
|
|
36
|
+
* (in-repo inlining plugin). Never silently swallowed: a missing bundle is a build defect, not
|
|
37
37
|
* a runtime fallback case. The error that reaches the MCP client names no filesystem path;
|
|
38
38
|
* the detail goes to stderr (server side) only.
|
|
39
39
|
*/
|