@vantageos/mcp-boilerplate 0.3.0 → 0.4.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 +35 -0
- package/README.fr.md +2 -2
- package/README.md +2 -2
- package/dist/httpApp.js +3 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/locales/en.json +6 -1
- package/dist/locales/fr.json +6 -1
- package/dist/mcpServerFactory.js +9 -1
- 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/stdio.js +3 -0
- package/dist/toolRecord.d.ts +35 -5
- package/dist/toolRecord.js +86 -24
- package/mcp-server.json +3 -2
- package/package.json +1 -1
- package/plugin.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vantage-mcp-boilerplate",
|
|
3
3
|
"displayName": "VantageOS MCP Boilerplate",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.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"
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-10-28
|
|
11
|
+
|
|
12
|
+
> **BREAKING for forks.** `CONTEXT_CEILING` is no longer exported and `mcp-server.json` must declare
|
|
13
|
+
> `contextCeiling`. Migration: add `contextCeiling: { global, perTool }` to your `mcp-server.json`
|
|
14
|
+
> (the seed uses `{ "global": 1200, "perTool": 700 }`) and replace every `CONTEXT_CEILING` import
|
|
15
|
+
> with `loadServerManifest().contextCeiling`.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **Per-server context ceiling** (G6): `mcp-server.json` carries `contextCeiling: { global, perTool }`
|
|
20
|
+
(positive integers, `global >= perTool`), read by `loadServerManifest`. A missing or non-numeric
|
|
21
|
+
value refuses at start, naming the field (FR+EN); `features` stays booleans only.
|
|
22
|
+
`checkContextCeiling` and `assertToolRecords` (exported) enforce the standard's rule against the
|
|
23
|
+
server's own ceiling: no tool above `perTool`, the sum not above `global`.
|
|
24
|
+
- **`upstream-delegated(<upstream>)` search mechanism** for a tool answered by a live upstream call.
|
|
25
|
+
`checkToolRecord` / `assertToolRecords` refuse an undeclared live call (a list/search tool with
|
|
26
|
+
`openWorldHint: true` and an indexed mechanism or none), the declaration without
|
|
27
|
+
`openWorldHint: true`, an empty upstream name, `unindexed-scan` and any value outside the set.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- **BREAKING for forks** (0.x): the start refusal for tool records now runs on the tools AS
|
|
32
|
+
REGISTERED. The factory no longer holds a hand-typed `{ ping, list_items }` map: it wraps
|
|
33
|
+
`server.registerTool` (`trackToolRecords`, exported) and asserts the collected annotations once
|
|
34
|
+
every tool is registered. A tool registered with an unsound record, or with none, refuses the
|
|
35
|
+
start by name (FR+EN); a fork adds nothing to a list.
|
|
36
|
+
- `upstream-delegated(<upstream>)` refuses `upstream-delegated())`; the indexed mechanism set is
|
|
37
|
+
matched whole, so `indexed-range(anything` is refused.
|
|
38
|
+
|
|
39
|
+
### Removed (BREAKING for forks)
|
|
40
|
+
|
|
41
|
+
- **`CONTEXT_CEILING`** is no longer exported by `src/toolRecord.ts`: read
|
|
42
|
+
`loadServerManifest().contextCeiling`. A fork's `mcp-server.json` must add `contextCeiling`
|
|
43
|
+
(the seed declares `{ "global": 1200, "perTool": 700 }`, the former constant).
|
|
44
|
+
|
|
10
45
|
## [0.3.0] - 2026-10-28
|
|
11
46
|
|
|
12
47
|
### Added
|
package/README.fr.md
CHANGED
|
@@ -92,9 +92,9 @@ Chaque outil déclare `title`, un schéma d'entrée et un schéma de sortie typ
|
|
|
92
92
|
| `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` | C-15 | les quatre booléens de comportement |
|
|
93
93
|
| `authRead`, `authWrite` | C-8 | couple d'autorité parmi `public` / `org-member` / `org-admin` / `fleet-internal` / `master`, distincts ; résolu depuis l'identité vérifiée, jamais depuis un argument |
|
|
94
94
|
| `verb`, `table` | C-9, C-12 | l'opération (`list`, `get`, `create`, `pure`...) et la donnée touchée |
|
|
95
|
-
| `searchMechanism` | C-10 | comment une liste/recherche est servie (`indexed-range` ici) ; `unindexed-scan` ne se publie pas |
|
|
95
|
+
| `searchMechanism` | C-10 | comment une liste/recherche est servie (`indexed-range` ici) ; `unindexed-scan` ne se publie pas. Un outil servi par un appel tiers en direct déclare `upstream-delegated(<amont>)` avec `openWorldHint: true` ; `assertToolRecords` refuse un appel en direct non déclaré, ou la déclaration sans le hint |
|
|
96
96
|
| `paginationCursor`, `paginationDefault`, `paginationMax` | C-11 | curseur, taille de page par défaut et maximum fini, appliqués par `src/pagination.ts` |
|
|
97
|
-
| `tokensEstimeContexte` | C-13 | coût de contexte estimé ; `
|
|
97
|
+
| `tokensEstimeContexte` | C-13 | coût de contexte estimé ; le `contextCeiling` propre au serveur (`global`, `perTool`, dans `mcp-server.json`) borne chaque outil et la somme, appliqué par `assertToolRecords` au démarrage ; un plafond absent ou non numérique arrête le serveur |
|
|
98
98
|
|
|
99
99
|
Les ensembles sont des unions TypeScript fermées : une valeur hors ensemble ne
|
|
100
100
|
compile pas, et un outil de liste ne peut omettre ni son mécanisme ni sa pagination.
|
package/README.md
CHANGED
|
@@ -91,9 +91,9 @@ and returns `structuredContent` plus a text block with the same JSON. Its
|
|
|
91
91
|
| `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` | C-15 | the four behavioural booleans |
|
|
92
92
|
| `authRead`, `authWrite` | C-8 | authority pair from `public` / `org-member` / `org-admin` / `fleet-internal` / `master`, distinct; resolved from the verified identity, never from an argument |
|
|
93
93
|
| `verb`, `table` | C-9, C-12 | the operation (`list`, `get`, `create`, `pure`...) and the data it touches |
|
|
94
|
-
| `searchMechanism` | C-10 | how a list/search is served (`indexed-range` here); `unindexed-scan` does not publish |
|
|
94
|
+
| `searchMechanism` | C-10 | how a list/search is served (`indexed-range` here); `unindexed-scan` does not publish. A tool answered by a live third-party call declares `upstream-delegated(<upstream>)` with `openWorldHint: true`; `assertToolRecords` refuses an undeclared live call, or the declaration without the hint |
|
|
95
95
|
| `paginationCursor`, `paginationDefault`, `paginationMax` | C-11 | cursor, default page size and a finite maximum, applied by `src/pagination.ts` |
|
|
96
|
-
| `tokensEstimeContexte` | C-13 | estimated context cost; `
|
|
96
|
+
| `tokensEstimeContexte` | C-13 | estimated context cost; the server's own `contextCeiling` (`global`, `perTool`, in `mcp-server.json`) bounds each tool and the sum, enforced by `assertToolRecords` at start; a missing or non-numeric ceiling stops the server |
|
|
97
97
|
|
|
98
98
|
The sets are closed TypeScript unions: a value outside them does not compile, and a
|
|
99
99
|
list tool cannot omit its mechanism or its pagination. Each tool's record is a plain
|
package/dist/httpApp.js
CHANGED
|
@@ -31,6 +31,7 @@ import { detectLocale, t } from "./i18n.js";
|
|
|
31
31
|
import { createBoilerplateMcpServer } from "./mcpServerFactory.js";
|
|
32
32
|
import { createAuthorizationServer, } from "./oauth/authorizationServer.js";
|
|
33
33
|
import { MCP_SCOPE } from "./oauth/settings.js";
|
|
34
|
+
import { loadServerManifest } from "./serverManifest.js";
|
|
34
35
|
import { resolveTenantFromJwtPayload } from "./tenancy.js";
|
|
35
36
|
import { assertIdentityDeps, resolveVerifiedPerson, } from "./verifiedPerson.js";
|
|
36
37
|
import { SERVER_VERSION } from "./version.js";
|
|
@@ -41,6 +42,8 @@ const PRM_PATHS = [
|
|
|
41
42
|
export function buildHttpApp(deps = {}) {
|
|
42
43
|
const app = new Hono();
|
|
43
44
|
const authMode = deps.authMode ?? "public";
|
|
45
|
+
// Refuses at start, naming the field, a manifest without a readable context ceiling (G6).
|
|
46
|
+
loadServerManifest();
|
|
44
47
|
if (deps.identity)
|
|
45
48
|
assertIdentityDeps(deps.identity);
|
|
46
49
|
// Unauthenticated (S-7): the version AND the identity of the tree being served (C-25), so a
|
package/dist/index.d.ts
CHANGED
|
@@ -12,3 +12,9 @@ export { UpstreamTokenError, clientCredentialsFromEnv, createClientCredentialsTo
|
|
|
12
12
|
export type { ClientAuthMethod, ClientCredentialsEnvOptions, ClientCredentialsEnvVars, ClientCredentialsOptions, ClientCredentialsTokenSource, } from "./upstream/clientCredentials.js";
|
|
13
13
|
export { UpstreamPageError, createPaginatedUpstreamClient } from "./upstream/paginatedClient.js";
|
|
14
14
|
export type { FetchLike, PaginatedUpstreamClient, PaginatedUpstreamOptions, PaginationPlacement, ParamObject, ParamValue, UpstreamPage, UpstreamPageErrorCode, UpstreamParams, } from "./upstream/paginatedClient.js";
|
|
15
|
+
export { ServerManifestError, loadServerManifest, parseServerManifest, } from "./serverManifest.js";
|
|
16
|
+
export type { ContextCeiling, ServerManifest } from "./serverManifest.js";
|
|
17
|
+
export { trackToolRecords } from "./servedToolRecords.js";
|
|
18
|
+
export type { ServedToolRecords } from "./servedToolRecords.js";
|
|
19
|
+
export { ToolRecordError, assertToolRecords, checkContextCeiling, checkToolRecord, } from "./toolRecord.js";
|
|
20
|
+
export type { ContextEstimate, SearchMechanism, ToolSlots } from "./toolRecord.js";
|
package/dist/index.js
CHANGED
|
@@ -6,3 +6,6 @@ export { buildHttpApp } from "./httpApp.js";
|
|
|
6
6
|
export { PERSON_UNRESOLVED_CODE, resolveVerifiedPerson, withVerifiedPerson, } from "./verifiedPerson.js";
|
|
7
7
|
export { UpstreamTokenError, clientCredentialsFromEnv, createClientCredentialsTokenSource, } from "./upstream/clientCredentials.js";
|
|
8
8
|
export { UpstreamPageError, createPaginatedUpstreamClient } from "./upstream/paginatedClient.js";
|
|
9
|
+
export { ServerManifestError, loadServerManifest, parseServerManifest, } from "./serverManifest.js";
|
|
10
|
+
export { trackToolRecords } from "./servedToolRecords.js";
|
|
11
|
+
export { ToolRecordError, assertToolRecords, checkContextCeiling, checkToolRecord, } from "./toolRecord.js";
|
package/dist/locales/en.json
CHANGED
|
@@ -36,9 +36,14 @@
|
|
|
36
36
|
"string": "a non-empty string",
|
|
37
37
|
"semver": "a semantic version (x.y.z)",
|
|
38
38
|
"object": "an object",
|
|
39
|
-
"boolean": "true or false"
|
|
39
|
+
"boolean": "true or false",
|
|
40
|
+
"positiveInteger": "a positive whole number",
|
|
41
|
+
"atLeastPerTool": "at least the perTool ceiling"
|
|
40
42
|
}
|
|
41
43
|
},
|
|
44
|
+
"toolRecord": {
|
|
45
|
+
"unsound": "tool records or the context ceiling are unsound: {problems}"
|
|
46
|
+
},
|
|
42
47
|
"pagination": {
|
|
43
48
|
"invalidCursor": "invalid cursor: it was not issued by this tool or is no longer valid; start again without a cursor"
|
|
44
49
|
},
|
package/dist/locales/fr.json
CHANGED
|
@@ -36,9 +36,14 @@
|
|
|
36
36
|
"string": "une chaîne non vide",
|
|
37
37
|
"semver": "une version sémantique (x.y.z)",
|
|
38
38
|
"object": "un objet",
|
|
39
|
-
"boolean": "true ou false"
|
|
39
|
+
"boolean": "true ou false",
|
|
40
|
+
"positiveInteger": "un entier strictement positif",
|
|
41
|
+
"atLeastPerTool": "au moins le plafond perTool"
|
|
40
42
|
}
|
|
41
43
|
},
|
|
44
|
+
"toolRecord": {
|
|
45
|
+
"unsound": "fiches d'outils ou plafond de contexte invalides : {problems}"
|
|
46
|
+
},
|
|
42
47
|
"pagination": {
|
|
43
48
|
"invalidCursor": "curseur invalide : il n'a pas été émis par cet outil ou n'est plus valide ; recommencez sans curseur"
|
|
44
49
|
},
|
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.",
|
|
@@ -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
|
}
|
|
@@ -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
|
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
|
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
|
+
}
|
package/mcp-server.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-boilerplate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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.4.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.4.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"
|