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,376 @@
1
+ /**
2
+ * Anonymous, opt-out tool-call telemetry for molecule-mcp — same wire
3
+ * contract as mlcl's telemetry (the @molecule/api-analytics-http bond's
4
+ * payload shape: { kind, source, sentAt, event }), emitted with a plain
5
+ * fetch. That bond has since published (mlcl's CLI bonds it), but this
6
+ * server still hand-rolls the POST to keep its dependency list free of
7
+ * @molecule/* packages; the payload shape parity above is what keeps the
8
+ * endpoint indifferent.
9
+ *
10
+ * Posture mirrors mlcl (see the mlcl README's "Telemetry & privacy"): opt-out
11
+ * (MOLECULE_TELEMETRY=0 or DO_NOT_TRACK=1; a local MOLECULE_ENV=local run stays
12
+ * silent unless MOLECULE_TELEMETRY=1 re-enables it), anonymous (a random
13
+ * per-install id in ~/.mlcl/telemetry-id, shared with the CLI), fire-and-forget
14
+ * with a 5s timeout, no-op without a reachable endpoint. Emit failures are
15
+ * swallowed — logged only under MLCL_DEBUG=1, like the CLI. Emits are also
16
+ * BOUNDED in flight ({@link MAX_IN_FLIGHT_EMITS}) and dropped past that bound,
17
+ * so incoming traffic cannot turn into unbounded outbound load.
18
+ */
19
+ import { randomUUID } from 'node:crypto';
20
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
21
+ import { homedir } from 'node:os';
22
+ import { join } from 'node:path';
23
+ import { apiBase } from './auth.js';
24
+ const ID_FILE = join(homedir(), '.mlcl', 'telemetry-id');
25
+ let cachedVersion = null;
26
+ /**
27
+ * The server's own version, read once from mcp/package.json — the same field
28
+ * `bin/*` and the SDK's serverInfo report. A hardcoded copy here (and in the
29
+ * McpServer constructor) went stale the first time the package was bumped, so
30
+ * the telemetry's `serverVersion` described a server that was not deployed.
31
+ */
32
+ export function serverVersion() {
33
+ if (cachedVersion === null) {
34
+ let resolved;
35
+ try {
36
+ const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
37
+ resolved = typeof manifest.version === 'string' ? manifest.version : 'unknown';
38
+ }
39
+ catch (_error) {
40
+ // A missing/unparseable manifest must not take the server down over a
41
+ // version string — report 'unknown' instead.
42
+ resolved = 'unknown';
43
+ }
44
+ cachedVersion = resolved;
45
+ }
46
+ return cachedVersion;
47
+ }
48
+ /**
49
+ * The ingestion route under the same API root as every other call this server
50
+ * makes ({@link apiBase}). A separate literal without the `/api` prefix the API
51
+ * mounts its routes under 404'd every event, so no tool call was ever recorded.
52
+ *
53
+ * @returns The telemetry endpoint URL.
54
+ */
55
+ export function telemetryUrl() {
56
+ return process.env.MOLECULE_TELEMETRY_URL ?? `${apiBase()}/v1/telemetry/cli`;
57
+ }
58
+ export function telemetryEnabled() {
59
+ // The cross-tool opt-out convention (https://consoledonottrack.com) — the
60
+ // same check the CLI runs, which both READMEs promise for molecule-mcp.
61
+ if (process.env.DO_NOT_TRACK === '1')
62
+ return false;
63
+ // Local-dev runs never report — MOLECULE_ENV=local implies opt-out unless
64
+ // telemetry is EXPLICITLY re-enabled (MOLECULE_TELEMETRY=1).
65
+ if (process.env.MOLECULE_ENV === 'local')
66
+ return process.env.MOLECULE_TELEMETRY === '1';
67
+ return process.env.MOLECULE_TELEMETRY !== '0';
68
+ }
69
+ /**
70
+ * How many times {@link installId} re-reads an id file a live writer is
71
+ * momentarily leaving empty, and the pause between those reads: 20 × 5 ms is
72
+ * far longer than the window between a winner's exclusive create and its write,
73
+ * and short enough that only the genuinely-empty-file case pays it.
74
+ */
75
+ const ID_ADOPT_ATTEMPTS = 20;
76
+ /** The pause between {@link installId}'s re-reads — see {@link ID_ADOPT_ATTEMPTS}. */
77
+ const ID_ADOPT_WAIT_MS = 5;
78
+ /** A bounded synchronous sleep, so the adopt loop above cannot yield to the caller. */
79
+ function sleepSync(ms) {
80
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
81
+ }
82
+ /**
83
+ * Read the id file, waiting out a live writer's bytes — the adopt loop, shared by
84
+ * the EEXIST branch of {@link installId} and its empty-leftover takeover.
85
+ *
86
+ * A read that fails ENOENT is "no id there RIGHT NOW", never "unreadable id
87
+ * file": the name is momentarily gone because a rival's exclusive-create
88
+ * takeover renamed the file aside (see {@link installId}), which is exactly the
89
+ * state this loop exists to wait out. Letting the ENOENT escape put it on
90
+ * {@link installId}'s outer catch, whose `'unknown'` id is the net for a file
91
+ * that cannot be read AT ALL — so a process racing a takeover reported a blank
92
+ * anonymousId for that event, the split-across-two-ids failure this whole
93
+ * module is built to avoid, reached through the wait instead of the create.
94
+ *
95
+ * @returns The published id, or null when the file still reads empty (or is
96
+ * still gone) after {@link ID_ADOPT_ATTEMPTS} tries.
97
+ */
98
+ function readPublishedId() {
99
+ for (let attempt = 0; attempt < ID_ADOPT_ATTEMPTS; attempt++) {
100
+ let won;
101
+ try {
102
+ won = readFileSync(ID_FILE, 'utf8').trim();
103
+ }
104
+ catch (error) {
105
+ if (error?.code !== 'ENOENT')
106
+ throw error;
107
+ // Gone for now — keep waiting for the winner's bytes.
108
+ sleepSync(ID_ADOPT_WAIT_MS);
109
+ continue;
110
+ }
111
+ if (won)
112
+ return won;
113
+ sleepSync(ID_ADOPT_WAIT_MS);
114
+ }
115
+ return null;
116
+ }
117
+ function installId() {
118
+ try {
119
+ if (existsSync(ID_FILE)) {
120
+ // A read that fails ENOENT is the file that was there a moment ago and is
121
+ // gone NOW — a rival's takeover renamed it aside. That is "no id to adopt
122
+ // yet", not an unreadable id file, so it falls through to the exclusive
123
+ // create below (which either publishes this process's id or adopts the
124
+ // winner's) instead of reaching the outer catch's `'unknown'` id, which is
125
+ // the net for a file that cannot be read at all.
126
+ let existing = '';
127
+ try {
128
+ existing = readFileSync(ID_FILE, 'utf8').trim();
129
+ }
130
+ catch (error) {
131
+ if (error?.code !== 'ENOENT')
132
+ throw error;
133
+ }
134
+ // A ZERO-LENGTH id file must not become the permanent anonymous id:
135
+ // writeFileSync opens with O_TRUNC before it writes, so a crash or a full
136
+ // disk between the two leaves an empty file — and an early return here
137
+ // would then read that '' back on every later run, reporting a blank
138
+ // anonymousId for the life of the install. Fall through and mint one
139
+ // (the write below fills the empty file in).
140
+ if (existing)
141
+ return existing;
142
+ }
143
+ const id = randomUUID();
144
+ mkdirSync(join(ID_FILE, '..'), { recursive: true, mode: 0o700 });
145
+ try {
146
+ // Exclusive create. The read above and this write are not atomic, and two
147
+ // processes sharing one `~/.mlcl` (the stdio server plus a hosted
148
+ // instance, two tool calls on their first use) can both reach here: a
149
+ // plain write published whichever id landed LAST while the loser's event
150
+ // already carried its own, splitting one install across two anonymousIds.
151
+ // `wx` makes the second writer fail instead, and it then adopts the id the
152
+ // winner published.
153
+ writeFileSync(ID_FILE, id, { mode: 0o600, flag: 'wx' });
154
+ return id;
155
+ }
156
+ catch (error) {
157
+ if (error?.code !== 'EEXIST')
158
+ throw error;
159
+ // Lost the race. An EMPTY file is either the winner mid-write — `wx`
160
+ // opens the name before it writes the bytes, so a live writer is
161
+ // momentarily observable as an empty file — or a writer that died
162
+ // between its open and its write (see the zero-length note above). The
163
+ // two are indistinguishable by content, so WAIT briefly for the winner's
164
+ // bytes before deciding: reading that window as "no id" published ours
165
+ // over a live holder's, splitting one install across two anonymousIds —
166
+ // the exact failure the exclusive create exists to prevent, reached
167
+ // through the empty-file branch instead of the create.
168
+ const adopted = readPublishedId();
169
+ if (adopted)
170
+ return adopted;
171
+ // Still empty after that budget: a genuinely empty leftover file (no
172
+ // live writer writes for that long), so this process takes the name
173
+ // over — through the SAME exclusive create the winner used, never a
174
+ // truncating in-place write. Two processes that exhausted the wait
175
+ // together each published over the other with `writeFileSync` (default
176
+ // 'w': truncate, then write) — the last truncate won while BOTH events
177
+ // already carried their own id, so one install was split across two
178
+ // anonymousIds, the exact failure the exclusive create exists to
179
+ // prevent, reached once more through this fallback — and each truncate
180
+ // read as a fresh empty file to the next reader's adopt loop. Renaming
181
+ // the empty file aside (a per-pid name, so a rival's own takeover can
182
+ // never replace it) frees the name for a `wx` create: exactly one
183
+ // process publishes, and every loser lands back in this EEXIST branch
184
+ // and adopts the winner's bytes.
185
+ const leftover = `${ID_FILE}.empty.${process.pid}`;
186
+ try {
187
+ renameSync(ID_FILE, leftover);
188
+ }
189
+ catch (_error) {
190
+ // The name is already gone — a rival took it over first. The exclusive
191
+ // create below settles which of us publishes.
192
+ }
193
+ let moved = null;
194
+ try {
195
+ moved = readFileSync(leftover, 'utf8').trim() || null;
196
+ }
197
+ catch (_error) {
198
+ // The moved file could not be read — treat it as the empty leftover
199
+ // this takeover took it for.
200
+ }
201
+ if (moved !== null) {
202
+ // A live writer's bytes landed between the adopt loop's last read and
203
+ // the rename: the file we just moved holds the id this install must
204
+ // keep. Put it back — through the SAME exclusive create the winner
205
+ // used, which is the only operation here that cannot replace a file
206
+ // that appeared in the meantime. `rename` (what this used to be) is
207
+ // NOT that operation: rename(2) atomically REPLACES its destination, so
208
+ // a rival that took the freed name in the gap had the id it had just
209
+ // published silently overwritten by the bytes captured here — while its
210
+ // own event already carried that id, splitting one install across two
211
+ // anonymousIds, the failure this whole function exists to prevent.
212
+ // `wx` fails with EEXIST instead, and the rival's bytes are adopted so
213
+ // the file and the reported id agree.
214
+ let published = moved;
215
+ try {
216
+ writeFileSync(ID_FILE, moved, { mode: 0o600, flag: 'wx' });
217
+ }
218
+ catch (putBackError) {
219
+ if (putBackError?.code !== 'EEXIST')
220
+ throw putBackError;
221
+ // A rival re-took the name in the gap: its bytes are the install's id
222
+ // now (its own event already carries them). Adopt them so the file
223
+ // and the reported id name the same install; an empty file there is a
224
+ // rival mid-write, which the captured bytes answer for instead.
225
+ try {
226
+ published = readFileSync(ID_FILE, 'utf8').trim() || moved;
227
+ }
228
+ catch (_readError) {
229
+ published = moved;
230
+ }
231
+ }
232
+ try {
233
+ rmSync(leftover, { force: true });
234
+ }
235
+ catch (_error) {
236
+ // Inert litter beside the id file; the id itself is published.
237
+ }
238
+ return published;
239
+ }
240
+ try {
241
+ rmSync(leftover, { force: true });
242
+ }
243
+ catch (_error) {
244
+ // Inert litter; the publish below needs only the freed name.
245
+ }
246
+ try {
247
+ writeFileSync(ID_FILE, id, { mode: 0o600, flag: 'wx' });
248
+ return id;
249
+ }
250
+ catch (takeoverError) {
251
+ if (takeoverError?.code !== 'EEXIST')
252
+ throw takeoverError;
253
+ // A rival published while this one renamed — adopt its bytes with the
254
+ // same short wait as the loop above. Exhausted here too, the failure
255
+ // reaches the outer catch, which reports the anonymous 'unknown' id
256
+ // rather than publishing a second id over a rival's.
257
+ const won = readPublishedId();
258
+ if (won)
259
+ return won;
260
+ throw takeoverError;
261
+ }
262
+ }
263
+ }
264
+ catch (_error) {
265
+ // Telemetry must never break a tool call over an unreadable id file —
266
+ // report the anonymous 'unknown' id and move on.
267
+ return 'unknown';
268
+ }
269
+ }
270
+ /**
271
+ * How many telemetry emits may be in flight at once.
272
+ *
273
+ * The hosted server is public and unauthenticated, and its request budget is
274
+ * per client ADDRESS — so a flood from many addresses (a botnet, or a
275
+ * header-rotating client on an unguarded proxy) stays inside each address's own
276
+ * budget. Every accepted tool call started one outbound POST here and nothing
277
+ * bounded them: a burst of B calls held B pending requests, B abort timers and B
278
+ * socket slots for the 5 s the timeout allows, growing with the incoming rate
279
+ * instead of with the work the server owes anyone. An emit is best-effort (see
280
+ * the module note, and the CLI's telemetry posture), so the overflow is DROPPED
281
+ * rather than queued — a queue is exactly the unbounded thing this bound exists
282
+ * to prevent, and a dropped event costs nothing but the comparison below.
283
+ */
284
+ export const MAX_IN_FLIGHT_EMITS = 8;
285
+ /** Emits started and not yet settled — see {@link MAX_IN_FLIGHT_EMITS}. */
286
+ let inFlightEmits = 0;
287
+ /**
288
+ * Whether a RESOLVED tool result is the MCP SDK's declared failure shape.
289
+ *
290
+ * A tool can fail in two ways: it throws (the wrapper sees the rejection), or it
291
+ * resolves with `isError: true` — which is how this server reports every
292
+ * hosted-service failure (`serviceResult` turns a `ServiceCallError` into that
293
+ * content block so the calling agent reads a sentence instead of a protocol
294
+ * error). A wrapper that watched only for the throw recorded every "no project
295
+ * API key", 401 "the key was rejected", 402 allowance, and "the service returned
296
+ * no …" answer as a SUCCESS, so the flag that exists to make this server's
297
+ * failures visible counted exactly those failures as good calls. The emitted
298
+ * flag follows what the CLIENT sees.
299
+ *
300
+ * @param result - The value a wrapped handler resolved with.
301
+ * @returns True when the result carries `isError: true`.
302
+ */
303
+ function isErrorResult(result) {
304
+ return (typeof result === 'object' &&
305
+ result !== null &&
306
+ result.isError === true);
307
+ }
308
+ /**
309
+ * Fire-and-forget `mcp.tool_call` event — never throws, never blocks, and never
310
+ * queues: once {@link MAX_IN_FLIGHT_EMITS} emits are in flight this call is
311
+ * dropped.
312
+ */
313
+ export function trackToolCall(tool, ok, startedAt) {
314
+ if (!telemetryEnabled())
315
+ return;
316
+ // Saturated — drop this event. Deliberately above the payload below: nothing
317
+ // on the dropped path reads the id file from disk or builds a body.
318
+ if (inFlightEmits >= MAX_IN_FLIGHT_EMITS)
319
+ return;
320
+ const url = telemetryUrl();
321
+ const payload = {
322
+ kind: 'track',
323
+ source: 'molecule-mcp',
324
+ sentAt: new Date().toISOString(),
325
+ event: {
326
+ name: 'mcp.tool_call',
327
+ anonymousId: installId(),
328
+ properties: {
329
+ tool,
330
+ ok,
331
+ durationMs: Math.round(Date.now() - startedAt),
332
+ serverVersion: serverVersion(),
333
+ nodeMajor: Number.parseInt(process.versions.node.split('.')[0] ?? '0', 10),
334
+ platform: process.platform,
335
+ arch: process.arch,
336
+ },
337
+ },
338
+ };
339
+ inFlightEmits++;
340
+ void fetch(url, {
341
+ method: 'POST',
342
+ headers: { 'content-type': 'application/json' },
343
+ body: JSON.stringify(payload),
344
+ signal: AbortSignal.timeout(5_000),
345
+ })
346
+ .catch((error) => {
347
+ // Telemetry must never break a tool call — but MLCL_DEBUG=1 promises
348
+ // inspectable traffic (mlcl README, "Telemetry & privacy"), so an emit
349
+ // failure is logged there, the same as the CLI's.
350
+ if (process.env.MLCL_DEBUG === '1')
351
+ console.error('[telemetry] emit failed:', error);
352
+ })
353
+ .finally(() => {
354
+ // The slot is released on EVERY outcome (resolved, rejected, aborted by
355
+ // the timeout above), so a saturated server unblocks itself as its own
356
+ // emits settle instead of going silent for the life of the process.
357
+ inFlightEmits--;
358
+ });
359
+ }
360
+ /** Wrap a tool handler with emission — used by every registerTool call. */
361
+ export function tracked(tool, handler) {
362
+ return async (args) => {
363
+ const startedAt = Date.now();
364
+ try {
365
+ const result = await handler(args);
366
+ // A resolved handler is not necessarily a SUCCESSFUL tool call: see
367
+ // isErrorResult — the hosted-service tools report their failures this way.
368
+ trackToolCall(tool, !isErrorResult(result), startedAt);
369
+ return result;
370
+ }
371
+ catch (error) {
372
+ trackToolCall(tool, false, startedAt);
373
+ throw error;
374
+ }
375
+ };
376
+ }
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "molecule-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "MCP server exposing the Molecule.dev package ecosystem — search, templates, scaffolding — to any MCP-capable coding agent",
5
+ "author": "Molecule Dev, Inc. (https://molecule.dev)",
6
+ "license": "Apache-2.0",
7
+ "type": "module",
8
+ "engines": {
9
+ "node": ">=22"
10
+ },
11
+ "bin": {
12
+ "molecule-mcp": "bin/run.js",
13
+ "molecule-mcp-serve": "bin/serve.js"
14
+ },
15
+ "files": [
16
+ "/bin",
17
+ "/dist",
18
+ "/assets",
19
+ "/README.md"
20
+ ],
21
+ "scripts": {
22
+ "build": "shx rm -rf dist && tsc -b",
23
+ "smoke": "node scripts/smoke.mjs",
24
+ "serve": "node dist/http.js"
25
+ },
26
+ "dependencies": {
27
+ "@modelcontextprotocol/sdk": "1.17.0",
28
+ "execa": "10.0.0",
29
+ "zod": "3.25.76",
30
+ "express": "5.2.1"
31
+ },
32
+ "devDependencies": {
33
+ "@types/express": "5.0.6",
34
+ "@types/node": "26.1.2",
35
+ "shx": "0.4.0",
36
+ "typescript": "6.0.3"
37
+ }
38
+ }