SPB Git

Repository Public

fsx-mcp-config.ts 10.7 KB 266 lines typescript

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
Files main
Menu

Theme