molecule-mcp 0.2.0 → 0.3.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.
@@ -4,9 +4,21 @@ export interface ServiceKey {
4
4
  source: string;
5
5
  }
6
6
  /**
7
- * The project API key for service calls: MOLECULE_API_KEY from the server's
8
- * environment, else `MOLECULE_API_KEY` in `.env` at `directory` or up to two
9
- * parents (a full-stack project keeps it in `api/.env` or the root).
7
+ * The project API key for service calls, resolved in this documented order:
8
+ *
9
+ * 1. `MOLECULE_API_KEY` in this server's environment (always wins);
10
+ * 2. `.env`, then every `.env.<variant>` (byte-sorted), in `directory` —
11
+ * then the same set in `directory/api`;
12
+ * 3. the same two locations in each parent, up to two parents.
13
+ *
14
+ * Inside one file, Node's dotenv rules apply: the LAST assignment wins, and
15
+ * inline comments / quoting are honoured. A file that does not define a
16
+ * non-blank MOLECULE_API_KEY is skipped, and placeholder variants
17
+ * (`.env.example`, `.env.schema`, …) are never read. Because `create_api_key`
18
+ * writes only these names through the same predicate, a key it writes at
19
+ * `<dir>/.env[.<variant>]` or `<dir>/api/.env[.<variant>]` is found by a later
20
+ * service call whose `directory` is that project directory (or up to two
21
+ * levels below it).
10
22
  *
11
23
  * @param directory - Project directory to search (default cwd).
12
24
  * @returns The key and where it came from, or null.
@@ -48,8 +60,14 @@ export interface CreatedKey {
48
60
  }
49
61
  /**
50
62
  * Mint a project API key and write it into a dotenv file as
51
- * MOLECULE_API_KEY, replacing an existing line. The plaintext is returned by
52
- * the platform exactly once and is never echoed back to the agent.
63
+ * MOLECULE_API_KEY, replacing an existing assignment and dropping duplicate
64
+ * ones. The plaintext is returned by the platform exactly once and is never
65
+ * echoed back to the agent.
66
+ *
67
+ * The destination is validated BEFORE the platform is asked to mint anything,
68
+ * and only names `resolveServiceKey` reads are accepted (`.env` or
69
+ * `.env.<variant>`, never a placeholder like `.env.example`) — so a written
70
+ * key is always discoverable by the service tools that follow.
53
71
  *
54
72
  * @param projectId - The project the key bills to.
55
73
  * @param name - A label shown in the project's key list.
package/dist/services.js CHANGED
@@ -6,28 +6,116 @@
6
6
  * projects, mint a project API key;
7
7
  * - a PROJECT API key (`mk_…`) pays for service calls, metered and billed to
8
8
  * that project exactly like a deployed app calling the same URL. It is read
9
- * from MOLECULE_API_KEY, else from the project's `.env` (the same place the
10
- * app's `@molecule/*-molecule` bonds read it), so the key never has to pass
11
- * through the chat transcript.
9
+ * from MOLECULE_API_KEY, else from the project's dotenv files (`.env`, then
10
+ * `.env.<variant>`, in the project root and its `api/` directory — the same
11
+ * place the app's `@molecule/*-molecule` bonds read it), so the key never
12
+ * has to pass through the chat transcript.
12
13
  */
