@vantageos/mcp-boilerplate 0.4.0 → 0.6.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.
@@ -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,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
+ }
@@ -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 {