@vantageos/mcp-boilerplate 0.6.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +39 -0
  3. package/README.fr.md +146 -10
  4. package/README.md +141 -12
  5. package/dist/build-info.json +1 -0
  6. package/dist/buildIdentity.d.ts +16 -0
  7. package/dist/buildIdentity.js +39 -0
  8. package/dist/embedded.generated.d.ts +225 -0
  9. package/dist/embedded.generated.js +8 -0
  10. package/dist/hostVerification.d.ts +15 -0
  11. package/dist/hostVerification.js +32 -0
  12. package/dist/http.js +9 -4
  13. package/dist/httpApp.d.ts +15 -0
  14. package/dist/httpApp.js +119 -9
  15. package/dist/i18n.js +6 -16
  16. package/dist/landingPage.d.ts +24 -0
  17. package/dist/landingPage.js +109 -0
  18. package/dist/listCursor.d.ts +10 -0
  19. package/dist/listCursor.js +37 -0
  20. package/dist/mcpServerFactory.d.ts +22 -2
  21. package/dist/mcpServerFactory.js +125 -22
  22. package/dist/oauth/authorizationServer.js +33 -12
  23. package/dist/oauth/picker.js +1 -0
  24. package/dist/oauth/settings.d.ts +2 -0
  25. package/dist/oauth/settings.js +2 -0
  26. package/dist/oauth/tokenIssuer.d.ts +0 -1
  27. package/dist/oauth/tokenIssuer.js +1 -18
  28. package/dist/pagination.d.ts +2 -1
  29. package/dist/pagination.js +2 -1
  30. package/dist/rateLimit.d.ts +33 -0
  31. package/dist/rateLimit.js +48 -0
  32. package/dist/servedToolRecords.d.ts +6 -0
  33. package/dist/servedToolRecords.js +11 -1
  34. package/dist/serverCard.d.ts +68 -0
  35. package/dist/serverCard.js +68 -0
  36. package/dist/serverIcon.d.ts +17 -0
  37. package/dist/serverIcon.js +26 -0
  38. package/dist/serverIdentity.d.ts +83 -0
  39. package/dist/serverIdentity.js +130 -0
  40. package/dist/serverManifest.d.ts +9 -1
  41. package/dist/serverManifest.js +10 -1
  42. package/dist/signedCursor.js +6 -1
  43. package/dist/stdio.js +2 -2
  44. package/dist/tenancy.d.ts +4 -14
  45. package/dist/tenancy.js +7 -18
  46. package/dist/toolRecord.d.ts +35 -2
  47. package/dist/toolRecord.js +44 -2
  48. package/dist/toolRoleGate.d.ts +41 -0
  49. package/dist/toolRoleGate.js +94 -0
  50. package/dist/toolRoles.d.ts +57 -0
  51. package/dist/toolRoles.js +113 -0
  52. package/dist/ui/sandboxDomain.d.ts +13 -0
  53. package/dist/ui/sandboxDomain.js +23 -0
  54. package/dist/ui/uiResource.d.ts +14 -1
  55. package/dist/ui/uiResource.js +11 -1
  56. package/dist/upstream/paginatedClient.js +1 -0
  57. package/dist/verifiedPerson.d.ts +6 -0
  58. package/dist/verifiedPerson.js +9 -0
  59. package/dist/version.js +6 -5
  60. package/docs/manufact-gap.md +200 -0
  61. package/docs/releasing.md +19 -0
  62. package/mcp-server.json +3 -2
  63. package/package.json +11 -3
  64. package/plugin.json +2 -4
  65. package/scripts/plugin-manifest.source.json +19 -0
  66. package/scripts/readme-fr.mjs +11 -4
  67. package/dist/locales/en.json +0 -77
  68. package/dist/locales/fr.json +0 -77
  69. package/dist/oauth/clientAssertion.d.ts +0 -28
  70. package/dist/oauth/clientAssertion.js +0 -106
