@vantageos/mcp-boilerplate 0.2.4 → 0.3.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.
Files changed (57) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +147 -0
  3. package/README.fr.md +318 -15
  4. package/README.md +302 -15
  5. package/dist/buildIdentity.d.ts +19 -0
  6. package/dist/buildIdentity.js +62 -0
  7. package/dist/declaredVariables.d.ts +205 -0
  8. package/dist/declaredVariables.js +451 -0
  9. package/dist/exampleItems.d.ts +15 -0
  10. package/dist/exampleItems.js +28 -1
  11. package/dist/httpApp.d.ts +17 -0
  12. package/dist/httpApp.js +19 -2
  13. package/dist/i18n.d.ts +10 -0
  14. package/dist/i18n.js +12 -0
  15. package/dist/index.d.ts +12 -0
  16. package/dist/index.js +6 -0
  17. package/dist/lake/convex.d.ts +101 -0
  18. package/dist/lake/convex.js +224 -0
  19. package/dist/lake/reader.d.ts +88 -0
  20. package/dist/lake/reader.js +120 -0
  21. package/dist/locales/en.json +42 -0
  22. package/dist/locales/fr.json +42 -0
  23. package/dist/mcpServerFactory.d.ts +31 -14
  24. package/dist/mcpServerFactory.js +116 -48
  25. package/dist/oauth/authorizationServer.js +63 -18
  26. package/dist/oauth/clerk.d.ts +2 -0
  27. package/dist/oauth/clerk.js +15 -5
  28. package/dist/oauth/clientRegistry.d.ts +19 -4
  29. package/dist/oauth/clientRegistry.js +20 -10
  30. package/dist/oauth/picker.d.ts +5 -2
  31. package/dist/oauth/picker.js +34 -4
  32. package/dist/oauth/registerLimiter.d.ts +34 -0
  33. package/dist/oauth/registerLimiter.js +95 -0
  34. package/dist/oauth/settings.d.ts +2 -0
  35. package/dist/oauth/settings.js +2 -0
  36. package/dist/pagination.d.ts +66 -0
  37. package/dist/pagination.js +100 -0
  38. package/dist/serverManifest.d.ts +32 -0
  39. package/dist/serverManifest.js +68 -0
  40. package/dist/stdio.js +5 -1
  41. package/dist/tenancy.d.ts +1 -1
  42. package/dist/toolRecord.d.ts +94 -0
  43. package/dist/toolRecord.js +52 -0
  44. package/dist/ui/uiResource.d.ts +11 -4
  45. package/dist/ui/uiResource.js +8 -4
  46. package/dist/upstream/clientCredentials.d.ts +106 -0
  47. package/dist/upstream/clientCredentials.js +283 -0
  48. package/dist/upstream/paginatedClient.d.ts +142 -0
  49. package/dist/upstream/paginatedClient.js +422 -0
  50. package/dist/verifiedPerson.d.ts +85 -0
  51. package/dist/verifiedPerson.js +112 -0
  52. package/docs/oauth-stores.md +2 -1
  53. package/examples/oauth.env.example +5 -0
  54. package/mcp-server.json +13 -0
  55. package/package.json +19 -4
  56. package/plugin.json +1 -1
  57. package/skills/connect-mcp-server/SKILL.md +5 -5
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vantage-mcp-boilerplate",
3
3
  "displayName": "VantageOS MCP Boilerplate",
