@vantageos/mcp-boilerplate 0.1.2 → 0.2.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 (50) hide show
  1. package/.claude-plugin/plugin.json +16 -8
  2. package/.mcp.json +8 -0
  3. package/CHANGELOG.md +55 -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 +36 -3
  40. package/dist/ui/uiResource.js +40 -6
  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 +22 -13
  48. package/plugin.json +43 -0
  49. package/scripts/plugin-manifest.source.json +35 -0
  50. package/skills/connect-mcp-server/SKILL.md +40 -0
package/dist/tenancy.d.ts CHANGED
@@ -1,8 +1,7 @@
1
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
2
+ * Multi-tenant glue: takes the verified session claims produced by
3
+ * auth.ts's OAuth 2.1 resource-server gate (@vantageos/cloud-identity's `verifyMcpAccessToken`,
4
+ * `session.claims`) and hands them to @vantageos/cloud-identity to resolve the caller's tenant and scope
6
5
  * rows. This module never re-verifies the token and never reimplements
7
6
  * cloud-identity's authorization logic — it only adapts shapes.
8
7
  *
@@ -11,7 +10,7 @@
11
10
  * entry path expects a full `TenantContext` ({ workspaceId, userId, roles })
12
11
  * — the shape cloud-identity's OWN workspace-membership layer produces
13
12
  * (`decodeUnverifiedBearer`), not a generic OAuth JWT. This boilerplate's
14
- * verified payload (a plain `jose` `JWTPayload`, Clerk-as-AS) carries no
13
+ * verified claims (a plain claims record, Clerk-as-AS) carries no
15
14
  * `workspaceId`/`roles` claims, so it is NOT a `TenantContext` and cannot
16
15
  * go through the "bearer" path without inventing fields cloud-identity
17
16
  * never asked for. It goes through the "session" path instead
@@ -21,29 +20,25 @@
21
20
  * organization, on either path) — this is a compatible substitution
22
21
  * documented at the one call site it affects, never a bypass of the guard.
23
22
  */
24
- import { type OAuthCtx, type ScopeFilterable } from "@vantageos/cloud-identity";
25
- import type { JWTPayload } from "jose";
26
23
  export interface ResolvedTenant {
27
24
  tenantId: string;
28
25
  }
29
26
  /**
30
- * Resolve the caller's tenant from the verified JWT payload. Refuses
27
+ * Resolve the caller's tenant from the verified session claims. Refuses
31
28
  * (throws) when the token carries no organization — never falls through to
32
29
  * an unscoped/master view. Callers MUST treat the thrown error as a refusal
33
30
  * (an explicit permission-denied response), never silently grant access.
34
31
  */
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;
32
+ export declare function resolveTenantFromJwtPayload(payload: Readonly<Record<string, unknown>>): ResolvedTenant;
33
+ /** A row that carries the STORED id of the organisation that owns it. */
34
+ export interface TenantOwned {
35
+ orgId?: string | null;
36
+ }
44
37
  /**
45
- * Filter rows by the caller's tenant, reusing cloud-identity's
46
- * `scopeFilterList` unmodified — this module never reimplements row
47
- * filtering.
38
+ * Filter rows by the caller's tenant with cloud-identity's `isRowInTenant`: a row is visible
39
+ * only when it is stamped with an organisation and that id strictly equals the caller's. An
40
+ * unstamped row is never visible. This replaces the deprecated `scopeFilterList`, which
41
+ * selected rows by a creator NAME (backend standard R-53 clause 1 forbids that); identifiers
42
+ * are compared byte for byte, a slug is never compared against an id.
48
43
  */
49
- export declare function scopeRowsForTenant<T extends ScopeFilterable>(tenantId: string, rows: T[]): T[];
44
+ export declare function scopeRowsForTenant<T extends TenantOwned>(tenantId: string, rows: T[]): T[];
package/dist/tenancy.js CHANGED
@@ -1,8 +1,7 @@
1
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
2
+ * Multi-tenant glue: takes the verified session claims produced by
3
+ * auth.ts's OAuth 2.1 resource-server gate (@vantageos/cloud-identity's `verifyMcpAccessToken`,
4
+ * `session.claims`) and hands them to @vantageos/cloud-identity to resolve the caller's tenant and scope
6
5
  * rows. This module never re-verifies the token and never reimplements
