@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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +86 -3
- package/README.fr.md +74 -5
- package/README.md +69 -5
- package/dist/config.d.ts +1 -1
- package/dist/config.js +3 -2
- package/dist/http.js +7 -3
- package/dist/httpApp.d.ts +12 -0
- package/dist/httpApp.js +20 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/lake/convex.d.ts +2 -0
- package/dist/lake/convex.js +8 -3
- package/dist/locales/en.json +2 -1
- package/dist/locales/fr.json +2 -1
- package/dist/mcpServerFactory.d.ts +7 -0
- package/dist/mcpServerFactory.js +1 -1
- package/dist/pagination.d.ts +13 -14
- package/dist/pagination.js +28 -25
- package/dist/settings.d.ts +72 -0
- package/dist/settings.js +53 -0
- package/dist/signedCursor.d.ts +48 -0
- package/dist/signedCursor.js +98 -0
- package/dist/upstream/clientCredentials.d.ts +2 -0
- package/dist/upstream/clientCredentials.js +10 -4
- package/dist/upstream/paginatedClient.d.ts +22 -10
- package/dist/upstream/paginatedClient.js +51 -33
- package/dist/upstream/timeout.d.ts +26 -0
- package/dist/upstream/timeout.js +37 -0
- package/examples/oauth.env.example +7 -0
- package/mcp-server.json +1 -1
- package/package.json +5 -3
- package/plugin.json +1 -1
- package/scripts/readme-fr.mjs +150 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vantage-mcp-boilerplate",
|
|
3
3
|
"displayName": "VantageOS MCP Boilerplate",
|
|
4
|
-
"version": "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.
|
|
418
|
-
[0.
|
|
419
|
-
[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
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
|
329
|
-
to the path and params
|
|
330
|
-
|
|
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";
|
package/dist/lake/convex.d.ts
CHANGED
|
@@ -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
|
package/dist/lake/convex.js
CHANGED
|
@@ -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, {
|
|
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 ??
|
|
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 = {
|
package/dist/locales/en.json
CHANGED
package/dist/locales/fr.json
CHANGED
|
@@ -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;
|
package/dist/mcpServerFactory.js
CHANGED
|
@@ -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) {
|