molecule-mcp 0.0.0-stage → 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 +196 -2
- package/assets/conventions.md +59 -0
- package/bin/run.js +2 -0
- package/bin/serve.js +2 -0
- package/dist/auth.d.ts +43 -0
- package/dist/auth.js +163 -0
- package/dist/http.d.ts +1 -0
- package/dist/http.js +181 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +14 -0
- package/dist/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.d.ts +85 -0
- package/dist/remote.js +478 -0
- package/dist/scaffold-args.d.ts +12 -0
- package/dist/scaffold-args.js +17 -0
- package/dist/server.d.ts +20 -0
- package/dist/server.js +2791 -0
- package/dist/services.d.ts +78 -0
- package/dist/services.js +321 -0
- package/dist/telemetry.d.ts +39 -0
- package/dist/telemetry.js +376 -0
- package/package.json +46 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI entry — connects the molecule MCP server over stdio. The server itself
|
|
3
|
+
* lives in server.ts so tests can import it without triggering a transport.
|
|
4
|
+
*/
|
|
5
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
|
+
import { moleculeServer } from './server.js';
|
|
7
|
+
async function main() {
|
|
8
|
+
await moleculeServer.connect(new StdioServerTransport());
|
|
9
|
+
process.stderr.write('molecule-mcp ready\n');
|
|
10
|
+
}
|
|
11
|
+
main().catch((e) => {
|
|
12
|
+
process.stderr.write(`molecule-mcp failed to start: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
});
|
|
@@ -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[];
|