@vantageos/mcp-boilerplate 0.6.2 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vantage-mcp-boilerplate",
3
3
  "displayName": "VantageOS MCP Boilerplate",
4
- "version": "0.6.2",
4
+ "version": "0.6.3",
5
5
  "description": "Clonable MCP server template: SDK v2 on protocol 2026-07-28 (stdio + stateless Streamable HTTP), its own OAuth 2.1 authorization server (Clerk person + organisation), MCP Apps UI, FR/EN i18n and a declarative eval harness. Scaffold, extend, publish.",
6
6
  "author": {
7
7
  "name": "ElPi Corp / Laurent Perello"
package/CHANGELOG.md CHANGED
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.3] - 2026-11-01
11
+
12
+ ### Fixed
13
+ - The consent approval (and denial) no longer breaks for clients whose redirect target redirects again (VS Code: `vscode.dev/redirect`, then `vscode://` or a loopback). A browser applies the consent page's `form-action` to every redirect hop of the form submission, so the 302 to the client was blocked at the client's own next hop. The POST to `/authorize/org` now answers a same-origin hand-off page (200, meta refresh plus a visible link, CSP `default-src 'none'; form-action 'none'; frame-ancestors 'none'`), which ends the form submission here. The return from sign-in (a GET) still answers a plain redirect.
14
+
15
+ ### Added
16
+ - `AUTHORIZE_ALLOWED_ORG_IDS` (comma-separated Clerk organisation ids): the consent offers only the person's organisations that this server authorises. Any other membership is not listed, an approval for one is refused with 403, and a person with none of them gets a 403 with a localized message instead of a picker. Unset, behaviour is unchanged.
17
+
10
18
  ## [0.6.2] - 2026-11-01
11
19
 
12
20
  ### Fixed
@@ -498,6 +506,7 @@ Initial published release.
498
506
  - MIT license, clonable as a starter template.
499
507
 
500
508
  [Unreleased]: https://github.com/elpiarthera/vantage-mcp-boilerplate/compare/v0.4.0...HEAD
509
+ [0.6.3]: https://www.npmjs.com/package/@vantageos/mcp-boilerplate/v/0.6.3
501
510
  [0.6.2]: https://www.npmjs.com/package/@vantageos/mcp-boilerplate/v/0.6.2
502
511
  [0.6.1]: https://www.npmjs.com/package/@vantageos/mcp-boilerplate/v/0.6.1
503
512
  [0.6.0]: https://www.npmjs.com/package/@vantageos/mcp-boilerplate/v/0.6.0
package/README.fr.md CHANGED
@@ -259,7 +259,14 @@ fonctionne sur un serveur que vous hébergez, dans cet ordre :
259
259
  VantagePeers : sombre par défaut, clair quand le système de la personne le demande, une
260
260
  carte par organisation avec son rôle. Elle ne charge aucune ressource externe et porte donc
261
261
  ses propres tokens. Réglez `AUTHORIZE_BRAND_NAME` pour afficher le nom de votre produit
