@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.
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The server's public identity: the ONE source of every string a host reads about this server
3
+ * before or at connection — the `serverInfo` of `initialize` / `server/discover`
4
+ * (src/mcpServerFactory.ts), its `instructions`, the MCP Server Card (src/serverCard.ts) and the
5
+ * setup page at the MCP URL (src/landingPage.ts). No copy can drift from another.
6
+ *
7
+ * Every value is DERIVED, never a literal a fork must remember to edit:
8
+ *
9
+ * | Item | Environment override | Otherwise, package.json |
10
+ * |----------------|-------------------------------|-------------------------|
11
+ * | title | `MCP_SERVER_TITLE` | `displayName` |
12
+ * | description EN | `MCP_SERVER_DESCRIPTION` | `description` |
13
+ * | description FR | `MCP_SERVER_DESCRIPTION_FR` | `description_fr` |
14
+ * | website | `MCP_SERVER_WEBSITE_URL` | `homepage` |
15
+ *
16
+ * `name` and `version` keep their own sources (src/version.ts). The template's package.json
17
+ * carries the template's values; a fork edits package.json (or sets the variables) and every
18
+ * surface follows. A value neither source carries is refused at start, naming the variable AND
19
+ * the package.json field, in English and French: a served placeholder is what Manufact and
20
+ * mcp-doctor S-49 score as missing, so the server never invents one.
21
+ */
22
+ import type { Icon, Implementation } from "@modelcontextprotocol/server";
23
+ import { type McpLocale } from "./i18n.js";
24
+ /** The package.json fields the identity reads. */
25
+ export interface PackageIdentityFields {
26
+ displayName?: unknown;
27
+ description?: unknown;
28
+ description_fr?: unknown;
29
+ homepage?: unknown;
30
+ }
31
+ export interface ServerIdentity {
32
+ name: string;
33
+ version: string;
34
+ title: string;
35
+ description: Record<McpLocale, string>;
36
+ websiteUrl: string;
37
+ }
38
+ /** The environment variables, by identity item (documented in README and .env.example). */
39
+ export declare const IDENTITY_ENV: {
40
+ readonly title: "MCP_SERVER_TITLE";
41
+ readonly description: "MCP_SERVER_DESCRIPTION";
42
+ readonly descriptionFr: "MCP_SERVER_DESCRIPTION_FR";
43
+ readonly websiteUrl: "MCP_SERVER_WEBSITE_URL";
44
+ };
45
+ /** Resolves the identity from `env` and the package.json fields. Throws, naming what is missing. */
46
+ export declare function resolveServerIdentity(env: NodeJS.ProcessEnv, pkg: PackageIdentityFields): ServerIdentity;
47
+ /**
48
+ * The identity of THIS process: the package fields embedded at build (src/embedded.generated.ts, from
49
+ * package.json; no file read, so a bundled server has them too) and process.env. Once.
50
+ */
51
+ export declare function getServerIdentity(): ServerIdentity;
52
+ /**
53
+ * The icons a host reads. With an HTTP base URL (the origin the server is reached at, ending in
54
+ * `/`), the icon is the URL src/httpApp.ts serves; without one (stdio), it travels inline.
55
+ */
56
+ export declare function serverIcons(baseUrl?: string): Icon[];
57
+ /**
58
+ * The `serverInfo` (MCP `Implementation`) items beyond name and version, for a caller's locale.
59
+ * The factory writes `name` and `version` beside them from src/version.ts, where the
60
+ * version-observability check (D16) reads them.
61
+ */
62
+ export declare function serverPresentation(identity: ServerIdentity, locale: McpLocale, baseUrl?: string): Omit<Implementation, "name" | "version">;
63
+ /**
64
+ * The server instructions (`InitializeResult.instructions` / `DiscoverResult.instructions`): how
65
+ * an agent should use THIS server, critical guidance first, from the locale files
66
+ * (`Mcp.server.instructions`). A fork rewrites them there for its own tools;
67
+ * tests/serverMetadata.test.ts fails when a served tool goes unnamed or the text passes Claude
68
+ * Code's 2048-byte field budget.
69
+ */
70
+ export declare function serverInstructions(locale: McpLocale): string;
71
+ /**
72
+ * The card's reverse-DNS name, derived from the website: `io.github.<owner>/<name>` for a GitHub
73
+ * website (the MCP Registry convention), the reversed host otherwise (`www.` dropped).
74
+ */
75
+ export declare function cardName(websiteUrl: string, serverName: string): string;
76
+ /** The card schema's `description` cap. */
77
+ export declare const CARD_DESCRIPTION_MAX = 100;
78
+ /**
79
+ * The card description: the English description whole when it fits the schema's 100 characters;
80
+ * otherwise cut on the last word boundary that leaves room for an ellipsis. Declared divergence:
81
+ * the card then carries a shortened text, the serverInfo the full one.
82
+ */
83
+ export declare function cardDescription(description: string): string;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The server's public identity: the ONE source of every string a host reads about this server
3
+ * before or at connection — the `serverInfo` of `initialize` / `server/discover`
4
+ * (src/mcpServerFactory.ts), its `instructions`, the MCP Server Card (src/serverCard.ts) and the
5
+ * setup page at the MCP URL (src/landingPage.ts). No copy can drift from another.
6
+ *
7
+ * Every value is DERIVED, never a literal a fork must remember to edit:
8
+ *
9
+ * | Item | Environment override | Otherwise, package.json |
10
+ * |----------------|-------------------------------|-------------------------|
11
+ * | title | `MCP_SERVER_TITLE` | `displayName` |
12
+ * | description EN | `MCP_SERVER_DESCRIPTION` | `description` |
13
+ * | description FR | `MCP_SERVER_DESCRIPTION_FR` | `description_fr` |
14
+ * | website | `MCP_SERVER_WEBSITE_URL` | `homepage` |
15
+ *
16
+ * `name` and `version` keep their own sources (src/version.ts). The template's package.json
17
+ * carries the template's values; a fork edits package.json (or sets the variables) and every
18
+ * surface follows. A value neither source carries is refused at start, naming the variable AND
19
+ * the package.json field, in English and French: a served placeholder is what Manufact and
20
+ * mcp-doctor S-49 score as missing, so the server never invents one.
21
+ */
22
+ import { isSecureUrl } from "./declaredVariables.js";
23
+ import { EMBEDDED_PACKAGE_IDENTITY } from "./embedded.generated.js";
24
+ import { t, tBoth } from "./i18n.js";
25
+ import { SERVER_ICON_MIME_TYPE, SERVER_ICON_PATH, serverIconDataUri } from "./serverIcon.js";
26
+ import { SERVER_NAME, SERVER_VERSION } from "./version.js";
27
+ /** The environment variables, by identity item (documented in README and .env.example). */
28
+ export const IDENTITY_ENV = {
29
+ title: "MCP_SERVER_TITLE",
30
+ description: "MCP_SERVER_DESCRIPTION",
31
+ descriptionFr: "MCP_SERVER_DESCRIPTION_FR",
32
+ websiteUrl: "MCP_SERVER_WEBSITE_URL",
33
+ };
34
+ function pick(env, pkg, item, variable, field) {
35
+ const fromEnv = env[variable];
36
+ if (fromEnv !== undefined && fromEnv.trim() !== "")
37
+ return { value: fromEnv, source: variable };
38
+ const fromPkg = pkg[field];
39
+ if (typeof fromPkg === "string" && fromPkg.trim() !== "")
40
+ return { value: fromPkg, source: `package.json ${field}` };
41
+ throw new Error(tBoth("server.identityMissing", { item, variable, field }));
42
+ }
43
+ /** Resolves the identity from `env` and the package.json fields. Throws, naming what is missing. */
44
+ export function resolveServerIdentity(env, pkg) {
45
+ const title = pick(env, pkg, "title", IDENTITY_ENV.title, "displayName");
46
+ const en = pick(env, pkg, "description", IDENTITY_ENV.description, "description");
47
+ const fr = pick(env, pkg, "description_fr", IDENTITY_ENV.descriptionFr, "description_fr");
48
+ const site = pick(env, pkg, "websiteUrl", IDENTITY_ENV.websiteUrl, "homepage");
49
+ if (!isSecureUrl(site.value))
50
+ throw new Error(tBoth("server.identityInvalid", { item: "websiteUrl", source: site.source }));
51
+ return {
52
+ name: SERVER_NAME,
53
+ version: SERVER_VERSION,
54
+ title: title.value,
55
+ description: { en: en.value, fr: fr.value },
56
+ websiteUrl: site.value,
57
+ };
58
+ }
59
+ let processIdentity;
60
+ /**
61
+ * The identity of THIS process: the package fields embedded at build (src/embedded.generated.ts, from
62
+ * package.json; no file read, so a bundled server has them too) and process.env. Once.
63
+ */
64
+ export function getServerIdentity() {
65
+ processIdentity ??= resolveServerIdentity(process.env, EMBEDDED_PACKAGE_IDENTITY);
66
+ return processIdentity;
67
+ }
68
+ /**
69
+ * The icons a host reads. With an HTTP base URL (the origin the server is reached at, ending in
70
+ * `/`), the icon is the URL src/httpApp.ts serves; without one (stdio), it travels inline.
71
+ */
72
+ export function serverIcons(baseUrl) {
73
+ const src = baseUrl ? new URL(SERVER_ICON_PATH, baseUrl).href : serverIconDataUri();
74
+ return [{ src, mimeType: SERVER_ICON_MIME_TYPE, sizes: ["any"] }];
75
+ }
76
+ /**
77
+ * The `serverInfo` (MCP `Implementation`) items beyond name and version, for a caller's locale.
78
+ * The factory writes `name` and `version` beside them from src/version.ts, where the
79
+ * version-observability check (D16) reads them.
80
+ */
81
+ export function serverPresentation(identity, locale, baseUrl) {
82
+ return {
83
+ title: identity.title,
84
+ description: identity.description[locale],
85
+ websiteUrl: identity.websiteUrl,
86
+ icons: serverIcons(baseUrl),
87
+ };
88
+ }
89
+ /**
90
+ * The server instructions (`InitializeResult.instructions` / `DiscoverResult.instructions`): how
91
+ * an agent should use THIS server, critical guidance first, from the locale files
92
+ * (`Mcp.server.instructions`). A fork rewrites them there for its own tools;
93
+ * tests/serverMetadata.test.ts fails when a served tool goes unnamed or the text passes Claude
94
+ * Code's 2048-byte field budget.
95
+ */
96
+ export function serverInstructions(locale) {
97
+ return t(locale, "server.instructions");
98
+ }
99
+ /** The Server Card `name` pattern (experimental-ext-server-card schema). */
100
+ const CARD_NAME = /^[a-zA-Z0-9.-]+\/[a-zA-Z0-9._-]+$/;
101
+ /**
102
+ * The card's reverse-DNS name, derived from the website: `io.github.<owner>/<name>` for a GitHub
103
+ * website (the MCP Registry convention), the reversed host otherwise (`www.` dropped).
104
+ */
105
+ export function cardName(websiteUrl, serverName) {
106
+ const url = new URL(websiteUrl);
107
+ const host = url.hostname.toLowerCase().replace(/^www\./, "");
108
+ const owner = url.pathname.split("/").filter(Boolean)[0];
109
+ const namespace = host === "github.com" && owner
110
+ ? `io.github.${owner.toLowerCase()}`
111
+ : host.split(".").reverse().join(".");
112
+ const name = `${namespace}/${serverName}`;
113
+ if (!CARD_NAME.test(name))
114
+ throw new Error(`server card name ${JSON.stringify(name)} does not match ${CARD_NAME}`);
115
+ return name;
116
+ }
117
+ /** The card schema's `description` cap. */
118
+ export const CARD_DESCRIPTION_MAX = 100;
119
+ /**
120
+ * The card description: the English description whole when it fits the schema's 100 characters;
121
+ * otherwise cut on the last word boundary that leaves room for an ellipsis. Declared divergence:
122
+ * the card then carries a shortened text, the serverInfo the full one.
123
+ */
124
+ export function cardDescription(description) {
125
+ if (description.length <= CARD_DESCRIPTION_MAX)
126
+ return description;
127
+ const room = description.slice(0, CARD_DESCRIPTION_MAX - 1);
128
+ const cut = room.lastIndexOf(" ");
129
+ return `${(cut > 0 ? room.slice(0, cut) : room).trimEnd()}…`;
130
+ }
@@ -21,6 +21,12 @@ export declare function buildItemsUiResourceMeta(domain?: string): McpUiResource
21
21
  * The widget template. It carries no row: the widget renders the `list_items` RESULT it is given,
