@vantageos/mcp-boilerplate 0.2.0 → 0.2.2
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 +63 -0
- package/README.fr.md +23 -2
- package/README.md +21 -2
- package/dist/envKnobs.d.ts +14 -0
- package/dist/envKnobs.js +54 -0
- package/dist/http.js +4 -20
- package/dist/httpApp.js +17 -0
- package/dist/locales/en.json +3 -0
- package/dist/locales/fr.json +3 -0
- package/dist/nodeAdapter.d.ts +20 -0
- package/dist/nodeAdapter.js +63 -0
- package/dist/oauth/authorizationServer.d.ts +15 -1
- package/dist/oauth/authorizationServer.js +48 -7
- package/dist/oauth/clerk.d.ts +5 -1
- package/dist/oauth/clerk.js +29 -11
- package/dist/oauth/clientRegistry.d.ts +13 -1
- package/dist/oauth/clientRegistry.js +76 -7
- package/dist/oauth/codeStore.d.ts +19 -1
- package/dist/oauth/codeStore.js +28 -3
- package/dist/oauth/picker.d.ts +6 -1
- package/dist/oauth/picker.js +8 -2
- package/dist/oauth/settings.d.ts +7 -0
- package/dist/oauth/settings.js +21 -3
- package/dist/ui/uiResource.d.ts +3 -3
- package/dist/ui/uiResource.js +22 -7
- package/dist/ui-bundle/widget.html +9 -49
- package/docs/oauth-stores.md +12 -1
- package/examples/oauth.env.example +10 -0
- package/package.json +6 -6
- package/plugin.json +1 -1
- package/scripts/generate-signing-key.mjs +14 -0
|
@@ -24,7 +24,19 @@ export interface ClientRegistry {
|
|
|
24
24
|
get(clientId: string): Promise<RegisteredClient | null>;
|
|
25
25
|
register(client: RegisteredClient): Promise<void>;
|
|
26
26
|
}
|
|
27
|
-
export
|
|
27
|
+
export interface InMemoryClientRegistryOptions {
|
|
28
|
+
/** Hard cap on stored clients; at the cap the OLDEST client is evicted. Default 10 000. */
|
|
29
|
+
maxClients?: number;
|
|
30
|
+
/** Lifetime of a registration, counted from `issuedAt`. Default 30 days. */
|
|
31
|
+
ttlSeconds?: number;
|
|
32
|
+
now?: () => number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Bounded in-memory registry. DCR is unauthenticated, so it can never grow without limit and
|
|
36
|
+
* never stops accepting registrations either: expired clients are swept on every registration,
|
|
37
|
+
* and at the cap the oldest client is evicted (an evicted client re-registers).
|
|
38
|
+
*/
|
|
39
|
+
export declare function createInMemoryClientRegistry(options?: InMemoryClientRegistryOptions): ClientRegistry;
|
|
28
40
|
/** True for an https URL, or an http URL on a loopback host (native-app redirects). */
|
|
29
41
|
export declare function isAllowedRedirectUri(raw: unknown): raw is string;
|
|
30
42
|
/** True only for a globally routable address. Fail-closed: anything unrecognised is refused. */
|
|
@@ -12,15 +12,38 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { lookup } from "node:dns/promises";
|
|
14
14
|
import { BlockList, isIP } from "node:net";
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Bounded in-memory registry. DCR is unauthenticated, so it can never grow without limit and
|
|
17
|
+
* never stops accepting registrations either: expired clients are swept on every registration,
|
|
18
|
+
* and at the cap the oldest client is evicted (an evicted client re-registers).
|
|
19
|
+
*/
|
|
20
|
+
export function createInMemoryClientRegistry(options = {}) {
|
|
21
|
+
const maxClients = options.maxClients ?? 10_000;
|
|
22
|
+
const ttlMs = (options.ttlSeconds ?? 30 * 24 * 3600) * 1000;
|
|
23
|
+
const now = options.now ?? (() => Date.now());
|
|
16
24
|
const clients = new Map();
|
|
25
|
+
const expired = (client) => client.issuedAt * 1000 + ttlMs <= now();
|
|
17
26
|
return {
|
|
18
27
|
async get(clientId) {
|
|
19
|
-
|
|
28
|
+
const client = clients.get(clientId);
|
|
29
|
+
if (!client)
|
|
30
|
+
return null;
|
|
31
|
+
if (expired(client)) {
|
|
32
|
+
clients.delete(clientId);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return client;
|
|
20
36
|
},
|
|
21
37
|
async register(client) {
|
|
22
|
-
|
|
23
|
-
|
|
38
|
+
for (const [id, row] of clients)
|
|
39
|
+
if (expired(row))
|
|
40
|
+
clients.delete(id);
|
|
41
|
+
clients.delete(client.clientId); // re-registering moves it to the newest position
|
|
42
|
+
while (clients.size >= maxClients) {
|
|
43
|
+
const oldest = clients.keys().next();
|
|
44
|
+
if (oldest.done)
|
|
45
|
+
break;
|
|
46
|
+
clients.delete(oldest.value);
|
|
24
47
|
}
|
|
25
48
|
clients.set(client.clientId, client);
|
|
26
49
|
},
|
|
@@ -73,6 +96,7 @@ const BLOCKED_V6 = [
|
|
|
73
96
|
["fe80::", 10], // link-local
|
|
74
97
|
["ff00::", 8], // multicast
|
|
75
98
|
["64:ff9b:1::", 48], // local-use NAT64 (RFC 8215)
|
|
99
|
+
["2001::", 32], // Teredo (RFC 4380): embeds a server and an obfuscated client IPv4
|
|
76
100
|
];
|
|
77
101
|
/** The v4 prefix `addr/len` written as a prefix under an IPv6 embedding of 96 leading bits. */
|
|
78
102
|
function embedV4(v4, len, prefix6) {
|
|
@@ -81,18 +105,58 @@ function embedV4(v4, len, prefix6) {
|
|
|
81
105
|
const lo = ((c << 8) | d).toString(16);
|
|
82
106
|
return [`${prefix6}${hi}:${lo}`, 96 + len];
|
|
83
107
|
}
|
|
108
|
+
/** 6to4 (RFC 3056): the v4 prefix `addr/len` sits in bits 16-47, under 2002::/16. */
|
|
109
|
+
function embedSixToFour(v4, len) {
|
|
110
|
+
const [a, b, c, d] = v4.split(".").map(Number);
|
|
111
|
+
const hi = ((a << 8) | b).toString(16);
|
|
112
|
+
const lo = ((c << 8) | d).toString(16);
|
|
113
|
+
return [`2002:${hi}:${lo}::`, 16 + len];
|
|
114
|
+
}
|
|
84
115
|
const blockList = new BlockList();
|
|
85
116
|
for (const [addr, len] of BLOCKED_V4) {
|
|
86
117
|
blockList.addSubnet(addr, len, "ipv4");
|
|
87
|
-
// IPv4-mapped (::ffff:a.b.c.d, any textual form)
|
|
88
|
-
// v4 address is held to the same
|
|
89
|
-
|
|
118
|
+
// IPv4-mapped (::ffff:a.b.c.d, any textual form), NAT64 (64:ff9b::a.b.c.d) and
|
|
119
|
+
// IPv4-translated (::ffff:0:a.b.c.d, RFC 2765): the embedded v4 address is held to the same
|
|
120
|
+
// list, in the last 32 bits of a /96.
|
|
121
|
+
for (const prefix6 of ["::ffff:", "64:ff9b::", "::ffff:0:"]) {
|
|
90
122
|
const [sub, bits] = embedV4(addr, len, prefix6);
|
|
91
123
|
blockList.addSubnet(sub, bits, "ipv6");
|
|
92
124
|
}
|
|
125
|
+
// 6to4 (2002::/16): the v4 address is in bits 16-47.
|
|
126
|
+
const [sixToFour, sixToFourBits] = embedSixToFour(addr, len);
|
|
127
|
+
blockList.addSubnet(sixToFour, sixToFourBits, "ipv6");
|
|
93
128
|
}
|
|
94
129
|
for (const [addr, len] of BLOCKED_V6)
|
|
95
130
|
blockList.addSubnet(addr, len, "ipv6");
|
|
131
|
+
/**
|
|
132
|
+
* The eight 16-bit groups of a SYNTACTICALLY VALID IPv6 literal (`isIP` has already accepted
|
|
133
|
+
* it). Only used to read bits that no BlockList prefix can express.
|
|
134
|
+
*/
|
|
135
|
+
function ipv6Groups(ip) {
|
|
136
|
+
let text = ip.toLowerCase();
|
|
137
|
+
const dotted = /(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(text);
|
|
138
|
+
if (dotted) {
|
|
139
|
+
const [, a, b, c, d] = dotted.map(Number);
|
|
140
|
+
text = `${text.slice(0, dotted.index)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
|
|
141
|
+
}
|
|
142
|
+
const [head = "", tail] = text.split("::");
|
|
143
|
+
const left = head ? head.split(":") : [];
|
|
144
|
+
const right = tail === undefined ? [] : tail ? tail.split(":") : [];
|
|
145
|
+
const fill = tail === undefined ? [] : Array(8 - left.length - right.length).fill("0");
|
|
146
|
+
return [...left, ...fill, ...right].map((g) => Number.parseInt(g, 16));
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* ISATAP (RFC 5214): bits 64-95 are 0000:5efe (or 0200:5efe, universal/local variant) under ANY
|
|
150
|
+
* prefix, and the low 32 bits are an embedded IPv4. No prefix expresses that, so it is read
|
|
151
|
+
* from the groups; the embedded v4 is held to the same blocked list.
|
|
152
|
+
*/
|
|
153
|
+
function isatapEmbeddedV4(ip) {
|
|
154
|
+
const g = ipv6Groups(ip);
|
|
155
|
+
if (g.length !== 8 || (g[4] !== 0x0000 && g[4] !== 0x0200) || g[5] !== 0x5efe)
|
|
156
|
+
return null;
|
|
157
|
+
const [hi, lo] = [g[6], g[7]];
|
|
158
|
+
return `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;
|
|
159
|
+
}
|
|
96
160
|
/** True only for a globally routable address. Fail-closed: anything unrecognised is refused. */
|
|
97
161
|
export function isPublicAddress(ip) {
|
|
98
162
|
if (typeof ip !== "string" || ip.includes("%"))
|
|
@@ -101,6 +165,11 @@ export function isPublicAddress(ip) {
|
|
|
101
165
|
if (version !== 4 && version !== 6)
|
|
102
166
|
return false;
|
|
103
167
|
try {
|
|
168
|
+
if (version === 6) {
|
|
169
|
+
const isatap = isatapEmbeddedV4(ip);
|
|
170
|
+
if (isatap !== null && blockList.check(isatap, "ipv4"))
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
104
173
|
return !blockList.check(ip, version === 4 ? "ipv4" : "ipv6");
|
|
105
174
|
}
|
|
106
175
|
catch {
|
|
@@ -8,4 +8,22 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import type { AuthorizationCodeRecord, AuthorizationCodeStore, ConsumeCodeResult } from "@vantageos/cloud-identity";
|
|
10
10
|
export type { AuthorizationCodeRecord, AuthorizationCodeStore, ConsumeCodeResult };
|
|
11
|
-
export
|
|
11
|
+
export interface InMemoryCodeStoreOptions {
|
|
12
|
+
now?: () => number;
|
|
13
|
+
/** Cap on live codes; past it the OLDEST live code is dropped. Default 10 000. */
|
|
14
|
+
maxEntries?: number;
|
|
15
|
+
}
|
|
16
|
+
export type InMemoryCodeStore = AuthorizationCodeStore & {
|
|
17
|
+
/** Sizes of the two internal sets, for tests and metrics. */
|
|
18
|
+
stats(): {
|
|
19
|
+
live: number;
|
|
20
|
+
used: number;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Bounded in-memory adapter. A code that was never redeemed is dropped once past its
|
|
25
|
+
* `expiresAt`; a redeemed code's "used" marker is kept only until that same instant, after
|
|
26
|
+
* which cloud-identity refuses the code as expired anyway. Both sweeps run on `put`, so memory
|
|
27
|
+
* stays proportional to the codes issued within one code lifetime (60 s by default).
|
|
28
|
+
*/
|
|
29
|
+
export declare function createInMemoryCodeStore(options?: InMemoryCodeStoreOptions): InMemoryCodeStore;
|
package/dist/oauth/codeStore.js
CHANGED
|
@@ -6,11 +6,35 @@
|
|
|
6
6
|
* The in-memory adapter is NOT for a multi-instance deployment: codes live in one process,
|
|
7
7
|
* so a second instance cannot redeem a code issued by the first.
|
|
8
8
|
*/
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Bounded in-memory adapter. A code that was never redeemed is dropped once past its
|
|
11
|
+
* `expiresAt`; a redeemed code's "used" marker is kept only until that same instant, after
|
|
12
|
+
* which cloud-identity refuses the code as expired anyway. Both sweeps run on `put`, so memory
|
|
13
|
+
* stays proportional to the codes issued within one code lifetime (60 s by default).
|
|
14
|
+
*/
|
|
15
|
+
export function createInMemoryCodeStore(options = {}) {
|
|
16
|
+
const now = options.now ?? (() => Date.now());
|
|
17
|
+
const maxEntries = options.maxEntries ?? 10_000;
|
|
10
18
|
const live = new Map();
|
|
11
|
-
const used = new
|
|
19
|
+
const used = new Map(); // codeHash -> expiresAt of the redeemed code
|
|
20
|
+
const sweep = () => {
|
|
21
|
+
const t = now();
|
|
22
|
+
for (const [hash, record] of live)
|
|
23
|
+
if (record.expiresAt <= t)
|
|
24
|
+
live.delete(hash);
|
|
25
|
+
for (const [hash, expiresAt] of used)
|
|
26
|
+
if (expiresAt <= t)
|
|
27
|
+
used.delete(hash);
|
|
28
|
+
};
|
|
12
29
|
return {
|
|
13
30
|
async put(record) {
|
|
31
|
+
sweep();
|
|
32
|
+
while (live.size >= maxEntries) {
|
|
33
|
+
const oldest = live.keys().next();
|
|
34
|
+
if (oldest.done)
|
|
35
|
+
break;
|
|
36
|
+
live.delete(oldest.value);
|
|
37
|
+
}
|
|
14
38
|
live.set(record.codeHash, record);
|
|
15
39
|
},
|
|
16
40
|
// Atomic by construction: this body has no await between the read and the delete, and
|
|
@@ -19,10 +43,11 @@ export function createInMemoryCodeStore() {
|
|
|
19
43
|
const record = live.get(codeHash);
|
|
20
44
|
if (record) {
|
|
21
45
|
live.delete(codeHash);
|
|
22
|
-
used.
|
|
46
|
+
used.set(codeHash, record.expiresAt);
|
|
23
47
|
return { status: "ok", record };
|
|
24
48
|
}
|
|
25
49
|
return used.has(codeHash) ? { status: "already-used" } : { status: "unknown" };
|
|
26
50
|
},
|
|
51
|
+
stats: () => ({ live: live.size, used: used.size }),
|
|
27
52
|
};
|
|
28
53
|
}
|
package/dist/oauth/picker.d.ts
CHANGED
|
@@ -8,4 +8,9 @@ import type { OrgPickerModel } from "@vantageos/cloud-identity";
|
|
|
8
8
|
import { type McpLocale } from "../i18n.js";
|
|
9
9
|
/** CSP for the picker page: no scripts, no external assets, one hashed inline style. */
|
|
10
10
|
export declare function pickerContentSecurityPolicy(): Promise<string>;
|
|
11
|
-
|
|
11
|
+
/** What the consent page must tell the person beyond the model: whether the client is trusted. */
|
|
12
|
+
export interface ConsentContext {
|
|
13
|
+
/** True for a Dynamic Client Registration client: it vouched for itself, nobody else did. */
|
|
14
|
+
unverified: boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare function renderOrgPicker(model: OrgPickerModel, locale: McpLocale, consent: ConsentContext): string;
|
package/dist/oauth/picker.js
CHANGED
|
@@ -27,7 +27,11 @@ export async function pickerContentSecurityPolicy() {
|
|
|
27
27
|
"frame-ancestors 'none'",
|
|
28
28
|
].join("; ");
|
|
29
29
|
}
|
|
30
|
-
export function renderOrgPicker(model, locale) {
|
|
30
|
+
export function renderOrgPicker(model, locale, consent) {
|
|
31
|
+
const destination = escapeHtml(model.redirectOrigin);
|
|
32
|
+
const unverified = consent.unverified
|
|
33
|
+
? `<p role="alert"><strong>${escapeHtml(t(locale, "oauth.pickerUnverified"))}</strong></p>\n`
|
|
34
|
+
: "";
|
|
31
35
|
const clientName = model.client.clientName ?? model.client.clientId;
|
|
32
36
|
const only = model.organizations.length === 1;
|
|
33
37
|
const orgs = model.organizations
|
|
@@ -48,7 +52,9 @@ export function renderOrgPicker(model, locale) {
|
|
|
48
52
|
<main>
|
|
49
53
|
<h1>${title}</h1>
|
|
50
54
|
<p>${intro}</p>
|
|
51
|
-
<
|
|
55
|
+
<p>${escapeHtml(t(locale, "oauth.pickerDestination"))} <strong>${destination}</strong><br>
|
|
56
|
+
${escapeHtml(t(locale, "oauth.pickerScope"))} <code>${escapeHtml(model.scope)}</code></p>
|
|
57
|
+
${unverified}<form method="post" action="/authorize/org">
|
|
52
58
|
<input type="hidden" name="state" value="${escapeHtml(model.state)}">
|
|
53
59
|
<input type="hidden" name="consentToken" value="${escapeHtml(model.consentToken ?? "")}">
|
|
54
60
|
<fieldset>
|
package/dist/oauth/settings.d.ts
CHANGED
|
@@ -14,8 +14,15 @@ export declare const OAUTH_ENV: {
|
|
|
14
14
|
readonly clerkSecretKey: "CLERK_SECRET_KEY";
|
|
15
15
|
readonly extraAuthorizedParties: "AUTHORIZE_AUTHORIZED_PARTIES";
|
|
16
16
|
readonly signingJwk: "MCP_OAUTH_SIGNING_JWK";
|
|
17
|
+
/** Number of trusted reverse proxies in front of this server (default 0). */
|
|
18
|
+
readonly trustedProxyCount: "MCP_TRUSTED_PROXY_COUNT";
|
|
19
|
+
/** Comma-separated browser origins allowed to call /register cross-origin (default: none). */
|
|
20
|
+
readonly registerCorsOrigins: "MCP_REGISTER_CORS_ORIGINS";
|
|
21
|
+
/** Exactly "1" lets NODE_ENV=production run on the in-memory stores (single instance only). */
|
|
22
|
+
readonly allowInMemoryStores: "MCP_ALLOW_IN_MEMORY_STORES";
|
|
17
23
|
};
|
|
18
24
|
/** Scope granted when a client names none: MCP access plus the OIDC subject. */
|
|
25
|
+
export declare const MCP_SCOPE = "mcp:full";
|
|
19
26
|
export declare const DEFAULT_SCOPE = "mcp:full openid";
|
|
20
27
|
export declare const SUPPORTED_SCOPES: readonly ["mcp:full", "openid", "email"];
|
|
21
28
|
export interface AuthorizeSettings {
|
package/dist/oauth/settings.js
CHANGED
|
@@ -14,13 +14,31 @@ export const OAUTH_ENV = {
|
|
|
14
14
|
clerkSecretKey: "CLERK_SECRET_KEY",
|
|
15
15
|
extraAuthorizedParties: "AUTHORIZE_AUTHORIZED_PARTIES",
|
|
16
16
|
signingJwk: "MCP_OAUTH_SIGNING_JWK",
|
|
17
|
+
/** Number of trusted reverse proxies in front of this server (default 0). */
|
|
18
|
+
trustedProxyCount: "MCP_TRUSTED_PROXY_COUNT",
|
|
19
|
+
/** Comma-separated browser origins allowed to call /register cross-origin (default: none). */
|
|
20
|
+
registerCorsOrigins: "MCP_REGISTER_CORS_ORIGINS",
|
|
21
|
+
/** Exactly "1" lets NODE_ENV=production run on the in-memory stores (single instance only). */
|
|
22
|
+
allowInMemoryStores: "MCP_ALLOW_IN_MEMORY_STORES",
|
|
17
23
|
};
|
|
18
24
|
/** Scope granted when a client names none: MCP access plus the OIDC subject. */
|
|
19
|
-
export const
|
|
20
|
-
export const
|
|
25
|
+
export const MCP_SCOPE = "mcp:full";
|
|
26
|
+
export const DEFAULT_SCOPE = `${MCP_SCOPE} openid`;
|
|
27
|
+
export const SUPPORTED_SCOPES = [MCP_SCOPE, "openid", "email"];
|
|
28
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
29
|
+
/**
|
|
30
|
+
* The origin of a configured URL, or null when it is unusable: unparsable, not https, or http
|
|
31
|
+
* on a host that is not loopback. A cleartext issuer, sign-in or callback URL would put the
|
|
32
|
+
* session token, the authorization code or the signing-key set on the wire in the clear.
|
|
33
|
+
*/
|
|
21
34
|
function originOf(url) {
|
|
22
35
|
try {
|
|
23
|
-
|
|
36
|
+
const parsed = new URL(url);
|
|
37
|
+
if (parsed.protocol === "https:")
|
|
38
|
+
return parsed.origin;
|
|
39
|
+
if (parsed.protocol === "http:" && LOOPBACK_HOSTS.has(parsed.hostname))
|
|
40
|
+
return parsed.origin;
|
|
41
|
+
return null;
|
|
24
42
|
}
|
|
25
43
|
catch {
|
|
26
44
|
return null;
|
package/dist/ui/uiResource.d.ts
CHANGED
|
@@ -3,9 +3,9 @@ import type { ExampleItem } from "../exampleItems.js";
|
|
|
3
3
|
export declare const ITEMS_UI_RESOURCE_URI = "ui://mcp-boilerplate/items-widget.html";
|
|
4
4
|
/**
|
|
5
5
|
* Reads the single-file bundle produced by `npm run build:ui`
|
|
6
|
-
* (
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* (in-repo inlining plugin). Never silently swallowed: a missing bundle is a build defect, not
|
|
7
|
+
* a runtime fallback case. The error that reaches the MCP client names no filesystem path;
|
|
8
|
+
* the detail goes to stderr (server side) only.
|
|
9
9
|
*/
|
|
10
10
|
export declare function readUiBundle(): string;
|
|
11
11
|
/**
|
package/dist/ui/uiResource.js
CHANGED
|
@@ -11,13 +11,18 @@
|
|
|
11
11
|
* shape written here.
|
|
12
12
|
*/
|
|
13
13
|
import { readFileSync } from "node:fs";
|
|
14
|
-
import
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
15
|
import { RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
|
|
16
16
|
export const ITEMS_UI_RESOURCE_URI = "ui://mcp-boilerplate/items-widget.html";
|
|
17
17
|
// dist/ui-bundle/ — NEVER dist/ui/ (that path is tsc's compiled-server
|
|
18
18
|
// output for this very module; see vite.config.ts for the collision this
|
|
19
|
-
// avoids).
|
|
20
|
-
|
|
19
|
+
// avoids). Resolved relative to THIS module, never to the working directory: an installed
|
|
20
|
+
// package runs from any cwd. Compiled (dist/ui/uiResource.js) the bundle is `../ui-bundle`;
|
|
21
|
+
// from source (src/ui/uiResource.ts, under vitest) it is the repository's built `dist/`.
|
|
22
|
+
const BUNDLE_CANDIDATES = [
|
|
23
|
+
fileURLToPath(new URL("../ui-bundle/widget.html", import.meta.url)),
|
|
24
|
+
fileURLToPath(new URL("../../dist/ui-bundle/widget.html", import.meta.url)),
|
|
25
|
+
];
|
|
21
26
|
function renderMarkdownFallback(items, locale) {
|
|
22
27
|
const title = locale === "fr" ? "Éléments" : "Items";
|
|
23
28
|
if (items.length === 0) {
|
|
@@ -28,12 +33,22 @@ function renderMarkdownFallback(items, locale) {
|
|
|
28
33
|
}
|
|
29
34
|
/**
|
|
30
35
|
* Reads the single-file bundle produced by `npm run build:ui`
|
|
31
|
-
* (
|
|
32
|
-
*
|
|
33
|
-
*
|
|
36
|
+
* (in-repo inlining plugin). Never silently swallowed: a missing bundle is a build defect, not
|
|
37
|
+
* a runtime fallback case. The error that reaches the MCP client names no filesystem path;
|
|
38
|
+
* the detail goes to stderr (server side) only.
|
|
34
39
|
*/
|
|
35
40
|
export function readUiBundle() {
|
|
36
|
-
|
|
41
|
+
let lastError;
|
|
42
|
+
for (const candidate of BUNDLE_CANDIDATES) {
|
|
43
|
+
try {
|
|
44
|
+
return readFileSync(candidate, "utf-8");
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
lastError = err;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
console.error(`[ui] UI bundle unreadable (tried ${BUNDLE_CANDIDATES.join(", ")}):`, lastError);
|
|
51
|
+
throw new Error("UI bundle unavailable on this server");
|
|
37
52
|
}
|
|
38
53
|
/**
|
|
39
54
|
* Resource-level `_meta.ui` (MCP Apps spec 2026-01-26). The CSP is derived from what the
|