@vantageos/mcp-boilerplate 0.6.3 → 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 +27 -0
- package/README.fr.md +97 -5
- package/README.md +93 -7
- 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 +12 -1
- package/dist/locales/fr.json +12 -1
- package/dist/mcpServerFactory.d.ts +11 -2
- package/dist/mcpServerFactory.js +97 -15
- package/dist/oauth/authorizationServer.js +33 -12
- package/dist/oauth/picker.js +1 -0
- package/dist/oauth/settings.d.ts +2 -0
- package/dist/oauth/settings.js +2 -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
package/dist/locales/fr.json
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
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
|
},
|
|
@@ -42,9 +45,17 @@
|
|
|
42
45
|
"object": "un objet",
|
|
43
46
|
"boolean": "true ou false",
|
|
44
47
|
"positiveInteger": "un entier strictement positif",
|
|
45
|
-
"atLeastPerTool": "au moins le plafond perTool"
|
|
48
|
+
"atLeastPerTool": "au moins le plafond perTool",
|
|
49
|
+
"stringArray": "un tableau de chaînes non vides"
|
|
46
50
|
}
|
|
47
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
|
+
},
|
|
48
59
|
"toolRecord": {
|
|
49
60
|
"unsound": "fiches d'outils ou plafond de contexte invalides : {problems}"
|
|
50
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,13 +20,15 @@
|
|
|
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
34
|
import { HANDOFF_CSP, pickerContentSecurityPolicy, renderHandoff, renderOrgPicker, } from "./picker.js";
|
|
@@ -36,6 +38,8 @@ 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());
|
|
@@ -270,6 +275,14 @@ export function createAuthorizationServer(deps = {}) {
|
|
|
270
275
|
prefix48LimitPerMinute: registerLimitPer48,
|
|
271
276
|
now,
|
|
272
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
|
+
});
|
|
273
286
|
return {
|
|
274
287
|
async resourceServer() {
|
|
275
288
|
const result = await settingsNow();
|
|
@@ -513,6 +526,10 @@ export function createAuthorizationServer(deps = {}) {
|
|
|
513
526
|
return respond(c, outcome, result.settings);
|
|
514
527
|
});
|
|
515
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
|
+
}
|
|
516
533
|
const result = await settingsNow();
|
|
517
534
|
if (!result.ok)
|
|
518
535
|
return unavailable(c, result.missing);
|
|
@@ -590,22 +607,26 @@ export function createAuthorizationServer(deps = {}) {
|
|
|
590
607
|
const result = await settingsNow();
|
|
591
608
|
if (!result.ok)
|
|
592
609
|
return unavailable(c, result.missing);
|
|
593
|
-
const header = c.req.header("authorization") ?? "";
|
|
594
|
-
const token = header.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() : "";
|
|
595
610
|
const invalid = () => {
|
|
596
611
|
c.header("WWW-Authenticate", 'Bearer error="invalid_token"');
|
|
597
612
|
return c.json({ error: "invalid_token" }, 401);
|
|
598
613
|
};
|
|
599
|
-
|
|
600
|
-
|
|
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.
|
|
601
616
|
const issuer = await issuerFor(result.settings);
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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)
|
|
607
625
|
return invalid();
|
|
608
|
-
|
|
626
|
+
const claims = {
|
|
627
|
+
sub: verified.session.userId,
|
|
628
|
+
scope: String(verified.session.claims.scope ?? ""),
|
|
629
|
+
};
|
|
609
630
|
let user;
|
|
610
631
|
try {
|
|
611
632
|
user = await clerkFor().getUser(claims.sub);
|
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 [
|
package/dist/oauth/settings.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ 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). */
|
package/dist/oauth/settings.js
CHANGED
|
@@ -18,6 +18,8 @@ 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). */
|
|
@@ -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;
|
|
@@ -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 {
|