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.
- package/README.md +178 -2
- package/assets/conventions.md +28 -0
- package/bin/run.js +2 -0
- package/bin/serve.js +2 -0
- package/dist/auth.d.ts +43 -0
- package/dist/auth.js +163 -0
- package/dist/http.d.ts +1 -0
- package/dist/http.js +181 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +14 -0
- package/dist/remote.d.ts +85 -0
- package/dist/remote.js +478 -0
- package/dist/server.d.ts +20 -0
- package/dist/server.js +2246 -0
- package/dist/services.d.ts +60 -0
- package/dist/services.js +223 -0
- package/dist/telemetry.d.ts +39 -0
- package/dist/telemetry.js +376 -0
- package/package.json +36 -4
package/dist/remote.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -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 };
|