@vantageos/mcp-boilerplate 0.3.0 → 0.4.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vantage-mcp-boilerplate",
3
3
  "displayName": "VantageOS MCP Boilerplate",
4
- "version": "0.3.0",
4
+ "version": "0.4.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"
package/CHANGELOG.md CHANGED
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-10-28
11
+
12
+ > **BREAKING for forks.** `CONTEXT_CEILING` is no longer exported and `mcp-server.json` must declare
13
+ > `contextCeiling`. Migration: add `contextCeiling: { global, perTool }` to your `mcp-server.json`
14
+ > (the seed uses `{ "global": 1200, "perTool": 700 }`) and replace every `CONTEXT_CEILING` import
15
+ > with `loadServerManifest().contextCeiling`.
16
+
17
+ ### Added
18
+
19
+ - **Per-server context ceiling** (G6): `mcp-server.json` carries `contextCeiling: { global, perTool }`
20
+ (positive integers, `global >= perTool`), read by `loadServerManifest`. A missing or non-numeric
21
+ value refuses at start, naming the field (FR+EN); `features` stays booleans only.
22
+ `checkContextCeiling` and `assertToolRecords` (exported) enforce the standard's rule against the
23
+ server's own ceiling: no tool above `perTool`, the sum not above `global`.
24
+ - **`upstream-delegated(<upstream>)` search mechanism** for a tool answered by a live upstream call.
25
+ `checkToolRecord` / `assertToolRecords` refuse an undeclared live call (a list/search tool with
26
+ `openWorldHint: true` and an indexed mechanism or none), the declaration without
27
+ `openWorldHint: true`, an empty upstream name, `unindexed-scan` and any value outside the set.
28
+
29
+ ### Changed
30
+
31
+ - **BREAKING for forks** (0.x): the start refusal for tool records now runs on the tools AS
32
+ REGISTERED. The factory no longer holds a hand-typed `{ ping, list_items }` map: it wraps
33
+ `server.registerTool` (`trackToolRecords`, exported) and asserts the collected annotations once
34
+ every tool is registered. A tool registered with an unsound record, or with none, refuses the
35
+ start by name (FR+EN); a fork adds nothing to a list.
36
+ - `upstream-delegated(<upstream>)` refuses `upstream-delegated())`; the indexed mechanism set is
37
+ matched whole, so `indexed-range(anything` is refused.
38
+
39
+ ### Removed (BREAKING for forks)
40
+
41
+ - **`CONTEXT_CEILING`** is no longer exported by `src/toolRecord.ts`: read
42
+ `loadServerManifest().contextCeiling`. A fork's `mcp-server.json` must add `contextCeiling`
43
+ (the seed declares `{ "global": 1200, "perTool": 700 }`, the former constant).
44
+
10
45
  ## [0.3.0] - 2026-10-28
11
46
 
12
47
  ### Added
package/README.fr.md CHANGED
@@ -92,9 +92,9 @@ Chaque outil déclare `title`, un schéma d'entrée et un schéma de sortie typ
92
92
  | `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` | C-15 | les quatre booléens de comportement |
93
93
  | `authRead`, `authWrite` | C-8 | couple d'autorité parmi `public` / `org-member` / `org-admin` / `fleet-internal` / `master`, distincts ; résolu depuis l'identité vérifiée, jamais depuis un argument |
94
94
  | `verb`, `table` | C-9, C-12 | l'opération (`list`, `get`, `create`, `pure`...) et la donnée touchée |
95
- | `searchMechanism` | C-10 | comment une liste/recherche est servie (`indexed-range` ici) ; `unindexed-scan` ne se publie pas |
95
+ | `searchMechanism` | C-10 | comment une liste/recherche est servie (`indexed-range` ici) ; `unindexed-scan` ne se publie pas. Un outil servi par un appel tiers en direct déclare `upstream-delegated(<amont>)` avec `openWorldHint: true` ; `assertToolRecords` refuse un appel en direct non déclaré, ou la déclaration sans le hint |
96
96
  | `paginationCursor`, `paginationDefault`, `paginationMax` | C-11 | curseur, taille de page par défaut et maximum fini, appliqués par `src/pagination.ts` |
