@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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +27 -0
- package/README.fr.md +97 -5
- package/README.md +93 -7
- package/dist/hostVerification.d.ts +15 -0
- package/dist/hostVerification.js +32 -0
- package/dist/http.js +4 -1
- package/dist/httpApp.d.ts +9 -0
- package/dist/httpApp.js +55 -2
- package/dist/listCursor.d.ts +10 -0
- package/dist/listCursor.js +37 -0
- package/dist/locales/en.json +12 -1
- package/dist/locales/fr.json +12 -1
- package/dist/mcpServerFactory.d.ts +11 -2
- package/dist/mcpServerFactory.js +97 -15
- package/dist/oauth/authorizationServer.js +33 -12
- package/dist/oauth/picker.js +1 -0
- package/dist/oauth/settings.d.ts +2 -0
- package/dist/oauth/settings.js +2 -0
- package/dist/oauth/tokenIssuer.d.ts +0 -1
- package/dist/oauth/tokenIssuer.js +1 -18
- package/dist/pagination.d.ts +2 -1
- package/dist/pagination.js +2 -1
- package/dist/rateLimit.d.ts +33 -0
- package/dist/rateLimit.js +48 -0
- package/dist/serverManifest.d.ts +9 -1
- package/dist/serverManifest.js +10 -1
- package/dist/signedCursor.js +6 -1
- package/dist/stdio.js +2 -2
- package/dist/tenancy.d.ts +4 -14
- package/dist/tenancy.js +7 -18
- package/dist/toolRecord.d.ts +35 -2
- package/dist/toolRecord.js +44 -2
- package/dist/toolRoleGate.d.ts +41 -0
- package/dist/toolRoleGate.js +94 -0
- package/dist/toolRoles.d.ts +57 -0
- package/dist/toolRoles.js +113 -0
- package/dist/ui/sandboxDomain.d.ts +13 -0
- package/dist/ui/sandboxDomain.js +23 -0
- package/dist/upstream/paginatedClient.js +1 -0
- package/dist/verifiedPerson.d.ts +6 -0
- package/dist/verifiedPerson.js +9 -0
- package/docs/releasing.md +19 -0
- package/mcp-server.json +3 -2
- package/package.json +10 -3
- package/plugin.json +1 -1
- package/scripts/plugin-manifest.source.json +19 -0
- package/scripts/readme-fr.mjs +3 -1
- package/dist/oauth/clientAssertion.d.ts +0 -28
- 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.
|
|
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).
|
|
322
|
-
|
|
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`
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
628
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
+
}
|
package/dist/locales/en.json
CHANGED
|
@@ -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
|
},
|