@vantageos/mcp-boilerplate 0.6.3 → 0.7.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 (50) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +27 -0
  3. package/README.fr.md +97 -5
  4. package/README.md +93 -7
  5. package/dist/hostVerification.d.ts +15 -0
  6. package/dist/hostVerification.js +32 -0
  7. package/dist/http.js +4 -1
  8. package/dist/httpApp.d.ts +9 -0
  9. package/dist/httpApp.js +55 -2
  10. package/dist/listCursor.d.ts +10 -0
  11. package/dist/listCursor.js +37 -0
  12. package/dist/locales/en.json +12 -1
  13. package/dist/locales/fr.json +12 -1
  14. package/dist/mcpServerFactory.d.ts +11 -2
  15. package/dist/mcpServerFactory.js +97 -15
  16. package/dist/oauth/authorizationServer.js +33 -12
  17. package/dist/oauth/picker.js +1 -0
  18. package/dist/oauth/settings.d.ts +2 -0
  19. package/dist/oauth/settings.js +2 -0
  20. package/dist/oauth/tokenIssuer.d.ts +0 -1
  21. package/dist/oauth/tokenIssuer.js +1 -18
  22. package/dist/pagination.d.ts +2 -1
  23. package/dist/pagination.js +2 -1
  24. package/dist/rateLimit.d.ts +33 -0
  25. package/dist/rateLimit.js +48 -0
  26. package/dist/serverManifest.d.ts +9 -1
  27. package/dist/serverManifest.js +10 -1
  28. package/dist/signedCursor.js +6 -1
  29. package/dist/stdio.js +2 -2
  30. package/dist/tenancy.d.ts +4 -14
  31. package/dist/tenancy.js +7 -18
  32. package/dist/toolRecord.d.ts +35 -2
  33. package/dist/toolRecord.js +44 -2
  34. package/dist/toolRoleGate.d.ts +41 -0
  35. package/dist/toolRoleGate.js +94 -0
  36. package/dist/toolRoles.d.ts +57 -0
  37. package/dist/toolRoles.js +113 -0
  38. package/dist/ui/sandboxDomain.d.ts +13 -0
  39. package/dist/ui/sandboxDomain.js +23 -0
  40. package/dist/upstream/paginatedClient.js +1 -0
  41. package/dist/verifiedPerson.d.ts +6 -0
  42. package/dist/verifiedPerson.js +9 -0
  43. package/docs/releasing.md +19 -0
  44. package/mcp-server.json +3 -2
  45. package/package.json +10 -3
  46. package/plugin.json +1 -1
  47. package/scripts/plugin-manifest.source.json +19 -0
  48. package/scripts/readme-fr.mjs +3 -1
  49. package/dist/oauth/clientAssertion.d.ts +0 -28
  50. 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.7.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,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.0] - 2026-11-02
