@vantageos/mcp-boilerplate 0.2.0 → 0.2.2

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.
@@ -24,7 +24,19 @@ export interface ClientRegistry {
24
24
  get(clientId: string): Promise<RegisteredClient | null>;
25
25
  register(client: RegisteredClient): Promise<void>;
26
26
  }
27
- export declare function createInMemoryClientRegistry(maxClients?: number): ClientRegistry;
27
+ export interface InMemoryClientRegistryOptions {
28
+ /** Hard cap on stored clients; at the cap the OLDEST client is evicted. Default 10 000. */
29
+ maxClients?: number;
30
+ /** Lifetime of a registration, counted from `issuedAt`. Default 30 days. */
31
+ ttlSeconds?: number;
32
+ now?: () => number;
33
+ }
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).
38
+ */
39
+ export declare function createInMemoryClientRegistry(options?: InMemoryClientRegistryOptions): ClientRegistry;
28
40
  /** True for an https URL, or an http URL on a loopback host (native-app redirects). */
29
41
  export declare function isAllowedRedirectUri(raw: unknown): raw is string;
30
42
  /** True only for a globally routable address. Fail-closed: anything unrecognised is refused. */
@@ -12,15 +12,38 @@
12
12
  */
13
13
  import { lookup } from "node:dns/promises";
14
14
  import { BlockList, isIP } from "node:net";
15
- export function createInMemoryClientRegistry(maxClients = 10_000) {
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
- return clients.get(clientId) ?? null;
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
- if (clients.size >= maxClients) {
23
- throw new Error("client registry is full");
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) and NAT64 (64:ff9b::a.b.c.d): the embedded
88
- // v4 address is held to the same list.
89
- for (const prefix6 of ["::ffff:", "64:ff9b::"]) {
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 {
@@ -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 declare function createInMemoryCodeStore(): AuthorizationCodeStore;
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;
@@ -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
- export function createInMemoryCodeStore() {
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 Set();
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.add(codeHash);
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
  }
@@ -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
- export declare function renderOrgPicker(model: OrgPickerModel, locale: McpLocale): string;
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;
@@ -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
- <form method="post" action="/authorize/org">
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>
@@ -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 {
@@ -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 DEFAULT_SCOPE = "mcp:full openid";
20
- export const SUPPORTED_SCOPES = ["mcp:full", "openid", "email"];
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
- return new URL(url).origin;
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;
@@ -3,9 +3,9 @@ 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
- * (vite-plugin-singlefile). Thrown, never silently swallowed, if the
7
- * bundle is missing — a missing bundle is a build defect, not a runtime
8
- * fallback case.
6
+ * (in-repo inlining plugin). Never silently swallowed: a missing bundle is a build defect, not
7
+ * a runtime fallback case. The error that reaches the MCP client names no filesystem path;
8
+ * the detail goes to stderr (server side) only.
9
9
  */
10
10
  export declare function readUiBundle(): string;
11
11
  /**
@@ -11,13 +11,18 @@
11
11
  * shape written here.
12
12
  */
13
13
  import { readFileSync } from "node:fs";
14
- import path from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
15
  import { RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
16
16
  export const ITEMS_UI_RESOURCE_URI = "ui://mcp-boilerplate/items-widget.html";
17
17
  // dist/ui-bundle/ — NEVER dist/ui/ (that path is tsc's compiled-server
18
18
  // output for this very module; see vite.config.ts for the collision this
19
- // avoids).
20
- const BUNDLE_PATH = path.join(process.cwd(), "dist", "ui-bundle", "widget.html");
19
+ // avoids). Resolved relative to THIS module, never to the working directory: an installed
20
+ // package runs from any cwd. Compiled (dist/ui/uiResource.js) the bundle is `../ui-bundle`;
21
+ // from source (src/ui/uiResource.ts, under vitest) it is the repository's built `dist/`.
22
+ const BUNDLE_CANDIDATES = [
23
+ fileURLToPath(new URL("../ui-bundle/widget.html", import.meta.url)),
24
+ fileURLToPath(new URL("../../dist/ui-bundle/widget.html", import.meta.url)),
25
+ ];
21
26
  function renderMarkdownFallback(items, locale) {
22
27
  const title = locale === "fr" ? "Éléments" : "Items";
23
28
  if (items.length === 0) {
@@ -28,12 +33,22 @@ function renderMarkdownFallback(items, locale) {
28
33
  }
29
34
  /**
30
35
  * Reads the single-file bundle produced by `npm run build:ui`
31
- * (vite-plugin-singlefile). Thrown, never silently swallowed, if the
32
- * bundle is missing — a missing bundle is a build defect, not a runtime
33
- * fallback case.
36
+ * (in-repo inlining plugin). Never silently swallowed: a missing bundle is a build defect, not
37
+ * a runtime fallback case. The error that reaches the MCP client names no filesystem path;
38
+ * the detail goes to stderr (server side) only.
34
39
  */
35
40
  export function readUiBundle() {
36
- return readFileSync(BUNDLE_PATH, "utf-8");
41
+ let lastError;
42
+ for (const candidate of BUNDLE_CANDIDATES) {
43
+ try {
44
+ return readFileSync(candidate, "utf-8");
45
+ }
46
+ catch (err) {
47
+ lastError = err;
48
+ }
49
+ }
50
+ console.error(`[ui] UI bundle unreadable (tried ${BUNDLE_CANDIDATES.join(", ")}):`, lastError);
51
+ throw new Error("UI bundle unavailable on this server");
37
52
  }
38
53
  /**
39
54
  * Resource-level `_meta.ui` (MCP Apps spec 2026-01-26). The CSP is derived from what the