@evantahler/mcpcli 0.5.1 → 0.6.4

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.
@@ -87,7 +87,15 @@ mcpcli deauth <server> # remove stored auth
87
87
  | `mcpcli auth <server> -s` | Check token status and TTL |
88
88
  | `mcpcli auth <server> -r` | Force token refresh |
89
89
  | `mcpcli deauth <server>` | Remove stored authentication |
90
+ | `mcpcli ping` | Check connectivity to all servers |
91
+ | `mcpcli ping <server> [server2...]` | Check specific server(s) |
90
92
  | `mcpcli add <name> --command <cmd>` | Add a stdio MCP server |
91
93
  | `mcpcli add <name> --url <url>` | Add an HTTP MCP server |
92
94
  | `mcpcli remove <name>` | Remove an MCP server |
93
95
  | `mcpcli skill install --claude` | Install mcpcli skill for Claude |
96
+ | `mcpcli resource` | List all resources across servers |
97
+ | `mcpcli resource <server>` | List resources for a server |
98
+ | `mcpcli resource <server> <uri>` | Read a specific resource |
99
+ | `mcpcli prompt` | List all prompts across servers |
100
+ | `mcpcli prompt <server>` | List prompts for a server |
101
+ | `mcpcli prompt <server> <name> '<json>'` | Get a specific prompt |
package/README.md CHANGED
@@ -49,26 +49,34 @@ mcpcli search -q "manage pull requests"
49
49
 
50
50
  ## Commands
51
51
 
52
- | Command | Description |
53
- | ------------------------------------ | -------------------------------------------- |
54
- | `mcpcli` | List all configured servers and tools |
55
- | `mcpcli info <server>` | Show tools for a server |
56
- | `mcpcli info <server> <tool>` | Show tool schema |
57
- | `mcpcli search <query>` | Search tools (keyword + semantic) |
58
- | `mcpcli search -k <pattern>` | Keyword/glob search only |
59
- | `mcpcli search -q <query>` | Semantic search only |
60
- | `mcpcli index` | Build/rebuild the search index |
61
- | `mcpcli index -i` | Show index status |
62
- | `mcpcli exec <server> <tool> [json]` | Validate inputs locally, then execute tool |
63
- | `mcpcli exec <server>` | List available tools for a server |
64
- | `mcpcli auth <server>` | Authenticate with an HTTP MCP server (OAuth) |
65
- | `mcpcli auth <server> -s` | Check auth status and token TTL |
66
- | `mcpcli auth <server> -r` | Force token refresh |
67
- | `mcpcli deauth <server>` | Remove stored authentication for a server |
68
- | `mcpcli add <name> --command <cmd>` | Add a stdio MCP server to your config |
69
- | `mcpcli add <name> --url <url>` | Add an HTTP MCP server to your config |
70
- | `mcpcli remove <name>` | Remove an MCP server from your config |
71
- | `mcpcli skill install --claude` | Install the mcpcli skill for Claude Code |
52
+ | Command | Description |
53
+ | -------------------------------------- | -------------------------------------------- |
54
+ | `mcpcli` | List all configured servers and tools |
55
+ | `mcpcli info <server>` | Show tools for a server |
56
+ | `mcpcli info <server> <tool>` | Show tool schema |
57
+ | `mcpcli search <query>` | Search tools (keyword + semantic) |
58
+ | `mcpcli search -k <pattern>` | Keyword/glob search only |
59
+ | `mcpcli search -q <query>` | Semantic search only |
60
+ | `mcpcli index` | Build/rebuild the search index |
61
+ | `mcpcli index -i` | Show index status |
62
+ | `mcpcli exec <server> <tool> [json]` | Validate inputs locally, then execute tool |
63
+ | `mcpcli exec <server>` | List available tools for a server |
64
+ | `mcpcli auth <server>` | Authenticate with an HTTP MCP server (OAuth) |
65
+ | `mcpcli auth <server> -s` | Check auth status and token TTL |
66
+ | `mcpcli auth <server> -r` | Force token refresh |
67
+ | `mcpcli deauth <server>` | Remove stored authentication for a server |
68
+ | `mcpcli add <name> --command <cmd>` | Add a stdio MCP server to your config |
69
+ | `mcpcli add <name> --url <url>` | Add an HTTP MCP server to your config |
70
+ | `mcpcli remove <name>` | Remove an MCP server from your config |
71
+ | `mcpcli ping` | Check connectivity to all configured servers |
72
+ | `mcpcli ping <server> [server2...]` | Check connectivity to specific server(s) |
73
+ | `mcpcli skill install --claude` | Install the mcpcli skill for Claude Code |
74
+ | `mcpcli resource` | List all resources across all servers |
75
+ | `mcpcli resource <server>` | List resources for a server |
76
+ | `mcpcli resource <server> <uri>` | Read a specific resource |
77
+ | `mcpcli prompt` | List all prompts across all servers |
78
+ | `mcpcli prompt <server>` | List prompts for a server |
79
+ | `mcpcli prompt <server> <name> [json]` | Get a specific prompt |
72
80
 
