@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
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/server";
|
|
2
|
+
/**
|
|
3
|
+
* C-11 (a) (mcp-standard): an invalid cursor sent to a protocol list method is JSON-RPC -32602 Invalid params, never a
|
|
4
|
+
* silent first page. The SDK serves `tools/list`, `resources/list`, `resources/templates/list` and `prompts/list` as
|
|
5
|
+
* ONE page and issues no `nextCursor`, so every cursor such a request carries is one this server never issued: invalid.
|
|
6
|
+
* The SDK itself ignores the cursor and answers page 1, so each of these handlers is wrapped in place (the SDK keeps them
|
|
7
|
+
* in a map) to refuse first. A list that cannot be wrapped refuses the start: it must not answer a cursor silently.
|
|
8
|
+
*/
|
|
9
|
+
export const SINGLE_PAGE_LISTS = [
|
|
10
|
+
"tools/list",
|
|
11
|
+
"resources/list",
|
|
12
|
+
"resources/templates/list",
|
|
13
|
+
// The seed registers no prompt, so it serves no prompts/list; a fork that registers one gets it guarded.
|
|
14
|
+
"prompts/list",
|
|
15
|
+
];
|
|
16
|
+
export function rejectCursorOnSinglePageLists(server) {
|
|
17
|
+
const handlers = server.server
|
|
18
|
+
._requestHandlers;
|
|
19
|
+
if (!handlers)
|
|
20
|
+
throw new Error("C-11 (a): the SDK's request handlers were not found; list cursors cannot be checked");
|
|
21
|
+
for (const method of SINGLE_PAGE_LISTS) {
|
|
22
|
+
const inner = handlers.get(method);
|
|
23
|
+
if (typeof inner !== "function")
|
|
24
|
+
continue; // a list this server does not serve (no resources) has no handler
|
|
25
|
+
handlers.set(method, async (req, ctx) => {
|
|
26
|
+
const cursor = req?.params?.cursor;
|
|
27
|
+
if (cursor !== undefined)
|
|
28
|
+
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `invalid cursor: ${method} is served as one page and issues no cursor`);
|
|
29
|
+
const result = (await inner(req, ctx));
|
|
30
|
+
// The refusal above rests on the SDK serving one page. If it ever returns a nextCursor, cursors become valid
|
|
31
|
+
// and refusing them all would be wrong: fail loud instead of answering a page with a cursor we would refuse.
|
|
32
|
+
if (result?.nextCursor !== undefined)
|
|
33
|
+
throw new Error(`C-11 (a): ${method} now returns a nextCursor; src/listCursor.ts must accept its cursors`);
|
|
34
|
+
return result;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
package/dist/locales/en.json
CHANGED
|
@@ -12,12 +12,18 @@
|
|
|
12
12
|
"noVerifiedSession": "permission denied: this tool needs the verified person, and this call carries no verified session",
|
|
13
13
|
"refused": "permission denied: the verified person behind this call could not be resolved"
|
|
14
14
|
},
|
|
15
|
+
"rateLimit": {
|
|
16
|
+
"exceeded": "too many tool calls: retry in {seconds} s"
|
|
17
|
+
},
|
|
15
18
|
"validation": {
|
|
16
19
|
"messageTooLong": "message exceeds the maximum length of 200 characters"
|
|
17
20
|
},
|
|
18
21
|
"oauth": {
|
|
19
22
|
"pickerTitle": "Connect {client}",
|
|
20
23
|
"pickerIntro": "{client} asks to act on your behalf. Choose the organisation it may reach, then approve or deny.",
|
|
24
|
+
"handoffTitle": "Returning to the application",
|
|
25
|
+
"handoffLink": "Continue to the application",
|
|
26
|
+
"pickerNoAllowedOrganisation": "None of your organisations is authorised on this server.",
|
|
21
27
|
"pickerDestination": "After approval you are sent to:",
|
|
22
28
|
"pickerScope": "Requested access:",
|
|
23
29
|
"pickerUnverified": "Unverified client: it registered itself automatically and nobody has vouched for it. Approve only if you recognise the address above.",
|
|
@@ -39,9 +45,17 @@
|
|
|
39
45
|
"object": "an object",
|
|
40
46
|
"boolean": "true or false",
|
|
41
47
|
"positiveInteger": "a positive whole number",
|
|
42
|
-
"atLeastPerTool": "at least the perTool ceiling"
|
|
48
|
+
"atLeastPerTool": "at least the perTool ceiling",
|
|
49
|
+
"stringArray": "an array of non-empty strings"
|
|
43
50
|
}
|
|
44
51
|
},
|
|
52
|
+
"toolRoles": {
|
|
53
|
+
"identityMissing": "the identity stores are not injected (AppDeps.identity): on the OAuth tier a caller's role is decided for the organisation the identity layer resolves, so both are required",
|
|
54
|
+
"denied": "permission denied: your role does not grant the tool {tool} ({reason})",
|
|
55
|
+
"unresolved": "permission denied: your role could not be read, so no tool is granted",
|
|
56
|
+
"configMissing": "{variable} is not set: on the OAuth tier every tool is decided by the caller's role, read from that roles file",
|
|
57
|
+
"configInvalid": "{variable} ({path}) is not a valid roles file: {problem}"
|
|
58
|
+
},
|
|
45
59
|
"toolRecord": {
|
|
46
60
|
"unsound": "tool records or the context ceiling are unsound: {problems}"
|
|
47
61
|
},
|
package/dist/locales/fr.json
CHANGED
|
@@ -12,12 +12,18 @@
|
|
|
12
12
|
"noVerifiedSession": "permission refusée : cet outil exige la personne vérifiée, et cet appel ne porte aucune session vérifiée",
|
|
13
13
|
"refused": "permission refusée : la personne vérifiée derrière cet appel n'a pas pu être résolue"
|
|
14
14
|
},
|
|
15
|
+
"rateLimit": {
|
|
16
|
+
"exceeded": "trop d’appels d’outils : réessayez dans {seconds} s"
|
|
17
|
+
},
|
|
15
18
|
"validation": {
|
|
16
19
|
"messageTooLong": "le message dépasse la longueur maximale de 200 caractères"
|
|
17
20
|
},
|
|
18
21
|
"oauth": {
|
|
19
22
|
"pickerTitle": "Connecter {client}",
|
|
20
23
|
"pickerIntro": "{client} demande à agir en votre nom. Choisissez l'organisation à laquelle il peut accéder, puis approuvez ou refusez.",
|
|
24
|
+
"handoffTitle": "Retour à l'application",
|
|
25
|
+
"handoffLink": "Continuer vers l'application",
|
|
26
|
+
"pickerNoAllowedOrganisation": "Aucune de vos organisations n'est autorisée sur ce serveur.",
|
|
21
27
|
"pickerDestination": "Redirection après validation vers :",
|
|
22
28
|
"pickerScope": "Accès demandé :",
|
|
23
29
|
"pickerUnverified": "Client non vérifié : il s'est enregistré automatiquement et personne ne s'en porte garant. N'approuvez que si vous reconnaissez l'adresse ci-dessus.",
|
|
@@ -39,9 +45,17 @@
|
|
|
39
45
|
"object": "un objet",
|
|
40
46
|
"boolean": "true ou false",
|
|
41
47
|
"positiveInteger": "un entier strictement positif",
|
|
42
|
-
"atLeastPerTool": "au moins le plafond perTool"
|
|
48
|
+
"atLeastPerTool": "au moins le plafond perTool",
|
|
49
|
+
"stringArray": "un tableau de chaînes non vides"
|
|
43
50
|
}
|
|
44
51
|
},
|
|
52
|
+
"toolRoles": {
|
|
53
|
+
"identityMissing": "les stores d'identité ne sont pas injectés (AppDeps.identity) : sur le niveau OAuth, le rôle d'un appelant est décidé pour l'organisation que la couche d'identité résout, les deux sont donc requis",
|
|
54
|
+
"denied": "permission refusée : votre rôle n'accorde pas l'outil {tool} ({reason})",
|
|
55
|
+
"unresolved": "permission refusée : votre rôle n'a pas pu être lu, aucun outil n'est accordé",
|
|
56
|
+
"configMissing": "{variable} n'est pas défini : sur le niveau OAuth, chaque outil est décidé par le rôle de l'appelant, lu dans ce fichier de rôles",
|
|
57
|
+
"configInvalid": "{variable} ({path}) n'est pas un fichier de rôles valide : {problem}"
|
|
58
|
+
},
|
|
45
59
|
"toolRecord": {
|
|
46
60
|
"unsound": "fiches d'outils ou plafond de contexte invalides : {problems}"
|
|
47
61
|
},
|
|
@@ -32,8 +32,10 @@ import { McpServer } from "@modelcontextprotocol/server";
|
|
|
32
32
|
import type { ExampleItem } from "./exampleItems.js";
|
|
33
33
|
import { type McpLocale } from "./i18n.js";
|
|
34
34
|
import { type LakeReader } from "./lake/reader.js";
|
|
35
|
+
import { type RateLimiter } from "./rateLimit.js";
|
|
35
36
|
import type { CursorSigner } from "./signedCursor.js";
|
|
36
37
|
import { type ResolvedTenant } from "./tenancy.js";
|
|
38
|
+
import { type ToolRolesContext } from "./toolRoleGate.js";
|
|
37
39
|
import type { CallerResolution } from "./verifiedPerson.js";
|
|
38
40
|
/** Oldest 2025-era protocol revision served; the legacy `initialize` handshake never offers older. */
|
|
39
41
|
export declare const SUPPORTED_LEGACY_PROTOCOL_VERSIONS: string[];
|
|
@@ -64,9 +66,11 @@ export interface McpServerDeps {
|
|
|
64
66
|
* httpApp.ts). Defaults to "en"; the stdio transport (no HTTP headers) uses the default.
|
|
65
67
|
*/
|
|
66
68
|
locale?: McpLocale;
|
|
69
|
+
/** The tool invocation limiter (S-24); default the process-wide `toolRateLimiter`. A test injects its own. */
|
|
70
|
+
rateLimiter?: RateLimiter;
|
|
67
71
|
/**
|
|
68
|
-
* Dedicated sandbox origin for the MCP Apps widget (`_meta.ui.domain`)
|
|
69
|
-
*
|
|
72
|
+
* Dedicated sandbox origin for the MCP Apps widget (`_meta.ui.domain`), derived from the served MCP URL by
|
|
73
|
+
* `claudeSandboxDomain` (src/ui/sandboxDomain.ts, A-11). Omitted from the resource when there is no served URL.
|
|
70
74
|
*/
|
|
71
75
|
uiDomain?: string;
|
|
72
76
|
/**
|
|
@@ -75,6 +79,11 @@ export interface McpServerDeps {
|
|
|
75
79
|
* before. A derived server injects its own (e.g. `@vantageos/mcp-boilerplate/lake/convex`).
|
|
76
80
|
*/
|
|
77
81
|
lake?: LakeReader<ExampleItem>;
|
|
82
|
+
/**
|
|
83
|
+
* Agent tool roles for this request (src/toolRoleGate.ts): on the OAuth tier, the caller's role lookup and member
|
|
84
|
+
* scope, resolved by httpApp before the server is built; elsewhere `{ applicable: false }`. Default: not applicable.
|
|
85
|
+
*/
|
|
86
|
+
toolRoles?: ToolRolesContext;
|
|
78
87
|
/**
|
|
79
88
|
* Signs and verifies the `list_items` cursor (src/signedCursor.ts). Default: the process-wide
|
|
80
89
|
* signer of src/pagination.ts, whose key is random per process. A multi-instance deployment
|
package/dist/mcpServerFactory.js
CHANGED
|
@@ -34,16 +34,21 @@ import { z } from "zod";
|
|
|
34
34
|
import { getBuildIdentity } from "./buildIdentity.js";
|
|
35
35
|
import { t } from "./i18n.js";
|
|
36
36
|
import { EXAMPLE_ITEMS_READER, LAKE_UNREADABLE_CODE, LakeReadError, } from "./lake/reader.js";
|
|
37
|
+
import { rejectCursorOnSinglePageLists } from "./listCursor.js";
|
|
37
38
|
import { INVALID_CURSOR_CODE, InvalidCursorError, PAGINATION, readPage } from "./pagination.js";
|
|
39
|
+
import { RATE_LIMITED_CODE, toolRateLimiter } from "./rateLimit.js";
|
|
38
40
|
import { trackToolRecords } from "./servedToolRecords.js";
|
|
39
41
|
import { loadServerManifest } from "./serverManifest.js";
|
|
40
42
|
import { scopeRowsForTenant } from "./tenancy.js";
|
|
41
|
-
import { LIST_ITEMS_ANNOTATIONS, PING_ANNOTATIONS } from "./toolRecord.js";
|
|
43
|
+
import { LIST_ITEMS_ANNOTATIONS, PING_ANNOTATIONS, WHOAMI_ANNOTATIONS } from "./toolRecord.js";
|
|
44
|
+
import { grantedToolNames, installToolRoleGate } from "./toolRoleGate.js";
|
|
42
45
|
import { ITEMS_TOOL_META, ITEMS_UI_RESOURCE_URI, buildItemsUiResourceMeta, buildListItemsResult, buildUiResource, } from "./ui/uiResource.js";
|
|
43
46
|
import { SERVER_NAME, SERVER_VERSION } from "./version.js";
|
|
44
47
|
/** Oldest 2025-era protocol revision served; the legacy `initialize` handshake never offers older. */
|
|
45
48
|
export const SUPPORTED_LEGACY_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18"];
|
|
46
|
-
|
|
49
|
+
// S-44: output objects stay open (z.looseObject): a field added in a later release must not fail a client that
|
|
50
|
+
// validated against the schema it read at connect time.
|
|
51
|
+
const itemSchema = z.looseObject({
|
|
47
52
|
id: z.string(),
|
|
48
53
|
name: z.string(),
|
|
49
54
|
orgId: z.string(),
|
|
@@ -54,7 +59,21 @@ const localeSchema = z.enum(["en", "fr"]);
|
|
|
54
59
|
const pingInput = z.object({
|
|
55
60
|
message: z.string().optional().describe("Optional message echoed back verbatim"),
|
|
56
61
|
});
|
|
57
|
-
const pingOutput = z.
|
|
62
|
+
const pingOutput = z.looseObject({ reply: z.string() });
|
|
63
|
+
/** The identity tool takes no argument (C-33): the identity never arrives as an argument. */
|
|
64
|
+
const whoamiInput = z.object({});
|
|
65
|
+
const whoamiOutput = z.looseObject({
|
|
66
|
+
/** The verified subject behind the credentials; null where there is no caller (public HTTP, stdio). */
|
|
67
|
+
id: z.string().nullable(),
|
|
68
|
+
/** The caller's role, or null: no role, or roles not applicable. */
|
|
69
|
+
role: z.looseObject({ id: z.string(), name: z.string() }).nullable(),
|
|
70
|
+
/** The tools the caller's role grants, by the same decision as tools/list. */
|
|
71
|
+
grantedTools: z.array(z.string()),
|
|
72
|
+
/** False on the public HTTP tier and stdio: there is no caller, so no role is decided. */
|
|
73
|
+
rolesApplicable: z.boolean(),
|
|
74
|
+
});
|
|
75
|
+
/** The identity tool, outside the role gate (rule 6). */
|
|
76
|
+
const IDENTITY_TOOL = "whoami";
|
|
58
77
|
// Pagination (C-11): the bounds are declared in src/toolRecord.ts and applied in
|
|
59
78
|
// src/pagination.ts; the schema only describes them to the caller.
|
|
60
79
|
const pageInput = {
|
|
@@ -66,7 +85,8 @@ const pageInput = {
|
|
|
66
85
|
.number()
|
|
67
86
|
.int()
|
|
68
87
|
.min(1)
|
|
69
|
-
|
|
88
|
+
// Served in the inputSchema (C-11): the default is declared where a caller reads it, not only applied.
|
|
89
|
+
.default(PAGINATION.default)
|
|
70
90
|
.describe(`Page size; default ${PAGINATION.default}, capped at ${PAGINATION.max}`),
|
|
71
91
|
};
|
|
72
92
|
const pageOutput = {
|
|
@@ -79,7 +99,7 @@ const listItemsInput = z.object({
|
|
|
79
99
|
.describe("Widget and fallback language; defaults to the caller's X-MCP-Locale"),
|
|
80
100
|
...pageInput,
|
|
81
101
|
});
|
|
82
|
-
const listItemsOutput = z.
|
|
102
|
+
const listItemsOutput = z.looseObject({
|
|
83
103
|
items: z.array(itemSchema),
|
|
84
104
|
locale: localeSchema,
|
|
85
105
|
...pageOutput,
|
|
@@ -121,26 +141,51 @@ export function createBoilerplateMcpServer(deps = {}) {
|
|
|
121
141
|
},
|
|
122
142
|
});
|
|
123
143
|
const served = trackToolRecords(server);
|
|
144
|
+
// Agent tool roles: every tool registered below is gated (call refused) and the list filtered, both by the same
|
|
145
|
+
// decision; the identity tool is the one exemption.
|
|
146
|
+
const roles = deps.toolRoles ?? { applicable: false };
|
|
147
|
+
const gate = installToolRoleGate(server, roles, locale, new Set([IDENTITY_TOOL]));
|
|
148
|
+
// S-24: every tool invocation takes a token from the caller's bucket (keyed by the resolved organisation, or
|
|
149
|
+
// "public" on the unscoped tier); an empty bucket is a tool error with a stable code and the wait, never a silent drop.
|
|
150
|
+
const limiter = deps.rateLimiter ?? toolRateLimiter;
|
|
151
|
+
const rateKey = deps.tenant?.tenantId ?? "public";
|
|
152
|
+
const withRateLimit = (handler) => async (args) => {
|
|
153
|
+
const d = limiter.consume(rateKey);
|
|
154
|
+
if (d.ok)
|
|
155
|
+
return handler(args);
|
|
156
|
+
const message = t(locale, "rateLimit.exceeded").replace("{seconds}", () => String(Math.ceil(d.retryAfterMs / 1000)));
|
|
157
|
+
return {
|
|
158
|
+
content: [{ type: "text", text: message }],
|
|
159
|
+
structuredContent: {
|
|
160
|
+
error: { code: RATE_LIMITED_CODE, retryAfterMs: d.retryAfterMs, message },
|
|
161
|
+
},
|
|
162
|
+
isError: true,
|
|
163
|
+
};
|
|
164
|
+
};
|
|
124
165
|
server.registerTool("ping", {
|
|
125
166
|
title: "Ping",
|
|
126
|
-
|
|
167
|
+
// C-19: served in both languages in the one description field ("<en> / <fr>"), with its trigger phrase.
|
|
168
|
+
description: "Echoes the given message (or 'pong' if none given) and proves the MCP handshake end to end. " +
|
|
169
|
+
"Use this whenever you need to check that this server answers, even if they don't say 'ping'. / " +
|
|
170
|
+
"Renvoie le message donné (ou 'pong' sans message) et prouve la poignée de main MCP de bout en bout. " +
|
|
171
|
+
"À utiliser pour vérifier que ce serveur répond, même sans le mot 'ping'.",
|
|
127
172
|
inputSchema: pingInput,
|
|
128
173
|
outputSchema: pingOutput,
|
|
129
174
|
annotations: PING_ANNOTATIONS,
|
|
130
|
-
}, async (args) => {
|
|
175
|
+
}, withRateLimit(async (args) => {
|
|
131
176
|
// Application-level input validation beyond the Zod shape: an overly long echo message
|
|
132
177
|
// is refused as an MCP tool-call error, localized per the caller's X-MCP-Locale.
|
|
133
178
|
if (args.message !== undefined && args.message.length > 200) {
|
|
134
179
|
return refusal(t(locale, "validation.messageTooLong"));
|
|
135
180
|
}
|
|
136
181
|
return structuredResult({ reply: args.message ?? "pong" });
|
|
137
|
-
});
|
|
182
|
+
}));
|
|
138
183
|
const lake = deps.lake ?? EXAMPLE_ITEMS_READER;
|
|
139
184
|
// An invalid or expired cursor is a TOOL INPUT error (spec 2025-11-25, SEP-1303: input
|
|
140
185
|
// validation errors are Tool Execution Errors, not Protocol Errors): a result with
|
|
141
186
|
// `isError: true` whose structuredContent carries a STABLE machine code beside a message
|
|
142
187
|
// localized per X-MCP-Locale. It is never a silent first page. (-32602 stays with the protocol
|
|
143
|
-
// list methods' cursor,
|
|
188
|
+
// list methods' cursor: src/listCursor.ts refuses it there, since the SDK ignores it.)
|
|
144
189
|
const invalidCursor = () => {
|
|
145
190
|
const message = t(locale, "pagination.invalidCursor");
|
|
146
191
|
return {
|
|
@@ -166,10 +211,15 @@ export function createBoilerplateMcpServer(deps = {}) {
|
|
|
166
211
|
// MCP Apps (mcp-app-standard A-1). The widget renders THIS result.
|
|
167
212
|
server.registerTool("list_items", {
|
|
168
213
|
title: "List items",
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
"
|
|
172
|
-
"
|
|
214
|
+
// C-19: served in both languages in the one description field ("<en> / <fr>"), with its trigger phrase.
|
|
215
|
+
description: "Lists example items, one page at a time, and shows them as an interactive widget in hosts that " +
|
|
216
|
+
"support MCP Apps. Use this whenever the user asks to list, see or render the items, even if they " +
|
|
217
|
+
"don't say 'list'. On the OAuth tier, results are scoped to the caller's organisation; on the public " +
|
|
218
|
+
"tier (no auth gate), they are unscoped. / " +
|
|
219
|
+
"Liste les éléments d'exemple, une page à la fois, et les affiche en widget interactif dans les hôtes " +
|
|
220
|
+
"MCP Apps. À utiliser quand l'utilisateur demande de lister, voir ou afficher les éléments, même sans " +
|
|
221
|
+
"le mot 'lister'. Sur le niveau OAuth, les résultats sont limités à l'organisation de l'appelant ; sur " +
|
|
222
|
+
"le niveau public (sans authentification), ils ne le sont pas.",
|
|
173
223
|
inputSchema: listItemsInput,
|
|
174
224
|
outputSchema: listItemsOutput,
|
|
175
225
|
annotations: LIST_ITEMS_ANNOTATIONS,
|
|
@@ -178,7 +228,7 @@ export function createBoilerplateMcpServer(deps = {}) {
|
|
|
178
228
|
...ITEMS_TOOL_META,
|
|
179
229
|
description_fr: "Liste les éléments d'exemple, une page à la fois, et les affiche en widget interactif dans les hôtes MCP Apps. Utilise cet outil quand l'utilisateur demande de lister, voir ou afficher les éléments.",
|
|
180
230
|
},
|
|
181
|
-
}, async (args) => {
|
|
231
|
+
}, withRateLimit(async (args) => {
|
|
182
232
|
if (deps.requireTenant && !deps.tenant) {
|
|
183
233
|
// Refuse explicitly — never fall through to an unscoped/master view just because the
|
|
184
234
|
// caller's token had no organization.
|
|
@@ -202,7 +252,7 @@ export function createBoilerplateMcpServer(deps = {}) {
|
|
|
202
252
|
return lakeUnreadable(error.code);
|
|
203
253
|
throw error;
|
|
204
254
|
}
|
|
205
|
-
});
|
|
255
|
+
}));
|
|
206
256
|
// MCP Apps UI layer (io.modelcontextprotocol/ui, spec 2026-01-26). `list_items` is registered
|
|
207
257
|
// with `server.registerTool` and the GA nested `_meta.ui` form, NOT with ext-apps'
|
|
208
258
|
// `registerAppTool`: that helper back-fills the deprecated flat `_meta["ui/resourceUri"]`
|
|
@@ -218,6 +268,38 @@ export function createBoilerplateMcpServer(deps = {}) {
|
|
|
218
268
|
}, async () => ({
|
|
219
269
|
contents: [buildUiResource({ domain: deps.uiDomain })],
|
|
220
270
|
}));
|
|
271
|
+
server.registerTool(IDENTITY_TOOL, {
|
|
272
|
+
title: "Who am I",
|
|
273
|
+
// C-19: both languages in the one served field ("<en> / <fr>") with its trigger phrase; no separate
|
|
274
|
+
// `_meta.description_fr`, which would only repeat the French half.
|
|
275
|
+
description: "Returns who you are to this server: your verified subject, the role your tools are decided by, " +
|
|
276
|
+
"and the tools it grants. Use this whenever you need to know what you may call, even if they don't " +
|
|
277
|
+
"say 'whoami'. / Renvoie qui vous êtes pour ce serveur : sujet vérifié, rôle qui décide de vos " +
|
|
278
|
+
"outils, outils accordés. À utiliser pour savoir ce que vous pouvez appeler, même sans le mot 'whoami'.",
|
|
279
|
+
inputSchema: whoamiInput,
|
|
280
|
+
outputSchema: whoamiOutput,
|
|
281
|
+
annotations: WHOAMI_ANNOTATIONS,
|
|
282
|
+
}, withRateLimit(async () => {
|
|
283
|
+
const person = deps.caller?.ok ? deps.caller.person : undefined;
|
|
284
|
+
const lookup = roles.applicable ? roles.lookup : undefined;
|
|
285
|
+
const role = lookup?.status === "resolved" && roles.applicable
|
|
286
|
+
? {
|
|
287
|
+
id: lookup.role.roleId,
|
|
288
|
+
name: roles.roleName(lookup.role.roleId) ?? lookup.role.roleId,
|
|
289
|
+
}
|
|
290
|
+
: null;
|
|
291
|
+
return structuredResult({
|
|
292
|
+
id: person?.subject ?? null,
|
|
293
|
+
role,
|
|
294
|
+
grantedTools: grantedToolNames(roles, gate.declarations),
|
|
295
|
+
rolesApplicable: roles.applicable,
|
|
296
|
+
});
|
|
297
|
+
}));
|
|
298
|
+
// tools/list narrowed to the granted tools (and the identity tool), once every tool is registered.
|
|
299
|
+
gate.filterList();
|
|
300
|
+
// C-11 (a): a cursor sent to a single-page protocol list method is -32602 (src/listCursor.ts). Installed after the
|
|
301
|
+
// role filter, so it wraps it: the cursor is refused before any list is built (tests/toolRoles.test.ts).
|
|
302
|
+
rejectCursorOnSinglePageLists(server);
|
|
221
303
|
// Start refusal: every registered tool's record, as registered, must be sound.
|
|
222
304
|
served.assertServed(manifest.contextCeiling);
|
|
223
305
|
return server;
|
|
@@ -20,22 +20,26 @@
|
|
|
20
20
|
* FAIL-CLOSED: any missing configuration value answers 503 `temporarily_unavailable`.
|
|
21
21
|
* No refresh tokens are issued; access tokens live one hour.
|
|
22
22
|
*/
|
|
23
|
-
import { buildDiscoveryDocument, buildUserInfo, exchangeAuthorizationCode, oauthErrorFor, resolveClerkSessionFromRequest, resumeAuthorize, startAuthorize, } from "@vantageos/cloud-identity";
|
|
23
|
+
import { buildDiscoveryDocument, buildUserInfo, exchangeAuthorizationCode, oauthErrorFor, resolveClerkSessionFromRequest, resumeAuthorize, startAuthorize, verifyMcpAccessToken, } from "@vantageos/cloud-identity";
|
|
24
|
+
// The private_key_jwt client-assertion verifier lives in the identity layer (DLocalIdentity), as a subpath: it uses
|
|
25
|
+
// node:net / node:dns for its SSRF guard, which the root entry stays free of.
|
|
26
|
+
import { ASSERTION_SIGNING_ALGS, createClientAssertionVerifier, } from "@vantageos/cloud-identity/client-assertion";
|
|
24
27
|
import { getCookie } from "hono/cookie";
|
|
25
28
|
import { cors } from "hono/cors";
|
|
26
29
|
import { flagKnob, integerKnob, originListKnob } from "../envKnobs.js";
|
|
27
30
|
import { detectLocale, detectLocaleFromAcceptLanguage, t } from "../i18n.js";
|
|
28
31
|
import { createClerkBackendCollaborators } from "./clerk.js";
|
|
29
|
-
import { ASSERTION_SIGNING_ALGS, createClientAssertionVerifier } from "./clientAssertion.js";
|
|
30
32
|
import { RegistryFullError, createInMemoryClientRegistry, isAllowedRedirectUri, isUrlShapedClientId, resolveCimdClient, } from "./clientRegistry.js";
|
|
31
33
|
import { createInMemoryCodeStore } from "./codeStore.js";
|
|
32
|
-
import { pickerContentSecurityPolicy, renderOrgPicker } from "./picker.js";
|
|
34
|
+
import { HANDOFF_CSP, pickerContentSecurityPolicy, renderHandoff, renderOrgPicker, } from "./picker.js";
|
|
33
35
|
import { createRegisterLimiter } from "./registerLimiter.js";
|
|
34
36
|
import { DEFAULT_SCOPE, OAUTH_ENV, SUPPORTED_SCOPES, isDevClerkInstanceOnRemoteServer, readAuthorizeSettings, } from "./settings.js";
|
|
35
37
|
import { ACCESS_TOKEN_TTL_SECONDS, createTokenIssuer } from "./tokenIssuer.js";
|
|
36
38
|
const REGISTER_LIMIT_PER_MINUTE = 20;
|
|
37
39
|
/** Default of OAUTH_ENV.registerLimitPer48: five /64 budgets for a whole /48. */
|
|
38
40
|
const REGISTER_LIMIT_PER_48_PER_MINUTE = 100;
|
|
41
|
+
/** Default of OAUTH_ENV.tokenLimitPerMinute: /token requests per client address per minute. */
|
|
42
|
+
const TOKEN_LIMIT_PER_MINUTE = 60;
|
|
39
43
|
const MAX_REDIRECT_URIS = 10;
|
|
40
44
|
const REGISTRY_FULL_RETRY_AFTER_SECONDS = 300;
|
|
41
45
|
function noStore(c) {
|
|
@@ -89,6 +93,7 @@ export function createAuthorizationServer(deps = {}) {
|
|
|
89
93
|
// Operator knobs are validated here, at construction: a bad value stops the boot.
|
|
90
94
|
const trustedProxyCount = integerKnob(env, OAUTH_ENV.trustedProxyCount, 0, 10) ?? 0;
|
|
91
95
|
const registerLimitPer48 = integerKnob(env, OAUTH_ENV.registerLimitPer48, 1) ?? REGISTER_LIMIT_PER_48_PER_MINUTE;
|
|
96
|
+
const tokenLimitPerMinute = integerKnob(env, OAUTH_ENV.tokenLimitPerMinute, 1) ?? TOKEN_LIMIT_PER_MINUTE;
|
|
92
97
|
const registerOrigins = originListKnob(env, OAUTH_ENV.registerCorsOrigins);
|
|
93
98
|
const allowInMemory = flagKnob(env, OAUTH_ENV.allowInMemoryStores) === true;
|
|
94
99
|
const now = deps.now ?? (() => Date.now());
|
|
@@ -198,6 +203,16 @@ export function createAuthorizationServer(deps = {}) {
|
|
|
198
203
|
const client = await lookupClient(model.client.clientId);
|
|
199
204
|
return (client?.redirectUris ?? []).filter((u) => !/^https?:/i.test(u));
|
|
200
205
|
};
|
|
206
|
+
/** The organisations this server authorises (AUTHORIZE_ALLOWED_ORG_IDS), or undefined when every membership is. */
|
|
207
|
+
const allowedOrgIds = () => {
|
|
208
|
+
const raw = env[OAUTH_ENV.allowedOrgIds]?.trim();
|
|
209
|
+
if (!raw)
|
|
210
|
+
return undefined;
|
|
211
|
+
return new Set(raw
|
|
212
|
+
.split(",")
|
|
213
|
+
.map((s) => s.trim())
|
|
214
|
+
.filter(Boolean));
|
|
215
|
+
};
|
|
201
216
|
const respond = async (c, outcome, settings) => {
|
|
202
217
|
noStore(c);
|
|
203
218
|
c.header("Referrer-Policy", "no-referrer");
|
|
@@ -208,12 +223,33 @@ export function createAuthorizationServer(deps = {}) {
|
|
|
208
223
|
// RFC 9207 section 2: identify the issuer in the authorization response.
|
|
209
224
|
const back = new URL(outcome.url);
|
|
210
225
|
back.searchParams.set("iss", settings.baseUrl);
|
|
226
|
+
// The consent form's POST ends here, in a same-origin page, so its form-action does not follow the client's
|
|
227
|
+
// own redirect hops (renderHandoff). A GET (the return from sign-in) still answers a plain redirect.
|
|
228
|
+
if (c.req.method === "POST") {
|
|
229
|
+
c.header("Content-Security-Policy", HANDOFF_CSP);
|
|
230
|
+
const locale = detectLocaleFromAcceptLanguage(c.req.header("accept-language"));
|
|
231
|
+
return c.html(renderHandoff(back.toString(), locale));
|
|
232
|
+
}
|
|
211
233
|
return c.redirect(back.toString(), 302);
|
|
212
234
|
}
|
|
213
235
|
case "org-picker": {
|
|
236
|
+
// Only this server's authorised organisations are offered (data per server).
|
|
237
|
+
const allowed = allowedOrgIds();
|
|
238
|
+
let model = outcome.model;
|
|
239
|
+
if (allowed) {
|
|
240
|
+
const organizations = model.organizations.filter((o) => allowed.has(o.id));
|
|
241
|
+
if (organizations.length === 0) {
|
|
242
|
+
const locale = detectLocaleFromAcceptLanguage(c.req.header("accept-language"));
|
|
243
|
+
return c.json({
|
|
244
|
+
error: "access_denied",
|
|
245
|
+
error_description: t(locale, "oauth.pickerNoAllowedOrganisation"),
|
|
246
|
+
}, 403);
|
|
247
|
+
}
|
|
248
|
+
model = { ...model, organizations };
|
|
249
|
+
}
|
|
214
250
|
let csp;
|
|
215
251
|
try {
|
|
216
|
-
csp = await pickerContentSecurityPolicy(await redirectTargetsOf(
|
|
252
|
+
csp = await pickerContentSecurityPolicy(await redirectTargetsOf(model));
|
|
217
253
|
}
|
|
218
254
|
catch (err) {
|
|
219
255
|
// Fail closed: without a parseable destination the page is not rendered at all.
|
|
@@ -222,9 +258,9 @@ export function createAuthorizationServer(deps = {}) {
|
|
|
222
258
|
}
|
|
223
259
|
c.header("Content-Security-Policy", csp);
|
|
224
260
|
const locale = detectLocaleFromAcceptLanguage(c.req.header("accept-language"));
|
|
225
|
-
return c.html(renderOrgPicker(
|
|
261
|
+
return c.html(renderOrgPicker(model, locale, {
|
|
226
262
|
// Only a URL-shaped (CIMD) client_id is bound to a domain its owner controls.
|
|
227
|
-
unverified: !isUrlShapedClientId(
|
|
263
|
+
unverified: !isUrlShapedClientId(model.client.clientId),
|
|
228
264
|
brandName: env[OAUTH_ENV.brandName]?.trim() || undefined,
|
|
229
265
|
}));
|
|
230
266
|
}
|
|
@@ -239,6 +275,14 @@ export function createAuthorizationServer(deps = {}) {
|
|
|
239
275
|
prefix48LimitPerMinute: registerLimitPer48,
|
|
240
276
|
now,
|
|
241
277
|
});
|
|
278
|
+
// /token is unauthenticated until the client is verified, and each private_key_jwt assertion it verifies occupies a
|
|
279
|
+
// single-use jti slot until it expires: an unbounded caller could fill the jti store. The same address-keyed
|
|
280
|
+
// limiter as /register (an IPv6 client on its /64, and its /48 at five budgets) bounds every caller.
|
|
281
|
+
const tokenLimiter = createRegisterLimiter({
|
|
282
|
+
limitPerMinute: tokenLimitPerMinute,
|
|
283
|
+
prefix48LimitPerMinute: tokenLimitPerMinute * 5,
|
|
284
|
+
now,
|
|
285
|
+
});
|
|
242
286
|
return {
|
|
243
287
|
async resourceServer() {
|
|
244
288
|
const result = await settingsNow();
|
|
@@ -463,6 +507,15 @@ export function createAuthorizationServer(deps = {}) {
|
|
|
463
507
|
}, cfg, authDeps);
|
|
464
508
|
return respond(c, denied, result.settings);
|
|
465
509
|
}
|
|
510
|
+
// An approval for an organisation this server does not authorise never reaches the code issuer.
|
|
511
|
+
const allowed = allowedOrgIds();
|
|
512
|
+
if (allowed && !allowed.has(field("orgId") ?? "")) {
|
|
513
|
+
const locale = detectLocaleFromAcceptLanguage(c.req.header("accept-language"));
|
|
514
|
+
return c.json({
|
|
515
|
+
error: "access_denied",
|
|
516
|
+
error_description: t(locale, "oauth.pickerNoAllowedOrganisation"),
|
|
517
|
+
}, 403);
|
|
518
|
+
}
|
|
466
519
|
const outcome = await resumeAuthorize({
|
|
467
520
|
state: field("state") ?? "",
|
|
468
521
|
sessionToken: sessionTokenFrom(c),
|
|
@@ -473,6 +526,10 @@ export function createAuthorizationServer(deps = {}) {
|
|
|
473
526
|
return respond(c, outcome, result.settings);
|
|
474
527
|
});
|
|
475
528
|
app.post("/token", async (c) => {
|
|
529
|
+
if (!tokenLimiter.allow(clientKeyOf(c, trustedProxyCount))) {
|
|
530
|
+
c.header("Retry-After", "60");
|
|
531
|
+
return c.json({ error: "too_many_requests" }, 429);
|
|
532
|
+
}
|
|
476
533
|
const result = await settingsNow();
|
|
477
534
|
if (!result.ok)
|
|
478
535
|
return unavailable(c, result.missing);
|
|
@@ -550,22 +607,26 @@ export function createAuthorizationServer(deps = {}) {
|
|
|
550
607
|
const result = await settingsNow();
|
|
551
608
|
if (!result.ok)
|
|
552
609
|
return unavailable(c, result.missing);
|
|
553
|
-
const header = c.req.header("authorization") ?? "";
|
|
554
|
-
const token = header.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() : "";
|
|
555
610
|
const invalid = () => {
|
|
556
611
|
c.header("WWW-Authenticate", 'Bearer error="invalid_token"');
|
|
557
612
|
return c.json({ error: "invalid_token" }, 401);
|
|
558
613
|
};
|
|
559
|
-
|
|
560
|
-
|
|
614
|
+
// Our own access token is verified by the identity layer, never here (DLocalIdentity):
|
|
615
|
+
// issuer = this server, audience = the protected resource, key set = our published JWKS.
|
|
561
616
|
const issuer = await issuerFor(result.settings);
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
617
|
+
const verified = await verifyMcpAccessToken(c.req.header("authorization"), {
|
|
618
|
+
realm: "mcp",
|
|
619
|
+
resourceMetadataUrl: `${result.settings.baseUrl}/.well-known/oauth-protected-resource`,
|
|
620
|
+
issuer: result.settings.baseUrl,
|
|
621
|
+
audience: result.settings.resource,
|
|
622
|
+
jwks: issuer.jwks,
|
|
623
|
+
}, now());
|
|
624
|
+
if (!verified.ok)
|
|
567
625
|
return invalid();
|
|
568
|
-
|
|
626
|
+
const claims = {
|
|
627
|
+
sub: verified.session.userId,
|
|
628
|
+
scope: String(verified.session.claims.scope ?? ""),
|
|
629
|
+
};
|
|
569
630
|
let user;
|
|
570
631
|
try {
|
|
571
632
|
user = await clerkFor().getUser(claims.sub);
|
package/dist/oauth/picker.d.ts
CHANGED
|
@@ -19,3 +19,14 @@ export interface ConsentContext {
|
|
|
19
19
|
brandName?: string;
|
|
20
20
|
}
|
|
21
21
|
export declare function renderOrgPicker(model: OrgPickerModel, locale: McpLocale, consent: ConsentContext): string;
|
|
22
|
+
/** The CSP of the hand-off page: nothing runs, nothing is submitted, nothing frames it. */
|
|
23
|
+
export declare const HANDOFF_CSP = "default-src 'none'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'";
|
|
24
|
+
/**
|
|
25
|
+
* The page that ends the consent form submission inside this origin and sends the browser on to the client.
|
|
26
|
+
*
|
|
27
|
+
* A browser applies the consent page's `form-action` to every redirect hop of the form submission, so answering the
|
|
28
|
+
* POST with a 302 to the client's redirect_uri lets the client's OWN next hop (vscode.dev/redirect -> vscode:// or a
|
|
29
|
+
* loopback) be blocked by our policy. This page is a 200 response: the navigation to the client is a meta refresh (and
|
|
30
|
+
* a visible link), not a form submission, so `form-action` no longer follows it.
|
|
31
|
+
*/
|
|
32
|
+
export declare function renderHandoff(target: string, locale: McpLocale): string;
|
package/dist/oauth/picker.js
CHANGED
|
@@ -92,6 +92,7 @@ function formActionSources(redirectUris) {
|
|
|
92
92
|
*/
|
|
93
93
|
export async function pickerContentSecurityPolicy(redirectUris) {
|
|
94
94
|
const formAction = ["'self'", ...formActionSources(redirectUris)].join(" ");
|
|
95
|
+
// allow-local-identity: CSP hash of the picker's own constant stylesheet; no credential, caller or tenant
|
|
95
96
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(PICKER_STYLE));
|
|
96
97
|
const hash = btoa(String.fromCharCode(...new Uint8Array(digest)));
|
|
97
98
|
return [
|
|
@@ -148,3 +149,30 @@ ${orgs}
|
|
|
148
149
|
</html>
|
|
149
150
|
`;
|
|
150
151
|
}
|
|
152
|
+
/** The CSP of the hand-off page: nothing runs, nothing is submitted, nothing frames it. */
|
|
153
|
+
export const HANDOFF_CSP = "default-src 'none'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'";
|
|
154
|
+
/**
|
|
155
|
+
* The page that ends the consent form submission inside this origin and sends the browser on to the client.
|
|
156
|
+
*
|
|
157
|
+
* A browser applies the consent page's `form-action` to every redirect hop of the form submission, so answering the
|
|
158
|
+
* POST with a 302 to the client's redirect_uri lets the client's OWN next hop (vscode.dev/redirect -> vscode:// or a
|
|
159
|
+
* loopback) be blocked by our policy. This page is a 200 response: the navigation to the client is a meta refresh (and
|
|
160
|
+
* a visible link), not a form submission, so `form-action` no longer follows it.
|
|
161
|
+
*/
|
|
162
|
+
export function renderHandoff(target, locale) {
|
|
163
|
+
const url = escapeHtml(target);
|
|
164
|
+
const title = escapeHtml(t(locale, "oauth.handoffTitle"));
|
|
165
|
+
return `<!doctype html>
|
|
166
|
+
<html lang="${locale}">
|
|
167
|
+
<head>
|
|
168
|
+
<meta charset="utf-8">
|
|
169
|
+
<meta http-equiv="refresh" content="0;url=${url}">
|
|
170
|
+
<title>${title}</title>
|
|
171
|
+
</head>
|
|
172
|
+
<body>
|
|
173
|
+
<p>${title}</p>
|
|
174
|
+
<p><a href="${url}">${escapeHtml(t(locale, "oauth.handoffLink"))}</a></p>
|
|
175
|
+
</body>
|
|
176
|
+
</html>
|
|
177
|
+
`;
|
|
178
|
+
}
|
package/dist/oauth/settings.d.ts
CHANGED
|
@@ -18,12 +18,19 @@ export declare const OAUTH_ENV: {
|
|
|
18
18
|
readonly trustedProxyCount: "MCP_TRUSTED_PROXY_COUNT";
|
|
19
19
|
/** /register budget shared by every /64 inside one IPv6 /48, per minute (default 100). */
|
|
20
20
|
readonly registerLimitPer48: "MCP_REGISTER_LIMIT_PER_48_PER_MINUTE";
|
|
21
|
+
/** /token requests per client address per minute (default 60); its /48 gets five times that. */
|
|
22
|
+
readonly tokenLimitPerMinute: "MCP_TOKEN_LIMIT_PER_MINUTE";
|
|
21
23
|
/** Comma-separated browser origins allowed to call /register cross-origin (default: none). */
|
|
22
24
|
readonly registerCorsOrigins: "MCP_REGISTER_CORS_ORIGINS";
|
|
23
25
|
/** Exactly "1" lets NODE_ENV=production run on the in-memory stores (single instance only). */
|
|
24
26
|
readonly allowInMemoryStores: "MCP_ALLOW_IN_MEMORY_STORES";
|
|
25
27
|
/** Optional display name shown above the consent page title (default: none). */
|
|
26
28
|
readonly brandName: "AUTHORIZE_BRAND_NAME";
|
|
29
|
+
/**
|
|
30
|
+
* Optional comma-separated Clerk organisation ids this server authorises. When set, the consent offers only the
|
|
31
|
+
* person's memberships in this list and refuses an approval for any other. Data per server, never code.
|
|
32
|
+
*/
|
|
33
|
+
readonly allowedOrgIds: "AUTHORIZE_ALLOWED_ORG_IDS";
|
|
27
34
|
};
|
|
28
35
|
/** Scope granted when a client names none: MCP access plus the OIDC subject. */
|
|
29
36
|
export declare const MCP_SCOPE = "mcp:full";
|
package/dist/oauth/settings.js
CHANGED
|
@@ -18,12 +18,19 @@ export const OAUTH_ENV = {
|
|
|
18
18
|
trustedProxyCount: "MCP_TRUSTED_PROXY_COUNT",
|
|
19
19
|
/** /register budget shared by every /64 inside one IPv6 /48, per minute (default 100). */
|
|
20
20
|
registerLimitPer48: "MCP_REGISTER_LIMIT_PER_48_PER_MINUTE",
|
|
21
|
+
/** /token requests per client address per minute (default 60); its /48 gets five times that. */
|
|
22
|
+
tokenLimitPerMinute: "MCP_TOKEN_LIMIT_PER_MINUTE",
|
|
21
23
|
/** Comma-separated browser origins allowed to call /register cross-origin (default: none). */
|
|
22
24
|
registerCorsOrigins: "MCP_REGISTER_CORS_ORIGINS",
|
|
23
25
|
/** Exactly "1" lets NODE_ENV=production run on the in-memory stores (single instance only). */
|
|
24
26
|
allowInMemoryStores: "MCP_ALLOW_IN_MEMORY_STORES",
|
|
25
27
|
/** Optional display name shown above the consent page title (default: none). */
|
|
26
28
|
brandName: "AUTHORIZE_BRAND_NAME",
|
|
29
|
+
/**
|
|
30
|
+
* Optional comma-separated Clerk organisation ids this server authorises. When set, the consent offers only the
|
|
31
|
+
* person's memberships in this list and refuses an approval for any other. Data per server, never code.
|
|
32
|
+
*/
|
|
33
|
+
allowedOrgIds: "AUTHORIZE_ALLOWED_ORG_IDS",
|
|
27
34
|
};
|
|
28
35
|
/** Scope granted when a client names none: MCP access plus the OIDC subject. */
|
|
29
36
|
export const MCP_SCOPE = "mcp:full";
|
|
@@ -15,7 +15,6 @@ export interface TokenIssuer {
|
|
|
15
15
|
keys: JWK[];
|
|
16
16
|
};
|
|
17
17
|
sign(claims: AuthorizedTokenClaims, issuer: string, nowMs: number): Promise<string>;
|
|
18
|
-
verify(token: string, issuer: string, audience: string): Promise<AuthorizedTokenClaims>;
|
|
19
18
|
}
|
|
20
19
|
export declare function createTokenIssuer(privateJwk: JWK & {
|
|
21
20
|
kid: string;
|