@vantageos/mcp-boilerplate 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +12 -0
- package/README.fr.md +49 -5
- package/README.md +48 -5
- package/dist/build-info.json +1 -0
- package/dist/buildIdentity.d.ts +16 -0
- package/dist/buildIdentity.js +39 -0
- package/dist/embedded.generated.d.ts +225 -0
- package/dist/embedded.generated.js +8 -0
- package/dist/http.js +5 -3
- package/dist/httpApp.d.ts +6 -0
- package/dist/httpApp.js +64 -7
- package/dist/i18n.js +6 -16
- package/dist/landingPage.d.ts +24 -0
- package/dist/landingPage.js +109 -0
- package/dist/mcpServerFactory.d.ts +11 -0
- package/dist/mcpServerFactory.js +28 -7
- package/dist/servedToolRecords.d.ts +6 -0
- package/dist/servedToolRecords.js +11 -1
- package/dist/serverCard.d.ts +68 -0
- package/dist/serverCard.js +68 -0
- package/dist/serverIcon.d.ts +17 -0
- package/dist/serverIcon.js +26 -0
- package/dist/serverIdentity.d.ts +83 -0
- package/dist/serverIdentity.js +130 -0
- package/dist/ui/uiResource.d.ts +14 -1
- package/dist/ui/uiResource.js +11 -1
- package/dist/version.js +6 -5
- package/docs/manufact-gap.md +200 -0
- package/mcp-server.json +1 -1
- package/package.json +3 -2
- package/plugin.json +2 -4
- package/scripts/readme-fr.mjs +8 -3
- package/dist/locales/en.json +0 -88
- package/dist/locales/fr.json +0 -88
package/dist/httpApp.js
CHANGED
|
@@ -26,18 +26,28 @@ import { createMcpHandler } from "@modelcontextprotocol/server";
|
|
|
26
26
|
import { unauthorizedChallenge } from "@vantageos/cloud-identity";
|
|
27
27
|
import { Hono } from "hono";
|
|
28
28
|
import { authenticateRequest, protectedResourceMetadataDocument } from "./auth.js";
|
|
29
|
-
import { getBuildIdentity } from "./buildIdentity.js";
|
|
29
|
+
import { getBuildCommit, getBuildIdentity } from "./buildIdentity.js";
|
|
30
30
|
import { registerHostVerification } from "./hostVerification.js";
|
|
31
31
|
import { detectLocale, t, tBoth } from "./i18n.js";
|
|
32
|
-
import {
|
|
32
|
+
import { isBrowserLandingRequest, landingPageResponse } from "./landingPage.js";
|
|
33
|
+
import { createBoilerplateMcpServer, registeredToolSummaries } from "./mcpServerFactory.js";
|
|
33
34
|
import { createAuthorizationServer, } from "./oauth/authorizationServer.js";
|
|
34
35
|
import { MCP_SCOPE } from "./oauth/settings.js";
|
|
36
|
+
import { SERVER_CARD_PATHS, buildServerCard, serverCardResponse } from "./serverCard.js";
|
|
37
|
+
import { SERVER_ICON_MIME_TYPE, SERVER_ICON_PATH, SERVER_ICON_SVG } from "./serverIcon.js";
|
|
38
|
+
import { getServerIdentity } from "./serverIdentity.js";
|
|
35
39
|
import { loadServerManifest } from "./serverManifest.js";
|
|
36
40
|
import { resolveTenantFromJwtPayload } from "./tenancy.js";
|
|
37
41
|
import { callerScopeOf } from "./toolRoleGate.js";
|
|
38
42
|
import { ToolRolesConfigError, loadToolRoles } from "./toolRoles.js";
|
|
43
|
+
import { servedMcpUrl } from "./ui/sandboxDomain.js";
|
|
39
44
|
import { assertIdentityDeps, resolveVerifiedPerson, } from "./verifiedPerson.js";
|
|
40
45
|
import { SERVER_VERSION } from "./version.js";
|
|
46
|
+
function servedLocation(publicBaseUrl, requestUrl) {
|
|
47
|
+
const configured = publicBaseUrl ? servedMcpUrl({ PUBLIC_BASE_URL: publicBaseUrl }) : undefined;
|
|
48
|
+
const mcpUrl = configured ?? new URL("/mcp", requestUrl).href;
|
|
49
|
+
return { mcpUrl, baseUrl: new URL("./", mcpUrl).href, configured: configured !== undefined };
|
|
50
|
+
}
|
|
41
51
|
const PRM_PATHS = [
|
|
42
52
|
"/.well-known/oauth-protected-resource",
|
|
43
53
|
"/.well-known/oauth-protected-resource/mcp",
|
|
@@ -94,9 +104,45 @@ export function buildHttpApp(deps = {}) {
|
|
|
94
104
|
return config;
|
|
95
105
|
})()
|
|
96
106
|
: { applicable: false };
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
107
|
+
// The public identity (S-49), read once at start: a value neither the environment nor
|
|
108
|
+
// package.json carries stops the server here, naming it, instead of serving a placeholder.
|
|
109
|
+
const identity = getServerIdentity();
|
|
110
|
+
// Unauthenticated (S-7): the version AND the identity of the tree being served (C-25): buildId is
|
|
111
|
+
// the git commit the tree was built from, buildHash the hash of the files this process runs
|
|
112
|
+
// from, so a deployment is proven from what the server returns, not from what the deploy printed.
|
|
113
|
+
app.get("/health", (c) => c.json({
|
|
114
|
+
status: "ok",
|
|
115
|
+
version: SERVER_VERSION,
|
|
116
|
+
buildId: getBuildCommit(),
|
|
117
|
+
buildHash: getBuildIdentity(),
|
|
118
|
+
}));
|
|
119
|
+
// Discoverability (S-49 icon, S-50), public and registered BEFORE the auth gate below: a route
|
|
120
|
+
// that answers ends the chain, so none of them is ever gated. They read no credential and grant
|
|
121
|
+
// nothing. The setup page at /mcp sits after the Origin check (S-22 holds on every /mcp method).
|
|
122
|
+
// - the serverInfo icon, as SVG, under a CSP that forbids script and external references;
|
|
123
|
+
app.get(`/${SERVER_ICON_PATH}`, (c) => c.body(SERVER_ICON_SVG, 200, {
|
|
124
|
+
"content-type": `${SERVER_ICON_MIME_TYPE}; charset=utf-8`,
|
|
125
|
+
"cache-control": "public, max-age=86400",
|
|
126
|
+
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'",
|
|
127
|
+
"x-content-type-options": "nosniff",
|
|
128
|
+
}));
|
|
129
|
+
// - the MCP Server Card at every path a scanner probes (src/serverCard.ts), its tool summary
|
|
130
|
+
// read from the tools a server built by the factory registers (computed once, on first read);
|
|
131
|
+
let cardTools;
|
|
132
|
+
for (const path of SERVER_CARD_PATHS) {
|
|
133
|
+
app.get(path, (c) => {
|
|
134
|
+
cardTools ??= registeredToolSummaries(createBoilerplateMcpServer({ locale: "en" }));
|
|
135
|
+
const where = servedLocation(deps.publicBaseUrl, c.req.url);
|
|
136
|
+
const card = buildServerCard({
|
|
137
|
+
identity,
|
|
138
|
+
mcpUrl: where.mcpUrl,
|
|
139
|
+
baseUrl: where.baseUrl,
|
|
140
|
+
capabilities: manifest.capabilities,
|
|
141
|
+
tools: [...cardTools],
|
|
142
|
+
});
|
|
143
|
+
return serverCardResponse(c.req.raw, card, where.configured);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
100
146
|
// S-45: the verification path of every declared target host, served from configuration.
|
|
101
147
|
registerHostVerification(app, manifest.distribution.targets);
|
|
102
148
|
// S-22 Origin validation, on /mcp ONLY and registered BEFORE the auth gate: a request that carries
|
|
@@ -104,8 +150,9 @@ export function buildHttpApp(deps = {}) {
|
|
|
104
150
|
// insensitive), else 403 before any token is looked at. No Origin header (server-to-server, Claude
|
|
105
151
|
// Code, a connector backend) passes as before. `null`, an empty value and near-misses such as
|
|
106
152
|
// `https://claude.ai.evil.example` are not in the list, so they are refused.
|
|
107
|
-
// Declared divergence: /health
|
|
108
|
-
//
|
|
153
|
+
// Declared divergence: /health, the icon, the server card paths (/mcp/server-card included, served
|
|
154
|
+
// above) and the OAuth/discovery routes (/.well-known/*, /authorize, /token, /register) are NOT
|
|
155
|
+
// covered — they are public metadata, browser navigations or CORS calls of the sign-in flow
|
|
109
156
|
// (/register has its own MCP_REGISTER_CORS_ORIGINS allowlist) and S-22 guards the MCP endpoint.
|
|
110
157
|
const allowedOrigins = new Set((deps.allowedOrigins ?? []).map((o) => o.toLowerCase()));
|
|
111
158
|
app.use("/mcp", async (c, next) => {
|
|
@@ -118,6 +165,15 @@ export function buildHttpApp(deps = {}) {
|
|
|
118
165
|
message_fr: t("fr", "auth.originNotAllowed"),
|
|
119
166
|
}, 403);
|
|
120
167
|
});
|
|
168
|
+
// S-50 setup page at the MCP URL, after the Origin check and BEFORE the auth gate: a request that
|
|
169
|
+
// cannot be an MCP request (a browser GET: no Authorization, no text/event-stream) gets the
|
|
170
|
+
// public page (src/landingPage.ts); every other request continues to the gate unchanged.
|
|
171
|
+
app.get("/mcp", async (c, next) => {
|
|
172
|
+
if (!isBrowserLandingRequest(c.req.raw))
|
|
173
|
+
return next();
|
|
174
|
+
const where = servedLocation(deps.publicBaseUrl, c.req.url);
|
|
175
|
+
return landingPageResponse(identity, where.mcpUrl, authMode, where.configured);
|
|
176
|
+
});
|
|
121
177
|
if (authMode === "oauth") {
|
|
122
178
|
// Resolves the resource-server configuration per request: the own-AS tier reads its
|
|
123
179
|
// environment lazily, so a missing value is a 503 on the request, not a crash at boot.
|
|
@@ -211,6 +267,7 @@ export function buildHttpApp(deps = {}) {
|
|
|
211
267
|
caller,
|
|
212
268
|
locale,
|
|
213
269
|
uiDomain: deps.uiDomain,
|
|
270
|
+
baseUrl: servedLocation(deps.publicBaseUrl, c.req.url).baseUrl,
|
|
214
271
|
lake: deps.lake,
|
|
215
272
|
cursorSigner: deps.cursorSigner,
|
|
216
273
|
rateLimiter: deps.rateLimiter,
|
package/dist/i18n.js
CHANGED
|
@@ -18,23 +18,13 @@
|
|
|
18
18
|
* (§Bilingual, l.234): "fr" selects the French resource, anything else
|
|
19
19
|
* (including a missing header) defaults to English.
|
|
20
20
|
*/
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// requires an explicit import attribute for a JSON module import, which the
|
|
26
|
-
// project's current `tsconfig.json` `module` target does not emit. This
|
|
27
|
-
// project's `resolveJsonModule` + a plain JSON import already work for
|
|
28
|
-
// bundler-processed code (e.g. src/ui/Widget.tsx, inlined by Vite at build
|
|
29
|
-
// time and never hitting Node's loader at all) but not for tsc's own Node
|
|
30
|
-
// ESM output, so the two resources here are read as plain files instead.
|
|
31
|
-
function loadLocaleResource(fileName) {
|
|
32
|
-
const path = fileURLToPath(new URL(`./locales/${fileName}`, import.meta.url));
|
|
33
|
-
return JSON.parse(readFileSync(path, "utf8"));
|
|
34
|
-
}
|
|
21
|
+
import { EMBEDDED_LOCALES } from "./embedded.generated.js";
|
|
22
|
+
// The two resources are module values (src/embedded.generated.ts, generated from src/locales/{en,fr}.json by
|
|
23
|
+
// scripts/embed-static.mjs at build), never a file read at import: a bundled consumer (the Convex bundler,
|
|
24
|
+
// esbuild) carries this module and no `locales/` folder beside it (task k17aj33ks3ndyxgt7jch68rpwx8hz9x5).
|
|
35
25
|
const RESOURCES = {
|
|
36
|
-
en:
|
|
37
|
-
fr:
|
|
26
|
+
en: EMBEDDED_LOCALES.en,
|
|
27
|
+
fr: EMBEDDED_LOCALES.fr,
|
|
38
28
|
};
|
|
39
29
|
/**
|
|
40
30
|
* Resolves the caller's locale from the `X-MCP-Locale` request header.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public setup page at the MCP URL (mcp-server-standard S-50, Manufact "Setup instructions at
|
|
3
|
+
* the MCP URL"). A person who pastes the MCP URL into a browser gets a short bilingual page
|
|
4
|
+
* saying what the server is and how to connect, instead of a bare 405 or 401.
|
|
5
|
+
*
|
|
6
|
+
* The page is static text built from src/serverIdentity.ts and the locale files: it reads no
|
|
7
|
+
* credential, calls no backend and grants nothing. Only a request that cannot be an MCP request
|
|
8
|
+
* is answered with it (`isBrowserLandingRequest`); every other request on /mcp goes on to the
|
|
9
|
+
* Origin check, the auth gate and the MCP handler exactly as before, including the
|
|
10
|
+
* 401 + WWW-Authenticate that OAuth discovery depends on.
|
|
11
|
+
*/
|
|
12
|
+
import type { AuthMode } from "./config.js";
|
|
13
|
+
import type { ServerIdentity } from "./serverIdentity.js";
|
|
14
|
+
/**
|
|
15
|
+
* True only for a browser-style GET: no Authorization header, an Accept that does not include
|
|
16
|
+
* text/event-stream, and an Accept that includes text/html, or is exactly `*` `/` `*`, or is
|
|
17
|
+
* absent. RFC 9110 §12.5.1: an absent Accept means any media type; an MCP client opening the SSE
|
|
18
|
+
* stream always sends `Accept: text/event-stream`, so none of these is an MCP request.
|
|
19
|
+
*/
|
|
20
|
+
export declare function isBrowserLandingRequest(req: Request): boolean;
|
|
21
|
+
/** The page for the server whose MCP endpoint is `mcpUrl`: English, then French. */
|
|
22
|
+
export declare function renderLandingPage(identity: ServerIdentity, mcpUrl: string, authMode: AuthMode): string;
|
|
23
|
+
/** The setup page as an HTTP response. `cacheable`: see serverCardResponse (src/serverCard.ts). */
|
|
24
|
+
export declare function landingPageResponse(identity: ServerIdentity, mcpUrl: string, authMode: AuthMode, cacheable: boolean): Response;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public setup page at the MCP URL (mcp-server-standard S-50, Manufact "Setup instructions at
|
|
3
|
+
* the MCP URL"). A person who pastes the MCP URL into a browser gets a short bilingual page
|
|
4
|
+
* saying what the server is and how to connect, instead of a bare 405 or 401.
|
|
5
|
+
*
|
|
6
|
+
* The page is static text built from src/serverIdentity.ts and the locale files: it reads no
|
|
7
|
+
* credential, calls no backend and grants nothing. Only a request that cannot be an MCP request
|
|
8
|
+
* is answered with it (`isBrowserLandingRequest`); every other request on /mcp goes on to the
|
|
9
|
+
* Origin check, the auth gate and the MCP handler exactly as before, including the
|
|
10
|
+
* 401 + WWW-Authenticate that OAuth discovery depends on.
|
|
11
|
+
*/
|
|
12
|
+
import { t } from "./i18n.js";
|
|
13
|
+
/** The media ranges of an Accept header, lower-cased, parameters dropped. */
|
|
14
|
+
function mediaRanges(accept) {
|
|
15
|
+
return accept
|
|
16
|
+
.split(",")
|
|
17
|
+
.map((part) => (part.split(";")[0] ?? "").trim().toLowerCase())
|
|
18
|
+
.filter((part) => part.length > 0);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* True only for a browser-style GET: no Authorization header, an Accept that does not include
|
|
22
|
+
* text/event-stream, and an Accept that includes text/html, or is exactly `*` `/` `*`, or is
|
|
23
|
+
* absent. RFC 9110 §12.5.1: an absent Accept means any media type; an MCP client opening the SSE
|
|
24
|
+
* stream always sends `Accept: text/event-stream`, so none of these is an MCP request.
|
|
25
|
+
*/
|
|
26
|
+
export function isBrowserLandingRequest(req) {
|
|
27
|
+
if (req.method !== "GET")
|
|
28
|
+
return false;
|
|
29
|
+
if (req.headers.has("authorization"))
|
|
30
|
+
return false;
|
|
31
|
+
const accept = req.headers.get("accept");
|
|
32
|
+
if (accept === null)
|
|
33
|
+
return true;
|
|
34
|
+
const ranges = mediaRanges(accept);
|
|
35
|
+
if (ranges.includes("text/event-stream"))
|
|
36
|
+
return false;
|
|
37
|
+
if (ranges.includes("text/html"))
|
|
38
|
+
return true;
|
|
39
|
+
return ranges.length === 1 && ranges[0] === "*/*";
|
|
40
|
+
}
|
|
41
|
+
function escapeHtml(value) {
|
|
42
|
+
return value
|
|
43
|
+
.replace(/&/g, "&")
|
|
44
|
+
.replace(/</g, "<")
|
|
45
|
+
.replace(/>/g, ">")
|
|
46
|
+
.replace(/"/g, """)
|
|
47
|
+
.replace(/'/g, "'");
|
|
48
|
+
}
|
|
49
|
+
function section(locale, identity, mcpUrl, authMode) {
|
|
50
|
+
const tx = (key) => escapeHtml(t(locale, `landing.${key}`));
|
|
51
|
+
const endpoint = escapeHtml(mcpUrl);
|
|
52
|
+
const site = escapeHtml(identity.websiteUrl);
|
|
53
|
+
const add = escapeHtml(`claude mcp add --transport http ${identity.name} ${mcpUrl}`);
|
|
54
|
+
return `<section lang="${locale}">
|
|
55
|
+
<h1>${escapeHtml(identity.title)}</h1>
|
|
56
|
+
<p>${escapeHtml(identity.description[locale])}</p>
|
|
57
|
+
<p>${tx("intro")}</p>
|
|
58
|
+
<p><code>${endpoint}</code></p>
|
|
59
|
+
<ul>
|
|
60
|
+
<li>${tx("claudeAi")}</li>
|
|
61
|
+
<li>${tx("chatgpt")}</li>
|
|
62
|
+
<li>${tx("claudeCode")} <code>${add}</code></li>
|
|
63
|
+
<li>${tx("other")}</li>
|
|
64
|
+
</ul>
|
|
65
|
+
<p>${tx(authMode === "oauth" ? "authOauth" : "authPublic")}</p>
|
|
66
|
+
<p>${tx("website")} <a href="${site}">${site}</a></p>
|
|
67
|
+
</section>`;
|
|
68
|
+
}
|
|
69
|
+
/** The page for the server whose MCP endpoint is `mcpUrl`: English, then French. */
|
|
70
|
+
export function renderLandingPage(identity, mcpUrl, authMode) {
|
|
71
|
+
const title = escapeHtml(t("en", "landing.pageTitle").replace("{title}", () => identity.title));
|
|
72
|
+
return `<!doctype html>
|
|
73
|
+
<html lang="en">
|
|
74
|
+
<head>
|
|
75
|
+
<meta charset="utf-8">
|
|
76
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
77
|
+
<title>${title}</title>
|
|
78
|
+
<link rel="icon" href="icon.svg" type="image/svg+xml">
|
|
79
|
+
<style>
|
|
80
|
+
body { font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; max-width: 42rem; margin: 3rem auto; padding: 0 1.25rem; color: #1f2328; background: #ffffff; }
|
|
81
|
+
code { background: #f6f8fa; padding: 0.1rem 0.35rem; border-radius: 4px; overflow-wrap: anywhere; }
|
|
82
|
+
h1 { font-size: 1.6rem; margin-bottom: 0.25rem; }
|
|
83
|
+
li { margin: 0.4rem 0; }
|
|
84
|
+
section + section { border-top: 1px solid #d0d7de; margin-top: 2rem; padding-top: 1rem; }
|
|
85
|
+
@media (prefers-color-scheme: dark) { body { background: #0d1117; color: #e6edf3; } code { background: #161b22; } a { color: #58a6ff; } section + section { border-color: #30363d; } }
|
|
86
|
+
</style>
|
|
87
|
+
</head>
|
|
88
|
+
<body>
|
|
89
|
+
<main>
|
|
90
|
+
${section("en", identity, mcpUrl, authMode)}
|
|
91
|
+
${section("fr", identity, mcpUrl, authMode)}
|
|
92
|
+
</main>
|
|
93
|
+
</body>
|
|
94
|
+
</html>`;
|
|
95
|
+
}
|
|
96
|
+
/** The setup page as an HTTP response. `cacheable`: see serverCardResponse (src/serverCard.ts). */
|
|
97
|
+
export function landingPageResponse(identity, mcpUrl, authMode, cacheable) {
|
|
98
|
+
return new Response(renderLandingPage(identity, mcpUrl, authMode), {
|
|
99
|
+
status: 200,
|
|
100
|
+
headers: {
|
|
101
|
+
"content-type": "text/html; charset=utf-8",
|
|
102
|
+
"cache-control": cacheable ? "public, max-age=300" : "no-store",
|
|
103
|
+
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
104
|
+
"x-content-type-options": "nosniff",
|
|
105
|
+
// The same URL answers MCP requests differently by method, Accept and credential.
|
|
106
|
+
vary: "Accept, Authorization",
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -33,6 +33,7 @@ import type { ExampleItem } from "./exampleItems.js";
|
|
|
33
33
|
import { type McpLocale } from "./i18n.js";
|
|
34
34
|
import { type LakeReader } from "./lake/reader.js";
|
|
35
35
|
import { type RateLimiter } from "./rateLimit.js";
|
|
36
|
+
import { type ServedToolRecords } from "./servedToolRecords.js";
|
|
36
37
|
import type { CursorSigner } from "./signedCursor.js";
|
|
37
38
|
import { type ResolvedTenant } from "./tenancy.js";
|
|
38
39
|
import { type ToolRolesContext } from "./toolRoleGate.js";
|
|
@@ -66,6 +67,11 @@ export interface McpServerDeps {
|
|
|
66
67
|
* httpApp.ts). Defaults to "en"; the stdio transport (no HTTP headers) uses the default.
|
|
67
68
|
*/
|
|
68
69
|
locale?: McpLocale;
|
|
70
|
+
/**
|
|
71
|
+
* The base URL (ending in `/`) the HTTP transport is reached at, resolved per request by
|
|
72
|
+
* src/httpApp.ts: the serverInfo icon is served under it. Absent (stdio): the icon travels inline.
|
|
73
|
+
*/
|
|
74
|
+
baseUrl?: string;
|
|
69
75
|
/** The tool invocation limiter (S-24); default the process-wide `toolRateLimiter`. A test injects its own. */
|
|
70
76
|
rateLimiter?: RateLimiter;
|
|
71
77
|
/**
|
|
@@ -92,3 +98,8 @@ export interface McpServerDeps {
|
|
|
92
98
|
cursorSigner?: CursorSigner;
|
|
93
99
|
}
|
|
94
100
|
export declare function createBoilerplateMcpServer(deps?: McpServerDeps): McpServer;
|
|
101
|
+
/**
|
|
102
|
+
* The tools a server built by this factory registered (name, title, description), for the public
|
|
103
|
+
* server card (src/serverCard.ts): read from the registrations, never a second list.
|
|
104
|
+
*/
|
|
105
|
+
export declare function registeredToolSummaries(server: McpServer): ServedToolRecords["summaries"];
|
package/dist/mcpServerFactory.js
CHANGED
|
@@ -31,13 +31,14 @@
|
|
|
31
31
|
import { registerAppResource } from "@modelcontextprotocol/ext-apps/server";
|
|
32
32
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
33
33
|
import { z } from "zod";
|
|
34
|
-
import { getBuildIdentity } from "./buildIdentity.js";
|
|
34
|
+
import { getBuildCommit, getBuildIdentity } from "./buildIdentity.js";
|
|
35
35
|
import { t } from "./i18n.js";
|
|
36
36
|
import { EXAMPLE_ITEMS_READER, LAKE_UNREADABLE_CODE, LakeReadError, } from "./lake/reader.js";
|
|
37
37
|
import { rejectCursorOnSinglePageLists } from "./listCursor.js";
|
|
38
38
|
import { INVALID_CURSOR_CODE, InvalidCursorError, PAGINATION, readPage } from "./pagination.js";
|
|
39
39
|
import { RATE_LIMITED_CODE, toolRateLimiter } from "./rateLimit.js";
|
|
40
40
|
import { trackToolRecords } from "./servedToolRecords.js";
|
|
41
|
+
import { getServerIdentity, serverInstructions, serverPresentation } from "./serverIdentity.js";
|
|
41
42
|
import { loadServerManifest } from "./serverManifest.js";
|
|
42
43
|
import { scopeRowsForTenant } from "./tenancy.js";
|
|
43
44
|
import { LIST_ITEMS_ANNOTATIONS, PING_ANNOTATIONS, WHOAMI_ANNOTATIONS } from "./toolRecord.js";
|
|
@@ -120,18 +121,26 @@ export function createBoilerplateMcpServer(deps = {}) {
|
|
|
120
121
|
// at registration and checked against it (and the closed mechanism set) before the server is
|
|
121
122
|
// returned: the check covers what is served, there is no list to keep in step.
|
|
122
123
|
const manifest = loadServerManifest();
|
|
123
|
-
// The identity the server announces: version
|
|
124
|
-
//
|
|
125
|
-
// (
|
|
126
|
-
//
|
|
124
|
+
// The identity the server announces (S-49): name, version, title, description (in the caller's
|
|
125
|
+
// locale), websiteUrl and icons, all from src/serverIdentity.ts (package.json or environment),
|
|
126
|
+
// plus the build identity (C-25): buildId = the git commit the tree was built from, buildHash =
|
|
127
|
+
// the hash of the files this process runs from (src/buildIdentity.ts). The SDK sends
|
|
128
|
+
// `serverInfo` as given, on `server/discover` (2026-07-28,
|
|
129
|
+
// `_meta["io.modelcontextprotocol/serverInfo"]`) and on `initialize` (2025-era). The build keys
|
|
130
|
+
// are not modelled `Implementation` keys, hence the spread (no excess-key check).
|
|
127
131
|
const server = new McpServer({
|
|
128
132
|
name: SERVER_NAME,
|
|
129
133
|
version: SERVER_VERSION,
|
|
130
|
-
...
|
|
134
|
+
...serverPresentation(getServerIdentity(), locale, deps.baseUrl),
|
|
135
|
+
...{
|
|
136
|
+
buildId: getBuildCommit(),
|
|
137
|
+
buildHash: getBuildIdentity(),
|
|
138
|
+
},
|
|
131
139
|
}, {
|
|
132
140
|
// The capabilities come from the server manifest (mcp-server.json, C-18): one source.
|
|
133
141
|
capabilities: manifest.capabilities,
|
|
134
|
-
|
|
142
|
+
// How an agent uses this server, in the caller's locale (src/serverIdentity.ts).
|
|
143
|
+
instructions: serverInstructions(locale),
|
|
135
144
|
supportedProtocolVersions: SUPPORTED_LEGACY_PROTOCOL_VERSIONS,
|
|
136
145
|
cacheHints: {
|
|
137
146
|
"tools/list": { ttlMs: 60_000, cacheScope: "public" },
|
|
@@ -302,5 +311,17 @@ export function createBoilerplateMcpServer(deps = {}) {
|
|
|
302
311
|
rejectCursorOnSinglePageLists(server);
|
|
303
312
|
// Start refusal: every registered tool's record, as registered, must be sound.
|
|
304
313
|
served.assertServed(manifest.contextCeiling);
|
|
314
|
+
SUMMARIES.set(server, served.summaries);
|
|
305
315
|
return server;
|
|
306
316
|
}
|
|
317
|
+
const SUMMARIES = new WeakMap();
|
|
318
|
+
/**
|
|
319
|
+
* The tools a server built by this factory registered (name, title, description), for the public
|
|
320
|
+
* server card (src/serverCard.ts): read from the registrations, never a second list.
|
|
321
|
+
*/
|
|
322
|
+
export function registeredToolSummaries(server) {
|
|
323
|
+
const summaries = SUMMARIES.get(server);
|
|
324
|
+
if (!summaries)
|
|
325
|
+
throw new Error("registeredToolSummaries: not a server built by this factory");
|
|
326
|
+
return summaries;
|
|
327
|
+
}
|
|
@@ -12,6 +12,12 @@ import type { ContextCeiling } from "./serverManifest.js";
|
|
|
12
12
|
export interface ServedToolRecords {
|
|
13
13
|
/** The annotations of every tool registered so far, by tool name. */
|
|
14
14
|
readonly records: Readonly<Record<string, Record<string, unknown>>>;
|
|
15
|
+
/** Each registered tool's name, title and description, in registration order (the server card). */
|
|
16
|
+
readonly summaries: ReadonlyArray<{
|
|
17
|
+
name: string;
|
|
18
|
+
title?: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
}>;
|
|
15
21
|
/** Throws `ToolRecordError` naming every unsound tool; same check as the wire test. */
|
|
16
22
|
assertServed(ceiling: ContextCeiling): void;
|
|
17
23
|
}
|
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import { assertToolRecords } from "./toolRecord.js";
|
|
2
2
|
export function trackToolRecords(server) {
|
|
3
3
|
const records = {};
|
|
4
|
+
const summaries = [];
|
|
4
5
|
const register = server.registerTool.bind(server);
|
|
5
6
|
server.registerTool = ((name, config, callback) => {
|
|
6
7
|
records[name] = { ...(config.annotations ?? {}) };
|
|
8
|
+
summaries.push({
|
|
9
|
+
name,
|
|
10
|
+
...(config.title !== undefined ? { title: config.title } : {}),
|
|
11
|
+
...(config.description !== undefined ? { description: config.description } : {}),
|
|
12
|
+
});
|
|
7
13
|
return register(name, config, callback);
|
|
8
14
|
});
|
|
9
|
-
return {
|
|
15
|
+
return {
|
|
16
|
+
records,
|
|
17
|
+
summaries,
|
|
18
|
+
assertServed: (ceiling) => assertToolRecords(records, ceiling),
|
|
19
|
+
};
|
|
10
20
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public MCP Server Card (mcp-server-standard S-50, Manufact "mcp.json or MCP Server Card
|
|
3
|
+
* served"). Served by src/httpApp.ts, before every gate, at each path in `SERVER_CARD_PATHS`.
|
|
4
|
+
*
|
|
5
|
+
* Shape: the `ServerCard` of the MCP Server Card extension (SEP-2127,
|
|
6
|
+
* modelcontextprotocol/experimental-ext-server-card `schema.json` at commit
|
|
7
|
+
* 526201bbc80231daa40ffcdecfc9da4e54e5dc93, sha256
|
|
8
|
+
* 2c772b51edb367f154771d84ddbae87ddba00a624422c8e46f218a9ac03bf042), vendored at
|
|
9
|
+
* tests/fixtures/server-card.schema.json and validated in tests/serverMetadata.test.ts.
|
|
10
|
+
*
|
|
11
|
+
* Declared divergence: the extension says a card does not enumerate primitives, while Manufact's
|
|
12
|
+
* check and mcp-doctor S-50 read `capabilities` and a tool summary (`tools`) on the card. The
|
|
13
|
+
* schema allows additional members, so the card carries both: the capabilities of
|
|
14
|
+
* mcp-server.json (the same object `initialize` serves) and each served tool's name, title and
|
|
15
|
+
* description, read from the tools as registered — never a second list to keep in step.
|
|
16
|
+
*
|
|
17
|
+
* The card is public metadata and grants nothing. Every string comes from src/serverIdentity.ts
|
|
18
|
+
* (the source of the serverInfo), and the URLs from the origin the server is reached at.
|
|
19
|
+
*/
|
|
20
|
+
import type { Icon } from "@modelcontextprotocol/server";
|
|
21
|
+
import { type ServerIdentity } from "./serverIdentity.js";
|
|
22
|
+
export declare const SERVER_CARD_SCHEMA_URL = "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json";
|
|
23
|
+
export declare const SERVER_CARD_MEDIA_TYPE = "application/mcp-server-card+json";
|
|
24
|
+
/**
|
|
25
|
+
* Where the card is served: `/mcp/server-card` is the location the extension reserves
|
|
26
|
+
* (`<streamable-http-url>/server-card`); the others are the origin-level paths Manufact and
|
|
27
|
+
* mcp-doctor S-50 probe. Manufact also reads `/.well-known/ai-catalog.json` and the parent
|
|
28
|
+
* domain; an AI Catalog lists cards rather than being one, so it is not served here.
|
|
29
|
+
*/
|
|
30
|
+
export declare const SERVER_CARD_PATHS: readonly ["/mcp/server-card", "/.well-known/mcp/server-card.json", "/.well-known/mcp-server-card", "/mcp.json", "/.well-known/mcp.json"];
|
|
31
|
+
/** One served tool, as summarised on the card. */
|
|
32
|
+
export interface CardTool {
|
|
33
|
+
name: string;
|
|
34
|
+
title?: string;
|
|
35
|
+
description?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface ServerCard {
|
|
38
|
+
$schema: string;
|
|
39
|
+
name: string;
|
|
40
|
+
version: string;
|
|
41
|
+
description: string;
|
|
42
|
+
title: string;
|
|
43
|
+
websiteUrl: string;
|
|
44
|
+
icons: Icon[];
|
|
45
|
+
remotes: Array<{
|
|
46
|
+
type: "streamable-http";
|
|
47
|
+
url: string;
|
|
48
|
+
}>;
|
|
49
|
+
capabilities: Record<string, unknown>;
|
|
50
|
+
tools: CardTool[];
|
|
51
|
+
}
|
|
52
|
+
export interface CardInput {
|
|
53
|
+
identity: ServerIdentity;
|
|
54
|
+
/** The served MCP endpoint URL. */
|
|
55
|
+
mcpUrl: string;
|
|
56
|
+
/** The base URL the icon is served under (ends in `/`). */
|
|
57
|
+
baseUrl: string;
|
|
58
|
+
capabilities: Record<string, unknown>;
|
|
59
|
+
tools: CardTool[];
|
|
60
|
+
}
|
|
61
|
+
export declare function buildServerCard(input: CardInput): ServerCard;
|
|
62
|
+
/**
|
|
63
|
+
* The card as an HTTP response: the card media type when the client asks for it, JSON otherwise.
|
|
64
|
+
* `cacheable` is false when the origin was read from the
|
|
65
|
+
* request (no configured public base URL): a shared cache must not keep a card naming a Host
|
|
66
|
+
* header another client sent.
|
|
67
|
+
*/
|
|
68
|
+
export declare function serverCardResponse(req: Request, card: ServerCard, cacheable: boolean): Response;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public MCP Server Card (mcp-server-standard S-50, Manufact "mcp.json or MCP Server Card
|
|
3
|
+
* served"). Served by src/httpApp.ts, before every gate, at each path in `SERVER_CARD_PATHS`.
|
|
4
|
+
*
|
|
5
|
+
* Shape: the `ServerCard` of the MCP Server Card extension (SEP-2127,
|
|
6
|
+
* modelcontextprotocol/experimental-ext-server-card `schema.json` at commit
|
|
7
|
+
* 526201bbc80231daa40ffcdecfc9da4e54e5dc93, sha256
|
|
8
|
+
* 2c772b51edb367f154771d84ddbae87ddba00a624422c8e46f218a9ac03bf042), vendored at
|
|
9
|
+
* tests/fixtures/server-card.schema.json and validated in tests/serverMetadata.test.ts.
|
|
10
|
+
*
|
|
11
|
+
* Declared divergence: the extension says a card does not enumerate primitives, while Manufact's
|
|
12
|
+
* check and mcp-doctor S-50 read `capabilities` and a tool summary (`tools`) on the card. The
|
|
13
|
+
* schema allows additional members, so the card carries both: the capabilities of
|
|
14
|
+
* mcp-server.json (the same object `initialize` serves) and each served tool's name, title and
|
|
15
|
+
* description, read from the tools as registered — never a second list to keep in step.
|
|
16
|
+
*
|
|
17
|
+
* The card is public metadata and grants nothing. Every string comes from src/serverIdentity.ts
|
|
18
|
+
* (the source of the serverInfo), and the URLs from the origin the server is reached at.
|
|
19
|
+
*/
|
|
20
|
+
import { cardDescription, cardName, serverIcons } from "./serverIdentity.js";
|
|
21
|
+
export const SERVER_CARD_SCHEMA_URL = "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json";
|
|
22
|
+
export const SERVER_CARD_MEDIA_TYPE = "application/mcp-server-card+json";
|
|
23
|
+
/**
|
|
24
|
+
* Where the card is served: `/mcp/server-card` is the location the extension reserves
|
|
25
|
+
* (`<streamable-http-url>/server-card`); the others are the origin-level paths Manufact and
|
|
26
|
+
* mcp-doctor S-50 probe. Manufact also reads `/.well-known/ai-catalog.json` and the parent
|
|
27
|
+
* domain; an AI Catalog lists cards rather than being one, so it is not served here.
|
|
28
|
+
*/
|
|
29
|
+
export const SERVER_CARD_PATHS = [
|
|
30
|
+
"/mcp/server-card",
|
|
31
|
+
"/.well-known/mcp/server-card.json",
|
|
32
|
+
"/.well-known/mcp-server-card",
|
|
33
|
+
"/mcp.json",
|
|
34
|
+
"/.well-known/mcp.json",
|
|
35
|
+
];
|
|
36
|
+
export function buildServerCard(input) {
|
|
37
|
+
const { identity } = input;
|
|
38
|
+
return {
|
|
39
|
+
$schema: SERVER_CARD_SCHEMA_URL,
|
|
40
|
+
name: cardName(identity.websiteUrl, identity.name),
|
|
41
|
+
version: identity.version,
|
|
42
|
+
description: cardDescription(identity.description.en),
|
|
43
|
+
title: identity.title,
|
|
44
|
+
websiteUrl: identity.websiteUrl,
|
|
45
|
+
icons: serverIcons(input.baseUrl),
|
|
46
|
+
remotes: [{ type: "streamable-http", url: input.mcpUrl }],
|
|
47
|
+
capabilities: input.capabilities,
|
|
48
|
+
tools: input.tools,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The card as an HTTP response: the card media type when the client asks for it, JSON otherwise.
|
|
53
|
+
* `cacheable` is false when the origin was read from the
|
|
54
|
+
* request (no configured public base URL): a shared cache must not keep a card naming a Host
|
|
55
|
+
* header another client sent.
|
|
56
|
+
*/
|
|
57
|
+
export function serverCardResponse(req, card, cacheable) {
|
|
58
|
+
const accept = req.headers.get("accept") ?? "";
|
|
59
|
+
const headers = new Headers({
|
|
60
|
+
"content-type": accept.includes(SERVER_CARD_MEDIA_TYPE)
|
|
61
|
+
? SERVER_CARD_MEDIA_TYPE
|
|
62
|
+
: "application/json",
|
|
63
|
+
"cache-control": cacheable ? "public, max-age=3600" : "no-store",
|
|
64
|
+
"access-control-allow-origin": "*",
|
|
65
|
+
"x-content-type-options": "nosniff",
|
|
66
|
+
});
|
|
67
|
+
return new Response(JSON.stringify(card), { status: 200, headers });
|
|
68
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server icon (MCP `Implementation.icons`, Manufact "Scalable or high-resolution icon"). A
|
|
3
|
+
* vector, so it is sharp at every size a host renders and needs no `sizes` beyond `"any"`.
|
|
4
|
+
*
|
|
5
|
+
* A fork replaces `SERVER_ICON_SVG` with its own mark. Keep it a self-contained SVG: no script,
|
|
6
|
+
* no external reference (the route serves it under a CSP that forbids both), because a host may
|
|
7
|
+
* render it in its own page.
|
|
8
|
+
*
|
|
9
|
+
* Served by src/httpApp.ts at `SERVER_ICON_PATH`, before any authentication, and inlined as a
|
|
10
|
+
* `data:` URI where there is no HTTP origin (stdio).
|
|
11
|
+
*/
|
|
12
|
+
/** Where the HTTP transport serves the icon, relative to the public base URL. */
|
|
13
|
+
export declare const SERVER_ICON_PATH = "icon.svg";
|
|
14
|
+
export declare const SERVER_ICON_MIME_TYPE = "image/svg+xml";
|
|
15
|
+
export declare const SERVER_ICON_SVG = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 128 128\" role=\"img\" aria-label=\"MCP server\">\n<rect width=\"128\" height=\"128\" rx=\"28\" fill=\"#1f2a44\"/>\n<path d=\"M40 44 L88 44 M40 44 L64 88 M88 44 L64 88\" stroke=\"#8fb3ff\" stroke-width=\"8\" stroke-linecap=\"round\" fill=\"none\"/>\n<circle cx=\"40\" cy=\"44\" r=\"12\" fill=\"#ffffff\"/>\n<circle cx=\"88\" cy=\"44\" r=\"12\" fill=\"#ffffff\"/>\n<circle cx=\"64\" cy=\"88\" r=\"12\" fill=\"#ffffff\"/>\n</svg>\n";
|
|
16
|
+
/** The icon as a `data:` URI, for a transport with no HTTP origin to serve it from. */
|
|
17
|
+
export declare function serverIconDataUri(): string;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server icon (MCP `Implementation.icons`, Manufact "Scalable or high-resolution icon"). A
|
|
3
|
+
* vector, so it is sharp at every size a host renders and needs no `sizes` beyond `"any"`.
|
|
4
|
+
*
|
|
5
|
+
* A fork replaces `SERVER_ICON_SVG` with its own mark. Keep it a self-contained SVG: no script,
|
|
6
|
+
* no external reference (the route serves it under a CSP that forbids both), because a host may
|
|
7
|
+
* render it in its own page.
|
|
8
|
+
*
|
|
9
|
+
* Served by src/httpApp.ts at `SERVER_ICON_PATH`, before any authentication, and inlined as a
|
|
10
|
+
* `data:` URI where there is no HTTP origin (stdio).
|
|
11
|
+
*/
|
|
12
|
+
/** Where the HTTP transport serves the icon, relative to the public base URL. */
|
|
13
|
+
export const SERVER_ICON_PATH = "icon.svg";
|
|
14
|
+
export const SERVER_ICON_MIME_TYPE = "image/svg+xml";
|
|
15
|
+
export const SERVER_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="MCP server">
|
|
16
|
+
<rect width="128" height="128" rx="28" fill="#1f2a44"/>
|
|
17
|
+
<path d="M40 44 L88 44 M40 44 L64 88 M88 44 L64 88" stroke="#8fb3ff" stroke-width="8" stroke-linecap="round" fill="none"/>
|
|
18
|
+
<circle cx="40" cy="44" r="12" fill="#ffffff"/>
|
|
19
|
+
<circle cx="88" cy="44" r="12" fill="#ffffff"/>
|
|
20
|
+
<circle cx="64" cy="88" r="12" fill="#ffffff"/>
|
|
21
|
+
</svg>
|
|
22
|
+
`;
|
|
23
|
+
/** The icon as a `data:` URI, for a transport with no HTTP origin to serve it from. */
|
|
24
|
+
export function serverIconDataUri() {
|
|
25
|
+
return `data:${SERVER_ICON_MIME_TYPE};base64,${Buffer.from(SERVER_ICON_SVG, "utf8").toString("base64")}`;
|
|
26
|
+
}
|