73
81
  ## Options
74
82
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evantahler/mcpcli",
3
- "version": "0.5.1",
3
+ "version": "0.6.4",
4
4
  "description": "A command-line interface for MCP servers. curl for MCP.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -10,6 +10,9 @@ import { registerIndexCommand } from "./commands/index.ts";
10
10
  import { registerAddCommand } from "./commands/add.ts";
11
11
  import { registerRemoveCommand } from "./commands/remove.ts";
12
12
  import { registerSkillCommand } from "./commands/skill.ts";
13
+ import { registerPingCommand } from "./commands/ping.ts";
14
+ import { registerResourceCommand } from "./commands/resource.ts";
15
+ import { registerPromptCommand } from "./commands/prompt.ts";
13
16
 
14
17
  declare const BUILD_VERSION: string | undefined;
15
18
 
@@ -35,5 +38,8 @@ registerIndexCommand(program);
35
38
  registerAddCommand(program);
36
39
  registerRemoveCommand(program);
37
40
  registerSkillCommand(program);
41
+ registerPingCommand(program);
42
+ registerResourceCommand(program);
43
+ registerPromptCommand(program);
38
44
 
39
45
  program.parse();
@@ -1,7 +1,14 @@
1
1
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
2
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
3
3
  import picomatch from "picomatch";
4
- import type { Tool, ServerConfig, ServersFile, AuthFile } from "../config/schemas.ts";
4
+ import type {
5
+ Tool,
6
+ Resource,
7
+ Prompt,
8
+ ServerConfig,
9
+ ServersFile,
10
+ AuthFile,
11
+ } from "../config/schemas.ts";
5
12
  import { isStdioServer, isHttpServer } from "../config/schemas.ts";
6
13
  import { createStdioTransport } from "./stdio.ts";
7
14
  import { createHttpTransport } from "./http.ts";
@@ -12,6 +19,16 @@ export interface ToolWithServer {
12
19
  tool: Tool;
13
20
  }
14
21
 
