@vantageos/mcp-boilerplate 0.4.0 → 0.6.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vantage-mcp-boilerplate",
3
3
  "displayName": "VantageOS MCP Boilerplate",
4
- "version": "0.4.0",
4
+ "version": "0.6.0",
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,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-11-01
11
+
12
+ ### Added
13
+ - `mcp-readme-fr` (bin, `scripts/readme-fr.mjs`): generates `README.fr.md` from a bilingual `README.md` whose passages carry explicit `<!-- lang:en -->` / `<!-- lang:fr -->` / `<!-- /lang -->` markers (mcp-standard C-1: one French file, never hand-copied). `--check` fails when the two diverge, a malformed marker is an error naming its line, and a hand-written `README.fr.md` (no banner) is never overwritten. `tests/readmeFr.test.ts`.
14
+
15
+ ### Fixed
16
+ - The CHANGELOG link block covers every version: a GitHub compare or tag link where a tag exists (v0.1.1, v0.1.2, v0.3.0, v0.4.0), the npm page of the version otherwise (0.1.0, 0.2.x, 0.5.0; the dead `v0.1.0` tag link is replaced). `[Unreleased]` compares the newest tag, v0.4.0. `tests/changelogLinks.test.ts` fails when a `## [x]` heading has no link line, a link has no heading, or a URL is of no known form.
17
+
18
+ ## [0.5.0] - 2026-10-29
19
+
20
+ > **Breaking for multi-instance deployments.** Cursors are now signed under a key drawn once per
21
+ > process. Two instances behind a load balancer reject each other's cursors, and a restart
22
+ > invalidates outstanding ones (`invalid_cursor`, the caller starts again). Migration: build one
23
+ > signer on a shared key of at least 32 bytes, `createCursorSigner({ key })`, and pass it as
24
+ > `cursorSigner` to `buildHttpApp` / `createBoilerplateMcpServer` and to every
25
+ > `createPaginatedUpstreamClient`.
26
+ >
27
+ > **Rupture pour les déploiements multi-instance.** Les curseurs sont désormais signés sous une clé
28
+ > tirée une fois par processus. Deux instances derrière un répartiteur de charge refusent les
29
+ > curseurs l'une de l'autre, et un redémarrage invalide ceux en circulation (`invalid_cursor`,
30
+ > l'appelant recommence). Migration : construire un signataire sur une clé partagée d'au moins
31
+ > 32 octets, `createCursorSigner({ key })`, et le passer en `cursorSigner` à `buildHttpApp` /
32
+ > `createBoilerplateMcpServer` et à chaque `createPaginatedUpstreamClient`.
33
+
34
+ ### Added
35
+
36
+ - **`createCursorSigner({ key? })`** (EN), exported with `InvalidCursorError`,
37
+ `INVALID_CURSOR_CODE` and `CURSOR_KEY_BYTES`: `sign(payload)` returns
38
+ `<base64url payload>.<base64url HMAC-SHA256 tag>`, `verify(token)` returns the payload or throws
39
+ `InvalidCursorError` with a named `reason` (`empty`, `missing_separator`, `extra_separator`,
40
+ `empty_part`, `not_base64url`, `tag_length`, `tag_mismatch`). Constant-time tag comparison, no
41
+ unsigned fallback; default key 32 random bytes, an injected key shorter than 32 bytes throws.
42
+ - **`createCursorSigner({ key? })`** (FR), exporté avec `InvalidCursorError`,
43
+ `INVALID_CURSOR_CODE` et `CURSOR_KEY_BYTES` : `sign(payload)` renvoie
44
+ `<charge base64url>.<étiquette HMAC-SHA256 base64url>`, `verify(token)` renvoie la charge ou lève
45
+ `InvalidCursorError` avec une `reason` nommée. Comparaison des étiquettes en temps constant, aucun
46
+ repli non signé ; clé par défaut de 32 octets aléatoires, une clé injectée de moins de 32 octets
47
+ lève une erreur.
48
+ - `cursorSigner` option on `createPaginatedUpstreamClient`, `createBoilerplateMcpServer` and
49
+ `buildHttpApp` / option `cursorSigner` sur ces trois points d'entrée.
50
+
51
+ ### Security
52
+
53
+ - **Signed cursors** (EN): the `readPage` upstream offset cursor and the `list_items` cursor were
54
+ base64url JSON guarded by an UNKEYED fingerprint, so a caller could edit the offset (or the list
55
+ key), recompute the fingerprint and have the cursor accepted. Both are now signed: an edited or
56
+ minted cursor is refused as `invalid_cursor` before any upstream request or lake read.
57
+ - **Curseurs signés** (FR) : le curseur d'offset amont de `readPage` et le curseur de `list_items`
58
+ étaient du JSON base64url gardé par une empreinte NON signée ; un appelant pouvait modifier
59
+ l'offset (ou la clé de liste), recalculer l'empreinte et faire accepter le curseur. Les deux sont
60
+ désormais signés : un curseur modifié ou fabriqué est refusé en `invalid_cursor` avant toute
61
+ requête amont ou lecture du lac.
62
+
63
+ - **S-13 outbound request timeout** (EN): every request the server emits (paginated upstream pages,
64
+ token-source API calls, external-issuer JWKS, Convex lake reads) carries a timeout set by
65
+ `UPSTREAM_TIMEOUT_MS` (declared variable, default 30000 ms, range 1000 to 300000, `ConfigurationError`
66
+ naming the variable when invalid); a timeout surfaces as the typed error each site already uses.
67
+ The JWKS fetch and the Convex fetch had none.
68
+ - **Délai des requêtes sortantes S-13** (FR) : toute requête émise par le serveur (pages d'amont
69
+ paginées, appels d'API de la source de jetons, JWKS de l'émetteur externe, lectures du lac Convex)
70
+ porte un délai fixé par `UPSTREAM_TIMEOUT_MS` (variable déclarée, 30000 ms par défaut, de 1000 à
71
+ 300000, `ConfigurationError` nommant la variable si invalide) ; un dépassement remonte sous
72
+ l'erreur typée que chaque site utilise déjà. Le JWKS et le fetch Convex n'en avaient aucun.
73
+ - **S-22 Origin validation on `/mcp`** (EN): a request carrying an `Origin` header not listed in
74
+ `MCP_ALLOWED_ORIGINS` (declared variable, default empty) is refused `403 origin_not_allowed`
75
+ (FR+EN message) before the auth gate; no `Origin` passes; a wildcard or path in the list stops the
76
+ server at start. OAuth/discovery routes and `/health` are not covered.
77
+ - **Validation de l'origine S-22 sur `/mcp`** (FR) : une requête dont l'en-tête `Origin` ne figure
78
+ pas dans `MCP_ALLOWED_ORIGINS` (variable déclarée, vide par défaut) est refusée `403
79
+ origin_not_allowed` (message FR+EN) avant la barrière d'authentification ; sans `Origin`, elle
80
+ passe ; un joker ou un chemin dans la liste arrête le serveur au démarrage. Les routes
81
+ OAuth/découverte et `/health` ne sont pas couvertes.
82
+
10
83
  ## [0.4.0] - 2026-10-28
11
84
 
12
85
  > **BREAKING for forks.** `CONTEXT_CEILING` is no longer exported and `mcp-server.json` must declare
@@ -414,6 +487,16 @@ Initial published release.
414
487
  - Reproducible build via `npm ci`; 52/52 tests passing at release time.
415
488
  - MIT license, clonable as a starter template.
416
489
 
417
- [Unreleased]: https://github.com/elpiarthera/vantage-mcp-boilerplate/compare/v0.1.1...HEAD
418
- [0.1.1]: https://github.com/elpiarthera/vantage-mcp-boilerplate/compare/v0.1.0...v0.1.1
419
- [0.1.0]: https://github.com/elpiarthera/vantage-mcp-boilerplate/releases/tag/v0.1.0
490
+ [Unreleased]: https://github.com/elpiarthera/vantage-mcp-boilerplate/compare/v0.4.0...HEAD
491
+ [0.6.0]: https://www.npmjs.com/package/@vantageos/mcp-boilerplate/v/0.6.0
492
+ [0.5.0]: https://www.npmjs.com/package/@vantageos/mcp-boilerplate/v/0.5.0
493
+ [0.4.0]: https://github.com/elpiarthera/vantage-mcp-boilerplate/compare/v0.3.0...v0.4.0
494
+ [0.3.0]: https://github.com/elpiarthera/vantage-mcp-boilerplate/compare/v0.1.2...v0.3.0
495
+ [0.2.4]: https://www.npmjs.com/package/@vantageos/mcp-boilerplate/v/0.2.4
496
+ [0.2.3]: https://www.npmjs.com/package/@vantageos/mcp-boilerplate/v/0.2.3
497
+ [0.2.2]: https://www.npmjs.com/package/@vantageos/mcp-boilerplate/v/0.2.2
498
+ [0.2.1]: https://www.npmjs.com/package/@vantageos/mcp-boilerplate/v/0.2.1
499
+ [0.2.0]: https://www.npmjs.com/package/@vantageos/mcp-boilerplate/v/0.2.0
500
+ [0.1.2]: https://github.com/elpiarthera/vantage-mcp-boilerplate/compare/v0.1.1...v0.1.2
501
+ [0.1.1]: https://github.com/elpiarthera/vantage-mcp-boilerplate/releases/tag/v0.1.1
502
+ [0.1.0]: https://www.npmjs.com/package/@vantageos/mcp-boilerplate/v/0.1.0
package/README.fr.md CHANGED
@@ -22,6 +22,7 @@ src/
22
22
  serverManifest.ts # chargeur fail-closed de mcp-server.json (le manifeste serveur)
23
23
  toolRecord.ts # fiche d'outil typée : autorité, verbe, table, mécanisme, pagination, coût
24
24
  pagination.ts # pagination par curseur appliquée par l'outil (défaut, max fini, invalid_cursor)
25
+ signedCursor.ts # primitive de curseur signé HMAC (createCursorSigner), utilisée par tout curseur
25
26
  buildIdentity.ts # identité de build dérivée de l'arbre servi
26
27
  stdio.ts # serveStdio(factory) : les deux ères du protocole sur stdio
27
28
  http.ts # adaptateur Node http.Server autour de l'app Hono
@@ -100,7 +101,7 @@ Les ensembles sont des unions TypeScript fermées : une valeur hors ensemble ne
100
101
  compile pas, et un outil de liste ne peut omettre ni son mécanisme ni sa pagination.
101
102
  La fiche de chaque outil est un objet de littéraux, lisible par un contrôleur
102
103
  statique. `list_items` pagine ses lignes : `limit` (20 par
103
- défaut, plafonné à 100) et un `cursor` opaque ; une page tronquée porte `nextCursor`
104
+ défaut, plafonné à 100) et un `cursor` opaque et signé ; une page tronquée porte `nextCursor`
104
105
  et `exhausted: false`, la dernière page `exhausted: true` ; un curseur invalide ou expiré est
