Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,7 @@ Usage: mcpc [<@session>] [<command>] [options]
Universal command-line client for the Model Context Protocol (MCP).

Commands:
connect <server> [@session] Connect to an MCP server and start a named @session (name
auto-generated if omitted)
connect <server> [@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
Expand All @@ -127,8 +126,6 @@ Options:
--json Output in JSON format for scripting
--verbose Enable debug logging
--profile <name> OAuth profile for the server ("default" if not provided)
--schema <file> Validate tool/prompt schema against expected schema
--schema-mode <mode> Schema validation mode: strict, compatible (default), ignore
--timeout <seconds> Request timeout in seconds (default: 300)
--max-chars <n> Truncate output to n characters (ignored in --json mode)
--insecure Skip TLS certificate verification (for self-signed certs)
Expand Down Expand Up @@ -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"
```
Expand Down
45 changes: 1 addition & 44 deletions src/cli/commands/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
30 changes: 18 additions & 12 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,8 +397,6 @@ function createTopLevelProgram(): Command {
.option('--json', 'Output in JSON format for scripting')
.option('--verbose', 'Enable debug logging')
.option('--profile <name>', 'OAuth profile for the server ("default" if not provided)')
.option('--schema <file>', 'Validate tool/prompt schema against expected schema')
.option('--schema-mode <mode>', 'Schema validation mode: strict, compatible (default), ignore')
.option('--timeout <seconds>', 'Request timeout in seconds (default: 300)')
.option('--max-chars <n>', 'Truncate output to n characters (ignored in --json mode)')
.option('--insecure', 'Skip TLS certificate verification (for self-signed certs)')
Expand Down Expand Up @@ -436,9 +434,7 @@ Full docs: ${docsUrl}`
program
.command('connect [server] [@session]')
.usage('<server> [@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 <header>', 'HTTP header (can be repeated)')
.option('--profile <name>', 'OAuth profile to use ("default" if skipped)')
.option('--no-profile', 'Skip OAuth profile (connect anonymously)')
Expand Down Expand Up @@ -897,13 +893,19 @@ ${jsonHelp('`{ tools?: Tool[], resources?: Resource[], prompts?: Prompt[], instr
program
.command('tools-get <name>')
.description('Get details and schema for an MCP tool.')
.option('--schema <file>', 'Validate tool schema against expected schema')
.option('--schema-mode <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 <file> Validate against expected schema (save with tools-get --json)
--schema-mode <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));
Expand All @@ -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 <file>', 'Validate tool schema against expected schema before calling')
.option('--schema-mode <mode>', 'Schema validation mode: strict, compatible (default), ignore')
.addHelpText(
'after',
`
Expand All @@ -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 <file> Validate tool schema before calling (save with tools-get --json)
--schema-mode <mode> strict | compatible (default) | ignore
${jsonHelp('`CallToolResult`', '`{ content: [{ type, text?, ... }], isError?, structuredContent? }`', `${SCHEMA_BASE}#calltoolresult`)}`
)
.action(async (name, args, options, command) => {
Expand Down Expand Up @@ -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 <name>', 'OAuth profile override')
.option('--schema <file>', 'Validate tool/prompt schema against expected schema')
.option('--schema-mode <mode>', 'Schema validation mode: strict, compatible (default), ignore')
.option('--timeout <seconds>', 'Request timeout in seconds (default: 300)')
.option('--max-chars <n>', 'Truncate output to n characters (ignored in --json mode)')
.option('--insecure', 'Skip TLS certificate verification (for self-signed certs)')
Expand Down
33 changes: 4 additions & 29 deletions src/cli/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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',
Expand Down Expand Up @@ -74,9 +69,6 @@ const KNOWN_OPTIONS = [
'--insecure',
];

// Valid --schema-mode values
const VALID_SCHEMA_MODES = ['strict', 'compatible', 'ignore'];

/**
* All known top-level commands
*/
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down
14 changes: 0 additions & 14 deletions test/e2e/suites/basic/errors.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
29 changes: 1 addition & 28 deletions test/e2e/suites/basic/schema-validation.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# =============================================================================
Expand Down Expand Up @@ -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
# =============================================================================
Expand Down Expand Up @@ -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
Expand Down
25 changes: 2 additions & 23 deletions test/unit/cli/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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');
Expand Down
Loading