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.
@@ -0,0 +1,241 @@
1
+ // GENERATED FILE — DO NOT EDIT.
2
+ //
3
+ // Byte-for-byte copy of `mlcl/src/lib/package-search.ts` (the canonical search
4
+ // owner shared with `mlcl search` and the stdio MCP), made standalone so this
5
+ // package keeps its own build/deploy closure: mcp/Dockerfile builds `mcp/`
6
+ // alone and mcp/scripts/deploy-remote.sh ships only this package, so this file
7
+ // must not import the CLI, oclif, node:fs or any @molecule/* package.
8
+ //
9
+ // Regenerate after ANY edit to the owner:
10
+ // node scripts/generate-mcp-package-search.mjs
11
+ // Drift fails src/lib/__tests__/mcp-search-owner-drift.test.ts.
12
+ /**
13
+ * The package frameworks a project of one app framework can import.
14
+ *
15
+ * Its own, the UI set it renders with (`uiFramework`), and every framework whose
16
+ * identifying runtime the project installs in full. A framework is identified by
17
+ * its `dependencies`, or by its `devDependencies` when it has none (the rule
18
+ * `getAppFrameworkDeps` reads). A framework that renders with another
19
+ * framework's UI set is identified by that set, so its packages are never
20
+ * accepted by runtime alone. So a react-native-web project (react, react-dom,
21
+ * react-router) imports React packages, a react-native one (no react-dom) does
22
+ * not, and a React project does not import react-native-web packages.
23
+ *
24
+ * @param project - The project's framework key (`molecule.framework`).
25
+ * @param appFrameworks - The registry's `appFrameworks`.
26
+ * @returns The accepted package frameworks, the project's own first; just the
27
+ * project's key when the registry does not know it.
28
+ */
29
+ export function frameworksAcceptedBy(project, appFrameworks) {
30
+ const own = appFrameworks[project];
31
+ // The hosted reader passes an unvalidated registry document.
32
+ if (!own || typeof own !== 'object')
33
+ return [project];
34
+ const installed = new Set([
35
+ ...Object.keys(own.dependencies ?? {}),
36
+ ...Object.keys(own.devDependencies ?? {}),
37
+ ]);
38
+ const accepted = [project];
39
+ if (own.uiFramework && own.uiFramework !== project)
40
+ accepted.push(own.uiFramework);
41
+ for (const [key, identifying] of runtimeFrameworks(appFrameworks)) {
42
+ if (accepted.includes(key))
43
+ continue;
44
+ if ([...identifying].every((dep) => installed.has(dep)))
45
+ accepted.push(key);
46
+ }
47
+ return accepted;
48
+ }
49
+ /**
50
+ * The frameworks that are a runtime of their own, each with the dependencies that
51
+ * identify it: its `dependencies`, or its `devDependencies` when it has none (the
52
+ * rule `getAppFrameworkDeps` reads). A framework that renders with another
53
+ * framework's UI set (react-native-web) is not one: it is identified by that set.
54
+ *
55
+ * @param appFrameworks - The registry's `appFrameworks` (unvalidated on the hosted side).
56
+ * @returns Framework key → identifying dependency names, frameworks with none left out.
57
+ */
58
+ function runtimeFrameworks(appFrameworks) {
59
+ const runtimes = [];
60
+ for (const [key, fw] of Object.entries(appFrameworks)) {
61
+ if (!fw || typeof fw !== 'object')
62
+ continue;
63
+ if (fw.uiFramework && fw.uiFramework !== key)
64
+ continue;
65
+ const runtime = Object.keys(fw.dependencies ?? {});
66
+ const identifying = runtime.length > 0 ? runtime : Object.keys(fw.devDependencies ?? {});
67
+ if (identifying.length > 0)
68
+ runtimes.push([key, new Set(identifying)]);
69
+ }
70
+ return runtimes;
71
+ }
72
+ /**
73
+ * The framework a package is written for: its registry `framework`, else the one
74
+ * its `runtimeDependencies` (dependencies and peers, as sync-registry records
75
+ * them) name.
76
+ *
77
+ * A dependency names a framework when it identifies a runtime framework (react,
78
+ * react-dom, vue, solid-js, svelte, …). The package is written for the framework
79
+ * with the fewest identifying dependencies that still has every one the package
80
+ * names: `react` alone is react, `react` with `react-native` is react-native.
81
+ * The registry `framework` covers a package whose dependencies name none, like a
82
+ * bond over `@react-native-clipboard/clipboard`.
83
+ *
84
+ * @param entry - The package's registry entry.
85
+ * @param appFrameworks - The registry's `appFrameworks`.
86
+ * @returns The framework key, or undefined for a framework-neutral package (and for
87
+ * dependencies two frameworks fit equally, which the registry tests refuse).
88
+ */
89
+ export function packageFramework(entry, appFrameworks) {
90
+ if (typeof entry.framework === 'string' && entry.framework)
91
+ return entry.framework;
92
+ // UI frameworks are the app stack's: an API package that peers react (to render an
93
+ // email, say) is not a React binding.
94
+ if (entry.stack === 'api' || !Array.isArray(entry.runtimeDependencies))
95
+ return undefined;
96
+ const runtimes = runtimeFrameworks(appFrameworks);
97
+ const named = entry.runtimeDependencies.filter((dep) => typeof dep === 'string' && runtimes.some(([, identifying]) => identifying.has(dep)));
98
+ if (named.length === 0)
99
+ return undefined;
100
+ const fits = runtimes
101
+ .filter(([, identifying]) => named.every((dep) => identifying.has(dep)))
102
+ .sort((a, b) => a[1].size - b[1].size);
103
+ if (fits.length === 0 || (fits.length > 1 && fits[1][1].size === fits[0][1].size))
104
+ return undefined;
105
+ return fits[0][0];
106
+ }
107
+ /** Result count both `mlcl search` and `search_packages` use when none is given. */
108
+ export const DEFAULT_SEARCH_LIMIT = 20;
109
+ /**
110
+ * The stack a hit reports. A declared stack wins; a legacy/malformed entry
111
+ * falls back to the package-name convention (the same rule both readers used
112
+ * before the contract was shared).
113
+ *
114
+ * @param entry - A registry-shaped package.
115
+ * @returns The stack to report on the hit.
116
+ */
117
+ function resolveStack(entry) {
118
+ return entry.stack ?? (entry.name.includes('/app-') ? 'app' : 'api');
119
+ }
120
+ /**
121
+ * Score a package against the query tokens. Higher = more relevant.
122
+ * Exact names, exported symbols and complete capability phrases outrank
123
+ * incidental name substrings ("form" in "formula"). The existing token scores
124
+ * remain useful for partial package names and broad category/provider queries.
125
+ *
126
+ * @param entry - A registry-shaped package.
127
+ * @param q - Package name, exported symbol or natural capability query.
128
+ * @returns Relevance score; zero means no match.
129
+ */
130
+ export function scoreSearchEntry(entry, q) {
131
+ const name = entry.name.replace('@molecule/', '');
132
+ const tokens = q.toLowerCase().split(/\s+/).filter(Boolean);
133
+ if (tokens.length === 0)
134
+ return 0;
135
+ let score = 0;
136
+ const phrase = (value) => value
137
+ .toLowerCase()
138
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
139
+ .trim();
140
+ const query = phrase(q);
141
+ const symbolQuery = q.trim().replace(/\(\)$/, '').toLowerCase();
142
+ // The hosted server scores an unvalidated document, so `exports` may not be
143
+ // the string[] the registry contract says: treat anything else as no export
144
+ // list rather than throwing inside the tool handler.
145
+ const exportsList = Array.isArray(entry.exports) ? entry.exports : [];
146
+ const exactSymbol = /^[\p{L}_$][\p{L}\p{N}_$]*$/u.test(symbolQuery) &&
147
+ exportsList.some((symbol) => typeof symbol === 'string' && symbol.toLowerCase() === symbolQuery);
148
+ if (!query && !exactSymbol)
149
+ return 0;
150
+ const containsPhrase = (text, candidate) => ` ${text} `.includes(` ${candidate} `);
151
+ const hints = (entry.intentHints ?? []).map(phrase);
152
+ if (q.trim().toLowerCase() === entry.name.toLowerCase() ||
153
+ q.trim().toLowerCase() === name.toLowerCase()) {
154
+ score += 400;
155
+ }
156
+ // Match the WHOLE symbol, never its prefix/substring; e.g. "use" must not
157
+ // match every hook, and "useProfileFormState" must not match useProfileForm.
158
+ if (exactSymbol)
159
+ score += 160;
160
+ // A short query can name both a category and a common export (e.g. query).
161
+ // Keep that category and its providers ahead of unrelated symbol owners.
162
+ if (entry.category && phrase(entry.category) === query)
163
+ score += 160;
164
+ if (query.includes(' ')) {
165
+ if (hints.includes(query))
166
+ score += 120;
167
+ else if (hints.some((hint) => hint.includes(' ') && (containsPhrase(query, hint) || containsPhrase(hint, query))))
168
+ score += 80;
169
+ else if ([name, entry.category, entry.label, entry.description].some((field) => field && containsPhrase(phrase(field), query)))
170
+ score += 60;
171
+ }
172
+ for (const tok of tokens) {
173
+ if (name === tok)
174
+ score += 40;
175
+ else if (name.includes(tok))
176
+ score += 20;
177
+ if (entry.category?.includes(tok))
178
+ score += 8;
179
+ if (entry.intentHints?.some((h) => h.includes(tok) || tok.includes(h)))
180
+ score += 6;
181
+ if (entry.description?.toLowerCase().includes(tok))
182
+ score += 3;
183
+ if (entry.label?.toLowerCase().includes(tok))
184
+ score += 3;
185
+ }
186
+ return score;
187
+ }
188
+ /**
189
+ * Rank a registry into the shared discovery DTO.
190
+ *
191
+ * The stack filter is applied to the SOURCE entries first — before any scoring,
192
+ * sorting or truncation — because the limit window is otherwise claimed by the
193
+ * highest-scoring packages of BOTH stacks and a stack-restricted caller can be
194
+ * left with none of its own.
195
+ *
196
+ * @param entries - Registry-shaped packages, from either reader.
197
+ * @param query - Package name, exported symbol or natural capability query.
198
+ * @param options - Optional stack and framework restrictions and result limit.
199
+ * @returns Ranked hits (score stripped), at most `limit` of them.
200
+ */
201
+ export function searchPackages(entries, query, options = {}) {
202
+ const { stack, framework, appFrameworks, limit = DEFAULT_SEARCH_LIMIT } = options;
203
+ const accepted = framework
204
+ ? new Set(appFrameworks ? frameworksAcceptedBy(framework, appFrameworks) : [framework])
205
+ : null;
206
+ const ranked = [];
207
+ for (const entry of entries) {
208
+ // The rule `loadRegistry(stack)` applies — declared stack equality — kept
209
+ // here so the transport that has no loader (hosted MCP) filters identically.
210
+ if (stack && entry.stack !== stack)
211
+ continue;
212
+ if (accepted) {
213
+ const written = appFrameworks
214
+ ? packageFramework(entry, appFrameworks)
215
+ : typeof entry.framework === 'string'
216
+ ? entry.framework
217
+ : undefined;
218
+ if (written && !accepted.has(written))
219
+ continue;
220
+ }
221
+ const score = scoreSearchEntry(entry, query);
222
+ if (score <= 0)
223
+ continue;
224
+ const configuration = Array.isArray(entry.decisionGuide?.configuration)
225
+ ? entry.decisionGuide.configuration.filter((line) => typeof line === 'string' && line.length > 0)
226
+ : [];
227
+ ranked.push({
228
+ name: entry.name,
229
+ stack: resolveStack(entry),
230
+ category: entry.category ?? '',
231
+ label: entry.label ?? entry.name,
232
+ description: entry.description ?? '',
233
+ ...(configuration.length > 0 ? { configuration } : {}),
234
+ score,
235
+ });
236
+ }
237
+ return ranked
238
+ .sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
239
+ .slice(0, limit)
240
+ .map(({ score: _score, ...hit }) => hit);
241
+ }
package/dist/remote.js CHANGED
@@ -16,6 +16,9 @@
16
16
  import { readFileSync } from 'node:fs';
