@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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +9 -0
- package/README.fr.md +8 -1
- package/README.md +8 -1
- package/dist/locales/en.json +3 -0
- package/dist/locales/fr.json +3 -0
- package/dist/oauth/authorizationServer.js +44 -4
- package/dist/oauth/picker.d.ts +11 -0
- package/dist/oauth/picker.js +27 -0
- package/dist/oauth/settings.d.ts +5 -0
- package/dist/oauth/settings.js +5 -0
- package/mcp-server.json +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vantage-mcp-boilerplate",
|
|
3
3
|
"displayName": "VantageOS MCP Boilerplate",
|
|
4
|
-
"version": "0.6.
|
|
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
|
|
package/dist/locales/en.json
CHANGED
|
@@ -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.",
|
package/dist/locales/fr.json
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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),
|
package/dist/oauth/picker.d.ts
CHANGED
|
@@ -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;
|
package/dist/oauth/picker.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/oauth/settings.d.ts
CHANGED
|
@@ -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";
|
package/dist/oauth/settings.js
CHANGED
|
@@ -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vantageos/mcp-boilerplate",
|
|
3
|
-
"version": "0.6.
|
|
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.
|
|
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"
|