262
- au-dessus du titre ; rien n'est affiché par défaut.
262
+ au-dessus du titre ; rien n'est affiché par défaut. Réglez `AUTHORIZE_ALLOWED_ORG_IDS`
263
+ (identifiants d'organisation Clerk séparés par des virgules) pour ne proposer que les
264
+ organisations que ce serveur autorise : les autres appartenances ne sont pas listées, une
265
+ validation pour l'une d'elles est refusée (403), et une personne qui n'en a aucune reçoit un
266
+ 403 avec un message au lieu du choix. Non réglé, toutes les appartenances sont proposées.
267
+ Valider ou refuser répond une courte page de la même origine qui renvoie le navigateur vers
268
+ le client, pour que le `form-action` de la page ne bloque pas un client dont la cible de
269
+ redirection redirige à nouveau (le `vscode.dev/redirect` de VS Code).
263
270
 
264
271
  ### Multi-tenance
265
272
 
package/README.md CHANGED
@@ -245,7 +245,14 @@ server you host, in this order:
245
245
  6. **The consent page.** The organisation picker uses the VantagePeers dashboard design: dark
246
246
  by default, light when the person's system asks for it, one card per organisation with its
247
247
  role. It loads no external asset, so it carries its own tokens. Set `AUTHORIZE_BRAND_NAME`
248
- to show your product's name above the title; nothing is shown by default.
248
+ to show your product's name above the title; nothing is shown by default. Set
249
+ `AUTHORIZE_ALLOWED_ORG_IDS` (comma-separated Clerk organisation ids) to offer only the
250
+ organisations this server authorises: other memberships are not listed, an approval for one
251
+ of them is refused (403), and a person with none of them gets a 403 with a message instead of
252
+ a picker. Unset, every membership is offered.
253
+ Approving or denying answers a short same-origin page that sends the browser on to the
254
+ client, so the page's `form-action` does not block a client whose redirect target redirects
255
+ again (VS Code's `vscode.dev/redirect`).
249
256
 
250
257
  ### Multi-tenancy
251
258
 
@@ -18,6 +18,9 @@
18
18
  "oauth": {
19
19
  "pickerTitle": "Connect {client}",
20
20
  "pickerIntro": "{client} asks to act on your behalf. Choose the organisation it may reach, then approve or deny.",
21
+ "handoffTitle": "Returning to the application",
22
+ "handoffLink": "Continue to the application",
23
+ "pickerNoAllowedOrganisation": "None of your organisations is authorised on this server.",
21
24
  "pickerDestination": "After approval you are sent to:",
22
25
  "pickerScope": "Requested access:",
23
26
  "pickerUnverified": "Unverified client: it registered itself automatically and nobody has vouched for it. Approve only if you recognise the address above.",
@@ -18,6 +18,9 @@
18
18
  "oauth": {
19
19
  "pickerTitle": "Connecter {client}",
20
20
  "pickerIntro": "{client} demande à agir en votre nom. Choisissez l'organisation à laquelle il peut accéder, puis approuvez ou refusez.",
21
+ "handoffTitle": "Retour à l'application",
22
+ "handoffLink": "Continuer vers l'application",
23
+ "pickerNoAllowedOrganisation": "Aucune de vos organisations n'est autorisée sur ce serveur.",
21
24
  "pickerDestination": "Redirection après validation vers :",
22
25
  "pickerScope": "Accès demandé :",
23
26
  "pickerUnverified": "Client non vérifié : il s'est enregistré automatiquement et personne ne s'en porte garant. N'approuvez que si vous reconnaissez l'adresse ci-dessus.",
@@ -29,7 +29,7 @@ import { createClerkBackendCollaborators } from "./clerk.js";
29
29
  import { ASSERTION_SIGNING_ALGS, createClientAssertionVerifier } from "./clientAssertion.js";
30
30
  import { RegistryFullError, createInMemoryClientRegistry, isAllowedRedirectUri, isUrlShapedClientId, resolveCimdClient, } from "./clientRegistry.js";
31
31
  import { createInMemoryCodeStore } from "./codeStore.js";
32
- import { pickerContentSecurityPolicy, renderOrgPicker } from "./picker.js";
32
+ import { HANDOFF_CSP, pickerContentSecurityPolicy, renderHandoff, renderOrgPicker, } from "./picker.js";
33
33
  import { createRegisterLimiter } from "./registerLimiter.js";
34
34
  import { DEFAULT_SCOPE, OAUTH_ENV, SUPPORTED_SCOPES, isDevClerkInstanceOnRemoteServer, readAuthorizeSettings, } from "./settings.js";
35
35
  import { ACCESS_TOKEN_TTL_SECONDS, createTokenIssuer } from "./tokenIssuer.js";
@@ -198,6 +198,16 @@ export function createAuthorizationServer(deps = {}) {
198
198
  const client = await lookupClient(model.client.clientId);
199
199
  return (client?.redirectUris ?? []).filter((u) => !/^https?:/i.test(u));
200
200
  };
201
+ /** The organisations this server authorises (AUTHORIZE_ALLOWED_ORG_IDS), or undefined when every membership is. */
202
+ const allowedOrgIds = () => {
203
+ const raw = env[OAUTH_ENV.allowedOrgIds]?.trim();
204
+ if (!raw)
205
+ return undefined;
206
+ return new Set(raw
207
+ .split(",")
208
+ .map((s) => s.trim())
209
+ .filter(Boolean));
210
+ };
201
211
  const respond = async (c, outcome, settings) => {
202
212
  noStore(c);
203
213
  c.header("Referrer-Policy", "no-referrer");
@@ -208,12 +218,33 @@ export function createAuthorizationServer(deps = {}) {
208
218
  // RFC 9207 section 2: identify the issuer in the authorization response.
209
219
  const back = new URL(outcome.url);
210
220
  back.searchParams.set("iss", settings.baseUrl);
221
+ // The consent form's POST ends here, in a same-origin page, so its form-action does not follow the client's
222
+ // own redirect hops (renderHandoff). A GET (the return from sign-in) still answers a plain redirect.
223
+ if (c.req.method === "POST") {
224
+ c.header("Content-Security-Policy", HANDOFF_CSP);
225
+ const locale = detectLocaleFromAcceptLanguage(c.req.header("accept-language"));
226
+ return c.html(renderHandoff(back.toString(), locale));
227
+ }
211
228
  return c.redirect(back.toString(), 302);
212
229
  }
213
230
  case "org-picker": {
231
+ // Only this server's authorised organisations are offered (data per server).
232
+ const allowed = allowedOrgIds();
233
+ let model = outcome.model;
234
+ if (allowed) {
235
+ const organizations = model.organizations.filter((o) => allowed.has(o.id));
236
+ if (organizations.length === 0) {
237
+ const locale = detectLocaleFromAcceptLanguage(c.req.header("accept-language"));
238
+ return c.json({
239
+ error: "access_denied",
240
+ error_description: t(locale, "oauth.pickerNoAllowedOrganisation"),
241
+ }, 403);
242
+ }
243
+ model = { ...model, organizations };
244
+ }
214
245
  let csp;
215
246
  try {
216
- csp = await pickerContentSecurityPolicy(await redirectTargetsOf(outcome.model));
247
+ csp = await pickerContentSecurityPolicy(await redirectTargetsOf(model));
217
248
  }
218
249
  catch (err) {
219
250
  // Fail closed: without a parseable destination the page is not rendered at all.
@@ -222,9 +253,9 @@ export function createAuthorizationServer(deps = {}) {
222
253
  }
223
254
  c.header("Content-Security-Policy", csp);
224
255
  const locale = detectLocaleFromAcceptLanguage(c.req.header("accept-language"));
225
- return c.html(renderOrgPicker(outcome.model, locale, {
256
+ return c.html(renderOrgPicker(model, locale, {
226
257
  // Only a URL-shaped (CIMD) client_id is bound to a domain its owner controls.
227
- unverified: !isUrlShapedClientId(outcome.model.client.clientId),
258
+ unverified: !isUrlShapedClientId(model.client.clientId),
228
259
  brandName: env[OAUTH_ENV.brandName]?.trim() || undefined,
229
260
  }));
230
261
  }
@@ -463,6 +494,15 @@ export function createAuthorizationServer(deps = {}) {
463
494
  }, cfg, authDeps);
464
495
  return respond(c, denied, result.settings);
465
496
  }
497
+ // An approval for an organisation this server does not authorise never reaches the code issuer.
498
+ const allowed = allowedOrgIds();
499
+ if (allowed && !allowed.has(field("orgId") ?? "")) {
500
+ const locale = detectLocaleFromAcceptLanguage(c.req.header("accept-language"));
501
+ return c.json({
502
+ error: "access_denied",
503
+ error_description: t(locale, "oauth.pickerNoAllowedOrganisation"),
504
+ }, 403);
505
+ }
466
506
  const outcome = await resumeAuthorize({
467
507
  state: field("state") ?? "",
468
508
  sessionToken: sessionTokenFrom(c),
@@ -19,3 +19,14 @@ export interface ConsentContext {
19
19
  brandName?: string;
20
20
  }
21
21
  export declare function renderOrgPicker(model: OrgPickerModel, locale: McpLocale, consent: ConsentContext): string;
22
+ /** The CSP of the hand-off page: nothing runs, nothing is submitted, nothing frames it. */
23
+ export declare const HANDOFF_CSP = "default-src 'none'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'";
24
+ /**
25
+ * The page that ends the consent form submission inside this origin and sends the browser on to the client.
26
+ *
27
+ * A browser applies the consent page's `form-action` to every redirect hop of the form submission, so answering the
28
+ * POST with a 302 to the client's redirect_uri lets the client's OWN next hop (vscode.dev/redirect -> vscode:// or a
29
+ * loopback) be blocked by our policy. This page is a 200 response: the navigation to the client is a meta refresh (and
30
+ * a visible link), not a form submission, so `form-action` no longer follows it.
31
+ */
32
+ export declare function renderHandoff(target: string, locale: McpLocale): string;
@@ -148,3 +148,30 @@ ${orgs}
148
148
  </html>
149
149
  `;
150
150
  }
151
+ /** The CSP of the hand-off page: nothing runs, nothing is submitted, nothing frames it. */
152
+ export const HANDOFF_CSP = "default-src 'none'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'";
153
+ /**
154
+ * The page that ends the consent form submission inside this origin and sends the browser on to the client.
155
+ *
156
+ * A browser applies the consent page's `form-action` to every redirect hop of the form submission, so answering the
157
+ * POST with a 302 to the client's redirect_uri lets the client's OWN next hop (vscode.dev/redirect -> vscode:// or a
158
+ * loopback) be blocked by our policy. This page is a 200 response: the navigation to the client is a meta refresh (and
159
+ * a visible link), not a form submission, so `form-action` no longer follows it.
160
+ */
161
+ export function renderHandoff(target, locale) {
162
+ const url = escapeHtml(target);
163
+ const title = escapeHtml(t(locale, "oauth.handoffTitle"));
164
+ return `<!doctype html>
165
+ <html lang="${locale}">
166
+ <head>
167
+ <meta charset="utf-8">
168
+ <meta http-equiv="refresh" content="0;url=${url}">
169
+ <title>${title}</title>
170
+ </head>
171
+ <body>
172
+ <p>${title}</p>
173
+ <p><a href="${url}">${escapeHtml(t(locale, "oauth.handoffLink"))}</a></p>
174
+ </body>
175
+ </html>
176
+ `;
177
+ }
@@ -24,6 +24,11 @@ export declare const OAUTH_ENV: {
24
24
  readonly allowInMemoryStores: "MCP_ALLOW_IN_MEMORY_STORES";
25
25
  /** Optional display name shown above the consent page title (default: none). */
26
26
  readonly brandName: "AUTHORIZE_BRAND_NAME";
27
+ /**
28
+ * Optional comma-separated Clerk organisation ids this server authorises. When set, the consent offers only the
29
+ * person's memberships in this list and refuses an approval for any other. Data per server, never code.
30
+ */
31
+ readonly allowedOrgIds: "AUTHORIZE_ALLOWED_ORG_IDS";
27
32
  };
28
33
  /** Scope granted when a client names none: MCP access plus the OIDC subject. */
29
34
  export declare const MCP_SCOPE = "mcp:full";
@@ -24,6 +24,11 @@ export const OAUTH_ENV = {
24
24
  allowInMemoryStores: "MCP_ALLOW_IN_MEMORY_STORES",
25
25
  /** Optional display name shown above the consent page title (default: none). */
26
26
  brandName: "AUTHORIZE_BRAND_NAME",
27
+ /**
28
+ * Optional comma-separated Clerk organisation ids this server authorises. When set, the consent offers only the
29
+ * person's memberships in this list and refuses an approval for any other. Data per server, never code.
30
+ */
31
+ allowedOrgIds: "AUTHORIZE_ALLOWED_ORG_IDS",
27
32
  };
28
33
  /** Scope granted when a client names none: MCP access plus the OIDC subject. */
29
34
  export const MCP_SCOPE = "mcp:full";
package/mcp-server.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-boilerplate",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "capabilities": {
5
5
  "tools": {},
6
6
  "resources": {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vantageos/mcp-boilerplate",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "Clonable MCP server skeleton on the official @modelcontextprotocol/server SDK v2 (protocol 2026-07-28 + 2025 legacy, stdio + stateless Streamable HTTP), with its own OAuth 2.1 authorization server (Clerk person + organisation, cloud-identity), MCP Apps UI, tenant scoping, FR/EN i18n and a declarative eval harness.",
5
5
  "description_fr": "Squelette de serveur MCP clonable sur le SDK officiel @modelcontextprotocol/server v2 (protocole 2026-07-28 + héritage 2025, stdio + Streamable HTTP sans état), avec son propre serveur d'autorisation OAuth 2.1 (personne + organisation Clerk, cloud-identity), UI MCP Apps, scoping tenant, i18n FR/EN et un harnais d'évaluations déclaratif.",
6
6
  "type": "module",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "vantage-mcp-boilerplate",
4
- "version": "0.6.2",
4
+ "version": "0.6.3",
5
5
  "description": "Clonable MCP server template: SDK v2 on protocol 2026-07-28 (stdio + stateless Streamable HTTP), its own OAuth 2.1 authorization server (Clerk person + organisation), MCP Apps UI, FR/EN i18n and a declarative eval harness. Scaffold, extend, publish.",
6
6
  "author": {
7
7
  "name": "ElPi Corp / Laurent Perello"