4
- "version": "0.2.4",
4
+ "version": "0.3.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,153 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-10-28
11
+
12
+ ### Added
13
+
14
+ - **Data-lake reader** (`src/lake/reader.ts`, exported from the package root): a derived server reads
15
+ its client's data lake through a READ-ONLY interface, `LakeReader` — `listPage({ after?, limit,
16
+ scope? })` -> `{ rows, exhausted }` in ascending key order, `get(key, scope?)`, `keyOf(row)`; no
17
+ write method. `list_items` reads through the reader injected as `AppDeps.lake` /
18
+ `McpServerDeps.lake`; the default is the in-memory seed (`EXAMPLE_ITEMS_READER`,
19
+ `createInMemoryLakeReader`), so the output is unchanged. The caller's tenant travels into the read
20
+ as `scope`, and a row of another tenant coming back fails the call closed. `readPage` (exported)
21
+ applies the tool's bounds and cursor to any reader and checks each page against the contract: an
22
+ unreadable page is a `LakeReadError` (`malformed_page`, `malformed_row`, `out_of_scope`,
23
+ `read_failed`), answered by `list_items` as a tool error with the stable code `lake_unreadable`
24
+ and a FR/EN message, never an empty page. The `list_items` widget resource no longer takes rows it
25
+ never rendered (`buildUiResource({ domain })`).
26
+ - **Convex lake adapter** at its own subpath, `@vantageos/mcp-boilerplate/lake/convex`, with `convex`
27
+ as an OPTIONAL peer dependency (`^1.46.0`; dev dependency pinned `1.46.0`).
28
+ `createConvexLakeReader` takes the deployment URL (https, or http on loopback; no query, fragment
29
+ or credentials), an optional read token (sent through `setAuth`, or as the argument named by
30
+ `tokenArg`), the NAMES of the page and get query functions and the key field — all configuration
31
+ — and an optional `parseRow`. It only ever calls `ConvexHttpClient.query`, never a mutation, an
32
+ action or `function`. `convexLakeReaderFromEnv(env, names, options)` declares the connection
33
+ variables with `defineVariables` (url; token as a secret) and refuses at start, naming every
34
+ missing or invalid variable, never a value. A failing call is `read_failed` and the client's
35
+ message is not forwarded; no error carries the token, the URL or a row value.
36
+ - **The verified person reaches tool handlers** (`src/verifiedPerson.ts`, exported from the package
37
+ root). With `AppDeps.identity` (`orgLookups`, `principalLookups`, and `adminRoles`, required and
38
+ non-empty, refused at build otherwise, no default role), each `/mcp` request resolves the
39
+ caller's person ONCE through `@vantageos/cloud-identity`'s `resolveCallerFromClaims`, from the
40
+ same verified claims as the tenant; nothing reads a claim locally. The person travels in the
41
+ per-request server deps beside the tenant (`McpServerDeps.caller`), frozen, exactly the package's
42
+ fields (`subject`, `actorName`, `org`, `principal`, `role`, `isOrgAdmin`), a copy that never
43
+ freezes the injected store's objects. A tool declares it needs the person with
44
+ `withVerifiedPerson(deps, handler)`; unless the person resolved, the handler does not run and the
45
+ call is an MCP tool error with the stable code `person_unresolved`, a FR/EN message, and a
46
+ `reason`: `identity_not_configured`, `no_verified_session` (public tier, and stdio, which never
47
+ has a person) or `refused` (the package's refusal `code` and `reason`, never a claim value).
48
+ Tools that do not wrap their handler are unchanged.
49
+ - **Paginated upstream client** (`src/upstream/paginatedClient.ts`, exported from the package root)
50
+ for a derived server that reads a third-party endpoint paginated by offset and limit.
51
+ `createPaginatedUpstreamClient` takes the offset and limit parameter names, their placement
52
+ (`query` or `jsonBody`) and the method as configuration, a REQUIRED `allowedOrigins` list (every
53
+ request URL is checked before any network call, https or loopback http; redirects are refused,
54
+ never followed) and a REQUIRED finite `rowCeiling`. `readAll` walks the pages to the short page
55
+ (or the declared total) and refuses a set above the ceiling with an `UpstreamPageError`
56
+ (`row_ceiling_exceeded`) naming it, never a silently truncated set. `readPage` reads one page per
57
+ MCP call behind an opaque cursor bound to the path and params; a cursor replayed against another
58
+ query or altered without recomputing its unkeyed fingerprint is refused as `invalid_cursor`
59
+ (misuse detection, not forgery protection: a minted offset above the ceiling is refused before any
60
+ request). An empty page before a declared total is `inconsistent_page`, so a walk never
61
+ re-requests the same offset. 429 and 503 are retried with exponential backoff and jitter,
62
+ honouring `Retry-After` (capped); no other status is retried. An unreadable page fails closed.
63
+ Errors name the path and status, never a token, a parameter value or the body. Accepts the token
64
+ source's `fetchWithToken` as `fetchImpl`.
65
+ - **Declared variables: a configuration extension point** (`src/declaredVariables.ts`, exported
66
+ from the package root) for a derived server's own environment variables. `defineVariables`
67
+ takes, per logical key, `{ env, type, required?, default?, secret?, description: { en, fr } }`
68
+ with `type` one of `string`, `integer` (`min`/`max`), `boolean` (`"0"`/`"1"`), `url` (https,
69
+ http on loopback only), `originList`, `enum` (`values`) and `json` (object or array); it reuses
70
+ the `src/envKnobs.ts` readers. `loadVariables` returns a frozen object whose type is inferred
71
+ from the declaration (an undeclared key does not compile, and reading one at runtime throws,
72
+ naming it), or throws ONE `ConfigurationError` whose `problems` list every missing or invalid
73
+ variable (`{ key, env, reason, expected }`) and whose FR+EN message names them, never a value.
74
+ A broken declaration (duplicate env name, unknown type or property, a default that does not
75
+ validate, a default on a required variable) is a `VariableDeclarationError` at definition.
76
+ `describeVariables` lists the contract without values, for `--help` or docs. Serialising the
77
+ result (`JSON.stringify`, `String()`, template literals, `util.inspect`) never throws and shows a
78
+ `secret: true` value as the fixed mask `[secret]`; a secret is non-enumerable, so spread,
79
+ `Object.entries` and `Object.keys` never copy it, and it is read by name only. The boilerplate's
80
+ own readers (`OAUTH_ENV`, the envKnobs knobs, `clientCredentialsFromEnv`) are left as they are:
81
+ candidates to migrate onto the registry, not migrated here.
82
+ - **Outbound OAuth 2 `client_credentials` token source** (`src/upstream/clientCredentials.ts`,
83
+ exported from the package root) for a derived server that reads a third-party upstream API.
84
+ `createClientCredentialsTokenSource` caches the token until `expires_in` minus a skew (default
85
+ 60 s; 300 s assumed when `expires_in` is absent), shares one in-flight token request between
86
+ concurrent callers, and `fetchWithToken` attaches the bearer token with one refetch and one
87
+ retry on a 401. `client_secret_basic` (default) or `client_secret_post`. A token endpoint
88
+ failure or an unreadable response is an `UpstreamTokenError` naming the status and the OAuth
89
+ `error` code, never a credential or a token. `clientCredentialsFromEnv` reads the variable
90
+ names the caller chooses and names any missing one, never its value. Create it once at module
91
+ scope (the server is rebuilt per request); both READMEs show the pattern.
92
+ The token request refuses redirects (`redirect: "error"`), so a 307/308 never re-sends the secret.
93
+ - **`/register` budget per IPv6 /48** (`MCP_REGISTER_LIMIT_PER_48_PER_MINUTE`, default 100). Every
94
+ /64 inside one /48 shares it, beside the per-/64 budget, so one /48 cannot fill the client
95
+ registry to its 503. An invalid value stops the server at start.
96
+ - A test pins that a denial with a forged `consentToken` is refused with no redirect; the deny
97
+ comment now states that the package verifies the token (cloud-identity >= 0.18).
98
+ - **Server manifest** `mcp-server.json` (mcp-standard C-18): capabilities, transport, version and
99
+ feature switches in one file. `src/serverManifest.ts` loads it fail-closed, refusing an unreadable
100
+ field by name (FR+EN in one message). The factory serves its capabilities;
101
+ `scripts/sync-plugin-version.mjs` reads the version from it and refuses a `package.json` that
102
+ disagrees, naming both. Bump `package.json` and `mcp-server.json` together.
103
+ - **Typed tool record** (`src/toolRecord.ts`, C-8, C-9, C-10, C-12, C-13): every tool declares in
104
+ its `annotations` the `authRead`/`authWrite` pair, the `verb`, the `table`, the `searchMechanism`
105
+ (`indexed-range`, never `unindexed-scan`) and `tokensEstimeContexte`, plus a `CONTEXT_CEILING`
106
+ (global and per tool). The sets are closed TypeScript unions. Served on the wire in both protocol
107
+ eras (the SDK `Client` drops unknown annotation keys; the tests read the raw result).
108
+ - **Cursor pagination** on `list_items` (C-11, S-12): `limit` (default 20,
109
+ capped at 100) and an opaque `cursor` over the key-sorted collection; `nextCursor` and
110
+ `exhausted` tell a truncated page from the last one. An invalid or expired cursor is a TOOL INPUT
111
+ error, not a protocol error: a result with `isError: true` and the stable machine code
112
+ `invalid_cursor` in `structuredContent.error.code`, with an FR+EN message via `X-MCP-Locale`;
113
+ never a silent first page. **C-11 scope split:** the spec 2025-11-25 changelog (SEP-1303) says
114
+ input validation errors are returned as Tool Execution Errors, so JSON-RPC `-32602` stays only
115
+ with the cursor of the protocol list methods, which the SDK serves (S-12). The standard amendment
116
+ is tracked in task `k178z7mdt51jchj82e10jq1qp18g36s4`.
117
+ - **Build identity** (C-25, S-7): `buildId`, a hash of the files the process runs from, beside
118
+ `version` on the unauthenticated `/health` and in the server identity of `server/discover` and
119
+ `initialize`. It changes when `dist/` changes.
120
+ - **Release workflow** `.github/workflows/release.yml` (C-17): `npm publish --provenance`, then the
121
+ `dist-tag` re-read at the registry with the cache bypassed, then the GitHub release. GitHub
122
+ Actions is disabled across the fleet; the README documents the equivalent without CI (packed
123
+ tarball checksum against `dist.shasum`).
124
+ - README (EN+FR): exact-version pin, rollback line and verification (C-7).
125
+ - Evals: `list_items` has eight cases, covering the page limit, a truncated page, the
126
+ invalid-cursor failure path, the `ui://` link and the French fallback. The eval harness reads the
127
+ JSON body from the first text block that parses and gains a `resourceUri` assertion.
128
+
129
+ ### Removed (BREAKING for forks)
130
+
131
+ - **The `render_items_ui` tool is removed; `list_items` carries the UI.** One operation on one
132
+ table is one tool (mcp-standard C-12): `render_items_ui` and `list_items` shared the triplet
133
+ (`list`, `items`, `indexed-range`). `list_items` now holds `_meta.ui.resourceUri` (plus the
134
+ additive `openai/outputTemplate` keys), takes an optional `locale`, and returns
135
+ `structuredContent` (`items`, `locale`, `exhausted`, `nextCursor`) with a markdown fallback first
136
+ (A-1) and the `ui://` resource. The `ui://` resource is kept and renders the `list_items` result.
137
+ A fork that calls or documents `render_items_ui` calls `list_items` instead. The seed is still 0.x.
138
+
139
+ ### Changed
140
+
141
+ - **`@vantageos/cloud-identity` `^0.19.1` -> `^0.20.2`** for `resolveCallerFromClaims` (tagged
142
+ `@callerResolver`), the package entry from verified claims to the calling person. No breaking
143
+ change for this boilerplate: the runtime modules that changed between the two versions
144
+ (`org-by-id`, `person-principal`, `principal-by-id`, plus new exports on the index) export none of
145
+ `requireTenantId`, `isRowInTenant`, `unauthorizedChallenge`, `verifyMcpAccessToken`,
146
+ `buildProtectedResourceMetadata`, the authorize flow or the token exchange. The package's own
147
+ dependencies are unchanged, so only its lockfile entry moves.
148
+
149
+ - `list_items` now returns one page (`exhausted`, optional `nextCursor`)
150
+ instead of every row, and its call result carries a markdown fallback, the JSON mirror and the
151
+ `ui://` resource; a call with no argument still returns the whole seed collection (three
152
+ rows, under the default page size).
153
+ - `TOOL_ANNOTATIONS` (four booleans shared by all tools) is replaced by one record per tool in
154
+ `src/toolRecord.ts`.
155
+ - `/health` answers `{ status, version, buildId }` instead of `{ status }`.
156
+
10
157
  ## [0.2.4] - 2026-10-11