22
22
  * so the template is the same for every caller and every page.
23
23
  */
24
+ /**
25
+ * What the widget shows, for the host and the model, in both languages ("EN / FR"). Hosts read it
26
+ * under two keys: `_meta.ui.widgetDescription` and the ChatGPT hint `_meta["openai/widgetDescription"]`
27
+ * (Manufact's tool-resource-metadata-complete reads exactly these); the same text under both.
28
+ */
29
+ export declare const WIDGET_DESCRIPTION: string;
24
30
  export declare function buildUiResource(props: {
25
31
  domain?: string;
26
32
  }): {
@@ -28,7 +34,14 @@ export declare function buildUiResource(props: {
28
34
  mimeType: string;
29
35
  text: string;
30
36
  _meta: {
31
- ui: McpUiResourceMeta;
37
+ ui: {
38
+ widgetDescription: string;
39
+ csp?: import("@modelcontextprotocol/ext-apps").McpUiResourceCsp;
40
+ permissions?: import("@modelcontextprotocol/ext-apps").McpUiResourcePermissions;
41
+ domain?: string;
42
+ prefersBorder?: boolean;
43
+ };
44
+ "openai/widgetDescription": string;
32
45
  };
33
46
  };
34
47
  /**
@@ -13,6 +13,7 @@
13
13
  import { readFileSync } from "node:fs";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
16
+ import { tBoth } from "../i18n.js";
16
17
  export const ITEMS_UI_RESOURCE_URI = "ui://mcp-boilerplate/items-widget.html";
17
18
  // dist/ui-bundle/ — NEVER dist/ui/ (that path is tsc's compiled-server
18
19
  // output for this very module; see vite.config.ts for the collision this
@@ -69,12 +70,21 @@ export function buildItemsUiResourceMeta(domain) {
69
70
  * The widget template. It carries no row: the widget renders the `list_items` RESULT it is given,
70
71
  * so the template is the same for every caller and every page.
71
72
  */
73
+ /**
74
+ * What the widget shows, for the host and the model, in both languages ("EN / FR"). Hosts read it
75
+ * under two keys: `_meta.ui.widgetDescription` and the ChatGPT hint `_meta["openai/widgetDescription"]`
76
+ * (Manufact's tool-resource-metadata-complete reads exactly these); the same text under both.
77
+ */
78
+ export const WIDGET_DESCRIPTION = tBoth("widget.description");
72
79
  export function buildUiResource(props) {
73
80
  return {
74
81
  uri: ITEMS_UI_RESOURCE_URI,
75
82
  mimeType: RESOURCE_MIME_TYPE,
76
83
  text: readUiBundle(),
77
- _meta: { ui: buildItemsUiResourceMeta(props.domain) },
84
+ _meta: {
85
+ ui: { ...buildItemsUiResourceMeta(props.domain), widgetDescription: WIDGET_DESCRIPTION },
86
+ "openai/widgetDescription": WIDGET_DESCRIPTION,
87
+ },
78
88
  };
79
89
  }
80
90
  /**
package/dist/version.js CHANGED
@@ -1,8 +1,9 @@
1
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.
2
+ * The advertised server version is DERIVED from package.json (never a literal): scripts/embed-static.mjs writes
3
+ * it into src/embedded.generated.ts at build, and `--check` (tests/embedded-static.test.ts) refuses a stale copy.
4
+ * It is a module value, not a file read at import: a bundled consumer carries no package.json beside this module
5
+ * (task k17aj33ks3ndyxgt7jch68rpwx8hz9x5).
5
6
  */
6
- import { readFileSync } from "node:fs";
7
+ import { EMBEDDED_PACKAGE_VERSION } from "./embedded.generated.js";
7
8
  export const SERVER_NAME = "mcp-boilerplate";
8
- export const SERVER_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
9
+ export const SERVER_VERSION = EMBEDDED_PACKAGE_VERSION;
@@ -0,0 +1,200 @@
1
+ # Manufact quality score — gap list of this boilerplate
2
+
3
+ Every check Manufact runs for its MCP quality score, the verdict this boilerplate gets on it today, the
4
+ `mcp-doctor` rule that measures the same property, the evidence, and the boilerplate file that owns the fix.
5
+
6
+ | | |
7
+ |---|---|
8
+ | Boilerplate | `elpiarthera/vantage-mcp-boilerplate` `main` @ `a772895e836422973a5d8bd60277d62e511f629b` (package `0.7.0`) |
9
+ | Scanner (primary) | `mcp-doctor` 0.19.0 at PR #139 head `0b2f29e1d0222e09e47b8485154d237c4f98e223` (carries the S-49, S-50, C-37, C-38, A-13 detectors) |
10
+ | Scanner (comparison) | `npx -y @vantageos/mcp-doctor@0.19.0` (npm `latest`) |
11
+ | Served targets | the local server (`AUTH_MODE=public PORT=8787 npm run start:http`, `http://127.0.0.1:8787/mcp`) and the public instance `https://vantage-mcp-boilerplate-production.up.railway.app/mcp` (Railway, `AUTH_MODE=public`). Both serve `buildId` `e1db86666c3c032b` and the same 3 tools (`tools/list` byte-equal), so they run the same `dist/` |
12
+ | Check list | the 31 check ids of the Manufact report on vantage-peers (fixture `mcp-doctor/fixtures/manufact-vantage-peers-2026-11-08/manufact-report.json`), its 8 Server Metadata sub-checks, its Discoverability block, the policy review, and the domain/TLS publishing checks |
13
+ | Rule mapping | `ElPi-Corp/resources/references/mcp-manufact-gap.md` @ `7e5513eff22c0e2174c100d26e0aa40f642a562f` |
14
+ | Date | 2026-11-08 |
15
+
16
+ ## Limits of this measurement
17
+
18
+ - **Manufact's engine is closed.** The open `mcp-use` code holds none of Manufact's checks. The only exact
19
+ value it publishes is the CLI score weighting, error 3 / warning 2 / info 1
20
+ (`mcp-use/mcp-use@ac0b6a53` `libraries/typescript/packages/cli/src/cloud/checklist/output.ts:85-105`).
21
+ Every verdict below is therefore our reading of the check's name, message and fix text, measured with
22
+ `mcp-doctor` or a raw wire read. It is never Manufact's own output.
23
+ - **Served checks ran twice**: on the local public-mode server and on the public Railway instance
24
+ (`https://vantage-mcp-boilerplate-production.up.railway.app/mcp`). Every `mcp-doctor` served verdict is identical on both, and so is every raw read
25
+ quoted below. The host-level checks (TLS, redirect, server card at the origin, setup page at the MCP URL)
26
+ were read on the public instance. The instance runs on Railway's `*.up.railway.app` hostname, which is not
27
+ an owned domain, and no directory submission has been made yet. No row is left `needs-public-instance`.
28
+ - A check passes only when a scanner output or a raw read-back proves it. `could-not-judge` means the property
29
+ is measured but Manufact's closed reading of it is ambiguous. The reason is stated on the row.
30
+
31
+ ## Verdicts
32
+
33
+ Verdict values: `pass`, `fail`, `could-not-judge`, `needs-public-instance`. The `Sev.` column gives
34
+ Manufact's severity (error / warning / info) where the report states one.
35
+
36
+ ### The 31 Manufact checks
37
+
38
+ | Check id | Name | Sev. | Verdict | mcp-doctor rule -> verdict | Evidence | Owning file (non-pass) |
39
+ |---|---|---|---|---|---|---|
40
+ | server-responds | MCP negotiation succeeds (v1/v2) | error | pass | S23 -> present (served); D3/D4 -> could-not-judge (the doctor obtained only a 2025-06-18 session) | `server/discover` at 2026-07-28 -> `supportedVersions:["2026-07-28"]`, `resultType:"complete"`, `ttlMs:300000`; `initialize` at 2025-11-25 -> HTTP 200, `protocolVersion:"2025-11-25"` | — |
41
+ | tools-list-responds | Tools list returns results | warning | pass | S12 -> present (served) | S12: "tools/list served 3 entries in one page with no nextCursor"; modern `tools/list` -> `ping`, `list_items`, `whoami` | — |
42
+ | skills-over-mcp-readable | Skills over MCP resources are readable | error | could-not-judge | C29 -> absent | Manufact answers `skip` when the extension is not declared. C29: "the extension io.modelcontextprotocol/skills is not declared … 1 SKILL.md file(s) sit in the tree (skills/connect-mcp-server/SKILL.md) and are not served". Whether a `skip` costs score is not public | `src/mcpServerFactory.ts` (declare `io.modelcontextprotocol/skills` and serve `skills/connect-mcp-server/SKILL.md`), if the skip is scored |
43
+ | tool-description-present | All tools have descriptions | warning | pass | C19 -> present (served) | C19: "3 served tool description(s) are bilingual, under the declared per-tool budget (700), and carry their trigger phrase" | — |
44
+ | tool-hints-present | All tools have OpenAI behavior hints | error | pass | C15 -> present (static) | C15: "3 tool(s) each declare all four behavioral annotations"; served, all 3 tools: `readOnlyHint:true`, `destructiveHint:false`, `openWorldHint:false`, all booleans | — |
45
+ | resources-list-responds | Resources list responds | info | pass | S12 -> present (served) | S12: "resources/list served 1 entries"; modern `resources/list` -> `ui://mcp-boilerplate/items-widget.html` | — |
46
+ | claude-code-metadata-length | Claude Code metadata fits its 2 KB fields | warning | pass | C38 -> present (served) | C38: "every budgeted field at or under its cap for claude-code (4 field(s) measured over 3 served tool(s), max 710 bytes)"; served `instructions` = 49 UTF-8 bytes | — |
47
+ | tool-title-present | All tools have a display title | error | pass | C35 -> present (served) | served `title`: `Ping`, `List items`, `Who am I`; C35: "titles and hints equal to the served ones" | — |
48
+ | claude-tool-hints-present | Claude tools declare applicable behavior hints | error | pass | C15 -> present (static) | served `readOnlyHint` and `destructiveHint` on all 3 tools | — |
49
+ | tool-input-header-annotations-valid | Tool input header annotations are valid | error | pass | no rule | no served tool carries `x-mcp-header`: nothing to invalidate (raw read of the 3 served tools) | — |
50
+ | openai-file-params-schema-valid | OpenAI file parameters use valid schemas | error | pass | no rule | no served tool declares an OpenAI file parameter. The `_meta` keys served are `ui`, `openai/outputTemplate`, `openai/toolInvocation/invoking`, `openai/toolInvocation/invoked`, `description_fr` on `list_items`, none on the other two | — |
51
+ | tool-name-protocol-length | Tool names follow the MCP 1–128 character range | warning | pass | DServer2 -> present (served) | served name lengths 4 / 10 / 6 | — |
52
+ | tool-input-schema-type | Tool input schemas have a valid object structure | error | pass | S43 -> present (served inputSchema read) | served `inputSchema.type` = `"object"` on all 3 tools | — |
53
+ | tool-name-format | Tool names use valid characters | warning | pass | DServer2 -> present (served) | each served name matches `[A-Za-z0-9_.-]{1,128}`; DServer2: "3 runtime tool name(s) snake_case" | — |
54
+ | tool-name-length | Claude tool names are within 64 characters | error | pass | DServer2 -> present (served) | longest served name `list_items`, 10 characters | — |
55
+ | cursor-tool-name-budget | Cursor tool-name budget advisory | warning | pass | no rule | longest served name 10 characters, against the 60-character combined budget | — |
56
+ | tool-names-protocol-unique | Tool names follow MCP uniqueness guidance | warning | pass | C12 -> present (static) | 3 served names, 3 distinct | — |
57
+ | tool-names-unique | ChatGPT tool names are unique | error | pass | C12 -> present (static) | 3 served names, 3 distinct | — |
58
+ | tool-output-schema-valid | Declared output schemas have a valid basic structure | error | pass | S44 -> present (served) | S44: "3 output schema(s) read (served tools/list outputSchema)"; each `type:"object"` | — |
59
+ | server-tools-capability-declared | Server declares tools capability | error | pass | S49 -> capabilities found (served) | `server/discover` -> `capabilities:{"tools":{"listChanged":true},"resources":{"listChanged":true}}` | — |
60
+ | tool-text-hidden-characters | Tool text has no hidden control characters | warning | pass | no rule | raw scan of the served name, title, description, inputSchema and annotations of the 3 tools for C0/C1 controls (tab, LF, CR excepted), U+200B–U+200F, U+202A–U+202E, U+2060–U+2064, U+FEFF, U+00AD: 0 hits | — |
61
+ | tool-resource-metadata-complete | View resource metadata complete | warning | fail | no rule | served resource `_meta.ui` = `{csp, prefersBorder}`. No `_meta.ui.widgetDescription` and no `_meta["openai/widgetDescription"]`, on the resource contents or on the linked tool `list_items`. Manufact's fix text reads exactly those two keys | `src/ui/uiResource.ts:71` (`buildItemsUiResourceMeta`) and `src/ui/uiResource.ts:103` (`ITEMS_TOOL_META`) |
62
+ | view-resource-coverage | All discovered view resources were inspected | warning | pass | D6 -> present (static) | 1 `ui://` resource listed, 1 read with HTTP 200 and a non-empty `contents[0]` | — |
63
+ | resource-content-valid | View resource envelope is valid | error | pass | D6 -> present (static) | `resources/read` -> `uri:"ui://mcp-boilerplate/items-widget.html"`, `mimeType:"text/html;profile=mcp-app"`, `text` 191777 characters | — |
64
+ | tool-resource-uri-readable | Tool-linked resource URI is readable | error | pass | D6 -> present (static) | `list_items._meta.ui.resourceUri` = `ui://mcp-boilerplate/items-widget.html`, read OK | — |
65
+ | tool-invocation-metadata-present | Tool invocation metadata present | warning | could-not-judge | A13 -> present (served) | A13 accepts the flat or the nested form: "1 App entry tool(s), each declaring invoking and invoked: list_items". The boilerplate serves only the flat keys `openai/toolInvocation/invoking` / `…/invoked`. Manufact's fix text names an `openai/toolInvocation` object "with string fields invoking and invoked". Which form the closed engine reads is unknown | `src/ui/uiResource.ts:103` (`ITEMS_TOOL_META`: serve the nested object beside the flat keys) |
66
+ | tool-visibility-valid | Tool visibility metadata is valid | error | pass | no rule | served `list_items._meta.ui.visibility` = `["model","app"]` | — |
67
+ | view-domain-present | View resources declare a domain | error | fail | A11 -> could-not-judge (static) | Locally (`PUBLIC_BASE_URL` unset), no `_meta.ui.domain`. With `PUBLIC_BASE_URL=https://mcp.example.com` the server serves `domain:"7a930d27a3f5c4643da88c1794444840.claudemcpcontent.com"`, which is Claude's sandbox hostname. That is not the exact HTTPS origin ChatGPT requires ("one exact HTTPS origin such as https://app.example.com … via _meta.ui.domain (or openai/widgetDomain)"), and no `openai/widgetDomain` is served. The public instance serves no domain either: its `resources/list` `_meta.ui` = `{csp, prefersBorder}`, because `PUBLIC_BASE_URL` is not set there | `src/ui/uiResource.ts:71` (add `openai/widgetDomain` = the owned HTTPS origin, keep `ui.domain` for Claude); `src/http.ts:10` (derive it from `PUBLIC_BASE_URL`); the Railway service env (`PUBLIC_BASE_URL` = the owned origin) |
68
+ | view-csp-origin-coverage | CSP metadata covers literal external origins | warning | could-not-judge | A2 -> present (static) | A2: "0 network/frame target(s) in the UI code". The served widget text holds 2 literal origins, both vendored React strings and neither fetched: `https://react.dev/errors/` (the error-URL builder) and `http://www.w3.org/2000/svg` (the `createElementNS` namespace). All CSP lists are empty. Whether Manufact's static detector counts vendored literals is unknown | `src/ui/uiResource.ts:71` (CSP lists), only if Manufact flags the vendored literals |
69
+ | view-frame-domains-absent | Views do not request nested iframe domains | warning | pass | A2 -> present (static, frame 0) | served `csp.frameDomains` = `[]`, so no frame domain is requested. If the closed engine treats the presence of the empty key as a request, drop the key in `src/ui/uiResource.ts:71` | — |
70
+ | view-domain-responds | Declared domains respond | warning | pass | — | No domain is declared, locally or on the public instance, so nothing can fail to respond. Manufact passed this check on vantage-peers in the same state ("All declared domains are reachable" next to view-domain-present "missing domain"). The row must be re-read once view-domain-present is fixed | — |
71
+
72
+ ### Server Metadata sub-checks (Manufact `protocolMetadata.metadataScore`, points in brackets)
73
+
74
+ | Check id | Name | Sev. | Verdict | mcp-doctor rule -> verdict | Evidence | Owning file (non-pass) |
75
+ |---|---|---|---|---|---|---|
76
+ | metadata:identity | Stable server name [10] | — | pass | S49 -> found (served) | S49: `found: name "mcp-boilerplate"` | — |
77
+ | metadata:title | Human-readable title [10] | — | fail | S49 -> non-conforming (served) | S49: "title absent" | `src/mcpServerFactory.ts:207` (`serverInfo` passed to `new McpServer`) |
78
+ | metadata:description | Useful description [20] | — | fail | S49 -> non-conforming (served) | S49: "description absent" | `src/mcpServerFactory.ts:207` |
79
+ | metadata:version | Server version [10] | — | pass | S49 -> found; D16 -> present (served) | D16: `running server serves version "0.7.0" pre-auth on initialize, matching package.json` | — |
80
+ | metadata:website | Public website [10] | — | fail | S49 -> non-conforming (served) | S49: "websiteUrl absent" | `src/mcpServerFactory.ts:207` |
81
+ | metadata:icon | Scalable or high-resolution icon [10] | — | fail | S49 -> non-conforming (served) | S49: "icons absent" | `src/mcpServerFactory.ts:207` (`icons`, plus an SVG or 128px asset served by `src/httpApp.ts`) |
82
+ | metadata:instructions | Server instructions [20] | — | could-not-judge | S49 -> found (served) | S49: "instructions 49 bytes". The served text is `Clonable MCP server boilerplate — see README.md`: it is present, but it only points to the README. Manufact scores whether the instructions are useful ("explain how an agent should use the server"), and that judgement is closed | `src/mcpServerFactory.ts:216` |
83
+ | metadata:capabilities | Declared capabilities [10] | — | pass | S49 -> found (served) | S49: `capabilities tools, resources` | — |
84
+
85
+ ### Discoverability (Manufact `discoverability`)
86
+
87
+ | Check id | Name | Sev. | Verdict | mcp-doctor rule -> verdict | Evidence | Owning file (non-pass) |
88
+ |---|---|---|---|---|---|---|
89
+ | discoverability:manifest | `mcp.json` or MCP Server Card served | — | fail | S50 -> non-conforming (served) | S50: "no server card served (/mcp.json 404, /.well-known/mcp.json 404, /mcp/server-card 404, /.well-known/mcp-server-card 404, /.well-known/mcp/server-card.json 404)"; raw `GET /.well-known/ai-catalog.json` -> 404. Public instance: all 7 paths -> 404 (S50 and raw read) | `src/httpApp.ts` (serve the card before the `/mcp` gate, built from `mcp-server.json` through `src/serverManifest.ts`) |
90
+ | discoverability:landingPage | Setup instructions at the MCP URL | — | fail | S50 -> non-conforming (served) | S50: "the MCP base URL http://127.0.0.1:8787/mcp answers HTTP 405: no setup instructions"; raw `GET /mcp` (Accept text/html) -> 405 `{"code":-32000,"message":"Method not allowed."}`. Public instance: `GET https://vantage-mcp-boilerplate-production.up.railway.app/mcp` -> 405, and S50 reads the same. On the OAuth tier the same GET would hit the auth gate (Manufact read HTTP 401 on vantage-peers) | `src/httpApp.ts:267` (answer an unauthenticated `GET /mcp` with `Accept: text/html` with a bilingual setup page, ahead of the auth middleware at `src/httpApp.ts:174`/`:216`) |
91
+ | discoverability:chatgpt | Listed on ChatGPT | — | fail | C37 -> non-conforming (static) | C37: "no listing proof for chatgpt (the ChatGPT app directory)". Not submitted, so not listed | `mcp-server.json` (`distribution.listings.chatgpt`, after the submission) |
92
+ | discoverability:claude | Listed on Claude (Verified by Anthropic) | — | fail | C37 -> non-conforming (static) | C37: "no listing proof for claude (the Claude connector directory)". Not submitted, so not listed | `mcp-server.json` (`distribution.listings.claude`, after the submission) |
93
+ | discoverability:cursor | Cursor marketplace | — | fail | C37 -> non-conforming (static) | C37: "no listing proof for cursor (the Cursor marketplace)". Not submitted, so not listed | `mcp-server.json` (`distribution.listings.cursor`, after the submission) |
94
+ | discoverability:muse | Listed on Muse | — | fail | no rule (Muse is not a declared target) | Not submitted, so not listed. `mcp-server.json` `distribution.targets` = `["claude","claude-code","vscode","cursor","chatgpt"]`: Muse is not declared, so C37 does not read it | `mcp-server.json` (`distribution.targets` + `listings`, if Muse is targeted) |
95
+
96
+ ### Policy review and domain checks
97
+
98
+ | Check id | Name | Sev. | Verdict | mcp-doctor rule -> verdict | Evidence | Owning file (non-pass) |
99
+ |---|---|---|---|---|---|---|
100
+ | policy-review | Directory-policy directives in tool descriptions (Manufact "Security & Policy": instruction-like tool copy) | — | could-not-judge | C16 -> absent (n/a); C19 -> present (served, requires the trigger phrase) | The served descriptions say "Use this whenever you need to check that this server answers, even if they don't say 'ping'." (`ping`), "Use this whenever the user asks to list, see or render the items, even if they don't say 'list'." (`list_items`), and the same pattern on `whoami`. The Anthropic Software Directory Policy (Instructional Software) says (A) tools are defined "through narrow, unambiguous natural language", and (D) descriptions "must not be written in a way that intentionally leads to other Software extraneously calling them". The "even if they don't say X" widening is the copy a policy agent can flag. C-19 currently REQUIRES that trigger phrase, so removing it needs a standard ruling first | `src/mcpServerFactory.ts:261`, `:321`, `:390` (the three bilingual descriptions); the C-19 trigger-phrase clause in the standard |
101
+ | domain-tls | Widgets resolve from an owned domain: hostname, TLS, redirects (Manufact "The Domain is Set") | — | fail | S45 -> could-not-judge (static) | TLS and redirect hold on the public instance: TLSv1.3, certificate `CN=*.up.railway.app` issued by Let's Encrypt and expiring 2026-12-26; `http://…/health` -> 301 to `https://…/health`. The hostname is Railway's, though, not an owned one. S45: "the public origin is not declared: only a development default (localhost at src/config.ts:39)", and the widget serves no domain | the Railway service (an owned custom domain); `mcp-server.json` / `.env.example` (`PUBLIC_BASE_URL` = that origin) |
102
+
103
+ ## Non-pass rows — the fix list
104
+
105
+ `fail`, fixable in the tree now:
106
+
107
+ 1. `tool-resource-metadata-complete`: `src/ui/uiResource.ts:71`, `:103`.
108
+ 2. `view-domain-present`: `src/ui/uiResource.ts:71`, `src/http.ts:10`, and `PUBLIC_BASE_URL` set on the Railway service.
109
+ 3. `metadata:title`, `metadata:description`, `metadata:website`, `metadata:icon`: `src/mcpServerFactory.ts:207` (and an icon asset served by `src/httpApp.ts`).
110
+ 4. `discoverability:manifest`: `src/httpApp.ts`, `src/serverManifest.ts`.
111
+ 5. `discoverability:landingPage`: `src/httpApp.ts:267` (before the gates at `:174` / `:216`).
112
+ 6. `domain-tls`: an owned custom domain on the Railway service, declared as `PUBLIC_BASE_URL` (`mcp-server.json` / `.env.example`). This is infrastructure, not code.
113
+ 7. `discoverability:chatgpt`, `:claude`, `:cursor`, `:muse`: submit to each directory, then record the proof in `mcp-server.json` `distribution.listings` (C-37). This needs the owned domain and the fixes above first.
114
+
115
+ `could-not-judge`, where a defensive fix settles the ambiguity:
116
+
117
+ 8. `tool-invocation-metadata-present`: `src/ui/uiResource.ts:103` (add the nested `openai/toolInvocation` object).
118
+ 9. `metadata:instructions`: `src/mcpServerFactory.ts:216` (actionable instructions, not a README pointer).
119
+ 10. `policy-review`: `src/mcpServerFactory.ts:261`, `:321`, `:390`. This needs a ruling on the C-19 trigger phrase first.
120
+ 11. `skills-over-mcp-readable`: `src/mcpServerFactory.ts` (serve the Skills extension), only if a skip is scored.
121
+ 12. `view-csp-origin-coverage`: `src/ui/uiResource.ts:71`, only if the vendored React literals are flagged.
122
+
123
+ ## What the scanners could not read
124
+
125
+ - **`mcp-doctor --url` never opens a 2026-07-28 session.** D3 and D4 are could-not-judge: "the session
126
+ negotiated 2025-06-18 … the doctor could not obtain a 2026-07-28 session". The raw read shows the server
127
+ does serve 2026-07-28. `server/discover` with the per-request envelope (`_meta` protocolVersion +
128
+ clientCapabilities, `Mcp-Method` header) answers `resultType:"complete"`, `ttlMs`, `cacheScope`.
129
+ - **C25 is non-conforming in both served runs** (not a Manufact check): `served build-identity "buildId"="e1db86666c3c032b" DIFFERS from the expected commit "a772895e…"`.
130
+ The boilerplate's `buildId` is a content hash of `dist/` by design (`src/buildIdentity.ts`), while
131
+ `--source` makes the doctor expect the git commit. The two contracts disagree, and that disagreement is
132
+ traced separately from this list.
133
+ - **No `mcp-doctor` rule reads** `tool-resource-metadata-complete` (widgetDescription),
134
+ `tool-input-header-annotations-valid`, `openai-file-params-schema-valid`, `cursor-tool-name-budget`,
135
+ `tool-visibility-valid` or `tool-text-hidden-characters`. Those rows rest on a raw wire read, cited on each
136
+ row.
137
+ - **Static could-not-judge rows that bear on these checks**: A11 (the domain derivation exists, but its value
138
+ is judged only when served), S45 (no public origin declared), S49 and S50 (judged on the served half, which
139
+ is above), and C38 (judged served, above).
140
+ - **The public-instance runs** (`--url https://vantage-mcp-boilerplate-production.up.railway.app/mcp`, PR #139 and npm 0.19.0) return the same states as the
141
+ local runs: PR #139 gives present 13 / non-conforming 3 (C25, S49, S50) / could-not-judge 6, and npm gives
142
+ present 11 / non-conforming 1 / could-not-judge 6.
143
+ - **npm `latest` 0.19.0 does not carry** A13, C37, C38, S49 or S50. Its static run has 118 rows against 123
144
+ for PR #139, and its served run has 18 against 22. Every row the two versions share has the same verdict.
145
+
146
+ ## How to reproduce
147
+
148
+ ```bash
149
+ git clone https://github.com/elpiarthera/vantage-mcp-boilerplate.git bp && cd bp
150
+ git checkout a772895e836422973a5d8bd60277d62e511f629b
151
+ npm ci && npm run build && npm run build:ui
152
+ AUTH_MODE=public PORT=8787 npm run start:http &
153
+
154
+ # mcp-doctor at PR #139 head (0b2f29e1), built in ../md
155
+ node ../md/dist/doctor.js . --mode=static --json > static.json
156
+ node ../md/dist/doctor.js --url http://127.0.0.1:8787/mcp --source . --json > served.json
157
+ npx -y @vantageos/mcp-doctor@0.19.0 . --mode=static --json > static-npm.json
158
+ npx -y @vantageos/mcp-doctor@0.19.0 --url http://127.0.0.1:8787/mcp --source . --json > served-npm.json
159
+
160
+ # raw 2026-07-28 reads (server/discover, tools/list, resources/list, resources/read)
161
+ M='"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"probe","version":"0"},"io.modelcontextprotocol/clientCapabilities":{}}'
162
+ for m in server/discover tools/list resources/list; do
163
+ curl -s -X POST http://127.0.0.1:8787/mcp -H 'content-type: application/json' \
164
+ -H 'accept: application/json, text/event-stream' -H 'mcp-protocol-version: 2026-07-28' \
165
+ -H "mcp-method: $m" -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"$m\",\"params\":{$M}}"; echo
166
+ done
167
+ curl -s -X POST http://127.0.0.1:8787/mcp -H 'content-type: application/json' \
168
+ -H 'accept: application/json, text/event-stream' -H 'mcp-protocol-version: 2026-07-28' \
169
+ -H 'mcp-method: resources/read' -H 'mcp-name: ui://mcp-boilerplate/items-widget.html' \
170
+ -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"resources/read\",\"params\":{\"uri\":\"ui://mcp-boilerplate/items-widget.html\",$M}}"
171
+ curl -s -o /dev/null -w '%{http_code}\n' -H 'accept: text/html' http://127.0.0.1:8787/mcp # 405
172
+
173
+ # public instance: the same doctor and raw reads with U=https://vantage-mcp-boilerplate-production.up.railway.app
174
+ node ../md/dist/doctor.js --url "$U/mcp" --source . --json > served-public.json
175
+ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' "http://${U#https://}/health" # 301 -> https
176
+ curl -sv -o /dev/null "$U/health" 2>&1 | grep -E 'SSL connection|subject:|issuer:|expire date'
177
+
178
+ # the domain served once a public origin is set
179
+ AUTH_MODE=public PORT=8788 PUBLIC_BASE_URL=https://mcp.example.com node dist/http.js &
180
+ ```
181
+
182
+ Count the verdicts on this file (verdict = 5th `|`-separated field of a check row):
183
+
184
+ ```bash
185
+ awk -F'|' '$2 ~ /^ (metadata:|discoverability:|policy-review|domain-tls|[a-z]+-[a-z-]+ )/ && $5 ~ /pass|fail|could-not-judge|needs-public-instance/ {gsub(/ /,"",$5); n[$5]++} END {for (v in n) print v, n[v]}' docs/manufact-gap.md
186
+ ```
187
+
188
+ ## Résumé (FR)
189
+
190
+ Ce document relève l'écart entre le boilerplate (`main` @ `a772895e`) et chacun des contrôles du score
191
+ qualité Manufact : les 31 contrôles du rapport, les 8 sous-contrôles « Server Metadata », la
192
+ découvrabilité, la revue de politique et les contrôles de domaine. Chaque ligne donne le verdict, la règle
193
+ `mcp-doctor` (PR #139 @ `0b2f29e1`), la preuve et le fichier propriétaire du correctif. Les échecs
194
+ corrigeables tout de suite portent sur les métadonnées serveur (title, description, websiteUrl, icons dans
195
+ `src/mcpServerFactory.ts`), la description et le domaine du widget (`src/ui/uiResource.ts`), la carte
196
+ serveur et la page d'installation à l'URL MCP (`src/httpApp.ts`). Les contrôles servis ont été lus sur le
197
+ serveur local et sur l'instance publique Railway, avec des verdicts identiques. TLS et la redirection vers
198
+ HTTPS tiennent, mais le nom d'hôte appartient à Railway et non à nous : il manque un domaine possédé. Aucune
199
+ inscription en annuaire n'a encore été faite. La revue de politique signale la formule « even if they don't say X » des descriptions, que la
200
+ règle C-19 impose aujourd'hui. La retirer demande d'abord un arbitrage sur la norme.
package/mcp-server.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-boilerplate",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "capabilities": {
5
5
  "tools": {},
6
6
  "resources": {}
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@vantageos/mcp-boilerplate",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
+ "displayName": "MCP Boilerplate",
4
5
  "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
6
  "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
7
  "type": "module",
@@ -53,7 +54,7 @@
53
54
  "homepage": "https://github.com/elpiarthera/vantage-mcp-boilerplate#readme",
54
55
  "author": "ElPi Corp / Laurent Perello",
55
56
  "scripts": {
56
- "build": "node scripts/clean-dist.mjs && tsc && mkdir -p dist/locales && cp src/locales/en.json src/locales/fr.json dist/locales/ && node scripts/sync-plugin-version.mjs && node scripts/listing-kit.mjs --check",
57
+ "build": "node scripts/clean-dist.mjs && node scripts/embed-static.mjs && tsc && node scripts/write-build-info.mjs && node scripts/sync-plugin-version.mjs && node scripts/listing-kit.mjs --check",
57
58
  "listing:write": "node scripts/listing-kit.mjs",
58
59
  "changelog:check": "node scripts/changelog-assemble.mjs --check",
59
60
  "changelog:release": "node scripts/changelog-assemble.mjs",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "vantage-mcp-boilerplate",
4
- "version": "0.7.0",
4
+ "version": "0.8.0",
5
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
6
  "author": {
7
7
  "name": "ElPi Corp / Laurent Perello"
@@ -34,9 +34,7 @@
34
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
35
  "developerName": "ElPi Corp / Laurent Perello",
36
36
  "websiteURL": "https://github.com/elpiarthera/vantage-mcp-boilerplate#readme",
37
- "capabilities": [
38
- "Read"
39
- ]
37
+ "capabilities": ["Read"]
40
38
  }
41
39
  }
42
40
  }
@@ -85,19 +85,24 @@ export function generate(text) {
85
85
  return;
86
86
  }
87
87
  // lang === "fr"
88
- if (state === "shared")
88
+ if (state === "shared") {
89
89
  errors.push(
90
90
  `line ${n}: lang:fr opens a region before its English part (<!-- lang:en --> first)`
91
91
  );
92
- else if (state === "fr")
92
+ // The region it opens is reported from this line, never from a stale or zero opening line.
93
+ openedAt = n;
94
+ } else if (state === "fr")
93
95
  errors.push(`line ${n}: a second lang:fr in the region opened at line ${openedAt}`);
94
96
  state = "fr";
95
97
  });
96
98
  if (fence !== null)
97
99
  errors.push(`line ${fence.at}: unclosed code fence (${fence.char.repeat(fence.len)})`);
100
+ // Every line number printed is 1-based. The last line of a file ending with a newline is the one before the empty
101
+ // string that split() leaves after it.
102
+ const lastLine = text.endsWith("\n") ? lines.length - 1 : lines.length;
98
103
  if (state !== "shared")
99
104
  errors.push(
100
- `line ${lines.length}: the region opened at line ${openedAt} is unclosed (<!-- /lang -->)`
105
+ `line ${lastLine}: the region opened at line ${openedAt} is unclosed (<!-- /lang -->)`
101
106
  );
102
107
  return { fr: out.join("\n"), errors };
103
108
  }