@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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +161 -0
- package/README.fr.md +323 -15
- package/README.md +306 -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 +19 -2
- package/dist/i18n.d.ts +10 -0
- package/dist/i18n.js +12 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +6 -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 +42 -0
- package/dist/locales/fr.json +42 -0
- package/dist/mcpServerFactory.d.ts +31 -14
- package/dist/mcpServerFactory.js +116 -48
- package/dist/oauth/authorizationServer.js +64 -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 +7 -2
- package/dist/oauth/picker.js +90 -13
- package/dist/oauth/registerLimiter.d.ts +34 -0
- package/dist/oauth/registerLimiter.js +95 -0
- package/dist/oauth/settings.d.ts +4 -0
- package/dist/oauth/settings.js +4 -0
- package/dist/pagination.d.ts +66 -0
- package/dist/pagination.js +100 -0
- package/dist/serverManifest.d.ts +32 -0
- package/dist/serverManifest.js +68 -0
- package/dist/stdio.js +5 -1
- package/dist/tenancy.d.ts +1 -1
- package/dist/toolRecord.d.ts +94 -0
- package/dist/toolRecord.js +52 -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 +7 -0
- package/mcp-server.json +13 -0
- package/package.json +19 -4
- package/plugin.json +1 -1
- 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 —
|
|
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 };
|
package/dist/ui/uiResource.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
71
|
+
export declare function buildListItemsResult(items: ExampleItem[], locale: "en" | "fr", domain?: string, page?: {
|
|
72
|
+
nextCursor?: string;
|
|
73
|
+
exhausted: boolean;
|
|
74
|
+
}): ListItemsResult;
|
package/dist/ui/uiResource.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* MCP Apps UI resource for the `
|
|
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
|
|
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({
|
|
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;
|