11
158
 
12
159
  ### Changed
package/README.fr.md CHANGED
@@ -10,8 +10,8 @@ serveur d'autorisation OAuth 2.1** (une personne Clerk choisit une organisation)
10
10
  une UI MCP Apps et des messages FR/EN. Aucune route REST personnalisée ne se place
11
11
  jamais devant un appel d'outil.
12
12
 
13
- Il embarque trois petits outils d'exemple (`ping`, `list_items`, `render_items_ui`)
14
- qui exercent chaque couche. Remplacez-les par les vôtres ; le câblage reste.
13
+ Il embarque deux petits outils d'exemple (`ping`, `list_items`) qui exercent
14
+ chaque couche ; `list_items` porte aussi le widget MCP Apps. Remplacez-les par les vôtres ; le câblage reste.
15
15
 
16
16
  ## Structure
17
17
 
@@ -19,16 +19,24 @@ qui exercent chaque couche. Remplacez-les par les vôtres ; le câblage reste.
19
19
  src/
20
20
  mcpServerFactory.ts # définition unique du serveur MCP, partagée par les deux transports
21
21
  version.ts # nom + version du serveur, dérivés de package.json
22
+ serverManifest.ts # chargeur fail-closed de mcp-server.json (le manifeste serveur)
23
+ toolRecord.ts # fiche d'outil typée : autorité, verbe, table, mécanisme, pagination, coût
24
+ pagination.ts # pagination par curseur appliquée par l'outil (défaut, max fini, invalid_cursor)
25
+ buildIdentity.ts # identité de build dérivée de l'arbre servi
22
26
  stdio.ts # serveStdio(factory) : les deux ères du protocole sur stdio
