@vantageos/mcp-boilerplate 0.2.3 → 0.3.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 +161 -0
- package/README.fr.md +323 -15
- package/README.md +306 -15
- package/dist/buildIdentity.d.ts +19 -0
- package/dist/buildIdentity.js +62 -0
- package/dist/declaredVariables.d.ts +205 -0
- package/dist/declaredVariables.js +451 -0
- package/dist/exampleItems.d.ts +15 -0
- package/dist/exampleItems.js +28 -1
- package/dist/httpApp.d.ts +17 -0
- package/dist/httpApp.js +19 -2
- package/dist/i18n.d.ts +10 -0
- package/dist/i18n.js +12 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +6 -0
- package/dist/lake/convex.d.ts +101 -0
- package/dist/lake/convex.js +224 -0
- package/dist/lake/reader.d.ts +88 -0
- package/dist/lake/reader.js +120 -0
- package/dist/locales/en.json +42 -0
- package/dist/locales/fr.json +42 -0
- package/dist/mcpServerFactory.d.ts +31 -14
- package/dist/mcpServerFactory.js +116 -48
- package/dist/oauth/authorizationServer.js +64 -18
- package/dist/oauth/clerk.d.ts +2 -0
- package/dist/oauth/clerk.js +15 -5
- package/dist/oauth/clientRegistry.d.ts +19 -4
- package/dist/oauth/clientRegistry.js +20 -10
- package/dist/oauth/picker.d.ts +7 -2
- package/dist/oauth/picker.js +90 -13
- package/dist/oauth/registerLimiter.d.ts +34 -0
- package/dist/oauth/registerLimiter.js +95 -0
- package/dist/oauth/settings.d.ts +4 -0
- package/dist/oauth/settings.js +4 -0
- package/dist/pagination.d.ts +66 -0
- package/dist/pagination.js +100 -0
- package/dist/serverManifest.d.ts +32 -0
- package/dist/serverManifest.js +68 -0
- package/dist/stdio.js +5 -1
- package/dist/tenancy.d.ts +1 -1
- package/dist/toolRecord.d.ts +94 -0
- package/dist/toolRecord.js +52 -0
- package/dist/ui/uiResource.d.ts +11 -4
- package/dist/ui/uiResource.js +8 -4
- package/dist/upstream/clientCredentials.d.ts +106 -0
- package/dist/upstream/clientCredentials.js +283 -0
- package/dist/upstream/paginatedClient.d.ts +142 -0
- package/dist/upstream/paginatedClient.js +422 -0
- package/dist/verifiedPerson.d.ts +85 -0
- package/dist/verifiedPerson.js +112 -0
- package/docs/oauth-stores.md +2 -1
- package/examples/oauth.env.example +7 -0
- package/mcp-server.json +13 -0
- package/package.json +19 -4
- package/plugin.json +1 -1
- package/skills/connect-mcp-server/SKILL.md +5 -5
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paginated upstream client, for a server derived from this boilerplate that reads a third-party
|
|
3
|
+
* endpoint paginated by OFFSET and LIMIT. The upstream is configuration: the parameter names,
|
|
4
|
+
* where they travel (query string or JSON body) and the HTTP method are options, nothing here
|
|
5
|
+
* names a vendor.
|
|
6
|
+
*
|
|
7
|
+
* Two reads:
|
|
8
|
+
* - `readAll(path, params)` walks the offset pages until a short or empty page (or, when the
|
|
9
|
+
* response declares a total, until the total is reached) and returns every row. It is bounded
|
|
10
|
+
* by a REQUIRED finite `rowCeiling`: a set that would exceed it is refused with an
|
|
11
|
+
* `UpstreamPageError` (`row_ceiling_exceeded`) naming the ceiling. A truncated set is never
|
|
12
|
+
* returned as if it were complete.
|
|
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.
|
|
23
|
+
*
|
|
24
|
+
* ```ts
|
|
25
|
+
* // src/upstream.ts — evaluated once per process, beside the token source
|
|
26
|
+
* export const things = createPaginatedUpstreamClient({
|
|
27
|
+
* fetchImpl: upstream.fetchWithToken, // G1's token source; default: the global fetch
|
|
28
|
+
* baseUrl: "https://api.upstream.example/v1/",
|
|
29
|
+
* allowedOrigins: ["https://api.upstream.example"],
|
|
30
|
+
* offsetParam: "offset",
|
|
31
|
+
* limitParam: "limit",
|
|
32
|
+
* placement: "jsonBody", // or "query"
|
|
33
|
+
* rowCeiling: 5000,
|
|
34
|
+
* extractRows: (body) => (body as { data: unknown[] }).data,
|
|
35
|
+
* });
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* Safety:
|
|
39
|
+
* - Every request URL is resolved against `baseUrl` and its origin must be in `allowedOrigins`,
|
|
40
|
+
* checked BEFORE any network call: a path (possibly from a tool argument) naming another host
|
|
41
|
+
* never receives the bearer token. Origins are https, or http on loopback only (as G1).
|
|
42
|
+
* - Redirects are never followed (`redirect: "error"`): a 30x from an allowed origin cannot carry
|
|
43
|
+
* the request to another host. The refused redirect surfaces as `unreachable`, by error kind.
|
|
44
|
+
* - 429 and 503 are retried with exponential backoff and jitter, honouring `Retry-After`
|
|
45
|
+
* (seconds or HTTP date) capped by `maxDelayMs`; after `maxAttempts` the error names the
|
|
46
|
+
* status and the attempt count. No other status is retried, and neither is a transport
|
|
47
|
+
* failure (its error KIND is reported, never its message).
|
|
48
|
+
* - A body that is not JSON, an extractor that throws or does not return an array, an unreadable
|
|
49
|
+
* total, or a page longer than the limit asked for is refused (fail closed). There is no
|
|
50
|
+
* silent empty page: an empty page before a declared total is `inconsistent_page`, so a walk
|
|
51
|
+
* can never re-request the same offset forever.
|
|
52
|
+
* - Errors may name the request PATH (pathname only) and the HTTP status. They never carry a
|
|
53
|
+
* header, a token, the query string, a parameter value or the response body.
|
|
54
|
+
*
|
|
55
|
+
* Offset pagination over an order the upstream does not declare can skip or repeat rows when the
|
|
56
|
+
* data moves between two pages. This client cannot detect that; pass a sort parameter in
|
|
57
|
+
* `params` when the upstream offers one. And when the upstream silently caps the page below
|
|
58
|
+
* `pageSize`, a short page reads as the end of the set: keep `pageSize` within the upstream's
|
|
59
|
+
* real maximum, or supply `extractTotal`, which then decides the end instead.
|
|
60
|
+
*
|
|
61
|
+
* Errors are operator-facing (logged server-side), in English like `clientCredentials.ts`.
|
|
62
|
+
*/
|
|
63
|
+
import { createHash } from "node:crypto";
|
|
64
|
+
import { InvalidCursorError } from "../pagination.js";
|
|
65
|
+
/** A refused or failed upstream page read. Carries no header, token, parameter or body. */
|
|
66
|
+
export class UpstreamPageError extends Error {
|
|
67
|
+
code;
|
|
68
|
+
/** The request pathname (no query string), when a request was built. */
|
|
69
|
+
path;
|
|
70
|
+
/** HTTP status, when the upstream answered. */
|
|
71
|
+
status;
|
|
72
|
+
/** Attempts made, on `rate_limited`. */
|
|
73
|
+
attempts;
|
|
74
|
+
/** The ceiling, on `row_ceiling_exceeded`. */
|
|
75
|
+
rowCeiling;
|
|
76
|
+
constructor(code, message, details = {}) {
|
|
77
|
+
super(message);
|
|
78
|
+
this.name = "UpstreamPageError";
|
|
79
|
+
this.code = code;
|
|
80
|
+
this.path = details.path;
|
|
81
|
+
this.status = details.status;
|
|
82
|
+
this.attempts = details.attempts;
|
|
83
|
+
this.rowCeiling = details.rowCeiling;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const DEFAULT_PAGE_SIZE = 100;
|
|
87
|
+
const DEFAULT_MAX_ATTEMPTS = 5;
|
|
88
|
+
const DEFAULT_BASE_DELAY_MS = 500;
|
|
89
|
+
const DEFAULT_MAX_DELAY_MS = 30_000;
|
|
90
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
91
|
+
const RETRYABLE = new Set([429, 503]);
|
|
92
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
93
|
+
/**
|
|
94
|
+
* Cursor format. It mirrors `src/pagination.ts` (opaque base64url JSON with a version, refused as
|
|
95
|
+
* `InvalidCursorError`) but cannot reuse its `encodeCursor`/`decodeCursor`: those carry one
|
|
96
|
+
* 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.
|
|
98
|
+
*/
|
|
99
|
+
const CURSOR_VERSION = 2;
|
|
100
|
+
const isPositiveInteger = (n) => typeof n === "number" && Number.isSafeInteger(n) && n > 0;
|
|
101
|
+
function validateOrigin(entry) {
|
|
102
|
+
let url;
|
|
103
|
+
try {
|
|
104
|
+
url = typeof entry === "string" ? new URL(entry) : undefined;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
url = undefined;
|
|
108
|
+
}
|
|
109
|
+
if (!url || url.origin !== entry) {
|
|
110
|
+
throw new Error("paginated upstream: allowedOrigins entries must be origins like https://api.example (scheme, host, optional port; no path) (value not shown)");
|
|
111
|
+
}
|
|
112
|
+
const loopbackHttp = url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
|
|
113
|
+
if (url.protocol !== "https:" && !loopbackHttp) {
|
|
114
|
+
throw new Error("paginated upstream: allowedOrigins entries must use https (http is accepted on loopback only) (value not shown)");
|
|
115
|
+
}
|
|
116
|
+
return url.origin;
|
|
117
|
+
}
|
|
118
|
+
/** JSON with object keys sorted at every depth, so the fingerprint ignores key order. */
|
|
119
|
+
function canonicalJson(value) {
|
|
120
|
+
if (Array.isArray(value))
|
|
121
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
122
|
+
if (value !== null && typeof value === "object") {
|
|
123
|
+
const entries = Object.entries(value)
|
|
124
|
+
.filter(([, v]) => v !== undefined)
|
|
125
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
126
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`;
|
|
127
|
+
}
|
|
128
|
+
return JSON.stringify(value) ?? "null";
|
|
129
|
+
}
|
|
130
|
+
function fingerprint(path, params) {
|
|
131
|
+
return createHash("sha256")
|
|
132
|
+
.update(canonicalJson({ path, params }))
|
|
133
|
+
.digest()
|
|
134
|
+
.subarray(0, 16)
|
|
135
|
+
.toString("base64url");
|
|
136
|
+
}
|
|
137
|
+
function encodeOffsetCursor(offset, print) {
|
|
138
|
+
return Buffer.from(JSON.stringify({ v: CURSOR_VERSION, o: offset, f: print })).toString("base64url");
|
|
139
|
+
}
|
|
140
|
+
function decodeOffsetCursor(cursor, print) {
|
|
141
|
+
let decoded;
|
|
142
|
+
try {
|
|
143
|
+
decoded = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
throw new InvalidCursorError();
|
|
147
|
+
}
|
|
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();
|
|
153
|
+
}
|
|
154
|
+
// Only the exact string this client issues is accepted: base64url decoding is lenient
|
|
155
|
+
// (trailing characters, extra fields), so the cursor is re-encoded and compared.
|
|
156
|
+
if (encodeOffsetCursor(o, print) !== cursor)
|
|
157
|
+
throw new InvalidCursorError();
|
|
158
|
+
return o;
|
|
159
|
+
}
|
|
160
|
+
/** `Retry-After` (RFC 9110 §10.2.3) in ms, from delay-seconds or an HTTP date; else undefined. */
|
|
161
|
+
function retryAfterMs(header, now) {
|
|
162
|
+
if (header === null)
|
|
163
|
+
return undefined;
|
|
164
|
+
const value = header.trim();
|
|
165
|
+
if (/^\d+$/.test(value))
|
|
166
|
+
return Number(value) * 1000;
|
|
167
|
+
const date = Date.parse(value);
|
|
168
|
+
if (Number.isNaN(date))
|
|
169
|
+
return undefined;
|
|
170
|
+
return Math.max(0, date - now);
|
|
171
|
+
}
|
|
172
|
+
function isScalar(value) {
|
|
173
|
+
return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
174
|
+
}
|
|
175
|
+
export function createPaginatedUpstreamClient(options) {
|
|
176
|
+
const { fetchImpl = (input, init) => fetch(input, init), baseUrl, allowedOrigins, offsetParam, limitParam, placement, pageSize = DEFAULT_PAGE_SIZE, rowCeiling, extractRows, extractTotal, headers: extraHeaders = {}, maxAttempts = DEFAULT_MAX_ATTEMPTS, baseDelayMs = DEFAULT_BASE_DELAY_MS, maxDelayMs = DEFAULT_MAX_DELAY_MS, timeoutMs = DEFAULT_TIMEOUT_MS, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), random = Math.random, now = Date.now, } = options;
|
|
177
|
+
if (!Array.isArray(allowedOrigins) || allowedOrigins.length === 0) {
|
|
178
|
+
throw new Error("paginated upstream: allowedOrigins is required and must not be empty");
|
|
179
|
+
}
|
|
180
|
+
const origins = new Set(allowedOrigins.map(validateOrigin));
|
|
181
|
+
let base;
|
|
182
|
+
try {
|
|
183
|
+
base = new URL(baseUrl);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
throw new Error("paginated upstream: baseUrl is not a valid URL (value not shown)");
|
|
187
|
+
}
|
|
188
|
+
if (!origins.has(base.origin)) {
|
|
189
|
+
throw new Error("paginated upstream: the baseUrl origin is not in allowedOrigins");
|
|
190
|
+
}
|
|
191
|
+
if (placement !== "query" && placement !== "jsonBody") {
|
|
192
|
+
throw new Error('paginated upstream: placement must be "query" or "jsonBody"');
|
|
193
|
+
}
|
|
194
|
+
const method = (options.method ?? (placement === "query" ? "GET" : "POST")).toUpperCase();
|
|
195
|
+
if (placement === "jsonBody" && (method === "GET" || method === "HEAD")) {
|
|
196
|
+
throw new Error("paginated upstream: method GET or HEAD cannot carry a jsonBody placement");
|
|
197
|
+
}
|
|
198
|
+
if (typeof offsetParam !== "string" || offsetParam === "") {
|
|
199
|
+
throw new Error("paginated upstream: offsetParam must be a non-empty string");
|
|
200
|
+
}
|
|
201
|
+
if (typeof limitParam !== "string" || limitParam === "") {
|
|
202
|
+
throw new Error("paginated upstream: limitParam must be a non-empty string");
|
|
203
|
+
}
|
|
204
|
+
if (offsetParam === limitParam) {
|
|
205
|
+
throw new Error("paginated upstream: offsetParam and limitParam must differ");
|
|
206
|
+
}
|
|
207
|
+
if (!isPositiveInteger(pageSize)) {
|
|
208
|
+
throw new Error("paginated upstream: pageSize must be a positive integer");
|
|
209
|
+
}
|
|
210
|
+
if (!isPositiveInteger(rowCeiling)) {
|
|
211
|
+
throw new Error("paginated upstream: rowCeiling is required and must be a positive integer");
|
|
212
|
+
}
|
|
213
|
+
if (typeof extractRows !== "function") {
|
|
214
|
+
throw new Error("paginated upstream: extractRows must be a function");
|
|
215
|
+
}
|
|
216
|
+
if (!isPositiveInteger(maxAttempts)) {
|
|
217
|
+
throw new Error("paginated upstream: maxAttempts must be a positive integer");
|
|
218
|
+
}
|
|
219
|
+
for (const [name, value] of [
|
|
220
|
+
["baseDelayMs", baseDelayMs],
|
|
221
|
+
["maxDelayMs", maxDelayMs],
|
|
222
|
+
]) {
|
|
223
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
224
|
+
throw new Error(`paginated upstream: ${name} must be a non-negative number`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
228
|
+
throw new Error("paginated upstream: timeoutMs must be a positive number");
|
|
229
|
+
}
|
|
230
|
+
/** Resolves the path and refuses, before any network call, an origin not allowed. */
|
|
231
|
+
const resolveUrl = (path) => {
|
|
232
|
+
let url;
|
|
233
|
+
try {
|
|
234
|
+
url = new URL(path, base);
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
throw new UpstreamPageError("origin_not_allowed", "Upstream path does not resolve to a URL");
|
|
238
|
+
}
|
|
239
|
+
if (!origins.has(url.origin)) {
|
|
240
|
+
throw new UpstreamPageError("origin_not_allowed", `Upstream request refused: origin not in allowedOrigins (path ${url.pathname})`, { path: url.pathname });
|
|
241
|
+
}
|
|
242
|
+
return url;
|
|
243
|
+
};
|
|
244
|
+
const checkParams = (params, path) => {
|
|
245
|
+
if (params === null || typeof params !== "object" || Array.isArray(params)) {
|
|
246
|
+
throw new UpstreamPageError("invalid_params", `Upstream params must be an object (${path})`, {
|
|
247
|
+
path,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
for (const reserved of [offsetParam, limitParam]) {
|
|
251
|
+
if (Object.hasOwn(params, reserved)) {
|
|
252
|
+
throw new UpstreamPageError("invalid_params", `Upstream params must not set the pagination parameter "${reserved}" (${path})`, { path });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (placement === "query") {
|
|
256
|
+
for (const [key, value] of Object.entries(params)) {
|
|
257
|
+
if (value === undefined || isScalar(value))
|
|
258
|
+
continue;
|
|
259
|
+
if (Array.isArray(value) && value.every(isScalar))
|
|
260
|
+
continue;
|
|
261
|
+
throw new UpstreamPageError("invalid_params", `Upstream query param "${key}" must be a string, number, boolean or an array of those (${path})`, { path });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
const buildRequest = (url, params, offset) => {
|
|
266
|
+
const target = new URL(url);
|
|
267
|
+
const headers = { Accept: "application/json", ...extraHeaders };
|
|
268
|
+
let body;
|
|
269
|
+
if (placement === "query") {
|
|
270
|
+
for (const [key, value] of Object.entries(params)) {
|
|
271
|
+
if (value === undefined)
|
|
272
|
+
continue;
|
|
273
|
+
for (const item of Array.isArray(value) ? value : [value]) {
|
|
274
|
+
target.searchParams.append(key, String(item));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
target.searchParams.set(offsetParam, String(offset));
|
|
278
|
+
target.searchParams.set(limitParam, String(pageSize));
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
headers["Content-Type"] = "application/json";
|
|
282
|
+
body = JSON.stringify({ ...params, [offsetParam]: offset, [limitParam]: pageSize });
|
|
283
|
+
}
|
|
284
|
+
return { target: target.toString(), headers, body };
|
|
285
|
+
};
|
|
286
|
+
const backoffMs = (retry, response) => {
|
|
287
|
+
const told = retryAfterMs(response.headers.get("retry-after"), now());
|
|
288
|
+
if (told !== undefined)
|
|
289
|
+
return Math.min(told, maxDelayMs);
|
|
290
|
+
const ceiling = Math.min(maxDelayMs, baseDelayMs * 2 ** (retry - 1));
|
|
291
|
+
// Equal jitter: half the exponential delay, plus a random share of the other half.
|
|
292
|
+
return ceiling / 2 + random() * (ceiling / 2);
|
|
293
|
+
};
|
|
294
|
+
/** One upstream page at `offset`, with the 429/503 retry loop. */
|
|
295
|
+
const fetchPage = async (url, params, offset) => {
|
|
296
|
+
const path = url.pathname;
|
|
297
|
+
const { target, headers, body } = buildRequest(url, params, offset);
|
|
298
|
+
const send = async () => {
|
|
299
|
+
try {
|
|
300
|
+
return await fetchImpl(target, {
|
|
301
|
+
method,
|
|
302
|
+
headers,
|
|
303
|
+
body,
|
|
304
|
+
// A redirect is refused, never followed: the origin check above must stay total.
|
|
305
|
+
redirect: "error",
|
|
306
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
catch (err) {
|
|
310
|
+
// Only the error's NAME travels: a transport message is not ours to vouch for. A refused
|
|
311
|
+
// redirect lands here too (fetch rejects with a TypeError): its target never travels.
|
|
312
|
+
const kind = err instanceof Error ? err.name : "unknown error";
|
|
313
|
+
throw new UpstreamPageError("unreachable", `Upstream unreachable (${kind}) (path ${path})`, { path });
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
let response = await send();
|
|
317
|
+
for (let attempt = 1; RETRYABLE.has(response.status); attempt++) {
|
|
318
|
+
if (attempt >= maxAttempts) {
|
|
319
|
+
await response.body?.cancel().catch(() => { });
|
|
320
|
+
throw new UpstreamPageError("rate_limited", `Upstream answered ${response.status} after ${attempt} attempts (path ${path})`, { path, status: response.status, attempts: attempt });
|
|
321
|
+
}
|
|
322
|
+
const delay = backoffMs(attempt, response);
|
|
323
|
+
await response.body?.cancel().catch(() => { });
|
|
324
|
+
await sleep(delay);
|
|
325
|
+
response = await send();
|
|
326
|
+
}
|
|
327
|
+
if (!response.ok) {
|
|
328
|
+
await response.body?.cancel().catch(() => { });
|
|
329
|
+
throw new UpstreamPageError("upstream_status", `Upstream answered ${response.status} (path ${path})`, { path, status: response.status });
|
|
330
|
+
}
|
|
331
|
+
const status = response.status;
|
|
332
|
+
const refuse = (what) => new UpstreamPageError("unreadable_page", `Upstream answered ${status} with ${what} (path ${path})`, { path, status });
|
|
333
|
+
let parsed;
|
|
334
|
+
try {
|
|
335
|
+
parsed = await response.json();
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
throw refuse("a body that is not JSON");
|
|
339
|
+
}
|
|
340
|
+
let rows;
|
|
341
|
+
try {
|
|
342
|
+
rows = extractRows(parsed);
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
// The extractor's message may quote the body: it does not travel.
|
|
346
|
+
throw refuse("a page the row extractor could not read");
|
|
347
|
+
}
|
|
348
|
+
if (!Array.isArray(rows))
|
|
349
|
+
throw refuse("rows that are not an array");
|
|
350
|
+
if (rows.length > pageSize) {
|
|
351
|
+
throw new UpstreamPageError("inconsistent_page", `Upstream returned ${rows.length} rows for a page of ${pageSize}: the limit parameter was not applied (path ${path})`, { path, status });
|
|
352
|
+
}
|
|
353
|
+
let total;
|
|
354
|
+
if (extractTotal) {
|
|
355
|
+
let raw;
|
|
356
|
+
try {
|
|
357
|
+
raw = extractTotal(parsed);
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
throw refuse("a page the total extractor could not read");
|
|
361
|
+
}
|
|
362
|
+
if (raw !== undefined) {
|
|
363
|
+
if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw < 0) {
|
|
364
|
+
throw refuse("an unreadable total");
|
|
365
|
+
}
|
|
366
|
+
total = raw;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return { rows: rows, total };
|
|
370
|
+
};
|
|
371
|
+
/** Whether the page at `offset` is the last one. */
|
|
372
|
+
const isLast = (offset, count, total, path) => {
|
|
373
|
+
if (total === undefined)
|
|
374
|
+
return count < pageSize;
|
|
375
|
+
if (offset + count >= total)
|
|
376
|
+
return true;
|
|
377
|
+
if (count === 0) {
|
|
378
|
+
throw new UpstreamPageError("inconsistent_page", `Upstream returned an empty page before its declared total (path ${path})`, { path });
|
|
379
|
+
}
|
|
380
|
+
return false;
|
|
381
|
+
};
|
|
382
|
+
const ceilingError = (path) => new UpstreamPageError("row_ceiling_exceeded", `Upstream result exceeds the row ceiling of ${rowCeiling} rows (path ${path}); narrow the query`, { path, rowCeiling });
|
|
383
|
+
const readAll = async (path, params) => {
|
|
384
|
+
const url = resolveUrl(path);
|
|
385
|
+
checkParams(params, url.pathname);
|
|
386
|
+
const collected = [];
|
|
387
|
+
let offset = 0;
|
|
388
|
+
for (;;) {
|
|
389
|
+
const { rows, total } = await fetchPage(url, params, offset);
|
|
390
|
+
if (collected.length + rows.length > rowCeiling)
|
|
391
|
+
throw ceilingError(url.pathname);
|
|
392
|
+
if (total !== undefined && total > rowCeiling)
|
|
393
|
+
throw ceilingError(url.pathname);
|
|
394
|
+
collected.push(...rows);
|
|
395
|
+
if (isLast(offset, rows.length, total, url.pathname))
|
|
396
|
+
return collected;
|
|
397
|
+
offset += rows.length;
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
const readPage = async (path, params, cursor) => {
|
|
401
|
+
const url = resolveUrl(path);
|
|
402
|
+
checkParams(params, url.pathname);
|
|
403
|
+
const print = fingerprint(url.pathname + url.search, params);
|
|
404
|
+
const offset = cursor === undefined ? 0 : decodeOffsetCursor(cursor, print);
|
|
405
|
+
// A cursor at the ceiling may still meet an empty page (exact fit); beyond it, nothing is read.
|
|
406
|
+
if (offset > rowCeiling)
|
|
407
|
+
throw ceilingError(url.pathname);
|
|
408
|
+
const { rows, total } = await fetchPage(url, params, offset);
|
|
409
|
+
if (offset + rows.length > rowCeiling)
|
|
410
|
+
throw ceilingError(url.pathname);
|
|
411
|
+
const page = { rows, exhausted: false };
|
|
412
|
+
if (total !== undefined)
|
|
413
|
+
page.total = total;
|
|
414
|
+
if (isLast(offset, rows.length, total, url.pathname)) {
|
|
415
|
+
page.exhausted = true;
|
|
416
|
+
return page;
|
|
417
|
+
}
|
|
418
|
+
page.nextCursor = encodeOffsetCursor(offset + rows.length, print);
|
|
419
|
+
return page;
|
|
420
|
+
};
|
|
421
|
+
return { readAll, readPage };
|
|
422
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The caller's verified PERSON, for tool handlers.
|
|
3
|
+
*
|
|
4
|
+
* The person is resolved by @vantageos/cloud-identity's `resolveCallerFromClaims` (tagged
|
|
5
|
+
* `@callerResolver`), from the same verified session claims the tenant comes from, ONCE per /mcp
|
|
6
|
+
* request (src/httpApp.ts). This module never reads a claim itself: the claim record goes to the
|
|
7
|
+
* package whole, and only the package's result comes back.
|
|
8
|
+
*
|
|
9
|
+
* The stores the package reads (the organisation mapping and the people of each organisation)
|
|
10
|
+
* and the roles that make a person an admin of their organisation are INJECTED by the deployment
|
|
11
|
+
* (`AppDeps.identity`). The boilerplate builds no store and names no role.
|
|
12
|
+
*
|
|
13
|
+
* A tool that needs the person wraps its handler with `withVerifiedPerson`. Every member of
|
|
14
|
+
* `CallerResolution` other than `ok: true` refuses it, before the handler runs, with the stable
|
|
15
|
+
* code `PERSON_UNRESOLVED_CODE` and a message in the caller's language:
|
|
16
|
+
* - `identity_not_configured` — the deployment injected no `identity`;
|
|
17
|
+
* - `no_verified_session` — no verified session exists: the public HTTP tier, and stdio,
|
|
18
|
+
* where there is no bearer at all, so no person, ever;
|
|
19
|
+
* - `refused` — the package refused; its refusal code and reason are carried,
|
|
20
|
+
* never a claim value nor its free-text detail.
|
|
21
|
+
* A tool that does not wrap its handler is untouched and runs with no person.
|
|
22
|
+
*/
|
|
23
|
+
import type { CallToolResult, ServerContext } from "@modelcontextprotocol/server";
|
|
24
|
+
import { type CallerFromClaimsResult, type IdentityRefusalCode, type IdentityRefusalReason, type OrgMappingLookups, type PrincipalLookups } from "@vantageos/cloud-identity";
|
|
25
|
+
import { type McpLocale } from "./i18n.js";
|
|
26
|
+
/** What a deployment injects so the person can be resolved. `adminRoles` has no default. */
|
|
27
|
+
export interface IdentityDeps {
|
|
28
|
+
/** The organisation mapping store (the package reads `orgById`). */
|
|
29
|
+
readonly orgLookups: OrgMappingLookups;
|
|
30
|
+
/** The people of each organisation (the package reads `personById`, `organisationById`, `orgKindOf`). */
|
|
31
|
+
readonly principalLookups: PrincipalLookups;
|
|
32
|
+
/** The org roles that make a person an admin of their organisation, compared byte for byte. Non-empty. */
|
|
33
|
+
readonly adminRoles: readonly string[];
|
|
34
|
+
}
|
|
35
|
+
type CallerResolved = Extract<CallerFromClaimsResult, {
|
|
36
|
+
ok: true;
|
|
37
|
+
}>;
|
|
38
|
+
/** Read-only at every level. */
|
|
39
|
+
type DeepReadonly<T> = T extends (infer E)[] ? readonly DeepReadonly<E>[] : T extends object ? {
|
|
40
|
+
readonly [K in keyof T]: DeepReadonly<T[K]>;
|
|
41
|
+
} : T;
|
|
42
|
+
/** The verified person, exactly the fields the package returns, frozen. */
|
|
43
|
+
export type VerifiedPerson = DeepReadonly<Pick<CallerResolved, "subject" | "actorName" | "org" | "principal" | "role" | "isOrgAdmin">>;
|
|
44
|
+
export type PersonUnresolvedReason = "identity_not_configured" | "no_verified_session" | "refused";
|
|
45
|
+
/** The per-request outcome, carried beside the tenant in the server deps. */
|
|
46
|
+
export type CallerResolution = {
|
|
47
|
+
readonly ok: true;
|
|
48
|
+
readonly person: VerifiedPerson;
|
|
49
|
+
} | {
|
|
50
|
+
readonly ok: false;
|
|
51
|
+
readonly reason: PersonUnresolvedReason;
|
|
52
|
+
/** Present only when `reason` is `refused`: the package's code and reason, nothing else. */
|
|
53
|
+
readonly refusal?: {
|
|
54
|
+
readonly code: IdentityRefusalCode;
|
|
55
|
+
readonly reason: IdentityRefusalReason;
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
/** Stable machine code of the refusal a requiring tool returns. */
|
|
59
|
+
export declare const PERSON_UNRESOLVED_CODE = "person_unresolved";
|
|
60
|
+
/** This server's name for the door, carried on every package refusal. */
|
|
61
|
+
export declare const PERSON_DOOR = "mcp-tool-call";
|
|
62
|
+
/**
|
|
63
|
+
* Refuses an `identity` that cannot resolve anyone: called when the app is built, so a
|
|
64
|
+
* misconfiguration fails at start, never per request and never as a defaulted role.
|
|
65
|
+
*/
|
|
66
|
+
export declare function assertIdentityDeps(identity: IdentityDeps): void;
|
|
67
|
+
/**
|
|
68
|
+
* Resolves the person behind a verified session through the package. `claims` is the verified
|
|
69
|
+
* session's claim record, or undefined when no session was verified.
|
|
70
|
+
*/
|
|
71
|
+
export declare function resolveVerifiedPerson(claims: Readonly<Record<string, unknown>> | undefined, identity: IdentityDeps | undefined): Promise<CallerResolution>;
|
|
72
|
+
/** The per-request context a requiring tool reads: the same deps object the tenant travels in. */
|
|
73
|
+
export interface PersonContext {
|
|
74
|
+
caller?: CallerResolution;
|
|
75
|
+
locale?: McpLocale;
|
|
76
|
+
}
|
|
77
|
+
export type PersonRefusalResult = CallToolResult & {
|
|
78
|
+
isError: true;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Declares that a tool needs the verified person: the handler runs only with one, and receives it
|
|
82
|
+
* as its second argument. No caller resolution in the context (stdio) is `no_verified_session`.
|
|
83
|
+
*/
|
|
84
|
+
export declare function withVerifiedPerson<A, R extends CallToolResult>(context: PersonContext, handler: (args: A, person: VerifiedPerson, ctx: ServerContext) => R | Promise<R>): (args: A, ctx: ServerContext) => Promise<R | PersonRefusalResult>;
|
|
85
|
+
export {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The caller's verified PERSON, for tool handlers.
|
|
3
|
+
*
|
|
4
|
+
* The person is resolved by @vantageos/cloud-identity's `resolveCallerFromClaims` (tagged
|
|
5
|
+
* `@callerResolver`), from the same verified session claims the tenant comes from, ONCE per /mcp
|
|
6
|
+
* request (src/httpApp.ts). This module never reads a claim itself: the claim record goes to the
|
|
7
|
+
* package whole, and only the package's result comes back.
|
|
8
|
+
*
|
|
9
|
+
* The stores the package reads (the organisation mapping and the people of each organisation)
|
|
10
|
+
* and the roles that make a person an admin of their organisation are INJECTED by the deployment
|
|
11
|
+
* (`AppDeps.identity`). The boilerplate builds no store and names no role.
|
|
12
|
+
*
|
|
13
|
+
* A tool that needs the person wraps its handler with `withVerifiedPerson`. Every member of
|
|
14
|
+
* `CallerResolution` other than `ok: true` refuses it, before the handler runs, with the stable
|
|
15
|
+
* code `PERSON_UNRESOLVED_CODE` and a message in the caller's language:
|
|
16
|
+
* - `identity_not_configured` — the deployment injected no `identity`;
|
|
17
|
+
* - `no_verified_session` — no verified session exists: the public HTTP tier, and stdio,
|
|
18
|
+
* where there is no bearer at all, so no person, ever;
|
|
19
|
+
* - `refused` — the package refused; its refusal code and reason are carried,
|
|
20
|
+
* never a claim value nor its free-text detail.
|
|
21
|
+
* A tool that does not wrap its handler is untouched and runs with no person.
|
|
22
|
+
*/
|
|
23
|
+
import { resolveCallerFromClaims, } from "@vantageos/cloud-identity";
|
|
24
|
+
import { t } from "./i18n.js";
|
|
25
|
+
/** Stable machine code of the refusal a requiring tool returns. */
|
|
26
|
+
export const PERSON_UNRESOLVED_CODE = "person_unresolved";
|
|
27
|
+
/** This server's name for the door, carried on every package refusal. */
|
|
28
|
+
export const PERSON_DOOR = "mcp-tool-call";
|
|
29
|
+
/**
|
|
30
|
+
* Refuses an `identity` that cannot resolve anyone: called when the app is built, so a
|
|
31
|
+
* misconfiguration fails at start, never per request and never as a defaulted role.
|
|
32
|
+
*/
|
|
33
|
+
export function assertIdentityDeps(identity) {
|
|
34
|
+
const roles = Array.isArray(identity.adminRoles) ? identity.adminRoles : [];
|
|
35
|
+
if (roles.length === 0 || !roles.every((r) => typeof r === "string" && r.length > 0)) {
|
|
36
|
+
throw new Error("identity.adminRoles must name at least one role: the boilerplate never defaults an admin role.");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function deepFreeze(value) {
|
|
40
|
+
if (typeof value === "object" && value !== null) {
|
|
41
|
+
for (const child of Object.values(value))
|
|
42
|
+
deepFreeze(child);
|
|
43
|
+
Object.freeze(value);
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
function unresolved(reason) {
|
|
48
|
+
const outcome = { ok: false, reason };
|
|
49
|
+
return Object.freeze(outcome);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Resolves the person behind a verified session through the package. `claims` is the verified
|
|
53
|
+
* session's claim record, or undefined when no session was verified.
|
|
54
|
+
*/
|
|
55
|
+
export async function resolveVerifiedPerson(claims, identity) {
|
|
56
|
+
if (!identity)
|
|
57
|
+
return unresolved("identity_not_configured");
|
|
58
|
+
if (!claims)
|
|
59
|
+
return unresolved("no_verified_session");
|
|
60
|
+
const result = await resolveCallerFromClaims(claims, { orgLookups: identity.orgLookups, principalLookups: identity.principalLookups }, { adminRoles: identity.adminRoles, door: PERSON_DOOR });
|
|
61
|
+
if (!result.ok) {
|
|
62
|
+
const refused = {
|
|
63
|
+
ok: false,
|
|
64
|
+
reason: "refused",
|
|
65
|
+
refusal: { code: result.refusal.code, reason: result.refusal.reason },
|
|
66
|
+
};
|
|
67
|
+
return deepFreeze(refused);
|
|
68
|
+
}
|
|
69
|
+
// A copy (structuredClone), then frozen: the store's own objects are never frozen or shared.
|
|
70
|
+
const person = structuredClone({
|
|
71
|
+
subject: result.subject,
|
|
72
|
+
actorName: result.actorName,
|
|
73
|
+
org: result.org,
|
|
74
|
+
principal: result.principal,
|
|
75
|
+
role: result.role,
|
|
76
|
+
isOrgAdmin: result.isOrgAdmin,
|
|
77
|
+
});
|
|
78
|
+
const resolved = { ok: true, person: deepFreeze(person) };
|
|
79
|
+
return Object.freeze(resolved);
|
|
80
|
+
}
|
|
81
|
+
const MESSAGE_KEYS = {
|
|
82
|
+
identity_not_configured: "person.identityNotConfigured",
|
|
83
|
+
no_verified_session: "person.noVerifiedSession",
|
|
84
|
+
refused: "person.refused",
|
|
85
|
+
};
|
|
86
|
+
function personRefusal(caller, locale) {
|
|
87
|
+
const message = t(locale, MESSAGE_KEYS[caller.reason]);
|
|
88
|
+
return {
|
|
89
|
+
content: [{ type: "text", text: message }],
|
|
90
|
+
structuredContent: {
|
|
91
|
+
error: {
|
|
92
|
+
code: PERSON_UNRESOLVED_CODE,
|
|
93
|
+
message,
|
|
94
|
+
reason: caller.reason,
|
|
95
|
+
...(caller.refusal ? { refusal: { ...caller.refusal } } : {}),
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
isError: true,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Declares that a tool needs the verified person: the handler runs only with one, and receives it
|
|
103
|
+
* as its second argument. No caller resolution in the context (stdio) is `no_verified_session`.
|
|
104
|
+
*/
|
|
105
|
+
export function withVerifiedPerson(context, handler) {
|
|
106
|
+
return async (args, ctx) => {
|
|
107
|
+
const caller = context.caller ?? unresolved("no_verified_session");
|
|
108
|
+
if (!caller.ok)
|
|
109
|
+
return personRefusal(caller, context.locale ?? "en");
|
|
110
|
+
return handler(args, caller.person, ctx);
|
|
111
|
+
};
|
|
112
|
+
}
|
package/docs/oauth-stores.md
CHANGED
|
@@ -14,7 +14,8 @@ both `codeStore` and `clientRegistry` are injected, or `MCP_ALLOW_IN_MEMORY_STOR
|
|
|
14
14
|
single-instance deployment that accepts forgetting codes and DCR clients on restart (for example
|
|
15
15
|
a verification instance). The in-memory adapters are bounded: codes expire out of memory and the
|
|
16
16
|
used set is pruned past each code's expiry; registered clients expire after 30 days and the oldest
|
|
17
|
-
is evicted at the cap
|
|
17
|
+
is NOT evicted at the cap: a new registration is refused (`RegistryFullError`, answered `503`
|
|
18
|
+
with `Retry-After`) after expired clients are pruned. A durable adapter may throw the same error.
|
|
18
19
|
|
|
19
20
|
## `AuthorizationCodeStore` (cloud-identity type, `src/oauth/codeStore.ts`)
|
|
20
21
|
|
|
@@ -26,10 +26,17 @@ MCP_OAUTH_SIGNING_JWK=
|
|
|
26
26
|
|
|
27
27
|
# Number of trusted reverse proxies in front of this server (default 0): the /register rate limit
|
|
28
28
|
# keys on the socket address, or on the X-Forwarded-For entry the N-th proxy appended.
|
|
29
|
+
# N = proxy HOPS that append to X-Forwarded-For (Railway: 2, measured). Too LOW fails silently
|
|
30
|
+
# (the limiter keys on a shared proxy). Measure: 21 registrations from one client, 429 on the 21st.
|
|
29
31
|
# MCP_TRUSTED_PROXY_COUNT=1
|
|
32
|
+
# /register budget shared by every /64 inside one IPv6 /48, per minute (default 100). A /48 holds
|
|
33
|
+
# 65536 /64s; without this budget one /48 could fill the client registry to its 503.
|
|
34
|
+
# MCP_REGISTER_LIMIT_PER_48_PER_MINUTE=100
|
|
30
35
|
# NODE_ENV=production refuses the in-memory stores; exactly "1" accepts them (single instance).
|
|
31
36
|
# MCP_ALLOW_IN_MEMORY_STORES=1
|
|
32
37
|
# Largest request body the Node entry accepts, in bytes (default 1048576); over it answers 413.
|
|
33
38
|
# MCP_MAX_BODY_BYTES=1048576
|
|
34
39
|
# Browser origins allowed to call /register cross-origin (comma-separated; default none).
|
|
35
40
|
# MCP_REGISTER_CORS_ORIGINS=https://inspector.example.com
|
|
41
|
+
# Display name shown above the consent page title (default: none).
|
|
42
|
+
# AUTHORIZE_BRAND_NAME=Acme MCP
|
package/mcp-server.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mcp-boilerplate",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"capabilities": {
|
|
5
|
+
"tools": {},
|
|
6
|
+
"resources": {}
|
|
7
|
+
},
|
|
8
|
+
"transport": {
|
|
9
|
+
"stdio": { "bin": "mcp-boilerplate" },
|
|
10
|
+
"streamableHttp": { "path": "/mcp", "stateless": true }
|
|
11
|
+
},
|
|
12
|
+
"features": {}
|
|
13
|
+
}
|