@vantageos/mcp-boilerplate 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.claude-plugin/plugin.json +16 -8
  2. package/.mcp.json +8 -0
  3. package/CHANGELOG.md +67 -1
  4. package/README.fr.md +177 -142
  5. package/README.md +170 -133
  6. package/dist/auth.d.ts +20 -10
  7. package/dist/auth.js +9 -8
  8. package/dist/config.d.ts +10 -3
  9. package/dist/config.js +31 -3
  10. package/dist/exampleItems.d.ts +5 -4
  11. package/dist/exampleItems.js +3 -0
  12. package/dist/http.js +11 -1
  13. package/dist/httpApp.d.ts +23 -30
  14. package/dist/httpApp.js +73 -74
  15. package/dist/i18n.d.ts +6 -0
  16. package/dist/i18n.js +9 -0
  17. package/dist/locales/en.json +9 -0
  18. package/dist/locales/fr.json +9 -0
  19. package/dist/mcpServerFactory.d.ts +42 -41
  20. package/dist/mcpServerFactory.js +114 -165
  21. package/dist/oauth/authorizationServer.d.ts +56 -0
  22. package/dist/oauth/authorizationServer.js +423 -0
  23. package/dist/oauth/clerk.d.ts +15 -0
  24. package/dist/oauth/clerk.js +59 -0
  25. package/dist/oauth/clientRegistry.d.ts +50 -0
  26. package/dist/oauth/clientRegistry.js +203 -0
  27. package/dist/oauth/codeStore.d.ts +11 -0
  28. package/dist/oauth/codeStore.js +28 -0
  29. package/dist/oauth/picker.d.ts +11 -0
  30. package/dist/oauth/picker.js +67 -0
  31. package/dist/oauth/settings.d.ts +48 -0
  32. package/dist/oauth/settings.js +100 -0
  33. package/dist/oauth/tokenIssuer.d.ts +22 -0
  34. package/dist/oauth/tokenIssuer.js +61 -0
  35. package/dist/stdio.d.ts +7 -6
  36. package/dist/stdio.js +20 -19
  37. package/dist/tenancy.d.ts +16 -21
  38. package/dist/tenancy.js +12 -26
  39. package/dist/ui/uiResource.d.ts +39 -6
  40. package/dist/ui/uiResource.js +62 -13
  41. package/dist/version.d.ts +2 -0
  42. package/dist/version.js +8 -0
  43. package/docs/architecture.md +82 -0
  44. package/docs/oauth-stores.md +60 -0
  45. package/examples/oauth.env.example +25 -0
  46. package/mcp.json +19 -0
  47. package/package.json +23 -13
  48. package/plugin.json +43 -0
  49. package/scripts/generate-signing-key.mjs +14 -0
  50. package/scripts/plugin-manifest.source.json +35 -0
  51. package/skills/connect-mcp-server/SKILL.md +40 -0
@@ -1,199 +1,148 @@
1
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.
2
+ * Single MCP server definition shared by both transports. It is registered once, on the SDK
3
+ * v2 `McpServer`, and served by `createMcpHandler` (HTTP, src/httpApp.ts) and `serveStdio`
4
+ * (src/stdio.ts) — each owns the protocol-era decision, so ONE factory serves a 2026-07-28
5
+ * client (opens with `server/discover`) and a 2025-era client (opens with `initialize`).
6
+ * Each call builds a fresh instance with no shared mutable state; the HTTP transport builds
7
+ * one per request, which is what keeps the endpoint load-balanceable.
9
8
  *
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, ...).
9
+ * Wire posture:
10
+ * - the 2025-era floor is 2025-06-18: `supportedProtocolVersions` lists only 2025-11-25 and
11
+ * 2025-06-18 (the SDK option on `ProtocolOptions`); older revisions are never counter-offered;
12
+ * - `ttlMs` / `cacheScope` on 2026-07-28 results come from the SDK's `cacheHints` — no
13
+ * handler override, no private-field access, no hand-written discover schema;
14
+ * - the advertised version is `SERVER_VERSION`, derived from package.json.
13
15
  *
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.
16
+ * Tool records (mcp-standard C-4, C-15): every tool declares `title`, the four behavioural
17
+ * annotations as explicit booleans, a typed input schema AND a typed output schema (Zod 4),
18
+ * and returns `structuredContent` plus a text block carrying the same JSON.
19
+ * tests/toolRecordComplete.test.ts walks `tools/list` and fails on any gap.
19
20
  *
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.
21
+ * `list_items` is the multi-tenant example (tenancy.ts): with a resolved tenant, rows are
22
+ * filtered by stored `orgId` (cloud-identity `isRowInTenant`); on the public tier there is no
23
+ * tenant gate at all, by design (it mirrors stdio's local-trust model).
34
24
  */
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";
25
+ import { registerAppResource } from "@modelcontextprotocol/ext-apps/server";
26
+ import { McpServer } from "@modelcontextprotocol/server";
39
27
  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
28
  import { EXAMPLE_ITEMS } from "./exampleItems.js";
48
29
  import { t } from "./i18n.js";
49
30
  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: {} },
