@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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +100 -0
- package/README.fr.md +58 -7
- package/README.md +53 -7
- 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 +23 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +4 -0
- package/dist/lake/convex.d.ts +2 -0
- package/dist/lake/convex.js +8 -3
- package/dist/locales/en.json +8 -2
- package/dist/locales/fr.json +8 -2
- package/dist/mcpServerFactory.d.ts +7 -0
- package/dist/mcpServerFactory.js +10 -2
- package/dist/pagination.d.ts +13 -14
- package/dist/pagination.js +28 -25
- package/dist/servedToolRecords.d.ts +18 -0
- package/dist/servedToolRecords.js +10 -0
- package/dist/serverManifest.d.ts +12 -1
- package/dist/serverManifest.js +24 -2
- 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/stdio.js +3 -0
- package/dist/toolRecord.d.ts +35 -5
- package/dist/toolRecord.js +86 -24
- 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 +3 -2
- package/package.json +1 -1
- package/plugin.json +1 -1
package/dist/toolRecord.d.ts
CHANGED
|
@@ -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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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 {};
|
package/dist/toolRecord.js
CHANGED
|
@@ -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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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 {
|
|
@@ -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
|
|
@@ -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 "../
|
|
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` (
|
|
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
|
|
143
|
+
function encodeOffsetCursor(signer, offset, print) {
|
|
144
|
+
return signer.sign(JSON.stringify({ v: CURSOR_VERSION, o: offset, f: print }));
|
|
139
145
|
}
|
|
140
|
-
|
|
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(
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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 =
|
|
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 (
|
|
228
|
-
|
|
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
|
-
|
|
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
|
+
"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
|
+
"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
|
+
"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"
|