@vantageos/mcp-boilerplate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/i18n.js ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Thin server-side locale layer for MCP error/validation messages.
3
+ *
4
+ * REUSE: @vantageos/mosaic-i18n@0.1.1 is a React/react-i18next package
5
+ * (`peerDependencies: i18next, react-i18next`) — its runtime API targets a
6
+ * React component tree, not a JSON-RPC server, so it cannot be called
7
+ * directly from tool handlers here. What IS reused, verified firsthand in
8
+ * `node_modules/@vantageos/mosaic-i18n/src/locales/{en,fr}.json`, is its
9
+ * resource-file CONVENTION: a flat namespaced object
10
+ * (`<Component>.<category>.<key>` -> string), one file per locale, FR and
11
+ * EN kept in lockstep key-for-key. `src/locales/{en,fr}.json` in this
12
+ * boilerplate follows that exact shape under a single `Mcp` namespace
13
+ * (`Mcp.auth.*`, `Mcp.tenant.*`, `Mcp.validation.*`), so the two locale
14
+ * files could be merged into mosaic-i18n's own resource files verbatim if
15
+ * this boilerplate ever grows a React UI consumer of the same strings.
16
+ *
17
+ * Locale detection follows mcp-standard.md's `X-MCP-Locale` request header
18
+ * (§Bilingual, l.234): "fr" selects the French resource, anything else
19
+ * (including a missing header) defaults to English.
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
+ }
35
+ const RESOURCES = {
36
+ en: loadLocaleResource("en.json"),
37
+ fr: loadLocaleResource("fr.json"),
38
+ };
39
+ /**
40
+ * Resolves the caller's locale from the `X-MCP-Locale` request header.
41
+ * Defaults to "en" for a missing header or any value other than "fr".
42
+ */
43
+ export function detectLocale(headerValue) {
44
+ return headerValue?.toLowerCase() === "fr" ? "fr" : "en";
45
+ }
46
+ /**
47
+ * Looks up a translated MCP message. `key` is a dot path under the `Mcp`
48
+ * namespace, e.g. `"auth.unauthorized"` or `"tenant.permissionDenied"`.
49
+ * Falls back to the English string (never to the raw key) if the requested
50
+ * locale is somehow missing a key the English resource carries.
51
+ */
52
+ export function t(locale, key) {
53
+ const path = key.split(".");
54
+ const fromLocale = readPath(RESOURCES[locale].Mcp, path);
55
+ if (typeof fromLocale === "string") {
56
+ return fromLocale;
57
+ }
58
+ const fromEnglish = readPath(RESOURCES.en.Mcp, path);
59
+ return typeof fromEnglish === "string" ? fromEnglish : key;
60
+ }
61
+ function readPath(root, path) {
62
+ let node = root;
63
+ for (const segment of path) {
64
+ if (typeof node !== "object" || node === null) {
65
+ return undefined;
66
+ }
67
+ node = node[segment];
68
+ }
69
+ return node;
70
+ }
@@ -0,0 +1,2 @@
1
+ export { createBoilerplateMcpServer } from "./mcpServerFactory.js";
2
+ export { buildHttpApp } from "./httpApp.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { createBoilerplateMcpServer } from "./mcpServerFactory.js";
2
+ export { buildHttpApp } from "./httpApp.js";
@@ -0,0 +1,13 @@
1
+ {
2
+ "Mcp": {
3
+ "auth": {
4
+ "unauthorized": "Unauthorized"
5
+ },
6
+ "tenant": {
7
+ "permissionDenied": "permission denied: no tenant resolved for this caller"
8
+ },
9
+ "validation": {
10
+ "messageTooLong": "message exceeds the maximum length of 200 characters"
11
+ }
12
+ }
13
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "Mcp": {
3
+ "auth": {
4
+ "unauthorized": "Non autorisé"
5
+ },
6
+ "tenant": {
7
+ "permissionDenied": "permission refusée : aucun tenant résolu pour cet appelant"
8
+ },
9
+ "validation": {
10
+ "messageTooLong": "le message dépasse la longueur maximale de 200 caractères"
11
+ }
12
+ }
13
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Single MCP server definition shared by both transports (stdio and HTTP).
3
+ * The example tool is registered here, once, on the SDK's `McpServer` —
4
+ * never on ad-hoc REST routes. Each call to `createBoilerplateMcpServer`
5
+ * builds a fresh server instance with no shared mutable state; the HTTP
6
+ * transport creates one per request (see httpApp.ts), which is what makes
7
+ * the Streamable HTTP stateless mode genuinely load-balanceable: nothing
8
+ * survives past the single request/response cycle.
9
+ *
10
+ * This is a SKELETON: `ping` is the only tool, kept trivial on purpose to
11
+ * prove the MCP handshake on both transports. Later layers replace or
12
+ * extend it (auth-gated tools, tenant-scoped tools, ...).
13
+ *
14
+ * Protocol-kit wiring (`@vantageos/mcp-protocol-kit` 0.2.0+): `tools/list`
15
+ * is re-shaped through `buildToolsListResult` so every response carries
16
+ * `resultType: "complete"` + a CacheableResult (`ttlMs`/`cacheScope`) on top
17
+ * of the `tools` key the official SDK's `ListToolsResultSchema` requires. The
18
+ * kit emits that key directly now — no hand alias.
19
+ *
20
+ * IMPORTANT — tools/call posture: `ping`'s tool-call result is NOT run
21
+ * through the cache builders. The official `CallToolResultSchema` is the
22
+ * fixed `{ content, isError? }` shape with no room for extra top-level fields
23
+ * without breaking client-side schema validation, so `resultType`/
24
+ * `CacheableResult` never apply to a tool-call result. The kit's
25
+ * `buildToolCallResult` (0.2.0+) is the conformant builder for that case; the
26
+ * inline `toolResult` helper here predates it and stays for the skeleton's
27
+ * trivial `{ content }` returns.
28
+ *
29
+ * `list_items` is the multi-tenant example tool (tenancy.ts): when a
30
+ * resolved tenant is supplied (OAuth tier — see httpApp.ts), results are
31
+ * scoped via `scopeRowsForTenant` (@vantageos/cloud-identity, reused
32
+ * unmodified); when no tenant is supplied (public tier — no auth gate at
33
+ * all, mirrors stdio's trust model), results are returned unscoped.
34
+ */
35
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
36
+ import { type McpLocale } from "./i18n.js";
37
+ import { type ResolvedTenant } from "./tenancy.js";
38
+ export interface McpServerDeps {
39
+ /**
40
+ * The caller's resolved tenant (tenancy.ts), when the OAuth tier verified
41
+ * a JWT carrying an organization. Undefined on the public tier (no gate)
42
+ * or when the verified token itself carries no organization.
43
+ */
44
+ tenant?: ResolvedTenant;
45
+ /**
46
+ * True when this deployment's tenant-scoped tools MUST see a resolved
47
+ * tenant (the OAuth tier). A verified-but-org-less caller then gets an
48
+ * explicit MCP tool-call refusal from `list_items` rather than silently
49
+ * falling through to an unscoped/master view. False on the public tier,
50
+ * where there is no tenant gate at all by design.
51
+ */
52
+ requireTenant?: boolean;
53
+ /**
54
+ * The caller's locale, resolved from the `X-MCP-Locale` request header
55
+ * (see i18n.ts + httpApp.ts). Defaults to "en" when not supplied — the
56
+ * stdio transport (no HTTP headers) always uses the default.
57
+ */
58
+ locale?: McpLocale;
59
+ }
60
+ export declare function createBoilerplateMcpServer(deps?: McpServerDeps): McpServer;
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Single MCP server definition shared by both transports (stdio and HTTP).
3
+ * The example tool is registered here, once, on the SDK's `McpServer` —
4
+ * never on ad-hoc REST routes. Each call to `createBoilerplateMcpServer`
5
+ * builds a fresh server instance with no shared mutable state; the HTTP
6
+ * transport creates one per request (see httpApp.ts), which is what makes
7
+ * the Streamable HTTP stateless mode genuinely load-balanceable: nothing
8
+ * survives past the single request/response cycle.
9
+ *
10
+ * This is a SKELETON: `ping` is the only tool, kept trivial on purpose to
11
+ * prove the MCP handshake on both transports. Later layers replace or
12
+ * extend it (auth-gated tools, tenant-scoped tools, ...).
13
+ *
14
+ * Protocol-kit wiring (`@vantageos/mcp-protocol-kit` 0.2.0+): `tools/list`
15
+ * is re-shaped through `buildToolsListResult` so every response carries
16
+ * `resultType: "complete"` + a CacheableResult (`ttlMs`/`cacheScope`) on top
17
+ * of the `tools` key the official SDK's `ListToolsResultSchema` requires. The
18
+ * kit emits that key directly now — no hand alias.
19
+ *
20
+ * IMPORTANT — tools/call posture: `ping`'s tool-call result is NOT run
21
+ * through the cache builders. The official `CallToolResultSchema` is the
22
+ * fixed `{ content, isError? }` shape with no room for extra top-level fields
23
+ * without breaking client-side schema validation, so `resultType`/
24
+ * `CacheableResult` never apply to a tool-call result. The kit's
25
+ * `buildToolCallResult` (0.2.0+) is the conformant builder for that case; the
26
+ * inline `toolResult` helper here predates it and stays for the skeleton's
27
+ * trivial `{ content }` returns.
28
+ *
29
+ * `list_items` is the multi-tenant example tool (tenancy.ts): when a
30
+ * resolved tenant is supplied (OAuth tier — see httpApp.ts), results are
31
+ * scoped via `scopeRowsForTenant` (@vantageos/cloud-identity, reused
32
+ * unmodified); when no tenant is supplied (public tier — no auth gate at
33
+ * all, mirrors stdio's trust model), results are returned unscoped.
34
+ */
35
+ import { registerAppResource, registerAppTool } from "@modelcontextprotocol/ext-apps/server";
36
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
37
+ import { ListToolsRequestSchema, RequestSchema } from "@modelcontextprotocol/sdk/types.js";
38
+ import { buildToolsListResult, handleServerDiscover } from "@vantageos/mcp-protocol-kit";
39
+ import { z } from "zod";
40
+ // The SDK's own request schemas (RequestSchema, ListToolsRequestSchema, ...)
41
+ // are built with the zod/v4 API. Extending RequestSchema for the custom
42
+ // server/discover method must use the same zod build, or `.extend()`
43
+ // fails with "expected a Zod schema" (v3/v4 schema objects are not
44
+ // interchangeable). Tool input shapes below keep using the default `zod`
45
+ // (v3) import, which is what `registerTool`'s public API expects.
46
+ import { z as zv4 } from "zod/v4";
47
+ import { EXAMPLE_ITEMS } from "./exampleItems.js";
48
+ import { t } from "./i18n.js";
49
+ import { scopeRowsForTenant } from "./tenancy.js";
50
+ import { ITEMS_UI_META, ITEMS_UI_RESOURCE_URI, buildRenderItemsUiResult, buildUiResource, } from "./ui/uiResource.js";
51
+ const pingInputShape = {
52
+ message: z.string().optional().describe("Optional message echoed back verbatim"),
53
+ };
54
+ // Zod shape of the single `ping` tool, kept alongside the SDK registration
55
+ // so `tools/list` can re-describe it through the kit's `buildListResult`
56
+ // without reaching into the SDK's private registered-tools map.
57
+ const PING_TOOL_DESCRIPTOR = {
58
+ name: "ping",
59
+ description: "Echoes the given message (or 'pong' if none given). Proves the MCP handshake end to end.",
60
+ inputSchema: {
61
+ type: "object",
62
+ properties: { message: { type: "string" } },
63
+ },
64
+ };
65
+ const listItemsInputShape = {};
66
+ const LIST_ITEMS_TOOL_DESCRIPTOR = {
67
+ name: "list_items",
68
+ description: "Lists example items. On the OAuth tier, results are scoped to the caller's tenant " +
69
+ "(@vantageos/cloud-identity); on the public tier (no auth gate), results are unscoped.",
70
+ inputSchema: { type: "object", properties: {} },
71
+ };
72
+ // server/discover is not yet part of the installed SDK's request-schema
73
+ // union (protocol 2026-07-28 is ahead of the SDK's negotiated versions —
74
+ // see docs/architecture.md "Wire posture"), so its request shape is
75
+ // declared locally and registered via the low-level `Server` instance.
76
+ const ServerDiscoverRequestSchema = RequestSchema.extend({
77
+ method: zv4.literal("server/discover"),
78
+ });
79
+ function toolResult(payload) {
80
+ return {
81
+ content: [{ type: "text", text: JSON.stringify(payload) }],
82
+ };
83
+ }
84
+ export function createBoilerplateMcpServer(deps = {}) {
85
+ const locale = deps.locale ?? "en";
86
+ const server = new McpServer({ name: "mcp-boilerplate", version: "0.1.0" }, { capabilities: { tools: {} } });
87
+ server.registerTool(PING_TOOL_DESCRIPTOR.name, {
88
+ description: PING_TOOL_DESCRIPTOR.description,
89
+ inputSchema: pingInputShape,
90
+ }, async (args) => {
91
+ // Application-level input validation (beyond the Zod type shape):
92
+ // an overly long echo message is refused as an MCP tool-call error,
93
+ // localized per the caller's X-MCP-Locale.
94
+ if (args.message !== undefined && args.message.length > 200) {
95
+ return {
96
+ content: [
97
+ {
98
+ type: "text",
99
+ text: t(locale, "validation.messageTooLong"),
100
+ },
101
+ ],
102
+ isError: true,
103
+ };
104
+ }
105
+ return toolResult({ reply: args.message ?? "pong" });
106
+ });
107
+ server.registerTool(LIST_ITEMS_TOOL_DESCRIPTOR.name, {
108
+ description: LIST_ITEMS_TOOL_DESCRIPTOR.description,
109
+ inputSchema: listItemsInputShape,
110
+ }, async () => {
111
+ if (deps.requireTenant && !deps.tenant) {
112
+ // Refuse explicitly — never fall through to an unscoped/master view
113
+ // just because the caller's token had no organization.
114
+ return {
115
+ content: [
116
+ {
117
+ type: "text",
118
+ text: t(locale, "tenant.permissionDenied"),
119
+ },
120
+ ],
121
+ isError: true,
122
+ };
123
+ }
124
+ const items = deps.tenant
125
+ ? scopeRowsForTenant(deps.tenant.tenantId, EXAMPLE_ITEMS)
126
+ : EXAMPLE_ITEMS;
127
+ return toolResult({ items });
128
+ });
129
+ // MCP Apps UI layer (io.modelcontextprotocol/ui, GA 2026-01-26): renders
130
+ // EXAMPLE_ITEMS as an interactive widget. `registerAppResource` +
131
+ // `registerAppTool` (official `@modelcontextprotocol/ext-apps/server`)
132
+ // write the GA nested `_meta.ui.resourceUri` form — never the deprecated
133
+ // flat `_meta["ui/resourceUri"]` key. The markdown fallback in
134
+ // `content[0]` is mandatory and always present, even for clients that
135
+ // never negotiate the extension (mcp-app-standard.md Critical Rule #1).
136
+ registerAppResource(server, "Items widget", ITEMS_UI_RESOURCE_URI, { description: "Interactive items list", mimeType: "text/html;profile=mcp-app" }, async () => ({
137
+ contents: [buildUiResource({ items: EXAMPLE_ITEMS, locale: "en" })],
138
+ }));
139
+ registerAppTool(server, "render_items_ui", {
140
+ description: "Render the example items as an interactive widget. Use this whenever the user asks for a visual/rendered view of the items.",
141
+ inputSchema: {
142
+ locale: z.enum(["en", "fr"]).default("en"),
143
+ },
144
+ // `description_fr` (mcp-standard.md convention) lives alongside
145
+ // `ui` in `_meta` — `McpUiAppToolConfig._meta` carries an index
146
+ // signature for exactly this kind of ElPi Corp addition.
147
+ _meta: {
148
+ ui: ITEMS_UI_META,
149
+ description_fr: "Affiche les éléments d'exemple sous forme de widget interactif. Utilise cet outil quand l'utilisateur demande une vue visuelle des éléments.",
150
+ },
151
+ }, async (args) => {
152
+ const items = deps.tenant
153
+ ? scopeRowsForTenant(deps.tenant.tenantId, EXAMPLE_ITEMS)
154
+ : EXAMPLE_ITEMS;
155
+ return buildRenderItemsUiResult(items, args.locale);
156
+ });
157
+ // Re-shape tools/list through the kit: resultType + CacheableResult on
158
+ // top of the standard `tools` array every MCP client expects. The kit's
159
+ // `buildToolsListResult` (0.2.0+) emits the `tools` key the official
160
+ // `ListToolsResultSchema` requires directly — no hand alias needed.
161
+ server.server.setRequestHandler(ListToolsRequestSchema, async () => {
162
+ return buildToolsListResult([PING_TOOL_DESCRIPTOR, LIST_ITEMS_TOOL_DESCRIPTOR], {
163
+ ttlMs: 60_000,
164
+ cacheScope: "public",
165
+ });
166
+ });
167
+ // server/discover via the kit's handleServerDiscover — DiscoverResult
168
+ // extends CacheableResult, so resultType/ttlMs/cacheScope are always set
169
+ // alongside supportedVersions/capabilities.
170
+ server.server.setRequestHandler(ServerDiscoverRequestSchema, async () => {
171
+ return handleServerDiscover({
172
+ supportedVersions: ["2026-07-28", "2025-11-25"],
173
+ capabilities: { tools: {} },
174
+ ttlMs: 300_000,
175
+ cacheScope: "public",
176
+ instructions: "Clonable MCP server boilerplate — see README.md",
177
+ });
178
+ });
179
+ return server;
180
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Stdio transport (local/self-host mode, e.g. Claude Code / Cursor local
3
+ * config). Trust model: the local process invoking stdio IS the caller —
4
+ * no auth challenge on this transport. The server definition (tools) lives
5
+ * once in mcpServerFactory.ts, shared with the HTTP transport — this file
6
+ * only wires the official SDK stdio Transport onto that shared server.
7
+ */
8
+ export declare function startStdioServer(): Promise<void>;
package/dist/stdio.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Stdio transport (local/self-host mode, e.g. Claude Code / Cursor local
3
+ * config). Trust model: the local process invoking stdio IS the caller —
4
+ * no auth challenge on this transport. The server definition (tools) lives
5
+ * once in mcpServerFactory.ts, shared with the HTTP transport — this file
6
+ * only wires the official SDK stdio Transport onto that shared server.
7
+ */
8
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
+ import { createBoilerplateMcpServer } from "./mcpServerFactory.js";
10
+ export async function startStdioServer() {
11
+ const server = createBoilerplateMcpServer();
12
+ const transport = new StdioServerTransport();
13
+ await server.connect(transport);
14
+ process.stderr.write("[mcp-boilerplate] stdio server started — 1 tool available\n");
15
+ }
16
+ startStdioServer().catch((err) => {
17
+ process.stderr.write(`[mcp-boilerplate] fatal: ${err instanceof Error ? err.message : String(err)}\n`);
18
+ process.exit(1);
19
+ });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Multi-tenant glue: takes the already-verified JWT payload produced by
3
+ * auth.ts's OAuth 2.1 resource-server gate (BP-T3, reusing
4
+ * @vantageos/mcp-auth-kit's `verifyMcpBearerRequest` unmodified) and hands
5
+ * it to @vantageos/cloud-identity to resolve the caller's tenant and scope
6
+ * rows. This module never re-verifies the token and never reimplements
7
+ * cloud-identity's authorization logic — it only adapts shapes.
8
+ *
9
+ * API-shape note (first real consumer of `requireTenantId`, new in
10
+ * cloud-identity 0.3.0 with zero prior callers): the package's "bearer"
11
+ * entry path expects a full `TenantContext` ({ workspaceId, userId, roles })
12
+ * — the shape cloud-identity's OWN workspace-membership layer produces
13
+ * (`decodeUnverifiedBearer`), not a generic OAuth JWT. This boilerplate's
14
+ * verified payload (a plain `jose` `JWTPayload`, Clerk-as-AS) carries no
15
+ * `workspaceId`/`roles` claims, so it is NOT a `TenantContext` and cannot
16
+ * go through the "bearer" path without inventing fields cloud-identity
17
+ * never asked for. It goes through the "session" path instead
18
+ * (`identity: { orgId }`), which matches the JWT's `org_id` claim exactly
19
+ * as Clerk mints it. Both entry paths converge on the same
20
+ * refuse-on-absence contract (`requireTenantId` throws when there is no
21
+ * organization, on either path) — this is a compatible substitution
22
+ * documented at the one call site it affects, never a bypass of the guard.
23
+ */
24
+ import { type OAuthCtx, type ScopeFilterable } from "@vantageos/cloud-identity";
25
+ import type { JWTPayload } from "jose";
26
+ export interface ResolvedTenant {
27
+ tenantId: string;
28
+ }
29
+ /**
30
+ * Resolve the caller's tenant from the verified JWT payload. Refuses
31
+ * (throws) when the token carries no organization — never falls through to
32
+ * an unscoped/master view. Callers MUST treat the thrown error as a refusal
33
+ * (an explicit permission-denied response), never silently grant access.
34
+ */
35
+ export declare function resolveTenantFromJwtPayload(payload: JWTPayload): ResolvedTenant;
36
+ /**
37
+ * Minimal scope profile derived from the resolved tenant: the tenant id
38
+ * doubles as both the row-ownership allow-list entry and the namespace
39
+ * prefix, so a tool result is scoped to rows this tenant created (or that
40
+ * live under its namespace) and nothing else. This is the boilerplate's
41
+ * example convention — a real deployment may derive richer allow-lists.
42
+ */
43
+ export declare function scopeForTenant(tenantId: string): OAuthCtx;
44
+ /**
45
+ * Filter rows by the caller's tenant, reusing cloud-identity's
46
+ * `scopeFilterList` unmodified — this module never reimplements row
47
+ * filtering.
48
+ */
49
+ export declare function scopeRowsForTenant<T extends ScopeFilterable>(tenantId: string, rows: T[]): T[];
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Multi-tenant glue: takes the already-verified JWT payload produced by
3
+ * auth.ts's OAuth 2.1 resource-server gate (BP-T3, reusing
4
+ * @vantageos/mcp-auth-kit's `verifyMcpBearerRequest` unmodified) and hands
5
+ * it to @vantageos/cloud-identity to resolve the caller's tenant and scope
6
+ * rows. This module never re-verifies the token and never reimplements
7
+ * cloud-identity's authorization logic — it only adapts shapes.
8
+ *
9
+ * API-shape note (first real consumer of `requireTenantId`, new in
10
+ * cloud-identity 0.3.0 with zero prior callers): the package's "bearer"
11
+ * entry path expects a full `TenantContext` ({ workspaceId, userId, roles })
12
+ * — the shape cloud-identity's OWN workspace-membership layer produces
13
+ * (`decodeUnverifiedBearer`), not a generic OAuth JWT. This boilerplate's
14
+ * verified payload (a plain `jose` `JWTPayload`, Clerk-as-AS) carries no
15
+ * `workspaceId`/`roles` claims, so it is NOT a `TenantContext` and cannot
16
+ * go through the "bearer" path without inventing fields cloud-identity
17
+ * never asked for. It goes through the "session" path instead
18
+ * (`identity: { orgId }`), which matches the JWT's `org_id` claim exactly
19
+ * as Clerk mints it. Both entry paths converge on the same
20
+ * refuse-on-absence contract (`requireTenantId` throws when there is no
21
+ * organization, on either path) — this is a compatible substitution
22
+ * documented at the one call site it affects, never a bypass of the guard.
23
+ */
24
+ import { requireTenantId, scopeFilterList, } from "@vantageos/cloud-identity";
25
+ /**
26
+ * Resolve the caller's tenant from the verified JWT payload. Refuses
27
+ * (throws) when the token carries no organization — never falls through to
28
+ * an unscoped/master view. Callers MUST treat the thrown error as a refusal
29
+ * (an explicit permission-denied response), never silently grant access.
30
+ */
31
+ export function resolveTenantFromJwtPayload(payload) {
32
+ const orgId = typeof payload.org_id === "string" ? payload.org_id : undefined;
33
+ const tenantId = requireTenantId({ kind: "session", identity: { orgId } });
34
+ return { tenantId };
35
+ }
36
+ /**
37
+ * Minimal scope profile derived from the resolved tenant: the tenant id
38
+ * doubles as both the row-ownership allow-list entry and the namespace
39
+ * prefix, so a tool result is scoped to rows this tenant created (or that
40
+ * live under its namespace) and nothing else. This is the boilerplate's
41
+ * example convention — a real deployment may derive richer allow-lists.
42
+ */
43
+ export function scopeForTenant(tenantId) {
44
+ return {
45
+ fromAllowList: [tenantId],
46
+ namespaceReadPrefixes: [tenantId],
47
+ namespaceWritePrefixes: [tenantId],
48
+ scope: "tenant",
49
+ };
50
+ }
51
+ /**
52
+ * Filter rows by the caller's tenant, reusing cloud-identity's
53
+ * `scopeFilterList` unmodified — this module never reimplements row
54
+ * filtering.
55
+ */
56
+ export function scopeRowsForTenant(tenantId, rows) {
57
+ return scopeFilterList(scopeForTenant(tenantId), rows);
58
+ }
@@ -0,0 +1,34 @@
1
+ import type { McpUiToolMeta } from "@modelcontextprotocol/ext-apps";
2
+ import type { ExampleItem } from "../exampleItems.js";
3
+ export declare const ITEMS_UI_RESOURCE_URI = "ui://mcp-boilerplate/items-widget.html";
4
+ /**
5
+ * Reads the single-file bundle produced by `npm run build:ui`
6
+ * (vite-plugin-singlefile). Thrown, never silently swallowed, if the
7
+ * bundle is missing — a missing bundle is a build defect, not a runtime
8
+ * fallback case.
9
+ */
10
+ export declare function readUiBundle(): string;
11
+ export declare function buildUiResource(props: {
12
+ items: ExampleItem[];
13
+ locale: "en" | "fr";
14
+ }): {
15
+ uri: string;
16
+ mimeType: string;
17
+ text: string;
18
+ };
19
+ /** GA nested `_meta.ui` value shared by the tool registration and the result. */
20
+ export declare const ITEMS_UI_META: McpUiToolMeta;
21
+ export interface RenderItemsUiResult {
22
+ [key: string]: unknown;
23
+ content: Array<{
24
+ type: "text";
25
+ text: string;
26
+ } | {
27
+ type: "resource";
28
+ resource: ReturnType<typeof buildUiResource>;
29
+ }>;
30
+ _meta: {
31
+ ui: McpUiToolMeta;
32
+ };
33
+ }
34
+ export declare function buildRenderItemsUiResult(items: ExampleItem[], locale: "en" | "fr"): RenderItemsUiResult;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * MCP Apps UI resource for the `render_items_ui` tool — GA `_meta.ui`
3
+ * nested form (`io.modelcontextprotocol/ui`, stable 2026-01-26), built via
4
+ * the official `@modelcontextprotocol/ext-apps/server` helpers.
5
+ *
6
+ * `RESOURCE_URI_META_KEY` (the deprecated flat `_meta["ui/resourceUri"]`
7
+ * key) is imported ONLY to assert its absence in tests — this module never
8
+ * writes it. It also never writes a bespoke `_meta` key (the shape found
9
+ * in `@vantageos/mcp-architect@1.1.0` and flagged non-conformant in the
10
+ * CRM's own audit) — `registerAppTool`'s `_meta.ui` field is the only
11
+ * shape written here.
12
+ */
13
+ import { readFileSync } from "node:fs";
14
+ import path from "node:path";
15
+ import { RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
16
+ export const ITEMS_UI_RESOURCE_URI = "ui://mcp-boilerplate/items-widget.html";
17
+ // dist/ui-bundle/ — NEVER dist/ui/ (that path is tsc's compiled-server
18
+ // output for this very module; see vite.config.ts for the collision this
19
+ // avoids).
20
+ const BUNDLE_PATH = path.join(process.cwd(), "dist", "ui-bundle", "widget.html");
21
+ function renderMarkdownFallback(items, locale) {
22
+ const title = locale === "fr" ? "Éléments" : "Items";
23
+ if (items.length === 0) {
24
+ return `## ${title}\n\n${locale === "fr" ? "Aucun élément." : "No items."}`;
25
+ }
26
+ const rows = items.map((item) => `- **${item.name}** (${item.namespace})`).join("\n");
27
+ return `## ${title}\n\n${rows}`;
28
+ }
29
+ /**
30
+ * Reads the single-file bundle produced by `npm run build:ui`
31
+ * (vite-plugin-singlefile). Thrown, never silently swallowed, if the
32
+ * bundle is missing — a missing bundle is a build defect, not a runtime
33
+ * fallback case.
34
+ */
35
+ export function readUiBundle() {
36
+ return readFileSync(BUNDLE_PATH, "utf-8");
37
+ }
38
+ export function buildUiResource(props) {
39
+ return {
40
+ uri: ITEMS_UI_RESOURCE_URI,
41
+ mimeType: RESOURCE_MIME_TYPE,
42
+ text: readUiBundle(),
43
+ };
44
+ }
45
+ /** GA nested `_meta.ui` value shared by the tool registration and the result. */
46
+ export const ITEMS_UI_META = {
47
+ resourceUri: ITEMS_UI_RESOURCE_URI,
48
+ };
49
+ export function buildRenderItemsUiResult(items, locale) {
50
+ const fallbackMarkdown = renderMarkdownFallback(items, locale);
51
+ const resource = buildUiResource({ items, locale });
52
+ return {
53
+ content: [
54
+ { type: "text", text: fallbackMarkdown },
55
+ { type: "resource", resource },
56
+ ],
57
+ _meta: { ui: ITEMS_UI_META },
58
+ };
59
+ }