31
+ import { ITEMS_TOOL_META, ITEMS_UI_RESOURCE_URI, buildItemsUiResourceMeta, buildRenderItemsUiResult, buildUiResource, } from "./ui/uiResource.js";
32
+ import { SERVER_NAME, SERVER_VERSION } from "./version.js";
33
+ /** Oldest 2025-era protocol revision served; the legacy `initialize` handshake never offers older. */
34
+ export const SUPPORTED_LEGACY_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18"];
35
+ /** The four behavioural annotations, explicit on every tool (all three tools are pure reads). */
36
+ export const TOOL_ANNOTATIONS = {
37
+ readOnlyHint: true,
38
+ destructiveHint: false,
39
+ idempotentHint: true,
40
+ openWorldHint: false,
71
41
  };
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"),
42
+ const itemSchema = z.object({
43
+ id: z.string(),
44
+ name: z.string(),
45
+ orgId: z.string(),
46
+ createdBy: z.string(),
47
+ namespace: z.string(),
78
48
  });
79
- function toolResult(payload) {
49
+ const localeSchema = z.enum(["en", "fr"]);
50
+ const pingInput = z.object({
51
+ message: z.string().optional().describe("Optional message echoed back verbatim"),
52
+ });
53
+ const pingOutput = z.object({ reply: z.string() });
54
+ const listItemsInput = z.object({});
55
+ const listItemsOutput = z.object({ items: z.array(itemSchema) });
56
+ const renderInput = z.object({
57
+ locale: localeSchema
58
+ .optional()
59
+ .describe("Widget language; defaults to the caller's X-MCP-Locale"),
60
+ });
61
+ const renderOutput = z.object({ items: z.array(itemSchema), locale: localeSchema });
62
+ /** A text block carrying the same JSON as `structuredContent`. */
63
+ function structuredResult(payload) {
80
64
  return {
81
65
  content: [{ type: "text", text: JSON.stringify(payload) }],
66
+ structuredContent: payload,
82
67
  };
83
68
  }
69
+ function refusal(text) {
70
+ return { content: [{ type: "text", text }], isError: true };
71
+ }
84
72
  export function createBoilerplateMcpServer(deps = {}) {
85
73
  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,
74
+ const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, {
75
+ capabilities: { tools: {}, resources: {} },
76
+ instructions: "Clonable MCP server boilerplate — see README.md",
77
+ supportedProtocolVersions: SUPPORTED_LEGACY_PROTOCOL_VERSIONS,
78
+ cacheHints: {
79
+ "tools/list": { ttlMs: 60_000, cacheScope: "public" },
80
+ "resources/list": { ttlMs: 60_000, cacheScope: "public" },
81
+ "resources/read": { ttlMs: 60_000, cacheScope: "public" },
82
+ "server/discover": { ttlMs: 300_000, cacheScope: "public" },
83
+ },
84
+ });
85
+ server.registerTool("ping", {
86
+ title: "Ping",
87
+ description: "Echoes the given message (or 'pong' if none given). Proves the MCP handshake end to end.",
88
+ inputSchema: pingInput,
89
+ outputSchema: pingOutput,
90
+ annotations: TOOL_ANNOTATIONS,
90
91
  }, 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.
92
+ // Application-level input validation beyond the Zod shape: an overly long echo message
93
+ // is refused as an MCP tool-call error, localized per the caller's X-MCP-Locale.
94
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
- };
95
+ return refusal(t(locale, "validation.messageTooLong"));
104
96
  }
