@vantageos/mcp-boilerplate 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +36 -0
  3. package/README.fr.md +105 -6
  4. package/README.md +101 -8
  5. package/dist/hostVerification.d.ts +15 -0
  6. package/dist/hostVerification.js +32 -0
  7. package/dist/http.js +4 -1
  8. package/dist/httpApp.d.ts +9 -0
  9. package/dist/httpApp.js +55 -2
  10. package/dist/listCursor.d.ts +10 -0
  11. package/dist/listCursor.js +37 -0
  12. package/dist/locales/en.json +15 -1
  13. package/dist/locales/fr.json +15 -1
  14. package/dist/mcpServerFactory.d.ts +11 -2
  15. package/dist/mcpServerFactory.js +97 -15
  16. package/dist/oauth/authorizationServer.js +77 -16
  17. package/dist/oauth/picker.d.ts +11 -0
  18. package/dist/oauth/picker.js +28 -0
  19. package/dist/oauth/settings.d.ts +7 -0
  20. package/dist/oauth/settings.js +7 -0
  21. package/dist/oauth/tokenIssuer.d.ts +0 -1
  22. package/dist/oauth/tokenIssuer.js +1 -18
  23. package/dist/pagination.d.ts +2 -1
  24. package/dist/pagination.js +2 -1
  25. package/dist/rateLimit.d.ts +33 -0
  26. package/dist/rateLimit.js +48 -0
  27. package/dist/serverManifest.d.ts +9 -1
  28. package/dist/serverManifest.js +10 -1
  29. package/dist/signedCursor.js +6 -1
  30. package/dist/stdio.js +2 -2
  31. package/dist/tenancy.d.ts +4 -14
  32. package/dist/tenancy.js +7 -18
  33. package/dist/toolRecord.d.ts +35 -2
  34. package/dist/toolRecord.js +44 -2
  35. package/dist/toolRoleGate.d.ts +41 -0
  36. package/dist/toolRoleGate.js +94 -0
  37. package/dist/toolRoles.d.ts +57 -0
  38. package/dist/toolRoles.js +113 -0
  39. package/dist/ui/sandboxDomain.d.ts +13 -0
  40. package/dist/ui/sandboxDomain.js +23 -0
  41. package/dist/upstream/paginatedClient.js +1 -0
  42. package/dist/verifiedPerson.d.ts +6 -0
  43. package/dist/verifiedPerson.js +9 -0
  44. package/docs/releasing.md +19 -0
  45. package/mcp-server.json +3 -2
  46. package/package.json +10 -3
  47. package/plugin.json +1 -1
  48. package/scripts/plugin-manifest.source.json +19 -0
  49. package/scripts/readme-fr.mjs +3 -1
  50. package/dist/oauth/clientAssertion.d.ts +0 -28
  51. package/dist/oauth/clientAssertion.js +0 -106
@@ -0,0 +1,37 @@
1
+ import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/server";
2
+ /**
3
+ * C-11 (a) (mcp-standard): an invalid cursor sent to a protocol list method is JSON-RPC -32602 Invalid params, never a
4
+ * silent first page. The SDK serves `tools/list`, `resources/list`, `resources/templates/list` and `prompts/list` as
5
+ * ONE page and issues no `nextCursor`, so every cursor such a request carries is one this server never issued: invalid.
6
+ * The SDK itself ignores the cursor and answers page 1, so each of these handlers is wrapped in place (the SDK keeps them
7
+ * in a map) to refuse first. A list that cannot be wrapped refuses the start: it must not answer a cursor silently.
8
+ */
9
+ export const SINGLE_PAGE_LISTS = [
10
+ "tools/list",
11
+ "resources/list",
12
+ "resources/templates/list",
13
+ // The seed registers no prompt, so it serves no prompts/list; a fork that registers one gets it guarded.
14
+ "prompts/list",
15
+ ];
16
+ export function rejectCursorOnSinglePageLists(server) {
17
+ const handlers = server.server
18
+ ._requestHandlers;
19
+ if (!handlers)
20
+ throw new Error("C-11 (a): the SDK's request handlers were not found; list cursors cannot be checked");
21
+ for (const method of SINGLE_PAGE_LISTS) {
22
+ const inner = handlers.get(method);
23
+ if (typeof inner !== "function")
24
+ continue; // a list this server does not serve (no resources) has no handler
25
+ handlers.set(method, async (req, ctx) => {
26
+ const cursor = req?.params?.cursor;
27
+ if (cursor !== undefined)
28
+ throw new ProtocolError(ProtocolErrorCode.InvalidParams, `invalid cursor: ${method} is served as one page and issues no cursor`);
29
+ const result = (await inner(req, ctx));
30
+ // The refusal above rests on the SDK serving one page. If it ever returns a nextCursor, cursors become valid
31
+ // and refusing them all would be wrong: fail loud instead of answering a page with a cursor we would refuse.
32
+ if (result?.nextCursor !== undefined)
33
+ throw new Error(`C-11 (a): ${method} now returns a nextCursor; src/listCursor.ts must accept its cursors`);
34
+ return result;
35
+ });
36
+ }
37
+ }
@@ -12,12 +12,18 @@
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
  },
