@vantageos/mcp-boilerplate 0.6.2 → 0.7.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 +36 -0
- package/README.fr.md +105 -6
- package/README.md +101 -8
- package/dist/hostVerification.d.ts +15 -0
- package/dist/hostVerification.js +32 -0
- package/dist/http.js +4 -1
- package/dist/httpApp.d.ts +9 -0
- package/dist/httpApp.js +55 -2
- package/dist/listCursor.d.ts +10 -0
- package/dist/listCursor.js +37 -0
- package/dist/locales/en.json +15 -1
- package/dist/locales/fr.json +15 -1
- package/dist/mcpServerFactory.d.ts +11 -2
- package/dist/mcpServerFactory.js +97 -15
- package/dist/oauth/authorizationServer.js +77 -16
- package/dist/oauth/picker.d.ts +11 -0
- package/dist/oauth/picker.js +28 -0
- package/dist/oauth/settings.d.ts +7 -0
- package/dist/oauth/settings.js +7 -0
- package/dist/oauth/tokenIssuer.d.ts +0 -1
- package/dist/oauth/tokenIssuer.js +1 -18
- package/dist/pagination.d.ts +2 -1
- package/dist/pagination.js +2 -1
- package/dist/rateLimit.d.ts +33 -0
- package/dist/rateLimit.js +48 -0
- package/dist/serverManifest.d.ts +9 -1
- package/dist/serverManifest.js +10 -1
- package/dist/signedCursor.js +6 -1
- package/dist/stdio.js +2 -2
- package/dist/tenancy.d.ts +4 -14
- package/dist/tenancy.js +7 -18
- package/dist/toolRecord.d.ts +35 -2
- package/dist/toolRecord.js +44 -2
- package/dist/toolRoleGate.d.ts +41 -0
- package/dist/toolRoleGate.js +94 -0
- package/dist/toolRoles.d.ts +57 -0
- package/dist/toolRoles.js +113 -0
- package/dist/ui/sandboxDomain.d.ts +13 -0
- package/dist/ui/sandboxDomain.js +23 -0
- package/dist/upstream/paginatedClient.js +1 -0
- package/dist/verifiedPerson.d.ts +6 -0
- package/dist/verifiedPerson.js +9 -0
- package/docs/releasing.md +19 -0
- package/mcp-server.json +3 -2
- package/package.json +10 -3
- package/plugin.json +1 -1
- package/scripts/plugin-manifest.source.json +19 -0
- package/scripts/readme-fr.mjs +3 -1
- package/dist/oauth/clientAssertion.d.ts +0 -28
- package/dist/oauth/clientAssertion.js +0 -106
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* deployment's key. The resource server (@vantageos/cloud-identity `verifyMcpAccessToken`)
|
|
5
5
|
* verifies the result against the public half published at /.well-known/jwks.json.
|
|
6
6
|
*/
|
|
7
|
-
import { SignJWT,
|
|
7
|
+
import { SignJWT, importJWK } from "jose";
|
|
8
8
|
export const ACCESS_TOKEN_TTL_SECONDS = 3600;
|
|
9
9
|
export async function createTokenIssuer(privateJwk) {
|
|
10
10
|
const key = await importJWK(privateJwk, "RS256");
|
|
@@ -18,7 +18,6 @@ export async function createTokenIssuer(privateJwk) {
|
|
|
18
18
|
use: "sig",
|
|
19
19
|
};
|
|
20
20
|
const jwks = { keys: [publicJwk] };
|
|
21
|
-
const verifyKey = createLocalJWKSet(jwks);
|
|
22
21
|
return {
|
|
23
22
|
kid: privateJwk.kid,
|
|
24
23
|
publicJwk,
|
|
@@ -41,21 +40,5 @@ export async function createTokenIssuer(privateJwk) {
|
|
|
41
40
|
.setJti(crypto.randomUUID())
|
|
42
41
|
.sign(key);
|
|
43
42
|
},
|
|
44
|
-
async verify(token, issuer, audience) {
|
|
45
|
-
const { payload } = await jwtVerify(token, verifyKey, {
|
|
46
|
-
issuer,
|
|
47
|
-
audience,
|
|
48
|
-
algorithms: ["RS256"],
|
|
49
|
-
});
|
|
50
|
-
return {
|
|
51
|
-
sub: String(payload.sub),
|
|
52
|
-
org_id: String(payload.org_id),
|
|
53
|
-
org_slug: payload.org_slug ?? null,
|
|
54
|
-
org_role: String(payload.org_role),
|
|
55
|
-
aud: audience,
|
|
56
|
-
client_id: String(payload.client_id),
|
|
57
|
-
scope: String(payload.scope),
|
|
58
|
-
};
|
|
59
|
-
},
|
|
60
43
|
};
|
|
61
44
|
}
|
package/dist/pagination.d.ts
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
* fit is exhausted, never a false truncation);
|
|
15
15
|
* - an invalid or expired cursor is refused with the stable code `invalid_cursor`, never read as
|
|
16
16
|
* "page one". It is a TOOL INPUT error (a result with `isError: true`, spec 2025-11-25
|
|
17
|
-
* SEP-1303), not a JSON-RPC error: -32602 stays with the protocol list methods' cursor
|
|
17
|
+
* SEP-1303), not a JSON-RPC error: -32602 stays with the protocol list methods' cursor
|
|
18
|
+
* (src/listCursor.ts).
|
|
18
19
|
*/
|
|
19
20
|
import { type LakeReader, type LakeScope } from "./lake/reader.js";
|
|
20
21
|
import { type CursorSigner } from "./signedCursor.js";
|
package/dist/pagination.js
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
* fit is exhausted, never a false truncation);
|
|
15
15
|
* - an invalid or expired cursor is refused with the stable code `invalid_cursor`, never read as
|
|
16
16
|
* "page one". It is a TOOL INPUT error (a result with `isError: true`, spec 2025-11-25
|
|
17
|
-
* SEP-1303), not a JSON-RPC error: -32602 stays with the protocol list methods' cursor
|
|
17
|
+
* SEP-1303), not a JSON-RPC error: -32602 stays with the protocol list methods' cursor
|
|
18
|
+
* (src/listCursor.ts).
|
|
18
19
|
*/
|
|
19
20
|
import { rangeAfter } from "./exampleItems.js";
|
|
20
21
|
import { checkLakePage } from "./lake/reader.js";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool invocation rate limiting (mcp-server-standard S-24: every published tool applies rate limiting to its
|
|
3
|
+
* invocation, a MUST at the rank of input validation). A token bucket per caller: `capacity` calls may burst, then
|
|
4
|
+
* the bucket refills at `perMinute`. The buckets live for the process, not for one request: the HTTP tier builds a
|
|
5
|
+
* server per request, so a per-server bucket would never fill.
|
|
6
|
+
*
|
|
7
|
+
* Configuration (environment, read once at start): TOOL_RATE_LIMIT_PER_MINUTE (default 60) and TOOL_RATE_LIMIT_BURST
|
|
8
|
+
* (default 30). A value that is not a positive integer refuses the start, naming the variable.
|
|
9
|
+
*/
|
|
10
|
+
export type RateDecision = {
|
|
11
|
+
ok: true;
|
|
12
|
+
} | {
|
|
13
|
+
ok: false;
|
|
14
|
+
retryAfterMs: number;
|
|
15
|
+
};
|
|
16
|
+
export interface RateLimiter {
|
|
17
|
+
/** Takes one token from `key`'s bucket, or says how long until one is available. */
|
|
18
|
+
consume(key: string): RateDecision;
|
|
19
|
+
}
|
|
20
|
+
export declare function createTokenBucketLimiter(opts: {
|
|
21
|
+
capacity: number;
|
|
22
|
+
perMinute: number;
|
|
23
|
+
now?: () => number;
|
|
24
|
+
}): RateLimiter;
|
|
25
|
+
/** The limits read from the environment. */
|
|
26
|
+
export declare function loadToolRateLimit(env?: NodeJS.ProcessEnv): {
|
|
27
|
+
capacity: number;
|
|
28
|
+
perMinute: number;
|
|
29
|
+
};
|
|
30
|
+
/** The process-wide limiter every tool invocation goes through, unless a test injects its own. */
|
|
31
|
+
export declare const toolRateLimiter: RateLimiter;
|
|
32
|
+
/** The stable machine code of a refused invocation (beside a message localized per X-MCP-Locale). */
|
|
33
|
+
export declare const RATE_LIMITED_CODE = "rate_limited";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool invocation rate limiting (mcp-server-standard S-24: every published tool applies rate limiting to its
|
|
3
|
+
* invocation, a MUST at the rank of input validation). A token bucket per caller: `capacity` calls may burst, then
|
|
4
|
+
* the bucket refills at `perMinute`. The buckets live for the process, not for one request: the HTTP tier builds a
|
|
5
|
+
* server per request, so a per-server bucket would never fill.
|
|
6
|
+
*
|
|
7
|
+
* Configuration (environment, read once at start): TOOL_RATE_LIMIT_PER_MINUTE (default 60) and TOOL_RATE_LIMIT_BURST
|
|
8
|
+
* (default 30). A value that is not a positive integer refuses the start, naming the variable.
|
|
9
|
+
*/
|
|
10
|
+
export function createTokenBucketLimiter(opts) {
|
|
11
|
+
const now = opts.now ?? Date.now;
|
|
12
|
+
const refillPerMs = opts.perMinute / 60_000;
|
|
13
|
+
const buckets = new Map();
|
|
14
|
+
return {
|
|
15
|
+
consume(key) {
|
|
16
|
+
const t = now();
|
|
17
|
+
const b = buckets.get(key) ?? { tokens: opts.capacity, at: t };
|
|
18
|
+
b.tokens = Math.min(opts.capacity, b.tokens + (t - b.at) * refillPerMs);
|
|
19
|
+
b.at = t;
|
|
20
|
+
buckets.set(key, b);
|
|
21
|
+
if (b.tokens >= 1) {
|
|
22
|
+
b.tokens -= 1;
|
|
23
|
+
return { ok: true };
|
|
24
|
+
}
|
|
25
|
+
return { ok: false, retryAfterMs: Math.ceil((1 - b.tokens) / refillPerMs) };
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function positiveInt(env, name, fallback) {
|
|
30
|
+
const raw = env[name];
|
|
31
|
+
if (raw === undefined || raw === "")
|
|
32
|
+
return fallback;
|
|
33
|
+
const n = Number(raw);
|
|
34
|
+
if (!Number.isInteger(n) || n <= 0)
|
|
35
|
+
throw new Error(`${name} must be a positive integer (got a value that is not)`);
|
|
36
|
+
return n;
|
|
37
|
+
}
|
|
38
|
+
/** The limits read from the environment. */
|
|
39
|
+
export function loadToolRateLimit(env = process.env) {
|
|
40
|
+
return {
|
|
41
|
+
capacity: positiveInt(env, "TOOL_RATE_LIMIT_BURST", 30),
|
|
42
|
+
perMinute: positiveInt(env, "TOOL_RATE_LIMIT_PER_MINUTE", 60),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** The process-wide limiter every tool invocation goes through, unless a test injects its own. */
|
|
46
|
+
export const toolRateLimiter = createTokenBucketLimiter(loadToolRateLimit());
|
|
47
|
+
/** The stable machine code of a refused invocation (beside a message localized per X-MCP-Locale). */
|
|
48
|
+
export const RATE_LIMITED_CODE = "rate_limited";
|
package/dist/serverManifest.d.ts
CHANGED
|
@@ -26,8 +26,16 @@ export interface ServerManifest {
|
|
|
26
26
|
features: Record<string, boolean>;
|
|
27
27
|
/** The per-server context ceiling. A number cannot ride in `features` (booleans only). */
|
|
28
28
|
contextCeiling: ContextCeiling;
|
|
29
|
+
/**
|
|
30
|
+
* The hosts the server is distributed on (mcp-server-standard S-45 / S-46), read from `distribution.targets`.
|
|
31
|
+
* Absent in the manifest: no target, `[]`. The ids are not validated here: the closed set is the standard's,
|
|
32
|
+
* checked by mcp-doctor; a fork adds a host by data, never by code.
|
|
33
|
+
*/
|
|
34
|
+
distribution: {
|
|
35
|
+
targets: readonly string[];
|
|
36
|
+
};
|
|
29
37
|
}
|
|
30
|
-
type Expectation = "string" | "semver" | "object" | "boolean" | "positiveInteger" | "atLeastPerTool";
|
|
38
|
+
type Expectation = "string" | "semver" | "object" | "boolean" | "positiveInteger" | "atLeastPerTool" | "stringArray";
|
|
31
39
|
/** Thrown when a manifest field cannot be read. `field` is the dotted path of the offender. */
|
|
32
40
|
export declare class ServerManifestError extends Error {
|
|
33
41
|
readonly field: string;
|
package/dist/serverManifest.js
CHANGED
|
@@ -35,7 +35,7 @@ function isPositiveInteger(value) {
|
|
|
35
35
|
export function parseServerManifest(document, source) {
|
|
36
36
|
if (!isRecord(document))
|
|
37
37
|
throw new ServerManifestError("(document)", source, "object");
|
|
38
|
-
const { name, version, capabilities, transport, features, contextCeiling } = document;
|
|
38
|
+
const { name, version, capabilities, transport, features, contextCeiling, distribution } = document;
|
|
39
39
|
if (typeof name !== "string" || name.length === 0) {
|
|
40
40
|
throw new ServerManifestError("name", source, "string");
|
|
41
41
|
}
|
|
@@ -65,6 +65,14 @@ export function parseServerManifest(document, source) {
|
|
|
65
65
|
if (global < perTool) {
|
|
66
66
|
throw new ServerManifestError("contextCeiling.global", source, "atLeastPerTool");
|
|
67
67
|
}
|
|
68
|
+
let targets = [];
|
|
69
|
+
if (distribution !== undefined) {
|
|
70
|
+
const raw = isRecord(distribution) ? distribution.targets : undefined;
|
|
71
|
+
if (!Array.isArray(raw) || raw.some((t) => typeof t !== "string" || t.length === 0)) {
|
|
72
|
+
throw new ServerManifestError("distribution.targets", source, "stringArray");
|
|
73
|
+
}
|
|
74
|
+
targets = raw;
|
|
75
|
+
}
|
|
68
76
|
return {
|
|
69
77
|
name,
|
|
70
78
|
version,
|
|
@@ -72,6 +80,7 @@ export function parseServerManifest(document, source) {
|
|
|
72
80
|
transport,
|
|
73
81
|
features: features,
|
|
74
82
|
contextCeiling: { global, perTool },
|
|
83
|
+
distribution: { targets },
|
|
75
84
|
};
|
|
76
85
|
}
|
|
77
86
|
/**
|
package/dist/signedCursor.js
CHANGED
|
@@ -56,7 +56,11 @@ export function createCursorSigner(options = {}) {
|
|
|
56
56
|
}
|
|
57
57
|
// A copy: the caller zeroing or reusing its buffer must not change what this signer accepts.
|
|
58
58
|
const key = supplied === undefined ? randomBytes(CURSOR_KEY_BYTES) : Buffer.from(supplied);
|
|
59
|
-
const tagOf = (payloadPart) =>
|
|
59
|
+
const tagOf = (payloadPart) =>
|
|
60
|
+
// allow-local-identity: HMAC proving a pagination cursor was issued by this server; it identifies no caller or tenant
|
|
61
|
+
createHmac("sha256", key)
|
|
62
|
+
.update(payloadPart)
|
|
63
|
+
.digest();
|
|
60
64
|
const sign = (payload) => {
|
|
61
65
|
// A cursor always says something; an empty one would encode to an empty part, which `verify`
|
|
62
66
|
// refuses. Refused here, loudly, rather than issued and then rejected.
|
|
@@ -89,6 +93,7 @@ export function createCursorSigner(options = {}) {
|
|
|
89
93
|
// the tag matches. Equal lengths (32 bytes each, checked above): timingSafeEqual cannot throw.
|
|
90
94
|
if (!BASE64URL.test(payloadPart))
|
|
91
95
|
throw new InvalidCursorError("not_base64url");
|
|
96
|
+
// allow-local-identity: constant-time check of the cursor's own HMAC tag; no credential a caller presented
|
|
92
97
|
if (!timingSafeEqual(tagOf(payloadPart), tag))
|
|
93
98
|
throw new InvalidCursorError("tag_mismatch");
|
|
94
99
|
// Signed by us, so canonical; re-checked so a decode is never lenient.
|
package/dist/stdio.js
CHANGED
|
@@ -18,12 +18,12 @@ export function startStdioServer() {
|
|
|
18
18
|
// Refuses at start, naming the field, a manifest without a readable context ceiling (G6).
|
|
19
19
|
loadServerManifest();
|
|
20
20
|
const requireTenant = process.env.MCP_BOILERPLATE_REQUIRE_TENANT === "1";
|
|
21
|
-
const uiDomain = process.env.MCP_UI_DOMAIN || undefined;
|
|
22
21
|
// No verified person on stdio, by design: there is no bearer and no verified session on this
|
|
23
22
|
// transport, so `caller` is deliberately left unset and a tool wrapped with
|
|
24
23
|
// `withVerifiedPerson` is refused as `no_verified_session` (src/verifiedPerson.ts). The local
|
|
25
24
|
// process is trusted as the caller, never impersonated as a person.
|
|
26
|
-
|
|
25
|
+
// No served URL on stdio, so no Claude sandbox domain to derive (A-11): the widget declares none.
|
|
26
|
+
serveStdio(() => createBoilerplateMcpServer({ requireTenant }));
|
|
27
27
|
process.stderr.write("[mcp-boilerplate] stdio server started — 2 tools available\n");
|
|
28
28
|
}
|
|
29
29
|
try {
|
package/dist/tenancy.d.ts
CHANGED
|
@@ -5,20 +5,10 @@
|
|
|
5
5
|
* rows. This module never re-verifies the token and never reimplements
|
|
6
6
|
* cloud-identity's authorization logic — it only adapts shapes.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* (`decodeUnverifiedBearer`), not a generic OAuth JWT. This boilerplate's
|
|
13
|
-
* verified claims (a plain claims record, Clerk-as-AS) carries no
|
|
14
|
-
* `workspaceId`/`roles` claims, so it is NOT a `TenantContext` and cannot
|
|
15
|
-
* go through the "bearer" path without inventing fields cloud-identity
|
|
16
|
-
* never asked for. It goes through the "session" path instead
|
|
17
|
-
* (`identity: { orgId }`), which matches the JWT's `org_id` claim exactly
|
|
18
|
-
* as Clerk mints it. Both entry paths converge on the same
|
|
19
|
-
* refuse-on-absence contract (`requireTenantId` throws when there is no
|
|
20
|
-
* organization, on either path) — this is a compatible substitution
|
|
21
|
-
* documented at the one call site it affects, never a bypass of the guard.
|
|
8
|
+
* The organisation is read by cloud-identity's one claim reader (`requireTenantFromIdentity`: the same
|
|
9
|
+
* claim names and the same Clerk `org_` shape as `resolveTenantFromAccessToken`), never by this module.
|
|
10
|
+
* No claim, or a claim that is not an organisation id, refuses (throws); there is no fallback to a slug, a
|
|
11
|
+
* name or a default tenant.
|
|
22
12
|
*/
|
|
23
13
|
export interface ResolvedTenant {
|
|
24
14
|
tenantId: string;
|
package/dist/tenancy.js
CHANGED
|
@@ -5,22 +5,12 @@
|
|
|
5
5
|
* rows. This module never re-verifies the token and never reimplements
|
|
6
6
|
* cloud-identity's authorization logic — it only adapts shapes.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* (`decodeUnverifiedBearer`), not a generic OAuth JWT. This boilerplate's
|
|
13
|
-
* verified claims (a plain claims record, Clerk-as-AS) carries no
|
|
14
|
-
* `workspaceId`/`roles` claims, so it is NOT a `TenantContext` and cannot
|
|
15
|
-
* go through the "bearer" path without inventing fields cloud-identity
|
|
16
|
-
* never asked for. It goes through the "session" path instead
|
|
17
|
-
* (`identity: { orgId }`), which matches the JWT's `org_id` claim exactly
|
|
18
|
-
* as Clerk mints it. Both entry paths converge on the same
|
|
19
|
-
* refuse-on-absence contract (`requireTenantId` throws when there is no
|
|
20
|
-
* organization, on either path) — this is a compatible substitution
|
|
21
|
-
* documented at the one call site it affects, never a bypass of the guard.
|
|
8
|
+
* The organisation is read by cloud-identity's one claim reader (`requireTenantFromIdentity`: the same
|
|
9
|
+
* claim names and the same Clerk `org_` shape as `resolveTenantFromAccessToken`), never by this module.
|
|
10
|
+
* No claim, or a claim that is not an organisation id, refuses (throws); there is no fallback to a slug, a
|
|
11
|
+
* name or a default tenant.
|
|
22
12
|
*/
|
|
23
|
-
import { isRowInTenant,
|
|
13
|
+
import { isRowInTenant, requireTenantFromIdentity } from "@vantageos/cloud-identity";
|
|
24
14
|
/**
|
|
25
15
|
* Resolve the caller's tenant from the verified session claims. Refuses
|
|
26
16
|
* (throws) when the token carries no organization — never falls through to
|
|
@@ -28,9 +18,8 @@ import { isRowInTenant, requireTenantId } from "@vantageos/cloud-identity";
|
|
|
28
18
|
* (an explicit permission-denied response), never silently grant access.
|
|
29
19
|
*/
|
|
30
20
|
export function resolveTenantFromJwtPayload(payload) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
return { tenantId };
|
|
21
|
+
// The organisation claim is read by the identity layer's one claim reader, never here (DLocalIdentity).
|
|
22
|
+
return { tenantId: requireTenantFromIdentity(payload, { door: "mcp" }) };
|
|
34
23
|
}
|
|
35
24
|
/**
|
|
36
25
|
* Filter rows by the caller's tenant with cloud-identity's `isRowInTenant`: a row is visible
|
package/dist/toolRecord.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ import type { ContextCeiling } from "./serverManifest.js";
|
|
|
10
10
|
* searchMechanism C-10 how a list/search is served; `unindexed-scan` does not publish
|
|
11
11
|
* paginationCursor/Default/Max C-11 declared here, applied by src/pagination.ts
|
|
12
12
|
* tokensEstimeContexte C-13 the estimated context cost of the served tool
|
|
13
|
+
* domain, mode Agent tool roles rule 2: the business domain the tool belongs to, and `read` | `write`;
|
|
14
|
+
* what `decideToolRole` (@vantageos/cloud-identity) reads to allow or deny it per role
|
|
13
15
|
*
|
|
14
16
|
* The sets are closed UNIONS, so a value outside them is a compile error, and the verb decides
|
|
15
17
|
* which other slots are required (a list/search tool cannot omit its mechanism or its pagination).
|
|
@@ -34,6 +36,11 @@ export type ToolVerb = "list" | "get" | "search" | "aggregate" | "create" | "upd
|
|
|
34
36
|
* must declare it (`checkToolRecord`).
|
|
35
37
|
*/
|
|
36
38
|
export type SearchMechanism = "indexed-range" | "indexed-bm25" | `indexed-vector(${string})` | "indexed-hybrid-rrf" | `upstream-delegated(${string})`;
|
|
39
|
+
/**
|
|
40
|
+
* Agent tool roles rule 2 (mcp-server-standard "Agent tool roles"): exactly `read` or `write`. `read` must agree with
|
|
41
|
+
* `readOnlyHint: true`, `write` with `readOnlyHint: false` (C-15). Same union as cloud-identity's `ToolMode`.
|
|
42
|
+
*/
|
|
43
|
+
export type ToolMode = "read" | "write";
|
|
37
44
|
/** The four behavioural annotations (C-15), explicit booleans on every tool. */
|
|
38
45
|
interface BehaviourHints {
|
|
39
46
|
readOnlyHint: boolean;
|
|
@@ -44,6 +51,10 @@ interface BehaviourHints {
|
|
|
44
51
|
interface CommonSlots extends BehaviourHints {
|
|
45
52
|
authRead: AuthTier;
|
|
46
53
|
authWrite: AuthTier;
|
|
54
|
+
/** The business domain a role grants (rule 1); a non-empty name. */
|
|
55
|
+
domain: string;
|
|
56
|
+
/** read | write (rule 2); agrees with readOnlyHint. */
|
|
57
|
+
mode: ToolMode;
|
|
47
58
|
/** Estimated context tokens of the served tool (C-13); tests/contextCeiling.test.ts checks it. */
|
|
48
59
|
tokensEstimeContexte: number;
|
|
49
60
|
}
|
|
@@ -75,8 +86,22 @@ export declare const PING_ANNOTATIONS: {
|
|
|
75
86
|
readonly openWorldHint: false;
|
|
76
87
|
readonly authRead: "public";
|
|
77
88
|
readonly authWrite: "master";
|
|
89
|
+
readonly domain: "system";
|
|
90
|
+
readonly mode: "read";
|
|
78
91
|
readonly verb: "pure";
|
|
79
|
-
readonly tokensEstimeContexte:
|
|
92
|
+
readonly tokensEstimeContexte: 280;
|
|
93
|
+
};
|
|
94
|
+
export declare const WHOAMI_ANNOTATIONS: {
|
|
95
|
+
readonly readOnlyHint: true;
|
|
96
|
+
readonly destructiveHint: false;
|
|
97
|
+
readonly idempotentHint: true;
|
|
98
|
+
readonly openWorldHint: false;
|
|
99
|
+
readonly authRead: "public";
|
|
100
|
+
readonly authWrite: "master";
|
|
101
|
+
readonly domain: "identity";
|
|
102
|
+
readonly mode: "read";
|
|
103
|
+
readonly verb: "introspect";
|
|
104
|
+
readonly tokensEstimeContexte: 330;
|
|
80
105
|
};
|
|
81
106
|
export declare const LIST_ITEMS_ANNOTATIONS: {
|
|
82
107
|
readonly readOnlyHint: true;
|
|
@@ -85,13 +110,15 @@ export declare const LIST_ITEMS_ANNOTATIONS: {
|
|
|
85
110
|
readonly openWorldHint: false;
|
|
86
111
|
readonly authRead: "org-member";
|
|
87
112
|
readonly authWrite: "master";
|
|
113
|
+
readonly domain: "items";
|
|
114
|
+
readonly mode: "read";
|
|
88
115
|
readonly verb: "list";
|
|
89
116
|
readonly table: "items";
|
|
90
117
|
readonly searchMechanism: "indexed-range";
|
|
91
118
|
readonly paginationCursor: true;
|
|
92
119
|
readonly paginationDefault: 20;
|
|
93
120
|
readonly paginationMax: 100;
|
|
94
|
-
readonly tokensEstimeContexte:
|
|
121
|
+
readonly tokensEstimeContexte: 690;
|
|
95
122
|
};
|
|
96
123
|
/** One tool's declared context cost, as `checkContextCeiling` reads it. */
|
|
97
124
|
export interface ContextEstimate {
|
|
@@ -111,6 +138,12 @@ export declare function checkContextCeiling(estimates: readonly ContextEstimate[
|
|
|
111
138
|
* refused. A tool that is not a list/search is not asked for a mechanism.
|
|
112
139
|
*/
|
|
113
140
|
export declare function checkToolRecord(name: string, record: Record<string, unknown>): string[];
|
|
141
|
+
/**
|
|
142
|
+
* Agent tool roles rule 2: every tool declares a non-empty `domain` and a `mode` of exactly `read` or `write`, and the
|
|
143
|
+
* mode agrees with `readOnlyHint` (read <-> true, write <-> false). A tool missing either, or with any other value, is
|
|
144
|
+
* served to no one: the start refuses, naming it.
|
|
145
|
+
*/
|
|
146
|
+
export declare function checkToolRole(name: string, record: Record<string, unknown>): string[];
|
|
114
147
|
/** Thrown at start when a tool record or the ceiling is unsound. `problems` name every tool. */
|
|
115
148
|
export declare class ToolRecordError extends Error {
|
|
116
149
|
readonly problems: readonly string[];
|
package/dist/toolRecord.js
CHANGED
|
@@ -9,8 +9,24 @@ export const PING_ANNOTATIONS = {
|
|
|
9
9
|
openWorldHint: false,
|
|
10
10
|
authRead: "public",
|
|
11
11
|
authWrite: "master",
|
|
12
|
+
domain: "system",
|
|
13
|
+
mode: "read",
|
|
12
14
|
verb: "pure",
|
|
13
|
-
tokensEstimeContexte:
|
|
15
|
+
tokensEstimeContexte: 280,
|
|
16
|
+
};
|
|
17
|
+
// The identity tool (C-33; agent tool roles rule 6): the caller's own identity and granted tools. It reveals nothing
|
|
18
|
+
// beyond the caller's own standing, so it is `public` to read; it writes nothing.
|
|
19
|
+
export const WHOAMI_ANNOTATIONS = {
|
|
20
|
+
readOnlyHint: true,
|
|
21
|
+
destructiveHint: false,
|
|
22
|
+
idempotentHint: true,
|
|
23
|
+
openWorldHint: false,
|
|
24
|
+
authRead: "public",
|
|
25
|
+
authWrite: "master",
|
|
26
|
+
domain: "identity",
|
|
27
|
+
mode: "read",
|
|
28
|
+
verb: "introspect",
|
|
29
|
+
tokensEstimeContexte: 330,
|
|
14
30
|
};
|
|
15
31
|
export const LIST_ITEMS_ANNOTATIONS = {
|
|
16
32
|
readOnlyHint: true,
|
|
@@ -19,13 +35,15 @@ export const LIST_ITEMS_ANNOTATIONS = {
|
|
|
19
35
|
openWorldHint: false,
|
|
20
36
|
authRead: "org-member",
|
|
21
37
|
authWrite: "master",
|
|
38
|
+
domain: "items",
|
|
39
|
+
mode: "read",
|
|
22
40
|
verb: "list",
|
|
23
41
|
table: "items",
|
|
24
42
|
searchMechanism: "indexed-range",
|
|
25
43
|
paginationCursor: true,
|
|
26
44
|
paginationDefault: 20,
|
|
27
45
|
paginationMax: 100,
|
|
28
|
-
tokensEstimeContexte:
|
|
46
|
+
tokensEstimeContexte: 690,
|
|
29
47
|
};
|
|
30
48
|
/** Every problem found, each naming the tool; empty when the ceiling holds. Never skips a tool. */
|
|
31
49
|
export function checkContextCeiling(estimates, ceiling) {
|
|
@@ -66,6 +84,30 @@ function isIndexedMechanism(value) {
|
|
|
66
84
|
* refused. A tool that is not a list/search is not asked for a mechanism.
|
|
67
85
|
*/
|
|
68
86
|
export function checkToolRecord(name, record) {
|
|
87
|
+
return [...checkToolRole(name, record), ...checkMechanism(name, record)];
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Agent tool roles rule 2: every tool declares a non-empty `domain` and a `mode` of exactly `read` or `write`, and the
|
|
91
|
+
* mode agrees with `readOnlyHint` (read <-> true, write <-> false). A tool missing either, or with any other value, is
|
|
92
|
+
* served to no one: the start refuses, naming it.
|
|
93
|
+
*/
|
|
94
|
+
export function checkToolRole(name, record) {
|
|
95
|
+
const problems = [];
|
|
96
|
+
const { domain, mode, readOnlyHint } = record;
|
|
97
|
+
if (typeof domain !== "string" || domain.trim() === "") {
|
|
98
|
+
problems.push(`${name}: declares no domain (agent tool roles rule 2)`);
|
|
99
|
+
}
|
|
100
|
+
if (mode !== "read" && mode !== "write") {
|
|
101
|
+
problems.push(mode === undefined
|
|
102
|
+
? `${name}: declares no mode (agent tool roles rule 2: "read" or "write")`
|
|
103
|
+
: `${name}: mode ${JSON.stringify(mode)} is not "read" or "write"`);
|
|
104
|
+
}
|
|
105
|
+
else if ((mode === "read") !== (readOnlyHint === true)) {
|
|
106
|
+
problems.push(`${name}: mode "${mode}" disagrees with readOnlyHint ${String(readOnlyHint)} (read <-> true, write <-> false)`);
|
|
107
|
+
}
|
|
108
|
+
return problems;
|
|
109
|
+
}
|
|
110
|
+
function checkMechanism(name, record) {
|
|
69
111
|
if (!LIST_VERBS.includes(record.verb))
|
|
70
112
|
return [];
|
|
71
113
|
const mechanism = record.searchMechanism;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
+
import { type StandingScope, type ToolDeclaration, type ToolRoleLookup } from "@vantageos/cloud-identity";
|
|
3
|
+
import { type McpLocale } from "./i18n.js";
|
|
4
|
+
import { type CallerResolution, type VerifiedPerson } from "./verifiedPerson.js";
|
|
5
|
+
/**
|
|
6
|
+
* The two enforcement points of agent tool roles (mcp-server-standard "Agent tool roles", rule 4), both calling the
|
|
7
|
+
* same decision of @vantageos/cloud-identity:
|
|
8
|
+
* - tools/call is REFUSED: every registered handler is wrapped; a denied call returns a tool result with
|
|
9
|
+
* `isError: true` and `structuredContent.error.code` = TOOL_ROLE_DENIED (or TOOL_ROLE_UNRESOLVED when the role could
|
|
10
|
+
* not be read), and the handler does not run. Never a protocol error, never an empty success.
|
|
11
|
+
* - tools/list is FILTERED: the SDK's own tools/list handler runs, then the denied tools are removed. The SDK's
|
|
12
|
+
* `disable()` cannot be used: a disabled tool answers tools/call with a protocol error, which rule 4 forbids.
|
|
13
|
+
*
|
|
14
|
+
* The identity tool is outside the gate (rule 6: a caller with no role still gets `role: null` and an empty list,
|
|
15
|
+
* never an error); it is named in `exempt`, here and nowhere else.
|
|
16
|
+
*/
|
|
17
|
+
/** The per-request role context the server is built with. */
|
|
18
|
+
export type ToolRolesContext = {
|
|
19
|
+
applicable: false;
|
|
20
|
+
} | {
|
|
21
|
+
applicable: true;
|
|
22
|
+
lookup: ToolRoleLookup;
|
|
23
|
+
/** The caller's member scope, or null when the person was not resolved (every tool is then denied). */
|
|
24
|
+
caller: StandingScope | null;
|
|
25
|
+
/** The identity layer's resolution of the caller: an unresolved one is refused with its own code (callerRefusal). */
|
|
26
|
+
resolution: CallerResolution | undefined;
|
|
27
|
+
/** The display name of a role, for the identity tool. */
|
|
28
|
+
roleName: (roleId: string) => string | undefined;
|
|
29
|
+
};
|
|
30
|
+
/** The caller's member scope from the person the identity layer resolved, or null. Never built from a claim here. */
|
|
31
|
+
export declare function callerScopeOf(person: VerifiedPerson | undefined): StandingScope | null;
|
|
32
|
+
/**
|
|
33
|
+
* Wraps `server.registerTool` so every handler registered AFTER this call is gated, and returns the declarations seen
|
|
34
|
+
* (for the list filter and the identity tool). With roles not applicable, nothing is wrapped and nothing is filtered.
|
|
35
|
+
*/
|
|
36
|
+
export declare function installToolRoleGate(server: McpServer, roles: ToolRolesContext, locale: McpLocale, exempt: ReadonlySet<string>): {
|
|
37
|
+
declarations: ToolDeclaration[];
|
|
38
|
+
filterList: () => void;
|
|
39
|
+
};
|
|
40
|
+
/** The tools the caller is granted, by the same decision as the list (for the identity tool's `grantedTools`). */
|
|
41
|
+
export declare function grantedToolNames(roles: ToolRolesContext, declarations: readonly ToolDeclaration[]): string[];
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { decideToolRole, filterGrantedTools, memberScopeFrom, } from "@vantageos/cloud-identity";
|
|
2
|
+
import { t } from "./i18n.js";
|
|
3
|
+
import { callerRefusal } from "./verifiedPerson.js";
|
|
4
|
+
/** A scope `scopeStanding` reads as "refused": the decision denies with caller-not-resolved. */
|
|
5
|
+
const UNRESOLVED_CALLER = { isMaster: false, fleetWide: false, refused: true };
|
|
6
|
+
/** The caller's member scope from the person the identity layer resolved, or null. Never built from a claim here. */
|
|
7
|
+
export function callerScopeOf(person) {
|
|
8
|
+
if (!person)
|
|
9
|
+
return null;
|
|
10
|
+
// The person is deep-readonly; the package reads the org row without mutating it, so a shallow copy of its arrays
|
|
11
|
+
// satisfies the mutable parameter type.
|
|
12
|
+
const org = {
|
|
13
|
+
id: person.org.id,
|
|
14
|
+
label: person.org.label,
|
|
15
|
+
allowedAgentIds: person.org.allowedAgentIds ? [...person.org.allowedAgentIds] : undefined,
|
|
16
|
+
scopes: [...person.org.scopes],
|
|
17
|
+
};
|
|
18
|
+
const r = memberScopeFrom(org, {
|
|
19
|
+
userId: person.subject,
|
|
20
|
+
orgRole: person.role,
|
|
21
|
+
orgId: person.org.id,
|
|
22
|
+
});
|
|
23
|
+
return r.ok ? r.scope : null;
|
|
24
|
+
}
|
|
25
|
+
function declarationOf(name, annotations) {
|
|
26
|
+
const a = (annotations ?? {});
|
|
27
|
+
return { toolId: name, domain: a.domain, mode: a.mode };
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Wraps `server.registerTool` so every handler registered AFTER this call is gated, and returns the declarations seen
|
|
31
|
+
* (for the list filter and the identity tool). With roles not applicable, nothing is wrapped and nothing is filtered.
|
|
32
|
+
*/
|
|
33
|
+
export function installToolRoleGate(server, roles, locale, exempt) {
|
|
34
|
+
const declarations = [];
|
|
35
|
+
if (!roles.applicable)
|
|
36
|
+
return { declarations, filterList: () => { } };
|
|
37
|
+
const register = server.registerTool.bind(server);
|
|
38
|
+
server.registerTool = ((name, config, callback) => {
|
|
39
|
+
if (exempt.has(name))
|
|
40
|
+
return register(name, config, callback);
|
|
41
|
+
const declaration = declarationOf(name, config.annotations);
|
|
42
|
+
declarations.push(declaration);
|
|
43
|
+
const gated = async (...args) => {
|
|
44
|
+
const refused = callerRefusal(roles.resolution, locale);
|
|
45
|
+
if (refused)
|
|
46
|
+
return refused;
|
|
47
|
+
const decision = decideToolRole(roles.lookup, declaration, roles.caller ?? UNRESOLVED_CALLER, { door: name });
|
|
48
|
+
if (!decision.ok) {
|
|
49
|
+
const { code, reason } = decision.refusal;
|
|
50
|
+
const message = code === "TOOL_ROLE_UNRESOLVED"
|
|
51
|
+
? t(locale, "toolRoles.unresolved")
|
|
52
|
+
: t(locale, "toolRoles.denied")
|
|
53
|
+
.replace("{tool}", () => name)
|
|
54
|
+
.replace("{reason}", () => reason);
|
|
55
|
+
return {
|
|
56
|
+
content: [{ type: "text", text: message }],
|
|
57
|
+
structuredContent: { error: { code, reason, message } },
|
|
58
|
+
isError: true,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return callback(...args);
|
|
62
|
+
};
|
|
63
|
+
return register(name, config, gated);
|
|
64
|
+
});
|
|
65
|
+
const filterList = () => {
|
|
66
|
+
// The SDK keeps its handlers in a map; the tools/list entry is wrapped in place so the SDK's own listing (schemas,
|
|
67
|
+
// eras, pagination) runs unchanged and only its result is narrowed. A missing entry refuses the start: a server
|
|
68
|
+
// that cannot filter its list must not serve it unfiltered.
|
|
69
|
+
const handlers = server.server
|
|
70
|
+
._requestHandlers;
|
|
71
|
+
const inner = handlers?.get("tools/list");
|
|
72
|
+
if (!handlers || typeof inner !== "function")
|
|
73
|
+
throw new Error("agent tool roles: the SDK's tools/list handler was not found; the list cannot be filtered");
|
|
74
|
+
handlers.set("tools/list", async (req, ctx) => {
|
|
75
|
+
// Only `tools` is narrowed: every other field of the SDK's page, `nextCursor` included, is carried unchanged, so
|
|
76
|
+
// pagination is the SDK's (and src/listCursor.ts's) exactly as without the gate.
|
|
77
|
+
const result = (await inner(req, ctx));
|
|
78
|
+
// Decided at list time, over every declaration seen so far: a tool registered after the gate was installed is
|
|
79
|
+
// listed by the same decision that gates its calls.
|
|
80
|
+
const granted = new Set(filterGrantedTools(roles.lookup, declarations, roles.caller ?? UNRESOLVED_CALLER).map((d) => d.toolId));
|
|
81
|
+
return {
|
|
82
|
+
...result,
|
|
83
|
+
tools: (result.tools ?? []).filter((tool) => exempt.has(tool.name) || granted.has(tool.name)),
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
return { declarations, filterList };
|
|
88
|
+
}
|
|
89
|
+
/** The tools the caller is granted, by the same decision as the list (for the identity tool's `grantedTools`). */
|
|
90
|
+
export function grantedToolNames(roles, declarations) {
|
|
91
|
+
if (!roles.applicable)
|
|
92
|
+
return [];
|
|
93
|
+
return filterGrantedTools(roles.lookup, declarations, roles.caller ?? UNRESOLVED_CALLER).map((d) => d.toolId);
|
|
94
|
+
}
|