97
- | `tokensEstimeContexte` | C-13 | coût de contexte estimé ; `CONTEXT_CEILING` borne chaque outil et la somme |
97
+ | `tokensEstimeContexte` | C-13 | coût de contexte estimé ; le `contextCeiling` propre au serveur (`global`, `perTool`, dans `mcp-server.json`) borne chaque outil et la somme, appliqué par `assertToolRecords` au démarrage ; un plafond absent ou non numérique arrête le serveur |
98
98
 
99
99
  Les ensembles sont des unions TypeScript fermées : une valeur hors ensemble ne
100
100
  compile pas, et un outil de liste ne peut omettre ni son mécanisme ni sa pagination.
package/README.md CHANGED
@@ -91,9 +91,9 @@ and returns `structuredContent` plus a text block with the same JSON. Its
91
91
  | `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` | C-15 | the four behavioural booleans |
92
92
  | `authRead`, `authWrite` | C-8 | authority pair from `public` / `org-member` / `org-admin` / `fleet-internal` / `master`, distinct; resolved from the verified identity, never from an argument |
93
93
  | `verb`, `table` | C-9, C-12 | the operation (`list`, `get`, `create`, `pure`...) and the data it touches |
94
- | `searchMechanism` | C-10 | how a list/search is served (`indexed-range` here); `unindexed-scan` does not publish |
94
+ | `searchMechanism` | C-10 | how a list/search is served (`indexed-range` here); `unindexed-scan` does not publish. A tool answered by a live third-party call declares `upstream-delegated(<upstream>)` with `openWorldHint: true`; `assertToolRecords` refuses an undeclared live call, or the declaration without the hint |
95
95
  | `paginationCursor`, `paginationDefault`, `paginationMax` | C-11 | cursor, default page size and a finite maximum, applied by `src/pagination.ts` |
96
- | `tokensEstimeContexte` | C-13 | estimated context cost; `CONTEXT_CEILING` bounds each tool and the sum |
96
+ | `tokensEstimeContexte` | C-13 | estimated context cost; the server's own `contextCeiling` (`global`, `perTool`, in `mcp-server.json`) bounds each tool and the sum, enforced by `assertToolRecords` at start; a missing or non-numeric ceiling stops the server |
97
97
 
98
98
  The sets are closed TypeScript unions: a value outside them does not compile, and a
99
99
  list tool cannot omit its mechanism or its pagination. Each tool's record is a plain
package/dist/httpApp.js CHANGED
@@ -31,6 +31,7 @@ import { detectLocale, t } from "./i18n.js";
31
31
  import { createBoilerplateMcpServer } from "./mcpServerFactory.js";
32
32
  import { createAuthorizationServer, } from "./oauth/authorizationServer.js";
33
33
  import { MCP_SCOPE } from "./oauth/settings.js";
34
+ import { loadServerManifest } from "./serverManifest.js";
34
35
  import { resolveTenantFromJwtPayload } from "./tenancy.js";
35
36
  import { assertIdentityDeps, resolveVerifiedPerson, } from "./verifiedPerson.js";
36
37
  import { SERVER_VERSION } from "./version.js";