18
21
  "oauth": {
19
22
  "pickerTitle": "Connect {client}",
20
23
  "pickerIntro": "{client} asks to act on your behalf. Choose the organisation it may reach, then approve or deny.",
24
+ "handoffTitle": "Returning to the application",
25
+ "handoffLink": "Continue to the application",
26
+ "pickerNoAllowedOrganisation": "None of your organisations is authorised on this server.",
21
27
  "pickerDestination": "After approval you are sent to:",
22
28
  "pickerScope": "Requested access:",
23
29
  "pickerUnverified": "Unverified client: it registered itself automatically and nobody has vouched for it. Approve only if you recognise the address above.",
@@ -39,9 +45,17 @@
39
45
  "object": "an object",
40
46
  "boolean": "true or false",
41
47
  "positiveInteger": "a positive whole number",
42
- "atLeastPerTool": "at least the perTool ceiling"
48
+ "atLeastPerTool": "at least the perTool ceiling",
49
+ "stringArray": "an array of non-empty strings"
43
50
  }
44
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
+ },
45
59
  "toolRecord": {
46
60
  "unsound": "tool records or the context ceiling are unsound: {problems}"
47
61
  },
@@ -12,12 +12,18 @@
12
12
  "noVerifiedSession": "permission refusée : cet outil exige la personne vérifiée, et cet appel ne porte aucune session vérifiée",
13
13
  "refused": "permission refusée : la personne vérifiée derrière cet appel n'a pas pu être résolue"
14
14
  },
15
+ "rateLimit": {
16
+ "exceeded": "trop d’appels d’outils : réessayez dans {seconds} s"
17
+ },
15
18
  "validation": {
16
19
  "messageTooLong": "le message dépasse la longueur maximale de 200 caractères"
17
20
  },
18
21
  "oauth": {
19
22
  "pickerTitle": "Connecter {client}",
20
23
  "pickerIntro": "{client} demande à agir en votre nom. Choisissez l'organisation à laquelle il peut accéder, puis approuvez ou refusez.",
24
+ "handoffTitle": "Retour à l'application",
25
+ "handoffLink": "Continuer vers l'application",
26
+ "pickerNoAllowedOrganisation": "Aucune de vos organisations n'est autorisée sur ce serveur.",
21
27
  "pickerDestination": "Redirection après validation vers :",
22
28
  "pickerScope": "Accès demandé :",
23
29
  "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.",
@@ -39,9 +45,17 @@
39
45
  "object": "un objet",
40
46
  "boolean": "true ou false",
41
47
  "positiveInteger": "un entier strictement positif",
42
- "atLeastPerTool": "au moins le plafond perTool"
48
+ "atLeastPerTool": "au moins le plafond perTool",
49
+ "stringArray": "un tableau de chaînes non vides"
43
50
  }
44
51
  },
52
+ "toolRoles": {
53
+ "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",
54
+ "denied": "permission refusée : votre rôle n'accorde pas l'outil {tool} ({reason})",
55
+ "unresolved": "permission refusée : votre rôle n'a pas pu être lu, aucun outil n'est accordé",
56
+ "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",
57
+ "configInvalid": "{variable} ({path}) n'est pas un fichier de rôles valide : {problem}"
58
+ },
45
59
  "toolRecord": {
46
60
  "unsound": "fiches d'outils ou plafond de contexte invalides : {problems}"
47
61
  },
@@ -32,8 +32,10 @@ import { McpServer } from "@modelcontextprotocol/server";
32
32
  import type { ExampleItem } from "./exampleItems.js";
33
33
  import { type McpLocale } from "./i18n.js";
34
34
  import { type LakeReader } from "./lake/reader.js";
35
+ import { type RateLimiter } from "./rateLimit.js";
35
36
  import type { CursorSigner } from "./signedCursor.js";
36
37
  import { type ResolvedTenant } from "./tenancy.js";
38
+ import { type ToolRolesContext } from "./toolRoleGate.js";
37
39
  import type { CallerResolution } from "./verifiedPerson.js";
38
40
  /** Oldest 2025-era protocol revision served; the legacy `initialize` handshake never offers older. */
39
41
  export declare const SUPPORTED_LEGACY_PROTOCOL_VERSIONS: string[];
@@ -64,9 +66,11 @@ export interface McpServerDeps {
64
66
  * httpApp.ts). Defaults to "en"; the stdio transport (no HTTP headers) uses the default.
65
67
  */
66
68
  locale?: McpLocale;
69
+ /** The tool invocation limiter (S-24); default the process-wide `toolRateLimiter`. A test injects its own. */
70
+ rateLimiter?: RateLimiter;
67
71
  /**
68
- * Dedicated sandbox origin for the MCP Apps widget (`_meta.ui.domain`). Host-dependent; set
69
- * only when the deployment has one (MCP_UI_DOMAIN). Omitted from the resource when unset.
72
+ * Dedicated sandbox origin for the MCP Apps widget (`_meta.ui.domain`), derived from the served MCP URL by
73
+ * `claudeSandboxDomain` (src/ui/sandboxDomain.ts, A-11). Omitted from the resource when there is no served URL.
70
74
  */
71
75
  uiDomain?: string;
72
76
  /**
@@ -75,6 +79,11 @@ export interface McpServerDeps {
75
79
  * before. A derived server injects its own (e.g. `@vantageos/mcp-boilerplate/lake/convex`).
76
80
  */
77
81
  lake?: LakeReader<ExampleItem>;