@@ -0,0 +1,225 @@
1
+ export declare const EMBEDDED_LOCALES: {
2
+ readonly en: {
3
+ readonly Mcp: {
4
+ readonly auth: {
5
+ readonly unauthorized: "Unauthorized";
6
+ readonly originNotAllowed: "This origin is not allowed to call this endpoint";
7
+ };
8
+ readonly tenant: {
9
+ readonly permissionDenied: "permission denied: no tenant resolved for this caller";
10
+ };
11
+ readonly person: {
12
+ readonly identityNotConfigured: "permission denied: this tool needs the verified person, and this server resolves no person (identity is not configured)";
13
+ readonly noVerifiedSession: "permission denied: this tool needs the verified person, and this call carries no verified session";
14
+ readonly refused: "permission denied: the verified person behind this call could not be resolved";
15
+ };
16
+ readonly rateLimit: {
17
+ readonly exceeded: "too many tool calls: retry in {seconds} s";
18
+ };
19
+ readonly validation: {
20
+ readonly messageTooLong: "message exceeds the maximum length of 200 characters";
21
+ };
22
+ readonly oauth: {
23
+ readonly pickerTitle: "Connect {client}";
24
+ readonly pickerIntro: "{client} asks to act on your behalf. Choose the organisation it may reach, then approve or deny.";
25
+ readonly handoffTitle: "Returning to the application";
26
+ readonly handoffLink: "Continue to the application";
27
+ readonly pickerNoAllowedOrganisation: "None of your organisations is authorised on this server.";
28
+ readonly pickerDestination: "After approval you are sent to:";
29
+ readonly pickerScope: "Requested access:";
30
+ readonly pickerUnverified: "Unverified client: it registered itself automatically and nobody has vouched for it. Approve only if you recognise the address above.";
31
+ readonly pickerOrganisation: "Organisation";
32
+ readonly approve: "Approve";
33
+ readonly deny: "Deny";
34
+ readonly denied: "The request was denied; you can close this window.";
35
+ readonly unavailable: "Authorization is not configured on this server.";
36
+ readonly inMemoryWarning: "Running on in-memory OAuth stores: codes and registered clients are lost on restart and not shared between instances. Fine for development; set {knob}=1 to acknowledge, or inject durable adapters.";
37
+ readonly consentTokenRequired: "The consent token is missing: reload the page and try again.";
38
+ readonly registryFull: "Client registration is temporarily unavailable: the registry is full. Retry later.";
39
+ readonly devInstanceRemote: "Development Clerk instance on a remote server: its hosted sign-in page redirects only to localhost or to the instance Home URL. Set the development instance's Home URL (development origin) to this server's origin for a remote test and revert it afterwards, or use a Clerk production instance (see README, \"Sign-in with ChatGPT and Claude\").";
40
+ };
41
+ readonly manifest: {
42
+ readonly unreadableField: "cannot read field \"{field}\" of {source} (expected: {expected})";
43
+ readonly expect: {
44
+ readonly string: "a non-empty string";
45
+ readonly semver: "a semantic version (x.y.z)";
46
+ readonly object: "an object";
47
+ readonly boolean: "true or false";
48
+ readonly positiveInteger: "a positive whole number";
49
+ readonly atLeastPerTool: "at least the perTool ceiling";
50
+ readonly stringArray: "an array of non-empty strings";
51
+ };
52
+ };
53
+ readonly toolRoles: {
54
+ readonly 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";
55
+ readonly denied: "permission denied: your role does not grant the tool {tool} ({reason})";
56
+ readonly unresolved: "permission denied: your role could not be read, so no tool is granted";
57
+ readonly configMissing: "{variable} is not set: on the OAuth tier every tool is decided by the caller's role, read from that roles file";
58
+ readonly configInvalid: "{variable} ({path}) is not a valid roles file: {problem}";
59
+ };
60
+ readonly toolRecord: {
61
+ readonly unsound: "tool records or the context ceiling are unsound: {problems}";
62
+ };
63
+ readonly pagination: {
64
+ readonly invalidCursor: "invalid cursor: it was not issued by this tool or is no longer valid; start again without a cursor";
65
+ };
66
+ readonly lake: {
67
+ readonly unreadable: "the data source answered something this server cannot read; no row is shown rather than a partial or wrong page";
68
+ };
69
+ readonly buildIdentity: {
70
+ readonly unreadable: "cannot compute the build identity: {dir} is unreadable ({reason})";
71
+ readonly empty: "cannot compute the build identity: {dir} is empty (no file), nothing was built";
72
+ readonly commitUnreadable: "cannot read the build commit from {file} ({reason}): run npm run build, which writes it (scripts/write-build-info.mjs)";
73
+ };
74
+ readonly config: {
75
+ readonly refused: "configuration refused: {problems}";
76
+ readonly missing: "{env} is missing (expected: {expected})";
77
+ readonly invalid: "{env} is set but invalid (expected: {expected}; value not shown)";
78
+ readonly expect: {
79
+ readonly string: "a non-empty string";
80
+ readonly integer: "an integer from {min} to {max}";
81
+ readonly integerAtLeast: "an integer of at least {min}";
82
+ readonly boolean: "\"0\" or \"1\"";
83
+ readonly url: "an https URL (http on loopback only)";
84
+ readonly originList: "a comma-separated list of origins (scheme://host[:port], no path)";
85
+ readonly enum: "one of: {values}";
86
+ readonly json: "a JSON object or array";
87
+ };
88
+ };
89
+ readonly server: {
90
+ readonly instructions: "This server exposes three read-only example tools.\n1. Call whoami first. It returns who is calling on this connection (null on the public tier and on stdio), the caller's role, and grantedTools: the tools that role may call. Never ask the user for their identity and never pass one as an argument.\n2. ping proves the connection end to end; pass message to have it echoed back.\n3. list_items lists the items of the caller's organisation and renders them in an interactive widget where the client supports MCP Apps. It is paged: pass the returned nextCursor to read the next page; exhausted: true means the last page. Never invent a cursor.\n4. A result with isError and structuredContent.error.code is a refusal, not an empty result: rate_limited (wait retryAfterMs, then retry once), invalid_cursor (restart from the first page), lake_unreadable, TOOL_ROLE_DENIED or TOOL_ROLE_UNRESOLVED (the caller may not use this tool). Report it to the user; never read it as \"nothing exists\".\n5. Send the X-MCP-Locale header (en or fr) to receive messages in that language.";
91
+ readonly identityMissing: "the server identity has no {item}: set {variable}, or the \"{field}\" field of package.json";
92
+ readonly identityInvalid: "the server identity's {item} is not an https URL (http on a loopback host only): {source}";
93
+ };
94
+ readonly landing: {
95
+ readonly pageTitle: "{title} — MCP server";
96
+ readonly intro: "This address is a Model Context Protocol (MCP) server. Add it to your AI client as a remote MCP server:";
97
+ readonly claudeAi: "Claude.ai: add a custom connector with this URL, then Connect.";
98
+ readonly chatgpt: "ChatGPT: add a custom MCP server (developer mode) with this URL.";
99
+ readonly claudeCode: "Claude Code:";
100
+ readonly other: "Any other MCP client: a Streamable HTTP server at the URL above.";
101
+ readonly authOauth: "Your client then opens a sign-in page (OAuth 2.1). There is no token to copy.";
102
+ readonly authPublic: "This server runs without sign-in: connect directly.";
103
+ readonly website: "Website:";
104
+ };
105
+ readonly widget: {
106
+ readonly description: "Shows the example items returned by list_items as a list, with their name and status; read-only, it makes no request of its own.";
107
+ };
108
+ };
109
+ };
110
+ readonly fr: {
111
+ readonly Mcp: {
112
+ readonly auth: {
113
+ readonly unauthorized: "Non autorisé";
114
+ readonly originNotAllowed: "Cette origine n'est pas autorisée à appeler ce point d'accès";
115
+ };
116
+ readonly tenant: {
117
+ readonly permissionDenied: "permission refusée : aucun tenant résolu pour cet appelant";
118
+ };
119
+ readonly person: {
120
+ readonly identityNotConfigured: "permission refusée : cet outil exige la personne vérifiée, et ce serveur ne résout aucune personne (identité non configurée)";
121
+ readonly noVerifiedSession: "permission refusée : cet outil exige la personne vérifiée, et cet appel ne porte aucune session vérifiée";
122
+ readonly refused: "permission refusée : la personne vérifiée derrière cet appel n'a pas pu être résolue";
123
+ };
124
+ readonly rateLimit: {
125
+ readonly exceeded: "trop d’appels d’outils : réessayez dans {seconds} s";
126
+ };
127
+ readonly validation: {
128
+ readonly messageTooLong: "le message dépasse la longueur maximale de 200 caractères";
129
+ };
130
+ readonly oauth: {
131
+ readonly pickerTitle: "Connecter {client}";
132
+ readonly pickerIntro: "{client} demande à agir en votre nom. Choisissez l'organisation à laquelle il peut accéder, puis approuvez ou refusez.";
133
+ readonly handoffTitle: "Retour à l'application";
134
+ readonly handoffLink: "Continuer vers l'application";
135
+ readonly pickerNoAllowedOrganisation: "Aucune de vos organisations n'est autorisée sur ce serveur.";
136
+ readonly pickerDestination: "Redirection après validation vers :";
137
+ readonly pickerScope: "Accès demandé :";
138
+ readonly pickerUnverified: "Client non vérifié : il s'est enregistré automatiquement et personne ne s'en porte garant. N'approuvez que si vous reconnaissez l'adresse ci-dessus.";
139
+ readonly pickerOrganisation: "Organisation";
140
+ readonly approve: "Approuver";
141
+ readonly deny: "Refuser";
142
+ readonly denied: "La demande a été refusée ; vous pouvez fermer cette fenêtre.";
143
+ readonly unavailable: "L'autorisation n'est pas configurée sur ce serveur.";
144
+ readonly inMemoryWarning: "Stores OAuth en mémoire : les codes et les clients enregistrés sont perdus au redémarrage et non partagés entre instances. Acceptable en développement ; définissez {knob}=1 pour l'accepter, ou injectez des adaptateurs durables.";
145
+ readonly consentTokenRequired: "Le jeton de consentement est absent : rechargez la page et réessayez.";
146
+ readonly registryFull: "L'enregistrement de clients est temporairement indisponible : le registre est plein. Réessayez plus tard.";
147
+ readonly devInstanceRemote: "Instance Clerk de développement sur un serveur distant : sa page de connexion hébergée ne redirige que vers localhost ou vers l'URL d'accueil (Home URL) de l'instance. Pour un test distant, réglez l'URL d'accueil (origine de développement) de l'instance sur l'origine de ce serveur, puis rétablissez-la ; sinon utilisez une instance Clerk de production (voir README, « Connexion avec ChatGPT et Claude »).";
148
+ };
149
+ readonly manifest: {
150
+ readonly unreadableField: "champ \"{field}\" illisible dans {source} (attendu : {expected})";
151
+ readonly expect: {
152
+ readonly string: "une chaîne non vide";
153
+ readonly semver: "une version sémantique (x.y.z)";
154
+ readonly object: "un objet";
155
+ readonly boolean: "true ou false";
156
+ readonly positiveInteger: "un entier strictement positif";
157
+ readonly atLeastPerTool: "au moins le plafond perTool";
158
+ readonly stringArray: "un tableau de chaînes non vides";
159
+ };
160
+ };
161
+ readonly toolRoles: {
162
+ readonly identityMissing: "les stores d'identité ne sont pas injectés (AppDeps.identity) : sur le niveau OAuth, le rôle d'un appelant est décidé pour l'organisation que la couche d'identité résout, les deux sont donc requis";
163
+ readonly denied: "permission refusée : votre rôle n'accorde pas l'outil {tool} ({reason})";
164
+ readonly unresolved: "permission refusée : votre rôle n'a pas pu être lu, aucun outil n'est accordé";
165
+ readonly configMissing: "{variable} n'est pas défini : sur le niveau OAuth, chaque outil est décidé par le rôle de l'appelant, lu dans ce fichier de rôles";
166
+ readonly configInvalid: "{variable} ({path}) n'est pas un fichier de rôles valide : {problem}";
167
+ };
168
+ readonly toolRecord: {
169
+ readonly unsound: "fiches d'outils ou plafond de contexte invalides : {problems}";
170
+ };
171
+ readonly pagination: {
172
+ readonly invalidCursor: "curseur invalide : il n'a pas été émis par cet outil ou n'est plus valide ; recommencez sans curseur";
173
+ };
174
+ readonly lake: {
175
+ readonly unreadable: "la source de données a renvoyé une réponse illisible pour ce serveur ; aucune ligne n'est affichée plutôt qu'une page partielle ou fausse";
176
+ };
177
+ readonly buildIdentity: {
178
+ readonly unreadable: "identité de build incalculable : {dir} est illisible ({reason})";
179
+ readonly empty: "identité de build incalculable : {dir} est vide (aucun fichier), rien n'a été construit";
180
+ readonly commitUnreadable: "impossible de lire le commit de build dans {file} ({reason}) : lancez npm run build, qui l'écrit (scripts/write-build-info.mjs)";
181
+ };
182
+ readonly config: {
183
+ readonly refused: "configuration refusée : {problems}";
184
+ readonly missing: "{env} est manquante (attendu : {expected})";
185
+ readonly invalid: "{env} est définie mais invalide (attendu : {expected} ; valeur non affichée)";
186
+ readonly expect: {
187
+ readonly string: "une chaîne non vide";
188
+ readonly integer: "un entier de {min} à {max}";
189
+ readonly integerAtLeast: "un entier d'au moins {min}";
190
+ readonly boolean: "\"0\" ou \"1\"";
191
+ readonly url: "une URL https (http seulement sur la boucle locale)";
192
+ readonly originList: "une liste d'origines séparées par des virgules (schéma://hôte[:port], sans chemin)";
193
+ readonly enum: "une valeur parmi : {values}";
194
+ readonly json: "un objet ou un tableau JSON";
195
+ };
196
+ };
197
+ readonly server: {
198
+ readonly instructions: "Ce serveur expose trois outils d'exemple en lecture seule.\n1. Appelez whoami d'abord. Il renvoie qui appelle sur cette connexion (null sur le niveau public et en stdio), le rôle de l'appelant et grantedTools : les outils que ce rôle peut appeler. Ne demandez jamais son identité à l'utilisateur et n'en passez jamais une en argument.\n2. ping prouve la connexion de bout en bout ; passez message pour qu'il soit renvoyé tel quel.\n3. list_items liste les éléments de l'organisation de l'appelant et les affiche dans un widget interactif quand le client prend en charge MCP Apps. Il est paginé : passez le nextCursor renvoyé pour lire la page suivante ; exhausted: true signale la dernière page. N'inventez jamais de curseur.\n4. Un résultat avec isError et structuredContent.error.code est un refus, pas un résultat vide : rate_limited (attendez retryAfterMs, puis réessayez une fois), invalid_cursor (repartez de la première page), lake_unreadable, TOOL_ROLE_DENIED ou TOOL_ROLE_UNRESOLVED (l'appelant ne peut pas utiliser cet outil). Signalez-le à l'utilisateur ; ne le lisez jamais comme « rien n'existe ».\n5. Envoyez l'en-tête X-MCP-Locale (en ou fr) pour recevoir les messages dans cette langue.";
199
+ readonly identityMissing: "l'identité du serveur n'a pas de {item} : définissez {variable}, ou le champ « {field} » de package.json";
200
+ readonly identityInvalid: "{item} de l'identité du serveur n'est pas une URL https (http sur un hôte de bouclage seulement) : {source}";
201
+ };
202
+ readonly landing: {
203
+ readonly pageTitle: "{title} — serveur MCP";
204
+ readonly intro: "Cette adresse est un serveur Model Context Protocol (MCP). Ajoutez-la à votre client IA comme serveur MCP distant :";
205
+ readonly claudeAi: "Claude.ai : ajoutez un connecteur personnalisé avec cette URL, puis Connecter.";
206
+ readonly chatgpt: "ChatGPT : ajoutez un serveur MCP personnalisé (mode développeur) avec cette URL.";
207
+ readonly claudeCode: "Claude Code :";
208
+ readonly other: "Tout autre client MCP : un serveur Streamable HTTP à l'URL ci-dessus.";
209
+ readonly authOauth: "Votre client ouvre ensuite une page de connexion (OAuth 2.1). Aucun jeton à copier.";
210
+ readonly authPublic: "Ce serveur fonctionne sans connexion : branchez-vous directement.";
211
+ readonly website: "Site :";
212
+ };
213
+ readonly widget: {
214
+ readonly description: "Affiche sous forme de liste les éléments d'exemple renvoyés par list_items, avec leur nom et leur statut ; en lecture seule, il ne fait aucune requête lui-même.";
215
+ };
216
+ };
217
+ };
218
+ };
219
+ export declare const EMBEDDED_PACKAGE_VERSION = "0.8.0";
220
+ export declare const EMBEDDED_PACKAGE_IDENTITY: {
221
+ displayName: string;
222
+ description: string;
223
+ description_fr: string;
224
+ homepage: string;
225
+ };
@@ -0,0 +1,8 @@
1
+ // GENERATED by scripts/embed-static.mjs from src/locales/{en,fr}.json and package.json. Do not edit:
2
+ // `node scripts/embed-static.mjs --check` refuses any difference (tests/embedded-static.test.ts).
3
+ export const EMBEDDED_LOCALES = {
4
+ en: { "Mcp": { "auth": { "unauthorized": "Unauthorized", "originNotAllowed": "This origin is not allowed to call this endpoint" }, "tenant": { "permissionDenied": "permission denied: no tenant resolved for this caller" }, "person": { "identityNotConfigured": "permission denied: this tool needs the verified person, and this server resolves no person (identity is not configured)", "noVerifiedSession": "permission denied: this tool needs the verified person, and this call carries no verified session", "refused": "permission denied: the verified person behind this call could not be resolved" }, "rateLimit": { "exceeded": "too many tool calls: retry in {seconds} s" }, "validation": { "messageTooLong": "message exceeds the maximum length of 200 characters" }, "oauth": { "pickerTitle": "Connect {client}", "pickerIntro": "{client} asks to act on your behalf. Choose the organisation it may reach, then approve or deny.", "handoffTitle": "Returning to the application", "handoffLink": "Continue to the application", "pickerNoAllowedOrganisation": "None of your organisations is authorised on this server.", "pickerDestination": "After approval you are sent to:", "pickerScope": "Requested access:", "pickerUnverified": "Unverified client: it registered itself automatically and nobody has vouched for it. Approve only if you recognise the address above.", "pickerOrganisation": "Organisation", "approve": "Approve", "deny": "Deny", "denied": "The request was denied; you can close this window.", "unavailable": "Authorization is not configured on this server.", "inMemoryWarning": "Running on in-memory OAuth stores: codes and registered clients are lost on restart and not shared between instances. Fine for development; set {knob}=1 to acknowledge, or inject durable adapters.", "consentTokenRequired": "The consent token is missing: reload the page and try again.", "registryFull": "Client registration is temporarily unavailable: the registry is full. Retry later.", "devInstanceRemote": "Development Clerk instance on a remote server: its hosted sign-in page redirects only to localhost or to the instance Home URL. Set the development instance's Home URL (development origin) to this server's origin for a remote test and revert it afterwards, or use a Clerk production instance (see README, \"Sign-in with ChatGPT and Claude\")." }, "manifest": { "unreadableField": "cannot read field \"{field}\" of {source} (expected: {expected})", "expect": { "string": "a non-empty string", "semver": "a semantic version (x.y.z)", "object": "an object", "boolean": "true or false", "positiveInteger": "a positive whole number", "atLeastPerTool": "at least the perTool ceiling", "stringArray": "an array of non-empty strings" } }, "toolRoles": { "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", "denied": "permission denied: your role does not grant the tool {tool} ({reason})", "unresolved": "permission denied: your role could not be read, so no tool is granted", "configMissing": "{variable} is not set: on the OAuth tier every tool is decided by the caller's role, read from that roles file", "configInvalid": "{variable} ({path}) is not a valid roles file: {problem}" }, "toolRecord": { "unsound": "tool records or the context ceiling are unsound: {problems}" }, "pagination": { "invalidCursor": "invalid cursor: it was not issued by this tool or is no longer valid; start again without a cursor" }, "lake": { "unreadable": "the data source answered something this server cannot read; no row is shown rather than a partial or wrong page" }, "buildIdentity": { "unreadable": "cannot compute the build identity: {dir} is unreadable ({reason})", "empty": "cannot compute the build identity: {dir} is empty (no file), nothing was built", "commitUnreadable": "cannot read the build commit from {file} ({reason}): run npm run build, which writes it (scripts/write-build-info.mjs)" }, "config": { "refused": "configuration refused: {problems}", "missing": "{env} is missing (expected: {expected})", "invalid": "{env} is set but invalid (expected: {expected}; value not shown)", "expect": { "string": "a non-empty string", "integer": "an integer from {min} to {max}", "integerAtLeast": "an integer of at least {min}", "boolean": "\"0\" or \"1\"", "url": "an https URL (http on loopback only)", "originList": "a comma-separated list of origins (scheme://host[:port], no path)", "enum": "one of: {values}", "json": "a JSON object or array" } }, "server": { "instructions": "This server exposes three read-only example tools.\n1. Call whoami first. It returns who is calling on this connection (null on the public tier and on stdio), the caller's role, and grantedTools: the tools that role may call. Never ask the user for their identity and never pass one as an argument.\n2. ping proves the connection end to end; pass message to have it echoed back.\n3. list_items lists the items of the caller's organisation and renders them in an interactive widget where the client supports MCP Apps. It is paged: pass the returned nextCursor to read the next page; exhausted: true means the last page. Never invent a cursor.\n4. A result with isError and structuredContent.error.code is a refusal, not an empty result: rate_limited (wait retryAfterMs, then retry once), invalid_cursor (restart from the first page), lake_unreadable, TOOL_ROLE_DENIED or TOOL_ROLE_UNRESOLVED (the caller may not use this tool). Report it to the user; never read it as \"nothing exists\".\n5. Send the X-MCP-Locale header (en or fr) to receive messages in that language.", "identityMissing": "the server identity has no {item}: set {variable}, or the \"{field}\" field of package.json", "identityInvalid": "the server identity's {item} is not an https URL (http on a loopback host only): {source}" }, "landing": { "pageTitle": "{title} — MCP server", "intro": "This address is a Model Context Protocol (MCP) server. Add it to your AI client as a remote MCP server:", "claudeAi": "Claude.ai: add a custom connector with this URL, then Connect.", "chatgpt": "ChatGPT: add a custom MCP server (developer mode) with this URL.", "claudeCode": "Claude Code:", "other": "Any other MCP client: a Streamable HTTP server at the URL above.", "authOauth": "Your client then opens a sign-in page (OAuth 2.1). There is no token to copy.", "authPublic": "This server runs without sign-in: connect directly.", "website": "Website:" }, "widget": { "description": "Shows the example items returned by list_items as a list, with their name and status; read-only, it makes no request of its own." } } },
5
+ fr: { "Mcp": { "auth": { "unauthorized": "Non autorisé", "originNotAllowed": "Cette origine n'est pas autorisée à appeler ce point d'accès" }, "tenant": { "permissionDenied": "permission refusée : aucun tenant résolu pour cet appelant" }, "person": { "identityNotConfigured": "permission refusée : cet outil exige la personne vérifiée, et ce serveur ne résout aucune personne (identité non configurée)", "noVerifiedSession": "permission refusée : cet outil exige la personne vérifiée, et cet appel ne porte aucune session vérifiée", "refused": "permission refusée : la personne vérifiée derrière cet appel n'a pas pu être résolue" }, "rateLimit": { "exceeded": "trop d’appels d’outils : réessayez dans {seconds} s" }, "validation": { "messageTooLong": "le message dépasse la longueur maximale de 200 caractères" }, "oauth": { "pickerTitle": "Connecter {client}", "pickerIntro": "{client} demande à agir en votre nom. Choisissez l'organisation à laquelle il peut accéder, puis approuvez ou refusez.", "handoffTitle": "Retour à l'application", "handoffLink": "Continuer vers l'application", "pickerNoAllowedOrganisation": "Aucune de vos organisations n'est autorisée sur ce serveur.", "pickerDestination": "Redirection après validation vers :", "pickerScope": "Accès demandé :", "pickerUnverified": "Client non vérifié : il s'est enregistré automatiquement et personne ne s'en porte garant. N'approuvez que si vous reconnaissez l'adresse ci-dessus.", "pickerOrganisation": "Organisation", "approve": "Approuver", "deny": "Refuser", "denied": "La demande a été refusée ; vous pouvez fermer cette fenêtre.", "unavailable": "L'autorisation n'est pas configurée sur ce serveur.", "inMemoryWarning": "Stores OAuth en mémoire : les codes et les clients enregistrés sont perdus au redémarrage et non partagés entre instances. Acceptable en développement ; définissez {knob}=1 pour l'accepter, ou injectez des adaptateurs durables.", "consentTokenRequired": "Le jeton de consentement est absent : rechargez la page et réessayez.", "registryFull": "L'enregistrement de clients est temporairement indisponible : le registre est plein. Réessayez plus tard.", "devInstanceRemote": "Instance Clerk de développement sur un serveur distant : sa page de connexion hébergée ne redirige que vers localhost ou vers l'URL d'accueil (Home URL) de l'instance. Pour un test distant, réglez l'URL d'accueil (origine de développement) de l'instance sur l'origine de ce serveur, puis rétablissez-la ; sinon utilisez une instance Clerk de production (voir README, « Connexion avec ChatGPT et Claude »)." }, "manifest": { "unreadableField": "champ \"{field}\" illisible dans {source} (attendu : {expected})", "expect": { "string": "une chaîne non vide", "semver": "une version sémantique (x.y.z)", "object": "un objet", "boolean": "true ou false", "positiveInteger": "un entier strictement positif", "atLeastPerTool": "au moins le plafond perTool", "stringArray": "un tableau de chaînes non vides" } }, "toolRoles": { "identityMissing": "les stores d'identité ne sont pas injectés (AppDeps.identity) : sur le niveau OAuth, le rôle d'un appelant est décidé pour l'organisation que la couche d'identité résout, les deux sont donc requis", "denied": "permission refusée : votre rôle n'accorde pas l'outil {tool} ({reason})", "unresolved": "permission refusée : votre rôle n'a pas pu être lu, aucun outil n'est accordé", "configMissing": "{variable} n'est pas défini : sur le niveau OAuth, chaque outil est décidé par le rôle de l'appelant, lu dans ce fichier de rôles", "configInvalid": "{variable} ({path}) n'est pas un fichier de rôles valide : {problem}" }, "toolRecord": { "unsound": "fiches d'outils ou plafond de contexte invalides : {problems}" }, "pagination": { "invalidCursor": "curseur invalide : il n'a pas été émis par cet outil ou n'est plus valide ; recommencez sans curseur" }, "lake": { "unreadable": "la source de données a renvoyé une réponse illisible pour ce serveur ; aucune ligne n'est affichée plutôt qu'une page partielle ou fausse" }, "buildIdentity": { "unreadable": "identité de build incalculable : {dir} est illisible ({reason})", "empty": "identité de build incalculable : {dir} est vide (aucun fichier), rien n'a été construit", "commitUnreadable": "impossible de lire le commit de build dans {file} ({reason}) : lancez npm run build, qui l'écrit (scripts/write-build-info.mjs)" }, "config": { "refused": "configuration refusée : {problems}", "missing": "{env} est manquante (attendu : {expected})", "invalid": "{env} est définie mais invalide (attendu : {expected} ; valeur non affichée)", "expect": { "string": "une chaîne non vide", "integer": "un entier de {min} à {max}", "integerAtLeast": "un entier d'au moins {min}", "boolean": "\"0\" ou \"1\"", "url": "une URL https (http seulement sur la boucle locale)", "originList": "une liste d'origines séparées par des virgules (schéma://hôte[:port], sans chemin)", "enum": "une valeur parmi : {values}", "json": "un objet ou un tableau JSON" } }, "server": { "instructions": "Ce serveur expose trois outils d'exemple en lecture seule.\n1. Appelez whoami d'abord. Il renvoie qui appelle sur cette connexion (null sur le niveau public et en stdio), le rôle de l'appelant et grantedTools : les outils que ce rôle peut appeler. Ne demandez jamais son identité à l'utilisateur et n'en passez jamais une en argument.\n2. ping prouve la connexion de bout en bout ; passez message pour qu'il soit renvoyé tel quel.\n3. list_items liste les éléments de l'organisation de l'appelant et les affiche dans un widget interactif quand le client prend en charge MCP Apps. Il est paginé : passez le nextCursor renvoyé pour lire la page suivante ; exhausted: true signale la dernière page. N'inventez jamais de curseur.\n4. Un résultat avec isError et structuredContent.error.code est un refus, pas un résultat vide : rate_limited (attendez retryAfterMs, puis réessayez une fois), invalid_cursor (repartez de la première page), lake_unreadable, TOOL_ROLE_DENIED ou TOOL_ROLE_UNRESOLVED (l'appelant ne peut pas utiliser cet outil). Signalez-le à l'utilisateur ; ne le lisez jamais comme « rien n'existe ».\n5. Envoyez l'en-tête X-MCP-Locale (en ou fr) pour recevoir les messages dans cette langue.", "identityMissing": "l'identité du serveur n'a pas de {item} : définissez {variable}, ou le champ « {field} » de package.json", "identityInvalid": "{item} de l'identité du serveur n'est pas une URL https (http sur un hôte de bouclage seulement) : {source}" }, "landing": { "pageTitle": "{title} — serveur MCP", "intro": "Cette adresse est un serveur Model Context Protocol (MCP). Ajoutez-la à votre client IA comme serveur MCP distant :", "claudeAi": "Claude.ai : ajoutez un connecteur personnalisé avec cette URL, puis Connecter.", "chatgpt": "ChatGPT : ajoutez un serveur MCP personnalisé (mode développeur) avec cette URL.", "claudeCode": "Claude Code :", "other": "Tout autre client MCP : un serveur Streamable HTTP à l'URL ci-dessus.", "authOauth": "Votre client ouvre ensuite une page de connexion (OAuth 2.1). Aucun jeton à copier.", "authPublic": "Ce serveur fonctionne sans connexion : branchez-vous directement.", "website": "Site :" }, "widget": { "description": "Affiche sous forme de liste les éléments d'exemple renvoyés par list_items, avec leur nom et leur statut ; en lecture seule, il ne fait aucune requête lui-même." } } },
6
+ };
7
+ export const EMBEDDED_PACKAGE_VERSION = "0.8.0";
8
+ export const EMBEDDED_PACKAGE_IDENTITY = { "displayName": "MCP Boilerplate", "description": "Clonable MCP server skeleton on the official @modelcontextprotocol/server SDK v2 (protocol 2026-07-28 + 2025 legacy, stdio + stateless Streamable HTTP), with its own OAuth 2.1 authorization server (Clerk person + organisation, cloud-identity), MCP Apps UI, tenant scoping, FR/EN i18n and a declarative eval harness.", "description_fr": "Squelette de serveur MCP clonable sur le SDK officiel @modelcontextprotocol/server v2 (protocole 2026-07-28 + héritage 2025, stdio + Streamable HTTP sans état), avec son propre serveur d'autorisation OAuth 2.1 (personne + organisation Clerk, cloud-identity), UI MCP Apps, scoping tenant, i18n FR/EN et un harnais d'évaluations déclaratif.", "homepage": "https://github.com/elpiarthera/vantage-mcp-boilerplate#readme" };
@@ -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();
@@ -12,11 +15,13 @@ const { allowedOrigins } = loadHttpSettings();
12
15
  // settings from the environment per request — a missing value answers 503, see
