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.
- package/README.md +23 -5
- package/assets/conventions.md +31 -0
- package/dist/package-doc-files.d.ts +15 -0
- package/dist/package-doc-files.js +121 -0
- package/dist/package-doc-search.d.ts +20 -0
- package/dist/package-doc-search.js +199 -0
- package/dist/package-search.d.ts +171 -0
- package/dist/package-search.js +241 -0
- package/dist/remote.js +41 -41
- package/dist/scaffold-args.d.ts +12 -0
- package/dist/scaffold-args.js +17 -0
- package/dist/server.js +643 -98
- package/dist/services.d.ts +23 -5
- package/dist/services.js +148 -50
- package/package.json +16 -6
|
@@ -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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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:
|
|
374
|
-
//
|
|
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
|
|
391
|
-
.
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
+
}
|