105
- return toolResult({ reply: args.message ?? "pong" });
97
+ return structuredResult({ reply: args.message ?? "pong" });
106
98
  });
107
- server.registerTool(LIST_ITEMS_TOOL_DESCRIPTOR.name, {
108
- description: LIST_ITEMS_TOOL_DESCRIPTOR.description,
109
- inputSchema: listItemsInputShape,
99
+ const scopedItems = () => deps.tenant ? scopeRowsForTenant(deps.tenant.tenantId, EXAMPLE_ITEMS) : EXAMPLE_ITEMS;
100
+ server.registerTool("list_items", {
101
+ title: "List items",
102
+ description: "Lists example items. On the OAuth tier, results are scoped to the caller's organisation " +
103
+ "(@vantageos/cloud-identity); on the public tier (no auth gate), results are unscoped.",
104
+ inputSchema: listItemsInput,
105
+ outputSchema: listItemsOutput,
106
+ annotations: TOOL_ANNOTATIONS,
110
107
  }, async () => {
111
108
  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
- };
109
+ // Refuse explicitly — never fall through to an unscoped/master view just because the
110
+ // caller's token had no organization.
111
+ return refusal(t(locale, "tenant.permissionDenied"));
123
112
  }
124
- const items = deps.tenant
125
- ? scopeRowsForTenant(deps.tenant.tenantId, EXAMPLE_ITEMS)
126
- : EXAMPLE_ITEMS;
127
- return toolResult({ items });
113
+ return structuredResult({ items: scopedItems() });
128
114
  });
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" })],
115
+ // MCP Apps UI layer (io.modelcontextprotocol/ui, spec 2026-01-26). The tool is registered
116
+ // with `server.registerTool` and the GA nested `_meta.ui` form, NOT with ext-apps'
117
+ // `registerAppTool`: that helper back-fills the deprecated flat `_meta["ui/resourceUri"]`
118
+ // key, which mcp-app-standard A-6 forbids writing (tests/mcpAppsMeta.test.ts asserts its
119
+ // absence). The markdown fallback in the call result is mandatory even for clients that never
120
+ // negotiate the extension (A-1).
121
+ const resourceUiMeta = buildItemsUiResourceMeta(deps.uiDomain);
122
+ registerAppResource(server, "Items widget", ITEMS_UI_RESOURCE_URI, {
123
+ title: "Items widget",
124
+ description: "Interactive items list",
125
+ mimeType: "text/html;profile=mcp-app",
126
+ _meta: { ui: resourceUiMeta },
127
+ }, async () => ({
128
+ contents: [buildUiResource({ items: scopedItems(), locale, domain: deps.uiDomain })],
138
129
  }));
