@evantahler/mcpx 0.15.3 → 0.15.8

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.
@@ -1,46 +1,42 @@
1
1
  import type { Command } from "commander";
2
- import { getContext } from "../context.ts";
3
2
  import {
4
3
  formatResourceList,
5
4
  formatServerResources,
6
5
  formatResourceContents,
7
6
  formatError,
8
7
  } from "../output/formatter.ts";
9
- import { logger } from "../output/logger.ts";
8
+ import { withCommand } from "./with-command.ts";
10
9
 
11
10
  export function registerResourceCommand(program: Command) {
12
11
  program
13
12
  .command("resource [server] [uri]")
14
13
  .description("list resources for a server, or read a specific resource")
15
- .action(async (server: string | undefined, uri: string | undefined) => {
16
- const { manager, formatOptions } = await getContext(program);
17
- const spinner = logger.startSpinner(
18
- server ? `Connecting to ${server}...` : "Connecting to servers...",
19
- formatOptions,
20
- );
21
- try {
22
- if (server && uri) {
23
- const result = await manager.readResource(server, uri);
24
- spinner.stop();
25
- console.log(formatResourceContents(server, uri, result, formatOptions));
26
- } else if (server) {
27
- const resources = await manager.listResources(server);
28
- spinner.stop();
29
- console.log(formatServerResources(server, resources, formatOptions));
30
- } else {
31
- const { resources, errors } = await manager.getAllResources();
32
- spinner.stop();
33
- console.log(formatResourceList(resources, formatOptions));
34
- for (const err of errors) {
35
- console.error(formatError(`${err.server}: ${err.message}`, formatOptions));
14
+ .action(
15
+ withCommand(
16
+ program,
17
+ { spinnerText: "Connecting to servers..." },
18
+ async ({ manager, formatOptions, spinner }, server?: string, uri?: string) => {
19
+ if (server) {
20
+ spinner.update(`Connecting to ${server}...`);
36
21
  }
37
- }
38
- } catch (err) {
39
- spinner.error("Failed");
40
- console.error(formatError(String(err), formatOptions));
41
- process.exit(1);
42
- } finally {
43
- await manager.close();
44
- }
45
- });
22
+
23
+ if (server && uri) {
24
+ const result = await manager.readResource(server, uri);
25
+ spinner.stop();
26
+ console.log(formatResourceContents(server, uri, result, formatOptions));
27
+ } else if (server) {
28
+ const resources = await manager.listResources(server);
29
+ spinner.stop();
30
+ console.log(formatServerResources(server, resources, formatOptions));
31
+ } else {
32
+ const { resources, errors } = await manager.getAllResources();
33
+ spinner.stop();
34
+ console.log(formatResourceList(resources, formatOptions));
35
+ for (const err of errors) {
36
+ console.error(formatError(`${err.server}: ${err.message}`, formatOptions));
37
+ }
38
+ }
39
+ },
40
+ ),
41
+ );
46
42
  }
@@ -4,6 +4,7 @@ import { search } from "../search/index.ts";
4
4
  import { getStaleServers } from "../search/staleness.ts";
5
5
  import { formatError, formatSearchResults } from "../output/formatter.ts";
6
6
  import { logger } from "../output/logger.ts";
7
+ import { DEFAULTS } from "../constants.ts";
7
8
 
8
9
  export function registerSearchCommand(program: Command) {
9
10
  program
@@ -11,7 +12,7 @@ export function registerSearchCommand(program: Command) {
11
12
  .description("search tools by keyword and/or semantic similarity")
12
13
  .option("-k, --keyword", "keyword/glob search only")
13
14
  .option("-q, --query", "semantic search only")
14
- .option("-n, --limit <number>", "max results to return", "10")
15
+ .option("-n, --limit <number>", "max results to return", String(DEFAULTS.SEARCH_TOP_K))
15
16
  .action(
16
17
  async (terms: string[], options: { keyword?: boolean; query?: boolean; limit: string }) => {
17
18
  const query = terms.join(" ");
@@ -1,16 +1,15 @@
1
1
  import { cyan, dim, green, yellow } from "ansis";
2
2
  import type { Command } from "commander";
3
- import { getContext } from "../context.ts";
4
3
  import { isStdioServer, isHttpServer } from "../config/schemas.ts";
5
- import { formatError, isInteractive } from "../output/formatter.ts";
4
+ import { isInteractive } from "../output/formatter.ts";
5
+ import { withCommand } from "./with-command.ts";
6
6
 
7
7
  export function registerServersCommand(program: Command) {
8
8
  program
9
9
  .command("servers")
10
10
  .description("List configured MCP servers")
11
- .action(async () => {
12
- const { manager, config, formatOptions } = await getContext(program);
13
- try {
11
+ .action(
12
+ withCommand(program, {}, async ({ config, formatOptions }) => {
14
13
  const servers = Object.entries(config.servers.mcpServers);
15
14
 
16
15
  if (!isInteractive(formatOptions)) {
@@ -56,11 +55,6 @@ export function registerServersCommand(program: Command) {
56
55
  : dim(cfg.url);
57
56
  console.log(`${n} ${type} ${detail}`);
58
57
  }
59
- } catch (err) {
60
- console.error(formatError(String(err), formatOptions));
61
- process.exit(1);
62
- } finally {
63
- await manager.close();
64
- }
65
- });
58
+ }),
59
+ );
66
60
  }
@@ -1,82 +1,66 @@
1
1
  import type { Command } from "commander";
2
- import { getContext } from "../context.ts";
3
2
  import {
4
3
  formatCallResult,
5
4
  formatError,
6
5
  formatTaskStatus,
7
6
  formatTasksList,
8
7
  } from "../output/formatter.ts";
9
- import { logger } from "../output/logger.ts";
8
+ import { withCommand } from "./with-command.ts";
10
9
 
11
10
  export function registerTaskCommand(program: Command) {
12
11
  program
13
12
  .command("task <action> <server> [taskId]")
14
13
  .description("manage tasks (actions: get, list, result, cancel)")
15
- .action(async (action: string, server: string, taskId: string | undefined) => {
16
- const { manager, formatOptions } = await getContext(program);
17
- const spinner = logger.startSpinner(`Connecting to ${server}...`, formatOptions);
14
+ .action(
15
+ withCommand(
16
+ program,
17
+ { spinnerText: "Connecting..." },
18
+ async (
19
+ { manager, formatOptions, spinner },
20
+ action: string,
21
+ server: string,
22
+ taskId?: string,
23
+ ) => {
24
+ spinner.update(`Connecting to ${server}...`);
18
25
 
19
- try {
20
- switch (action) {
21
- case "list": {
22
- const result = await manager.listTasks(server);
23
- spinner.stop();
24
- console.log(formatTasksList(result.tasks, result.nextCursor, formatOptions));
25
- break;
26
- }
27
- case "get": {
28
- if (!taskId) {
29
- spinner.error("Missing task ID");
30
- console.error(formatError("Usage: mcpx task get <server> <taskId>", formatOptions));
31
- process.exit(1);
26
+ switch (action) {
27
+ case "list": {
28
+ const result = await manager.listTasks(server);
29
+ spinner.stop();
30
+ console.log(formatTasksList(result.tasks, result.nextCursor, formatOptions));
31
+ break;
32
32
  }
33
- const task = await manager.getTask(server, taskId);
34
- spinner.stop();
35
- console.log(formatTaskStatus(task, formatOptions));
36
- break;
37
- }
38
- case "result": {
39
- if (!taskId) {
40
- spinner.error("Missing task ID");
41
- console.error(
42
- formatError("Usage: mcpx task result <server> <taskId>", formatOptions),
43
- );
44
- process.exit(1);
33
+ case "get": {
34
+ if (!taskId) {
35
+ throw new Error("Usage: mcpx task get <server> <taskId>");
36
+ }
37
+ const task = await manager.getTask(server, taskId);
38
+ spinner.stop();
39
+ console.log(formatTaskStatus(task, formatOptions));
40
+ break;
45
41
  }
46
- const result = await manager.getTaskResult(server, taskId);
47
- spinner.stop();
48
- console.log(formatCallResult(result, formatOptions));
49
- break;
50
- }
51
- case "cancel": {
52
- if (!taskId) {
53
- spinner.error("Missing task ID");
54
- console.error(
55
- formatError("Usage: mcpx task cancel <server> <taskId>", formatOptions),
56
- );
57
- process.exit(1);
42
+ case "result": {
43
+ if (!taskId) {
44
+ throw new Error("Usage: mcpx task result <server> <taskId>");
45
+ }
46
+ const result = await manager.getTaskResult(server, taskId);
47
+ spinner.stop();
48
+ console.log(formatCallResult(result, formatOptions));
49
+ break;
50
+ }
51
+ case "cancel": {
52
+ if (!taskId) {
53
+ throw new Error("Usage: mcpx task cancel <server> <taskId>");
54
+ }
55
+ const cancelled = await manager.cancelTask(server, taskId);
56
+ spinner.stop();
57
+ console.log(formatTaskStatus(cancelled, formatOptions));
58
+ break;
58
59
  }
59
- const cancelled = await manager.cancelTask(server, taskId);
60
- spinner.stop();
61
- console.log(formatTaskStatus(cancelled, formatOptions));
62
- break;
60
+ default:
61
+ throw new Error(`Unknown task action: "${action}". Use: get, list, result, cancel`);
63
62
  }
64
- default:
65
- spinner.error("Unknown action");
66
- console.error(
67
- formatError(
68
- `Unknown task action: "${action}". Use: get, list, result, cancel`,
69
- formatOptions,
70
- ),
71
- );
72
- process.exit(1);
73
- }
74
- } catch (err) {
75
- spinner.error("Failed");
76
- console.error(formatError(String(err), formatOptions));
77
- process.exit(1);
78
- } finally {
79
- await manager.close();
80
- }
81
- });
63
+ },
64
+ ),
65
+ );
82
66
  }
@@ -0,0 +1,59 @@
1
+ import type { Command } from "commander";
2
+ import { getContext, type AppContext } from "../context.ts";
3
+ import { formatError } from "../output/formatter.ts";
4
+ import { logger, type Spinner } from "../output/logger.ts";
5
+
6
+ export interface CommandContext extends AppContext {
7
+ spinner: Spinner;
8
+ }
9
+
10
+ interface WithCommandOptions {
11
+ /** Spinner text shown during execution. If omitted, no spinner is started. */
12
+ spinnerText?: string;
13
+ /** Error message for spinner.error(). Defaults to "Failed". */
14
+ errorLabel?: string;
15
+ }
16
+
17
+ const noopSpinner: Spinner = {
18
+ update() {},
19
+ success() {},
20
+ error() {},
21
+ stop() {},
22
+ };
23
+
24
+ /**
25
+ * Wrap a command action with standard context setup, spinner, error handling,
26
+ * and manager cleanup.
27
+ *
28
+ * The handler receives { config, manager, formatOptions, spinner } and should:
29
+ * 1. Do async work
30
+ * 2. Call spinner.stop() when done
31
+ * 3. Output results via console.log()
32
+ *
33
+ * Errors are caught, formatted, and cause process.exit(1).
34
+ * manager.close() is always called in finally.
35
+ */
36
+ export function withCommand<TArgs extends unknown[]>(
37
+ program: Command,
38
+ options: WithCommandOptions,
39
+ handler: (ctx: CommandContext, ...args: TArgs) => Promise<void>,
40
+ ): (...args: TArgs) => Promise<void> {
41
+ return async (...args: TArgs) => {
42
+ const appCtx = await getContext(program);
43
+ const { manager, formatOptions } = appCtx;
44
+
45
+ const spinner = options.spinnerText
46
+ ? logger.startSpinner(options.spinnerText, formatOptions)
47
+ : noopSpinner;
48
+
49
+ try {
50
+ await handler({ ...appCtx, spinner }, ...args);
51
+ } catch (err) {
52
+ spinner.error(options.errorLabel ?? "Failed");
53
+ console.error(formatError(String(err), formatOptions));
54
+ process.exit(1);
55
+ } finally {
56
+ await manager.close();
57
+ }
58
+ };
59
+ }
package/src/config/env.ts CHANGED
@@ -1,8 +1,10 @@
1
+ import { ENV } from "../constants.ts";
2
+
1
3
  const ENV_VAR_PATTERN = /\$\{([^}]+)\}/g;
2
4
 
3
5
  /** Whether to throw on missing env vars (default: true) */
4
6
  function isStrictEnv(): boolean {
5
- return process.env.MCP_STRICT_ENV !== "false";
7
+ return process.env[ENV.STRICT_ENV] !== "false";
6
8
  }
7
9
 
8
10
  /** Replace ${VAR_NAME} in a string with the corresponding env var value */
@@ -12,7 +14,7 @@ export function interpolateEnvString(value: string): string {
12
14
  if (envValue === undefined) {
13
15
  if (isStrictEnv()) {
14
16
  throw new Error(
15
- `Environment variable "${varName}" is not set (set MCP_STRICT_ENV=false to warn instead)`,
17
+ `Environment variable "${varName}" is not set (set ${ENV.STRICT_ENV}=false to warn instead)`,
16
18
  );
17
19
  }
18
20
  console.warn(`Warning: environment variable "${varName}" is not set`);
@@ -1,6 +1,7 @@
1
1
  import { join, resolve } from "path";
2
2
  import { homedir } from "os";
3
3
  import { interpolateEnv } from "./env.ts";
4
+ import { ENV } from "../constants.ts";
4
5
  import {
5
6
  type Config,
6
7
  type ServersFile,
@@ -36,7 +37,7 @@ function resolveConfigDir(configFlag?: string): string {
36
37
  if (configFlag) return resolve(configFlag);
37
38
 
38
39
  // 2. MCP_CONFIG_PATH env var
39
- const envPath = process.env.MCP_CONFIG_PATH;
40
+ const envPath = process.env[ENV.CONFIG_PATH];
40
41
  if (envPath) return resolve(envPath);
41
42
 
42
43
  // 3. ./servers.json exists in cwd → use cwd
@@ -107,9 +108,14 @@ export async function loadConfig(options: LoadConfigOptions = {}): Promise<Confi
107
108
  return { configDir, servers, auth, searchIndex };
108
109
  }
109
110
 
111
+ /** Write a JSON file to the config directory */
112
+ async function saveJsonFile(configDir: string, filename: string, data: unknown): Promise<void> {
113
+ await Bun.write(join(configDir, filename), JSON.stringify(data, null, 2) + "\n");
114
+ }
115
+
110
116
  /** Save auth.json to the config directory */
111
117
  export async function saveAuth(configDir: string, auth: AuthFile): Promise<void> {
112
- await Bun.write(join(configDir, "auth.json"), JSON.stringify(auth, null, 2) + "\n");
118
+ return saveJsonFile(configDir, "auth.json", auth);
113
119
  }
114
120
 
115
121
  /** Load search.json from the config directory */
@@ -120,12 +126,12 @@ export async function loadSearchIndex(configDir: string): Promise<SearchIndex> {
120
126
 
121
127
  /** Save search.json to the config directory */
122
128
  export async function saveSearchIndex(configDir: string, index: SearchIndex): Promise<void> {
123
- await Bun.write(join(configDir, "search.json"), JSON.stringify(index, null, 2) + "\n");
129
+ return saveJsonFile(configDir, "search.json", index);
124
130
  }
125
131
 
126
132
  /** Save servers.json to the config directory */
127
133
  export async function saveServers(configDir: string, servers: ServersFile): Promise<void> {
128
- await Bun.write(join(configDir, "servers.json"), JSON.stringify(servers, null, 2) + "\n");
134
+ return saveJsonFile(configDir, "servers.json", servers);
129
135
  }
130
136
 
131
137
  /** Load servers.json without env interpolation (preserves ${VAR} placeholders) */
@@ -0,0 +1,19 @@
1
+ /** Environment variable names used by mcpx */
2
+ export const ENV = {
3
+ DEBUG: "MCP_DEBUG",
4
+ CONCURRENCY: "MCP_CONCURRENCY",
5
+ TIMEOUT: "MCP_TIMEOUT",
6
+ MAX_RETRIES: "MCP_MAX_RETRIES",
7
+ STRICT_ENV: "MCP_STRICT_ENV",
8
+ CONFIG_PATH: "MCP_CONFIG_PATH",
9
+ } as const;
10
+
11
+ /** Default values for configurable options */
12
+ export const DEFAULTS = {
13
+ CONCURRENCY: 5,
14
+ TIMEOUT_SECONDS: 1800,
15
+ MAX_RETRIES: 3,
16
+ TASK_TTL_MS: 60_000,
17
+ SEARCH_TOP_K: 10,
18
+ LOG_LEVEL: "warning",
19
+ } as const;
package/src/context.ts CHANGED
@@ -4,6 +4,7 @@ import { ServerManager } from "./client/manager.ts";
4
4
  import type { Config } from "./config/schemas.ts";
5
5
  import type { FormatOptions } from "./output/formatter.ts";
6
6
  import { logger } from "./output/logger.ts";
7
+ import { ENV, DEFAULTS } from "./constants.ts";
7
8
 
8
9
  export interface AppContext {
9
10
  config: Config;
@@ -21,14 +22,14 @@ export async function getContext(program: Command): Promise<AppContext> {
21
22
 
22
23
  const verbose = !!(
23
24
  (opts.verbose as boolean | undefined) ||
24
- process.env.MCP_DEBUG === "1" ||
25
- process.env.MCP_DEBUG === "true"
25
+ process.env[ENV.DEBUG] === "1" ||
26
+ process.env[ENV.DEBUG] === "true"
26
27
  );
27
28
  const showSecrets = !!(opts.showSecrets as boolean | undefined);
28
- const concurrency = Number(process.env.MCP_CONCURRENCY ?? 5);
29
- const timeout = Number(process.env.MCP_TIMEOUT ?? 1800) * 1000;
30
- const maxRetries = Number(process.env.MCP_MAX_RETRIES ?? 3);
31
- const logLevel = (opts.logLevel as string | undefined) ?? "warning";
29
+ const concurrency = Number(process.env[ENV.CONCURRENCY] ?? DEFAULTS.CONCURRENCY);
30
+ const timeout = Number(process.env[ENV.TIMEOUT] ?? DEFAULTS.TIMEOUT_SECONDS) * 1000;
31
+ const maxRetries = Number(process.env[ENV.MAX_RETRIES] ?? DEFAULTS.MAX_RETRIES);
32
+ const logLevel = (opts.logLevel as string | undefined) ?? DEFAULTS.LOG_LEVEL;
32
33
 
33
34
  const json = !!(opts.json as boolean | undefined);
34
35
  // Commander's --no-interactive sets opts.interactive = false (default true)
@@ -0,0 +1,18 @@
1
+ import type { FormatOptions } from "./formatter.ts";
2
+ import { isInteractive } from "./formatter.ts";
3
+
4
+ /**
5
+ * Format output with automatic JSON/interactive branching.
6
+ * In non-interactive mode, returns JSON.stringify of jsonData.
7
+ * In interactive mode, calls interactiveFn() for formatted output.
8
+ */
9
+ export function formatOutput(
10
+ jsonData: unknown,
11
+ interactiveFn: () => string,
12
+ options: FormatOptions,
13
+ ): string {
14
+ if (!isInteractive(options)) {
15
+ return JSON.stringify(jsonData, null, 2);
16
+ }
17
+ return interactiveFn();
18
+ }
@@ -0,0 +1,63 @@
1
+ import ansis from "ansis";
2
+ import { dim } from "ansis";
3
+ import { wrapDescription } from "./formatter.ts";
4
+
5
+ export interface Column<T> {
6
+ value: (item: T) => string;
7
+ style: (text: string) => string;
8
+ }
9
+
10
+ export interface TableOptions<T> {
11
+ columns: Column<T>[];
12
+ description?: (item: T) => string | undefined;
13
+ separator?: string;
14
+ emptyMessage?: string;
15
+ }
16
+
17
+ /** Measure visible length of a string (excluding ANSI escape codes) */
18
+ function visibleLength(s: string): number {
19
+ return ansis.strip(s).length;
20
+ }
21
+
22
+ /** Get terminal width, or undefined if not a TTY */
23
+ function getTerminalWidth(): number | undefined {
24
+ if (process.stdout.isTTY) return Math.max(process.stdout.columns - 1, 40);
25
+ return undefined;
26
+ }
27
+
28
+ /**
29
+ * Format a list of items as an aligned table with optional description wrapping.
30
+ */
31
+ export function formatTable<T>(items: T[], options: TableOptions<T>): string {
32
+ if (items.length === 0) {
33
+ return dim(options.emptyMessage ?? "No items found");
34
+ }
35
+
36
+ const sep = options.separator ?? " ";
37
+ const termWidth = getTerminalWidth();
38
+
39
+ // Calculate max width for each column
40
+ const maxWidths = options.columns.map((col) =>
41
+ Math.max(...items.map((item) => col.value(item).length)),
42
+ );
43
+
44
+ return items
45
+ .map((item) => {
46
+ const parts = options.columns.map((col, i) => {
47
+ const raw = col.value(item);
48
+ const pad = maxWidths[i]! - raw.length;
49
+ return col.style(raw) + " ".repeat(Math.max(0, pad));
50
+ });
51
+ const prefix = parts.join(sep);
52
+
53
+ const desc = options.description?.(item);
54
+ if (desc) {
55
+ const pw = visibleLength(prefix) + sep.length;
56
+ const formatted = termWidth != null ? wrapDescription(desc, pw, termWidth) : dim(desc);
57
+ return `${prefix}${sep}${formatted}`;
58
+ }
59
+
60
+ return prefix;
61
+ })
62
+ .join("\n");
63
+ }