@evantahler/mcpx 0.20.1 → 0.21.1
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/mcpx.md +2 -2
- package/.cursor/rules/mcpx.mdc +2 -2
- package/README.md +9 -4
- package/package.json +3 -1
- package/src/client/elicitation.ts +81 -24
- package/src/client/manager.ts +3 -0
- package/src/commands/add.ts +63 -3
- package/src/commands/exec.ts +16 -3
- package/src/commands/search.ts +9 -2
- package/src/constants.ts +6 -0
- package/src/context.ts +2 -1
- package/src/output/formatter.ts +28 -3
- package/src/search/indexer.ts +2 -1
- package/src/search/onnx-wasm-paths.ts +20 -0
- package/src/search/semantic.ts +49 -4
- package/src/search/staleness.ts +6 -0
- package/src/types/file-imports.d.ts +14 -0
package/.claude/skills/mcpx.md
CHANGED
|
@@ -162,8 +162,8 @@ mcpx deauth <server> # remove stored auth
|
|
|
162
162
|
| `mcpx deauth <server>` | Remove stored authentication |
|
|
163
163
|
| `mcpx ping` | Check connectivity to all servers |
|
|
164
164
|
| `mcpx ping <server> [server2...]` | Check specific server(s) |
|
|
165
|
-
| `mcpx add <name> --command <cmd>` | Add a stdio MCP server
|
|
166
|
-
| `mcpx add
|
|
165
|
+
| `mcpx add <name> --command <cmd>` | Add a stdio MCP server |
|
|
166
|
+
| `mcpx add [name] --url <url>` | Add an HTTP MCP server (name derived from URL if omitted) |
|
|
167
167
|
| `mcpx remove <name>` | Remove an MCP server |
|
|
168
168
|
| `mcpx skill install --claude` | Install mcpx skill for Claude |
|
|
169
169
|
| `mcpx skill install --cursor` | Install mcpx rule for Cursor |
|
package/.cursor/rules/mcpx.mdc
CHANGED
|
@@ -158,8 +158,8 @@ mcpx deauth <server> # remove stored auth
|
|
|
158
158
|
| `mcpx deauth <server>` | Remove stored authentication |
|
|
159
159
|
| `mcpx ping` | Check connectivity to all servers |
|
|
160
160
|
| `mcpx ping <server> [server2...]` | Check specific server(s) |
|
|
161
|
-
| `mcpx add <name> --command <cmd>` | Add a stdio MCP server
|
|
162
|
-
| `mcpx add
|
|
161
|
+
| `mcpx add <name> --command <cmd>` | Add a stdio MCP server |
|
|
162
|
+
| `mcpx add [name] --url <url>` | Add an HTTP MCP server (name derived from URL if omitted) |
|
|
163
163
|
| `mcpx remove <name>` | Remove an MCP server |
|
|
164
164
|
| `mcpx skill install --claude` | Install mcpx skill for Claude |
|
|
165
165
|
| `mcpx skill install --cursor` | Install mcpx rule for Cursor |
|
package/README.md
CHANGED
|
@@ -92,7 +92,7 @@ mcpx search -n 5 "manage pull requests"
|
|
|
92
92
|
| `mcpx auth <server> -r` | Force token refresh |
|
|
93
93
|
| `mcpx deauth <server>` | Remove stored authentication for a server |
|
|
94
94
|
| `mcpx add <name> --command <cmd>` | Add a stdio MCP server to your config |
|
|
95
|
-
| `mcpx add
|
|
95
|
+
| `mcpx add [name] --url <url>` | Add an HTTP MCP server (name derived from URL if omitted) |
|
|
96
96
|
| `mcpx remove <name>` | Remove an MCP server from your config |
|
|
97
97
|
| `mcpx ping` | Check connectivity to all configured servers |
|
|
98
98
|
| `mcpx ping <server> [server2...]` | Check connectivity to specific server(s) |
|
|
@@ -153,6 +153,11 @@ mcpx add filesystem --command npx --args "-y,@modelcontextprotocol/server-filesy
|
|
|
153
153
|
# Add an HTTP server with headers
|
|
154
154
|
mcpx add my-api --url https://api.example.com/mcp --header "Authorization:Bearer tok123"
|
|
155
155
|
|
|
156
|
+
# When --url is used, the name is optional — derived from the URL's last path
|
|
157
|
+
# segment (or hostname if there is none). The example below stores the server
|
|
158
|
+
# under the name "evan-coding".
|
|
159
|
+
mcpx add --url https://api.arcade.dev/mcp/evan-coding
|
|
160
|
+
|
|
156
161
|
# Add with tool filtering (repeatable, or comma-separated)
|
|
157
162
|
mcpx add github --url https://mcp.github.com --allowed-tools "search_*" --allowed-tools "get_*"
|
|
158
163
|
|
|
@@ -274,7 +279,7 @@ Contains every discovered tool with metadata for semantic search. Built and upda
|
|
|
274
279
|
{
|
|
275
280
|
"version": 1,
|
|
276
281
|
"indexed_at": "2026-03-03T10:00:00Z",
|
|
277
|
-
"embedding_model": "Xenova/
|
|
282
|
+
"embedding_model": "Xenova/bge-small-en-v1.5",
|
|
278
283
|
"tools": [
|
|
279
284
|
{
|
|
280
285
|
"server": "linear",
|
|
@@ -295,7 +300,7 @@ Each tool gets:
|
|
|
295
300
|
- **keywords** — terms extracted by splitting the tool name on `_`, `-`, and camelCase boundaries
|
|
296
301
|
- **embedding** — 384-dim vector for cosine similarity search
|
|
297
302
|
|
|
298
|
-
Scenarios and keywords are extracted heuristically from tool names and descriptions. Embeddings are generated in-process using `Xenova/
|
|
303
|
+
Scenarios and keywords are extracted heuristically from tool names and descriptions. Embeddings are generated in-process using `Xenova/bge-small-en-v1.5` (~33MB ONNX model, downloaded on first run). No API keys needed.
|
|
299
304
|
|
|
300
305
|
## Config Resolution Order
|
|
301
306
|
|
|
@@ -820,7 +825,7 @@ bun lint
|
|
|
820
825
|
| MCP Client | `@modelcontextprotocol/sdk` |
|
|
821
826
|
| CLI Parsing | `commander` |
|
|
822
827
|
| Validation | `ajv` (JSON Schema) |
|
|
823
|
-
| Embeddings | `@huggingface/transformers` (Xenova/
|
|
828
|
+
| Embeddings | `@huggingface/transformers` (Xenova/bge-small-en-v1.5) |
|
|
824
829
|
|
|
825
830
|
## Inspiration
|
|
826
831
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evantahler/mcpx",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.1",
|
|
4
4
|
"description": "A command-line interface for MCP servers. curl for MCP.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"test:e2e": "bun test test/integration/remote-server.test.ts",
|
|
26
26
|
"lint": "biome ci . && tsc --noEmit",
|
|
27
27
|
"format": "biome check --write .",
|
|
28
|
+
"prebuild": "bash scripts/apply-transformers-patch.sh",
|
|
28
29
|
"build": "bun build --compile --minify --sourcemap ./src/cli.ts --outfile dist/mcpx"
|
|
29
30
|
},
|
|
30
31
|
"publishConfig": {
|
|
@@ -51,6 +52,7 @@
|
|
|
51
52
|
"ansis": "^4.2.0",
|
|
52
53
|
"commander": "^14.0.3",
|
|
53
54
|
"nanospinner": "^1.2.2",
|
|
55
|
+
"onnxruntime-web": "1.26.0-dev.20260416-b7804b056c",
|
|
54
56
|
"picomatch": "^4.0.4",
|
|
55
57
|
"@types/picomatch": "^4.0.3"
|
|
56
58
|
},
|
|
@@ -283,16 +283,35 @@ async function promptMultiSelect(
|
|
|
283
283
|
// URL mode
|
|
284
284
|
// ---------------------------------------------------------------------------
|
|
285
285
|
|
|
286
|
-
async function handleUrlElicitation(
|
|
286
|
+
export async function handleUrlElicitation(
|
|
287
287
|
params: ElicitRequestURLParams,
|
|
288
288
|
options: ElicitationOptions,
|
|
289
289
|
): Promise<ElicitResult> {
|
|
290
290
|
if (options.json) {
|
|
291
291
|
return handleUrlJson(params);
|
|
292
292
|
}
|
|
293
|
+
if (options.noInteractive) {
|
|
294
|
+
printUrlElicitation(params);
|
|
295
|
+
return { action: "decline" };
|
|
296
|
+
}
|
|
293
297
|
return handleUrlInteractive(params);
|
|
294
298
|
}
|
|
295
299
|
|
|
300
|
+
function printUrlElicitation(params: ElicitRequestURLParams): void {
|
|
301
|
+
const domain = (() => {
|
|
302
|
+
try {
|
|
303
|
+
return new URL(params.url).hostname;
|
|
304
|
+
} catch {
|
|
305
|
+
return "unknown";
|
|
306
|
+
}
|
|
307
|
+
})();
|
|
308
|
+
|
|
309
|
+
logger.writeRaw(`\n${ansis.bold("Server requests URL interaction:")}\n`);
|
|
310
|
+
logger.writeRaw(` ${params.message}\n`);
|
|
311
|
+
logger.writeRaw(` ${ansis.yellow("Domain:")} ${domain}\n`);
|
|
312
|
+
logger.writeRaw(` ${ansis.yellow("URL:")} ${params.url}\n`);
|
|
313
|
+
}
|
|
314
|
+
|
|
296
315
|
async function handleUrlJson(params: ElicitRequestURLParams): Promise<ElicitResult> {
|
|
297
316
|
const request = {
|
|
298
317
|
type: "elicitation",
|
|
@@ -313,32 +332,70 @@ async function handleUrlJson(params: ElicitRequestURLParams): Promise<ElicitResu
|
|
|
313
332
|
}
|
|
314
333
|
|
|
315
334
|
async function handleUrlInteractive(params: ElicitRequestURLParams): Promise<ElicitResult> {
|
|
316
|
-
|
|
317
|
-
const question = (prompt: string): Promise<string> => new Promise((resolve) => rl.question(prompt, resolve));
|
|
318
|
-
|
|
319
|
-
try {
|
|
320
|
-
const domain = (() => {
|
|
321
|
-
try {
|
|
322
|
-
return new URL(params.url).hostname;
|
|
323
|
-
} catch {
|
|
324
|
-
return "unknown";
|
|
325
|
-
}
|
|
326
|
-
})();
|
|
335
|
+
printUrlElicitation(params);
|
|
327
336
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
337
|
+
const yes = await promptYesNo(` Open in browser? [y/n]: `);
|
|
338
|
+
if (yes) {
|
|
339
|
+
await openBrowser(params.url);
|
|
340
|
+
return { action: "accept" };
|
|
341
|
+
}
|
|
342
|
+
return { action: "decline" };
|
|
343
|
+
}
|
|
332
344
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
345
|
+
/**
|
|
346
|
+
* Prompt for a yes/no answer.
|
|
347
|
+
* On a TTY, accepts a single keypress (y/Y/n/N/Enter/Esc) without requiring Enter.
|
|
348
|
+
* Off a TTY, falls back to line-buffered input so piped tests still work.
|
|
349
|
+
*/
|
|
350
|
+
function promptYesNo(prompt: string): Promise<boolean> {
|
|
351
|
+
logger.writeRaw(prompt);
|
|
352
|
+
const stdin = process.stdin;
|
|
353
|
+
|
|
354
|
+
if (!stdin.isTTY) {
|
|
355
|
+
return new Promise((resolve) => {
|
|
356
|
+
const rl = createInterface({ input: stdin });
|
|
357
|
+
rl.once("line", (line) => {
|
|
358
|
+
rl.close();
|
|
359
|
+
const ch = line.trim().toLowerCase();
|
|
360
|
+
resolve(ch === "y" || ch === "yes");
|
|
361
|
+
});
|
|
362
|
+
rl.once("close", () => resolve(false));
|
|
363
|
+
});
|
|
341
364
|
}
|
|
365
|
+
|
|
366
|
+
return new Promise((resolve) => {
|
|
367
|
+
stdin.setRawMode(true);
|
|
368
|
+
stdin.resume();
|
|
369
|
+
|
|
370
|
+
const cleanup = () => {
|
|
371
|
+
stdin.removeListener("data", onData);
|
|
372
|
+
stdin.setRawMode(false);
|
|
373
|
+
stdin.pause();
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const onData = (data: Buffer) => {
|
|
377
|
+
const key = data.toString();
|
|
378
|
+
// Ctrl+C
|
|
379
|
+
if (key === "\u0003") {
|
|
380
|
+
cleanup();
|
|
381
|
+
logger.writeRaw("\n");
|
|
382
|
+
process.exit(130);
|
|
383
|
+
}
|
|
384
|
+
const ch = key.toLowerCase();
|
|
385
|
+
if (ch === "y") {
|
|
386
|
+
cleanup();
|
|
387
|
+
logger.writeRaw("y\n");
|
|
388
|
+
resolve(true);
|
|
389
|
+
} else if (ch === "n" || key === "\u001b") {
|
|
390
|
+
cleanup();
|
|
391
|
+
logger.writeRaw("n\n");
|
|
392
|
+
resolve(false);
|
|
393
|
+
}
|
|
394
|
+
// Ignore other keys
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
stdin.on("data", onData);
|
|
398
|
+
});
|
|
342
399
|
}
|
|
343
400
|
|
|
344
401
|
// ---------------------------------------------------------------------------
|
package/src/client/manager.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
CallToolResultSchema,
|
|
14
14
|
ElicitRequestSchema,
|
|
15
15
|
LoggingMessageNotificationSchema,
|
|
16
|
+
UrlElicitationRequiredError,
|
|
16
17
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
17
18
|
import picomatch from "picomatch";
|
|
18
19
|
import pkg from "../../package.json";
|
|
@@ -305,6 +306,8 @@ export class ServerManager {
|
|
|
305
306
|
return await fn();
|
|
306
307
|
} catch (err) {
|
|
307
308
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
309
|
+
// Don't retry auth challenges — the user needs to authorize first
|
|
310
|
+
if (err instanceof UrlElicitationRequiredError) throw lastError;
|
|
308
311
|
if (attempt < this.maxRetries && serverName) {
|
|
309
312
|
// Clear cached client so next attempt reconnects fresh
|
|
310
313
|
try {
|
package/src/commands/add.ts
CHANGED
|
@@ -2,12 +2,13 @@ import type { Command } from "commander";
|
|
|
2
2
|
import { resolveResourceUrl, tryOAuthIfSupported } from "../client/oauth.ts";
|
|
3
3
|
import { loadRawAuth, loadRawServers, saveServers } from "../config/loader.ts";
|
|
4
4
|
import type { ServerConfig } from "../config/schemas.ts";
|
|
5
|
+
import { logger } from "../output/logger.ts";
|
|
5
6
|
import { runIndex } from "./index.ts";
|
|
6
7
|
|
|
7
8
|
export function registerAddCommand(program: Command) {
|
|
8
9
|
program
|
|
9
|
-
.command("add
|
|
10
|
-
.description("add an MCP server to your config")
|
|
10
|
+
.command("add [name] [passthroughArgs...]")
|
|
11
|
+
.description("add an MCP server to your config (name derived from URL when omitted with --url)")
|
|
11
12
|
.option("--command <cmd>", "command to run (stdio server)")
|
|
12
13
|
.option("--args <arg>", "argument for the command (repeatable, comma-separated, or pass after --)", collect, [])
|
|
13
14
|
.option("--env <KEY=VAL>", "environment variable (repeatable or comma-separated)", collect, [])
|
|
@@ -22,7 +23,7 @@ export function registerAddCommand(program: Command) {
|
|
|
22
23
|
.option("--no-index", "skip rebuilding the search index after adding")
|
|
23
24
|
.action(
|
|
24
25
|
async (
|
|
25
|
-
name: string,
|
|
26
|
+
name: string | undefined,
|
|
26
27
|
passthroughArgs: string[],
|
|
27
28
|
options: {
|
|
28
29
|
command?: string;
|
|
@@ -55,6 +56,23 @@ export function registerAddCommand(program: Command) {
|
|
|
55
56
|
process.exit(1);
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
if (!name) {
|
|
60
|
+
if (hasUrl) {
|
|
61
|
+
const derived = deriveNameFromUrl(options.url!);
|
|
62
|
+
if (!derived) {
|
|
63
|
+
console.error(`Could not derive a server name from URL "${options.url}". Pass an explicit name.`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
name = derived;
|
|
67
|
+
logger.warn(
|
|
68
|
+
`Using derived server name "${name}". Pass an explicit name to override: mcpx add <name> --url ${options.url}`,
|
|
69
|
+
);
|
|
70
|
+
} else {
|
|
71
|
+
console.error("A server name is required when using --command. Usage: mcpx add <name> --command <cmd>");
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
58
76
|
const configFlag = program.opts().config;
|
|
59
77
|
const { configDir, servers } = await loadRawServers(configFlag);
|
|
60
78
|
|
|
@@ -133,6 +151,48 @@ function collect(value: string, previous: string[]): string[] {
|
|
|
133
151
|
return previous.concat([value]);
|
|
134
152
|
}
|
|
135
153
|
|
|
154
|
+
function sanitizeName(s: string): string {
|
|
155
|
+
return s
|
|
156
|
+
.toLowerCase()
|
|
157
|
+
.replace(/[^a-z0-9_-]+/g, "-")
|
|
158
|
+
.replace(/^-+|-+$/g, "");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Generic path segments that don't make good server names on their own
|
|
162
|
+
// (e.g. https://mcp.linear.app/mcp should derive "linear", not "mcp").
|
|
163
|
+
const GENERIC_SEGMENTS = new Set(["mcp", "api", "sse", "v1", "v2", "v3", "rpc"]);
|
|
164
|
+
|
|
165
|
+
// Derive a server name from a URL. Strategy:
|
|
166
|
+
// 1. Walk path segments from last to first; return the first non-generic one.
|
|
167
|
+
// 2. Otherwise fall back to the second-to-last hostname label
|
|
168
|
+
// (e.g. "mcp.linear.app" → "linear", "api.arcade.dev" → "arcade").
|
|
169
|
+
// 3. Otherwise fall back to the full hostname.
|
|
170
|
+
export function deriveNameFromUrl(rawUrl: string): string | null {
|
|
171
|
+
let parsed: URL;
|
|
172
|
+
try {
|
|
173
|
+
parsed = new URL(rawUrl);
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const segments = parsed.pathname.split("/").filter((s) => s.length > 0);
|
|
179
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
180
|
+
const candidate = sanitizeName(segments[i]!);
|
|
181
|
+
if (candidate.length > 1 && !GENERIC_SEGMENTS.has(candidate)) {
|
|
182
|
+
return candidate;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const hostnameParts = parsed.hostname.split(".").filter((s) => s.length > 0);
|
|
187
|
+
if (hostnameParts.length >= 2) {
|
|
188
|
+
const secondToLast = sanitizeName(hostnameParts[hostnameParts.length - 2]!);
|
|
189
|
+
if (secondToLast.length > 0) return secondToLast;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const fullHost = sanitizeName(parsed.hostname);
|
|
193
|
+
return fullHost.length > 0 ? fullHost : null;
|
|
194
|
+
}
|
|
195
|
+
|
|
136
196
|
// Flatten a list of repeated CLI values, splitting each on commas and trimming.
|
|
137
197
|
// Supports both `--flag a --flag b` and `--flag "a,b"` forms.
|
|
138
198
|
function splitCommaList(values: string[]): string[] {
|
package/src/commands/exec.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { UrlElicitationRequiredError } from "@modelcontextprotocol/sdk/types.js";
|
|
1
2
|
import type { Command } from "commander";
|
|
3
|
+
import { handleUrlElicitation } from "../client/elicitation.ts";
|
|
2
4
|
import type { ServerManager } from "../client/manager.ts";
|
|
3
5
|
import { DEFAULTS } from "../constants.ts";
|
|
4
6
|
import { getContext } from "../context.ts";
|
|
@@ -108,7 +110,7 @@ export function registerExecCommand(program: Command) {
|
|
|
108
110
|
trailing: string[],
|
|
109
111
|
options: { file?: string; wait: boolean; ttl: string },
|
|
110
112
|
) => {
|
|
111
|
-
const { manager, formatOptions } = await getContext(program);
|
|
113
|
+
const { manager, formatOptions, noInteractive } = await getContext(program);
|
|
112
114
|
|
|
113
115
|
let resolved: ResolvedArgs;
|
|
114
116
|
try {
|
|
@@ -252,11 +254,22 @@ export function registerExecCommand(program: Command) {
|
|
|
252
254
|
} else {
|
|
253
255
|
// Standard synchronous tool call
|
|
254
256
|
const spinner = logger.startSpinner(`Executing ${server}/${tool}...`, formatOptions);
|
|
255
|
-
|
|
256
|
-
|
|
257
|
+
let result: unknown;
|
|
258
|
+
try {
|
|
259
|
+
result = await manager.callTool(server, tool, args);
|
|
260
|
+
} finally {
|
|
261
|
+
spinner.stop();
|
|
262
|
+
}
|
|
257
263
|
console.log(formatCallResult(result, formatOptions));
|
|
258
264
|
}
|
|
259
265
|
} catch (err) {
|
|
266
|
+
if (err instanceof UrlElicitationRequiredError) {
|
|
267
|
+
const elicitOptions = { noInteractive, json: !!formatOptions.json };
|
|
268
|
+
for (const elicitation of err.elicitations) {
|
|
269
|
+
await handleUrlElicitation(elicitation, elicitOptions);
|
|
270
|
+
}
|
|
271
|
+
process.exit(1);
|
|
272
|
+
}
|
|
260
273
|
console.error(formatError(String(err), formatOptions));
|
|
261
274
|
process.exit(1);
|
|
262
275
|
} finally {
|
package/src/commands/search.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
|
-
import { DEFAULTS } from "../constants.ts";
|
|
2
|
+
import { DEFAULTS, EMBEDDING_MODEL } from "../constants.ts";
|
|
3
3
|
import { getContext } from "../context.ts";
|
|
4
4
|
import { formatError, formatSearchResults } from "../output/formatter.ts";
|
|
5
5
|
import { logger } from "../output/logger.ts";
|
|
6
6
|
import { search } from "../search/index.ts";
|
|
7
|
-
import { getStaleServers } from "../search/staleness.ts";
|
|
7
|
+
import { getStaleServers, isEmbeddingModelStale } from "../search/staleness.ts";
|
|
8
8
|
|
|
9
9
|
export function registerSearchCommand(program: Command) {
|
|
10
10
|
program
|
|
@@ -17,6 +17,13 @@ export function registerSearchCommand(program: Command) {
|
|
|
17
17
|
const query = terms.join(" ");
|
|
18
18
|
const { config, formatOptions } = await getContext(program);
|
|
19
19
|
|
|
20
|
+
if (isEmbeddingModelStale(config.searchIndex)) {
|
|
21
|
+
logger.warn(
|
|
22
|
+
`Index was built with embedding model "${config.searchIndex.embedding_model}", but mcpx now uses "${EMBEDDING_MODEL.REPO}". Run: mcpx index`,
|
|
23
|
+
);
|
|
24
|
+
config.searchIndex.tools = [];
|
|
25
|
+
}
|
|
26
|
+
|
|
20
27
|
if (config.searchIndex.tools.length === 0) {
|
|
21
28
|
console.error(formatError("No search index found. Run: mcpx index", formatOptions));
|
|
22
29
|
process.exit(1);
|
package/src/constants.ts
CHANGED
|
@@ -26,3 +26,9 @@ export const DEFAULTS = {
|
|
|
26
26
|
UPDATE_CHECK_INTERVAL_MS: 24 * 60 * 60 * 1000,
|
|
27
27
|
UPDATE_CHECK_TIMEOUT_MS: 5_000,
|
|
28
28
|
} as const;
|
|
29
|
+
|
|
30
|
+
/** Hugging Face repo + revision used for the bundled embedding model. */
|
|
31
|
+
export const EMBEDDING_MODEL = {
|
|
32
|
+
REPO: "Xenova/bge-small-en-v1.5",
|
|
33
|
+
REVISION: "main",
|
|
34
|
+
} as const;
|
package/src/context.ts
CHANGED
|
@@ -10,6 +10,7 @@ export interface AppContext {
|
|
|
10
10
|
config: Config;
|
|
11
11
|
manager: ServerManager;
|
|
12
12
|
formatOptions: FormatOptions;
|
|
13
|
+
noInteractive: boolean;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
/** Build the app context from the root commander program options */
|
|
@@ -67,5 +68,5 @@ export async function getContext(program: Command): Promise<AppContext> {
|
|
|
67
68
|
|
|
68
69
|
logger.configure(formatOptions);
|
|
69
70
|
|
|
70
|
-
return { config, manager, formatOptions };
|
|
71
|
+
return { config, manager, formatOptions, noInteractive };
|
|
71
72
|
}
|
package/src/output/formatter.ts
CHANGED
|
@@ -363,16 +363,25 @@ function formatCallResultAsMarkdown(result: unknown): string {
|
|
|
363
363
|
mimeType?: string;
|
|
364
364
|
uri?: string;
|
|
365
365
|
}>;
|
|
366
|
+
structuredContent?: unknown;
|
|
367
|
+
_meta?: unknown;
|
|
366
368
|
isError?: boolean;
|
|
367
369
|
};
|
|
368
370
|
|
|
369
|
-
|
|
371
|
+
const hasContent = Array.isArray(r.content) && r.content.length > 0;
|
|
372
|
+
const hasStructured = r.structuredContent !== undefined && r.structuredContent !== null;
|
|
373
|
+
const hasMeta =
|
|
374
|
+
r._meta !== undefined &&
|
|
375
|
+
r._meta !== null &&
|
|
376
|
+
!(typeof r._meta === "object" && Object.keys(r._meta as object).length === 0);
|
|
377
|
+
|
|
378
|
+
if (!hasContent && !hasStructured && !hasMeta) {
|
|
370
379
|
return renderMarkdownToAnsi(jsonToMarkdown(result));
|
|
371
380
|
}
|
|
372
381
|
|
|
373
382
|
const parts: string[] = [];
|
|
374
383
|
|
|
375
|
-
for (const block of r.content) {
|
|
384
|
+
for (const block of r.content ?? []) {
|
|
376
385
|
switch (block.type) {
|
|
377
386
|
case "text":
|
|
378
387
|
if (block.text !== undefined) {
|
|
@@ -390,15 +399,31 @@ function formatCallResultAsMarkdown(result: unknown): string {
|
|
|
390
399
|
`[image: ${block.mimeType ?? "unknown type"}, ${block.data ? Math.ceil((block.data.length * 3) / 4) : 0} bytes]`,
|
|
391
400
|
);
|
|
392
401
|
break;
|
|
402
|
+
case "audio":
|
|
403
|
+
parts.push(
|
|
404
|
+
`[audio: ${block.mimeType ?? "unknown type"}, ${block.data ? Math.ceil((block.data.length * 3) / 4) : 0} bytes]`,
|
|
405
|
+
);
|
|
406
|
+
break;
|
|
393
407
|
case "resource":
|
|
394
408
|
parts.push(`[resource: ${block.uri ?? "unknown"}]`);
|
|
395
409
|
break;
|
|
410
|
+
case "resource_link":
|
|
411
|
+
parts.push(`[resource_link: ${block.uri ?? "unknown"}]`);
|
|
412
|
+
break;
|
|
396
413
|
default:
|
|
397
|
-
parts.push(`[${block.type}]
|
|
414
|
+
parts.push(`[${block.type}]\n\n\`\`\`json\n${JSON.stringify(block, null, 2)}\n\`\`\``);
|
|
398
415
|
break;
|
|
399
416
|
}
|
|
400
417
|
}
|
|
401
418
|
|
|
419
|
+
if (hasStructured) {
|
|
420
|
+
parts.push(`**Structured Content:**\n\n${jsonToMarkdown(r.structuredContent)}`);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (hasMeta) {
|
|
424
|
+
parts.push(`**Meta:**\n\n${jsonToMarkdown(r._meta)}`);
|
|
425
|
+
}
|
|
426
|
+
|
|
402
427
|
let output = parts.join("\n\n");
|
|
403
428
|
if (r.isError) {
|
|
404
429
|
output = `**error:** ${output}`;
|
package/src/search/indexer.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ServerManager, ToolWithServer } from "../client/manager.ts";
|
|
2
2
|
import type { IndexedTool, SearchIndex } from "../config/schemas.ts";
|
|
3
|
+
import { EMBEDDING_MODEL } from "../constants.ts";
|
|
3
4
|
import { logger } from "../output/logger.ts";
|
|
4
5
|
import { generateEmbedding } from "./semantic.ts";
|
|
5
6
|
|
|
@@ -86,7 +87,7 @@ export async function buildSearchIndex(
|
|
|
86
87
|
return {
|
|
87
88
|
version: 1,
|
|
88
89
|
indexed_at: new Date().toISOString(),
|
|
89
|
-
embedding_model:
|
|
90
|
+
embedding_model: EMBEDDING_MODEL.REPO,
|
|
90
91
|
tools: indexed,
|
|
91
92
|
};
|
|
92
93
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Embed the onnxruntime-web WASM runtime files into the compiled binary
|
|
2
|
+
// (`bun build --compile`) so they survive in a single-binary distribution
|
|
3
|
+
// where the user has no node_modules.
|
|
4
|
+
//
|
|
5
|
+
// This file is loaded **dynamically** by semantic.ts. The relative paths
|
|
6
|
+
// only resolve in the local repo / compiled binary; for npm/bun-installed
|
|
7
|
+
// mcpx the parent directory layout is different (deps are hoisted), the
|
|
8
|
+
// dynamic import throws, and we fall back to letting transformers.js
|
|
9
|
+
// load WASM via its default mechanism — which works fine because in
|
|
10
|
+
// that environment node_modules exists and onnxruntime-web is reachable
|
|
11
|
+
// through normal module resolution.
|
|
12
|
+
|
|
13
|
+
import wasmMjsPath from "../../node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.asyncify.mjs" with {
|
|
14
|
+
type: "file",
|
|
15
|
+
};
|
|
16
|
+
import wasmBinPath from "../../node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.asyncify.wasm" with {
|
|
17
|
+
type: "file",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export { wasmBinPath, wasmMjsPath };
|
package/src/search/semantic.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
1
3
|
import type { IndexedTool } from "../config/schemas.ts";
|
|
2
|
-
import { DEFAULTS } from "../constants.ts";
|
|
4
|
+
import { DEFAULTS, EMBEDDING_MODEL } from "../constants.ts";
|
|
5
|
+
import { logger } from "../output/logger.ts";
|
|
3
6
|
import type { BaseMatch } from "./types.ts";
|
|
4
7
|
|
|
5
8
|
export type SemanticMatch = BaseMatch;
|
|
@@ -11,9 +14,51 @@ let pipelineInstance: ((text: string) => Promise<Float32Array>) | null = null;
|
|
|
11
14
|
async function getEmbedder(): Promise<(text: string) => Promise<Float32Array>> {
|
|
12
15
|
if (pipelineInstance) return pipelineInstance;
|
|
13
16
|
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
const transformers = await import("@huggingface/transformers");
|
|
18
|
+
|
|
19
|
+
// transformers.js is patched (see patches/@huggingface%2Ftransformers@4.2.0.patch,
|
|
20
|
+
// applied by `bun run scripts/apply-transformers-patch.sh` during prebuild) to
|
|
21
|
+
// force the WASM backend instead of onnxruntime-node — the native bindings can't
|
|
22
|
+
// be bundled into the Bun --compile single binary.
|
|
23
|
+
const ortWasm = transformers.env.backends.onnx?.wasm;
|
|
24
|
+
if (ortWasm) {
|
|
25
|
+
ortWasm.numThreads = 1;
|
|
26
|
+
ortWasm.proxy = false;
|
|
27
|
+
|
|
28
|
+
// For the compiled binary, embed the onnxruntime-web .wasm/.mjs files via
|
|
29
|
+
// Bun's `with { type: "file" }` and point the loader at them. The dynamic
|
|
30
|
+
// import is wrapped in a try because the asset paths only resolve in the
|
|
31
|
+
// local repo / compiled binary; for npm/bun-installed mcpx the deps are
|
|
32
|
+
// hoisted to a different layout, the import throws, and transformers.js
|
|
33
|
+
// loads WASM via its default mechanism (which works because node_modules
|
|
34
|
+
// is reachable in that environment).
|
|
35
|
+
try {
|
|
36
|
+
const { wasmMjsPath, wasmBinPath } = await import("./onnx-wasm-paths.ts");
|
|
37
|
+
const toFileUrl = (p: string) => (p.startsWith("file://") ? p : `file://${p}`);
|
|
38
|
+
ortWasm.wasmPaths = {
|
|
39
|
+
mjs: toFileUrl(wasmMjsPath),
|
|
40
|
+
wasm: toFileUrl(wasmBinPath),
|
|
41
|
+
};
|
|
42
|
+
} catch (err) {
|
|
43
|
+
logger.debug(`Bundled onnxruntime-web assets not found, using default loader: ${err}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Inside a `bun build --compile` binary, `import.meta.url` resolves under the
|
|
48
|
+
// read-only `/$bunfs` virtual filesystem, so transformers' default cacheDir
|
|
49
|
+
// becomes unwritable. Redirect cache to the user's home so model downloads
|
|
50
|
+
// (and any future cached files) land somewhere we can write to.
|
|
51
|
+
const userCacheDir = join(homedir(), ".cache", "mcpx", "transformers");
|
|
52
|
+
transformers.env.cacheDir = userCacheDir;
|
|
53
|
+
transformers.env.localModelPath = join(userCacheDir, "models");
|
|
54
|
+
|
|
55
|
+
// WASM device defaults to q8 quantization, which gives near-identical
|
|
56
|
+
// embedding quality at ~25% the model size (≈22 MB vs ≈86 MB for fp32).
|
|
57
|
+
// Both CI and `bun run build` apply the transformers patch first, so
|
|
58
|
+
// wasm is the only supported device in this codepath.
|
|
59
|
+
const extractor = await transformers.pipeline("feature-extraction", EMBEDDING_MODEL.REPO, {
|
|
60
|
+
device: "wasm",
|
|
61
|
+
dtype: "q8",
|
|
17
62
|
});
|
|
18
63
|
|
|
19
64
|
pipelineInstance = async (text: string): Promise<Float32Array> => {
|
package/src/search/staleness.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { SearchIndex, ServersFile } from "../config/schemas.ts";
|
|
2
|
+
import { EMBEDDING_MODEL } from "../constants.ts";
|
|
2
3
|
|
|
3
4
|
/** Return server names that appear in the index but not in the current config */
|
|
4
5
|
export function getStaleServers(index: SearchIndex, servers: ServersFile): string[] {
|
|
@@ -6,3 +7,8 @@ export function getStaleServers(index: SearchIndex, servers: ServersFile): strin
|
|
|
6
7
|
const indexed = new Set(index.tools.map((t) => t.server));
|
|
7
8
|
return [...indexed].filter((s) => !configured.has(s));
|
|
8
9
|
}
|
|
10
|
+
|
|
11
|
+
/** Return true if the index was built with a different embedding model than the one we'd use now. */
|
|
12
|
+
export function isEmbeddingModelStale(index: SearchIndex): boolean {
|
|
13
|
+
return index.tools.length > 0 && index.embedding_model !== EMBEDDING_MODEL.REPO;
|
|
14
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Type declarations for Bun's `import ... with { type: "file" }` asset embedding.
|
|
2
|
+
// TS doesn't natively know how to resolve `.wasm` or `.mjs` modules, so we
|
|
3
|
+
// declare them as default-exporting strings (Bun returns the embedded file's
|
|
4
|
+
// runtime path).
|
|
5
|
+
|
|
6
|
+
declare module "*.wasm" {
|
|
7
|
+
const path: string;
|
|
8
|
+
export default path;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
declare module "*.mjs" {
|
|
12
|
+
const path: string;
|
|
13
|
+
export default path;
|
|
14
|
+
}
|