@evantahler/mcpx 0.21.0 → 0.21.2

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.21.0",
3
+ "version": "0.21.2",
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
  },
@@ -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,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;
@@ -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
+ }