13
- import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
14
+ import { spawnSync } from 'node:child_process';
15
+ import { chmodSync, existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
14
16
  import { basename, dirname, join, resolve } from 'node:path';
17
+ import { parseEnv } from 'node:util';
15
18
  import { apiBase, sessionToken } from './auth.js';
16
- /** Parse one variable out of a dotenv file; undefined when absent. */
19
+ /**
20
+ * The dotenv file names this server treats as project env files — the names
21
+ * `create_api_key` may write and the names key resolution reads. The SAME
22
+ * predicate on both sides is what keeps a written key discoverable.
23
+ */
24
+ const ENV_FILE_NAME_RE = /^\.env(?:\.([A-Za-z0-9_-]+))?$/;
25
+ /**
26
+ * Variants that hold placeholders or tool bookkeeping, never credentials
27
+ * (`.env.example`, `.env.schema`, `.env.vault`, …). Auto-loading one would
28
+ * send a placeholder as the bearer key, so neither side accepts them.
29
+ * Compared case-insensitively.
30
+ */
31
+ const PLACEHOLDER_ENV_VARIANTS = new Set([
32
+ 'example',
33
+ 'sample',
34
+ 'schema',
35
+ 'template',
36
+ 'tmpl',
37
+ 'dist',
38
+ 'vault',
39
+ 'keys',
40
+ ]);
41
+ /**
42
+ * Whether a dotenv basename is a project env file: `.env`, or `.env.<variant>`
43
+ * where the variant is not a placeholder.
44
+ *
45
+ * @param name - A dotenv file's basename.
46
+ * @returns True when the file may hold — and be written with — the project key.
47
+ */
48
+ function isProjectEnvFileName(name) {
49
+ const match = ENV_FILE_NAME_RE.exec(name);
50
+ if (!match)
51
+ return false;
52
+ return match[1] === undefined || !PLACEHOLDER_ENV_VARIANTS.has(match[1].toLowerCase());
53
+ }
54
+ /**
55
+ * Parse one variable out of a dotenv file with Node's own dotenv parser
56
+ * (`util.parseEnv`), so inline comments, single/double quoting, `export`
57
+ * prefixes, duplicate assignments (last wins) and multi-line quoted values
58
+ * follow exactly the rules `node --env-file` applies — not a lookalike regex.
59
+ *
60
+ * @param file - Dotenv file to read.
61
+ * @param name - Variable name.
62
+ * @returns The parsed non-blank value, or undefined when absent.
63
+ */
17
64
  function readDotenvVar(file, name) {
18
65
  if (!existsSync(file))
19
66
  return undefined;
20
- for (const line of readFileSync(file, 'utf8').split('\n')) {
21
- const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
22
- if (m && m[1] === name)
23
- return m[2].replace(/^(['"])(.*)\1$/, '$2') || undefined;
67
+ // A BOM would otherwise become part of the first variable's NAME.
68
+ const content = readFileSync(file, 'utf8');
69
+ const parsed = parseEnv(content.charCodeAt(0) === 0xfeff ? content.slice(1) : content);
70
+ const value = parsed[name];
71
+ return typeof value === 'string' && value.trim() ? value : undefined;
72
+ }
73
+ /**
74
+ * The dotenv files in one directory that may hold the project key, in
75
+ * precedence order: `.env` first, then `.env.<variant>` byte-sorted — a fixed
76
+ * order across filesystems, so the same tree always resolves the same file.
77
+ *
78
+ * @param dir - Directory to list.
79
+ * @returns Candidate paths (empty when `dir` is not a readable directory).
80
+ */
81
+ function dotenvCandidates(dir) {
82
+ let entries;
83
+ try {
84
+ entries = readdirSync(dir);
85
+ }
86
+ catch (_error) {
87
+ // Not a directory, or not readable: this location has no candidates.
88
+ return [];
24
89
  }
25
- return undefined;
90
+ return entries
91
+ .filter(isProjectEnvFileName)
92
+ .sort((a, b) => {
93
+ if (a === b)
94
+ return 0;
95
+ if (a === '.env')
96
+ return -1;
97
+ if (b === '.env')
98
+ return 1;
99
+ return a < b ? -1 : 1;
100
+ })
101
+ .map((name) => join(dir, name));
26
102
  }
27
103
  /**
28
- * The project API key for service calls: MOLECULE_API_KEY from the server's
29
- * environment, else `MOLECULE_API_KEY` in `.env` at `directory` or up to two
30
- * parents (a full-stack project keeps it in `api/.env` or the root).
104
+ * The project API key for service calls, resolved in this documented order:
105
+ *
106
+ * 1. `MOLECULE_API_KEY` in this server's environment (always wins);
107
+ * 2. `.env`, then every `.env.<variant>` (byte-sorted), in `directory` —
108
+ * then the same set in `directory/api`;
109
+ * 3. the same two locations in each parent, up to two parents.
110
+ *
111
+ * Inside one file, Node's dotenv rules apply: the LAST assignment wins, and
112
+ * inline comments / quoting are honoured. A file that does not define a
113
+ * non-blank MOLECULE_API_KEY is skipped, and placeholder variants
114
+ * (`.env.example`, `.env.schema`, …) are never read. Because `create_api_key`
115
+ * writes only these names through the same predicate, a key it writes at
116
+ * `<dir>/.env[.<variant>]` or `<dir>/api/.env[.<variant>]` is found by a later
117
+ * service call whose `directory` is that project directory (or up to two
118
+ * levels below it).
31
119
  *
32
120
  * @param directory - Project directory to search (default cwd).
33
121
  * @returns The key and where it came from, or null.
@@ -37,11 +125,13 @@ export function resolveServiceKey(directory) {
37
125
  return { key: process.env.MOLECULE_API_KEY, source: 'MOLECULE_API_KEY env' };
38
126
  }
39
127
  let dir = resolve(directory ?? process.cwd());
40
- for (let i = 0; i < 3; i++) {
41
- for (const file of [join(dir, '.env'), join(dir, 'api', '.env')]) {
42
- const key = readDotenvVar(file, 'MOLECULE_API_KEY');
43
- if (key)
44
- return { key, source: file };
128
+ for (let level = 0; level < 3; level++) {
129
+ for (const location of [dir, join(dir, 'api')]) {
130
+ for (const file of dotenvCandidates(location)) {
131
+ const key = readDotenvVar(file, 'MOLECULE_API_KEY');
132
+ if (key)
133
+ return { key, source: file };
134
+ }
45
135
  }
46
136
  const parent = dirname(dir);
47
137
  if (parent === dir)
@@ -83,7 +173,7 @@ function retryHint(seconds, errorKey) {
83
173
  export async function callHostedService(service, operation, body, directory) {
84
174
  const found = resolveServiceKey(directory);
85
175
  if (!found) {
86
- throw new ServiceCallError('No molecule project API key. Call create_api_key (it writes MOLECULE_API_KEY into the project .env), or set MOLECULE_API_KEY for this server.');
176
+ throw new ServiceCallError('No molecule project API key. Call create_api_key (it writes MOLECULE_API_KEY into the project .env or .env.<name>), or set MOLECULE_API_KEY for this server.');
87
177
  }
88
178
  const response = await fetch(`${apiBase()}/v1/services/${service}/${operation}`, {
89
179
  method: 'POST',
@@ -156,12 +246,16 @@ export async function listProjects() {
156
246
  return { id: p.id, name: p.name, ...((p.role ?? r.role) ? { role: p.role ?? r.role } : {}) };
157
247
  });
158
248
  }
159
- /** The only files create_api_key will write: a dotenv file by name. */
160
- const ENV_FILE_RE = /^\.env(\.[A-Za-z0-9_-]+)?$/;
161
249
  /**
162
250
  * Mint a project API key and write it into a dotenv file as
163
- * MOLECULE_API_KEY, replacing an existing line. The plaintext is returned by
164
- * the platform exactly once and is never echoed back to the agent.
251
+ * MOLECULE_API_KEY, replacing an existing assignment and dropping duplicate
252
+ * ones. The plaintext is returned by the platform exactly once and is never
253
+ * echoed back to the agent.
254
+ *
255
+ * The destination is validated BEFORE the platform is asked to mint anything,
256
+ * and only names `resolveServiceKey` reads are accepted (`.env` or
257
+ * `.env.<variant>`, never a placeholder like `.env.example`) — so a written
258
+ * key is always discoverable by the service tools that follow.
165
259
  *
166
260
  * @param projectId - The project the key bills to.
167
261
  * @param name - A label shown in the project's key list.
@@ -171,8 +265,8 @@ const ENV_FILE_RE = /^\.env(\.[A-Za-z0-9_-]+)?$/;
171
265
  */
172
266
  export async function createApiKeyIntoEnv(projectId, name, envFile, scopes) {
173
267
  const target = resolve(envFile);
174
- if (!ENV_FILE_RE.test(basename(target))) {
175
- throw new ServiceCallError('env_file must be a dotenv file named .env or .env.<name>.');
268
+ if (!isProjectEnvFileName(basename(target))) {
269
+ throw new ServiceCallError('env_file must be a dotenv file named .env or .env.<name>; placeholder files such as .env.example are never read for credentials.');
176
270
  }
177
271
  if (!existsSync(dirname(target))) {
178
272
  throw new ServiceCallError(`Directory ${dirname(target)} does not exist.`);
@@ -186,33 +280,37 @@ export async function createApiKeyIntoEnv(projectId, name, envFile, scopes) {
186
280
  const line = `MOLECULE_API_KEY=${minted.token}`;
187
281
  const existed = existsSync(target);
188
282
  const lines = existed ? readFileSync(target, 'utf8').split('\n') : [];
189
- const at = lines.findIndex((l) => /^\s*(?:export\s+)?MOLECULE_API_KEY\s*=/.test(l));
190
- if (at >= 0)
191
- lines[at] = line;
192
- else {
193
- if (lines.length && lines[lines.length - 1] === '')
194
- lines.pop();
195
- lines.push(line, '');
196
- }
197
- writeFileSync(target, lines.join('\n'), { mode: 0o600 });
283
+ // Rewrite the FIRST assignment and drop any later ones: Node's dotenv parser
284
+ // takes the LAST assignment in a file, so leaving an older line below the new
285
+ // one would keep the superseded key in force and every service call would use
286
+ // it. Only assignment lines are matched (a commented-out `# KEY=` is not one).
287
+ const assignment = /^\s*(?:export\s+)?MOLECULE_API_KEY\s*=/;
288
+ const next = [];
289
+ let written = false;
290
+ for (const current of lines) {
291
+ if (!assignment.test(current)) {
292
+ next.push(current);
293
+ continue;
294
+ }
295
+ if (!written) {
296
+ next.push(line);
297
+ written = true;
298
+ }
299
+ }
300
+ if (!written) {
301
+ if (next.length && next[next.length - 1] === '')
302
+ next.pop();
303
+ next.push(line, '');
304
+ }
305
+ writeFileSync(target, next.join('\n'), { mode: 0o600 });
198
306
  // The file now holds a secret: owner-only even if it existed with wider bits.
199
307
  chmodSync(target, 0o600);
200
- // A .gitignore in the file's directory or up to three parents that names it.
201
- // A heuristic for a warning, not a guarantee — git's own rules are richer.
202
- const patterns = ['.env', '.env*', '.env.*', '*.env', basename(target), `/${basename(target)}`];
203
- let gitignored = false;
204
- for (let dir = dirname(target), i = 0; i < 4 && !gitignored; i++) {
205
- const gitignore = join(dir, '.gitignore');
206
- gitignored =
207
- existsSync(gitignore) &&
208
- readFileSync(gitignore, 'utf8')
209
- .split('\n')
210
- .some((l) => patterns.includes(l.trim()));
211
- const parent = dirname(dir);
212
- if (parent === dir)
213
- break;
214
- dir = parent;
215
- }
308
+ // Ask Git, including negations, nested rules and tracked-file status. A failed
309
+ // or unavailable Git check never claims that a secret is excluded.
310
+ const gitignored = spawnSync('git', ['check-ignore', '--quiet', '--', target], {
311
+ cwd: dirname(target),
312
+ stdio: 'ignore',
313
+ }).status === 0;
216
314
  return {
217
315
  id: minted.id,
218
316
  masked: minted.masked,
package/package.json CHANGED
@@ -1,9 +1,18 @@
1
1
  {
2
2
  "name": "molecule-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "MCP server exposing the Molecule.dev package ecosystem — search, templates, scaffolding — to any MCP-capable coding agent",
5
5
  "author": "Molecule Dev, Inc. (https://molecule.dev)",
6
6
  "license": "Apache-2.0",
7
+ "homepage": "https://www.molecule.dev/mlcl",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/molecule-dev/mlcl.git",
11
+ "directory": "mcp"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/molecule-dev/mlcl/issues"
15
+ },
7
16
  "type": "module",
8
17
  "engines": {
9
18
  "node": ">=22"
@@ -20,18 +29,19 @@
20
29
  ],
21
30
  "scripts": {
22
31
  "build": "shx rm -rf dist && tsc -b",
32
+ "gen:package-search": "node ../scripts/generate-mcp-package-search.mjs",
23
33
  "smoke": "node scripts/smoke.mjs",
24
34
  "serve": "node dist/http.js"
25
35
  },
26
36
  "dependencies": {
27
- "@modelcontextprotocol/sdk": "1.17.0",
28
- "execa": "10.0.0",
29
- "zod": "3.25.76",
30
- "express": "5.2.1"
37
+ "@modelcontextprotocol/sdk": "1.32.2",
38
+ "execa": "10.2.1",
39
+ "zod": "4.6.7",
40
+ "express": "5.3.0"
31
41
  },
32
42
  "devDependencies": {
33
43
  "@types/express": "5.0.6",
34
- "@types/node": "26.1.2",
44
+ "@types/node": "26.6.5",
35
45
  "shx": "0.4.0",
36
46
  "typescript": "6.0.3"
37
47
  }