@vantageos/mcp-boilerplate 0.2.4 → 0.4.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-plugin/plugin.json +1 -1
- package/CHANGELOG.md +182 -0
- package/README.fr.md +318 -15
- package/README.md +302 -15
- package/dist/buildIdentity.d.ts +19 -0
- package/dist/buildIdentity.js +62 -0
- package/dist/declaredVariables.d.ts +205 -0
- package/dist/declaredVariables.js +451 -0
- package/dist/exampleItems.d.ts +15 -0
- package/dist/exampleItems.js +28 -1
- package/dist/httpApp.d.ts +17 -0
- package/dist/httpApp.js +22 -2
- package/dist/i18n.d.ts +10 -0
- package/dist/i18n.js +12 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +9 -0
- package/dist/lake/convex.d.ts +101 -0
- package/dist/lake/convex.js +224 -0
- package/dist/lake/reader.d.ts +88 -0
- package/dist/lake/reader.js +120 -0
- package/dist/locales/en.json +47 -0
- package/dist/locales/fr.json +47 -0
- package/dist/mcpServerFactory.d.ts +31 -14
- package/dist/mcpServerFactory.js +124 -48
- package/dist/oauth/authorizationServer.js +63 -18
- package/dist/oauth/clerk.d.ts +2 -0
- package/dist/oauth/clerk.js +15 -5
- package/dist/oauth/clientRegistry.d.ts +19 -4
- package/dist/oauth/clientRegistry.js +20 -10
- package/dist/oauth/picker.d.ts +5 -2
- package/dist/oauth/picker.js +34 -4
- package/dist/oauth/registerLimiter.d.ts +34 -0
- package/dist/oauth/registerLimiter.js +95 -0
- package/dist/oauth/settings.d.ts +2 -0
- package/dist/oauth/settings.js +2 -0
- package/dist/pagination.d.ts +66 -0
- package/dist/pagination.js +100 -0
- package/dist/servedToolRecords.d.ts +18 -0
- package/dist/servedToolRecords.js +10 -0
- package/dist/serverManifest.d.ts +43 -0
- package/dist/serverManifest.js +90 -0
- package/dist/stdio.js +8 -1
- package/dist/tenancy.d.ts +1 -1
- package/dist/toolRecord.d.ts +124 -0
- package/dist/toolRecord.js +114 -0
- package/dist/ui/uiResource.d.ts +11 -4
- package/dist/ui/uiResource.js +8 -4
- package/dist/upstream/clientCredentials.d.ts +106 -0
- package/dist/upstream/clientCredentials.js +283 -0
- package/dist/upstream/paginatedClient.d.ts +142 -0
- package/dist/upstream/paginatedClient.js +422 -0
- package/dist/verifiedPerson.d.ts +85 -0
- package/dist/verifiedPerson.js +112 -0
- package/docs/oauth-stores.md +2 -1
- package/examples/oauth.env.example +5 -0
- package/mcp-server.json +14 -0
- package/package.json +19 -4
- package/plugin.json +1 -1
- package/skills/connect-mcp-server/SKILL.md +5 -5
|
@@ -20,20 +20,24 @@
|
|
|
20
20
|
* FAIL-CLOSED: any missing configuration value answers 503 `temporarily_unavailable`.
|
|
21
21
|
* No refresh tokens are issued; access tokens live one hour.
|
|
22
22
|
*/
|
|
23
|
-
import { buildDiscoveryDocument, buildUserInfo, exchangeAuthorizationCode, oauthErrorFor, resumeAuthorize, startAuthorize,
|
|
23
|
+
import { buildDiscoveryDocument, buildUserInfo, exchangeAuthorizationCode, oauthErrorFor, resolveClerkSessionFromRequest, resumeAuthorize, startAuthorize, } from "@vantageos/cloud-identity";
|
|
24
24
|
import { getCookie } from "hono/cookie";
|
|
25
25
|
import { cors } from "hono/cors";
|
|
26
26
|
import { flagKnob, integerKnob, originListKnob } from "../envKnobs.js";
|
|
27
|
-
import { detectLocaleFromAcceptLanguage, t } from "../i18n.js";
|
|
27
|
+
import { detectLocale, detectLocaleFromAcceptLanguage, t } from "../i18n.js";
|
|
28
28
|
import { createClerkBackendCollaborators } from "./clerk.js";
|
|
29
29
|
import { ASSERTION_SIGNING_ALGS, createClientAssertionVerifier } from "./clientAssertion.js";
|
|
30
|
-
import { createInMemoryClientRegistry, isAllowedRedirectUri, isUrlShapedClientId, resolveCimdClient, } from "./clientRegistry.js";
|
|
30
|
+
import { RegistryFullError, createInMemoryClientRegistry, isAllowedRedirectUri, isUrlShapedClientId, resolveCimdClient, } from "./clientRegistry.js";
|
|
31
31
|
import { createInMemoryCodeStore } from "./codeStore.js";
|
|
32
32
|
import { pickerContentSecurityPolicy, renderOrgPicker } from "./picker.js";
|
|
33
|
+
import { createRegisterLimiter } from "./registerLimiter.js";
|
|
33
34
|
import { DEFAULT_SCOPE, OAUTH_ENV, SUPPORTED_SCOPES, isDevClerkInstanceOnRemoteServer, readAuthorizeSettings, } from "./settings.js";
|
|
34
35
|
import { ACCESS_TOKEN_TTL_SECONDS, createTokenIssuer } from "./tokenIssuer.js";
|
|
35
36
|
const REGISTER_LIMIT_PER_MINUTE = 20;
|
|
37
|
+
/** Default of OAUTH_ENV.registerLimitPer48: five /64 budgets for a whole /48. */
|
|
38
|
+
const REGISTER_LIMIT_PER_48_PER_MINUTE = 100;
|
|
36
39
|
const MAX_REDIRECT_URIS = 10;
|
|
40
|
+
const REGISTRY_FULL_RETRY_AFTER_SECONDS = 300;
|
|
37
41
|
function noStore(c) {
|
|
38
42
|
c.header("Cache-Control", "no-store");
|
|
39
43
|
c.header("Pragma", "no-cache");
|
|
@@ -84,11 +88,16 @@ export function createAuthorizationServer(deps = {}) {
|
|
|
84
88
|
const env = deps.env ?? process.env;
|
|
85
89
|
// Operator knobs are validated here, at construction: a bad value stops the boot.
|
|
86
90
|
const trustedProxyCount = integerKnob(env, OAUTH_ENV.trustedProxyCount, 0, 10) ?? 0;
|
|
91
|
+
const registerLimitPer48 = integerKnob(env, OAUTH_ENV.registerLimitPer48, 1) ?? REGISTER_LIMIT_PER_48_PER_MINUTE;
|
|
87
92
|
const registerOrigins = originListKnob(env, OAUTH_ENV.registerCorsOrigins);
|
|
88
93
|
const allowInMemory = flagKnob(env, OAUTH_ENV.allowInMemoryStores) === true;
|
|
89
94
|
const now = deps.now ?? (() => Date.now());
|
|
90
95
|
// Fail at boot, loudly, rather than lose every code and client on the first restart.
|
|
91
96
|
if (!deps.codeStore || !deps.clientRegistry) {
|
|
97
|
+
if (env.NODE_ENV !== "production" && !allowInMemory) {
|
|
98
|
+
// Development, test or unset: start, but never silently. One line, both languages.
|
|
99
|
+
console.warn(`[oauth] ${t("en", "oauth.inMemoryWarning").replace("{knob}", OAUTH_ENV.allowInMemoryStores)} / ${t("fr", "oauth.inMemoryWarning").replace("{knob}", OAUTH_ENV.allowInMemoryStores)}`);
|
|
100
|
+
}
|
|
92
101
|
if (env.NODE_ENV === "production") {
|
|
93
102
|
if (!allowInMemory) {
|
|
94
103
|
throw new Error(`NODE_ENV=production refuses the in-memory OAuth stores (codes and registered clients are lost on restart and not shared between instances). Inject durable adapters (docs/oauth-stores.md), or set ${OAUTH_ENV.allowInMemoryStores}=1 to accept a single-instance deployment that forgets them on restart.`);
|
|
@@ -177,6 +186,18 @@ export function createAuthorizationServer(deps = {}) {
|
|
|
177
186
|
error_description: t("en", "oauth.unavailable"),
|
|
178
187
|
}, 503);
|
|
179
188
|
};
|
|
189
|
+
/**
|
|
190
|
+
* The redirect URI(s) the picker's form may end up at. cloud-identity hands the page only the
|
|
191
|
+
* ORIGIN of the validated redirect_uri: for http(s) that is the destination itself; for a
|
|
192
|
+
* custom scheme the origin is the opaque "null", so the scheme is read from the client's
|
|
193
|
+
* registered URIs of that scheme (the validated one is among them).
|
|
194
|
+
*/
|
|
195
|
+
const redirectTargetsOf = async (model) => {
|
|
196
|
+
if (/^https?:\/\//.test(model.redirectOrigin))
|
|
197
|
+
return [model.redirectOrigin];
|
|
198
|
+
const client = await lookupClient(model.client.clientId);
|
|
199
|
+
return (client?.redirectUris ?? []).filter((u) => !/^https?:/i.test(u));
|
|
200
|
+
};
|
|
180
201
|
const respond = async (c, outcome, settings) => {
|
|
181
202
|
noStore(c);
|
|
182
203
|
c.header("Referrer-Policy", "no-referrer");
|
|
@@ -190,7 +211,16 @@ export function createAuthorizationServer(deps = {}) {
|
|
|
190
211
|
return c.redirect(back.toString(), 302);
|
|
191
212
|
}
|
|
192
213
|
case "org-picker": {
|
|
193
|
-
|
|
214
|
+
let csp;
|
|
215
|
+
try {
|
|
216
|
+
csp = await pickerContentSecurityPolicy(await redirectTargetsOf(outcome.model));
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
// Fail closed: without a parseable destination the page is not rendered at all.
|
|
220
|
+
console.error("[oauth] picker refused:", err instanceof Error ? err.message : err);
|
|
221
|
+
return c.json({ error: "server_error", error_description: "picker unavailable" }, 500);
|
|
222
|
+
}
|
|
223
|
+
c.header("Content-Security-Policy", csp);
|
|
194
224
|
const locale = detectLocaleFromAcceptLanguage(c.req.header("accept-language"));
|
|
195
225
|
return c.html(renderOrgPicker(outcome.model, locale, {
|
|
196
226
|
// Only a URL-shaped (CIMD) client_id is bound to a domain its owner controls.
|
|
@@ -204,19 +234,11 @@ export function createAuthorizationServer(deps = {}) {
|
|
|
204
234
|
}
|
|
205
235
|
}
|
|
206
236
|
};
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
registerHits.set(key, { windowStart: t0, count: 1 });
|
|
213
|
-
if (registerHits.size > 5000)
|
|
214
|
-
registerHits.clear();
|
|
215
|
-
return true;
|
|
216
|
-
}
|
|
217
|
-
hit.count += 1;
|
|
218
|
-
return hit.count <= REGISTER_LIMIT_PER_MINUTE;
|
|
219
|
-
};
|
|
237
|
+
const registerLimiter = createRegisterLimiter({
|
|
238
|
+
limitPerMinute: REGISTER_LIMIT_PER_MINUTE,
|
|
239
|
+
prefix48LimitPerMinute: registerLimitPer48,
|
|
240
|
+
now,
|
|
241
|
+
});
|
|
220
242
|
return {
|
|
221
243
|
async resourceServer() {
|
|
222
244
|
const result = await settingsNow();
|
|
@@ -282,7 +304,7 @@ export function createAuthorizationServer(deps = {}) {
|
|
|
282
304
|
});
|
|
283
305
|
app.post("/register", async (c) => {
|
|
284
306
|
const key = clientKeyOf(c, trustedProxyCount);
|
|
285
|
-
if (!
|
|
307
|
+
if (!registerLimiter.allow(key)) {
|
|
286
308
|
c.header("Retry-After", "60");
|
|
287
309
|
return c.json({ error: "too_many_requests" }, 429);
|
|
288
310
|
}
|
|
@@ -326,6 +348,19 @@ export function createAuthorizationServer(deps = {}) {
|
|
|
326
348
|
});
|
|
327
349
|
}
|
|
328
350
|
catch (err) {
|
|
351
|
+
if (err instanceof RegistryFullError) {
|
|
352
|
+
// RFC 7591 style JSON error; the caller retries, no live client was displaced.
|
|
353
|
+
const locale = c.req.header("x-mcp-locale")
|
|
354
|
+
? detectLocale(c.req.header("x-mcp-locale"))
|
|
355
|
+
: detectLocaleFromAcceptLanguage(c.req.header("accept-language"));
|
|
356
|
+
console.error("[oauth] /register refused: client registry is full");
|
|
357
|
+
c.header("Retry-After", String(REGISTRY_FULL_RETRY_AFTER_SECONDS));
|
|
358
|
+
noStore(c);
|
|
359
|
+
return c.json({
|
|
360
|
+
error: "temporarily_unavailable",
|
|
361
|
+
error_description: t(locale, "oauth.registryFull"),
|
|
362
|
+
}, 503);
|
|
363
|
+
}
|
|
329
364
|
console.error("[oauth] /register failed:", err instanceof Error ? err.message : err);
|
|
330
365
|
return c.json({ error: "server_error", error_description: "failed to persist client" }, 500);
|
|
331
366
|
}
|
|
@@ -409,6 +444,16 @@ export function createAuthorizationServer(deps = {}) {
|
|
|
409
444
|
// session, then answers the redirect-to-client with error=access_denied and the
|
|
410
445
|
// client's state. `respond` appends `iss` to it like to every authorization response.
|
|
411
446
|
if (field("approved") === "false") {
|
|
447
|
+
// The denial is a state-changing POST like the approval. An absent token is
|
|
448
|
+
// refused here with a localized 400; a present one is verified by the package
|
|
449
|
+
// (cloud-identity >= 0.18) with the approve-path function, and a forged one is
|
|
450
|
+
// refused with no redirect.
|
|
451
|
+
if (!field("consentToken")) {
|
|
452
|
+
return c.json({
|
|
453
|
+
error: "invalid_request",
|
|
454
|
+
error_description: t(detectLocaleFromAcceptLanguage(c.req.header("accept-language")), "oauth.consentTokenRequired"),
|
|
455
|
+
}, 400);
|
|
456
|
+
}
|
|
412
457
|
const denied = await resumeAuthorize({
|
|
413
458
|
state: field("state") ?? "",
|
|
414
459
|
sessionToken: sessionTokenFrom(c),
|
package/dist/oauth/clerk.d.ts
CHANGED
|
@@ -15,5 +15,7 @@ export interface ClerkCollaborators {
|
|
|
15
15
|
export interface ClerkBackendOptions {
|
|
16
16
|
/** Per-call time budget (JWKS, memberships, user). Default 5 s. */
|
|
17
17
|
timeoutMs?: number;
|
|
18
|
+
/** Total budget of ONE lookup across all its pages and calls. Default 15 s. */
|
|
19
|
+
totalTimeoutMs?: number;
|
|
18
20
|
}
|
|
19
21
|
export declare function createClerkBackendCollaborators(secretKey: string, fetchImpl?: typeof fetch, options?: ClerkBackendOptions): ClerkCollaborators;
|
package/dist/oauth/clerk.js
CHANGED
|
@@ -12,13 +12,22 @@ const MEMBERSHIP_PAGE_LIMIT = 100;
|
|
|
12
12
|
/** 50 pages x 100 = 5 000 memberships: past it the lookup FAILS rather than truncate. */
|
|
13
13
|
const MEMBERSHIP_MAX_PAGES = 50;
|
|
14
14
|
const CLERK_TIMEOUT_MS = 5000;
|
|
15
|
+
const CLERK_TOTAL_TIMEOUT_MS = 15_000;
|
|
15
16
|
export function createClerkBackendCollaborators(secretKey, fetchImpl = fetch, options = {}) {
|
|
16
17
|
const timeoutMs = options.timeoutMs ?? CLERK_TIMEOUT_MS;
|
|
17
18
|
let cachedJwks = null;
|
|
18
|
-
const
|
|
19
|
+
const totalMs = options.totalTimeoutMs ?? CLERK_TOTAL_TIMEOUT_MS;
|
|
20
|
+
/** Per-call timeout AND what is left of the lookup's total deadline, whichever is sooner. */
|
|
21
|
+
const signalFor = (deadline) => {
|
|
22
|
+
const left = deadline - Date.now();
|
|
23
|
+
if (left <= 0)
|
|
24
|
+
throw new Error(`Clerk lookup exceeded its total deadline of ${totalMs} ms`);
|
|
25
|
+
return AbortSignal.any([AbortSignal.timeout(timeoutMs), AbortSignal.timeout(left)]);
|
|
26
|
+
};
|
|
27
|
+
const api = async (path, deadline) => {
|
|
19
28
|
const res = await fetchImpl(`${CLERK_API}${path}`, {
|
|
20
29
|
headers: { Authorization: `Bearer ${secretKey}`, Accept: "application/json" },
|
|
21
|
-
signal:
|
|
30
|
+
signal: signalFor(deadline),
|
|
22
31
|
});
|
|
23
32
|
if (!res.ok)
|
|
24
33
|
throw new Error(`Clerk Backend API answered ${res.status}`);
|
|
@@ -31,7 +40,7 @@ export function createClerkBackendCollaborators(secretKey, fetchImpl = fetch, op
|
|
|
31
40
|
return cachedJwks.keys;
|
|
32
41
|
}
|
|
33
42
|
const res = await fetchImpl(`${issuer}/.well-known/jwks.json`, {
|
|
34
|
-
signal:
|
|
43
|
+
signal: signalFor(now + totalMs),
|
|
35
44
|
});
|
|
36
45
|
if (!res.ok)
|
|
37
46
|
throw new Error(`Clerk JWKS answered ${res.status}`);
|
|
@@ -41,9 +50,10 @@ export function createClerkBackendCollaborators(secretKey, fetchImpl = fetch, op
|
|
|
41
50
|
},
|
|
42
51
|
async listMemberships(userId) {
|
|
43
52
|
const all = [];
|
|
53
|
+
const deadline = Date.now() + totalMs;
|
|
44
54
|
for (let page = 0; page < MEMBERSHIP_MAX_PAGES; page++) {
|
|
45
55
|
const offset = page * MEMBERSHIP_PAGE_LIMIT;
|
|
46
|
-
const body = await api(`/users/${encodeURIComponent(userId)}/organization_memberships?limit=${MEMBERSHIP_PAGE_LIMIT}&offset=${offset}
|
|
56
|
+
const body = await api(`/users/${encodeURIComponent(userId)}/organization_memberships?limit=${MEMBERSHIP_PAGE_LIMIT}&offset=${offset}`, deadline);
|
|
47
57
|
all.push(...body.data);
|
|
48
58
|
const last = body.data.length < MEMBERSHIP_PAGE_LIMIT ||
|
|
49
59
|
(typeof body.total_count === "number" && all.length >= body.total_count);
|
|
@@ -62,7 +72,7 @@ export function createClerkBackendCollaborators(secretKey, fetchImpl = fetch, op
|
|
|
62
72
|
throw new Error(`Clerk memberships exceed ${MEMBERSHIP_MAX_PAGES * MEMBERSHIP_PAGE_LIMIT}`);
|
|
63
73
|
},
|
|
64
74
|
async getUser(userId) {
|
|
65
|
-
const user = await api(`/users/${encodeURIComponent(userId)}
|
|
75
|
+
const user = await api(`/users/${encodeURIComponent(userId)}`, Date.now() + totalMs);
|
|
66
76
|
return {
|
|
67
77
|
id: user.id,
|
|
68
78
|
primaryEmailAddressId: user.primary_email_address_id ?? null,
|
|
@@ -25,20 +25,35 @@ export interface ClientRegistry {
|
|
|
25
25
|
register(client: RegisteredClient): Promise<void>;
|
|
26
26
|
}
|
|
27
27
|
export interface InMemoryClientRegistryOptions {
|
|
28
|
-
/** Hard cap on stored clients; at the cap
|
|
28
|
+
/** Hard cap on stored clients; at the cap a new registration is REFUSED. Default 10 000. */
|
|
29
29
|
maxClients?: number;
|
|
30
30
|
/** Lifetime of a registration, counted from `issuedAt`. Default 30 days. */
|
|
31
31
|
ttlSeconds?: number;
|
|
32
32
|
now?: () => number;
|
|
33
33
|
}
|
|
34
34
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
|
|
35
|
+
* Thrown by `register` when the registry is full of live clients. The route answers 503 with
|
|
36
|
+
* `Retry-After`; a durable adapter may throw the same error for its own quota.
|
|
37
|
+
*/
|
|
38
|
+
export declare class RegistryFullError extends Error {
|
|
39
|
+
readonly code = "registry_full";
|
|
40
|
+
constructor();
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Bounded in-memory registry. DCR is unauthenticated, so it can never grow without limit.
|
|
44
|
+
* Expired clients are pruned on every registration BEFORE the cap is judged; at the cap a new
|
|
45
|
+
* registration is refused with `RegistryFullError`. A live client is never evicted: an
|
|
46
|
+
* unauthenticated flood must not be able to push a real client out (eviction, introduced for
|
|
47
|
+
* issue #9, allowed exactly that).
|
|
38
48
|
*/
|
|
39
49
|
export declare function createInMemoryClientRegistry(options?: InMemoryClientRegistryOptions): ClientRegistry;
|
|
40
50
|
/** True for an https URL, or an http URL on a loopback host (native-app redirects). */
|
|
41
51
|
export declare function isAllowedRedirectUri(raw: unknown): raw is string;
|
|
52
|
+
/**
|
|
53
|
+
* The eight 16-bit groups of a SYNTACTICALLY VALID IPv6 literal (`isIP` has already accepted
|
|
54
|
+
* it). Only used to read bits that no BlockList prefix can express.
|
|
55
|
+
*/
|
|
56
|
+
export declare function ipv6Groups(ip: string): number[];
|
|
42
57
|
/** True only for a globally routable address. Fail-closed: anything unrecognised is refused. */
|
|
43
58
|
export declare function isPublicAddress(ip: string): boolean;
|
|
44
59
|
export declare function defaultResolveHost(host: string): Promise<string[]>;
|
|
@@ -13,9 +13,22 @@
|
|
|
13
13
|
import { lookup } from "node:dns/promises";
|
|
14
14
|
import { BlockList, isIP } from "node:net";
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
|
|
16
|
+
* Thrown by `register` when the registry is full of live clients. The route answers 503 with
|
|
17
|
+
* `Retry-After`; a durable adapter may throw the same error for its own quota.
|
|
18
|
+
*/
|
|
19
|
+
export class RegistryFullError extends Error {
|
|
20
|
+
code = "registry_full";
|
|
21
|
+
constructor() {
|
|
22
|
+
super("client registry is full");
|
|
23
|
+
this.name = "RegistryFullError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Bounded in-memory registry. DCR is unauthenticated, so it can never grow without limit.
|
|
28
|
+
* Expired clients are pruned on every registration BEFORE the cap is judged; at the cap a new
|
|
29
|
+
* registration is refused with `RegistryFullError`. A live client is never evicted: an
|
|
30
|
+
* unauthenticated flood must not be able to push a real client out (eviction, introduced for
|
|
31
|
+
* issue #9, allowed exactly that).
|
|
19
32
|
*/
|
|
20
33
|
export function createInMemoryClientRegistry(options = {}) {
|
|
21
34
|
const maxClients = options.maxClients ?? 10_000;
|
|
@@ -38,12 +51,9 @@ export function createInMemoryClientRegistry(options = {}) {
|
|
|
38
51
|
for (const [id, row] of clients)
|
|
39
52
|
if (expired(row))
|
|
40
53
|
clients.delete(id);
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (oldest.done)
|
|
45
|
-
break;
|
|
46
|
-
clients.delete(oldest.value);
|
|
54
|
+
// Re-registering an id already held replaces it: that is not a new slot.
|
|
55
|
+
if (!clients.has(client.clientId) && clients.size >= maxClients) {
|
|
56
|
+
throw new RegistryFullError();
|
|
47
57
|
}
|
|
48
58
|
clients.set(client.clientId, client);
|
|
49
59
|
},
|
|
@@ -132,7 +142,7 @@ for (const [addr, len] of BLOCKED_V6)
|
|
|
132
142
|
* The eight 16-bit groups of a SYNTACTICALLY VALID IPv6 literal (`isIP` has already accepted
|
|
133
143
|
* it). Only used to read bits that no BlockList prefix can express.
|
|
134
144
|
*/
|
|
135
|
-
function ipv6Groups(ip) {
|
|
145
|
+
export function ipv6Groups(ip) {
|
|
136
146
|
let text = ip.toLowerCase();
|
|
137
147
|
const dotted = /(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(text);
|
|
138
148
|
if (dotted) {
|
package/dist/oauth/picker.d.ts
CHANGED
|
@@ -6,8 +6,11 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { OrgPickerModel } from "@vantageos/cloud-identity";
|
|
8
8
|
import { type McpLocale } from "../i18n.js";
|
|
9
|
-
/**
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* CSP for the picker page: no scripts, no external assets, one hashed inline style, and forms
|
|
11
|
+
* that post to this server and are redirected to the client's `redirect_uri` only.
|
|
12
|
+
*/
|
|
13
|
+
export declare function pickerContentSecurityPolicy(redirectUris: readonly string[]): Promise<string>;
|
|
11
14
|
/** What the consent page must tell the person beyond the model: whether the client is trusted. */
|
|
12
15
|
export interface ConsentContext {
|
|
13
16
|
/** True for a Dynamic Client Registration client: it vouched for itself, nobody else did. */
|
package/dist/oauth/picker.js
CHANGED
|
@@ -61,14 +61,44 @@ const PICKER_STYLE = [
|
|
|
61
61
|
"button.primary{background:var(--primary);color:var(--primary-foreground);border-color:var(--primary)}",
|
|
62
62
|
"button:hover{opacity:.9}button:focus-visible,.org:focus-within{outline:2px solid var(--ring);outline-offset:2px}",
|
|
63
63
|
].join("");
|
|
64
|
-
/**
|
|
65
|
-
|
|
64
|
+
/**
|
|
65
|
+
* The `form-action` sources for the redirect URIs of THIS authorization. Chromium enforces
|
|
66
|
+
* `form-action` on the redirect that follows a form POST, and POST /authorize/org answers a
|
|
67
|
+
* 302 to the client's `redirect_uri` (always another origin), so that destination MUST be
|
|
68
|
+
* listed or the client never receives its code. Built from `new URL(...)`, never from raw
|
|
69
|
+
* input: an http(s) URI contributes its origin (loopback keeps its port), any other URI its
|
|
70
|
+
* scheme (`cursor:`). Fail closed: no URI, or one that does not parse, throws; the page is
|
|
71
|
+
* never rendered with `'self'` alone.
|
|
72
|
+
*/
|
|
73
|
+
function formActionSources(redirectUris) {
|
|
74
|
+
if (redirectUris.length === 0)
|
|
75
|
+
throw new Error("picker CSP: no redirect_uri to permit");
|
|
76
|
+
const sources = new Set();
|
|
77
|
+
for (const raw of redirectUris) {
|
|
78
|
+
let url;
|
|
79
|
+
try {
|
|
80
|
+
url = new URL(raw);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
throw new Error("picker CSP: redirect_uri does not parse");
|
|
84
|
+
}
|
|
85
|
+
sources.add(url.protocol === "https:" || url.protocol === "http:" ? url.origin : url.protocol);
|
|
86
|
+
}
|
|
87
|
+
return [...sources];
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* CSP for the picker page: no scripts, no external assets, one hashed inline style, and forms
|
|
91
|
+
* that post to this server and are redirected to the client's `redirect_uri` only.
|
|
92
|
+
*/
|
|
93
|
+
export async function pickerContentSecurityPolicy(redirectUris) {
|
|
94
|
+
const formAction = ["'self'", ...formActionSources(redirectUris)].join(" ");
|
|
66
95
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(PICKER_STYLE));
|
|
67
96
|
const hash = btoa(String.fromCharCode(...new Uint8Array(digest)));
|
|
68
97
|
return [
|
|
69
98
|
"default-src 'none'",
|
|
70
99
|
`style-src 'sha256-${hash}'`,
|
|
71
100
|
"base-uri 'none'",
|
|
101
|
+
`form-action ${formAction}`,
|
|
72
102
|
"frame-ancestors 'none'",
|
|
73
103
|
].join("; ");
|
|
74
104
|
}
|
|
@@ -82,8 +112,8 @@ export function renderOrgPicker(model, locale, consent) {
|
|
|
82
112
|
const orgs = model.organizations
|
|
83
113
|
.map((o, i) => `<label class="org"><input type="radio" name="orgId" value="${escapeHtml(o.id)}" required${only && i === 0 ? " checked" : ""}><span class="name">${escapeHtml(o.name)}</span><span class="role">${escapeHtml(o.role.replace(/^org:/, ""))}</span></label>`)
|
|
84
114
|
.join("\n");
|
|
85
|
-
const title = escapeHtml(t(locale, "oauth.pickerTitle").replace("{client}", clientName));
|
|
86
|
-
const intro = escapeHtml(t(locale, "oauth.pickerIntro").replaceAll("{client}", clientName));
|
|
115
|
+
const title = escapeHtml(t(locale, "oauth.pickerTitle").replace("{client}", () => clientName));
|
|
116
|
+
const intro = escapeHtml(t(locale, "oauth.pickerIntro").replaceAll("{client}", () => clientName));
|
|
87
117
|
const brand = consent.brandName ? `<p class="brand">${escapeHtml(consent.brandName)}</p>\n` : "";
|
|
88
118
|
return `<!doctype html>
|
|
89
119
|
<html lang="${locale}">
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate limiter of the unauthenticated `POST /register`.
|
|
3
|
+
*
|
|
4
|
+
* Keys are NORMALISED so one host cannot multiply its budget: an IPv6 client is keyed on its
|
|
5
|
+
* /64 prefix (a host owns a whole /64, so per-address keys are free), an IPv4-mapped IPv6
|
|
6
|
+
* address is keyed as the IPv4 address it carries, IPv4 keeps the full address. Parsing is
|
|
7
|
+
* `node:net`'s (`isIP`) plus the group reader the SSRF guard already uses.
|
|
8
|
+
*
|
|
9
|
+
* A /48 holds 65536 /64s, each with its own budget, so an IPv6 client is ALSO counted against
|
|
10
|
+
* its /48 when a `/48` budget is configured: a request passes only while both its /64 and its
|
|
11
|
+
* /48 are under their limits. A request already refused by its /64 is not charged to the /48. IPv4 and IPv4-mapped addresses have no /48 and are unaffected.
|
|
12
|
+
*
|
|
13
|
+
* Memory is bounded without ever handing an attacker a reset. When the table is full the
|
|
14
|
+
* least-recently-used key is evicted, but keys still inside a window and not yet limited go
|
|
15
|
+
* first: a key that already hit the limit is the last thing dropped, so a flood of fresh keys
|
|
16
|
+
* cannot wipe a counter that is doing its job. (A bare `clear()` did exactly that.)
|
|
17
|
+
*/
|
|
18
|
+
/** The counter key of a client address. A value that is not an IP literal is returned as is. */
|
|
19
|
+
export declare function normaliseClientKey(address: string): string;
|
|
20
|
+
/** The /48 aggregate key of a native IPv6 address, or null for IPv4, IPv4-mapped or non-IP. */
|
|
21
|
+
export declare function prefix48Key(address: string): string | null;
|
|
22
|
+
export interface RegisterLimiter {
|
|
23
|
+
/** Counts one request for `address`; false when it is over the limit for the window. */
|
|
24
|
+
allow(address: string): boolean;
|
|
25
|
+
/** Keys currently tracked (tests and diagnostics). */
|
|
26
|
+
size(): number;
|
|
27
|
+
}
|
|
28
|
+
export declare function createRegisterLimiter(options: {
|
|
29
|
+
limitPerMinute: number;
|
|
30
|
+
/** Aggregate budget of every /64 inside one IPv6 /48; absent means no /48 budget. */
|
|
31
|
+
prefix48LimitPerMinute?: number;
|
|
32
|
+
maxKeys?: number;
|
|
33
|
+
now: () => number;
|
|
34
|
+
}): RegisterLimiter;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate limiter of the unauthenticated `POST /register`.
|
|
3
|
+
*
|
|
4
|
+
* Keys are NORMALISED so one host cannot multiply its budget: an IPv6 client is keyed on its
|
|
5
|
+
* /64 prefix (a host owns a whole /64, so per-address keys are free), an IPv4-mapped IPv6
|
|
6
|
+
* address is keyed as the IPv4 address it carries, IPv4 keeps the full address. Parsing is
|
|
7
|
+
* `node:net`'s (`isIP`) plus the group reader the SSRF guard already uses.
|
|
8
|
+
*
|
|
9
|
+
* A /48 holds 65536 /64s, each with its own budget, so an IPv6 client is ALSO counted against
|
|
10
|
+
* its /48 when a `/48` budget is configured: a request passes only while both its /64 and its
|
|
11
|
+
* /48 are under their limits. A request already refused by its /64 is not charged to the /48. IPv4 and IPv4-mapped addresses have no /48 and are unaffected.
|
|
12
|
+
*
|
|
13
|
+
* Memory is bounded without ever handing an attacker a reset. When the table is full the
|
|
14
|
+
* least-recently-used key is evicted, but keys still inside a window and not yet limited go
|
|
15
|
+
* first: a key that already hit the limit is the last thing dropped, so a flood of fresh keys
|
|
16
|
+
* cannot wipe a counter that is doing its job. (A bare `clear()` did exactly that.)
|
|
17
|
+
*/
|
|
18
|
+
import { isIP } from "node:net";
|
|
19
|
+
import { ipv6Groups } from "./clientRegistry.js";
|
|
20
|
+
const WINDOW_MS = 60_000;
|
|
21
|
+
/** The counter key of a client address. A value that is not an IP literal is returned as is. */
|
|
22
|
+
export function normaliseClientKey(address) {
|
|
23
|
+
const bare = address.replace(/^\[|\]$/g, "").split("%")[0] ?? address;
|
|
24
|
+
const version = isIP(bare);
|
|
25
|
+
if (version === 4)
|
|
26
|
+
return bare;
|
|
27
|
+
if (version !== 6)
|
|
28
|
+
return address;
|
|
29
|
+
const g = ipv6Groups(bare);
|
|
30
|
+
const mapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 0xffff;
|
|
31
|
+
if (mapped)
|
|
32
|
+
return `${g[6] >> 8}.${g[6] & 255}.${g[7] >> 8}.${g[7] & 255}`;
|
|
33
|
+
return `${g
|
|
34
|
+
.slice(0, 4)
|
|
35
|
+
.map((x) => x.toString(16))
|
|
36
|
+
.join(":")}::/64`;
|
|
37
|
+
}
|
|
38
|
+
/** The /48 aggregate key of a native IPv6 address, or null for IPv4, IPv4-mapped or non-IP. */
|
|
39
|
+
export function prefix48Key(address) {
|
|
40
|
+
const bare = address.replace(/^\[|\]$/g, "").split("%")[0] ?? address;
|
|
41
|
+
if (isIP(bare) !== 6)
|
|
42
|
+
return null;
|
|
43
|
+
const g = ipv6Groups(bare);
|
|
44
|
+
const mapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 0xffff;
|
|
45
|
+
if (mapped)
|
|
46
|
+
return null;
|
|
47
|
+
return `${g
|
|
48
|
+
.slice(0, 3)
|
|
49
|
+
.map((x) => x.toString(16))
|
|
50
|
+
.join(":")}::/48`;
|
|
51
|
+
}
|
|
52
|
+
export function createRegisterLimiter(options) {
|
|
53
|
+
const { limitPerMinute, prefix48LimitPerMinute, now } = options;
|
|
54
|
+
const maxKeys = options.maxKeys ?? 5000;
|
|
55
|
+
// Two LRU tables (Map iteration order = insertion order, re-inserted on every hit): keys
|
|
56
|
+
// still allowed, and keys already limited. Eviction drains the first before the second.
|
|
57
|
+
const open = new Map();
|
|
58
|
+
const limited = new Map();
|
|
59
|
+
const evictOne = () => {
|
|
60
|
+
const table = open.size > 0 ? open : limited;
|
|
61
|
+
const oldest = table.keys().next();
|
|
62
|
+
if (!oldest.done)
|
|
63
|
+
table.delete(oldest.value);
|
|
64
|
+
};
|
|
65
|
+
/** Counts one hit on `key`; true when that key is now over `limit` for its window. */
|
|
66
|
+
const bump = (key, limit, t0) => {
|
|
67
|
+
const prior = open.get(key) ?? limited.get(key);
|
|
68
|
+
const expired = prior !== undefined && t0 - prior.windowStart >= WINDOW_MS;
|
|
69
|
+
open.delete(key);
|
|
70
|
+
limited.delete(key);
|
|
71
|
+
if (!prior || expired) {
|
|
72
|
+
while (open.size + limited.size >= maxKeys)
|
|
73
|
+
evictOne();
|
|
74
|
+
const fresh = { windowStart: t0, count: 1 };
|
|
75
|
+
(fresh.count > limit ? limited : open).set(key, fresh);
|
|
76
|
+
return fresh.count > limit;
|
|
77
|
+
}
|
|
78
|
+
prior.count += 1;
|
|
79
|
+
const over = prior.count > limit;
|
|
80
|
+
(over ? limited : open).set(key, prior);
|
|
81
|
+
return over;
|
|
82
|
+
};
|
|
83
|
+
return {
|
|
84
|
+
allow(address) {
|
|
85
|
+
const t0 = now();
|
|
86
|
+
const overHost = bump(normaliseClientKey(address), limitPerMinute, t0);
|
|
87
|
+
// A host already over its own limit is refused without spending the shared /48 budget,
|
|
88
|
+
// so one hammering /64 cannot lock out the other /64s of its /48.
|
|
89
|
+
const wide = overHost || prefix48LimitPerMinute === undefined ? null : prefix48Key(address);
|
|
90
|
+
const overWide = wide === null ? false : bump(wide, prefix48LimitPerMinute, t0);
|
|
91
|
+
return !overHost && !overWide;
|
|
92
|
+
},
|
|
93
|
+
size: () => open.size + limited.size,
|
|
94
|
+
};
|
|
95
|
+
}
|
package/dist/oauth/settings.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ export declare const OAUTH_ENV: {
|
|
|
16
16
|
readonly signingJwk: "MCP_OAUTH_SIGNING_JWK";
|
|
17
17
|
/** Number of trusted reverse proxies in front of this server (default 0). */
|
|
18
18
|
readonly trustedProxyCount: "MCP_TRUSTED_PROXY_COUNT";
|
|
19
|
+
/** /register budget shared by every /64 inside one IPv6 /48, per minute (default 100). */
|
|
20
|
+
readonly registerLimitPer48: "MCP_REGISTER_LIMIT_PER_48_PER_MINUTE";
|
|
19
21
|
/** Comma-separated browser origins allowed to call /register cross-origin (default: none). */
|
|
20
22
|
readonly registerCorsOrigins: "MCP_REGISTER_CORS_ORIGINS";
|
|
21
23
|
/** Exactly "1" lets NODE_ENV=production run on the in-memory stores (single instance only). */
|
package/dist/oauth/settings.js
CHANGED
|
@@ -16,6 +16,8 @@ export const OAUTH_ENV = {
|
|
|
16
16
|
signingJwk: "MCP_OAUTH_SIGNING_JWK",
|
|
17
17
|
/** Number of trusted reverse proxies in front of this server (default 0). */
|
|
18
18
|
trustedProxyCount: "MCP_TRUSTED_PROXY_COUNT",
|
|
19
|
+
/** /register budget shared by every /64 inside one IPv6 /48, per minute (default 100). */
|
|
20
|
+
registerLimitPer48: "MCP_REGISTER_LIMIT_PER_48_PER_MINUTE",
|
|
19
21
|
/** Comma-separated browser origins allowed to call /register cross-origin (default: none). */
|
|
20
22
|
registerCorsOrigins: "MCP_REGISTER_CORS_ORIGINS",
|
|
21
23
|
/** Exactly "1" lets NODE_ENV=production run on the in-memory stores (single instance only). */
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor pagination applied by the tool (mcp-standard C-11, S-12, §3 point 4). A list tool
|
|
3
|
+
* declares a cursor, a default and a finite maximum (src/toolRecord.ts) and THIS module applies
|
|
4
|
+
* them, so the bound never depends on the store behind:
|
|
5
|
+
*
|
|
6
|
+
* - the page size is the caller's `limit`, else the default, never above the maximum;
|
|
7
|
+
* - the cursor is OPAQUE (base64url of a small JSON object) and carries the last key served,
|
|
8
|
+
* so it is stable: rows added or removed elsewhere do not shift the next page;
|
|
9
|
+
* - exhausted and truncated are told apart: a truncated page carries `nextCursor` and
|
|
10
|
+
* `exhausted: false`; the last page carries `exhausted: true` and no `nextCursor` (an exact
|
|
11
|
+
* fit is exhausted, never a false truncation);
|
|
12
|
+
* - an invalid or expired cursor is refused with the stable code `invalid_cursor`, never read as
|
|
13
|
+
* "page one". It is a TOOL INPUT error (a result with `isError: true`, spec 2025-11-25
|
|
14
|
+
* SEP-1303), not a JSON-RPC error: -32602 stays with the protocol list methods' cursor.
|
|
15
|
+
*/
|
|
16
|
+
import { type LakeReader, type LakeScope } from "./lake/reader.js";
|
|
17
|
+
/**
|
|
18
|
+
* The applied bounds, taken from the tool's own declaration so the number is typed once
|
|
19
|
+
* (tests/paginationApplied.test.ts proves the served annotations agree).
|
|
20
|
+
*/
|
|
21
|
+
export declare const PAGINATION: {
|
|
22
|
+
readonly default: 20;
|
|
23
|
+
readonly max: 100;
|
|
24
|
+
};
|
|
25
|
+
/** The stable machine code of an invalid or expired cursor, in `structuredContent.error.code`. */
|
|
26
|
+
export declare const INVALID_CURSOR_CODE = "invalid_cursor";
|
|
27
|
+
/** The cursor did not decode to a position in the collection. */
|
|
28
|
+
export declare class InvalidCursorError extends Error {
|
|
29
|
+
readonly code = "invalid_cursor";
|
|
30
|
+
constructor();
|
|
31
|
+
}
|
|
32
|
+
export declare function encodeCursor(afterKey: string): string;
|
|
33
|
+
export declare function decodeCursor(cursor: string): string;
|
|
34
|
+
export interface Page<T> {
|
|
35
|
+
items: T[];
|
|
36
|
+
/** Present exactly when the page was truncated. */
|
|
37
|
+
nextCursor?: string;
|
|
38
|
+
exhausted: boolean;
|
|
39
|
+
}
|
|
40
|
+
export interface PageRequest {
|
|
41
|
+
cursor?: string;
|
|
42
|
+
limit?: number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* One page of `sortedRows` (sorted ascending by `id`, the collection key). The read is a key
|
|
46
|
+
* range (src/exampleItems.ts `rangeAfter`: binary search on the key), so its cost follows the
|
|
47
|
+
* page, not the collection.
|
|
48
|
+
*/
|
|
49
|
+
export declare function paginate<T extends {
|
|
50
|
+
id: string;
|
|
51
|
+
}>(sortedRows: readonly T[], request: PageRequest, bounds: {
|
|
52
|
+
default: number;
|
|
53
|
+
max: number;
|
|
54
|
+
}): Page<T>;
|
|
55
|
+
/**
|
|
56
|
+
* One page read through a lake reader (src/lake/reader.ts): the same bounds and cursor as
|
|
57
|
+
* `paginate`, the range read delegated to the reader. The cursor is decoded BEFORE the reader is
|
|
58
|
+
* asked anything (an invalid cursor never costs a read), the page size is the caller's `limit`,
|
|
59
|
+
* else the default, never above the maximum, and the reader's answer is checked against the page
|
|
60
|
+
* contract (`checkLakePage`): an unreadable page is a `LakeReadError`, never an empty page. With
|
|
61
|
+
* the in-memory reader, the result is exactly `paginate`'s (tests/lakeReader.test.ts).
|
|
62
|
+
*/
|
|
63
|
+
export declare function readPage<Row>(reader: LakeReader<Row>, request: PageRequest, bounds: {
|
|
64
|
+
default: number;
|
|
65
|
+
max: number;
|
|
66
|
+
}, scope?: LakeScope): Promise<Page<Row>>;
|