17
17
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
18
18
  import { z } from 'zod';
19
+ import { localPackageDoc } from './package-doc-files.js';
20
+ import { docSearchSchema, searchPackageDoc } from './package-doc-search.js';
21
+ import { searchPackages, } from './package-search.js';
19
22
  import { serverVersion, tracked } from './telemetry.js';
20
23
  const REGISTRY_URL = process.env.MOLECULE_REGISTRY_URL ??
21
24
  'https://raw.githubusercontent.com/molecule-dev/mlcl/main/registry.json';
@@ -335,29 +338,12 @@ async function fetchJsonUncached(url, file, timeoutMs, check, generation) {
335
338
  cache.set(key, { at: Date.now(), data });
336
339
  return data;
337
340
  }
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
- }
341
+ // Ranking, stack filtering and the result DTO live in `./package-search.js` — a
342
+ // GENERATED verbatim copy of the canonical owner
343
+ // (`mlcl/src/lib/package-search.ts`) shared with `mlcl search` and the stdio
344
+ // MCP. The copy is what keeps this server buildable from the `mcp/` package
345
+ // alone (mcp/Dockerfile + scripts/deploy-remote.sh ship no CLI); a drift test
346
+ // fails if the copy and the owner diverge.
361
347
  const text = (t) => ({
362
348
  content: [{ type: 'text', text: t }],
363
349
  });
