@vantageos/mcp-boilerplate 0.6.3 → 0.8.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 (70) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +39 -0
  3. package/README.fr.md +146 -10
  4. package/README.md +141 -12
  5. package/dist/build-info.json +1 -0
  6. package/dist/buildIdentity.d.ts +16 -0
  7. package/dist/buildIdentity.js +39 -0
  8. package/dist/embedded.generated.d.ts +225 -0
  9. package/dist/embedded.generated.js +8 -0
  10. package/dist/hostVerification.d.ts +15 -0
  11. package/dist/hostVerification.js +32 -0
  12. package/dist/http.js +9 -4
  13. package/dist/httpApp.d.ts +15 -0
  14. package/dist/httpApp.js +119 -9
  15. package/dist/i18n.js +6 -16
  16. package/dist/landingPage.d.ts +24 -0
  17. package/dist/landingPage.js +109 -0
  18. package/dist/listCursor.d.ts +10 -0
  19. package/dist/listCursor.js +37 -0
  20. package/dist/mcpServerFactory.d.ts +22 -2
  21. package/dist/mcpServerFactory.js +125 -22
  22. package/dist/oauth/authorizationServer.js +33 -12
  23. package/dist/oauth/picker.js +1 -0
  24. package/dist/oauth/settings.d.ts +2 -0
  25. package/dist/oauth/settings.js +2 -0
  26. package/dist/oauth/tokenIssuer.d.ts +0 -1
  27. package/dist/oauth/tokenIssuer.js +1 -18
  28. package/dist/pagination.d.ts +2 -1
  29. package/dist/pagination.js +2 -1
  30. package/dist/rateLimit.d.ts +33 -0
  31. package/dist/rateLimit.js +48 -0
  32. package/dist/servedToolRecords.d.ts +6 -0
  33. package/dist/servedToolRecords.js +11 -1
  34. package/dist/serverCard.d.ts +68 -0
  35. package/dist/serverCard.js +68 -0
  36. package/dist/serverIcon.d.ts +17 -0
  37. package/dist/serverIcon.js +26 -0
  38. package/dist/serverIdentity.d.ts +83 -0
  39. package/dist/serverIdentity.js +130 -0
  40. package/dist/serverManifest.d.ts +9 -1
  41. package/dist/serverManifest.js +10 -1
  42. package/dist/signedCursor.js +6 -1
  43. package/dist/stdio.js +2 -2
  44. package/dist/tenancy.d.ts +4 -14
  45. package/dist/tenancy.js +7 -18
  46. package/dist/toolRecord.d.ts +35 -2
  47. package/dist/toolRecord.js +44 -2
  48. package/dist/toolRoleGate.d.ts +41 -0
  49. package/dist/toolRoleGate.js +94 -0
  50. package/dist/toolRoles.d.ts +57 -0
  51. package/dist/toolRoles.js +113 -0
  52. package/dist/ui/sandboxDomain.d.ts +13 -0
  53. package/dist/ui/sandboxDomain.js +23 -0
  54. package/dist/ui/uiResource.d.ts +14 -1
  55. package/dist/ui/uiResource.js +11 -1
  56. package/dist/upstream/paginatedClient.js +1 -0
  57. package/dist/verifiedPerson.d.ts +6 -0
  58. package/dist/verifiedPerson.js +9 -0
  59. package/dist/version.js +6 -5
  60. package/docs/manufact-gap.md +200 -0
  61. package/docs/releasing.md +19 -0
  62. package/mcp-server.json +3 -2
  63. package/package.json +11 -3
  64. package/plugin.json +2 -4
  65. package/scripts/plugin-manifest.source.json +19 -0
  66. package/scripts/readme-fr.mjs +11 -4
  67. package/dist/locales/en.json +0 -77
  68. package/dist/locales/fr.json +0 -77
  69. package/dist/oauth/clientAssertion.d.ts +0 -28
  70. package/dist/oauth/clientAssertion.js +0 -106
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vantage-mcp-boilerplate",
3
3
  "displayName": "VantageOS MCP Boilerplate",
