@evantahler/mcpx 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evantahler/mcpx",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "A command-line interface for MCP servers. curl for MCP.",
5
5
  "type": "module",
6
6
  "exports": {
package/src/cli.ts CHANGED
@@ -21,6 +21,7 @@ import { registerServersCommand } from "./commands/servers.ts";
21
21
  import { registerSkillCommand } from "./commands/skill.ts";
22
22
  import { registerTaskCommand } from "./commands/task.ts";
23
23
  import { registerUpgradeCommand } from "./commands/upgrade.ts";
24
+ import { logger } from "./output/logger.ts";
24
25
  import { maybeCheckForUpdate } from "./update/background.ts";
25
26
 
26
27
  program
@@ -96,5 +97,5 @@ program.parse();
96
97
  // Print update notice after command output completes
97
98
  process.on("beforeExit", async () => {
98
99
  const notice = await updateNotice;
99
- if (notice) process.stderr.write(notice);
100
+ if (notice) logger.writeRaw(notice);
100
101
  });
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { logger } from "../output/logger.ts";
2
3
 
3
4
  /**
4
5
  * Open a URL in the default browser (macOS/Windows/Linux).
@@ -12,11 +13,11 @@ export function openBrowser(url: string): Promise<void> {
12
13
  try {
13
14
  const parsed = new URL(url);
14
15
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
15
- process.stderr.write(`Refusing to open non-HTTP URL: ${url}\n`);
16
+ logger.error(`Refusing to open non-HTTP URL: ${url}`);
16
17
  return Promise.resolve();
17
18
  }
18
19
  } catch {
19
- process.stderr.write(`Invalid URL: ${url}\n`);
20
+ logger.error(`Invalid URL: ${url}`);
20
21
  return Promise.resolve();
21
22
  }
22
23
 
@@ -37,7 +38,7 @@ export function openBrowser(url: string): Promise<void> {
37
38
  return new Promise((resolve) => {
38
39
  execFile(cmd, args, (err) => {
39
40
  if (err) {
40
- process.stderr.write(`Could not open browser. Please visit:\n ${url}\n`);
41
+ logger.warn(`Could not open browser. Please visit:\n ${url}`);
41
42
  }
42
43
  resolve();
43
44
  });
@@ -7,6 +7,7 @@ import type {
7
7
  PrimitiveSchemaDefinition,
8
8
  } from "@modelcontextprotocol/sdk/types.js";
9
9
  import ansis from "ansis";
10
+ import { logger } from "../output/logger.ts";
10
11
  import { validateElicitationResponse } from "../validation/schema.ts";
11
12
  import { openBrowser } from "./browser.ts";
12
13
 
@@ -74,7 +75,7 @@ async function handleFormInteractive(params: ElicitRequestFormParams): Promise<E
74
75
  const question = (prompt: string): Promise<string> => new Promise((resolve) => rl.question(prompt, resolve));
75
76
 
76
77
  try {
77
- process.stderr.write(`\n${ansis.bold("Server requests input:")} ${params.message}\n`);
78
+ logger.writeRaw(`\n${ansis.bold("Server requests input:")} ${params.message}\n`);
78
79
 
79
80
  const schema = params.requestedSchema;
80
81
  const properties = schema.properties ?? {};
@@ -86,7 +87,7 @@ async function handleFormInteractive(params: ElicitRequestFormParams): Promise<E
86
87
  const value = await promptField(key, fieldSchema, isRequired, question);
87
88
  if (value === undefined) {
88
89
  if (isRequired) {
89
- process.stderr.write(ansis.yellow("Cancelled.\n"));
90
+ logger.writeRaw(ansis.yellow("Cancelled.\n"));
90
91
  return { action: "cancel" };
91
92
  }
92
93
  continue;
@@ -98,7 +99,7 @@ async function handleFormInteractive(params: ElicitRequestFormParams): Promise<E
98
99
  const validation = validateElicitationResponse(schema as unknown as Record<string, unknown>, content);
99
100
  if (!validation.valid) {
100
101
  const msgs = validation.errors.map((e) => ` ${e.path}: ${e.message}`).join("\n");
101
- process.stderr.write(ansis.red(`Validation failed:\n${msgs}\n`));
102
+ logger.writeRaw(ansis.red(`Validation failed:\n${msgs}\n`));
102
103
  return { action: "cancel" };
103
104
  }
104
105
 
@@ -121,7 +122,7 @@ async function promptField(
121
122
 
122
123
  // Show description if present
123
124
  if (desc) {
124
- process.stderr.write(ansis.dim(` ${desc}\n`));
125
+ logger.writeRaw(ansis.dim(` ${desc}\n`));
125
126
  }
126
127
 
127
128
  // Enum (single-select)
@@ -178,7 +179,7 @@ async function promptNumber(
178
179
  if (!answer) return undefined;
179
180
  const num = Number(answer);
180
181
  if (Number.isNaN(num)) {
181
- process.stderr.write(ansis.red(` Invalid number: ${answer}\n`));
182
+ logger.writeRaw(ansis.red(` Invalid number: ${answer}\n`));
182
183
  return undefined;
183
184
  }
184
185
  return num;
@@ -206,10 +207,10 @@ async function promptEnum(
206
207
  ): Promise<string | undefined> {
207
208
  const values = (schema as { enum: string[] }).enum;
208
209
  const def = (schema as { default?: string }).default;
209
- process.stderr.write(` ${marker}${label}:\n`);
210
+ logger.writeRaw(` ${marker}${label}:\n`);
210
211
  values.forEach((v, i) => {
211
212
  const defMark = v === def ? ansis.dim(" (default)") : "";
212
- process.stderr.write(` [${i + 1}] ${v}${defMark}\n`);
213
+ logger.writeRaw(` [${i + 1}] ${v}${defMark}\n`);
213
214
  });
214
215
  const answer = await question(" > ");
215
216
  if (!answer && def !== undefined) return def;
@@ -228,10 +229,10 @@ async function promptOneOfEnum(
228
229
  ): Promise<string | undefined> {
229
230
  const options = (schema as { oneOf: { const: string; title: string }[] }).oneOf;
230
231
  const def = (schema as { default?: string }).default;
231
- process.stderr.write(` ${marker}${label}:\n`);
232
+ logger.writeRaw(` ${marker}${label}:\n`);
232
233
  options.forEach((opt, i) => {
233
234
  const defMark = opt.const === def ? ansis.dim(" (default)") : "";
234
- process.stderr.write(` [${i + 1}] ${opt.title} (${opt.const})${defMark}\n`);
235
+ logger.writeRaw(` [${i + 1}] ${opt.title} (${opt.const})${defMark}\n`);
235
236
  });
236
237
  const answer = await question(" > ");
237
238
  if (!answer && def !== undefined) return def;
@@ -264,10 +265,10 @@ async function promptMultiSelect(
264
265
  return undefined;
265
266
  }
266
267
 
267
- process.stderr.write(` ${marker}${label} (select multiple, comma-separated):\n`);
268
+ logger.writeRaw(` ${marker}${label} (select multiple, comma-separated):\n`);
268
269
  values.forEach((v, i) => {
269
270
  const display = titles ? `${titles[i]} (${v})` : v;
270
- process.stderr.write(` [${i + 1}] ${display}\n`);
271
+ logger.writeRaw(` [${i + 1}] ${display}\n`);
271
272
  });
272
273
  const answer = await question(" > ");
273
274
  if (!answer && def !== undefined) return def;
@@ -282,16 +283,35 @@ async function promptMultiSelect(
282
283
  // URL mode
283
284
  // ---------------------------------------------------------------------------
284
285
 
285
- async function handleUrlElicitation(
286
+ export async function handleUrlElicitation(
286
287
  params: ElicitRequestURLParams,
287
288
  options: ElicitationOptions,
288
289
  ): Promise<ElicitResult> {
289
290
  if (options.json) {
290
291
  return handleUrlJson(params);
291
292
  }
293
+ if (options.noInteractive) {
294
+ printUrlElicitation(params);
295
+ return { action: "decline" };
296
+ }
292
297
  return handleUrlInteractive(params);
293
298
  }
294
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
+
295
315
  async function handleUrlJson(params: ElicitRequestURLParams): Promise<ElicitResult> {
296
316
  const request = {
297
317
  type: "elicitation",
@@ -312,32 +332,70 @@ async function handleUrlJson(params: ElicitRequestURLParams): Promise<ElicitResu
312
332
  }
313
333
 
314
334
  async function handleUrlInteractive(params: ElicitRequestURLParams): Promise<ElicitResult> {
315
- const rl = createInterface({ input: process.stdin, output: process.stderr });
316
- const question = (prompt: string): Promise<string> => new Promise((resolve) => rl.question(prompt, resolve));
317
-
318
- try {
319
- const domain = (() => {
320
- try {
321
- return new URL(params.url).hostname;
322
- } catch {
323
- return "unknown";
324
- }
325
- })();
335
+ printUrlElicitation(params);
326
336
 
327
- process.stderr.write(`\n${ansis.bold("Server requests URL interaction:")}\n`);
328
- process.stderr.write(` ${params.message}\n`);
329
- process.stderr.write(` ${ansis.yellow("Domain:")} ${domain}\n`);
330
- process.stderr.write(` ${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
+ }
331
344
 
332
- const answer = await question(` Open in browser? [y/n]: `);
333
- if (["y", "yes"].includes(answer.toLowerCase())) {
334
- await openBrowser(params.url);
335
- return { action: "accept" };
336
- }
337
- return { action: "decline" };
338
- } finally {
339
- 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
+ });
340
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
+ });
341
399
  }
342
400
 
343
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 {
@@ -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,6 +1,7 @@
1
1
  import { chmod } from "node:fs/promises";
2
2
  import { join, resolve } from "node:path";
3
3
  import { DEFAULT_CONFIG_DIR, ENV } from "../constants.ts";
4
+ import { logger } from "../output/logger.ts";
4
5
  import { interpolateEnv } from "./env.ts";
5
6
  import {
6
7
  type AuthFile,
@@ -64,7 +65,7 @@ export async function loadConfig(options: LoadConfigOptions = {}): Promise<Confi
64
65
  const cwd = process.cwd();
65
66
  if (await hasServersFile(cwd)) {
66
67
  configDir = cwd;
67
- process.stderr.write(`Note: using servers.json from current directory (${cwd})\n`);
68
+ logger.info(`Note: using servers.json from current directory (${cwd})`);
68
69
  }
69
70
  }
70
71
 
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}`;