@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
package/dist/stdio.js CHANGED
@@ -18,12 +18,12 @@ export function startStdioServer() {
18
18
  // Refuses at start, naming the field, a manifest without a readable context ceiling (G6).
19
19
  loadServerManifest();
20
20
  const requireTenant = process.env.MCP_BOILERPLATE_REQUIRE_TENANT === "1";
21
- const uiDomain = process.env.MCP_UI_DOMAIN || undefined;
22
21
  // No verified person on stdio, by design: there is no bearer and no verified session on this
23
22
  // transport, so `caller` is deliberately left unset and a tool wrapped with
24
23
  // `withVerifiedPerson` is refused as `no_verified_session` (src/verifiedPerson.ts). The local
25
24
  // process is trusted as the caller, never impersonated as a person.
26
- serveStdio(() => createBoilerplateMcpServer({ requireTenant, uiDomain }));
25
+ // No served URL on stdio, so no Claude sandbox domain to derive (A-11): the widget declares none.
26
+ serveStdio(() => createBoilerplateMcpServer({ requireTenant }));
27
27
  process.stderr.write("[mcp-boilerplate] stdio server started — 2 tools available\n");
28
28
  }
29
29
  try {
package/dist/tenancy.d.ts CHANGED
@@ -5,20 +5,10 @@
5
5
  * rows. This module never re-verifies the token and never reimplements
6
6
  * cloud-identity's authorization logic — it only adapts shapes.
7
7
  *
8
- * API-shape note (first real consumer of `requireTenantId`, new in
9
- * cloud-identity 0.3.0 with zero prior callers): the package's "bearer"
10
- * entry path expects a full `TenantContext` ({ workspaceId, userId, roles })
11
- * — the shape cloud-identity's OWN workspace-membership layer produces
12
- * (`decodeUnverifiedBearer`), not a generic OAuth JWT. This boilerplate's
13
- * verified claims (a plain claims record, Clerk-as-AS) carries no
14
- * `workspaceId`/`roles` claims, so it is NOT a `TenantContext` and cannot
15
- * go through the "bearer" path without inventing fields cloud-identity
16
- * never asked for. It goes through the "session" path instead
17
- * (`identity: { orgId }`), which matches the JWT's `org_id` claim exactly
18
- * as Clerk mints it. Both entry paths converge on the same
19
- * refuse-on-absence contract (`requireTenantId` throws when there is no
20
- * organization, on either path) — this is a compatible substitution
21
- * documented at the one call site it affects, never a bypass of the guard.
8
+ * The organisation is read by cloud-identity's one claim reader (`requireTenantFromIdentity`: the same
9
+ * claim names and the same Clerk `org_` shape as `resolveTenantFromAccessToken`), never by this module.
10
+ * No claim, or a claim that is not an organisation id, refuses (throws); there is no fallback to a slug, a
11
+ * name or a default tenant.
22
12
  */
23
13
  export interface ResolvedTenant {
24
14
  tenantId: string;
package/dist/tenancy.js CHANGED
@@ -5,22 +5,12 @@
5
5
  * rows. This module never re-verifies the token and never reimplements
6
6
  * cloud-identity's authorization logic — it only adapts shapes.
7
7
  *
8
- * API-shape note (first real consumer of `requireTenantId`, new in
9
- * cloud-identity 0.3.0 with zero prior callers): the package's "bearer"
10
- * entry path expects a full `TenantContext` ({ workspaceId, userId, roles })
11
- * — the shape cloud-identity's OWN workspace-membership layer produces
12
- * (`decodeUnverifiedBearer`), not a generic OAuth JWT. This boilerplate's
13
- * verified claims (a plain claims record, Clerk-as-AS) carries no
14
- * `workspaceId`/`roles` claims, so it is NOT a `TenantContext` and cannot
15
- * go through the "bearer" path without inventing fields cloud-identity
16
- * never asked for. It goes through the "session" path instead
17
- * (`identity: { orgId }`), which matches the JWT's `org_id` claim exactly
18
- * as Clerk mints it. Both entry paths converge on the same
19
- * refuse-on-absence contract (`requireTenantId` throws when there is no
20
- * organization, on either path) — this is a compatible substitution
21
- * documented at the one call site it affects, never a bypass of the guard.
8
+ * The organisation is read by cloud-identity's one claim reader (`requireTenantFromIdentity`: the same
9
+ * claim names and the same Clerk `org_` shape as `resolveTenantFromAccessToken`), never by this module.
10
+ * No claim, or a claim that is not an organisation id, refuses (throws); there is no fallback to a slug, a
11
+ * name or a default tenant.
22
12
  */