13
16
  // src/oauth/settings.ts.
14
17
  const externalIssuer = Boolean(process.env.MCP_OAUTH_ISSUER && process.env.MCP_OAUTH_AUDIENCE);
18
+ // S-50: the server card, the setup page and the icon name the public origin when it is set.
19
+ const publicBaseUrl = process.env.PUBLIC_BASE_URL || undefined;
15
20
  const app = buildHttpApp(authMode === "oauth"
16
21
  ? externalIssuer
17
- ? { authMode, auth: loadAuthConfig(), uiDomain, allowedOrigins }
18
- : { authMode, authorizationServer: {}, uiDomain, allowedOrigins }
19
- : { authMode, uiDomain, allowedOrigins });
22
+ ? { authMode, auth: loadAuthConfig(), uiDomain, allowedOrigins, publicBaseUrl }
23
+ : { authMode, authorizationServer: {}, uiDomain, allowedOrigins, publicBaseUrl }
24
+ : { authMode, uiDomain, allowedOrigins, publicBaseUrl });
20
25
  const port = Number(process.env.PORT ?? 8787);
21
26
  // Hono's fetch handler adapted onto Node's http server, with the request-body cap
22
27
  // (MCP_MAX_BODY_BYTES, default 1 MiB) enforced while the body streams in: src/nodeAdapter.ts.
