diff --git a/CHANGELOG.md b/CHANGELOG.md index 716bdda..4f2172b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - JSON output for session info (`mcpc @session --json` and `mcpc connect --json`) now returns `toolNames` (array of tool name strings) instead of full `tools` objects, keeping it concise and consistent with the human-readable output +- `--schema` and `--schema-mode` options moved from global scope to `tools-get` and `tools-call` only (removed from `prompts-get`) ### Fixed diff --git a/README.md b/README.md index a9edf2f..3ad7979 100644 --- a/README.md +++ b/README.md @@ -111,8 +111,7 @@ 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 named @session (name - auto-generated if omitted) + connect [@session] Connect to an MCP server and start a named @session close <@session> Close a session restart <@session> Restart a session (losing all state) shell <@session> Open interactive shell for a session @@ -127,8 +126,6 @@ Options: --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) --max-chars Truncate output to n characters (ignored in --json mode) --insecure Skip TLS certificate verification (for self-signed certs) @@ -679,12 +676,15 @@ For a complete example script, see [`docs/examples/company-lookup.sh`](./docs/ex ### Schema validation -Validate tool/prompt schemas using the `--schema` option to detect breaking changes early: +The `tools-get` and `tools-call` commands support `--schema` to validate a tool's schema against an expected snapshot. This helps detect breaking changes early in scripts and CI: ```bash # Save expected schema mcpc --json @apify tools-get search-actors > expected.json +# Validate without calling (read-only check) +mcpc @apify tools-get search-actors --schema expected.json + # Validate before calling (fails if schema changed incompatibly) mcpc @apify tools-call search-actors --schema expected.json keywords:="test" ``` diff --git a/src/cli/commands/prompts.ts b/src/cli/commands/prompts.ts index 33fc59d..79c0ba9 100644 --- a/src/cli/commands/prompts.ts +++ b/src/cli/commands/prompts.ts @@ -3,17 +3,9 @@ */ import type { CommandOptions } from '../../lib/types.js'; -import { formatOutput, formatWarning } from '../output.js'; +import { formatOutput } from '../output.js'; import { withMcpClient } from '../helpers.js'; import { parseCommandArgs, hasStdinData, readStdinArgs } from '../parser.js'; -import { ClientError } from '../../lib/errors.js'; -import { - loadSchemaFromFile, - validatePromptSchema, - formatValidationError, - type PromptSchema, - type SchemaMode, -} from '../../lib/schema-validator.js'; /** * List available prompts @@ -73,42 +65,7 @@ export async function getPrompt( promptArgs[key] = typeof value === 'string' ? value : JSON.stringify(value); } - // Load expected schema if provided - let expectedSchema: PromptSchema | undefined; - if (options.schema) { - expectedSchema = (await loadSchemaFromFile(options.schema)) as PromptSchema; - } - await withMcpClient(target, options, async (client, _context) => { - // Validate schema if provided (skip entirely in ignore mode) - const schemaMode: SchemaMode = options.schemaMode || 'compatible'; - if (expectedSchema && schemaMode !== 'ignore') { - const result = await client.listPrompts(); - const actualSchema = result.prompts.find((p) => p.name === name); - - if (!actualSchema) { - throw new ClientError(`Prompt not found: ${name}`); - } - - const validation = validatePromptSchema( - actualSchema as PromptSchema, - expectedSchema, - schemaMode, - promptArgs - ); - - if (!validation.valid) { - throw new ClientError(formatValidationError(validation, `prompt "${name}"`)); - } - - // Show warnings in human mode - if (validation.warnings.length > 0 && options.outputMode === 'human') { - for (const warning of validation.warnings) { - console.log(formatWarning(`Schema warning: ${warning}`)); - } - } - } - const result = await client.getPrompt(name, promptArgs); console.log( diff --git a/src/cli/index.ts b/src/cli/index.ts index ed10c58..05d4f42 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -397,8 +397,6 @@ function createTopLevelProgram(): Command { .option('--json', 'Output in JSON format for scripting') .option('--verbose', 'Enable debug logging') .option('--profile ', 'OAuth profile for the server ("default" if not provided)') - .option('--schema ', 'Validate tool/prompt schema against expected schema') - .option('--schema-mode ', 'Schema validation mode: strict, compatible (default), ignore') .option('--timeout ', 'Request timeout in seconds (default: 300)') .option('--max-chars ', 'Truncate output to n characters (ignored in --json mode)') .option('--insecure', 'Skip TLS certificate verification (for self-signed certs)') @@ -436,9 +434,7 @@ Full docs: ${docsUrl}` program .command('connect [server] [@session]') .usage(' [@session]') - .description( - 'Connect to an MCP server and start a named @session (name auto-generated if omitted)' - ) + .description('Connect to an MCP server and start a named @session') // keep this short .option('-H, --header
', 'HTTP header (can be repeated)') .option('--profile ', 'OAuth profile to use ("default" if skipped)') .option('--no-profile', 'Skip OAuth profile (connect anonymously)') @@ -897,13 +893,19 @@ ${jsonHelp('`{ tools?: Tool[], resources?: Resource[], prompts?: Prompt[], instr program .command('tools-get ') .description('Get details and schema for an MCP tool.') + .option('--schema ', 'Validate tool schema against expected schema') + .option('--schema-mode ', 'Schema validation mode: strict, compatible (default), ignore') .addHelpText( 'after', - jsonHelp( - '`Tool` object', - '`{ name, description?, inputSchema, annotations? }`', - `${SCHEMA_BASE}#tool` - ) + ` +${chalk.bold('Schema validation:')} + --schema Validate against expected schema (save with tools-get --json) + --schema-mode strict | compatible (default) | ignore +${jsonHelp( + '`Tool` object', + '`{ name, description?, inputSchema, annotations? }`', + `${SCHEMA_BASE}#tool` +)}` ) .action(async (name, _options, command) => { await tools.getTool(session, name, getOptionsFromCommand(command)); @@ -915,6 +917,8 @@ ${jsonHelp('`{ tools?: Tool[], resources?: Resource[], prompts?: Prompt[], instr .helpOption(false) // Disable built-in --help so we can intercept it for tool schema .option('--task', 'Use async task execution (experimental)') .option('--detach', 'Start task and return immediately with task ID (implies --task)') + .option('--schema ', 'Validate tool schema against expected schema before calling') + .option('--schema-mode ', 'Schema validation mode: strict, compatible (default), ignore') .addHelpText( 'after', ` @@ -925,6 +929,10 @@ ${chalk.bold('Arguments:')} Values are auto-parsed: strings, numbers, booleans, JSON objects/arrays. To force a string, wrap in quotes: id:='"123"' + +${chalk.bold('Schema validation:')} + --schema Validate tool schema before calling (save with tools-get --json) + --schema-mode strict | compatible (default) | ignore ${jsonHelp('`CallToolResult`', '`{ content: [{ type, text?, ... }], isError?, structuredContent? }`', `${SCHEMA_BASE}#calltoolresult`)}` ) .action(async (name, args, options, command) => { @@ -1177,8 +1185,6 @@ function createSessionProgram(): Command { .option('--json', 'Output in JSON format for scripting and code mode') .option('--verbose', 'Enable debug logging') .option('--profile ', 'OAuth profile override') - .option('--schema ', 'Validate tool/prompt schema against expected schema') - .option('--schema-mode ', 'Schema validation mode: strict, compatible (default), ignore') .option('--timeout ', 'Request timeout in seconds (default: 300)') .option('--max-chars ', 'Truncate output to n characters (ignored in --json mode)') .option('--insecure', 'Skip TLS certificate verification (for self-signed certs)') diff --git a/src/cli/parser.ts b/src/cli/parser.ts index f668fe8..ede2098 100644 --- a/src/cli/parser.ts +++ b/src/cli/parser.ts @@ -2,8 +2,7 @@ * Command-line argument parsing utilities * Pure functions with no external dependencies for easy testing */ -import { existsSync } from 'fs'; -import { ClientError, resolvePath } from '../lib/index.js'; +import { ClientError } from '../lib/index.js'; /** * Check if an environment variable is set to a truthy value @@ -30,19 +29,15 @@ export function getJsonFromEnv(): boolean { } // Global options that take a value (not boolean flags) -const GLOBAL_OPTIONS_WITH_VALUES = [ - '--timeout', - '--profile', - '--schema', - '--schema-mode', - '--max-chars', -]; +const GLOBAL_OPTIONS_WITH_VALUES = ['--timeout', '--profile', '--max-chars']; // All options that take a value — used by optionTakesValue() to correctly skip // the next arg when scanning for command tokens. Includes subcommand-specific // options so misplaced flags still get their values skipped during scanning. const OPTIONS_WITH_VALUES = [ ...GLOBAL_OPTIONS_WITH_VALUES, + '--schema', + '--schema-mode', '-H', '--header', '--proxy', @@ -74,9 +69,6 @@ const KNOWN_OPTIONS = [ '--insecure', ]; -// Valid --schema-mode values -const VALID_SCHEMA_MODES = ['strict', 'compatible', 'ignore']; - /** * All known top-level commands */ @@ -261,15 +253,6 @@ export function validateArgValues(args: string[]): void { break; } - // Validate --schema-mode value - if (arg === '--schema-mode' && nextArg) { - if (!VALID_SCHEMA_MODES.includes(nextArg)) { - throw new ClientError( - `Invalid --schema-mode value: "${nextArg}". Valid modes are: ${VALID_SCHEMA_MODES.join(', ')}` - ); - } - } - // Validate --timeout is a number if (arg === '--timeout' && nextArg) { const timeout = parseInt(nextArg, 10); @@ -280,14 +263,6 @@ export function validateArgValues(args: string[]): void { } } - // Validate --schema file exists - if (arg === '--schema' && nextArg) { - const schemaPath = resolvePath(nextArg); - if (!existsSync(schemaPath)) { - throw new ClientError(`Schema file not found: ${nextArg}`); - } - } - // Validate --proxy format (but don't parse yet, just check basic format) if (arg === '--proxy' && nextArg) { // Basic validation - just check it's not empty diff --git a/test/e2e/suites/basic/errors.test.sh b/test/e2e/suites/basic/errors.test.sh index ca4ef21..9837e61 100755 --- a/test/e2e/suites/basic/errors.test.sh +++ b/test/e2e/suites/basic/errors.test.sh @@ -187,13 +187,6 @@ assert_failure assert_contains "$STDERR" "Invalid header format" test_pass -# Test: invalid --schema-mode value -test_case "invalid --schema-mode fails" -run_mcpc @nonexistent tools-list --schema-mode invalid -assert_failure -assert_contains "$STDERR" "Invalid --schema-mode" -test_pass - # Test: non-numeric --timeout value test_case "non-numeric --timeout fails" run_mcpc @nonexistent tools-list --timeout notanumber @@ -208,11 +201,4 @@ assert_failure assert_contains "$STDERR" "not found" test_pass -# Test: non-existent --schema file -test_case "non-existent --schema file fails" -run_mcpc @nonexistent tools-list --schema /nonexistent/schema-$RANDOM.json -assert_failure -assert_contains "$STDERR" "not found" -test_pass - test_done diff --git a/test/e2e/suites/basic/schema-validation.test.sh b/test/e2e/suites/basic/schema-validation.test.sh index ef801e9..db9b9fd 100755 --- a/test/e2e/suites/basic/schema-validation.test.sh +++ b/test/e2e/suites/basic/schema-validation.test.sh @@ -29,14 +29,6 @@ assert_success echo "$STDOUT" > "$TEST_TMP/echo-schema.json" test_pass -# Save the greeting prompt schema (from prompts-list) -test_case "setup: save greeting prompt schema" -run_mcpc --json "$SESSION" prompts-list -assert_success -# Extract the greeting prompt from the array -echo "$STDOUT" | jq '.[] | select(.name == "greeting")' > "$TEST_TMP/greeting-schema.json" -test_pass - # ============================================================================= # Test: tools-call with --schema (compatible mode, default) # ============================================================================= @@ -83,23 +75,6 @@ run_mcpc "$SESSION" tools-call echo message:=test \ assert_success test_pass -# ============================================================================= -# Test: prompts-get with --schema validation -# ============================================================================= - -test_case "prompts-get with valid schema passes" -run_mcpc "$SESSION" prompts-get greeting name:=Test \ - --schema "$TEST_TMP/greeting-schema.json" -assert_success -test_pass - -test_case "prompts-get with valid schema (JSON mode)" -run_mcpc --json "$SESSION" prompts-get greeting name:=Test \ - --schema "$TEST_TMP/greeting-schema.json" -assert_success -assert_json_valid "$STDOUT" -test_pass - # ============================================================================= # Test: Schema validation failures # ============================================================================= @@ -153,11 +128,9 @@ cat > "$TEST_TMP/extra-required-schema.json" << 'EOF' EOF test_pass -test_case "tools-call fails when server removes required field" +test_case "tools-get fails when server removes required field" # The actual server doesn't have "extra" as required, so validation should fail # in compatible mode when extra is in expected but not in actual. -# Note: We use tools-get instead of tools-call because tools-call with args -# only validates the passed args (by design). tools-get validates the full schema. run_mcpc "$SESSION" tools-get echo \ --schema "$TEST_TMP/extra-required-schema.json" assert_failure diff --git a/test/unit/cli/parser.test.ts b/test/unit/cli/parser.test.ts index 4f79b59..267560b 100644 --- a/test/unit/cli/parser.test.ts +++ b/test/unit/cli/parser.test.ts @@ -329,11 +329,11 @@ describe('optionTakesValue', () => { expect(optionTakesValue('--header')).toBe(true); expect(optionTakesValue('--timeout')).toBe(true); expect(optionTakesValue('--profile')).toBe(true); - expect(optionTakesValue('--schema')).toBe(true); - expect(optionTakesValue('--schema-mode')).toBe(true); }); it('should return true for subcommand-specific options that take values', () => { + expect(optionTakesValue('--schema')).toBe(true); + expect(optionTakesValue('--schema-mode')).toBe(true); expect(optionTakesValue('--proxy')).toBe(true); expect(optionTakesValue('--proxy-bearer-token')).toBe(true); expect(optionTakesValue('--scope')).toBe(true); @@ -491,31 +491,10 @@ describe('validateOptions', () => { it('should handle --option=value syntax', () => { expect(() => validateOptions(['--timeout=30'])).not.toThrow(); - expect(() => validateOptions(['--schema-mode=strict'])).not.toThrow(); }); }); describe('validateArgValues', () => { - it('should not throw for valid --schema-mode values', () => { - expect(() => validateArgValues(['--schema-mode', 'strict'])).not.toThrow(); - expect(() => validateArgValues(['--schema-mode', 'compatible'])).not.toThrow(); - expect(() => validateArgValues(['--schema-mode', 'ignore'])).not.toThrow(); - }); - - it('should throw for invalid --schema-mode value before command token', () => { - expect(() => validateArgValues(['--schema-mode', 'bad'])).toThrow(ClientError); - expect(() => validateArgValues(['--schema-mode', 'bad'])).toThrow( - 'Invalid --schema-mode value' - ); - }); - - it('should not validate --schema-mode value after command token', () => { - // Even an invalid value is not checked once we are past a command token - expect(() => - validateArgValues(['connect', 'example.com', '--schema-mode', 'bad']) - ).not.toThrow(); - }); - it('should throw for invalid --timeout value before command token', () => { expect(() => validateArgValues(['--timeout', 'notanumber'])).toThrow(ClientError); expect(() => validateArgValues(['--timeout', 'notanumber'])).toThrow('Invalid --timeout value');