diff --git a/CHANGELOG.md b/CHANGELOG.md index af2e8b00..c866c5fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `--json` output documentation in `--help` for all commands, describing the MCP object shape returned +- `tools-get` now shows an example `tools-call` command with placeholder arguments based on the tool's schema + +### Fixed + +- `build:readme` script failing on macOS due to `sed -i` platform difference + ## [0.2.4] - 2026-04-07 ### Security diff --git a/README.md b/README.md index 95d90980..a3d943bd 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,53 @@ mcpc @fs tools-list ``` +Usage: mcpc [<@session>] [] [options] + +Universal command-line client for the Model Context Protocol (MCP). + +Commands: + connect <@session> Connect to an MCP server and start a new named @session + close <@session> Close a session + restart <@session> Restart a session (losing all state) + shell <@session> Open interactive shell for a session + login Interactively login to a server using OAuth and save profile + logout Delete an OAuth profile for a server + clean [resources...] Clean up mcpc data (sessions, profiles, logs, all) + grep Search tools and instructions across all active sessions + x402 [subcommand] [args...] Configure an x402 payment wallet (EXPERIMENTAL) + help [command] [subcommand] Show help for a specific command + +Options: + -j, --json Output in JSON format for scripting + --verbose Enable debug logging + --profile OAuth profile for the server ("default" if not provided) + --schema Validate tool/prompt schema against expected schema + --schema-mode Schema validation mode: strict, compatible (default), ignore + --timeout Request timeout in seconds (default: 300) + --insecure Skip TLS certificate verification (for self-signed certs) + -v, --version Output the version number + -h, --help Display help + +MCP session commands (after connecting): + <@session> Show MCP server info, capabilities, and tools + <@session> grep Search tools and instructions + <@session> tools-list List all server tools + <@session> tools-get Get tool details and schema + <@session> tools-call [arg:=val ... | | prompts-list + <@session> prompts-get [arg:=val ... | | resources-list + <@session> resources-read + <@session> resources-subscribe + <@session> resources-unsubscribe + <@session> resources-templates-list + <@session> tasks-list + <@session> tasks-get + <@session> tasks-cancel + <@session> logging-set-level + <@session> ping + +Run "mcpc" without arguments to show active sessions and OAuth profiles. ``` ### General actions diff --git a/docs/TODOs.md b/docs/TODOs.md index 492582bc..08544c43 100644 --- a/docs/TODOs.md +++ b/docs/TODOs.md @@ -1,6 +1,9 @@ # TODOs +- "mcpc help tools-call" - show more info how to pass args, including stdio pipe and JSON. Maybe add short examples. +Make "mcpc @apify grep --help" and "mcpc grep --help" more consistent with info what they print. +The former should provide the --json example. ## NEW @@ -30,6 +33,8 @@ - Make "mcpc connect mcp.apify.com" work without @session, and generate session name on best effort basis (e.g. use the main hostname without TLD + suffix) +- and finally, "mcpc connect" should connect to all server configs found - see https://www.withone.ai/docs/cli#mcp-server-installation + - mcpc @apify tools-get fetch-actor-details => should print also "object" properties in human mode - mcpc @apify tools-call xxx --help / "mcpc @apify/xxx --help" should print tools-get + command info diff --git a/scripts/update-readme.sh b/scripts/update-readme.sh index 1e687f52..5187a0eb 100755 --- a/scripts/update-readme.sh +++ b/scripts/update-readme.sh @@ -66,7 +66,8 @@ echo " Done" echo "Updating table of contents..." doctoc "$README" --github --notitle --maxlevel 2 # Remove mcpc: entries from TOC (internal anchors that shouldn't be in TOC) -sed -i '/^- \[mcpc:/d' "$README" +# Use temp file for cross-platform compatibility (macOS sed -i requires different syntax) +sed '/^- \[mcpc:/d' "$README" > "$README.tmp" && mv "$README.tmp" "$README" echo " Done" # Step 3: Check for broken internal links diff --git a/src/cli/commands/grep.ts b/src/cli/commands/grep.ts index 80bf126c..98687682 100644 --- a/src/cli/commands/grep.ts +++ b/src/cli/commands/grep.ts @@ -421,7 +421,7 @@ function formatGrepResultHuman( pattern && options ? highlightMatch(result.instructions, pattern, options) : result.instructions; - lines.push(`${indent}${chalk.bold('Instructions:')} ${chalk.dim('````' + snippet + '````')}`); + lines.push(`${indent}${chalk.bold('Instructions:')} ${chalk.dim('````' + snippet + '````')}`); } lines.push( ...formatResultSection('Tools', result.tools, formatToolLine as (item: never) => string, indent) diff --git a/src/cli/commands/tools.ts b/src/cli/commands/tools.ts index fe7f9547..9b7173ba 100644 --- a/src/cli/commands/tools.ts +++ b/src/cli/commands/tools.ts @@ -4,7 +4,13 @@ import ora from 'ora'; import chalk from 'chalk'; -import { formatOutput, formatToolDetail, formatSuccess, formatWarning } from '../output.js'; +import { + formatOutput, + formatToolDetail, + formatToolCallExample, + formatSuccess, + formatWarning, +} from '../output.js'; import { ClientError } from '../../lib/errors.js'; import type { CommandOptions, TaskUpdate } from '../../lib/types.js'; import { withMcpClient } from '../helpers.js'; @@ -85,6 +91,10 @@ export async function getTool( if (options.outputMode === 'human') { console.log(formatToolDetail(tool)); + const example = formatToolCallExample(tool, target); + if (example) { + console.log('\n' + example + '\n'); + } } else { console.log(formatOutput(tool, 'json')); } diff --git a/src/cli/index.ts b/src/cli/index.ts index 6b1c9be4..a57953b5 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -122,6 +122,18 @@ function getOptionsFromCommand(command: Command): HandlerOptions { return options; } +/** + * Format a JSON output help line with backtick-style Markdown formatting. + * Optional schemaUrl adds a "Schema:" link for AI agents. + */ +function jsonHelp(description: string, shape?: string, schemaUrl?: string): string { + const line = shape ? ` ${description}: ${shape}` : ` ${description}`; + const link = schemaUrl ? `\n Schema: ${schemaUrl}` : ''; + return `\n${chalk.bold('JSON output (--json):')}\n${line}${link}\n`; +} + +const SCHEMA_BASE = 'https://modelcontextprotocol.io/specification/2025-11-25/schema'; + async function main(): Promise { const args = process.argv.slice(2); @@ -153,17 +165,23 @@ async function main(): Promise { // Check for help flag // x402 has its own Commander program with full subcommand help, so pass --help through + // Session commands (@name ...) also handle --help via their own Commander program if (args.includes('--help') || args.includes('-h')) { - if (args.includes('x402')) { + // Check if this is a session command — let it fall through to session handling + const hasSessionArg = args.some((a) => a.startsWith('@') && !a.startsWith('--')); + if (hasSessionArg) { + // Fall through — handleSessionCommands will parse --help via Commander + } else if (args.includes('x402')) { const x402Index = args.indexOf('x402'); const x402Args = args.slice(x402Index + 1); await handleX402Command(x402Args); await closeFileLogger(); return; + } else { + const program = createTopLevelProgram(); + await program.parseAsync(process.argv); + return; } - const program = createTopLevelProgram(); - await program.parseAsync(process.argv); - return; } // Validate all options are known (before any processing) @@ -402,7 +420,7 @@ Full docs: ${docsUrl}` ${chalk.bold('Server formats:')} mcp.apify.com Remote HTTP server (https:// added automatically) ~/.vscode/mcp.json:puppeteer Config file entry (file:entry) -` +${jsonHelp('`InitializeResult`', '`{ protocolVersion, capabilities, serverInfo, instructions?, tools? }`', `${SCHEMA_BASE}#initializeresult`)}` ) .action(async (server, sessionName, opts, command) => { if (!server) { @@ -460,6 +478,7 @@ ${chalk.bold('Server formats:')} .command('close [@session]') .usage('<@session>') .description('Close a session') + .addHelpText('after', jsonHelp('`{ sessionName, closed: true }`')) .action(async (sessionName, _opts, command) => { if (!sessionName) { throw new ClientError('Missing required argument: @session\n\nExample: mcpc close @myapp'); @@ -508,6 +527,7 @@ ${chalk.bold('Server formats:')} '--client-secret ', 'OAuth client secret (for servers without dynamic client registration)' ) + .addHelpText('after', jsonHelp('`{ profile, serverUrl, scopes }`')) .action(async (server, opts, command) => { if (!server) { throw new ClientError( @@ -529,6 +549,7 @@ ${chalk.bold('Server formats:')} .usage('') .description('Delete an OAuth profile for a server') .option('--profile ', 'Profile name (default: "default")') + .addHelpText('after', jsonHelp('`{ profile, serverUrl, deleted: true, affectedSessions }`')) .action(async (server, opts, command) => { if (!server) { throw new ClientError( @@ -555,7 +576,7 @@ ${chalk.bold('Resources:')} all Remove all of the above Without arguments, performs safe cleanup of stale data only. -` +${jsonHelp('`{ crashedBridges, expiredSessions, orphanedBridgeLogs, sessions, profiles, logs }`')}` ) .action(async (resources: string[], _opts, command) => { const globalOpts = getOptionsFromCommand(command); @@ -606,7 +627,7 @@ ${chalk.bold('Examples:')} mcpc @apify grep "actor" Search within a single session mcpc grep "file" --json JSON output for scripting mcpc grep "actor" -m 5 Show at most 5 results -` +${jsonHelp('`[{ sessionName, tools?: Tool[], resources?: Resource[], prompts?: Prompt[], instructions?: string[] }]`')}` ) .action(async (pattern, opts, command) => { if (!pattern) { @@ -664,9 +685,15 @@ ${chalk.bold('Examples:')} } // Check session subcommands - const dummyProgram = new Command(); - dummyProgram.name('mcpc <@session>'); - registerSessionCommands(dummyProgram, '@dummy'); + const dummyProgram = createSessionProgram(); + registerSessionCommands(dummyProgram, '<@session>'); + for (const cmd of dummyProgram.commands) { + cmd.option('-j, --json', 'Output in JSON format'); + cmd.helpOption('-h, --help', 'Display help'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const helpOpt = (cmd as any)._getHelpOption?.(); + if (helpOpt) helpOpt.hidden = true; + } const sessionCmd = dummyProgram.commands.find( (c) => c.name() === cmdName || c.aliases().includes(cmdName) ); @@ -702,7 +729,14 @@ function registerSessionCommands(program: Command, session: string): void { // Help command program .command('help') - .description('Show server instructions and available capabilities') + .description('Show MCP server instructions, capabilities, and tools.') + .addHelpText( + 'after', + jsonHelp( + '`InitializeResult`', + '`{ protocolVersion, capabilities, serverInfo, instructions?, tools? }`' + ) + ) .action(async (_options, command) => { await sessions.showHelp(session, getOptionsFromCommand(command)); }); @@ -710,7 +744,7 @@ function registerSessionCommands(program: Command, session: string): void { // Shell command program .command('shell') - .description('Interactive shell for the session') + .description('Launch interactive MCP shell for the session.') .action(async () => { await sessions.openShell(session); }); @@ -718,7 +752,7 @@ function registerSessionCommands(program: Command, session: string): void { // Close command program .command('close', { hidden: true }) - .description('Close the session') + .description('Close the MCP server session.') .action(async (_options, command) => { await sessions.closeSession(session, getOptionsFromCommand(command)); }); @@ -726,7 +760,7 @@ function registerSessionCommands(program: Command, session: string): void { // Restart command program .command('restart') - .description('Restart the session (stop and start the bridge)') + .description('Restart the MCP server session (losing all state).') .action(async (_options, command) => { await sessions.restartSession(session, getOptionsFromCommand(command)); }); @@ -734,32 +768,68 @@ function registerSessionCommands(program: Command, session: string): void { // Tools commands program .command('tools') - .description('List available tools (shorthand for tools-list)') + .description('List all available MCP server tools (shorthand for tools-list).') .option('--full', 'Show full tool details including complete input schema') + .addHelpText( + 'after', + jsonHelp( + 'Array of `Tool` objects', + '`[{ name, description?, inputSchema, annotations? }, ...]`', + `${SCHEMA_BASE}#tool` + ) + ) .action(async (_options, command) => { await tools.listTools(session, getOptionsFromCommand(command)); }); program .command('tools-list') - .description('List available tools') + .description('List all available MCP server tools.') .option('--full', 'Show full tool details including complete input schema') + .addHelpText( + 'after', + jsonHelp( + 'Array of `Tool` objects', + '`[{ name, description?, inputSchema, annotations? }, ...]`', + `${SCHEMA_BASE}#tool` + ) + ) .action(async (_options, command) => { await tools.listTools(session, getOptionsFromCommand(command)); }); program .command('tools-get ') - .description('Get information about a specific tool') + .description('Get full details and schema for a specific MCP server tool.') + .addHelpText( + 'after', + jsonHelp( + '`Tool` object', + '`{ name, description?, inputSchema, annotations? }`', + `${SCHEMA_BASE}#tool` + ) + ) .action(async (name, _options, command) => { await tools.getTool(session, name, getOptionsFromCommand(command)); }); program .command('tools-call [args...]') - .description('Call a tool with arguments (key:=value pairs or JSON)') - .option('--task', 'Use task execution (experimental)') + .description('Call an MCP server tool with arguments (key:=value pairs or JSON)') + .option('--task', 'Use async task execution (experimental)') .option('--detach', 'Start task and return immediately with task ID (implies --task)') + .addHelpText( + 'after', + ` +${chalk.bold('Arguments:')} + key:=value pairs mcpc ${session} tools-call search query:=hello limit:=10 + Inline JSON mcpc ${session} tools-call search '{"query":"hello"}' + Stdin pipe echo '{"query":"hello"}' | mcpc ${session} tools-call search + + Values are auto-parsed: strings, numbers, booleans, JSON objects/arrays. + To force a string, wrap in quotes: id:='"123"' +${jsonHelp('`CallToolResult`', '`{ content: [{ type, text?, ... }], isError?, structuredContent? }`', `${SCHEMA_BASE}#calltoolresult`)}` + ) .action(async (name, args, options, command) => { await tools.callTool(session, name, { args, @@ -772,21 +842,33 @@ function registerSessionCommands(program: Command, session: string): void { // Tasks commands program .command('tasks-list') - .description('List active tasks') + .description('List all MCP server tasks.') + .addHelpText( + 'after', + jsonHelp( + '`{ tasks: Task[] }`', + '`{ tasks: [{ taskId, status, statusMessage?, createdAt?, lastUpdatedAt? }] }`' + ) + ) .action(async (_options, command) => { await tasks.listTasks(session, getOptionsFromCommand(command)); }); program .command('tasks-get ') - .description('Get status of a specific task') + .description('Get status of a specific MCP task.') + .addHelpText( + 'after', + jsonHelp('`Task` object', '`{ taskId, status, statusMessage?, createdAt?, lastUpdatedAt? }`') + ) .action(async (taskId, _options, command) => { await tasks.getTask(session, taskId, getOptionsFromCommand(command)); }); program .command('tasks-cancel ') - .description('Cancel a running task') + .description('Cancel a running MCP task.') + .addHelpText('after', jsonHelp('`Task` object', '`{ taskId, status, statusMessage? }`')) .action(async (taskId, _options, command) => { await tasks.cancelTask(session, taskId, getOptionsFromCommand(command)); }); @@ -794,23 +876,47 @@ function registerSessionCommands(program: Command, session: string): void { // Resources commands program .command('resources') - .description('List available resources (shorthand for resources-list)') + .description('List available MCP server resources (shorthand for resources-list).') + .addHelpText( + 'after', + jsonHelp( + 'Array of `Resource` objects', + '`[{ uri, name?, description?, mimeType? }, ...]`', + `${SCHEMA_BASE}#resource` + ) + ) .action(async (_options, command) => { await resources.listResources(session, getOptionsFromCommand(command)); }); program .command('resources-list') - .description('List available resources') + .description('List available MCP server resources.') + .addHelpText( + 'after', + jsonHelp( + 'Array of `Resource` objects', + '`[{ uri, name?, description?, mimeType? }, ...]`', + `${SCHEMA_BASE}#resource` + ) + ) .action(async (_options, command) => { await resources.listResources(session, getOptionsFromCommand(command)); }); program .command('resources-read ') - .description('Get a resource by URI') + .description('Get an MCP server resource by URI.') .option('-o, --output ', 'Write resource to file') .option('--max-size ', 'Maximum resource size in bytes') + .addHelpText( + 'after', + jsonHelp( + '`ReadResourceResult`', + '`{ contents: [{ uri, mimeType?, text? | blob? }] }`', + `${SCHEMA_BASE}#readresourceresult` + ) + ) .action(async (uri, options, command) => { await resources.getResource(session, uri, { output: options.output, @@ -821,21 +927,31 @@ function registerSessionCommands(program: Command, session: string): void { program .command('resources-subscribe ') - .description('Subscribe to resource updates') + .description('Subscribe to MCP server resource updates.') + .addHelpText('after', jsonHelp('`{ subscribed: true, uri: string }`')) .action(async (uri, _options, command) => { await resources.subscribeResource(session, uri, getOptionsFromCommand(command)); }); program .command('resources-unsubscribe ') - .description('Unsubscribe from resource updates') + .description('Unsubscribe from MCP server resource updates.') + .addHelpText('after', jsonHelp('`{ unsubscribed: true, uri: string }`')) .action(async (uri, _options, command) => { await resources.unsubscribeResource(session, uri, getOptionsFromCommand(command)); }); program .command('resources-templates-list') - .description('List available resource templates') + .description('List available MCP server resource templates.') + .addHelpText( + 'after', + jsonHelp( + 'Array of `ResourceTemplate` objects', + '`[{ uriTemplate, name?, description?, mimeType? }, ...]`', + `${SCHEMA_BASE}#resourcetemplate` + ) + ) .action(async (_options, command) => { await resources.listResourceTemplates(session, getOptionsFromCommand(command)); }); @@ -843,21 +959,45 @@ function registerSessionCommands(program: Command, session: string): void { // Prompts commands program .command('prompts') - .description('List available prompts (shorthand for prompts-list)') + .description('List all available MCP server prompts (shorthand for prompts-list).') + .addHelpText( + 'after', + jsonHelp( + 'Array of `Prompt` objects', + '`[{ name, description?, arguments?: [{ name, required? }] }, ...]`', + `${SCHEMA_BASE}#prompt` + ) + ) .action(async (_options, command) => { await prompts.listPrompts(session, getOptionsFromCommand(command)); }); program .command('prompts-list') - .description('List available prompts') + .description('List all available MCP server prompts.') + .addHelpText( + 'after', + jsonHelp( + 'Array of `Prompt` objects', + '`[{ name, description?, arguments?: [{ name, required? }] }, ...]`', + `${SCHEMA_BASE}#prompt` + ) + ) .action(async (_options, command) => { await prompts.listPrompts(session, getOptionsFromCommand(command)); }); program .command('prompts-get [args...]') - .description('Get a prompt by name with arguments (key:=value pairs or JSON)') + .description('Get a prompt by name with arguments (key:=value pairs or JSON).') + .addHelpText( + 'after', + jsonHelp( + '`GetPromptResult`', + '`{ description?, messages: [{ role, content: { type, text?, ... } }] }`', + `${SCHEMA_BASE}#getpromptresult` + ) + ) .action(async (name, args, _options, command) => { await prompts.getPrompt(session, name, { args, @@ -869,8 +1009,9 @@ function registerSessionCommands(program: Command, session: string): void { program .command('logging-set-level ') .description( - 'Set server logging level (debug, info, notice, warning, error, critical, alert, emergency)' + 'Set MCP server server logging level (debug, info, notice, warning, error, critical, alert, emergency)' ) + .addHelpText('after', jsonHelp('`{ level: string }`')) .action(async (level, _options, command) => { await logging.setLogLevel(session, level, getOptionsFromCommand(command)); }); @@ -878,7 +1019,8 @@ function registerSessionCommands(program: Command, session: string): void { // Server commands program .command('ping') - .description('Ping the MCP server to check if it is alive') + .description('Ping the MCP server to check it is alive.') + .addHelpText('after', jsonHelp('`{ success: true, durationMs: number }`')) .action(async (_options, command) => { await utilities.ping(session, getOptionsFromCommand(command)); }); @@ -886,7 +1028,8 @@ function registerSessionCommands(program: Command, session: string): void { // Grep command: @session grep program .command('grep ') - .description('Search tools and instructions') + .usage(' [options]') + .description('Search objects in an MCP server session.') .option('--tools', 'Search tools') .option('--resources', 'Search resources') .option('--prompts', 'Search prompts') @@ -894,6 +1037,19 @@ function registerSessionCommands(program: Command, session: string): void { .option('-E, --regex', 'Treat pattern as a regular expression') .option('-s, --case-sensitive', 'Case-sensitive matching') .option('-m, --max-results ', 'Limit the number of results') + .addHelpText( + 'after', + ` +${chalk.bold('Type filters:')} + By default, tools and instructions are searched. Use --resources or --prompts + to search those instead. Combine flags to search multiple types. + +${chalk.bold('Examples:')} + mcpc ${session} grep "search" Search tools and instructions + mcpc ${session} grep "search" --resources Search resources only + mcpc ${session} grep "search|find" -E Regex search +${jsonHelp('`{ tools?: Tool[], resources?: Resource[], prompts?: Prompt[], instructions?: string[] }`')}` + ) .action(async (pattern, opts, command) => { const globalOpts = getOptionsFromCommand(command); const maxResults = opts.maxResults ? parseInt(opts.maxResults as string, 10) : undefined; @@ -924,6 +1080,12 @@ function createSessionProgram(): Command { getErrHelpWidth: () => 100, }); + // Match the top-level help styling: bold titles, cyan subcommand text + program.configureHelp({ + styleTitle: (str) => chalk.bold(str), + styleSubcommandText: (str) => chalk.cyan(str), + }); + program .name('mcpc <@session>') .helpOption('-h, --help', 'Display help') @@ -942,8 +1104,9 @@ function createSessionProgram(): Command { * Handle commands for a session target (@name) */ async function handleSessionCommands(session: string, args: string[]): Promise { - // Check if no subcommand provided - show server info - if (!hasSubcommand(args)) { + // Check if no subcommand provided - show server info (unless --help is requested) + const argsSlice = args.slice(2); + if (!hasSubcommand(args) && !argsSlice.includes('--help') && !argsSlice.includes('-h')) { const options = extractOptions(args); if (options.verbose) setVerbose(true); if (options.json) setJsonMode(true); @@ -961,6 +1124,17 @@ async function handleSessionCommands(session: string, args: string[]): Promise 0 ? parts.join(', ') : null; } +/** + * Get the task support mode for a tool ('required', 'optional', or undefined) + */ +export function getToolTaskSupport(tool: Tool): string | undefined { + const toolAny = tool as Record; + const execution = toolAny.execution as Record | undefined; + return execution?.taskSupport as string | undefined; +} + +/** + * Format tool hints: annotations + task support mode. + * Returns a string like "destructive, open-world, task:required" or null if empty. + */ +export function formatToolHints(tool: Tool): string | null { + const parts: string[] = []; + + const annotationsStr = formatToolAnnotations(tool.annotations); + if (annotationsStr) parts.push(annotationsStr); + + const taskSupport = getToolTaskSupport(tool); + if (taskSupport) parts.push(`task:${taskSupport}`); + + return parts.length > 0 ? parts.join(', ') : null; +} + /** * Convert a JSON Schema type definition to a simplified type string * e.g., { type: 'string' } -> 'string' @@ -466,15 +491,8 @@ export function formatToolParamsInline(schema: Record): string export function formatToolLine(tool: Tool): string { const bullet = chalk.dim('*'); const params = formatToolParamsInline(tool.inputSchema as Record); - const parts: string[] = []; - const annotationsStr = formatToolAnnotations(tool.annotations); - if (annotationsStr) parts.push(annotationsStr); - // Show task execution mode - const toolAny = tool as Record; - const execution = toolAny.execution as Record | undefined; - const taskSupport = execution?.taskSupport as string | undefined; - if (taskSupport) parts.push(`task:${taskSupport}`); - const suffix = parts.length > 0 ? ` ${chalk.gray(`[${parts.join(', ')}]`)}` : ''; + const hintsStr = formatToolHints(tool); + const suffix = hintsStr ? ` ${chalk.gray(`[${hintsStr}]`)}` : ''; return `${bullet} ${grayBacktick()}${chalk.cyan(tool.name)} ${params}${grayBacktick()}${suffix}`; } @@ -536,10 +554,10 @@ export function formatToolDetail(tool: Tool): string { lines.push(chalk.bold(`# ${title}`)); } - // Tool header: Tool: `name` [annotations] - const annotationsStr = formatToolAnnotations(tool.annotations); - const annotationsSuffix = annotationsStr ? ` ${chalk.gray(`[${annotationsStr}]`)}` : ''; - lines.push(`${chalk.bold('Tool:')} ${inBackticks(tool.name)}${annotationsSuffix}`); + // Tool header: Tool: `name` [hints] + const hintsStr = formatToolHints(tool); + const hintsSuffix = hintsStr ? ` ${chalk.gray(`[${hintsStr}]`)}` : ''; + lines.push(`${chalk.bold('Tool:')} ${inBackticks(tool.name)}${hintsSuffix}`); // Input args lines.push(''); @@ -568,6 +586,87 @@ export function formatToolDetail(tool: Tool): string { return lines.join('\n'); } +/** + * Generate an example placeholder value for a JSON Schema property. + * Uses the default value if available, otherwise a reasonable placeholder. + */ +function exampleValue(propSchema: Record): string { + // Use default value if available + if (propSchema.default !== undefined) { + return JSON.stringify(propSchema.default); + } + + // Use first enum value if available + if (propSchema.enum && Array.isArray(propSchema.enum) && propSchema.enum.length > 0) { + return JSON.stringify(propSchema.enum[0]); + } + + const schemaType = propSchema.type; + + if (schemaType === 'string') return '"something"'; + if (schemaType === 'number') return '1'; + if (schemaType === 'integer') { + // Respect minimum if set + const min = propSchema.minimum as number | undefined; + return String(min ?? 1); + } + if (schemaType === 'boolean') return 'true'; + + // Union types like ['string', 'null'] + if (Array.isArray(schemaType)) { + const nonNull = schemaType.filter((t) => t !== 'null'); + if (nonNull.includes('string')) return '"something"'; + if (nonNull.includes('number') || nonNull.includes('integer')) return '1'; + if (nonNull.includes('boolean')) return 'true'; + } + + return '"something"'; +} + +/** + * Format a tools-call usage example for a tool, showing how to invoke it. + * Shows required params first, then fills with optional params up to 3 total. + */ +export function formatToolCallExample(tool: Tool, sessionName?: string): string | null { + const schema = tool.inputSchema as Record | undefined; + const properties = schema?.properties as Record> | undefined; + const session = sessionName || '<@session>'; + + // Build --task flag based on task support + const taskSupport = getToolTaskSupport(tool); + const taskFlag = + taskSupport === 'required' ? ' --task' : taskSupport === 'optional' ? ' [--task]' : ''; + + const bullet = chalk.dim('*'); + + if (!properties || Object.keys(properties).length === 0) { + // Tool takes no arguments — still show the simple call + const cmd = `mcpc ${session} tools-call ${tool.name}${taskFlag}`; + return `${chalk.bold('Call example:')}\n${bullet} ${grayBacktick()}${chalk.cyan(cmd)}${grayBacktick()}`; + } + + const requiredNames = (schema?.required as string[]) || []; + const allNames = Object.keys(properties); + const requiredInOrder = allNames.filter((n) => requiredNames.includes(n)); + const optionalInOrder = allNames.filter((n) => !requiredNames.includes(n)); + + // Pick params: all required, then fill optional up to 3 total + const MAX_EXAMPLE_PARAMS = 3; + const params: string[] = [...requiredInOrder]; + if (params.length < MAX_EXAMPLE_PARAMS) { + const remaining = MAX_EXAMPLE_PARAMS - params.length; + params.push(...optionalInOrder.slice(0, remaining)); + } + + const argParts = params.map((name) => { + const val = exampleValue(properties[name] ?? {}); + return `${name}:=${val}`; + }); + + const cmd = `mcpc ${session} tools-call ${tool.name} ${argParts.join(' ')}${taskFlag}`; + return `${chalk.bold('Call example:')}\n${bullet} ${grayBacktick()}${chalk.cyan(cmd)}${grayBacktick()}`; +} + /** * Format a list of resources with Markdown-like display */ diff --git a/test/unit/cli/output.test.ts b/test/unit/cli/output.test.ts index d36a8044..ae853ece 100644 --- a/test/unit/cli/output.test.ts +++ b/test/unit/cli/output.test.ts @@ -55,6 +55,8 @@ import { formatSessionLine, formatHuman, logTarget, + formatToolCallExample, + formatToolHints, } from '../../../src/cli/output.js'; import type { Tool, @@ -828,6 +830,147 @@ describe('formatToolDetail', () => { }); }); +describe('formatToolCallExample', () => { + it('should show required params and fill optional up to 3', () => { + const tool: Tool = { + name: 'read_file', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string' }, + encoding: { type: 'string', default: 'utf-8' }, + tail: { type: 'integer', minimum: 0 }, + }, + required: ['path'], + }, + }; + + const output = formatToolCallExample(tool, '@fs'); + expect(output).not.toBeNull(); + expect(output).toContain('tools-call read_file'); + expect(output).toContain('@fs'); + expect(output).toContain('path:="something"'); + expect(output).toContain('encoding:="utf-8"'); + expect(output).toContain('tail:=0'); + }); + + it('should use default values when available', () => { + const tool: Tool = { + name: 'search', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string' }, + limit: { type: 'number', default: 10 }, + }, + required: ['query'], + }, + }; + + const output = formatToolCallExample(tool, '@test'); + expect(output).toContain('query:="something"'); + expect(output).toContain('limit:=10'); + }); + + it('should show example for tool with no parameters', () => { + const tool: Tool = { + name: 'ping', + inputSchema: { type: 'object', properties: {} }, + }; + + const output = formatToolCallExample(tool, '@srv'); + expect(output).not.toBeNull(); + expect(output).toContain('tools-call ping'); + // Should NOT contain any key:= pairs + expect(output).not.toContain(':='); + }); + + it('should use enum first value as example', () => { + const tool: Tool = { + name: 'set-mode', + inputSchema: { + type: 'object', + properties: { + mode: { type: 'string', enum: ['fast', 'slow', 'normal'] }, + }, + required: ['mode'], + }, + }; + + const output = formatToolCallExample(tool, '@s'); + expect(output).toContain('mode:="fast"'); + }); + + it('should use placeholder <@session> when no session name provided', () => { + const tool: Tool = { + name: 'test', + inputSchema: { type: 'object', properties: { a: { type: 'string' } } }, + }; + + const output = formatToolCallExample(tool); + expect(output).toContain('<@session>'); + }); + + it('should include --task for task:required tools', () => { + const tool = { + name: 'long-run', + inputSchema: { type: 'object', properties: { q: { type: 'string' } }, required: ['q'] }, + execution: { taskSupport: 'required' }, + } as unknown as Tool; + + const output = formatToolCallExample(tool, '@s'); + expect(output).toContain('--task'); + expect(output).not.toContain('[--task]'); + }); + + it('should include [--task] for task:optional tools', () => { + const tool = { + name: 'maybe-async', + inputSchema: { type: 'object', properties: {} }, + execution: { taskSupport: 'optional' }, + } as unknown as Tool; + + const output = formatToolCallExample(tool, '@s'); + expect(output).toContain('[--task]'); + }); +}); + +describe('formatToolHints', () => { + it('should combine annotations and task support', () => { + const tool = { + name: 'test', + inputSchema: { type: 'object', properties: {} }, + annotations: { destructiveHint: true, openWorldHint: true }, + execution: { taskSupport: 'required' }, + } as unknown as Tool; + + const hints = formatToolHints(tool); + expect(hints).toContain('destructive'); + expect(hints).toContain('open-world'); + expect(hints).toContain('task:required'); + }); + + it('should return null when no annotations and no task support', () => { + const tool: Tool = { + name: 'plain', + inputSchema: { type: 'object', properties: {} }, + }; + + expect(formatToolHints(tool)).toBeNull(); + }); + + it('should show only task support when no annotations', () => { + const tool = { + name: 'async-only', + inputSchema: { type: 'object', properties: {} }, + execution: { taskSupport: 'optional' }, + } as unknown as Tool; + + const hints = formatToolHints(tool); + expect(hints).toBe('task:optional'); + }); +}); + describe('formatServerDetails', () => { it('should format server info with all features', () => { const details: ServerDetails = {