package/dist/httpApp.d.ts CHANGED
@@ -28,15 +28,25 @@ 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`. */
39
43
  authorizationServer?: AuthorizationServerDeps;
44
+ /**
45
+ * The public base URL (`PUBLIC_BASE_URL`, src/http.ts): the server card, the setup page and the
46
+ * serverInfo icon name `<base>/mcp` and `<base>/icon.svg`. Absent (development), they name the
47
+ * origin the request was addressed to, and those answers are sent `no-store`.
48
+ */
49
+ publicBaseUrl?: string;
40
50
  /** Dedicated sandbox origin for the MCP Apps widget (`_meta.ui.domain`). */
41
51
  uiDomain?: string;
42
52
  /**
@@ -47,6 +57,11 @@ export interface AppDeps {
47
57
  * (`identity_not_configured`).
48
58
  */
49
59
  identity?: IdentityDeps;
60
+ /**
61
+ * Agent tool roles (src/toolRoles.ts). Default: read at start by `loadToolRoles` — on the OAuth tier `MCP_ROLES_FILE`
62
+ * is required; elsewhere roles are not applicable. A test or a deployment with another store injects its own.
63
+ */
64
+ toolRoles?: ToolRolesConfig;
50
65
  /**
51
66
  * The data-lake reader `list_items` reads through (src/lake/reader.ts), read-only by design.
52
67
  * Injected by the deployment like `identity`; absent, the in-memory seed is read.
package/dist/httpApp.js CHANGED
@@ -26,36 +26,133 @@ import { createMcpHandler } from "@modelcontextprotocol/server";
26
26
  import { unauthorizedChallenge } from "@vantageos/cloud-identity";
27
27
  import { Hono } from "hono";
28
28
  import { authenticateRequest, protectedResourceMetadataDocument } from "./auth.js";
29
- import { getBuildIdentity } from "./buildIdentity.js";
30
- import { detectLocale, t } from "./i18n.js";
31
- import { createBoilerplateMcpServer } from "./mcpServerFactory.js";
29
+ import { getBuildCommit, getBuildIdentity } from "./buildIdentity.js";
30
+ import { registerHostVerification } from "./hostVerification.js";
31
+ import { detectLocale, t, tBoth } from "./i18n.js";
32
+ import { isBrowserLandingRequest, landingPageResponse } from "./landingPage.js";
33
+ import { createBoilerplateMcpServer, registeredToolSummaries } from "./mcpServerFactory.js";
32
34
  import { createAuthorizationServer, } from "./oauth/authorizationServer.js";
33
35
  import { MCP_SCOPE } from "./oauth/settings.js";
36
+ import { SERVER_CARD_PATHS, buildServerCard, serverCardResponse } from "./serverCard.js";
37
+ import { SERVER_ICON_MIME_TYPE, SERVER_ICON_PATH, SERVER_ICON_SVG } from "./serverIcon.js";
38
+ import { getServerIdentity } from "./serverIdentity.js";
34
39
  import { loadServerManifest } from "./serverManifest.js";
35
40
  import { resolveTenantFromJwtPayload } from "./tenancy.js";
41
+ import { callerScopeOf } from "./toolRoleGate.js";
42
+ import { ToolRolesConfigError, loadToolRoles } from "./toolRoles.js";
43
+ import { servedMcpUrl } from "./ui/sandboxDomain.js";
36
44
  import { assertIdentityDeps, resolveVerifiedPerson, } from "./verifiedPerson.js";
37
45
  import { SERVER_VERSION } from "./version.js";
46
+ function servedLocation(publicBaseUrl, requestUrl) {
47
+ const configured = publicBaseUrl ? servedMcpUrl({ PUBLIC_BASE_URL: publicBaseUrl }) : undefined;
48
+ const mcpUrl = configured ?? new URL("/mcp", requestUrl).href;
49
+ return { mcpUrl, baseUrl: new URL("./", mcpUrl).href, configured: configured !== undefined };
50
+ }
38
51
  const PRM_PATHS = [
39
52
  "/.well-known/oauth-protected-resource",
40
53
  "/.well-known/oauth-protected-resource/mcp",
41
54
  ];
55
+ /**
56
+ * The per-request role context: the caller's member scope (from the person the identity layer resolved) and the role
57
+ * the provider returns for it. No resolved person -> no lookup and a null scope, so every gated tool is denied
58
+ * (caller-not-resolved). A provider that throws is a failed read -> TOOL_ROLE_UNRESOLVED, never "all tools".
59
+ */
60
+ async function toolRolesFor(config, caller) {
61
+ if (!config.applicable)
62
+ return { applicable: false };
63
+ const person = caller?.ok ? caller.person : undefined;
64
+ const scope = callerScopeOf(person);
65
+ let lookup = { status: "absent", cause: "unassigned" };
66
+ if (person && scope?.orgClerkId) {
67
+ try {
68
+ lookup = await config.provider({ subject: person.subject, orgClerkId: scope.orgClerkId });
69
+ }
70
+ catch {
71
+ lookup = { status: "unresolved" };
72
+ }
73
+ }
74
+ return { applicable: true, lookup, caller: scope, resolution: caller, roleName: config.roleName };
75
+ }
42
76
  export function buildHttpApp(deps = {}) {
43
77
  const app = new Hono();
44
78
  const authMode = deps.authMode ?? "public";
45
79
  // Refuses at start, naming the field, a manifest without a readable context ceiling (G6).
46
- loadServerManifest();
80
+ const manifest = loadServerManifest();
47
81
  if (deps.identity)
48
82
  assertIdentityDeps(deps.identity);
49
- // Unauthenticated (S-7): the version AND the identity of the tree being served (C-25), so a
50
- // deployment is proven from what the server returns, not from what the deploy printed.
51
- app.get("/health", (c) => c.json({ status: "ok", version: SERVER_VERSION, buildId: getBuildIdentity() }));
83
+ // Agent tool roles (Pi's rulings on task k17103j933e6en0aqyws2v1twx8hc0s3): on the OAuth tier every tool is decided
84
+ // by the caller's role, which needs BOTH a role store and the identity stores that resolve the caller to an
85
+ // organisation. The start refuses, naming each piece that is missing; an absent store never widens access.
86
+ const toolRoles = authMode === "oauth"
87
+ ? (() => {
88
+ const missing = [];
89
+ let config = deps.toolRoles;
90
+ if (!config) {
91
+ try {
92
+ config = loadToolRoles("oauth");
93
+ }
94
+ catch (e) {
95
+ if (!(e instanceof ToolRolesConfigError))
96
+ throw e;
97
+ missing.push(e.message);
98
+ }
99
+ }
100
+ if (!deps.identity)
101
+ missing.push(tBoth("toolRoles.identityMissing"));
102
+ if (missing.length > 0)
103
+ throw new ToolRolesConfigError(missing.join("; "));
104
+ return config;
105
+ })()
106
+ : { applicable: false };
107
+ // The public identity (S-49), read once at start: a value neither the environment nor
108
+ // package.json carries stops the server here, naming it, instead of serving a placeholder.
109
+ const identity = getServerIdentity();
110
+ // Unauthenticated (S-7): the version AND the identity of the tree being served (C-25): buildId is
111
+ // the git commit the tree was built from, buildHash the hash of the files this process runs
112
+ // from, so a deployment is proven from what the server returns, not from what the deploy printed.
113
+ app.get("/health", (c) => c.json({
114
+ status: "ok",
115
+ version: SERVER_VERSION,
116
+ buildId: getBuildCommit(),
117
+ buildHash: getBuildIdentity(),
118
+ }));
119
+ // Discoverability (S-49 icon, S-50), public and registered BEFORE the auth gate below: a route
120
+ // that answers ends the chain, so none of them is ever gated. They read no credential and grant
121
+ // nothing. The setup page at /mcp sits after the Origin check (S-22 holds on every /mcp method).
122
+ // - the serverInfo icon, as SVG, under a CSP that forbids script and external references;
123
+ app.get(`/${SERVER_ICON_PATH}`, (c) => c.body(SERVER_ICON_SVG, 200, {
124
+ "content-type": `${SERVER_ICON_MIME_TYPE}; charset=utf-8`,
125
+ "cache-control": "public, max-age=86400",
126
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'",
127
+ "x-content-type-options": "nosniff",
128
+ }));
129
+ // - the MCP Server Card at every path a scanner probes (src/serverCard.ts), its tool summary
130
+ // read from the tools a server built by the factory registers (computed once, on first read);
131
+ let cardTools;
132
+ for (const path of SERVER_CARD_PATHS) {
133
+ app.get(path, (c) => {
134
+ cardTools ??= registeredToolSummaries(createBoilerplateMcpServer({ locale: "en" }));
135
+ const where = servedLocation(deps.publicBaseUrl, c.req.url);
136
+ const card = buildServerCard({
137
+ identity,
138
+ mcpUrl: where.mcpUrl,
139
+ baseUrl: where.baseUrl,
140
+ capabilities: manifest.capabilities,
141
+ tools: [...cardTools],
142
+ });
143
+ return serverCardResponse(c.req.raw, card, where.configured);
144
+ });
145
+ }
146
+ // S-45: the verification path of every declared target host, served from configuration.
147
+ registerHostVerification(app, manifest.distribution.targets);
52
148
  // S-22 Origin validation, on /mcp ONLY and registered BEFORE the auth gate: a request that carries
53
149
  // an Origin header (a browser) must name an allowed origin (exact full-origin match, case-
54
150
  // insensitive), else 403 before any token is looked at. No Origin header (server-to-server, Claude
55
151
  // Code, a connector backend) passes as before. `null`, an empty value and near-misses such as
56
152
  // `https://claude.ai.evil.example` are not in the list, so they are refused.
