@vantageos/mcp-boilerplate 0.3.0 → 0.5.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.
@@ -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.",
@@ -181,7 +187,7 @@ export function createBoilerplateMcpServer(deps = {}) {
181
187
  try {
182
188
  const tenantId = deps.tenant?.tenantId;
183
189
  const scope = tenantId === undefined ? undefined : { tenantId };
184
- const { items, ...page } = await readPage(lake, args, PAGINATION, scope);
190
+ const { items, ...page } = await readPage(lake, args, PAGINATION, scope, deps.cursorSigner);
185
191
  // Defence in depth: the scope was applied inside the read; a row of another tenant that
186
192
  // still comes back means the reader ignored it. Refuse the page, never filter it.
187
193
  if (tenantId !== undefined && scopeRowsForTenant(tenantId, items).length !== items.length) {
@@ -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
  }
@@ -4,8 +4,11 @@
4
4
  * them, so the bound never depends on the store behind:
5
5
  *
6
6
  * - the page size is the caller's `limit`, else the default, never above the maximum;
7
- * - the cursor is OPAQUE (base64url of a small JSON object) and carries the last key served,
8
- * so it is stable: rows added or removed elsewhere do not shift the next page;
7
+ * - the cursor carries the last key served, so it is stable: rows added or removed elsewhere do
8
+ * not shift the next page. It is SIGNED (src/signedCursor.ts: HMAC-SHA256 under a server
9
+ * key), so a caller can read it but not edit or mint one: an edited key is `invalid_cursor`.
10
+ * The default signer's key is drawn once per process: a restart invalidates outstanding
11
+ * cursors, and a multi-instance deployment injects one shared signer (`cursorSigner`);
9
12
  * - exhausted and truncated are told apart: a truncated page carries `nextCursor` and
10
13
  * `exhausted: false`; the last page carries `exhausted: true` and no `nextCursor` (an exact
11
14
  * fit is exhausted, never a false truncation);
@@ -14,6 +17,8 @@
14
17
  * SEP-1303), not a JSON-RPC error: -32602 stays with the protocol list methods' cursor.
15
18
  */
16
19
  import { type LakeReader, type LakeScope } from "./lake/reader.js";
20
+ import { type CursorSigner } from "./signedCursor.js";
21
+ export { INVALID_CURSOR_CODE, InvalidCursorError } from "./signedCursor.js";
17
22
  /**
18
23
  * The applied bounds, taken from the tool's own declaration so the number is typed once
19
24
  * (tests/paginationApplied.test.ts proves the served annotations agree).
@@ -22,15 +27,8 @@ export declare const PAGINATION: {
22
27
  readonly default: 20;
23
28
  readonly max: 100;
24
29
  };
25
- /** The stable machine code of an invalid or expired cursor, in `structuredContent.error.code`. */
26
- export declare const INVALID_CURSOR_CODE = "invalid_cursor";
27
- /** The cursor did not decode to a position in the collection. */
28
- export declare class InvalidCursorError extends Error {
29
- readonly code = "invalid_cursor";
30
- constructor();
31
- }
32
- export declare function encodeCursor(afterKey: string): string;
33
- export declare function decodeCursor(cursor: string): string;
30
+ export declare function encodeCursor(afterKey: string, signer?: CursorSigner): string;
31
+ export declare function decodeCursor(cursor: string, signer?: CursorSigner): string;
34
32
  export interface Page<T> {
35
33
  items: T[];
36
34
  /** Present exactly when the page was truncated. */
@@ -51,16 +49,17 @@ export declare function paginate<T extends {
51
49
  }>(sortedRows: readonly T[], request: PageRequest, bounds: {
52
50
  default: number;
53
51
  max: number;
54
- }): Page<T>;
52
+ }, signer?: CursorSigner): Page<T>;
55
53
  /**
56
54
  * One page read through a lake reader (src/lake/reader.ts): the same bounds and cursor as
57
55
  * `paginate`, the range read delegated to the reader. The cursor is decoded BEFORE the reader is
58
56
  * asked anything (an invalid cursor never costs a read), the page size is the caller's `limit`,
59
57
  * else the default, never above the maximum, and the reader's answer is checked against the page
60
58
  * contract (`checkLakePage`): an unreadable page is a `LakeReadError`, never an empty page. With
61
- * the in-memory reader, the result is exactly `paginate`'s (tests/lakeReader.test.ts).
59
+ * the in-memory reader, the result is exactly `paginate`'s (tests/lakeReader.test.ts). `signer`
60
+ * signs and verifies the cursor; default: the process-wide signer.
62
61
  */