82
+ /**
83
+ * Agent tool roles for this request (src/toolRoleGate.ts): on the OAuth tier, the caller's role lookup and member
84
+ * scope, resolved by httpApp before the server is built; elsewhere `{ applicable: false }`. Default: not applicable.
85
+ */
86
+ toolRoles?: ToolRolesContext;
78
87
  /**
79
88
  * Signs and verifies the `list_items` cursor (src/signedCursor.ts). Default: the process-wide
80
89
  * signer of src/pagination.ts, whose key is random per process. A multi-instance deployment
@@ -34,16 +34,21 @@ import { z } from "zod";
34
34
  import { getBuildIdentity } from "./buildIdentity.js";
35
35
  import { t } from "./i18n.js";
36
36
  import { EXAMPLE_ITEMS_READER, LAKE_UNREADABLE_CODE, LakeReadError, } from "./lake/reader.js";
37
+ import { rejectCursorOnSinglePageLists } from "./listCursor.js";
37
38
  import { INVALID_CURSOR_CODE, InvalidCursorError, PAGINATION, readPage } from "./pagination.js";
39
+ import { RATE_LIMITED_CODE, toolRateLimiter } from "./rateLimit.js";
38
40
  import { trackToolRecords } from "./servedToolRecords.js";
39
41
  import { loadServerManifest } from "./serverManifest.js";
40
42
  import { scopeRowsForTenant } from "./tenancy.js";
41
- import { LIST_ITEMS_ANNOTATIONS, PING_ANNOTATIONS } from "./toolRecord.js";
43
+ import { LIST_ITEMS_ANNOTATIONS, PING_ANNOTATIONS, WHOAMI_ANNOTATIONS } from "./toolRecord.js";
44
+ import { grantedToolNames, installToolRoleGate } from "./toolRoleGate.js";
42
45
  import { ITEMS_TOOL_META, ITEMS_UI_RESOURCE_URI, buildItemsUiResourceMeta, buildListItemsResult, buildUiResource, } from "./ui/uiResource.js";
43
46
  import { SERVER_NAME, SERVER_VERSION } from "./version.js";
44
47
  /** Oldest 2025-era protocol revision served; the legacy `initialize` handshake never offers older. */
45
48
  export const SUPPORTED_LEGACY_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18"];