57
- // Declared divergence: /health and the OAuth/discovery routes (/.well-known/*, /authorize, /token,
58
- // /register) are NOT covered — they are browser navigations or CORS calls of the sign-in flow
153
+ // Declared divergence: /health, the icon, the server card paths (/mcp/server-card included, served
154
+ // above) and the OAuth/discovery routes (/.well-known/*, /authorize, /token, /register) are NOT
155
+ // covered — they are public metadata, browser navigations or CORS calls of the sign-in flow
59
156
  // (/register has its own MCP_REGISTER_CORS_ORIGINS allowlist) and S-22 guards the MCP endpoint.
60
157
  const allowedOrigins = new Set((deps.allowedOrigins ?? []).map((o) => o.toLowerCase()));
61
158
  app.use("/mcp", async (c, next) => {
@@ -68,6 +165,15 @@ export function buildHttpApp(deps = {}) {
68
165
  message_fr: t("fr", "auth.originNotAllowed"),
69
166
  }, 403);
70
167
  });
168
+ // S-50 setup page at the MCP URL, after the Origin check and BEFORE the auth gate: a request that
169
+ // cannot be an MCP request (a browser GET: no Authorization, no text/event-stream) gets the
170
+ // public page (src/landingPage.ts); every other request continues to the gate unchanged.
171
+ app.get("/mcp", async (c, next) => {
172
+ if (!isBrowserLandingRequest(c.req.raw))
173
+ return next();
174
+ const where = servedLocation(deps.publicBaseUrl, c.req.url);
175
+ return landingPageResponse(identity, where.mcpUrl, authMode, where.configured);
176
+ });
71
177
  if (authMode === "oauth") {
72
178
  // Resolves the resource-server configuration per request: the own-AS tier reads its
73
179
  // environment lazily, so a missing value is a 503 on the request, not a crash at boot.
@@ -154,14 +260,18 @@ export function buildHttpApp(deps = {}) {
154
260
  ? c.get("caller")
155
261
  : await resolveVerifiedPerson(undefined, deps.identity);
156
262
  const locale = detectLocale(c.req.header("X-MCP-Locale"));
263
+ const roles = await toolRolesFor(toolRoles, caller);
157
264
  const handler = createMcpHandler(() => createBoilerplateMcpServer({
158
265
  tenant,
159
266
  requireTenant: authMode === "oauth",
160
267
  caller,
161
268
  locale,
162
269
  uiDomain: deps.uiDomain,
270
+ baseUrl: servedLocation(deps.publicBaseUrl, c.req.url).baseUrl,
163
271
  lake: deps.lake,
164
272
  cursorSigner: deps.cursorSigner,
273
+ rateLimiter: deps.rateLimiter,
274
+ toolRoles: roles,
165
275
  }));
166
276
  return handler.fetch(c.req.raw);
167
277
  });
