@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.
Files changed (59) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +182 -0
  3. package/README.fr.md +318 -15
  4. package/README.md +302 -15
  5. package/dist/buildIdentity.d.ts +19 -0
  6. package/dist/buildIdentity.js +62 -0
  7. package/dist/declaredVariables.d.ts +205 -0
  8. package/dist/declaredVariables.js +451 -0
  9. package/dist/exampleItems.d.ts +15 -0
  10. package/dist/exampleItems.js +28 -1
  11. package/dist/httpApp.d.ts +17 -0
  12. package/dist/httpApp.js +22 -2
  13. package/dist/i18n.d.ts +10 -0
  14. package/dist/i18n.js +12 -0
  15. package/dist/index.d.ts +18 -0
  16. package/dist/index.js +9 -0
  17. package/dist/lake/convex.d.ts +101 -0
  18. package/dist/lake/convex.js +224 -0
  19. package/dist/lake/reader.d.ts +88 -0
  20. package/dist/lake/reader.js +120 -0
  21. package/dist/locales/en.json +47 -0
  22. package/dist/locales/fr.json +47 -0
  23. package/dist/mcpServerFactory.d.ts +31 -14
  24. package/dist/mcpServerFactory.js +124 -48
  25. package/dist/oauth/authorizationServer.js +63 -18
  26. package/dist/oauth/clerk.d.ts +2 -0
  27. package/dist/oauth/clerk.js +15 -5
  28. package/dist/oauth/clientRegistry.d.ts +19 -4
  29. package/dist/oauth/clientRegistry.js +20 -10
  30. package/dist/oauth/picker.d.ts +5 -2
  31. package/dist/oauth/picker.js +34 -4
  32. package/dist/oauth/registerLimiter.d.ts +34 -0
  33. package/dist/oauth/registerLimiter.js +95 -0
  34. package/dist/oauth/settings.d.ts +2 -0
  35. package/dist/oauth/settings.js +2 -0
  36. package/dist/pagination.d.ts +66 -0
  37. package/dist/pagination.js +100 -0
  38. package/dist/servedToolRecords.d.ts +18 -0
  39. package/dist/servedToolRecords.js +10 -0
  40. package/dist/serverManifest.d.ts +43 -0
  41. package/dist/serverManifest.js +90 -0
  42. package/dist/stdio.js +8 -1
  43. package/dist/tenancy.d.ts +1 -1
  44. package/dist/toolRecord.d.ts +124 -0
  45. package/dist/toolRecord.js +114 -0
  46. package/dist/ui/uiResource.d.ts +11 -4
  47. package/dist/ui/uiResource.js +8 -4
  48. package/dist/upstream/clientCredentials.d.ts +106 -0
  49. package/dist/upstream/clientCredentials.js +283 -0
  50. package/dist/upstream/paginatedClient.d.ts +142 -0
  51. package/dist/upstream/paginatedClient.js +422 -0
  52. package/dist/verifiedPerson.d.ts +85 -0
  53. package/dist/verifiedPerson.js +112 -0
  54. package/docs/oauth-stores.md +2 -1
  55. package/examples/oauth.env.example +5 -0
  56. package/mcp-server.json +14 -0
  57. package/package.json +19 -4
  58. package/plugin.json +1 -1
  59. package/skills/connect-mcp-server/SKILL.md +5 -5
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Cursor pagination applied by the tool (mcp-standard C-11, S-12, §3 point 4). A list tool
3
+ * declares a cursor, a default and a finite maximum (src/toolRecord.ts) and THIS module applies
4
+ * them, so the bound never depends on the store behind:
5
+ *
6
+ * - the page size is the caller's `limit`, else the default, never above the maximum;
7
+ * - the cursor is OPAQUE (base64url of a small JSON object) and carries the last key served,
8
+ * so it is stable: rows added or removed elsewhere do not shift the next page;
9
+ * - exhausted and truncated are told apart: a truncated page carries `nextCursor` and
10
+ * `exhausted: false`; the last page carries `exhausted: true` and no `nextCursor` (an exact
11
+ * fit is exhausted, never a false truncation);
12
+ * - an invalid or expired cursor is refused with the stable code `invalid_cursor`, never read as
13
+ * "page one". It is a TOOL INPUT error (a result with `isError: true`, spec 2025-11-25
14
+ * SEP-1303), not a JSON-RPC error: -32602 stays with the protocol list methods' cursor.
15
+ */
16
+ import { rangeAfter } from "./exampleItems.js";
17
+ import { checkLakePage } from "./lake/reader.js";
18
+ import { LIST_ITEMS_ANNOTATIONS } from "./toolRecord.js";
19
+ /**
20
+ * The applied bounds, taken from the tool's own declaration so the number is typed once
21
+ * (tests/paginationApplied.test.ts proves the served annotations agree).
22
+ */
23
+ export const PAGINATION = {
24
+ default: LIST_ITEMS_ANNOTATIONS.paginationDefault,
25
+ max: LIST_ITEMS_ANNOTATIONS.paginationMax,
26
+ };
27
+ const CURSOR_VERSION = 1;
28
+ /** The stable machine code of an invalid or expired cursor, in `structuredContent.error.code`. */
29
+ export const INVALID_CURSOR_CODE = "invalid_cursor";
30
+ /** The cursor did not decode to a position in the collection. */
31
+ export class InvalidCursorError extends Error {
32
+ code = INVALID_CURSOR_CODE;
33
+ constructor() {
34
+ super("invalid cursor");
35
+ this.name = "InvalidCursorError";
36
+ }
37
+ }
38
+ export function encodeCursor(afterKey) {
39
+ return Buffer.from(JSON.stringify({ v: CURSOR_VERSION, after: afterKey })).toString("base64url");
40
+ }
41
+ export function decodeCursor(cursor) {
42
+ let decoded;
43
+ try {
44
+ decoded = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
45
+ }
46
+ catch {
47
+ throw new InvalidCursorError();
48
+ }
49
+ if (typeof decoded !== "object" ||
50
+ decoded === null ||
51
+ decoded.v !== CURSOR_VERSION ||
52
+ typeof decoded.after !== "string" ||
53
+ decoded.after.length === 0) {
54
+ throw new InvalidCursorError();
55
+ }
56
+ return decoded.after;
57
+ }
58
+ /**
59
+ * One page of `sortedRows` (sorted ascending by `id`, the collection key). The read is a key
60
+ * range (src/exampleItems.ts `rangeAfter`: binary search on the key), so its cost follows the
61
+ * page, not the collection.
62
+ */
63
+ export function paginate(sortedRows, request, bounds) {
64
+ const afterKey = request.cursor === undefined ? undefined : decodeCursor(request.cursor);
65
+ const wanted = request.limit ?? bounds.default;
66
+ const size = Math.max(1, Math.min(Math.floor(wanted), bounds.max));
67
+ // One row more than the page: its existence is what separates truncated from exhausted.
68
+ const window = rangeAfter(sortedRows, afterKey, size + 1);
69
+ const items = window.slice(0, size);
70
+ const last = items[items.length - 1];
71
+ if (window.length > size && last) {
72
+ return { items, nextCursor: encodeCursor(last.id), exhausted: false };
73
+ }
74
+ return { items, exhausted: true };
75
+ }
76
+ /**
77
+ * One page read through a lake reader (src/lake/reader.ts): the same bounds and cursor as
78
+ * `paginate`, the range read delegated to the reader. The cursor is decoded BEFORE the reader is
79
+ * asked anything (an invalid cursor never costs a read), the page size is the caller's `limit`,
80
+ * else the default, never above the maximum, and the reader's answer is checked against the page
81
+ * 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).
83
+ */
84
+ export async function readPage(reader, request, bounds, scope) {
85
+ const after = request.cursor === undefined ? undefined : decodeCursor(request.cursor);
86
+ const wanted = request.limit ?? bounds.default;
87
+ const limit = Math.max(1, Math.min(Math.floor(wanted), bounds.max));
88
+ const lakeRequest = {
89
+ ...(after === undefined ? {} : { after }),
90
+ limit,
91
+ ...(scope === undefined ? {} : { scope }),
92
+ };
93
+ const page = checkLakePage(reader, lakeRequest, await reader.listPage(lakeRequest));
94
+ const items = [...page.rows];
95
+ const last = items[items.length - 1];
96
+ if (!page.exhausted && last !== undefined) {
97
+ return { items, nextCursor: encodeCursor(reader.keyOf(last)), exhausted: false };
98
+ }
99
+ return { items, exhausted: true };
100
+ }
@@ -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
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The server manifest loader (mcp-standard C-18). `mcp-server.json`, at the package root, is the
3
+ * ONE file that carries the server's capabilities, transport, version and feature switches
4
+ * (`mcp.json` is the OpenAI plugin config in this repository, so the manifest takes the other
5
+ * name). The factory reads its capabilities from here; later lanes read their switches here.
6
+ *
7
+ * Fail-closed: a field that cannot be read is refused BY NAME, in French and English in one
8
+ * message. Nothing is defaulted — a manifest that silently falls back hides the very drift
9
+ * C-18 exists to catch.
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
+ }
20
+ export interface ServerManifest {
21
+ name: string;
22
+ version: string;
23
+ capabilities: Record<string, unknown>;
24
+ transport: Record<string, unknown>;
25
+ /** Feature switches. Every value is a boolean; a lane adds its own key. */
26
+ features: Record<string, boolean>;
27
+ /** The per-server context ceiling. A number cannot ride in `features` (booleans only). */
28
+ contextCeiling: ContextCeiling;
29
+ }
30
+ type Expectation = "string" | "semver" | "object" | "boolean" | "positiveInteger" | "atLeastPerTool";
31
+ /** Thrown when a manifest field cannot be read. `field` is the dotted path of the offender. */
32
+ export declare class ServerManifestError extends Error {
33
+ readonly field: string;
34
+ constructor(field: string, source: string, expected: Expectation);
35
+ }
36
+ /** Validates an already-parsed manifest document. `source` names it in the refusal. */
37
+ export declare function parseServerManifest(document: unknown, source: string): ServerManifest;
38
+ /**
39
+ * Reads `mcp-server.json` from the package root. `src/` and `dist/` both sit one level under
40
+ * the root, so the same relative URL resolves in source mode (vitest) and in the built output.
41
+ */
42
+ export declare function loadServerManifest(path?: string): ServerManifest;
43
+ export {};
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The server manifest loader (mcp-standard C-18). `mcp-server.json`, at the package root, is the
3
+ * ONE file that carries the server's capabilities, transport, version and feature switches
4
+ * (`mcp.json` is the OpenAI plugin config in this repository, so the manifest takes the other
5
+ * name). The factory reads its capabilities from here; later lanes read their switches here.
6
+ *
7
+ * Fail-closed: a field that cannot be read is refused BY NAME, in French and English in one
8
+ * message. Nothing is defaulted — a manifest that silently falls back hides the very drift
9
+ * C-18 exists to catch.
10
+ */
11
+ import { readFileSync } from "node:fs";
12
+ import { fileURLToPath } from "node:url";
13
+ import { tBoth } from "./i18n.js";
14
+ /** Thrown when a manifest field cannot be read. `field` is the dotted path of the offender. */
15
+ export class ServerManifestError extends Error {
16
+ field;
17
+ constructor(field, source, expected) {
18
+ super(tBoth("manifest.unreadableField", {
19
+ field,
20
+ source,
21
+ expected: { key: `manifest.expect.${expected}` },
22
+ }));
23
+ this.name = "ServerManifestError";
24
+ this.field = field;
25
+ }
26
+ }
27
+ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
28
+ function isRecord(value) {
29
+ return typeof value === "object" && value !== null && !Array.isArray(value);
30
+ }
31
+ function isPositiveInteger(value) {
32
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
33
+ }
34
+ /** Validates an already-parsed manifest document. `source` names it in the refusal. */
35
+ export function parseServerManifest(document, source) {
36
+ if (!isRecord(document))
37
+ throw new ServerManifestError("(document)", source, "object");
38
+ const { name, version, capabilities, transport, features, contextCeiling } = document;
39
+ if (typeof name !== "string" || name.length === 0) {
40
+ throw new ServerManifestError("name", source, "string");
41
+ }
42
+ if (typeof version !== "string" || !SEMVER.test(version)) {
43
+ throw new ServerManifestError("version", source, "semver");
44
+ }
45
+ if (!isRecord(capabilities))
46
+ throw new ServerManifestError("capabilities", source, "object");
47
+ if (!isRecord(transport))
48
+ throw new ServerManifestError("transport", source, "object");
49
+ if (!isRecord(features))
50
+ throw new ServerManifestError("features", source, "object");
51
+ for (const [key, value] of Object.entries(features)) {
52
+ if (typeof value !== "boolean") {
53
+ throw new ServerManifestError(`features.${key}`, source, "boolean");
54
+ }
55
+ }
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
+ };
76
+ }
77
+ /**
78
+ * Reads `mcp-server.json` from the package root. `src/` and `dist/` both sit one level under
79
+ * the root, so the same relative URL resolves in source mode (vitest) and in the built output.
80
+ */
81
+ export function loadServerManifest(path = fileURLToPath(new URL("../mcp-server.json", import.meta.url))) {
82
+ let document;
83
+ try {
84
+ document = JSON.parse(readFileSync(path, "utf8"));
85
+ }
86
+ catch {
87
+ throw new ServerManifestError("(document)", path, "object");
88
+ }
89
+ return parseServerManifest(document, path);
90
+ }
package/dist/stdio.js CHANGED
@@ -8,15 +8,22 @@
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;
21
+ // No verified person on stdio, by design: there is no bearer and no verified session on this
22
+ // transport, so `caller` is deliberately left unset and a tool wrapped with
23
+ // `withVerifiedPerson` is refused as `no_verified_session` (src/verifiedPerson.ts). The local
24
+ // process is trusted as the caller, never impersonated as a person.
18
25
  serveStdio(() => createBoilerplateMcpServer({ requireTenant, uiDomain }));