105
106
  une erreur d'entrée de l'outil : un résultat `isError: true` avec
106
107
  `structuredContent.error.code` valant `invalid_cursor` (message FR/EN), jamais une
@@ -180,6 +181,29 @@ dynamique, et un refus redirige vers le client avec `error=access_denied` (plus
180
181
  `state` et `iss`). Les URL `http:` d'émetteur, de connexion et de rappel sont refusées
181
182
  hors boucle locale.
182
183
 
184
+ **Validation de l'origine sur `/mcp` (S-22)** : une requête vers `/mcp` qui porte un en-tête
185
+ `Origin` (un navigateur) est refusée `403 {"code":"origin_not_allowed","message_en":...,"message_fr":...}`,
186
+ avant la barrière d'authentification et sans répéter l'origine, sauf si l'origine figure dans
187
+ `MCP_ALLOWED_ORIGINS` (`schéma://hôte[:port]` séparés par des virgules, correspondance exacte
188
+ et insensible à la casse, déclarée dans `src/settings.ts`). Le défaut est vide : toute origine
189
+ de navigateur est refusée. `null`, une valeur vide et les quasi-correspondances comme
190
+ `https://claude.ai.evil.example` sont refusées. Une requête sans en-tête `Origin` (serveur à
191
+ serveur, Claude Code) passe comme avant. Un joker ou une entrée avec chemin arrête le serveur
192
+ au démarrage avec une `ConfigurationError` qui nomme la variable. Les routes OAuth et de
193
+ découverte (`/.well-known/*`, `/authorize`, `/token`, `/register`) et `/health` ne sont pas
194
+ couvertes : ce sont des navigations de navigateur ou des appels CORS du parcours de connexion.
195
+
196
+ **Délai des requêtes sortantes (S-13)** : toute requête que le serveur émet vers un amont (pages
197
+ d'API paginées, appels d'API via la source de jetons client-credentials, JWKS de l'émetteur
198
+ externe, lecteur de lac Convex) porte un délai, `UPSTREAM_TIMEOUT_MS` (entier en millisecondes,
199
+ `30000` par défaut, de `1000` à `300000`, déclaré dans `src/settings.ts`). Une requête qui le
200
+ dépasse est interrompue et remonte sous l'erreur typée que le site d'appel utilise déjà
201
+ (`UpstreamPageError` `unreachable` nommant `TimeoutError`, `LakeReadError` `read_failed`, un
202
+ échec de récupération du JWKS que le vérificateur traduit en 503) ; un appel qui progresse ne
203
+ lève jamais le plafond. Une valeur non entière ou hors plage arrête le serveur au démarrage avec
204
+ une `ConfigurationError` qui nomme la variable. Le mécanisme commun est `src/upstream/timeout.ts`,
205
+ qui liste chaque site d'appel sortant.
206
+
183
207
  **Non inclus** : jetons de rafraîchissement (les jetons d'accès durent une heure),
184
208
  clés d'API par organisation pour les clients hors ChatGPT (ce code n'a jamais eu ce
185
209
  chemin ; c'est un manque, pas une régression), URI de redirection à joker, et liste
@@ -344,10 +368,10 @@ est refusé avant tout appel réseau : le jeton ne part jamais vers un hôte nom
344
368
  d'outil, et les redirections sont refusées, jamais suivies (`redirect: "error"`). `readAll` s'arrête
345
369
  sur la page incomplète (ou au total, avec `extractTotal`) et refuse un ensemble au-delà du plafond
346
370
  avec `row_ceiling_exceeded`, jamais un ensemble tronqué en silence. `readPage` renvoie
347
- `{ rows, nextCursor?, exhausted }` ; son curseur est lié au chemin et aux paramètres, et un curseur rejoué
348
- sur une autre requête ou altéré sans recalcul de son empreinte est refusé en `invalid_cursor`.
349
- L'empreinte n'est pas signée : elle détecte un mauvais usage, pas une falsification ; l'offset ne
350
- confère aucun droit et un offset forgé au-delà du plafond est refusé avant toute requête. Une page
371
+ `{ rows, nextCursor?, exhausted }` ; son curseur est signé (voir
372
+ [Curseurs signés](#curseurs-signés)) et lié au chemin et aux paramètres : un curseur rejoué sur une
373
+ autre requête, modifié, ou signé sous une autre clé est refusé en `invalid_cursor` avant toute
374
+ requête. Une page
351
375
  vide avant le total déclaré est refusée en `inconsistent_page`, jamais redemandée. Les 429 et 503
352
376
  sont rejoués avec un délai exponentiel (5 tentatives par défaut, base 500 ms, plafond 30 s) et
353
377
  respectent `Retry-After` ; aucun autre statut n'est rejoué. Les erreurs (`UpstreamPageError`)
@@ -355,6 +379,33 @@ nomment le chemin et le statut, jamais un jeton, une valeur de paramètre ni le
355
379
  `pageSize` (défaut 100) dans le maximum réel de l'amont : une page plafonnée en silence en dessous
356
380
  se lit comme la dernière, sauf si la réponse déclare un total.
357
381
 
382
+ ### Curseurs signés
383
+
384
+ Tout curseur émis par le boilerplate (le curseur de `list_items` et celui de `readPage`) est un jeton
385
+ `<charge base64url>.<étiquette base64url>`, l'étiquette étant le HMAC-SHA256 de la charge sous une clé
386
+ du serveur (`src/signedCursor.ts`). Un appelant peut lire un curseur mais ni le modifier ni en
387
+ fabriquer un : une charge modifiée, une étiquette faite sous une autre clé, ou un jeton mal formé
388
+ (vide, sans séparateur, tronqué, hors base64url) est refusé en `invalid_cursor` avant toute lecture,
389
+ avec une `reason` destinée à l'opérateur sur l'`InvalidCursorError`. La vérification compare les
390
+ étiquettes en temps constant ; il n'existe aucun repli non signé. Un serveur dérivé qui émet ses
391
+ propres curseurs utilise la même primitive :
392
+
393
+ ```ts
394
+ import { createCursorSigner } from "@vantageos/mcp-boilerplate";
395
+
396
+ const signer = createCursorSigner(); // 32 octets aléatoires, tirés une fois
397
+ const cursor = signer.sign(JSON.stringify({ v: 1, after: "item-20" }));
398
+ const payload = signer.verify(cursor); // ou lève InvalidCursorError
399
+ ```
400
+
401
+ **Limite : la clé par défaut vit dans le processus.** Un redémarrage invalide tous les curseurs en
402
+ circulation (l'appelant reçoit `invalid_cursor` et recommence sans curseur), et des instances
403
+ derrière un répartiteur de charge refusent les curseurs les unes des autres. Un déploiement
404
+ multi-instance injecte dans chaque instance une même clé partagée d'au moins 32 octets (une clé plus
405
+ courte lève une erreur) : `createCursorSigner({ key })`, passé en `cursorSigner` à `buildHttpApp` /
406
+ `createBoilerplateMcpServer` (curseur de `list_items`) et à `createPaginatedUpstreamClient` (curseur
407
+ amont).
408
+
358
409
  ## Déclarer sa propre configuration
359
410
 
360
411
  Un serveur dérivé déclare une fois les variables d'environnement qu'il lit, et reçoit au
@@ -466,6 +517,24 @@ le client envoie `X-MCP-Locale: fr|en` (défaut `en`) ; le sélecteur navigateur
466
517
  convention `<Namespace>.<category>.<key>` de `@vantageos/mosaic-i18n`, FR et EN en
467
518
  parité.
468
519
 
520
+ ## Un README français généré depuis un README bilingue
521
+
522
+ Un serveur dont le `README.md` porte l'anglais et le français côte à côte livre un `README.fr.md` généré depuis lui
523
+ (mcp-standard C-1), jamais recopié à la main. Marquez chaque passage bilingue, puis lancez le générateur :
524
+
525
+ ```markdown
526
+ <!-- lang:en -->
527
+ A demo server.
528
+ <!-- lang:fr -->
529
+ Un serveur de démonstration.
530
+ <!-- /lang -->
531
+ ```
532
+
533
+ Le texte hors d'une région marquée (code, tableaux, liens) est copié dans les deux. `npx mcp-readme-fr --write` écrit
534
+ `README.fr.md` ; `npx mcp-readme-fr --check` (à mettre dans `npm test`) échoue quand les deux divergent. Un marqueur
535
+ mal formé est une erreur qui nomme sa ligne, et le générateur n'écrase jamais un `README.fr.md` écrit à la main (sans
536
+ sa bannière). Ce dépôt garde sa propre traduction écrite à la main.
537
+
469
538
  ## Tests
470
539
 
471
540
  ```bash
package/README.md CHANGED
@@ -21,6 +21,7 @@ src/
21
21
  serverManifest.ts # fail-closed loader of mcp-server.json (the server manifest)
22
22
  toolRecord.ts # typed tool record: authority, verb, table, mechanism, pagination, cost
23
23
  pagination.ts # cursor pagination applied by the tool (default, finite max, invalid_cursor)
24
+ signedCursor.ts # HMAC-signed cursor primitive (createCursorSigner), used by every cursor
24
25
  buildIdentity.ts # build identity derived from the served tree
25
26
  stdio.ts # serveStdio(factory): both protocol eras over stdio
26
27
  http.ts # Node http.Server adapter around the Hono app
@@ -98,7 +99,7 @@ and returns `structuredContent` plus a text block with the same JSON. Its
98
99
  The sets are closed TypeScript unions: a value outside them does not compile, and a
99
100
  list tool cannot omit its mechanism or its pagination. Each tool's record is a plain
100
101
  object of literals so a static checker can read it. `list_items` pages
101
- its rows: `limit` (default 20, capped at 100) and an opaque
102
+ its rows: `limit` (default 20, capped at 100) and an opaque, signed
102
103
  `cursor`; a truncated page carries `nextCursor` and `exhausted: false`, the last page
103
104
  `exhausted: true`; an invalid or expired cursor is a tool
104
105
  input error: a result with `isError: true` and `structuredContent.error.code`
@@ -174,6 +175,27 @@ flags a Dynamic-Client-Registration client as unverified, and a denial redirects
174
175
  to the client with `error=access_denied` (plus `state` and `iss`). `http:` issuer,
175
176
  sign-in and callback URLs are refused outside loopback.
176
177
 
178
+ **Origin validation on `/mcp` (S-22)**: a request to `/mcp` that carries an `Origin` header
179
+ (a browser) is refused `403 {"code":"origin_not_allowed","message_en":...,"message_fr":...}`,
180
+ before the auth gate and without echoing the origin, unless the origin is listed in
181
+ `MCP_ALLOWED_ORIGINS` (comma-separated `scheme://host[:port]`, exact and case-insensitive,
182
+ declared in `src/settings.ts`). The default is empty: every browser origin is refused. `null`,
183
+ an empty value and near-misses such as `https://claude.ai.evil.example` are refused. A request
184
+ with no `Origin` header (server-to-server, Claude Code) passes as before. A wildcard or an entry
185
+ with a path stops the server at start with a `ConfigurationError` naming the variable. The
186
+ OAuth and discovery routes (`/.well-known/*`, `/authorize`, `/token`, `/register`) and `/health`
187
+ are not covered: they are browser navigations or CORS calls of the sign-in flow.
188
+
189
+ **Outbound request timeout (S-13)**: every request the server emits to an upstream (paginated
190
+ API pages, API calls through the client-credentials token source, the external-issuer JWKS, the
191
+ Convex lake reader) carries a timeout, `UPSTREAM_TIMEOUT_MS` (integer milliseconds, default
192
+ `30000`, range `1000` to `300000`, declared in `src/settings.ts`). A request that exceeds it is
193
+ aborted and surfaces as the typed error the call site already uses (`UpstreamPageError`
194
+ `unreachable` naming `TimeoutError`, `LakeReadError` `read_failed`, a JWKS fetch failure the verifier
195
+ answers 503); a progressing call never lifts the ceiling. A value that is not an integer or is out of
196
+ range stops the server at start with a `ConfigurationError` naming the variable. The shared mechanism
197
+ is `src/upstream/timeout.ts`, which lists every outbound call site.
198
+
177
199
  **Not included**: refresh tokens (access tokens last one hour), per-organisation
178
200
  API keys for non-ChatGPT clients (this code never had that path; it is a gap, not
179
201
  a regression), wildcard redirect URIs, and a token revocation list.
@@ -325,10 +347,9 @@ const page = await things.readPage("things", { status: "active" }, cursor); // o
325
347
  any network call, so the bearer token never leaves for a host a tool argument names, and redirects
326
348
  are refused rather than followed (`redirect: "error"`). `readAll` stops on the short page (or at the
327
349
  total, with `extractTotal`) and refuses a set above the ceiling with `row_ceiling_exceeded`, never a
328
- silently truncated one. `readPage` returns `{ rows, nextCursor?, exhausted }`; its cursor is bound
329
- to the path and params, and one replayed against another query or altered without recomputing its
330
- fingerprint is refused as `invalid_cursor`. The fingerprint is unkeyed: it detects misuse, not
331
- forgery; the offset carries no authority and a minted offset above the ceiling is refused before any
350
+ silently truncated one. `readPage` returns `{ rows, nextCursor?, exhausted }`; its cursor is signed
351
+ (see [Signed cursors](#signed-cursors)) and bound to the path and params: one replayed against
352
+ another query, edited, or signed under another key is refused as `invalid_cursor` before any
332
353
  request. An empty page before a declared total is refused as `inconsistent_page`, never
333
354
  re-requested. 429 and 503 are retried with exponential backoff (default 5 attempts, 500 ms base,
334
355
  30 s cap) and honour `Retry-After`; no other status is retried. Errors (`UpstreamPageError`) name the
@@ -336,6 +357,31 @@ path and the status, never a token, a parameter value or the body. Keep `pageSiz
336
357
  within the upstream's real maximum: a page silently capped below it reads as the last one unless the
337
358
  response declares a total.
338
359
 
360
+ ### Signed cursors
361
+
362
+ Every cursor the boilerplate issues (the `list_items` cursor and the `readPage` cursor) is a token
363
+ `<base64url payload>.<base64url tag>`, the tag being HMAC-SHA256 of the payload under a server key
364
+ (`src/signedCursor.ts`). A caller can read a cursor but cannot edit or mint one: an edited payload,
365
+ a tag made under another key, or a malformed token (empty, no separator, truncated, not base64url)
366
+ is refused as `invalid_cursor` before any read, with an operator-facing `reason` on the
367
+ `InvalidCursorError`. Verification compares tags in constant time; there is no unsigned fallback.
368
+ A derived server that issues its own cursors uses the same primitive:
369
+
370
+ ```ts
371
+ import { createCursorSigner } from "@vantageos/mcp-boilerplate";
372
+
373
+ const signer = createCursorSigner(); // 32 random bytes, drawn once
374
+ const cursor = signer.sign(JSON.stringify({ v: 1, after: "item-20" }));
375
+ const payload = signer.verify(cursor); // or throws InvalidCursorError
376
+ ```
377
+
378
+ **Limit: the default key lives in the process.** A restart invalidates every outstanding cursor
379
+ (the caller gets `invalid_cursor` and starts again without one), and instances behind a load
380
+ balancer reject each other's cursors. A multi-instance deployment injects one shared key of at
381
+ least 32 bytes (a shorter one throws) into every instance: `createCursorSigner({ key })`, passed as
382
+ `cursorSigner` to `buildHttpApp` / `createBoilerplateMcpServer` (the `list_items` cursor) and to
383
+ `createPaginatedUpstreamClient` (the upstream cursor).
384
+
339
385
  ## Declaring your own configuration
340
386
 
341
387
  A derived server declares the environment variables it reads, once, and gets a typed, validated,
@@ -443,6 +489,24 @@ client sends `X-MCP-Locale: fr|en` (default `en`); the browser picker follows
443
489
  `<Namespace>.<category>.<key>` convention of `@vantageos/mosaic-i18n`, FR and EN
444
490
  in lockstep.
445
491
 
492
+ ## A French README generated from a bilingual one
493
+
494
+ A server whose `README.md` carries English and French side by side ships a `README.fr.md` generated from it
495
+ (mcp-standard C-1), never hand-copied. Mark each bilingual passage, then run the generator:
496
+
497
+ ```markdown
498
+ <!-- lang:en -->
499
+ A demo server.
500
+ <!-- lang:fr -->
501
+ Un serveur de démonstration.
502
+ <!-- /lang -->
503
+ ```
504
+
505
+ Text outside a marked region (code, tables, links) is copied to both. `npx mcp-readme-fr --write` writes
506
+ `README.fr.md`; `npx mcp-readme-fr --check` (put it in `npm test`) fails when the two diverge. A malformed marker is
507
+ an error naming its line, and the generator never overwrites a hand-written `README.fr.md` (one without its banner).
508
+ This repository keeps its own hand translation.
509
+
446
510
  ## Testing
447
511
 
448
512
  ```bash
package/dist/config.d.ts CHANGED
@@ -25,4 +25,4 @@ export declare function loadAuthConfig(env?: NodeJS.ProcessEnv, jwksOverride?: A
25
25
  * that into a 503 (`jwks-unavailable`), never a 401 — unless a previously fetched set is still
26
26
  * held, which is served rather than failing every request during a brief issuer blip.
27
27
  */
28
- export declare function createCachedJwksFetcher(url: string, fetchImpl?: typeof fetch, nowMs?: () => number): () => Promise<ClerkJwks>;
28
+ export declare function createCachedJwksFetcher(url: string, fetchImpl?: typeof fetch, nowMs?: () => number, timeoutMs?: number): () => Promise<ClerkJwks>;
package/dist/config.js CHANGED
@@ -13,6 +13,7 @@
13
13
  * URL, issuer, audience) comes from the environment, never a literal in
14
14
  * code.
15
15
  */
16
+ import { configuredUpstreamTimeoutMs, upstreamSignal } from "./upstream/timeout.js";
16
17
  export function loadAuthMode(env = process.env) {
17
18
  const raw = (env.AUTH_MODE ?? "oauth").toLowerCase();
18
19
  if (raw !== "oauth" && raw !== "public") {
@@ -47,13 +48,13 @@ const JWKS_TTL_MS = 10 * 60 * 1000;
47
48
  * that into a 503 (`jwks-unavailable`), never a 401 — unless a previously fetched set is still
48
49
  * held, which is served rather than failing every request during a brief issuer blip.
49
50
  */
50
- export function createCachedJwksFetcher(url, fetchImpl = fetch, nowMs = Date.now) {
51
+ export function createCachedJwksFetcher(url, fetchImpl = fetch, nowMs = Date.now, timeoutMs = configuredUpstreamTimeoutMs()) {
51
52
  let cached;
52
53
  return async () => {
53
54
  if (cached && nowMs() - cached.fetchedAt < JWKS_TTL_MS)
54
55
  return cached.jwks;
55
56
  try {
56
- const res = await fetchImpl(url);
57
+ const res = await fetchImpl(url, { signal: upstreamSignal(timeoutMs) });
57
58
  if (!res.ok)
58
59
  throw new Error(`JWKS fetch ${url} answered ${res.status}`);
59
60
  const body = (await res.json());
package/dist/http.js CHANGED
@@ -1,8 +1,12 @@
1
1
  import { loadAuthConfig, loadAuthMode } from "./config.js";
2
2
  import { buildHttpApp } from "./httpApp.js";
3
3
  import { createNodeListener, maxBodyBytesFrom } from "./nodeAdapter.js";
4
+ import { loadHttpSettings } from "./settings.js";
4
5
  const authMode = loadAuthMode();
5
6
  const uiDomain = process.env.MCP_UI_DOMAIN || undefined;
7
+ // S-22: MCP_ALLOWED_ORIGINS, declared in src/settings.ts. A wildcard or a path stops the server
8
+ // here with a ConfigurationError naming the variable.
9
+ const { allowedOrigins } = loadHttpSettings();
6
10
  // OAuth tier: MCP_OAUTH_ISSUER + MCP_OAUTH_AUDIENCE select an EXTERNAL issuer (resource server
7
11
  // only); otherwise this server is its own authorization server (ruling R1) and reads its
8
12
  // settings from the environment per request — a missing value answers 503, see
@@ -10,9 +14,9 @@ const uiDomain = process.env.MCP_UI_DOMAIN || undefined;
10
14
  const externalIssuer = Boolean(process.env.MCP_OAUTH_ISSUER && process.env.MCP_OAUTH_AUDIENCE);
11
15
  const app = buildHttpApp(authMode === "oauth"
12
16
  ? externalIssuer
13
- ? { authMode, auth: loadAuthConfig(), uiDomain }
14
- : { authMode, authorizationServer: {}, uiDomain }
15
- : { authMode, uiDomain });
17
+ ? { authMode, auth: loadAuthConfig(), uiDomain, allowedOrigins }
18
+ : { authMode, authorizationServer: {}, uiDomain, allowedOrigins }
19
+ : { authMode, uiDomain, allowedOrigins });
16
20
  const port = Number(process.env.PORT ?? 8787);
17
21
  // Hono's fetch handler adapted onto Node's http server, with the request-body cap
18
22
  // (MCP_MAX_BODY_BYTES, default 1 MiB) enforced while the body streams in: src/nodeAdapter.ts.
package/dist/httpApp.d.ts CHANGED
@@ -28,6 +28,7 @@ import type { AuthMode } from "./config.js";
28
28
  import type { ExampleItem } from "./exampleItems.js";
29
29
  import type { LakeReader } from "./lake/reader.js";
30
30
  import { type AuthorizationServerDeps } from "./oauth/authorizationServer.js";
31
+ import type { CursorSigner } from "./signedCursor.js";
31
32
  import { type ResolvedTenant } from "./tenancy.js";
32
33
  import { type CallerResolution, type IdentityDeps } from "./verifiedPerson.js";
33
34
  export interface AppDeps {
@@ -51,6 +52,17 @@ export interface AppDeps {
51
52
  * Injected by the deployment like `identity`; absent, the in-memory seed is read.
52
53
  */
53
54
  lake?: LakeReader<ExampleItem>;
55
+ /**
56
+ * Browser origins allowed to call `/mcp` (S-22). Data from `MCP_ALLOWED_ORIGINS`
57
+ * (src/settings.ts), never a constant; empty or absent refuses every browser origin.
58
+ */
59
+ allowedOrigins?: readonly string[];
60
+ /**
61
+ * Signs and verifies the `list_items` cursor (src/signedCursor.ts). Absent: the process-wide
62
+ * signer, random key per process. Behind a load balancer, inject one signer built on a shared
63
+ * key (`createCursorSigner({ key })`) into every instance.
64
+ */
65
+ cursorSigner?: CursorSigner;
54
66
  }
55
67
  type Variables = {
56
68
  tenant?: ResolvedTenant;
package/dist/httpApp.js CHANGED
@@ -49,6 +49,25 @@ export function buildHttpApp(deps = {}) {
49
49
  // Unauthenticated (S-7): the version AND the identity of the tree being served (C-25), so a
50
50
  // deployment is proven from what the server returns, not from what the deploy printed.
51
51
  app.get("/health", (c) => c.json({ status: "ok", version: SERVER_VERSION, buildId: getBuildIdentity() }));
52
+ // S-22 Origin validation, on /mcp ONLY and registered BEFORE the auth gate: a request that carries
53
+ // an Origin header (a browser) must name an allowed origin (exact full-origin match, case-
54
+ // insensitive), else 403 before any token is looked at. No Origin header (server-to-server, Claude
55
+ // Code, a connector backend) passes as before. `null`, an empty value and near-misses such as
56
+ // `https://claude.ai.evil.example` are not in the list, so they are refused.
57
+ // Declared divergence: /health and the OAuth/discovery routes (/.well-known/*, /authorize, /token,
58
+ // /register) are NOT covered — they are browser navigations or CORS calls of the sign-in flow
59
+ // (/register has its own MCP_REGISTER_CORS_ORIGINS allowlist) and S-22 guards the MCP endpoint.
60
+ const allowedOrigins = new Set((deps.allowedOrigins ?? []).map((o) => o.toLowerCase()));
61
+ app.use("/mcp", async (c, next) => {
62
+ const origin = c.req.raw.headers.get("origin");
63
+ if (origin === null || allowedOrigins.has(origin.toLowerCase()))
64
+ return next();
65
+ return c.json({
66
+ code: "origin_not_allowed",
67
+ message_en: t("en", "auth.originNotAllowed"),
68
+ message_fr: t("fr", "auth.originNotAllowed"),
69
+ }, 403);
70
+ });
52
71
  if (authMode === "oauth") {
53
72
  // Resolves the resource-server configuration per request: the own-AS tier reads its
54
73
  // environment lazily, so a missing value is a 503 on the request, not a crash at boot.
@@ -142,6 +161,7 @@ export function buildHttpApp(deps = {}) {
142
161
  locale,
143
162
  uiDomain: deps.uiDomain,
144
163
  lake: deps.lake,
164
+ cursorSigner: deps.cursorSigner,
145
165
  }));
146
166
  return handler.fetch(c.req.raw);
147
167
  });
package/dist/index.d.ts CHANGED
@@ -4,6 +4,8 @@ export { createBoilerplateMcpServer } from "./mcpServerFactory.js";
4
4
  export { EXAMPLE_ITEMS_READER, LAKE_UNREADABLE_CODE, LakeReadError, checkLakePage, createInMemoryLakeReader, } from "./lake/reader.js";
5
5
  export type { LakePage, LakePageRequest, LakeReadErrorCode, LakeReader, LakeScope, } from "./lake/reader.js";
6
6
  export { readPage } from "./pagination.js";
7
+ export { CURSOR_KEY_BYTES, INVALID_CURSOR_CODE, InvalidCursorError, createCursorSigner, } from "./signedCursor.js";
8
+ export type { CursorSigner, CursorSignerOptions, InvalidCursorReason } from "./signedCursor.js";
7
9
  export { buildHttpApp } from "./httpApp.js";
8
10
  export type { AppDeps } from "./httpApp.js";
9
11
  export { PERSON_UNRESOLVED_CODE, resolveVerifiedPerson, withVerifiedPerson, } from "./verifiedPerson.js";
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ export { ConfigurationError, VARIABLE_TYPES, VariableDeclarationError, defineVar
2
2
  export { createBoilerplateMcpServer } from "./mcpServerFactory.js";
3
3
  export { EXAMPLE_ITEMS_READER, LAKE_UNREADABLE_CODE, LakeReadError, checkLakePage, createInMemoryLakeReader, } from "./lake/reader.js";
4
4
  export { readPage } from "./pagination.js";
5
+ export { CURSOR_KEY_BYTES, INVALID_CURSOR_CODE, InvalidCursorError, createCursorSigner, } from "./signedCursor.js";
5
6
  export { buildHttpApp } from "./httpApp.js";
6
7
  export { PERSON_UNRESOLVED_CODE, resolveVerifiedPerson, withVerifiedPerson, } from "./verifiedPerson.js";
7
8
  export { UpstreamTokenError, clientCredentialsFromEnv, createClientCredentialsTokenSource, } from "./upstream/clientCredentials.js";
@@ -79,6 +79,8 @@ export interface ConvexLakeReaderOptions<Row = RawRow> {
79
79
  readonly client?: ConvexLakeQueryClient;
80
80
  /** A fetch implementation passed to the `ConvexHttpClient` built when `client` is absent. */
81
81
  readonly fetch?: typeof globalThis.fetch;
82
+ /** Time budget of one read. Default: `UPSTREAM_TIMEOUT_MS` (30 s). Applies when `client` is absent. */
83
+ readonly timeoutMs?: number;
82
84
  }
83
85
  /**
84
86
  * A read-only lake reader over a Convex deployment. Throws on an unreadable configuration (the
@@ -49,6 +49,7 @@
49
49
  import { ConvexHttpClient } from "convex/browser";
50
50
  import { makeFunctionReference } from "convex/server";
51
51
  import { defineVariables, isSecureUrl, loadVariables } from "../declaredVariables.js";
52
+ import { boundedFetch, configuredUpstreamTimeoutMs } from "../upstream/timeout.js";
52
53
  import { LakeReadError, assertOrderedKeys } from "./reader.js";
53
54
  const ARGUMENT_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
54
55
  function configurationError(message) {
@@ -91,10 +92,13 @@ function checkOptions(options) {
91
92
  return options.url.replace(/\/+$/, "");
92
93
  }
93
94
  /** A ConvexHttpClient seen through the read-only surface. Its constructor errors name the URL. */
94
- function httpClient(url, fetchImpl) {
95
+ function httpClient(url, fetchImpl, timeoutMs) {
95
96
  let http;
96
97
  try {
97
- http = new ConvexHttpClient(url, { logger: false, ...(fetchImpl ? { fetch: fetchImpl } : {}) });
98
+ http = new ConvexHttpClient(url, {
99
+ logger: false,
100
+ fetch: boundedFetch(fetchImpl ?? globalThis.fetch, timeoutMs),
101
+ });
98
102
  }
99
103
  catch {
100
104
  throw configurationError("the Convex client refused the deployment url (value not shown)");
@@ -114,7 +118,8 @@ function isRawRow(value) {
114
118
  export function createConvexLakeReader(options) {
115
119
  const url = checkOptions(options);
116
120
  const { functions, keyField, token, tokenArg, parseRow } = options;
117
- const client = options.client ?? httpClient(url, options.fetch);
121
+ const client = options.client ??
122
+ httpClient(url, options.fetch, options.timeoutMs ?? configuredUpstreamTimeoutMs());
118
123
  if (token !== undefined && tokenArg === undefined)
119
124
  client.setAuth(token);
120
125
  const refs = {
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "Mcp": {
3
3
  "auth": {
4
- "unauthorized": "Unauthorized"
4
+ "unauthorized": "Unauthorized",
5
+ "originNotAllowed": "This origin is not allowed to call this endpoint"
5
6
  },
6
7
  "tenant": {
7
8
  "permissionDenied": "permission denied: no tenant resolved for this caller"
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "Mcp": {
3
3
  "auth": {
4
- "unauthorized": "Non autorisé"
4
+ "unauthorized": "Non autorisé",
5
+ "originNotAllowed": "Cette origine n'est pas autorisée à appeler ce point d'accès"
5
6
  },
6
7
  "tenant": {
7
8
  "permissionDenied": "permission refusée : aucun tenant résolu pour cet appelant"
@@ -32,6 +32,7 @@ import { McpServer } from "@modelcontextprotocol/server";
32
32
  import type { ExampleItem } from "./exampleItems.js";
33
33
  import { type McpLocale } from "./i18n.js";
34
34
  import { type LakeReader } from "./lake/reader.js";
35
+ import type { CursorSigner } from "./signedCursor.js";
35
36
  import { type ResolvedTenant } from "./tenancy.js";
36
37
  import type { CallerResolution } from "./verifiedPerson.js";
37
38
  /** Oldest 2025-era protocol revision served; the legacy `initialize` handshake never offers older. */
@@ -74,5 +75,11 @@ export interface McpServerDeps {
74
75
  * before. A derived server injects its own (e.g. `@vantageos/mcp-boilerplate/lake/convex`).
75
76
  */
76
77
  lake?: LakeReader<ExampleItem>;
78
+ /**
79
+ * Signs and verifies the `list_items` cursor (src/signedCursor.ts). Default: the process-wide
80
+ * signer of src/pagination.ts, whose key is random per process. A multi-instance deployment
81
+ * injects one signer built on a shared key, or its instances reject each other's cursors.
82
+ */
83
+ cursorSigner?: CursorSigner;
77
84
  }
78
85
  export declare function createBoilerplateMcpServer(deps?: McpServerDeps): McpServer;
@@ -187,7 +187,7 @@ export function createBoilerplateMcpServer(deps = {}) {
187
187
  try {
188
188
  const tenantId = deps.tenant?.tenantId;
189
189
  const scope = tenantId === undefined ? undefined : { tenantId };
190
- const { items, ...page } = await readPage(lake, args, PAGINATION, scope);
190
+ const { items, ...page } = await readPage(lake, args, PAGINATION, scope, deps.cursorSigner);
191
191
  // Defence in depth: the scope was applied inside the read; a row of another tenant that
192
192
  // still comes back means the reader ignored it. Refuse the page, never filter it.
193
193
  if (tenantId !== undefined && scopeRowsForTenant(tenantId, items).length !== items.length) {