@@ -41,6 +42,8 @@ const PRM_PATHS = [
41
42
  export function buildHttpApp(deps = {}) {
42
43
  const app = new Hono();
43
44
  const authMode = deps.authMode ?? "public";
45
+ // Refuses at start, naming the field, a manifest without a readable context ceiling (G6).
46
+ loadServerManifest();
44
47
  if (deps.identity)
45
48
  assertIdentityDeps(deps.identity);
46
49
  // Unauthenticated (S-7): the version AND the identity of the tree being served (C-25), so a
package/dist/index.d.ts CHANGED
@@ -12,3 +12,9 @@ export { UpstreamTokenError, clientCredentialsFromEnv, createClientCredentialsTo
12
12
  export type { ClientAuthMethod, ClientCredentialsEnvOptions, ClientCredentialsEnvVars, ClientCredentialsOptions, ClientCredentialsTokenSource, } from "./upstream/clientCredentials.js";
13
13
  export { UpstreamPageError, createPaginatedUpstreamClient } from "./upstream/paginatedClient.js";
14
14
  export type { FetchLike, PaginatedUpstreamClient, PaginatedUpstreamOptions, PaginationPlacement, ParamObject, ParamValue, UpstreamPage, UpstreamPageErrorCode, UpstreamParams, } from "./upstream/paginatedClient.js";
15
+ export { ServerManifestError, loadServerManifest, parseServerManifest, } from "./serverManifest.js";
16
+ export type { ContextCeiling, ServerManifest } from "./serverManifest.js";
17
+ export { trackToolRecords } from "./servedToolRecords.js";
18
+ export type { ServedToolRecords } from "./servedToolRecords.js";
19
+ export { ToolRecordError, assertToolRecords, checkContextCeiling, checkToolRecord, } from "./toolRecord.js";
20
+ export type { ContextEstimate, SearchMechanism, ToolSlots } from "./toolRecord.js";
package/dist/index.js CHANGED
@@ -6,3 +6,6 @@ export { buildHttpApp } from "./httpApp.js";
6
6
  export { PERSON_UNRESOLVED_CODE, resolveVerifiedPerson, withVerifiedPerson, } from "./verifiedPerson.js";
7
7
  export { UpstreamTokenError, clientCredentialsFromEnv, createClientCredentialsTokenSource, } from "./upstream/clientCredentials.js";
8
8
  export { UpstreamPageError, createPaginatedUpstreamClient } from "./upstream/paginatedClient.js";
9
+ export { ServerManifestError, loadServerManifest, parseServerManifest, } from "./serverManifest.js";
10
+ export { trackToolRecords } from "./servedToolRecords.js";
11
+ export { ToolRecordError, assertToolRecords, checkContextCeiling, checkToolRecord, } from "./toolRecord.js";
@@ -36,9 +36,14 @@
36
36
  "string": "a non-empty string",
37
37
  "semver": "a semantic version (x.y.z)",
38
38
  "object": "an object",
39
- "boolean": "true or false"
39
+ "boolean": "true or false",
40
+ "positiveInteger": "a positive whole number",
41
+ "atLeastPerTool": "at least the perTool ceiling"
40
42
  }
41
43
  },
44
+ "toolRecord": {
45
+ "unsound": "tool records or the context ceiling are unsound: {problems}"
46
+ },
42
47
  "pagination": {
43
48
  "invalidCursor": "invalid cursor: it was not issued by this tool or is no longer valid; start again without a cursor"
44
49
  },
@@ -36,9 +36,14 @@
36
36
  "string": "une chaîne non vide",
37
37
  "semver": "une version sémantique (x.y.z)",
38
38
  "object": "un objet",
39
- "boolean": "true ou false"
39
+ "boolean": "true ou false",
40
+ "positiveInteger": "un entier strictement positif",
41
+ "atLeastPerTool": "au moins le plafond perTool"
40
42
  }
41
43
  },
44
+ "toolRecord": {
45
+ "unsound": "fiches d'outils ou plafond de contexte invalides : {problems}"
46
+ },
42
47
  "pagination": {
43
48
  "invalidCursor": "curseur invalide : il n'a pas été émis par cet outil ou n'est plus valide ; recommencez sans curseur"
44
49
  },