7
6
  * cloud-identity's authorization logic — it only adapts shapes.
8
7
  *
@@ -11,7 +10,7 @@
11
10
  * entry path expects a full `TenantContext` ({ workspaceId, userId, roles })
12
11
  * — the shape cloud-identity's OWN workspace-membership layer produces
13
12
  * (`decodeUnverifiedBearer`), not a generic OAuth JWT. This boilerplate's
14
- * verified payload (a plain `jose` `JWTPayload`, Clerk-as-AS) carries no
13
+ * verified claims (a plain claims record, Clerk-as-AS) carries no
15
14
  * `workspaceId`/`roles` claims, so it is NOT a `TenantContext` and cannot
16
15
  * go through the "bearer" path without inventing fields cloud-identity
17
16
  * never asked for. It goes through the "session" path instead
@@ -21,9 +20,9 @@
21
20
  * organization, on either path) — this is a compatible substitution
22
21
  * documented at the one call site it affects, never a bypass of the guard.
23
22
  */
24
- import { requireTenantId, scopeFilterList, } from "@vantageos/cloud-identity";
23
+ import { isRowInTenant, requireTenantId } from "@vantageos/cloud-identity";
25
24
  /**
26
- * Resolve the caller's tenant from the verified JWT payload. Refuses
25
+ * Resolve the caller's tenant from the verified session claims. Refuses
27
26
  * (throws) when the token carries no organization — never falls through to
28
27
  * an unscoped/master view. Callers MUST treat the thrown error as a refusal
29
28
  * (an explicit permission-denied response), never silently grant access.
@@ -34,25 +33,12 @@ export function resolveTenantFromJwtPayload(payload) {
34
33
  return { tenantId };
35
34
  }
36
35
  /**
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.
36
+ * Filter rows by the caller's tenant with cloud-identity's `isRowInTenant`: a row is visible
37
+ * only when it is stamped with an organisation and that id strictly equals the caller's. An
38
+ * unstamped row is never visible. This replaces the deprecated `scopeFilterList`, which
39
+ * selected rows by a creator NAME (backend standard R-53 clause 1 forbids that); identifiers
40
+ * are compared byte for byte, a slug is never compared against an id.
55
41
  */
56
42
  export function scopeRowsForTenant(tenantId, rows) {
57
- return scopeFilterList(scopeForTenant(tenantId), rows);
43
+ return rows.filter((row) => isRowInTenant({ rowOrgId: row.orgId, callerOrgId: tenantId }));
58
44
  }
@@ -1,4 +1,4 @@
1
- import type { McpUiToolMeta } from "@modelcontextprotocol/ext-apps";
1
+ import type { McpUiResourceMeta, McpUiToolMeta } from "@modelcontextprotocol/ext-apps";
2
2
  import type { ExampleItem } from "../exampleItems.js";
3
3
  export declare const ITEMS_UI_RESOURCE_URI = "ui://mcp-boilerplate/items-widget.html";
4
4
  /**
@@ -8,16 +8,40 @@ export declare const ITEMS_UI_RESOURCE_URI = "ui://mcp-boilerplate/items-widget.
8
8
  * fallback case.
9
9
  */
10
10
  export declare function readUiBundle(): string;
11
+ /**
12
+ * Resource-level `_meta.ui` (MCP Apps spec 2026-01-26). The CSP is derived from what the
13
+ * widget (src/ui/Widget.tsx) actually does: it renders props it is given, makes no network
14
+ * request, loads no third-party asset and embeds no frame, so every domain list is EMPTY
15
+ * (the secure default stated in the spec) — not omitted, so the declaration is explicit and
16
+ * a fork that adds a network call has to edit a visible list. `domain` is host-dependent
17
+ * (the spec says consult each host), so it is set only when the deployment supplies one.
18
+ */
19
+ export declare function buildItemsUiResourceMeta(domain?: string): McpUiResourceMeta;
11
20
  export declare function buildUiResource(props: {
12
21
  items: ExampleItem[];
13
22
  locale: "en" | "fr";
23
+ domain?: string;
14
24
  }): {
15
25
  uri: string;
16
26
  mimeType: string;
17
27
  text: string;
28
+ _meta: {
29
+ ui: McpUiResourceMeta;
30
+ };
18
31
  };