package/dist/i18n.js CHANGED
@@ -18,23 +18,13 @@
18
18
  * (§Bilingual, l.234): "fr" selects the French resource, anything else
19
19
  * (including a missing header) defaults to English.
20
20
  */
21
- import { readFileSync } from "node:fs";
22
- import { fileURLToPath } from "node:url";
23
- // Loaded via fs + JSON.parse, not a JSON module import: Node's ESM loader
24
- // (the runtime the compiled dist/stdio.js and dist/http.js run under)
25
- // requires an explicit import attribute for a JSON module import, which the
26
- // project's current `tsconfig.json` `module` target does not emit. This
27
- // project's `resolveJsonModule` + a plain JSON import already work for
28
- // bundler-processed code (e.g. src/ui/Widget.tsx, inlined by Vite at build
29
- // time and never hitting Node's loader at all) but not for tsc's own Node
30
- // ESM output, so the two resources here are read as plain files instead.
31
- function loadLocaleResource(fileName) {
32
- const path = fileURLToPath(new URL(`./locales/${fileName}`, import.meta.url));
33
- return JSON.parse(readFileSync(path, "utf8"));
34
- }
21
+ import { EMBEDDED_LOCALES } from "./embedded.generated.js";
22
+ // The two resources are module values (src/embedded.generated.ts, generated from src/locales/{en,fr}.json by
23
+ // scripts/embed-static.mjs at build), never a file read at import: a bundled consumer (the Convex bundler,
24
+ // esbuild) carries this module and no `locales/` folder beside it (task k17aj33ks3ndyxgt7jch68rpwx8hz9x5).
35
25
  const RESOURCES = {
36
- en: loadLocaleResource("en.json"),
37
- fr: loadLocaleResource("fr.json"),
26
+ en: EMBEDDED_LOCALES.en,
27
+ fr: EMBEDDED_LOCALES.fr,
38
28
  };
39
29
  /**
40
30
  * Resolves the caller's locale from the `X-MCP-Locale` request header.