SPB Git

Repository Public

mcp.test.ts 21.3 KB 329 lines typescript

1import { afterAll, beforeAll, describe, expect, it } from 'vitest';2import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs';3import { randomBytes } from 'node:crypto';4import path from 'node:path';5import type { McpServerInfo } from '@cr/shared';6import { buildApp, call, errCode, makeConfig, makeTmp, rmTmp } from './fsx-testkit.js';7import { createMcpPlugin } from './mcp.js';8import { defaultClaudeEnv, isolatedClaudeEnv, resolveClaudeBinary, type ClaudeEnv } from './fsx-claude-env.js';9import {10  MASK,11  buildAddArgv,12  buildGetArgv,13  buildRemoveArgv,14  describeServer,15  looksHighEntropy,16  maskArgs,17  parseClaudeJson,18  parseMcpGet,19  parseMcpList,20  sanitizeUrl,21  validateAdd,22  type AddSpec,23} from './fsx-mcp-config.js';2425const SECRET = 'sk-live-9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c';26const HEX = 'a3f91c0b7e2d4f6a8b1c3d5e7f9a0b2c4d6e8f0a';2728let tmp: string;29let ws: string;30let proj: string;31let outside: string;3233beforeAll(() => {34  tmp = makeTmp('cr-mcp-');35  ws = path.join(tmp, 'ws');36  proj = path.join(ws, 'proj');37  outside = path.join(tmp, 'outside');38  mkdirSync(proj, { recursive: true });39  mkdirSync(outside, { recursive: true });40});41afterAll(() => rmTmp(tmp));4243describe('secret masking', () => {44  it('sanitizes URLs (userinfo, query, fragment, secret path segments)', () => {45    expect(sanitizeUrl(`https://user:${SECRET}@mcp.example.com/v1/mcp?api_key=${SECRET}#frag`)).toBe('https://mcp.example.com/v1/mcp');46    expect(sanitizeUrl(`https://mcp.example.com/${HEX}/sse`)).toBe(`https://mcp.example.com/${MASK}/sse`);47    expect(sanitizeUrl('http://localhost:8080/mcp')).toBe('http://localhost:8080/mcp');48    expect(sanitizeUrl(`not a url?token=${SECRET}`)).toBe('not a url');49    expect(sanitizeUrl(`user:${SECRET}@host/path?x=1`)).not.toContain(SECRET);50  });51  it('masks secret-looking stdio arguments and keeps ordinary ones', () => {52    expect(maskArgs(['-y', '@modelcontextprotocol/server-filesystem', '/Users/someone/Desktop/project-with-a-long-name-2026'])).toEqual([53      '-y',54      '@modelcontextprotocol/server-filesystem',55      '/Users/someone/Desktop/project-with-a-long-name-2026',56    ]);57    expect(maskArgs(['--api-key', 'abc123', '--port', '8080'])).toEqual(['--api-key', MASK, '--port', '8080']);58    expect(maskArgs(['--api-key=abc123', '--token=x', 'PASSWORD=hunter2', 'MODE=fast'])).toEqual([`--api-key=${MASK}`, `--token=${MASK}`, `PASSWORD=${MASK}`, 'MODE=fast']);59    expect(maskArgs(['--header', 'Authorization: Bearer abc'])).toEqual(['--header', MASK]);60    expect(maskArgs(['Bearer abc.def'])).toEqual([MASK]);61    expect(maskArgs([HEX, SECRET, `id=${HEX}`])).toEqual([MASK, MASK, `id=${MASK}`]);62    expect(maskArgs(['mcp-remote', `https://x.example/mcp?key=${SECRET}`])).toEqual(['mcp-remote', 'https://x.example/mcp']);63    expect(maskArgs(['--secret', '--verbose'])).toEqual(['--secret', '--verbose']);64    expect(maskArgs(['ghp_abcdefghij'])).toEqual([MASK]);65  });66  it('high-entropy heuristic', () => {67    expect(looksHighEntropy(HEX)).toBe(true);68    expect(looksHighEntropy('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1')).toBe(false);69    expect(looksHighEntropy('short1')).toBe(false);70    expect(looksHighEntropy('this-is-a-long-descriptive-package-name')).toBe(false);71  });72  it('describes servers without ever copying env or header values', () => {73    const stdio = describeServer('s', 'user', { type: 'stdio', command: 'npx', args: ['-y', 'pkg', '--token', SECRET], env: { API_KEY: SECRET, B: '2' } });74    expect(stdio).toEqual({ name: 's', scope: 'user', transport: 'stdio', target: `npx -y pkg --token ${MASK}`, envKeys: ['API_KEY', 'B'], status: 'unknown', lastError: null, checkedAt: null });75    const http = describeServer('h', 'project', { type: 'http', url: `https://u:${SECRET}@h.example/mcp?k=${SECRET}`, headers: { Authorization: `Bearer ${SECRET}` } });76    expect(http).toMatchObject({ transport: 'http', target: 'https://h.example/mcp', envKeys: [] });77    expect(describeServer('e', 'user', { type: 'sse', url: 'https://h.example/sse' })?.transport).toBe('sse');78    expect(describeServer('n', 'user', { command: 'node', args: ['a b.js'] })).toMatchObject({ transport: 'stdio', target: 'node "a b.js"' });79    expect(describeServer('u', 'user', { url: 'https://h.example/x' })?.transport).toBe('http');80    expect(describeServer('bad', 'user', { nothing: true })).toBeNull();81    expect(describeServer('bad', 'user', 'string')).toBeNull();82    expect(JSON.stringify([stdio, http])).not.toContain(SECRET);83  });84});8586describe('claude mcp list / get parsing', () => {87  const LIST = [88    'Checking MCP server health…',89    '',90    'apify: npx -y @apify/actors-mcp-server - ✔ Connected',91    'old-style: /bin/x - ✓ Connected',92    'hsrv: https://example.invalid/mcp?x=1 (HTTP) - ✘ Failed to connect — ENOTFOUND: TypeError dialing https://example.invalid[redacted]?x=REDACTED (ENOTFOUND)',93    'lsrv: /bin/echo l - ✗ Failed to connect',94    'oauth: https://mcp.example.com/mcp (HTTP) - ! Needs authentication',95    'psrv: /bin/echo p - ⏸ Pending approval (run `claude` to approve)',96    'dashy: tool - with - dashes --flag - ✔ Connected',97    'plugin:foo:bar: node server.js - ✔ Connected',98    'weird line without status',99    '  indented: x - ✔ Connected',100  ].join('\n');101  it('maps every known status and tolerates noise', () => {102    const m = parseMcpList(LIST);103    expect(m.get('apify')).toEqual({ status: 'connected', lastError: null });104    expect(m.get('old-style')?.status).toBe('connected');105    expect(m.get('hsrv')).toEqual({ status: 'failed', lastError: 'ENOTFOUND: TypeError dialing https://example.invalid[redacted] (ENOTFOUND)' });106    expect(m.get('lsrv')).toEqual({ status: 'failed', lastError: null });107    expect(m.get('oauth')).toEqual({ status: 'needs_auth', lastError: null });108    expect(m.get('psrv')).toEqual({ status: 'unknown', lastError: 'Pending approval' });109    expect(m.get('dashy')?.status).toBe('connected');110    expect(m.get('plugin:foo:bar')?.status).toBe('connected');111    expect(m.size).toBe(8);112    expect(parseMcpList('').size).toBe(0);113    expect(parseMcpList('No MCP servers configured. Use `claude mcp add` to add a server.').size).toBe(0);114  });115  it('reads only Status / Issue from `mcp get`', () => {116    const out = `hsrv:\n  Scope: User config\n  Status: ✘ Failed to connect\n  Issue: ENOTFOUND: dialing https://h.example/mcp?token=${SECRET}\n  Type: http\n  URL: https://h.example/mcp?x=1\n  Headers:\n    Authorization: Bearer ${SECRET}\n`;117    const r = parseMcpGet(out);118    expect(r.status).toBe('failed');119    expect(r.lastError).toBe('ENOTFOUND: dialing https://h.example/mcp');120    expect(parseMcpGet('x:\n  Status: ✔ Connected\n')).toEqual({ status: 'connected', lastError: null });121    expect(parseMcpGet('x:\n  Status: ! Needs authentication\n').status).toBe('needs_auth');122    expect(parseMcpGet('garbage')).toEqual({ status: 'unknown', lastError: null });123  });124});125126describe('argv builders + validation', () => {127  const base: AddSpec = { name: 'srv', transport: 'stdio', target: 'npx', args: [], env: {}, headers: {}, scope: 'user' };128  it('builds the exact `claude mcp add` argv', () => {129    expect(buildAddArgv({ ...base, args: ['-y', 'pkg', '--flag'], env: { A: '1', B: 'x y' } })).toEqual([130      'mcp', 'add', '--scope', 'user', '--transport', 'stdio', 'srv', '-e', 'A=1', '-e', 'B=x y', '--', 'npx', '-y', 'pkg', '--flag',131    ]);132    expect(buildAddArgv({ ...base, target: '--weird', scope: 'project' })).toEqual(['mcp', 'add', '--scope', 'project', '--transport', 'stdio', 'srv', '--', '--weird']);133    expect(buildAddArgv({ ...base, transport: 'http', target: 'https://h.example/mcp', headers: { Authorization: 'Bearer t', 'X-A': '1' } })).toEqual([134      'mcp', 'add', '--scope', 'user', '--transport', 'http', 'srv', 'https://h.example/mcp', '--header', 'Authorization: Bearer t', '--header', 'X-A: 1',135    ]);136    expect(buildAddArgv({ ...base, transport: 'sse', target: 'https://h.example/sse' })).toEqual(['mcp', 'add', '--scope', 'user', '--transport', 'sse', 'srv', 'https://h.example/sse']);137    expect(buildRemoveArgv('srv', 'local')).toEqual(['mcp', 'remove', '--scope', 'local', 'srv']);138    expect(buildGetArgv('srv')).toEqual(['mcp', 'get', 'srv']);139  });140  it('rejects option injection and malformed input', () => {141    expect(validateAdd(base)).toBeNull();142    expect(validateAdd({ ...base, transport: 'http', target: 'https://h.example/mcp' })).toBeNull();143    expect(validateAdd({ ...base, name: '-s' })).not.toBeNull();144    expect(validateAdd({ ...base, name: 'a b' })).not.toBeNull();145    expect(validateAdd({ ...base, env: { 'BAD-NAME': '1' } })).not.toBeNull();146    expect(validateAdd({ ...base, env: { '--scope': 'user' } })).not.toBeNull();147    expect(validateAdd({ ...base, env: { A: 'x\0y' } })).not.toBeNull();148    expect(validateAdd({ ...base, headers: { A: '1' } })).not.toBeNull();149    expect(validateAdd({ ...base, target: ' ' })).not.toBeNull();150    expect(validateAdd({ ...base, transport: 'http', target: '--scope' })).not.toBeNull();151    expect(validateAdd({ ...base, transport: 'http', target: 'file:///etc/passwd' })).not.toBeNull();152    expect(validateAdd({ ...base, transport: 'http', target: 'https://h.example/x', headers: { 'X-A': 'v\r\nX-Evil: 1' } })).not.toBeNull();153    expect(validateAdd({ ...base, transport: 'http', target: 'https://h.example/x', headers: { 'Bad Name': 'v' } })).not.toBeNull();154    expect(validateAdd({ ...base, transport: 'sse', target: 'https://h.example/x', args: ['x'] })).not.toBeNull();155    expect(validateAdd({ ...base, transport: 'sse', target: 'https://h.example/x', env: { A: '1' } })).not.toBeNull();156  });157});158159describe('GET /mcp (config files, fake CLI)', () => {160  let home: string;161  let env: ClaudeEnv;162  function fakeClaude(listOut: string, getOut = '', exit = 0): string {163    const p = path.join(tmp, `claude-fake-${randomBytes(4).toString('hex')}`);164    const counter = `${p}.calls`;165    writeFileSync(166      p,167      `#!/bin/sh\necho "$@" >> "${counter}"\nif [ "$1 $2" = "mcp list" ]; then cat <<'EOF'\n${listOut}\nEOF\nexit ${exit}; fi\nif [ "$1 $2" = "mcp get" ]; then cat <<'EOF'\n${getOut}\nEOF\nexit ${exit}; fi\nexit 3\n`,168      { mode: 0o755 },169    );170    return p;171  }172  beforeAll(() => {173    home = path.join(tmp, 'home-fake');174    env = isolatedClaudeEnv(home);175    mkdirSync(env.configDir, { recursive: true });176    writeFileSync(177      env.claudeJson,178      JSON.stringify({179        numStartups: 3,180        mcpServers: {181          alpha: { type: 'stdio', command: 'npx', args: ['-y', 'alpha-mcp', '--api-key', SECRET], env: { ALPHA_TOKEN: SECRET } },182          web: { type: 'http', url: `https://user:${SECRET}@web.example/mcp?key=${SECRET}`, headers: { Authorization: `Bearer ${SECRET}` } },183          shared: { command: 'user-version' },184        },185        projects: {186          [proj]: { mcpServers: { localsrv: { type: 'sse', url: 'https://local.example/sse' }, shared: { command: 'local-version' } } },187          '/somewhere/else': { mcpServers: { foreign: { command: 'x' } } },188        },189      }),190    );191    writeFileSync(path.join(proj, '.mcp.json'), JSON.stringify({ mcpServers: { projsrv: { command: 'node', args: ['server.js'], env: { P: SECRET } } } }));192  });193194  it('merges scopes, masks secrets and applies live statuses', async () => {195    const bin = fakeClaude(196      ['Checking MCP server health…', '', 'alpha: npx -y alpha-mcp - ✔ Connected', 'web: https://web.example/mcp (HTTP) - ! Needs authentication', 'shared: local-version - ✘ Failed to connect — CONNECTION_CLOSED: Connection closed', 'projsrv: node server.js - ⏸ Pending approval (run `claude` to approve)'].join('\n'),197    );198    const app = await buildApp(makeConfig([ws]), createMcpPlugin({ env: { ...env, claudeBin: bin } }));199    const r = await call<McpServerInfo[]>(app, 'GET', `/mcp?root=${encodeURIComponent(proj)}`);200    expect(r.status).toBe(200);201    expect(r.raw.toString('utf8')).not.toContain(SECRET);202    const key = (s: McpServerInfo): string => `${s.name}/${s.scope}`;203    const by = Object.fromEntries(r.body.map((s) => [key(s), s]));204    expect(Object.keys(by).sort()).toEqual(['alpha/user', 'localsrv/local', 'projsrv/project', 'shared/local', 'shared/user', 'web/user']);205    expect(by['alpha/user']).toMatchObject({ transport: 'stdio', target: `npx -y alpha-mcp --api-key ${MASK}`, envKeys: ['ALPHA_TOKEN'], status: 'connected', lastError: null });206    expect(typeof by['alpha/user']?.checkedAt).toBe('string');207    expect(by['web/user']).toMatchObject({ transport: 'http', target: 'https://web.example/mcp', envKeys: [], status: 'needs_auth' });208    expect(by['localsrv/local']).toMatchObject({ transport: 'sse', status: 'unknown', checkedAt: null });209    expect(by['shared/local']).toMatchObject({ status: 'failed', lastError: 'CONNECTION_CLOSED: Connection closed' });210    expect(by['shared/user']).toMatchObject({ status: 'unknown' }); // shadowed by the local one211    expect(by['projsrv/project']).toMatchObject({ target: 'node server.js', envKeys: ['P'], status: 'unknown', lastError: 'Pending approval' });212    // without root: user scope only; cached per cwd for 60 s; live=0 skips the CLI213    const userOnly = await call<McpServerInfo[]>(app, 'GET', '/mcp');214    expect(userOnly.body.map(key)).toEqual(['alpha/user', 'shared/user', 'web/user']);215    await call(app, 'GET', `/mcp?root=${encodeURIComponent(proj)}`);216    await call(app, 'GET', `/mcp?root=${encodeURIComponent(proj)}&live=0`);217    expect(readFileSync(`${bin}.calls`, 'utf8').trim().split('\n').length).toBe(2);218    await call(app, 'GET', `/mcp?root=${encodeURIComponent(proj)}&refresh=1`);219    expect(readFileSync(`${bin}.calls`, 'utf8').trim().split('\n').length).toBe(3);220    expect(errCode(await call(app, 'GET', `/mcp?root=${encodeURIComponent(outside)}`))).toBe('root_not_allowed');221    await app.close();222  });223  it('reports status unknown when the CLI fails or is missing, and never follows a .mcp.json symlink out', async () => {224    const failing = await buildApp(makeConfig([ws]), createMcpPlugin({ env: { ...env, claudeBin: fakeClaude('boom', '', 1) } }));225    const r = await call<McpServerInfo[]>(failing, 'GET', '/mcp');226    expect(r.body.length).toBe(3);227    expect(r.body.every((s) => s.status === 'unknown' && s.checkedAt === null)).toBe(true);228    await failing.close();229    const missing = await buildApp(makeConfig([ws], { claudeBin: 'cr-not-installed-xyz' }), createMcpPlugin({ env }));230    expect((await call<McpServerInfo[]>(missing, 'GET', '/mcp')).body.every((s) => s.status === 'unknown')).toBe(true);231    const evil = path.join(ws, 'evil');232    mkdirSync(evil);233    writeFileSync(path.join(outside, 'mcp.json'), JSON.stringify({ mcpServers: { leaked: { command: 'OUTSIDE' } } }));234    symlinkSync(path.join(outside, 'mcp.json'), path.join(evil, '.mcp.json'));235    const e = await call(missing, 'GET', `/mcp?root=${encodeURIComponent(evil)}`);236    expect(JSON.stringify(e.body)).not.toContain('OUTSIDE');237    expect(errCode(e)).toBe('path_outside_root');238    await missing.close();239  });240  it('tolerates a missing or malformed global config', async () => {241    const empty = isolatedClaudeEnv(path.join(tmp, 'home-empty'));242    const app = await buildApp(makeConfig([ws]), createMcpPlugin({ env: { ...empty, claudeBin: fakeClaude('') } }));243    expect((await call(app, 'GET', '/mcp')).body).toEqual([]);244    mkdirSync(empty.configDir, { recursive: true });245    writeFileSync(empty.claudeJson, '{not json');246    expect((await call(app, 'GET', '/mcp')).body).toEqual([]);247    await app.close();248    expect(parseClaudeJson({ mcpServers: [] }, [])).toEqual([]);249    expect(parseClaudeJson(null, ['/x'])).toEqual([]);250  });251  it('/mcp/test returns status + lastError only; body validation', async () => {252    const getOut = `web:\n  Scope: User config\n  Status: ✘ Failed to connect\n  Issue: ENOTFOUND: nope\n  Headers:\n    Authorization: Bearer ${SECRET}`;253    const app = await buildApp(makeConfig([ws]), createMcpPlugin({ env: { ...env, claudeBin: fakeClaude('', getOut) } }));254    const r = await call<{ status: string; lastError: string; checkedAt: string }>(app, 'POST', '/mcp/test', { name: 'web' });255    expect(r.body).toMatchObject({ status: 'failed', lastError: 'ENOTFOUND: nope' });256    expect(r.raw.toString('utf8')).not.toContain(SECRET);257    expect((await call(app, 'POST', '/mcp/test', { name: '--help' })).status).toBe(400);258    expect((await call(app, 'POST', '/mcp/remove', { name: 'x y', scope: 'user' })).status).toBe(400);259    expect(errCode(await call(app, 'POST', '/mcp/remove', { name: 'x', scope: 'project' }))).toBe('bad_request');260    expect(errCode(await call(app, 'POST', '/mcp/add', { name: 'x', transport: 'stdio', target: 'cmd', scope: 'project' }))).toBe('bad_request');261    expect(errCode(await call(app, 'POST', '/mcp/add', { name: 'x', transport: 'http', target: 'not-a-url' }))).toBe('bad_request');262    const leak = await call(app, 'POST', '/mcp/add', { name: 'x', transport: 'stdio', target: 'cmd', env: { 'BAD NAME': SECRET } });263    expect(leak.status).toBe(400);264    expect(leak.raw.toString('utf8')).not.toContain(SECRET);265    await app.close();266  });267});268269describe('real `claude mcp add|remove|get` in an isolated HOME + CLAUDE_CONFIG_DIR', () => {270  it('writes only inside the temp config dir (skipped when claude is not installed)', async () => {271    const config = makeConfig([ws]);272    const real = defaultClaudeEnv();273    if (!(await resolveClaudeBinary(config, real)).path) return;274    const iso = isolatedClaudeEnv(path.join(tmp, 'home-live'));275    mkdirSync(iso.configDir, { recursive: true });276    expect(iso.childEnv.HOME).toBe(path.join(tmp, 'home-live'));277    expect(iso.childEnv.CLAUDE_CONFIG_DIR?.startsWith(tmp)).toBe(true);278    const app = await buildApp(config, createMcpPlugin({ env: iso }));279    const uniq = `crtest-${randomBytes(6).toString('hex')}`;280    const liveProj = path.join(ws, 'live-proj');281    mkdirSync(liveProj);282283    const add = await call(app, 'POST', '/mcp/add', { name: uniq, transport: 'stdio', target: '/bin/echo', args: ['hello', '--flag'], env: { CR_TOKEN: SECRET, OTHER: 'v' }, scope: 'user' });284    expect(add.status).toBe(200);285    const written = JSON.parse(readFileSync(iso.claudeJson, 'utf8')) as { mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }> };286    expect(written.mcpServers[uniq]).toMatchObject({ command: '/bin/echo', args: ['hello', '--flag'], env: { CR_TOKEN: SECRET, OTHER: 'v' } });287    expect(add.raw.toString('utf8')).not.toContain(SECRET);288289    const http = await call(app, 'POST', '/mcp/add', { name: `${uniq}-h`, transport: 'http', target: 'https://example.invalid/mcp?x=1', headers: { Authorization: `Bearer ${SECRET}`, 'X-Two': '2' }, scope: 'user' });290    expect(http.status).toBe(200);291    expect(http.raw.toString('utf8')).not.toContain(SECRET);292    const again = JSON.parse(readFileSync(iso.claudeJson, 'utf8')) as { mcpServers: Record<string, { type: string; url: string; headers: Record<string, string> }> };293    expect(again.mcpServers[`${uniq}-h`]).toMatchObject({ type: 'http', url: 'https://example.invalid/mcp?x=1', headers: { Authorization: `Bearer ${SECRET}`, 'X-Two': '2' } });294295    expect(errCode(await call(app, 'POST', '/mcp/add', { name: uniq, transport: 'stdio', target: '/bin/echo', scope: 'user' }))).toBe('exists');296297    const projAdd = await call(app, 'POST', '/mcp/add', { name: `${uniq}-p`, transport: 'stdio', target: '/bin/echo', args: ['p'], scope: 'project', root: liveProj });298    expect(projAdd.status).toBe(200);299    expect(JSON.parse(readFileSync(path.join(liveProj, '.mcp.json'), 'utf8')).mcpServers[`${uniq}-p`].command).toBe('/bin/echo');300301    const listed = await call<McpServerInfo[]>(app, 'GET', `/mcp?root=${encodeURIComponent(liveProj)}&live=0`);302    expect(listed.raw.toString('utf8')).not.toContain(SECRET);303    expect(listed.body.map((s) => `${s.name}/${s.scope}/${s.transport}`).sort()).toEqual([`${uniq}-h/user/http`, `${uniq}-p/project/stdio`, `${uniq}/user/stdio`]);304    expect(listed.body.find((s) => s.name === uniq)).toMatchObject({ target: '/bin/echo hello --flag', envKeys: ['CR_TOKEN', 'OTHER'] });305    expect(listed.body.find((s) => s.name === `${uniq}-h`)?.target).toBe('https://example.invalid/mcp');306307    // live status through the real `claude mcp list` (the echo server cannot speak MCP → failed)308    const live = await call<McpServerInfo[]>(app, 'GET', '/mcp');309    expect(live.raw.toString('utf8')).not.toContain(SECRET);310    expect(live.body.find((s) => s.name === uniq)?.status).toBe('failed');311    expect(live.body.find((s) => s.name === `${uniq}-h`)?.status).toBe('failed');312313    const test = await call<{ status: string }>(app, 'POST', '/mcp/test', { name: `${uniq}-h` });314    expect(test.body.status).toBe('failed');315    expect(test.raw.toString('utf8')).not.toContain(SECRET);316    expect((await call(app, 'POST', '/mcp/test', { name: 'crtest-nope' })).status).toBe(404);317318    expect((await call(app, 'POST', '/mcp/remove', { name: uniq, scope: 'user' })).status).toBe(200);319    expect((await call(app, 'POST', '/mcp/remove', { name: uniq, scope: 'user' })).status).toBe(404);320    expect((await call(app, 'POST', '/mcp/remove', { name: `${uniq}-p`, scope: 'project', root: liveProj })).status).toBe(200);321    const after = JSON.parse(readFileSync(iso.claudeJson, 'utf8')) as { mcpServers: Record<string, unknown> };322    expect(Object.keys(after.mcpServers)).toEqual([`${uniq}-h`]);323324    // the developer's real configuration never saw this server (read-only check)325    if (existsSync(real.claudeJson)) expect(readFileSync(real.claudeJson, 'utf8')).not.toContain(uniq);326    await app.close();327  }, 180_000);328});329
Files main
Menu

Theme