molecule-mcp 0.0.0-stage → 0.2.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.
@@ -0,0 +1,85 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ /**
3
+ * Bound on every outbound fetch this server makes (the mlcl repo's
4
+ * registry/templates and the npm registry's package metadata). Node's undici
5
+ * defaults let an upstream that accepts and then stalls hold the call — and
6
+ * with it the client's whole HTTP request — for ~5 minutes; a hung
7
+ * raw.githubusercontent.com must fail the tool call in seconds instead. The
8
+ * failure is the caller's to surface: a timed-out fetch leaves the cache
9
+ * untouched, so the next call retries the network (and a stale entry keeps
10
+ * serving until its TTL, never a failed one).
11
+ */
12
+ export declare const FETCH_TIMEOUT_MS = 15000;
13
+ /** Cached packages kept before the least-recently-used entry is evicted (see the cap logic below). */
14
+ export declare const README_CACHE_LIMIT = 50;
15
+ /**
16
+ * Drop every cached registry/templates payload and package README.
17
+ *
18
+ * Both maps are process-global ON PURPOSE — a long-lived hosted server pays one
19
+ * outbound fetch per document per TTL window instead of one per call — which
20
+ * also means the state survives between callers in one process, INCLUDING
21
+ * between the tests that point `MOLECULE_REGISTRY_FILE` at a rewritten file and
22
+ * then assert the new bytes are what the tool read. Those suites have to say
23
+ * that reset out loud rather than depend on the order they happen to run in, so
24
+ * this is the reset they call (the same affordance `clearRegistryCache` gives
25
+ * mlcl's own registry.ts, for the same reason).
26
+ */
27
+ export declare function clearRemoteCaches(): void;
28
+ /**
29
+ * Fetch a package's published README from the npm registry, cached per package.
30
+ *
31
+ * The network branch is bounded by {@link FETCH_TIMEOUT_MS} (`timeoutMs`
32
+ * shortens it for tests, like {@link fetchJson}). A fetched body with no
33
+ * `readme` resolves to `undefined`; a rejected, aborted, or non-2xx fetch
34
+ * REJECTS, the same as {@link fetchJson} — the caller's own try/catch is the
35
+ * fallback (see the `read_package_doc` handler). Neither case caches anything,
36
+ * so the next call retries the network. Callers that arrive while this
37
+ * package's fetch is already in flight share that one request instead of
38
+ * starting another (see {@link inFlight}).
39
+ *
40
+ * @param short - Package name WITHOUT the `@molecule/` scope.
41
+ * @param timeoutMs - Abort bound for the outbound fetch.
42
+ * @returns The published README, or undefined when the registry has none.
43
+ */
44
+ export declare function fetchPackageReadme(short: string, timeoutMs?: number): Promise<string | undefined>;
45
+ /**
46
+ * A predicate a fetched payload must satisfy before it is handed to the caller
47
+ * as the shape that caller declared, or cached as one.
48
+ */
49
+ export type PayloadCheck = (data: unknown) => boolean;
50
+ /** Whether a payload is the package registry: an object carrying a `packages` object. */
51
+ export declare function isRegistryPayload(data: unknown): boolean;
52
+ /** Whether a payload is the templates index: an object carrying a `templates` object. */
53
+ export declare function isTemplatesPayload(data: unknown): boolean;
54
+ /**
55
+ * Fetch (and cache) the registry/templates JSON, or read it from a local file.
56
+ *
57
+ * The network branch is bounded by {@link FETCH_TIMEOUT_MS}; `timeoutMs`
58
+ * shortens that bound — the timeout regression test drives a never-responding
59
+ * local server through it with a 250 ms bound, well inside vitest's own 5 s
60
+ * default test timeout. The 15 s default would outlast that timeout, so the
61
+ * case could never pass without the seam.
62
+ * A failed (timed-out, rejected, non-2xx, or unrecognized) fetch throws without
63
+ * touching the cache, so the next call retries the network.
64
+ *
65
+ * `check` is what makes "recognized" mean anything. A 2xx (like a readable
66
+ * local file) proves only that bytes ARRIVED: `{"version":1}`, a proxy's
67
+ * 200 error page, a shrunken rewrite of the registry — each deserializes as
68
+ * happily as the real document and was then handed out as the caller's
69
+ * declared type and cached. The first reader of the missing section threw a
70
+ * bare `TypeError` from inside a tool handler (`search_packages` died on
71
+ * `Object.entries(undefined)`, reporting "Cannot convert undefined or null to
72
+ * object" to the client), and every call for the next 10 minutes was served
73
+ * the same non-payload. A payload that fails `check` is reported as the
74
+ * failure it is and caches nothing, exactly like a non-2xx answer. Callers that
75
+ * arrive while the same document is already being fetched share that one
76
+ * request instead of starting another (see {@link inFlight}).
77
+ *
78
+ * The check is the CALLER's, and it is re-applied to whatever the shortcuts
79
+ * hand over: a cache hit or a shared in-flight answer was validated by the
80
+ * FIRST reader's check, and the key is the document's SOURCE (url or file), not
81
+ * its shape — a second reader declaring a different shape for one source must
82
+ * not be handed a payload its own check refuses.
83
+ */
84
+ export declare function fetchJson<T>(url: string, file?: string, timeoutMs?: number, check?: PayloadCheck): Promise<T>;
85
+ export declare function buildRemoteServer(): McpServer;
package/dist/remote.js ADDED
@@ -0,0 +1,478 @@
1
+ /**
2
+ * molecule-mcp REMOTE mode — the hosted, read-only face of the server.
3
+ *
4
+ * Vibecoding platforms (Lovable chat connectors, v0 MCP integrations,
5
+ * bolt.new Connectors) and any client preferring a URL over a local process
6
+ * connect to this via HTTP transport. The stdio server in server.ts wraps a
7
+ * LOCAL mlcl; this one needs no local install at all, so its tools are the
8
+ * read/discovery layer, sourced live:
9
+ *
10
+ * - registry + templates fetched from the public mlcl repo (raw GitHub),
11
+ * env-overridable (MOLECULE_REGISTRY_URL / MOLECULE_TEMPLATES_URL or
12
+ * *_FILE for local dev), cached 10 minutes
13
+ * - package docs from the npm registry API (the authoritative published
14
+ * README for each @molecule/* package), also cached 10 minutes
15
+ */
16
+ import { readFileSync } from 'node:fs';
17
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
18
+ import { z } from 'zod';
19
+ import { serverVersion, tracked } from './telemetry.js';
20
+ const REGISTRY_URL = process.env.MOLECULE_REGISTRY_URL ??
21
+ 'https://raw.githubusercontent.com/molecule-dev/mlcl/main/registry.json';
22
+ const REGISTRY_FILE = process.env.MOLECULE_REGISTRY_FILE;
23
+ const TEMPLATES_URL = process.env.MOLECULE_TEMPLATES_URL ??
24
+ 'https://raw.githubusercontent.com/molecule-dev/mlcl/main/templates/apps/registry.json';
25
+ const TEMPLATES_FILE = process.env.MOLECULE_TEMPLATES_FILE;
26
+ const CACHE_TTL_MS = 10 * 60_000;
27
+ /**
28
+ * Bound on every outbound fetch this server makes (the mlcl repo's
29
+ * registry/templates and the npm registry's package metadata). Node's undici
30
+ * defaults let an upstream that accepts and then stalls hold the call — and
31
+ * with it the client's whole HTTP request — for ~5 minutes; a hung
32
+ * raw.githubusercontent.com must fail the tool call in seconds instead. The
33
+ * failure is the caller's to surface: a timed-out fetch leaves the cache
34
+ * untouched, so the next call retries the network (and a stale entry keeps
35
+ * serving until its TTL, never a failed one).
36
+ */
37
+ export const FETCH_TIMEOUT_MS = 15_000;
38
+ const cache = new Map();
39
+ /**
40
+ * Resolved package READMEs, keyed by the npm package name, with the same TTL as
41
+ * {@link cache}.
42
+ *
43
+ * The npm registry serves a README only inside the FULL packument for a package
44
+ * — `https://registry.npmjs.org/@molecule%2F<name>` — which nests one manifest
45
+ * per published VERSION (`/<name>/latest` carries no `readme`). At this fleet's
46
+ * release cadence that document is ~270 kB for a package with a few dozen
47
+ * versions, and it grows with every publish, while the tool only ever reads the
48
+ * one `readme` field (~64 kB). Without a cache, every `read_package_doc` call —
49
+ * a public, unauthenticated endpoint — re-downloaded and re-parsed the whole
50
+ * thing, so a client asking about the same package repeatedly paid the full
51
+ * egress and CPU each time (and the load on registry.npmjs.org scaled with the
52
+ * call rate rather than the package count). Only a SUCCESSFUL lookup is cached:
53
+ * a missing/unreadable README keeps the file's own policy — the next call
54
+ * retries the network — and the map is capped so a long-lived server cannot
55
+ * accumulate one cached doc per package name in the fleet.
56
+ */
57
+ const README_CACHE_TTL_MS = 10 * 60_000;
58
+ /** Cached packages kept before the least-recently-used entry is evicted (see the cap logic below). */
59
+ export const README_CACHE_LIMIT = 50;
60
+ const readmeCache = new Map();
61
+ /**
62
+ * What is being FETCHED right now, keyed like the caches above (`json:<key>` /
63
+ * `readme:<short>`).
64
+ *
65
+ * Both loaders read their cache and then write it across an `await`, and the
66
+ * hosted server answers requests concurrently, so two callers asking for the
67
+ * same uncached document both miss and both go to the network — each paying the
68
+ * whole packument/registry egress and parse, which is exactly the load the cache
69
+ * exists to remove ("the load on registry.npmjs.org scaled with the call rate
70
+ * rather than the package count" is the defect the cache was added for, and
71
+ * under concurrency it was still true). A caller that finds work in flight
72
+ * awaits THAT promise instead of starting a second one; the entry is dropped
73
+ * when the work settles, so a failed fetch is retried by the next caller rather
74
+ * than answered from a cached failure.
75
+ */
76
+ const inFlight = new Map();
77
+ /**
78
+ * Bumped by every {@link clearRemoteCaches}, and stamped into the keys and the
79
+ * cache writes below.
80
+ *
81
+ * The reset's whole purpose is that a caller AFTER it reads the world as it is
82
+ * now, not as the previous caller saw it — and a fetch that was in flight when
83
+ * the reset ran is exactly that previous caller's view. Without this stamp the
84
+ * reset cleared the two maps but not the work: a post-reset caller with the same
85
+ * key was handed the PRE-reset promise (a payload fetched before the reset,
86
+ * shared with nobody it belongs to), and the orphan settling a moment later
87
+ * wrote its pre-reset bytes into the freshly cleared cache — undoing the reset
88
+ * it was supposed to be outside of. A shared promise is keyed with the
89
+ * generation it was started under, and a fetch only publishes into the cache
90
+ * while that generation is still current.
91
+ */
92
+ let cacheGeneration = 0;
93
+ /**
94
+ * Drop every cached registry/templates payload and package README.
95
+ *
96
+ * Both maps are process-global ON PURPOSE — a long-lived hosted server pays one
97
+ * outbound fetch per document per TTL window instead of one per call — which
98
+ * also means the state survives between callers in one process, INCLUDING
99
+ * between the tests that point `MOLECULE_REGISTRY_FILE` at a rewritten file and
100
+ * then assert the new bytes are what the tool read. Those suites have to say
101
+ * that reset out loud rather than depend on the order they happen to run in, so
102
+ * this is the reset they call (the same affordance `clearRegistryCache` gives
103
+ * mlcl's own registry.ts, for the same reason).
104
+ */
105
+ export function clearRemoteCaches() {
106
+ cacheGeneration += 1;
107
+ cache.clear();
108
+ readmeCache.clear();
109
+ // Work already in flight is part of the state being reset: a post-reset
110
+ // caller must not be handed it, and (see cacheGeneration) its result must not
111
+ // land in the maps cleared here.
112
+ inFlight.clear();
113
+ }
114
+ /**
115
+ * Fetch a package's published README from the npm registry, cached per package.
116
+ *
117
+ * The network branch is bounded by {@link FETCH_TIMEOUT_MS} (`timeoutMs`
118
+ * shortens it for tests, like {@link fetchJson}). A fetched body with no
119
+ * `readme` resolves to `undefined`; a rejected, aborted, or non-2xx fetch
120
+ * REJECTS, the same as {@link fetchJson} — the caller's own try/catch is the
121
+ * fallback (see the `read_package_doc` handler). Neither case caches anything,
122
+ * so the next call retries the network. Callers that arrive while this
123
+ * package's fetch is already in flight share that one request instead of
124
+ * starting another (see {@link inFlight}).
125
+ *
126
+ * @param short - Package name WITHOUT the `@molecule/` scope.
127
+ * @param timeoutMs - Abort bound for the outbound fetch.
128
+ * @returns The published README, or undefined when the registry has none.
129
+ */
130
+ export async function fetchPackageReadme(short, timeoutMs = FETCH_TIMEOUT_MS) {
131
+ const generation = cacheGeneration;
132
+ const hit = readmeCache.get(short);
133
+ if (hit) {
134
+ if (Date.now() - hit.at < README_CACHE_TTL_MS) {
135
+ // Move the entry to the END of the map's order ("used most recently"),
136
+ // the same access order the rate limiter's buckets keep: the cap below
137
+ // evicts its FIRST key, and a Map does not move a key when it is
138
+ // re-set. Without this the cap evicted by INSERTION order — a session
139
+ // walking more than {@link README_CACHE_LIMIT} packages evicted the
140
+ // package it had just read, so the very working set the cache exists for
141
+ // (a client asking about the same packages repeatedly) still re-fetched
142
+ // the whole ~270 kB packument per call.
143
+ readmeCache.delete(short);
144
+ readmeCache.set(short, hit);
145
+ return hit.readme;
146
+ }
147
+ // Stale: drop it now so the fresh entry below lands at the end rather than
148
+ // inheriting the old position.
149
+ readmeCache.delete(short);
150
+ }
151
+ // One outbound fetch per uncached package, however many callers ask at once
152
+ // (see {@link inFlight}) — and only among the callers of THIS generation: work
153
+ // started before a {@link clearRemoteCaches} belongs to the callers of that
154
+ // era, not to this one.
155
+ const key = `readme:${generation}:${short}`;
156
+ const pending = inFlight.get(key);
157
+ if (pending)
158
+ return pending;
159
+ const work = fetchPackageReadmeUncached(short, timeoutMs, generation);
160
+ inFlight.set(key, work);
161
+ try {
162
+ return await work;
163
+ }
164
+ finally {
165
+ inFlight.delete(key);
166
+ }
167
+ }
168
+ /**
169
+ * The network half of {@link fetchPackageReadme} — see {@link inFlight} for why
170
+ * it is split out.
171
+ *
172
+ * `generation` is the {@link cacheGeneration} this fetch was started under: the
173
+ * README it returns is published only while that generation is still current,
174
+ * so a fetch that was in flight when {@link clearRemoteCaches} ran cannot write
175
+ * the bytes it read BEFORE the reset into the map the reset just emptied.
176
+ *
177
+ * @param short - Package name WITHOUT the `@molecule/` scope.
178
+ * @param timeoutMs - Abort bound for the outbound fetch.
179
+ * @param generation - The cache generation this fetch belongs to.
180
+ * @returns The published README, or undefined when the registry has none.
181
+ */
182
+ async function fetchPackageReadmeUncached(short, timeoutMs, generation) {
183
+ const response = await fetch(`https://registry.npmjs.org/@molecule%2F${short}`, {
184
+ signal: AbortSignal.timeout(timeoutMs),
185
+ });
186
+ // A non-2xx is a FAILURE, not a body without a readme — the same gate
187
+ // {@link fetchJson} applies. An error body deserializes as happily as a
188
+ // packument does (a 404's `{"error":"Not found"}`, a 429/503's JSON error
189
+ // from the registry itself — routine for unauthenticated packument traffic),
190
+ // so without the gate an outage answered every call with "doc not fetched",
191
+ // the same sentence a package that genuinely has no README gets.
192
+ if (!response.ok) {
193
+ throw new Error(`GET registry.npmjs.org/@molecule/${short} failed: HTTP ${response.status}`);
194
+ }
195
+ const meta = (await response.json());
196
+ if (typeof meta.readme !== 'string' || meta.readme.length === 0)
197
+ return undefined;
198
+ if (generation !== cacheGeneration)
199
+ return meta.readme;
200
+ if (readmeCache.size >= README_CACHE_LIMIT && !readmeCache.has(short)) {
201
+ const oldest = readmeCache.keys().next().value;
202
+ if (oldest !== undefined)
203
+ readmeCache.delete(oldest);
204
+ }
205
+ readmeCache.set(short, { at: Date.now(), readme: meta.readme });
206
+ return meta.readme;
207
+ }
208
+ /** Whether `value` is a plain object — the shape of a registry/templates section. */
209
+ function isRecord(value) {
210
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
211
+ }
212
+ /** Whether a payload is the package registry: an object carrying a `packages` object. */
213
+ export function isRegistryPayload(data) {
214
+ return isRecord(data) && isRecord(data.packages);
215
+ }
216
+ /** Whether a payload is the templates index: an object carrying a `templates` object. */
217
+ export function isTemplatesPayload(data) {
218
+ return isRecord(data) && isRecord(data.templates);
219
+ }
220
+ /**
221
+ * Fetch (and cache) the registry/templates JSON, or read it from a local file.
222
+ *
223
+ * The network branch is bounded by {@link FETCH_TIMEOUT_MS}; `timeoutMs`
224
+ * shortens that bound — the timeout regression test drives a never-responding
225
+ * local server through it with a 250 ms bound, well inside vitest's own 5 s
226
+ * default test timeout. The 15 s default would outlast that timeout, so the
227
+ * case could never pass without the seam.
228
+ * A failed (timed-out, rejected, non-2xx, or unrecognized) fetch throws without
229
+ * touching the cache, so the next call retries the network.
230
+ *
231
+ * `check` is what makes "recognized" mean anything. A 2xx (like a readable
232
+ * local file) proves only that bytes ARRIVED: `{"version":1}`, a proxy's
233
+ * 200 error page, a shrunken rewrite of the registry — each deserializes as
234
+ * happily as the real document and was then handed out as the caller's
235
+ * declared type and cached. The first reader of the missing section threw a
236
+ * bare `TypeError` from inside a tool handler (`search_packages` died on
237
+ * `Object.entries(undefined)`, reporting "Cannot convert undefined or null to
238
+ * object" to the client), and every call for the next 10 minutes was served
239
+ * the same non-payload. A payload that fails `check` is reported as the
240
+ * failure it is and caches nothing, exactly like a non-2xx answer. Callers that
241
+ * arrive while the same document is already being fetched share that one
242
+ * request instead of starting another (see {@link inFlight}).
243
+ *
244
+ * The check is the CALLER's, and it is re-applied to whatever the shortcuts
245
+ * hand over: a cache hit or a shared in-flight answer was validated by the
246
+ * FIRST reader's check, and the key is the document's SOURCE (url or file), not
247
+ * its shape — a second reader declaring a different shape for one source must
248
+ * not be handed a payload its own check refuses.
249
+ */
250
+ export async function fetchJson(url, file, timeoutMs = FETCH_TIMEOUT_MS, check) {
251
+ const key = file ?? url;
252
+ const generation = cacheGeneration;
253
+ const hit = cache.get(key);
254
+ if (hit && Date.now() - hit.at < CACHE_TTL_MS) {
255
+ // THIS caller's check governs what it is handed (see the note above): the
256
+ // entry holds whatever the FIRST reader's check admitted, so handing it over
257
+ // unvalidated answers a caller with a payload its own check refuses — and
258
+ // its handler then dereferences the missing section, the exact crash the
259
+ // check exists to prevent, reached through the cache instead of the fetch.
260
+ const cached = hit.data;
261
+ if (check && !check(cached))
262
+ throw new Error(`${key} did not return the expected payload`);
263
+ return cached;
264
+ }
265
+ // One outbound fetch per uncached document, however many callers ask at once
266
+ // (see {@link inFlight}) — and only among the callers of THIS generation: a
267
+ // fetch started before a {@link clearRemoteCaches} belongs to the callers of
268
+ // that era, not to this one.
269
+ const pendingKey = `json:${generation}:${key}`;
270
+ const pending = inFlight.get(pendingKey);
271
+ if (pending) {
272
+ // The shared work ran the FIRST caller's check; this caller re-runs its own
273
+ // before treating the answer as its declared shape — the same rule the cache
274
+ // hit above applies.
275
+ const shared = (await pending);
276
+ if (check && !check(shared))
277
+ throw new Error(`${key} did not return the expected payload`);
278
+ return shared;
279
+ }
280
+ const work = fetchJsonUncached(url, file, timeoutMs, check, generation);
281
+ inFlight.set(pendingKey, work);
282
+ try {
283
+ return await work;
284
+ }
285
+ finally {
286
+ inFlight.delete(pendingKey);
287
+ }
288
+ }
289
+ /**
290
+ * The read/parse half of {@link fetchJson} — see {@link inFlight} for why it is
291
+ * split out.
292
+ *
293
+ * `generation` is the {@link cacheGeneration} this fetch was started under: the
294
+ * payload is published only while that generation is still current, so a fetch
295
+ * in flight when {@link clearRemoteCaches} ran cannot write the bytes it read
296
+ * BEFORE the reset into the map the reset just emptied.
297
+ *
298
+ * @param url - The document's URL (used when `file` is absent).
299
+ * @param file - A local file to read INSTEAD of the URL.
300
+ * @param timeoutMs - Abort bound for the outbound fetch.
301
+ * @param check - The shape the payload must satisfy before it is cached.
302
+ * @param generation - The cache generation this fetch belongs to.
303
+ * @returns The parsed payload.
304
+ */
305
+ async function fetchJsonUncached(url, file, timeoutMs, check, generation) {
306
+ const key = file ?? url;
307
+ let data;
308
+ if (file) {
309
+ data = JSON.parse(readFileSync(file, 'utf8'));
310
+ }
311
+ else {
312
+ const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
313
+ // A non-2xx answer is a FAILURE, not a payload. An error body deserializes
314
+ // as happily as the registry does — a 404 whose body is `{"error":"Not
315
+ // found"}`, a proxy's 503 `{"message":…}` — so without this gate that body
316
+ // is returned AS the registry/templates and cached, and every call for the
317
+ // next 10 minutes serves it (search_packages then dies on
318
+ // `Object.entries(undefined)` instead of reporting the upstream failure).
319
+ if (!response.ok) {
320
+ throw new Error(`GET ${url} failed: HTTP ${response.status}`);
321
+ }
322
+ data = (await response.json());
323
+ }
324
+ // The same refusal one step later, for an answer that arrived but is not the
325
+ // document that was asked for — see the `check` note above. Nothing is cached,
326
+ // so the next call retries instead of serving the non-payload for 10 minutes.
327
+ if (check && !check(data)) {
328
+ throw new Error(`${key} did not return the expected payload`);
329
+ }
330
+ // Only a fetch of the CURRENT generation may publish: a reset that ran while
331
+ // this one was in flight means the bytes read here describe a world the
332
+ // caller after the reset must not be served (see cacheGeneration).
333
+ if (generation !== cacheGeneration)
334
+ return data;
335
+ cache.set(key, { at: Date.now(), data });
336
+ return data;
337
+ }
338
+ /** Same scoring shape as `mlcl search` — name match dominates, intent/category tokens follow. */
339
+ function scoreEntry(name, entry, q) {
340
+ const short = name.replace('@molecule/', '');
341
+ const tokens = q.toLowerCase().split(/\s+/).filter(Boolean);
342
+ if (tokens.length === 0)
343
+ return 0;
344
+ let score = 0;
345
+ for (const tok of tokens) {
346
+ if (short === tok)
347
+ score += 40;
348
+ else if (short.includes(tok))
349
+ score += 20;
350
+ if (entry.category?.includes(tok))
351
+ score += 8;
352
+ if (entry.intentHints?.some((h) => h.includes(tok) || tok.includes(h)))
353
+ score += 6;
354
+ if (entry.description?.toLowerCase().includes(tok))
355
+ score += 3;
356
+ if (entry.label?.toLowerCase().includes(tok))
357
+ score += 3;
358
+ }
359
+ return score;
360
+ }
361
+ const text = (t) => ({
362
+ content: [{ type: 'text', text: t }],
363
+ });
364
+ export function buildRemoteServer() {
365
+ // The package.json version, not a hardcoded copy — the literal here went
366
+ // stale on the first package bump and every client's initialize response
367
+ // then reported a server that was not deployed.
368
+ const server = new McpServer({ name: 'molecule-remote', version: serverVersion() });
369
+ server.registerTool('search_packages', {
370
+ title: 'Search @molecule packages',
371
+ description: 'Search the @molecule/* package fleet by name or capability (e.g. "payments", "social login"). Returns matching packages with stack, category, and install command. Prefer an existing package over hand-rolling any capability.',
372
+ inputSchema: {
373
+ // Bounded: `scoreEntry` splits the query on whitespace and runs one
374
+ // pass over EVERY fleet package (~900) for each token, so the work one
375
+ // request costs is (tokens × packages). With no cap, an 800 KB body of
376
+ // whitespace-separated words (a few hundred thousand tokens, well under
377
+ // the 1 MB body limit) pinned a core of this public, single-threaded
378
+ // server for minutes — a per-address rate budget does not bound the
379
+ // work a single allowed request may do. A capability query is a phrase.
380
+ query: z.string().max(200).describe('Capability or package-name query'),
381
+ limit: z.number().int().min(1).max(100).optional(),
382
+ },
383
+ }, tracked('search_packages', async ({ query, limit }) => {
384
+ // `isRegistryPayload` is the gate the type below cannot provide on its
385
+ // own: without it a body carrying no `packages` map reached
386
+ // `Object.entries(undefined)` and the client was told "Cannot convert
387
+ // undefined or null to object" instead of what went wrong upstream.
388
+ const registry = await fetchJson(REGISTRY_URL, REGISTRY_FILE, FETCH_TIMEOUT_MS, isRegistryPayload);
389
+ const packages = registry.packages;
390
+ const hits = Object.entries(packages)
391
+ .map(([name, entry]) => ({
392
+ name,
393
+ stack: entry.stack ?? (name.includes('/app-') ? 'app' : 'api'),
394
+ category: entry.category ?? '',
395
+ description: entry.description ?? '',
396
+ score: scoreEntry(name, entry, query),
397
+ }))
398
+ .filter((h) => h.score > 0)
399
+ .sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
400
+ .slice(0, limit ?? 20)
401
+ .map(({ score: _score, ...hit }) => ({
402
+ ...hit,
403
+ install: `npx mlcl add ${hit.name} --inject`,
404
+ }));
405
+ return text(JSON.stringify(hits, null, 2));
406
+ }));
407
+ server.registerTool('list_templates', {
408
+ title: 'List molecule app templates',
409
+ description: 'List the flagship full-stack app templates (blog, crm, team-chat, …) — the fastest starting point for a new app.',
410
+ inputSchema: {},
411
+ }, tracked('list_templates', async () => {
412
+ // Same gate as `search_packages`, and for the same reason: `t.templates
413
+ // ?? {}` used to answer an unrecognized payload with an EMPTY gallery —
414
+ // a wrong answer ("there are no templates") instead of the upstream
415
+ // failure, and one cached for 10 minutes.
416
+ const t = await fetchJson(TEMPLATES_URL, TEMPLATES_FILE, FETCH_TIMEOUT_MS, isTemplatesPayload);
417
+ const list = Object.entries(t.templates)
418
+ .sort(([a], [b]) => a.localeCompare(b))
419
+ .map(([slug, e]) => ({
420
+ slug,
421
+ label: e.label ?? slug,
422
+ description: e.description ?? '',
423
+ type: e.type ?? 'full-stack',
424
+ packageCount: e.packages?.length ?? 0,
425
+ create: `npx mlcl create my-app --template ${slug} --no-interactive`,
426
+ }));
427
+ return text(JSON.stringify(list, null, 2));
428
+ }));
429
+ server.registerTool('read_package_doc', {
430
+ title: 'Read a package README',
431
+ description: "Fetch a @molecule/* package's generated usage reference (API, examples, wiring) — the authoritative published doc.",
432
+ inputSchema: {
433
+ // Bounded, like the sibling `search_packages` query: this is a public,
434
+ // unauthenticated server, and the name is interpolated into the outbound
435
+ // npm registry URL verbatim — an unbounded name is an outbound request
436
+ // sized by the caller, up to the HTTP body cap, at the per-address
437
+ // budget's full rate. A package short name is under 100 characters.
438
+ name: z.string().max(200).describe('Package name, with or without the @molecule/ scope'),
439
+ },
440
+ }, tracked('read_package_doc', async ({ name }) => {
441
+ const short = name.replace(/^@molecule\//, '');
442
+ // The same package-name shape the stdio server's path guard enforces (see
443
+ // mcp/src/server.ts): a name is a single descending segment, so it cannot
444
+ // begin with `.` — `..` is a relative path, and this value is
445
+ // interpolated into an outbound URL and echoed back.
446
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(short)) {
447
+ return text(`Invalid package name "${short}" — expected @molecule/<name>.`);
448
+ }
449
+ try {
450
+ const doc = await fetchPackageReadme(short);
451
+ if (doc) {
452
+ if (doc.length > 100_000)
453
+ return text(`${doc.slice(0, 100_000)}\n\n[…truncated — full doc: https://www.molecule.dev/packages/${short}]`);
454
+ return text(doc);
455
+ }
456
+ }
457
+ catch (_error) {
458
+ // fall through to the pointer below
459
+ }
460
+ return text(`Doc not fetched for @molecule/${short} — browse https://www.molecule.dev/packages/${short}`);
461
+ }));
462
+ server.registerResource('conventions', 'molecule://conventions', {
463
+ title: 'The molecule way',
464
+ description: 'The molecule conventions for building and extending apps — packages before hand-rolled code, bonds for providers, ClassMap styling, i18n text, plus the mlcl quickstart.',
465
+ mimeType: 'text/markdown',
466
+ }, async () => ({
467
+ contents: [
468
+ {
469
+ uri: 'molecule://conventions',
470
+ mimeType: 'text/markdown',
471
+ // Bundled copy of mlcl's templates/shared/agent-init-section.md.tpl —
472
+ // keep the two in sync when the doctrine changes.
473
+ text: readFileSync(new URL('../assets/conventions.md', import.meta.url), 'utf8'),
474
+ },
475
+ ],
476
+ }));
477
+ return server;
478
+ }
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ declare const server: McpServer;
4
+ /**
5
+ * Mask an environment value for a reader that did not ask to reveal it.
6
+ *
7
+ * The prefix is a HINT ("which key is set", `sk-…`), never the value: taking
8
+ * `slice(0, 3)` unconditionally emitted the WHOLE secret for any value of three
9
+ * characters or fewer through a call whose contract is "masked unless reveal"
10
+ * (`OTP=123` came back as `123•••`), and three of four characters for a
11
+ * four-character one. A value too short to have a prefix worth showing is
12
+ * masked entirely instead.
13
+ *
14
+ * @param value - The value read from the project's `.env`.
15
+ * @returns The masked form: `abc•••`, or `•••` when the value is too short for
16
+ * its prefix to be anything but the value.
17
+ */
18
+ export declare function maskEnvValue(value: string): string;
19
+ /** The configured server — exported so tests can link an in-memory client to it. */
20
+ export { server as moleculeServer };