@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/mcpServerFactory.js
CHANGED
|
@@ -35,6 +35,7 @@ import { getBuildIdentity } from "./buildIdentity.js";
|
|
|
35
35
|
import { t } from "./i18n.js";
|
|
36
36
|
import { EXAMPLE_ITEMS_READER, LAKE_UNREADABLE_CODE, LakeReadError, } from "./lake/reader.js";
|
|
37
37
|
import { INVALID_CURSOR_CODE, InvalidCursorError, PAGINATION, readPage } from "./pagination.js";
|
|
38
|
+
import { trackToolRecords } from "./servedToolRecords.js";
|
|
38
39
|
import { loadServerManifest } from "./serverManifest.js";
|
|
39
40
|
import { scopeRowsForTenant } from "./tenancy.js";
|
|
40
41
|
import { LIST_ITEMS_ANNOTATIONS, PING_ANNOTATIONS } from "./toolRecord.js";
|
|
@@ -95,6 +96,10 @@ function refusal(text) {
|
|
|
95
96
|
}
|
|
96
97
|
export function createBoilerplateMcpServer(deps = {}) {
|
|
97
98
|
const locale = deps.locale ?? "en";
|
|
99
|
+
// The manifest carries the server's context ceiling. Every tool registered below is recorded
|
|
100
|
+
// at registration and checked against it (and the closed mechanism set) before the server is
|
|
101
|
+
// returned: the check covers what is served, there is no list to keep in step.
|
|
102
|
+
const manifest = loadServerManifest();
|
|
98
103
|
// The identity the server announces: version (declared) AND buildId (derived from the served
|
|
99
104
|
// tree, src/buildIdentity.ts). The SDK sends `serverInfo` as given, on `server/discover`
|
|
100
105
|
// (2026-07-28, `_meta["io.modelcontextprotocol/serverInfo"]`) and on `initialize` (2025-era).
|
|
@@ -105,7 +110,7 @@ export function createBoilerplateMcpServer(deps = {}) {
|
|
|
105
110
|
...{ buildId: getBuildIdentity() },
|
|
106
111
|
}, {
|
|
107
112
|
// The capabilities come from the server manifest (mcp-server.json, C-18): one source.
|
|
108
|
-
capabilities:
|
|
113
|
+
capabilities: manifest.capabilities,
|
|
109
114
|
instructions: "Clonable MCP server boilerplate — see README.md",
|
|
110
115
|
supportedProtocolVersions: SUPPORTED_LEGACY_PROTOCOL_VERSIONS,
|
|
111
116
|
cacheHints: {
|
|
@@ -115,6 +120,7 @@ export function createBoilerplateMcpServer(deps = {}) {
|
|
|
115
120
|
"server/discover": { ttlMs: 300_000, cacheScope: "public" },
|
|
116
121
|
},
|
|
117
122
|
});
|
|
123
|
+
const served = trackToolRecords(server);
|
|
118
124
|
server.registerTool("ping", {
|
|
119
125
|
title: "Ping",
|
|
120
126
|
description: "Echoes the given message (or 'pong' if none given). Proves the MCP handshake end to end.",
|
|
@@ -181,7 +187,7 @@ export function createBoilerplateMcpServer(deps = {}) {
|
|
|
181
187
|
try {
|
|
182
188
|
const tenantId = deps.tenant?.tenantId;
|
|
183
189
|
const scope = tenantId === undefined ? undefined : { tenantId };
|
|
184
|
-
const { items, ...page } = await readPage(lake, args, PAGINATION, scope);
|
|
190
|
+
const { items, ...page } = await readPage(lake, args, PAGINATION, scope, deps.cursorSigner);
|
|
185
191
|
// Defence in depth: the scope was applied inside the read; a row of another tenant that
|
|
186
192
|
// still comes back means the reader ignored it. Refuse the page, never filter it.
|
|
187
193
|
if (tenantId !== undefined && scopeRowsForTenant(tenantId, items).length !== items.length) {
|
|
@@ -212,5 +218,7 @@ export function createBoilerplateMcpServer(deps = {}) {
|
|
|
212
218
|
}, async () => ({
|
|
213
219
|
contents: [buildUiResource({ domain: deps.uiDomain })],
|
|
214
220
|
}));
|
|
221
|
+
// Start refusal: every registered tool's record, as registered, must be sound.
|
|
222
|
+
served.assertServed(manifest.contextCeiling);
|
|
215
223
|
return server;
|
|
216
224
|
}
|
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,18 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
+
import type { ContextCeiling } from "./serverManifest.js";
|
|
3
|
+
/**
|
|
4
|
+
* The tool records AS REGISTERED. The start refusal (`assertToolRecords`) must run on the tools
|
|
5
|
+
* the server actually serves, not on a second list a fork has to keep in step with its
|
|
6
|
+
* `registerTool` calls: a tool missing from such a list would ship unchecked.
|
|
7
|
+
*
|
|
8
|
+
* `trackToolRecords` wraps `server.registerTool` on that instance and keeps each tool's
|
|
9
|
+
* `annotations` as given. A tool registered with none is kept as `{}`, so it fails the check
|
|
10
|
+
* by name instead of being skipped. Call `assertServed` once every tool is registered.
|
|
11
|
+
*/
|
|
12
|
+
export interface ServedToolRecords {
|
|
13
|
+
/** The annotations of every tool registered so far, by tool name. */
|
|
14
|
+
readonly records: Readonly<Record<string, Record<string, unknown>>>;
|
|
15
|
+
/** Throws `ToolRecordError` naming every unsound tool; same check as the wire test. */
|
|
16
|
+
assertServed(ceiling: ContextCeiling): void;
|
|
17
|
+
}
|
|
18
|
+
export declare function trackToolRecords(server: McpServer): ServedToolRecords;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { assertToolRecords } from "./toolRecord.js";
|
|
2
|
+
export function trackToolRecords(server) {
|
|
3
|
+
const records = {};
|
|
4
|
+
const register = server.registerTool.bind(server);
|
|
5
|
+
server.registerTool = ((name, config, callback) => {
|
|
6
|
+
records[name] = { ...(config.annotations ?? {}) };
|
|
7
|
+
return register(name, config, callback);
|
|
8
|
+
});
|
|
9
|
+
return { records, assertServed: (ceiling) => assertToolRecords(records, ceiling) };
|
|
10
|
+
}
|
package/dist/serverManifest.d.ts
CHANGED
|
@@ -8,6 +8,15 @@
|
|
|
8
8
|
* message. Nothing is defaulted — a manifest that silently falls back hides the very drift
|
|
9
9
|
* C-18 exists to catch.
|
|
10
10
|
*/
|
|
11
|
+
/**
|
|
12
|
+
* The context ceiling (mcp-standard C-13), in estimated tokens, declared by THIS server: no tool
|
|
13
|
+
* may pass `perTool`, and the tools together may not pass `global`. A server with more tools
|
|
14
|
+
* declares a larger ceiling here; `checkContextCeiling` (src/toolRecord.ts) enforces it.
|
|
15
|
+
*/
|
|
16
|
+
export interface ContextCeiling {
|
|
17
|
+
global: number;
|
|
18
|
+
perTool: number;
|
|
19
|
+
}
|
|
11
20
|
export interface ServerManifest {
|
|
12
21
|
name: string;
|
|
13
22
|
version: string;
|
|
@@ -15,8 +24,10 @@ export interface ServerManifest {
|
|
|
15
24
|
transport: Record<string, unknown>;
|
|
16
25
|
/** Feature switches. Every value is a boolean; a lane adds its own key. */
|
|
17
26
|
features: Record<string, boolean>;
|
|
27
|
+
/** The per-server context ceiling. A number cannot ride in `features` (booleans only). */
|
|
28
|
+
contextCeiling: ContextCeiling;
|
|
18
29
|
}
|
|
19
|
-
type Expectation = "string" | "semver" | "object" | "boolean";
|
|
30
|
+
type Expectation = "string" | "semver" | "object" | "boolean" | "positiveInteger" | "atLeastPerTool";
|
|
20
31
|
/** Thrown when a manifest field cannot be read. `field` is the dotted path of the offender. */
|
|
21
32
|
export declare class ServerManifestError extends Error {
|
|
22
33
|
readonly field: string;
|
package/dist/serverManifest.js
CHANGED
|
@@ -28,11 +28,14 @@ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
|
28
28
|
function isRecord(value) {
|
|
29
29
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
30
30
|
}
|
|
31
|
+
function isPositiveInteger(value) {
|
|
32
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
33
|
+
}
|
|
31
34
|
/** Validates an already-parsed manifest document. `source` names it in the refusal. */
|
|
32
35
|
export function parseServerManifest(document, source) {
|
|
33
36
|
if (!isRecord(document))
|
|
34
37
|
throw new ServerManifestError("(document)", source, "object");
|
|
35
|
-
const { name, version, capabilities, transport, features } = document;
|
|
38
|
+
const { name, version, capabilities, transport, features, contextCeiling } = document;
|
|
36
39
|
if (typeof name !== "string" || name.length === 0) {
|
|
37
40
|
throw new ServerManifestError("name", source, "string");
|
|
38
41
|
}
|
|
@@ -50,7 +53,26 @@ export function parseServerManifest(document, source) {
|
|
|
50
53
|
throw new ServerManifestError(`features.${key}`, source, "boolean");
|
|
51
54
|
}
|
|
52
55
|
}
|
|
53
|
-
|
|
56
|
+
if (!isRecord(contextCeiling))
|
|
57
|
+
throw new ServerManifestError("contextCeiling", source, "object");
|
|
58
|
+
const { global, perTool } = contextCeiling;
|
|
59
|
+
if (!isPositiveInteger(global)) {
|
|
60
|
+
throw new ServerManifestError("contextCeiling.global", source, "positiveInteger");
|
|
61
|
+
}
|
|
62
|
+
if (!isPositiveInteger(perTool)) {
|
|
63
|
+
throw new ServerManifestError("contextCeiling.perTool", source, "positiveInteger");
|
|
64
|
+
}
|
|
65
|
+
if (global < perTool) {
|
|
66
|
+
throw new ServerManifestError("contextCeiling.global", source, "atLeastPerTool");
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
name,
|
|
70
|
+
version,
|
|
71
|
+
capabilities,
|
|
72
|
+
transport,
|
|
73
|
+
features: features,
|
|
74
|
+
contextCeiling: { global, perTool },
|
|
75
|
+
};
|
|
54
76
|
}
|
|
55
77
|
/**
|
|
56
78
|
* Reads `mcp-server.json` from the package root. `src/` and `dist/` both sit one level under
|
|
@@ -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
|
+
}
|
package/dist/stdio.js
CHANGED
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
10
10
|
import { createBoilerplateMcpServer } from "./mcpServerFactory.js";
|
|
11
|
+
import { loadServerManifest } from "./serverManifest.js";
|
|
11
12
|
export function startStdioServer() {
|
|
12
13
|
// `requireTenant` is otherwise only reachable from the OAuth-tier HTTP transport (see
|
|
13
14
|
// httpApp.ts). This env toggle exists solely so the eval suite (evals/evals.json
|
|
14
15
|
// "list-items-requires-tenant-refusal") can exercise the existing `list_items`
|
|
15
16
|
// tenant-refusal branch through the real stdio transport. Unset by default.
|
|
17
|
+
// Refuses at start, naming the field, a manifest without a readable context ceiling (G6).
|
|
18
|
+
loadServerManifest();
|
|
16
19
|
const requireTenant = process.env.MCP_BOILERPLATE_REQUIRE_TENANT === "1";
|
|
17
20
|
const uiDomain = process.env.MCP_UI_DOMAIN || undefined;
|
|
18
21
|
// No verified person on stdio, by design: there is no bearer and no verified session on this
|