@vantageos/mcp-boilerplate 0.1.1 → 0.2.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 (50) hide show
  1. package/.claude-plugin/plugin.json +29 -8
  2. package/.mcp.json +8 -0
  3. package/CHANGELOG.md +71 -1
  4. package/README.fr.md +177 -142
  5. package/README.md +170 -133
  6. package/dist/auth.d.ts +20 -10
  7. package/dist/auth.js +9 -8
  8. package/dist/config.d.ts +10 -3
  9. package/dist/config.js +31 -3
  10. package/dist/exampleItems.d.ts +5 -4
  11. package/dist/exampleItems.js +3 -0
  12. package/dist/http.js +11 -1
  13. package/dist/httpApp.d.ts +23 -30
  14. package/dist/httpApp.js +73 -74
  15. package/dist/i18n.d.ts +6 -0
  16. package/dist/i18n.js +9 -0
  17. package/dist/locales/en.json +9 -0
  18. package/dist/locales/fr.json +9 -0
  19. package/dist/mcpServerFactory.d.ts +42 -41
  20. package/dist/mcpServerFactory.js +114 -146
  21. package/dist/oauth/authorizationServer.d.ts +56 -0
  22. package/dist/oauth/authorizationServer.js +423 -0
  23. package/dist/oauth/clerk.d.ts +15 -0
  24. package/dist/oauth/clerk.js +59 -0
  25. package/dist/oauth/clientRegistry.d.ts +50 -0
  26. package/dist/oauth/clientRegistry.js +203 -0
  27. package/dist/oauth/codeStore.d.ts +11 -0
  28. package/dist/oauth/codeStore.js +28 -0
  29. package/dist/oauth/picker.d.ts +11 -0
  30. package/dist/oauth/picker.js +67 -0
  31. package/dist/oauth/settings.d.ts +48 -0
  32. package/dist/oauth/settings.js +100 -0
  33. package/dist/oauth/tokenIssuer.d.ts +22 -0
  34. package/dist/oauth/tokenIssuer.js +61 -0
  35. package/dist/stdio.d.ts +7 -6
  36. package/dist/stdio.js +20 -19
  37. package/dist/tenancy.d.ts +16 -21
  38. package/dist/tenancy.js +12 -26
  39. package/dist/ui/uiResource.d.ts +36 -3
  40. package/dist/ui/uiResource.js +40 -6
  41. package/dist/version.d.ts +2 -0
  42. package/dist/version.js +8 -0
  43. package/docs/architecture.md +82 -0
  44. package/docs/oauth-stores.md +60 -0
  45. package/examples/oauth.env.example +25 -0
  46. package/mcp.json +19 -0
  47. package/package.json +23 -14
  48. package/plugin.json +43 -0
  49. package/scripts/plugin-manifest.source.json +35 -0
  50. package/skills/connect-mcp-server/SKILL.md +40 -0