@@ -35,6 +35,7 @@ import { 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 { INVALID_CURSOR_CODE, InvalidCursorError, PAGINATION, readPage } from "./pagination.js";
38
+ import { trackToolRecords } from "./servedToolRecords.js";
38
39
  import { loadServerManifest } from "./serverManifest.js";
39
40
  import { scopeRowsForTenant } from "./tenancy.js";
40
41
  import { LIST_ITEMS_ANNOTATIONS, PING_ANNOTATIONS } from "./toolRecord.js";
@@ -95,6 +96,10 @@ function refusal(text) {
95
96
  }
96
97
  export function createBoilerplateMcpServer(deps = {}) {
97
98
  const locale = deps.locale ?? "en";
99
+ // The manifest carries the server's context ceiling. Every tool registered below is recorded
100
+ // at registration and checked against it (and the closed mechanism set) before the server is
101
+ // returned: the check covers what is served, there is no list to keep in step.
102
+ const manifest = loadServerManifest();
98
103
  // The identity the server announces: version (declared) AND buildId (derived from the served
99
104
  // tree, src/buildIdentity.ts). The SDK sends `serverInfo` as given, on `server/discover`
100
105
  // (2026-07-28, `_meta["io.modelcontextprotocol/serverInfo"]`) and on `initialize` (2025-era).
@@ -105,7 +110,7 @@ export function createBoilerplateMcpServer(deps = {}) {
105
110
  ...{ buildId: getBuildIdentity() },
106
111
  }, {
107
112
  // The capabilities come from the server manifest (mcp-server.json, C-18): one source.
108
- capabilities: loadServerManifest().capabilities,
113
+ capabilities: manifest.capabilities,
109
114
  instructions: "Clonable MCP server boilerplate — see README.md",
110
115
  supportedProtocolVersions: SUPPORTED_LEGACY_PROTOCOL_VERSIONS,
111
116
  cacheHints: {
@@ -115,6 +120,7 @@ export function createBoilerplateMcpServer(deps = {}) {
115
120
  "server/discover": { ttlMs: 300_000, cacheScope: "public" },
116
121
  },
117
122
  });
123
+ const served = trackToolRecords(server);
118
124
  server.registerTool("ping", {
119
125
  title: "Ping",
120
126
  description: "Echoes the given message (or 'pong' if none given). Proves the MCP handshake end to end.",
@@ -212,5 +218,7 @@ export function createBoilerplateMcpServer(deps = {}) {
212
218
  }, async () => ({
213
219
  contents: [buildUiResource({ domain: deps.uiDomain })],
214
220
  }));
221
+ // Start refusal: every registered tool's record, as registered, must be sound.
222
+ served.assertServed(manifest.contextCeiling);
215
223
  return server;
216
224
  }
@@ -0,0 +1,18 @@
1
+ import type { McpServer } from "@modelcontextprotocol/server";
2
+ import type { ContextCeiling } from "./serverManifest.js";
3
+ /**
4
+ * The tool records AS REGISTERED. The start refusal (`assertToolRecords`) must run on the tools
5
+ * the server actually serves, not on a second list a fork has to keep in step with its
6
+ * `registerTool` calls: a tool missing from such a list would ship unchecked.
7
+ *
8
+ * `trackToolRecords` wraps `server.registerTool` on that instance and keeps each tool's
9
+ * `annotations` as given. A tool registered with none is kept as `{}`, so it fails the check
10
+ * by name instead of being skipped. Call `assertServed` once every tool is registered.
11
+ */
12
+ export interface ServedToolRecords {
13
+ /** The annotations of every tool registered so far, by tool name. */
14
+ readonly records: Readonly<Record<string, Record<string, unknown>>>;
15
+ /** Throws `ToolRecordError` naming every unsound tool; same check as the wire test. */
16
+ assertServed(ceiling: ContextCeiling): void;
17
+ }
18
+ export declare function trackToolRecords(server: McpServer): ServedToolRecords;
@@ -0,0 +1,10 @@
1
+ import { assertToolRecords } from "./toolRecord.js";
2
+ export function trackToolRecords(server) {
3
+ const records = {};
4
+ const register = server.registerTool.bind(server);
5
+ server.registerTool = ((name, config, callback) => {
6
+ records[name] = { ...(config.annotations ?? {}) };
7
+ return register(name, config, callback);
8
+ });
9
+ return { records, assertServed: (ceiling) => assertToolRecords(records, ceiling) };
10
+ }
@@ -8,6 +8,15 @@
8
8
  * message. Nothing is defaulted — a manifest that silently falls back hides the very drift
9
9
  * C-18 exists to catch.
10
10
  */
11
+ /**
12
+ * The context ceiling (mcp-standard C-13), in estimated tokens, declared by THIS server: no tool
13
+ * may pass `perTool`, and the tools together may not pass `global`. A server with more tools
14
+ * declares a larger ceiling here; `checkContextCeiling` (src/toolRecord.ts) enforces it.
15
+ */
16
+ export interface ContextCeiling {
17
+ global: number;
18
+ perTool: number;
19
+ }
11
20
  export interface ServerManifest {
12
21
  name: string;
13
22
  version: string;
@@ -15,8 +24,10 @@ export interface ServerManifest {
15
24
  transport: Record<string, unknown>;
16
25
  /** Feature switches. Every value is a boolean; a lane adds its own key. */
17
26
  features: Record<string, boolean>;
27
+ /** The per-server context ceiling. A number cannot ride in `features` (booleans only). */
28
+ contextCeiling: ContextCeiling;
18
29
  }
19
- type Expectation = "string" | "semver" | "object" | "boolean";
30
+ type Expectation = "string" | "semver" | "object" | "boolean" | "positiveInteger" | "atLeastPerTool";
20
31
  /** Thrown when a manifest field cannot be read. `field` is the dotted path of the offender. */
21
32
  export declare class ServerManifestError extends Error {
22
33
  readonly field: string;
@@ -28,11 +28,14 @@ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
28
28
  function isRecord(value) {
29
29
  return typeof value === "object" && value !== null && !Array.isArray(value);
30
30
  }
31
+ function isPositiveInteger(value) {
32
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
33
+ }
31
34
  /** Validates an already-parsed manifest document. `source` names it in the refusal. */
32
35
  export function parseServerManifest(document, source) {
33
36
  if (!isRecord(document))
34
37
  throw new ServerManifestError("(document)", source, "object");
35
- const { name, version, capabilities, transport, features } = document;
38
+ const { name, version, capabilities, transport, features, contextCeiling } = document;
36
39
  if (typeof name !== "string" || name.length === 0) {
37
40
  throw new ServerManifestError("name", source, "string");
38
41
  }
@@ -50,7 +53,26 @@ export function parseServerManifest(document, source) {
50
53
  throw new ServerManifestError(`features.${key}`, source, "boolean");
51
54
  }
52
55
  }
53
- return { name, version, capabilities, transport, features: features };
56
+ if (!isRecord(contextCeiling))
57
+ throw new ServerManifestError("contextCeiling", source, "object");
58
+ const { global, perTool } = contextCeiling;
59
+ if (!isPositiveInteger(global)) {
60
+ throw new ServerManifestError("contextCeiling.global", source, "positiveInteger");
61
+ }
62
+ if (!isPositiveInteger(perTool)) {
63
+ throw new ServerManifestError("contextCeiling.perTool", source, "positiveInteger");
64
+ }
65
+ if (global < perTool) {
66
+ throw new ServerManifestError("contextCeiling.global", source, "atLeastPerTool");
67
+ }
68
+ return {
69
+ name,
70
+ version,
71
+ capabilities,
72
+ transport,
73
+ features: features,
74
+ contextCeiling: { global, perTool },
75
+ };
54
76
  }
55
77
  /**
56
78
  * Reads `mcp-server.json` from the package root. `src/` and `dist/` both sit one level under
package/dist/stdio.js CHANGED
@@ -8,11 +8,14 @@
8
8
  */
9
9
  import { serveStdio } from "@modelcontextprotocol/server/stdio";
10
10
  import { createBoilerplateMcpServer } from "./mcpServerFactory.js";
11
+ import { loadServerManifest } from "./serverManifest.js";
11
12
  export function startStdioServer() {
12
13
  // `requireTenant` is otherwise only reachable from the OAuth-tier HTTP transport (see
13
14
  // httpApp.ts). This env toggle exists solely so the eval suite (evals/evals.json
14
15
  // "list-items-requires-tenant-refusal") can exercise the existing `list_items`
15
16
  // tenant-refusal branch through the real stdio transport. Unset by default.
17
+ // Refuses at start, naming the field, a manifest without a readable context ceiling (G6).
18
+ loadServerManifest();
16
19
  const requireTenant = process.env.MCP_BOILERPLATE_REQUIRE_TENANT === "1";
17
20
  const uiDomain = process.env.MCP_UI_DOMAIN || undefined;
18
21
  // No verified person on stdio, by design: there is no bearer and no verified session on this
@@ -1,3 +1,4 @@
1
+ import type { ContextCeiling } from "./serverManifest.js";
1
2
  /**
2
3
  * The typed tool record (mcp-standard C-4, C-8, C-9, C-10, C-11, C-12, C-13, C-15): everything
3
4
  * a caller reads from `tools/list` WITHOUT reading the source, declared once per tool as a
@@ -26,8 +27,13 @@ export type ToolVerb = "list" | "get" | "search" | "aggregate" | "create" | "upd
26
27
  /**
27
28
  * mcp-standard.md §3 point 3 — search mechanisms, closed set. `unindexed-scan` is deliberately
28
29
  * absent: it is refused at publication, so the type refuses it at compile time.
30
+ *
31
+ * `upstream-delegated(<upstream>)` is the honest value for a tool answered by a LIVE call to a
32
+ * third-party API: this server keeps no index for it, and the upstream is named. It is allowed
33
+ * only together with `openWorldHint: true`, and a list/search tool with `openWorldHint: true`
34
+ * must declare it (`checkToolRecord`).
29
35
  */
30
- export type SearchMechanism = "indexed-range" | "indexed-bm25" | `indexed-vector(${string})` | "indexed-hybrid-rrf";
36
+ export type SearchMechanism = "indexed-range" | "indexed-bm25" | `indexed-vector(${string})` | "indexed-hybrid-rrf" | `upstream-delegated(${string})`;
31
37
  /** The four behavioural annotations (C-15), explicit booleans on every tool. */
32
38
  interface BehaviourHints {
33
39
  readOnlyHint: boolean;
@@ -87,8 +93,32 @@ export declare const LIST_ITEMS_ANNOTATIONS: {
87
93
  readonly paginationMax: 100;
88
94
  readonly tokensEstimeContexte: 640;
89
95
  };
90
- export declare const CONTEXT_CEILING: {
91
- readonly global: 1200;
92
- readonly perTool: 700;
93
- };
96
+ /** One tool's declared context cost, as `checkContextCeiling` reads it. */
97
+ export interface ContextEstimate {
98
+ name: string;
99
+ tokens: number;
100
+ }
101
+ /** Every problem found, each naming the tool; empty when the ceiling holds. Never skips a tool. */
102
+ export declare function checkContextCeiling(estimates: readonly ContextEstimate[], ceiling: ContextCeiling): string[];
103
+ /**
104
+ * Problems with ONE tool's served record; empty when it is sound. Reads a loose record because the
105
+ * same check runs on the declared literals and on the annotations as served.
106
+ *
107
+ * A list/search tool must declare a mechanism of the closed set: an indexed one when it reads
108
+ * data this server holds (`openWorldHint: false`), `upstream-delegated(<upstream>)` when it calls
109
+ * out (`openWorldHint: true`). The two slots must agree, so a live-call tool cannot pass for an
110
+ * indexed one, nor an indexed one claim to be delegated. `unindexed-scan` and any other value are
111
+ * refused. A tool that is not a list/search is not asked for a mechanism.
112
+ */
113
+ export declare function checkToolRecord(name: string, record: Record<string, unknown>): string[];
114
+ /** Thrown at start when a tool record or the ceiling is unsound. `problems` name every tool. */
115
+ export declare class ToolRecordError extends Error {
116
+ readonly problems: readonly string[];
117
+ constructor(problems: readonly string[]);
118
+ }
119
+ /**
120
+ * Refuses at start: every record passes `checkToolRecord`, and the declared estimates fit the
121
+ * server's own ceiling (from its manifest). A derived server passes ALL its tools' records.
122
+ */
123
+ export declare function assertToolRecords(records: Readonly<Record<string, Record<string, unknown>>>, ceiling: ContextCeiling): void;
94
124
  export {};
@@ -1,24 +1,4 @@
1
- /**
2
- * The typed tool record (mcp-standard C-4, C-8, C-9, C-10, C-11, C-12, C-13, C-15): everything
3
- * a caller reads from `tools/list` WITHOUT reading the source, declared once per tool as a
4
- * literal object and served as its `annotations`.
5
- *
6
- * authRead, authWrite C-8 the (read, write) pair, from the closed five-tier set, distinct
7
- * verb C-9 the operation, from the closed verb set (`rpc` is not a verb)
8
- * table C-12 the data it touches, beside the verb
9
- * searchMechanism C-10 how a list/search is served; `unindexed-scan` does not publish
10
- * paginationCursor/Default/Max C-11 declared here, applied by src/pagination.ts
11
- * tokensEstimeContexte C-13 the estimated context cost of the served tool
12
- *
13
- * The sets are closed UNIONS, so a value outside them is a compile error, and the verb decides
14
- * which other slots are required (a list/search tool cannot omit its mechanism or its pagination).
15
- * Each tool's record is a plain `const` object of string/number/boolean literals on purpose:
16
- * mcp-doctor reads the registration site statically and resolves `annotations: NAME` to the
17
- * literals below; a computed or spread value would read as "could not judge".
18
- *
19
- * SDK 2.3.1 serves unknown annotation keys on the wire (tests/toolAuthorityDeclared.test.ts
20
- * reads them there); its `Client` drops them, which is why those tests read the raw result.
21
- */
1
+ import { tBoth } from "./i18n.js";
22
2
  // Authority decisions (C-8). The seed tools read; none writes anything, so the write side
23
3
  // is `master`, the strictest tier: no caller gains a write through them. `ping` touches no data
24
4
  // and is `public`; the item tool is scoped to the caller's organisation on the OAuth tier.
@@ -47,6 +27,88 @@ export const LIST_ITEMS_ANNOTATIONS = {
47
27
  paginationMax: 100,
48
28
  tokensEstimeContexte: 640,
49
29
  };
50
- // Context ceiling (C-13), global AND per tool, in estimated tokens. The sum of the per-tool
51
- // estimates above must not pass `global`; no single estimate may pass `perTool`.
52
- export const CONTEXT_CEILING = { global: 1200, perTool: 700 };
30
+ /** Every problem found, each naming the tool; empty when the ceiling holds. Never skips a tool. */
31
+ export function checkContextCeiling(estimates, ceiling) {
32
+ const problems = [];
33
+ let sum = 0;
34
+ for (const { name, tokens } of estimates) {
35
+ if (!Number.isInteger(tokens) || tokens < 0) {
36
+ problems.push(`${name}: tokensEstimeContexte ${String(tokens)} is not a non-negative integer`);
37
+ continue;
38
+ }
39
+ sum += tokens;
40
+ if (tokens > ceiling.perTool) {
41
+ problems.push(`${name}: ${tokens} tokens pass the per-tool ceiling ${ceiling.perTool}`);
42
+ }
43
+ }
44
+ if (sum > ceiling.global) {
45
+ problems.push(`global: ${sum} tokens pass the global ceiling ${ceiling.global}`);
46
+ }
47
+ return problems;
48
+ }
49
+ const LIST_VERBS = ["list", "search"];
50
+ // The closed indexed set, exactly: the three parameterless forms, and `indexed-vector(<metric>)`.
51
+ const INDEXED_EXACT = ["indexed-range", "indexed-bm25", "indexed-hybrid-rrf"];
52
+ const INDEXED_VECTOR = /^indexed-vector\([^()\s][^()]*\)$/;
53
+ const DELEGATED = /^upstream-delegated\(\s*[^\s)][^)]*\)$/;
54
+ /** True for a mechanism of the closed indexed set, matched whole (no prefix, no open paren). */
55
+ function isIndexedMechanism(value) {
56
+ return INDEXED_EXACT.includes(value) || INDEXED_VECTOR.test(value);
57
+ }
58
+ /**
59
+ * Problems with ONE tool's served record; empty when it is sound. Reads a loose record because the
60
+ * same check runs on the declared literals and on the annotations as served.
61
+ *
62
+ * A list/search tool must declare a mechanism of the closed set: an indexed one when it reads
63
+ * data this server holds (`openWorldHint: false`), `upstream-delegated(<upstream>)` when it calls
64
+ * out (`openWorldHint: true`). The two slots must agree, so a live-call tool cannot pass for an
65
+ * indexed one, nor an indexed one claim to be delegated. `unindexed-scan` and any other value are
66
+ * refused. A tool that is not a list/search is not asked for a mechanism.
67
+ */
68
+ export function checkToolRecord(name, record) {
69
+ if (!LIST_VERBS.includes(record.verb))
70
+ return [];
71
+ const mechanism = record.searchMechanism;
72
+ if (typeof mechanism !== "string") {
73
+ return [`${name}: a ${String(record.verb)} tool declares no searchMechanism`];
74
+ }
75
+ const openWorld = record.openWorldHint === true;
76
+ if (DELEGATED.test(mechanism)) {
77
+ return openWorld
78
+ ? []
79
+ : [`${name}: ${mechanism} is a live upstream call, but openWorldHint is not true`];
80
+ }
81
+ if (isIndexedMechanism(mechanism)) {
82
+ return openWorld
83
+ ? [
84
+ `${name}: ${mechanism} claims an index, but openWorldHint is true (a live call must declare upstream-delegated(<upstream>))`,
85
+ ]
86
+ : [];
87
+ }
88
+ return [`${name}: searchMechanism "${mechanism}" is not in the closed set`];
89
+ }
90
+ /** Thrown at start when a tool record or the ceiling is unsound. `problems` name every tool. */
91
+ export class ToolRecordError extends Error {
92
+ problems;
93
+ constructor(problems) {
94
+ super(tBoth("toolRecord.unsound", { problems: problems.join("; ") }));
95
+ this.name = "ToolRecordError";
96
+ this.problems = problems;
97
+ }
98
+ }
99
+ /**
100
+ * Refuses at start: every record passes `checkToolRecord`, and the declared estimates fit the
101
+ * server's own ceiling (from its manifest). A derived server passes ALL its tools' records.
102
+ */
103
+ export function assertToolRecords(records, ceiling) {
104
+ const problems = Object.entries(records).flatMap(([name, record]) => [
105
+ ...checkToolRecord(name, record),
106
+ ]);
107
+ const estimates = Object.entries(records).map(([name, record]) => ({
108
+ name,
109
+ tokens: record.tokensEstimeContexte,
110
+ }));
111
+ problems.push(...checkContextCeiling(estimates, ceiling));
112
+ if (problems.length > 0)
113
+ throw new ToolRecordError(problems);
114
+ }
package/mcp-server.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-boilerplate",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "capabilities": {
5
5
  "tools": {},
6
6
  "resources": {}
@@ -9,5 +9,6 @@
9
9
  "stdio": { "bin": "mcp-boilerplate" },
10
10
  "streamableHttp": { "path": "/mcp", "stateless": true }
11
11
  },
12
- "features": {}
12
+ "features": {},
13
+ "contextCeiling": { "global": 1200, "perTool": 700 }
13
14
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vantageos/mcp-boilerplate",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Clonable MCP server skeleton on the official @modelcontextprotocol/server SDK v2 (protocol 2026-07-28 + 2025 legacy, stdio + stateless Streamable HTTP), with its own OAuth 2.1 authorization server (Clerk person + organisation, cloud-identity), MCP Apps UI, tenant scoping, FR/EN i18n and a declarative eval harness.",
5
5
  "description_fr": "Squelette de serveur MCP clonable sur le SDK officiel @modelcontextprotocol/server v2 (protocole 2026-07-28 + héritage 2025, stdio + Streamable HTTP sans état), avec son propre serveur d'autorisation OAuth 2.1 (personne + organisation Clerk, cloud-identity), UI MCP Apps, scoping tenant, i18n FR/EN et un harnais d'évaluations déclaratif.",
6
6
  "type": "module",
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.3.0",
4
+ "version": "0.4.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"