@vantageos/mcp-boilerplate 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +161 -0
  3. package/README.fr.md +323 -15
  4. package/README.md +306 -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 +19 -2
  13. package/dist/i18n.d.ts +10 -0
  14. package/dist/i18n.js +12 -0
  15. package/dist/index.d.ts +12 -0
  16. package/dist/index.js +6 -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 +42 -0
  22. package/dist/locales/fr.json +42 -0
  23. package/dist/mcpServerFactory.d.ts +31 -14
  24. package/dist/mcpServerFactory.js +116 -48
  25. package/dist/oauth/authorizationServer.js +64 -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 +7 -2
  31. package/dist/oauth/picker.js +90 -13
  32. package/dist/oauth/registerLimiter.d.ts +34 -0
  33. package/dist/oauth/registerLimiter.js +95 -0
  34. package/dist/oauth/settings.d.ts +4 -0
  35. package/dist/oauth/settings.js +4 -0
  36. package/dist/pagination.d.ts +66 -0
  37. package/dist/pagination.js +100 -0
  38. package/dist/serverManifest.d.ts +32 -0
  39. package/dist/serverManifest.js +68 -0
  40. package/dist/stdio.js +5 -1
  41. package/dist/tenancy.d.ts +1 -1
  42. package/dist/toolRecord.d.ts +94 -0
  43. package/dist/toolRecord.js +52 -0
  44. package/dist/ui/uiResource.d.ts +11 -4
  45. package/dist/ui/uiResource.js +8 -4
  46. package/dist/upstream/clientCredentials.d.ts +106 -0
  47. package/dist/upstream/clientCredentials.js +283 -0
  48. package/dist/upstream/paginatedClient.d.ts +142 -0
  49. package/dist/upstream/paginatedClient.js +422 -0
  50. package/dist/verifiedPerson.d.ts +85 -0
  51. package/dist/verifiedPerson.js +112 -0
  52. package/docs/oauth-stores.md +2 -1
  53. package/examples/oauth.env.example +7 -0
  54. package/mcp-server.json +13 -0
  55. package/package.json +19 -4
  56. package/plugin.json +1 -1
  57. package/skills/connect-mcp-server/SKILL.md +5 -5
