Repository Public
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