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 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..88721ee 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: [] } - await Promise.all(promises) - return primitives + + 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 (shouldInclude(filter, 'tools')) { + result.tools = rawData.tools + } + if (shouldInclude(filter, 'prompts')) { + result.prompts = rawData.prompts + } + if (shouldInclude(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,39 @@ 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({ + title: colors.bold('tool(' + item.name + ')'), + description: formatDescription(item.description, options.compact), + value: { type: 'tool', value: item } + })) + data.resources.forEach((item) => choices.push({ + title: colors.bold('resource(' + item.name + ')'), + description: formatDescription(item.description, options.compact), + value: { type: 'resource', value: item } + })) + data.resourceTemplates.forEach((item) => choices.push({ + title: colors.bold('resource-template(' + item.name + ')'), + description: formatDescription(item.description, options.compact), + value: { type: 'resource-template', value: item } + })) + data.prompts.forEach((item) => choices.push({ + title: colors.bold('prompt(' + item.name + ')'), + description: formatDescription(item.description, options.compact), + value: { type: 'prompt', value: item } + })) + 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 () => { @@ -242,18 +308,31 @@ 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() + } } } -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 +340,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 +355,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) + } }