19
- process.stderr.write("[mcp-boilerplate] stdio server started — 3 tools available\n");
26
+ process.stderr.write("[mcp-boilerplate] stdio server started — 2 tools available\n");
20
27
  }
21
28
  try {
22
29
  startStdioServer();
package/dist/tenancy.d.ts CHANGED
@@ -41,4 +41,4 @@ export interface TenantOwned {
41
41
  * selected rows by a creator NAME (backend standard R-53 clause 1 forbids that); identifiers
42
42
  * are compared byte for byte, a slug is never compared against an id.
43
43
  */
44
- export declare function scopeRowsForTenant<T extends TenantOwned>(tenantId: string, rows: T[]): T[];
44
+ export declare function scopeRowsForTenant<T extends TenantOwned>(tenantId: string, rows: readonly T[]): T[];
@@ -0,0 +1,124 @@
1
+ import type { ContextCeiling } from "./serverManifest.js";
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
4
+ * a caller reads from `tools/list` WITHOUT reading the source, declared once per tool as a
5
+ * literal object and served as its `annotations`.
6
+ *
7
+ * authRead, authWrite C-8 the (read, write) pair, from the closed five-tier set, distinct
8
+ * verb C-9 the operation, from the closed verb set (`rpc` is not a verb)
9
+ * table C-12 the data it touches, beside the verb
10
+ * searchMechanism C-10 how a list/search is served; `unindexed-scan` does not publish
11
+ * paginationCursor/Default/Max C-11 declared here, applied by src/pagination.ts
12
+ * tokensEstimeContexte C-13 the estimated context cost of the served tool
13
+ *
14
+ * The sets are closed UNIONS, so a value outside them is a compile error, and the verb decides
15
+ * which other slots are required (a list/search tool cannot omit its mechanism or its pagination).
16
+ * Each tool's record is a plain `const` object of string/number/boolean literals on purpose:
17
+ * mcp-doctor reads the registration site statically and resolves `annotations: NAME` to the
18
+ * literals below; a computed or spread value would read as "could not judge".
19
+ *
20
+ * SDK 2.3.1 serves unknown annotation keys on the wire (tests/toolAuthorityDeclared.test.ts
21
+ * reads them there); its `Client` drops them, which is why those tests read the raw result.
22
+ */
23
+ /** mcp-standard.md §3 point 1 — authority tiers, closed set. */
24
+ export type AuthTier = "public" | "org-member" | "org-admin" | "fleet-internal" | "master";
25
+ /** mcp-standard.md §3 point 2 — operation verbs, closed set. `rpc` is deliberately absent. */
26
+ export type ToolVerb = "list" | "get" | "search" | "aggregate" | "create" | "update" | "delete" | "upsert" | "transition" | "bulk-write" | "pure" | "introspect" | "external-effect";
27
+ /**
28
+ * mcp-standard.md §3 point 3 — search mechanisms, closed set. `unindexed-scan` is deliberately
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`).
35
+ */
36
+ export type SearchMechanism = "indexed-range" | "indexed-bm25" | `indexed-vector(${string})` | "indexed-hybrid-rrf" | `upstream-delegated(${string})`;
37
+ /** The four behavioural annotations (C-15), explicit booleans on every tool. */
38
+ interface BehaviourHints {
39
+ readOnlyHint: boolean;
40
+ destructiveHint: boolean;
41
+ idempotentHint: boolean;
42
+ openWorldHint: boolean;
43
+ }
44
+ interface CommonSlots extends BehaviourHints {
45
+ authRead: AuthTier;
46
+ authWrite: AuthTier;
47
+ /** Estimated context tokens of the served tool (C-13); tests/contextCeiling.test.ts checks it. */
48
+ tokensEstimeContexte: number;
49
+ }
50
+ /** A tool that touches no table. */
51
+ export interface PureToolSlots extends CommonSlots {
52
+ verb: "pure" | "introspect";
53
+ }
54
+ /** A tool that lists or searches: mechanism and pagination are required, not optional. */
55
+ export interface ListToolSlots extends CommonSlots {
56
+ verb: "list" | "search";
57
+ table: string;
58
+ searchMechanism: SearchMechanism;
59
+ paginationCursor: true;
60
+ /** Page size when the caller gives none. */
61
+ paginationDefault: number;
62
+ /** Finite maximum page size; the tool caps a larger request at this. */
63
+ paginationMax: number;
64
+ }
65
+ /** Any other data tool (get, create, update ...): the verb and the table are required. */
66
+ export interface DataToolSlots extends CommonSlots {
67
+ verb: Exclude<ToolVerb, "list" | "search" | "pure" | "introspect">;
68
+ table: string;
69
+ }
70
+ export type ToolSlots = PureToolSlots | ListToolSlots | DataToolSlots;
71
+ export declare const PING_ANNOTATIONS: {
72
+ readonly readOnlyHint: true;
73
+ readonly destructiveHint: false;
74
+ readonly idempotentHint: true;
75
+ readonly openWorldHint: false;
76
+ readonly authRead: "public";
77
+ readonly authWrite: "master";
78
+ readonly verb: "pure";
79
+ readonly tokensEstimeContexte: 200;
80
+ };
81
+ export declare const LIST_ITEMS_ANNOTATIONS: {
82
+ readonly readOnlyHint: true;
83
+ readonly destructiveHint: false;
84
+ readonly idempotentHint: true;
85
+ readonly openWorldHint: false;
86
+ readonly authRead: "org-member";
87
+ readonly authWrite: "master";
88
+ readonly verb: "list";
89
+ readonly table: "items";
90
+ readonly searchMechanism: "indexed-range";
91
+ readonly paginationCursor: true;
92
+ readonly paginationDefault: 20;
93
+ readonly paginationMax: 100;
94
+ readonly tokensEstimeContexte: 640;
95
+ };
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;
124
+ export {};
@@ -0,0 +1,114 @@
1
+ import { tBoth } from "./i18n.js";
2
+ // Authority decisions (C-8). The seed tools read; none writes anything, so the write side
3
+ // is `master`, the strictest tier: no caller gains a write through them. `ping` touches no data
4
+ // and is `public`; the item tool is scoped to the caller's organisation on the OAuth tier.
5
+ export const PING_ANNOTATIONS = {
6
+ readOnlyHint: true,
7
+ destructiveHint: false,
8
+ idempotentHint: true,
9
+ openWorldHint: false,
10
+ authRead: "public",
11
+ authWrite: "master",
12
+ verb: "pure",
13
+ tokensEstimeContexte: 200,
14
+ };
15
+ export const LIST_ITEMS_ANNOTATIONS = {
16
+ readOnlyHint: true,
17
+ destructiveHint: false,
18
+ idempotentHint: true,
19
+ openWorldHint: false,
20
+ authRead: "org-member",
21
+ authWrite: "master",
22
+ verb: "list",
23
+ table: "items",
24
+ searchMechanism: "indexed-range",
25
+ paginationCursor: true,
26
+ paginationDefault: 20,
27
+ paginationMax: 100,
28
+ tokensEstimeContexte: 640,
29
+ };
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
+ }
@@ -17,9 +17,11 @@ export declare function readUiBundle(): string;
17
17
  * (the spec says consult each host), so it is set only when the deployment supplies one.
18
18
  */
19
19
  export declare function buildItemsUiResourceMeta(domain?: string): McpUiResourceMeta;
20
+ /**
21
+ * The widget template. It carries no row: the widget renders the `list_items` RESULT it is given,
22
+ * so the template is the same for every caller and every page.
23
+ */
20
24
  export declare function buildUiResource(props: {
21
- items: ExampleItem[];
22
- locale: "en" | "fr";
23
25
  domain?: string;
24
26
  }): {
25
27
  uri: string;
@@ -42,7 +44,7 @@ export declare const ITEMS_TOOL_META: {
42
44
  readonly "openai/toolInvocation/invoking": "Rendering items…";
43
45
  readonly "openai/toolInvocation/invoked": "Items ready.";
44
46
  };
45
- export interface RenderItemsUiResult {
47
+ export interface ListItemsResult {
46
48
  [key: string]: unknown;
47
49
  content: Array<{
48
50
  type: "text";
@@ -54,6 +56,8 @@ export interface RenderItemsUiResult {
54
56
  structuredContent: {
55
57
  items: ExampleItem[];
56
58
  locale: "en" | "fr";
59
+ nextCursor?: string;
60
+ exhausted?: boolean;
57
61
  };
58
62
  _meta: {
59
63
  ui: McpUiToolMeta;
@@ -64,4 +68,7 @@ export interface RenderItemsUiResult {
64
68
  * A-1: it stays first, as before); content[1] mirrors `structuredContent` as JSON text (clients
65
69
  * that only read text still get the data); content[2] is the embedded ui:// resource.
66
70
  */
67
- export declare function buildRenderItemsUiResult(items: ExampleItem[], locale: "en" | "fr", domain?: string): RenderItemsUiResult;
71
+ export declare function buildListItemsResult(items: ExampleItem[], locale: "en" | "fr", domain?: string, page?: {
72
+ nextCursor?: string;
73
+ exhausted: boolean;
74
+ }): ListItemsResult;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * MCP Apps UI resource for the `render_items_ui` tool — GA `_meta.ui`
2
+ * MCP Apps UI resource for the `list_items` tool — GA `_meta.ui`
3
3
  * nested form (`io.modelcontextprotocol/ui`, stable 2026-01-26), built via
4
4
  * the official `@modelcontextprotocol/ext-apps/server` helpers.
5
5
  *
@@ -65,6 +65,10 @@ export function buildItemsUiResourceMeta(domain) {
65
65
  prefersBorder: true,
66
66
  };
67
67
  }
68
+ /**
69
+ * The widget template. It carries no row: the widget renders the `list_items` RESULT it is given,
70
+ * so the template is the same for every caller and every page.
71
+ */
68
72
  export function buildUiResource(props) {
69
73
  return {
70
74
  uri: ITEMS_UI_RESOURCE_URI,
@@ -94,13 +98,13 @@ export const ITEMS_TOOL_META = {
94
98
  * A-1: it stays first, as before); content[1] mirrors `structuredContent` as JSON text (clients
95
99
  * that only read text still get the data); content[2] is the embedded ui:// resource.
96
100
  */
97
- export function buildRenderItemsUiResult(items, locale, domain) {
98
- const structuredContent = { items, locale };
101
+ export function buildListItemsResult(items, locale, domain, page = { exhausted: true }) {
102
+ const structuredContent = { items, locale, ...page };
99
103
  return {
100
104
  content: [
101
105
  { type: "text", text: renderMarkdownFallback(items, locale) },
102
106
  { type: "text", text: JSON.stringify(structuredContent) },
103
- { type: "resource", resource: buildUiResource({ items, locale, domain }) },
107
+ { type: "resource", resource: buildUiResource({ domain }) },
104
108
  ],
105
109
  structuredContent,
106
110
  _meta: { ui: ITEMS_UI_META },