@felixgeelhaar/cclint
Version:
Catch CLAUDE.md drift before Claude misbehaves. Lints CLAUDE.md, skills, subagents, and hooks for Claude Code projects.
281 lines • 10.8 kB
JavaScript
import { ContextFile } from '../domain/ContextFile.js';
import { Violation } from '../domain/Violation.js';
import { Location } from '../domain/Location.js';
import { Severity } from '../domain/Severity.js';
const AT_START = new Location(1, 1);
/** Transport `type` values accepted for a remote MCP server. */
const REMOTE_TYPES = new Set(['sse', 'http']);
/**
* Validates a Model Context Protocol configuration file (`.mcp.json`).
*
* @remarks
* Claude Code loads MCP servers from `.mcp.json`; a malformed entry silently
* drops the server. This rule enforces the shape Claude Code expects: a
* top-level `mcpServers` object whose every entry is *either* a stdio server
* (`command`, optional `args`/`env`) *or* a remote server (`url` + a `type` of
* `sse`/`http`) — never both or neither. It also checks that `${VAR}`
* environment placeholders are well-formed and that no server name is declared
* twice (JSON silently keeps only the last duplicate key). It is a pure rule
* and performs no IO.
*/
export class McpConfigRule {
id = 'mcp-config';
description = 'Validates Claude Code MCP server configuration (.mcp.json)';
appliesTo(file) {
return file.isMcpConfig();
}
lint(file) {
if (!file.isMcpConfig()) {
return [];
}
const parsed = parseJson(file.content);
if (!parsed.ok) {
return [
new Violation(this.id, `Invalid JSON: ${parsed.error}`, Severity.ERROR, AT_START),
];
}
const root = parsed.value;
if (!isRecord(root)) {
return [
new Violation(this.id, '.mcp.json must be a JSON object.', Severity.ERROR, AT_START),
];
}
const servers = root['mcpServers'];
if (servers === undefined) {
return [
new Violation(this.id, '.mcp.json is missing a "mcpServers" object.', Severity.ERROR, AT_START),
];
}
if (!isRecord(servers)) {
return [
new Violation(this.id, '"mcpServers" must be an object keyed by server name.', Severity.ERROR, AT_START),
];
}
const violations = [];
violations.push(...this.reportDuplicateServerNames(file.content));
for (const [name, server] of Object.entries(servers)) {
violations.push(...this.validateServer(name, server));
}
return violations;
}
validateServer(name, server) {
if (!isRecord(server)) {
return [
new Violation(this.id, `Server "${name}" must be an object.`, Severity.ERROR, AT_START),
];
}
const hasCommand = server['command'] !== undefined;
const hasUrl = server['url'] !== undefined;
if (hasCommand && hasUrl) {
return [
new Violation(this.id, `Server "${name}" declares both "command" (stdio) and "url" (remote); it must be exactly one.`, Severity.ERROR, AT_START),
];
}
if (!hasCommand && !hasUrl) {
return [
new Violation(this.id, `Server "${name}" must declare either "command" (stdio) or "url" (remote).`, Severity.ERROR, AT_START),
];
}
return hasCommand
? this.validateStdioServer(name, server)
: this.validateRemoteServer(name, server);
}
validateStdioServer(name, server) {
const violations = [];
const command = server['command'];
if (typeof command !== 'string' || command.trim() === '') {
violations.push(new Violation(this.id, `Server "${name}" has a non-empty "command" requirement.`, Severity.ERROR, AT_START));
}
const args = server['args'];
if (args !== undefined) {
if (!Array.isArray(args)) {
violations.push(new Violation(this.id, `Server "${name}" "args" must be an array of strings.`, Severity.ERROR, AT_START));
}
else if (!args.every(item => typeof item === 'string')) {
violations.push(new Violation(this.id, `Server "${name}" "args" must contain only strings.`, Severity.ERROR, AT_START));
}
}
violations.push(...this.validateEnv(name, server['env']));
violations.push(...this.collectPlaceholders(name, server));
return violations;
}
validateRemoteServer(name, server) {
const violations = [];
const url = server['url'];
if (typeof url !== 'string' || url.trim() === '') {
violations.push(new Violation(this.id, `Server "${name}" has a non-empty "url" requirement.`, Severity.ERROR, AT_START));
}
const type = server['type'];
if (type === undefined) {
violations.push(new Violation(this.id, `Remote server "${name}" is missing a "type" ("sse" or "http").`, Severity.WARNING, AT_START));
}
else if (typeof type !== 'string' || !REMOTE_TYPES.has(type)) {
violations.push(new Violation(this.id, `Remote server "${name}" has an invalid "type" (${JSON.stringify(type)}); expected "sse" or "http".`, Severity.ERROR, AT_START));
}
violations.push(...this.validateEnv(name, server['env']));
violations.push(...this.collectPlaceholders(name, server));
return violations;
}
validateEnv(name, env) {
if (env === undefined) {
return [];
}
if (!isRecord(env)) {
return [
new Violation(this.id, `Server "${name}" "env" must be an object of string values.`, Severity.ERROR, AT_START),
];
}
if (!Object.values(env).every(value => typeof value === 'string')) {
return [
new Violation(this.id, `Server "${name}" "env" values must all be strings.`, Severity.ERROR, AT_START),
];
}
return [];
}
/** Flag malformed `${VAR}` placeholders in any string value of a server. */
collectPlaceholders(name, server) {
const violations = [];
for (const raw of collectStrings(server)) {
for (const bad of findMalformedPlaceholders(raw)) {
violations.push(new Violation(this.id, `Server "${name}" has a malformed environment placeholder "${bad}"; use "\${VAR_NAME}".`, Severity.WARNING, AT_START));
}
}
return violations;
}
/**
* Detect server names declared more than once. JSON.parse keeps only the last
* value for a duplicate key, so the parsed object cannot reveal the collision;
* this scans the raw text with a string-aware brace walk.
*/
reportDuplicateServerNames(content) {
const duplicates = findDuplicateMcpServerKeys(content);
return duplicates.map(name => new Violation(this.id, `Server "${name}" is declared more than once; duplicate keys silently overwrite each other.`, Severity.ERROR, AT_START));
}
}
function parseJson(content) {
try {
return { ok: true, value: JSON.parse(content) };
}
catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : 'Unknown JSON error',
};
}
}
function isRecord(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** Recursively collect every string value nested under an object/array. */
function collectStrings(value) {
if (typeof value === 'string') {
return [value];
}
if (Array.isArray(value)) {
return value.flatMap(collectStrings);
}
if (isRecord(value)) {
return Object.values(value).flatMap(collectStrings);
}
return [];
}
/**
* Return every malformed `${...}` placeholder in a string. A well-formed
* placeholder is `${NAME}` where NAME matches a shell identifier
* (`[A-Za-z_][A-Za-z0-9_]*`). An unterminated `${` or an invalid name is
* reported.
*/
function findMalformedPlaceholders(input) {
const bad = [];
const valid = /^[A-Za-z_][A-Za-z0-9_]*$/;
for (let i = input.indexOf('${'); i !== -1; i = input.indexOf('${', i + 2)) {
const close = input.indexOf('}', i);
if (close === -1) {
bad.push(input.slice(i));
break;
}
const nameToken = input.slice(i + 2, close);
if (!valid.test(nameToken)) {
bad.push(input.slice(i, close + 1));
}
}
return bad;
}
/**
* Find keys declared more than once as *direct children* of the `mcpServers`
* object, using a string-aware single pass so braces inside string values
* (including `${VAR}`) never skew the depth tracking.
*/
function findDuplicateMcpServerKeys(content) {
const seen = new Set();
const duplicates = new Set();
let depth = 0;
let inString = false;
let escaped = false;
let buffer = '';
let lastString = null;
let serversDepth = -1;
let pendingServers = false;
for (const ch of content) {
if (inString) {
if (escaped) {
escaped = false;
}
else if (ch === '\\') {
escaped = true;
}
else if (ch === '"') {
inString = false;
lastString = buffer;
}
else {
buffer += ch;
}
continue;
}
switch (ch) {
case '"':
inString = true;
buffer = '';
break;
case ':':
if (lastString !== null) {
if (lastString === 'mcpServers') {
pendingServers = true;
}
else if (depth === serversDepth) {
if (seen.has(lastString)) {
duplicates.add(lastString);
}
else {
seen.add(lastString);
}
}
}
lastString = null;
break;
case '{':
depth++;
if (pendingServers) {
serversDepth = depth;
pendingServers = false;
}
lastString = null;
break;
case '}':
if (depth === serversDepth) {
serversDepth = -1;
}
depth--;
lastString = null;
break;
case ',':
lastString = null;
break;
default:
break;
}
}
return [...duplicates];
}
//# sourceMappingURL=McpConfigRule.js.map