@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.
@@ -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
+ }
@@ -58,6 +58,8 @@ export interface ClientCredentialsOptions {
58
58
  refreshSkewSeconds?: number;
59
59
  /** Time budget of one token request. Default 10 s. */
60
60
  timeoutMs?: number;
61
+ /** Time budget of one API request through `fetchWithToken`. Default: `UPSTREAM_TIMEOUT_MS` (30 s). */
62
+ requestTimeoutMs?: number;
61
63
  /** Default: the global fetch. Also used by `fetchWithToken` for the upstream API. */
62
64
  fetchImpl?: typeof fetch;
63
65
  /** Milliseconds since the epoch. Default `Date.now`. */
@@ -43,6 +43,7 @@
43
43
  * localised tool message.
44
44
  */
45
45
  import { rawKnob } from "../envKnobs.js";
46
+ import { boundedFetch, configuredUpstreamTimeoutMs } from "./timeout.js";
46
47
  /** A failure of the upstream token endpoint. Carries no credential and no token. */
47
48
  export class UpstreamTokenError extends Error {
48
49
  /** HTTP status of the token endpoint, when it answered. */
@@ -86,7 +87,7 @@ function validateTokenUrl(tokenUrl) {
86
87
  }
87
88
  }
88
89
  export function createClientCredentialsTokenSource(options) {
89
- const { tokenUrl, clientId, clientSecret, scope, audience, authMethod = "client_secret_basic", refreshSkewSeconds = DEFAULT_SKEW_SECONDS, timeoutMs = DEFAULT_TIMEOUT_MS, fetchImpl = fetch, now = Date.now, } = options;
90
+ const { tokenUrl, clientId, clientSecret, scope, audience, authMethod = "client_secret_basic", refreshSkewSeconds = DEFAULT_SKEW_SECONDS, timeoutMs = DEFAULT_TIMEOUT_MS, fetchImpl: rawFetch = fetch, requestTimeoutMs = configuredUpstreamTimeoutMs(), now = Date.now, } = options;
90
91
  validateTokenUrl(tokenUrl);
91
92
  if (!clientId)
92
93
  throw new Error("client_credentials: clientId is empty");
@@ -101,6 +102,12 @@ export function createClientCredentialsTokenSource(options) {
101
102
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
102
103
  throw new Error("client_credentials: timeoutMs must be a positive number");
103
104
  }
105
+ if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) {
106
+ throw new Error("client_credentials: requestTimeoutMs must be a positive number");
107
+ }
108
+ // S-13: the token request and the API requests each carry a timeout, whatever fetch they use.
109
+ const tokenFetch = boundedFetch(rawFetch, timeoutMs);
110
+ const apiFetch = boundedFetch(rawFetch, requestTimeoutMs);
104
111
  // Each part is form-encoded first (RFC 6749 §2.3.1), so the input to btoa is ASCII.
105
112
  const basicCredentials = btoa(`${formEncode(clientId)}:${formEncode(clientSecret)}`);
106
113
  const basicHeader = `Basic ${basicCredentials}`;
@@ -137,14 +144,13 @@ export function createClientCredentialsTokenSource(options) {
137
144
  }
138
145
  let res;
139
146
  try {
140
- res = await fetchImpl(tokenUrl, {
147
+ res = await tokenFetch(tokenUrl, {
141
148
  method: "POST",
142
149
  headers,
143
150
  body: form.toString(),
144
151
  // A redirect is refused, never followed: a 307/308 would re-send this body, which carries
145
152
  // the client secret under client_secret_post, to whatever host the Location names.
146
153
  redirect: "error",
147
- signal: AbortSignal.timeout(timeoutMs),
148
154
  });
149
155
  }
150
156
  catch (err) {
@@ -242,7 +248,7 @@ export function createClientCredentialsTokenSource(options) {
242
248
  const target = input instanceof Request && !last ? input.clone() : input;
243
249
  // The caller's init is spread as given, redirect mode included: the caller decides whether
244
250
  // its API request follows redirects (the paginated client passes redirect: "error").
245
- return fetchImpl(target, { ...init, headers });
251
+ return apiFetch(target, { ...init, headers });
246
252
  };
247
253
  const token = await getToken();
248
254
  const first = await send(token, !replayable);
@@ -11,15 +11,20 @@
11
11
  * `UpstreamPageError` (`row_ceiling_exceeded`) naming the ceiling. A truncated set is never
12
12
  * returned as if it were complete.
13
13
  * - `readPage(path, params, cursor?)` reads ONE upstream page per MCP call and returns
14
- * `{ rows, nextCursor?, exhausted }`. The cursor is opaque: base64url of a small JSON object
15
- * carrying a version, the upstream offset and a fingerprint of path + params. A cursor that
16
- * does not decode, is replayed against another path or other params, or was altered without
17
- * recomputing its fingerprint is refused with the `invalid_cursor` semantics of
18
- * `src/pagination.ts` (`InvalidCursorError`), never read as page one. The fingerprint is an
19
- * UNKEYED hash: it detects misuse, not forgery. Anyone can mint a well-formed cursor at any
20
- * offset, which is acceptable because the offset carries no authority (the caller could ask
21
- * for that offset anyway): a minted offset above the ceiling is refused before any request,
22
- * and the same ceiling bounds the page-by-page walk.
14
+ * `{ rows, nextCursor?, exhausted }`. The cursor carries a version, the upstream offset and a
15
+ * fingerprint of path + params, and it is SIGNED (src/signedCursor.ts): the token is the
16
+ * base64url payload and its HMAC-SHA256 tag under the client's `cursorSigner` key. The HMAC is
17
+ * the guard; the fingerprint only binds the cursor to its request INSIDE the signed payload.
18
+ * What it guarantees: a cursor this client did not issue (an edited offset, a fingerprint
19
+ * recomputed for other params, a token minted from the public format, one signed under another
20
+ * key) is refused with the `invalid_cursor` semantics of `src/pagination.ts`
21
+ * (`InvalidCursorError`) BEFORE any upstream request, never read as page one; and an issued
22
+ * cursor replayed against another path or other params is refused the same way. The ceiling
23
+ * check on the decoded offset stays, as defence in depth for whoever holds the key.
24
+ * Declared limit: the default signer's key is 32 random bytes drawn once per client, so a
25
+ * process restart invalidates every outstanding cursor (the caller starts again without one),
26
+ * and instances of a multi-instance deployment reject each other's cursors unless they are
27
+ * given one shared key (`cursorSigner: createCursorSigner({ key })`, 32 bytes or more).
23
28
  *
24
29
  * ```ts
25
30
  * // src/upstream.ts — evaluated once per process, beside the token source
@@ -60,6 +65,7 @@
60
65
  *
61
66
  * Errors are operator-facing (logged server-side), in English like `clientCredentials.ts`.
62
67
  */
68
+ import { type CursorSigner } from "../signedCursor.js";
63
69
  export type PaginationPlacement = "query" | "jsonBody";
64
70
  /** Any `fetch`-compatible function: the global fetch, or G1's `tokenSource.fetchWithToken`. */
65
71
  export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
@@ -94,7 +100,7 @@ export interface PaginatedUpstreamOptions<Row = unknown> {
94
100
  baseDelayMs?: number;
95
101
  /** Cap on any one delay, `Retry-After` included. Default 30 000 ms. */
96
102
  maxDelayMs?: number;
97
- /** Time budget of one request. Default 30 s. */
103
+ /** Time budget of one request. Default: `UPSTREAM_TIMEOUT_MS` (30 s when unset). */
98
104
  timeoutMs?: number;
99
105
  /** Default: a `setTimeout` promise. */
100
106
  sleep?: (ms: number) => Promise<void>;
@@ -102,6 +108,12 @@ export interface PaginatedUpstreamOptions<Row = unknown> {
102
108
  random?: () => number;
103
109
  /** Milliseconds since the epoch, for `Retry-After` dates. Default `Date.now`. */
104
110
  now?: () => number;
111
+ /**
112
+ * Signs and verifies `readPage` cursors (src/signedCursor.ts). Default: a signer of this client
113
+ * with a random key, so a restart invalidates outstanding cursors. Inject one signer built on a
114
+ * shared key (`createCursorSigner({ key })`) into every instance of a multi-instance deployment.
115
+ */
116
+ cursorSigner?: CursorSigner;
105
117
  }
106
118
  export type ParamValue = string | number | boolean | null | ParamValue[] | ParamObject;
107
119
  export interface ParamObject {
@@ -11,15 +11,20 @@
11
11
  * `UpstreamPageError` (`row_ceiling_exceeded`) naming the ceiling. A truncated set is never
12
12
  * returned as if it were complete.
13
13
  * - `readPage(path, params, cursor?)` reads ONE upstream page per MCP call and returns
14
- * `{ rows, nextCursor?, exhausted }`. The cursor is opaque: base64url of a small JSON object
15
- * carrying a version, the upstream offset and a fingerprint of path + params. A cursor that
16
- * does not decode, is replayed against another path or other params, or was altered without
17
- * recomputing its fingerprint is refused with the `invalid_cursor` semantics of
18
- * `src/pagination.ts` (`InvalidCursorError`), never read as page one. The fingerprint is an
19
- * UNKEYED hash: it detects misuse, not forgery. Anyone can mint a well-formed cursor at any
20
- * offset, which is acceptable because the offset carries no authority (the caller could ask
21
- * for that offset anyway): a minted offset above the ceiling is refused before any request,
22
- * and the same ceiling bounds the page-by-page walk.
14
+ * `{ rows, nextCursor?, exhausted }`. The cursor carries a version, the upstream offset and a
15
+ * fingerprint of path + params, and it is SIGNED (src/signedCursor.ts): the token is the
16
+ * base64url payload and its HMAC-SHA256 tag under the client's `cursorSigner` key. The HMAC is
17
+ * the guard; the fingerprint only binds the cursor to its request INSIDE the signed payload.
18
+ * What it guarantees: a cursor this client did not issue (an edited offset, a fingerprint
19
+ * recomputed for other params, a token minted from the public format, one signed under another
20
+ * key) is refused with the `invalid_cursor` semantics of `src/pagination.ts`
21
+ * (`InvalidCursorError`) BEFORE any upstream request, never read as page one; and an issued
22
+ * cursor replayed against another path or other params is refused the same way. The ceiling
23
+ * check on the decoded offset stays, as defence in depth for whoever holds the key.
24
+ * Declared limit: the default signer's key is 32 random bytes drawn once per client, so a
25
+ * process restart invalidates every outstanding cursor (the caller starts again without one),
26
+ * and instances of a multi-instance deployment reject each other's cursors unless they are
27
+ * given one shared key (`cursorSigner: createCursorSigner({ key })`, 32 bytes or more).
23
28
  *
24
29
  * ```ts
25
30
  * // src/upstream.ts — evaluated once per process, beside the token source
@@ -61,7 +66,8 @@
61
66
  * Errors are operator-facing (logged server-side), in English like `clientCredentials.ts`.
62
67
  */
63
68
  import { createHash } from "node:crypto";
64
- import { InvalidCursorError } from "../pagination.js";
69
+ import { InvalidCursorError, createCursorSigner } from "../signedCursor.js";
70
+ import { boundedFetch, configuredUpstreamTimeoutMs, upstreamSignal } from "./timeout.js";
65
71
  /** A refused or failed upstream page read. Carries no header, token, parameter or body. */
66
72
  export class UpstreamPageError extends Error {
67
73
  code;
@@ -87,14 +93,14 @@ const DEFAULT_PAGE_SIZE = 100;
87
93
  const DEFAULT_MAX_ATTEMPTS = 5;
88
94
  const DEFAULT_BASE_DELAY_MS = 500;
89
95
  const DEFAULT_MAX_DELAY_MS = 30_000;
90
- const DEFAULT_TIMEOUT_MS = 30_000;
91
96
  const RETRYABLE = new Set([429, 503]);
92
97
  const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
93
98
  /**
94
- * Cursor format. It mirrors `src/pagination.ts` (opaque base64url JSON with a version, refused as
99
+ * Cursor format. It mirrors `src/pagination.ts` (a signed JSON payload with a version, refused as
95
100
  * `InvalidCursorError`) but cannot reuse its `encodeCursor`/`decodeCursor`: those carry one
96
101
  * string key (`after`), and this cursor carries a numeric offset plus a fingerprint. The version
97
- * differs (2) so a `list_items` cursor is never read as an upstream cursor, nor the reverse.
102
+ * differs (2) so a `list_items` cursor is never read as an upstream cursor, nor the reverse, even
103
+ * when both are signed under one shared key.
98
104
  */
99
105
  const CURSOR_VERSION = 2;
100
106
  const isPositiveInteger = (n) => typeof n === "number" && Number.isSafeInteger(n) && n > 0;
@@ -134,27 +140,29 @@ function fingerprint(path, params) {
134
140
  .subarray(0, 16)
135
141
  .toString("base64url");
136
142
  }
137
- function encodeOffsetCursor(offset, print) {
138
- return Buffer.from(JSON.stringify({ v: CURSOR_VERSION, o: offset, f: print })).toString("base64url");
143
+ function encodeOffsetCursor(signer, offset, print) {
144
+ return signer.sign(JSON.stringify({ v: CURSOR_VERSION, o: offset, f: print }));
139
145
  }
140
- function decodeOffsetCursor(cursor, print) {
146
+ /** The offset of a cursor this client issued for this request, or `InvalidCursorError`. */
147
+ function decodeOffsetCursor(signer, cursor, print) {
148
+ // Verified first: nothing in an unsigned or edited token is read.
149
+ const payload = signer.verify(cursor);
141
150
  let decoded;
142
151
  try {
143
- decoded = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
152
+ decoded = JSON.parse(payload);
144
153
  }
145
154
  catch {
146
- throw new InvalidCursorError();
155
+ throw new InvalidCursorError("malformed_payload");
147
156
  }
148
- if (typeof decoded !== "object" || decoded === null)
149
- throw new InvalidCursorError();
150
- const { v, o, f } = decoded;
151
- if (v !== CURSOR_VERSION || !isPositiveInteger(o) || f !== print) {
152
- throw new InvalidCursorError();
157
+ if (typeof decoded !== "object" || decoded === null) {
158
+ throw new InvalidCursorError("malformed_payload");
153
159
  }
154
- // Only the exact string this client issues is accepted: base64url decoding is lenient
155
- // (trailing characters, extra fields), so the cursor is re-encoded and compared.
156
- if (encodeOffsetCursor(o, print) !== cursor)
157
- throw new InvalidCursorError();
160
+ const { v, o } = decoded;
161
+ if (v !== CURSOR_VERSION || !isPositiveInteger(o)) {
162
+ throw new InvalidCursorError("malformed_payload");
163
+ }
164
+ if (decoded.f !== print)
165
+ throw new InvalidCursorError("other_request");
158
166
  return o;
159
167
  }
160
168
  /** `Retry-After` (RFC 9110 §10.2.3) in ms, from delay-seconds or an HTTP date; else undefined. */
@@ -173,7 +181,12 @@ function isScalar(value) {
173
181
  return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
174
182
  }
175
183
  export function createPaginatedUpstreamClient(options) {
176
- const { fetchImpl = (input, init) => fetch(input, init), baseUrl, allowedOrigins, offsetParam, limitParam, placement, pageSize = DEFAULT_PAGE_SIZE, rowCeiling, extractRows, extractTotal, headers: extraHeaders = {}, maxAttempts = DEFAULT_MAX_ATTEMPTS, baseDelayMs = DEFAULT_BASE_DELAY_MS, maxDelayMs = DEFAULT_MAX_DELAY_MS, timeoutMs = DEFAULT_TIMEOUT_MS, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), random = Math.random, now = Date.now, } = options;
184
+ const { fetchImpl: rawFetch = fetch, baseUrl, allowedOrigins, offsetParam, limitParam, placement, pageSize = DEFAULT_PAGE_SIZE, rowCeiling, extractRows, extractTotal, headers: extraHeaders = {}, maxAttempts = DEFAULT_MAX_ATTEMPTS, baseDelayMs = DEFAULT_BASE_DELAY_MS, maxDelayMs = DEFAULT_MAX_DELAY_MS, timeoutMs = configuredUpstreamTimeoutMs(), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), random = Math.random, now = Date.now, cursorSigner = createCursorSigner(), } = options;
185
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
186
+ throw new Error("paginated upstream: timeoutMs must be a positive number");
187
+ }
188
+ // S-13: every page request, whatever fetch it goes through, carries the timeout.
189
+ const fetchImpl = boundedFetch(rawFetch, timeoutMs);
177
190
  if (!Array.isArray(allowedOrigins) || allowedOrigins.length === 0) {
178
191
  throw new Error("paginated upstream: allowedOrigins is required and must not be empty");
179
192
  }
@@ -224,8 +237,11 @@ export function createPaginatedUpstreamClient(options) {
224
237
  throw new Error(`paginated upstream: ${name} must be a non-negative number`);
225
238
  }
226
239
  }
227
- if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
228
- throw new Error("paginated upstream: timeoutMs must be a positive number");
240
+ if (cursorSigner === null ||
241
+ typeof cursorSigner !== "object" ||
242
+ typeof cursorSigner.sign !== "function" ||
243
+ typeof cursorSigner.verify !== "function") {
244
+ throw new Error("paginated upstream: cursorSigner must be a signer from createCursorSigner");
229
245
  }
230
246
  /** Resolves the path and refuses, before any network call, an origin not allowed. */
231
247
  const resolveUrl = (path) => {
@@ -303,7 +319,9 @@ export function createPaginatedUpstreamClient(options) {
303
319
  body,
304
320
  // A redirect is refused, never followed: the origin check above must stay total.
305
321
  redirect: "error",
306
- signal: AbortSignal.timeout(timeoutMs),
322
+ // Explicit at the call so the budget is readable (and statically checkable) here;
323
+ // `boundedFetch` above bounds every other path to `fetchImpl` the same way.
324
+ signal: upstreamSignal(timeoutMs),
307
325
  });
308
326
  }
309
327
  catch (err) {
@@ -401,7 +419,7 @@ export function createPaginatedUpstreamClient(options) {
401
419
  const url = resolveUrl(path);
402
420
  checkParams(params, url.pathname);
403
421
  const print = fingerprint(url.pathname + url.search, params);
404
- const offset = cursor === undefined ? 0 : decodeOffsetCursor(cursor, print);
422
+ const offset = cursor === undefined ? 0 : decodeOffsetCursor(cursorSigner, cursor, print);
405
423
  // A cursor at the ceiling may still meet an empty page (exact fit); beyond it, nothing is read.
406
424
  if (offset > rowCeiling)
407
425
  throw ceilingError(url.pathname);
@@ -415,7 +433,7 @@ export function createPaginatedUpstreamClient(options) {
415
433
  page.exhausted = true;
416
434
  return page;
417
435
  }
418
- page.nextCursor = encodeOffsetCursor(offset + rows.length, print);
436
+ page.nextCursor = encodeOffsetCursor(cursorSigner, offset + rows.length, print);
419
437
  return page;
420
438
  };
421
439
  return { readAll, readPage };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The one mechanism behind S-13 ("every request the server emits carries a configured timeout").
3
+ *
4
+ * CENSUS of outbound call sites in src/ (`rg -n '\bfetch\(|fetchImpl\(|undici|http\.request|https\.request' src`,
5
+ * plus the one site that hands `fetch` to a library, `ConvexHttpClient`). Each carries an
6
+ * AbortSignal timeout:
7
+ * upstream/paginatedClient.ts page request (fetchImpl, default global fetch) boundedFetch, UPSTREAM_TIMEOUT_MS
8
+ * upstream/clientCredentials.ts token request boundedFetch, option timeoutMs (10 s)
9
+ * upstream/clientCredentials.ts fetchWithToken (API request) boundedFetch, UPSTREAM_TIMEOUT_MS
10
+ * oauth/clerk.ts Backend API + JWKS (2 sites) per-call + total deadline (own signal)
11
+ * oauth/clientRegistry.ts CIMD metadata fetch own signal, option timeoutMs (5 s)
12
+ * config.ts external-issuer JWKS fetch upstreamSignal, UPSTREAM_TIMEOUT_MS
13
+ * lake/convex.ts ConvexHttpClient `fetch` option boundedFetch, UPSTREAM_TIMEOUT_MS
14
+ * src/nodeAdapter.ts and src/httpApp.ts `.fetch(` are the INBOUND handler, not a request emitted.
15
+ * A new outbound site belongs in this list and uses `boundedFetch` or `upstreamSignal`.
16
+ */
17
+ /** The configured budget: `UPSTREAM_TIMEOUT_MS`, validated at read (ConfigurationError if bad). */
18
+ export declare function configuredUpstreamTimeoutMs(env?: NodeJS.ProcessEnv): number;
19
+ /** A timeout signal, combined with the caller's own signal when there is one (first to fire wins). */
20
+ export declare function upstreamSignal(timeoutMs: number, callerSignal?: AbortSignal | null): AbortSignal;
21
+ /**
22
+ * `fetchImpl` such that EVERY call carries the timeout, whatever the caller passed. A caller
23
+ * signal (in `init`, or on a `Request` input) still applies: the earlier of the two aborts.
24
+ * The abort surfaces as the fetch's own rejection (`TimeoutError`), for the call site to type.
25
+ */
26
+ export declare function boundedFetch(fetchImpl: typeof fetch, timeoutMs: number): typeof fetch;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The one mechanism behind S-13 ("every request the server emits carries a configured timeout").
3
+ *
4
+ * CENSUS of outbound call sites in src/ (`rg -n '\bfetch\(|fetchImpl\(|undici|http\.request|https\.request' src`,
5
+ * plus the one site that hands `fetch` to a library, `ConvexHttpClient`). Each carries an
6
+ * AbortSignal timeout:
7
+ * upstream/paginatedClient.ts page request (fetchImpl, default global fetch) boundedFetch, UPSTREAM_TIMEOUT_MS
8
+ * upstream/clientCredentials.ts token request boundedFetch, option timeoutMs (10 s)
9
+ * upstream/clientCredentials.ts fetchWithToken (API request) boundedFetch, UPSTREAM_TIMEOUT_MS
10
+ * oauth/clerk.ts Backend API + JWKS (2 sites) per-call + total deadline (own signal)
11
+ * oauth/clientRegistry.ts CIMD metadata fetch own signal, option timeoutMs (5 s)
12
+ * config.ts external-issuer JWKS fetch upstreamSignal, UPSTREAM_TIMEOUT_MS
13
+ * lake/convex.ts ConvexHttpClient `fetch` option boundedFetch, UPSTREAM_TIMEOUT_MS
14
+ * src/nodeAdapter.ts and src/httpApp.ts `.fetch(` are the INBOUND handler, not a request emitted.
15
+ * A new outbound site belongs in this list and uses `boundedFetch` or `upstreamSignal`.
16
+ */
17
+ import { UPSTREAM_TIMEOUT_DEFAULT_MS, loadUpstreamSettings } from "../settings.js";
18
+ /** The configured budget: `UPSTREAM_TIMEOUT_MS`, validated at read (ConfigurationError if bad). */
19
+ export function configuredUpstreamTimeoutMs(env = process.env) {
20
+ return loadUpstreamSettings(env).upstreamTimeoutMs ?? UPSTREAM_TIMEOUT_DEFAULT_MS;
21
+ }
22
+ /** A timeout signal, combined with the caller's own signal when there is one (first to fire wins). */
23
+ export function upstreamSignal(timeoutMs, callerSignal) {
24
+ const timeout = AbortSignal.timeout(timeoutMs);
25
+ return callerSignal ? AbortSignal.any([timeout, callerSignal]) : timeout;
26
+ }
27
+ /**
28
+ * `fetchImpl` such that EVERY call carries the timeout, whatever the caller passed. A caller
29
+ * signal (in `init`, or on a `Request` input) still applies: the earlier of the two aborts.
30
+ * The abort surfaces as the fetch's own rejection (`TimeoutError`), for the call site to type.
31
+ */
32
+ export function boundedFetch(fetchImpl, timeoutMs) {
33
+ return ((input, init) => {
34
+ const caller = init?.signal ?? (input instanceof Request ? input.signal : undefined);
35
+ return fetchImpl(input, { ...init, signal: upstreamSignal(timeoutMs, caller) });
36
+ });
37
+ }
@@ -38,5 +38,12 @@ MCP_OAUTH_SIGNING_JWK=
38
38
  # MCP_MAX_BODY_BYTES=1048576
39
39
  # Browser origins allowed to call /register cross-origin (comma-separated; default none).
40
40
  # MCP_REGISTER_CORS_ORIGINS=https://inspector.example.com
41
+ # Browser origins allowed to call /mcp (S-22; comma-separated, scheme://host[:port]; default none =
42
+ # every request carrying an Origin header is refused 403; no Origin header = not a browser, passes).
43
+ # A wildcard or a path stops the server at start. /register keeps its own list above.
44
+ # MCP_ALLOWED_ORIGINS=https://claude.ai
45
+ # Time budget of one outbound request to an upstream (S-13; integer milliseconds, 1000 to 300000,
46
+ # default 30000). A value that is not an integer or is out of range stops the server at start.
47
+ # UPSTREAM_TIMEOUT_MS=30000
41
48
  # Display name shown above the consent page title (default: none).
42
49
  # AUTHORIZE_BRAND_NAME=Acme MCP
package/mcp-server.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-boilerplate",
3
- "version": "0.3.0",
3
+ "version": "0.5.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.5.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.5.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"