@evantahler/mcpx 0.20.1 → 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.1",
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": {
@@ -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 {
@@ -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 {
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}`;