4
- "version": "0.6.3",
4
+ "version": "0.8.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,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.0] - 2026-11-10
11
+
12
+ ### Added
13
+ - `docs/manufact-gap.md`: the gap list of this boilerplate against every Manufact quality-score check (31 checks, server metadata, discoverability, policy review, domain), each with its verdict, the mcp-doctor rule, the evidence and the file that owns the fix.
14
+ - Server metadata and discoverability (mcp-server-standard S-49, S-50; Manufact "Server Metadata" and "Discoverability"): `serverInfo` serves `title`, `description` (English, or French on `X-MCP-Locale: fr`), `websiteUrl` and a vector icon served at `/icon.svg`, all derived from `package.json` (`displayName`, `description`, `description_fr`, `homepage`) or the `MCP_SERVER_*` variables (`.env.example`); the instructions say how to use the tools instead of pointing to the README; the MCP Server Card is served without credential at `/mcp/server-card`, `/.well-known/mcp/server-card.json`, `/.well-known/mcp-server-card`, `/mcp.json` and `/.well-known/mcp.json`; a browser `GET /mcp` gets a bilingual setup page naming the MCP URL, ahead of the auth gate. Build identity (C-25): `/health` and `serverInfo` `buildId` is now the git commit the tree was built from (`dist/build-info.json`, written by `npm run build`), and the hash of the built files moves to `buildHash`.
15
+ - **The view resource describes itself.** The `ui://` resource carries a bilingual `widgetDescription` (EN / FR) under `_meta.ui.widgetDescription` and `_meta["openai/widgetDescription"]`, the two keys Manufact's *tool-resource-metadata-complete* check reads (T2, docs/manufact-gap.md). The text lives in `src/locales/{en,fr}.json` (`widget.description`).
16
+
17
+ ### Fixed
18
+ - **The package imports from a bundle: locales, version and identity fields are module values, never a file read at import.** `src/i18n.ts` read `locales/{en,fr}.json` and `src/version.ts` read `package.json` when imported, so a bundled consumer (a Convex action importing `@vantageos/lojii-client`, which depends on this package) failed with ENOENT before any code ran. `scripts/embed-static.mjs` now writes `src/embedded.generated.ts` from those files at build (`--check` refuses a stale copy); `getServerIdentity()` reads the embedded package fields too. Proven by importing the built `dist/` from a directory with no `locales/` and no `package.json` (tests/i18n-bundled.test.ts). Declared, unchanged: the server's runtime assets (mcp-server.json, the widget bundle, tool roles, the build-tree hash) are still read on demand when a server starts, not at import. (README: #server-side-i18n-fren)
19
+ - `mcp-readme-fr` prints every line number 1-based: a region opened by `lang:fr` without its English part was reported as "opened at line 0", and an unclosed region at the end of a file ending with a newline named the empty line after it as the last line.
20
+
21
+ ## [0.7.0] - 2026-11-02
22
+
23
+ ### Added
24
+ - Every tool record declares its `domain` and `mode` (`read` | `write`, agreeing with `readOnlyHint`), served in its annotations (mcp-server-standard "Agent tool roles", rule 2): `ping` is `system` / `read`, `list_items` is `items` / `read`. The start refuses a tool missing either, or with another mode, or whose mode disagrees with `readOnlyHint`, naming it.
25
+ - **Agent tool roles enforced on the OAuth tier** (mcp-server-standard "Agent tool roles"): every tool is granted or refused by the caller's role through `@vantageos/cloud-identity` `decideToolRole`, at both points — `tools/list` holds only the granted tools, and a call to any other tool is `isError: true` with `TOOL_ROLE_DENIED` / `TOOL_ROLE_UNRESOLVED`, its handler never run. The OAuth start now requires both a role store (`MCP_ROLES_FILE`, format in `examples/roles.example.json`, or an injected `toolRoles`) and the identity stores, and is refused naming each missing piece. A caller the identity layer did not resolve is refused with its `person_unresolved` code before any tool runs, `ping` included. New identity tool `whoami` (outside the gate): who the caller is, the role, the granted tools, `rolesApplicable`. The declared global context ceiling rises 1200 -> 1400 for this third tool.
26
+ - Tool invocation rate limiting (mcp-server-standard S-24): a token bucket per caller, `TOOL_RATE_LIMIT_BURST` (default 30) and `TOOL_RATE_LIMIT_PER_MINUTE` (default 60); a call past the limit is a tool error `rate_limited` with `retryAfterMs`; `withRateLimit(…)` wraps every tool handler.
27
+ - The directory listing kit `listing/listing.json` (mcp-standard C-35): description, served tools with titles and hints, 5 positive and 3 negative test prompts by eval id, and how a reviewer obtains an account; written by `npm run listing:write` from `scripts/plugin-manifest.source.json` and the served `tools/list`; `npm run build` runs `scripts/listing-kit.mjs --check` and fails on a stale committed kit, as does `tests/listingKit.test.ts`.
28
+ - `mcp-server.json` declares the target hosts (`distribution.targets`: claude, claude-code, vscode, cursor, chatgpt; mcp-server-standard S-45), and each declared host's verification path is served from configuration: `/.well-known/openai-apps-challenge` from `OPENAI_APPS_CHALLENGE`, `/.well-known/google-connected-apps` from `GOOGLE_CONNECTED_APPS_VERIFICATION`; unset, a 404 naming the variable.
29
+ - An axe-core accessibility gate (mcp-app-standard A-4): `tests/widgetAxe.test.ts`, run by `npm run test:a11y` and by `npm test` (so by `prepublishOnly`), audits the shipped `dist/ui-bundle/widget.html` against WCAG 2.1 A/AA in both locales, empty and populated, and fails on any serious or critical violation; colour contrast is not proven (jsdom computes no layout). Dev dependencies: `axe-core`, `jsdom`, `@types/jsdom`.
30
+
31
+ ### Changed
32
+ - The changelog is written as one fragment per pull request in `changelog.d/`, folded into `CHANGELOG.md` at release by `npm run changelog:release -- --version <x.y.z>`; `## [Unreleased]` stays empty and `npm run changelog:check` (part of `npm test`) refuses an entry written there. The entries accumulated under `## [Unreleased]` since 0.6.3 moved into fragments (one duplicated, stale entry dropped). `docs/releasing.md` describes the release.
33
+ - The widget's `_meta.ui.domain` is derived from the served MCP URL (`PUBLIC_BASE_URL` + `/mcp`): the first 32 hex characters of its SHA-256 followed by `.claudemcpcontent.com` (mcp-app-standard A-11), in `src/ui/sandboxDomain.ts`. `MCP_UI_DOMAIN` is removed: a typed domain went stale the moment the URL changed. Without `PUBLIC_BASE_URL`, or on stdio, no domain is declared.
34
+ - `@vantageos/cloud-identity` ^0.20.2 -> ^0.33.1. `/userinfo` verifies our own access token with cloud-identity's `verifyMcpAccessToken` (issuer = this server, audience = the protected resource, key set = the published JWKS) instead of a local `jwtVerify`; `TokenIssuer` only signs. The tenant is read by cloud-identity's `requireTenantFromIdentity` instead of a local `org_id` read. DLocalIdentity: 6 identity sites leave the boilerplate.
35
+ - The `private_key_jwt` client-assertion verifier comes from `@vantageos/cloud-identity/client-assertion` (^0.34.1, which caps an assertion's lifetime at 300 s); the boilerplate's own copy, `src/oauth/clientAssertion.ts`, is removed. DLocalIdentity: 0 local identity primitives.
36
+
37
+ ### Fixed
38
+ - A cursor sent to `tools/list`, `resources/list` or `resources/templates/list` is refused with JSON-RPC -32602 Invalid params (mcp-standard C-11 (a)); the SDK served those lists as one page and silently answered page 1. `list_items` now declares its default page size in its served `inputSchema`.
39
+ - The C-11 (a) cursor guard also covers `prompts/list`: a fork that registers a prompt gets -32602 on a cursor its single-page list never issued, as for `tools/list`, `resources/list` and `resources/templates/list` (review correction on #52).
40
+ - The served descriptions of `ping` and `list_items` carry both languages in their one field ("<en> / <fr>") and their trigger phrase (mcp-standard C-19); `ping` was English-only on the wire. Their declared context estimates rise to cover the longer text (`ping` 280, `list_items` 690 tokens), within the ceilings.
41
+ - `scripts/readme-fr.mjs` reads a language marker only at 0 to 3 spaces of indent: a line indented 4 or more spaces, or with a tab, is an indented code block (CommonMark), so a marker there is text, copied with the part it sits in.
42
+ - The tools' output schemas stay open to added fields (mcp-server-standard S-44): `ping`, `list_items` and its items are `z.looseObject`, so a field added in a later release no longer fails a client that validated against the schema it read at connect time.
43
+
44
+ ### Security
45
+ - `/token` is rate limited per client address (`MCP_TOKEN_LIMIT_PER_MINUTE`, default 60; five times that per IPv6 /48; `429` with `Retry-After: 60`), so a caller cannot fill the single-use assertion slots and lock assertion-authenticated clients out.
46
+
10
47
  ## [0.6.3] - 2026-11-01
11
48
 
12
49
  ### Fixed
@@ -506,6 +543,8 @@ Initial published release.
506
543
  - MIT license, clonable as a starter template.
507
544
 
508
545
  [Unreleased]: https://github.com/elpiarthera/vantage-mcp-boilerplate/compare/v0.4.0...HEAD
546
+ [0.8.0]: https://www.npmjs.com/package/@vantageos/mcp-boilerplate/v/0.8.0
547
+ [0.7.0]: https://www.npmjs.com/package/@vantageos/mcp-boilerplate/v/0.7.0
509
548
  [0.6.3]: https://www.npmjs.com/package/@vantageos/mcp-boilerplate/v/0.6.3
510
549
  [0.6.2]: https://www.npmjs.com/package/@vantageos/mcp-boilerplate/v/0.6.2
511
550
  [0.6.1]: https://www.npmjs.com/package/@vantageos/mcp-boilerplate/v/0.6.1
package/README.fr.md CHANGED
@@ -23,7 +23,10 @@ src/
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
25
  signedCursor.ts # primitive de curseur signé HMAC (createCursorSigner), utilisée par tout curseur
26
- buildIdentity.ts # identité de build dérivée de l'arbre servi
26
+ buildIdentity.ts # identité de build : le commit git construit (buildId) + le hachage des fichiers construits (buildHash)
27
+ serverIdentity.ts # titre, description, site, icône, instructions : package.json ou env
28
+ serverCard.ts # la MCP Server Card publique
29
+ landingPage.ts # la page d'installation servie à un navigateur sur /mcp
27
30
  stdio.ts # serveStdio(factory) : les deux ères du protocole sur stdio
28
31
  http.ts # adaptateur Node http.Server autour de l'app Hono
29
32
  httpApp.ts # createMcpHandler(factory) sur /mcp + la barrière OAuth
@@ -112,6 +115,13 @@ de liste du protocole, servies par le SDK.
112
115
  échouent au moindre manque. Les annotations sont des indications non fiables pour le
113
116
  client (mcp-standard C-15) : ne conditionnez jamais une action sur elles seules.
114
117
 
118
+ Chaque appel d'outil est limité en débit (mcp-server-standard S-24) : un seau à jetons par appelant (l'organisation
119
+ résolue, ou `public` sur le palier non cloisonné), partagé par le processus. `TOOL_RATE_LIMIT_BURST` appels peuvent
120
+ partir en rafale (30 par défaut), puis le seau se remplit à `TOOL_RATE_LIMIT_PER_MINUTE` (60 par défaut). Un appel
121
+ au-delà de la limite est une erreur d'outil portant `structuredContent.error.code` `rate_limited` et `retryAfterMs`
122
+ (message FR/EN) ; une limite qui n'est pas un entier strictement positif arrête le serveur au démarrage. Un nouvel
123
+ outil enveloppe son handler avec `withRateLimit(…)`.
124
+
115
125
  ## Paliers d'authentification
116
126
 
117
127
  | `AUTH_MODE` | Comportement |
@@ -168,7 +178,11 @@ Pour la mesurer, envoyez 21 enregistrements depuis un même client et attendez u
168
178
  21e. Un client IPv6 est compté sur son /64 ET sur son /48 : tous les /64 d'un même /48 se
169
179
  partagent `MCP_REGISTER_LIMIT_PER_48_PER_MINUTE` enregistrements par minute (défaut `100`),
170
180
  si bien qu'un /48, qui contient 65536 /64, ne peut pas remplir le registre de clients jusqu'à
171
- son `503`. Une valeur invalide arrête le serveur au démarrage.
181
+ son `503`. Une valeur invalide arrête le serveur au démarrage. `/token` est limité de la même façon, sur la
182
+ même adresse : `MCP_TOKEN_LIMIT_PER_MINUTE` requêtes par minute (défaut `60`), cinq fois plus par /48 IPv6,
183
+ puis `429` avec `Retry-After: 60`. Chaque assertion client `private_key_jwt` qu'il vérifie occupe une place à
184
+ usage unique jusqu'à son expiration (300 s au plus, plafond de la couche d'identité) : un appelant sans limite
185
+ pourrait remplir ces places et bloquer tous les clients authentifiés par assertion ; la limite l'empêche.
172
186
 