@@ -0,0 +1,66 @@
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 { type LakeReader, type LakeScope } from "./lake/reader.js";
17
+ /**
18
+ * The applied bounds, taken from the tool's own declaration so the number is typed once
19
+ * (tests/paginationApplied.test.ts proves the served annotations agree).
20
+ */
21
+ export declare const PAGINATION: {
22
+ readonly default: 20;
23
+ readonly max: 100;
24
+ };
25
+ /** The stable machine code of an invalid or expired cursor, in `structuredContent.error.code`. */
26
+ export declare const INVALID_CURSOR_CODE = "invalid_cursor";
27
+ /** The cursor did not decode to a position in the collection. */
28
+ export declare class InvalidCursorError extends Error {
29
+ readonly code = "invalid_cursor";
30
+ constructor();
31
+ }
32
+ export declare function encodeCursor(afterKey: string): string;
33
+ export declare function decodeCursor(cursor: string): string;
34
+ export interface Page<T> {
35
+ items: T[];
36
+ /** Present exactly when the page was truncated. */
37
+ nextCursor?: string;
38
+ exhausted: boolean;
39
+ }
40
+ export interface PageRequest {
41
+ cursor?: string;
42
+ limit?: number;
43
+ }
44
+ /**
45
+ * One page of `sortedRows` (sorted ascending by `id`, the collection key). The read is a key
46
+ * range (src/exampleItems.ts `rangeAfter`: binary search on the key), so its cost follows the
47
+ * page, not the collection.
48
+ */
49
+ export declare function paginate<T extends {
50
+ id: string;
51
+ }>(sortedRows: readonly T[], request: PageRequest, bounds: {
52
+ default: number;
53
+ max: number;
54
+ }): Page<T>;
55
+ /**
56
+ * One page read through a lake reader (src/lake/reader.ts): the same bounds and cursor as
57
+ * `paginate`, the range read delegated to the reader. The cursor is decoded BEFORE the reader is
58
+ * asked anything (an invalid cursor never costs a read), the page size is the caller's `limit`,
59
+ * else the default, never above the maximum, and the reader's answer is checked against the page
60
+ * contract (`checkLakePage`): an unreadable page is a `LakeReadError`, never an empty page. With
61
+ * the in-memory reader, the result is exactly `paginate`'s (tests/lakeReader.test.ts).
62
+ */
63
+ export declare function readPage<Row>(reader: LakeReader<Row>, request: PageRequest, bounds: {
64
+ default: number;
65
+ max: number;
66
+ }, scope?: LakeScope): Promise<Page<Row>>;
@@ -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,32 @@
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
+ export interface ServerManifest {
12
+ name: string;
13
+ version: string;
14
+ capabilities: Record<string, unknown>;
15
+ transport: Record<string, unknown>;
16
+ /** Feature switches. Every value is a boolean; a lane adds its own key. */
17
+ features: Record<string, boolean>;
18
+ }
19
+ type Expectation = "string" | "semver" | "object" | "boolean";
20
+ /** Thrown when a manifest field cannot be read. `field` is the dotted path of the offender. */
21
+ export declare class ServerManifestError extends Error {
22
+ readonly field: string;
23
+ constructor(field: string, source: string, expected: Expectation);
24
+ }
25
+ /** Validates an already-parsed manifest document. `source` names it in the refusal. */
26
+ export declare function parseServerManifest(document: unknown, source: string): ServerManifest;
27
+ /**
28
+ * Reads `mcp-server.json` from the package root. `src/` and `dist/` both sit one level under
29
+ * the root, so the same relative URL resolves in source mode (vitest) and in the built output.
30
+ */
31
+ export declare function loadServerManifest(path?: string): ServerManifest;
32
+ export {};
@@ -0,0 +1,68 @@
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
+ /** Validates an already-parsed manifest document. `source` names it in the refusal. */
32
+ export function parseServerManifest(document, source) {
33
+ if (!isRecord(document))
34
+ throw new ServerManifestError("(document)", source, "object");
35
+ const { name, version, capabilities, transport, features } = document;
36
+ if (typeof name !== "string" || name.length === 0) {
37
+ throw new ServerManifestError("name", source, "string");
38
+ }
39
+ if (typeof version !== "string" || !SEMVER.test(version)) {
40
+ throw new ServerManifestError("version", source, "semver");
41
+ }
42
+ if (!isRecord(capabilities))
43
+ throw new ServerManifestError("capabilities", source, "object");
44
+ if (!isRecord(transport))
45
+ throw new ServerManifestError("transport", source, "object");
46
+ if (!isRecord(features))
47
+ throw new ServerManifestError("features", source, "object");
48
+ for (const [key, value] of Object.entries(features)) {
49
+ if (typeof value !== "boolean") {
50
+ throw new ServerManifestError(`features.${key}`, source, "boolean");
51
+ }
52
+ }
53
+ return { name, version, capabilities, transport, features: features };
54
+ }
55
+ /**
56
+ * Reads `mcp-server.json` from the package root. `src/` and `dist/` both sit one level under
57
+ * the root, so the same relative URL resolves in source mode (vitest) and in the built output.
58
+ */
59
+ export function loadServerManifest(path = fileURLToPath(new URL("../mcp-server.json", import.meta.url))) {
60
+ let document;
61
+ try {
62
+ document = JSON.parse(readFileSync(path, "utf8"));
63
+ }
64
+ catch {
65
+ throw new ServerManifestError("(document)", path, "object");
66
+ }
67
+ return parseServerManifest(document, path);
68
+ }
package/dist/stdio.js CHANGED
@@ -15,8 +15,12 @@ export function startStdioServer() {
15
15
  // tenant-refusal branch through the real stdio transport. Unset by default.
16
16
  const requireTenant = process.env.MCP_BOILERPLATE_REQUIRE_TENANT === "1";
17
17
  const uiDomain = process.env.MCP_UI_DOMAIN || undefined;
18
+ // No verified person on stdio, by design: there is no bearer and no verified session on this
19
+ // transport, so `caller` is deliberately left unset and a tool wrapped with
20
+ // `withVerifiedPerson` is refused as `no_verified_session` (src/verifiedPerson.ts). The local
21
+ // process is trusted as the caller, never impersonated as a person.
18
22
  serveStdio(() => createBoilerplateMcpServer({ requireTenant, uiDomain }));
19
- process.stderr.write("[mcp-boilerplate] stdio server started — 3 tools available\n");
23
+ process.stderr.write("[mcp-boilerplate] stdio server started — 2 tools available\n");
20
24
  }
