Repository Public
1/**2 * Pure helpers for the MCP IPC: config parsing, secret masking, `claude mcp list|get` output3 * parsing and argv builders. No I/O here, so everything is unit-testable without the CLI.4 *5 * Observed CLI formats (claude 2.1.x):6 * list: `name: <command or url> [(HTTP)] - ✔ Connected`7 * `name: … - ✘ Failed to connect — CODE: message`8 * `name: … - ! Needs authentication` / `name: … - ⏸ Pending approval (…)`9 * get: ` Status: ✘ Failed to connect` + optional ` Issue: CODE: message`10 * (the same output prints header and env VALUES — only Status/Issue are ever read)11 * add: `claude mcp add [--scope s] [--transport t] <name> [-e K=V …] -- <command> [args…]`12 * `claude mcp add [--scope s] --transport http|sse <name> <url> [--header "K: V" …]`13 * (options placed before <name> are swallowed by the variadic -e/-H, so they go after)14 */15import type { McpServerInfo } from '@cr/shared';1617export const MASK = '••••';18export type McpStatus = McpServerInfo['status'];19export type McpScope = McpServerInfo['scope'];20export interface LiveStatus {21 status: McpStatus;22 lastError: string | null;23}2425const SECRET_WORD = /(key|token|secret|password|passwd|bearer|credential|auth)/i;26const KNOWN_TOKEN = /^(sk-|pk-|ghp_|gho_|ghs_|github_pat_|xox[abpsr]-|glpat-|AKIA|AIza|ya29\.|eyJ[A-Za-z0-9_-]{8,}\.)/;2728function entropyBits(s: string): number {29 const freq = new Map<string, number>();30 for (const ch of s) freq.set(ch, (freq.get(ch) ?? 0) + 1);31 let h = 0;32 for (const n of freq.values()) {33 const p = n / s.length;34 h -= p * Math.log2(p);35 }36 return h;37}3839/** A long, random-looking token (≥ 32 chars, letters + digits, high entropy). */40export function looksHighEntropy(s: string): boolean {41 return s.length >= 32 && /^[A-Za-z0-9+_=.~-]+$/.test(s) && /[0-9]/.test(s) && /[A-Za-z]/.test(s) && entropyBits(s) >= 3.5;42}43function looksSecret(s: string): boolean {44 return KNOWN_TOKEN.test(s) || s.split(/[/:,@]/).some(looksHighEntropy);45}4647/** URL for display: no userinfo, no query, no fragment, secret-looking path segments masked. */48export function sanitizeUrl(raw: string): string {49 try {50 const u = new URL(raw);51 const segs = u.pathname.split('/').map((seg) => {52 let plain = seg;53 try {54 plain = decodeURIComponent(seg);55 } catch {56 /* keep raw */57 }58 return looksSecret(plain) ? MASK : seg;59 });60 return `${u.protocol}//${u.host}${segs.join('/')}`;61 } catch {62 const noQuery = raw.split(/[?#]/)[0] ?? '';63 return noQuery.replace(/^([a-z][a-z0-9+.-]*:\/\/)?[^/@\s]*@/i, '$1');64 }65}6667/** Mask anything in a stdio argv that may be a credential. */68export function maskArgs(args: readonly string[]): string[] {69 const out: string[] = [];70 let maskNext = false;71 for (const arg of args) {72 if (maskNext) {73 maskNext = false;74 if (!arg.startsWith('-')) {75 out.push(MASK);76 continue;77 }78 }79 const eq = arg.indexOf('=');80 if (/^[a-z][a-z0-9+.-]*:\/\//i.test(arg)) {81 out.push(sanitizeUrl(arg));82 } else if (eq > 0 && SECRET_WORD.test(arg.slice(0, eq))) {83 out.push(`${arg.slice(0, eq)}=${MASK}`); // --api-key=…, API_TOKEN=…84 } else if (arg.startsWith('-') && eq < 0 && SECRET_WORD.test(arg)) {85 out.push(arg); // `--token <value>`: the value is the next arg86 maskNext = true;87 } else if (/^(bearer|basic)\s+/i.test(arg) || /^authorization\s*:/i.test(arg)) {88 out.push(MASK);89 } else if (eq > 0 && looksSecret(arg.slice(eq + 1))) {90 out.push(`${arg.slice(0, eq)}=${MASK}`);91 } else if (looksSecret(arg)) {92 out.push(MASK);93 } else {94 out.push(arg);95 }96 }97 return out;98}99100function quoteForDisplay(arg: string): string {101 return /[\s"']/.test(arg) ? JSON.stringify(arg) : arg;102}103104type Unknown = Record<string, unknown>;105const isObj = (v: unknown): v is Unknown => typeof v === 'object' && v !== null && !Array.isArray(v);106107/** One `mcpServers` entry → display info. Env/header VALUES are never copied. */108export function describeServer(name: string, scope: McpScope, raw: unknown): McpServerInfo | null {109 if (!isObj(raw)) return null;110 const type = typeof raw.type === 'string' ? raw.type.toLowerCase() : '';111 const url = typeof raw.url === 'string' ? raw.url : null;112 const command = typeof raw.command === 'string' ? raw.command : null;113 let transport: McpServerInfo['transport'];114 let target: string;115 if (url !== null && type !== 'stdio') {116 transport = type === 'sse' ? 'sse' : 'http';117 target = sanitizeUrl(url);118 } else if (command !== null) {119 transport = 'stdio';120 const args = Array.isArray(raw.args) ? raw.args.filter((a): a is string => typeof a === 'string') : [];121 target = [looksSecret(command) ? MASK : command, ...maskArgs(args)].map(quoteForDisplay).join(' ').slice(0, 1000);122 } else {123 return null;124 }125 const envKeys = isObj(raw.env) ? Object.keys(raw.env).sort() : [];126 return { name, scope, transport, target, envKeys, status: 'unknown', lastError: null, checkedAt: null };127}128129/** `{ mcpServers: {...} }` (or a bare map) → infos for one scope. */130export function serversFrom(container: unknown, scope: McpScope): McpServerInfo[] {131 const map = isObj(container) && isObj(container.mcpServers) ? container.mcpServers : null;132 if (!map) return [];133 const out: McpServerInfo[] = [];134 for (const [name, raw] of Object.entries(map)) {135 const info = describeServer(name, scope, raw);136 if (info) out.push(info);137 }138 return out;139}140141/** User + local scopes from the global `.claude.json`; `roots` are candidate project keys. */142export function parseClaudeJson(doc: unknown, roots: readonly string[]): McpServerInfo[] {143 const out = serversFrom(doc, 'user');144 const projects = isObj(doc) && isObj(doc.projects) ? doc.projects : null;145 if (projects) {146 const seen = new Set<string>();147 for (const root of roots) {148 for (const info of serversFrom(projects[root], 'local')) {149 if (!seen.has(info.name)) {150 seen.add(info.name);151 out.push(info);152 }153 }154 }155 }156 return out;157}158159function cleanError(text: string): string | null {160 const t = text161 .replace(/[a-z][a-z0-9+.-]*:\/\/\S+/gi, (m) => sanitizeUrl(m))162 .replace(/\s+/g, ' ')163 .trim();164 return t ? t.slice(0, 200) : null;165}166167function classify(statusText: string): LiveStatus | null {168 const t = statusText.trim();169 if (/needs? auth/i.test(t)) return { status: 'needs_auth', lastError: null };170 if (/\bconnected\b/i.test(t) && !/not connected|failed/i.test(t)) return { status: 'connected', lastError: null };171 if (/fail|error|not connected|✗|✘/i.test(t)) {172 const detail = t.split(/\s[—–-]\s/).slice(1).join(' - ');173 return { status: 'failed', lastError: cleanError(detail) };174 }175 if (/pending approval/i.test(t)) return { status: 'unknown', lastError: 'Pending approval' };176 if (/disabled/i.test(t)) return { status: 'unknown', lastError: 'Disabled' };177 return null;178}179180/** Tolerant parser for `claude mcp list`: name → status. Unrecognised lines are ignored. */181export function parseMcpList(stdout: string): Map<string, LiveStatus> {182 const out = new Map<string, LiveStatus>();183 for (const line of stdout.split('\n')) {184 const sep = line.indexOf(': ');185 const dash = line.lastIndexOf(' - ');186 if (sep <= 0 || dash <= sep || /^\s/.test(line)) continue;187 // The status is what follows the LAST " - " that still starts a status phrase.188 let live: LiveStatus | null = null;189 for (let i = line.indexOf(' - ', sep); i >= 0 && !live; i = line.indexOf(' - ', i + 1)) {190 const candidate = line.slice(i + 3);191 if (/^[^A-Za-z0-9]{0,3}\s*(connected|failed|needs|pending|disabled)/i.test(candidate)) live = classify(candidate);192 }193 if (live) out.set(line.slice(0, sep).trim(), live);194 }195 return out;196}197198/** Parse `claude mcp get <name>`: only the Status and Issue lines are read. */199export function parseMcpGet(stdout: string): LiveStatus {200 const statusLine = /^\s*Status:\s*(.+)$/m.exec(stdout);201 if (!statusLine) return { status: 'unknown', lastError: null };202 const live = classify(statusLine[1] as string) ?? { status: 'unknown' as const, lastError: null };203 const issue = /^\s*Issue:\s*(.+)$/m.exec(stdout);204 if (live.status === 'failed' && issue) live.lastError = cleanError(issue[1] as string);205 return live;206}207208export interface AddSpec {209 name: string;210 transport: 'stdio' | 'http' | 'sse';211 target: string;212 args: string[];213 env: Record<string, string>;214 headers: Record<string, string>;215 scope: 'user' | 'project';216}217218/** Validate an AddMcpServer request beyond its zod shape. Returns the reason, or null. */219export function validateAdd(spec: AddSpec): string | null {220 if (!/^[A-Za-z0-9_-]{1,40}$/.test(spec.name) || spec.name.startsWith('-')) return 'invalid server name';221 const ctl = /[\x00-\x08\x0a-\x1f\x7f]/;222 for (const [k, v] of Object.entries(spec.env)) {223 if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) return 'invalid environment variable name';224 if (v.includes('\0')) return 'invalid environment variable value';225 }226 for (const [k, v] of Object.entries(spec.headers)) {227 if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(k)) return 'invalid header name';228 if (/[\r\n\0]/.test(v)) return 'invalid header value';229 }230 if (spec.transport === 'stdio') {231 if (Object.keys(spec.headers).length > 0) return 'headers are only valid for http/sse servers';232 if (spec.target.trim() === '' || ctl.test(spec.target)) return 'invalid command';233 if (spec.args.some((a) => a.includes('\0'))) return 'invalid argument';234 return null;235 }236 if (spec.args.length > 0) return 'args are only valid for stdio servers';237 if (Object.keys(spec.env).length > 0) return 'env is only valid for stdio servers';238 let u: URL;239 try {240 u = new URL(spec.target);241 } catch {242 return 'target must be a URL';243 }244 if (u.protocol !== 'https:' && u.protocol !== 'http:') return 'target must be an http(s) URL';245 if (/\s/.test(spec.target) || spec.target.startsWith('-')) return 'target must be a URL';246 return null;247}248249/** argv for `claude mcp add` (after the binary). Contains secrets: never log it. */250export function buildAddArgv(spec: AddSpec): string[] {251 const head = ['mcp', 'add', '--scope', spec.scope];252 if (spec.transport === 'stdio') {253 const env = Object.entries(spec.env).flatMap(([k, v]) => ['-e', `${k}=${v}`]);254 return [...head, '--transport', 'stdio', spec.name, ...env, '--', spec.target, ...spec.args];255 }256 const headers = Object.entries(spec.headers).flatMap(([k, v]) => ['--header', `${k}: ${v}`]);257 return [...head, '--transport', spec.transport, spec.name, spec.target, ...headers];258}259260export function buildRemoveArgv(name: string, scope: McpScope): string[] {261 return ['mcp', 'remove', '--scope', scope, name];262}263export function buildGetArgv(name: string): string[] {264 return ['mcp', 'get', name];265}266