11
+
12
+ ### Added
13
+ - 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.
14
+ - **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.
15
+ - 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.
16
+ - 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`.
17
+ - `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.
18
+ - 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`.
19
+
20
+ ### Changed
21
+ - 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.
22
+ - 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.
23
+ - `@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.
24
+ - 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.
25
+
26
+ ### Fixed
27
+ - 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`.
28
+ - 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).
29
+ - 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.
30
+ - `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.
31
+ - 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.
32
+
33
+ ### Security
34
+ - `/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.
35
+
10
36
  ## [0.6.3] - 2026-11-01
11
37
 
12
38
  ### Fixed
@@ -506,6 +532,7 @@ Initial published release.
506
532
  - MIT license, clonable as a starter template.
507
533
 
508
534
  [Unreleased]: https://github.com/elpiarthera/vantage-mcp-boilerplate/compare/v0.4.0...HEAD
535
+ [0.7.0]: https://www.npmjs.com/package/@vantageos/mcp-boilerplate/v/0.7.0
509
536
  [0.6.3]: https://www.npmjs.com/package/@vantageos/mcp-boilerplate/v/0.6.3
510
537
  [0.6.2]: https://www.npmjs.com/package/@vantageos/mcp-boilerplate/v/0.6.2
511
538
  [0.6.1]: https://www.npmjs.com/package/@vantageos/mcp-boilerplate/v/0.6.1
package/README.fr.md CHANGED
@@ -112,6 +112,13 @@ de liste du protocole, servies par le SDK.
112
112
  échouent au moindre manque. Les annotations sont des indications non fiables pour le
113
113
  client (mcp-standard C-15) : ne conditionnez jamais une action sur elles seules.
114
114
 
115
+ Chaque appel d'outil est limité en débit (mcp-server-standard S-24) : un seau à jetons par appelant (l'organisation
116
+ résolue, ou `public` sur le palier non cloisonné), partagé par le processus. `TOOL_RATE_LIMIT_BURST` appels peuvent
117
+ partir en rafale (30 par défaut), puis le seau se remplit à `TOOL_RATE_LIMIT_PER_MINUTE` (60 par défaut). Un appel
118
+ au-delà de la limite est une erreur d'outil portant `structuredContent.error.code` `rate_limited` et `retryAfterMs`
119
+ (message FR/EN) ; une limite qui n'est pas un entier strictement positif arrête le serveur au démarrage. Un nouvel
120
+ outil enveloppe son handler avec `withRateLimit(…)`.
121
+
115
122
  ## Paliers d'authentification
116
123
 
117
124
  | `AUTH_MODE` | Comportement |
@@ -168,7 +175,11 @@ Pour la mesurer, envoyez 21 enregistrements depuis un même client et attendez u
168
175
  21e. Un client IPv6 est compté sur son /64 ET sur son /48 : tous les /64 d'un même /48 se
169
176
  partagent `MCP_REGISTER_LIMIT_PER_48_PER_MINUTE` enregistrements par minute (défaut `100`),
170
177
  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.
178
+ son `503`. Une valeur invalide arrête le serveur au démarrage. `/token` est limité de la même façon, sur la
179
+ même adresse : `MCP_TOKEN_LIMIT_PER_MINUTE` requêtes par minute (défaut `60`), cinq fois plus par /48 IPv6,
180
+ puis `429` avec `Retry-After: 60`. Chaque assertion client `private_key_jwt` qu'il vérifie occupe une place à
181
+ usage unique jusqu'à son expiration (300 s au plus, plafond de la couche d'identité) : un appelant sans limite
182
+ pourrait remplir ces places et bloquer tous les clients authentifiés par assertion ; la limite l'empêche.
172
183
 
173
184
  **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
185
  (défaut 1 Mio) du corps d'une requête et répond `413` avant tout handler.
@@ -315,11 +326,39 @@ server.registerTool("my_rows", config, withVerifiedPerson(deps, async (args, per
315
326
 
316
327
  Sauf si la personne est résolue, le handler ne s'exécute pas : l'appel reçoit une erreur d'outil
317
328
  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),
329
+ `X-MCP-Locale`, et une `reason` : `identity_not_configured` (aucune `identity` injectée — sur le
330
+ palier public seulement, puisque le palier OAuth refuse de démarrer sans elle, voir plus bas),
319
331
  `no_verified_session` (palier public, et **stdio, qui n'a jamais de personne** : ce transport ne
320
332
  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.
333
+ jamais une valeur de claim). Sur le palier public et en stdio, un outil qui n'enveloppe pas son
334
+ handler s'exécute sans personne. Sur le palier OAuth, aucun outil ne s'exécute pour un appelant que
335
+ 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
336
+ refus.
337
+
338
+ ### Rôles d'outils des agents
339
+
340
+ Sur le palier OAuth, chaque outil est accordé ou refusé selon le RÔLE de l'appelant, par
341
+ `decideToolRole` de `@vantageos/cloud-identity` (mcp-server-standard « Agent tool roles »). Chaque
342
+ outil déclare son `domain` et son `mode` (voir « Ajouter un outil ») ; un rôle accorde la lecture
343
+ et/ou l'écriture par domaine, plus `add` (outils accordés hors de ses domaines) et `remove` (outils
344
+ retirés, qui l'emporte toujours). La même décision sert aux deux points : `tools/list` ne contient
345
+ que les outils accordés, et l'appel de tout autre outil reçoit un résultat `isError: true` au
346
+ `structuredContent.error.code` `TOOL_ROLE_DENIED` (`TOOL_ROLE_UNRESOLVED` quand le stockage des
347
+ rôles n'a pas pu être lu), sans que son handler s'exécute.
348
+
349
+ Le démarrage est refusé, en nommant chaque pièce manquante, sauf si les deux sont présents :
350
+
351
+ - **les stores d'identité** (`identity`, plus haut) : un rôle est décidé pour l'organisation que la
352
+ couche d'identité résout, jamais pour un claim lu ici ;
353
+ - **un stockage des rôles** : par défaut le fichier JSON nommé par `MCP_ROLES_FILE` (format dans
354
+ `examples/roles.example.json` : les rôles, et `agents` qui lie chaque sujet vérifié à un rôle).
355
+ Un fichier absent, illisible ou mal formé arrête le serveur ; aucun fichier ne veut jamais dire
356
+ « tous les outils ». Un déploiement avec un autre stockage injecte `toolRoles` (`src/toolRoles.ts`,
357
+ `RoleProvider`).
358
+
359
+ L'outil d'identité `whoami` reste hors du contrôle : tout appelant peut lui demander qui il est,
360
+ quel rôle décide de ses outils et quels outils ce rôle accorde (`rolesApplicable: false` sur le
361
+ palier public et en stdio, où il n'y a pas d'appelant). Tests : `tests/toolRoles.test.ts`.
323
362
 
324
363
  ## Appeler une API amont (OAuth 2 client_credentials)
325
364
 
@@ -553,6 +592,13 @@ pilote l'app Hono en processus avec le `Client` du SDK v2 ; la suite stdio lance
553
592
  `dist/stdio.js` construit. `npm run evals` exécute le jeu de cas déclaratif contre
554
593
  stdio.
555
594
 
595
+ **Accessibilité (mcp-app-standard A-4).** `npm run test:a11y` audite le widget livré, `dist/ui-bundle/widget.html`
596
+ (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,
597
+ vide et rempli. Toute violation grave ou critique le fait échouer, et un contrôle positif prouve qu'une violation
598
+ injectée est trouvée. Il fait partie de `npm test` : `prepublishOnly` l'exécute avant chaque publication. L'audit
599
+ tourne dans jsdom, qui ne calcule aucune mise en page : le contraste des couleurs y est signalé comme incomplet et
600
+ n'est pas prouvé par cette barrière.
601
+
556
602
  ## Ajouter un outil
557
603
 
558
604
  Déclarez sa fiche dans `src/toolRecord.ts` (couple d'autorité, verbe, table, et pour
@@ -562,6 +608,21 @@ une liste/recherche le mécanisme et la pagination), enregistrez-le une fois dan
562
608
  est alors servi sur les deux transports et les deux ères du protocole.
563
609
  `tests/toolRecordComplete.test.ts` le garde honnête.
564
610
 
611
+ La fiche déclare aussi le `domain` de l'outil (le domaine métier qu'un rôle accorde, par exemple `items`) et son
612
+ `mode`, exactement `read` ou `write`, en accord avec `readOnlyHint` (read <-> `true`). C'est sur eux que se décide le
613
+ rôle d'un agent (mcp-server-standard « Agent tool roles », règle 2). Un outil auquel l'un manque, ou dont le mode
614
+ vaut autre chose, n'est servi à personne : le serveur refuse de démarrer et le nomme
615
+ (`tests/toolRoleDeclarations.test.ts`).
616
+
617
+ ## Changelog
618
+
619
+ Une pull request ajoute UN fichier, `changelog.d/<branche>.md` (frontmatter `section:` et une entrée), et ne
620
+ modifie jamais `CHANGELOG.md`, dont le bloc `## [Unreleased]` reste vide. `npm run changelog:check`, inclus dans
621
+ `npm test`, refuse un fragment mal formé ou une entrée écrite sous `## [Unreleased]`. À la publication,
622
+ `npm run changelog:release -- --version <x.y.z>` regroupe les fragments en `## [x.y.z] - <date>`
623
+ (`changelog.d/README.md`, `docs/releasing.md`). Deux pull requests qui ajoutent deux fichiers n'entrent jamais
624
+ en conflit, là où deux modifications du même bloc le faisaient.
625
+
565
626
  ## Packaging & distribution
