From 0157b135bfbe1225804f4913c464eb52cd0b41c8 Mon Sep 17 00:00:00 2001 From: Josh Nichols Date: Sat, 6 Sep 2025 18:16:45 -0400 Subject: [PATCH 1/5] feat: Add non-interactive MCP server listing commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive CLI commands for listing MCP server capabilities without interactive mode: - list-tools: List all available tools with descriptions - list-resources: List static resources and resource templates - list-prompts: List available prompts with arguments - list-all: Comprehensive overview of all server capabilities Features: - Multiple server types: config stdio, remote HTTP/SSE, URL servers in config - Dual output formats: human-readable with colors + JSON (--json flag) - OAuth authentication support for remote servers - Consistent data structure across all commands - Performance optimized with concurrent Promise.all API calls Architecture: - Type-specific formatters (formatListTools, formatListPrompts, formatListResources) - Compositional design with formatListAll reusing existing formatters - Single unified data format: {capabilities, tools, prompts, resources, resourceTemplates} - Zero code duplication with helper functions like shouldInclude - Clean separation of data fetching, filtering, and formatting Maintains full backward compatibility - no breaking changes to existing CLI interface. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/cli.js | 34 ++++- src/mcp.js | 368 +++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 354 insertions(+), 48 deletions(-) diff --git a/src/cli.js b/src/cli.js index 7f7b3da..9809ea9 100755 --- a/src/cli.js +++ b/src/cli.js @@ -2,7 +2,7 @@ import meow from 'meow' import './eventsource-polyfill.js' -import { runWithCommand, runWithConfig, runWithConfigNonInteractive, runWithSSE, runWithURL } from './mcp.js' +import { runWithCommand, runWithConfig, runWithConfigNonInteractive, runWithSSE, runWithURL, runListCommand, LIST_COMMANDS } from './mcp.js' import { purge } from './config.js' const cli = meow( @@ -18,6 +18,10 @@ const cli = meow( $ mcp-cli [--config config.json] call-tool : [--args '{"key":"value"}'] $ mcp-cli [--config config.json] read-resource : $ mcp-cli [--config config.json] get-prompt : [--args '{"key":"value"}'] + $ mcp-cli [--config config.json] list-tools + $ mcp-cli [--config config.json] list-resources + $ mcp-cli [--config config.json] list-prompts + $ mcp-cli [--config config.json] list-all Options --config, -c Path to the config file @@ -26,6 +30,7 @@ const cli = meow( --url Streamable HTTP endpoint --sse SSE endpoint --args JSON arguments for tools and prompts (non-interactive mode) + --json Output results in JSON format (for list commands) `, { importMeta: import.meta, @@ -45,14 +50,25 @@ const cli = meow( args: { type: 'string', }, + json: { + type: 'boolean', + }, }, }, ) -const options = { compact: cli.flags.compact } +const options = { compact: cli.flags.compact, json: cli.flags.json } + +function isListCommand(command) { + return command in LIST_COMMANDS +} if (cli.input[0] === 'purge') { purge() +} else if (cli.input.length >= 2 && isListCommand(cli.input[0])) { + // Non-interactive list mode: mcp-cli [--config config.json] + const [command, serverName] = cli.input + await runListCommand(cli.flags.config, serverName, command, options) } else if ( cli.input.length >= 2 && (cli.input[0] === 'call-tool' || cli.input[0] === 'read-resource' || cli.input[0] === 'get-prompt') @@ -61,13 +77,19 @@ if (cli.input[0] === 'purge') { const [command, serverTarget] = cli.input const [serverName, target] = serverTarget.split(':') await runWithConfigNonInteractive(cli.flags.config, serverName, command, target, cli.flags.args) +} else if (cli.flags.url || cli.flags.sse) { + const endpoint = cli.flags.url || cli.flags.sse + const transportType = cli.flags.url ? 'url' : 'sse' + + if (cli.input.length >= 1 && isListCommand(cli.input[0])) { + await runListCommand(null, null, cli.input[0], { ...options, [transportType]: endpoint }) + } else { + const runner = cli.flags.url ? runWithURL : runWithSSE + await runner(endpoint, options) + } } else if (cli.input.length > 0) { const [command, ...args] = cli.input await runWithCommand(command, args, cli.flags.passEnv ? process.env : undefined, options) -} else if (cli.flags.url) { - await runWithURL(cli.flags.url, options) -} else if (cli.flags.sse) { - await runWithSSE(cli.flags.sse, options) } else { await runWithConfig(cli.flags.config, options) } diff --git a/src/mcp.js b/src/mcp.js index 81f2cac..743d64f 100644 --- a/src/mcp.js +++ b/src/mcp.js @@ -24,6 +24,15 @@ import { readPromptArgumentInputs, } from './utils.js' +// Transport factory functions +function createHTTPTransport(url, authProvider) { + return new StreamableHTTPClientTransport(new URL(url), { authProvider }) +} + +function createSSETransport(url, authProvider) { + return new SSEClientTransport(new URL(url), { authProvider }) +} + async function createClient() { const client = new Client({ name: 'mcp-cli', version: '1.0.0' }, { capabilities: {} }) client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { @@ -32,43 +41,81 @@ async function createClient() { return client } -async function listPrimitives(client) { +function shouldInclude(filter, type) { + return !filter || filter === type || filter === 'all' +} + +async function listPrimitives(client, filter = null) { const capabilities = client.getServerCapabilities() - const primitives = [] const promises = [] - if (capabilities.resources) { - promises.push( - client.listResources().then(({ resources }) => { - resources.forEach((item) => primitives.push({ type: 'resource', value: item })) - }), - ) - promises.push( - client.listResourceTemplates().then(({ resourceTemplates }) => { - resourceTemplates.forEach((item) => - primitives.push({ - type: 'resource-template', - value: item, - }), - ) - }), - ) + + if (capabilities.tools && shouldInclude(filter, 'tools')) { + promises.push(client.listTools().then(result => ({ type: 'tools', data: result.tools }))) } - if (capabilities.tools) { + + if (capabilities.resources && shouldInclude(filter, 'resources')) { promises.push( - client.listTools().then(({ tools }) => { - tools.forEach((item) => primitives.push({ type: 'tool', value: item })) - }), + client.listResources().then(result => ({ type: 'resources', data: result.resources })), + client.listResourceTemplates().then(result => ({ type: 'resourceTemplates', data: result.resourceTemplates })) ) } - if (capabilities.prompts) { - promises.push( - client.listPrompts().then(({ prompts }) => { - prompts.forEach((item) => primitives.push({ type: 'prompt', value: item })) - }), - ) + + if (capabilities.prompts && shouldInclude(filter, 'prompts')) { + promises.push(client.listPrompts().then(result => ({ type: 'prompts', data: result.prompts }))) + } + + // Resolve all and organize by type + const results = await Promise.all(promises) + const rawData = { + capabilities, + tools: [], + prompts: [], + resources: [], + resourceTemplates: [] + } + + results.forEach(({ type, data }) => { + rawData[type] = data + }) + + // Always return consistent structure with empty arrays for unfiltered types + const result = { + capabilities: rawData.capabilities, + tools: [], + prompts: [], + resources: [], + resourceTemplates: [] + } + + if (filter === 'all' || filter === 'tools') { + result.tools = rawData.tools + } + if (filter === 'all' || filter === 'prompts') { + result.prompts = rawData.prompts } - await Promise.all(promises) - return primitives + if (filter === 'all' || filter === 'resources') { + result.resources = rawData.resources + result.resourceTemplates = rawData.resourceTemplates + } + + return result +} + +// Command mapping for list operations +export const LIST_COMMANDS = { + 'list-tools': 'tools', + 'list-resources': 'resources', + 'list-prompts': 'prompts', + 'list-all': 'all' +} + +// Unified listing function that handles all list commands +async function executeListCommand(client, command, options = {}) { + const filter = LIST_COMMANDS[command] + if (!filter) { + throw new Error(`Unknown list command: ${command}`) + } + return await listPrimitives(client, filter) } async function connectServer(transport, options = {}) { @@ -83,20 +130,43 @@ async function connectServer(transport, options = {}) { throw err } - const primitives = await listPrimitives(client) + const data = await listPrimitives(client) spinner.success(`Connected, server capabilities: ${Object.keys(client.getServerCapabilities()).join(', ')}`) + // Build choices array from the consistent data structure + const choices = [] + data.tools.forEach((item) => choices.push({ + type: 'tool', + value: item, + title: colors.bold('tool(' + item.name + ')'), + description: formatDescription(item.description, options.compact) + })) + data.resources.forEach((item) => choices.push({ + type: 'resource', + value: item, + title: colors.bold('resource(' + item.name + ')'), + description: formatDescription(item.description, options.compact) + })) + data.resourceTemplates.forEach((item) => choices.push({ + type: 'resource-template', + value: item, + title: colors.bold('resource-template(' + item.name + ')'), + description: formatDescription(item.description, options.compact) + })) + data.prompts.forEach((item) => choices.push({ + type: 'prompt', + value: item, + title: colors.bold('prompt(' + item.name + ')'), + description: formatDescription(item.description, options.compact) + })) + while (true) { const { primitive } = await prompts( { name: 'primitive', type: 'autocomplete', message: 'Pick a primitive', - choices: primitives.map((p) => ({ - title: colors.bold(p.type + '(' + p.value.name + ')'), - description: formatDescription(p.value.description, options.compact), - value: p, - })), + choices: choices, }, { onCancel: async () => { @@ -253,7 +323,7 @@ export async function runWithConfig(configPath, options = {}) { } } -async function connectRemoteServer(uri, initialTransport, options = {}) { +async function connectRemoteServer(uri, initialTransport, connectionHandler = null, options = {}) { const oauthConfig = { port: await getPort({ port: 49153 }), path: '/oauth/callback' } const createTransport = () => { const serverId = crypto.createHash('sha256').update(uri).digest('hex') @@ -261,9 +331,12 @@ async function connectRemoteServer(uri, initialTransport, options = {}) { const authProvider = new McpOAuthClientProvider(serverId, oauthRedirectUrl) return initialTransport(authProvider) } + const transport = createTransport() + const handler = connectionHandler || ((t, opts) => connectServer(t, opts)) + try { - await connectServer(transport, options) + return await handler(transport, options) } catch (err) { if (!(err instanceof UnauthorizedError)) { throw err @@ -273,15 +346,226 @@ async function connectRemoteServer(uri, initialTransport, options = {}) { const authCode = await callbackServer.listenForCode(oauthConfig.port, oauthConfig.path) await transport.finishAuth(authCode) spinner.success('Authorization successful') - // connect again with a new transport - await connectServer(createTransport(), options) + + // Connect again with a new transport + return await handler(createTransport(), options) } } export async function runWithSSE(uri, options = {}) { - await connectRemoteServer(uri, (authProvider) => new SSEClientTransport(new URL(uri), { authProvider }), options) + await connectRemoteServer(uri, (authProvider) => createSSETransport(uri, authProvider), null, options) } export async function runWithURL(uri, options = {}) { - await connectRemoteServer(uri, (authProvider) => new StreamableHTTPClientTransport(new URL(uri), { authProvider }), options) + await connectRemoteServer(uri, (authProvider) => createHTTPTransport(uri, authProvider), null, options) +} + +function formatListTools(tools, options = {}) { + if (tools.length === 0) { + return 'No tools available' + } + + const output = [colors.bold(`Tools (${tools.length}):`)] + tools.forEach((tool) => { + output.push(` ${colors.cyan(tool.name)}`) + if (tool.description) { + const desc = formatDescription(tool.description, options.compact) + output.push(` ${colors.dim(desc)}`) + } + }) + + return output.join('\n') +} + +function formatListPrompts(prompts, options = {}) { + if (prompts.length === 0) { + return 'No prompts available' + } + + const output = [colors.bold(`Prompts (${prompts.length}):`)] + prompts.forEach((prompt) => { + output.push(` ${colors.cyan(prompt.name)}`) + if (prompt.description) { + const desc = formatDescription(prompt.description, options.compact) + output.push(` ${colors.dim(desc)}`) + } + if (!options.summary && prompt.arguments && prompt.arguments.length > 0) { + output.push(` Arguments: ${prompt.arguments.map(arg => arg.name).join(', ')}`) + } + }) + + return output.join('\n') +} + +function formatListResources(resources, resourceTemplates, options = {}) { + const totalCount = resources.length + resourceTemplates.length + + if (totalCount === 0) { + return 'No resources available' + } + + const output = [colors.bold(`Resources (${totalCount}):`)] + + if (resources.length > 0) { + const label = options.summary ? 'Static:' : 'Static Resources:' + output.push(` ${colors.yellow(label)}`) + resources.forEach((resource) => { + output.push(` ${colors.cyan(resource.uri)}`) + if (!options.summary && resource.name) { + output.push(` Name: ${resource.name}`) + } + if (!options.summary && resource.description) { + const desc = formatDescription(resource.description, options.compact) + output.push(` ${colors.dim(desc)}`) + } + }) + } + + if (resourceTemplates.length > 0) { + const label = options.summary ? 'Templates:' : 'Resource Templates:' + output.push(` ${colors.yellow(label)}`) + resourceTemplates.forEach((template) => { + output.push(` ${colors.cyan(template.uriTemplate)}`) + if (!options.summary && template.name) { + output.push(` Name: ${template.name}`) + } + if (!options.summary && template.description) { + const desc = formatDescription(template.description, options.compact) + output.push(` ${colors.dim(desc)}`) + } + }) + } + + return output.join('\n') +} + +function formatListAll(data, options = {}) { + const { capabilities, tools, resources, resourceTemplates, prompts } = data + const output = [] + const summaryOptions = { ...options, summary: true } + + output.push(colors.bold('Server Capabilities:')) + output.push(` ${Object.keys(capabilities).join(', ') || 'None'}`) + output.push('') + + if (tools.length > 0) { + output.push(formatListTools(tools, options)) + output.push('') + } + + const totalResources = resources.length + resourceTemplates.length + if (totalResources > 0) { + output.push(formatListResources(resources, resourceTemplates, summaryOptions)) + output.push('') + } + + if (prompts.length > 0) { + output.push(formatListPrompts(prompts, summaryOptions)) + } + + return output.join('\n') +} + +function formatListOutput(data, command, options = {}) { + if (options.json) { + return JSON.stringify(data, null, 2) + } + + const filter = LIST_COMMANDS[command] + + switch (filter) { + case 'tools': + return formatListTools(data.tools, options) + case 'prompts': + return formatListPrompts(data.prompts, options) + case 'resources': + return formatListResources(data.resources, data.resourceTemplates, options) + case 'all': + return formatListAll(data, options) + default: + throw new Error(`Unknown filter: ${filter}`) + } +} + +async function connectServerNonInteractive(transport, options = {}) { + const spinner = createSpinner('Connecting to server...') + + let client + try { + client = await createClient() + await client.connect(transport) + } catch (err) { + spinner.stop() + throw err + } + + spinner.success(`Connected, server capabilities: ${Object.keys(client.getServerCapabilities()).join(', ')}`) + return client +} + +// connectRemoteServerForListing replaced by connectRemoteServer with connectionHandler + +export async function runListCommand(configPath, serverName, command, options = {}) { + try { + let client + + if (options.url) { + client = await connectRemoteServer( + options.url, + (authProvider) => createHTTPTransport(options.url, authProvider), + (transport, opts) => connectServerNonInteractive(transport, opts), + options + ) + } else if (options.sse) { + client = await connectRemoteServer( + options.sse, + (authProvider) => createSSETransport(options.sse, authProvider), + (transport, opts) => connectServerNonInteractive(transport, opts), + options + ) + } else { + // Config-based server + const defaultConfigFile = getClaudeConfigPath() + const config = await readConfig(configPath || defaultConfigFile, { silent: true }) + + if (!config.mcpServers || isEmpty(config.mcpServers)) { + throw new Error('No mcp servers found in config') + } + + const serverConfig = config.mcpServers[serverName] + if (!serverConfig) { + throw new Error(`Server '${serverName}' not found in config`) + } + + // Check if this is a URL/SSE server or stdio server + if (serverConfig.url) { + // URL-based server from config - try HTTP first since SSE may not be working + client = await connectRemoteServer( + serverConfig.url, + (authProvider) => createHTTPTransport(serverConfig.url, authProvider), + (transport, opts) => connectServerNonInteractive(transport, opts), + options + ) + } else { + // Stdio server from config + if (serverConfig.env) { + serverConfig.env = { ...serverConfig.env, PATH: process.env.PATH } + } + + const transport = new StdioClientTransport(serverConfig) + client = await connectServerNonInteractive(transport, options) + } + } + + const result = await executeListCommand(client, command, options) + + await client.close() + + const output = formatListOutput(result, command, options) + console.log(output) + + } catch (err) { + console.error(colors.red(`Error: ${err.message}`)) + process.exit(1) + } } From 0b12cddc181782af240a69052ae3de2e857283bf Mon Sep 17 00:00:00 2001 From: Josh Nichols Date: Sat, 6 Sep 2025 23:02:06 -0400 Subject: [PATCH 2/5] docs: add documentation for new list commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive documentation for the new non-interactive list commands: - list-tools, list-resources, list-prompts, list-all - Examples for config-based, stdio, and remote servers - JSON output flag usage for scripting 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- README.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/README.md b/README.md index 3a969de..5fd96d0 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,42 @@ npx @wong2/mcp-cli -c config.json get-prompt filesystem:create_summary --args '{ This mode is useful for scripting and automation, as it bypasses all interactive prompts and executes the specified primitive directly. +### List server capabilities + +Inspect server capabilities without entering interactive mode: + +```bash +# List all tools with descriptions +npx @wong2/mcp-cli [--config config.json] list-tools + +# List all resources and resource templates +npx @wong2/mcp-cli [--config config.json] list-resources + +# List all prompts with arguments +npx @wong2/mcp-cli [--config config.json] list-prompts + +# Show overview of all server capabilities +npx @wong2/mcp-cli [--config config.json] list-all +``` + +Examples: + +```bash +# Server from config file +npx @wong2/mcp-cli list-tools sqlite + +# Direct stdio server +npx @wong2/mcp-cli list-tools npx @modelcontextprotocol/server-sqlite /path/to/db + +# Remote server via URL +npx @wong2/mcp-cli --url http://localhost:8000/mcp list-resources + +# JSON output for scripting +npx @wong2/mcp-cli list-all sqlite --json +``` + +These commands are useful for exploring server capabilities and scripting automation workflows. + ### Purge stored data (OAuth tokens, etc.) ```bash From 4fcfcb296f19907e869ba3d816872f96fe23f22f Mon Sep 17 00:00:00 2001 From: Josh Nichols Date: Sun, 7 Sep 2025 11:44:25 -0400 Subject: [PATCH 3/5] fix: restore interactive mode primitive display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix regression in interactive mode where no primitives were shown after connecting to server. The filtering logic in listPrimitives was incorrectly excluding all primitive types when filter=null (interactive mode). Refactored to use existing shouldInclude() helper function which properly handles null filters for interactive mode while maintaining compatibility with filtered list commands. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/mcp.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/mcp.js b/src/mcp.js index 743d64f..5cbd17e 100644 --- a/src/mcp.js +++ b/src/mcp.js @@ -87,13 +87,13 @@ async function listPrimitives(client, filter = null) { resourceTemplates: [] } - if (filter === 'all' || filter === 'tools') { + if (shouldInclude(filter, 'tools')) { result.tools = rawData.tools } - if (filter === 'all' || filter === 'prompts') { + if (shouldInclude(filter, 'prompts')) { result.prompts = rawData.prompts } - if (filter === 'all' || filter === 'resources') { + if (shouldInclude(filter, 'resources')) { result.resources = rawData.resources result.resourceTemplates = rawData.resourceTemplates } From 19ad32044936508445603e81c6c8ba398ba3ebf8 Mon Sep 17 00:00:00 2001 From: Josh Nichols Date: Sun, 7 Sep 2025 11:47:16 -0400 Subject: [PATCH 4/5] fix: support HTTP/SSE servers in interactive mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix ERR_INVALID_ARG_TYPE error when using HTTP/SSE MCP servers from config in interactive mode. The runWithConfig function was incorrectly trying to use StdioClientTransport for all servers regardless of transport type. Now properly detects URL-based servers and uses connectRemoteServer with HTTP transport, while maintaining stdio support for command-based servers. Matches the logic already implemented in runListCommand for consistency. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/mcp.js | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/src/mcp.js b/src/mcp.js index 5cbd17e..fbcbaad 100644 --- a/src/mcp.js +++ b/src/mcp.js @@ -312,14 +312,27 @@ export async function runWithConfig(configPath, options = {}) { } const server = await pickServer(config) const serverConfig = config.mcpServers[server] - if (serverConfig.env) { - serverConfig.env = { ...serverConfig.env, PATH: process.env.PATH } - } - const transport = new StdioClientTransport(serverConfig) - try { - await connectServer(transport, options) - } finally { - await transport.close() + + // Check if this is a URL/SSE server or stdio server + if (serverConfig.url) { + // URL-based server from config - use HTTP transport + await connectRemoteServer( + serverConfig.url, + (authProvider) => createHTTPTransport(serverConfig.url, authProvider), + null, + options + ) + } else { + // Stdio server from config + if (serverConfig.env) { + serverConfig.env = { ...serverConfig.env, PATH: process.env.PATH } + } + const transport = new StdioClientTransport(serverConfig) + try { + await connectServer(transport, options) + } finally { + await transport.close() + } } } From ecf24bf0cc356cd13b82ef633767f486209d7ea6 Mon Sep 17 00:00:00 2001 From: Josh Nichols Date: Mon, 8 Sep 2025 12:05:31 -0400 Subject: [PATCH 5/5] fix: restore interactive mode primitive selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix regression where selecting primitives in interactive mode would not execute them. The choices array structure was changed during list commands refactoring but the selection handler still expected the old format with type and value properties. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/mcp.js | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/mcp.js b/src/mcp.js index fbcbaad..88721ee 100644 --- a/src/mcp.js +++ b/src/mcp.js @@ -136,28 +136,24 @@ async function connectServer(transport, options = {}) { // Build choices array from the consistent data structure const choices = [] data.tools.forEach((item) => choices.push({ - type: 'tool', - value: item, title: colors.bold('tool(' + item.name + ')'), - description: formatDescription(item.description, options.compact) + description: formatDescription(item.description, options.compact), + value: { type: 'tool', value: item } })) data.resources.forEach((item) => choices.push({ - type: 'resource', - value: item, title: colors.bold('resource(' + item.name + ')'), - description: formatDescription(item.description, options.compact) + description: formatDescription(item.description, options.compact), + value: { type: 'resource', value: item } })) data.resourceTemplates.forEach((item) => choices.push({ - type: 'resource-template', - value: item, title: colors.bold('resource-template(' + item.name + ')'), - description: formatDescription(item.description, options.compact) + description: formatDescription(item.description, options.compact), + value: { type: 'resource-template', value: item } })) data.prompts.forEach((item) => choices.push({ - type: 'prompt', - value: item, title: colors.bold('prompt(' + item.name + ')'), - description: formatDescription(item.description, options.compact) + description: formatDescription(item.description, options.compact), + value: { type: 'prompt', value: item } })) while (true) {