@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.
@@ -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 <name> --url <url>` | Add an HTTP MCP server |
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 |
@@ -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 <name> --url <url>` | Add an HTTP MCP server |
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 <name> --url <url>` | Add an HTTP MCP server to your config |
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/all-MiniLM-L6-v2",
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/all-MiniLM-L6-v2` (~23MB ONNX model, downloaded on first run). No API keys needed.
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/all-MiniLM-L6-v2) |
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.20.1",
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
- const rl = createInterface({ input: process.stdin, output: process.stderr });
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
- logger.writeRaw(`\n${ansis.bold("Server requests URL interaction:")}\n`);
329
- logger.writeRaw(` ${params.message}\n`);
330
- logger.writeRaw(` ${ansis.yellow("Domain:")} ${domain}\n`);
331
- logger.writeRaw(` ${ansis.yellow("URL:")} ${params.url}\n`);
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
- const answer = await question(` Open in browser? [y/n]: `);
334
- if (["y", "yes"].includes(answer.toLowerCase())) {
335
- await openBrowser(params.url);
336
- return { action: "accept" };
337
- }
338
- return { action: "decline" };
339
- } finally {
340
- rl.close();
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
  // ---------------------------------------------------------------------------
@@ -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 {
@@ -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 <name> [passthroughArgs...]")
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[] {
@@ -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
- const result = await manager.callTool(server, tool, args);
256
- spinner.stop();
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 {
@@ -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
  }
@@ -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
- if (!r.content || !Array.isArray(r.content) || r.content.length === 0) {
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}`;
@@ -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: "Xenova/all-MiniLM-L6-v2",
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 };
@@ -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 { pipeline } = await import("@huggingface/transformers");
15
- const extractor = await pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2", {
16
- dtype: "fp32",
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> => {
@@ -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
+ }