566
627
 
567
628
  Quatre manifestes sont **générés** depuis `scripts/plugin-manifest.source.json` et
@@ -581,6 +642,34 @@ Claude être surchargé par l'installateur. Le skill d'onboarding est
581
642
  `skills/connect-mcp-server/SKILL.md`, déclaré pour OpenAI en
582
643
  `extensions["com.openai"].onboardingSkill`.
583
644
 
645
+ ### Hôtes cibles et chemins de vérification
646
+
647
+ `mcp-server.json` déclare où le serveur est distribué, sous `distribution.targets` (par défaut : `claude`,
648
+ `claude-code`, `vscode`, `cursor`, `chatgpt`), comme le demande la règle S-45 du mcp-server-standard. Un hôte qui
649
+ vérifie que vous possédez l'origine en lisant un chemin connu voit ce chemin servi pour chaque cible déclarée, la
650
+ valeur étant lue dans l'environnement à chaque requête, jamais écrite dans le code :
651
+
652
+ | Cible | Chemin | Variable |
653
+ |---|---|---|
654
+ | `chatgpt` | `/.well-known/openai-apps-challenge` | `OPENAI_APPS_CHALLENGE` |
655
+ | `google-connected-apps` | `/.well-known/google-connected-apps` | `GOOGLE_CONNECTED_APPS_VERIFICATION` |
656
+
657
+ 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
658
+ visible. Un fork retire les cibles qu'il ne vise pas, définit les variables de celles qu'il garde, et règle
659
+ `PUBLIC_BASE_URL` sur son origine publique `https://` : la valeur du boilerplate est une valeur de développement, pas
660
+ une origine que vous possédez.
661
+
662
+ ### Kit de référencement pour les annuaires
663
+
664
+ `listing/listing.json` est ce que lit le relecteur d'un annuaire (mcp-standard C-35) : la description, chaque outil
665
+ servi avec son titre et ses indications lecture seule / destructif, 5 invites de test positives et 3 négatives
666
+ données par identifiant d'éval de `evals/evals.json`, et la façon d'obtenir un compte de relecture (jamais un compte).
667
+ Chaque champ est dérivé : `scripts/listing-kit.mjs` lit la description et les identifiants d'éval choisis dans
668
+ `scripts/plugin-manifest.source.json` (`listing`), et les outils dans le `tools/list` servi. Après avoir modifié une
669
+ source ou un outil, lancez `npm run listing:write` et commitez le fichier. `npm run build` se contente de le
670
+ vérifier : un kit commité qui s'écarte de ses sources, un identifiant d'éval inconnu ou un compte insuffisant fait
671
+ échouer la construction, et `tests/listingKit.test.ts` échoue aussi. Modifiez la source, jamais le fichier.
672
+
584
673
  ### Scaffold → build → publish
585
674
 