23
- import { isRowInTenant, requireTenantId } from "@vantageos/cloud-identity";
13
+ import { isRowInTenant, requireTenantFromIdentity } from "@vantageos/cloud-identity";
24
14
  /**
25
15
  * Resolve the caller's tenant from the verified session claims. Refuses
26
16
  * (throws) when the token carries no organization — never falls through to
@@ -28,9 +18,8 @@ import { isRowInTenant, requireTenantId } from "@vantageos/cloud-identity";
28
18
  * (an explicit permission-denied response), never silently grant access.
29
19
  */
30
20
  export function resolveTenantFromJwtPayload(payload) {
31
- const orgId = typeof payload.org_id === "string" ? payload.org_id : undefined;
32
- const tenantId = requireTenantId({ kind: "session", identity: { orgId } });
33
- return { tenantId };
21
+ // The organisation claim is read by the identity layer's one claim reader, never here (DLocalIdentity).
22
+ return { tenantId: requireTenantFromIdentity(payload, { door: "mcp" }) };
34
23
  }
35
24
  /**
36
25
  * Filter rows by the caller's tenant with cloud-identity's `isRowInTenant`: a row is visible
@@ -10,6 +10,8 @@ import type { ContextCeiling } from "./serverManifest.js";
10
10
  * searchMechanism C-10 how a list/search is served; `unindexed-scan` does not publish
11
11
  * paginationCursor/Default/Max C-11 declared here, applied by src/pagination.ts
12
12
  * tokensEstimeContexte C-13 the estimated context cost of the served tool
13
+ * domain, mode Agent tool roles rule 2: the business domain the tool belongs to, and `read` | `write`;
14
+ * what `decideToolRole` (@vantageos/cloud-identity) reads to allow or deny it per role
13
15
  *
14
16
  * The sets are closed UNIONS, so a value outside them is a compile error, and the verb decides
15
17
  * which other slots are required (a list/search tool cannot omit its mechanism or its pagination).
@@ -34,6 +36,11 @@ export type ToolVerb = "list" | "get" | "search" | "aggregate" | "create" | "upd
34
36
  * must declare it (`checkToolRecord`).
35
37
  */
36
38
  export type SearchMechanism = "indexed-range" | "indexed-bm25" | `indexed-vector(${string})` | "indexed-hybrid-rrf" | `upstream-delegated(${string})`;
39
+ /**
40
+ * Agent tool roles rule 2 (mcp-server-standard "Agent tool roles"): exactly `read` or `write`. `read` must agree with
41
+ * `readOnlyHint: true`, `write` with `readOnlyHint: false` (C-15). Same union as cloud-identity's `ToolMode`.
42
+ */
43
+ export type ToolMode = "read" | "write";
37
44
  /** The four behavioural annotations (C-15), explicit booleans on every tool. */
