@vantageos/mcp-boilerplate 0.6.2 → 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 (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +36 -0
  3. package/README.fr.md +105 -6
  4. package/README.md +101 -8
  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 +15 -1
  13. package/dist/locales/fr.json +15 -1
  14. package/dist/mcpServerFactory.d.ts +11 -2
  15. package/dist/mcpServerFactory.js +97 -15
  16. package/dist/oauth/authorizationServer.js +77 -16
  17. package/dist/oauth/picker.d.ts +11 -0
  18. package/dist/oauth/picker.js +28 -0
  19. package/dist/oauth/settings.d.ts +7 -0
  20. package/dist/oauth/settings.js +7 -0
  21. package/dist/oauth/tokenIssuer.d.ts +0 -1
  22. package/dist/oauth/tokenIssuer.js +1 -18
  23. package/dist/pagination.d.ts +2 -1
  24. package/dist/pagination.js +2 -1
  25. package/dist/rateLimit.d.ts +33 -0
  26. package/dist/rateLimit.js +48 -0
  27. package/dist/serverManifest.d.ts +9 -1
  28. package/dist/serverManifest.js +10 -1
  29. package/dist/signedCursor.js +6 -1
  30. package/dist/stdio.js +2 -2
  31. package/dist/tenancy.d.ts +4 -14
  32. package/dist/tenancy.js +7 -18
  33. package/dist/toolRecord.d.ts +35 -2
  34. package/dist/toolRecord.js +44 -2
  35. package/dist/toolRoleGate.d.ts +41 -0
  36. package/dist/toolRoleGate.js +94 -0
  37. package/dist/toolRoles.d.ts +57 -0
  38. package/dist/toolRoles.js +113 -0
  39. package/dist/ui/sandboxDomain.d.ts +13 -0
  40. package/dist/ui/sandboxDomain.js +23 -0
  41. package/dist/upstream/paginatedClient.js +1 -0
  42. package/dist/verifiedPerson.d.ts +6 -0
  43. package/dist/verifiedPerson.js +9 -0
  44. package/docs/releasing.md +19 -0
  45. package/mcp-server.json +3 -2
  46. package/package.json +10 -3
  47. package/plugin.json +1 -1
  48. package/scripts/plugin-manifest.source.json +19 -0
  49. package/scripts/readme-fr.mjs +3 -1
  50. package/dist/oauth/clientAssertion.d.ts +0 -28
  51. 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.2",
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,40 @@ 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
+
36
+ ## [0.6.3] - 2026-11-01
37
+
38
+ ### Fixed
39
+ - The consent approval (and denial) no longer breaks for clients whose redirect target redirects again (VS Code: `vscode.dev/redirect`, then `vscode://` or a loopback). A browser applies the consent page's `form-action` to every redirect hop of the form submission, so the 302 to the client was blocked at the client's own next hop. The POST to `/authorize/org` now answers a same-origin hand-off page (200, meta refresh plus a visible link, CSP `default-src 'none'; form-action 'none'; frame-ancestors 'none'`), which ends the form submission here. The return from sign-in (a GET) still answers a plain redirect.
40
+
41
+ ### Added
42
+ - `AUTHORIZE_ALLOWED_ORG_IDS` (comma-separated Clerk organisation ids): the consent offers only the person's organisations that this server authorises. Any other membership is not listed, an approval for one is refused with 403, and a person with none of them gets a 403 with a localized message instead of a picker. Unset, behaviour is unchanged.
43
+
10
44
  ## [0.6.2] - 2026-11-01
11
45
 
12
46
  ### Fixed
@@ -498,6 +532,8 @@ Initial published release.
498
532
  - MIT license, clonable as a starter template.
499
533
 
500
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
536
+ [0.6.3]: https://www.npmjs.com/package/@vantageos/mcp-boilerplate/v/0.6.3
501
537
  [0.6.2]: https://www.npmjs.com/package/@vantageos/mcp-boilerplate/v/0.6.2
502
538
  [0.6.1]: https://www.npmjs.com/package/@vantageos/mcp-boilerplate/v/0.6.1
503
539
  [0.6.0]: https://www.npmjs.com/package/@vantageos/mcp-boilerplate/v/0.6.0
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.
@@ -259,7 +270,14 @@ fonctionne sur un serveur que vous hébergez, dans cet ordre :
259
270
  VantagePeers : sombre par défaut, clair quand le système de la personne le demande, une
260
271
  carte par organisation avec son rôle. Elle ne charge aucune ressource externe et porte donc
261
272
  ses propres tokens. Réglez `AUTHORIZE_BRAND_NAME` pour afficher le nom de votre produit
262
- au-dessus du titre ; rien n'est affiché par défaut.
273
+ au-dessus du titre ; rien n'est affiché par défaut. Réglez `AUTHORIZE_ALLOWED_ORG_IDS`
274
+ (identifiants d'organisation Clerk séparés par des virgules) pour ne proposer que les
275
+ organisations que ce serveur autorise : les autres appartenances ne sont pas listées, une
276
+ validation pour l'une d'elles est refusée (403), et une personne qui n'en a aucune reçoit un
277
+ 403 avec un message au lieu du choix. Non réglé, toutes les appartenances sont proposées.
278
+ Valider ou refuser répond une courte page de la même origine qui renvoie le navigateur vers
279
+ le client, pour que le `form-action` de la page ne bloque pas un client dont la cible de
280
+ redirection redirige à nouveau (le `vscode.dev/redirect` de VS Code).
263
281
 
264
282
  ### Multi-tenance
265
283
 
@@ -308,11 +326,39 @@ server.registerTool("my_rows", config, withVerifiedPerson(deps, async (args, per
308
326
 
309
327
  Sauf si la personne est résolue, le handler ne s'exécute pas : l'appel reçoit une erreur d'outil
310
328
  MCP au code stable `person_unresolved` (`structuredContent.error.code`), un message FR/EN selon
311
- `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),
312
331
  `no_verified_session` (palier public, et **stdio, qui n'a jamais de personne** : ce transport ne
313
332
  porte aucun bearer) ou `refused` (le paquet a refusé ; son `code` et sa `reason` sont transmis,
314
- jamais une valeur de claim). Un outil qui n'enveloppe pas son handler s'exécute exactement comme
315
- 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`.
316
362
 
317
363
  ## Appeler une API amont (OAuth 2 client_credentials)
318
364
 
@@ -546,6 +592,13 @@ pilote l'app Hono en processus avec le `Client` du SDK v2 ; la suite stdio lance
546
592
  `dist/stdio.js` construit. `npm run evals` exécute le jeu de cas déclaratif contre
547
593
  stdio.
548
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
+
549
602
  ## Ajouter un outil
550
603
 
551
604
  Déclarez sa fiche dans `src/toolRecord.ts` (couple d'autorité, verbe, table, et pour
@@ -555,6 +608,21 @@ une liste/recherche le mécanisme et la pagination), enregistrez-le une fois dan
555
608
  est alors servi sur les deux transports et les deux ères du protocole.
556
609
  `tests/toolRecordComplete.test.ts` le garde honnête.
557
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
+
558
626
  ## Packaging & distribution
559
627
 
560
628
  Quatre manifestes sont **générés** depuis `scripts/plugin-manifest.source.json` et
@@ -574,6 +642,34 @@ Claude être surchargé par l'installateur. Le skill d'onboarding est
574
642
  `skills/connect-mcp-server/SKILL.md`, déclaré pour OpenAI en
575
643
  `extensions["com.openai"].onboardingSkill`.
576
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
+
577
673
  ### Scaffold → build → publish
578
674
 
579
675
  ```bash
@@ -651,7 +747,10 @@ n'y a pas d'outil de rendu séparé.
651
747
  - La ressource déclare `_meta.ui` : `csp` avec `connectDomains`, `resourceDomains`,
652
748
  `frameDomains` et `baseUriDomains` (tous vides — le widget ne fait aucune requête
653
749
  réseau, ne charge aucun actif tiers et n'intègre aucune frame), `prefersBorder`, et
654
- `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é.
655
754
  Son type MIME est `text/html;profile=mcp-app`.
656
755
  - **Les clés ChatGPT sont additives** : `openai/outputTemplate` (même URI) et les
657
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.
@@ -245,7 +255,14 @@ server you host, in this order:
245
255
  6. **The consent page.** The organisation picker uses the VantagePeers dashboard design: dark
246
256
  by default, light when the person's system asks for it, one card per organisation with its
247
257
  role. It loads no external asset, so it carries its own tokens. Set `AUTHORIZE_BRAND_NAME`
248
- to show your product's name above the title; nothing is shown by default.
258
+ to show your product's name above the title; nothing is shown by default. Set
259
+ `AUTHORIZE_ALLOWED_ORG_IDS` (comma-separated Clerk organisation ids) to offer only the
260
+ organisations this server authorises: other memberships are not listed, an approval for one
261
+ of them is refused (403), and a person with none of them gets a 403 with a message instead of
262
+ a picker. Unset, every membership is offered.
263
+ Approving or denying answers a short same-origin page that sends the browser on to the
264
+ client, so the page's `form-action` does not block a client whose redirect target redirects
265
+ again (VS Code's `vscode.dev/redirect`).
249
266
 
250
267
  ### Multi-tenancy
251
268
 
@@ -292,10 +309,37 @@ server.registerTool("my_rows", config, withVerifiedPerson(deps, async (args, per
292
309
 
293
310
  Unless the person resolved, the handler does not run: the call gets an MCP tool error with the
294
311
  stable code `person_unresolved` (`structuredContent.error.code`), a FR/EN message per
295
- `X-MCP-Locale`, and a `reason`: `identity_not_configured` (no `identity` injected),
296
- `no_verified_session` (public tier, and **stdio, which never has a person**: there is no bearer
297
- on that transport) or `refused` (the package refused; its `code` and `reason` are carried, never
298
- 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`.
299
343
 
300
344
  ## Calling an upstream API (OAuth 2 client_credentials)
301
345
 
@@ -517,6 +561,12 @@ Runs both transports through the real SDK code paths: the HTTP suite drives the
517
561
  Hono app in-process with the SDK v2 `Client`; the stdio suite spawns the built
518
562
  `dist/stdio.js`. `npm run evals` runs the declarative case-set against stdio.
519
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
+
520
570
  ## Adding a tool
521
571
 
522
572
  Declare its record in `src/toolRecord.ts` (authority pair, verb, table, and for a
@@ -525,6 +575,20 @@ list/search the mechanism and pagination), register it once in
525
575
  `inputSchema` and `outputSchema`, and return `structuredResult(...)`. It is then served on both transports and both
526
576
  protocol eras. `tests/toolRecordComplete.test.ts` keeps it honest.
527
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
+
528
592
  ## Packaging & distribution
529
593
 
530
594
  Four manifests are **generated** from `scripts/plugin-manifest.source.json` and
@@ -544,6 +608,33 @@ to be overridden by the installer. The onboarding skill is
544
608
  `skills/connect-mcp-server/SKILL.md`, declared for OpenAI as
545
609
  `extensions["com.openai"].onboardingSkill`.
546
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
+
547
638
  ### Scaffold → build → publish
548
639
 
549
640
  ```bash
@@ -617,8 +708,10 @@ separate render tool.
617
708
  - The resource declares `_meta.ui`: `csp` with `connectDomains`,
618
709
  `resourceDomains`, `frameDomains` and `baseUriDomains` (all empty — the widget
619
710
  makes no network request, loads no third-party asset and embeds no frame),
620
- `prefersBorder`, and `domain` only when `MCP_UI_DOMAIN` is set (the format is
621
- 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`.
622
715
  - **ChatGPT keys are additive**: `openai/outputTemplate` (same URI) and the
623
716
  `openai/toolInvocation/invoking|invoked` strings. Nothing reads them; the
624
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;