586
675
  ```bash
@@ -658,7 +747,10 @@ n'y a pas d'outil de rendu séparé.
658
747
  - La ressource déclare `_meta.ui` : `csp` avec `connectDomains`, `resourceDomains`,
659
748
  `frameDomains` et `baseUriDomains` (tous vides — le widget ne fait aucune requête
660
749
  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).
750
+ `domain` : l'origine de bac à sable de Claude, dérivée au démarrage de l'URL MCP servie
751
+ (`PUBLIC_BASE_URL` + `/mcp`) comme les 32 premiers caractères hexadécimaux de son SHA-256 suivis de
752
+ `.claudemcpcontent.com` (mcp-app-standard A-11), jamais tapée. Sans `PUBLIC_BASE_URL`, ou en stdio, il
753
+ n'y a pas d'URL servie et aucun domaine n'est déclaré.
662
754
  Son type MIME est `text/html;profile=mcp-app`.
663
755
  - **Les clés ChatGPT sont additives** : `openai/outputTemplate` (même URI) et les
664
756
  chaînes `openai/toolInvocation/invoking|invoked`. Rien ne les lit ; le serveur
package/README.md CHANGED
@@ -110,6 +110,12 @@ stays with the protocol list methods' cursor, which the SDK serves.
110
110
  fail on any gap. Annotations are untrusted hints for the client (mcp-standard C-15);
111
111
  never gate an action on them alone.
112
112
 
113
+ Every tool invocation is rate limited (mcp-server-standard S-24): a token bucket per caller (the resolved
114
+ organisation, or `public` on the unscoped tier), shared by the process. `TOOL_RATE_LIMIT_BURST` calls may burst
115
+ (default 30), then the bucket refills at `TOOL_RATE_LIMIT_PER_MINUTE` (default 60). A call past the limit is a tool
116
+ error with `structuredContent.error.code` `rate_limited` and `retryAfterMs` (message FR/EN); a limit that is not a
117
+ positive integer stops the server at start. A new tool wraps its handler with `withRateLimit(…)`.
118
+
113
119
  ## Authentication tiers
114
120
 
115
121
  | `AUTH_MODE` | Behavior |
@@ -163,7 +169,11 @@ many clients. To measure it, send 21 registrations from one client and expect a
163
169
  the 21st. An IPv6 client is keyed on its /64 and ALSO counted against its /48: every /64 inside one
164
170
  /48 shares `MCP_REGISTER_LIMIT_PER_48_PER_MINUTE` registrations per minute (default `100`), so
165
171
  one /48, which holds 65536 /64s, cannot fill the client registry to its `503`. An invalid value
166
- stops the server at start.
172
+ stops the server at start. `/token` is limited the same way, keyed on the same address:
173
+ `MCP_TOKEN_LIMIT_PER_MINUTE` requests per minute (default `60`), five times that per IPv6 /48, then `429` with
174
+ `Retry-After: 60`. Each `private_key_jwt` client assertion it verifies holds a single-use slot until it expires
175
+ (at most 300 s, the identity layer's cap), so an unlimited caller could fill those slots and lock every
176
+ assertion-authenticated client out; the limit bounds that.
167
177
 
168
178
  **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
179
  (default 1 MiB) of a request body and answers `413` before any handler runs.
@@ -299,10 +309,37 @@ server.registerTool("my_rows", config, withVerifiedPerson(deps, async (args, per
299
309
 
300
310
  Unless the person resolved, the handler does not run: the call gets an MCP tool error with the
301
311
  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.
312
+ `X-MCP-Locale`, and a `reason`: `identity_not_configured` (no `identity` injected — on the public
313
+ tier only, since the OAuth tier refuses to start without it, see below), `no_verified_session`
314
+ (public tier, and **stdio, which never has a person**: there is no bearer on that transport) or
315
+ `refused` (the package refused; its `code` and `reason` are carried, never a claim value). On the
316
+ public tier and stdio, a tool that does not wrap its handler runs with no person. On the OAuth
317
+ tier no tool runs for a caller the identity layer did not resolve: the role gate below answers
318
+ first, with this same refusal.
319
+
320
+ ### Agent tool roles
321
+
322
+ On the OAuth tier, every tool is granted or refused by the caller's ROLE, through
323
+ `@vantageos/cloud-identity`'s `decideToolRole` (mcp-server-standard "Agent tool roles"). Each tool
324
+ declares its `domain` and `mode` (see "Adding a tool"); a role grants read and/or write per domain,
325
+ plus `add` (tools granted outside its domains) and `remove` (tools withheld, which always wins).
326
+ The same decision drives both points: `tools/list` holds only the granted tools, and a call to any
327
+ other tool is a tool result with `isError: true` and `structuredContent.error.code`
328
+ `TOOL_ROLE_DENIED` (`TOOL_ROLE_UNRESOLVED` when the role store could not be read), its handler
329
+ never run.
330
+
331
+ The start is refused, naming each missing piece, unless both of these are present:
332
+
333
+ - **the identity stores** (`identity`, above): a role is decided for the organisation the identity
334
+ layer resolves, never for a claim read here;
335
+ - **a role store**: by default the JSON file named by `MCP_ROLES_FILE` (format in
336
+ `examples/roles.example.json`: the roles, and `agents` binding each verified subject to one role).
337
+ A missing, unreadable or malformed file stops the server; no file never means "every tool". A
338
+ deployment with another store injects `toolRoles` (`src/toolRoles.ts`, `RoleProvider`).
339
+
340
+ The identity tool `whoami` stays outside the gate: every caller can ask it who they are, which role
341
+ decides their tools, and which tools that role grants (`rolesApplicable: false` on the public tier
342
+ and stdio, where there is no caller). Tests: `tests/toolRoles.test.ts`.
306
343
 
307
344
  ## Calling an upstream API (OAuth 2 client_credentials)
308
345
 
@@ -524,6 +561,12 @@ Runs both transports through the real SDK code paths: the HTTP suite drives the
524
561
  Hono app in-process with the SDK v2 `Client`; the stdio suite spawns the built
525
562
  `dist/stdio.js`. `npm run evals` runs the declarative case-set against stdio.
526
563
 
564
+ **Accessibility (mcp-app-standard A-4).** `npm run test:a11y` audits the shipped widget, `dist/ui-bundle/widget.html`
565
+ (build it first with `npm run build:ui`), against WCAG 2.1 A and AA with `axe-core`, in English and French, empty and
566
+ populated. Any serious or critical violation fails it, and a positive control proves an injected violation is found.
567
+ It is part of `npm test`, so `prepublishOnly` runs it before every publish. The audit runs in jsdom, which computes no
568
+ layout, so colour contrast is reported as incomplete and is not proven by this gate.
569
+
527
570
  ## Adding a tool
528
571
 
529
572
  Declare its record in `src/toolRecord.ts` (authority pair, verb, table, and for a
@@ -532,6 +575,20 @@ list/search the mechanism and pagination), register it once in
532
575
  `inputSchema` and `outputSchema`, and return `structuredResult(...)`. It is then served on both transports and both
533
576
  protocol eras. `tests/toolRecordComplete.test.ts` keeps it honest.
534
577
 
578
+ The record also declares the tool's `domain` (the business area a role grants, such as `items`) and its `mode`,
579
+ exactly `read` or `write`, agreeing with `readOnlyHint` (read <-> `true`). This is what an agent's role is decided
580
+ on (mcp-server-standard "Agent tool roles", rule 2). A tool missing either, or with any other mode, is served to no
581
+ one: the server refuses to start and names it (`tests/toolRoleDeclarations.test.ts`).
582
+
583
+ ## Changelog
584
+
585
+ A pull request adds ONE file, `changelog.d/<branch>.md` (frontmatter `section:` plus one entry), and never
586
+ edits `CHANGELOG.md`, whose `## [Unreleased]` block stays empty. `npm run changelog:check`, part of `npm test`,
587
+ refuses a malformed fragment or an entry written under `## [Unreleased]`. At release,
588
+ `npm run changelog:release -- --version <x.y.z>` folds the fragments into `## [x.y.z] - <date>`
589
+ (`changelog.d/README.md`, `docs/releasing.md`). Two pull requests adding two files never conflict, where two
590
+ edits of the same block did.
591
+
535
592
  ## Packaging & distribution
