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
package/README.md
CHANGED
|
@@ -5,9 +5,8 @@ The [Molecule.dev](https://molecule.dev) MCP server — structured tools over th
|
|
|
5
5
|
fleet, list flagship app templates, scaffold projects, add/wire packages, swap
|
|
6
6
|
providers, and read package docs.
|
|
7
7
|
|
|
8
|
-
>
|
|
9
|
-
|
|
10
|
-
> `@molecule/*` packages themselves are public on npm today.
|
|
8
|
+
> On npm as `molecule-mcp`: the `npx -y molecule-mcp` entries below run as
|
|
9
|
+
> written.
|
|
11
10
|
|
|
12
11
|
Also ships the molecule doctrine as a resource (`molecule://conventions`) and
|
|
13
12
|
an `agent_init` tool that writes the convention files your harness reads — so
|
|
@@ -94,9 +93,10 @@ your agent can read as context.
|
|
|
94
93
|
|
|
95
94
|
```jsonc
|
|
96
95
|
// search_packages — capability search, agent-side package discovery
|
|
97
|
-
{ "query": "payments", "limit": 3 }
|
|
96
|
+
{ "query": "payments", "stack": "api", "limit": 3 }
|
|
98
97
|
// → [{"name":"@molecule/api-payments-stripe","stack":"api","category":"payments",
|
|
99
|
-
// "
|
|
98
|
+
// "label":"Stripe","description":"Stripe payment provider…",
|
|
99
|
+
// "install":"mlcl add @molecule/api-payments-stripe --inject"}, …]
|
|
100
100
|
|
|
101
101
|
// scaffold_project — unattended scaffold, JSON result
|
|
102
102
|
{ "name": "my-store", "template": "online-store", "skip_install": false }
|
|
@@ -130,6 +130,24 @@ project, through that project's API key:
|
|
|
130
130
|
3. Service tools (`embed_text`, `text_to_speech`, `transcribe_audio`, …) read the key from that `.env` (pass
|
|
131
131
|
`directory`) or from `MOLECULE_API_KEY` in this server's environment.
|
|
132
132
|
|
|
133
|
+
Key resolution and `env_file` share one rule — the only names either side
|
|
134
|
+
accepts are `.env` and `.env.<variant>` (e.g. `.env.local`); placeholder
|
|
135
|
+
variants such as `.env.example`, `.env.schema` and `.env.template` are
|
|
136
|
+
refused on write and never read. Given a `directory`, resolution checks, in
|
|
137
|
+
order:
|
|
138
|
+
|
|
139
|
+
1. `MOLECULE_API_KEY` in this server's environment (always wins);
|
|
140
|
+
2. `directory/.env`, then every `directory/.env.<variant>` in byte order,
|
|
141
|
+
then the same set in `directory/api`;
|
|
142
|
+
3. the same two locations in each parent, up to two parents.
|
|
143
|
+
|
|
144
|
+
Inside one file the ordinary dotenv rules apply (Node's `util.parseEnv`):
|
|
145
|
+
inline `#` comments, single/double quotes, an `export` prefix and multi-line
|
|
146
|
+
quoted values are handled, and a duplicate assignment resolves to the last
|
|
147
|
+
one. `create_api_key` rewrites the key's assignment line — dropping older
|
|
148
|
+
duplicates — so the key it mints is always the one a later service call
|
|
149
|
+
reads.
|
|
150
|
+
|
|
133
151
|
The same HTTP API (`POST https://api.molecule.dev/api/v1/services/<service>/<operation>`,
|
|
134
152
|
catalog at `GET …/v1/services`) backs the `@molecule/*-molecule` bonds, so
|
|
135
153
|
app code reaches the same service through a normal, swappable bond.
|
package/assets/conventions.md
CHANGED
|
@@ -19,10 +19,41 @@ OAuth provider for another by changing one wiring file, not rewriting the app).
|
|
|
19
19
|
|
|
20
20
|
- `npx mlcl add @molecule/<name> --inject` — install + wire a package
|
|
21
21
|
- `npx mlcl swap @molecule/<new-provider>` — swap in a different provider for a wired capability
|
|
22
|
+
- `npx mlcl resources --contract <openapi file>` — write `app/src/resources/*` from the API contract the app serves (typed lists, records, creates and updates over the query cache); run it again when the contract changes
|
|
22
23
|
- `npx mlcl mcp install` — register the molecule MCP tools (search/scaffold/add/swap/docs) with this harness
|
|
23
24
|
|
|
25
|
+
**Before you build a capability** — a feature, an integration, a script, a tool:
|
|
26
|
+
|
|
27
|
+
1. **Inspect.** Start with `## Declared capability map` below when present. Map
|
|
28
|
+
every feature in the plan to its declared package and configuration; otherwise
|
|
29
|
+
use `npx mlcl search <capability>` (or `search_packages` over MCP). Read the
|
|
30
|
+
package's contract with `read_package_doc` or at
|
|
31
|
+
`node_modules/@molecule/<name>/README.md` before using it OR deciding not to.
|
|
32
|
+
Write `use @molecule/<name> with <configuration>` or
|
|
33
|
+
`Not using @molecule/<name> because <reason> (doc read)` for each feature. A
|
|
34
|
+
different storage key, header scheme, response envelope or endpoint is
|
|
35
|
+
configuration to look up, not a reason to write a parallel implementation.
|
|
36
|
+
2. **Reuse.** Call that package's API and the provider already wired for it
|
|
37
|
+
rather than re-implementing or copying it.
|
|
38
|
+
3. **Adapt.** If the shared capability is genuinely awkward or missing a piece,
|
|
39
|
+
fix it in the package or bond that owns it, then consume the fixed contract —
|
|
40
|
+
never fork it locally.
|
|
41
|
+
4. **Create only for a reusable gap.** A typed interface in a core package,
|
|
42
|
+
provider bonds per vendor, and ONE thin composition root (`src/bonds/`) that
|
|
43
|
+
picks the vendor; everything behind it talks to the interface only.
|
|
44
|
+
|
|
45
|
+
Single-use helpers, app-specific business logic and one-off scripts stay local —
|
|
46
|
+
do not extract a package for them.
|
|
47
|
+
|
|
24
48
|
**Inside a molecule-built project:** follow its AGENTS.md — prefer
|
|
25
49
|
`@molecule/*` packages over hand-rolled code, keep provider wiring in
|
|
26
50
|
`src/bonds/`, style via ClassMap (`getClassMap()`), route all user-facing text
|
|
27
51
|
through i18n. Every installed package documents its own API at
|
|
28
52
|
`node_modules/@molecule/<name>/README.md`.
|
|
53
|
+
|
|
54
|
+
**While implementing a supplied Playwright suite:** start `npx mlcl test-feedback
|
|
55
|
+
--suite <spec-directory> --sources <app-source-directory> --command "<the suite’s native
|
|
56
|
+
Playwright command targeting the local app>" --watch --json` as a background task.
|
|
57
|
+
It runs the real spec files while you build and reports native locator failures
|
|
58
|
+
with the rendered page and source paths. Passing runs are silent; keep building
|
|
59
|
+
without waiting. This is diagnosis; run the full suite for final verification.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import type { DocDeclaration } from './package-doc-search.js';
|
|
3
|
+
/** Bounded declaration reader, also serialized into the sandbox; keep it self-contained. */
|
|
4
|
+
export declare function readDocDeclarations(root: string, io: typeof fs): {
|
|
5
|
+
declarations: DocDeclaration[];
|
|
6
|
+
note?: string;
|
|
7
|
+
};
|
|
8
|
+
/** Read declarations beside the exact README selected by the existing doc resolver. */
|
|
9
|
+
export declare function declarationsBesideReadme(readmePath?: string): ReturnType<typeof readDocDeclarations>;
|
|
10
|
+
/** Local MCP resolution follows hoisted node_modules and never selects a different copy's types. */
|
|
11
|
+
export declare function localPackageDoc(name: string, base: string): {
|
|
12
|
+
readme: string;
|
|
13
|
+
declarations: DocDeclaration[];
|
|
14
|
+
note?: string;
|
|
15
|
+
} | null;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
/** Bounded declaration reader, also serialized into the sandbox; keep it self-contained. */
|
|
4
|
+
export function readDocDeclarations(root, io) {
|
|
5
|
+
const declarations = [];
|
|
6
|
+
let remaining = 524_288;
|
|
7
|
+
let visited = 0;
|
|
8
|
+
let cut = false;
|
|
9
|
+
function walk(relative, depth) {
|
|
10
|
+
if (depth > 12 || visited >= 1_024 || declarations.length >= 128 || remaining <= 0) {
|
|
11
|
+
cut = true;
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
let entries;
|
|
15
|
+
try {
|
|
16
|
+
entries = io.readdirSync(root + '/' + relative, { withFileTypes: true });
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (error.code !== 'ENOENT')
|
|
20
|
+
cut = true;
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
24
|
+
if (++visited > 1_024 || declarations.length >= 128 || remaining <= 0) {
|
|
25
|
+
cut = true;
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
const path = relative + '/' + entry.name;
|
|
29
|
+
if (entry.isSymbolicLink()) {
|
|
30
|
+
cut = true;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (entry.isDirectory()) {
|
|
34
|
+
walk(path, depth + 1);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (!entry.isFile() || !path.endsWith('.d.ts'))
|
|
38
|
+
continue;
|
|
39
|
+
let fd;
|
|
40
|
+
try {
|
|
41
|
+
fd = io.openSync(root + '/' + path, 'r');
|
|
42
|
+
const actual = io.realpathSync('/proc/self/fd/' + fd);
|
|
43
|
+
const before = io.fstatSync(fd);
|
|
44
|
+
if (!actual.startsWith(root + '/') ||
|
|
45
|
+
!before.isFile() ||
|
|
46
|
+
before.size > Math.min(262_144, remaining)) {
|
|
47
|
+
cut = true;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const bytes = Buffer.alloc(before.size + 1);
|
|
51
|
+
let length = 0;
|
|
52
|
+
while (length < bytes.length) {
|
|
53
|
+
const n = io.readSync(fd, bytes, length, bytes.length - length, null);
|
|
54
|
+
if (!n)
|
|
55
|
+
break;
|
|
56
|
+
length += n;
|
|
57
|
+
}
|
|
58
|
+
const after = io.fstatSync(fd);
|
|
59
|
+
if (length !== before.size ||
|
|
60
|
+
after.size !== before.size ||
|
|
61
|
+
after.mtimeMs !== before.mtimeMs) {
|
|
62
|
+
cut = true;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
declarations.push({
|
|
66
|
+
path,
|
|
67
|
+
text: new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, length)),
|
|
68
|
+
});
|
|
69
|
+
remaining -= length;
|
|
70
|
+
}
|
|
71
|
+
catch (_error) {
|
|
72
|
+
cut = true; // A partial scan must never establish declaration absence.
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
if (fd !== undefined)
|
|
76
|
+
io.closeSync(fd);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
walk('dist', 0);
|
|
81
|
+
return {
|
|
82
|
+
declarations,
|
|
83
|
+
...(cut
|
|
84
|
+
? {
|
|
85
|
+
note: 'Declaration scan incomplete (file/byte/depth cap or unreadable/unsafe path); no-hit terms do not establish absence.',
|
|
86
|
+
}
|
|
87
|
+
: {}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** Read declarations beside the exact README selected by the existing doc resolver. */
|
|
91
|
+
export function declarationsBesideReadme(readmePath) {
|
|
92
|
+
if (!readmePath)
|
|
93
|
+
return { declarations: [] };
|
|
94
|
+
return readDocDeclarations(fs.realpathSync(dirname(readmePath)), fs);
|
|
95
|
+
}
|
|
96
|
+
/** Local MCP resolution follows hoisted node_modules and never selects a different copy's types. */
|
|
97
|
+
export function localPackageDoc(name, base) {
|
|
98
|
+
let dir = resolve(base);
|
|
99
|
+
for (;;) {
|
|
100
|
+
const root = resolve(dir, 'node_modules', '@molecule', name);
|
|
101
|
+
for (const readme of ['README.md', 'dist/README.md']) {
|
|
102
|
+
try {
|
|
103
|
+
const realRoot = fs.realpathSync(root);
|
|
104
|
+
const path = fs.realpathSync(resolve(realRoot, readme));
|
|
105
|
+
if (!path.startsWith(realRoot + '/'))
|
|
106
|
+
continue;
|
|
107
|
+
const stat = fs.statSync(path);
|
|
108
|
+
if (!stat.isFile() || stat.size > 1_048_576)
|
|
109
|
+
continue;
|
|
110
|
+
return { readme: fs.readFileSync(path, 'utf8'), ...readDocDeclarations(realRoot, fs) };
|
|
111
|
+
}
|
|
112
|
+
catch (_error) {
|
|
113
|
+
// No installed doc at this node_modules level; continue the normal upward lookup.
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const parent = dirname(dir);
|
|
117
|
+
if (parent === dir)
|
|
118
|
+
return null;
|
|
119
|
+
dir = parent;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/** Literal, bounded README/type-reference search. Mirrored in molecule-mcp (no shared runtime dependency). */
|
|
3
|
+
/** One installed declaration file; paths are relative to the package root. */
|
|
4
|
+
export interface DocDeclaration {
|
|
5
|
+
path: string;
|
|
6
|
+
text: string;
|
|
7
|
+
}
|
|
8
|
+
/** Search output fits beneath the existing doc tool's transport budget. */
|
|
9
|
+
export declare const DOC_SEARCH_TOTAL_CHARS = 24000;
|
|
10
|
+
/** Validate the scalar/array input before opening any package files. */
|
|
11
|
+
export declare function parseDocSearch(value: unknown): string[];
|
|
12
|
+
/** Search README lines (one context line either side) and complete declaration blocks. */
|
|
13
|
+
export declare function searchPackageDoc(readme: string, terms: readonly string[], declarations?: readonly DocDeclaration[], sourceNote?: string): {
|
|
14
|
+
doc: string;
|
|
15
|
+
search: readonly string[];
|
|
16
|
+
truncated?: boolean;
|
|
17
|
+
note?: string;
|
|
18
|
+
};
|
|
19
|
+
/** The same bounded search input on stdio and hosted MCP. */
|
|
20
|
+
export declare const docSearchSchema: z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/** Search output fits beneath the existing doc tool's transport budget. */
|
|
3
|
+
export const DOC_SEARCH_TOTAL_CHARS = 24_000;
|
|
4
|
+
const TERM_CHARS = 6_000;
|
|
5
|
+
const SOURCE_CHARS = 2_600;
|
|
6
|
+
const DECLARATION_CHARS = 1_800;
|
|
7
|
+
/** Validate the scalar/array input before opening any package files. */
|
|
8
|
+
export function parseDocSearch(value) {
|
|
9
|
+
const terms = typeof value === 'string' ? [value] : value;
|
|
10
|
+
if (!Array.isArray(terms) ||
|
|
11
|
+
terms.length < 1 ||
|
|
12
|
+
terms.length > 6 ||
|
|
13
|
+
terms.some((term) => typeof term !== 'string' || !term.trim() || term.length > 200))
|
|
14
|
+
throw new Error('`search` must be one non-empty string or 1–6 non-empty strings (max 200 characters each).');
|
|
15
|
+
return terms;
|
|
16
|
+
}
|
|
17
|
+
/** Locate top-level declaration starts without treating quoted/commented braces as structure. */
|
|
18
|
+
function declarationRanges(lines) {
|
|
19
|
+
const masked = lines
|
|
20
|
+
.join('\n')
|
|
21
|
+
.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`/g, (text) => text.replace(/[^\n]/g, ' '))
|
|
22
|
+
.split('\n');
|
|
23
|
+
const starts = [];
|
|
24
|
+
let depth = 0;
|
|
25
|
+
for (let i = 0; i < masked.length; i++) {
|
|
26
|
+
if (depth === 0 &&
|
|
27
|
+
/^\s*(?:(?:export|declare|default|abstract)\s+)*(?:interface|type|class|function|const|let|var|enum|namespace|module|global|import|export)\b/.test(masked[i])) {
|
|
28
|
+
// The preceding comment belongs to this declaration, not the previous export.
|
|
29
|
+
let start = i;
|
|
30
|
+
while (start > 0 && !masked[start - 1].trim())
|
|
31
|
+
start--;
|
|
32
|
+
while (start < i && !lines[start].trim())
|
|
33
|
+
start++;
|
|
34
|
+
starts.push(start);
|
|
35
|
+
}
|
|
36
|
+
for (const char of masked[i]) {
|
|
37
|
+
if ('{[('.includes(char))
|
|
38
|
+
depth++;
|
|
39
|
+
if ('}])'.includes(char))
|
|
40
|
+
depth = Math.max(0, depth - 1);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (starts[0] !== 0)
|
|
44
|
+
starts.unshift(0);
|
|
45
|
+
return starts.map((start, i) => [start, (starts[i + 1] ?? lines.length) - 1]);
|
|
46
|
+
}
|
|
47
|
+
/** Retain the literal hit when a generated signature occupies one very long line. */
|
|
48
|
+
function searchLine(line, needle, cap) {
|
|
49
|
+
if (line.length <= cap)
|
|
50
|
+
return line;
|
|
51
|
+
const match = line.toLowerCase().indexOf(needle);
|
|
52
|
+
const start = Math.max(0, match - 100);
|
|
53
|
+
return `${start ? '…' : ''}${line.slice(start, start + cap)}…(line truncated)`;
|
|
54
|
+
}
|
|
55
|
+
/** Search README lines (one context line either side) and complete declaration blocks. */
|
|
56
|
+
export function searchPackageDoc(readme, terms, declarations = [], sourceNote) {
|
|
57
|
+
let truncated = false;
|
|
58
|
+
const readmeLines = readme.split(/\r?\n/);
|
|
59
|
+
const headings = [];
|
|
60
|
+
let heading = '(introduction)';
|
|
61
|
+
let fence = '';
|
|
62
|
+
for (const line of readmeLines) {
|
|
63
|
+
const marker = line.match(/^\s{0,3}(`{3,}|~{3,})/);
|
|
64
|
+
if (marker) {
|
|
65
|
+
if (!fence)
|
|
66
|
+
fence = marker[1][0];
|
|
67
|
+
else if (marker[1][0] === fence)
|
|
68
|
+
fence = '';
|
|
69
|
+
}
|
|
70
|
+
const title = !fence && line.match(/^\s{0,3}#{1,6}\s+(.+?)(?:\s+#+)?\s*$/);
|
|
71
|
+
if (title)
|
|
72
|
+
heading = title[1];
|
|
73
|
+
headings.push(heading);
|
|
74
|
+
}
|
|
75
|
+
const files = declarations.map((file) => {
|
|
76
|
+
const lines = file.text.split(/\r?\n/);
|
|
77
|
+
return { ...file, lines, ranges: declarationRanges(lines) };
|
|
78
|
+
});
|
|
79
|
+
const output = [];
|
|
80
|
+
for (const [termIndex, term] of terms.entries()) {
|
|
81
|
+
const needle = term.toLowerCase();
|
|
82
|
+
const readmeHits = [];
|
|
83
|
+
const declarationHits = [];
|
|
84
|
+
let readmeCut = false;
|
|
85
|
+
let declarationCut = false;
|
|
86
|
+
let readmeSize = 0;
|
|
87
|
+
let declarationSize = 0;
|
|
88
|
+
let last = -1;
|
|
89
|
+
for (let i = 0; i < readmeLines.length; i++) {
|
|
90
|
+
if (!readmeLines[i].toLowerCase().includes(needle))
|
|
91
|
+
continue;
|
|
92
|
+
const start = Math.max(last + 1, i - 1, 0);
|
|
93
|
+
const end = Math.min(readmeLines.length - 1, i + 1);
|
|
94
|
+
if (start > end)
|
|
95
|
+
continue;
|
|
96
|
+
const hit = `README.md:${i + 1} — section ${JSON.stringify(headings[i])}\n` +
|
|
97
|
+
readmeLines
|
|
98
|
+
.slice(start, end + 1)
|
|
99
|
+
.map((line, j) => `${start + j + 1}: ${searchLine(line, needle, start + j === i ? 1_400 : 300)}`)
|
|
100
|
+
.join('\n');
|
|
101
|
+
if (hit.includes('…(line truncated)'))
|
|
102
|
+
readmeCut = true;
|
|
103
|
+
if (readmeSize + hit.length > SOURCE_CHARS) {
|
|
104
|
+
readmeCut = true;
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
readmeHits.push(hit);
|
|
108
|
+
readmeSize += hit.length;
|
|
109
|
+
last = end;
|
|
110
|
+
}
|
|
111
|
+
for (const file of files) {
|
|
112
|
+
for (const [start, end] of file.ranges) {
|
|
113
|
+
let match = start;
|
|
114
|
+
while (match <= end && !file.lines[match].toLowerCase().includes(needle))
|
|
115
|
+
match++;
|
|
116
|
+
if (match > end)
|
|
117
|
+
continue;
|
|
118
|
+
let text = file.lines
|
|
119
|
+
.slice(start, end + 1)
|
|
120
|
+
.join('\n')
|
|
121
|
+
.trimEnd();
|
|
122
|
+
let at = start;
|
|
123
|
+
if (text.length > DECLARATION_CHARS) {
|
|
124
|
+
// Keep the matching line visible even inside a very large interface.
|
|
125
|
+
at = Math.max(start, match - 1);
|
|
126
|
+
const before = at < match ? searchLine(file.lines[at], needle, 200) + '\n' : '';
|
|
127
|
+
text = (before +
|
|
128
|
+
searchLine(file.lines[match], needle, 1_400) +
|
|
129
|
+
'\n' +
|
|
130
|
+
file.lines.slice(match + 1, end + 1).join('\n')).slice(0, DECLARATION_CHARS);
|
|
131
|
+
text += '\n…(declaration truncated; read this path for the complete declaration)';
|
|
132
|
+
declarationCut = true;
|
|
133
|
+
}
|
|
134
|
+
const hit = `${file.path}:${at + 1}\n${text}`;
|
|
135
|
+
if (declarationSize + hit.length > SOURCE_CHARS) {
|
|
136
|
+
declarationCut = true;
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
declarationHits.push(hit);
|
|
140
|
+
declarationSize += hit.length;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const chunks = [`### Search: ${JSON.stringify(term)}`, ...readmeHits, ...declarationHits];
|
|
144
|
+
if (readmeCut)
|
|
145
|
+
chunks.push('…(README matches truncated by the per-term cap)');
|
|
146
|
+
if (declarationCut)
|
|
147
|
+
chunks.push('…(declaration matches truncated by the per-term cap)');
|
|
148
|
+
if (!readmeHits.length && !declarationHits.length && !readmeCut && !declarationCut)
|
|
149
|
+
chunks.push('No matches in README.md or dist/**/*.d.ts.');
|
|
150
|
+
let block = chunks.join('\n\n');
|
|
151
|
+
if (block.length > TERM_CHARS) {
|
|
152
|
+
block = block.slice(0, TERM_CHARS - 100) + '\n…(per-term search cap reached)';
|
|
153
|
+
truncated = true;
|
|
154
|
+
}
|
|
155
|
+
const reserve = terms
|
|
156
|
+
.slice(termIndex + 1)
|
|
157
|
+
.reduce((size, value) => size + JSON.stringify(value).length + 100, 700);
|
|
158
|
+
if (output.join('\n\n').length + block.length > DOC_SEARCH_TOTAL_CHARS - reserve) {
|
|
159
|
+
output.push(`### Search: ${JSON.stringify(term)}\nOmitted — total search cap reached.`);
|
|
160
|
+
truncated = true;
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
output.push(block);
|
|
164
|
+
truncated ||= readmeCut || declarationCut;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const note = [
|
|
168
|
+
!declarations.length ? 'No installed dist/**/*.d.ts declarations available.' : '',
|
|
169
|
+
sourceNote?.slice(0, 500),
|
|
170
|
+
]
|
|
171
|
+
.filter(Boolean)
|
|
172
|
+
.join(' ');
|
|
173
|
+
if (note)
|
|
174
|
+
output.push(note);
|
|
175
|
+
return {
|
|
176
|
+
doc: output.join('\n\n'),
|
|
177
|
+
search: terms,
|
|
178
|
+
...(truncated ? { truncated: true } : {}),
|
|
179
|
+
...(note ? { note } : {}),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/** The same bounded search input on stdio and hosted MCP. */
|
|
183
|
+
export const docSearchSchema = z
|
|
184
|
+
.union([
|
|
185
|
+
z
|
|
186
|
+
.string()
|
|
187
|
+
.min(1)
|
|
188
|
+
.max(200)
|
|
189
|
+
.refine((term) => !!term.trim()),
|
|
190
|
+
z
|
|
191
|
+
.array(z
|
|
192
|
+
.string()
|
|
193
|
+
.min(1)
|
|
194
|
+
.max(200)
|
|
195
|
+
.refine((term) => !!term.trim()))
|
|
196
|
+
.min(1)
|
|
197
|
+
.max(6),
|
|
198
|
+
])
|
|
199
|
+
.describe('Case-insensitive literal string or up to 6 terms. Search README lines with context/headings and available installed dist/**/*.d.ts declarations; never implementation JS.');
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical package-search contract shared by `mlcl search` and the hosted
|
|
3
|
+
* MCP server.
|
|
4
|
+
*
|
|
5
|
+
* This module is PURE — registry-shaped data in, ranked hits out: no filesystem,
|
|
6
|
+
* no registry loader, no oclif/CLI, and no vendor or `@molecule/*` import. That
|
|
7
|
+
* is what lets the standalone hosted MCP (`mcp/src/package-search.ts`, which
|
|
8
|
+
* must build from the `mcp/` package alone — its Dockerfile and
|
|
9
|
+
* `scripts/deploy-remote.sh` ship no CLI or workspace) carry the SAME ranking,
|
|
10
|
+
* stack filtering and DTO instead of hand-maintaining a second scorer.
|
|
11
|
+
*
|
|
12
|
+
* The copy under `mcp/src/` is generated from this file by
|
|
13
|
+
* `scripts/generate-mcp-package-search.mjs`; `src/lib/__tests__/mcp-search-owner-drift.test.ts`
|
|
14
|
+
* fails when the two diverge. Edit this file, then regenerate — never the copy.
|
|
15
|
+
*
|
|
16
|
+
* Input is the loose registry-entry shape, because the two readers hold
|
|
17
|
+
* different views of one registry: the CLI adapts `loadRegistry()`'s
|
|
18
|
+
* `InferredPackage` (flattening its `molecule` metadata), and the hosted server
|
|
19
|
+
* passes the raw registry entry. Both therefore reach the same scorer.
|
|
20
|
+
*
|
|
21
|
+
* @module
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* One package as either registry reader can hand it to the search contract.
|
|
25
|
+
*
|
|
26
|
+
* Every metadata field is optional because the hosted side reads an
|
|
27
|
+
* UNVALIDATED registry document: an entry missing a label, or carrying a
|
|
28
|
+
* non-array `exports`, must degrade to a score (or no match), never throw
|
|
29
|
+
* inside the tool handler.
|
|
30
|
+
*/
|
|
31
|
+
export interface SearchSourceEntry {
|
|
32
|
+
/** Full package name, e.g. `@molecule/api-payments-stripe`. */
|
|
33
|
+
name: string;
|
|
34
|
+
/** Declared stack. Absent only in a malformed/legacy registry entry. */
|
|
35
|
+
stack?: 'api' | 'app';
|
|
36
|
+
category?: string;
|
|
37
|
+
label?: string;
|
|
38
|
+
description?: string;
|
|
39
|
+
exports?: readonly string[];
|
|
40
|
+
/**
|
|
41
|
+
* The UI framework the package is written for (`react`, `vue`, `svelte`, `solid`,
|
|
42
|
+
* `angular`, `react-native`); absent for a framework-neutral package.
|
|
43
|
+
*/
|
|
44
|
+
framework?: string;
|
|
45
|
+
/** Dependencies and peers, as sync-registry records them; {@link packageFramework} reads them. */
|
|
46
|
+
runtimeDependencies?: readonly string[];
|
|
47
|
+
intentHints?: readonly string[];
|
|
48
|
+
/** Optional package-authored decision guidance in the registry. */
|
|
49
|
+
decisionGuide?: {
|
|
50
|
+
configuration?: readonly string[];
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* One ranked discovery hit, as both transports return it.
|
|
55
|
+
*
|
|
56
|
+
* This is the shared DTO shape: transports add only their own `install`
|
|
57
|
+
* command, so a consumer reading `label` (or any other field) gets the same
|
|
58
|
+
* answer over the CLI, stdio MCP and hosted MCP.
|
|
59
|
+
*/
|
|
60
|
+
export interface SearchHit {
|
|
61
|
+
name: string;
|
|
62
|
+
stack: 'api' | 'app';
|
|
63
|
+
category: string;
|
|
64
|
+
label: string;
|
|
65
|
+
description: string;
|
|
66
|
+
/** Concrete common-shape configuration, when this package declares it. */
|
|
67
|
+
configuration?: string[];
|
|
68
|
+
}
|
|
69
|
+
/** What a caller may narrow before ranking and the limit cutoff. */
|
|
70
|
+
export interface SearchOptions {
|
|
71
|
+
/**
|
|
72
|
+
* Restrict to one stack. Applied BEFORE scoring/ranking/truncation, so a
|
|
73
|
+
* crowded query cannot lose same-stack matches to the limit window being
|
|
74
|
+
* filled by higher-scoring packages of the other stack.
|
|
75
|
+
*
|
|
76
|
+
* Matches the registry's DECLARED `stack` (the rule `loadRegistry(stack)`
|
|
77
|
+
* applies), not the name-derived display fallback below.
|
|
78
|
+
*/
|
|
79
|
+
stack?: 'api' | 'app';
|
|
80
|
+
/**
|
|
81
|
+
* The project's app framework (its `molecule.framework`). Applied BEFORE
|
|
82
|
+
* ranking, like `stack`: a package written for a framework the project cannot
|
|
83
|
+
* import is left out, framework-neutral packages stay. Every framework binding
|
|
84
|
+
* exports the same hook names (`useProfileForm`, …), so without it a symbol
|
|
85
|
+
* query returns each framework's package with the same score, and only the
|
|
86
|
+
* caller knows which one its project can import.
|
|
87
|
+
*/
|
|
88
|
+
framework?: string;
|
|
89
|
+
/**
|
|
90
|
+
* The registry's `appFrameworks`, which say which package frameworks a project
|
|
91
|
+
* framework can import ({@link frameworksAcceptedBy}). Without them only
|
|
92
|
+
* packages declaring exactly `framework` are kept.
|
|
93
|
+
*/
|
|
94
|
+
appFrameworks?: Record<string, AppFrameworkInstalls>;
|
|
95
|
+
/** Maximum number of hits. */
|
|
96
|
+
limit?: number;
|
|
97
|
+
}
|
|
98
|
+
/** What a registry `appFrameworks` entry says about the projects it scaffolds. */
|
|
99
|
+
export interface AppFrameworkInstalls {
|
|
100
|
+
/** The UI package set its projects render with, when it is another framework's (react-native-web → react-native). */
|
|
101
|
+
uiFramework?: string;
|
|
102
|
+
dependencies?: Record<string, string>;
|
|
103
|
+
devDependencies?: Record<string, string>;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The package frameworks a project of one app framework can import.
|
|
107
|
+
*
|
|
108
|
+
* Its own, the UI set it renders with (`uiFramework`), and every framework whose
|
|
109
|
+
* identifying runtime the project installs in full. A framework is identified by
|
|
110
|
+
* its `dependencies`, or by its `devDependencies` when it has none (the rule
|
|
111
|
+
* `getAppFrameworkDeps` reads). A framework that renders with another
|
|
112
|
+
* framework's UI set is identified by that set, so its packages are never
|
|
113
|
+
* accepted by runtime alone. So a react-native-web project (react, react-dom,
|
|
114
|
+
* react-router) imports React packages, a react-native one (no react-dom) does
|
|
115
|
+
* not, and a React project does not import react-native-web packages.
|
|
116
|
+
*
|
|
117
|
+
* @param project - The project's framework key (`molecule.framework`).
|
|
118
|
+
* @param appFrameworks - The registry's `appFrameworks`.
|
|
119
|
+
* @returns The accepted package frameworks, the project's own first; just the
|
|
120
|
+
* project's key when the registry does not know it.
|
|
121
|
+
*/
|
|
122
|
+
export declare function frameworksAcceptedBy(project: string, appFrameworks: Record<string, AppFrameworkInstalls>): string[];
|
|
123
|
+
/**
|
|
124
|
+
* The framework a package is written for: its registry `framework`, else the one
|
|
125
|
+
* its `runtimeDependencies` (dependencies and peers, as sync-registry records
|
|
126
|
+
* them) name.
|
|
127
|
+
*
|
|
128
|
+
* A dependency names a framework when it identifies a runtime framework (react,
|
|
129
|
+
* react-dom, vue, solid-js, svelte, …). The package is written for the framework
|
|
130
|
+
* with the fewest identifying dependencies that still has every one the package
|
|
131
|
+
* names: `react` alone is react, `react` with `react-native` is react-native.
|
|
132
|
+
* The registry `framework` covers a package whose dependencies name none, like a
|
|
133
|
+
* bond over `@react-native-clipboard/clipboard`.
|
|
134
|
+
*
|
|
135
|
+
* @param entry - The package's registry entry.
|
|
136
|
+
* @param appFrameworks - The registry's `appFrameworks`.
|
|
137
|
+
* @returns The framework key, or undefined for a framework-neutral package (and for
|
|
138
|
+
* dependencies two frameworks fit equally, which the registry tests refuse).
|
|
139
|
+
*/
|
|
140
|
+
export declare function packageFramework(entry: {
|
|
141
|
+
stack?: unknown;
|
|
142
|
+
framework?: unknown;
|
|
143
|
+
runtimeDependencies?: unknown;
|
|
144
|
+
}, appFrameworks: Record<string, AppFrameworkInstalls>): string | undefined;
|
|
145
|
+
/** Result count both `mlcl search` and `search_packages` use when none is given. */
|
|
146
|
+
export declare const DEFAULT_SEARCH_LIMIT = 20;
|
|
147
|
+
/**
|
|
148
|
+
* Score a package against the query tokens. Higher = more relevant.
|
|
149
|
+
* Exact names, exported symbols and complete capability phrases outrank
|
|
150
|
+
* incidental name substrings ("form" in "formula"). The existing token scores
|
|
151
|
+
* remain useful for partial package names and broad category/provider queries.
|
|
152
|
+
*
|
|
153
|
+
* @param entry - A registry-shaped package.
|
|
154
|
+
* @param q - Package name, exported symbol or natural capability query.
|
|
155
|
+
* @returns Relevance score; zero means no match.
|
|
156
|
+
*/
|
|
157
|
+
export declare function scoreSearchEntry(entry: SearchSourceEntry, q: string): number;
|
|
158
|
+
/**
|
|
159
|
+
* Rank a registry into the shared discovery DTO.
|
|
160
|
+
*
|
|
161
|
+
* The stack filter is applied to the SOURCE entries first — before any scoring,
|
|
162
|
+
* sorting or truncation — because the limit window is otherwise claimed by the
|
|
163
|
+
* highest-scoring packages of BOTH stacks and a stack-restricted caller can be
|
|
164
|
+
* left with none of its own.
|
|
165
|
+
*
|
|
166
|
+
* @param entries - Registry-shaped packages, from either reader.
|
|
167
|
+
* @param query - Package name, exported symbol or natural capability query.
|
|
168
|
+
* @param options - Optional stack and framework restrictions and result limit.
|
|
169
|
+
* @returns Ranked hits (score stripped), at most `limit` of them.
|
|
170
|
+
*/
|
|
171
|
+
export declare function searchPackages(entries: Iterable<SearchSourceEntry>, query: string, options?: SearchOptions): SearchHit[];
|