membot 0.5.1 → 0.6.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/.claude/skills/membot.md +25 -10
- package/.cursor/rules/membot.mdc +25 -10
- package/README.md +35 -4
- package/package.json +8 -5
- package/scripts/apply-patches.sh +0 -11
- package/src/cli.ts +2 -2
- package/src/commands/login-page.mustache +50 -0
- package/src/commands/login.ts +83 -0
- package/src/config/schemas.ts +17 -5
- package/src/constants.ts +13 -1
- package/src/context.ts +1 -24
- package/src/db/files.ts +21 -25
- package/src/db/migrations/003-downloader-columns.ts +58 -0
- package/src/db/migrations.ts +2 -1
- package/src/ingest/converter/index.ts +9 -0
- package/src/ingest/converter/xlsx.ts +111 -0
- package/src/ingest/downloaders/browser.ts +180 -0
- package/src/ingest/downloaders/generic-web.ts +81 -0
- package/src/ingest/downloaders/github.ts +178 -0
- package/src/ingest/downloaders/google-docs.ts +56 -0
- package/src/ingest/downloaders/google-shared.ts +86 -0
- package/src/ingest/downloaders/google-sheets.ts +58 -0
- package/src/ingest/downloaders/google-slides.ts +53 -0
- package/src/ingest/downloaders/index.ts +182 -0
- package/src/ingest/downloaders/linear.ts +291 -0
- package/src/ingest/fetcher.ts +107 -127
- package/src/ingest/ingest.ts +43 -69
- package/src/mcp/instructions.ts +4 -2
- package/src/operations/add.ts +6 -4
- package/src/operations/info.ts +4 -6
- package/src/operations/move.ts +2 -3
- package/src/operations/refresh.ts +2 -4
- package/src/operations/remove.ts +23 -2
- package/src/operations/tree.ts +1 -1
- package/src/operations/types.ts +1 -1
- package/src/refresh/runner.ts +59 -114
- package/src/types/text-modules.d.ts +5 -0
- package/patches/@evantahler%2Fmcpx@0.21.4.patch +0 -51
- package/src/commands/mcpx.ts +0 -112
- package/src/ingest/agent-fetcher.ts +0 -564
package/src/refresh/runner.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { FILES } from "../constants.ts";
|
|
2
3
|
import type { AppContext } from "../context.ts";
|
|
3
4
|
import { upsertBlob } from "../db/blobs.ts";
|
|
4
5
|
import { insertChunksForVersion, rebuildFts } from "../db/chunks.ts";
|
|
5
|
-
import { getCurrent, insertVersion, millisIso, updateRefreshStatus } from "../db/files.ts";
|
|
6
|
+
import { type FetcherKind, getCurrent, insertVersion, millisIso, updateRefreshStatus } from "../db/files.ts";
|
|
6
7
|
import { HelpfulError } from "../errors.ts";
|
|
7
8
|
import { chunkDeterministic } from "../ingest/chunker.ts";
|
|
8
9
|
import { convert } from "../ingest/converter/index.ts";
|
|
9
10
|
import { describe } from "../ingest/describer.ts";
|
|
11
|
+
import { BrowserPool } from "../ingest/downloaders/browser.ts";
|
|
10
12
|
import { embed } from "../ingest/embedder.ts";
|
|
11
|
-
import {
|
|
13
|
+
import { fetchRemoteByDownloader } from "../ingest/fetcher.ts";
|
|
12
14
|
import { mimeFromPath, readLocalFile, sha256Hex } from "../ingest/local-reader.ts";
|
|
13
15
|
import { buildSearchText } from "../ingest/search-text.ts";
|
|
14
16
|
|
|
@@ -20,19 +22,20 @@ export interface RefreshOutcome {
|
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
|
-
* Refresh one logical_path. Re-reads its source (local stat+sha or
|
|
24
|
-
* via the persisted
|
|
25
|
-
* the source bytes changed. Always
|
|
26
|
-
* `last_refresh_status` on the row. Returns
|
|
27
|
-
* throws unless the path doesn't exist. The
|
|
28
|
-
* callback is forwarded to the embedder so
|
|
29
|
-
* `refresh` operation) can drive a
|
|
25
|
+
* Refresh one logical_path. Re-reads its source (local stat+sha or
|
|
26
|
+
* remote via the persisted downloader name + the original URL), and
|
|
27
|
+
* creates a new version only if the source bytes changed. Always
|
|
28
|
+
* updates `refreshed_at` and `last_refresh_status` on the row. Returns
|
|
29
|
+
* a per-path outcome — never throws unless the path doesn't exist. The
|
|
30
|
+
* optional `onPhase` callback is forwarded to the embedder so
|
|
31
|
+
* interactive callers (e.g. the `refresh` operation) can drive a
|
|
32
|
+
* spinner during the slow phase.
|
|
30
33
|
*/
|
|
31
34
|
export async function refreshOne(
|
|
32
35
|
ctx: AppContext,
|
|
33
36
|
logicalPath: string,
|
|
34
37
|
force = false,
|
|
35
|
-
|
|
38
|
+
onPhase?: (sublabel: string) => void,
|
|
36
39
|
): Promise<RefreshOutcome> {
|
|
37
40
|
const cur = await getCurrent(ctx.db, logicalPath);
|
|
38
41
|
if (!cur) {
|
|
@@ -49,10 +52,10 @@ export async function refreshOne(
|
|
|
49
52
|
|
|
50
53
|
try {
|
|
51
54
|
if (cur.source_type === "local") {
|
|
52
|
-
return await refreshLocal(ctx, cur, force,
|
|
55
|
+
return await refreshLocal(ctx, cur, force, onPhase);
|
|
53
56
|
}
|
|
54
57
|
if (cur.source_type === "remote") {
|
|
55
|
-
return await refreshRemote(ctx, cur, force,
|
|
58
|
+
return await refreshRemote(ctx, cur, force, onPhase);
|
|
56
59
|
}
|
|
57
60
|
} catch (err) {
|
|
58
61
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -74,9 +77,8 @@ interface CurrentRow {
|
|
|
74
77
|
source_sha256: string | null;
|
|
75
78
|
mime_type: string | null;
|
|
76
79
|
fetcher: string | null;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
fetcher_args: Record<string, unknown> | null;
|
|
80
|
+
downloader: string | null;
|
|
81
|
+
downloader_args: Record<string, unknown> | null;
|
|
80
82
|
refresh_frequency_sec: number | null;
|
|
81
83
|
}
|
|
82
84
|
|
|
@@ -85,7 +87,7 @@ async function refreshLocal(
|
|
|
85
87
|
ctx: AppContext,
|
|
86
88
|
cur: CurrentRow,
|
|
87
89
|
force: boolean,
|
|
88
|
-
|
|
90
|
+
onPhase?: (sublabel: string) => void,
|
|
89
91
|
): Promise<RefreshOutcome> {
|
|
90
92
|
if (!cur.source_path) {
|
|
91
93
|
throw new HelpfulError({
|
|
@@ -116,22 +118,28 @@ async function refreshLocal(
|
|
|
116
118
|
sourceMtimeMs: local.mtimeMs,
|
|
117
119
|
sourceSha: local.sha256,
|
|
118
120
|
fetcher: "local",
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
fetcherArgs: null,
|
|
121
|
+
downloader: null,
|
|
122
|
+
downloaderArgs: null,
|
|
122
123
|
refreshSec: cur.refresh_frequency_sec,
|
|
123
124
|
},
|
|
124
|
-
|
|
125
|
+
onPhase,
|
|
125
126
|
);
|
|
126
127
|
return { logical_path: cur.logical_path, status: "ok", new_version_id: versionId };
|
|
127
128
|
}
|
|
128
129
|
|
|
129
|
-
/**
|
|
130
|
+
/**
|
|
131
|
+
* Remote refresh: replay the persisted downloader against the original
|
|
132
|
+
* URL. Each downloader is deterministic (no LLM, no agent loop), so a
|
|
133
|
+
* row with `downloader='google-docs'` always re-runs the Google Docs
|
|
134
|
+
* downloader; rows from older membot versions whose `downloader` is
|
|
135
|
+
* NULL fall back to URL-based dispatch (the registry's `findDownloader`
|
|
136
|
+
* picks the right handler from the URL itself).
|
|
137
|
+
*/
|
|
130
138
|
async function refreshRemote(
|
|
131
139
|
ctx: AppContext,
|
|
132
140
|
cur: CurrentRow,
|
|
133
141
|
force: boolean,
|
|
134
|
-
|
|
142
|
+
onPhase?: (sublabel: string) => void,
|
|
135
143
|
): Promise<RefreshOutcome> {
|
|
136
144
|
if (!cur.source_path) {
|
|
137
145
|
throw new HelpfulError({
|
|
@@ -140,7 +148,14 @@ async function refreshRemote(
|
|
|
140
148
|
hint: "Inspect with `membot info` and consider re-ingesting.",
|
|
141
149
|
});
|
|
142
150
|
}
|
|
143
|
-
const
|
|
151
|
+
const userDataDir = join(ctx.dataDir, FILES.BROWSER_PROFILE);
|
|
152
|
+
const pool = new BrowserPool({ userDataDir });
|
|
153
|
+
let fetched: Awaited<ReturnType<typeof fetchRemoteByDownloader>>;
|
|
154
|
+
try {
|
|
155
|
+
fetched = await fetchRemoteByDownloader(cur.downloader, cur.source_path, pool, ctx.config);
|
|
156
|
+
} finally {
|
|
157
|
+
await pool.dispose();
|
|
158
|
+
}
|
|
144
159
|
|
|
145
160
|
if (!force && cur.source_sha256 === fetched.sha256) {
|
|
146
161
|
await updateRefreshStatus(ctx.db, cur.logical_path, cur.version_id, {
|
|
@@ -161,93 +176,16 @@ async function refreshRemote(
|
|
|
161
176
|
sourcePath: cur.source_path,
|
|
162
177
|
sourceMtimeMs: null,
|
|
163
178
|
sourceSha: fetched.sha256,
|
|
164
|
-
fetcher:
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
fetcherArgs: fetched.fetcherArgs,
|
|
179
|
+
fetcher: "downloader",
|
|
180
|
+
downloader: fetched.downloader,
|
|
181
|
+
downloaderArgs: fetched.downloaderArgs,
|
|
168
182
|
refreshSec: cur.refresh_frequency_sec,
|
|
169
183
|
},
|
|
170
|
-
|
|
184
|
+
onPhase,
|
|
171
185
|
);
|
|
172
186
|
return { logical_path: cur.logical_path, status: "ok", new_version_id: versionId };
|
|
173
187
|
}
|
|
174
188
|
|
|
175
|
-
/**
|
|
176
|
-
* Re-fetch a remote source. When the row recorded an mcpx invocation,
|
|
177
|
-
* call it directly with the same args (no agent re-routing); otherwise
|
|
178
|
-
* fall back to plain HTTP. The choice is deterministic — same row always
|
|
179
|
-
* produces the same fetch path.
|
|
180
|
-
*/
|
|
181
|
-
async function replayFetch(
|
|
182
|
-
cur: CurrentRow,
|
|
183
|
-
mcpx: McpxClient | null,
|
|
184
|
-
): Promise<{
|
|
185
|
-
bytes: Uint8Array;
|
|
186
|
-
sha256: string;
|
|
187
|
-
mimeType: string;
|
|
188
|
-
fetcherServer: string | null;
|
|
189
|
-
fetcherTool: string | null;
|
|
190
|
-
fetcherArgs: Record<string, unknown> | null;
|
|
191
|
-
}> {
|
|
192
|
-
if (cur.fetcher === "mcpx" && cur.fetcher_server && cur.fetcher_tool && mcpx) {
|
|
193
|
-
const args = cur.fetcher_args ?? {};
|
|
194
|
-
const result = await mcpx.exec(cur.fetcher_server, cur.fetcher_tool, args);
|
|
195
|
-
if (isMcpToolError(result)) {
|
|
196
|
-
const detail = extractText(result).trim();
|
|
197
|
-
throw new HelpfulError({
|
|
198
|
-
kind: "network_error",
|
|
199
|
-
message: `mcpx tool ${cur.fetcher_server}/${cur.fetcher_tool} returned isError=true${detail ? `: ${detail}` : ""}`,
|
|
200
|
-
hint: `Re-add with a working fetcher: \`membot remove ${cur.logical_path}\` then \`membot add ${cur.source_path} --fetcher http\` (or another --fetcher hint).`,
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
const text = extractText(result);
|
|
204
|
-
const bytes = new TextEncoder().encode(text);
|
|
205
|
-
return {
|
|
206
|
-
bytes,
|
|
207
|
-
sha256: sha256Hex(bytes),
|
|
208
|
-
mimeType: "text/markdown",
|
|
209
|
-
fetcherServer: cur.fetcher_server,
|
|
210
|
-
fetcherTool: cur.fetcher_tool,
|
|
211
|
-
fetcherArgs: args,
|
|
212
|
-
};
|
|
213
|
-
}
|
|
214
|
-
const r = await fetchRemote(cur.source_path ?? "", { hint: "http" });
|
|
215
|
-
return {
|
|
216
|
-
bytes: r.bytes,
|
|
217
|
-
sha256: r.sha256,
|
|
218
|
-
mimeType: r.mimeType,
|
|
219
|
-
fetcherServer: null,
|
|
220
|
-
fetcherTool: null,
|
|
221
|
-
fetcherArgs: null,
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/** Pull a string out of whatever shape an mcpx tool happens to return. */
|
|
226
|
-
function extractText(result: unknown): string {
|
|
227
|
-
if (typeof result === "string") return result;
|
|
228
|
-
if (result && typeof result === "object") {
|
|
229
|
-
const r = result as Record<string, unknown>;
|
|
230
|
-
if (typeof r.text === "string") return r.text;
|
|
231
|
-
if (typeof r.content === "string") return r.content;
|
|
232
|
-
if (typeof r.markdown === "string") return r.markdown;
|
|
233
|
-
if (Array.isArray(r.content)) {
|
|
234
|
-
const out: string[] = [];
|
|
235
|
-
for (const c of r.content) {
|
|
236
|
-
if (c && typeof c === "object") {
|
|
237
|
-
const inner = c as Record<string, unknown>;
|
|
238
|
-
if (typeof inner.text === "string") out.push(inner.text);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
if (out.length > 0) return out.join("\n\n");
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
try {
|
|
245
|
-
return JSON.stringify(result);
|
|
246
|
-
} catch {
|
|
247
|
-
return "";
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
251
189
|
interface PipelineParams {
|
|
252
190
|
logicalPath: string;
|
|
253
191
|
bytes: Uint8Array;
|
|
@@ -257,10 +195,9 @@ interface PipelineParams {
|
|
|
257
195
|
sourcePath: string | null;
|
|
258
196
|
sourceMtimeMs: number | null;
|
|
259
197
|
sourceSha: string;
|
|
260
|
-
fetcher:
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
fetcherArgs: Record<string, unknown> | null;
|
|
198
|
+
fetcher: FetcherKind;
|
|
199
|
+
downloader: string | null;
|
|
200
|
+
downloaderArgs: Record<string, unknown> | null;
|
|
264
201
|
refreshSec: number | null;
|
|
265
202
|
}
|
|
266
203
|
|
|
@@ -273,8 +210,9 @@ interface PipelineParams {
|
|
|
273
210
|
async function runPipelineForRefresh(
|
|
274
211
|
ctx: AppContext,
|
|
275
212
|
p: PipelineParams,
|
|
276
|
-
|
|
213
|
+
onPhase?: (sublabel: string) => void,
|
|
277
214
|
): Promise<string> {
|
|
215
|
+
onPhase?.("storing blob");
|
|
278
216
|
await upsertBlob(ctx.db, {
|
|
279
217
|
sha256: p.sourceSha,
|
|
280
218
|
mime_type: p.mime,
|
|
@@ -282,12 +220,17 @@ async function runPipelineForRefresh(
|
|
|
282
220
|
bytes: p.bytes,
|
|
283
221
|
});
|
|
284
222
|
|
|
223
|
+
onPhase?.("converting");
|
|
285
224
|
const conversion = await convert(p.bytes, p.mime, p.source, ctx.config.llm);
|
|
286
225
|
const markdown = conversion.markdown;
|
|
226
|
+
onPhase?.("describing");
|
|
287
227
|
const description = await describe(p.logicalPath, p.mime, markdown, ctx.config.llm);
|
|
228
|
+
onPhase?.("chunking");
|
|
288
229
|
const chunks = chunkDeterministic(markdown, ctx.config.chunker);
|
|
289
230
|
const searchTexts = chunks.map((c) => buildSearchText(p.logicalPath, description, c.content));
|
|
290
|
-
const embeddings = await embed(searchTexts, ctx.config.embedding_model, {
|
|
231
|
+
const embeddings = await embed(searchTexts, ctx.config.embedding_model, {
|
|
232
|
+
onProgress: (done, total) => onPhase?.(`embedding ${done}/${total}`),
|
|
233
|
+
});
|
|
291
234
|
|
|
292
235
|
const versionId = millisIso(Date.now());
|
|
293
236
|
const contentSha = sha256Hex(new TextEncoder().encode(markdown));
|
|
@@ -305,15 +248,16 @@ async function runPipelineForRefresh(
|
|
|
305
248
|
mime_type: p.mime,
|
|
306
249
|
size_bytes: p.bytes.byteLength,
|
|
307
250
|
fetcher: p.fetcher,
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
fetcher_args: p.fetcherArgs,
|
|
251
|
+
downloader: p.downloader,
|
|
252
|
+
downloader_args: p.downloaderArgs,
|
|
311
253
|
refresh_frequency_sec: p.refreshSec,
|
|
312
254
|
refreshed_at: new Date().toISOString(),
|
|
313
255
|
last_refresh_status: "ok",
|
|
314
256
|
change_note: "refresh: source updated",
|
|
315
257
|
});
|
|
316
258
|
|
|
259
|
+
onPhase?.("persisting");
|
|
260
|
+
|
|
317
261
|
await insertChunksForVersion(
|
|
318
262
|
ctx.db,
|
|
319
263
|
p.logicalPath,
|
|
@@ -326,6 +270,7 @@ async function runPipelineForRefresh(
|
|
|
326
270
|
})),
|
|
327
271
|
);
|
|
328
272
|
|
|
273
|
+
onPhase?.("indexing");
|
|
329
274
|
await rebuildFts(ctx.db);
|
|
330
275
|
return versionId;
|
|
331
276
|
}
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
diff --git a/src/search/onnx-wasm-paths.ts b/src/search/onnx-wasm-paths.ts
|
|
2
|
-
--- a/src/search/onnx-wasm-paths.ts
|
|
3
|
-
+++ b/src/search/onnx-wasm-paths.ts
|
|
4
|
-
@@ -1,31 +1,16 @@
|
|
5
|
-
-// Embed the onnxruntime-web WASM runtime files into the compiled binary
|
|
6
|
-
-// (`bun build --compile`) so they survive in a single-binary distribution
|
|
7
|
-
-// where the user has no node_modules.
|
|
8
|
-
-//
|
|
9
|
-
-// This file is loaded **dynamically** by semantic.ts. The relative paths
|
|
10
|
-
-// only resolve in the local repo / compiled binary; for npm/bun-installed
|
|
11
|
-
-// mcpx the parent directory layout is different (deps are hoisted), the
|
|
12
|
-
-// dynamic import throws, and we fall back to letting transformers.js
|
|
13
|
-
-// load WASM via its default mechanism — which works fine because in
|
|
14
|
-
-// that environment node_modules exists and onnxruntime-web is reachable
|
|
15
|
-
-// through normal module resolution.
|
|
16
|
-
-
|
|
17
|
-
-// The relative `../../node_modules/...` paths only resolve from the local repo
|
|
18
|
-
-// layout (and inside `bun build --compile`). When this file is shipped via npm,
|
|
19
|
-
-// deps are hoisted, so consumer `tsc` runs hit TS2307. The `ts-ignore` directive
|
|
20
|
-
-// below silences that for consumers; we avoid the stricter `expect-error` form
|
|
21
|
-
-// because in the local repo the path resolves fine and there would be no error
|
|
22
|
-
-// to expect. At runtime the dynamic import in semantic.ts is wrapped in
|
|
23
|
-
-// try/catch and falls back to transformers.js's default WASM loader (issue #85).
|
|
24
|
-
-// biome-ignore lint/suspicious/noTsIgnore: must stay as ts-ignore per comment above
|
|
25
|
-
-// @ts-ignore - dynamic-only import
|
|
26
|
-
-import wasmMjsPath from "../../node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.asyncify.mjs" with {
|
|
27
|
-
- type: "file",
|
|
28
|
-
-};
|
|
29
|
-
-// biome-ignore lint/suspicious/noTsIgnore: must stay as ts-ignore per comment above
|
|
30
|
-
-// @ts-ignore - dynamic-only import
|
|
31
|
-
-import wasmBinPath from "../../node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.asyncify.wasm" with {
|
|
32
|
-
- type: "file",
|
|
33
|
-
-};
|
|
34
|
-
-
|
|
35
|
-
-export { wasmBinPath, wasmMjsPath };
|
|
36
|
-
+// PATCHED (membot): point mcpx's onnx-wasm-paths at the onnxruntime-web installed
|
|
37
|
-
+// at the top of membot's node_modules. Upstream's `../../node_modules/...` only
|
|
38
|
-
+// resolves in mcpx's standalone repo layout; for consumers we walk up 4 levels:
|
|
39
|
-
+// node_modules/@evantahler/mcpx/src/search → node_modules → onnxruntime-web.
|
|
40
|
-
+// biome-ignore lint/suspicious/noTsIgnore: must stay as ts-ignore — relative path only resolves at runtime in consumer layout
|
|
41
|
-
+// @ts-ignore - dynamic-only import
|
|
42
|
-
+import wasmMjsPath from "../../../../onnxruntime-web/dist/ort-wasm-simd-threaded.asyncify.mjs" with {
|
|
43
|
-
+ type: "file",
|
|
44
|
-
+};
|
|
45
|
-
+// biome-ignore lint/suspicious/noTsIgnore: must stay as ts-ignore — relative path only resolves at runtime in consumer layout
|
|
46
|
-
+// @ts-ignore - dynamic-only import
|
|
47
|
-
+import wasmBinPath from "../../../../onnxruntime-web/dist/ort-wasm-simd-threaded.asyncify.wasm" with {
|
|
48
|
-
+ type: "file",
|
|
49
|
-
+};
|
|
50
|
-
+
|
|
51
|
-
+export { wasmBinPath, wasmMjsPath };
|
package/src/commands/mcpx.ts
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import { createRequire } from "node:module";
|
|
2
|
-
import { fileURLToPath } from "node:url";
|
|
3
|
-
import type { Command } from "commander";
|
|
4
|
-
import { logger } from "../output/logger.ts";
|
|
5
|
-
|
|
6
|
-
const require = createRequire(import.meta.url);
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Resolve the path to the bundled mcpx CLI entrypoint. We spawn it as a
|
|
10
|
-
* child process rather than calling its functions directly so that the
|
|
11
|
-
* upstream's argv parsing, output formatting, and config conventions stay
|
|
12
|
-
* authoritative — `membot mcpx <subcmd>` behaves identically to the user
|
|
13
|
-
* running `mcpx <subcmd>` themselves, just with our project's `--config`
|
|
14
|
-
* resolution layered on top when applicable.
|
|
15
|
-
*/
|
|
16
|
-
const MCPX_CLI = fileURLToPath(import.meta.resolve("@evantahler/mcpx/cli"));
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Forward an argv slice to the bundled mcpx CLI. Inherits stdio so prompts
|
|
20
|
-
* and pretty output flow through as if mcpx were called directly. Exits
|
|
21
|
-
* the parent process with the child's status code on failure.
|
|
22
|
-
*/
|
|
23
|
-
export async function runMcpx(args: string[]): Promise<void> {
|
|
24
|
-
const proc = Bun.spawn(["bun", MCPX_CLI, ...args], {
|
|
25
|
-
stdout: "inherit",
|
|
26
|
-
stderr: "inherit",
|
|
27
|
-
stdin: "inherit",
|
|
28
|
-
});
|
|
29
|
-
const code = await proc.exited;
|
|
30
|
-
if (code !== 0) process.exit(code);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Pull the verbatim argv tokens that follow `mcpx` in `process.argv`. We
|
|
35
|
-
* forward them unmodified so flags (`--help`, `-c`, etc.) reach the
|
|
36
|
-
* upstream CLI exactly as the user typed them.
|
|
37
|
-
*/
|
|
38
|
-
function getRawMcpxArgs(): string[] {
|
|
39
|
-
const idx = process.argv.indexOf("mcpx");
|
|
40
|
-
return idx === -1 ? [] : process.argv.slice(idx + 1);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const PASSTHROUGH_SUBCOMMANDS: ReadonlyArray<[name: string, desc: string]> = [
|
|
44
|
-
["servers", "List configured MCP server names"],
|
|
45
|
-
["info", "Show server overview or schema for a specific tool"],
|
|
46
|
-
["search", "Search tools by keyword and/or semantic similarity"],
|
|
47
|
-
["exec", "Execute a tool call"],
|
|
48
|
-
["add", "Add an MCP server"],
|
|
49
|
-
["remove", "Remove an MCP server"],
|
|
50
|
-
["ping", "Check connectivity to MCP servers"],
|
|
51
|
-
["auth", "Authenticate with an HTTP MCP server"],
|
|
52
|
-
["deauth", "Remove stored authentication for a server"],
|
|
53
|
-
["resource", "List resources for a server, or read a specific resource"],
|
|
54
|
-
["prompt", "List prompts for a server, or get a specific prompt"],
|
|
55
|
-
["task", "Manage async tool tasks (list, get, result, cancel)"],
|
|
56
|
-
["index", "Build the search index from all configured servers"],
|
|
57
|
-
];
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Register `membot mcpx <subcommand>` for every passthrough subcommand on
|
|
61
|
-
* the upstream CLI. `--help` and unknown options are forwarded so users
|
|
62
|
-
* always get authoritative mcpx help text.
|
|
63
|
-
*/
|
|
64
|
-
export function registerMcpxCommand(program: Command): void {
|
|
65
|
-
const mcpx = program.command("mcpx").description("Forward to the bundled mcpx CLI for managing MCP servers");
|
|
66
|
-
|
|
67
|
-
const verifyVersion = (() => {
|
|
68
|
-
try {
|
|
69
|
-
const ourPkg = require("../../package.json") as { dependencies: Record<string, string> };
|
|
70
|
-
const mcpxPkg = require("@evantahler/mcpx/package.json") as { version: string };
|
|
71
|
-
const declared = ourPkg.dependencies["@evantahler/mcpx"];
|
|
72
|
-
if (!declared) return true;
|
|
73
|
-
return (
|
|
74
|
-
mcpxPkg.version === declared ||
|
|
75
|
-
declared.startsWith(mcpxPkg.version) ||
|
|
76
|
-
mcpxPkg.version.startsWith(declared.replace(/^[\^~]/, ""))
|
|
77
|
-
);
|
|
78
|
-
} catch {
|
|
79
|
-
return true;
|
|
80
|
-
}
|
|
81
|
-
})();
|
|
82
|
-
if (!verifyVersion) {
|
|
83
|
-
logger.warn("@evantahler/mcpx version mismatch — `membot mcpx` may behave unexpectedly.");
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
for (const [name, description] of PASSTHROUGH_SUBCOMMANDS) {
|
|
87
|
-
mcpx
|
|
88
|
-
.command(name)
|
|
89
|
-
.description(description)
|
|
90
|
-
.allowUnknownOption(true)
|
|
91
|
-
.helpOption(false)
|
|
92
|
-
.argument("[args...]", "arguments forwarded to mcpx")
|
|
93
|
-
.action(async () => {
|
|
94
|
-
await runMcpx(getRawMcpxArgs());
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Upstream mcpx's "list" is the default action when invoked with no
|
|
99
|
-
// subcommand — not a registered subcommand — so strip the "list" token
|
|
100
|
-
// before forwarding.
|
|
101
|
-
mcpx
|
|
102
|
-
.command("list")
|
|
103
|
-
.description("List all tools, resources, and prompts across all configured servers")
|
|
104
|
-
.allowUnknownOption(true)
|
|
105
|
-
.helpOption(false)
|
|
106
|
-
.argument("[args...]", "arguments forwarded to mcpx")
|
|
107
|
-
.action(async () => {
|
|
108
|
-
const raw = getRawMcpxArgs();
|
|
109
|
-
const args = raw[0] === "list" ? raw.slice(1) : raw;
|
|
110
|
-
await runMcpx(args);
|
|
111
|
-
});
|
|
112
|
-
}
|