173
187
  **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`
174
188
  (défaut 1 Mio) du corps d'une requête et répond `413` avant tout handler.
@@ -315,11 +329,39 @@ server.registerTool("my_rows", config, withVerifiedPerson(deps, async (args, per
315
329
 
316
330
  Sauf si la personne est résolue, le handler ne s'exécute pas : l'appel reçoit une erreur d'outil
317
331
  MCP au code stable `person_unresolved` (`structuredContent.error.code`), un message FR/EN selon
318
- `X-MCP-Locale`, et une `reason` : `identity_not_configured` (aucune `identity` injectée),
332
+ `X-MCP-Locale`, et une `reason` : `identity_not_configured` (aucune `identity` injectée — sur le
333
+ palier public seulement, puisque le palier OAuth refuse de démarrer sans elle, voir plus bas),
319
334
  `no_verified_session` (palier public, et **stdio, qui n'a jamais de personne** : ce transport ne
320
335
  porte aucun bearer) ou `refused` (le paquet a refusé ; son `code` et sa `reason` sont transmis,
321
- jamais une valeur de claim). Un outil qui n'enveloppe pas son handler s'exécute exactement comme
322
- avant, sans personne.
336
+ jamais une valeur de claim). Sur le palier public et en stdio, un outil qui n'enveloppe pas son
337
+ handler s'exécute sans personne. Sur le palier OAuth, aucun outil ne s'exécute pour un appelant que
338
+ la couche d'identité n'a pas résolu : le contrôle des rôles ci-dessous répond d'abord, avec ce même
339
+ refus.
340
+
341
+ ### Rôles d'outils des agents
342
+
343
+ Sur le palier OAuth, chaque outil est accordé ou refusé selon le RÔLE de l'appelant, par
344
+ `decideToolRole` de `@vantageos/cloud-identity` (mcp-server-standard « Agent tool roles »). Chaque
345
+ outil déclare son `domain` et son `mode` (voir « Ajouter un outil ») ; un rôle accorde la lecture
346
+ et/ou l'écriture par domaine, plus `add` (outils accordés hors de ses domaines) et `remove` (outils
347
+ retirés, qui l'emporte toujours). La même décision sert aux deux points : `tools/list` ne contient
348
+ que les outils accordés, et l'appel de tout autre outil reçoit un résultat `isError: true` au
349
+ `structuredContent.error.code` `TOOL_ROLE_DENIED` (`TOOL_ROLE_UNRESOLVED` quand le stockage des
350
+ rôles n'a pas pu être lu), sans que son handler s'exécute.
351
+
352
+ Le démarrage est refusé, en nommant chaque pièce manquante, sauf si les deux sont présents :
353
+
354
+ - **les stores d'identité** (`identity`, plus haut) : un rôle est décidé pour l'organisation que la
355
+ couche d'identité résout, jamais pour un claim lu ici ;
356
+ - **un stockage des rôles** : par défaut le fichier JSON nommé par `MCP_ROLES_FILE` (format dans
357
+ `examples/roles.example.json` : les rôles, et `agents` qui lie chaque sujet vérifié à un rôle).
358
+ Un fichier absent, illisible ou mal formé arrête le serveur ; aucun fichier ne veut jamais dire
359
+ « tous les outils ». Un déploiement avec un autre stockage injecte `toolRoles` (`src/toolRoles.ts`,
360
+ `RoleProvider`).
361
+
362
+ L'outil d'identité `whoami` reste hors du contrôle : tout appelant peut lui demander qui il est,
363
+ quel rôle décide de ses outils et quels outils ce rôle accorde (`rolesApplicable: false` sur le
364
+ palier public et en stdio, où il n'y a pas d'appelant). Tests : `tests/toolRoles.test.ts`.
323
365
 
324
366
  ## Appeler une API amont (OAuth 2 client_credentials)
325
367
 
@@ -522,7 +564,10 @@ ligne.
522
564
  le client envoie `X-MCP-Locale: fr|en` (défaut `en`) ; le sélecteur navigateur suit
523
565
  `Accept-Language`. Les fichiers de ressources (`src/locales/{en,fr}.json`) gardent la
524
566
  convention `<Namespace>.<category>.<key>` de `@vantageos/mosaic-i18n`, FR et EN en
525
- parité.
567
+ parité. Ils sont intégrés au module au build (`scripts/embed-static.mjs` écrit
568
+ `src/embedded.generated.ts`, avec la version et les champs d'identité du paquet) : rien n'est lu sur le disque
569
+ à l'import, et un consommateur empaqueté (le bundler Convex, esbuild) exécute le paquet sans dossier `locales/`
570
+ ni `package.json` à côté ; `--check` refuse une copie périmée.
526
571
 
527
572
  ## Un README français généré depuis un README bilingue
528
573
 
@@ -553,6 +598,13 @@ pilote l'app Hono en processus avec le `Client` du SDK v2 ; la suite stdio lance
553
598
  `dist/stdio.js` construit. `npm run evals` exécute le jeu de cas déclaratif contre
554
599
  stdio.
555
600
 
601
+ **Accessibilité (mcp-app-standard A-4).** `npm run test:a11y` audite le widget livré, `dist/ui-bundle/widget.html`
602
+ (construisez-le d'abord avec `npm run build:ui`), selon WCAG 2.1 A et AA avec `axe-core`, en anglais et en français,
603
+ vide et rempli. Toute violation grave ou critique le fait échouer, et un contrôle positif prouve qu'une violation
604
+ injectée est trouvée. Il fait partie de `npm test` : `prepublishOnly` l'exécute avant chaque publication. L'audit
605
+ tourne dans jsdom, qui ne calcule aucune mise en page : le contraste des couleurs y est signalé comme incomplet et
606
+ n'est pas prouvé par cette barrière.
607
+
556
608
  ## Ajouter un outil
557
609
 
558
610
  Déclarez sa fiche dans `src/toolRecord.ts` (couple d'autorité, verbe, table, et pour
@@ -562,6 +614,55 @@ une liste/recherche le mécanisme et la pagination), enregistrez-le une fois dan
562
614
  est alors servi sur les deux transports et les deux ères du protocole.
563
615
  `tests/toolRecordComplete.test.ts` le garde honnête.
564
616
 
617
+ La fiche déclare aussi le `domain` de l'outil (le domaine métier qu'un rôle accorde, par exemple `items`) et son
618
+ `mode`, exactement `read` ou `write`, en accord avec `readOnlyHint` (read <-> `true`). C'est sur eux que se décide le
619
+ rôle d'un agent (mcp-server-standard « Agent tool roles », règle 2). Un outil auquel l'un manque, ou dont le mode
620
+ vaut autre chose, n'est servi à personne : le serveur refuse de démarrer et le nomme
621
+ (`tests/toolRoleDeclarations.test.ts`).
622
+
623
+ ## Changelog
624
+
625
+ Une pull request ajoute UN fichier, `changelog.d/<branche>.md` (frontmatter `section:` et une entrée), et ne
626
+ modifie jamais `CHANGELOG.md`, dont le bloc `## [Unreleased]` reste vide. `npm run changelog:check`, inclus dans
627
+ `npm test`, refuse un fragment mal formé ou une entrée écrite sous `## [Unreleased]`. À la publication,
628
+ `npm run changelog:release -- --version <x.y.z>` regroupe les fragments en `## [x.y.z] - <date>`
629
+ (`changelog.d/README.md`, `docs/releasing.md`). Deux pull requests qui ajoutent deux fichiers n'entrent jamais
630
+ en conflit, là où deux modifications du même bloc le faisaient.
631
+
632
+ ## Identité du serveur et découverte
633
+
634
+ Ce qu'un hôte ou un annuaire lit du serveur avant et pendant la connexion est dérivé, jamais tapé dans le
635
+ source (`src/serverIdentity.ts`, mcp-server-standard S-49 / S-50) :
636
+
637
+ | Servi comme | Variable d'environnement | Sinon, `package.json` |
638
+ |---|---|---|
639
+ | `serverInfo.title` | `MCP_SERVER_TITLE` | `displayName` |
640
+ | `serverInfo.description` (anglais) | `MCP_SERVER_DESCRIPTION` | `description` |
641
+ | `serverInfo.description` (français, `X-MCP-Locale: fr`) | `MCP_SERVER_DESCRIPTION_FR` | `description_fr` |
642
+ | `serverInfo.websiteUrl` | `MCP_SERVER_WEBSITE_URL` (https, ou http en bouclage) | `homepage` |
643
+
644
+ Un fork modifie ces champs de `package.json`, ou définit les variables (`.env.example`). Une valeur qu'aucune
645
+ des deux sources ne porte arrête le serveur au démarrage, en nommant la variable et le champ : le serveur ne
646
+ sert jamais de valeur fictive.
647
+
648
+ - **Instructions** (`initialize` / `server/discover`) : comment un agent utilise les outils, dans la langue de
649
+ l'appelant, depuis `Mcp.server.instructions` dans `src/locales/{en,fr}.json`. Réécrivez-les pour vos
650
+ outils : `tests/serverMetadata.test.ts` échoue quand un outil servi n'y est pas nommé ou que le texte dépasse
651
+ 2048 octets (le budget de champ de Claude Code).
652
+ - **Icône** : un vectoriel, `src/serverIcon.ts` (remplacez le SVG par votre logo), servi à `/icon.svg` et
653
+ nommé dans `serverInfo.icons` ; en stdio il voyage en URI `data:`.
654
+ - **Carte serveur** : la MCP Server Card (forme SEP-2127, plus `capabilities` et un résumé `tools` lu sur les
655
+ outils enregistrés), servie sans identifiant à `/mcp/server-card`, `/.well-known/mcp/server-card.json`,
656
+ `/.well-known/mcp-server-card`, `/mcp.json` et `/.well-known/mcp.json`. Son `name` est en DNS inversé, dérivé
657
+ du site (`io.github.<propriétaire>/<nom>` pour une URL GitHub).
658
+ - **Page d'installation** : un `GET /mcp` de navigateur (sans `Authorization`, avec un `Accept` sans
659
+ `text/event-stream`) reçoit une page bilingue qui nomme l'URL MCP et comment se connecter, sur les deux
660
+ paliers. Toute requête MCP est inchangée, y compris le 401 + `WWW-Authenticate` du palier OAuth.
661
+
662
+ La carte, la page et l'URL de l'icône nomment `PUBLIC_BASE_URL` + `/mcp` quand il est défini. Non défini
663
+ (développement), elles nomment l'origine à laquelle la requête était adressée et partent avec
664
+ `Cache-Control: no-store`. Définissez `PUBLIC_BASE_URL` sur tout déploiement public.
665
+
565
666
  ## Packaging & distribution