63
62
  export declare function readPage<Row>(reader: LakeReader<Row>, request: PageRequest, bounds: {
64
63
  default: number;
65
64
  max: number;
66
- }, scope?: LakeScope): Promise<Page<Row>>;
65
+ }, scope?: LakeScope, signer?: CursorSigner): Promise<Page<Row>>;
@@ -4,8 +4,11 @@
4
4
  * them, so the bound never depends on the store behind:
5
5
  *
6
6
  * - the page size is the caller's `limit`, else the default, never above the maximum;
7
- * - the cursor is OPAQUE (base64url of a small JSON object) and carries the last key served,
8
- * so it is stable: rows added or removed elsewhere do not shift the next page;
7
+ * - the cursor carries the last key served, so it is stable: rows added or removed elsewhere do
8
+ * not shift the next page. It is SIGNED (src/signedCursor.ts: HMAC-SHA256 under a server
9
+ * key), so a caller can read it but not edit or mint one: an edited key is `invalid_cursor`.
10
+ * The default signer's key is drawn once per process: a restart invalidates outstanding
11
+ * cursors, and a multi-instance deployment injects one shared signer (`cursorSigner`);
9
12
  * - exhausted and truncated are told apart: a truncated page carries `nextCursor` and
10
13
  * `exhausted: false`; the last page carries `exhausted: true` and no `nextCursor` (an exact
11
14
  * fit is exhausted, never a false truncation);
@@ -15,7 +18,9 @@
15
18
  */
16
19
  import { rangeAfter } from "./exampleItems.js";
17
20
  import { checkLakePage } from "./lake/reader.js";
21
+ import { InvalidCursorError, createCursorSigner } from "./signedCursor.js";
18
22
  import { LIST_ITEMS_ANNOTATIONS } from "./toolRecord.js";