23
27
  http.ts # adaptateur Node http.Server autour de l'app Hono
24
28
  httpApp.ts # createMcpHandler(factory) sur /mcp + la barrière OAuth
25
29
  auth.ts # câblage resource-server (@vantageos/cloud-identity)
26
30
  config.ts # AuthMode + AuthConfig d'émetteur externe, dérivés de l'environnement
27
31
  tenancy.ts # JWT vérifié -> tenant ; lignes filtrées par orgId stocké
32
+ lake/ # lecteur de data-lake en lecture seule : interface + défaut en mémoire, adaptateur Convex
33
+ reader.ts convex.ts
28
34
  oauth/ # serveur d'autorisation propre (cloud-identity décide)
29
35
  authorizationServer.ts settings.ts clerk.ts tokenIssuer.ts
30
36
  clientRegistry.ts codeStore.ts picker.ts
31
37
  ui/ # widget MCP Apps + ressource ui://
38
+ mcp-server.json # le manifeste serveur : capabilities, transport, version, interrupteurs de fonctionnalités
39
+ .github/workflows/release.yml # npm publish --provenance + preuve au registre (Actions est désactivé dans la flotte)
32
40
  skills/connect-mcp-server/SKILL.md # skill d'onboarding (anglais)
33
41
  scripts/
34
42
  plugin-manifest.source.json # l'UNIQUE source de tous les manifestes de packaging
@@ -74,13 +82,34 @@ lue dans `package.json`.
74
82
 
75
83
  ## Fiches d'outils
76
84
 
