@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/.claude-plugin/plugin.json +12 -0
- package/LICENSE +21 -0
- package/README.md +183 -0
- package/dist/auth.d.ts +27 -0
- package/dist/auth.js +31 -0
- package/dist/config.d.ts +21 -0
- package/dist/config.js +43 -0
- package/dist/exampleItems.d.ts +13 -0
- package/dist/exampleItems.js +20 -0
- package/dist/http.d.ts +1 -0
- package/dist/http.js +28 -0
- package/dist/httpApp.d.ts +52 -0
- package/dist/httpApp.js +113 -0
- package/dist/i18n.d.ts +33 -0
- package/dist/i18n.js +70 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/locales/en.json +13 -0
- package/dist/locales/fr.json +13 -0
- package/dist/mcpServerFactory.d.ts +60 -0
- package/dist/mcpServerFactory.js +180 -0
- package/dist/stdio.d.ts +8 -0
- package/dist/stdio.js +19 -0
- package/dist/tenancy.d.ts +49 -0
- package/dist/tenancy.js +58 -0
- package/dist/ui/uiResource.d.ts +34 -0
- package/dist/ui/uiResource.js +59 -0
- package/dist/ui-bundle/widget.html +61 -0
- package/examples/claude-desktop.json +8 -0
- package/package.json +86 -0
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
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -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
|
+
}
|
package/dist/stdio.d.ts
ADDED
|
@@ -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[];
|
package/dist/tenancy.js
ADDED
|
@@ -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
|
+
}
|