38
45
  interface BehaviourHints {
39
46
  readOnlyHint: boolean;
@@ -44,6 +51,10 @@ interface BehaviourHints {
44
51
  interface CommonSlots extends BehaviourHints {
45
52
  authRead: AuthTier;
46
53
  authWrite: AuthTier;
54
+ /** The business domain a role grants (rule 1); a non-empty name. */
55
+ domain: string;
56
+ /** read | write (rule 2); agrees with readOnlyHint. */
57
+ mode: ToolMode;
47
58
  /** Estimated context tokens of the served tool (C-13); tests/contextCeiling.test.ts checks it. */
48
59
  tokensEstimeContexte: number;
49
60
  }
@@ -75,8 +86,22 @@ export declare const PING_ANNOTATIONS: {
75
86
  readonly openWorldHint: false;
76
87
  readonly authRead: "public";
77
88
  readonly authWrite: "master";
89
+ readonly domain: "system";
90
+ readonly mode: "read";
78
91
  readonly verb: "pure";
79
- readonly tokensEstimeContexte: 200;
92
+ readonly tokensEstimeContexte: 280;
93
+ };
94
+ export declare const WHOAMI_ANNOTATIONS: {
95
+ readonly readOnlyHint: true;
96
+ readonly destructiveHint: false;
97
+ readonly idempotentHint: true;
98
+ readonly openWorldHint: false;
99
+ readonly authRead: "public";
100
+ readonly authWrite: "master";
101
+ readonly domain: "identity";
102
+ readonly mode: "read";
103
+ readonly verb: "introspect";
104
+ readonly tokensEstimeContexte: 330;
80
105
  };
81
106
  export declare const LIST_ITEMS_ANNOTATIONS: {
82
107
  readonly readOnlyHint: true;
@@ -85,13 +110,15 @@ export declare const LIST_ITEMS_ANNOTATIONS: {
85
110
  readonly openWorldHint: false;
86
111
  readonly authRead: "org-member";
87
112
  readonly authWrite: "master";
113
+ readonly domain: "items";
114
+ readonly mode: "read";
88
115
  readonly verb: "list";
89
116
  readonly table: "items";
90
117
  readonly searchMechanism: "indexed-range";
91
118
  readonly paginationCursor: true;
92
119
  readonly paginationDefault: 20;
93
120
  readonly paginationMax: 100;
94
- readonly tokensEstimeContexte: 640;
121
+ readonly tokensEstimeContexte: 690;
95
122
  };
96
123
  /** One tool's declared context cost, as `checkContextCeiling` reads it. */
97
124
  export interface ContextEstimate {
@@ -111,6 +138,12 @@ export declare function checkContextCeiling(estimates: readonly ContextEstimate[
111
138
  * refused. A tool that is not a list/search is not asked for a mechanism.
112
139
  */
113
140
  export declare function checkToolRecord(name: string, record: Record<string, unknown>): string[];
141
+ /**
142
+ * Agent tool roles rule 2: every tool declares a non-empty `domain` and a `mode` of exactly `read` or `write`, and the
143
+ * mode agrees with `readOnlyHint` (read <-> true, write <-> false). A tool missing either, or with any other value, is
144
+ * served to no one: the start refuses, naming it.
145
+ */
146
+ export declare function checkToolRole(name: string, record: Record<string, unknown>): string[];
114
147
  /** Thrown at start when a tool record or the ceiling is unsound. `problems` name every tool. */
115
148
  export declare class ToolRecordError extends Error {
116
149
  readonly problems: readonly string[];
@@ -9,8 +9,24 @@ export const PING_ANNOTATIONS = {
9
9
  openWorldHint: false,
10
10
  authRead: "public",
11
11
  authWrite: "master",
12
+ domain: "system",
13
+ mode: "read",
12
14
  verb: "pure",
13
- tokensEstimeContexte: 200,
15
+ tokensEstimeContexte: 280,
16
+ };
17
+ // The identity tool (C-33; agent tool roles rule 6): the caller's own identity and granted tools. It reveals nothing
18
+ // beyond the caller's own standing, so it is `public` to read; it writes nothing.
19
+ export const WHOAMI_ANNOTATIONS = {
20
+ readOnlyHint: true,
21
+ destructiveHint: false,
22
+ idempotentHint: true,
23
+ openWorldHint: false,
24
+ authRead: "public",
25
+ authWrite: "master",
26
+ domain: "identity",
27
+ mode: "read",
28
+ verb: "introspect",
29
+ tokensEstimeContexte: 330,
14
30
  };
15
31
  export const LIST_ITEMS_ANNOTATIONS = {
16
32
  readOnlyHint: true,
@@ -19,13 +35,15 @@ export const LIST_ITEMS_ANNOTATIONS = {
19
35
  openWorldHint: false,
20
36
  authRead: "org-member",
21
37
  authWrite: "master",
38
+ domain: "items",
39
+ mode: "read",
22
40
  verb: "list",
23
41
  table: "items",
24
42
  searchMechanism: "indexed-range",
25
43
  paginationCursor: true,
26
44
  paginationDefault: 20,
27
45
  paginationMax: 100,
28
- tokensEstimeContexte: 640,
46
+ tokensEstimeContexte: 690,
29
47
  };
30
48
  /** Every problem found, each naming the tool; empty when the ceiling holds. Never skips a tool. */
31
49
  export function checkContextCeiling(estimates, ceiling) {
@@ -66,6 +84,30 @@ function isIndexedMechanism(value) {
66
84
  * refused. A tool that is not a list/search is not asked for a mechanism.
67
85
  */
68
86
  export function checkToolRecord(name, record) {
87
+ return [...checkToolRole(name, record), ...checkMechanism(name, record)];
88
+ }
89
+ /**
90
+ * Agent tool roles rule 2: every tool declares a non-empty `domain` and a `mode` of exactly `read` or `write`, and the
91
+ * mode agrees with `readOnlyHint` (read <-> true, write <-> false). A tool missing either, or with any other value, is
92
+ * served to no one: the start refuses, naming it.
93
+ */
94
+ export function checkToolRole(name, record) {
95
+ const problems = [];
96
+ const { domain, mode, readOnlyHint } = record;
97
+ if (typeof domain !== "string" || domain.trim() === "") {
98
+ problems.push(`${name}: declares no domain (agent tool roles rule 2)`);
99
+ }
100
+ if (mode !== "read" && mode !== "write") {
101
+ problems.push(mode === undefined
102
+ ? `${name}: declares no mode (agent tool roles rule 2: "read" or "write")`
103
+ : `${name}: mode ${JSON.stringify(mode)} is not "read" or "write"`);
104
+ }
105
+ else if ((mode === "read") !== (readOnlyHint === true)) {
106
+ problems.push(`${name}: mode "${mode}" disagrees with readOnlyHint ${String(readOnlyHint)} (read <-> true, write <-> false)`);
107
+ }
108
+ return problems;
109
+ }
110
+ function checkMechanism(name, record) {
69
111
  if (!LIST_VERBS.includes(record.verb))
70
112
  return [];
71
113
  const mechanism = record.searchMechanism;
@@ -0,0 +1,41 @@
1
+ import type { McpServer } from "@modelcontextprotocol/server";
2
+ import { type StandingScope, type ToolDeclaration, type ToolRoleLookup } from "@vantageos/cloud-identity";
3
+ import { type McpLocale } from "./i18n.js";
4
+ import { type CallerResolution, type VerifiedPerson } from "./verifiedPerson.js";
5
+ /**
6
+ * The two enforcement points of agent tool roles (mcp-server-standard "Agent tool roles", rule 4), both calling the
7
+ * same decision of @vantageos/cloud-identity:
8
+ * - tools/call is REFUSED: every registered handler is wrapped; a denied call returns a tool result with
9
+ * `isError: true` and `structuredContent.error.code` = TOOL_ROLE_DENIED (or TOOL_ROLE_UNRESOLVED when the role could
10
+ * not be read), and the handler does not run. Never a protocol error, never an empty success.
11
+ * - tools/list is FILTERED: the SDK's own tools/list handler runs, then the denied tools are removed. The SDK's
12
+ * `disable()` cannot be used: a disabled tool answers tools/call with a protocol error, which rule 4 forbids.
13
+ *
14
+ * The identity tool is outside the gate (rule 6: a caller with no role still gets `role: null` and an empty list,
15
+ * never an error); it is named in `exempt`, here and nowhere else.
16
+ */
17
+ /** The per-request role context the server is built with. */
18
+ export type ToolRolesContext = {
19
+ applicable: false;
20
+ } | {
21
+ applicable: true;
22
+ lookup: ToolRoleLookup;
23
+ /** The caller's member scope, or null when the person was not resolved (every tool is then denied). */
24
+ caller: StandingScope | null;
25
+ /** The identity layer's resolution of the caller: an unresolved one is refused with its own code (callerRefusal). */
26
+ resolution: CallerResolution | undefined;
27
+ /** The display name of a role, for the identity tool. */
28
+ roleName: (roleId: string) => string | undefined;
29
+ };
30
+ /** The caller's member scope from the person the identity layer resolved, or null. Never built from a claim here. */
31
+ export declare function callerScopeOf(person: VerifiedPerson | undefined): StandingScope | null;
32
+ /**
33
+ * Wraps `server.registerTool` so every handler registered AFTER this call is gated, and returns the declarations seen
34
+ * (for the list filter and the identity tool). With roles not applicable, nothing is wrapped and nothing is filtered.
35
+ */
36
+ export declare function installToolRoleGate(server: McpServer, roles: ToolRolesContext, locale: McpLocale, exempt: ReadonlySet<string>): {
37
+ declarations: ToolDeclaration[];
38
+ filterList: () => void;
39
+ };
40
+ /** The tools the caller is granted, by the same decision as the list (for the identity tool's `grantedTools`). */
41
+ export declare function grantedToolNames(roles: ToolRolesContext, declarations: readonly ToolDeclaration[]): string[];
@@ -0,0 +1,94 @@
1
+ import { decideToolRole, filterGrantedTools, memberScopeFrom, } from "@vantageos/cloud-identity";
2
+ import { t } from "./i18n.js";
3
+ import { callerRefusal } from "./verifiedPerson.js";
4
+ /** A scope `scopeStanding` reads as "refused": the decision denies with caller-not-resolved. */
5
+ const UNRESOLVED_CALLER = { isMaster: false, fleetWide: false, refused: true };
6
+ /** The caller's member scope from the person the identity layer resolved, or null. Never built from a claim here. */
7
+ export function callerScopeOf(person) {
8
+ if (!person)
9
+ return null;
10
+ // The person is deep-readonly; the package reads the org row without mutating it, so a shallow copy of its arrays
11
+ // satisfies the mutable parameter type.
12
+ const org = {
13
+ id: person.org.id,
14
+ label: person.org.label,
15
+ allowedAgentIds: person.org.allowedAgentIds ? [...person.org.allowedAgentIds] : undefined,
16
+ scopes: [...person.org.scopes],
17
+ };
18
+ const r = memberScopeFrom(org, {
19
+ userId: person.subject,
20
+ orgRole: person.role,
21
+ orgId: person.org.id,
22
+ });
23
+ return r.ok ? r.scope : null;
24
+ }
25
+ function declarationOf(name, annotations) {
26
+ const a = (annotations ?? {});
27
+ return { toolId: name, domain: a.domain, mode: a.mode };
28
+ }
29
+ /**
30
+ * Wraps `server.registerTool` so every handler registered AFTER this call is gated, and returns the declarations seen
31
+ * (for the list filter and the identity tool). With roles not applicable, nothing is wrapped and nothing is filtered.
32
+ */
33
+ export function installToolRoleGate(server, roles, locale, exempt) {
34
+ const declarations = [];
35
+ if (!roles.applicable)
36
+ return { declarations, filterList: () => { } };
37
+ const register = server.registerTool.bind(server);
38
+ server.registerTool = ((name, config, callback) => {
39
+ if (exempt.has(name))
40
+ return register(name, config, callback);
41
+ const declaration = declarationOf(name, config.annotations);
42
+ declarations.push(declaration);
43
+ const gated = async (...args) => {
44
+ const refused = callerRefusal(roles.resolution, locale);
45
+ if (refused)
46
+ return refused;
47
+ const decision = decideToolRole(roles.lookup, declaration, roles.caller ?? UNRESOLVED_CALLER, { door: name });
48
+ if (!decision.ok) {
49
+ const { code, reason } = decision.refusal;
50
+ const message = code === "TOOL_ROLE_UNRESOLVED"
51
+ ? t(locale, "toolRoles.unresolved")
52
+ : t(locale, "toolRoles.denied")
53
+ .replace("{tool}", () => name)
54
+ .replace("{reason}", () => reason);
55
+ return {
56
+ content: [{ type: "text", text: message }],
57
+ structuredContent: { error: { code, reason, message } },
58
+ isError: true,
59
+ };
60
+ }
61
+ return callback(...args);
62
+ };
63
+ return register(name, config, gated);
64
+ });
65
+ const filterList = () => {
66
+ // The SDK keeps its handlers in a map; the tools/list entry is wrapped in place so the SDK's own listing (schemas,
67
+ // eras, pagination) runs unchanged and only its result is narrowed. A missing entry refuses the start: a server
68
+ // that cannot filter its list must not serve it unfiltered.
69
+ const handlers = server.server
70
+ ._requestHandlers;
71
+ const inner = handlers?.get("tools/list");
72
+ if (!handlers || typeof inner !== "function")
73
+ throw new Error("agent tool roles: the SDK's tools/list handler was not found; the list cannot be filtered");
74
+ handlers.set("tools/list", async (req, ctx) => {
75
+ // Only `tools` is narrowed: every other field of the SDK's page, `nextCursor` included, is carried unchanged, so
76
+ // pagination is the SDK's (and src/listCursor.ts's) exactly as without the gate.
77
+ const result = (await inner(req, ctx));
78
+ // Decided at list time, over every declaration seen so far: a tool registered after the gate was installed is
79
+ // listed by the same decision that gates its calls.
80
+ const granted = new Set(filterGrantedTools(roles.lookup, declarations, roles.caller ?? UNRESOLVED_CALLER).map((d) => d.toolId));
81
+ return {
82
+ ...result,
83
+ tools: (result.tools ?? []).filter((tool) => exempt.has(tool.name) || granted.has(tool.name)),
84
+ };
85
+ });
86
+ };
87
+ return { declarations, filterList };
88
+ }
89
+ /** The tools the caller is granted, by the same decision as the list (for the identity tool's `grantedTools`). */
90
+ export function grantedToolNames(roles, declarations) {
91
+ if (!roles.applicable)
92
+ return [];
93
+ return filterGrantedTools(roles.lookup, declarations, roles.caller ?? UNRESOLVED_CALLER).map((d) => d.toolId);
94
+ }
@@ -0,0 +1,57 @@
1
+ import type { ToolRole, ToolRoleLookup } from "@vantageos/cloud-identity";
2
+ /**
3
+ * Agent tool roles (mcp-server-standard "Agent tool roles"): which tools exist for the calling agent.
4
+ *
5
+ * The decision is @vantageos/cloud-identity's (`decideToolRole` / `filterGrantedTools`); this module only supplies its
6
+ * input, the role the caller holds, through ONE seam: a `RoleProvider`. The boilerplate ships a data-backed provider
7
+ * (roles in a JSON file named by `MCP_ROLES_FILE`, no business value in code); the Convex agent-roles component plugs
8
+ * into the same seam.
9
+ *
10
+ * Where roles apply (Pi's ruling on task k17103j933e6en0aqyws2v1twx8hc0s3):
11
+ * - the OAuth tier: a provider is REQUIRED. No `MCP_ROLES_FILE`, an unreadable one or a malformed one stops the
12
+ * server at start, naming the variable. An absent store never widens access.
13
+ * - the public HTTP tier and stdio have no caller: roles are NOT APPLICABLE there, said at start and in the identity
14
+ * tool (`rolesApplicable: false`), never a silent pass.
15
+ */
16
+ /** The verified caller a provider reads its role from: what the identity layer resolved, never a claim read here. */
17
+ export interface RoleCaller {
18
+ /** The verified subject (the person or agent behind the credential). */
19
+ subject: string;
20
+ /** The organisation's permanent ID, as resolved by the identity layer. */
21
+ orgClerkId: string;
22
+ }
23
+ /** The role store seam: the role the caller holds, or why there is none. A thrown error is read as unresolved. */
24
+ export type RoleProvider = (caller: RoleCaller) => Promise<ToolRoleLookup>;
25
+ /** A role as the roles file holds it: cloud-identity's `ToolRole` plus a display name for the identity tool. */
26
+ export interface NamedToolRole extends ToolRole {
27
+ name: string;
28
+ }
29
+ /** The roles file: the roles, and the binding of each verified subject to exactly one role. */
30
+ export interface RolesFile {
31
+ roles: NamedToolRole[];
32
+ agents: Record<string, string>;
33
+ }
34
+ /** Where roles apply, decided once at start. */
35
+ export type ToolRolesConfig = {
36
+ applicable: true;
37
+ provider: RoleProvider;
38
+ roleName: (roleId: string) => string | undefined;
39
+ } | {
40
+ applicable: false;
41
+ };
42
+ /** Thrown at start when the OAuth tier has no usable roles file. */
43
+ export declare class ToolRolesConfigError extends Error {
44
+ constructor(message: string);
45
+ }
46
+ /** Every problem with a parsed roles file; empty when sound. A malformed file is refused whole, never half-used. */
47
+ export declare function checkRolesFile(value: unknown): string[];
48
+ /**
49
+ * The provider over a roles file already read and checked. A subject with no binding -> absent ("unassigned"); a
50
+ * binding to a role the file does not hold -> absent ("deleted"). Both deny, by the decision function.
51
+ */
52
+ export declare function fileRoleProvider(file: RolesFile): RoleProvider;
53
+ /**
54
+ * Where roles apply, read at start. OAuth: `MCP_ROLES_FILE` is required and must hold a sound roles file, else the
55
+ * start is refused naming the variable. Public HTTP and stdio: not applicable.
56
+ */
57
+ export declare function loadToolRoles(authMode: "oauth" | "public" | "stdio", env?: NodeJS.ProcessEnv): ToolRolesConfig;
@@ -0,0 +1,113 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { tBoth } from "./i18n.js";
3
+ const ROLES_FILE_VARIABLE = "MCP_ROLES_FILE";
4
+ /** Thrown at start when the OAuth tier has no usable roles file. */
5
+ export class ToolRolesConfigError extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = "ToolRolesConfigError";
9
+ }
10
+ }
11
+ const isStringList = (v) => Array.isArray(v) && v.every((x) => typeof x === "string" && x.length > 0);
12
+ /** Every problem with a parsed roles file; empty when sound. A malformed file is refused whole, never half-used. */
13
+ export function checkRolesFile(value) {
14
+ if (!value || typeof value !== "object" || Array.isArray(value))
15
+ return ["not a JSON object"];
16
+ const { roles, agents } = value;
17
+ const problems = [];
18
+ if (!Array.isArray(roles))
19
+ problems.push("`roles` is not a list");
20
+ if (!agents || typeof agents !== "object" || Array.isArray(agents))
21
+ problems.push("`agents` is not an object");
22
+ if (problems.length > 0)
23
+ return problems;
24
+ const ids = new Set();
25
+ roles.forEach((r, i) => {
26
+ const role = r;
27
+ const at = `roles[${i}]`;
28
+ if (!role || typeof role !== "object")
29
+ return void problems.push(`${at} is not an object`);
30
+ for (const key of ["roleId", "orgClerkId", "name"])
31
+ if (typeof role[key] !== "string" || role[key].trim() === "")
32
+ problems.push(`${at}.${key} is missing`);
33
+ if (typeof role.roleId === "string") {
34
+ if (ids.has(role.roleId))
35
+ problems.push(`${at}.roleId ${role.roleId} is declared twice`);
36
+ ids.add(role.roleId);
37
+ }
38
+ if (!Array.isArray(role.domains))
39
+ problems.push(`${at}.domains is not a list`);
40
+ else
41
+ role.domains.forEach((d, k) => {
42
+ if (typeof d?.domain !== "string" || d.domain.trim() === "")
43
+ problems.push(`${at}.domains[${k}].domain is missing`);
44
+ if (typeof d?.read !== "boolean" || typeof d?.write !== "boolean")
45
+ problems.push(`${at}.domains[${k}] needs boolean read and write`);
46
+ });
47
+ if (!isStringList(role.add ?? []))
48
+ problems.push(`${at}.add is not a list of tool names`);
49
+ if (!isStringList(role.remove ?? []))
50
+ problems.push(`${at}.remove is not a list of tool names`);
51
+ });
52
+ for (const [subject, roleId] of Object.entries(agents))
53
+ if (typeof roleId !== "string" || roleId === "")
54
+ problems.push(`agents["${subject}"] is not a role ID`);
55
+ return problems;
56
+ }
57
+ /**
58
+ * The provider over a roles file already read and checked. A subject with no binding -> absent ("unassigned"); a
59
+ * binding to a role the file does not hold -> absent ("deleted"). Both deny, by the decision function.
60
+ */
61
+ export function fileRoleProvider(file) {
62
+ const byId = new Map(file.roles.map((r) => [r.roleId, r]));
63
+ return async ({ subject }) => {
64
+ const roleId = Object.hasOwn(file.agents, subject) ? file.agents[subject] : undefined;
65
+ if (roleId === undefined)
66
+ return { status: "absent", cause: "unassigned" };
67
+ const role = byId.get(roleId);
68
+ if (!role)
69
+ return { status: "absent", cause: "deleted" };
70
+ return {
71
+ status: "resolved",
72
+ role: {
73
+ roleId: role.roleId,
74
+ orgClerkId: role.orgClerkId,
75
+ domains: role.domains,
76
+ add: role.add ?? [],
77
+ remove: role.remove ?? [],
78
+ },
79
+ };
80
+ };
81
+ }
82
+ /**
83
+ * Where roles apply, read at start. OAuth: `MCP_ROLES_FILE` is required and must hold a sound roles file, else the
84
+ * start is refused naming the variable. Public HTTP and stdio: not applicable.
85
+ */
86
+ export function loadToolRoles(authMode, env = process.env) {
87
+ if (authMode !== "oauth")
88
+ return { applicable: false };
89
+ const path = env[ROLES_FILE_VARIABLE]?.trim();
90
+ if (!path)
91
+ throw new ToolRolesConfigError(tBoth("toolRoles.configMissing", { variable: ROLES_FILE_VARIABLE }));
92
+ let parsed;
93
+ try {
94
+ parsed = JSON.parse(readFileSync(path, "utf8"));
95
+ }
96
+ catch (e) {
97
+ throw new ToolRolesConfigError(tBoth("toolRoles.configInvalid", {
98
+ variable: ROLES_FILE_VARIABLE,
99
+ path,
100
+ problem: e.message,
101
+ }));
102
+ }
103
+ const problems = checkRolesFile(parsed);
104
+ if (problems.length > 0)
105
+ throw new ToolRolesConfigError(tBoth("toolRoles.configInvalid", {
106
+ variable: ROLES_FILE_VARIABLE,
107
+ path,
108
+ problem: problems.join("; "),
109
+ }));
110
+ const file = parsed;
111
+ const names = new Map(file.roles.map((r) => [r.roleId, r.name]));
112
+ return { applicable: true, provider: fileRoleProvider(file), roleName: (id) => names.get(id) };
113
+ }
@@ -0,0 +1,13 @@
1
+ /** The suffix of every Claude MCP Apps sandbox origin. Half of the derivation, never a whole domain. */
2
+ export declare const CLAUDE_SANDBOX_SUFFIX = ".claudemcpcontent.com";
3
+ /**
4
+ * The sandbox domain Claude serves this server's widget from (mcp-app-standard A-11): the first 32 hexadecimal
5
+ * characters of SHA-256 of the served MCP URL, followed by `.claudemcpcontent.com`. Computed at start from the URL
6
+ * the server is reached at, never written as a literal: a typed domain is wrong as soon as the URL changes.
7
+ */
8
+ export declare function claudeSandboxDomain(servedMcpUrl: string): string;
9
+ /**
10
+ * The served MCP URL: `PUBLIC_BASE_URL` (the public origin, a path prefix allowed) joined to the `/mcp` endpoint.
11
+ * Unset, there is no served URL to derive from and the widget declares no domain (development).
12
+ */
13
+ export declare function servedMcpUrl(env: NodeJS.ProcessEnv): string | undefined;
@@ -0,0 +1,23 @@
1
+ import { createHash } from "node:crypto";
2
+ /** The suffix of every Claude MCP Apps sandbox origin. Half of the derivation, never a whole domain. */
3
+ export const CLAUDE_SANDBOX_SUFFIX = ".claudemcpcontent.com";
4
+ /**
5
+ * The sandbox domain Claude serves this server's widget from (mcp-app-standard A-11): the first 32 hexadecimal
6
+ * characters of SHA-256 of the served MCP URL, followed by `.claudemcpcontent.com`. Computed at start from the URL
7
+ * the server is reached at, never written as a literal: a typed domain is wrong as soon as the URL changes.
8
+ */
9
+ // allow-local-identity: hashes the PUBLIC served MCP URL to name a sandbox origin; no credential, no caller, no tenant
10
+ export function claudeSandboxDomain(servedMcpUrl) {
11
+ const label = createHash("sha256").update(servedMcpUrl).digest("hex").slice(0, 32);
12
+ return `${label}${CLAUDE_SANDBOX_SUFFIX}`;
13
+ }
14
+ /**
15
+ * The served MCP URL: `PUBLIC_BASE_URL` (the public origin, a path prefix allowed) joined to the `/mcp` endpoint.
16
+ * Unset, there is no served URL to derive from and the widget declares no domain (development).
17
+ */
18
+ export function servedMcpUrl(env) {
19
+ const base = env.PUBLIC_BASE_URL;
20
+ if (!base)
21
+ return undefined;
22
+ return new URL("mcp", base.endsWith("/") ? base : `${base}/`).href;
23
+ }
@@ -21,6 +21,12 @@ export declare function buildItemsUiResourceMeta(domain?: string): McpUiResource
21
21
  * The widget template. It carries no row: the widget renders the `list_items` RESULT it is given,
22
22
  * so the template is the same for every caller and every page.
23
23
  */
24
+ /**
25
+ * What the widget shows, for the host and the model, in both languages ("EN / FR"). Hosts read it
26
+ * under two keys: `_meta.ui.widgetDescription` and the ChatGPT hint `_meta["openai/widgetDescription"]`
27
+ * (Manufact's tool-resource-metadata-complete reads exactly these); the same text under both.
28
+ */
29
+ export declare const WIDGET_DESCRIPTION: string;
24
30
  export declare function buildUiResource(props: {
25
31
  domain?: string;
26
32
  }): {
@@ -28,7 +34,14 @@ export declare function buildUiResource(props: {
28
34
  mimeType: string;
29
35
  text: string;
30
36
  _meta: {
31
- ui: McpUiResourceMeta;
37
+ ui: {
38
+ widgetDescription: string;
39
+ csp?: import("@modelcontextprotocol/ext-apps").McpUiResourceCsp;
40
+ permissions?: import("@modelcontextprotocol/ext-apps").McpUiResourcePermissions;
41
+ domain?: string;
42
+ prefersBorder?: boolean;
43
+ };
44
+ "openai/widgetDescription": string;
32
45
  };
33
46
  };
34
47
  /**
@@ -13,6 +13,7 @@
13
13
  import { readFileSync } from "node:fs";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
16
+ import { tBoth } from "../i18n.js";
16
17
  export const ITEMS_UI_RESOURCE_URI = "ui://mcp-boilerplate/items-widget.html";
17
18
  // dist/ui-bundle/ — NEVER dist/ui/ (that path is tsc's compiled-server
18
19
  // output for this very module; see vite.config.ts for the collision this
@@ -69,12 +70,21 @@ export function buildItemsUiResourceMeta(domain) {
69
70
  * The widget template. It carries no row: the widget renders the `list_items` RESULT it is given,
70
71
  * so the template is the same for every caller and every page.
71
72
  */
73
+ /**
74
+ * What the widget shows, for the host and the model, in both languages ("EN / FR"). Hosts read it
75
+ * under two keys: `_meta.ui.widgetDescription` and the ChatGPT hint `_meta["openai/widgetDescription"]`
76
+ * (Manufact's tool-resource-metadata-complete reads exactly these); the same text under both.
77
+ */
78
+ export const WIDGET_DESCRIPTION = tBoth("widget.description");
72
79
  export function buildUiResource(props) {
73
80
  return {
74
81
  uri: ITEMS_UI_RESOURCE_URI,
75
82
  mimeType: RESOURCE_MIME_TYPE,
76
83
  text: readUiBundle(),
77
- _meta: { ui: buildItemsUiResourceMeta(props.domain) },
84
+ _meta: {
85
+ ui: { ...buildItemsUiResourceMeta(props.domain), widgetDescription: WIDGET_DESCRIPTION },
86
+ "openai/widgetDescription": WIDGET_DESCRIPTION,
87
+ },
78
88
  };
79
89
  }
80
90
  /**