@vantageos/mcp-boilerplate 0.4.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.
@@ -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.4.0",
3
+ "version": "0.5.0",
4
4
  "capabilities": {
5
5
  "tools": {},
6
6
  "resources": {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vantageos/mcp-boilerplate",
3
- "version": "0.4.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.4.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"