22
+ export interface ResourceWithServer {
23
+ server: string;
24
+ resource: Resource;
25
+ }
26
+
27
+ export interface PromptWithServer {
28
+ server: string;
29
+ prompt: Prompt;
30
+ }
31
+
15
32
  export interface ServerError {
16
33
  server: string;
17
34
  message: string;
@@ -30,6 +47,7 @@ export interface ServerManagerOptions {
30
47
 
31
48
  export class ServerManager {
32
49
  private clients = new Map<string, Client>();
50
+ private connecting = new Map<string, Promise<Client>>();
33
51
  private transports = new Map<string, Transport>();
34
52
  private oauthProviders = new Map<string, McpOAuthProvider>();
35
53
  private servers: ServersFile;
@@ -57,32 +75,45 @@ export class ServerManager {
57
75
  const existing = this.clients.get(serverName);
58
76
  if (existing) return existing;
59
77
 
78
+ // If a connection is already in flight, wait for it instead of opening a second one
79
+ const inflight = this.connecting.get(serverName);
80
+ if (inflight) return inflight;
81
+
60
82
  const config = this.servers.mcpServers[serverName];
61
83
  if (!config) {
62
84
  throw new Error(`Unknown server: "${serverName}"`);
63
85
  }
64
86
 
65
- // Auto-refresh expired OAuth tokens before connecting to HTTP servers
66
- if (isHttpServer(config)) {
67
- const provider = this.getOrCreateOAuthProvider(serverName);
68
- if (!provider.isComplete()) {
69
- throw new Error(`Not authenticated with "${serverName}". Run: mcpcli auth ${serverName}`);
70
- }
71
- try {
72
- await provider.refreshIfNeeded(config.url);
73
- } catch {
74
- // If refresh fails, continue — the transport will send the existing token
87
+ const connectPromise = (async () => {
88
+ // Auto-refresh expired OAuth tokens before connecting to HTTP servers
89
+ if (isHttpServer(config)) {
90
+ const provider = this.getOrCreateOAuthProvider(serverName);
91
+ if (!provider.isComplete()) {
92
+ throw new Error(`Not authenticated with "${serverName}". Run: mcpcli auth ${serverName}`);
93
+ }
94
+ try {
95
+ await provider.refreshIfNeeded(config.url);
96
+ } catch {
97
+ // If refresh fails, continue — the transport will send the existing token
98
+ }
75
99
  }
76
- }
77
100
 
78
- const transport = this.createTransport(serverName, config);
79
- this.transports.set(serverName, transport);
101
+ const transport = this.createTransport(serverName, config);
102
+ this.transports.set(serverName, transport);
80
103
 
81
- const client = new Client({ name: "mcpcli", version: "0.1.0" });
82
- await this.withTimeout(client.connect(transport), `connect(${serverName})`);
83
- this.clients.set(serverName, client);
104
+ const client = new Client({ name: "mcpcli", version: "0.1.0" });
105
+ await this.withTimeout(client.connect(transport), `connect(${serverName})`);
106
+ this.clients.set(serverName, client);
107
+ this.connecting.delete(serverName);
108
+
109
+ return client;
110
+ })().catch((err) => {
111
+ this.connecting.delete(serverName);
112
+ throw err;
113
+ });
84
114
 
85
- return client;
115
+ this.connecting.set(serverName, connectPromise);
116
+ return connectPromise;
86
117
  }
87
118
 
88
119
  private getOrCreateOAuthProvider(serverName: string): McpOAuthProvider {
@@ -150,6 +181,7 @@ export class ServerManager {
150
181
  // ignore close errors
151
182
  }
152
183
  this.clients.delete(serverName);
184
+ this.connecting.delete(serverName);
153
185
  this.transports.delete(serverName);
154
186
  }
155
187
  }
@@ -228,6 +260,128 @@ export class ServerManager {
228
260
  return tools.find((t) => t.name === toolName);
229
261
  }
230
262
 
263
+ /** List resources for a single server */
264
+ async listResources(serverName: string): Promise<Resource[]> {
265
+ return this.withRetry(
266
+ async () => {
267
+ const client = await this.getClient(serverName);
268
+ const result = await this.withTimeout(
269
+ client.listResources(),
270
+ `listResources(${serverName})`,
271
+ );
272
+ return result.resources;
273
+ },
274
+ `listResources(${serverName})`,
275
+ serverName,
276
+ );
277
+ }
278
+
279
+ /** List resources across all configured servers */
280
+ async getAllResources(): Promise<{ resources: ResourceWithServer[]; errors: ServerError[] }> {
281
+ const serverNames = Object.keys(this.servers.mcpServers);
282
+ const resources: ResourceWithServer[] = [];
283
+ const errors: ServerError[] = [];
284
+
285
+ for (let i = 0; i < serverNames.length; i += this.concurrency) {
286
+ const batch = serverNames.slice(i, i + this.concurrency);
287
+ const batchResults = await Promise.allSettled(
288
+ batch.map(async (name) => {
289
+ const serverResources = await this.listResources(name);
290
+ return serverResources.map((resource) => ({ server: name, resource }));
291
+ }),
292
+ );
293
+
294
+ for (let j = 0; j < batchResults.length; j++) {
295
+ const result = batchResults[j]!;
296
+ if (result.status === "fulfilled") {
297
+ resources.push(...result.value);
298
+ } else {
299
+ const name = batch[j]!;
300
+ const message =
301
+ result.reason instanceof Error ? result.reason.message : String(result.reason);
302
+ errors.push({ server: name, message });
303
+ }
304
+ }
305
+ }
306
+
307
+ return { resources, errors };
308
+ }
309
+
310
+ /** Read a specific resource by URI */
311
+ async readResource(serverName: string, uri: string): Promise<unknown> {
312
+ return this.withRetry(
313
+ async () => {
314
+ const client = await this.getClient(serverName);
315
+ return this.withTimeout(client.readResource({ uri }), `readResource(${serverName}/${uri})`);
316
+ },
317
+ `readResource(${serverName}/${uri})`,
318
+ serverName,
319
+ );
320
+ }
321
+
322
+ /** List prompts for a single server */
323
+ async listPrompts(serverName: string): Promise<Prompt[]> {
324
+ return this.withRetry(
325
+ async () => {
326
+ const client = await this.getClient(serverName);
327
+ const result = await this.withTimeout(client.listPrompts(), `listPrompts(${serverName})`);
328
+ return result.prompts;
329
+ },
330
+ `listPrompts(${serverName})`,
331
+ serverName,
332
+ );
333
+ }
334
+
335
+ /** List prompts across all configured servers */
336
+ async getAllPrompts(): Promise<{ prompts: PromptWithServer[]; errors: ServerError[] }> {
337
+ const serverNames = Object.keys(this.servers.mcpServers);
338
+ const prompts: PromptWithServer[] = [];
339
+ const errors: ServerError[] = [];
340
+
341
+ for (let i = 0; i < serverNames.length; i += this.concurrency) {
342
+ const batch = serverNames.slice(i, i + this.concurrency);
343
+ const batchResults = await Promise.allSettled(
344
+ batch.map(async (name) => {
345
+ const serverPrompts = await this.listPrompts(name);
346
+ return serverPrompts.map((prompt) => ({ server: name, prompt }));
347
+ }),
348
+ );
349
+
350
+ for (let j = 0; j < batchResults.length; j++) {
351
+ const result = batchResults[j]!;
352
+ if (result.status === "fulfilled") {
353
+ prompts.push(...result.value);
354
+ } else {
355
+ const name = batch[j]!;
356
+ const message =
357
+ result.reason instanceof Error ? result.reason.message : String(result.reason);
358
+ errors.push({ server: name, message });
359
+ }
360
+ }
361
+ }
362
+
363
+ return { prompts, errors };
364
+ }
365
+
366
+ /** Get a specific prompt by name, optionally with arguments */
367
+ async getPrompt(
368
+ serverName: string,
369
+ name: string,
370
+ args?: Record<string, string>,
371
+ ): Promise<unknown> {
372
+ return this.withRetry(
373
+ async () => {
374
+ const client = await this.getClient(serverName);
375
+ return this.withTimeout(
376
+ client.getPrompt({ name, arguments: args }),
377
+ `getPrompt(${serverName}/${name})`,
378
+ );
379
+ },
380
+ `getPrompt(${serverName}/${name})`,
381
+ serverName,
382
+ );
383
+ }
384
+
231
385
  /** Get all server names */
232
386
  getServerNames(): string[] {
233
387
  return Object.keys(this.servers.mcpServers);
@@ -245,6 +399,7 @@ export class ServerManager {
245
399
  this.transports.delete(name);
246
400
  });
247
401
  await Promise.allSettled(closePromises);
402
+ this.connecting.clear();
248
403
  }
249
404
  }
250
405
 
@@ -248,6 +248,23 @@ export function startCallbackServer(): {
248
248
  return { server, authCodePromise };
249
249
  }
250
250
 
251
+ /** Resolve the canonical resource URL for an HTTP MCP server.
252
+ * Some servers advertise a canonical URL in their OAuth protected resource metadata
253
+ * that may differ from the URL provided by the user (e.g. hf.co → huggingface.co).
254
+ * Returns the canonical URL if found, or the original URL otherwise. */
255
+ export async function resolveResourceUrl(serverUrl: string): Promise<string> {
256
+ try {
257
+ const info = await discoverOAuthServerInfo(serverUrl);
258
+ const canonical = info.resourceMetadata?.resource;
259
+ if (canonical && canonical !== serverUrl) {
260
+ return canonical;
261
+ }
262
+ } catch {
263
+ // OAuth discovery not available — use original URL
264
+ }
265
+ return serverUrl;
266
+ }
267
+
251
268
  /** Probe for OAuth support and run the auth flow if the server supports it.
252
269
  * Returns true if auth ran, false if server doesn't support OAuth (silent skip). */
253
270
  export async function tryOAuthIfSupported(
@@ -1,7 +1,7 @@
1
1
  import type { Command } from "commander";
2
2
  import type { ServerConfig } from "../config/schemas.ts";
3
3
  import { loadRawAuth, loadRawServers, saveServers } from "../config/loader.ts";
4
- import { tryOAuthIfSupported } from "../client/oauth.ts";
4
+ import { tryOAuthIfSupported, resolveResourceUrl } from "../client/oauth.ts";
5
5
  import { runIndex } from "./index.ts";
6
6
 
7
7
  export function registerAddCommand(program: Command) {
@@ -72,6 +72,20 @@ export function registerAddCommand(program: Command) {
72
72
  config.disabledTools = options.disabledTools.split(",").map((t) => t.trim());
73
73
  }
74
74
 
75
+ // For HTTP servers, resolve the canonical resource URL before saving.
76
+ // Some servers (e.g. hf.co → huggingface.co) advertise a different canonical
77
+ // URL in their OAuth protected resource metadata, and the SDK enforces that the
78
+ // stored URL matches this canonical URL during the OAuth token flow.
79
+ let effectiveUrl = options.url!;
80
+ if (hasUrl && options.auth !== false) {
81
+ const canonical = await resolveResourceUrl(effectiveUrl);
82
+ if (canonical !== effectiveUrl) {
83
+ (config as { url: string }).url = canonical;
84
+ effectiveUrl = canonical;
85
+ console.log(`Resolved canonical URL: ${canonical}`);
86
+ }
87
+ }
88
+
75
89
  servers.mcpServers[name] = config;
76
90
  await saveServers(configDir, servers);
77
91
  console.log(`Added server "${name}" to ${configDir}/servers.json`);
@@ -85,7 +99,7 @@ export function registerAddCommand(program: Command) {
85
99
  showSecrets: false,
86
100
  };
87
101
  try {
88
- await tryOAuthIfSupported(name, options.url!, configDir, auth, formatOptions);
102
+ await tryOAuthIfSupported(name, effectiveUrl, configDir, auth, formatOptions);
89
103
  } catch {
90
104
  console.error(`Warning: OAuth authentication failed. Run: mcpcli auth ${name}`);
91
105
  }
@@ -1,6 +1,7 @@
1
1
  import type { Command } from "commander";
2
2
  import { getContext } from "../context.ts";
3
- import { formatToolList, formatError } from "../output/formatter.ts";
3
+ import { formatUnifiedList, formatError } from "../output/formatter.ts";
4
+ import type { UnifiedItem } from "../output/formatter.ts";
4
5
  import { logger } from "../output/logger.ts";
5
6
 
6
7
  export function registerListCommand(program: Command) {
@@ -8,19 +9,52 @@ export function registerListCommand(program: Command) {
8
9
  const { manager, formatOptions } = await getContext(program);
9
10
  const spinner = logger.startSpinner("Connecting to servers...", formatOptions);
10
11
  try {
11
- const { tools, errors } = await manager.getAllTools();
12
+ const [toolsResult, resourcesResult, promptsResult] = await Promise.all([
13
+ manager.getAllTools(),
14
+ manager.getAllResources(),
15
+ manager.getAllPrompts(),
16
+ ]);
12
17
  spinner.stop();
13
18
 
19
+ const items: UnifiedItem[] = [
20
+ ...toolsResult.tools.map((t) => ({
21
+ server: t.server,
22
+ type: "tool" as const,
23
+ name: t.tool.name,
24
+ description: t.tool.description,
25
+ })),
26
+ ...resourcesResult.resources.map((r) => ({
27
+ server: r.server,
28
+ type: "resource" as const,
29
+ name: r.resource.uri,
30
+ description: r.resource.description,
31
+ })),
32
+ ...promptsResult.prompts.map((p) => ({
33
+ server: p.server,
34
+ type: "prompt" as const,
35
+ name: p.prompt.name,
36
+ description: p.prompt.description,
37
+ })),
38
+ ];
39
+
40
+ const typeOrder = { tool: 0, resource: 1, prompt: 2 };
41
+ items.sort((a, b) => {
42
+ if (a.server !== b.server) return a.server.localeCompare(b.server);
43
+ if (a.type !== b.type) return typeOrder[a.type] - typeOrder[b.type];
44
+ return a.name.localeCompare(b.name);
45
+ });
46
+
47
+ const errors = [...toolsResult.errors, ...resourcesResult.errors, ...promptsResult.errors];
14
48
  if (errors.length > 0) {
15
49
  for (const err of errors) {
16
50
  console.error(`"${err.server}": ${err.message}`);
17
51
  }
18
- if (tools.length > 0) console.log("");
52
+ if (items.length > 0) console.log("");
19
53
  }
20
54
 
21
- console.log(formatToolList(tools, formatOptions));
55
+ console.log(formatUnifiedList(items, formatOptions));
22
56
  } catch (err) {
23
- spinner.error("Failed to list tools");
57
+ spinner.error("Failed to list servers");
24
58
  console.error(formatError(String(err), formatOptions));
25
59
  process.exit(1);
26
60
  } finally {
@@ -0,0 +1,69 @@
1
+ import { green, red } from "ansis";
2
+ import type { Command } from "commander";
3
+ import { getContext } from "../context.ts";
4
+ import { formatError } from "../output/formatter.ts";
5
+ import { logger } from "../output/logger.ts";
6
+
7
+ interface PingResult {
8
+ server: string;
9
+ success: boolean;
10
+ latencyMs?: number;
11
+ error?: string;
12
+ }
13
+
14
+ export function registerPingCommand(program: Command) {
15
+ program
16
+ .command("ping [servers...]")
17
+ .description("Check connectivity to MCP servers")
18
+ .action(async (servers: string[]) => {
19
+ const { manager, formatOptions } = await getContext(program);
20
+
21
+ const targetServers = servers.length > 0 ? servers : manager.getServerNames();
22
+
23
+ if (targetServers.length === 0) {
24
+ console.error(formatError("No servers configured", formatOptions));
25
+ await manager.close();
26
+ process.exit(1);
27
+ }
28
+
29
+ const spinner = logger.startSpinner(
30
+ `Pinging ${targetServers.length} server(s)...`,
31
+ formatOptions,
32
+ );
33
+
34
+ const results: PingResult[] = [];
35
+
36
+ try {
37
+ await Promise.all(
38
+ targetServers.map(async (serverName) => {
39
+ const start = Date.now();
40
+ try {
41
+ await manager.getClient(serverName);
42
+ results.push({ server: serverName, success: true, latencyMs: Date.now() - start });
43
+ } catch (err) {
44
+ results.push({ server: serverName, success: false, error: String(err) });
45
+ }
46
+ }),
47
+ );
48
+
49
+ spinner.stop();
50
+
51
+ if (formatOptions.json) {
52
+ console.log(JSON.stringify(results, null, 2));
53
+ } else {
54
+ for (const r of results) {
55
+ if (r.success) {
56
+ console.log(`${green("✔")} ${r.server} connected (${r.latencyMs}ms)`);
57
+ } else {
58
+ console.log(`${red("✖")} ${r.server} failed: ${r.error}`);
59
+ }
60
+ }
61
+ }
62
+ } finally {
63
+ await manager.close();
64
+ }
65
+
66
+ const anyFailed = results.some((r) => !r.success);
67
+ if (anyFailed) process.exit(1);
68
+ });
69
+ }
@@ -0,0 +1,85 @@
1
+ import type { Command } from "commander";
2
+ import { getContext } from "../context.ts";
3
+ import {
4
+ formatPromptList,
5
+ formatServerPrompts,
6
+ formatPromptMessages,
7
+ formatError,
8
+ } from "../output/formatter.ts";
9
+ import { logger } from "../output/logger.ts";
10
+
11
+ export function registerPromptCommand(program: Command) {
12
+ program
13
+ .command("prompt [server] [name] [args]")
14
+ .description("list prompts for a server, or get a specific prompt")
15
+ .action(
16
+ async (server: string | undefined, name: string | undefined, argsStr: string | undefined) => {
17
+ const { manager, formatOptions } = await getContext(program);
18
+ const spinner = logger.startSpinner(
19
+ server ? `Connecting to ${server}...` : "Connecting to servers...",
20
+ formatOptions,
21
+ );
22
+ try {
23
+ if (server && name) {
24
+ let args: Record<string, string> | undefined;
25
+
26
+ if (argsStr) {
27
+ args = parseJsonArgs(argsStr);
28
+ } else if (!process.stdin.isTTY) {
29
+ const stdin = await readStdin();
30
+ if (stdin.trim()) {
31
+ args = parseJsonArgs(stdin);
32
+ }
33
+ }
34
+
35
+ const result = await manager.getPrompt(server, name, args);
36
+ spinner.stop();
37
+ console.log(formatPromptMessages(server, name, result, formatOptions));
38
+ } else if (server) {
39
+ const prompts = await manager.listPrompts(server);
40
+ spinner.stop();
41
+ console.log(formatServerPrompts(server, prompts, formatOptions));
42
+ } else {
43
+ const { prompts, errors } = await manager.getAllPrompts();
44
+ spinner.stop();
45
+ console.log(formatPromptList(prompts, formatOptions));
46
+ for (const err of errors) {
47
+ console.error(formatError(`${err.server}: ${err.message}`, formatOptions));
48
+ }
49
+ }
50
+ } catch (err) {
51
+ spinner.error("Failed");
52
+ console.error(formatError(String(err), formatOptions));
53
+ process.exit(1);
54
+ } finally {
55
+ await manager.close();
56
+ }
57
+ },
58
+ );
59
+ }
60
+
61
+ function parseJsonArgs(str: string): Record<string, string> {
62
+ try {
63
+ const parsed = JSON.parse(str);
64
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
65
+ throw new Error("Prompt arguments must be a JSON object");
66
+ }
67
+ // Coerce all values to strings (MCP prompt args are Record<string, string>)
68
+ return Object.fromEntries(Object.entries(parsed).map(([k, v]) => [k, String(v)]));
69
+ } catch (err) {
70
+ if (err instanceof SyntaxError) {
71
+ throw new Error(`Invalid JSON: ${err.message}`);
72
+ }
73
+ throw err;
74
+ }
75
+ }
76
+
77
+ async function readStdin(): Promise<string> {
78
+ const chunks: string[] = [];
79
+ const reader = process.stdin;
80
+ reader.setEncoding("utf-8");
81
+ for await (const chunk of reader) {
82
+ chunks.push(chunk as string);
83
+ }
84
+ return chunks.join("");
85
+ }
@@ -0,0 +1,46 @@
1
+ import type { Command } from "commander";
2
+ import { getContext } from "../context.ts";
3
+ import {
4
+ formatResourceList,
5
+ formatServerResources,
6
+ formatResourceContents,
7
+ formatError,
8
+ } from "../output/formatter.ts";
9
+ import { logger } from "../output/logger.ts";
10
+
11
+ export function registerResourceCommand(program: Command) {
12
+ program
13
+ .command("resource [server] [uri]")
14
+ .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));
36
+ }
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
+ });
46
+ }
@@ -1,4 +1,4 @@
1
- import type { Tool } from "@modelcontextprotocol/sdk/types.js";
1
+ import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js";
2
2
  import type {
3
3
  OAuthTokens,
4
4
  OAuthClientInformation,
@@ -6,7 +6,14 @@ import type {
6
6
  } from "@modelcontextprotocol/sdk/shared/auth.js";
7
7
 
8
8
  // Re-export SDK types we use throughout the codebase
9
- export type { Tool, OAuthTokens, OAuthClientInformation, OAuthClientInformationMixed };
9
+ export type {
10
+ Tool,
11
+ Resource,
12
+ Prompt,
13
+ OAuthTokens,
14
+ OAuthClientInformation,
15
+ OAuthClientInformationMixed,
16
+ };
10
17
 
11
18
  // --- Server config (our format, not MCP spec) ---
12
19
 
@@ -1,6 +1,6 @@
1
1
  import { bold, cyan, dim, green, red, yellow } from "ansis";
2
- import type { Tool } from "../config/schemas.ts";
3
- import type { ToolWithServer } from "../client/manager.ts";
2
+ import type { Tool, Resource, Prompt } from "../config/schemas.ts";
3
+ import type { ToolWithServer, ResourceWithServer, PromptWithServer } from "../client/manager.ts";
4
4
  import type { ValidationError } from "../validation/schema.ts";
5
5
  import type { SearchResult } from "../search/index.ts";
6
6
 
@@ -11,6 +11,13 @@ export interface FormatOptions {
11
11
  showSecrets?: boolean;
12
12
  }
13
13
 
14
+ export interface UnifiedItem {
15
+ server: string;
16
+ type: "tool" | "resource" | "prompt";
17
+ name: string;
18
+ description?: string;
19
+ }
20
+
14
21
  /** Check if stdout is a TTY (interactive terminal) */
15
22
  export function isInteractive(options: FormatOptions): boolean {
16
23
  if (options.json) return false;
@@ -307,6 +314,273 @@ export function formatSearchResults(results: SearchResult[], options: FormatOpti
307
314
  .join("\n");
308
315
  }
309
316
 
317
+ /** Format a list of resources with server names */
318
+ export function formatResourceList(
319
+ resources: ResourceWithServer[],
320
+ options: FormatOptions,
321
+ ): string {
322
+ if (!isInteractive(options)) {
323
+ return JSON.stringify(
324
+ resources.map((r) => ({
325
+ server: r.server,
326
+ uri: r.resource.uri,
327
+ name: r.resource.name,
328
+ ...(options.withDescriptions ? { description: r.resource.description ?? "" } : {}),
329
+ })),
330
+ null,
331
+ 2,
332
+ );
333
+ }
334
+
335
+ if (resources.length === 0) {
336
+ return dim("No resources found");
337
+ }
338
+
339
+ const maxServer = Math.max(...resources.map((r) => r.server.length));
340
+ const maxUri = Math.max(...resources.map((r) => r.resource.uri.length));
341
+
342
+ return resources
343
+ .map((r) => {
344
+ const server = cyan(r.server.padEnd(maxServer));
345
+ const uri = bold(r.resource.uri.padEnd(maxUri));
346
+ if (options.withDescriptions && r.resource.description) {
347
+ return `${server} ${uri} ${dim(r.resource.description)}`;
348
+ }
349
+ return `${server} ${uri}`;
350
+ })
351
+ .join("\n");
352
+ }
353
+
354
+ /** Format resources for a single server */
355
+ export function formatServerResources(
356
+ serverName: string,
357
+ resources: Resource[],
358
+ options: FormatOptions,
359
+ ): string {
360
+ if (!isInteractive(options)) {
361
+ return JSON.stringify(
362
+ {
363
+ server: serverName,
364
+ resources: resources.map((r) => ({
365
+ uri: r.uri,
366
+ name: r.name,
367
+ description: r.description ?? "",
368
+ mimeType: r.mimeType ?? "",
369
+ })),
370
+ },
371
+ null,
372
+ 2,
373
+ );
374
+ }
375
+
376
+ if (resources.length === 0) {
377
+ return dim(`No resources found for ${serverName}`);
378
+ }
379
+
380
+ const header = cyan.bold(serverName);
381
+ const maxUri = Math.max(...resources.map((r) => r.uri.length));
382
+
383
+ const lines = resources.map((r) => {
384
+ const uri = ` ${bold(r.uri.padEnd(maxUri))}`;
385
+ if (r.description) {
386
+ return `${uri} ${dim(r.description)}`;
387
+ }
388
+ return uri;
389
+ });
390
+
391
+ return [header, ...lines].join("\n");
392
+ }
393
+
394
+ /** Format resource contents */
395
+ export function formatResourceContents(
396
+ serverName: string,
397
+ uri: string,
398
+ result: unknown,
399
+ options: FormatOptions,
400
+ ): string {
401
+ if (!isInteractive(options)) {
402
+ return JSON.stringify(
403
+ { server: serverName, uri, contents: (result as { contents: unknown })?.contents ?? result },
404
+ null,
405
+ 2,
406
+ );
407
+ }
408
+
409
+ const contents =
410
+ (result as { contents?: Array<{ text?: string; blob?: string; mimeType?: string }> })
411
+ ?.contents ?? [];
412
+ const lines: string[] = [];
413
+ lines.push(`${cyan(serverName)}/${bold(uri)}`);
414
+ lines.push("");
415
+
416
+ if (contents.length === 0) {
417
+ lines.push(dim("(empty)"));
418
+ } else {
419
+ for (const item of contents) {
420
+ if (item.text !== undefined) {
421
+ lines.push(item.text);
422
+ } else if (item.blob !== undefined) {
423
+ lines.push(dim(`<binary blob, ${item.blob.length} bytes base64>`));
424
+ }
425
+ }
426
+ }
427
+
428
+ return lines.join("\n");
429
+ }
430
+
431
+ /** Format a list of prompts with server names */
432
+ export function formatPromptList(prompts: PromptWithServer[], options: FormatOptions): string {
433
+ if (!isInteractive(options)) {
434
+ return JSON.stringify(
435
+ prompts.map((p) => ({
436
+ server: p.server,
437
+ name: p.prompt.name,
438
+ ...(options.withDescriptions ? { description: p.prompt.description ?? "" } : {}),
439
+ })),
440
+ null,
441
+ 2,
442
+ );
443
+ }
444
+
445
+ if (prompts.length === 0) {
446
+ return dim("No prompts found");
447
+ }
448
+
449
+ const maxServer = Math.max(...prompts.map((p) => p.server.length));
450
+ const maxName = Math.max(...prompts.map((p) => p.prompt.name.length));
451
+
452
+ return prompts
453
+ .map((p) => {
454
+ const server = cyan(p.server.padEnd(maxServer));
455
+ const name = bold(p.prompt.name.padEnd(maxName));
456
+ if (options.withDescriptions && p.prompt.description) {
457
+ return `${server} ${name} ${dim(p.prompt.description)}`;
458
+ }
459
+ return `${server} ${name}`;
460
+ })
461
+ .join("\n");
462
+ }
463
+
464
+ /** Format prompts for a single server */
465
+ export function formatServerPrompts(
466
+ serverName: string,
467
+ prompts: Prompt[],
468
+ options: FormatOptions,
469
+ ): string {
470
+ if (!isInteractive(options)) {
471
+ return JSON.stringify(
472
+ {
473
+ server: serverName,
474
+ prompts: prompts.map((p) => ({
475
+ name: p.name,
476
+ description: p.description ?? "",
477
+ arguments: p.arguments ?? [],
478
+ })),
479
+ },
480
+ null,
481
+ 2,
482
+ );
483
+ }
484
+
485
+ if (prompts.length === 0) {
486
+ return dim(`No prompts found for ${serverName}`);
487
+ }
488
+
489
+ const header = cyan.bold(serverName);
490
+ const maxName = Math.max(...prompts.map((p) => p.name.length));
491
+
492
+ const lines = prompts.map((p) => {
493
+ const name = ` ${bold(p.name.padEnd(maxName))}`;
494
+ const args =
495
+ p.arguments && p.arguments.length > 0
496
+ ? ` ${dim(`(${p.arguments.map((a) => (a.required ? a.name : `[${a.name}]`)).join(", ")})`)}`
497
+ : "";
498
+ if (p.description) {
499
+ return `${name}${args} ${dim(p.description)}`;
500
+ }
501
+ return `${name}${args}`;
502
+ });
503
+
504
+ return [header, ...lines].join("\n");
505
+ }
506
+
507
+ /** Format prompt messages */
508
+ export function formatPromptMessages(
509
+ serverName: string,
510
+ name: string,
511
+ result: unknown,
512
+ options: FormatOptions,
513
+ ): string {
514
+ if (!isInteractive(options)) {
515
+ return JSON.stringify({ server: serverName, prompt: name, ...(result as object) }, null, 2);
516
+ }
517
+
518
+ const r = result as {
519
+ description?: string;
520
+ messages?: Array<{ role: string; content: { type: string; text?: string } }>;
521
+ };
522
+ const lines: string[] = [];
523
+ lines.push(`${cyan(serverName)}/${bold(name)}`);
524
+
525
+ if (r.description) {
526
+ lines.push(dim(r.description));
527
+ }
528
+
529
+ lines.push("");
530
+
531
+ for (const msg of r.messages ?? []) {
532
+ lines.push(`${bold(msg.role)}:`);
533
+ if (msg.content.text !== undefined) {
534
+ lines.push(` ${msg.content.text}`);
535
+ }
536
+ }
537
+
538
+ return lines.join("\n");
539
+ }
540
+
541
+ /** Format a unified list of tools, resources, and prompts across servers */
542
+ export function formatUnifiedList(items: UnifiedItem[], options: FormatOptions): string {
543
+ if (!isInteractive(options)) {
544
+ return JSON.stringify(
545
+ items.map((i) => ({
546
+ server: i.server,
547
+ type: i.type,
548
+ name: i.name,
549
+ ...(options.withDescriptions ? { description: i.description ?? "" } : {}),
550
+ })),
551
+ null,
552
+ 2,
553
+ );
554
+ }
555
+
556
+ if (items.length === 0) {
557
+ return dim("No tools, resources, or prompts found");
558
+ }
559
+
560
+ const maxServer = Math.max(...items.map((i) => i.server.length));
561
+ const maxType = 8; // "resource" is the longest at 8 chars
562
+ const maxName = Math.max(...items.map((i) => i.name.length));
563
+
564
+ const typeLabel = (t: UnifiedItem["type"]) => {
565
+ const padded = t.padEnd(maxType);
566
+ if (t === "tool") return green(padded);
567
+ if (t === "resource") return cyan(padded);
568
+ return yellow(padded);
569
+ };
570
+
571
+ return items
572
+ .map((i) => {
573
+ const server = cyan(i.server.padEnd(maxServer));
574
+ const type = typeLabel(i.type);
575
+ const name = bold(i.name.padEnd(maxName));
576
+ if (options.withDescriptions && i.description) {
577
+ return `${server} ${type} ${name} ${dim(i.description)}`;
578
+ }
579
+ return `${server} ${type} ${name}`;
580
+ })
581
+ .join("\n");
582
+ }
583
+
310
584
  /** Format an error message */
311
585
  export function formatError(message: string, options: FormatOptions): string {
312
586
  if (!isInteractive(options)) {