21
25
  try {
22
26
  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,94 @@
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
+ */
22
+ /** mcp-standard.md §3 point 1 — authority tiers, closed set. */
23
+ export type AuthTier = "public" | "org-member" | "org-admin" | "fleet-internal" | "master";
24
+ /** mcp-standard.md §3 point 2 — operation verbs, closed set. `rpc` is deliberately absent. */
25
+ export type ToolVerb = "list" | "get" | "search" | "aggregate" | "create" | "update" | "delete" | "upsert" | "transition" | "bulk-write" | "pure" | "introspect" | "external-effect";
26
+ /**
27
+ * mcp-standard.md §3 point 3 — search mechanisms, closed set. `unindexed-scan` is deliberately
28
+ * absent: it is refused at publication, so the type refuses it at compile time.
29
+ */
30
+ export type SearchMechanism = "indexed-range" | "indexed-bm25" | `indexed-vector(${string})` | "indexed-hybrid-rrf";
31
+ /** The four behavioural annotations (C-15), explicit booleans on every tool. */
32
+ interface BehaviourHints {
33
+ readOnlyHint: boolean;
34
+ destructiveHint: boolean;
35
+ idempotentHint: boolean;
36
+ openWorldHint: boolean;
37
+ }
38
+ interface CommonSlots extends BehaviourHints {
39
+ authRead: AuthTier;
40
+ authWrite: AuthTier;
41
+ /** Estimated context tokens of the served tool (C-13); tests/contextCeiling.test.ts checks it. */
42
+ tokensEstimeContexte: number;
43
+ }
44
+ /** A tool that touches no table. */
45
+ export interface PureToolSlots extends CommonSlots {
46
+ verb: "pure" | "introspect";
47
+ }
48
+ /** A tool that lists or searches: mechanism and pagination are required, not optional. */
49
+ export interface ListToolSlots extends CommonSlots {
50
+ verb: "list" | "search";
51
+ table: string;
52
+ searchMechanism: SearchMechanism;
53
+ paginationCursor: true;
54
+ /** Page size when the caller gives none. */
55
+ paginationDefault: number;
56
+ /** Finite maximum page size; the tool caps a larger request at this. */
57
+ paginationMax: number;
58
+ }
59
+ /** Any other data tool (get, create, update ...): the verb and the table are required. */
60
+ export interface DataToolSlots extends CommonSlots {
61
+ verb: Exclude<ToolVerb, "list" | "search" | "pure" | "introspect">;
62
+ table: string;
63
+ }
64
+ export type ToolSlots = PureToolSlots | ListToolSlots | DataToolSlots;
65
+ export declare const PING_ANNOTATIONS: {
66
+ readonly readOnlyHint: true;
67
+ readonly destructiveHint: false;
68
+ readonly idempotentHint: true;
69
+ readonly openWorldHint: false;
70
+ readonly authRead: "public";
71
+ readonly authWrite: "master";
72
+ readonly verb: "pure";
73
+ readonly tokensEstimeContexte: 200;
74
+ };
75
+ export declare const LIST_ITEMS_ANNOTATIONS: {
76
+ readonly readOnlyHint: true;
77
+ readonly destructiveHint: false;
78
+ readonly idempotentHint: true;
79
+ readonly openWorldHint: false;
80
+ readonly authRead: "org-member";
81
+ readonly authWrite: "master";
82
+ readonly verb: "list";
83
+ readonly table: "items";
84
+ readonly searchMechanism: "indexed-range";
85
+ readonly paginationCursor: true;
86
+ readonly paginationDefault: 20;
87
+ readonly paginationMax: 100;
88
+ readonly tokensEstimeContexte: 640;
89
+ };
90
+ export declare const CONTEXT_CEILING: {
91
+ readonly global: 1200;
92
+ readonly perTool: 700;
93
+ };
94
+ export {};
@@ -0,0 +1,52 @@
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
+ */
22
+ // Authority decisions (C-8). The seed tools read; none writes anything, so the write side
23
+ // is `master`, the strictest tier: no caller gains a write through them. `ping` touches no data
24
+ // and is `public`; the item tool is scoped to the caller's organisation on the OAuth tier.
25
+ export const PING_ANNOTATIONS = {
26
+ readOnlyHint: true,
27
+ destructiveHint: false,
28
+ idempotentHint: true,
29
+ openWorldHint: false,
30
+ authRead: "public",
31
+ authWrite: "master",
32
+ verb: "pure",
33
+ tokensEstimeContexte: 200,
34
+ };
35
+ export const LIST_ITEMS_ANNOTATIONS = {
36
+ readOnlyHint: true,
37
+ destructiveHint: false,
38
+ idempotentHint: true,
39
+ openWorldHint: false,
40
+ authRead: "org-member",
41
+ authWrite: "master",
42
+ verb: "list",
43
+ table: "items",
44
+ searchMechanism: "indexed-range",
45
+ paginationCursor: true,
46
+ paginationDefault: 20,
47
+ paginationMax: 100,
48
+ tokensEstimeContexte: 640,
49
+ };
50
+ // Context ceiling (C-13), global AND per tool, in estimated tokens. The sum of the per-tool
51
+ // estimates above must not pass `global`; no single estimate may pass `perTool`.
52
+ export const CONTEXT_CEILING = { global: 1200, perTool: 700 };
@@ -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 },
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Outbound OAuth 2 `client_credentials` token source (RFC 6749 §4.4), for a server derived from
3
+ * this boilerplate that reads a third-party upstream API. It is the client side only: the inbound
4
+ * authorization server in `src/oauth/` is unrelated and untouched.
5
+ *
6
+ * CREATE IT ONCE, AT MODULE SCOPE. The MCP server is rebuilt per request, so a token source built
7
+ * inside the factory or a tool handler would fetch a fresh token on every request and defeat the
8
+ * cache. Build it once per process and reuse it from every request:
9
+ *
10
+ * ```ts
11
+ * // src/upstream.ts — evaluated once per process
12
+ * import { clientCredentialsFromEnv, createClientCredentialsTokenSource } from "./upstream/clientCredentials.js";
13
+ *
14
+ * export const upstream = createClientCredentialsTokenSource(
15
+ * clientCredentialsFromEnv({
16
+ * clientIdVar: "UPSTREAM_CLIENT_ID",
17
+ * clientSecretVar: "UPSTREAM_CLIENT_SECRET",
18
+ * tokenUrlVar: "UPSTREAM_TOKEN_URL",
19
+ * scopeVar: "UPSTREAM_SCOPE",
20
+ * })
21
+ * );
22
+ *
23
+ * // in a tool handler, on any request
24
+ * const res = await upstream.fetchWithToken("https://api.upstream.example/v1/things");
25
+ * ```
26
+ *
27
+ * The variable names above are examples: the caller chooses them, nothing here hardcodes one.
28
+ *
29
+ * Behaviour:
30
+ * - The token is cached until `expires_in − refreshSkewSeconds` (default skew 60 s), measured from
31
+ * the moment the token was REQUESTED, on the injectable `now()` clock.
32
+ * - Concurrent callers while a token is being fetched share ONE in-flight request (single-flight).
33
+ * A failed fetch rejects every waiter and is never cached: the next call fetches again.
34
+ * - `fetchWithToken` attaches `Authorization: Bearer`; on a 401 it invalidates the token, fetches a
35
+ * new one ONCE and retries ONCE. A second 401 is returned to the caller, never looped.
36
+ * - Every failure of the token endpoint is an `UpstreamTokenError` naming the HTTP status and the
37
+ * OAuth `error` code. It never contains the client secret, the Basic header, an access token,
38
+ * or the endpoint's `error_description` (free text an endpoint may fill with the request).
39
+ * - A response it cannot read is refused (fail closed); there is no fallback.
40
+ *
41
+ * Errors are operator-facing (configuration and upstream failures, logged server-side), so they
42
+ * stay in English like `src/oauth/` and `src/envKnobs.ts`; they never reach the MCP client as a
43
+ * localised tool message.
44
+ */
45
+ export type ClientAuthMethod = "client_secret_basic" | "client_secret_post";
46
+ export interface ClientCredentialsOptions {
47
+ /** The upstream token endpoint. https, or http on loopback only (local development). */
48
+ tokenUrl: string;
49
+ clientId: string;
50
+ clientSecret: string;
51
+ /** Space-separated scopes, sent as `scope` when set. */
52
+ scope?: string;
53
+ /** Sent as `audience` when set (an extension several providers require). */
54
+ audience?: string;
55
+ /** RFC 6749 §2.3.1. Default `client_secret_basic`. */
56
+ authMethod?: ClientAuthMethod;
57
+ /** Seconds before expiry at which the token is refreshed. Default 60. */
58
+ refreshSkewSeconds?: number;
59
+ /** Time budget of one token request. Default 10 s. */
60
+ timeoutMs?: number;
61
+ /** Default: the global fetch. Also used by `fetchWithToken` for the upstream API. */
62
+ fetchImpl?: typeof fetch;
63
+ /** Milliseconds since the epoch. Default `Date.now`. */
64
+ now?: () => number;
65
+ }
66
+ export interface ClientCredentialsTokenSource {
67
+ /** A valid access token, from the cache or from one (shared) token request. */
68
+ getToken(): Promise<string>;
69
+ /**
70
+ * Drops the cached token. With an argument, drops it only if it is still that token, so a stale
71
+ * 401 never discards a token another request has just refreshed.
72
+ */
73
+ invalidate(token?: string): void;
74
+ /** `fetch` with `Authorization: Bearer <token>`; one refetch + one retry on a 401. */
75
+ fetchWithToken(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
76
+ }
77
+ /** A failure of the upstream token endpoint. Carries no credential and no token. */
78
+ export declare class UpstreamTokenError extends Error {
79
+ /** HTTP status of the token endpoint, when it answered. */
80
+ readonly status: number | undefined;
81
+ /** The OAuth `error` code (RFC 6749 §5.2), when the endpoint sent a readable one. */
82
+ readonly oauthError: string | undefined;
83
+ constructor(message: string, details?: {
84
+ status?: number;
85
+ oauthError?: string;
86
+ });
87
+ }
88
+ export declare function createClientCredentialsTokenSource(options: ClientCredentialsOptions): ClientCredentialsTokenSource;
89
+ /** Which environment variables hold the configuration. The caller names them. */
90
+ export interface ClientCredentialsEnvVars {
91
+ clientIdVar: string;
92
+ clientSecretVar: string;
93
+ tokenUrlVar: string;
94
+ /** Optional: when the variable is unset or empty, no scope is sent. */
95
+ scopeVar?: string;
96
+ /** Optional: when the variable is unset or empty, no audience is sent. */
97
+ audienceVar?: string;
98
+ }
99
+ export type ClientCredentialsEnvOptions = Pick<ClientCredentialsOptions, "tokenUrl" | "clientId" | "clientSecret" | "scope" | "audience">;
100
+ /**
101
+ * Reads the token source's configuration from the variables the caller names. Unset and empty
102
+ * are both missing. Every missing required variable is named in one error; no value is ever
103
+ * shown. Pass the result (plus any `fetchImpl`, `authMethod`, ...) to
104
+ * `createClientCredentialsTokenSource`.
105
+ */
106
+ export declare function clientCredentialsFromEnv(vars: ClientCredentialsEnvVars, env?: NodeJS.ProcessEnv): ClientCredentialsEnvOptions;