139
- registerAppTool(server, "render_items_ui", {
130
+ server.registerTool("render_items_ui", {
131
+ title: "Render items",
140
132
  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.
133
+ inputSchema: renderInput,
134
+ outputSchema: renderOutput,
135
+ annotations: TOOL_ANNOTATIONS,
136
+ // `description_fr` (mcp-standard.md convention) lives alongside `ui` in `_meta`.
147
137
  _meta: {
148
- ui: ITEMS_UI_META,
138
+ ...ITEMS_TOOL_META,
149
139
  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
140
  },
151
141
  }, 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
- //
162
- // DERIVATION (not a hand-typed descriptor list): the SDK's own McpServer
163
- // already builds the `tools/list` response from its single source of
164
- // truth — `this._registeredTools`, populated by every `registerTool`/
165
- // `registerAppTool` call above — and installs that handler on
166
- // `server.server` (the low-level `Server`) via `setToolRequestHandlers()`
167
- // BEFORE this function runs. Re-invoking that already-installed handler,
168
- // then re-shaping its `tools` array through the kit, means a 4th
169
- // registered tool appears here automatically — nothing here enumerates
170
- // tool names by hand, so a tool can never again be silently missing from
171
- // discovery just because this list forgot to mention it.
172
- const baseListToolsHandler = server.server._requestHandlers.get("tools/list");
173
- if (!baseListToolsHandler) {
174
- throw new Error("tools/list handler not found on the SDK's Server — McpServer.registerTool() " +
175
- "installs it lazily on first registration; this indicates no tool was ever " +
176
- "registered, which is a boilerplate wiring bug, not a runtime condition to " +
177
- "silently work around.");
178
- }
179
- server.server.setRequestHandler(ListToolsRequestSchema, async (request, extra) => {
180
- const { tools } = await baseListToolsHandler(request, extra);
181
- return buildToolsListResult(tools, {
182
- ttlMs: 60_000,
183
- cacheScope: "public",
184
- });
185
- });
186
- // server/discover via the kit's handleServerDiscover — DiscoverResult
187
- // extends CacheableResult, so resultType/ttlMs/cacheScope are always set
188
- // alongside supportedVersions/capabilities.
189
- server.server.setRequestHandler(ServerDiscoverRequestSchema, async () => {
190
- return handleServerDiscover({
191
- supportedVersions: ["2026-07-28", "2025-11-25"],
192
- capabilities: { tools: {} },
193
- ttlMs: 300_000,
194
- cacheScope: "public",
195
- instructions: "Clonable MCP server boilerplate — see README.md",
196
- });
142
+ if (deps.requireTenant && !deps.tenant) {
143
+ return refusal(t(locale, "tenant.permissionDenied"));
144
+ }
145
+ return buildRenderItemsUiResult(scopedItems(), args.locale ?? locale, deps.uiDomain);
197
146
  });
198
147
  return server;
199
148
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Own OAuth 2.1 authorization server (ruling R1). A code is issued ONLY to a signed-in Clerk
3
+ * person who picked one of their own organisations and approved the request on the picker
4
+ * page (consent ON). Routes:
5
+ *
6
+ * GET /.well-known/oauth-authorization-server (+ /.well-known/openid-configuration)
7
+ * GET /.well-known/jwks.json public signing keys
8
+ * POST /register Dynamic Client Registration (public clients)
9
+ * GET /authorize start; CIMD or DCR client_id
10
+ * GET /authorize/callback return from Clerk sign-in
11
+ * POST /authorize/org person + organisation picker submit
12
+ * POST /token authorization_code + PKCE S256
13
+ * GET /userinfo
14
+ *
15
+ * EVERY decision lives in @vantageos/cloud-identity (startAuthorize, resumeAuthorize,
16
+ * exchangeAuthorizationCode, buildDiscoveryDocument, buildUserInfo, oauthErrorFor). This
17
+ * module owns only what the package takes as parameters: environment, stores, the client
18
+ * lookup, the Clerk calls, the picker page, and the RS256 signature of the access token.
19
+ *
20
+ * FAIL-CLOSED: any missing configuration value answers 503 `temporarily_unavailable`.
21
+ * No refresh tokens are issued; access tokens live one hour.
22
+ */
23
+ import { type AuthorizationCodeStore } from "@vantageos/cloud-identity";
24
+ import type { Hono } from "hono";
25
+ import type { AuthConfig } from "../auth.js";
26
+ import { type ClerkCollaborators } from "./clerk.js";
27
+ import { type CimdOptions, type ClientRegistry } from "./clientRegistry.js";
28
+ export interface AuthorizationServerDeps {
29
+ /** Defaults to `process.env`. */
30
+ env?: NodeJS.ProcessEnv;
31
+ /** Defaults to an in-memory store (single process only; see docs/oauth-stores.md). */
32
+ codeStore?: AuthorizationCodeStore;
33
+ /** Defaults to an in-memory registry (single process only; see docs/oauth-stores.md). */
34
+ clientRegistry?: ClientRegistry;
35
+ /** Defaults to Clerk's Backend REST API with CLERK_SECRET_KEY. */
36
+ clerk?: Partial<ClerkCollaborators>;
37
+ /** CIMD fetch + DNS seam. */
38
+ fetchImpl?: typeof fetch;
39
+ resolveHost?: CimdOptions["resolveHost"];
40
+ now?: () => number;
41
+ }
42
+ export type ResourceServerConfig = {
43
+ ok: true;
44
+ auth: AuthConfig;
45
+ } | {
46
+ ok: false;
47
+ missing: string[];
48
+ };
49
+ type AnyHono = Hono<any>;
50
+ export interface AuthorizationServer {
51
+ mount(app: AnyHono): void;
52
+ /** The resource-server (`/mcp` gate) configuration derived from the same settings. */
53
+ resourceServer(): Promise<ResourceServerConfig>;
54
+ }
55
+ export declare function createAuthorizationServer(deps?: AuthorizationServerDeps): AuthorizationServer;
56
+ export {};