@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
|
@@ -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";
|
|
@@ -12,6 +12,12 @@ import type { ContextCeiling } from "./serverManifest.js";
|
|
|
12
12
|
export interface ServedToolRecords {
|
|
13
13
|
/** The annotations of every tool registered so far, by tool name. */
|
|
14
14
|
readonly records: Readonly<Record<string, Record<string, unknown>>>;
|
|
15
|
+
/** Each registered tool's name, title and description, in registration order (the server card). */
|
|
16
|
+
readonly summaries: ReadonlyArray<{
|
|
17
|
+
name: string;
|
|
18
|
+
title?: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
}>;
|
|
15
21
|
/** Throws `ToolRecordError` naming every unsound tool; same check as the wire test. */
|
|
16
22
|
assertServed(ceiling: ContextCeiling): void;
|
|
17
23
|
}
|
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import { assertToolRecords } from "./toolRecord.js";
|
|
2
2
|
export function trackToolRecords(server) {
|
|
3
3
|
const records = {};
|
|
4
|
+
const summaries = [];
|
|
4
5
|
const register = server.registerTool.bind(server);
|
|
5
6
|
server.registerTool = ((name, config, callback) => {
|
|
6
7
|
records[name] = { ...(config.annotations ?? {}) };
|
|
8
|
+
summaries.push({
|
|
9
|
+
name,
|
|
10
|
+
...(config.title !== undefined ? { title: config.title } : {}),
|
|
11
|
+
...(config.description !== undefined ? { description: config.description } : {}),
|
|
12
|
+
});
|
|
7
13
|
return register(name, config, callback);
|
|
8
14
|
});
|
|
9
|
-
return {
|
|
15
|
+
return {
|
|
16
|
+
records,
|
|
17
|
+
summaries,
|
|
18
|
+
assertServed: (ceiling) => assertToolRecords(records, ceiling),
|
|
19
|
+
};
|
|
10
20
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public MCP Server Card (mcp-server-standard S-50, Manufact "mcp.json or MCP Server Card
|
|
3
|
+
* served"). Served by src/httpApp.ts, before every gate, at each path in `SERVER_CARD_PATHS`.
|
|
4
|
+
*
|
|
5
|
+
* Shape: the `ServerCard` of the MCP Server Card extension (SEP-2127,
|
|
6
|
+
* modelcontextprotocol/experimental-ext-server-card `schema.json` at commit
|
|
7
|
+
* 526201bbc80231daa40ffcdecfc9da4e54e5dc93, sha256
|
|
8
|
+
* 2c772b51edb367f154771d84ddbae87ddba00a624422c8e46f218a9ac03bf042), vendored at
|
|
9
|
+
* tests/fixtures/server-card.schema.json and validated in tests/serverMetadata.test.ts.
|
|
10
|
+
*
|
|
11
|
+
* Declared divergence: the extension says a card does not enumerate primitives, while Manufact's
|
|
12
|
+
* check and mcp-doctor S-50 read `capabilities` and a tool summary (`tools`) on the card. The
|
|
13
|
+
* schema allows additional members, so the card carries both: the capabilities of
|
|
14
|
+
* mcp-server.json (the same object `initialize` serves) and each served tool's name, title and
|
|
15
|
+
* description, read from the tools as registered — never a second list to keep in step.
|
|
16
|
+
*
|
|
17
|
+
* The card is public metadata and grants nothing. Every string comes from src/serverIdentity.ts
|
|
18
|
+
* (the source of the serverInfo), and the URLs from the origin the server is reached at.
|
|
19
|
+
*/
|
|
20
|
+
import type { Icon } from "@modelcontextprotocol/server";
|
|
21
|
+
import { type ServerIdentity } from "./serverIdentity.js";
|
|
22
|
+
export declare const SERVER_CARD_SCHEMA_URL = "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json";
|
|
23
|
+
export declare const SERVER_CARD_MEDIA_TYPE = "application/mcp-server-card+json";
|
|
24
|
+
/**
|
|
25
|
+
* Where the card is served: `/mcp/server-card` is the location the extension reserves
|
|
26
|
+
* (`<streamable-http-url>/server-card`); the others are the origin-level paths Manufact and
|
|
27
|
+
* mcp-doctor S-50 probe. Manufact also reads `/.well-known/ai-catalog.json` and the parent
|
|
28
|
+
* domain; an AI Catalog lists cards rather than being one, so it is not served here.
|
|
29
|
+
*/
|
|
30
|
+
export declare const SERVER_CARD_PATHS: readonly ["/mcp/server-card", "/.well-known/mcp/server-card.json", "/.well-known/mcp-server-card", "/mcp.json", "/.well-known/mcp.json"];
|
|
31
|
+
/** One served tool, as summarised on the card. */
|
|
32
|
+
export interface CardTool {
|
|
33
|
+
name: string;
|
|
34
|
+
title?: string;
|
|
35
|
+
description?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface ServerCard {
|
|
38
|
+
$schema: string;
|
|
39
|
+
name: string;
|
|
40
|
+
version: string;
|
|
41
|
+
description: string;
|
|
42
|
+
title: string;
|
|
43
|
+
websiteUrl: string;
|
|
44
|
+
icons: Icon[];
|
|
45
|
+
remotes: Array<{
|
|
46
|
+
type: "streamable-http";
|
|
47
|
+
url: string;
|
|
48
|
+
}>;
|
|
49
|
+
capabilities: Record<string, unknown>;
|
|
50
|
+
tools: CardTool[];
|
|
51
|
+
}
|
|
52
|
+
export interface CardInput {
|
|
53
|
+
identity: ServerIdentity;
|
|
54
|
+
/** The served MCP endpoint URL. */
|
|
55
|
+
mcpUrl: string;
|
|
56
|
+
/** The base URL the icon is served under (ends in `/`). */
|
|
57
|
+
baseUrl: string;
|
|
58
|
+
capabilities: Record<string, unknown>;
|
|
59
|
+
tools: CardTool[];
|
|
60
|
+
}
|
|
61
|
+
export declare function buildServerCard(input: CardInput): ServerCard;
|
|
62
|
+
/**
|
|
63
|
+
* The card as an HTTP response: the card media type when the client asks for it, JSON otherwise.
|
|
64
|
+
* `cacheable` is false when the origin was read from the
|
|
65
|
+
* request (no configured public base URL): a shared cache must not keep a card naming a Host
|
|
66
|
+
* header another client sent.
|
|
67
|
+
*/
|
|
68
|
+
export declare function serverCardResponse(req: Request, card: ServerCard, cacheable: boolean): Response;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public MCP Server Card (mcp-server-standard S-50, Manufact "mcp.json or MCP Server Card
|
|
3
|
+
* served"). Served by src/httpApp.ts, before every gate, at each path in `SERVER_CARD_PATHS`.
|
|
4
|
+
*
|
|
5
|
+
* Shape: the `ServerCard` of the MCP Server Card extension (SEP-2127,
|
|
6
|
+
* modelcontextprotocol/experimental-ext-server-card `schema.json` at commit
|
|
7
|
+
* 526201bbc80231daa40ffcdecfc9da4e54e5dc93, sha256
|
|
8
|
+
* 2c772b51edb367f154771d84ddbae87ddba00a624422c8e46f218a9ac03bf042), vendored at
|
|
9
|
+
* tests/fixtures/server-card.schema.json and validated in tests/serverMetadata.test.ts.
|
|
10
|
+
*
|
|
11
|
+
* Declared divergence: the extension says a card does not enumerate primitives, while Manufact's
|
|
12
|
+
* check and mcp-doctor S-50 read `capabilities` and a tool summary (`tools`) on the card. The
|
|
13
|
+
* schema allows additional members, so the card carries both: the capabilities of
|
|
14
|
+
* mcp-server.json (the same object `initialize` serves) and each served tool's name, title and
|
|
15
|
+
* description, read from the tools as registered — never a second list to keep in step.
|
|
16
|
+
*
|
|
17
|
+
* The card is public metadata and grants nothing. Every string comes from src/serverIdentity.ts
|
|
18
|
+
* (the source of the serverInfo), and the URLs from the origin the server is reached at.
|
|
19
|
+
*/
|
|
20
|
+
import { cardDescription, cardName, serverIcons } from "./serverIdentity.js";
|
|
21
|
+
export const SERVER_CARD_SCHEMA_URL = "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json";
|
|
22
|
+
export const SERVER_CARD_MEDIA_TYPE = "application/mcp-server-card+json";
|
|
23
|
+
/**
|
|
24
|
+
* Where the card is served: `/mcp/server-card` is the location the extension reserves
|
|
25
|
+
* (`<streamable-http-url>/server-card`); the others are the origin-level paths Manufact and
|
|
26
|
+
* mcp-doctor S-50 probe. Manufact also reads `/.well-known/ai-catalog.json` and the parent
|
|
27
|
+
* domain; an AI Catalog lists cards rather than being one, so it is not served here.
|
|
28
|
+
*/
|
|
29
|
+
export const SERVER_CARD_PATHS = [
|
|
30
|
+
"/mcp/server-card",
|
|
31
|
+
"/.well-known/mcp/server-card.json",
|
|
32
|
+
"/.well-known/mcp-server-card",
|
|
33
|
+
"/mcp.json",
|
|
34
|
+
"/.well-known/mcp.json",
|
|
35
|
+
];
|
|
36
|
+
export function buildServerCard(input) {
|
|
37
|
+
const { identity } = input;
|
|
38
|
+
return {
|
|
39
|
+
$schema: SERVER_CARD_SCHEMA_URL,
|
|
40
|
+
name: cardName(identity.websiteUrl, identity.name),
|
|
41
|
+
version: identity.version,
|
|
42
|
+
description: cardDescription(identity.description.en),
|
|
43
|
+
title: identity.title,
|
|
44
|
+
websiteUrl: identity.websiteUrl,
|
|
45
|
+
icons: serverIcons(input.baseUrl),
|
|
46
|
+
remotes: [{ type: "streamable-http", url: input.mcpUrl }],
|
|
47
|
+
capabilities: input.capabilities,
|
|
48
|
+
tools: input.tools,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The card as an HTTP response: the card media type when the client asks for it, JSON otherwise.
|
|
53
|
+
* `cacheable` is false when the origin was read from the
|
|
54
|
+
* request (no configured public base URL): a shared cache must not keep a card naming a Host
|
|
55
|
+
* header another client sent.
|
|
56
|
+
*/
|
|
57
|
+
export function serverCardResponse(req, card, cacheable) {
|
|
58
|
+
const accept = req.headers.get("accept") ?? "";
|
|
59
|
+
const headers = new Headers({
|
|
60
|
+
"content-type": accept.includes(SERVER_CARD_MEDIA_TYPE)
|
|
61
|
+
? SERVER_CARD_MEDIA_TYPE
|
|
62
|
+
: "application/json",
|
|
63
|
+
"cache-control": cacheable ? "public, max-age=3600" : "no-store",
|
|
64
|
+
"access-control-allow-origin": "*",
|
|
65
|
+
"x-content-type-options": "nosniff",
|
|
66
|
+
});
|
|
67
|
+
return new Response(JSON.stringify(card), { status: 200, headers });
|
|
68
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server icon (MCP `Implementation.icons`, Manufact "Scalable or high-resolution icon"). A
|
|
3
|
+
* vector, so it is sharp at every size a host renders and needs no `sizes` beyond `"any"`.
|
|
4
|
+
*
|
|
5
|
+
* A fork replaces `SERVER_ICON_SVG` with its own mark. Keep it a self-contained SVG: no script,
|
|
6
|
+
* no external reference (the route serves it under a CSP that forbids both), because a host may
|
|
7
|
+
* render it in its own page.
|
|
8
|
+
*
|
|
9
|
+
* Served by src/httpApp.ts at `SERVER_ICON_PATH`, before any authentication, and inlined as a
|
|
10
|
+
* `data:` URI where there is no HTTP origin (stdio).
|
|
11
|
+
*/
|
|
12
|
+
/** Where the HTTP transport serves the icon, relative to the public base URL. */
|
|
13
|
+
export declare const SERVER_ICON_PATH = "icon.svg";
|
|
14
|
+
export declare const SERVER_ICON_MIME_TYPE = "image/svg+xml";
|
|
15
|
+
export declare const SERVER_ICON_SVG = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 128 128\" role=\"img\" aria-label=\"MCP server\">\n<rect width=\"128\" height=\"128\" rx=\"28\" fill=\"#1f2a44\"/>\n<path d=\"M40 44 L88 44 M40 44 L64 88 M88 44 L64 88\" stroke=\"#8fb3ff\" stroke-width=\"8\" stroke-linecap=\"round\" fill=\"none\"/>\n<circle cx=\"40\" cy=\"44\" r=\"12\" fill=\"#ffffff\"/>\n<circle cx=\"88\" cy=\"44\" r=\"12\" fill=\"#ffffff\"/>\n<circle cx=\"64\" cy=\"88\" r=\"12\" fill=\"#ffffff\"/>\n</svg>\n";
|
|
16
|
+
/** The icon as a `data:` URI, for a transport with no HTTP origin to serve it from. */
|
|
17
|
+
export declare function serverIconDataUri(): string;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server icon (MCP `Implementation.icons`, Manufact "Scalable or high-resolution icon"). A
|
|
3
|
+
* vector, so it is sharp at every size a host renders and needs no `sizes` beyond `"any"`.
|
|
4
|
+
*
|
|
5
|
+
* A fork replaces `SERVER_ICON_SVG` with its own mark. Keep it a self-contained SVG: no script,
|
|
6
|
+
* no external reference (the route serves it under a CSP that forbids both), because a host may
|
|
7
|
+
* render it in its own page.
|
|
8
|
+
*
|
|
9
|
+
* Served by src/httpApp.ts at `SERVER_ICON_PATH`, before any authentication, and inlined as a
|
|
10
|
+
* `data:` URI where there is no HTTP origin (stdio).
|
|
11
|
+
*/
|
|
12
|
+
/** Where the HTTP transport serves the icon, relative to the public base URL. */
|
|
13
|
+
export const SERVER_ICON_PATH = "icon.svg";
|
|
14
|
+
export const SERVER_ICON_MIME_TYPE = "image/svg+xml";
|
|
15
|
+
export const SERVER_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="MCP server">
|
|
16
|
+
<rect width="128" height="128" rx="28" fill="#1f2a44"/>
|
|
17
|
+
<path d="M40 44 L88 44 M40 44 L64 88 M88 44 L64 88" stroke="#8fb3ff" stroke-width="8" stroke-linecap="round" fill="none"/>
|
|
18
|
+
<circle cx="40" cy="44" r="12" fill="#ffffff"/>
|
|
19
|
+
<circle cx="88" cy="44" r="12" fill="#ffffff"/>
|
|
20
|
+
<circle cx="64" cy="88" r="12" fill="#ffffff"/>
|
|
21
|
+
</svg>
|
|
22
|
+
`;
|
|
23
|
+
/** The icon as a `data:` URI, for a transport with no HTTP origin to serve it from. */
|
|
24
|
+
export function serverIconDataUri() {
|
|
25
|
+
return `data:${SERVER_ICON_MIME_TYPE};base64,${Buffer.from(SERVER_ICON_SVG, "utf8").toString("base64")}`;
|
|
26
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server's public identity: the ONE source of every string a host reads about this server
|
|
3
|
+
* before or at connection — the `serverInfo` of `initialize` / `server/discover`
|
|
4
|
+
* (src/mcpServerFactory.ts), its `instructions`, the MCP Server Card (src/serverCard.ts) and the
|
|
5
|
+
* setup page at the MCP URL (src/landingPage.ts). No copy can drift from another.
|
|
6
|
+
*
|
|
7
|
+
* Every value is DERIVED, never a literal a fork must remember to edit:
|
|
8
|
+
*
|
|
9
|
+
* | Item | Environment override | Otherwise, package.json |
|
|
10
|
+
* |----------------|-------------------------------|-------------------------|
|
|
11
|
+
* | title | `MCP_SERVER_TITLE` | `displayName` |
|
|
12
|
+
* | description EN | `MCP_SERVER_DESCRIPTION` | `description` |
|
|
13
|
+
* | description FR | `MCP_SERVER_DESCRIPTION_FR` | `description_fr` |
|
|
14
|
+
* | website | `MCP_SERVER_WEBSITE_URL` | `homepage` |
|
|
15
|
+
*
|
|
16
|
+
* `name` and `version` keep their own sources (src/version.ts). The template's package.json
|
|
17
|
+
* carries the template's values; a fork edits package.json (or sets the variables) and every
|
|
18
|
+
* surface follows. A value neither source carries is refused at start, naming the variable AND
|
|
19
|
+
* the package.json field, in English and French: a served placeholder is what Manufact and
|
|
20
|
+
* mcp-doctor S-49 score as missing, so the server never invents one.
|
|
21
|
+
*/
|
|
22
|
+
import type { Icon, Implementation } from "@modelcontextprotocol/server";
|
|
23
|
+
import { type McpLocale } from "./i18n.js";
|
|
24
|
+
/** The package.json fields the identity reads. */
|
|
25
|
+
export interface PackageIdentityFields {
|
|
26
|
+
displayName?: unknown;
|
|
27
|
+
description?: unknown;
|
|
28
|
+
description_fr?: unknown;
|
|
29
|
+
homepage?: unknown;
|
|
30
|
+
}
|
|
31
|
+
export interface ServerIdentity {
|
|
32
|
+
name: string;
|
|
33
|
+
version: string;
|
|
34
|
+
title: string;
|
|
35
|
+
description: Record<McpLocale, string>;
|
|
36
|
+
websiteUrl: string;
|
|
37
|
+
}
|
|
38
|
+
/** The environment variables, by identity item (documented in README and .env.example). */
|
|
39
|
+
export declare const IDENTITY_ENV: {
|
|
40
|
+
readonly title: "MCP_SERVER_TITLE";
|
|
41
|
+
readonly description: "MCP_SERVER_DESCRIPTION";
|
|
42
|
+
readonly descriptionFr: "MCP_SERVER_DESCRIPTION_FR";
|
|
43
|
+
readonly websiteUrl: "MCP_SERVER_WEBSITE_URL";
|
|
44
|
+
};
|
|
45
|
+
/** Resolves the identity from `env` and the package.json fields. Throws, naming what is missing. */
|
|
46
|
+
export declare function resolveServerIdentity(env: NodeJS.ProcessEnv, pkg: PackageIdentityFields): ServerIdentity;
|
|
47
|
+
/**
|
|
48
|
+
* The identity of THIS process: the package fields embedded at build (src/embedded.generated.ts, from
|
|
49
|
+
* package.json; no file read, so a bundled server has them too) and process.env. Once.
|
|
50
|
+
*/
|
|
51
|
+
export declare function getServerIdentity(): ServerIdentity;
|
|
52
|
+
/**
|
|
53
|
+
* The icons a host reads. With an HTTP base URL (the origin the server is reached at, ending in
|
|
54
|
+
* `/`), the icon is the URL src/httpApp.ts serves; without one (stdio), it travels inline.
|
|
55
|
+
*/
|
|
56
|
+
export declare function serverIcons(baseUrl?: string): Icon[];
|
|
57
|
+
/**
|
|
58
|
+
* The `serverInfo` (MCP `Implementation`) items beyond name and version, for a caller's locale.
|
|
59
|
+
* The factory writes `name` and `version` beside them from src/version.ts, where the
|
|
60
|
+
* version-observability check (D16) reads them.
|
|
61
|
+
*/
|
|
62
|
+
export declare function serverPresentation(identity: ServerIdentity, locale: McpLocale, baseUrl?: string): Omit<Implementation, "name" | "version">;
|
|
63
|
+
/**
|
|
64
|
+
* The server instructions (`InitializeResult.instructions` / `DiscoverResult.instructions`): how
|
|
65
|
+
* an agent should use THIS server, critical guidance first, from the locale files
|
|
66
|
+
* (`Mcp.server.instructions`). A fork rewrites them there for its own tools;
|
|
67
|
+
* tests/serverMetadata.test.ts fails when a served tool goes unnamed or the text passes Claude
|
|
68
|
+
* Code's 2048-byte field budget.
|
|
69
|
+
*/
|
|
70
|
+
export declare function serverInstructions(locale: McpLocale): string;
|
|
71
|
+
/**
|
|
72
|
+
* The card's reverse-DNS name, derived from the website: `io.github.<owner>/<name>` for a GitHub
|
|
73
|
+
* website (the MCP Registry convention), the reversed host otherwise (`www.` dropped).
|
|
74
|
+
*/
|
|
75
|
+
export declare function cardName(websiteUrl: string, serverName: string): string;
|
|
76
|
+
/** The card schema's `description` cap. */
|
|
77
|
+
export declare const CARD_DESCRIPTION_MAX = 100;
|
|
78
|
+
/**
|
|
79
|
+
* The card description: the English description whole when it fits the schema's 100 characters;
|
|
80
|
+
* otherwise cut on the last word boundary that leaves room for an ellipsis. Declared divergence:
|
|
81
|
+
* the card then carries a shortened text, the serverInfo the full one.
|
|
82
|
+
*/
|
|
83
|
+
export declare function cardDescription(description: string): string;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server's public identity: the ONE source of every string a host reads about this server
|
|
3
|
+
* before or at connection — the `serverInfo` of `initialize` / `server/discover`
|
|
4
|
+
* (src/mcpServerFactory.ts), its `instructions`, the MCP Server Card (src/serverCard.ts) and the
|
|
5
|
+
* setup page at the MCP URL (src/landingPage.ts). No copy can drift from another.
|
|
6
|
+
*
|
|
7
|
+
* Every value is DERIVED, never a literal a fork must remember to edit:
|
|
8
|
+
*
|
|
9
|
+
* | Item | Environment override | Otherwise, package.json |
|
|
10
|
+
* |----------------|-------------------------------|-------------------------|
|
|
11
|
+
* | title | `MCP_SERVER_TITLE` | `displayName` |
|
|
12
|
+
* | description EN | `MCP_SERVER_DESCRIPTION` | `description` |
|
|
13
|
+
* | description FR | `MCP_SERVER_DESCRIPTION_FR` | `description_fr` |
|
|
14
|
+
* | website | `MCP_SERVER_WEBSITE_URL` | `homepage` |
|
|
15
|
+
*
|
|
16
|
+
* `name` and `version` keep their own sources (src/version.ts). The template's package.json
|
|
17
|
+
* carries the template's values; a fork edits package.json (or sets the variables) and every
|
|
18
|
+
* surface follows. A value neither source carries is refused at start, naming the variable AND
|
|
19
|
+
* the package.json field, in English and French: a served placeholder is what Manufact and
|
|
20
|
+
* mcp-doctor S-49 score as missing, so the server never invents one.
|
|
21
|
+
*/
|
|
22
|
+
import { isSecureUrl } from "./declaredVariables.js";
|
|
23
|
+
import { EMBEDDED_PACKAGE_IDENTITY } from "./embedded.generated.js";
|
|
24
|
+
import { t, tBoth } from "./i18n.js";
|
|
25
|
+
import { SERVER_ICON_MIME_TYPE, SERVER_ICON_PATH, serverIconDataUri } from "./serverIcon.js";
|
|
26
|
+
import { SERVER_NAME, SERVER_VERSION } from "./version.js";
|
|
27
|
+
/** The environment variables, by identity item (documented in README and .env.example). */
|
|
28
|
+
export const IDENTITY_ENV = {
|
|
29
|
+
title: "MCP_SERVER_TITLE",
|
|
30
|
+
description: "MCP_SERVER_DESCRIPTION",
|
|
31
|
+
descriptionFr: "MCP_SERVER_DESCRIPTION_FR",
|
|
32
|
+
websiteUrl: "MCP_SERVER_WEBSITE_URL",
|
|
33
|
+
};
|
|
34
|
+
function pick(env, pkg, item, variable, field) {
|
|
35
|
+
const fromEnv = env[variable];
|
|
36
|
+
if (fromEnv !== undefined && fromEnv.trim() !== "")
|
|
37
|
+
return { value: fromEnv, source: variable };
|
|
38
|
+
const fromPkg = pkg[field];
|
|
39
|
+
if (typeof fromPkg === "string" && fromPkg.trim() !== "")
|
|
40
|
+
return { value: fromPkg, source: `package.json ${field}` };
|
|
41
|
+
throw new Error(tBoth("server.identityMissing", { item, variable, field }));
|
|
42
|
+
}
|
|
43
|
+
/** Resolves the identity from `env` and the package.json fields. Throws, naming what is missing. */
|
|
44
|
+
export function resolveServerIdentity(env, pkg) {
|
|
45
|
+
const title = pick(env, pkg, "title", IDENTITY_ENV.title, "displayName");
|
|
46
|
+
const en = pick(env, pkg, "description", IDENTITY_ENV.description, "description");
|
|
47
|
+
const fr = pick(env, pkg, "description_fr", IDENTITY_ENV.descriptionFr, "description_fr");
|
|
48
|
+
const site = pick(env, pkg, "websiteUrl", IDENTITY_ENV.websiteUrl, "homepage");
|
|
49
|
+
if (!isSecureUrl(site.value))
|
|
50
|
+
throw new Error(tBoth("server.identityInvalid", { item: "websiteUrl", source: site.source }));
|
|
51
|
+
return {
|
|
52
|
+
name: SERVER_NAME,
|
|
53
|
+
version: SERVER_VERSION,
|
|
54
|
+
title: title.value,
|
|
55
|
+
description: { en: en.value, fr: fr.value },
|
|
56
|
+
websiteUrl: site.value,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
let processIdentity;
|
|
60
|
+
/**
|
|
61
|
+
* The identity of THIS process: the package fields embedded at build (src/embedded.generated.ts, from
|
|
62
|
+
* package.json; no file read, so a bundled server has them too) and process.env. Once.
|
|
63
|
+
*/
|
|
64
|
+
export function getServerIdentity() {
|
|
65
|
+
processIdentity ??= resolveServerIdentity(process.env, EMBEDDED_PACKAGE_IDENTITY);
|
|
66
|
+
return processIdentity;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The icons a host reads. With an HTTP base URL (the origin the server is reached at, ending in
|
|
70
|
+
* `/`), the icon is the URL src/httpApp.ts serves; without one (stdio), it travels inline.
|
|
71
|
+
*/
|
|
72
|
+
export function serverIcons(baseUrl) {
|
|
73
|
+
const src = baseUrl ? new URL(SERVER_ICON_PATH, baseUrl).href : serverIconDataUri();
|
|
74
|
+
return [{ src, mimeType: SERVER_ICON_MIME_TYPE, sizes: ["any"] }];
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The `serverInfo` (MCP `Implementation`) items beyond name and version, for a caller's locale.
|
|
78
|
+
* The factory writes `name` and `version` beside them from src/version.ts, where the
|
|
79
|
+
* version-observability check (D16) reads them.
|
|
80
|
+
*/
|
|
81
|
+
export function serverPresentation(identity, locale, baseUrl) {
|
|
82
|
+
return {
|
|
83
|
+
title: identity.title,
|
|
84
|
+
description: identity.description[locale],
|
|
85
|
+
websiteUrl: identity.websiteUrl,
|
|
86
|
+
icons: serverIcons(baseUrl),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The server instructions (`InitializeResult.instructions` / `DiscoverResult.instructions`): how
|
|
91
|
+
* an agent should use THIS server, critical guidance first, from the locale files
|
|
92
|
+
* (`Mcp.server.instructions`). A fork rewrites them there for its own tools;
|
|
93
|
+
* tests/serverMetadata.test.ts fails when a served tool goes unnamed or the text passes Claude
|
|
94
|
+
* Code's 2048-byte field budget.
|
|
95
|
+
*/
|
|
96
|
+
export function serverInstructions(locale) {
|
|
97
|
+
return t(locale, "server.instructions");
|
|
98
|
+
}
|
|
99
|
+
/** The Server Card `name` pattern (experimental-ext-server-card schema). */
|
|
100
|
+
const CARD_NAME = /^[a-zA-Z0-9.-]+\/[a-zA-Z0-9._-]+$/;
|
|
101
|
+
/**
|
|
102
|
+
* The card's reverse-DNS name, derived from the website: `io.github.<owner>/<name>` for a GitHub
|
|
103
|
+
* website (the MCP Registry convention), the reversed host otherwise (`www.` dropped).
|
|
104
|
+
*/
|
|
105
|
+
export function cardName(websiteUrl, serverName) {
|
|
106
|
+
const url = new URL(websiteUrl);
|
|
107
|
+
const host = url.hostname.toLowerCase().replace(/^www\./, "");
|
|
108
|
+
const owner = url.pathname.split("/").filter(Boolean)[0];
|
|
109
|
+
const namespace = host === "github.com" && owner
|
|
110
|
+
? `io.github.${owner.toLowerCase()}`
|
|
111
|
+
: host.split(".").reverse().join(".");
|
|
112
|
+
const name = `${namespace}/${serverName}`;
|
|
113
|
+
if (!CARD_NAME.test(name))
|
|
114
|
+
throw new Error(`server card name ${JSON.stringify(name)} does not match ${CARD_NAME}`);
|
|
115
|
+
return name;
|
|
116
|
+
}
|
|
117
|
+
/** The card schema's `description` cap. */
|
|
118
|
+
export const CARD_DESCRIPTION_MAX = 100;
|
|
119
|
+
/**
|
|
120
|
+
* The card description: the English description whole when it fits the schema's 100 characters;
|
|
121
|
+
* otherwise cut on the last word boundary that leaves room for an ellipsis. Declared divergence:
|
|
122
|
+
* the card then carries a shortened text, the serverInfo the full one.
|
|
123
|
+
*/
|
|
124
|
+
export function cardDescription(description) {
|
|
125
|
+
if (description.length <= CARD_DESCRIPTION_MAX)
|
|
126
|
+
return description;
|
|
127
|
+
const room = description.slice(0, CARD_DESCRIPTION_MAX - 1);
|
|
128
|
+
const cut = room.lastIndexOf(" ");
|
|
129
|
+
return `${(cut > 0 ? room.slice(0, cut) : room).trimEnd()}…`;
|
|
130
|
+
}
|
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.
|