@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/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 { createBoilerplateMcpServer } from "./mcpServerFactory.js";
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
- // Unauthenticated (S-7): the version AND the identity of the tree being served (C-25), so a
98
- // deployment is proven from what the server returns, not from what the deploy printed.
99
- app.get("/health", (c) => c.json({ status: "ok", version: SERVER_VERSION, buildId: getBuildIdentity() }));
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 and the OAuth/discovery routes (/.well-known/*, /authorize, /token,
108
- // /register) are NOT covered — they are browser navigations or CORS calls of the sign-in flow
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 { readFileSync } from "node:fs";
22
- import { fileURLToPath } from "node:url";
23
- // Loaded via fs + JSON.parse, not a JSON module import: Node's ESM loader
24
- // (the runtime the compiled dist/stdio.js and dist/http.js run under)
25
- // requires an explicit import attribute for a JSON module import, which the
26
- // project's current `tsconfig.json` `module` target does not emit. This
27
- // project's `resolveJsonModule` + a plain JSON import already work for
28
- // bundler-processed code (e.g. src/ui/Widget.tsx, inlined by Vite at build
29
- // time and never hitting Node's loader at all) but not for tsc's own Node
30
- // ESM output, so the two resources here are read as plain files instead.
31
- function loadLocaleResource(fileName) {
32
- const path = fileURLToPath(new URL(`./locales/${fileName}`, import.meta.url));
33
- return JSON.parse(readFileSync(path, "utf8"));
34
- }
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: loadLocaleResource("en.json"),
37
- fr: loadLocaleResource("fr.json"),
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, "&lt;")
45
+ .replace(/>/g, "&gt;")
46
+ .replace(/"/g, "&quot;")
47
+ .replace(/'/g, "&#39;");
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"];
@@ -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 (declared) AND buildId (derived from the served
124
- // tree, src/buildIdentity.ts). The SDK sends `serverInfo` as given, on `server/discover`
125
- // (2026-07-28, `_meta["io.modelcontextprotocol/serverInfo"]`) and on `initialize` (2025-era).
126
- // `buildId` is not a modelled `Implementation` key, hence the spread (no excess-key check).
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
- ...{ buildId: getBuildIdentity() },
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
- instructions: "Clonable MCP server boilerplate — see README.md",
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 { records, assertServed: (ceiling) => assertToolRecords(records, ceiling) };
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
+ }