mcp-doctor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/dist/cli/commands/check.d.ts +5 -0
- package/dist/cli/commands/check.js +67 -0
- package/dist/cli/commands/check.js.map +1 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +24 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/config/locations.d.ts +3 -0
- package/dist/config/locations.js +138 -0
- package/dist/config/locations.js.map +1 -0
- package/dist/config/types.d.ts +37 -0
- package/dist/config/types.js +2 -0
- package/dist/config/types.js.map +1 -0
- package/dist/utils/output.d.ts +6 -0
- package/dist/utils/output.js +102 -0
- package/dist/utils/output.js.map +1 -0
- package/dist/validators/env-vars.d.ts +2 -0
- package/dist/validators/env-vars.js +70 -0
- package/dist/validators/env-vars.js.map +1 -0
- package/dist/validators/index.d.ts +4 -0
- package/dist/validators/index.js +5 -0
- package/dist/validators/index.js.map +1 -0
- package/dist/validators/json-syntax.d.ts +6 -0
- package/dist/validators/json-syntax.js +77 -0
- package/dist/validators/json-syntax.js.map +1 -0
- package/dist/validators/paths.d.ts +2 -0
- package/dist/validators/paths.js +125 -0
- package/dist/validators/paths.js.map +1 -0
- package/dist/validators/server-health.d.ts +2 -0
- package/dist/validators/server-health.js +135 -0
- package/dist/validators/server-health.js.map +1 -0
- package/package.json +45 -0
- package/src/cli/commands/check.ts +103 -0
- package/src/cli/index.ts +29 -0
- package/src/config/locations.ts +141 -0
- package/src/config/types.ts +45 -0
- package/src/utils/output.ts +128 -0
- package/src/validators/env-vars.ts +85 -0
- package/src/validators/index.ts +4 -0
- package/src/validators/json-syntax.ts +83 -0
- package/src/validators/paths.ts +140 -0
- package/src/validators/server-health.ts +165 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { homedir, platform } from 'os';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { ConfigLocation } from './types.js';
|
|
5
|
+
|
|
6
|
+
export function getConfigLocations(projectDir?: string): ConfigLocation[] {
|
|
7
|
+
const home = homedir();
|
|
8
|
+
const os = platform();
|
|
9
|
+
const cwd = projectDir || process.cwd();
|
|
10
|
+
|
|
11
|
+
const locations: Array<Omit<ConfigLocation, 'exists'>> = [];
|
|
12
|
+
|
|
13
|
+
// ============================================
|
|
14
|
+
// Claude Desktop
|
|
15
|
+
// ============================================
|
|
16
|
+
if (os === 'darwin') {
|
|
17
|
+
locations.push({
|
|
18
|
+
client: 'Claude Desktop',
|
|
19
|
+
scope: 'global',
|
|
20
|
+
path: join(home, 'Library/Application Support/Claude/claude_desktop_config.json'),
|
|
21
|
+
});
|
|
22
|
+
} else if (os === 'win32') {
|
|
23
|
+
locations.push({
|
|
24
|
+
client: 'Claude Desktop',
|
|
25
|
+
scope: 'global',
|
|
26
|
+
path: join(process.env.APPDATA || join(home, 'AppData/Roaming'), 'Claude/claude_desktop_config.json'),
|
|
27
|
+
});
|
|
28
|
+
} else {
|
|
29
|
+
// Linux
|
|
30
|
+
locations.push({
|
|
31
|
+
client: 'Claude Desktop',
|
|
32
|
+
scope: 'global',
|
|
33
|
+
path: join(home, '.config/claude/claude_desktop_config.json'),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ============================================
|
|
38
|
+
// Claude Code
|
|
39
|
+
// ============================================
|
|
40
|
+
locations.push({
|
|
41
|
+
client: 'Claude Code',
|
|
42
|
+
scope: 'global',
|
|
43
|
+
path: join(home, '.claude.json'),
|
|
44
|
+
});
|
|
45
|
+
locations.push({
|
|
46
|
+
client: 'Claude Code',
|
|
47
|
+
scope: 'project',
|
|
48
|
+
path: join(cwd, '.mcp.json'),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ============================================
|
|
52
|
+
// Cursor
|
|
53
|
+
// ============================================
|
|
54
|
+
if (os === 'darwin') {
|
|
55
|
+
locations.push({
|
|
56
|
+
client: 'Cursor',
|
|
57
|
+
scope: 'global',
|
|
58
|
+
path: join(home, 'Library/Application Support/Cursor/User/globalStorage/cursor.mcp/mcp.json'),
|
|
59
|
+
});
|
|
60
|
+
} else if (os === 'win32') {
|
|
61
|
+
locations.push({
|
|
62
|
+
client: 'Cursor',
|
|
63
|
+
scope: 'global',
|
|
64
|
+
path: join(process.env.APPDATA || join(home, 'AppData/Roaming'), 'Cursor/User/globalStorage/cursor.mcp/mcp.json'),
|
|
65
|
+
});
|
|
66
|
+
} else {
|
|
67
|
+
locations.push({
|
|
68
|
+
client: 'Cursor',
|
|
69
|
+
scope: 'global',
|
|
70
|
+
path: join(home, '.config/Cursor/User/globalStorage/cursor.mcp/mcp.json'),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
// Also check older Cursor location
|
|
74
|
+
locations.push({
|
|
75
|
+
client: 'Cursor',
|
|
76
|
+
scope: 'global',
|
|
77
|
+
path: join(home, '.cursor/mcp.json'),
|
|
78
|
+
});
|
|
79
|
+
locations.push({
|
|
80
|
+
client: 'Cursor',
|
|
81
|
+
scope: 'project',
|
|
82
|
+
path: join(cwd, '.cursor/mcp.json'),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ============================================
|
|
86
|
+
// VS Code (MCP extension)
|
|
87
|
+
// ============================================
|
|
88
|
+
if (os === 'darwin') {
|
|
89
|
+
locations.push({
|
|
90
|
+
client: 'VS Code',
|
|
91
|
+
scope: 'global',
|
|
92
|
+
path: join(home, 'Library/Application Support/Code/User/settings.json'),
|
|
93
|
+
});
|
|
94
|
+
} else if (os === 'win32') {
|
|
95
|
+
locations.push({
|
|
96
|
+
client: 'VS Code',
|
|
97
|
+
scope: 'global',
|
|
98
|
+
path: join(process.env.APPDATA || join(home, 'AppData/Roaming'), 'Code/User/settings.json'),
|
|
99
|
+
});
|
|
100
|
+
} else {
|
|
101
|
+
locations.push({
|
|
102
|
+
client: 'VS Code',
|
|
103
|
+
scope: 'global',
|
|
104
|
+
path: join(home, '.config/Code/User/settings.json'),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
locations.push({
|
|
108
|
+
client: 'VS Code',
|
|
109
|
+
scope: 'project',
|
|
110
|
+
path: join(cwd, '.vscode/settings.json'),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ============================================
|
|
114
|
+
// Windsurf
|
|
115
|
+
// ============================================
|
|
116
|
+
locations.push({
|
|
117
|
+
client: 'Windsurf',
|
|
118
|
+
scope: 'global',
|
|
119
|
+
path: join(home, '.codeium/windsurf/mcp_config.json'),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Check existence and return
|
|
123
|
+
return locations.map((loc) => ({
|
|
124
|
+
...loc,
|
|
125
|
+
exists: existsSync(loc.path),
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function getServersFromConfig(config: Record<string, unknown>): Record<string, unknown> | null {
|
|
130
|
+
// Try different config formats
|
|
131
|
+
if (config.mcpServers && typeof config.mcpServers === 'object') {
|
|
132
|
+
return config.mcpServers as Record<string, unknown>;
|
|
133
|
+
}
|
|
134
|
+
if (config['mcp.servers'] && typeof config['mcp.servers'] === 'object') {
|
|
135
|
+
return config['mcp.servers'] as Record<string, unknown>;
|
|
136
|
+
}
|
|
137
|
+
if (config.servers && typeof config.servers === 'object') {
|
|
138
|
+
return config.servers as Record<string, unknown>;
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export interface MCPServerConfig {
|
|
2
|
+
command?: string;
|
|
3
|
+
args?: string[];
|
|
4
|
+
env?: Record<string, string>;
|
|
5
|
+
cwd?: string;
|
|
6
|
+
// HTTP transport
|
|
7
|
+
url?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface MCPConfig {
|
|
11
|
+
mcpServers?: Record<string, MCPServerConfig>;
|
|
12
|
+
// VS Code style
|
|
13
|
+
'mcp.servers'?: Record<string, MCPServerConfig>;
|
|
14
|
+
// Claude Code style (servers at root)
|
|
15
|
+
servers?: Record<string, MCPServerConfig>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ConfigLocation {
|
|
19
|
+
client: string;
|
|
20
|
+
scope: 'global' | 'project';
|
|
21
|
+
path: string;
|
|
22
|
+
exists: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ValidationResult {
|
|
26
|
+
level: 'error' | 'warning' | 'info';
|
|
27
|
+
code: string;
|
|
28
|
+
message: string;
|
|
29
|
+
file: string;
|
|
30
|
+
line?: number;
|
|
31
|
+
suggestion?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ServerTestResult {
|
|
35
|
+
name: string;
|
|
36
|
+
healthy: boolean;
|
|
37
|
+
responseTime?: number;
|
|
38
|
+
error?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CheckResults {
|
|
42
|
+
configsFound: ConfigLocation[];
|
|
43
|
+
validationResults: ValidationResult[];
|
|
44
|
+
serverResults: ServerTestResult[];
|
|
45
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { ValidationResult, ServerTestResult, ConfigLocation } from '../config/types.js';
|
|
3
|
+
|
|
4
|
+
export function printHeader(text: string): void {
|
|
5
|
+
console.log(chalk.bold(`\n${text}\n`));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function printConfigsFound(configs: ConfigLocation[]): void {
|
|
9
|
+
const found = configs.filter((c) => c.exists);
|
|
10
|
+
const notFound = configs.filter((c) => !c.exists);
|
|
11
|
+
|
|
12
|
+
if (found.length === 0) {
|
|
13
|
+
console.log(chalk.yellow('No MCP configuration files found.\n'));
|
|
14
|
+
console.log(chalk.gray('Looked in:'));
|
|
15
|
+
notFound.slice(0, 5).forEach((c) => {
|
|
16
|
+
console.log(chalk.gray(` ${c.client}: ${c.path}`));
|
|
17
|
+
});
|
|
18
|
+
if (notFound.length > 5) {
|
|
19
|
+
console.log(chalk.gray(` ... and ${notFound.length - 5} more locations`));
|
|
20
|
+
}
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
console.log(chalk.green(`Found ${found.length} config file${found.length > 1 ? 's' : ''}:\n`));
|
|
25
|
+
found.forEach((c) => {
|
|
26
|
+
const scope = c.scope === 'project' ? chalk.gray(' (project)') : '';
|
|
27
|
+
console.log(` ${chalk.cyan(c.client)}${scope}`);
|
|
28
|
+
console.log(chalk.gray(` ${c.path}\n`));
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function printValidationResults(results: ValidationResult[]): void {
|
|
33
|
+
const errors = results.filter((r) => r.level === 'error');
|
|
34
|
+
const warnings = results.filter((r) => r.level === 'warning');
|
|
35
|
+
|
|
36
|
+
if (errors.length > 0) {
|
|
37
|
+
console.log(chalk.red(`❌ ${errors.length} Error${errors.length > 1 ? 's' : ''}\n`));
|
|
38
|
+
errors.forEach((e) => {
|
|
39
|
+
const location = e.line ? `:${e.line}` : '';
|
|
40
|
+
console.log(chalk.red(` ${shortPath(e.file)}${location}`));
|
|
41
|
+
console.log(chalk.red(` ${e.message}`));
|
|
42
|
+
if (e.suggestion) {
|
|
43
|
+
console.log(chalk.gray(` 💡 ${e.suggestion}`));
|
|
44
|
+
}
|
|
45
|
+
console.log('');
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (warnings.length > 0) {
|
|
50
|
+
console.log(chalk.yellow(`⚠️ ${warnings.length} Warning${warnings.length > 1 ? 's' : ''}\n`));
|
|
51
|
+
warnings.forEach((w) => {
|
|
52
|
+
const location = w.line ? `:${w.line}` : '';
|
|
53
|
+
console.log(chalk.yellow(` ${shortPath(w.file)}${location}`));
|
|
54
|
+
console.log(chalk.yellow(` ${w.message}`));
|
|
55
|
+
if (w.suggestion) {
|
|
56
|
+
console.log(chalk.gray(` 💡 ${w.suggestion}`));
|
|
57
|
+
}
|
|
58
|
+
console.log('');
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function printServerResults(results: ServerTestResult[]): void {
|
|
64
|
+
if (results.length === 0) {
|
|
65
|
+
console.log(chalk.gray('No servers to test.\n'));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const healthy = results.filter((s) => s.healthy);
|
|
70
|
+
const unhealthy = results.filter((s) => !s.healthy);
|
|
71
|
+
|
|
72
|
+
console.log(
|
|
73
|
+
`🔌 Server Health: ${chalk.green(healthy.length)} healthy, ${
|
|
74
|
+
unhealthy.length > 0 ? chalk.red(unhealthy.length + ' failed') : chalk.gray('0 failed')
|
|
75
|
+
}\n`
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
healthy.forEach((s) => {
|
|
79
|
+
const time = s.responseTime ? chalk.gray(` (${s.responseTime}ms)`) : '';
|
|
80
|
+
console.log(chalk.green(` ✓ ${s.name}${time}`));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
unhealthy.forEach((s) => {
|
|
84
|
+
console.log(chalk.red(` ✗ ${s.name}`));
|
|
85
|
+
if (s.error) {
|
|
86
|
+
console.log(chalk.gray(` ${s.error}`));
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
console.log('');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function printSummary(
|
|
94
|
+
errors: number,
|
|
95
|
+
warnings: number,
|
|
96
|
+
healthyServers: number,
|
|
97
|
+
totalServers: number
|
|
98
|
+
): void {
|
|
99
|
+
console.log(chalk.gray('─'.repeat(50)));
|
|
100
|
+
|
|
101
|
+
if (errors === 0 && warnings === 0) {
|
|
102
|
+
console.log(chalk.green('\n✓ All configurations valid'));
|
|
103
|
+
} else if (errors === 0) {
|
|
104
|
+
console.log(chalk.yellow(`\n⚠️ Configuration valid with ${warnings} warning${warnings > 1 ? 's' : ''}`));
|
|
105
|
+
} else {
|
|
106
|
+
console.log(chalk.red(`\n❌ Found ${errors} error${errors > 1 ? 's' : ''} that need${errors === 1 ? 's' : ''} fixing`));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (totalServers > 0) {
|
|
110
|
+
if (healthyServers === totalServers) {
|
|
111
|
+
console.log(chalk.green(`✓ All ${totalServers} server${totalServers > 1 ? 's' : ''} responding`));
|
|
112
|
+
} else {
|
|
113
|
+
console.log(
|
|
114
|
+
chalk.yellow(`⚠️ ${healthyServers}/${totalServers} servers responding`)
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
console.log('');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function shortPath(path: string): string {
|
|
123
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
124
|
+
if (home && path.startsWith(home)) {
|
|
125
|
+
return '~' + path.slice(home.length);
|
|
126
|
+
}
|
|
127
|
+
return path;
|
|
128
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { ValidationResult, MCPServerConfig } from '../config/types.js';
|
|
2
|
+
import { getServersFromConfig } from '../config/locations.js';
|
|
3
|
+
|
|
4
|
+
export function validateEnvVars(
|
|
5
|
+
config: Record<string, unknown>,
|
|
6
|
+
configPath: string
|
|
7
|
+
): ValidationResult[] {
|
|
8
|
+
const results: ValidationResult[] = [];
|
|
9
|
+
const servers = getServersFromConfig(config);
|
|
10
|
+
|
|
11
|
+
if (!servers) {
|
|
12
|
+
return results;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
for (const [name, serverRaw] of Object.entries(servers)) {
|
|
16
|
+
const server = serverRaw as MCPServerConfig;
|
|
17
|
+
|
|
18
|
+
if (!server.env) continue;
|
|
19
|
+
|
|
20
|
+
for (const [key, value] of Object.entries(server.env)) {
|
|
21
|
+
if (typeof value !== 'string') continue;
|
|
22
|
+
|
|
23
|
+
// Check for ${VAR} or $VAR references
|
|
24
|
+
const varRefs = value.match(/\$\{([A-Z_][A-Z0-9_]*)\}|\$([A-Z_][A-Z0-9_]*)/g) || [];
|
|
25
|
+
|
|
26
|
+
for (const ref of varRefs) {
|
|
27
|
+
const varName = ref.replace(/^\$\{?|\}?$/g, '');
|
|
28
|
+
|
|
29
|
+
if (!process.env[varName]) {
|
|
30
|
+
results.push({
|
|
31
|
+
level: 'warning',
|
|
32
|
+
code: 'ENV_VAR_MISSING',
|
|
33
|
+
message: `Server "${name}": Environment variable ${varName} is not set`,
|
|
34
|
+
file: configPath,
|
|
35
|
+
suggestion: `Set it with: export ${varName}="your-value"`,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Check for empty values that look like they should be set
|
|
41
|
+
if (value === '' || value === '""' || value === "''") {
|
|
42
|
+
results.push({
|
|
43
|
+
level: 'warning',
|
|
44
|
+
code: 'ENV_VAR_EMPTY',
|
|
45
|
+
message: `Server "${name}": Environment variable ${key} is empty`,
|
|
46
|
+
file: configPath,
|
|
47
|
+
suggestion: 'This may cause authentication or configuration issues',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Warn about hardcoded secrets
|
|
52
|
+
const secretKeyPatterns = [
|
|
53
|
+
/^(api[_-]?key|secret|token|password|pwd|auth|credential)/i,
|
|
54
|
+
/_(api[_-]?key|secret|token|password|pwd|auth|credential)$/i,
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const secretValuePatterns = [
|
|
58
|
+
/^sk[-_][a-zA-Z0-9]{20,}$/, // OpenAI-style keys
|
|
59
|
+
/^[a-f0-9]{32,}$/i, // Hex strings (32+ chars)
|
|
60
|
+
/^eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/, // JWT tokens
|
|
61
|
+
/^ghp_[a-zA-Z0-9]{36}$/, // GitHub tokens
|
|
62
|
+
/^github_pat_[a-zA-Z0-9_]{22,}/, // GitHub PAT
|
|
63
|
+
/^xoxb-[0-9]{10,}/, // Slack bot tokens
|
|
64
|
+
/^xoxp-[0-9]{10,}/, // Slack user tokens
|
|
65
|
+
/^AKIA[A-Z0-9]{16}$/, // AWS access keys
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
const isSecretKey = secretKeyPatterns.some((p) => p.test(key));
|
|
69
|
+
const looksLikeSecret = secretValuePatterns.some((p) => p.test(value));
|
|
70
|
+
|
|
71
|
+
// Only warn if it's not a variable reference
|
|
72
|
+
if ((isSecretKey || looksLikeSecret) && !value.includes('$')) {
|
|
73
|
+
results.push({
|
|
74
|
+
level: 'warning',
|
|
75
|
+
code: 'HARDCODED_SECRET',
|
|
76
|
+
message: `Server "${name}": Possible hardcoded secret in ${key}`,
|
|
77
|
+
file: configPath,
|
|
78
|
+
suggestion: 'Use an environment variable reference like ${' + key.toUpperCase() + '} instead',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return results;
|
|
85
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { ValidationResult } from '../config/types.js';
|
|
3
|
+
|
|
4
|
+
export function validateJsonSyntax(filePath: string): { valid: boolean; results: ValidationResult[]; config?: Record<string, unknown> } {
|
|
5
|
+
const results: ValidationResult[] = [];
|
|
6
|
+
|
|
7
|
+
let content: string;
|
|
8
|
+
try {
|
|
9
|
+
content = readFileSync(filePath, 'utf-8');
|
|
10
|
+
} catch (error) {
|
|
11
|
+
results.push({
|
|
12
|
+
level: 'error',
|
|
13
|
+
code: 'FILE_READ_ERROR',
|
|
14
|
+
message: `Cannot read file: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
15
|
+
file: filePath,
|
|
16
|
+
});
|
|
17
|
+
return { valid: false, results };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Check for empty file
|
|
21
|
+
if (!content.trim()) {
|
|
22
|
+
results.push({
|
|
23
|
+
level: 'warning',
|
|
24
|
+
code: 'EMPTY_FILE',
|
|
25
|
+
message: 'Config file is empty',
|
|
26
|
+
file: filePath,
|
|
27
|
+
});
|
|
28
|
+
return { valid: true, results, config: {} };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Try to parse JSON
|
|
32
|
+
try {
|
|
33
|
+
const config = JSON.parse(content);
|
|
34
|
+
return { valid: true, results, config };
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error instanceof SyntaxError) {
|
|
37
|
+
// Try to find the line number
|
|
38
|
+
const match = error.message.match(/position\s+(\d+)/i);
|
|
39
|
+
let line: number | undefined;
|
|
40
|
+
let column: number | undefined;
|
|
41
|
+
|
|
42
|
+
if (match) {
|
|
43
|
+
const position = parseInt(match[1], 10);
|
|
44
|
+
const lines = content.substring(0, position).split('\n');
|
|
45
|
+
line = lines.length;
|
|
46
|
+
column = lines[lines.length - 1].length + 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Check for common issues
|
|
50
|
+
const trailingCommaMatch = content.match(/,\s*([}\]])/);
|
|
51
|
+
if (trailingCommaMatch) {
|
|
52
|
+
// Find line of trailing comma
|
|
53
|
+
const beforeMatch = content.substring(0, content.indexOf(trailingCommaMatch[0]));
|
|
54
|
+
const trailingCommaLine = beforeMatch.split('\n').length;
|
|
55
|
+
|
|
56
|
+
results.push({
|
|
57
|
+
level: 'error',
|
|
58
|
+
code: 'JSON_TRAILING_COMMA',
|
|
59
|
+
message: 'Trailing comma detected (not allowed in JSON)',
|
|
60
|
+
file: filePath,
|
|
61
|
+
line: trailingCommaLine,
|
|
62
|
+
suggestion: 'Remove the comma before the closing bracket/brace',
|
|
63
|
+
});
|
|
64
|
+
} else {
|
|
65
|
+
// Generic JSON error
|
|
66
|
+
let message = error.message;
|
|
67
|
+
if (line && column) {
|
|
68
|
+
message = `Invalid JSON at line ${line}, column ${column}: ${error.message}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
results.push({
|
|
72
|
+
level: 'error',
|
|
73
|
+
code: 'JSON_SYNTAX',
|
|
74
|
+
message,
|
|
75
|
+
file: filePath,
|
|
76
|
+
line,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { valid: false, results };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { existsSync, accessSync, constants } from 'fs';
|
|
2
|
+
import { isAbsolute, resolve, dirname } from 'path';
|
|
3
|
+
import { ValidationResult, MCPServerConfig } from '../config/types.js';
|
|
4
|
+
import { getServersFromConfig } from '../config/locations.js';
|
|
5
|
+
|
|
6
|
+
export function validatePaths(
|
|
7
|
+
config: Record<string, unknown>,
|
|
8
|
+
configPath: string
|
|
9
|
+
): ValidationResult[] {
|
|
10
|
+
const results: ValidationResult[] = [];
|
|
11
|
+
const servers = getServersFromConfig(config);
|
|
12
|
+
|
|
13
|
+
if (!servers) {
|
|
14
|
+
return results;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const configDir = dirname(configPath);
|
|
18
|
+
|
|
19
|
+
for (const [name, serverRaw] of Object.entries(servers)) {
|
|
20
|
+
const server = serverRaw as MCPServerConfig;
|
|
21
|
+
|
|
22
|
+
// Skip HTTP/URL-based servers
|
|
23
|
+
if (server.url) continue;
|
|
24
|
+
|
|
25
|
+
// Skip if no command specified
|
|
26
|
+
if (!server.command) {
|
|
27
|
+
results.push({
|
|
28
|
+
level: 'error',
|
|
29
|
+
code: 'MISSING_COMMAND',
|
|
30
|
+
message: `Server "${name}": No command specified`,
|
|
31
|
+
file: configPath,
|
|
32
|
+
suggestion: 'Add a "command" field specifying how to start the server',
|
|
33
|
+
});
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const command = server.command;
|
|
38
|
+
|
|
39
|
+
// Check if command is a path (contains / or \)
|
|
40
|
+
if (command.includes('/') || command.includes('\\')) {
|
|
41
|
+
const resolvedPath = isAbsolute(command)
|
|
42
|
+
? command
|
|
43
|
+
: resolve(configDir, command);
|
|
44
|
+
|
|
45
|
+
// Check existence
|
|
46
|
+
if (!existsSync(resolvedPath)) {
|
|
47
|
+
results.push({
|
|
48
|
+
level: 'error',
|
|
49
|
+
code: 'PATH_NOT_FOUND',
|
|
50
|
+
message: `Server "${name}": Command path does not exist: ${command}`,
|
|
51
|
+
file: configPath,
|
|
52
|
+
suggestion: `Check the path is correct. Resolved to: ${resolvedPath}`,
|
|
53
|
+
});
|
|
54
|
+
} else {
|
|
55
|
+
// Check executable permission (Unix only)
|
|
56
|
+
if (process.platform !== 'win32') {
|
|
57
|
+
try {
|
|
58
|
+
accessSync(resolvedPath, constants.X_OK);
|
|
59
|
+
} catch {
|
|
60
|
+
results.push({
|
|
61
|
+
level: 'error',
|
|
62
|
+
code: 'PATH_NOT_EXECUTABLE',
|
|
63
|
+
message: `Server "${name}": Command is not executable: ${command}`,
|
|
64
|
+
file: configPath,
|
|
65
|
+
suggestion: `Run: chmod +x "${resolvedPath}"`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Warn about relative paths
|
|
72
|
+
if (!isAbsolute(command)) {
|
|
73
|
+
results.push({
|
|
74
|
+
level: 'warning',
|
|
75
|
+
code: 'PATH_RELATIVE',
|
|
76
|
+
message: `Server "${name}": Using relative path "${command}"`,
|
|
77
|
+
file: configPath,
|
|
78
|
+
suggestion: `Consider using absolute path: ${resolvedPath}`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
// Command is not a path (e.g., "npx", "node", "python")
|
|
83
|
+
// We can't easily validate these as they depend on PATH
|
|
84
|
+
// But we can warn if it looks like a typo
|
|
85
|
+
|
|
86
|
+
const commonCommands = ['npx', 'node', 'python', 'python3', 'uv', 'uvx', 'deno', 'bun'];
|
|
87
|
+
const looksLikePath = command.includes('.') && !commonCommands.includes(command);
|
|
88
|
+
|
|
89
|
+
if (looksLikePath) {
|
|
90
|
+
results.push({
|
|
91
|
+
level: 'warning',
|
|
92
|
+
code: 'POSSIBLE_PATH_TYPO',
|
|
93
|
+
message: `Server "${name}": "${command}" looks like a filename but isn't a path`,
|
|
94
|
+
file: configPath,
|
|
95
|
+
suggestion: 'If this is a file, use a full path like "./server.js" or "/path/to/server.js"',
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Check cwd if specified
|
|
101
|
+
if (server.cwd) {
|
|
102
|
+
const cwdPath = isAbsolute(server.cwd)
|
|
103
|
+
? server.cwd
|
|
104
|
+
: resolve(configDir, server.cwd);
|
|
105
|
+
|
|
106
|
+
if (!existsSync(cwdPath)) {
|
|
107
|
+
results.push({
|
|
108
|
+
level: 'error',
|
|
109
|
+
code: 'CWD_NOT_FOUND',
|
|
110
|
+
message: `Server "${name}": Working directory does not exist: ${server.cwd}`,
|
|
111
|
+
file: configPath,
|
|
112
|
+
suggestion: `Create the directory or update the path. Resolved to: ${cwdPath}`,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Check args for paths
|
|
118
|
+
if (server.args && Array.isArray(server.args)) {
|
|
119
|
+
for (const arg of server.args) {
|
|
120
|
+
// If arg looks like an absolute path, check it exists
|
|
121
|
+
if (typeof arg === 'string' && isAbsolute(arg) && (arg.includes('/') || arg.includes('\\'))) {
|
|
122
|
+
// Only check if it looks like a file path (not a URL or flag)
|
|
123
|
+
if (!arg.startsWith('-') && !arg.startsWith('http')) {
|
|
124
|
+
if (!existsSync(arg)) {
|
|
125
|
+
results.push({
|
|
126
|
+
level: 'warning',
|
|
127
|
+
code: 'ARG_PATH_NOT_FOUND',
|
|
128
|
+
message: `Server "${name}": Argument path may not exist: ${arg}`,
|
|
129
|
+
file: configPath,
|
|
130
|
+
suggestion: 'Verify this path is correct',
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return results;
|
|
140
|
+
}
|