46
- const itemSchema = z.object({
49
+ // S-44: output objects stay open (z.looseObject): a field added in a later release must not fail a client that
50
+ // validated against the schema it read at connect time.
51
+ const itemSchema = z.looseObject({
47
52
  id: z.string(),
48
53
  name: z.string(),
49
54
  orgId: z.string(),
@@ -54,7 +59,21 @@ const localeSchema = z.enum(["en", "fr"]);
54
59
  const pingInput = z.object({
55
60
  message: z.string().optional().describe("Optional message echoed back verbatim"),
56
61
  });
57
- const pingOutput = z.object({ reply: z.string() });
62
+ const pingOutput = z.looseObject({ reply: z.string() });
63
+ /** The identity tool takes no argument (C-33): the identity never arrives as an argument. */
64
+ const whoamiInput = z.object({});
65
+ const whoamiOutput = z.looseObject({
66
+ /** The verified subject behind the credentials; null where there is no caller (public HTTP, stdio). */
67
+ id: z.string().nullable(),
68
+ /** The caller's role, or null: no role, or roles not applicable. */
69
+ role: z.looseObject({ id: z.string(), name: z.string() }).nullable(),
70
+ /** The tools the caller's role grants, by the same decision as tools/list. */
71
+ grantedTools: z.array(z.string()),
72
+ /** False on the public HTTP tier and stdio: there is no caller, so no role is decided. */
73
+ rolesApplicable: z.boolean(),
74
+ });
75
+ /** The identity tool, outside the role gate (rule 6). */
76
+ const IDENTITY_TOOL = "whoami";
58
77
  // Pagination (C-11): the bounds are declared in src/toolRecord.ts and applied in
59
78
  // src/pagination.ts; the schema only describes them to the caller.
60
79
  const pageInput = {
@@ -66,7 +85,8 @@ const pageInput = {
66
85
  .number()
67
86
  .int()
68
87
  .min(1)
69
- .optional()
88
+ // Served in the inputSchema (C-11): the default is declared where a caller reads it, not only applied.
89
+ .default(PAGINATION.default)
70
90
  .describe(`Page size; default ${PAGINATION.default}, capped at ${PAGINATION.max}`),
71
91
  };
72
92
  const pageOutput = {
@@ -79,7 +99,7 @@ const listItemsInput = z.object({
79
99
  .describe("Widget and fallback language; defaults to the caller's X-MCP-Locale"),
80
100
  ...pageInput,
81
101
  });
82
- const listItemsOutput = z.object({
102
+ const listItemsOutput = z.looseObject({
83
103
  items: z.array(itemSchema),
84
104
  locale: localeSchema,
85
105
  ...pageOutput,
@@ -121,26 +141,51 @@ export function createBoilerplateMcpServer(deps = {}) {
121
141
  },
122
142
  });
123
143
  const served = trackToolRecords(server);
144
+ // Agent tool roles: every tool registered below is gated (call refused) and the list filtered, both by the same
145
+ // decision; the identity tool is the one exemption.
146
+ const roles = deps.toolRoles ?? { applicable: false };
147
+ const gate = installToolRoleGate(server, roles, locale, new Set([IDENTITY_TOOL]));
148
+ // S-24: every tool invocation takes a token from the caller's bucket (keyed by the resolved organisation, or
149
+ // "public" on the unscoped tier); an empty bucket is a tool error with a stable code and the wait, never a silent drop.
150
+ const limiter = deps.rateLimiter ?? toolRateLimiter;
151
+ const rateKey = deps.tenant?.tenantId ?? "public";
152
+ const withRateLimit = (handler) => async (args) => {
153
+ const d = limiter.consume(rateKey);
154
+ if (d.ok)
155
+ return handler(args);
156
+ const message = t(locale, "rateLimit.exceeded").replace("{seconds}", () => String(Math.ceil(d.retryAfterMs / 1000)));
157
+ return {
158
+ content: [{ type: "text", text: message }],
159
+ structuredContent: {
160
+ error: { code: RATE_LIMITED_CODE, retryAfterMs: d.retryAfterMs, message },
161
+ },
162
+ isError: true,
163
+ };
164
+ };
124
165
  server.registerTool("ping", {
125
166
  title: "Ping",
126
- description: "Echoes the given message (or 'pong' if none given). Proves the MCP handshake end to end.",
167
+ // C-19: served in both languages in the one description field ("<en> / <fr>"), with its trigger phrase.
168
+ description: "Echoes the given message (or 'pong' if none given) and proves the MCP handshake end to end. " +
169
+ "Use this whenever you need to check that this server answers, even if they don't say 'ping'. / " +
170
+ "Renvoie le message donné (ou 'pong' sans message) et prouve la poignée de main MCP de bout en bout. " +
171
+ "À utiliser pour vérifier que ce serveur répond, même sans le mot 'ping'.",
127
172
  inputSchema: pingInput,
128
173
  outputSchema: pingOutput,
129
174
  annotations: PING_ANNOTATIONS,
130
- }, async (args) => {
175
+ }, withRateLimit(async (args) => {
131
176
  // Application-level input validation beyond the Zod shape: an overly long echo message
132
177
  // is refused as an MCP tool-call error, localized per the caller's X-MCP-Locale.
133
178
  if (args.message !== undefined && args.message.length > 200) {
134
179
  return refusal(t(locale, "validation.messageTooLong"));
135
180
  }
136
181
  return structuredResult({ reply: args.message ?? "pong" });
137
- });
182
+ }));
138
183
  const lake = deps.lake ?? EXAMPLE_ITEMS_READER;
139
184
  // An invalid or expired cursor is a TOOL INPUT error (spec 2025-11-25, SEP-1303: input
140
185
  // validation errors are Tool Execution Errors, not Protocol Errors): a result with
141
186
  // `isError: true` whose structuredContent carries a STABLE machine code beside a message
142
187
  // localized per X-MCP-Locale. It is never a silent first page. (-32602 stays with the protocol
143
- // list methods' cursor, which the SDK serves.)
188
+ // list methods' cursor: src/listCursor.ts refuses it there, since the SDK ignores it.)
144
189
  const invalidCursor = () => {
145
190
  const message = t(locale, "pagination.invalidCursor");
146
191
  return {
@@ -166,10 +211,15 @@ export function createBoilerplateMcpServer(deps = {}) {
166
211
  // MCP Apps (mcp-app-standard A-1). The widget renders THIS result.
167
212
  server.registerTool("list_items", {
168
213
  title: "List items",
169
- description: "Lists example items, one page at a time, and shows them as an interactive widget in " +
170
- "hosts that support MCP Apps. Use this whenever the user asks to list, see or render " +
171
- "the items. On the OAuth tier, results are scoped to the caller's organisation " +
172
- "(@vantageos/cloud-identity); on the public tier (no auth gate), results are unscoped.",
214
+ // C-19: served in both languages in the one description field ("<en> / <fr>"), with its trigger phrase.
215
+ description: "Lists example items, one page at a time, and shows them as an interactive widget in hosts that " +
216
+ "support MCP Apps. Use this whenever the user asks to list, see or render the items, even if they " +
217
+ "don't say 'list'. On the OAuth tier, results are scoped to the caller's organisation; on the public " +
218
+ "tier (no auth gate), they are unscoped. / " +
219
+ "Liste les éléments d'exemple, une page à la fois, et les affiche en widget interactif dans les hôtes " +
220
+ "MCP Apps. À utiliser quand l'utilisateur demande de lister, voir ou afficher les éléments, même sans " +
221
+ "le mot 'lister'. Sur le niveau OAuth, les résultats sont limités à l'organisation de l'appelant ; sur " +
222
+ "le niveau public (sans authentification), ils ne le sont pas.",
173
223
  inputSchema: listItemsInput,
174
224
  outputSchema: listItemsOutput,
175
225
  annotations: LIST_ITEMS_ANNOTATIONS,
@@ -178,7 +228,7 @@ export function createBoilerplateMcpServer(deps = {}) {
178
228
  ...ITEMS_TOOL_META,
179
229
  description_fr: "Liste les éléments d'exemple, une page à la fois, et les affiche en widget interactif dans les hôtes MCP Apps. Utilise cet outil quand l'utilisateur demande de lister, voir ou afficher les éléments.",
180
230
  },
181
- }, async (args) => {
231
+ }, withRateLimit(async (args) => {
182
232
  if (deps.requireTenant && !deps.tenant) {
183
233
  // Refuse explicitly — never fall through to an unscoped/master view just because the
184
234
  // caller's token had no organization.
@@ -202,7 +252,7 @@ export function createBoilerplateMcpServer(deps = {}) {
202
252
  return lakeUnreadable(error.code);
203
253
  throw error;
204
254
  }
205
- });
255
+ }));
206
256
  // MCP Apps UI layer (io.modelcontextprotocol/ui, spec 2026-01-26). `list_items` is registered
207
257
  // with `server.registerTool` and the GA nested `_meta.ui` form, NOT with ext-apps'
208
258
  // `registerAppTool`: that helper back-fills the deprecated flat `_meta["ui/resourceUri"]`
@@ -218,6 +268,38 @@ export function createBoilerplateMcpServer(deps = {}) {
218
268
  }, async () => ({
219
269
  contents: [buildUiResource({ domain: deps.uiDomain })],
220
270
  }));
271
+ server.registerTool(IDENTITY_TOOL, {
272
+ title: "Who am I",
273
+ // C-19: both languages in the one served field ("<en> / <fr>") with its trigger phrase; no separate
274
+ // `_meta.description_fr`, which would only repeat the French half.
275
+ description: "Returns who you are to this server: your verified subject, the role your tools are decided by, " +
276
+ "and the tools it grants. Use this whenever you need to know what you may call, even if they don't " +
277
+ "say 'whoami'. / Renvoie qui vous êtes pour ce serveur : sujet vérifié, rôle qui décide de vos " +
278
+ "outils, outils accordés. À utiliser pour savoir ce que vous pouvez appeler, même sans le mot 'whoami'.",
279
+ inputSchema: whoamiInput,
280
+ outputSchema: whoamiOutput,
281
+ annotations: WHOAMI_ANNOTATIONS,
282
+ }, withRateLimit(async () => {
283
+ const person = deps.caller?.ok ? deps.caller.person : undefined;
284
+ const lookup = roles.applicable ? roles.lookup : undefined;
285
+ const role = lookup?.status === "resolved" && roles.applicable
286
+ ? {
287
+ id: lookup.role.roleId,
288
+ name: roles.roleName(lookup.role.roleId) ?? lookup.role.roleId,
289
+ }
290
+ : null;
291
+ return structuredResult({
292
+ id: person?.subject ?? null,
293
+ role,
294
+ grantedTools: grantedToolNames(roles, gate.declarations),
295
+ rolesApplicable: roles.applicable,
296
+ });
297
+ }));
298
+ // tools/list narrowed to the granted tools (and the identity tool), once every tool is registered.
299
+ gate.filterList();
300
+ // C-11 (a): a cursor sent to a single-page protocol list method is -32602 (src/listCursor.ts). Installed after the
301
+ // role filter, so it wraps it: the cursor is refused before any list is built (tests/toolRoles.test.ts).
302
+ rejectCursorOnSinglePageLists(server);
221
303
  // Start refusal: every registered tool's record, as registered, must be sound.
222
304
  served.assertServed(manifest.contextCeiling);
223
305
  return server;
@@ -20,22 +20,26 @@
20
20
  * FAIL-CLOSED: any missing configuration value answers 503 `temporarily_unavailable`.
21
21
  * No refresh tokens are issued; access tokens live one hour.
22
22
  */
23
- import { buildDiscoveryDocument, buildUserInfo, exchangeAuthorizationCode, oauthErrorFor, resolveClerkSessionFromRequest, resumeAuthorize, startAuthorize, } from "@vantageos/cloud-identity";
23
+ import { buildDiscoveryDocument, buildUserInfo, exchangeAuthorizationCode, oauthErrorFor, resolveClerkSessionFromRequest, resumeAuthorize, startAuthorize, verifyMcpAccessToken, } from "@vantageos/cloud-identity";
24
+ // The private_key_jwt client-assertion verifier lives in the identity layer (DLocalIdentity), as a subpath: it uses
25
+ // node:net / node:dns for its SSRF guard, which the root entry stays free of.
26
+ import { ASSERTION_SIGNING_ALGS, createClientAssertionVerifier, } from "@vantageos/cloud-identity/client-assertion";
24
27
  import { getCookie } from "hono/cookie";
25
28
  import { cors } from "hono/cors";
26
29
  import { flagKnob, integerKnob, originListKnob } from "../envKnobs.js";
27
30
  import { detectLocale, detectLocaleFromAcceptLanguage, t } from "../i18n.js";
28
31
  import { createClerkBackendCollaborators } from "./clerk.js";
29
- import { ASSERTION_SIGNING_ALGS, createClientAssertionVerifier } from "./clientAssertion.js";
30
32
  import { RegistryFullError, createInMemoryClientRegistry, isAllowedRedirectUri, isUrlShapedClientId, resolveCimdClient, } from "./clientRegistry.js";
31
33
  import { createInMemoryCodeStore } from "./codeStore.js";
32
- import { pickerContentSecurityPolicy, renderOrgPicker } from "./picker.js";
34
+ import { HANDOFF_CSP, pickerContentSecurityPolicy, renderHandoff, renderOrgPicker, } from "./picker.js";
33
35
  import { createRegisterLimiter } from "./registerLimiter.js";
34
36
  import { DEFAULT_SCOPE, OAUTH_ENV, SUPPORTED_SCOPES, isDevClerkInstanceOnRemoteServer, readAuthorizeSettings, } from "./settings.js";
35
37
  import { ACCESS_TOKEN_TTL_SECONDS, createTokenIssuer } from "./tokenIssuer.js";
36
38
  const REGISTER_LIMIT_PER_MINUTE = 20;
37
39
  /** Default of OAUTH_ENV.registerLimitPer48: five /64 budgets for a whole /48. */
38
40
  const REGISTER_LIMIT_PER_48_PER_MINUTE = 100;
41
+ /** Default of OAUTH_ENV.tokenLimitPerMinute: /token requests per client address per minute. */
42
+ const TOKEN_LIMIT_PER_MINUTE = 60;
39
43
  const MAX_REDIRECT_URIS = 10;
40
44
  const REGISTRY_FULL_RETRY_AFTER_SECONDS = 300;
41
45
  function noStore(c) {
@@ -89,6 +93,7 @@ export function createAuthorizationServer(deps = {}) {
89
93
  // Operator knobs are validated here, at construction: a bad value stops the boot.
90
94
  const trustedProxyCount = integerKnob(env, OAUTH_ENV.trustedProxyCount, 0, 10) ?? 0;
91
95
  const registerLimitPer48 = integerKnob(env, OAUTH_ENV.registerLimitPer48, 1) ?? REGISTER_LIMIT_PER_48_PER_MINUTE;
96
+ const tokenLimitPerMinute = integerKnob(env, OAUTH_ENV.tokenLimitPerMinute, 1) ?? TOKEN_LIMIT_PER_MINUTE;
92
97
  const registerOrigins = originListKnob(env, OAUTH_ENV.registerCorsOrigins);
93
98
  const allowInMemory = flagKnob(env, OAUTH_ENV.allowInMemoryStores) === true;
94
99
  const now = deps.now ?? (() => Date.now());
@@ -198,6 +203,16 @@ export function createAuthorizationServer(deps = {}) {
198
203
  const client = await lookupClient(model.client.clientId);
199
204
  return (client?.redirectUris ?? []).filter((u) => !/^https?:/i.test(u));
200
205
  };
206
+ /** The organisations this server authorises (AUTHORIZE_ALLOWED_ORG_IDS), or undefined when every membership is. */
207
+ const allowedOrgIds = () => {
208
+ const raw = env[OAUTH_ENV.allowedOrgIds]?.trim();
209
+ if (!raw)
210
+ return undefined;
211
+ return new Set(raw
212
+ .split(",")
213
+ .map((s) => s.trim())
214
+ .filter(Boolean));
215
+ };
201
216
  const respond = async (c, outcome, settings) => {
202
217
  noStore(c);
203
218
  c.header("Referrer-Policy", "no-referrer");
@@ -208,12 +223,33 @@ export function createAuthorizationServer(deps = {}) {
208
223
  // RFC 9207 section 2: identify the issuer in the authorization response.
209
224
  const back = new URL(outcome.url);
210
225
  back.searchParams.set("iss", settings.baseUrl);
226
+ // The consent form's POST ends here, in a same-origin page, so its form-action does not follow the client's
227
+ // own redirect hops (renderHandoff). A GET (the return from sign-in) still answers a plain redirect.
228
+ if (c.req.method === "POST") {
229
+ c.header("Content-Security-Policy", HANDOFF_CSP);
230
+ const locale = detectLocaleFromAcceptLanguage(c.req.header("accept-language"));
231
+ return c.html(renderHandoff(back.toString(), locale));
232
+ }
211
233
  return c.redirect(back.toString(), 302);
212
234
  }
213
235
  case "org-picker": {
236
+ // Only this server's authorised organisations are offered (data per server).
237
+ const allowed = allowedOrgIds();
238
+ let model = outcome.model;
239
+ if (allowed) {
240
+ const organizations = model.organizations.filter((o) => allowed.has(o.id));
241
+ if (organizations.length === 0) {
242
+ const locale = detectLocaleFromAcceptLanguage(c.req.header("accept-language"));
243
+ return c.json({
244
+ error: "access_denied",
245
+ error_description: t(locale, "oauth.pickerNoAllowedOrganisation"),
246
+ }, 403);
247
+ }
248
+ model = { ...model, organizations };
249
+ }
214
250
  let csp;
215
251
  try {
216
- csp = await pickerContentSecurityPolicy(await redirectTargetsOf(outcome.model));
252
+ csp = await pickerContentSecurityPolicy(await redirectTargetsOf(model));
217
253
  }
218
254
  catch (err) {
219
255
  // Fail closed: without a parseable destination the page is not rendered at all.
@@ -222,9 +258,9 @@ export function createAuthorizationServer(deps = {}) {
222
258
  }
223
259
  c.header("Content-Security-Policy", csp);
224
260
  const locale = detectLocaleFromAcceptLanguage(c.req.header("accept-language"));
225
- return c.html(renderOrgPicker(outcome.model, locale, {
261
+ return c.html(renderOrgPicker(model, locale, {
226
262
  // Only a URL-shaped (CIMD) client_id is bound to a domain its owner controls.
227
- unverified: !isUrlShapedClientId(outcome.model.client.clientId),
263
+ unverified: !isUrlShapedClientId(model.client.clientId),
228
264
  brandName: env[OAUTH_ENV.brandName]?.trim() || undefined,
229
265
  }));
230
266
  }
@@ -239,6 +275,14 @@ export function createAuthorizationServer(deps = {}) {
239
275
  prefix48LimitPerMinute: registerLimitPer48,
240
276
  now,
241
277
  });
278
+ // /token is unauthenticated until the client is verified, and each private_key_jwt assertion it verifies occupies a
279
+ // single-use jti slot until it expires: an unbounded caller could fill the jti store. The same address-keyed
280
+ // limiter as /register (an IPv6 client on its /64, and its /48 at five budgets) bounds every caller.
281
+ const tokenLimiter = createRegisterLimiter({
282
+ limitPerMinute: tokenLimitPerMinute,
283
+ prefix48LimitPerMinute: tokenLimitPerMinute * 5,
284
+ now,
285
+ });
242
286
  return {
243
287
  async resourceServer() {
244
288
  const result = await settingsNow();
@@ -463,6 +507,15 @@ export function createAuthorizationServer(deps = {}) {
463
507
  }, cfg, authDeps);
464
508
  return respond(c, denied, result.settings);
465
509
  }
510
+ // An approval for an organisation this server does not authorise never reaches the code issuer.
511
+ const allowed = allowedOrgIds();
512
+ if (allowed && !allowed.has(field("orgId") ?? "")) {
513
+ const locale = detectLocaleFromAcceptLanguage(c.req.header("accept-language"));
514
+ return c.json({
515
+ error: "access_denied",
516
+ error_description: t(locale, "oauth.pickerNoAllowedOrganisation"),
517
+ }, 403);
518
+ }
466
519
  const outcome = await resumeAuthorize({
467
520
  state: field("state") ?? "",
468
521
  sessionToken: sessionTokenFrom(c),
@@ -473,6 +526,10 @@ export function createAuthorizationServer(deps = {}) {
473
526
  return respond(c, outcome, result.settings);
474
527
  });
475
528
  app.post("/token", async (c) => {
529
+ if (!tokenLimiter.allow(clientKeyOf(c, trustedProxyCount))) {
530
+ c.header("Retry-After", "60");
531
+ return c.json({ error: "too_many_requests" }, 429);
532
+ }
476
533
  const result = await settingsNow();
477
534
  if (!result.ok)
478
535
  return unavailable(c, result.missing);
@@ -550,22 +607,26 @@ export function createAuthorizationServer(deps = {}) {
550
607
  const result = await settingsNow();
551
608
  if (!result.ok)
552
609
  return unavailable(c, result.missing);
553
- const header = c.req.header("authorization") ?? "";
554
- const token = header.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() : "";
555
610
  const invalid = () => {
556
611
  c.header("WWW-Authenticate", 'Bearer error="invalid_token"');
557
612
  return c.json({ error: "invalid_token" }, 401);
558
613
  };
559
- if (!token)
560
- return invalid();
614
+ // Our own access token is verified by the identity layer, never here (DLocalIdentity):
615
+ // issuer = this server, audience = the protected resource, key set = our published JWKS.
561
616
  const issuer = await issuerFor(result.settings);
562
- let claims;
563
- try {
564
- claims = await issuer.verify(token, result.settings.baseUrl, result.settings.resource);
565
- }
566
- catch {
617
+ const verified = await verifyMcpAccessToken(c.req.header("authorization"), {
618
+ realm: "mcp",
619
+ resourceMetadataUrl: `${result.settings.baseUrl}/.well-known/oauth-protected-resource`,
620
+ issuer: result.settings.baseUrl,
621
+ audience: result.settings.resource,
622
+ jwks: issuer.jwks,
623
+ }, now());
624
+ if (!verified.ok)
567
625
  return invalid();
568
- }
626
+ const claims = {
627
+ sub: verified.session.userId,
628
+ scope: String(verified.session.claims.scope ?? ""),
629
+ };
569
630
  let user;
570
631
  try {
571
632
  user = await clerkFor().getUser(claims.sub);
@@ -19,3 +19,14 @@ export interface ConsentContext {
19
19
  brandName?: string;
20
20
  }
21
21
  export declare function renderOrgPicker(model: OrgPickerModel, locale: McpLocale, consent: ConsentContext): string;
22
+ /** The CSP of the hand-off page: nothing runs, nothing is submitted, nothing frames it. */
23
+ export declare const HANDOFF_CSP = "default-src 'none'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'";
24
+ /**
25
+ * The page that ends the consent form submission inside this origin and sends the browser on to the client.
26
+ *
27
+ * A browser applies the consent page's `form-action` to every redirect hop of the form submission, so answering the
28
+ * POST with a 302 to the client's redirect_uri lets the client's OWN next hop (vscode.dev/redirect -> vscode:// or a
29
+ * loopback) be blocked by our policy. This page is a 200 response: the navigation to the client is a meta refresh (and
30
+ * a visible link), not a form submission, so `form-action` no longer follows it.
31
+ */
32
+ export declare function renderHandoff(target: string, locale: McpLocale): string;
@@ -92,6 +92,7 @@ function formActionSources(redirectUris) {
92
92
  */
93
93
  export async function pickerContentSecurityPolicy(redirectUris) {
94
94
  const formAction = ["'self'", ...formActionSources(redirectUris)].join(" ");
95
+ // allow-local-identity: CSP hash of the picker's own constant stylesheet; no credential, caller or tenant
95
96
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(PICKER_STYLE));
96
97
  const hash = btoa(String.fromCharCode(...new Uint8Array(digest)));
97
98
  return [
@@ -148,3 +149,30 @@ ${orgs}
148
149
  </html>
149
150
  `;
150
151
  }
152
+ /** The CSP of the hand-off page: nothing runs, nothing is submitted, nothing frames it. */
153
+ export const HANDOFF_CSP = "default-src 'none'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'";
154
+ /**
155
+ * The page that ends the consent form submission inside this origin and sends the browser on to the client.
156
+ *
157
+ * A browser applies the consent page's `form-action` to every redirect hop of the form submission, so answering the
158
+ * POST with a 302 to the client's redirect_uri lets the client's OWN next hop (vscode.dev/redirect -> vscode:// or a
159
+ * loopback) be blocked by our policy. This page is a 200 response: the navigation to the client is a meta refresh (and
160
+ * a visible link), not a form submission, so `form-action` no longer follows it.
161
+ */
162
+ export function renderHandoff(target, locale) {
163
+ const url = escapeHtml(target);
164
+ const title = escapeHtml(t(locale, "oauth.handoffTitle"));
165
+ return `<!doctype html>
166
+ <html lang="${locale}">
167
+ <head>
168
+ <meta charset="utf-8">
169
+ <meta http-equiv="refresh" content="0;url=${url}">
170
+ <title>${title}</title>
171
+ </head>
172
+ <body>
173
+ <p>${title}</p>
174
+ <p><a href="${url}">${escapeHtml(t(locale, "oauth.handoffLink"))}</a></p>
175
+ </body>
176
+ </html>
177
+ `;
178
+ }
@@ -18,12 +18,19 @@ export declare const OAUTH_ENV: {
18
18
  readonly trustedProxyCount: "MCP_TRUSTED_PROXY_COUNT";
19
19
  /** /register budget shared by every /64 inside one IPv6 /48, per minute (default 100). */
20
20
  readonly registerLimitPer48: "MCP_REGISTER_LIMIT_PER_48_PER_MINUTE";
21
+ /** /token requests per client address per minute (default 60); its /48 gets five times that. */
22
+ readonly tokenLimitPerMinute: "MCP_TOKEN_LIMIT_PER_MINUTE";
21
23
  /** Comma-separated browser origins allowed to call /register cross-origin (default: none). */
22
24
  readonly registerCorsOrigins: "MCP_REGISTER_CORS_ORIGINS";
23
25
  /** Exactly "1" lets NODE_ENV=production run on the in-memory stores (single instance only). */
24
26
  readonly allowInMemoryStores: "MCP_ALLOW_IN_MEMORY_STORES";
25
27
  /** Optional display name shown above the consent page title (default: none). */
26
28
  readonly brandName: "AUTHORIZE_BRAND_NAME";
29
+ /**
30
+ * Optional comma-separated Clerk organisation ids this server authorises. When set, the consent offers only the
31
+ * person's memberships in this list and refuses an approval for any other. Data per server, never code.
32
+ */
33
+ readonly allowedOrgIds: "AUTHORIZE_ALLOWED_ORG_IDS";
27
34
  };
28
35
  /** Scope granted when a client names none: MCP access plus the OIDC subject. */
29
36
  export declare const MCP_SCOPE = "mcp:full";
@@ -18,12 +18,19 @@ export const OAUTH_ENV = {
18
18
  trustedProxyCount: "MCP_TRUSTED_PROXY_COUNT",
19
19
  /** /register budget shared by every /64 inside one IPv6 /48, per minute (default 100). */
20
20
  registerLimitPer48: "MCP_REGISTER_LIMIT_PER_48_PER_MINUTE",
21
+ /** /token requests per client address per minute (default 60); its /48 gets five times that. */
22
+ tokenLimitPerMinute: "MCP_TOKEN_LIMIT_PER_MINUTE",
21
23
  /** Comma-separated browser origins allowed to call /register cross-origin (default: none). */
22
24
  registerCorsOrigins: "MCP_REGISTER_CORS_ORIGINS",
23
25
  /** Exactly "1" lets NODE_ENV=production run on the in-memory stores (single instance only). */
24
26
  allowInMemoryStores: "MCP_ALLOW_IN_MEMORY_STORES",
25
27
  /** Optional display name shown above the consent page title (default: none). */
26
28
  brandName: "AUTHORIZE_BRAND_NAME",
29
+ /**
30
+ * Optional comma-separated Clerk organisation ids this server authorises. When set, the consent offers only the
31
+ * person's memberships in this list and refuses an approval for any other. Data per server, never code.
32
+ */
33
+ allowedOrgIds: "AUTHORIZE_ALLOWED_ORG_IDS",
27
34
  };
28
35
  /** Scope granted when a client names none: MCP access plus the OIDC subject. */
29
36
  export const MCP_SCOPE = "mcp:full";
@@ -15,7 +15,6 @@ export interface TokenIssuer {
15
15
  keys: JWK[];
16
16
  };
17
17
  sign(claims: AuthorizedTokenClaims, issuer: string, nowMs: number): Promise<string>;
18
- verify(token: string, issuer: string, audience: string): Promise<AuthorizedTokenClaims>;
19
18
  }
20
19
  export declare function createTokenIssuer(privateJwk: JWK & {
21
20
  kid: string;