@vantageos/mcp-boilerplate 0.2.1 → 0.2.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.2.1",
4
+ "version": "0.2.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,96 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.3] - 2026-10-11
11
+
12
+ ### Added
13
+
14
+ - `/authorize/callback` resolves the person's Clerk session with Clerk's own handshake
15
+ (`@vantageos/cloud-identity` `^0.19.0`, `resolveClerkSessionFromRequest`, built on
16
+ `@clerk/backend` `authenticateRequest()`). A return from Clerk's hosted sign-in with no session
17
+ on this host used to answer `403 session-required`; it now redirects through the handshake and
18
+ continues. An explicit Bearer token and injected Clerk collaborators keep their path. The
19
+ publishable key is `CLERK_PUBLISHABLE_KEY` or derived from `CLERK_DOMAIN`.
20
+ - README section "Sign-in with ChatGPT and Claude" (EN + FR): the production instance on the
21
+ server's own registrable domain, the three URLs including `AUTHORIZE_SIGN_IN_URL`, why no
22
+ redirect URL is registered, development-instance limits, how the session arrives, what is and
23
+ is not yet measured.
24
+ - The handshake redirect carries `Cache-Control: no-store`, and a forged or stale
25
+ `__clerk_handshake` continues signed out instead of answering 500.
26
+ - Boot warning (EN + FR) when the Clerk instance is a development one (`sk_test_` key or an
27
+ `accounts.dev` domain) and `PUBLIC_BASE_URL` is not localhost: it names the Home URL
28
+ requirement. A warning, never a refusal.
29
+
30
+ ### Fixed
31
+
32
+ - The Node adapter emitted only the last `Set-Cookie` of a response. A response with N cookies
33
+ now yields N (Clerk's handshake return sets five).
34
+ - The deny route (`POST /authorize/org` with `approved=false`) forwards `consentToken`;
35
+ cloud-identity 0.18 and later verify it on a denial too.
36
+
37
+ - CIMD: a Client ID Metadata Document that declares `token_endpoint_auth_method` other than
38
+ `none` (for example ChatGPT's `private_key_jwt`) is now accepted when its
39
+ `token_endpoint_auth_methods_supported` includes `none`. A document with neither is still
40
+ refused. All other checks (client_id equality, https, SSRF guard, size cap, redirect_uris)
41
+ are unchanged. `/authorize` no longer answers `unknown-client` for ChatGPT.
42
+ - `/token` verifies a `private_key_jwt` client assertion instead of ignoring it: keys from the
43
+ CIMD document's `jwks_uri` (same SSRF-guarded fetch), RS256 only, `iss` = `sub` = `client_id`,
44
+ `aud` = the token endpoint or the issuer, `exp` required (30 s skew), `jti` required and
45
+ accepted once (bounded in-memory set, fail closed when full). Any failure answers 401
46
+ `invalid_client`. A request without an assertion keeps the public-client path (`client_id` +
47
+ PKCE). AS metadata advertises `["none","private_key_jwt"]` and RS256.
48
+
49
+ ## [0.2.2] - 2026-10-10
50
+
51
+ ### Security
52
+
53
+ - Consent page shows the ORIGIN the authorization code is sent to and the requested scope, and
54
+ flags a Dynamic-Client-Registration client as unverified (FR + EN).
55
+ - `/register` rate limit keys on the socket address, or on the `X-Forwarded-For` entry the
56
+ N-th trusted proxy appended (`MCP_TRUSTED_PROXY_COUNT`, default 0); a client-supplied first
57
+ entry no longer bypasses it.
58
+ - In-memory client registry expires registrations (30 days) and evicts the oldest at the cap
59
+ instead of refusing forever; in-memory code store drops expired codes and prunes its used set.
60
+ - `NODE_ENV=production` refuses the in-memory stores unless both durable adapters are injected
61
+ or `MCP_ALLOW_IN_MEMORY_STORES=1` is set (explicit, logged opt-in).
62
+ - The Node entry caps the request body (`MCP_MAX_BODY_BYTES`, default 1 MiB): `413` before any
63
+ handler runs, counted while the body streams in.
64
+ - `/mcp` enforces the `mcp:full` scope on the own-authorization-server tier (`403
65
+ insufficient_scope`).
66
+ - Clerk Backend calls time out (5 s); organisation memberships are paginated past 100 and fail
67
+ loudly rather than truncate past 5 000.
68
+ - `http:` issuer, sign-in and callback URLs are refused outside loopback.
69
+ - A consent denial answers the OAuth error redirect (`error=access_denied`, `state`, `iss`)
70
+ instead of a `403` JSON body; the redirect target is read only from a state the flow verified.
71
+ - `/register` sends no CORS headers unless the origin is listed in `MCP_REGISTER_CORS_ORIGINS`.
72
+ - SSRF guard also blocks the IPv4 ranges embedded in IPv4-translated (`::ffff:0:0/96`) and
73
+ 6to4 (`2002::/16`) addresses, and refuses Teredo (`2001::/32`).
74
+
75
+ - A SET but invalid `MCP_MAX_BODY_BYTES`, `MCP_TRUSTED_PROXY_COUNT`, `MCP_REGISTER_CORS_ORIGINS`
76
+ or `MCP_ALLOW_IN_MEMORY_STORES` refuses to start, naming the variable and what was expected
77
+ (the value is never echoed); unset or empty keeps the default. No silent fallback.
78
+ - SSRF guard reads the IPv4 embedded in ISATAP addresses (interface id `0000:5efe` /
79
+ `0200:5efe`, any prefix) and refuses it when that IPv4 is in a blocked range.
80
+
81
+ - `@vantageos/cloud-identity` ^0.17.0: the consent page reads `OrgPickerModel.redirectOrigin` and a
82
+ denial is `resumeAuthorize({ denied: true })` (a verified redirect-to-client, `iss` appended in
83
+ one place). The local authorize-state parser (`src/oauth/stateBlob.ts`) is removed.
84
+
85
+ ### Changed
86
+
87
+ - `createInMemoryClientRegistry` takes an options object (`maxClients`, `ttlSeconds`, `now`)
88
+ instead of a bare number; `createInMemoryCodeStore` takes `{ now, maxEntries }` and exposes
89
+ `stats()`; `renderOrgPicker` takes a third `ConsentContext` argument (`redirectUri`,
90
+ `unverified`).
91
+ - `tests/toolRecordComplete.test.ts` asserts the four annotations are declared booleans, not
92
+ fixed values (a fork's write tool is rightly `readOnlyHint: false`).
93
+
94
+ ### Dependencies
95
+
96
+ - `hono` 4.13.13, `vitest` 5, `vite` 8. `vite-plugin-singlefile` is removed (its
97
+ `micromatch` -> `braces` chain has an advisory with no patched release); `vite.config.ts`
98
+ inlines the bundle itself. `npm audit`: 0 critical, 0 high.
99
+
10
100
  ## [0.2.1] - 2026-10-10
11
101
 
12
102
  ### Fixed
package/README.fr.md CHANGED
@@ -122,11 +122,79 @@ interfaces avec adaptateurs en mémoire pour les tests et le développement
122
122
  mono-processus. Utilisez des adaptateurs durables en production — contrat dans
123
123
  [`docs/oauth-stores.md`](docs/oauth-stores.md).
124
124
 
125
+ **Garde de production** : avec `NODE_ENV=production`, le serveur refuse de démarrer
126
+ sur les stores en mémoire (codes et clients enregistrés perdus au redémarrage, non
127
+ partagés entre instances) sauf si les deux adaptateurs durables sont injectés ou si
128
+ `MCP_ALLOW_IN_MEMORY_STORES=1` est défini — un choix explicite et journalisé pour un
129
+ déploiement mono-instance. Derrière un reverse proxy, réglez `MCP_TRUSTED_PROXY_COUNT`
130
+ sur le nombre de proxys de confiance (défaut `0`) : la limite de débit de `/register`
131
+ se fonde alors sur l'entrée `X-Forwarded-For` ajoutée par le proxy, jamais sur la
132
+ première, fournie par le client ; avec `0`, elle se fonde sur l'adresse du socket et
133
+ ignore l'en-tête.
134
+
135
+ **Limites de requête et CORS** : Une variable d'exploitation définie mais invalide (par exemple `MCP_MAX_BODY_BYTES=abc`) arrête le serveur au démarrage avec le nom de la variable et la forme attendue, jamais un défaut silencieux ; non définie ou vide, le défaut s'applique. l'entrée Node lit au plus `MCP_MAX_BODY_BYTES`
136
+ (défaut 1 Mio) du corps d'une requête et répond `413` avant tout handler.
137
+ `/register` n'envoie aucun en-tête CORS sauf si l'origine du navigateur figure dans
138
+ `MCP_REGISTER_CORS_ORIGINS` (séparées par des virgules). `/mcp` exige le scope
139
+ `mcp:full` sur le palier serveur d'autorisation propre (`403 insufficient_scope`
140
+ sinon). La page de consentement affiche l'origine vers laquelle le code est envoyé et
141
+ le scope demandé, signale comme non vérifié un client issu de l'enregistrement
142
+ dynamique, et un refus redirige vers le client avec `error=access_denied` (plus
143
+ `state` et `iss`). Les URL `http:` d'émetteur, de connexion et de rappel sont refusées
144
+ hors boucle locale.
145
+
125
146
  **Non inclus** : jetons de rafraîchissement (les jetons d'accès durent une heure),
126
147
  clés d'API par organisation pour les clients hors ChatGPT (ce code n'a jamais eu ce
127
148
  chemin ; c'est un manque, pas une régression), URI de redirection à joker, et liste
128
149
  de révocation des jetons.
129
150
 
151
+ ### Connexion avec ChatGPT et Claude
152
+
153
+ ChatGPT et Claude envoient la personne sur le `/authorize` de ce serveur, qui l'envoie vers la
154
+ page de connexion hébergée par Clerk puis la ramène sur `/authorize/callback`. Pour que cela
155
+ fonctionne sur un serveur que vous hébergez, dans cet ordre :
156
+
157
+ 1. **Utilisez une instance Clerk de PRODUCTION sur le domaine enregistrable du serveur.** Un
158
+ serveur sur `mcp.example.com` prend le domaine Clerk `example.com` : Clerk sert alors
159
+ `clerk.example.com` et `accounts.example.com`. Tout autre domaine relève d'une configuration
160
+ satellite, non mesurée. Ajoutez les CNAME DNS listés par le tableau de bord Clerk. Créez votre propre client OAuth Google
161
+ avec l'URI de redirection autorisée `https://clerk.<domaine>/v1/oauth_callback`, renseignez
162
+ son identifiant et son secret dans le tableau de bord Clerk, et publiez l'écran de
163
+ consentement Google avec une page d'accueil d'application et une politique de
164
+ confidentialité (un écran non publié n'admet que les utilisateurs de test).
165
+ 2. **Réglez les trois URL.** `PUBLIC_BASE_URL` = `https://<serveur>` ; `AUTHORIZE_CALLBACK_URL`
166
+ = `https://<serveur>/authorize/callback` ; `AUTHORIZE_SIGN_IN_URL` = la page de connexion
167
+ hébergée de l'instance : `https://accounts.<domaine>/sign-in` en production,
168
+ `https://<instance>.accounts.dev/sign-in` en développement (l'URL de l'Account Portal,
169
+ affichée dans le tableau de bord Clerk sous **Account Portal**). Ce parcours n'a aucune
170
+ « URL de redirection autorisée » à remplir dans le tableau de bord : la page hébergée ne
171
+ renvoie le navigateur que vers un hôte du domaine de l'instance (production, selon la
172
+ documentation Clerk, pas encore mesuré) ou vers `localhost` / la Home URL (développement,
173
+ mesuré). C'est pourquoi l'étape 1 met le serveur et l'instance sur un même domaine.
174
+ *Mesuré* : la liste `redirect_urls` de l'API Backend n'est pas consultée par les pages
175
+ hébergées.
176
+ 3. **Une instance de développement sert uniquement en local ou en test.** Sa page de connexion
177
+ hébergée ne redirige que vers `localhost` ou vers l'URL d'accueil (Home URL) de l'instance.
178
+ Pour un test distant, réglez l'origine de développement / Home URL de l'instance de
179
+ développement sur l'origine du serveur dans le tableau de bord Clerk, faites le test, puis
180
+ rétablissez-la.
181
+ 4. **Comment la session arrive au serveur.** Après la connexion, le navigateur revient sur
182
+ `/authorize/callback` sans session sur votre hôte. Le serveur la résout avec la poignée de
183
+ main (handshake) propre à Clerk (`@vantageos/cloud-identity` 0.19.0, fondé sur
184
+ `authenticateRequest()` de `@clerk/backend`) : il redirige le navigateur par le point
185
+ d'entrée handshake de Clerk, et Clerk pose `__session` sur votre hôte. Il faut
186
+ `CLERK_SECRET_KEY` et la clé publiable : `CLERK_PUBLISHABLE_KEY`, ou, à défaut, dérivée de
187
+ `CLERK_DOMAIN` sous la forme `pk_test_` / `pk_live_` (selon la clé secrète) +
188
+ base64(`<hôte de l'API Frontend>$`). *Mesuré* : une instance de développement avec un serveur
189
+ distant fonctionne dès que sa Home URL pointe sur le serveur. *Pas encore mesuré* : une
190
+ instance de production sur un domaine enregistrable différent de celui du serveur.
191
+ 5. **Avertissement au démarrage.** Quand l'instance configurée est de développement (clé
192
+ `sk_test_`, ou `CLERK_DOMAIN` sous `accounts.dev`) et que `PUBLIC_BASE_URL` n'est pas
193
+ localhost, le serveur écrit un avertissement au démarrage, en anglais et en français, qui
194
+ nomme l'exigence de Home URL. C'est un avertissement, jamais un refus : il signifie « une
195
+ connexion distante ne marchera que si la Home URL pointe sur ce serveur, ou si vous passez à
196
+ une instance de production ». Il ne contient ni clé ni secret.
197
+
130
198
  ### Multi-tenance
131
199
 
132
200
  `list_items` filtre les lignes par leur `orgId` stocké face à l'organisation
@@ -217,11 +285,11 @@ de `npm publish`.
217
285
  qui ne négocient jamais l'extension.
218
286
 
219
287
  Le widget (`src/ui/`) réutilise une famille `MosaicCard` vendorisée et est empaqueté
220
- en un seul fichier HTML autonome (`vite-plugin-singlefile`,
288
+ en un seul fichier HTML autonome (un plugin d'inlining local dans `vite.config.ts`,
221
289
  `dist/ui-bundle/widget.html`) par `npm run build:ui`, sur un chemin qui n'entre
222
290
  jamais en collision avec `dist/ui/` de tsc.
223
291
 
224
- **Couche d'identité** : `@vantageos/cloud-identity` (^0.15.0) est le seul paquet
292
+ **Couche d'identité** : `@vantageos/cloud-identity` (^0.19.0) est le seul paquet
225
293
  d'identité. Son point d'entrée resource-server (`verifyMcpAccessToken`,
226
294
  `buildProtectedResourceMetadata`) vérifie le jeton Bearer sur `/mcp` ; `jose` ne sert
227
295
  plus qu'à signer les jetons d'accès du serveur d'autorisation. Un jeu de clés
package/README.md CHANGED
@@ -118,10 +118,72 @@ log (names, never values). There is no auto-approve path.
118
118
  with in-memory adapters for tests and single-process development. Use durable
119
119
  adapters in production — contract in [`docs/oauth-stores.md`](docs/oauth-stores.md).
120
120
 
121
+ **Production guard**: with `NODE_ENV=production` the server refuses to start on
122
+ the in-memory stores (codes and registered clients are lost on restart and not
123
+ shared between instances) unless both durable adapters are injected or
124
+ `MCP_ALLOW_IN_MEMORY_STORES=1` is set — an explicit, logged opt-in for a
125
+ single-instance deployment. Behind a reverse proxy set `MCP_TRUSTED_PROXY_COUNT`
126
+ to the number of trusted proxies (default `0`): the `/register` rate limit then
127
+ keys on the proxy-appended `X-Forwarded-For` entry, never on the client-supplied
128
+ first one; with `0` it keys on the socket address and ignores the header.
129
+
130
+ **Request limits and CORS**: An operator variable that is set but invalid (for example `MCP_MAX_BODY_BYTES=abc`) stops the server at startup with the variable's name and the expected form, never a silent default; unset or empty keeps the default. the Node entry reads at most `MCP_MAX_BODY_BYTES`
131
+ (default 1 MiB) of a request body and answers `413` before any handler runs.
132
+ `/register` sends no CORS headers unless the browser origin is listed in
133
+ `MCP_REGISTER_CORS_ORIGINS` (comma-separated). `/mcp` requires the `mcp:full`
134
+ scope on the own-authorization-server tier (`403 insufficient_scope` otherwise).
135
+ The consent page shows the origin the code is sent to and the requested scope,
136
+ flags a Dynamic-Client-Registration client as unverified, and a denial redirects
137
+ to the client with `error=access_denied` (plus `state` and `iss`). `http:` issuer,
138
+ sign-in and callback URLs are refused outside loopback.
139
+
121
140
  **Not included**: refresh tokens (access tokens last one hour), per-organisation
122
141
  API keys for non-ChatGPT clients (this code never had that path; it is a gap, not
123
142
  a regression), wildcard redirect URIs, and a token revocation list.
124
143
 
144
+ ### Sign-in with ChatGPT and Claude
145
+
146
+ ChatGPT and Claude send the person to this server's `/authorize`, which sends them to
147
+ Clerk's hosted sign-in and takes them back to `/authorize/callback`. To make that work on a
148
+ server you host, in this order:
149
+
150
+ 1. **Use a Clerk PRODUCTION instance on the server's own registrable domain.** A server at
151
+ `mcp.example.com` takes the Clerk domain `example.com`, so Clerk serves `clerk.example.com`
152
+ and `accounts.example.com`. Any other domain is a satellite setup and is not measured. Add
153
+ the DNS CNAMEs the Clerk dashboard lists. Create your own Google OAuth client with the authorized redirect URI
154
+ `https://clerk.<domain>/v1/oauth_callback`, put its id and secret in the Clerk dashboard, and
155
+ publish the Google consent screen with an application home page and a privacy policy (an
156
+ unpublished consent screen only lets test users in).
157
+ 2. **Set the three URLs.** `PUBLIC_BASE_URL` = `https://<server>`; `AUTHORIZE_CALLBACK_URL` =
158
+ `https://<server>/authorize/callback`; `AUTHORIZE_SIGN_IN_URL` = the instance's hosted
159
+ sign-in page: `https://accounts.<domain>/sign-in` on production,
160
+ `https://<instance>.accounts.dev/sign-in` on development (the Account Portal URL, shown in
161
+ the Clerk dashboard under **Account Portal**). There is no "allowed redirect URL" to fill in
162
+ the dashboard for this flow: the hosted page sends the browser back only to a host under
163
+ the instance's own domain (production, per Clerk's documentation, not yet measured) or to
164
+ `localhost` / the Home URL (development, measured). This is why step 1 puts the server and
165
+ the instance on one domain. *Measured*: the Backend API `redirect_urls` list is not
166
+ consulted by the hosted pages.
167
+ 3. **A development instance is for local or test use only.** Its hosted sign-in page redirects
168
+ only to `localhost` or to the instance Home URL. For a remote test, set the development
169
+ instance's development origin / Home URL to the server origin in the Clerk dashboard, run
170
+ the test, and revert it afterwards.
171
+ 4. **How the session reaches the server.** After sign-in the browser comes back to
172
+ `/authorize/callback` without a session on your host. The server resolves it with Clerk's
173
+ own handshake (`@vantageos/cloud-identity` 0.19.0, built on `@clerk/backend`
174
+ `authenticateRequest()`): it redirects the browser through Clerk's handshake endpoint and
175
+ Clerk sets `__session` on your host. It needs `CLERK_SECRET_KEY` and the publishable key:
176
+ `CLERK_PUBLISHABLE_KEY`, or, when unset, derived from `CLERK_DOMAIN` as
177
+ `pk_test_` / `pk_live_` (following the secret key) + base64(`<frontend API host>$`).
178
+ *Measured*: a development instance with a remote server works once its Home URL points at
179
+ the server. *Not yet measured*: a production instance on a registrable domain different
180
+ from the server's.
181
+ 5. **Boot warning.** When the configured instance is a development one (an `sk_test_` key, or
182
+ a `CLERK_DOMAIN` under `accounts.dev`) and `PUBLIC_BASE_URL` is not localhost, the server
183
+ logs one warning at boot, in English and French, naming the Home URL requirement. It is a
184
+ warning, never a refusal: it means "a remote sign-in will only work if the Home URL points
185
+ at this server, or if you move to a production instance". It carries no key or secret.
186
+
125
187
  ### Multi-tenancy
126
188
 
127
189
  `list_items` filters rows by their stored `orgId` against the caller's verified
@@ -211,11 +273,11 @@ Publication is proven at the registry's `dist-tag`, never by the exit code of
211
273
  negotiate the extension.
212
274
 
213
275
  The widget (`src/ui/`) reuses a vendored `MosaicCard` family and is bundled to
214
- one self-contained HTML file (`vite-plugin-singlefile`,
276
+ one self-contained HTML file (an in-repo inlining plugin in `vite.config.ts`,
215
277
  `dist/ui-bundle/widget.html`) by `npm run build:ui`, on a path that never
216
278
  collides with tsc's `dist/ui/`.
217
279
 
218
- **Identity layer**: `@vantageos/cloud-identity` (^0.15.0) is the only identity
280
+ **Identity layer**: `@vantageos/cloud-identity` (^0.19.0) is the only identity
219
281
  package. Its resource-server entry point (`verifyMcpAccessToken`,
220
282
  `buildProtectedResourceMetadata`) verifies the Bearer token on `/mcp`; `jose` is
221
283
  kept only to mint the authorization server's access tokens. An unreachable key set
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Operator knobs read from the environment. UNSET (or empty) means the default; a value that is
3
+ * SET and invalid throws at startup, naming the variable and what was expected. The value is
4
+ * never echoed (it may be a secret pasted into the wrong variable) and never replaced by the
5
+ * default: a silent fallback would run the server on a setting its operator did not choose.
6
+ */
7
+ /** The raw value, or undefined when the variable is unset or empty. */
8
+ export declare function rawKnob(env: NodeJS.ProcessEnv, name: string): string | undefined;
9
+ /** A base-10 integer within [min, max]; undefined when unset. */
10
+ export declare function integerKnob(env: NodeJS.ProcessEnv, name: string, min: number, max?: number): number | undefined;
11
+ /** Exactly "0" or "1"; undefined when unset. */
12
+ export declare function flagKnob(env: NodeJS.ProcessEnv, name: string): boolean | undefined;
13
+ /** A comma-separated list of origins (`scheme://host[:port]`, http or https, no path). */
14
+ export declare function originListKnob(env: NodeJS.ProcessEnv, name: string): string[];
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Operator knobs read from the environment. UNSET (or empty) means the default; a value that is
3
+ * SET and invalid throws at startup, naming the variable and what was expected. The value is
4
+ * never echoed (it may be a secret pasted into the wrong variable) and never replaced by the
5
+ * default: a silent fallback would run the server on a setting its operator did not choose.
6
+ */
7
+ /** The raw value, or undefined when the variable is unset or empty. */
8
+ export function rawKnob(env, name) {
9
+ const raw = env[name];
10
+ return raw === undefined || raw === "" ? undefined : raw;
11
+ }
12
+ /** A base-10 integer within [min, max]; undefined when unset. */
13
+ export function integerKnob(env, name, min, max = Number.MAX_SAFE_INTEGER) {
14
+ const raw = rawKnob(env, name);
15
+ if (raw === undefined)
16
+ return undefined;
17
+ const n = /^\d+$/.test(raw) ? Number(raw) : Number.NaN;
18
+ if (!Number.isSafeInteger(n) || n < min || n > max) {
19
+ const range = max === Number.MAX_SAFE_INTEGER ? `at least ${min}` : `from ${min} to ${max}`;
20
+ throw new Error(`${name} is set but invalid: expected ${min > 0 ? "a positive" : "a non-negative"} integer ${range} (value not shown)`);
21
+ }
22
+ return n;
23
+ }
24
+ /** Exactly "0" or "1"; undefined when unset. */
25
+ export function flagKnob(env, name) {
26
+ const raw = rawKnob(env, name);
27
+ if (raw === undefined)
28
+ return undefined;
29
+ if (raw !== "0" && raw !== "1") {
30
+ throw new Error(`${name} is set but invalid: expected "0" or "1" (value not shown)`);
31
+ }
32
+ return raw === "1";
33
+ }
34
+ /** A comma-separated list of origins (`scheme://host[:port]`, http or https, no path). */
35
+ export function originListKnob(env, name) {
36
+ const raw = rawKnob(env, name);
37
+ if (raw === undefined)
38
+ return [];
39
+ const origins = raw.split(",").map((entry) => entry.trim());
40
+ for (const entry of origins) {
41
+ let ok = false;
42
+ try {
43
+ const url = new URL(entry);
44
+ ok = (url.protocol === "https:" || url.protocol === "http:") && url.origin === entry;
45
+ }
46
+ catch {
47
+ ok = false;
48
+ }
49
+ if (!ok) {
50
+ throw new Error(`${name} is set but invalid: expected a comma-separated list of origins like https://app.example.com (scheme, host, optional port; no path, no "*") (value not shown)`);
51
+ }
52
+ }
53
+ return origins;
54
+ }
package/dist/http.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { loadAuthConfig, loadAuthMode } from "./config.js";
2
2
  import { buildHttpApp } from "./httpApp.js";
3
+ import { createNodeListener, maxBodyBytesFrom } from "./nodeAdapter.js";
3
4
  const authMode = loadAuthMode();
4
5
  const uiDomain = process.env.MCP_UI_DOMAIN || undefined;
5
6
  // OAuth tier: MCP_OAUTH_ISSUER + MCP_OAUTH_AUDIENCE select an EXTERNAL issuer (resource server
@@ -13,26 +14,9 @@ const app = buildHttpApp(authMode === "oauth"
13
14
  : { authMode, authorizationServer: {}, uiDomain }
14
15
  : { authMode, uiDomain });
15
16
  const port = Number(process.env.PORT ?? 8787);
16
- // Hono's fetch handler adapted onto Node's http server (no extra framework
17
- // dependency beyond hono + node:http).
17
+ // Hono's fetch handler adapted onto Node's http server, with the request-body cap
18
+ // (MCP_MAX_BODY_BYTES, default 1 MiB) enforced while the body streams in: src/nodeAdapter.ts.
18
19
  const { createServer } = await import("node:http");
19
- createServer(async (req, res) => {
20
- const url = `http://${req.headers.host ?? "localhost"}${req.url}`;
21
- const chunks = [];
22
- for await (const chunk of req)
23
- chunks.push(chunk);
24
- const body = req.method === "GET" || req.method === "HEAD" ? undefined : Buffer.concat(chunks);
25
- const headers = new Headers();
26
- for (const [key, value] of Object.entries(req.headers)) {
27
- if (typeof value === "string")
28
- headers.set(key, value);
29
- }
30
- const request = new Request(url, { method: req.method, headers, body });
31
- const response = await app.fetch(request);
32
- res.statusCode = response.status;
33
- response.headers.forEach((value, key) => res.setHeader(key, value));
34
- const buf = Buffer.from(await response.arrayBuffer());
35
- res.end(buf);
36
- }).listen(port, () => {
20
+ createServer(createNodeListener(app, { maxBodyBytes: maxBodyBytesFrom(process.env) })).listen(port, () => {
37
21
  console.log(`mcp-boilerplate HTTP transport listening on :${port} (authMode: ${authMode})`);
38
22
  });
package/dist/httpApp.js CHANGED
@@ -23,11 +23,13 @@
23
23
  * trusted (mirrors stdio's trust model). The DEFAULT when `buildHttpApp()` gets no deps.
24
24
  */
25
25
  import { createMcpHandler } from "@modelcontextprotocol/server";
26
+ import { unauthorizedChallenge } from "@vantageos/cloud-identity";
26
27
  import { Hono } from "hono";
27
28
  import { authenticateRequest, protectedResourceMetadataDocument } from "./auth.js";
28
29
  import { detectLocale, t } from "./i18n.js";
29
30
  import { createBoilerplateMcpServer } from "./mcpServerFactory.js";
30
31
  import { createAuthorizationServer, } from "./oauth/authorizationServer.js";
32
+ import { MCP_SCOPE } from "./oauth/settings.js";
31
33
  import { resolveTenantFromJwtPayload } from "./tenancy.js";
32
34
  const PRM_PATHS = [
33
35
  "/.well-known/oauth-protected-resource",
@@ -82,6 +84,21 @@ export function buildHttpApp(deps = {}) {
82
84
  c.header("WWW-Authenticate", result.headers["WWW-Authenticate"]);
83
85
  return c.json({ error: t(locale, "auth.unauthorized") }, result.status);
84
86
  }
87
+ // A token must carry the scope this resource is protected by. Only the own-authorization-
88
+ // server tier knows its scope vocabulary; an external issuer's scopes are its own.
89
+ if (deps.authorizationServer) {
90
+ const granted = String(result.session.claims.scope ?? "").split(/\s+/);
91
+ if (!granted.includes(MCP_SCOPE)) {
92
+ const challenge = unauthorizedChallenge({
93
+ realm: rs.auth.realm,
94
+ resourceMetadataUrl: rs.auth.resourceMetadataUrl,
95
+ error: "insufficient_scope",
96
+ scope: MCP_SCOPE,
97
+ });
98
+ c.header("WWW-Authenticate", challenge.headers["WWW-Authenticate"]);
99
+ return c.json({ error: "insufficient_scope" }, 403);
100
+ }
101
+ }
85
102
  // Tenant resolution (tenancy.ts) is attempted here but is NOT request-fatal:
86
103
  // `initialize`/`tools/list` and any tenant-agnostic tool (e.g. `ping`) must keep working
87
104
  // for a verified caller with no organization on their token. Tenant-scoped tools enforce
@@ -12,11 +12,15 @@
12
12
  "oauth": {
13
13
  "pickerTitle": "Connect {client}",
14
14
  "pickerIntro": "{client} asks to act on your behalf. Choose the organisation it may reach, then approve or deny.",
15
+ "pickerDestination": "After approval you are sent to:",
16
+ "pickerScope": "Requested access:",
17
+ "pickerUnverified": "Unverified client: it registered itself automatically and nobody has vouched for it. Approve only if you recognise the address above.",
15
18
  "pickerOrganisation": "Organisation",
16
19
  "approve": "Approve",
17
20
  "deny": "Deny",
18
21
  "denied": "The request was denied; you can close this window.",
19
- "unavailable": "Authorization is not configured on this server."
22
+ "unavailable": "Authorization is not configured on this server.",
23
+ "devInstanceRemote": "Development Clerk instance on a remote server: its hosted sign-in page redirects only to localhost or to the instance Home URL. Set the development instance's Home URL (development origin) to this server's origin for a remote test and revert it afterwards, or use a Clerk production instance (see README, \"Sign-in with ChatGPT and Claude\")."
20
24
  }
21
25
  }
22
26
  }
@@ -12,11 +12,15 @@
12
12
  "oauth": {
13
13
  "pickerTitle": "Connecter {client}",
14
14
  "pickerIntro": "{client} demande à agir en votre nom. Choisissez l'organisation à laquelle il peut accéder, puis approuvez ou refusez.",
15
+ "pickerDestination": "Redirection après validation vers :",
16
+ "pickerScope": "Accès demandé :",
17
+ "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.",
15
18
  "pickerOrganisation": "Organisation",
16
19
  "approve": "Approuver",
17
20
  "deny": "Refuser",
18
21
  "denied": "La demande a été refusée ; vous pouvez fermer cette fenêtre.",
19
- "unavailable": "L'autorisation n'est pas configurée sur ce serveur."
22
+ "unavailable": "L'autorisation n'est pas configurée sur ce serveur.",
23
+ "devInstanceRemote": "Instance Clerk de développement sur un serveur distant : sa page de connexion hébergée ne redirige que vers localhost ou vers l'URL d'accueil (Home URL) de l'instance. Pour un test distant, réglez l'URL d'accueil (origine de développement) de l'instance sur l'origine de ce serveur, puis rétablissez-la ; sinon utilisez une instance Clerk de production (voir README, « Connexion avec ChatGPT et Claude »)."
20
24
  }
21
25
  }
22
26
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Hono's fetch handler adapted onto Node's `http` server (no framework beyond hono + node:http).
3
+ *
4
+ * The request body is read here, so the cap lives here: bytes are counted as they stream in and
5
+ * the read stops the moment the cap is crossed — an oversized body is answered 413 before any
6
+ * handler runs and is never held whole in memory.
7
+ */
8
+ import type { IncomingMessage, ServerResponse } from "node:http";
9
+ /** 1 MiB: far above any MCP JSON-RPC request or OAuth form this server accepts. */
10
+ export declare const DEFAULT_MAX_BODY_BYTES = 1048576;
11
+ /** `MCP_MAX_BODY_BYTES`, a positive integer. Unset = default; set and invalid throws. */
12
+ export declare function maxBodyBytesFrom(env: NodeJS.ProcessEnv): number;
13
+ export interface FetchApp {
14
+ fetch(request: Request, bindings?: {
15
+ remoteAddress?: string;
16
+ }): Response | Promise<Response>;
17
+ }
18
+ export declare function createNodeListener(app: FetchApp, options?: {
19
+ maxBodyBytes?: number;
20
+ }): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Hono's fetch handler adapted onto Node's `http` server (no framework beyond hono + node:http).
3
+ *
4
+ * The request body is read here, so the cap lives here: bytes are counted as they stream in and
5
+ * the read stops the moment the cap is crossed — an oversized body is answered 413 before any
6
+ * handler runs and is never held whole in memory.
7
+ */
8
+ import { integerKnob } from "./envKnobs.js";
9
+ /** 1 MiB: far above any MCP JSON-RPC request or OAuth form this server accepts. */
10
+ export const DEFAULT_MAX_BODY_BYTES = 1_048_576;
11
+ /** `MCP_MAX_BODY_BYTES`, a positive integer. Unset = default; set and invalid throws. */
12
+ export function maxBodyBytesFrom(env) {
13
+ return integerKnob(env, "MCP_MAX_BODY_BYTES", 1) ?? DEFAULT_MAX_BODY_BYTES;
14
+ }
15
+ function tooLarge(res, maxBodyBytes) {
16
+ res.writeHead(413, { "content-type": "application/json", connection: "close" });
17
+ res.end(JSON.stringify({
18
+ error: "payload_too_large",
19
+ error_description: `request body exceeds ${maxBodyBytes} bytes`,
20
+ }));
21
+ }
22
+ export function createNodeListener(app, options = {}) {
23
+ const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
24
+ return async (req, res) => {
25
+ try {
26
+ const hasBody = req.method !== "GET" && req.method !== "HEAD";
27
+ let body;
28
+ if (hasBody) {
29
+ const declared = Number(req.headers["content-length"]);
30
+ if (Number.isFinite(declared) && declared > maxBodyBytes)
31
+ return tooLarge(res, maxBodyBytes);
32
+ const chunks = [];
33
+ let received = 0;
34
+ for await (const chunk of req) {
35
+ const piece = chunk;
36
+ received += piece.byteLength;
37
+ if (received > maxBodyBytes)
38
+ return tooLarge(res, maxBodyBytes);
39
+ chunks.push(piece);
40
+ }
41
+ body = new Uint8Array(Buffer.concat(chunks));
42
+ }
43
+ const headers = new Headers();
44
+ for (const [key, value] of Object.entries(req.headers)) {
45
+ if (typeof value === "string")
46
+ headers.set(key, value);
47
+ }
48
+ const url = `http://${req.headers.host ?? "localhost"}${req.url}`;
49
+ const request = new Request(url, { method: req.method, headers, body });
50
+ const response = await app.fetch(request, { remoteAddress: req.socket.remoteAddress });
51
+ res.statusCode = response.status;
52
+ // `forEach` hands back Set-Cookie folded into one value, and setHeader overwrites: several
53
+ // cookies (Clerk's handshake sets five) would collapse to the last. Emit them as an array.
54
+ const cookies = response.headers.getSetCookie();
55
+ response.headers.forEach((value, key) => {
56
+ if (key.toLowerCase() !== "set-cookie")
57
+ res.setHeader(key, value);
58
+ });
59
+ if (cookies.length > 0)
60
+ res.setHeader("Set-Cookie", cookies);
61
+ res.end(Buffer.from(await response.arrayBuffer()));
62
+ }
63
+ catch (err) {
64
+ console.error("[http] request failed:", err instanceof Error ? err.message : err);
65
+ if (!res.headersSent) {
66
+ res.writeHead(500, { "content-type": "application/json" });
67
+ }
68
+ res.end(JSON.stringify({ error: "server_error" }));
69
+ }
70
+ };
71
+ }
@@ -21,7 +21,7 @@
21
21
  * No refresh tokens are issued; access tokens live one hour.
22
22
  */
23
23
  import { type AuthorizationCodeStore } from "@vantageos/cloud-identity";
24
- import type { Hono } from "hono";
24
+ import type { Context, Hono } from "hono";
25
25
  import type { AuthConfig } from "../auth.js";
26
26
  import { type ClerkCollaborators } from "./clerk.js";
27
27
  import { type CimdOptions, type ClientRegistry } from "./clientRegistry.js";
@@ -52,5 +52,25 @@ export interface AuthorizationServer {
52
52
  /** The resource-server (`/mcp` gate) configuration derived from the same settings. */
53
53
  resourceServer(): Promise<ResourceServerConfig>;
54
54
  }
55
+ /** Bindings the Node entry (src/http.ts) hands to `app.fetch(request, bindings)`. */
56
+ export interface NodeBindings {
57
+ /** Address of the TCP peer, from the socket. */
58
+ remoteAddress?: string;
59
+ }
60
+ /**
61
+ * The rate-limit key of a request: who is really on the other end.
62
+ *
63
+ * `X-Forwarded-For` is client-controlled up to the point a trusted proxy appends to it. With
64
+ * N trusted proxies the real client is the N-th entry from the END (each proxy appended the
65
+ * peer it saw); anything left of it was supplied by the client and is ignored. With none
66
+ * configured the header is not read at all and the socket address decides.
67
+ */
68
+ export declare function clientKeyOf(c: Context, trustedProxyCount: number): string;
69
+ /**
70
+ * The instance publishable key: `CLERK_PUBLISHABLE_KEY` when set, else derived from the Frontend
71
+ * API host in `CLERK_DOMAIN` as `pk_<test|live>_` + base64(host + "$"), the format Clerk documents
72
+ * (the prefix follows the secret key's `sk_live_` / `sk_test_`).
73
+ */
74
+ export declare function publishableKeyFor(env: Record<string, string | undefined>, clerkIssuer: string): string;
55
75
  export declare function createAuthorizationServer(deps?: AuthorizationServerDeps): AuthorizationServer;
56
76
  export {};