@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.
Files changed (59) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +182 -0
  3. package/README.fr.md +318 -15
  4. package/README.md +302 -15
  5. package/dist/buildIdentity.d.ts +19 -0
  6. package/dist/buildIdentity.js +62 -0
  7. package/dist/declaredVariables.d.ts +205 -0
  8. package/dist/declaredVariables.js +451 -0
  9. package/dist/exampleItems.d.ts +15 -0
  10. package/dist/exampleItems.js +28 -1
  11. package/dist/httpApp.d.ts +17 -0
  12. package/dist/httpApp.js +22 -2
  13. package/dist/i18n.d.ts +10 -0
  14. package/dist/i18n.js +12 -0
  15. package/dist/index.d.ts +18 -0
  16. package/dist/index.js +9 -0
  17. package/dist/lake/convex.d.ts +101 -0
  18. package/dist/lake/convex.js +224 -0
  19. package/dist/lake/reader.d.ts +88 -0
  20. package/dist/lake/reader.js +120 -0
  21. package/dist/locales/en.json +47 -0
  22. package/dist/locales/fr.json +47 -0
  23. package/dist/mcpServerFactory.d.ts +31 -14
  24. package/dist/mcpServerFactory.js +124 -48
  25. package/dist/oauth/authorizationServer.js +63 -18
  26. package/dist/oauth/clerk.d.ts +2 -0
  27. package/dist/oauth/clerk.js +15 -5
  28. package/dist/oauth/clientRegistry.d.ts +19 -4
  29. package/dist/oauth/clientRegistry.js +20 -10
  30. package/dist/oauth/picker.d.ts +5 -2
  31. package/dist/oauth/picker.js +34 -4
  32. package/dist/oauth/registerLimiter.d.ts +34 -0
  33. package/dist/oauth/registerLimiter.js +95 -0
  34. package/dist/oauth/settings.d.ts +2 -0
  35. package/dist/oauth/settings.js +2 -0
  36. package/dist/pagination.d.ts +66 -0
  37. package/dist/pagination.js +100 -0
  38. package/dist/servedToolRecords.d.ts +18 -0
  39. package/dist/servedToolRecords.js +10 -0
  40. package/dist/serverManifest.d.ts +43 -0
  41. package/dist/serverManifest.js +90 -0
  42. package/dist/stdio.js +8 -1
  43. package/dist/tenancy.d.ts +1 -1
  44. package/dist/toolRecord.d.ts +124 -0
  45. package/dist/toolRecord.js +114 -0
  46. package/dist/ui/uiResource.d.ts +11 -4
  47. package/dist/ui/uiResource.js +8 -4
  48. package/dist/upstream/clientCredentials.d.ts +106 -0
  49. package/dist/upstream/clientCredentials.js +283 -0
  50. package/dist/upstream/paginatedClient.d.ts +142 -0
  51. package/dist/upstream/paginatedClient.js +422 -0
  52. package/dist/verifiedPerson.d.ts +85 -0
  53. package/dist/verifiedPerson.js +112 -0
  54. package/docs/oauth-stores.md +2 -1
  55. package/examples/oauth.env.example +5 -0
  56. package/mcp-server.json +14 -0
  57. package/package.json +19 -4
  58. package/plugin.json +1 -1
  59. 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, resolveClerkSessionFromRequest, } from "@vantageos/cloud-identity";
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
- c.header("Content-Security-Policy", await pickerContentSecurityPolicy());
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 registerHits = new Map();
208
- const registerAllowed = (key) => {
209
- const t0 = now();
210
- const hit = registerHits.get(key);
211
- if (!hit || t0 - hit.windowStart >= 60_000) {
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 (!registerAllowed(key)) {
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),
@@ -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;
@@ -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 api = async (path) => {
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: AbortSignal.timeout(timeoutMs),
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: AbortSignal.timeout(timeoutMs),
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 the OLDEST client is evicted. Default 10 000. */
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
- * Bounded in-memory registry. DCR is unauthenticated, so it can never grow without limit and
36
- * never stops accepting registrations either: expired clients are swept on every registration,
37
- * and at the cap the oldest client is evicted (an evicted client re-registers).
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
- * 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).
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
- 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);
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) {
@@ -6,8 +6,11 @@
6
6
  */
7
7
  import type { OrgPickerModel } from "@vantageos/cloud-identity";
8
8
  import { type McpLocale } from "../i18n.js";
9
- /** CSP for the picker page: no scripts, no external assets, one hashed inline style. */
10
- export declare function pickerContentSecurityPolicy(): Promise<string>;
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. */
@@ -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
- /** CSP for the picker page: no scripts, no external assets, one hashed inline style. */
65
- export async function pickerContentSecurityPolicy() {
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
+ }
@@ -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). */
@@ -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>>;