566
667
 
567
668
  Quatre manifestes sont **générés** depuis `scripts/plugin-manifest.source.json` et
@@ -581,6 +682,34 @@ Claude être surchargé par l'installateur. Le skill d'onboarding est
581
682
  `skills/connect-mcp-server/SKILL.md`, déclaré pour OpenAI en
582
683
  `extensions["com.openai"].onboardingSkill`.
583
684
 
685
+ ### Hôtes cibles et chemins de vérification
686
+
687
+ `mcp-server.json` déclare où le serveur est distribué, sous `distribution.targets` (par défaut : `claude`,
688
+ `claude-code`, `vscode`, `cursor`, `chatgpt`), comme le demande la règle S-45 du mcp-server-standard. Un hôte qui
689
+ vérifie que vous possédez l'origine en lisant un chemin connu voit ce chemin servi pour chaque cible déclarée, la
690
+ valeur étant lue dans l'environnement à chaque requête, jamais écrite dans le code :
691
+
692
+ | Cible | Chemin | Variable |
693
+ |---|---|---|
694
+ | `chatgpt` | `/.well-known/openai-apps-challenge` | `OPENAI_APPS_CHALLENGE` |
695
+ | `google-connected-apps` | `/.well-known/google-connected-apps` | `GOOGLE_CONNECTED_APPS_VERIFICATION` |
696
+
697
+ Non définie, la variable fait répondre le chemin en 404 en la nommant : la vérification de l'hôte échoue de façon
698
+ visible. Un fork retire les cibles qu'il ne vise pas, définit les variables de celles qu'il garde, et règle
699
+ `PUBLIC_BASE_URL` sur son origine publique `https://` : la valeur du boilerplate est une valeur de développement, pas
700
+ une origine que vous possédez.
701
+
702
+ ### Kit de référencement pour les annuaires
703
+
704
+ `listing/listing.json` est ce que lit le relecteur d'un annuaire (mcp-standard C-35) : la description, chaque outil
705
+ servi avec son titre et ses indications lecture seule / destructif, 5 invites de test positives et 3 négatives
706
+ données par identifiant d'éval de `evals/evals.json`, et la façon d'obtenir un compte de relecture (jamais un compte).
707
+ Chaque champ est dérivé : `scripts/listing-kit.mjs` lit la description et les identifiants d'éval choisis dans
708
+ `scripts/plugin-manifest.source.json` (`listing`), et les outils dans le `tools/list` servi. Après avoir modifié une
709
+ source ou un outil, lancez `npm run listing:write` et commitez le fichier. `npm run build` se contente de le
710
+ vérifier : un kit commité qui s'écarte de ses sources, un identifiant d'éval inconnu ou un compte insuffisant fait
711
+ échouer la construction, et `tests/listingKit.test.ts` échoue aussi. Modifiez la source, jamais le fichier.
712
+
584
713
  ### Scaffold → build → publish