536
593
 
537
594
  Four manifests are **generated** from `scripts/plugin-manifest.source.json` and
@@ -551,6 +608,33 @@ to be overridden by the installer. The onboarding skill is
551
608
  `skills/connect-mcp-server/SKILL.md`, declared for OpenAI as
552
609
  `extensions["com.openai"].onboardingSkill`.
553
610
 
611
+ ### Target hosts and their verification paths
612
+
613
+ `mcp-server.json` declares where the server is distributed, under `distribution.targets` (default: `claude`,
614
+ `claude-code`, `vscode`, `cursor`, `chatgpt`), as mcp-server-standard S-45 asks. A host that confirms you own the
615
+ origin by reading a well-known path gets that path served for each declared target, with the value read from the
616
+ environment at request time, never written in source:
617
+
618
+ | Target | Path | Variable |
619
+ |---|---|---|
620
+ | `chatgpt` | `/.well-known/openai-apps-challenge` | `OPENAI_APPS_CHALLENGE` |
621
+ | `google-connected-apps` | `/.well-known/google-connected-apps` | `GOOGLE_CONNECTED_APPS_VERIFICATION` |
622
+
623
+ Unset, the path answers 404 naming the variable, so the host's check fails visibly. A fork removes the targets it
624
+ does not ship to, sets the variables of the ones it keeps, and sets `PUBLIC_BASE_URL` to its public `https://` origin:
625
+ the boilerplate's own value is a development default, not an origin you own.
626
+
627
+ ### Directory listing kit
628
+
629
+ `listing/listing.json` is what a directory reviewer reads (mcp-standard C-35): the description, every served tool
630
+ with its title and its read-only / destructive hints, 5 positive and 3 negative test prompts given as eval ids of
631
+ `evals/evals.json`, and where a reviewer obtains an account (never one). Every field is derived:
632
+ `scripts/listing-kit.mjs` reads the description and the chosen eval ids from
633
+ `scripts/plugin-manifest.source.json` (`listing`), and the tools from the served `tools/list`. After changing a
634
+ source or a tool, run `npm run listing:write` and commit the file. `npm run build` only checks it: a committed kit
635
+ that differs from its sources, an unknown eval id or a short count fails the build, and `tests/listingKit.test.ts`
636
+ fails too. Edit the source, never the file.
637
+
554
638
  ### Scaffold → build → publish
555
639
 
556
640
  ```bash
@@ -624,8 +708,10 @@ separate render tool.
624
708
  - The resource declares `_meta.ui`: `csp` with `connectDomains`,
625
709
  `resourceDomains`, `frameDomains` and `baseUriDomains` (all empty — the widget
626
710
  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`.
711
+ `prefersBorder`, and `domain`: Claude's sandbox origin, derived at start from the served MCP URL
712
+ (`PUBLIC_BASE_URL` + `/mcp`) as the first 32 hex characters of its SHA-256 followed by
713
+ `.claudemcpcontent.com` (mcp-app-standard A-11), never typed. Without `PUBLIC_BASE_URL`, or on stdio,
714
+ there is no served URL and no domain is declared. Its mime type is `text/html;profile=mcp-app`.
629
715
  - **ChatGPT keys are additive**: `openai/outputTemplate` (same URI) and the
630
716
  `openai/toolInvocation/invoking|invoked` strings. Nothing reads them; the
631
717
  server works with every `openai/*` key absent. `openai/ui` entrypoints are
