Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,40 @@ npx @wong2/mcp-cli -c config.json

The config file has the same format as the Claude Desktop config file.

### Configuration Priority

The CLI uses the following priority order for finding the configuration file:

1. Command line flag: `--config` or `-c`
2. Environment variable: `MCP_CLI_CONFIG`
3. Default Claude Desktop config file

Example using environment variable:
```bash
export MCP_CLI_CONFIG=/path/to/my/config.json
npx @wong2/mcp-cli
```

### Configuration File Format

The configuration file uses the same JSON format as Claude Desktop:

```json
{
"mcpServers": {
"server-name": {
"command": "command",
"args": ["arg1", "arg2"]
}
}
}
```

The CLI provides detailed error messages for common configuration issues including:
- Invalid JSON syntax with line/column information
- Missing required sections
- Incorrect server configuration structure

### Run servers from NPM

```bash
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"uri-template": "^2.0.0",
"yocto-spinner": "^0.2.2",
"yoctocolors": "^2.1.1",
"strict-url-sanitise": "^0.0.1"
"strict-url-sanitise": "^0.0.1",
"zod": "^3.23.8"
}
}
245 changes: 128 additions & 117 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

121 changes: 121 additions & 0 deletions src/config/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { existsSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { homedir } from 'os'
import { join } from 'path'
import prompts from 'prompts'
import { createSpinner } from '../utils.js'
import { ConfigSchema, formatZodError } from './schema.js'

function resolveConfigPath(cliConfigPath) {
// Priority: CLI arg → env var → platform default
if (cliConfigPath) {
return cliConfigPath
}

const envConfigPath = process.env.MCP_CLI_CONFIG
if (envConfigPath) {
return envConfigPath
}

if (process.platform === 'win32') {
return join(homedir(), 'AppData', 'Roaming', 'Claude', 'claude_desktop_config.json')
}
if (process.platform === 'darwin') {
return join(homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json')
}
}

function validateConfig(config, configFilePath) {
const result = ConfigSchema.safeParse(config)

if (!result.success) {
throw new Error(formatZodError(result.error, configFilePath))
}

return result.data
}

export async function loadConfig(configPath, { silent = false } = {}) {
const resolvedPath = resolveConfigPath(configPath)

if (!resolvedPath) {
throw new Error('No config file path provided')
}

if (!existsSync(resolvedPath)) {
throw new Error(`Config file not found: ${resolvedPath}
Please check that the file exists and you have read permissions.`)
}

let spinner
if (!silent) {
spinner = createSpinner(`Loading config from ${resolvedPath}`)
}

try {
const configContent = await readFile(resolvedPath, 'utf-8')

if (!configContent.trim()) {
throw new Error(`Config file contains no data: ${resolvedPath}`)
}

let config
try {
config = JSON.parse(configContent)
} catch (parseError) {
let errorMessage = `Invalid JSON in config file: ${resolvedPath}\n`

// Add line/column info if available
if (parseError.message.includes('Unexpected token')) {
const match = parseError.message.match(/position (\d+)/)
if (match) {
const position = parseInt(match[1])
const lines = configContent.substring(0, position).split('\n')
const lineNumber = lines.length
const columnNumber = lines[lines.length - 1].length + 1
errorMessage += `Error at line ${lineNumber}, column ${columnNumber}\n`
}
}

errorMessage += `Common issues: missing commas, trailing commas, unquoted property names`
throw new Error(errorMessage)
}

const validatedConfig = validateConfig(config, resolvedPath)

if (spinner) {
spinner.success()
}

return validatedConfig
} catch (error) {
if (spinner) {
spinner.error(`Failed to load config: ${error.message}`)
}

// Handle file system errors
if (error.code === 'EACCES') {
throw new Error(`Permission denied reading config file: ${resolvedPath}`)
} else if (error.code === 'ENOENT') {
throw new Error(`Config file not found: ${resolvedPath}`)
} else if (error.code === 'EISDIR') {
throw new Error(`Expected file but found directory: ${resolvedPath}`)
}

throw error
}
}

export async function pickServer(config) {
const { server } = await prompts({
name: 'server',
type: 'autocomplete',
message: 'Pick a server',
choices: Object.keys(config.mcpServers).map((s) => ({
title: s,
value: s,
})),
})
return server
}

23 changes: 23 additions & 0 deletions src/config/schema.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { z } from 'zod'

const ServerConfigSchema = z.object({
command: z.string().min(1, 'Command cannot be empty'),
args: z.array(z.string()).optional(),
env: z.record(z.string()).optional()
})

export const ConfigSchema = z.object({
mcpServers: z.record(ServerConfigSchema).refine(
(servers) => Object.keys(servers).length > 0,
{ message: 'mcpServers must contain at least one server configuration' }
)
}).strict()

export function formatZodError(error, configFilePath) {
const issues = error.errors.map(issue => {
const path = issue.path.length > 0 ? issue.path.join('.') : 'root'
return ` • ${path}: ${issue.message}`
}).join('\n')

return `Invalid configuration in ${configFilePath}:\n${issues}`
}
44 changes: 7 additions & 37 deletions src/mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@ import { McpOAuthClientProvider } from './oauth/provider.js'
import {
createSpinner,
formatDescription,
getClaudeConfigPath,
logger,
populateURITemplateParts,
prettyPrint,
readJSONSchemaInputs,
readPromptArgumentInputs,
} from './utils.js'
import {
loadConfig,
pickServer,
} from './config/index.js'


async function createClient() {
const client = new Client({ name: 'mcp-cli', version: '1.0.0' }, { capabilities: {} })
Expand Down Expand Up @@ -150,32 +154,6 @@ async function connectServer(transport, options = {}) {
}
}

async function readConfig(configFilePath, { silent = false } = {}) {
if (!configFilePath || !existsSync(configFilePath)) {
throw new Error(`Config file not found: ${configFilePath}`)
}
if (silent) {
const config = await readFile(configFilePath, 'utf-8')
return JSON.parse(config)
}
const spinner = createSpinner(`Loading config from ${configFilePath}`)
const config = await readFile(configFilePath, 'utf-8')
spinner.success()
return JSON.parse(config)
}

async function pickServer(config) {
const { server } = await prompts({
name: 'server',
type: 'autocomplete',
message: 'Pick a server',
choices: Object.keys(config.mcpServers).map((s) => ({
title: s,
value: s,
})),
})
return server
}

export async function runWithCommand(command, args, env, options = {}) {
const transport = new StdioClientTransport({ command, args, env })
Expand All @@ -188,11 +166,7 @@ export async function runWithCommand(command, args, env, options = {}) {

export async function runWithConfigNonInteractive(configPath, serverName, command, target, argsString) {
try {
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 config = await loadConfig(configPath, { silent: true })

const serverConfig = config.mcpServers[serverName]
if (!serverConfig) {
Expand Down Expand Up @@ -235,11 +209,7 @@ export async function runWithConfigNonInteractive(configPath, serverName, comman
}

export async function runWithConfig(configPath, options = {}) {
const defaultConfigFile = getClaudeConfigPath()
const config = await readConfig(configPath || defaultConfigFile)
if (!config.mcpServers || isEmpty(config.mcpServers)) {
throw new Error('No mcp servers found in config')
}
const config = await loadConfig(configPath)
const server = await pickServer(config)
const serverConfig = config.mcpServers[server]
if (serverConfig.env) {
Expand Down
11 changes: 1 addition & 10 deletions src/utils.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import traverse from '@json-schema-tools/traverse'
import { isEmpty, set as setPath, truncate } from 'lodash-es'
import { Console } from 'node:console'
import { homedir } from 'os'
import { join } from 'path'
import prompts from 'prompts'
import { parse } from 'uri-template'
import yoctoSpinner from 'yocto-spinner'
import colors from 'yoctocolors'


export const logger = new Console({ stdout: process.stderr, stderr: process.stderr })

export function prettyPrint(obj) {
Expand All @@ -18,14 +17,6 @@ export function createSpinner(text) {
return yoctoSpinner({ text, stream: process.stderr }).start()
}

export function getClaudeConfigPath() {
if (process.platform === 'win32') {
return join(homedir(), 'AppData', 'Roaming', 'Claude', 'claude_desktop_config.json')
}
if (process.platform === 'darwin') {
return join(homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json')
}
}

export async function readPromptArgumentInputs(args) {
if (!args || args.length === 0) {
Expand Down