@vantageos/mcp-boilerplate 0.6.3 → 0.8.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 +39 -0
- package/README.fr.md +146 -10
- package/README.md +141 -12
- package/dist/build-info.json +1 -0
- package/dist/buildIdentity.d.ts +16 -0
- package/dist/buildIdentity.js +39 -0
- package/dist/embedded.generated.d.ts +225 -0
- package/dist/embedded.generated.js +8 -0
- package/dist/hostVerification.d.ts +15 -0
- package/dist/hostVerification.js +32 -0
- package/dist/http.js +9 -4
- package/dist/httpApp.d.ts +15 -0
- package/dist/httpApp.js +119 -9
- package/dist/i18n.js +6 -16
- package/dist/landingPage.d.ts +24 -0
- package/dist/landingPage.js +109 -0
- package/dist/listCursor.d.ts +10 -0
- package/dist/listCursor.js +37 -0
- package/dist/mcpServerFactory.d.ts +22 -2
- package/dist/mcpServerFactory.js +125 -22
- 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/servedToolRecords.d.ts +6 -0
- package/dist/servedToolRecords.js +11 -1
- package/dist/serverCard.d.ts +68 -0
- package/dist/serverCard.js +68 -0
- package/dist/serverIcon.d.ts +17 -0
- package/dist/serverIcon.js +26 -0
- package/dist/serverIdentity.d.ts +83 -0
- package/dist/serverIdentity.js +130 -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/ui/uiResource.d.ts +14 -1
- package/dist/ui/uiResource.js +11 -1
- package/dist/upstream/paginatedClient.js +1 -0
- package/dist/verifiedPerson.d.ts +6 -0
- package/dist/verifiedPerson.js +9 -0
- package/dist/version.js +6 -5
- package/docs/manufact-gap.md +200 -0
- package/docs/releasing.md +19 -0
- package/mcp-server.json +3 -2
- package/package.json +11 -3
- package/plugin.json +2 -4
- package/scripts/plugin-manifest.source.json +19 -0
- package/scripts/readme-fr.mjs +11 -4
- package/dist/locales/en.json +0 -77
- package/dist/locales/fr.json +0 -77
- package/dist/oauth/clientAssertion.d.ts +0 -28
- package/dist/oauth/clientAssertion.js +0 -106
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public setup page at the MCP URL (mcp-server-standard S-50, Manufact "Setup instructions at
|
|
3
|
+
* the MCP URL"). A person who pastes the MCP URL into a browser gets a short bilingual page
|
|
4
|
+
* saying what the server is and how to connect, instead of a bare 405 or 401.
|
|
5
|
+
*
|
|
6
|
+
* The page is static text built from src/serverIdentity.ts and the locale files: it reads no
|
|
7
|
+
* credential, calls no backend and grants nothing. Only a request that cannot be an MCP request
|
|
8
|
+
* is answered with it (`isBrowserLandingRequest`); every other request on /mcp goes on to the
|
|
9
|
+
* Origin check, the auth gate and the MCP handler exactly as before, including the
|
|
10
|
+
* 401 + WWW-Authenticate that OAuth discovery depends on.
|
|
11
|
+
*/
|
|
12
|
+
import type { AuthMode } from "./config.js";
|
|
13
|
+
import type { ServerIdentity } from "./serverIdentity.js";
|
|
14
|
+
/**
|
|
15
|
+
* True only for a browser-style GET: no Authorization header, an Accept that does not include
|
|
16
|
+
* text/event-stream, and an Accept that includes text/html, or is exactly `*` `/` `*`, or is
|
|
17
|
+
* absent. RFC 9110 §12.5.1: an absent Accept means any media type; an MCP client opening the SSE
|
|
18
|
+
* stream always sends `Accept: text/event-stream`, so none of these is an MCP request.
|
|
19
|
+
*/
|
|
20
|
+
export declare function isBrowserLandingRequest(req: Request): boolean;
|
|
21
|
+
/** The page for the server whose MCP endpoint is `mcpUrl`: English, then French. */
|
|
22
|
+
export declare function renderLandingPage(identity: ServerIdentity, mcpUrl: string, authMode: AuthMode): string;
|
|
23
|
+
/** The setup page as an HTTP response. `cacheable`: see serverCardResponse (src/serverCard.ts). */
|
|
24
|
+
export declare function landingPageResponse(identity: ServerIdentity, mcpUrl: string, authMode: AuthMode, cacheable: boolean): Response;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public setup page at the MCP URL (mcp-server-standard S-50, Manufact "Setup instructions at
|
|
3
|
+
* the MCP URL"). A person who pastes the MCP URL into a browser gets a short bilingual page
|
|
4
|
+
* saying what the server is and how to connect, instead of a bare 405 or 401.
|
|
5
|
+
*
|
|
6
|
+
* The page is static text built from src/serverIdentity.ts and the locale files: it reads no
|
|
7
|
+
* credential, calls no backend and grants nothing. Only a request that cannot be an MCP request
|
|
8
|
+
* is answered with it (`isBrowserLandingRequest`); every other request on /mcp goes on to the
|
|
9
|
+
* Origin check, the auth gate and the MCP handler exactly as before, including the
|
|
10
|
+
* 401 + WWW-Authenticate that OAuth discovery depends on.
|
|
11
|
+
*/
|
|
12
|
+
import { t } from "./i18n.js";
|
|
13
|
+
/** The media ranges of an Accept header, lower-cased, parameters dropped. */
|
|
14
|
+
function mediaRanges(accept) {
|
|
15
|
+
return accept
|
|
16
|
+
.split(",")
|
|
17
|
+
.map((part) => (part.split(";")[0] ?? "").trim().toLowerCase())
|
|
18
|
+
.filter((part) => part.length > 0);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* True only for a browser-style GET: no Authorization header, an Accept that does not include
|
|
22
|
+
* text/event-stream, and an Accept that includes text/html, or is exactly `*` `/` `*`, or is
|
|
23
|
+
* absent. RFC 9110 §12.5.1: an absent Accept means any media type; an MCP client opening the SSE
|
|
24
|
+
* stream always sends `Accept: text/event-stream`, so none of these is an MCP request.
|
|
25
|
+
*/
|
|
26
|
+
export function isBrowserLandingRequest(req) {
|
|
27
|
+
if (req.method !== "GET")
|
|
28
|
+
return false;
|
|
29
|
+
if (req.headers.has("authorization"))
|
|
30
|
+
return false;
|
|
31
|
+
const accept = req.headers.get("accept");
|
|
32
|
+
if (accept === null)
|
|
33
|
+
return true;
|
|
34
|
+
const ranges = mediaRanges(accept);
|
|
35
|
+
if (ranges.includes("text/event-stream"))
|
|
36
|
+
return false;
|
|
37
|
+
if (ranges.includes("text/html"))
|
|
38
|
+
return true;
|
|
39
|
+
return ranges.length === 1 && ranges[0] === "*/*";
|
|
40
|
+
}
|
|
41
|
+
function escapeHtml(value) {
|
|
42
|
+
return value
|
|
43
|
+
.replace(/&/g, "&")
|
|
44
|
+
.replace(/</g, "<")
|
|
45
|
+
.replace(/>/g, ">")
|
|
46
|
+
.replace(/"/g, """)
|
|
47
|
+
.replace(/'/g, "'");
|
|
48
|
+
}
|
|
49
|
+
function section(locale, identity, mcpUrl, authMode) {
|
|
50
|
+
const tx = (key) => escapeHtml(t(locale, `landing.${key}`));
|
|
51
|
+
const endpoint = escapeHtml(mcpUrl);
|
|
52
|
+
const site = escapeHtml(identity.websiteUrl);
|
|
53
|
+
const add = escapeHtml(`claude mcp add --transport http ${identity.name} ${mcpUrl}`);
|
|
54
|
+
return `<section lang="${locale}">
|
|
55
|
+
<h1>${escapeHtml(identity.title)}</h1>
|
|
56
|
+
<p>${escapeHtml(identity.description[locale])}</p>
|
|
57
|
+
<p>${tx("intro")}</p>
|
|
58
|
+
<p><code>${endpoint}</code></p>
|
|
59
|
+
<ul>
|
|
60
|
+
<li>${tx("claudeAi")}</li>
|
|
61
|
+
<li>${tx("chatgpt")}</li>
|
|
62
|
+
<li>${tx("claudeCode")} <code>${add}</code></li>
|
|
63
|
+
<li>${tx("other")}</li>
|
|
64
|
+
</ul>
|
|
65
|
+
<p>${tx(authMode === "oauth" ? "authOauth" : "authPublic")}</p>
|
|
66
|
+
<p>${tx("website")} <a href="${site}">${site}</a></p>
|
|
67
|
+
</section>`;
|
|
68
|
+
}
|
|
69
|
+
/** The page for the server whose MCP endpoint is `mcpUrl`: English, then French. */
|
|
70
|
+
export function renderLandingPage(identity, mcpUrl, authMode) {
|
|
71
|
+
const title = escapeHtml(t("en", "landing.pageTitle").replace("{title}", () => identity.title));
|
|
72
|
+
return `<!doctype html>
|
|
73
|
+
<html lang="en">
|
|
74
|
+
<head>
|
|
75
|
+
<meta charset="utf-8">
|
|
76
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
77
|
+
<title>${title}</title>
|
|
78
|
+
<link rel="icon" href="icon.svg" type="image/svg+xml">
|
|
79
|
+
<style>
|
|
80
|
+
body { font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; max-width: 42rem; margin: 3rem auto; padding: 0 1.25rem; color: #1f2328; background: #ffffff; }
|
|
81
|
+
code { background: #f6f8fa; padding: 0.1rem 0.35rem; border-radius: 4px; overflow-wrap: anywhere; }
|
|
82
|
+
h1 { font-size: 1.6rem; margin-bottom: 0.25rem; }
|
|
83
|
+
li { margin: 0.4rem 0; }
|
|
84
|
+
section + section { border-top: 1px solid #d0d7de; margin-top: 2rem; padding-top: 1rem; }
|
|
85
|
+
@media (prefers-color-scheme: dark) { body { background: #0d1117; color: #e6edf3; } code { background: #161b22; } a { color: #58a6ff; } section + section { border-color: #30363d; } }
|
|
86
|
+
</style>
|
|
87
|
+
</head>
|
|
88
|
+
<body>
|
|
89
|
+
<main>
|
|
90
|
+
${section("en", identity, mcpUrl, authMode)}
|
|
91
|
+
${section("fr", identity, mcpUrl, authMode)}
|
|
92
|
+
</main>
|
|
93
|
+
</body>
|
|
94
|
+
</html>`;
|
|
95
|
+
}
|
|
96
|
+
/** The setup page as an HTTP response. `cacheable`: see serverCardResponse (src/serverCard.ts). */
|
|
97
|
+
export function landingPageResponse(identity, mcpUrl, authMode, cacheable) {
|
|
98
|
+
return new Response(renderLandingPage(identity, mcpUrl, authMode), {
|
|
99
|
+
status: 200,
|
|
100
|
+
headers: {
|
|
101
|
+
"content-type": "text/html; charset=utf-8",
|
|
102
|
+
"cache-control": cacheable ? "public, max-age=300" : "no-store",
|
|
103
|
+
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
104
|
+
"x-content-type-options": "nosniff",
|
|
105
|
+
// The same URL answers MCP requests differently by method, Accept and credential.
|
|
106
|
+
vary: "Accept, Authorization",
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type McpServer } 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 declare const SINGLE_PAGE_LISTS: readonly ["tools/list", "resources/list", "resources/templates/list", "prompts/list"];
|
|
10
|
+
export declare function rejectCursorOnSinglePageLists(server: McpServer): void;
|
|
@@ -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
|
+
}
|
|
@@ -32,8 +32,11 @@ 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";
|
|
36
|
+
import { type ServedToolRecords } from "./servedToolRecords.js";
|
|
35
37
|
import type { CursorSigner } from "./signedCursor.js";
|
|
36
38
|
import { type ResolvedTenant } from "./tenancy.js";
|
|
39
|
+
import { type ToolRolesContext } from "./toolRoleGate.js";
|
|
37
40
|
import type { CallerResolution } from "./verifiedPerson.js";
|
|
38
41
|
/** Oldest 2025-era protocol revision served; the legacy `initialize` handshake never offers older. */
|
|
39
42
|
export declare const SUPPORTED_LEGACY_PROTOCOL_VERSIONS: string[];
|
|
@@ -65,8 +68,15 @@ export interface McpServerDeps {
|
|
|
65
68
|
*/
|
|
66
69
|
locale?: McpLocale;
|
|
67
70
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
71
|
+
* The base URL (ending in `/`) the HTTP transport is reached at, resolved per request by
|
|
72
|
+
* src/httpApp.ts: the serverInfo icon is served under it. Absent (stdio): the icon travels inline.
|
|
73
|
+
*/
|
|
74
|
+
baseUrl?: string;
|
|
75
|
+
/** The tool invocation limiter (S-24); default the process-wide `toolRateLimiter`. A test injects its own. */
|
|
76
|
+
rateLimiter?: RateLimiter;
|
|
77
|
+
/**
|
|
78
|
+
* Dedicated sandbox origin for the MCP Apps widget (`_meta.ui.domain`), derived from the served MCP URL by
|
|
79
|
+
* `claudeSandboxDomain` (src/ui/sandboxDomain.ts, A-11). Omitted from the resource when there is no served URL.
|
|
70
80
|
*/
|
|
71
81
|
uiDomain?: string;
|
|
72
82
|
/**
|
|
@@ -75,6 +85,11 @@ export interface McpServerDeps {
|
|
|
75
85
|
* before. A derived server injects its own (e.g. `@vantageos/mcp-boilerplate/lake/convex`).
|
|
76
86
|
*/
|
|
77
87
|
lake?: LakeReader<ExampleItem>;
|
|
88
|
+
/**
|
|
89
|
+
* Agent tool roles for this request (src/toolRoleGate.ts): on the OAuth tier, the caller's role lookup and member
|
|
90
|
+
* scope, resolved by httpApp before the server is built; elsewhere `{ applicable: false }`. Default: not applicable.
|
|
91
|
+
*/
|
|
92
|
+
toolRoles?: ToolRolesContext;
|
|
78
93
|
/**
|
|
79
94
|
* Signs and verifies the `list_items` cursor (src/signedCursor.ts). Default: the process-wide
|
|
80
95
|
* signer of src/pagination.ts, whose key is random per process. A multi-instance deployment
|
|
@@ -83,3 +98,8 @@ export interface McpServerDeps {
|
|
|
83
98
|
cursorSigner?: CursorSigner;
|
|
84
99
|
}
|
|
85
100
|
export declare function createBoilerplateMcpServer(deps?: McpServerDeps): McpServer;
|
|
101
|
+
/**
|
|
102
|
+
* The tools a server built by this factory registered (name, title, description), for the public
|
|
103
|
+
* server card (src/serverCard.ts): read from the registrations, never a second list.
|
|
104
|
+
*/
|
|
105
|
+
export declare function registeredToolSummaries(server: McpServer): ServedToolRecords["summaries"];
|
package/dist/mcpServerFactory.js
CHANGED
|
@@ -31,19 +31,25 @@
|
|
|
31
31
|
import { registerAppResource } from "@modelcontextprotocol/ext-apps/server";
|
|
32
32
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
33
33
|
import { z } from "zod";
|
|
34
|
-
import { getBuildIdentity } from "./buildIdentity.js";
|
|
34
|
+
import { getBuildCommit, 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";
|
|
41
|
+
import { getServerIdentity, serverInstructions, serverPresentation } from "./serverIdentity.js";
|
|
39
42
|
import { loadServerManifest } from "./serverManifest.js";
|
|
40
43
|
import { scopeRowsForTenant } from "./tenancy.js";
|
|
41
|
-
import { LIST_ITEMS_ANNOTATIONS, PING_ANNOTATIONS } from "./toolRecord.js";
|
|
44
|
+
import { LIST_ITEMS_ANNOTATIONS, PING_ANNOTATIONS, WHOAMI_ANNOTATIONS } from "./toolRecord.js";
|
|
45
|
+
import { grantedToolNames, installToolRoleGate } from "./toolRoleGate.js";
|
|
42
46
|
import { ITEMS_TOOL_META, ITEMS_UI_RESOURCE_URI, buildItemsUiResourceMeta, buildListItemsResult, buildUiResource, } from "./ui/uiResource.js";
|
|
43
47
|
import { SERVER_NAME, SERVER_VERSION } from "./version.js";
|
|
44
48
|
/** Oldest 2025-era protocol revision served; the legacy `initialize` handshake never offers older. */
|
|
45
49
|
export const SUPPORTED_LEGACY_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18"];
|
|
46
|
-
|
|
50
|
+
// S-44: output objects stay open (z.looseObject): a field added in a later release must not fail a client that
|
|
51
|
+
// validated against the schema it read at connect time.
|
|
52
|
+
const itemSchema = z.looseObject({
|
|
47
53
|
id: z.string(),
|
|
48
54
|
name: z.string(),
|
|
49
55
|
orgId: z.string(),
|
|
@@ -54,7 +60,21 @@ const localeSchema = z.enum(["en", "fr"]);
|
|
|
54
60
|
const pingInput = z.object({
|
|
55
61
|
message: z.string().optional().describe("Optional message echoed back verbatim"),
|
|
56
62
|
});
|
|
57
|
-
const pingOutput = z.
|
|
63
|
+
const pingOutput = z.looseObject({ reply: z.string() });
|
|
64
|
+
/** The identity tool takes no argument (C-33): the identity never arrives as an argument. */
|
|
65
|
+
const whoamiInput = z.object({});
|
|
66
|
+
const whoamiOutput = z.looseObject({
|
|
67
|
+
/** The verified subject behind the credentials; null where there is no caller (public HTTP, stdio). */
|
|
68
|
+
id: z.string().nullable(),
|
|
69
|
+
/** The caller's role, or null: no role, or roles not applicable. */
|
|
70
|
+
role: z.looseObject({ id: z.string(), name: z.string() }).nullable(),
|
|
71
|
+
/** The tools the caller's role grants, by the same decision as tools/list. */
|
|
72
|
+
grantedTools: z.array(z.string()),
|
|
73
|
+
/** False on the public HTTP tier and stdio: there is no caller, so no role is decided. */
|
|
74
|
+
rolesApplicable: z.boolean(),
|
|
75
|
+
});
|
|
76
|
+
/** The identity tool, outside the role gate (rule 6). */
|
|
77
|
+
const IDENTITY_TOOL = "whoami";
|
|
58
78
|
// Pagination (C-11): the bounds are declared in src/toolRecord.ts and applied in
|
|
59
79
|
// src/pagination.ts; the schema only describes them to the caller.
|
|
60
80
|
const pageInput = {
|
|
@@ -66,7 +86,8 @@ const pageInput = {
|
|
|
66
86
|
.number()
|
|
67
87
|
.int()
|
|
68
88
|
.min(1)
|
|
69
|
-
|
|
89
|
+
// Served in the inputSchema (C-11): the default is declared where a caller reads it, not only applied.
|
|
90
|
+
.default(PAGINATION.default)
|
|
70
91
|
.describe(`Page size; default ${PAGINATION.default}, capped at ${PAGINATION.max}`),
|
|
71
92
|
};
|
|
72
93
|
const pageOutput = {
|
|
@@ -79,7 +100,7 @@ const listItemsInput = z.object({
|
|
|
79
100
|
.describe("Widget and fallback language; defaults to the caller's X-MCP-Locale"),
|
|
80
101
|
...pageInput,
|
|
81
102
|
});
|
|
82
|
-
const listItemsOutput = z.
|
|
103
|
+
const listItemsOutput = z.looseObject({
|
|
83
104
|
items: z.array(itemSchema),
|
|
84
105
|
locale: localeSchema,
|
|
85
106
|
...pageOutput,
|
|
@@ -100,18 +121,26 @@ export function createBoilerplateMcpServer(deps = {}) {
|
|
|
100
121
|
// at registration and checked against it (and the closed mechanism set) before the server is
|
|
101
122
|
// returned: the check covers what is served, there is no list to keep in step.
|
|
102
123
|
const manifest = loadServerManifest();
|
|
103
|
-
// The identity the server announces: version
|
|
104
|
-
//
|
|
105
|
-
// (
|
|
106
|
-
//
|
|
124
|
+
// The identity the server announces (S-49): name, version, title, description (in the caller's
|
|
125
|
+
// locale), websiteUrl and icons, all from src/serverIdentity.ts (package.json or environment),
|
|
126
|
+
// plus the build identity (C-25): buildId = the git commit the tree was built from, buildHash =
|
|
127
|
+
// the hash of the files this process runs from (src/buildIdentity.ts). The SDK sends
|
|
128
|
+
// `serverInfo` as given, on `server/discover` (2026-07-28,
|
|
129
|
+
// `_meta["io.modelcontextprotocol/serverInfo"]`) and on `initialize` (2025-era). The build keys
|
|
130
|
+
// are not modelled `Implementation` keys, hence the spread (no excess-key check).
|
|
107
131
|
const server = new McpServer({
|
|
108
132
|
name: SERVER_NAME,
|
|
109
133
|
version: SERVER_VERSION,
|
|
110
|
-
...
|
|
134
|
+
...serverPresentation(getServerIdentity(), locale, deps.baseUrl),
|
|
135
|
+
...{
|
|
136
|
+
buildId: getBuildCommit(),
|
|
137
|
+
buildHash: getBuildIdentity(),
|
|
138
|
+
},
|
|
111
139
|
}, {
|
|
112
140
|
// The capabilities come from the server manifest (mcp-server.json, C-18): one source.
|
|
113
141
|
capabilities: manifest.capabilities,
|
|
114
|
-
|
|
142
|
+
// How an agent uses this server, in the caller's locale (src/serverIdentity.ts).
|
|
143
|
+
instructions: serverInstructions(locale),
|
|
115
144
|
supportedProtocolVersions: SUPPORTED_LEGACY_PROTOCOL_VERSIONS,
|
|
116
145
|
cacheHints: {
|
|
117
146
|
"tools/list": { ttlMs: 60_000, cacheScope: "public" },
|
|
@@ -121,26 +150,51 @@ export function createBoilerplateMcpServer(deps = {}) {
|
|
|
121
150
|
},
|
|
122
151
|
});
|
|
123
152
|
const served = trackToolRecords(server);
|
|
153
|
+
// Agent tool roles: every tool registered below is gated (call refused) and the list filtered, both by the same
|
|
154
|
+
// decision; the identity tool is the one exemption.
|
|
155
|
+
const roles = deps.toolRoles ?? { applicable: false };
|
|
156
|
+
const gate = installToolRoleGate(server, roles, locale, new Set([IDENTITY_TOOL]));
|
|
157
|
+
// S-24: every tool invocation takes a token from the caller's bucket (keyed by the resolved organisation, or
|
|
158
|
+
// "public" on the unscoped tier); an empty bucket is a tool error with a stable code and the wait, never a silent drop.
|
|
159
|
+
const limiter = deps.rateLimiter ?? toolRateLimiter;
|
|
160
|
+
const rateKey = deps.tenant?.tenantId ?? "public";
|
|
161
|
+
const withRateLimit = (handler) => async (args) => {
|
|
162
|
+
const d = limiter.consume(rateKey);
|
|
163
|
+
if (d.ok)
|
|
164
|
+
return handler(args);
|
|
165
|
+
const message = t(locale, "rateLimit.exceeded").replace("{seconds}", () => String(Math.ceil(d.retryAfterMs / 1000)));
|
|
166
|
+
return {
|
|
167
|
+
content: [{ type: "text", text: message }],
|
|
168
|
+
structuredContent: {
|
|
169
|
+
error: { code: RATE_LIMITED_CODE, retryAfterMs: d.retryAfterMs, message },
|
|
170
|
+
},
|
|
171
|
+
isError: true,
|
|
172
|
+
};
|
|
173
|
+
};
|
|
124
174
|
server.registerTool("ping", {
|
|
125
175
|
title: "Ping",
|
|
126
|
-
|
|
176
|
+
// C-19: served in both languages in the one description field ("<en> / <fr>"), with its trigger phrase.
|
|
177
|
+
description: "Echoes the given message (or 'pong' if none given) and proves the MCP handshake end to end. " +
|
|
178
|
+
"Use this whenever you need to check that this server answers, even if they don't say 'ping'. / " +
|
|
179
|
+
"Renvoie le message donné (ou 'pong' sans message) et prouve la poignée de main MCP de bout en bout. " +
|
|
180
|
+
"À utiliser pour vérifier que ce serveur répond, même sans le mot 'ping'.",
|
|
127
181
|
inputSchema: pingInput,
|
|
128
182
|
outputSchema: pingOutput,
|
|
129
183
|
annotations: PING_ANNOTATIONS,
|
|
130
|
-
}, async (args) => {
|
|
184
|
+
}, withRateLimit(async (args) => {
|
|
131
185
|
// Application-level input validation beyond the Zod shape: an overly long echo message
|
|
132
186
|
// is refused as an MCP tool-call error, localized per the caller's X-MCP-Locale.
|
|
133
187
|
if (args.message !== undefined && args.message.length > 200) {
|
|
134
188
|
return refusal(t(locale, "validation.messageTooLong"));
|
|
135
189
|
}
|
|
136
190
|
return structuredResult({ reply: args.message ?? "pong" });
|
|
137
|
-
});
|
|
191
|
+
}));
|
|
138
192
|
const lake = deps.lake ?? EXAMPLE_ITEMS_READER;
|
|
139
193
|
// An invalid or expired cursor is a TOOL INPUT error (spec 2025-11-25, SEP-1303: input
|
|
140
194
|
// validation errors are Tool Execution Errors, not Protocol Errors): a result with
|
|
141
195
|
// `isError: true` whose structuredContent carries a STABLE machine code beside a message
|
|
142
196
|
// localized per X-MCP-Locale. It is never a silent first page. (-32602 stays with the protocol
|
|
143
|
-
// list methods' cursor,
|
|
197
|
+
// list methods' cursor: src/listCursor.ts refuses it there, since the SDK ignores it.)
|
|
144
198
|
const invalidCursor = () => {
|
|
145
199
|
const message = t(locale, "pagination.invalidCursor");
|
|
146
200
|
return {
|
|
@@ -166,10 +220,15 @@ export function createBoilerplateMcpServer(deps = {}) {
|
|
|
166
220
|
// MCP Apps (mcp-app-standard A-1). The widget renders THIS result.
|
|
167
221
|
server.registerTool("list_items", {
|
|
168
222
|
title: "List items",
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
"
|
|
172
|
-
"
|
|
223
|
+
// C-19: served in both languages in the one description field ("<en> / <fr>"), with its trigger phrase.
|
|
224
|
+
description: "Lists example items, one page at a time, and shows them as an interactive widget in hosts that " +
|
|
225
|
+
"support MCP Apps. Use this whenever the user asks to list, see or render the items, even if they " +
|
|
226
|
+
"don't say 'list'. On the OAuth tier, results are scoped to the caller's organisation; on the public " +
|
|
227
|
+
"tier (no auth gate), they are unscoped. / " +
|
|
228
|
+
"Liste les éléments d'exemple, une page à la fois, et les affiche en widget interactif dans les hôtes " +
|
|
229
|
+
"MCP Apps. À utiliser quand l'utilisateur demande de lister, voir ou afficher les éléments, même sans " +
|
|
230
|
+
"le mot 'lister'. Sur le niveau OAuth, les résultats sont limités à l'organisation de l'appelant ; sur " +
|
|
231
|
+
"le niveau public (sans authentification), ils ne le sont pas.",
|
|
173
232
|
inputSchema: listItemsInput,
|
|
174
233
|
outputSchema: listItemsOutput,
|
|
175
234
|
annotations: LIST_ITEMS_ANNOTATIONS,
|
|
@@ -178,7 +237,7 @@ export function createBoilerplateMcpServer(deps = {}) {
|
|
|
178
237
|
...ITEMS_TOOL_META,
|
|
179
238
|
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
239
|
},
|
|
181
|
-
}, async (args) => {
|
|
240
|
+
}, withRateLimit(async (args) => {
|
|
182
241
|
if (deps.requireTenant && !deps.tenant) {
|
|
183
242
|
// Refuse explicitly — never fall through to an unscoped/master view just because the
|
|
184
243
|
// caller's token had no organization.
|
|
@@ -202,7 +261,7 @@ export function createBoilerplateMcpServer(deps = {}) {
|
|
|
202
261
|
return lakeUnreadable(error.code);
|
|
203
262
|
throw error;
|
|
204
263
|
}
|
|
205
|
-
});
|
|
264
|
+
}));
|
|
206
265
|
// MCP Apps UI layer (io.modelcontextprotocol/ui, spec 2026-01-26). `list_items` is registered
|
|
207
266
|
// with `server.registerTool` and the GA nested `_meta.ui` form, NOT with ext-apps'
|
|
208
267
|
// `registerAppTool`: that helper back-fills the deprecated flat `_meta["ui/resourceUri"]`
|
|
@@ -218,7 +277,51 @@ export function createBoilerplateMcpServer(deps = {}) {
|
|
|
218
277
|
}, async () => ({
|
|
219
278
|
contents: [buildUiResource({ domain: deps.uiDomain })],
|
|
220
279
|
}));
|
|
280
|
+
server.registerTool(IDENTITY_TOOL, {
|
|
281
|
+
title: "Who am I",
|
|
282
|
+
// C-19: both languages in the one served field ("<en> / <fr>") with its trigger phrase; no separate
|
|
283
|
+
// `_meta.description_fr`, which would only repeat the French half.
|
|
284
|
+
description: "Returns who you are to this server: your verified subject, the role your tools are decided by, " +
|
|
285
|
+
"and the tools it grants. Use this whenever you need to know what you may call, even if they don't " +
|
|
286
|
+
"say 'whoami'. / Renvoie qui vous êtes pour ce serveur : sujet vérifié, rôle qui décide de vos " +
|
|
287
|
+
"outils, outils accordés. À utiliser pour savoir ce que vous pouvez appeler, même sans le mot 'whoami'.",
|
|
288
|
+
inputSchema: whoamiInput,
|
|
289
|
+
outputSchema: whoamiOutput,
|
|
290
|
+
annotations: WHOAMI_ANNOTATIONS,
|
|
291
|
+
}, withRateLimit(async () => {
|
|
292
|
+
const person = deps.caller?.ok ? deps.caller.person : undefined;
|
|
293
|
+
const lookup = roles.applicable ? roles.lookup : undefined;
|
|
294
|
+
const role = lookup?.status === "resolved" && roles.applicable
|
|
295
|
+
? {
|
|
296
|
+
id: lookup.role.roleId,
|
|
297
|
+
name: roles.roleName(lookup.role.roleId) ?? lookup.role.roleId,
|
|
298
|
+
}
|
|
299
|
+
: null;
|
|
300
|
+
return structuredResult({
|
|
301
|
+
id: person?.subject ?? null,
|
|
302
|
+
role,
|
|
303
|
+
grantedTools: grantedToolNames(roles, gate.declarations),
|
|
304
|
+
rolesApplicable: roles.applicable,
|
|
305
|
+
});
|
|
306
|
+
}));
|
|
307
|
+
// tools/list narrowed to the granted tools (and the identity tool), once every tool is registered.
|
|
308
|
+
gate.filterList();
|
|
309
|
+
// C-11 (a): a cursor sent to a single-page protocol list method is -32602 (src/listCursor.ts). Installed after the
|
|
310
|
+
// role filter, so it wraps it: the cursor is refused before any list is built (tests/toolRoles.test.ts).
|
|
311
|
+
rejectCursorOnSinglePageLists(server);
|
|
221
312
|
// Start refusal: every registered tool's record, as registered, must be sound.
|
|
222
313
|
served.assertServed(manifest.contextCeiling);
|
|
314
|
+
SUMMARIES.set(server, served.summaries);
|
|
223
315
|
return server;
|
|
224
316
|
}
|
|
317
|
+
const SUMMARIES = new WeakMap();
|
|
318
|
+
/**
|
|
319
|
+
* The tools a server built by this factory registered (name, title, description), for the public
|
|
320
|
+
* server card (src/serverCard.ts): read from the registrations, never a second list.
|
|
321
|
+
*/
|
|
322
|
+
export function registeredToolSummaries(server) {
|
|
323
|
+
const summaries = SUMMARIES.get(server);
|
|
324
|
+
if (!summaries)
|
|
325
|
+
throw new Error("registeredToolSummaries: not a server built by this factory");
|
|
326
|
+
return summaries;
|
|
327
|
+
}
|
|
@@ -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;
|