@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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +86 -3
- package/README.fr.md +74 -5
- package/README.md +69 -5
- package/dist/config.d.ts +1 -1
- package/dist/config.js +3 -2
- package/dist/http.js +7 -3
- package/dist/httpApp.d.ts +12 -0
- package/dist/httpApp.js +20 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/lake/convex.d.ts +2 -0
- package/dist/lake/convex.js +8 -3
- package/dist/locales/en.json +2 -1
- package/dist/locales/fr.json +2 -1
- package/dist/mcpServerFactory.d.ts +7 -0
- package/dist/mcpServerFactory.js +1 -1
- package/dist/pagination.d.ts +13 -14
- package/dist/pagination.js +28 -25
- package/dist/settings.d.ts +72 -0
- package/dist/settings.js +53 -0
- package/dist/signedCursor.d.ts +48 -0
- package/dist/signedCursor.js +98 -0
- package/dist/upstream/clientCredentials.d.ts +2 -0
- package/dist/upstream/clientCredentials.js +10 -4
- package/dist/upstream/paginatedClient.d.ts +22 -10
- package/dist/upstream/paginatedClient.js +51 -33
- package/dist/upstream/timeout.d.ts +26 -0
- package/dist/upstream/timeout.js +37 -0
- package/examples/oauth.env.example +7 -0
- package/mcp-server.json +1 -1
- package/package.json +5 -3
- package/plugin.json +1 -1
- package/scripts/readme-fr.mjs +150 -0
package/dist/pagination.d.ts
CHANGED
|
@@ -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
|
|
8
|
-
*
|
|
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
|
-
|
|
26
|
-
export declare
|
|
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>>;
|
package/dist/pagination.js
CHANGED
|
@@ -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
|
|
8
|
-
*
|
|
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
|
-
/**
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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(
|
|
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
|
+
}>;
|
package/dist/settings.js
ADDED
|
@@ -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
|
|
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
|
|
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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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 {
|