19
- /** GA nested `_meta.ui` value shared by the tool registration and the result. */
32
+ /**
33
+ * Tool-level `_meta`: the GA nested `_meta.ui.resourceUri` is the PRIMARY link, with explicit
34
+ * visibility. The `openai/*` keys are ADDITIVE hints for ChatGPT (same URI as the template,
35
+ * plus status strings); a client that ignores them loses nothing, and the server never reads
36
+ * them. `openai/ui` entrypoints are deliberately not emitted (optional; see the README).
37
+ */
20
38
  export declare const ITEMS_UI_META: McpUiToolMeta;
39
+ export declare const ITEMS_TOOL_META: {
40
+ readonly ui: McpUiToolMeta;
41
+ readonly "openai/outputTemplate": "ui://mcp-boilerplate/items-widget.html";
42
+ readonly "openai/toolInvocation/invoking": "Rendering items…";
43
+ readonly "openai/toolInvocation/invoked": "Items ready.";
44
+ };
21
45
  export interface RenderItemsUiResult {
22
46
  [key: string]: unknown;
23
47
  content: Array<{
@@ -27,8 +51,17 @@ export interface RenderItemsUiResult {
27
51
  type: "resource";
28
52
  resource: ReturnType<typeof buildUiResource>;
29
53
  }>;
54
+ structuredContent: {
55
+ items: ExampleItem[];
56
+ locale: "en" | "fr";
57
+ };
30
58
  _meta: {
31
59
  ui: McpUiToolMeta;
32
60
  };
33
61
  }
34
- export declare function buildRenderItemsUiResult(items: ExampleItem[], locale: "en" | "fr"): RenderItemsUiResult;
62
+ /**
63
+ * content[0] is the mandatory markdown fallback for hosts without MCP Apps (mcp-app-standard
64
+ * A-1: it stays first, as before); content[1] mirrors `structuredContent` as JSON text (clients
65
+ * that only read text still get the data); content[2] is the embedded ui:// resource.
66
+ */
67
+ export declare function buildRenderItemsUiResult(items: ExampleItem[], locale: "en" | "fr", domain?: string): RenderItemsUiResult;
@@ -35,25 +35,59 @@ function renderMarkdownFallback(items, locale) {
35
35
  export function readUiBundle() {
36
36
  return readFileSync(BUNDLE_PATH, "utf-8");
37
37
  }
38
+ /**
39
+ * Resource-level `_meta.ui` (MCP Apps spec 2026-01-26). The CSP is derived from what the
40
+ * widget (src/ui/Widget.tsx) actually does: it renders props it is given, makes no network
41
+ * request, loads no third-party asset and embeds no frame, so every domain list is EMPTY
42
+ * (the secure default stated in the spec) — not omitted, so the declaration is explicit and
43
+ * a fork that adds a network call has to edit a visible list. `domain` is host-dependent
44
+ * (the spec says consult each host), so it is set only when the deployment supplies one.
45
+ */
46
+ export function buildItemsUiResourceMeta(domain) {
47
+ return {
48
+ csp: { connectDomains: [], resourceDomains: [], frameDomains: [], baseUriDomains: [] },
49
+ ...(domain ? { domain } : {}),
50
+ prefersBorder: true,
51
+ };
52
+ }
38
53
  export function buildUiResource(props) {
39
54
  return {
40
55
  uri: ITEMS_UI_RESOURCE_URI,
41
56
  mimeType: RESOURCE_MIME_TYPE,
42
57
  text: readUiBundle(),
58
+ _meta: { ui: buildItemsUiResourceMeta(props.domain) },
43
59
  };
44
60
  }
45
- /** GA nested `_meta.ui` value shared by the tool registration and the result. */
61
+ /**
62
+ * Tool-level `_meta`: the GA nested `_meta.ui.resourceUri` is the PRIMARY link, with explicit
63
+ * visibility. The `openai/*` keys are ADDITIVE hints for ChatGPT (same URI as the template,
64
+ * plus status strings); a client that ignores them loses nothing, and the server never reads
65
+ * them. `openai/ui` entrypoints are deliberately not emitted (optional; see the README).
66
+ */
46
67
  export const ITEMS_UI_META = {
47
68
  resourceUri: ITEMS_UI_RESOURCE_URI,
69
+ visibility: ["model", "app"],
70
+ };
71
+ export const ITEMS_TOOL_META = {
72
+ ui: ITEMS_UI_META,
73
+ "openai/outputTemplate": ITEMS_UI_RESOURCE_URI,
74
+ "openai/toolInvocation/invoking": "Rendering items…",
75
+ "openai/toolInvocation/invoked": "Items ready.",
48
76
  };
49
- export function buildRenderItemsUiResult(items, locale) {
50
- const fallbackMarkdown = renderMarkdownFallback(items, locale);
51
- const resource = buildUiResource({ items, locale });
77
+ /**
78
+ * content[0] is the mandatory markdown fallback for hosts without MCP Apps (mcp-app-standard
79
+ * A-1: it stays first, as before); content[1] mirrors `structuredContent` as JSON text (clients
80
+ * that only read text still get the data); content[2] is the embedded ui:// resource.
81
+ */
82
+ export function buildRenderItemsUiResult(items, locale, domain) {
83
+ const structuredContent = { items, locale };
52
84
  return {
53
85
  content: [
54
- { type: "text", text: fallbackMarkdown },
55
- { type: "resource", resource },
86
+ { type: "text", text: renderMarkdownFallback(items, locale) },
87
+ { type: "text", text: JSON.stringify(structuredContent) },
88
+ { type: "resource", resource: buildUiResource({ items, locale, domain }) },
56
89
  ],
90
+ structuredContent,
57
91
  _meta: { ui: ITEMS_UI_META },
58
92
  };
59
93
  }
@@ -0,0 +1,2 @@
1
+ export declare const SERVER_NAME = "mcp-boilerplate";
2
+ export declare const SERVER_VERSION: string;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The advertised server version is DERIVED from package.json (never a literal).
3
+ * `src/version.ts` and `dist/version.js` both sit one level under the package root,
4
+ * so the same relative URL resolves in source mode (vitest) and in the built output.
5
+ */
6
+ import { readFileSync } from "node:fs";
7
+ export const SERVER_NAME = "mcp-boilerplate";
8
+ export const SERVER_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
@@ -0,0 +1,82 @@
1
+ # Architecture
2
+
3
+ ## Single server, two transports
4
+
5
+ `src/mcpServerFactory.ts` builds one `McpServer` definition (tools registered
6
+ once). `src/stdio.ts` and `src/httpApp.ts` each hand that factory to an official
7
+ SDK v2 serving entry: `serveStdio` for local/self-host clients and
8
+ `createMcpHandler` for remote clients over stateless Streamable HTTP. Neither
9
+ transport exposes a custom REST route for tool calls; every tool invocation goes
10
+ through the SDK's own JSON-RPC method dispatch (`tools/list`, `tools/call`).
11
+
12
+ The HTTP transport builds a brand-new `McpServer` for every request through the
13
+ handler's factory and discards it at the end of the request. Nothing survives
14
+ past one request/response cycle, so the endpoint is horizontally
15
+ load-balanceable and never leaks state between concurrent callers.
16
+
17
+ ## Wire posture
18
+
19
+ The server speaks MCP protocol **2026-07-28** and still serves the 2025 era from
20
+ the same factory, with no private SDK field and no hand-written request schema:
21
+
22
+ - **2026-07-28**: the client opens with `server/discover`. The SDK builds the
23
+ discover result and stamps `resultType`, `ttlMs` and `cacheScope` on
24
+ `server/discover`, `tools/list` and `resources/*` from the `cacheHints` option
25
+ set in the factory.
26
+ - **2025 era**: the client opens with `initialize`. On HTTP the SDK serves it
27
+ through its stateless legacy leg (`createMcpHandler` default `legacy:
28
+ "stateless"`); on stdio `serveStdio` pins one instance to the connection.
29
+ Legacy results never carry the cache fields.
30
+ - **Floor 2025-06-18**: `supportedProtocolVersions` (a `ProtocolOptions` field of
31
+ `@modelcontextprotocol/server`) lists `2025-11-25` and `2025-06-18` only. An
32
+ `initialize` below the floor is counter-offered `2025-11-25`.
33
+ - **Version**: `serverInfo.version` is read from `package.json`
34
+ (`src/version.ts`), never typed.
35
+
36
+ `@vantageos/mcp-protocol-kit` is no longer a dependency: the SDK emits the
37
+ result types the kit used to add. Tests assert both eras
38
+ (`tests/protocol20260728.test.ts`, HTTP in-process and stdio on the built binary).
39
+
40
+ ## Authentication posture
41
+
42
+ Three tiers (`AUTH_MODE`, README): `public` (no gate), `oauth` with this server as
43
+ its own authorization server (default), and `oauth` against an external issuer
44
+ (resource server only).
45
+
46
+ In the default tier `src/oauth/` is the authorization server and
47
+ `@vantageos/cloud-identity` is also the resource server in front of `/mcp`
48
+ (`verifyMcpAccessToken`; an unreachable key set answers 503, never 401), so it is the
49
+ only identity layer. The authorization decisions are delegated to it; this
50
+ repository owns HTTP, the stores (interfaces, in-memory adapters, durable-adapter
51
+ contract in `docs/oauth-stores.md`), the Clerk calls and the RS256 signature. The
52
+ flow is: Clerk sign-in, organisation picker with consent, a code bound to the
53
+ person and the chosen organisation, then `POST /token` (PKCE S256) mints a JWT
54
+ whose `aud` is `<base>/mcp` and whose `org_id` is the chosen organisation.
55
+ `tenancy.ts` reads that claim and `list_items` keeps the rows whose stored
56
+ `orgId` equals it. Missing configuration answers 503 on every authorization route
57
+ and on `/mcp`.
58
+
59
+ ## Evals layer
60
+
61
+ `evals/evals.json` declares a case-set (handshake, `tools/list` coverage,
62
+ `tools/call` happy paths, and the public-tier tenancy assertion) as data,
63
+ never as hand-written test code. `evals/run.ts` is the minimal harness that
64
+ drives one JSON-RPC op per case against an already-connected SDK `Client`
65
+ and checks the declared assertion — it reuses the SDK's own `Client`, never
66
+ a reinvented protocol layer.
67
+
68
+ Two consumers, on purpose:
69
+
70
+ - `evals/evals.test.ts` (`npm run evals`) spawns the real built
71
+ `dist/stdio.js` child (same pattern as `tests/stdioHandshake.test.ts`)
72
+ and runs every declared case against it end to end. Vitest's own
73
+ pass/fail reporting and non-zero exit on any failing case ARE the
74
+ harness's ratio + exit-code contract — no separate reporter is built.
75
+ - `tests/evalsRunner.test.ts` (`npm test`) unit-tests `evals/run.ts`'s
76
+ assertion logic against an in-process fake client, including the RED
77
+ path (a deliberately broken case failing with a message naming both the
78
+ expected and observed values) and the fail-loud empty-case-set guard.
79
+
80
+ `vitest.evals.config.ts` keeps the eval-case glob (`evals/**/*.test.ts`)
81
+ separate from the default test glob (`tests/**/*.test.ts`), so the two
82
+ ratios stay independently reproducible from a clean build.
@@ -0,0 +1,60 @@
1
+ # Authorization-server stores and the durable-adapter contract
2
+
3
+ The authorization server (`src/oauth/`) keeps two kinds of state behind small interfaces. The
4
+ in-memory adapters ship for tests and single-process development; a production deployment with
5
+ more than one instance (or one that restarts) MUST inject durable adapters through
6
+ `AuthorizationServerDeps` (`buildHttpApp({ authMode: "oauth", authorizationServer: { codeStore, clientRegistry } })`).
7
+
8
+ ## `AuthorizationCodeStore` (cloud-identity type, `src/oauth/codeStore.ts`)
9
+
10
+ ```ts
11
+ interface AuthorizationCodeStore {
12
+ put(record: AuthorizationCodeRecord): Promise<void>;
13
+ consume(codeHash: string): Promise<ConsumeCodeResult>; // ok | unknown | already-used
14
+ }
15
+ ```
16
+
17
+ A durable adapter MUST honour all of the following; cloud-identity relies on each:
18
+
19
+ 1. **Digest only.** `codeHash` is the digest of the code; the raw code is never stored.
20
+ 2. **Atomic consume.** Of two concurrent `consume` calls for the same digest exactly one returns
21
+ `{ status: "ok", record }`; the other returns `{ status: "already-used" }`. A read followed by
22
+ a write is not an implementation (use a single conditional update, `DELETE ... RETURNING`,
23
+ `GETDEL`, or a transaction).
24
+ 3. **Distinguish the two misses.** `unknown` (never seen, or expired and swept) is not
25
+ `already-used`: a replayed code is reported as `code-reused`, which lets the operator revoke
26
+ what the first redemption issued.
27
+ 4. **Expiry.** `record.expiresAt` (epoch ms) is enforced by cloud-identity at exchange time; the
28
+ store should also sweep expired rows (a TTL index) so the table stays bounded.
29
+ 5. **Failure is loud.** A store that cannot answer throws; cloud-identity turns that into a 503
30
+ refusal, never a grant.
31
+
32
+ Access tokens are self-contained RS256 JWTs (one hour, no refresh token), so there is no token
33
+ table. An operator who needs revocation before expiry adds a `jti` deny-list checked by the
34
+ resource-server gate; that is outside this boilerplate.
35
+
36
+ ## `ClientRegistry` (`src/oauth/clientRegistry.ts`)
37
+
38
+ ```ts
39
+ interface ClientRegistry {
40
+ get(clientId: string): Promise<RegisteredClient | null>;
41
+ register(client: RegisteredClient): Promise<void>;
42
+ }
43
+ ```
44
+
45
+ Holds Dynamic Client Registration (DCR) clients only. A URL-shaped `client_id` is a Client ID
46
+ Metadata Document (CIMD) and is never stored: it is fetched, validated and used per request.
47
+
48
+ A durable adapter MUST: store `redirectUris` exactly as registered (cloud-identity compares them
49
+ by exact equality), return `revoked: true` for a revoked client instead of deleting it, and cap
50
+ the number of rows (DCR is an unauthenticated endpoint; `/register` is also rate-limited per IP
51
+ in-process).
52
+
53
+ ## CIMD fetch and DNS rebinding
54
+
55
+ `resolveCimdClient` accepts an https URL with a path only, refuses any host that resolves to a
56
+ non-public address (loopback, private, link-local, CGNAT, multicast, and the IPv4-mapped, IPv4-compatible and NAT64 spellings of those, in any textual form), follows no
57
+ redirect, reads at most 16 KiB (streamed, the read is cancelled once the cap is crossed) and gives up after 5 s. It resolves the host and then `fetch`
58
+ resolves it again, so a DNS-rebinding window exists. A deployment on a hostile network injects a
59
+ `fetchImpl` that connects to the validated address (for example an undici dispatcher with a
60
+ custom `lookup`).
@@ -0,0 +1,25 @@
1
+ # Own authorization server (OAuth tier, the default). Every value below is REQUIRED; a missing
2
+ # one makes /authorize, /token, /register and /mcp answer 503 (fail-closed). No secret is
3
+ # committed here — fill these from your secret manager.
4
+
5
+ # Public origin of this server (no trailing slash). The protected resource is <PUBLIC_BASE_URL>/mcp.
6
+ PUBLIC_BASE_URL=https://your-server.example.com
7
+
8
+ # Clerk: the instance's frontend API URL (issuer of the person's session token).
9
+ CLERK_DOMAIN=https://clerk.your-domain.example.com
10
+ # Clerk sign-in page the person is sent to, and this server's /authorize/callback URL.
11
+ AUTHORIZE_SIGN_IN_URL=https://accounts.your-domain.example.com/sign-in
12
+ AUTHORIZE_CALLBACK_URL=https://your-server.example.com/authorize/callback
13
+ # Clerk Backend API key (memberships + user lookup).
14
+ CLERK_SECRET_KEY=
15
+
16
+ # HMAC secret for the signed authorize state, at least 32 characters.
17
+ AUTHORIZE_STATE_SECRET=
18
+
19
+ # RS256 private JWK that signs access tokens: `node scripts/generate-signing-key.mjs`.
20
+ MCP_OAUTH_SIGNING_JWK=
21
+
22
+ # Optional: extra Clerk `azp` origins accepted for the session token (comma-separated).
23
+ # AUTHORIZE_AUTHORIZED_PARTIES=
24
+ # Optional: dedicated sandbox origin of the MCP Apps widget (host-dependent).
25
+ # MCP_UI_DOMAIN=
package/mcp.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
3
+ "mcpServers": {
4
+ "mcp-boilerplate": {
5
+ "type": "streamable-http",
6
+ "url": "https://your-server.example.com/mcp",
7
+ "extensions": {
8
+ "com.openai": {
9
+ "auth": {
10
+ "type": "oauth",
11
+ "client": {
12
+ "mode": "cimd"
13
+ }
14
+ }
15
+ }
16
+ }
17
+ }
18
+ }
19
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@vantageos/mcp-boilerplate",
3
- "version": "0.1.2",
4
- "description": "Clonable MCP server skeleton: dual-transport bootstrap (stdio + Streamable HTTP, stateless) on the official @modelcontextprotocol/sdk, ready to extend with protocol-kit, auth, tenant and UI layers.",
5
- "description_fr": "Squelette de serveur MCP clonable : bootstrap double-transport (stdio + Streamable HTTP, sans état) sur le @modelcontextprotocol/sdk officiel, prêt à étendre avec les couches protocol-kit, authentification, tenant et UI.",
3
+ "version": "0.2.0",
4
+ "description": "Clonable MCP server skeleton on the official @modelcontextprotocol/server SDK v2 (protocol 2026-07-28 + 2025 legacy, stdio + stateless Streamable HTTP), with its own OAuth 2.1 authorization server (Clerk person + organisation, cloud-identity), MCP Apps UI, tenant scoping, FR/EN i18n and a declarative eval harness.",
5
+ "description_fr": "Squelette de serveur MCP clonable sur le SDK officiel @modelcontextprotocol/server v2 (protocole 2026-07-28 + héritage 2025, stdio + Streamable HTTP sans état), avec son propre serveur d'autorisation OAuth 2.1 (personne + organisation Clerk, cloud-identity), UI MCP Apps, scoping tenant, i18n FR/EN et un harnais d'évaluations déclaratif.",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",
8
8
  "types": "./dist/index.d.ts",
@@ -23,7 +23,14 @@
23
23
  "CHANGELOG.md",
24
24
  "LICENSE",
25
25
  ".claude-plugin/plugin.json",
26
- "examples/claude-desktop.json"
26
+ ".mcp.json",
27
+ "plugin.json",
28
+ "mcp.json",
29
+ "skills",
30
+ "scripts/plugin-manifest.source.json",
31
+ "docs",
32
+ "examples/claude-desktop.json",
33
+ "examples/oauth.env.example"
27
34
  ],
28
35
  "publishConfig": {
29
36
  "access": "public"
@@ -48,32 +55,34 @@
48
55
  "start:stdio": "node dist/stdio.js",
49
56
  "evals": "npm run build && npm run build:ui && vitest run --config vitest.evals.config.ts",
50
57
  "prepublishOnly": "npm run build && npm run build:ui && npm test && npm run evals",
51
- "lint": "biome check src evals tests",
52
- "lint:fix": "biome check --write src evals tests"
58
+ "lint": "biome check src evals tests scripts",
59
+ "lint:fix": "biome check --write src evals tests scripts"
53
60
  },
54
61
  "keywords": [
55
62
  "mcp",
56
63
  "model-context-protocol",
57
64
  "boilerplate",
58
- "starter"
65
+ "starter",
66
+ "oauth",
67
+ "mcp-apps",
68
+ "chatgpt"
59
69
  ],
60
70
  "license": "MIT",
61
71
  "engines": {
62
72
  "node": ">=20"
63
73
  },
64
74
  "dependencies": {
65
- "@modelcontextprotocol/ext-apps": "^1.7.5",
66
- "@modelcontextprotocol/sdk": "^1.13.0",
67
- "@vantageos/cloud-identity": "^0.3.0",
68
- "@vantageos/mcp-auth-kit": "^0.1.0",
69
- "@vantageos/mcp-protocol-kit": "^0.2.0",
75
+ "@modelcontextprotocol/ext-apps": "^2.0.3",
76
+ "@modelcontextprotocol/server": "^2.3.1",
77
+ "@vantageos/cloud-identity": "^0.15.0",
70
78
  "@vantageos/mosaic-i18n": "^0.1.1",
71
79
  "hono": "^4.6.0",
72
80
  "jose": "^6.2.7",
73
- "zod": "^3.23.8"
81
+ "zod": "^4.4.3"
74
82
  },
75
83
  "devDependencies": {
76
84
  "@biomejs/biome": "1.9.4",
85
+ "@modelcontextprotocol/client": "^2.3.1",
77
86
  "@types/node": "^20.14.0",
78
87
  "@types/react": "^19.0.0",
79
88
  "@types/react-dom": "^19.0.0",
package/plugin.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
+ "name": "vantage-mcp-boilerplate",
4
+ "version": "0.2.0",
5
+ "description": "Clonable MCP server template: SDK v2 on protocol 2026-07-28 (stdio + stateless Streamable HTTP), its own OAuth 2.1 authorization server (Clerk person + organisation), MCP Apps UI, FR/EN i18n and a declarative eval harness. Scaffold, extend, publish.",
6
+ "author": {
7
+ "name": "ElPi Corp / Laurent Perello"
8
+ },
9
+ "homepage": "https://github.com/elpiarthera/vantage-mcp-boilerplate#readme",
10
+ "repository": "https://github.com/elpiarthera/vantage-mcp-boilerplate",
11
+ "license": "MIT",
12
+ "keywords": [
13
+ "mcp",
14
+ "claude-code",
15
+ "chatgpt",
16
+ "boilerplate",
17
+ "starter",
18
+ "template",
19
+ "stdio",
20
+ "streamable-http",
21
+ "oauth",
22
+ "multi-tenant",
23
+ "mcp-apps",
24
+ "i18n",
25
+ "vantage",
26
+ "elpi"
27
+ ],
28
+ "extensions": {
29
+ "com.openai": {
30
+ "onboardingSkill": "./skills/connect-mcp-server/SKILL.md",
31
+ "interface": {
32
+ "displayName": "VantageOS MCP Boilerplate",
33
+ "shortDescription": "Clonable MCP server template with OAuth 2.1, MCP Apps UI and FR/EN i18n.",
34
+ "longDescription": "Clonable MCP server template: SDK v2 on protocol 2026-07-28 (stdio + stateless Streamable HTTP), its own OAuth 2.1 authorization server (Clerk person + organisation), MCP Apps UI, FR/EN i18n and a declarative eval harness. Scaffold, extend, publish.",
35
+ "developerName": "ElPi Corp / Laurent Perello",
36
+ "websiteURL": "https://github.com/elpiarthera/vantage-mcp-boilerplate#readme",
37
+ "capabilities": [
38
+ "Read"
39
+ ]
40
+ }
41
+ }
42
+ }
43
+ }
@@ -0,0 +1,35 @@
1
+ {
2
+ "serverName": "mcp-boilerplate",
3
+ "pluginName": "vantage-mcp-boilerplate",
4
+ "displayName": "VantageOS MCP Boilerplate",
5
+ "serverUrlPlaceholder": "https://your-server.example.com/mcp",
6
+ "urlEnvVar": "MCP_BOILERPLATE_URL",
7
+ "onboardingSkill": "skills/connect-mcp-server/SKILL.md",
8
+ "category": "mcp-server",
9
+ "bilingual": true,
10
+ "description": {
11
+ "en": "Clonable MCP server template: SDK v2 on protocol 2026-07-28 (stdio + stateless Streamable HTTP), its own OAuth 2.1 authorization server (Clerk person + organisation), MCP Apps UI, FR/EN i18n and a declarative eval harness. Scaffold, extend, publish.",
12
+ "fr": "Template de serveur MCP clonable : SDK v2 sur le protocole 2026-07-28 (stdio + Streamable HTTP sans état), serveur d'autorisation OAuth 2.1 propre (personne + organisation Clerk), UI MCP Apps, i18n FR/EN et un harnais d'évaluations déclaratif. Scaffolder, étendre, publier."
13
+ },
14
+ "shortDescription": "Clonable MCP server template with OAuth 2.1, MCP Apps UI and FR/EN i18n.",
15
+ "keywords": [
16
+ "mcp",
17
+ "claude-code",
18
+ "chatgpt",
19
+ "boilerplate",
20
+ "starter",
21
+ "template",
22
+ "stdio",
23
+ "streamable-http",
24
+ "oauth",
25
+ "multi-tenant",
26
+ "mcp-apps",
27
+ "i18n",
28
+ "vantage",
29
+ "elpi"
30
+ ],
31
+ "openai": {
32
+ "auth": { "type": "oauth", "client": { "mode": "cimd" } },
33
+ "capabilities": ["Read"]
34
+ }
35
+ }