@@ -368,37 +354,44 @@ export function buildRemoteServer() {
368
354
  const server = new McpServer({ name: 'molecule-remote', version: serverVersion() });
369
355
  server.registerTool('search_packages', {
370
356
  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.',
357
+ description: 'Search the @molecule/* package fleet by name or capability (e.g. "payments", "social login"). Returns matching packages with stack, category, label, description, common-shape configuration when declared, and the install command; `stack` restricts results to one stack and `framework` to one UI framework (plus framework-neutral packages). Prefer an existing package over hand-rolling any capability and read its doc before rejecting it.',
372
358
  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
359
+ // Bounded: the scorer splits the query on whitespace and runs one pass
360
+ // over EVERY fleet package (~900) for each token, so the work one
375
361
  // request costs is (tokens × packages). With no cap, an 800 KB body of
376
362
  // whitespace-separated words (a few hundred thousand tokens, well under
377
363
  // the 1 MB body limit) pinned a core of this public, single-threaded
378
364
  // server for minutes — a per-address rate budget does not bound the
379
365
  // work a single allowed request may do. A capability query is a phrase.
380
366
  query: z.string().max(200).describe('Capability or package-name query'),
367
+ // Same contract as the stdio server and `mlcl search --stack`, and
368
+ // applied by the shared owner BEFORE ranking/truncation: an api-only
369
+ // caller on a crowded query must not lose its matches to app packages
370
+ // filling the limit window.
371
+ stack: z.enum(['api', 'app']).optional().describe('Restrict results to one stack'),
372
+ // A registry appFrameworks key, checked against the fetched registry so a
373
+ // framework the registry adds needs no change here.
374
+ framework: z
375
+ .string()
376
+ .regex(/^[a-z][a-z0-9-]*$/)
377
+ .max(40)
378
+ .optional()
379
+ .describe("The project's app framework, as its package.json molecule.framework names it (react, vue, svelte, solid, angular, react-native, react-native-web): leaves out packages that framework cannot import (every framework binding exports the same hook names)"),
381
380
  limit: z.number().int().min(1).max(100).optional(),
382
381
  },
383
- }, tracked('search_packages', async ({ query, limit }) => {
382
+ }, tracked('search_packages', async ({ query, stack, framework, limit }) => {
384
383
  // `isRegistryPayload` is the gate the type below cannot provide on its
385
384
  // own: without it a body carrying no `packages` map reached
386
385
  // `Object.entries(undefined)` and the client was told "Cannot convert
387
386
  // undefined or null to object" instead of what went wrong upstream.
388
387
  const registry = await fetchJson(REGISTRY_URL, REGISTRY_FILE, FETCH_TIMEOUT_MS, isRegistryPayload);
389
388
  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 }) => ({
389
+ const appFrameworks = isRecord(registry.appFrameworks)
390
+ ? registry.appFrameworks
391
+ : {};
392
+ if (framework && !appFrameworks[framework])
393
+ throw new Error(`Unknown framework "${framework}". Known: ${Object.keys(appFrameworks).join(', ')}`);
394
+ const hits = searchPackages(Object.entries(packages).map(([name, entry]) => ({ ...entry, name })), query, { stack, framework, appFrameworks, limit }).map((hit) => ({
402
395
  ...hit,
403
396
  install: `npx mlcl add ${hit.name} --inject`,
404
397
  }));
@@ -428,7 +421,7 @@ export function buildRemoteServer() {
428
421
  }));
429
422
  server.registerTool('read_package_doc', {
430
423
  title: 'Read a package README',
431
- description: "Fetch a @molecule/* package's generated usage reference (API, examples, wiring) — the authoritative published doc.",
424
+ description: "Fetch a @molecule/* package's generated usage reference (API, examples, wiring) — the authoritative published doc. For “where is X”, use search before grepping package internals: literal README lines with headings, then any locally installed type declarations.",
432
425
  inputSchema: {
433
426
  // Bounded, like the sibling `search_packages` query: this is a public,
434
427
  // unauthenticated server, and the name is interpolated into the outbound
@@ -436,8 +429,9 @@ export function buildRemoteServer() {
436
429
  // sized by the caller, up to the HTTP body cap, at the per-address
437
430
  // budget's full rate. A package short name is under 100 characters.
438
431
  name: z.string().max(200).describe('Package name, with or without the @molecule/ scope'),
432
+ search: docSearchSchema.optional(),
439
433
  },
440
- }, tracked('read_package_doc', async ({ name }) => {
434
+ }, tracked('read_package_doc', async ({ name, search }) => {
441
435
  const short = name.replace(/^@molecule\//, '');
442
436
  // The same package-name shape the stdio server's path guard enforces (see
443
437
  // mcp/src/server.ts): a name is a single descending segment, so it cannot
@@ -446,9 +440,15 @@ export function buildRemoteServer() {
446
440
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(short)) {
447
441
  return text(`Invalid package name "${short}" — expected @molecule/<name>.`);
448
442
  }
443
+ const terms = typeof search === 'string' ? [search] : search;
444
+ const local = terms ? localPackageDoc(short, import.meta.dirname) : null;
445
+ if (local && terms)
446
+ return text(searchPackageDoc(local.readme, terms, local.declarations, local.note).doc);
449
447
  try {
450
448
  const doc = await fetchPackageReadme(short);
451
449
  if (doc) {
450
+ if (terms)
451
+ return text(searchPackageDoc(doc, terms, [], 'Published README; declaration files are unavailable on this host.').doc);
452
452
  if (doc.length > 100_000)
453
453
  return text(`${doc.slice(0, 100_000)}\n\n[…truncated — full doc: https://www.molecule.dev/packages/${short}]`);
454
454
  return text(doc);
@@ -0,0 +1,12 @@
1
+ /** Arguments accepted by the MCP scaffold_project tool. */
2
+ export interface ScaffoldProjectOptions {
3
+ name: string;
4
+ template?: string;
5
+ type?: string;
6
+ framework?: string;
7
+ packages?: string;
8
+ api_contract?: string;
9
+ skip_install?: boolean;
10
+ }
11
+ /** Build the real CLI call; auth derivation stays in mlcl's shared scaffold. */
12
+ export declare function scaffoldProjectCliArgs(options: ScaffoldProjectOptions): string[];
@@ -0,0 +1,17 @@
1
+ /** Build the real CLI call; auth derivation stays in mlcl's shared scaffold. */
2
+ export function scaffoldProjectCliArgs(options) {
3
+ const args = ['create', options.name, '--no-interactive', '--json'];
4
+ if (options.template)
5
+ args.push('--template', options.template);
6
+ if (options.type)
7
+ args.push('--type', options.type);
8
+ if (options.framework)
9
+ args.push('--framework', options.framework);
10
+ if (options.packages)
11
+ args.push('--packages', options.packages);
12
+ if (options.api_contract)
13
+ args.push('--api-contract', options.api_contract);
14
+ if (options.skip_install)
15
+ args.push('--skip-install');
16
+ return args;
17
+ }