@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.
- package/.claude-plugin/plugin.json +16 -8
- package/.mcp.json +8 -0
- package/CHANGELOG.md +67 -1
- package/README.fr.md +177 -142
- package/README.md +170 -133
- package/dist/auth.d.ts +20 -10
- package/dist/auth.js +9 -8
- package/dist/config.d.ts +10 -3
- package/dist/config.js +31 -3
- package/dist/exampleItems.d.ts +5 -4
- package/dist/exampleItems.js +3 -0
- package/dist/http.js +11 -1
- package/dist/httpApp.d.ts +23 -30
- package/dist/httpApp.js +73 -74
- package/dist/i18n.d.ts +6 -0
- package/dist/i18n.js +9 -0
- package/dist/locales/en.json +9 -0
- package/dist/locales/fr.json +9 -0
- package/dist/mcpServerFactory.d.ts +42 -41
- package/dist/mcpServerFactory.js +114 -165
- package/dist/oauth/authorizationServer.d.ts +56 -0
- package/dist/oauth/authorizationServer.js +423 -0
- package/dist/oauth/clerk.d.ts +15 -0
- package/dist/oauth/clerk.js +59 -0
- package/dist/oauth/clientRegistry.d.ts +50 -0
- package/dist/oauth/clientRegistry.js +203 -0
- package/dist/oauth/codeStore.d.ts +11 -0
- package/dist/oauth/codeStore.js +28 -0
- package/dist/oauth/picker.d.ts +11 -0
- package/dist/oauth/picker.js +67 -0
- package/dist/oauth/settings.d.ts +48 -0
- package/dist/oauth/settings.js +100 -0
- package/dist/oauth/tokenIssuer.d.ts +22 -0
- package/dist/oauth/tokenIssuer.js +61 -0
- package/dist/stdio.d.ts +7 -6
- package/dist/stdio.js +20 -19
- package/dist/tenancy.d.ts +16 -21
- package/dist/tenancy.js +12 -26
- package/dist/ui/uiResource.d.ts +39 -6
- package/dist/ui/uiResource.js +62 -13
- package/dist/version.d.ts +2 -0
- package/dist/version.js +8 -0
- package/docs/architecture.md +82 -0
- package/docs/oauth-stores.md +60 -0
- package/examples/oauth.env.example +25 -0
- package/mcp.json +19 -0
- package/package.json +23 -13
- package/plugin.json +43 -0
- package/scripts/generate-signing-key.mjs +14 -0
- package/scripts/plugin-manifest.source.json +35 -0
- 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
|
|
3
|
-
* auth.ts's OAuth 2.1 resource-server gate (
|
|
4
|
-
* @vantageos/
|
|
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
|
|
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
|
|
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:
|
|
36
|
-
/**
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
|
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
|
|
3
|
-
* auth.ts's OAuth 2.1 resource-server gate (
|
|
4
|
-
* @vantageos/
|
|
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
|
|
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 {
|
|
23
|
+
import { isRowInTenant, requireTenantId } from "@vantageos/cloud-identity";
|
|
25
24
|
/**
|
|
26
|
-
* Resolve the caller's tenant from the verified
|
|
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
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
|
43
|
+
return rows.filter((row) => isRowInTenant({ rowOrgId: row.orgId, callerOrgId: tenantId }));
|
|
58
44
|
}
|
package/dist/ui/uiResource.d.ts
CHANGED
|
@@ -1,23 +1,47 @@
|
|
|
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
|
/**
|
|
5
5
|
* Reads the single-file bundle produced by `npm run build:ui`
|
|
6
|
-
* (vite-plugin-singlefile).
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* (vite-plugin-singlefile). Never silently swallowed: a missing bundle is a build defect, not
|
|
7
|
+
* a runtime fallback case. The error that reaches the MCP client names no filesystem path;
|
|
8
|
+
* the detail goes to stderr (server side) only.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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;
|
package/dist/ui/uiResource.js
CHANGED
|
@@ -11,13 +11,18 @@
|
|
|
11
11
|
* shape written here.
|
|
12
12
|
*/
|
|
13
13
|
import { readFileSync } from "node:fs";
|
|
14
|
-
import
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
15
|
import { RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
|
|
16
16
|
export const ITEMS_UI_RESOURCE_URI = "ui://mcp-boilerplate/items-widget.html";
|
|
17
17
|
// dist/ui-bundle/ — NEVER dist/ui/ (that path is tsc's compiled-server
|
|
18
18
|
// output for this very module; see vite.config.ts for the collision this
|
|
19
|
-
// avoids).
|
|
20
|
-
|
|
19
|
+
// avoids). Resolved relative to THIS module, never to the working directory: an installed
|
|
20
|
+
// package runs from any cwd. Compiled (dist/ui/uiResource.js) the bundle is `../ui-bundle`;
|
|
21
|
+
// from source (src/ui/uiResource.ts, under vitest) it is the repository's built `dist/`.
|
|
22
|
+
const BUNDLE_CANDIDATES = [
|
|
23
|
+
fileURLToPath(new URL("../ui-bundle/widget.html", import.meta.url)),
|
|
24
|
+
fileURLToPath(new URL("../../dist/ui-bundle/widget.html", import.meta.url)),
|
|
25
|
+
];
|
|
21
26
|
function renderMarkdownFallback(items, locale) {
|
|
22
27
|
const title = locale === "fr" ? "Éléments" : "Items";
|
|
23
28
|
if (items.length === 0) {
|
|
@@ -28,32 +33,76 @@ function renderMarkdownFallback(items, locale) {
|
|
|
28
33
|
}
|
|
29
34
|
/**
|
|
30
35
|
* Reads the single-file bundle produced by `npm run build:ui`
|
|
31
|
-
* (vite-plugin-singlefile).
|
|
32
|
-
*
|
|
33
|
-
*
|
|
36
|
+
* (vite-plugin-singlefile). Never silently swallowed: a missing bundle is a build defect, not
|
|
37
|
+
* a runtime fallback case. The error that reaches the MCP client names no filesystem path;
|
|
38
|
+
* the detail goes to stderr (server side) only.
|
|
34
39
|
*/
|
|
35
40
|
export function readUiBundle() {
|
|
36
|
-
|
|
41
|
+
let lastError;
|
|
42
|
+
for (const candidate of BUNDLE_CANDIDATES) {
|
|
43
|
+
try {
|
|
44
|
+
return readFileSync(candidate, "utf-8");
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
lastError = err;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
console.error(`[ui] UI bundle unreadable (tried ${BUNDLE_CANDIDATES.join(", ")}):`, lastError);
|
|
51
|
+
throw new Error("UI bundle unavailable on this server");
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Resource-level `_meta.ui` (MCP Apps spec 2026-01-26). The CSP is derived from what the
|
|
55
|
+
* widget (src/ui/Widget.tsx) actually does: it renders props it is given, makes no network
|
|
56
|
+
* request, loads no third-party asset and embeds no frame, so every domain list is EMPTY
|
|
57
|
+
* (the secure default stated in the spec) — not omitted, so the declaration is explicit and
|
|
58
|
+
* a fork that adds a network call has to edit a visible list. `domain` is host-dependent
|
|
59
|
+
* (the spec says consult each host), so it is set only when the deployment supplies one.
|
|
60
|
+
*/
|
|
61
|
+
export function buildItemsUiResourceMeta(domain) {
|
|
62
|
+
return {
|
|
63
|
+
csp: { connectDomains: [], resourceDomains: [], frameDomains: [], baseUriDomains: [] },
|
|
64
|
+
...(domain ? { domain } : {}),
|
|
65
|
+
prefersBorder: true,
|
|
66
|
+
};
|
|
37
67
|
}
|
|
38
68
|
export function buildUiResource(props) {
|
|
39
69
|
return {
|
|
40
70
|
uri: ITEMS_UI_RESOURCE_URI,
|
|
41
71
|
mimeType: RESOURCE_MIME_TYPE,
|
|
42
72
|
text: readUiBundle(),
|
|
73
|
+
_meta: { ui: buildItemsUiResourceMeta(props.domain) },
|
|
43
74
|
};
|
|
44
75
|
}
|
|
45
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* Tool-level `_meta`: the GA nested `_meta.ui.resourceUri` is the PRIMARY link, with explicit
|
|
78
|
+
* visibility. The `openai/*` keys are ADDITIVE hints for ChatGPT (same URI as the template,
|
|
79
|
+
* plus status strings); a client that ignores them loses nothing, and the server never reads
|
|
80
|
+
* them. `openai/ui` entrypoints are deliberately not emitted (optional; see the README).
|
|
81
|
+
*/
|
|
46
82
|
export const ITEMS_UI_META = {
|
|
47
83
|
resourceUri: ITEMS_UI_RESOURCE_URI,
|
|
84
|
+
visibility: ["model", "app"],
|
|
85
|
+
};
|
|
86
|
+
export const ITEMS_TOOL_META = {
|
|
87
|
+
ui: ITEMS_UI_META,
|
|
88
|
+
"openai/outputTemplate": ITEMS_UI_RESOURCE_URI,
|
|
89
|
+
"openai/toolInvocation/invoking": "Rendering items…",
|
|
90
|
+
"openai/toolInvocation/invoked": "Items ready.",
|
|
48
91
|
};
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
92
|
+
/**
|
|
93
|
+
* content[0] is the mandatory markdown fallback for hosts without MCP Apps (mcp-app-standard
|
|
94
|
+
* A-1: it stays first, as before); content[1] mirrors `structuredContent` as JSON text (clients
|
|
95
|
+
* that only read text still get the data); content[2] is the embedded ui:// resource.
|
|
96
|
+
*/
|
|
97
|
+
export function buildRenderItemsUiResult(items, locale, domain) {
|
|
98
|
+
const structuredContent = { items, locale };
|
|
52
99
|
return {
|
|
53
100
|
content: [
|
|
54
|
-
{ type: "text", text:
|
|
55
|
-
{ type: "
|
|
101
|
+
{ type: "text", text: renderMarkdownFallback(items, locale) },
|
|
102
|
+
{ type: "text", text: JSON.stringify(structuredContent) },
|
|
103
|
+
{ type: "resource", resource: buildUiResource({ items, locale, domain }) },
|
|
56
104
|
],
|
|
105
|
+
structuredContent,
|
|
57
106
|
_meta: { ui: ITEMS_UI_META },
|
|
58
107
|
};
|
|
59
108
|
}
|
package/dist/version.js
ADDED
|
@@ -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
|
|
4
|
-
"description": "Clonable MCP server skeleton
|
|
5
|
-
"description_fr": "Squelette de serveur MCP clonable
|
|
3
|
+
"version": "0.2.1",
|
|
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,15 @@
|
|
|
23
23
|
"CHANGELOG.md",
|
|
24
24
|
"LICENSE",
|
|
25
25
|
".claude-plugin/plugin.json",
|
|
26
|
-
"
|
|
26
|
+
".mcp.json",
|
|
27
|
+
"plugin.json",
|
|
28
|
+
"mcp.json",
|
|
29
|
+
"skills",
|
|
30
|
+
"scripts/plugin-manifest.source.json",
|
|
31
|
+
"scripts/generate-signing-key.mjs",
|
|
32
|
+
"docs",
|
|
33
|
+
"examples/claude-desktop.json",
|
|
34
|
+
"examples/oauth.env.example"
|
|
27
35
|
],
|
|
28
36
|
"publishConfig": {
|
|
29
37
|
"access": "public"
|
|
@@ -48,32 +56,34 @@
|
|
|
48
56
|
"start:stdio": "node dist/stdio.js",
|
|
49
57
|
"evals": "npm run build && npm run build:ui && vitest run --config vitest.evals.config.ts",
|
|
50
58
|
"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"
|
|
59
|
+
"lint": "biome check src evals tests scripts",
|
|
60
|
+
"lint:fix": "biome check --write src evals tests scripts"
|
|
53
61
|
},
|
|
54
62
|
"keywords": [
|
|
55
63
|
"mcp",
|
|
56
64
|
"model-context-protocol",
|
|
57
65
|
"boilerplate",
|
|
58
|
-
"starter"
|
|
66
|
+
"starter",
|
|
67
|
+
"oauth",
|
|
68
|
+
"mcp-apps",
|
|
69
|
+
"chatgpt"
|
|
59
70
|
],
|
|
60
71
|
"license": "MIT",
|
|
61
72
|
"engines": {
|
|
62
73
|
"node": ">=20"
|
|
63
74
|
},
|
|
64
75
|
"dependencies": {
|
|
65
|
-
"@modelcontextprotocol/ext-apps": "^
|
|
66
|
-
"@modelcontextprotocol/
|
|
67
|
-
"@vantageos/cloud-identity": "^0.
|
|
68
|
-
"@vantageos/mcp-auth-kit": "^0.1.0",
|
|
69
|
-
"@vantageos/mcp-protocol-kit": "^0.2.0",
|
|
76
|
+
"@modelcontextprotocol/ext-apps": "^2.0.3",
|
|
77
|
+
"@modelcontextprotocol/server": "^2.3.1",
|
|
78
|
+
"@vantageos/cloud-identity": "^0.15.0",
|
|
70
79
|
"@vantageos/mosaic-i18n": "^0.1.1",
|
|
71
80
|
"hono": "^4.6.0",
|
|
72
81
|
"jose": "^6.2.7",
|
|
73
|
-
"zod": "^
|
|
82
|
+
"zod": "^4.4.3"
|
|
74
83
|
},
|
|
75
84
|
"devDependencies": {
|
|
76
85
|
"@biomejs/biome": "1.9.4",
|
|
86
|
+
"@modelcontextprotocol/client": "^2.3.1",
|
|
77
87
|
"@types/node": "^20.14.0",
|
|
78
88
|
"@types/react": "^19.0.0",
|
|
79
89
|
"@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.1",
|
|
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
|
+
}
|