@vantageos/mcp-boilerplate 0.2.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +182 -0
- package/README.fr.md +318 -15
- package/README.md +302 -15
- package/dist/buildIdentity.d.ts +19 -0
- package/dist/buildIdentity.js +62 -0
- package/dist/declaredVariables.d.ts +205 -0
- package/dist/declaredVariables.js +451 -0
- package/dist/exampleItems.d.ts +15 -0
- package/dist/exampleItems.js +28 -1
- package/dist/httpApp.d.ts +17 -0
- package/dist/httpApp.js +22 -2
- package/dist/i18n.d.ts +10 -0
- package/dist/i18n.js +12 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +9 -0
- package/dist/lake/convex.d.ts +101 -0
- package/dist/lake/convex.js +224 -0
- package/dist/lake/reader.d.ts +88 -0
- package/dist/lake/reader.js +120 -0
- package/dist/locales/en.json +47 -0
- package/dist/locales/fr.json +47 -0
- package/dist/mcpServerFactory.d.ts +31 -14
- package/dist/mcpServerFactory.js +124 -48
- package/dist/oauth/authorizationServer.js +63 -18
- package/dist/oauth/clerk.d.ts +2 -0
- package/dist/oauth/clerk.js +15 -5
- package/dist/oauth/clientRegistry.d.ts +19 -4
- package/dist/oauth/clientRegistry.js +20 -10
- package/dist/oauth/picker.d.ts +5 -2
- package/dist/oauth/picker.js +34 -4
- package/dist/oauth/registerLimiter.d.ts +34 -0
- package/dist/oauth/registerLimiter.js +95 -0
- package/dist/oauth/settings.d.ts +2 -0
- package/dist/oauth/settings.js +2 -0
- package/dist/pagination.d.ts +66 -0
- package/dist/pagination.js +100 -0
- package/dist/servedToolRecords.d.ts +18 -0
- package/dist/servedToolRecords.js +10 -0
- package/dist/serverManifest.d.ts +43 -0
- package/dist/serverManifest.js +90 -0
- package/dist/stdio.js +8 -1
- package/dist/tenancy.d.ts +1 -1
- package/dist/toolRecord.d.ts +124 -0
- package/dist/toolRecord.js +114 -0
- package/dist/ui/uiResource.d.ts +11 -4
- package/dist/ui/uiResource.js +8 -4
- package/dist/upstream/clientCredentials.d.ts +106 -0
- package/dist/upstream/clientCredentials.js +283 -0
- package/dist/upstream/paginatedClient.d.ts +142 -0
- package/dist/upstream/paginatedClient.js +422 -0
- package/dist/verifiedPerson.d.ts +85 -0
- package/dist/verifiedPerson.js +112 -0
- package/docs/oauth-stores.md +2 -1
- package/examples/oauth.env.example +5 -0
- package/mcp-server.json +14 -0
- package/package.json +19 -4
- package/plugin.json +1 -1
- package/skills/connect-mcp-server/SKILL.md +5 -5
|
@@ -0,0 +1,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 —
|
|
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
|
+
}
|
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 },
|