@@ -0,0 +1,203 @@
1
+ /**
2
+ * OAuth client registry: how `/authorize` and `/token` learn who a `client_id` is.
3
+ *
4
+ * Two sources, tried by the SHAPE of the client_id:
5
+ * - a URL-shaped client_id is a Client ID Metadata Document (CIMD): fetched over https,
6
+ * SSRF-guarded (public addresses only, no redirects, size and time capped), and accepted
7
+ * only when the document names exactly that URL;
8
+ * - anything else is a Dynamic Client Registration (DCR) id looked up in the registry.
9
+ *
10
+ * Only PUBLIC clients (PKCE, no client secret) exist here: MCP clients are public clients,
11
+ * so the server issues and stores no client secret at all.
12
+ */
13
+ import { lookup } from "node:dns/promises";
14
+ import { BlockList, isIP } from "node:net";
15
+ export function createInMemoryClientRegistry(maxClients = 10_000) {
16
+ const clients = new Map();
17
+ return {
18
+ async get(clientId) {
19
+ return clients.get(clientId) ?? null;
20
+ },
21
+ async register(client) {
22
+ if (clients.size >= maxClients) {
23
+ throw new Error("client registry is full");
24
+ }
25
+ clients.set(client.clientId, client);
26
+ },
27
+ };
28
+ }
29
+ /** True for an https URL, or an http URL on a loopback host (native-app redirects). */
30
+ export function isAllowedRedirectUri(raw) {
31
+ if (typeof raw !== "string")
32
+ return false;
33
+ let url;
34
+ try {
35
+ url = new URL(raw);
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ if (url.hash)
41
+ return false;
42
+ if (url.protocol === "https:")
43
+ return true;
44
+ return (url.protocol === "http:" &&
45
+ (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]"));
46
+ }
47
+ // ---------------------------------------------------------------------------
48
+ // SSRF guard for the CIMD fetch
49
+ // ---------------------------------------------------------------------------
50
+ /**
51
+ * Non-public ranges: the union required by mcp-server-standard.md S-10 (cloud metadata,
52
+ * 127/8, 0/8, ::1, RFC 1918, link-local, unique-local, multicast) plus the extra ranges this
53
+ * guard has always refused (CGNAT, IETF protocol block, benchmarking, reserved >= 240).
54
+ * Address parsing and range matching belong to `node:net` BlockList: this module writes no
55
+ * address parser and compares the PARSER'S view of the address, never the raw string.
56
+ */
57
+ const BLOCKED_V4 = [
58
+ ["0.0.0.0", 8],
59
+ ["10.0.0.0", 8],
60
+ ["100.64.0.0", 10], // CGNAT
61
+ ["127.0.0.0", 8],
62
+ ["169.254.0.0", 16], // link-local, includes 169.254.169.254 cloud metadata
63
+ ["172.16.0.0", 12],
64
+ ["192.0.0.0", 24],
65
+ ["192.168.0.0", 16],
66
+ ["198.18.0.0", 15],
67
+ ["224.0.0.0", 4], // multicast
68
+ ["240.0.0.0", 4], // reserved, includes the broadcast address
69
+ ];
70
+ const BLOCKED_V6 = [
71
+ ["::", 96], // unspecified, loopback and every IPv4-compatible address (::a.b.c.d)
72
+ ["fc00::", 7], // unique local
73
+ ["fe80::", 10], // link-local
74
+ ["ff00::", 8], // multicast
75
+ ["64:ff9b:1::", 48], // local-use NAT64 (RFC 8215)
76
+ ];
77
+ /** The v4 prefix `addr/len` written as a prefix under an IPv6 embedding of 96 leading bits. */
78
+ function embedV4(v4, len, prefix6) {
79
+ const [a, b, c, d] = v4.split(".").map(Number);
80
+ const hi = ((a << 8) | b).toString(16);
81
+ const lo = ((c << 8) | d).toString(16);
82
+ return [`${prefix6}${hi}:${lo}`, 96 + len];
83
+ }
84
+ const blockList = new BlockList();
85
+ for (const [addr, len] of BLOCKED_V4) {
86
+ 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::"]) {
90
+ const [sub, bits] = embedV4(addr, len, prefix6);
91
+ blockList.addSubnet(sub, bits, "ipv6");
92
+ }
93
+ }
94
+ for (const [addr, len] of BLOCKED_V6)
95
+ blockList.addSubnet(addr, len, "ipv6");
96
+ /** True only for a globally routable address. Fail-closed: anything unrecognised is refused. */
97
+ export function isPublicAddress(ip) {
98
+ if (typeof ip !== "string" || ip.includes("%"))
99
+ return false; // zone ids are never public
100
+ const version = isIP(ip);
101
+ if (version !== 4 && version !== 6)
102
+ return false;
103
+ try {
104
+ return !blockList.check(ip, version === 4 ? "ipv4" : "ipv6");
105
+ }
106
+ catch {
107
+ return false;
108
+ }
109
+ }
110
+ export async function defaultResolveHost(host) {
111
+ const records = await lookup(host, { all: true });
112
+ return records.map((r) => r.address);
113
+ }
114
+ /**
115
+ * Reads a response body as UTF-8 text, buffering at most `maxBytes`. The stream is cancelled
116
+ * the moment the cap is crossed, so an oversized body is never held in memory. Null = too big
117
+ * (or no body).
118
+ */
119
+ async function readCappedText(res, maxBytes) {
120
+ if (!res.body)
121
+ return null;
122
+ const reader = res.body.getReader();
123
+ const chunks = [];
124
+ let received = 0;
125
+ for (;;) {
126
+ const { done, value } = await reader.read();
127
+ if (done)
128
+ break;
129
+ received += value.byteLength;
130
+ if (received > maxBytes) {
131
+ await reader.cancel().catch(() => undefined);
132
+ return null;
133
+ }
134
+ chunks.push(value);
135
+ }
136
+ return new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks));
137
+ }
138
+ /** True when a client_id is URL-shaped, i.e. a CIMD identifier (valid or not). */
139
+ export function isUrlShapedClientId(clientId) {
140
+ return /^https?:\/\//i.test(clientId);
141
+ }
142
+ /**
143
+ * Fetches and validates a Client ID Metadata Document. Returns null for ANY failure — an
144
+ * unreachable, oversized, malformed, private-addressed or self-contradicting document is an
145
+ * unknown client, never a partial one.
146
+ *
147
+ * Limitation (documented in docs/oauth-stores.md): the address check resolves the host and
148
+ * the fetch resolves it again, so a DNS-rebinding window exists. A hostile-network deployment
149
+ * injects a `fetchImpl` that pins the validated address (for example an undici dispatcher).
150
+ */
151
+ export async function resolveCimdClient(clientId, options = {}) {
152
+ const { fetchImpl = fetch, resolveHost = defaultResolveHost } = options;
153
+ const maxBytes = options.maxBytes ?? 16 * 1024;
154
+ const timeoutMs = options.timeoutMs ?? 5000;
155
+ let url;
156
+ try {
157
+ url = new URL(clientId);
158
+ }
159
+ catch {
160
+ return null;
161
+ }
162
+ if (url.protocol !== "https:" || url.username || url.password || url.hash)
163
+ return null;
164
+ if (url.pathname === "/" || url.pathname === "")
165
+ return null; // the spec requires a path
166
+ if (url.href !== clientId)
167
+ return null; // client_id is compared as a string; no normalisation
168
+ try {
169
+ const host = url.hostname.replace(/^\[|\]$/g, "");
170
+ const addresses = isIP(host) ? [host] : await resolveHost(host);
171
+ if (addresses.length === 0 || !addresses.every(isPublicAddress))
172
+ return null;
173
+ const res = await fetchImpl(url, {
174
+ redirect: "error",
175
+ headers: { Accept: "application/json" },
176
+ signal: AbortSignal.timeout(timeoutMs),
177
+ });
178
+ if (!res.ok)
179
+ return null;
180
+ const text = await readCappedText(res, maxBytes);
181
+ if (text === null)
182
+ return null;
183
+ const doc = JSON.parse(text);
184
+ if (doc.client_id !== clientId)
185
+ return null;
186
+ const method = doc.token_endpoint_auth_method;
187
+ if (method !== undefined && method !== "none")
188
+ return null; // public clients only
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 {
195
+ clientId,
196
+ redirectUris: uris,
197
+ ...(typeof doc.client_name === "string" ? { clientName: doc.client_name.slice(0, 200) } : {}),
198
+ };
199
+ }
200
+ catch {
201
+ return null;
202
+ }
203
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Authorization-code store: an INTERFACE (cloud-identity's `AuthorizationCodeStore`) with an
3
+ * in-memory adapter for tests and single-process development. The durable-adapter contract
4
+ * (atomic consume, TTL, digest-only storage) is documented in docs/oauth-stores.md.
5
+ *
6
+ * The in-memory adapter is NOT for a multi-instance deployment: codes live in one process,
7
+ * so a second instance cannot redeem a code issued by the first.
8
+ */
9
+ import type { AuthorizationCodeRecord, AuthorizationCodeStore, ConsumeCodeResult } from "@vantageos/cloud-identity";
10
+ export type { AuthorizationCodeRecord, AuthorizationCodeStore, ConsumeCodeResult };
11
+ export declare function createInMemoryCodeStore(): AuthorizationCodeStore;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Authorization-code store: an INTERFACE (cloud-identity's `AuthorizationCodeStore`) with an
3
+ * in-memory adapter for tests and single-process development. The durable-adapter contract
4
+ * (atomic consume, TTL, digest-only storage) is documented in docs/oauth-stores.md.
5
+ *
6
+ * The in-memory adapter is NOT for a multi-instance deployment: codes live in one process,
7
+ * so a second instance cannot redeem a code issued by the first.
8
+ */
9
+ export function createInMemoryCodeStore() {
10
+ const live = new Map();
11
+ const used = new Set();
12
+ return {
13
+ async put(record) {
14
+ live.set(record.codeHash, record);
15
+ },
16
+ // Atomic by construction: this body has no await between the read and the delete, and
17
+ // JavaScript runs it to completion, so two concurrent consumes cannot both see "ok".
18
+ async consume(codeHash) {
19
+ const record = live.get(codeHash);
20
+ if (record) {
21
+ live.delete(codeHash);
22
+ used.add(codeHash);
23
+ return { status: "ok", record };
24
+ }
25
+ return used.has(codeHash) ? { status: "already-used" } : { status: "unknown" };
26
+ },
27
+ };
28
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Server-rendered organisation picker and consent page (FR/EN by `Accept-Language`). It posts
3
+ * `state`, `orgId`, `approved` and the hidden `consentToken` to POST /authorize/org. The
4
+ * consentToken IS the CSRF protection of that POST: a cross-site form cannot know it, and
5
+ * cloud-identity's `resumeAuthorize` refuses `approved` without it. Do not drop it.
6
+ */
7
+ import type { OrgPickerModel } from "@vantageos/cloud-identity";
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>;
11
+ export declare function renderOrgPicker(model: OrgPickerModel, locale: McpLocale): string;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Server-rendered organisation picker and consent page (FR/EN by `Accept-Language`). It posts
3
+ * `state`, `orgId`, `approved` and the hidden `consentToken` to POST /authorize/org. The
4
+ * consentToken IS the CSRF protection of that POST: a cross-site form cannot know it, and
5
+ * cloud-identity's `resumeAuthorize` refuses `approved` without it. Do not drop it.
6
+ */
7
+ import { t } from "../i18n.js";
8
+ function escapeHtml(value) {
9
+ return value
10
+ .replace(/&/g, "&amp;")
11
+ .replace(/</g, "&lt;")
12
+ .replace(/>/g, "&gt;")
13
+ .replace(/"/g, "&quot;")
14
+ .replace(/'/g, "&#39;");
15
+ }
16
+ const PICKER_STYLE = "body{font-family:system-ui,sans-serif;max-width:32rem;margin:3rem auto;padding:0 1rem;line-height:1.5}" +
17
+ "fieldset{border:1px solid #888;padding:1rem}label{display:block;margin:.5rem 0}" +
18
+ "button{font:inherit;padding:.5rem 1rem;margin-right:.5rem}";
19
+ /** CSP for the picker page: no scripts, no external assets, one hashed inline style. */
20
+ export async function pickerContentSecurityPolicy() {
21
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(PICKER_STYLE));
22
+ const hash = btoa(String.fromCharCode(...new Uint8Array(digest)));
23
+ return [
24
+ "default-src 'none'",
25
+ `style-src 'sha256-${hash}'`,
26
+ "base-uri 'none'",
27
+ "frame-ancestors 'none'",
28
+ ].join("; ");
29
+ }
30
+ export function renderOrgPicker(model, locale) {
31
+ const clientName = model.client.clientName ?? model.client.clientId;
32
+ const only = model.organizations.length === 1;
33
+ const orgs = model.organizations
34
+ .map((o, i) => `<label><input type="radio" name="orgId" value="${escapeHtml(o.id)}" required${only && i === 0 ? " checked" : ""}> ${escapeHtml(o.name)} <small>(${escapeHtml(o.role)})</small></label>`)
35
+ .join("\n");
36
+ const title = escapeHtml(t(locale, "oauth.pickerTitle").replace("{client}", clientName));
37
+ const intro = escapeHtml(t(locale, "oauth.pickerIntro").replaceAll("{client}", clientName));
38
+ return `<!doctype html>
39
+ <html lang="${locale}">
40
+ <head>
41
+ <meta charset="utf-8">
42
+ <meta name="viewport" content="width=device-width, initial-scale=1">
43
+ <meta name="referrer" content="no-referrer">
44
+ <title>${title}</title>
45
+ <style>${PICKER_STYLE}</style>
46
+ </head>
47
+ <body>
48
+ <main>
49
+ <h1>${title}</h1>
50
+ <p>${intro}</p>
51
+ <form method="post" action="/authorize/org">
52
+ <input type="hidden" name="state" value="${escapeHtml(model.state)}">
53
+ <input type="hidden" name="consentToken" value="${escapeHtml(model.consentToken ?? "")}">
54
+ <fieldset>
55
+ <legend>${escapeHtml(t(locale, "oauth.pickerOrganisation"))}</legend>
56
+ ${orgs}
57
+ </fieldset>
58
+ <p>
59
+ <button type="submit" name="approved" value="true">${escapeHtml(t(locale, "oauth.approve"))}</button>
60
+ <button type="submit" name="approved" value="false" formnovalidate>${escapeHtml(t(locale, "oauth.deny"))}</button>
61
+ </p>
62
+ </form>
63
+ </main>
64
+ </body>
65
+ </html>
66
+ `;
67
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Environment-derived configuration of the authorization server. FAIL-CLOSED: when any
3
+ * required value is absent (or malformed) the result is `{ ok: false, missing }`, naming the
4
+ * variables — never their values — and every authorization route answers 503. There is no
5
+ * auto-approve path and no default secret.
6
+ */
7
+ import { type JWK } from "jose";
8
+ export declare const OAUTH_ENV: {
9
+ readonly publicBaseUrl: "PUBLIC_BASE_URL";
10
+ readonly stateSecret: "AUTHORIZE_STATE_SECRET";
11
+ readonly clerkIssuer: "CLERK_DOMAIN";
12
+ readonly signInUrl: "AUTHORIZE_SIGN_IN_URL";
13
+ readonly callbackUrl: "AUTHORIZE_CALLBACK_URL";
14
+ readonly clerkSecretKey: "CLERK_SECRET_KEY";
15
+ readonly extraAuthorizedParties: "AUTHORIZE_AUTHORIZED_PARTIES";
16
+ readonly signingJwk: "MCP_OAUTH_SIGNING_JWK";
17
+ };
18
+ /** Scope granted when a client names none: MCP access plus the OIDC subject. */
19
+ export declare const DEFAULT_SCOPE = "mcp:full openid";
20
+ export declare const SUPPORTED_SCOPES: readonly ["mcp:full", "openid", "email"];
21
+ export interface AuthorizeSettings {
22
+ baseUrl: string;
23
+ /** The protected resource identifier (RFC 8707) and the access-token audience. */
24
+ resource: string;
25
+ stateSecret: string;
26
+ clerkIssuer: string;
27
+ signInUrl: string;
28
+ callbackUrl: string;
29
+ authorizedParties: string[];
30
+ /** Private signing key (RS256). Its public half is published at /.well-known/jwks.json. */
31
+ signingJwk: JWK & {
32
+ kid: string;
33
+ };
34
+ hasClerkSecretKey: boolean;
35
+ }
36
+ export type AuthorizeSettingsResult = {
37
+ ok: true;
38
+ settings: AuthorizeSettings;
39
+ } | {
40
+ ok: false;
41
+ missing: string[];
42
+ };
43
+ /**
44
+ * Reads the settings from `env`. `clerkCollaboratorsInjected` is true when the caller supplied
45
+ * its own Clerk Backend access (tests, or a deployment with another membership source): the
46
+ * secret key is then not required.
47
+ */
48
+ export declare function readAuthorizeSettings(env: NodeJS.ProcessEnv, clerkCollaboratorsInjected?: boolean): Promise<AuthorizeSettingsResult>;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Environment-derived configuration of the authorization server. FAIL-CLOSED: when any
3
+ * required value is absent (or malformed) the result is `{ ok: false, missing }`, naming the
4
+ * variables — never their values — and every authorization route answers 503. There is no
5
+ * auto-approve path and no default secret.
6
+ */
7
+ import { calculateJwkThumbprint } from "jose";
8
+ export const OAUTH_ENV = {
9
+ publicBaseUrl: "PUBLIC_BASE_URL",
10
+ stateSecret: "AUTHORIZE_STATE_SECRET",
11
+ clerkIssuer: "CLERK_DOMAIN",
12
+ signInUrl: "AUTHORIZE_SIGN_IN_URL",
13
+ callbackUrl: "AUTHORIZE_CALLBACK_URL",
14
+ clerkSecretKey: "CLERK_SECRET_KEY",
15
+ extraAuthorizedParties: "AUTHORIZE_AUTHORIZED_PARTIES",
16
+ signingJwk: "MCP_OAUTH_SIGNING_JWK",
17
+ };
18
+ /** 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"];
21
+ function originOf(url) {
22
+ try {
23
+ return new URL(url).origin;
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ async function parseSigningJwk(raw) {
30
+ try {
31
+ const jwk = JSON.parse(raw);
32
+ if (jwk.kty !== "RSA" || typeof jwk.d !== "string" || !jwk.n || !jwk.e)
33
+ return null;
34
+ // The key id is DERIVED from the key (RFC 7638 thumbprint) when the operator did not set one.
35
+ const kid = jwk.kid ?? (await calculateJwkThumbprint(jwk));
36
+ return { ...jwk, kid, alg: "RS256", use: "sig" };
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ /**
43
+ * Reads the settings from `env`. `clerkCollaboratorsInjected` is true when the caller supplied
44
+ * its own Clerk Backend access (tests, or a deployment with another membership source): the
45
+ * secret key is then not required.
46
+ */
47
+ export async function readAuthorizeSettings(env, clerkCollaboratorsInjected = false) {
48
+ const missing = [];
49
+ const stateSecret = env[OAUTH_ENV.stateSecret] ?? "";
50
+ if (stateSecret.length < 32)
51
+ missing.push(OAUTH_ENV.stateSecret);
52
+ const clerkIssuer = (env[OAUTH_ENV.clerkIssuer] ?? "").replace(/\/+$/, "");
53
+ if (!originOf(clerkIssuer))
54
+ missing.push(OAUTH_ENV.clerkIssuer);
55
+ const signInUrl = env[OAUTH_ENV.signInUrl] ?? "";
56
+ if (!originOf(signInUrl))
57
+ missing.push(OAUTH_ENV.signInUrl);
58
+ const callbackUrl = env[OAUTH_ENV.callbackUrl] ?? "";
59
+ if (!originOf(callbackUrl))
60
+ missing.push(OAUTH_ENV.callbackUrl);
61
+ const baseUrl = (env[OAUTH_ENV.publicBaseUrl] ?? "").replace(/\/+$/, "");
62
+ if (!originOf(baseUrl))
63
+ missing.push(OAUTH_ENV.publicBaseUrl);
64
+ const hasClerkSecretKey = (env[OAUTH_ENV.clerkSecretKey] ?? "") !== "";
65
+ if (!hasClerkSecretKey && !clerkCollaboratorsInjected)
66
+ missing.push(OAUTH_ENV.clerkSecretKey);
67
+ const signingJwk = await parseSigningJwk(env[OAUTH_ENV.signingJwk] ?? "");
68
+ if (!signingJwk)
69
+ missing.push(OAUTH_ENV.signingJwk);
70
+ if (missing.length > 0 || !signingJwk)
71
+ return { ok: false, missing };
72
+ // A Clerk session token minted for ANOTHER application carries that application's origin as
73
+ // `azp`; only this deployment's own origins are accepted.
74
+ const parties = new Set();
75
+ for (const url of [signInUrl, callbackUrl, baseUrl]) {
76
+ const origin = originOf(url);
77
+ if (origin)
78
+ parties.add(origin);
79
+ }
80
+ for (const extra of (env[OAUTH_ENV.extraAuthorizedParties] ?? "")
81
+ .split(",")
82
+ .map((s) => s.trim())
83
+ .filter(Boolean)) {
84
+ parties.add(extra);
85
+ }
86
+ return {
87
+ ok: true,
88
+ settings: {
89
+ baseUrl,
90
+ resource: `${baseUrl}/mcp`,
91
+ stateSecret,
92
+ clerkIssuer,
93
+ signInUrl,
94
+ callbackUrl,
95
+ authorizedParties: [...parties],
96
+ signingJwk,
97
+ hasClerkSecretKey,
98
+ },
99
+ };
100
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Access-token signing. cloud-identity decides WHAT claims a token carries (the verified
3
+ * claims bound to the redeemed code); this module only signs them, as an RS256 JWT, with the
4
+ * deployment's key. The resource server (@vantageos/cloud-identity `verifyMcpAccessToken`)
5
+ * verifies the result against the public half published at /.well-known/jwks.json.
6
+ */
7
+ import type { AuthorizedTokenClaims } from "@vantageos/cloud-identity";
8
+ import { type JWK } from "jose";
9
+ export declare const ACCESS_TOKEN_TTL_SECONDS = 3600;
10
+ export interface TokenIssuer {
11
+ kid: string;
12
+ /** Public half only; safe to publish. */
13
+ publicJwk: JWK;
14
+ jwks: {
15
+ keys: JWK[];
16
+ };
17
+ sign(claims: AuthorizedTokenClaims, issuer: string, nowMs: number): Promise<string>;
18
+ verify(token: string, issuer: string, audience: string): Promise<AuthorizedTokenClaims>;
19
+ }
20
+ export declare function createTokenIssuer(privateJwk: JWK & {
21
+ kid: string;
22
+ }): Promise<TokenIssuer>;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Access-token signing. cloud-identity decides WHAT claims a token carries (the verified
3
+ * claims bound to the redeemed code); this module only signs them, as an RS256 JWT, with the
4
+ * deployment's key. The resource server (@vantageos/cloud-identity `verifyMcpAccessToken`)
5
+ * verifies the result against the public half published at /.well-known/jwks.json.
6
+ */
7
+ import { SignJWT, createLocalJWKSet, importJWK, jwtVerify } from "jose";
8
+ export const ACCESS_TOKEN_TTL_SECONDS = 3600;
9
+ export async function createTokenIssuer(privateJwk) {
10
+ const key = await importJWK(privateJwk, "RS256");
11
+ // Public members only: kty/n/e + metadata. Every private member (d, p, q, dp, dq, qi) is left out.
12
+ const publicJwk = {
13
+ kty: "RSA",
14
+ n: privateJwk.n,
15
+ e: privateJwk.e,
16
+ kid: privateJwk.kid,
17
+ alg: "RS256",
18
+ use: "sig",
19
+ };
20
+ const jwks = { keys: [publicJwk] };
21
+ const verifyKey = createLocalJWKSet(jwks);
22
+ return {
23
+ kid: privateJwk.kid,
24
+ publicJwk,
25
+ jwks,
26
+ async sign(claims, issuer, nowMs) {
27
+ const iat = Math.floor(nowMs / 1000);
28
+ return new SignJWT({
29
+ org_id: claims.org_id,
30
+ org_slug: claims.org_slug,
31
+ org_role: claims.org_role,
32
+ client_id: claims.client_id,
33
+ scope: claims.scope,
34
+ })
35
+ .setProtectedHeader({ alg: "RS256", kid: privateJwk.kid, typ: "at+jwt" })
36
+ .setIssuer(issuer)
37
+ .setAudience(claims.aud)
38
+ .setSubject(claims.sub)
39
+ .setIssuedAt(iat)
40
+ .setExpirationTime(iat + ACCESS_TOKEN_TTL_SECONDS)
41
+ .setJti(crypto.randomUUID())
42
+ .sign(key);
43
+ },
44
+ async verify(token, issuer, audience) {
45
+ const { payload } = await jwtVerify(token, verifyKey, {
46
+ issuer,
47
+ audience,
48
+ algorithms: ["RS256"],
49
+ });
50
+ return {
51
+ sub: String(payload.sub),
52
+ org_id: String(payload.org_id),
53
+ org_slug: payload.org_slug ?? null,
54
+ org_role: String(payload.org_role),
55
+ aud: audience,
56
+ client_id: String(payload.client_id),
57
+ scope: String(payload.scope),
58
+ };
59
+ },
60
+ };
61
+ }
package/dist/stdio.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Stdio transport (local/self-host mode, e.g. Claude Code / Cursor local
3
- * config). Trust model: the local process invoking stdio IS the caller —
4
- * no auth challenge on this transport. The server definition (tools) lives
5
- * once in mcpServerFactory.ts, shared with the HTTP transport — this file
6
- * only wires the official SDK stdio Transport onto that shared server.
2
+ * Stdio transport (local/self-host mode, e.g. Claude Code / Cursor local config). Trust
3
+ * model: the local process invoking stdio IS the caller — no auth challenge on this
4
+ * transport. The server definition lives once in mcpServerFactory.ts, shared with the HTTP
5
+ * transport. `serveStdio` owns the era decision: a 2026-07-28 client opens with
6
+ * `server/discover`, a 2025-era client with `initialize`, and one instance from the factory
7
+ * is pinned for the connection either way.
7
8
  */
8
- export declare function startStdioServer(): Promise<void>;
9
+ export declare function startStdioServer(): void;
package/dist/stdio.js CHANGED
@@ -1,26 +1,27 @@
1
1
  /**
2
- * Stdio transport (local/self-host mode, e.g. Claude Code / Cursor local
3
- * config). Trust model: the local process invoking stdio IS the caller —
4
- * no auth challenge on this transport. The server definition (tools) lives
5
- * once in mcpServerFactory.ts, shared with the HTTP transport — this file
6
- * only wires the official SDK stdio Transport onto that shared server.
2
+ * Stdio transport (local/self-host mode, e.g. Claude Code / Cursor local config). Trust
3
+ * model: the local process invoking stdio IS the caller — no auth challenge on this
4
+ * transport. The server definition lives once in mcpServerFactory.ts, shared with the HTTP
5
+ * transport. `serveStdio` owns the era decision: a 2026-07-28 client opens with
6
+ * `server/discover`, a 2025-era client with `initialize`, and one instance from the factory
7
+ * is pinned for the connection either way.
7
8
  */
8
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
+ import { serveStdio } from "@modelcontextprotocol/server/stdio";
9
10
  import { createBoilerplateMcpServer } from "./mcpServerFactory.js";
10
- export async function startStdioServer() {
11
- // `requireTenant` is otherwise only reachable from the OAuth-tier HTTP
12
- // transport (see httpApp.ts). This env toggle exists solely so the eval
13
- // suite (evals/evals.json "list-items-requires-tenant-refusal") can
14
- // exercise the ALREADY-EXISTING `list_items` tenant-refusal branch in
15
- // mcpServerFactory.ts through the real stdio transport, without adding a
16
- // new code path. Unset by default — normal stdio usage is unaffected.
11
+ export function startStdioServer() {
12
+ // `requireTenant` is otherwise only reachable from the OAuth-tier HTTP transport (see
13
+ // httpApp.ts). This env toggle exists solely so the eval suite (evals/evals.json
14
+ // "list-items-requires-tenant-refusal") can exercise the existing `list_items`
15
+ // tenant-refusal branch through the real stdio transport. Unset by default.
17
16
  const requireTenant = process.env.MCP_BOILERPLATE_REQUIRE_TENANT === "1";
18
- const server = createBoilerplateMcpServer({ requireTenant });
19
- const transport = new StdioServerTransport();
20
- await server.connect(transport);
21
- process.stderr.write("[mcp-boilerplate] stdio server started — 1 tool available\n");
17
+ const uiDomain = process.env.MCP_UI_DOMAIN || undefined;
18
+ serveStdio(() => createBoilerplateMcpServer({ requireTenant, uiDomain }));
19
+ process.stderr.write("[mcp-boilerplate] stdio server started — 3 tools available\n");
22
20
  }
23
- startStdioServer().catch((err) => {
21
+ try {
22
+ startStdioServer();
23
+ }
24
+ catch (err) {
24
25
  process.stderr.write(`[mcp-boilerplate] fatal: ${err instanceof Error ? err.message : String(err)}\n`);
25
26
  process.exit(1);
26
- });
27
+ }