@@ -0,0 +1,15 @@
1
+ import type { Env, Hono } from "hono";
2
+ /**
3
+ * The verification path each host reads to confirm we own the origin (mcp-server-standard S-45), served for every
4
+ * DECLARED target (`mcp-server.json` `distribution.targets`) from configuration — never a token written in source.
5
+ * A host with no verification path (claude, cursor, vscode, …) needs no route. One explicit route per host that has
6
+ * one, each reading `process.env` in its own handler, so a reader (and mcp-doctor) sees both the path and where its
7
+ * value comes from; a fork sets the value in its environment.
8
+ */
9
+ export declare const HOST_VERIFICATION: ReadonlyArray<{
10
+ host: string;
11
+ path: string;
12
+ env: string;
13
+ }>;
14
+ /** Registers the verification route of every declared target that has one; returns the paths registered. */
15
+ export declare function registerHostVerification<E extends Env>(app: Hono<E>, targets: readonly string[]): string[];
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The verification path each host reads to confirm we own the origin (mcp-server-standard S-45), served for every
3
+ * DECLARED target (`mcp-server.json` `distribution.targets`) from configuration — never a token written in source.
4
+ * A host with no verification path (claude, cursor, vscode, …) needs no route. One explicit route per host that has
5
+ * one, each reading `process.env` in its own handler, so a reader (and mcp-doctor) sees both the path and where its
6
+ * value comes from; a fork sets the value in its environment.
7
+ */
8
+ export const HOST_VERIFICATION = [
9
+ { host: "chatgpt", path: "/.well-known/openai-apps-challenge", env: "OPENAI_APPS_CHALLENGE" },
10
+ {
11
+ host: "google-connected-apps",
12
+ path: "/.well-known/google-connected-apps",
13
+ env: "GOOGLE_CONNECTED_APPS_VERIFICATION",
14
+ },
15
+ ];
16
+ /** The configured value, read at request time; unset, 404 naming the variable (the host's check fails visibly). */
17
+ function answer(c, value, variable) {
18
+ return value ? c.text(value) : c.text(`${variable} is not configured`, 404);
19
+ }
20
+ /** Registers the verification route of every declared target that has one; returns the paths registered. */
21
+ export function registerHostVerification(app, targets) {
22
+ const registered = [];
23
+ if (targets.includes("chatgpt")) {
24
+ app.get("/.well-known/openai-apps-challenge", (c) => answer(c, process.env.OPENAI_APPS_CHALLENGE, "OPENAI_APPS_CHALLENGE"));
25
+ registered.push("/.well-known/openai-apps-challenge");
26
+ }
27
+ if (targets.includes("google-connected-apps")) {
28
+ app.get("/.well-known/google-connected-apps", (c) => answer(c, process.env.GOOGLE_CONNECTED_APPS_VERIFICATION, "GOOGLE_CONNECTED_APPS_VERIFICATION"));
29
+ registered.push("/.well-known/google-connected-apps");
30
+ }
31
+ return registered;
32
+ }
package/dist/http.js CHANGED
@@ -2,8 +2,11 @@ import { loadAuthConfig, loadAuthMode } from "./config.js";
2
2
  import { buildHttpApp } from "./httpApp.js";
3
3
  import { createNodeListener, maxBodyBytesFrom } from "./nodeAdapter.js";
4
4
  import { loadHttpSettings } from "./settings.js";
5
+ import { claudeSandboxDomain, servedMcpUrl } from "./ui/sandboxDomain.js";
5
6
  const authMode = loadAuthMode();
6
- const uiDomain = process.env.MCP_UI_DOMAIN || undefined;
7
+ // A-11: the widget's Claude sandbox domain is derived from the served MCP URL, never configured as a literal.
8
+ const mcpUrl = servedMcpUrl(process.env);
9
+ const uiDomain = mcpUrl ? claudeSandboxDomain(mcpUrl) : undefined;
7
10
  // S-22: MCP_ALLOWED_ORIGINS, declared in src/settings.ts. A wildcard or a path stops the server
8
11
  // here with a ConfigurationError naming the variable.
9
12
  const { allowedOrigins } = loadHttpSettings();
package/dist/httpApp.d.ts CHANGED
@@ -28,11 +28,15 @@ import type { AuthMode } from "./config.js";
28
28
  import type { ExampleItem } from "./exampleItems.js";
29
29
  import type { LakeReader } from "./lake/reader.js";
30
30
  import { type AuthorizationServerDeps } from "./oauth/authorizationServer.js";
31
+ import type { RateLimiter } from "./rateLimit.js";
31
32
  import type { CursorSigner } from "./signedCursor.js";
32
33
  import { type ResolvedTenant } from "./tenancy.js";
34
+ import { type ToolRolesConfig } from "./toolRoles.js";
33
35
  import { type CallerResolution, type IdentityDeps } from "./verifiedPerson.js";
