@vantageos/mcp-boilerplate 0.2.4 → 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 +182 -0
- package/README.fr.md +318 -15
- package/README.md +302 -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 +22 -2
- package/dist/i18n.d.ts +10 -0
- package/dist/i18n.js +12 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +9 -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 +47 -0
- package/dist/locales/fr.json +47 -0
- package/dist/mcpServerFactory.d.ts +31 -14
- package/dist/mcpServerFactory.js +124 -48
- package/dist/oauth/authorizationServer.js +63 -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 +5 -2
- package/dist/oauth/picker.js +34 -4
- package/dist/oauth/registerLimiter.d.ts +34 -0
- package/dist/oauth/registerLimiter.js +95 -0
- package/dist/oauth/settings.d.ts +2 -0
- package/dist/oauth/settings.js +2 -0
- package/dist/pagination.d.ts +66 -0
- package/dist/pagination.js +100 -0
- package/dist/servedToolRecords.d.ts +18 -0
- package/dist/servedToolRecords.js +10 -0
- package/dist/serverManifest.d.ts +43 -0
- package/dist/serverManifest.js +90 -0
- package/dist/stdio.js +8 -1
- package/dist/tenancy.d.ts +1 -1
- package/dist/toolRecord.d.ts +124 -0
- package/dist/toolRecord.js +114 -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 +5 -0
- package/mcp-server.json +14 -0
- package/package.json +19 -4
- package/plugin.json +1 -1
- package/skills/connect-mcp-server/SKILL.md +5 -5
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data-lake reader: the READ-ONLY interface through which a server derived from this boilerplate
|
|
3
|
+
* reads its client's data lake (the single persisted source). The tools never know which store is
|
|
4
|
+
* behind it; the store is chosen by injection (`AppDeps.lake` / `McpServerDeps.lake`).
|
|
5
|
+
*
|
|
6
|
+
* The contract mirrors the cursor contract of src/pagination.ts:
|
|
7
|
+
* - rows are keyed by a STABLE, non-empty string key (`keyOf`), served in strictly ascending key
|
|
8
|
+
* order, so the last key served is the cursor;
|
|
9
|
+
* - `listPage({ after, limit, scope })` returns at most `limit` rows whose key is strictly after
|
|
10
|
+
* `after` (all rows when it is undefined), and `exhausted: true` exactly when no row follows
|
|
11
|
+
* them (an exact fit is exhausted, never a false truncation); a truncated page has at least one
|
|
12
|
+
* row;
|
|
13
|
+
* - `scope` (the caller's resolved tenant) is applied INSIDE the range read — a compound
|
|
14
|
+
* (tenant, key) index — never as a filter after the limit, which would shorten a page and make
|
|
15
|
+
* its cursor lie;
|
|
16
|
+
* - `get(key, scope)` returns the row with that key, or undefined.
|
|
17
|
+
*
|
|
18
|
+
* What a derived server adds: its own filters (a status, a date window, a person's portfolio) as
|
|
19
|
+
* arguments of its own adapter or query functions, and its own row type. What is out of scope, by
|
|
20
|
+
* design: writes. The interface has no write method, and the Convex adapter (src/lake/convex.ts)
|
|
21
|
+
* only ever calls queries.
|
|
22
|
+
*
|
|
23
|
+
* Fail closed: `readPage` (src/pagination.ts) checks every page against this contract with
|
|
24
|
+
* `checkLakePage`; a page it cannot read is a `LakeReadError`, never an empty or partial page.
|
|
25
|
+
* Its message names the contract clause broken, never a row value.
|
|
26
|
+
*
|
|
27
|
+
* The in-memory adapter below wraps the boilerplate's seed (src/exampleItems.ts) and is the
|
|
28
|
+
* default reader, so a server with no lake injected behaves exactly as before.
|
|
29
|
+
*/
|
|
30
|
+
import { EXAMPLE_ITEMS, rangeAfter } from "../exampleItems.js";
|
|
31
|
+
import { scopeRowsForTenant } from "../tenancy.js";
|
|
32
|
+
/** The stable machine code of a tool result refused because the lake answer was unreadable. */
|
|
33
|
+
export const LAKE_UNREADABLE_CODE = "lake_unreadable";
|
|
34
|
+
export class LakeReadError extends Error {
|
|
35
|
+
code;
|
|
36
|
+
constructor(code, message) {
|
|
37
|
+
super(`lake read: ${message}`);
|
|
38
|
+
this.name = "LakeReadError";
|
|
39
|
+
this.code = code;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** The row's key, or a `malformed_row` refusal when it is not a non-empty string. */
|
|
43
|
+
function readKey(keyOf, row) {
|
|
44
|
+
let key;
|
|
45
|
+
try {
|
|
46
|
+
key = keyOf(row);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
key = undefined;
|
|
50
|
+
}
|
|
51
|
+
if (typeof key !== "string" || key === "") {
|
|
52
|
+
throw new LakeReadError("malformed_row", "a row has no readable key (a non-empty string)");
|
|
53
|
+
}
|
|
54
|
+
return key;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Checks that `keys` are strictly ascending and all strictly after `after`. Shared by
|
|
58
|
+
* `checkLakePage` and by adapters that read a raw answer.
|
|
59
|
+
*/
|
|
60
|
+
export function assertOrderedKeys(keys, after) {
|
|
61
|
+
let previous = after;
|
|
62
|
+
for (const key of keys) {
|
|
63
|
+
if (previous !== undefined && !(key > previous)) {
|
|
64
|
+
throw new LakeReadError("malformed_page", after !== undefined && previous === after
|
|
65
|
+
? "a row key is not after the cursor"
|
|
66
|
+
: "row keys are not strictly ascending");
|
|
67
|
+
}
|
|
68
|
+
previous = key;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Checks a page against the contract above; returns it, or throws a `LakeReadError`. */
|
|
72
|
+
export function checkLakePage(reader, request, page) {
|
|
73
|
+
if (typeof page !== "object" || page === null) {
|
|
74
|
+
throw new LakeReadError("malformed_page", "the page is not an object");
|
|
75
|
+
}
|
|
76
|
+
const { rows, exhausted } = page;
|
|
77
|
+
if (!Array.isArray(rows))
|
|
78
|
+
throw new LakeReadError("malformed_page", "rows is not a list");
|
|
79
|
+
if (typeof exhausted !== "boolean") {
|
|
80
|
+
throw new LakeReadError("malformed_page", "exhausted is not a boolean");
|
|
81
|
+
}
|
|
82
|
+
if (rows.length > request.limit) {
|
|
83
|
+
throw new LakeReadError("malformed_page", "the page holds more rows than its limit");
|
|
84
|
+
}
|
|
85
|
+
if (!exhausted && rows.length === 0) {
|
|
86
|
+
throw new LakeReadError("malformed_page", "a truncated page holds no row (no cursor to give)");
|
|
87
|
+
}
|
|
88
|
+
const keys = rows.map((row) => readKey(reader.keyOf, row));
|
|
89
|
+
assertOrderedKeys(keys, request.after);
|
|
90
|
+
return { rows: rows, exhausted };
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* An in-memory reader over rows SORTED strictly ascending by `id` (refused otherwise, at
|
|
94
|
+
* creation: the range read relies on the order). The scope keeps the rows stamped with the
|
|
95
|
+
* caller's organisation (tenancy.ts `scopeRowsForTenant`) BEFORE the range, so a page is never
|
|
96
|
+
* shortened afterwards. A store-backed reader reads the same range from a (tenant, key) index.
|
|
97
|
+
*/
|
|
98
|
+
export function createInMemoryLakeReader(sortedRows) {
|
|
99
|
+
for (let i = 1; i < sortedRows.length; i += 1) {
|
|
100
|
+
const previous = sortedRows[i - 1]?.id ?? "";
|
|
101
|
+
const current = sortedRows[i]?.id ?? "";
|
|
102
|
+
if (!(current > previous)) {
|
|
103
|
+
throw new Error("in-memory lake reader: rows must be sorted strictly ascending by id (unique keys)");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const scoped = (scope) => scope ? scopeRowsForTenant(scope.tenantId, sortedRows) : sortedRows;
|
|
107
|
+
return Object.freeze({
|
|
108
|
+
keyOf: (row) => row.id,
|
|
109
|
+
async listPage(request) {
|
|
110
|
+
// One row more than the page: its existence separates truncated from exhausted.
|
|
111
|
+
const window = rangeAfter(scoped(request.scope), request.after, request.limit + 1);
|
|
112
|
+
return { rows: window.slice(0, request.limit), exhausted: window.length <= request.limit };
|
|
113
|
+
},
|
|
114
|
+
async get(key, scope) {
|
|
115
|
+
return scoped(scope).find((row) => row.id === key);
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/** The default reader behind `list_items`: the in-memory seed. */
|
|
120
|
+
export const EXAMPLE_ITEMS_READER = createInMemoryLakeReader(EXAMPLE_ITEMS);
|
package/dist/locales/en.json
CHANGED
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
"tenant": {
|
|
7
7
|
"permissionDenied": "permission denied: no tenant resolved for this caller"
|
|
8
8
|
},
|
|
9
|
+
"person": {
|
|
10
|
+
"identityNotConfigured": "permission denied: this tool needs the verified person, and this server resolves no person (identity is not configured)",
|
|
11
|
+
"noVerifiedSession": "permission denied: this tool needs the verified person, and this call carries no verified session",
|
|
12
|
+
"refused": "permission denied: the verified person behind this call could not be resolved"
|
|
13
|
+
},
|
|
9
14
|
"validation": {
|
|
10
15
|
"messageTooLong": "message exceeds the maximum length of 200 characters"
|
|
11
16
|
},
|
|
@@ -20,7 +25,49 @@
|
|
|
20
25
|
"deny": "Deny",
|
|
21
26
|
"denied": "The request was denied; you can close this window.",
|
|
22
27
|
"unavailable": "Authorization is not configured on this server.",
|
|
28
|
+
"inMemoryWarning": "Running on in-memory OAuth stores: codes and registered clients are lost on restart and not shared between instances. Fine for development; set {knob}=1 to acknowledge, or inject durable adapters.",
|
|
29
|
+
"consentTokenRequired": "The consent token is missing: reload the page and try again.",
|
|
30
|
+
"registryFull": "Client registration is temporarily unavailable: the registry is full. Retry later.",
|
|
23
31
|
"devInstanceRemote": "Development Clerk instance on a remote server: its hosted sign-in page redirects only to localhost or to the instance Home URL. Set the development instance's Home URL (development origin) to this server's origin for a remote test and revert it afterwards, or use a Clerk production instance (see README, \"Sign-in with ChatGPT and Claude\")."
|
|
32
|
+
},
|
|
33
|
+
"manifest": {
|
|
34
|
+
"unreadableField": "cannot read field \"{field}\" of {source} (expected: {expected})",
|
|
35
|
+
"expect": {
|
|
36
|
+
"string": "a non-empty string",
|
|
37
|
+
"semver": "a semantic version (x.y.z)",
|
|
38
|
+
"object": "an object",
|
|
39
|
+
"boolean": "true or false",
|
|
40
|
+
"positiveInteger": "a positive whole number",
|
|
41
|
+
"atLeastPerTool": "at least the perTool ceiling"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"toolRecord": {
|
|
45
|
+
"unsound": "tool records or the context ceiling are unsound: {problems}"
|
|
46
|
+
},
|
|
47
|
+
"pagination": {
|
|
48
|
+
"invalidCursor": "invalid cursor: it was not issued by this tool or is no longer valid; start again without a cursor"
|
|
49
|
+
},
|
|
50
|
+
"lake": {
|
|
51
|
+
"unreadable": "the data source answered something this server cannot read; no row is shown rather than a partial or wrong page"
|
|
52
|
+
},
|
|
53
|
+
"buildIdentity": {
|
|
54
|
+
"unreadable": "cannot compute the build identity: {dir} is unreadable ({reason})",
|
|
55
|
+
"empty": "cannot compute the build identity: {dir} is empty (no file), nothing was built"
|
|
56
|
+
},
|
|
57
|
+
"config": {
|
|
58
|
+
"refused": "configuration refused: {problems}",
|
|
59
|
+
"missing": "{env} is missing (expected: {expected})",
|
|
60
|
+
"invalid": "{env} is set but invalid (expected: {expected}; value not shown)",
|
|
61
|
+
"expect": {
|
|
62
|
+
"string": "a non-empty string",
|
|
63
|
+
"integer": "an integer from {min} to {max}",
|
|
64
|
+
"integerAtLeast": "an integer of at least {min}",
|
|
65
|
+
"boolean": "\"0\" or \"1\"",
|
|
66
|
+
"url": "an https URL (http on loopback only)",
|
|
67
|
+
"originList": "a comma-separated list of origins (scheme://host[:port], no path)",
|
|
68
|
+
"enum": "one of: {values}",
|
|
69
|
+
"json": "a JSON object or array"
|
|
70
|
+
}
|
|
24
71
|
}
|
|
25
72
|
}
|
|
26
73
|
}
|
package/dist/locales/fr.json
CHANGED
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
"tenant": {
|
|
7
7
|
"permissionDenied": "permission refusée : aucun tenant résolu pour cet appelant"
|
|
8
8
|
},
|
|
9
|
+
"person": {
|
|
10
|
+
"identityNotConfigured": "permission refusée : cet outil exige la personne vérifiée, et ce serveur ne résout aucune personne (identité non configurée)",
|
|
11
|
+
"noVerifiedSession": "permission refusée : cet outil exige la personne vérifiée, et cet appel ne porte aucune session vérifiée",
|
|
12
|
+
"refused": "permission refusée : la personne vérifiée derrière cet appel n'a pas pu être résolue"
|
|
13
|
+
},
|
|
9
14
|
"validation": {
|
|
10
15
|
"messageTooLong": "le message dépasse la longueur maximale de 200 caractères"
|
|
11
16
|
},
|
|
@@ -20,7 +25,49 @@
|
|
|
20
25
|
"deny": "Refuser",
|
|
21
26
|
"denied": "La demande a été refusée ; vous pouvez fermer cette fenêtre.",
|
|
22
27
|
"unavailable": "L'autorisation n'est pas configurée sur ce serveur.",
|
|
28
|
+
"inMemoryWarning": "Stores OAuth en mémoire : les codes et les clients enregistrés sont perdus au redémarrage et non partagés entre instances. Acceptable en développement ; définissez {knob}=1 pour l'accepter, ou injectez des adaptateurs durables.",
|
|
29
|
+
"consentTokenRequired": "Le jeton de consentement est absent : rechargez la page et réessayez.",
|
|
30
|
+
"registryFull": "L'enregistrement de clients est temporairement indisponible : le registre est plein. Réessayez plus tard.",
|
|
23
31
|
"devInstanceRemote": "Instance Clerk de développement sur un serveur distant : sa page de connexion hébergée ne redirige que vers localhost ou vers l'URL d'accueil (Home URL) de l'instance. Pour un test distant, réglez l'URL d'accueil (origine de développement) de l'instance sur l'origine de ce serveur, puis rétablissez-la ; sinon utilisez une instance Clerk de production (voir README, « Connexion avec ChatGPT et Claude »)."
|
|
32
|
+
},
|
|
33
|
+
"manifest": {
|
|
34
|
+
"unreadableField": "champ \"{field}\" illisible dans {source} (attendu : {expected})",
|
|
35
|
+
"expect": {
|
|
36
|
+
"string": "une chaîne non vide",
|
|
37
|
+
"semver": "une version sémantique (x.y.z)",
|
|
38
|
+
"object": "un objet",
|
|
39
|
+
"boolean": "true ou false",
|
|
40
|
+
"positiveInteger": "un entier strictement positif",
|
|
41
|
+
"atLeastPerTool": "au moins le plafond perTool"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"toolRecord": {
|
|
45
|
+
"unsound": "fiches d'outils ou plafond de contexte invalides : {problems}"
|
|
46
|
+
},
|
|
47
|
+
"pagination": {
|
|
48
|
+
"invalidCursor": "curseur invalide : il n'a pas été émis par cet outil ou n'est plus valide ; recommencez sans curseur"
|
|
49
|
+
},
|
|
50
|
+
"lake": {
|
|
51
|
+
"unreadable": "la source de données a renvoyé une réponse illisible pour ce serveur ; aucune ligne n'est affichée plutôt qu'une page partielle ou fausse"
|
|
52
|
+
},
|
|
53
|
+
"buildIdentity": {
|
|
54
|
+
"unreadable": "identité de build incalculable : {dir} est illisible ({reason})",
|
|
55
|
+
"empty": "identité de build incalculable : {dir} est vide (aucun fichier), rien n'a été construit"
|
|
56
|
+
},
|
|
57
|
+
"config": {
|
|
58
|
+
"refused": "configuration refusée : {problems}",
|
|
59
|
+
"missing": "{env} est manquante (attendu : {expected})",
|
|
60
|
+
"invalid": "{env} est définie mais invalide (attendu : {expected} ; valeur non affichée)",
|
|
61
|
+
"expect": {
|
|
62
|
+
"string": "une chaîne non vide",
|
|
63
|
+
"integer": "un entier de {min} à {max}",
|
|
64
|
+
"integerAtLeast": "un entier d'au moins {min}",
|
|
65
|
+
"boolean": "\"0\" ou \"1\"",
|
|
66
|
+
"url": "une URL https (http seulement sur la boucle locale)",
|
|
67
|
+
"originList": "une liste d'origines séparées par des virgules (schéma://hôte[:port], sans chemin)",
|
|
68
|
+
"enum": "une valeur parmi : {values}",
|
|
69
|
+
"json": "un objet ou un tableau JSON"
|
|
70
|
+
}
|
|
24
71
|
}
|
|
25
72
|
}
|
|
26
73
|
}
|
|
@@ -13,27 +13,29 @@
|
|
|
13
13
|
* handler override, no private-field access, no hand-written discover schema;
|
|
14
14
|
* - the advertised version is `SERVER_VERSION`, derived from package.json.
|
|
15
15
|
*
|
|
16
|
-
* Tool records (mcp-standard C-4, C-15): every tool declares `title`,
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
16
|
+
* Tool records (mcp-standard C-4, C-8..C-13, C-15): every tool declares `title`, a typed input
|
|
17
|
+
* schema AND a typed output schema (Zod 4), and the typed record of src/toolRecord.ts as its
|
|
18
|
+
* `annotations` (the four behavioural booleans, the (authRead, authWrite) pair, the verb, the
|
|
19
|
+
* table, the search mechanism, the pagination bounds and the context-cost estimate). It returns
|
|
20
|
+
* `structuredContent` plus a text block carrying the same JSON. tests/toolRecordComplete.test.ts
|
|
21
|
+
* and the C-8..C-13 suites walk `tools/list` and fail on any gap.
|
|
20
22
|
*
|
|
21
|
-
* `list_items` is the multi-tenant example (tenancy.ts):
|
|
22
|
-
*
|
|
23
|
-
* tenant
|
|
23
|
+
* `list_items` is the multi-tenant example (tenancy.ts): it reads through the injected lake
|
|
24
|
+
* reader (src/lake/reader.ts, `deps.lake`; the in-memory seed when none is injected). With a
|
|
25
|
+
* resolved tenant, the tenant travels INTO the read as its scope, and every row that comes back
|
|
26
|
+
* is re-checked by stored `orgId` (cloud-identity `isRowInTenant`): a row of another tenant fails
|
|
27
|
+
* the call closed (`lake_unreadable`, reason `out_of_scope`), it is never filtered out silently.
|
|
28
|
+
* On the public tier there is no tenant gate at all, by design (it mirrors stdio's local-trust
|
|
29
|
+
* model).
|
|
24
30
|
*/
|
|
25
31
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
32
|
+
import type { ExampleItem } from "./exampleItems.js";
|
|
26
33
|
import { type McpLocale } from "./i18n.js";
|
|
34
|
+
import { type LakeReader } from "./lake/reader.js";
|
|
27
35
|
import { type ResolvedTenant } from "./tenancy.js";
|
|
36
|
+
import type { CallerResolution } from "./verifiedPerson.js";
|
|
28
37
|
/** Oldest 2025-era protocol revision served; the legacy `initialize` handshake never offers older. */
|
|
29
38
|
export declare const SUPPORTED_LEGACY_PROTOCOL_VERSIONS: string[];
|
|
30
|
-
/** The four behavioural annotations, explicit on every tool (all three tools are pure reads). */
|
|
31
|
-
export declare const TOOL_ANNOTATIONS: {
|
|
32
|
-
readonly readOnlyHint: true;
|
|
33
|
-
readonly destructiveHint: false;
|
|
34
|
-
readonly idempotentHint: true;
|
|
35
|
-
readonly openWorldHint: false;
|
|
36
|
-
};
|
|
37
39
|
export interface McpServerDeps {
|
|
38
40
|
/**
|
|
39
41
|
* The caller's resolved tenant (tenancy.ts), when the OAuth tier verified a JWT carrying an
|
|
@@ -47,6 +49,15 @@ export interface McpServerDeps {
|
|
|
47
49
|
* `list_items` rather than silently falling through to an unscoped/master view.
|
|
48
50
|
*/
|
|
49
51
|
requireTenant?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* The caller's verified person, resolved once per request by @vantageos/cloud-identity
|
|
54
|
+
* (src/verifiedPerson.ts) beside the tenant: `{ ok: true, person }` (frozen, exactly the
|
|
55
|
+
* package's fields), or `{ ok: false, reason }`. A tool reads it by wrapping its handler with
|
|
56
|
+
* `withVerifiedPerson(deps, handler)`, which refuses before the handler runs unless `ok`.
|
|
57
|
+
* Undefined on stdio: there is no verified session there, so a requiring tool is refused as
|
|
58
|
+
* `no_verified_session`, never given a default person.
|
|
59
|
+
*/
|
|
60
|
+
caller?: CallerResolution;
|
|
50
61
|
/**
|
|
51
62
|
* The caller's locale, resolved from the `X-MCP-Locale` request header (see i18n.ts +
|
|
52
63
|
* httpApp.ts). Defaults to "en"; the stdio transport (no HTTP headers) uses the default.
|
|
@@ -57,5 +68,11 @@ export interface McpServerDeps {
|
|
|
57
68
|
* only when the deployment has one (MCP_UI_DOMAIN). Omitted from the resource when unset.
|
|
58
69
|
*/
|
|
59
70
|
uiDomain?: string;
|
|
71
|
+
/**
|
|
72
|
+
* The data-lake reader `list_items` reads through (src/lake/reader.ts). Read-only by design.
|
|
73
|
+
* Default: the in-memory seed (`EXAMPLE_ITEMS_READER`), so a server with no lake behaves as
|
|
74
|
+
* before. A derived server injects its own (e.g. `@vantageos/mcp-boilerplate/lake/convex`).
|
|
75
|
+
*/
|
|
76
|
+
lake?: LakeReader<ExampleItem>;
|
|
60
77
|
}
|
|
61
78
|
export declare function createBoilerplateMcpServer(deps?: McpServerDeps): McpServer;
|
package/dist/mcpServerFactory.js
CHANGED
|
@@ -13,32 +13,36 @@
|
|
|
13
13
|
* handler override, no private-field access, no hand-written discover schema;
|
|
14
14
|
* - the advertised version is `SERVER_VERSION`, derived from package.json.
|
|
15
15
|
*
|
|
16
|
-
* Tool records (mcp-standard C-4, C-15): every tool declares `title`,
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
16
|
+
* Tool records (mcp-standard C-4, C-8..C-13, C-15): every tool declares `title`, a typed input
|
|
17
|
+
* schema AND a typed output schema (Zod 4), and the typed record of src/toolRecord.ts as its
|
|
18
|
+
* `annotations` (the four behavioural booleans, the (authRead, authWrite) pair, the verb, the
|
|
19
|
+
* table, the search mechanism, the pagination bounds and the context-cost estimate). It returns
|
|
20
|
+
* `structuredContent` plus a text block carrying the same JSON. tests/toolRecordComplete.test.ts
|
|
21
|
+
* and the C-8..C-13 suites walk `tools/list` and fail on any gap.
|
|
20
22
|
*
|
|
21
|
-
* `list_items` is the multi-tenant example (tenancy.ts):
|
|
22
|
-
*
|
|
23
|
-
* tenant
|
|
23
|
+
* `list_items` is the multi-tenant example (tenancy.ts): it reads through the injected lake
|
|
24
|
+
* reader (src/lake/reader.ts, `deps.lake`; the in-memory seed when none is injected). With a
|
|
25
|
+
* resolved tenant, the tenant travels INTO the read as its scope, and every row that comes back
|
|
26
|
+
* is re-checked by stored `orgId` (cloud-identity `isRowInTenant`): a row of another tenant fails
|
|
27
|
+
* the call closed (`lake_unreadable`, reason `out_of_scope`), it is never filtered out silently.
|
|
28
|
+
* On the public tier there is no tenant gate at all, by design (it mirrors stdio's local-trust
|
|
29
|
+
* model).
|
|
24
30
|
*/
|
|
25
31
|
import { registerAppResource } from "@modelcontextprotocol/ext-apps/server";
|
|
26
32
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
27
33
|
import { z } from "zod";
|
|
28
|
-
import {
|
|
34
|
+
import { getBuildIdentity } from "./buildIdentity.js";
|
|
29
35
|
import { t } from "./i18n.js";
|
|
36
|
+
import { EXAMPLE_ITEMS_READER, LAKE_UNREADABLE_CODE, LakeReadError, } from "./lake/reader.js";
|
|
37
|
+
import { INVALID_CURSOR_CODE, InvalidCursorError, PAGINATION, readPage } from "./pagination.js";
|
|
38
|
+
import { trackToolRecords } from "./servedToolRecords.js";
|
|
39
|
+
import { loadServerManifest } from "./serverManifest.js";
|
|
30
40
|
import { scopeRowsForTenant } from "./tenancy.js";
|
|
31
|
-
import {
|
|
41
|
+
import { LIST_ITEMS_ANNOTATIONS, PING_ANNOTATIONS } from "./toolRecord.js";
|
|
42
|
+
import { ITEMS_TOOL_META, ITEMS_UI_RESOURCE_URI, buildItemsUiResourceMeta, buildListItemsResult, buildUiResource, } from "./ui/uiResource.js";
|
|
32
43
|
import { SERVER_NAME, SERVER_VERSION } from "./version.js";
|
|
33
44
|
/** Oldest 2025-era protocol revision served; the legacy `initialize` handshake never offers older. */
|
|
34
45
|
export const SUPPORTED_LEGACY_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18"];
|
|
35
|
-
/** The four behavioural annotations, explicit on every tool (all three tools are pure reads). */
|
|
36
|
-
export const TOOL_ANNOTATIONS = {
|
|
37
|
-
readOnlyHint: true,
|
|
38
|
-
destructiveHint: false,
|
|
39
|
-
idempotentHint: true,
|
|
40
|
-
openWorldHint: false,
|
|
41
|
-
};
|
|
42
46
|
const itemSchema = z.object({
|
|
43
47
|
id: z.string(),
|
|
44
48
|
name: z.string(),
|
|
@@ -51,14 +55,35 @@ const pingInput = z.object({
|
|
|
51
55
|
message: z.string().optional().describe("Optional message echoed back verbatim"),
|
|
52
56
|
});
|
|
53
57
|
const pingOutput = z.object({ reply: z.string() });
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
58
|
+
// Pagination (C-11): the bounds are declared in src/toolRecord.ts and applied in
|
|
59
|
+
// src/pagination.ts; the schema only describes them to the caller.
|
|
60
|
+
const pageInput = {
|
|
61
|
+
cursor: z
|
|
62
|
+
.string()
|
|
63
|
+
.optional()
|
|
64
|
+
.describe("Opaque cursor from a previous page's nextCursor; omit for the first page"),
|
|
65
|
+
limit: z
|
|
66
|
+
.number()
|
|
67
|
+
.int()
|
|
68
|
+
.min(1)
|
|
69
|
+
.optional()
|
|
70
|
+
.describe(`Page size; default ${PAGINATION.default}, capped at ${PAGINATION.max}`),
|
|
71
|
+
};
|
|
72
|
+
const pageOutput = {
|
|
73
|
+
nextCursor: z.string().optional().describe("Present only when the page was truncated"),
|
|
74
|
+
exhausted: z.boolean().describe("True on the last page: no more rows follow"),
|
|
75
|
+
};
|
|
76
|
+
const listItemsInput = z.object({
|
|
57
77
|
locale: localeSchema
|
|
58
78
|
.optional()
|
|
59
|
-
.describe("Widget language; defaults to the caller's X-MCP-Locale"),
|
|
79
|
+
.describe("Widget and fallback language; defaults to the caller's X-MCP-Locale"),
|
|
80
|
+
...pageInput,
|
|
81
|
+
});
|
|
82
|
+
const listItemsOutput = z.object({
|
|
83
|
+
items: z.array(itemSchema),
|
|
84
|
+
locale: localeSchema,
|
|
85
|
+
...pageOutput,
|
|
60
86
|
});
|
|
61
|
-
const renderOutput = z.object({ items: z.array(itemSchema), locale: localeSchema });
|
|
62
87
|
/** A text block carrying the same JSON as `structuredContent`. */
|
|
63
88
|
function structuredResult(payload) {
|
|
64
89
|
return {
|
|
@@ -71,8 +96,21 @@ function refusal(text) {
|
|
|
71
96
|
}
|
|
72
97
|
export function createBoilerplateMcpServer(deps = {}) {
|
|
73
98
|
const locale = deps.locale ?? "en";
|
|
74
|
-
|
|
75
|
-
|
|
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();
|
|
103
|
+
// The identity the server announces: version (declared) AND buildId (derived from the served
|
|
104
|
+
// tree, src/buildIdentity.ts). The SDK sends `serverInfo` as given, on `server/discover`
|
|
105
|
+
// (2026-07-28, `_meta["io.modelcontextprotocol/serverInfo"]`) and on `initialize` (2025-era).
|
|
106
|
+
// `buildId` is not a modelled `Implementation` key, hence the spread (no excess-key check).
|
|
107
|
+
const server = new McpServer({
|
|
108
|
+
name: SERVER_NAME,
|
|
109
|
+
version: SERVER_VERSION,
|
|
110
|
+
...{ buildId: getBuildIdentity() },
|
|
111
|
+
}, {
|
|
112
|
+
// The capabilities come from the server manifest (mcp-server.json, C-18): one source.
|
|
113
|
+
capabilities: manifest.capabilities,
|
|
76
114
|
instructions: "Clonable MCP server boilerplate — see README.md",
|
|
77
115
|
supportedProtocolVersions: SUPPORTED_LEGACY_PROTOCOL_VERSIONS,
|
|
78
116
|
cacheHints: {
|
|
@@ -82,12 +120,13 @@ export function createBoilerplateMcpServer(deps = {}) {
|
|
|
82
120
|
"server/discover": { ttlMs: 300_000, cacheScope: "public" },
|
|
83
121
|
},
|
|
84
122
|
});
|
|
123
|
+
const served = trackToolRecords(server);
|
|
85
124
|
server.registerTool("ping", {
|
|
86
125
|
title: "Ping",
|
|
87
126
|
description: "Echoes the given message (or 'pong' if none given). Proves the MCP handshake end to end.",
|
|
88
127
|
inputSchema: pingInput,
|
|
89
128
|
outputSchema: pingOutput,
|
|
90
|
-
annotations:
|
|
129
|
+
annotations: PING_ANNOTATIONS,
|
|
91
130
|
}, async (args) => {
|
|
92
131
|
// Application-level input validation beyond the Zod shape: an overly long echo message
|
|
93
132
|
// is refused as an MCP tool-call error, localized per the caller's X-MCP-Locale.
|
|
@@ -96,23 +135,75 @@ export function createBoilerplateMcpServer(deps = {}) {
|
|
|
96
135
|
}
|
|
97
136
|
return structuredResult({ reply: args.message ?? "pong" });
|
|
98
137
|
});
|
|
99
|
-
const
|
|
138
|
+
const lake = deps.lake ?? EXAMPLE_ITEMS_READER;
|
|
139
|
+
// An invalid or expired cursor is a TOOL INPUT error (spec 2025-11-25, SEP-1303: input
|
|
140
|
+
// validation errors are Tool Execution Errors, not Protocol Errors): a result with
|
|
141
|
+
// `isError: true` whose structuredContent carries a STABLE machine code beside a message
|
|
142
|
+
// localized per X-MCP-Locale. It is never a silent first page. (-32602 stays with the protocol
|
|
143
|
+
// list methods' cursor, which the SDK serves.)
|
|
144
|
+
const invalidCursor = () => {
|
|
145
|
+
const message = t(locale, "pagination.invalidCursor");
|
|
146
|
+
return {
|
|
147
|
+
content: [{ type: "text", text: message }],
|
|
148
|
+
structuredContent: { error: { code: INVALID_CURSOR_CODE, message } },
|
|
149
|
+
isError: true,
|
|
150
|
+
};
|
|
151
|
+
};
|
|
152
|
+
// The lake answered something this server cannot read (src/lake/reader.ts): a tool error with
|
|
153
|
+
// a STABLE code and the reason, a message localized per X-MCP-Locale, and no row. Never an empty
|
|
154
|
+
// or partial page passed off as the data.
|
|
155
|
+
const lakeUnreadable = (reason) => {
|
|
156
|
+
const message = t(locale, "lake.unreadable");
|
|
157
|
+
return {
|
|
158
|
+
content: [{ type: "text", text: message }],
|
|
159
|
+
structuredContent: { error: { code: LAKE_UNREADABLE_CODE, reason, message } },
|
|
160
|
+
isError: true,
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
// `list_items` is both the data tool and the UI tool (C-12: one operation on one table is one
|
|
164
|
+
// tool). It carries the `ui://` link (`_meta.ui.resourceUri`, plus the additive `openai/*`
|
|
165
|
+
// keys), returns `structuredContent`, and puts a markdown fallback first for hosts without
|
|
166
|
+
// MCP Apps (mcp-app-standard A-1). The widget renders THIS result.
|
|
100
167
|
server.registerTool("list_items", {
|
|
101
168
|
title: "List items",
|
|
102
|
-
description: "Lists example items
|
|
169
|
+
description: "Lists example items, one page at a time, and shows them as an interactive widget in " +
|
|
170
|
+
"hosts that support MCP Apps. Use this whenever the user asks to list, see or render " +
|
|
171
|
+
"the items. On the OAuth tier, results are scoped to the caller's organisation " +
|
|
103
172
|
"(@vantageos/cloud-identity); on the public tier (no auth gate), results are unscoped.",
|
|
104
173
|
inputSchema: listItemsInput,
|
|
105
174
|
outputSchema: listItemsOutput,
|
|
106
|
-
annotations:
|
|
107
|
-
|
|
175
|
+
annotations: LIST_ITEMS_ANNOTATIONS,
|
|
176
|
+
// `description_fr` (mcp-standard.md convention) lives alongside `ui` in `_meta`.
|
|
177
|
+
_meta: {
|
|
178
|
+
...ITEMS_TOOL_META,
|
|
179
|
+
description_fr: "Liste les éléments d'exemple, une page à la fois, et les affiche en widget interactif dans les hôtes MCP Apps. Utilise cet outil quand l'utilisateur demande de lister, voir ou afficher les éléments.",
|
|
180
|
+
},
|
|
181
|
+
}, async (args) => {
|
|
108
182
|
if (deps.requireTenant && !deps.tenant) {
|
|
109
183
|
// Refuse explicitly — never fall through to an unscoped/master view just because the
|
|
110
184
|
// caller's token had no organization.
|
|
111
185
|
return refusal(t(locale, "tenant.permissionDenied"));
|
|
112
186
|
}
|
|
113
|
-
|
|
187
|
+
try {
|
|
188
|
+
const tenantId = deps.tenant?.tenantId;
|
|
189
|
+
const scope = tenantId === undefined ? undefined : { tenantId };
|
|
190
|
+
const { items, ...page } = await readPage(lake, args, PAGINATION, scope);
|
|
191
|
+
// Defence in depth: the scope was applied inside the read; a row of another tenant that
|
|
192
|
+
// still comes back means the reader ignored it. Refuse the page, never filter it.
|
|
193
|
+
if (tenantId !== undefined && scopeRowsForTenant(tenantId, items).length !== items.length) {
|
|
194
|
+
throw new LakeReadError("out_of_scope", "a row of another tenant came back");
|
|
195
|
+
}
|
|
196
|
+
return buildListItemsResult(items, args.locale ?? locale, deps.uiDomain, page);
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
if (error instanceof InvalidCursorError)
|
|
200
|
+
return invalidCursor();
|
|
201
|
+
if (error instanceof LakeReadError)
|
|
202
|
+
return lakeUnreadable(error.code);
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
114
205
|
});
|
|
115
|
-
// MCP Apps UI layer (io.modelcontextprotocol/ui, spec 2026-01-26).
|
|
206
|
+
// MCP Apps UI layer (io.modelcontextprotocol/ui, spec 2026-01-26). `list_items` is registered
|
|
116
207
|
// with `server.registerTool` and the GA nested `_meta.ui` form, NOT with ext-apps'
|
|
117
208
|
// `registerAppTool`: that helper back-fills the deprecated flat `_meta["ui/resourceUri"]`
|
|
118
209
|
// key, which mcp-app-standard A-6 forbids writing (tests/mcpAppsMeta.test.ts asserts its
|
|
@@ -125,24 +216,9 @@ export function createBoilerplateMcpServer(deps = {}) {
|
|
|
125
216
|
mimeType: "text/html;profile=mcp-app",
|
|
126
217
|
_meta: { ui: resourceUiMeta },
|
|
127
218
|
}, async () => ({
|
|
128
|
-
contents: [buildUiResource({
|
|
219
|
+
contents: [buildUiResource({ domain: deps.uiDomain })],
|
|
129
220
|
}));
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
description: "Render the example items as an interactive widget. Use this whenever the user asks for a visual/rendered view of the items.",
|
|
133
|
-
inputSchema: renderInput,
|
|
134
|
-
outputSchema: renderOutput,
|
|
135
|
-
annotations: TOOL_ANNOTATIONS,
|
|
136
|
-
// `description_fr` (mcp-standard.md convention) lives alongside `ui` in `_meta`.
|
|
137
|
-
_meta: {
|
|
138
|
-
...ITEMS_TOOL_META,
|
|
139
|
-
description_fr: "Affiche les éléments d'exemple sous forme de widget interactif. Utilise cet outil quand l'utilisateur demande une vue visuelle des éléments.",
|
|
140
|
-
},
|
|
141
|
-
}, async (args) => {
|
|
142
|
-
if (deps.requireTenant && !deps.tenant) {
|
|
143
|
-
return refusal(t(locale, "tenant.permissionDenied"));
|
|
144
|
-
}
|
|
145
|
-
return buildRenderItemsUiResult(scopedItems(), args.locale ?? locale, deps.uiDomain);
|
|
146
|
-
});
|
|
221
|
+
// Start refusal: every registered tool's record, as registered, must be sound.
|
|
222
|
+
served.assertServed(manifest.contextCeiling);
|
|
147
223
|
return server;
|
|
148
224
|
}
|