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
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|