34
36
  export interface AppDeps {
35
37
  authMode?: AuthMode;
38
+ /** The tool invocation limiter (S-24); default the process-wide one. A test injects its own. */
39
+ rateLimiter?: RateLimiter;
36
40
  /** External-issuer resource-server configuration (no authorization server mounted). */
37
41
  auth?: AuthConfig;
38
42
  /** Own authorization server (R1). Takes precedence over `auth`. */
@@ -47,6 +51,11 @@ export interface AppDeps {
47
51
  * (`identity_not_configured`).
48
52
  */
49
53
  identity?: IdentityDeps;
54
+ /**
55
+ * Agent tool roles (src/toolRoles.ts). Default: read at start by `loadToolRoles` — on the OAuth tier `MCP_ROLES_FILE`
56
+ * is required; elsewhere roles are not applicable. A test or a deployment with another store injects its own.
57
+ */
58
+ toolRoles?: ToolRolesConfig;
50
59
  /**
51
60
  * The data-lake reader `list_items` reads through (src/lake/reader.ts), read-only by design.
52
61
  * Injected by the deployment like `identity`; absent, the in-memory seed is read.
package/dist/httpApp.js CHANGED
@@ -27,28 +27,78 @@ import { unauthorizedChallenge } from "@vantageos/cloud-identity";
27
27
  import { Hono } from "hono";
28
28
  import { authenticateRequest, protectedResourceMetadataDocument } from "./auth.js";
29
29
  import { getBuildIdentity } from "./buildIdentity.js";
30
- import { detectLocale, t } from "./i18n.js";
30
+ import { registerHostVerification } from "./hostVerification.js";
31
+ import { detectLocale, t, tBoth } from "./i18n.js";
31
32
  import { createBoilerplateMcpServer } from "./mcpServerFactory.js";
32
33
  import { createAuthorizationServer, } from "./oauth/authorizationServer.js";
33
34
  import { MCP_SCOPE } from "./oauth/settings.js";
34
35
  import { loadServerManifest } from "./serverManifest.js";
35
36
  import { resolveTenantFromJwtPayload } from "./tenancy.js";
37
+ import { callerScopeOf } from "./toolRoleGate.js";
38
+ import { ToolRolesConfigError, loadToolRoles } from "./toolRoles.js";
36
39
  import { assertIdentityDeps, resolveVerifiedPerson, } from "./verifiedPerson.js";
37
40
  import { SERVER_VERSION } from "./version.js";
38
41
  const PRM_PATHS = [
39
42
  "/.well-known/oauth-protected-resource",
40
43
  "/.well-known/oauth-protected-resource/mcp",
41
44
  ];
45
+ /**
46
+ * The per-request role context: the caller's member scope (from the person the identity layer resolved) and the role
47
+ * the provider returns for it. No resolved person -> no lookup and a null scope, so every gated tool is denied
48
+ * (caller-not-resolved). A provider that throws is a failed read -> TOOL_ROLE_UNRESOLVED, never "all tools".
49
+ */
50
+ async function toolRolesFor(config, caller) {
51
+ if (!config.applicable)
52
+ return { applicable: false };
53
+ const person = caller?.ok ? caller.person : undefined;
54
+ const scope = callerScopeOf(person);
55
+ let lookup = { status: "absent", cause: "unassigned" };
56
+ if (person && scope?.orgClerkId) {
57
+ try {
58
+ lookup = await config.provider({ subject: person.subject, orgClerkId: scope.orgClerkId });
59
+ }
60
+ catch {
61
+ lookup = { status: "unresolved" };
62
+ }
63
+ }
64
+ return { applicable: true, lookup, caller: scope, resolution: caller, roleName: config.roleName };
65
+ }
42
66
  export function buildHttpApp(deps = {}) {
43
67
  const app = new Hono();
44
68
  const authMode = deps.authMode ?? "public";
45
69
  // Refuses at start, naming the field, a manifest without a readable context ceiling (G6).
46
- loadServerManifest();
70
+ const manifest = loadServerManifest();
47
71
  if (deps.identity)
48
72
  assertIdentityDeps(deps.identity);
73
+ // Agent tool roles (Pi's rulings on task k17103j933e6en0aqyws2v1twx8hc0s3): on the OAuth tier every tool is decided
74
+ // by the caller's role, which needs BOTH a role store and the identity stores that resolve the caller to an
75
+ // organisation. The start refuses, naming each piece that is missing; an absent store never widens access.
76
+ const toolRoles = authMode === "oauth"
77
+ ? (() => {
78
+ const missing = [];
79
+ let config = deps.toolRoles;
80
+ if (!config) {
81
+ try {
82
+ config = loadToolRoles("oauth");
83
+ }
84
+ catch (e) {
85
+ if (!(e instanceof ToolRolesConfigError))
86
+ throw e;
87
+ missing.push(e.message);
88
+ }
89
+ }
90
+ if (!deps.identity)
91
+ missing.push(tBoth("toolRoles.identityMissing"));
92
+ if (missing.length > 0)
93
+ throw new ToolRolesConfigError(missing.join("; "));
94
+ return config;
95
+ })()
96
+ : { applicable: false };
49
97
  // Unauthenticated (S-7): the version AND the identity of the tree being served (C-25), so a
50
98
  // deployment is proven from what the server returns, not from what the deploy printed.
51
99
  app.get("/health", (c) => c.json({ status: "ok", version: SERVER_VERSION, buildId: getBuildIdentity() }));
100
+ // S-45: the verification path of every declared target host, served from configuration.
101
+ registerHostVerification(app, manifest.distribution.targets);
52
102
  // S-22 Origin validation, on /mcp ONLY and registered BEFORE the auth gate: a request that carries
53
103
  // an Origin header (a browser) must name an allowed origin (exact full-origin match, case-
54
104
  // insensitive), else 403 before any token is looked at. No Origin header (server-to-server, Claude
@@ -154,6 +204,7 @@ export function buildHttpApp(deps = {}) {
154
204
  ? c.get("caller")
155
205
  : await resolveVerifiedPerson(undefined, deps.identity);
156
206
  const locale = detectLocale(c.req.header("X-MCP-Locale"));
207
+ const roles = await toolRolesFor(toolRoles, caller);
157
208
  const handler = createMcpHandler(() => createBoilerplateMcpServer({
158
209
  tenant,
159
210
  requireTenant: authMode === "oauth",
@@ -162,6 +213,8 @@ export function buildHttpApp(deps = {}) {
162
213
  uiDomain: deps.uiDomain,
163
214
  lake: deps.lake,
164
215
  cursorSigner: deps.cursorSigner,
216
+ rateLimiter: deps.rateLimiter,
217
+ toolRoles: roles,
165
218
  }));
166
219
  return handler.fetch(c.req.raw);
167
220
  });
@@ -0,0 +1,10 @@
1
+ import { type McpServer } from "@modelcontextprotocol/server";
2
+ /**
3
+ * C-11 (a) (mcp-standard): an invalid cursor sent to a protocol list method is JSON-RPC -32602 Invalid params, never a
4
+ * silent first page. The SDK serves `tools/list`, `resources/list`, `resources/templates/list` and `prompts/list` as
5
+ * ONE page and issues no `nextCursor`, so every cursor such a request carries is one this server never issued: invalid.
6
+ * The SDK itself ignores the cursor and answers page 1, so each of these handlers is wrapped in place (the SDK keeps them
7
+ * in a map) to refuse first. A list that cannot be wrapped refuses the start: it must not answer a cursor silently.
8
+ */
9
+ export declare const SINGLE_PAGE_LISTS: readonly ["tools/list", "resources/list", "resources/templates/list", "prompts/list"];
10
+ export declare function rejectCursorOnSinglePageLists(server: McpServer): void;
@@ -0,0 +1,37 @@
1
+ import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/server";
2
+ /**
3
+ * C-11 (a) (mcp-standard): an invalid cursor sent to a protocol list method is JSON-RPC -32602 Invalid params, never a
4
+ * silent first page. The SDK serves `tools/list`, `resources/list`, `resources/templates/list` and `prompts/list` as
5
+ * ONE page and issues no `nextCursor`, so every cursor such a request carries is one this server never issued: invalid.
6
+ * The SDK itself ignores the cursor and answers page 1, so each of these handlers is wrapped in place (the SDK keeps them
7
+ * in a map) to refuse first. A list that cannot be wrapped refuses the start: it must not answer a cursor silently.
8
+ */
9
+ export const SINGLE_PAGE_LISTS = [
10
+ "tools/list",
11
+ "resources/list",
12
+ "resources/templates/list",
13
+ // The seed registers no prompt, so it serves no prompts/list; a fork that registers one gets it guarded.
14
+ "prompts/list",
15
+ ];
16
+ export function rejectCursorOnSinglePageLists(server) {
17
+ const handlers = server.server
18
+ ._requestHandlers;
19
+ if (!handlers)
20
+ throw new Error("C-11 (a): the SDK's request handlers were not found; list cursors cannot be checked");
21
+ for (const method of SINGLE_PAGE_LISTS) {
22
+ const inner = handlers.get(method);
23
+ if (typeof inner !== "function")
24
+ continue; // a list this server does not serve (no resources) has no handler
25
+ handlers.set(method, async (req, ctx) => {
26
+ const cursor = req?.params?.cursor;
27
+ if (cursor !== undefined)
28
+ throw new ProtocolError(ProtocolErrorCode.InvalidParams, `invalid cursor: ${method} is served as one page and issues no cursor`);
29
+ const result = (await inner(req, ctx));
30
+ // The refusal above rests on the SDK serving one page. If it ever returns a nextCursor, cursors become valid
31
+ // and refusing them all would be wrong: fail loud instead of answering a page with a cursor we would refuse.
32
+ if (result?.nextCursor !== undefined)
33
+ throw new Error(`C-11 (a): ${method} now returns a nextCursor; src/listCursor.ts must accept its cursors`);
34
+ return result;
35
+ });
36
+ }
37
+ }
@@ -12,6 +12,9 @@
12
12
  "noVerifiedSession": "permission denied: this tool needs the verified person, and this call carries no verified session",
13
13
  "refused": "permission denied: the verified person behind this call could not be resolved"
14
14
  },
15
+ "rateLimit": {
16
+ "exceeded": "too many tool calls: retry in {seconds} s"
17
+ },
15
18
  "validation": {
16
19
  "messageTooLong": "message exceeds the maximum length of 200 characters"
17
20
  },
@@ -42,9 +45,17 @@
42
45
  "object": "an object",
43
46
  "boolean": "true or false",
44
47
  "positiveInteger": "a positive whole number",
45
- "atLeastPerTool": "at least the perTool ceiling"
48
+ "atLeastPerTool": "at least the perTool ceiling",
49
+ "stringArray": "an array of non-empty strings"
46
50
  }
47
51
  },
52
+ "toolRoles": {
53
+ "identityMissing": "the identity stores are not injected (AppDeps.identity): on the OAuth tier a caller's role is decided for the organisation the identity layer resolves, so both are required",
54
+ "denied": "permission denied: your role does not grant the tool {tool} ({reason})",
55
+ "unresolved": "permission denied: your role could not be read, so no tool is granted",
56
+ "configMissing": "{variable} is not set: on the OAuth tier every tool is decided by the caller's role, read from that roles file",
57
+ "configInvalid": "{variable} ({path}) is not a valid roles file: {problem}"
58
+ },
48
59
  "toolRecord": {
49
60
  "unsound": "tool records or the context ceiling are unsound: {problems}"
50
61
  },