585
714
 
586
715
  ```bash
@@ -640,9 +769,13 @@ curl -s -H 'Cache-Control: no-cache' https://registry.npmjs.org/@vantageos%2Fmcp
640
769
 
641
770
  Sommes égales : le registre sert ce que la source construit. Un serveur en marche
642
771
  prouve de la même façon ce qu'il sert : `GET /health` renvoie
643
- `{ status, version, buildId }` avant toute authentification. `buildId` est un hachage
644
- des fichiers dont le processus s'exécute ; il change quand ils changent, ce que
645
- `version` ne garantit pas.
772
+ `{ status, version, buildId, buildHash }` avant toute authentification. `buildId` est le
773
+ commit git à partir duquel l'arbre servi a été construit, dérivé par `npm run build`
774
+ (`scripts/write-build-info.mjs` écrit `dist/build-info.json`) : `BUILD_COMMIT`, sinon le commit qu'un
775
+ hébergeur injecte au build (`RAILWAY_GIT_COMMIT_SHA`, `VERCEL_GIT_COMMIT_SHA`, `RENDER_GIT_COMMIT`,
776
+ `GITHUB_SHA`, `SOURCE_VERSION`), sinon `git rev-parse HEAD` ; sans aucun, le build échoue en nommant
777
+ `BUILD_COMMIT`. `buildHash` est un hachage des fichiers dont le processus s'exécute ; il change aussi
778
+ quand un arbre modifié ou un `dist/` retouché est servi, ce que ni `version` ni le commit ne garantissent.
646
779
 
647
780
  ## Couche UI MCP Apps
648
781
 
@@ -658,7 +791,10 @@ n'y a pas d'outil de rendu séparé.
658
791
  - La ressource déclare `_meta.ui` : `csp` avec `connectDomains`, `resourceDomains`,
659
792
  `frameDomains` et `baseUriDomains` (tous vides — le widget ne fait aucune requête
660
793
  réseau, ne charge aucun actif tiers et n'intègre aucune frame), `prefersBorder`, et
661
- `domain` seulement quand `MCP_UI_DOMAIN` est défini (le format dépend de l'hôte).
794
+ `domain` : l'origine de bac à sable de Claude, dérivée au démarrage de l'URL MCP servie
795
+ (`PUBLIC_BASE_URL` + `/mcp`) comme les 32 premiers caractères hexadécimaux de son SHA-256 suivis de
796
+ `.claudemcpcontent.com` (mcp-app-standard A-11), jamais tapée. Sans `PUBLIC_BASE_URL`, ou en stdio, il
797
+ n'y a pas d'URL servie et aucun domaine n'est déclaré.
662
798
  Son type MIME est `text/html;profile=mcp-app`.
663
799
  - **Les clés ChatGPT sont additives** : `openai/outputTemplate` (même URI) et les
664
800
  chaînes `openai/toolInvocation/invoking|invoked`. Rien ne les lit ; le serveur
package/README.md CHANGED
@@ -22,7 +22,10 @@ src/
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
24
  signedCursor.ts # HMAC-signed cursor primitive (createCursorSigner), used by every cursor
25
- buildIdentity.ts # build identity derived from the served tree
25
+ buildIdentity.ts # build identity: the git commit built (buildId) + the hash of the built files (buildHash)
26
+ serverIdentity.ts # title, description, website, icon, instructions: package.json or env
27
+ serverCard.ts # the public MCP Server Card
28
+ landingPage.ts # the setup page a browser gets at /mcp
26
29
  stdio.ts # serveStdio(factory): both protocol eras over stdio
27
30
  http.ts # Node http.Server adapter around the Hono app
28
31
  httpApp.ts # createMcpHandler(factory) on /mcp + the OAuth gate
@@ -110,6 +113,12 @@ stays with the protocol list methods' cursor, which the SDK serves.
110
113
  fail on any gap. Annotations are untrusted hints for the client (mcp-standard C-15);
111
114
  never gate an action on them alone.
112
115
 
116
+ Every tool invocation is rate limited (mcp-server-standard S-24): a token bucket per caller (the resolved
117
+ organisation, or `public` on the unscoped tier), shared by the process. `TOOL_RATE_LIMIT_BURST` calls may burst
118
+ (default 30), then the bucket refills at `TOOL_RATE_LIMIT_PER_MINUTE` (default 60). A call past the limit is a tool
119
+ error with `structuredContent.error.code` `rate_limited` and `retryAfterMs` (message FR/EN); a limit that is not a
120
+ positive integer stops the server at start. A new tool wraps its handler with `withRateLimit(…)`.
121
+
113
122
  ## Authentication tiers
114
123
 
115
124
  | `AUTH_MODE` | Behavior |
@@ -163,7 +172,11 @@ many clients. To measure it, send 21 registrations from one client and expect a
163
172
  the 21st. An IPv6 client is keyed on its /64 and ALSO counted against its /48: every /64 inside one
164
173
  /48 shares `MCP_REGISTER_LIMIT_PER_48_PER_MINUTE` registrations per minute (default `100`), so
165
174
  one /48, which holds 65536 /64s, cannot fill the client registry to its `503`. An invalid value
166
- stops the server at start.
175
+ stops the server at start. `/token` is limited the same way, keyed on the same address:
176
+ `MCP_TOKEN_LIMIT_PER_MINUTE` requests per minute (default `60`), five times that per IPv6 /48, then `429` with
177
+ `Retry-After: 60`. Each `private_key_jwt` client assertion it verifies holds a single-use slot until it expires
178
+ (at most 300 s, the identity layer's cap), so an unlimited caller could fill those slots and lock every
179
+ assertion-authenticated client out; the limit bounds that.
167
180
 
168
181
  **Request limits and CORS**: An operator variable that is set but invalid (for example `MCP_MAX_BODY_BYTES=abc`) stops the server at startup with the variable's name and the expected form, never a silent default; unset or empty keeps the default. the Node entry reads at most `MCP_MAX_BODY_BYTES`
169
182
  (default 1 MiB) of a request body and answers `413` before any handler runs.
@@ -299,10 +312,37 @@ server.registerTool("my_rows", config, withVerifiedPerson(deps, async (args, per
299
312
 
300
313
  Unless the person resolved, the handler does not run: the call gets an MCP tool error with the
301
314
  stable code `person_unresolved` (`structuredContent.error.code`), a FR/EN message per
302
- `X-MCP-Locale`, and a `reason`: `identity_not_configured` (no `identity` injected),
303
- `no_verified_session` (public tier, and **stdio, which never has a person**: there is no bearer
304
- on that transport) or `refused` (the package refused; its `code` and `reason` are carried, never
305
- a claim value). A tool that does not wrap its handler runs exactly as before, with no person.
315
+ `X-MCP-Locale`, and a `reason`: `identity_not_configured` (no `identity` injected — on the public
316
+ tier only, since the OAuth tier refuses to start without it, see below), `no_verified_session`
317
+ (public tier, and **stdio, which never has a person**: there is no bearer on that transport) or
318
+ `refused` (the package refused; its `code` and `reason` are carried, never a claim value). On the
319
+ public tier and stdio, a tool that does not wrap its handler runs with no person. On the OAuth
320
+ tier no tool runs for a caller the identity layer did not resolve: the role gate below answers
321
+ first, with this same refusal.
322
+
323
+ ### Agent tool roles
324
+
325
+ On the OAuth tier, every tool is granted or refused by the caller's ROLE, through
326
+ `@vantageos/cloud-identity`'s `decideToolRole` (mcp-server-standard "Agent tool roles"). Each tool
327
+ declares its `domain` and `mode` (see "Adding a tool"); a role grants read and/or write per domain,
328
+ plus `add` (tools granted outside its domains) and `remove` (tools withheld, which always wins).
329
+ The same decision drives both points: `tools/list` holds only the granted tools, and a call to any
330
+ other tool is a tool result with `isError: true` and `structuredContent.error.code`
331
+ `TOOL_ROLE_DENIED` (`TOOL_ROLE_UNRESOLVED` when the role store could not be read), its handler
332
+ never run.
333
+
334
+ The start is refused, naming each missing piece, unless both of these are present:
335
+
336
+ - **the identity stores** (`identity`, above): a role is decided for the organisation the identity
337
+ layer resolves, never for a claim read here;
338
+ - **a role store**: by default the JSON file named by `MCP_ROLES_FILE` (format in
339
+ `examples/roles.example.json`: the roles, and `agents` binding each verified subject to one role).
340
+ A missing, unreadable or malformed file stops the server; no file never means "every tool". A
341
+ deployment with another store injects `toolRoles` (`src/toolRoles.ts`, `RoleProvider`).
342
+
343
+ The identity tool `whoami` stays outside the gate: every caller can ask it who they are, which role
344
+ decides their tools, and which tools that role grants (`rolesApplicable: false` on the public tier
345
+ and stdio, where there is no caller). Tests: `tests/toolRoles.test.ts`.
306
346
 
307
347
  ## Calling an upstream API (OAuth 2 client_credentials)
308
348
 
@@ -494,7 +534,10 @@ row value.
494
534
  client sends `X-MCP-Locale: fr|en` (default `en`); the browser picker follows
495
535
  `Accept-Language`. Resource files (`src/locales/{en,fr}.json`) keep the
496
536
  `<Namespace>.<category>.<key>` convention of `@vantageos/mosaic-i18n`, FR and EN
497
- in lockstep.
537
+ in lockstep. They are embedded in the module at build (`scripts/embed-static.mjs` writes
538
+ `src/embedded.generated.ts`, with the package version and identity fields), so nothing is read from disk at
539
+ import and a bundled consumer (the Convex bundler, esbuild) runs the package with no `locales/` folder or
540
+ `package.json` beside it; `--check` refuses a stale copy.
498
541
 
499
542
  ## A French README generated from a bilingual one
500
543
 
@@ -524,6 +567,12 @@ Runs both transports through the real SDK code paths: the HTTP suite drives the
524
567
  Hono app in-process with the SDK v2 `Client`; the stdio suite spawns the built
525
568
  `dist/stdio.js`. `npm run evals` runs the declarative case-set against stdio.
526
569
 
570
+ **Accessibility (mcp-app-standard A-4).** `npm run test:a11y` audits the shipped widget, `dist/ui-bundle/widget.html`
571
+ (build it first with `npm run build:ui`), against WCAG 2.1 A and AA with `axe-core`, in English and French, empty and
572
+ populated. Any serious or critical violation fails it, and a positive control proves an injected violation is found.
573
+ It is part of `npm test`, so `prepublishOnly` runs it before every publish. The audit runs in jsdom, which computes no
574
+ layout, so colour contrast is reported as incomplete and is not proven by this gate.
575
+
527
576
  ## Adding a tool
528
577
 
529
578
  Declare its record in `src/toolRecord.ts` (authority pair, verb, table, and for a
@@ -532,6 +581,53 @@ list/search the mechanism and pagination), register it once in
532
581
  `inputSchema` and `outputSchema`, and return `structuredResult(...)`. It is then served on both transports and both
533
582
  protocol eras. `tests/toolRecordComplete.test.ts` keeps it honest.
534
583
 
584
+ The record also declares the tool's `domain` (the business area a role grants, such as `items`) and its `mode`,
585
+ exactly `read` or `write`, agreeing with `readOnlyHint` (read <-> `true`). This is what an agent's role is decided
586
+ on (mcp-server-standard "Agent tool roles", rule 2). A tool missing either, or with any other mode, is served to no
587
+ one: the server refuses to start and names it (`tests/toolRoleDeclarations.test.ts`).
588
+
589
+ ## Changelog
590
+
591
+ A pull request adds ONE file, `changelog.d/<branch>.md` (frontmatter `section:` plus one entry), and never
592
+ edits `CHANGELOG.md`, whose `## [Unreleased]` block stays empty. `npm run changelog:check`, part of `npm test`,
593
+ refuses a malformed fragment or an entry written under `## [Unreleased]`. At release,
594
+ `npm run changelog:release -- --version <x.y.z>` folds the fragments into `## [x.y.z] - <date>`
595
+ (`changelog.d/README.md`, `docs/releasing.md`). Two pull requests adding two files never conflict, where two
596
+ edits of the same block did.
597
+
598
+ ## Server identity and discovery
599
+
600
+ What a host or a directory reads about the server before and at connection is derived, never typed in
601
+ source (`src/serverIdentity.ts`, mcp-server-standard S-49 / S-50):
602
+
603
+ | Served as | Environment override | Otherwise, `package.json` |
604
+ |---|---|---|
605
+ | `serverInfo.title` | `MCP_SERVER_TITLE` | `displayName` |
606
+ | `serverInfo.description` (English) | `MCP_SERVER_DESCRIPTION` | `description` |
607
+ | `serverInfo.description` (French, `X-MCP-Locale: fr`) | `MCP_SERVER_DESCRIPTION_FR` | `description_fr` |
608
+ | `serverInfo.websiteUrl` | `MCP_SERVER_WEBSITE_URL` (https, or http on loopback) | `homepage` |
609
+
610
+ A fork edits those `package.json` fields, or sets the variables (`.env.example`). A value neither source
611
+ carries stops the server at start, naming the variable and the field: the server never serves a placeholder.
612
+
613
+ - **Instructions** (`initialize` / `server/discover`): how an agent uses the tools, in the caller's locale, from
614
+ `Mcp.server.instructions` in `src/locales/{en,fr}.json`. Rewrite them for your tools:
615
+ `tests/serverMetadata.test.ts` fails when a served tool is not named or the text passes 2048 bytes (Claude
616
+ Code's field budget).
617
+ - **Icon**: a vector, `src/serverIcon.ts` (replace the SVG with your mark), served at `/icon.svg` and named
618
+ in `serverInfo.icons`; on stdio it travels as a `data:` URI.
619
+ - **Server card**: the MCP Server Card (SEP-2127 shape, plus `capabilities` and a `tools` summary read from
620
+ the registered tools), served without credential at `/mcp/server-card`, `/.well-known/mcp/server-card.json`,
621
+ `/.well-known/mcp-server-card`, `/mcp.json` and `/.well-known/mcp.json`. Its `name` is reverse-DNS, derived
622
+ from the website (`io.github.<owner>/<name>` for a GitHub URL).
623
+ - **Setup page**: a browser `GET /mcp` (no `Authorization`, an `Accept` without `text/event-stream`) gets a
624
+ bilingual page naming the MCP URL and how to connect, on both tiers. Every MCP request is unchanged,
625
+ including the OAuth tier's 401 + `WWW-Authenticate`.
626
+
627
+ The card, the page and the icon URL name `PUBLIC_BASE_URL` + `/mcp` when it is set. Unset (development),
628
+ they name the origin the request was addressed to and are sent `Cache-Control: no-store`. Set
629
+ `PUBLIC_BASE_URL` on every public deployment.
630
+
535
631
  ## Packaging & distribution
536
632
 
537
633
  Four manifests are **generated** from `scripts/plugin-manifest.source.json` and
@@ -551,6 +647,33 @@ to be overridden by the installer. The onboarding skill is
551
647
  `skills/connect-mcp-server/SKILL.md`, declared for OpenAI as
552
648
  `extensions["com.openai"].onboardingSkill`.
553
649
 
650
+ ### Target hosts and their verification paths
651
+
652
+ `mcp-server.json` declares where the server is distributed, under `distribution.targets` (default: `claude`,
653
+ `claude-code`, `vscode`, `cursor`, `chatgpt`), as mcp-server-standard S-45 asks. A host that confirms you own the
654
+ origin by reading a well-known path gets that path served for each declared target, with the value read from the
655
+ environment at request time, never written in source:
656
+
657
+ | Target | Path | Variable |
658
+ |---|---|---|
659
+ | `chatgpt` | `/.well-known/openai-apps-challenge` | `OPENAI_APPS_CHALLENGE` |
660
+ | `google-connected-apps` | `/.well-known/google-connected-apps` | `GOOGLE_CONNECTED_APPS_VERIFICATION` |
661
+
662
+ Unset, the path answers 404 naming the variable, so the host's check fails visibly. A fork removes the targets it
663
+ does not ship to, sets the variables of the ones it keeps, and sets `PUBLIC_BASE_URL` to its public `https://` origin:
664
+ the boilerplate's own value is a development default, not an origin you own.
665
+
666
+ ### Directory listing kit
667
+
668
+ `listing/listing.json` is what a directory reviewer reads (mcp-standard C-35): the description, every served tool
669
+ with its title and its read-only / destructive hints, 5 positive and 3 negative test prompts given as eval ids of
670
+ `evals/evals.json`, and where a reviewer obtains an account (never one). Every field is derived:
671
+ `scripts/listing-kit.mjs` reads the description and the chosen eval ids from
672
+ `scripts/plugin-manifest.source.json` (`listing`), and the tools from the served `tools/list`. After changing a
673
+ source or a tool, run `npm run listing:write` and commit the file. `npm run build` only checks it: a committed kit
674
+ that differs from its sources, an unknown eval id or a short count fails the build, and `tests/listingKit.test.ts`
675
+ fails too. Edit the source, never the file.
676
+
554
677
  ### Scaffold → build → publish
555
678
 
556
679
  ```bash
@@ -606,9 +729,13 @@ curl -s -H 'Cache-Control: no-cache' https://registry.npmjs.org/@vantageos%2Fmcp
606
729
  ```
607
730
 
608
731
  Equal sums: the registry serves what the source builds. A running server proves what
609
- it serves the same way: `GET /health` returns `{ status, version, buildId }` before any
610
- authentication. `buildId` is a hash of the files the process runs from, so it changes
611
- when they change, which `version` does not guarantee.
732
+ it serves the same way: `GET /health` returns `{ status, version, buildId, buildHash }` before any
733
+ authentication. `buildId` is the git commit the served tree was built from, derived by `npm run build`
734
+ (`scripts/write-build-info.mjs` writes `dist/build-info.json`): `BUILD_COMMIT`, else the commit a host
735
+ injects at build time (`RAILWAY_GIT_COMMIT_SHA`, `VERCEL_GIT_COMMIT_SHA`, `RENDER_GIT_COMMIT`, `GITHUB_SHA`,
736
+ `SOURCE_VERSION`), else `git rev-parse HEAD`; with none, the build fails, naming `BUILD_COMMIT`. `buildHash` is
737
+ a hash of the files the process runs from, so it also changes when a dirty tree or a patched `dist/` is
738
+ served, which neither `version` nor the commit guarantees.
612
739
 
613
740
  ## MCP Apps UI layer
614
741
 
@@ -624,8 +751,10 @@ separate render tool.
624
751
  - The resource declares `_meta.ui`: `csp` with `connectDomains`,
625
752
  `resourceDomains`, `frameDomains` and `baseUriDomains` (all empty — the widget
626
753
  makes no network request, loads no third-party asset and embeds no frame),
627
- `prefersBorder`, and `domain` only when `MCP_UI_DOMAIN` is set (the format is
628
- host-dependent). Its mime type is `text/html;profile=mcp-app`.
754
+ `prefersBorder`, and `domain`: Claude's sandbox origin, derived at start from the served MCP URL
755
+ (`PUBLIC_BASE_URL` + `/mcp`) as the first 32 hex characters of its SHA-256 followed by
756
+ `.claudemcpcontent.com` (mcp-app-standard A-11), never typed. Without `PUBLIC_BASE_URL`, or on stdio,
757
+ there is no served URL and no domain is declared. Its mime type is `text/html;profile=mcp-app`.
629
758
  - **ChatGPT keys are additive**: `openai/outputTemplate` (same URI) and the
630
759
  `openai/toolInvocation/invoking|invoked` strings. Nothing reads them; the
631
760
  server works with every `openai/*` key absent. `openai/ui` entrypoints are
@@ -0,0 +1 @@
1
+ {"commit":"4193f2a535d8229f071524265a7e6bb98e19d34e"}
@@ -17,3 +17,19 @@
17
17
  export declare function computeBuildIdentity(dir: string): string;
18
18
  /** The identity of the tree THIS process runs from, computed once. */
19
19
  export declare function getBuildIdentity(): string;
20
+ /**
21
+ * The git commit the served tree was built from: the `buildId` of /health and serverInfo (C-25,
22
+ * ruling: build identity = the commit). It is DERIVED at build time by
23
+ * scripts/write-build-info.mjs (`npm run build`), which writes `dist/build-info.json`, and read
24
+ * here — never typed. `src/` and `dist/` both sit one level under the package root, so the same
25
+ * relative URL resolves in source mode (vitest) and in the built output.
26
+ *
27
+ * The hash of the built files above stays served beside it as `buildHash`: the commit names the
28
+ * source, the hash proves the files a process runs from (a dirty tree or a patched dist changes
29
+ * the hash, not the commit).
30
+ */
31
+ export declare const BUILD_INFO_URL: URL;
32
+ /** The commit recorded in a build-info file. Throws, naming the file, if it is not readable. */
33
+ export declare function readBuildCommit(path: string): string;
34
+ /** The build commit of THIS process, read once. */
35
+ export declare function getBuildCommit(): string;
@@ -60,3 +60,42 @@ export function getBuildIdentity() {
60
60
  processIdentity ??= computeBuildIdentity(dirname(fileURLToPath(import.meta.url)));
61
61
  return processIdentity;
62
62
  }
63
+ /**
64
+ * The git commit the served tree was built from: the `buildId` of /health and serverInfo (C-25,
65
+ * ruling: build identity = the commit). It is DERIVED at build time by
66
+ * scripts/write-build-info.mjs (`npm run build`), which writes `dist/build-info.json`, and read
67
+ * here — never typed. `src/` and `dist/` both sit one level under the package root, so the same
68
+ * relative URL resolves in source mode (vitest) and in the built output.
69
+ *
70
+ * The hash of the built files above stays served beside it as `buildHash`: the commit names the
71
+ * source, the hash proves the files a process runs from (a dirty tree or a patched dist changes
72
+ * the hash, not the commit).
73
+ */
74
+ export const BUILD_INFO_URL = new URL("../dist/build-info.json", import.meta.url);
75
+ const FULL_COMMIT = /^[0-9a-f]{40}$/;
76
+ /** The commit recorded in a build-info file. Throws, naming the file, if it is not readable. */
77
+ export function readBuildCommit(path) {
78
+ let commit;
79
+ try {
80
+ commit = JSON.parse(readFileSync(path, "utf8")).commit;
81
+ }
82
+ catch (error) {
83
+ throw new Error(tBoth("buildIdentity.commitUnreadable", {
84
+ file: path,
85
+ reason: error instanceof Error ? error.message : String(error),
86
+ }));
87
+ }
88
+ if (typeof commit !== "string" || !FULL_COMMIT.test(commit)) {
89
+ throw new Error(tBoth("buildIdentity.commitUnreadable", {
90
+ file: path,
91
+ reason: "commit is not a 40-hex git commit id",
92
+ }));
93
+ }
94
+ return commit;
95
+ }
96
+ let processCommit;
97
+ /** The build commit of THIS process, read once. */
98
+ export function getBuildCommit() {
99
+ processCommit ??= readBuildCommit(fileURLToPath(BUILD_INFO_URL));
100
+ return processCommit;
101
+ }