23
+ export { INVALID_CURSOR_CODE, InvalidCursorError } from "./signedCursor.js";
19
24
  /**
20
25
  * The applied bounds, taken from the tool's own declaration so the number is typed once
21
26
  * (tests/paginationApplied.test.ts proves the served annotations agree).
@@ -25,33 +30,30 @@ export const PAGINATION = {
25
30
  max: LIST_ITEMS_ANNOTATIONS.paginationMax,
26
31
  };
27
32
  const CURSOR_VERSION = 1;
28
- /** The stable machine code of an invalid or expired cursor, in `structuredContent.error.code`. */
29
- export const INVALID_CURSOR_CODE = "invalid_cursor";
30
- /** The cursor did not decode to a position in the collection. */
31
- export class InvalidCursorError extends Error {
32
- code = INVALID_CURSOR_CODE;
33
- constructor() {
34
- super("invalid cursor");
35
- this.name = "InvalidCursorError";
36
- }
37
- }
38
- export function encodeCursor(afterKey) {
39
- return Buffer.from(JSON.stringify({ v: CURSOR_VERSION, after: afterKey })).toString("base64url");
33
+ /**
34
+ * The process-wide signer of tool cursors, used when none is injected. One per process (not per
35
+ * server): the HTTP transport builds a server per request, and a cursor must outlive the request
36
+ * that issued it.
37
+ */
38
+ const DEFAULT_SIGNER = createCursorSigner();
39
+ export function encodeCursor(afterKey, signer = DEFAULT_SIGNER) {
40
+ return signer.sign(JSON.stringify({ v: CURSOR_VERSION, after: afterKey }));
40
41
  }
41
- export function decodeCursor(cursor) {
42
+ export function decodeCursor(cursor, signer = DEFAULT_SIGNER) {
43
+ const payload = signer.verify(cursor);
42
44
  let decoded;
43
45
  try {
44
- decoded = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
46
+ decoded = JSON.parse(payload);
45
47
  }
46
48
  catch {
47
- throw new InvalidCursorError();
49
+ throw new InvalidCursorError("malformed_payload");
48
50
  }
49
51
  if (typeof decoded !== "object" ||
50
52
  decoded === null ||
51
53
  decoded.v !== CURSOR_VERSION ||
52
54
  typeof decoded.after !== "string" ||
53
55
  decoded.after.length === 0) {
54
- throw new InvalidCursorError();
56
+ throw new InvalidCursorError("malformed_payload");
55
57
  }
56
58
  return decoded.after;
57
59
  }
@@ -60,8 +62,8 @@ export function decodeCursor(cursor) {
60
62
  * range (src/exampleItems.ts `rangeAfter`: binary search on the key), so its cost follows the
61
63
  * page, not the collection.
62
64
  */
63
- export function paginate(sortedRows, request, bounds) {
64
- const afterKey = request.cursor === undefined ? undefined : decodeCursor(request.cursor);
65
+ export function paginate(sortedRows, request, bounds, signer = DEFAULT_SIGNER) {
66
+ const afterKey = request.cursor === undefined ? undefined : decodeCursor(request.cursor, signer);
65
67
  const wanted = request.limit ?? bounds.default;
66
68
  const size = Math.max(1, Math.min(Math.floor(wanted), bounds.max));
67
69
  // One row more than the page: its existence is what separates truncated from exhausted.
@@ -69,7 +71,7 @@ export function paginate(sortedRows, request, bounds) {
69
71
  const items = window.slice(0, size);
70
72
  const last = items[items.length - 1];
71
73
  if (window.length > size && last) {
72
- return { items, nextCursor: encodeCursor(last.id), exhausted: false };
74
+ return { items, nextCursor: encodeCursor(last.id, signer), exhausted: false };
73
75
  }
74
76
  return { items, exhausted: true };
75
77
  }
@@ -79,10 +81,11 @@ export function paginate(sortedRows, request, bounds) {
79
81
  * asked anything (an invalid cursor never costs a read), the page size is the caller's `limit`,
80
82
  * else the default, never above the maximum, and the reader's answer is checked against the page
81
83
  * contract (`checkLakePage`): an unreadable page is a `LakeReadError`, never an empty page. With
82
- * the in-memory reader, the result is exactly `paginate`'s (tests/lakeReader.test.ts).
84
+ * the in-memory reader, the result is exactly `paginate`'s (tests/lakeReader.test.ts). `signer`
85
+ * signs and verifies the cursor; default: the process-wide signer.
83
86
  */
84
- export async function readPage(reader, request, bounds, scope) {
85
- const after = request.cursor === undefined ? undefined : decodeCursor(request.cursor);
87
+ export async function readPage(reader, request, bounds, scope, signer = DEFAULT_SIGNER) {
88
+ const after = request.cursor === undefined ? undefined : decodeCursor(request.cursor, signer);
86
89
  const wanted = request.limit ?? bounds.default;
87
90
  const limit = Math.max(1, Math.min(Math.floor(wanted), bounds.max));
88
91
  const lakeRequest = {
@@ -94,7 +97,7 @@ export async function readPage(reader, request, bounds, scope) {
94
97
  const items = [...page.rows];
95
98
  const last = items[items.length - 1];
96
99
  if (!page.exhausted && last !== undefined) {
97
- return { items, nextCursor: encodeCursor(reader.keyOf(last)), exhausted: false };
100
+ return { items, nextCursor: encodeCursor(reader.keyOf(last), signer), exhausted: false };
98
101
  }
99
102
  return { items, exhausted: true };
100
103
  }
@@ -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
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The HTTP transport's operator configuration, declared through the declared-variables mechanism
3
+ * (src/declaredVariables.ts). Evaluated by the Node entry (src/http.ts) at start.
4
+ *
5
+ * `MCP_ALLOWED_ORIGINS` (S-22, MCP Streamable HTTP spec: validate `Origin` to stop DNS rebinding):
6
+ * the browser origins allowed to call `/mcp`. Data, never a constant. Default: empty, which
7
+ * refuses every browser origin (a request with no `Origin` header is not a browser and is not
8
+ * subject to it). A wildcard or an entry with a path is a `ConfigurationError` naming the variable.
9
+ *
10
+ * `UPSTREAM_TIMEOUT_MS` (S-13: every request the server emits carries a configured timeout):
11
+ * the time budget, in milliseconds, of one outbound request to an upstream (API page, API call
12
+ * through the token source, external-issuer JWKS, lake read). Default 30 000: long enough for a
13
+ * slow paginated read, short enough that the server answers with a typed error before an MCP
14
+ * client's own request timeout (commonly 60 s) fires. Range 1 000 to 300 000, enforced at start:
15
+ * below 1 s every healthy upstream times out, above 5 min a hung upstream pins a worker. An
16
+ * out-of-range or non-integer value is a `ConfigurationError` naming the variable.
17
+ */
18
+ export declare const httpVariables: import("./declaredVariables.js").DeclaredVariables<{
19
+ readonly allowedOrigins: {
20
+ readonly env: "MCP_ALLOWED_ORIGINS";
21
+ readonly type: "originList";
22
+ readonly required: false;
23
+ readonly default: readonly [];
24
+ readonly description: {
25
+ readonly en: "Browser origins allowed to call /mcp (comma-separated, scheme://host[:port]); empty refuses every browser origin";
26
+ readonly fr: "Origines de navigateur autorisées à appeler /mcp (séparées par des virgules, schéma://hôte[:port]) ; vide refuse toute origine de navigateur";
27
+ };
28
+ };
29
+ }>;
30
+ export declare function loadHttpSettings(env?: NodeJS.ProcessEnv): import("./declaredVariables.js").VariablesOf<{
31
+ readonly allowedOrigins: {
32
+ readonly env: "MCP_ALLOWED_ORIGINS";
33
+ readonly type: "originList";
34
+ readonly required: false;
35
+ readonly default: readonly [];
36
+ readonly description: {
37
+ readonly en: "Browser origins allowed to call /mcp (comma-separated, scheme://host[:port]); empty refuses every browser origin";
38
+ readonly fr: "Origines de navigateur autorisées à appeler /mcp (séparées par des virgules, schéma://hôte[:port]) ; vide refuse toute origine de navigateur";
39
+ };
40
+ };
41
+ }>;
42
+ export declare const UPSTREAM_TIMEOUT_DEFAULT_MS = 30000;
43
+ export declare const UPSTREAM_TIMEOUT_MIN_MS = 1000;
44
+ export declare const UPSTREAM_TIMEOUT_MAX_MS = 300000;
45
+ export declare const upstreamVariables: import("./declaredVariables.js").DeclaredVariables<{
46
+ readonly upstreamTimeoutMs: {
47
+ readonly env: "UPSTREAM_TIMEOUT_MS";
48
+ readonly type: "integer";
49
+ readonly required: false;
50
+ readonly min: 1000;
51
+ readonly max: 300000;
52
+ readonly default: 30000;
53
+ readonly description: {
54
+ readonly en: "Time budget in milliseconds of one outbound request to an upstream (1000 to 300000, default 30000)";
55
+ readonly fr: "Budget de temps en millisecondes d'une requête sortante vers un amont (1000 à 300000, 30000 par défaut)";
56
+ };
57
+ };
58
+ }>;
59
+ export declare function loadUpstreamSettings(env?: NodeJS.ProcessEnv): import("./declaredVariables.js").VariablesOf<{
60
+ readonly upstreamTimeoutMs: {
61
+ readonly env: "UPSTREAM_TIMEOUT_MS";
62
+ readonly type: "integer";
63
+ readonly required: false;
64
+ readonly min: 1000;
65
+ readonly max: 300000;
66
+ readonly default: 30000;
67
+ readonly description: {
68
+ readonly en: "Time budget in milliseconds of one outbound request to an upstream (1000 to 300000, default 30000)";
69
+ readonly fr: "Budget de temps en millisecondes d'une requête sortante vers un amont (1000 à 300000, 30000 par défaut)";
70
+ };
71
+ };
72
+ }>;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The HTTP transport's operator configuration, declared through the declared-variables mechanism
3
+ * (src/declaredVariables.ts). Evaluated by the Node entry (src/http.ts) at start.
4
+ *
5
+ * `MCP_ALLOWED_ORIGINS` (S-22, MCP Streamable HTTP spec: validate `Origin` to stop DNS rebinding):
6
+ * the browser origins allowed to call `/mcp`. Data, never a constant. Default: empty, which
7
+ * refuses every browser origin (a request with no `Origin` header is not a browser and is not
8
+ * subject to it). A wildcard or an entry with a path is a `ConfigurationError` naming the variable.
9
+ *
10
+ * `UPSTREAM_TIMEOUT_MS` (S-13: every request the server emits carries a configured timeout):
11
+ * the time budget, in milliseconds, of one outbound request to an upstream (API page, API call
12
+ * through the token source, external-issuer JWKS, lake read). Default 30 000: long enough for a
13
+ * slow paginated read, short enough that the server answers with a typed error before an MCP
14
+ * client's own request timeout (commonly 60 s) fires. Range 1 000 to 300 000, enforced at start:
15
+ * below 1 s every healthy upstream times out, above 5 min a hung upstream pins a worker. An
16
+ * out-of-range or non-integer value is a `ConfigurationError` naming the variable.
17
+ */
18
+ import { defineVariables, loadVariables } from "./declaredVariables.js";
19
+ export const httpVariables = defineVariables({
20
+ allowedOrigins: {
21
+ env: "MCP_ALLOWED_ORIGINS",
22
+ type: "originList",
23
+ required: false,
24
+ default: [],
25
+ description: {
26
+ en: "Browser origins allowed to call /mcp (comma-separated, scheme://host[:port]); empty refuses every browser origin",
27
+ fr: "Origines de navigateur autorisées à appeler /mcp (séparées par des virgules, schéma://hôte[:port]) ; vide refuse toute origine de navigateur",
28
+ },
29
+ },
30
+ });
31
+ export function loadHttpSettings(env = process.env) {
32
+ return loadVariables(httpVariables, env);
33
+ }
34
+ export const UPSTREAM_TIMEOUT_DEFAULT_MS = 30_000;
35
+ export const UPSTREAM_TIMEOUT_MIN_MS = 1_000;
36
+ export const UPSTREAM_TIMEOUT_MAX_MS = 300_000;
37
+ export const upstreamVariables = defineVariables({
38
+ upstreamTimeoutMs: {
39
+ env: "UPSTREAM_TIMEOUT_MS",
40
+ type: "integer",
41
+ required: false,
42
+ min: UPSTREAM_TIMEOUT_MIN_MS,
43
+ max: UPSTREAM_TIMEOUT_MAX_MS,
44
+ default: UPSTREAM_TIMEOUT_DEFAULT_MS,
45
+ description: {
46
+ en: "Time budget in milliseconds of one outbound request to an upstream (1000 to 300000, default 30000)",
47
+ fr: "Budget de temps en millisecondes d'une requête sortante vers un amont (1000 à 300000, 30000 par défaut)",
48
+ },
49
+ },
50
+ });
51
+ export function loadUpstreamSettings(env = process.env) {
52
+ return loadVariables(upstreamVariables, env);
53
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Signed cursors. Every cursor this boilerplate issues (the `list_items` tool cursor in
3
+ * `src/pagination.ts`, the upstream offset cursor in `src/upstream/paginatedClient.ts`) is a
4
+ * token `<payload>.<tag>`: the payload is base64url of the cursor's own text, the tag is base64url
5
+ * of HMAC-SHA256(key, payload part). A caller can read a cursor; it cannot edit one and have it
6
+ * accepted, because recomputing the tag needs the key.
7
+ *
8
+ * `verify` refuses with `InvalidCursorError` (code `invalid_cursor`) and a `reason` naming the
9
+ * form it met: `empty`, `missing_separator`, `extra_separator`, `empty_part`, `not_base64url`
10
+ * (a part outside the strict base64url alphabet, or not in its canonical encoding), `tag_length`
11
+ * (a tag that is not 32 bytes, e.g. truncated) and `tag_mismatch` (an edited payload, or a tag
12
+ * made under another key). A verified payload the caller cannot read is refused by the caller as
13
+ * `malformed_payload`, and one issued for another request as `other_request`. There is no unsigned
14
+ * fallback: a token that does not verify is never read.
15
+ *
16
+ * Declared limit. The default key is 32 random bytes drawn once per signer, so per process for
17
+ * the module-level signers: a process restart invalidates every outstanding cursor (the caller
18
+ * gets `invalid_cursor` and starts again without one), and two instances behind a load balancer
19
+ * reject each other's cursors. A multi-instance deployment injects one shared key of at least 32
20
+ * bytes into every instance (`createCursorSigner({ key })`).
21
+ */
22
+ /** The stable machine code of an invalid or expired cursor, in `structuredContent.error.code`. */
23
+ export declare const INVALID_CURSOR_CODE = "invalid_cursor";
24
+ /** Why a cursor was refused. Operator-facing: the caller only ever sees `invalid_cursor`. */
25
+ export type InvalidCursorReason = "empty" | "missing_separator" | "extra_separator" | "empty_part" | "not_base64url" | "tag_length" | "tag_mismatch" | "malformed_payload" | "other_request";
26
+ /** The cursor did not decode to a position this server issued. */
27
+ export declare class InvalidCursorError extends Error {
28
+ readonly code = "invalid_cursor";
29
+ /** The form the refused token took. */
30
+ readonly reason: InvalidCursorReason;
31
+ constructor(reason: InvalidCursorReason);
32
+ }
33
+ /** Minimum (and default) key length, in bytes: the HMAC-SHA256 output size. */
34
+ export declare const CURSOR_KEY_BYTES = 32;
35
+ export interface CursorSigner {
36
+ /** Returns `<base64url payload>.<base64url tag>`. */
37
+ sign(payload: string): string;
38
+ /** Returns the payload `sign` was given, or throws `InvalidCursorError`. */
39
+ verify(token: string): string;
40
+ }
41
+ export interface CursorSignerOptions {
42
+ /**
43
+ * The HMAC key, at least 32 bytes. Default: 32 random bytes, drawn once for this signer. Inject
44
+ * the same key into every instance of a multi-instance deployment.
45
+ */
46
+ key?: Uint8Array;
47
+ }
48
+ export declare function createCursorSigner(options?: CursorSignerOptions): CursorSigner;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Signed cursors. Every cursor this boilerplate issues (the `list_items` tool cursor in
3
+ * `src/pagination.ts`, the upstream offset cursor in `src/upstream/paginatedClient.ts`) is a
4
+ * token `<payload>.<tag>`: the payload is base64url of the cursor's own text, the tag is base64url
5
+ * of HMAC-SHA256(key, payload part). A caller can read a cursor; it cannot edit one and have it
6
+ * accepted, because recomputing the tag needs the key.
7
+ *
8
+ * `verify` refuses with `InvalidCursorError` (code `invalid_cursor`) and a `reason` naming the
9
+ * form it met: `empty`, `missing_separator`, `extra_separator`, `empty_part`, `not_base64url`
10
+ * (a part outside the strict base64url alphabet, or not in its canonical encoding), `tag_length`
11
+ * (a tag that is not 32 bytes, e.g. truncated) and `tag_mismatch` (an edited payload, or a tag
12
+ * made under another key). A verified payload the caller cannot read is refused by the caller as
13
+ * `malformed_payload`, and one issued for another request as `other_request`. There is no unsigned
14
+ * fallback: a token that does not verify is never read.
15
+ *
16
+ * Declared limit. The default key is 32 random bytes drawn once per signer, so per process for
17
+ * the module-level signers: a process restart invalidates every outstanding cursor (the caller
18
+ * gets `invalid_cursor` and starts again without one), and two instances behind a load balancer
19
+ * reject each other's cursors. A multi-instance deployment injects one shared key of at least 32
20
+ * bytes into every instance (`createCursorSigner({ key })`).
21
+ */
22
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
23
+ /** The stable machine code of an invalid or expired cursor, in `structuredContent.error.code`. */
24
+ export const INVALID_CURSOR_CODE = "invalid_cursor";
25
+ /** The cursor did not decode to a position this server issued. */
26
+ export class InvalidCursorError extends Error {
27
+ code = INVALID_CURSOR_CODE;
28
+ /** The form the refused token took. */
29
+ reason;
30
+ constructor(reason) {
31
+ super("invalid cursor");
32
+ this.name = "InvalidCursorError";
33
+ this.reason = reason;
34
+ }
35
+ }
36
+ /** Minimum (and default) key length, in bytes: the HMAC-SHA256 output size. */
37
+ export const CURSOR_KEY_BYTES = 32;
38
+ const TAG_BYTES = 32;
39
+ const BASE64URL = /^[A-Za-z0-9_-]+$/;
40
+ /** Strict base64url: the alphabet only, and the canonical encoding of what it decodes to. */
41
+ function decodeStrict(part) {
42
+ if (!BASE64URL.test(part))
43
+ throw new InvalidCursorError("not_base64url");
44
+ const bytes = Buffer.from(part, "base64url");
45
+ if (bytes.toString("base64url") !== part)
46
+ throw new InvalidCursorError("not_base64url");
47
+ return bytes;
48
+ }
49
+ export function createCursorSigner(options = {}) {
50
+ const supplied = options.key;
51
+ if (supplied !== undefined && !(supplied instanceof Uint8Array)) {
52
+ throw new Error(`cursor signer: key must be a Uint8Array of at least ${CURSOR_KEY_BYTES} bytes`);
53
+ }
54
+ if (supplied !== undefined && supplied.length < CURSOR_KEY_BYTES) {
55
+ throw new Error(`cursor signer: key must be at least ${CURSOR_KEY_BYTES} bytes (got ${supplied.length})`);
56
+ }
57
+ // A copy: the caller zeroing or reusing its buffer must not change what this signer accepts.
58
+ const key = supplied === undefined ? randomBytes(CURSOR_KEY_BYTES) : Buffer.from(supplied);
59
+ const tagOf = (payloadPart) => createHmac("sha256", key).update(payloadPart).digest();
60
+ const sign = (payload) => {
61
+ // A cursor always says something; an empty one would encode to an empty part, which `verify`
62
+ // refuses. Refused here, loudly, rather than issued and then rejected.
63
+ if (typeof payload !== "string" || payload.length === 0) {
64
+ throw new Error("cursor signer: the payload to sign must be a non-empty string");
65
+ }
66
+ const payloadPart = Buffer.from(payload, "utf8").toString("base64url");
67
+ return `${payloadPart}.${tagOf(payloadPart).toString("base64url")}`;
68
+ };
69
+ const verify = (token) => {
70
+ if (typeof token !== "string" || token.length === 0)
71
+ throw new InvalidCursorError("empty");
72
+ const parts = token.split(".");
73
+ if (parts.length < 2)
74
+ throw new InvalidCursorError("missing_separator");
75
+ if (parts.length > 2)
76
+ throw new InvalidCursorError("extra_separator");
77
+ const [payloadPart, tagPart] = parts;
78
+ if (payloadPart.length === 0 || tagPart.length === 0)
79
+ throw new InvalidCursorError("empty_part");
80
+ if (!BASE64URL.test(tagPart))
81
+ throw new InvalidCursorError("not_base64url");
82
+ // Length before canonical form, so a truncated tag is named as such.
83
+ const tag = Buffer.from(tagPart, "base64url");
84
+ if (tag.length !== TAG_BYTES)
85
+ throw new InvalidCursorError("tag_length");
86
+ if (tag.toString("base64url") !== tagPart)
87
+ throw new InvalidCursorError("not_base64url");
88
+ // The payload is checked for its alphabet before the tag; its content is trusted only once
89
+ // the tag matches. Equal lengths (32 bytes each, checked above): timingSafeEqual cannot throw.
90
+ if (!BASE64URL.test(payloadPart))
91
+ throw new InvalidCursorError("not_base64url");
92
+ if (!timingSafeEqual(tagOf(payloadPart), tag))
93
+ throw new InvalidCursorError("tag_mismatch");
94
+ // Signed by us, so canonical; re-checked so a decode is never lenient.
95
+ return decodeStrict(payloadPart).toString("utf8");
96
+ };
97
+ return { sign, verify };
98
+ }
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