@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 +1 -1
- package/src/cli.ts +2 -1
- package/src/client/browser.ts +4 -3
- package/src/client/elicitation.ts +93 -35
- package/src/client/manager.ts +3 -0
- package/src/commands/exec.ts +16 -3
- package/src/config/loader.ts +2 -1
- package/src/context.ts +2 -1
- package/src/output/formatter.ts +28 -3
package/package.json
CHANGED
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)
|
|
100
|
+
if (notice) logger.writeRaw(notice);
|
|
100
101
|
});
|
package/src/client/browser.ts
CHANGED
|
@@ -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
|
-
|
|
16
|
+
logger.error(`Refusing to open non-HTTP URL: ${url}`);
|
|
16
17
|
return Promise.resolve();
|
|
17
18
|
}
|
|
18
19
|
} catch {
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
210
|
+
logger.writeRaw(` ${marker}${label}:\n`);
|
|
210
211
|
values.forEach((v, i) => {
|
|
211
212
|
const defMark = v === def ? ansis.dim(" (default)") : "";
|
|
212
|
-
|
|
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
|
-
|
|
232
|
+
logger.writeRaw(` ${marker}${label}:\n`);
|
|
232
233
|
options.forEach((opt, i) => {
|
|
233
234
|
const defMark = opt.const === def ? ansis.dim(" (default)") : "";
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|
package/src/client/manager.ts
CHANGED
|
@@ -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 {
|
package/src/commands/exec.ts
CHANGED
|
@@ -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
|
-
|
|
256
|
-
|
|
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/config/loader.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/output/formatter.ts
CHANGED
|
@@ -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
|
-
|
|
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}`;
|