77
- Chaque outil déclare `title`, les quatre annotations en booléens explicites
78
- (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`), un schéma
79
- d'entrée et un schéma de sortie typés (Zod 4), et renvoie `structuredContent` plus
80
- un bloc texte portant le même JSON. `tests/toolRecordComplete.test.ts` parcourt
81
- `tools/list` et échoue au moindre manque. Les annotations sont des indications non
82
- fiables pour le client (mcp-standard C-15) : ne conditionnez jamais une action sur
83
- elles seules.
85
+ Chaque outil déclare `title`, un schéma d'entrée et un schéma de sortie typés
86
+ (Zod 4), et renvoie `structuredContent` plus un bloc texte portant le même JSON. Ses
87
+ `annotations` portent la fiche typée de `src/toolRecord.ts`, lisible depuis
88
+ `tools/list` sans lire la source :
89
+
90
+ | Clé | Règle | Sens |
91
+ |---|---|---|
92
+ | `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` | C-15 | les quatre booléens de comportement |
93
+ | `authRead`, `authWrite` | C-8 | couple d'autorité parmi `public` / `org-member` / `org-admin` / `fleet-internal` / `master`, distincts ; résolu depuis l'identité vérifiée, jamais depuis un argument |
94
+ | `verb`, `table` | C-9, C-12 | l'opération (`list`, `get`, `create`, `pure`...) et la donnée touchée |
95
+ | `searchMechanism` | C-10 | comment une liste/recherche est servie (`indexed-range` ici) ; `unindexed-scan` ne se publie pas |
96
+ | `paginationCursor`, `paginationDefault`, `paginationMax` | C-11 | curseur, taille de page par défaut et maximum fini, appliqués par `src/pagination.ts` |
97
+ | `tokensEstimeContexte` | C-13 | coût de contexte estimé ; `CONTEXT_CEILING` borne chaque outil et la somme |
98
+
99
+ Les ensembles sont des unions TypeScript fermées : une valeur hors ensemble ne
100
+ compile pas, et un outil de liste ne peut omettre ni son mécanisme ni sa pagination.
101
+ La fiche de chaque outil est un objet de littéraux, lisible par un contrôleur
102
+ 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
+ et `exhausted: false`, la dernière page `exhausted: true` ; un curseur invalide ou expiré est
105
+ une erreur d'entrée de l'outil : un résultat `isError: true` avec
106
+ `structuredContent.error.code` valant `invalid_cursor` (message FR/EN), jamais une
107
+ première page silencieuse. Le `-32602` JSON-RPC reste réservé au curseur des méthodes
108
+ de liste du protocole, servies par le SDK.
109
+
110
+ `tests/toolRecordComplete.test.ts` et les suites C-8..C-13 parcourent `tools/list` et
111
+ échouent au moindre manque. Les annotations sont des indications non fiables pour le
112
+ client (mcp-standard C-15) : ne conditionnez jamais une action sur elles seules.
84
113
 
85
114
  ## Paliers d'authentification
86
115
 
@@ -130,7 +159,15 @@ déploiement mono-instance. Derrière un reverse proxy, réglez `MCP_TRUSTED_PRO
130
159
  sur le nombre de proxys de confiance (défaut `0`) : la limite de débit de `/register`
131
160
  se fonde alors sur l'entrée `X-Forwarded-For` ajoutée par le proxy, jamais sur la
132
161
  première, fournie par le client ; avec `0`, elle se fonde sur l'adresse du socket et
133
- ignore l'en-tête.
162
+ ignore l'en-tête. La valeur est le nombre de SAUTS de proxy qui ajoutent une entrée à
163
+ `X-Forwarded-For`, pas le nombre de produits déployés : Railway demande `2` (mesuré : son
164
+ en-tête porte le client, puis le proxy d'entrée). Une valeur trop BASSE échoue en silence,
165
+ car le limiteur se fonde alors sur l'adresse d'un proxy partagée par de nombreux clients.
166
+ Pour la mesurer, envoyez 21 enregistrements depuis un même client et attendez un `429` au
167
+ 21e. Un client IPv6 est compté sur son /64 ET sur son /48 : tous les /64 d'un même /48 se
168
+ partagent `MCP_REGISTER_LIMIT_PER_48_PER_MINUTE` enregistrements par minute (défaut `100`),
169
+ si bien qu'un /48, qui contient 65536 /64, ne peut pas remplir le registre de clients jusqu'à
170
+ son `503`. Une valeur invalide arrête le serveur au démarrage.
134
171
 
135
172
  **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
173
  (défaut 1 Mio) du corps d'une requête et répond `413` avant tout handler.
@@ -207,6 +244,220 @@ vérifiée de l'appelant (`isRowInTenant`) ; un jeton sans organisation reçoit
207
244
  refus d'outil explicite, jamais une vue non filtrée. Ne résolvez le tenant de
208
245
  l'appelant qu'à partir du jeton vérifié, jamais d'un argument d'outil.
209
246
 
247
+ ### La personne vérifiée dans un outil
248
+
249
+ Un outil peut aussi avoir besoin de la PERSONNE derrière l'appel (sujet, rôle, statut
250
+ d'administrateur), par exemple pour filtrer des lignes par personne. La personne est résolue une
251
+ fois par requête `/mcp` par `resolveCallerFromClaims` de `@vantageos/cloud-identity`, à partir des
252
+ mêmes claims vérifiés que le tenant ; le serveur ne lit jamais un claim lui-même. Le paquet lit
253
+ deux magasins que vous injectez, et les rôles d'administrateur sont à vous de les nommer
254
+ (obligatoires, non vides, aucun défaut) :
255
+
256
+ ```ts
257
+ import { buildHttpApp } from "@vantageos/mcp-boilerplate";
258
+
259
+ const app = buildHttpApp({
260
+ authMode: "oauth",
261
+ authorizationServer,
262
+ identity: {
263
+ orgLookups: { orgById: (orgId) => orgStore.get(orgId) },
264
+ principalLookups: {
265
+ personById: (personId, orgId) => people.get(orgId, personId),
266
+ organisationById: (orgId) => orgStore.row(orgId),
267
+ orgKindOf: (orgId) => orgStore.kind(orgId),
268
+ },
269
+ adminRoles: ["org:admin"],
270
+ },
271
+ });
272
+ ```
273
+
274
+ Un outil déclare qu'il exige la personne en enveloppant son handler avec `withVerifiedPerson`
275
+ (dans `src/mcpServerFactory.ts`, où `deps` est le contexte par requête dans lequel voyage le
276
+ tenant) :
277
+
278
+ ```ts
279
+ server.registerTool("my_rows", config, withVerifiedPerson(deps, async (args, person) => {
280
+ // person : { subject, actorName, org, principal, role, isOrgAdmin }, gelée, telle que le paquet l'a rendue
281
+ return structuredResult({ rows: rowsVisibleTo(person.subject) });
282
+ }));
283
+ ```
284
+
285
+ Sauf si la personne est résolue, le handler ne s'exécute pas : l'appel reçoit une erreur d'outil
286
+ MCP au code stable `person_unresolved` (`structuredContent.error.code`), un message FR/EN selon
287
+ `X-MCP-Locale`, et une `reason` : `identity_not_configured` (aucune `identity` injectée),
288
+ `no_verified_session` (palier public, et **stdio, qui n'a jamais de personne** : ce transport ne
289
+ porte aucun bearer) ou `refused` (le paquet a refusé ; son `code` et sa `reason` sont transmis,
290
+ jamais une valeur de claim). Un outil qui n'enveloppe pas son handler s'exécute exactement comme
291
+ avant, sans personne.
292
+
293
+ ## Appeler une API amont (OAuth 2 client_credentials)
294
+
295
+ Un serveur dérivé de ce boilerplate qui lit une API tierce avec ses propres identifiants utilise
296
+ `createClientCredentialsTokenSource` (`src/upstream/clientCredentials.ts`). Créez-le UNE fois,
297
+ au niveau du module : le serveur MCP est reconstruit à chaque requête, et le cache du jeton vit
298
+ dans la source.
299
+
300
+ ```ts
301
+ const upstream = createClientCredentialsTokenSource(
302
+ clientCredentialsFromEnv({
303
+ clientIdVar: "UPSTREAM_CLIENT_ID",
304
+ clientSecretVar: "UPSTREAM_CLIENT_SECRET",
305
+ tokenUrlVar: "UPSTREAM_TOKEN_URL",
306
+ })
307
+ );
308
+ // dans un handler d'outil :
309
+ const res = await upstream.fetchWithToken("https://api.upstream.example/v1/things");
310
+ ```
311
+
312
+ Les noms de variables sont à votre choix. Le jeton est mis en cache jusqu'à `expires_in` moins
313
+ une marge de 60 s, les appels simultanés partagent une seule requête de jeton, et un 401
314
+ déclenche un seul nouveau jeton et une seule nouvelle tentative, jamais plus. L'authentification
315
+ du client est `client_secret_basic` par défaut, ou `client_secret_post`. Une variable manquante
316
+ est nommée au démarrage, sans jamais afficher sa valeur ; un échec du point de jeton est une
317
+ `UpstreamTokenError` portant le statut et le code OAuth `error`, jamais un identifiant ni un
318
+ jeton.
319
+
320
+ ### Lire toutes les pages d'un endpoint paginé par décalage
321
+
322
+ `createPaginatedUpstreamClient` (`src/upstream/paginatedClient.ts`) lit un endpoint amont paginé
323
+ par décalage (offset) et taille (limit). Les noms des paramètres, leur emplacement (`query` ou
324
+ `jsonBody`) et la méthode sont de la configuration. Créez-le une fois au niveau du module, à côté
325
+ de la source de jeton :
326
+
327
+ ```ts
328
+ const things = createPaginatedUpstreamClient({
329
+ fetchImpl: upstream.fetchWithToken,
330
+ baseUrl: "https://api.upstream.example/v1/",
331
+ allowedOrigins: ["https://api.upstream.example"],
332
+ offsetParam: "offset",
333
+ limitParam: "limit",
334
+ placement: "jsonBody", // POST avec offset et limit dans le corps JSON ; "query" envoie un GET
335
+ rowCeiling: 5000,
336
+ extractRows: (body) => (body as { data: unknown[] }).data,
337
+ });
338
+ const all = await things.readAll("things", { status: "active" }); // toutes les pages, ou un refus
339
+ const page = await things.readPage("things", { status: "active" }, cursor); // une page par appel
340
+ ```
341
+
342
+ `allowedOrigins` et `rowCeiling` sont obligatoires. Un chemin qui se résout vers une autre origine
343
+ est refusé avant tout appel réseau : le jeton ne part jamais vers un hôte nommé par un argument
344
+ d'outil, et les redirections sont refusées, jamais suivies (`redirect: "error"`). `readAll` s'arrête
345
+ sur la page incomplète (ou au total, avec `extractTotal`) et refuse un ensemble au-delà du plafond
346
+ 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
351
+ vide avant le total déclaré est refusée en `inconsistent_page`, jamais redemandée. Les 429 et 503
352
+ sont rejoués avec un délai exponentiel (5 tentatives par défaut, base 500 ms, plafond 30 s) et
353
+ respectent `Retry-After` ; aucun autre statut n'est rejoué. Les erreurs (`UpstreamPageError`)
354
+ nomment le chemin et le statut, jamais un jeton, une valeur de paramètre ni le corps. Gardez
355
+ `pageSize` (défaut 100) dans le maximum réel de l'amont : une page plafonnée en silence en dessous
356
+ se lit comme la dernière, sauf si la réponse déclare un total.
357
+
358
+ ## Déclarer sa propre configuration
359
+
360
+ Un serveur dérivé déclare une fois les variables d'environnement qu'il lit, et reçoit au
361
+ démarrage un objet typé, validé et gelé (`src/declaredVariables.ts`) :
362
+
363
+ ```ts
364
+ import { defineVariables, describeVariables, loadVariables } from "@vantageos/mcp-boilerplate";
365
+
366
+ export const declared = defineVariables({
367
+ apiBaseUrl: {
368
+ env: "UPSTREAM_API_BASE_URL",
369
+ type: "url",
370
+ description: { en: "Upstream API base URL", fr: "URL de base de l'API amont" },
371
+ },
372
+ readKey: {
373
+ env: "LAKE_READ_KEY",
374
+ type: "string",
375
+ secret: true,
376
+ description: { en: "Data lake read key", fr: "Clé de lecture du lac de données" },
377
+ },
378
+ pageSize: {
379
+ env: "UPSTREAM_PAGE_SIZE",
380
+ type: "integer",
381
+ min: 1,
382
+ max: 500,
383
+ required: false,
384
+ default: 100,
385
+ description: { en: "Rows per upstream page", fr: "Lignes par page amont" },
386
+ },
387
+ fieldMap: {
388
+ env: "FIELD_MAPPINGS",
389
+ type: "json",
390
+ description: { en: "Code-to-label mappings", fr: "Correspondances code-libellé" },
391
+ },
392
+ });
393
+
394
+ export const settings = loadVariables(declared); // settings.pageSize : number
395
+ console.log(describeVariables(declared)); // le contrat, pour --help : aucune valeur, aucun défaut
396
+ ```
397
+
398
+ Les noms de variables sont à votre choix. Types : `string`, `integer` (`min`/`max`), `boolean`
399
+ (`"0"` ou `"1"`), `url` (https, http seulement sur la boucle locale), `originList`, `enum`
400
+ (`values`) et `json` (un objet ou un tableau). Une variable est requise sauf si elle indique
401
+ `required: false` ; un `default` exige `required: false`. Absente ou vide, elle est manquante ;
402
+ une valeur définie mais invalide est refusée, jamais remplacée par le défaut. Toutes les variables
403
+ manquantes ou invalides sont signalées ensemble dans UNE `ConfigurationError`
404
+ (`problems: { key, env, reason, expected }[]`, message en anglais et en français) qui les nomme
405
+ sans jamais afficher de valeur. Le résultat est gelé et typé d'après la déclaration : une clé non
406
+ déclarée ne compile pas, et sa lecture à l'exécution lève une erreur. `JSON.stringify`, `String()`
407
+ et `console.log` du résultat ne lèvent jamais d'erreur et affichent une valeur `secret: true`
408
+ sous la forme `[secret]` ; un secret est ignoré par la décomposition (`...`) et `Object.entries`,
409
+ et se lit uniquement par son nom. Une déclaration cassée (nom de variable en double, type ou
410
+ propriété inconnus, défaut qui ne se valide pas) est refusée dès l'appel à `defineVariables`. Les
411
+ variables propres au boilerplate (`OAUTH_ENV`, les réglages du serveur, `clientCredentialsFromEnv`)
412
+ n'utilisent pas encore ce registre.
413
+
414
+ ## Lire le data-lake du client
415
+
416
+ Un serveur dérivé lit le data-lake de son client (l'unique source persistée) à travers une
417
+ interface en LECTURE SEULE, `LakeReader` (`src/lake/reader.ts`) : `listPage({ after?, limit, scope? })`
418
+ renvoie `{ rows, exhausted }` dans l'ordre croissant des clés, `get(key, scope?)` renvoie une ligne
419
+ ou `undefined`, et `keyOf(row)` désigne la clé stable que porte le curseur. Il n'existe aucune
420
+ méthode d'écriture. `list_items` lit à travers le lecteur injecté par `buildHttpApp({ lake })` (ou
421
+ `McpServerDeps.lake`) ; sans injection, il lit les données d'exemple en mémoire
422
+ (`EXAMPLE_ITEMS_READER`), exactement comme avant. Le tenant de l'appelant voyage DANS la lecture
423
+ sous la forme de `scope`, et chaque ligne renvoyée est revérifiée : une ligne d'un autre tenant fait
424
+ échouer l'appel, fermé. `readPage` applique les bornes et le curseur de l'outil à n'importe quel
425
+ lecteur et contrôle chaque page contre le contrat ; une page illisible (pas une liste, plus de
426
+ lignes que demandé, clés désordonnées ou non postérieures au curseur, page tronquée sans ligne,
427
+ ligne sans clé) est une `LakeReadError`, à laquelle `list_items` répond par une erreur d'outil au
428
+ code stable `lake_unreadable` avec la raison, jamais par une page vide ou partielle.
429
+
430
+ L'adaptateur Convex est livré sous son propre sous-chemin ; `convex` est une dépendance pair
431
+ optionnelle, installée seulement par un serveur qui l'importe :
432
+
433
+ ```ts
434
+ import { convexLakeReaderFromEnv } from "@vantageos/mcp-boilerplate/lake/convex";
435
+
436
+ const lake = convexLakeReaderFromEnv(
437
+ process.env,
438
+ { urlVar: "LAKE_URL", tokenVar: "LAKE_READ_KEY" },
439
+ {
440
+ functions: { page: "lakeRead:listRows", get: "lakeRead:getRow" },
441
+ keyField: "rowKey",
442
+ tokenArg: "readKey", // à omettre pour envoyer le jeton par setAuth (Authorization: Bearer)
443
+ }
444
+ );
445
+ const app = buildHttpApp({ authMode: "oauth", authorizationServer, lake });
446
+ ```
447
+
448
+ Les noms de variables, les noms des fonctions de requête et le champ clé sont de la configuration :
449
+ rien dans le paquet ne nomme une table ou une fonction. Les variables de connexion sont déclarées
450
+ avec `defineVariables` (l'URL en `url` : https, http seulement sur la boucle locale ; le jeton en
451
+ `secret`), si bien qu'une variable manquante ou invalide est refusée au démarrage, par son nom,
452
+ jamais par sa valeur. L'adaptateur n'appelle que `ConvexHttpClient.query`, jamais une mutation ni
453
+ une action. La fonction de page reçoit `{ after?, limit, tenantId? }` et renvoie les lignes dont la
454
+ clé suit `after`, par ordre croissant, au plus `limit` (l'adaptateur demande une ligne de plus que
455
+ la page pour distinguer une page tronquée de la dernière) ; la fonction de lecture unitaire reçoit
456
+ `{ key, tenantId? }` et renvoie la ligne ou `null`. Un `parseRow` optionnel (par exemple un `parse`
457
+ Zod) type chaque ligne et refuse celle qu'il ne sait pas lire. Un appel en échec est `read_failed`,
458
+ et le message du client n'est pas relayé : aucune erreur ne porte le jeton, l'URL ni une valeur de
459
+ ligne.
460
+
210
461
  ## i18n côté serveur (FR/EN)
211
462
 
212
463
  `src/i18n.ts` localise les messages d'erreur/validation MCP et la page de sélection :
@@ -228,8 +479,10 @@ stdio.
228
479
 
229
480
  ## Ajouter un outil
230
481
 
231
- Enregistrez-le une fois dans `src/mcpServerFactory.ts` avec `title`, les quatre
232
- annotations, `inputSchema` et `outputSchema`, et renvoyez `structuredResult(...)`. Il
482
+ Déclarez sa fiche dans `src/toolRecord.ts` (couple d'autorité, verbe, table, et pour
483
+ une liste/recherche le mécanisme et la pagination), enregistrez-le une fois dans
484
+ `src/mcpServerFactory.ts` avec `title`, `annotations: VOS_ANNOTATIONS`,
485
+ `inputSchema` et `outputSchema`, et renvoyez `structuredResult(...)`. Il
233
486
  est alors servi sur les deux transports et les deux ères du protocole.
234
487
  `tests/toolRecordComplete.test.ts` le garde honnête.
235
488
 
@@ -267,10 +520,60 @@ npm publish # prepublishOnly relance build + test + evals
267
520
  La publication se prouve par le `dist-tag` du registre, jamais par le code de sortie
268
521
  de `npm publish`.
269
522
 
523
+ La version vit dans `package.json` ET dans `mcp-server.json` (le manifeste serveur,
524
+ mcp-standard C-18). Montez les deux à la même valeur : `scripts/sync-plugin-version.mjs`
525
+ lit la version dans le manifeste et refuse un `package.json` qui diffère, en nommant
526
+ les deux. Le manifeste déclare aussi les capabilities du serveur, que la fabrique
527
+ sert, et les interrupteurs de fonctionnalités que les versions suivantes liront.
528
+
529
+ ## Épingler et vérifier le paquet publié
530
+
531
+ Installez depuis npm, le registre de référence, et épinglez une version EXACTE pour
532
+ qu'une mise à jour n'arrive jamais sans l'avoir demandée. `1.2.3` ci-dessous est un
533
+ exemple, pas une version publiée : listez les vraies et remplacez-la par la version
534
+ que vous avez auditée.
535
+
536
+ ```bash
537
+ npm view @vantageos/mcp-boilerplate versions --json # les versions publiées
538
+ npm install @vantageos/mcp-boilerplate@1.2.3 # épingler une version exacte
539
+ ```
540
+
541
+ Le retour arrière consiste à épingler la version précédente ; aucune version publiée
542
+ n'est jamais retirée.
543
+
544
+ ```bash
545
+ npm install @vantageos/mcp-boilerplate@1.2.2 # retour arrière : la version précédente
546
+ ```
547
+
548
+ Un second canal indépendant porte le même tag : la GitHub release de
549
+ `elpiarthera/vantage-mcp-boilerplate`. Le paquet est publié avec
550
+ `npm publish --provenance` par `.github/workflows/release.yml` ; vérifiez
551
+ l'attestation avec `npm audit signatures`.
552
+
553
+ GitHub Actions est désactivé dans toute la flotte ElPi Corp : une publication faite
554
+ sans CI n'a pas d'attestation de provenance. La vérification équivalente est la somme
555
+ de contrôle du tarball empaqueté, reproduite depuis un checkout propre du tag puis
556
+ relue au registre :
557
+
558
+ ```bash
559
+ npm pack # affiche le nom du tarball
560
+ shasum mcp-boilerplate-1.2.3.tgz # la somme que vous reproduisez (nom réel)
561
+ curl -s -H 'Cache-Control: no-cache' https://registry.npmjs.org/@vantageos%2Fmcp-boilerplate/1.2.3 \
562
+ | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).dist.shasum"
563
+ ```
564
+
565
+ Sommes égales : le registre sert ce que la source construit. Un serveur en marche
566
+ prouve de la même façon ce qu'il sert : `GET /health` renvoie
567
+ `{ status, version, buildId }` avant toute authentification. `buildId` est un hachage
568
+ des fichiers dont le processus s'exécute ; il change quand ils changent, ce que
569
+ `version` ne garantit pas.
570
+
270
571
  ## Couche UI MCP Apps
271
572
 
272
- `render_items_ui` rend `EXAMPLE_ITEMS` sous forme de widget interactif
273
- (`io.modelcontextprotocol/ui`, spec 2026-01-26).
573
+ `list_items` rend sa page de `EXAMPLE_ITEMS` sous forme de widget interactif
574
+ (`io.modelcontextprotocol/ui`, spec 2026-01-26). Une opération sur une table est un
575
+ seul outil (mcp-standard C-12) : l'outil de données porte lui-même le lien UI, il
576
+ n'y a pas d'outil de rendu séparé.
274
577
 
275
578
  - L'outil relie sa ressource `ui://` par la clé imbriquée `_meta.ui.resourceUri`
276
579
  avec `visibility: ["model", "app"]` explicite. L'outil est enregistré avec
@@ -294,7 +597,7 @@ en un seul fichier HTML autonome (un plugin d'inlining local dans `vite.config.t
294
597
  `dist/ui-bundle/widget.html`) par `npm run build:ui`, sur un chemin qui n'entre
295
598
  jamais en collision avec `dist/ui/` de tsc.
296
599
 
297
- **Couche d'identité** : `@vantageos/cloud-identity` (^0.19.0) est le seul paquet
600
+ **Couche d'identité** : `@vantageos/cloud-identity` (^0.20.2) est le seul paquet
298
601
  d'identité. Son point d'entrée resource-server (`verifyMcpAccessToken`,
299
602
  `buildProtectedResourceMetadata`) vérifie le jeton Bearer sur `/mcp` ; `jose` ne sert
300
603
  plus qu'à signer les jetons d'accès du serveur d'autorisation. Un jeu de clés