Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions src/__tests__/e2e/mcp-uninstall.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { spawn } from 'node:child_process';
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
import { fileURLToPath } from 'node:url';

// ─── MCP uninstall end-to-end ─────────────────────────────────
//
// Offline fixture (no TEAMAI_TEST_TOKEN). Drives the real CLI binary:
// mcp inject → servers land in ~/.claude.json
// uninstall → teamai-managed servers are removed; hand-added ones stay
//
// Catches the order bug where removeAll ran after ~/.teamai/ (and thus
// managed-mcp.json) had already been deleted.

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..', '..', '..');
const CLI = path.join(ROOT, 'dist', 'index.js');

interface RunResult {
code: number | null;
output: string;
}

function runCLI(args: string[], env: Record<string, string>, cwd: string): Promise<RunResult> {
return new Promise((resolve) => {
const child = spawn('node', [CLI, ...args], {
env: { ...process.env, FORCE_COLOR: '0', ...env },
stdio: ['pipe', 'pipe', 'pipe'],
cwd,
});
let out = '';
child.stdout.on('data', (d: Buffer) => { out += d.toString(); });
child.stderr.on('data', (d: Buffer) => { out += d.toString(); });
child.stdin.end();
child.on('close', (code) => resolve({ code, output: out }));
});
}

const TEAM_YAML = [
'team: mcp-uninstall-e2e',
'repo: https://example.com/mcp-e2e.git',
'provider: tgit',
'toolPaths:',
' claude:',
' skills: .claude/skills',
' rules: .claude/rules',
' settings: .claude/settings.json',
' claudemd: .claude/CLAUDE.md',
' mcp: .claude.json',
' mcpProject: .mcp.json',
].join('\n');

const MCP_YAML = [
'servers:',
' - name: team-mcp',
' transport: http',
' url: https://example.com/api/mcp',
' tools: [claude]',
].join('\n');

describe('MCP uninstall (e2e)', () => {
let sandbox: string;
let homeDir: string;
let claudeJson: string;
let teamaiHome: string;

beforeAll(() => {
if (!fs.existsSync(CLI)) {
throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`);
}

sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-mcp-uninstall-e2e-'));
homeDir = path.join(sandbox, 'home');
teamaiHome = path.join(homeDir, '.teamai');
claudeJson = path.join(homeDir, '.claude.json');

fs.mkdirSync(path.join(homeDir, '.claude', 'skills'), { recursive: true });
fs.mkdirSync(path.join(homeDir, '.claude', 'rules'), { recursive: true });

// Local team-repo (no git remote needed — inject/uninstall only read files).
const repoLocal = path.join(teamaiHome, 'team-repo');
fs.mkdirSync(path.join(repoLocal, 'mcp'), { recursive: true });
fs.writeFileSync(path.join(repoLocal, 'teamai.yaml'), TEAM_YAML);
fs.writeFileSync(path.join(repoLocal, 'mcp', 'mcp.yaml'), MCP_YAML);

fs.writeFileSync(
path.join(teamaiHome, 'config.yaml'),
[
'repo:',
` localPath: ${repoLocal}`,
' remote: https://example.com/mcp-e2e.git',
'username: e2e-user',
'updatePolicy: auto',
'scope: user',
].join('\n'),
);

// Hand-added server that uninstall must leave alone.
fs.writeFileSync(
claudeJson,
JSON.stringify({ mcpServers: { 'my-own': { command: 'my-server' } } }, null, 2),
);
}, 30_000);

afterAll(() => {
if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true });
});

it('injects a team MCP server, then uninstall removes it and keeps user servers', async () => {
const env = { HOME: homeDir };

const inject = await runCLI(['mcp', 'inject'], env, homeDir);
expect(inject.code).toBe(0);
expect(inject.output).toMatch(/added\s+claude\/team-mcp/);

const afterInject = JSON.parse(fs.readFileSync(claudeJson, 'utf-8'));
expect(afterInject.mcpServers['team-mcp']).toEqual({
type: 'http',
url: 'https://example.com/api/mcp',
});
expect(afterInject.mcpServers['my-own']).toEqual({ command: 'my-server' });
expect(fs.existsSync(path.join(teamaiHome, 'managed-mcp.json'))).toBe(true);

const uninstall = await runCLI(['uninstall', '--force'], env, homeDir);
expect(uninstall.code).toBe(0);
expect(uninstall.output).toMatch(/MCP server/i);

const afterUninstall = JSON.parse(fs.readFileSync(claudeJson, 'utf-8'));
expect(afterUninstall.mcpServers['team-mcp']).toBeUndefined();
expect(afterUninstall.mcpServers['my-own']).toEqual({ command: 'my-server' });
expect(fs.existsSync(teamaiHome)).toBe(false);
}, 60_000);
});
47 changes: 47 additions & 0 deletions src/__tests__/uninstall.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,53 @@ describe('uninstall', () => {
expect(await fse.pathExists(teamaiHome)).toBe(false);
});

// Regression: MCP cleanup used to run after ~/.teamai/ was deleted, so the
// ownership manifest was already gone and removeAll became a no-op.
it('卸载时移除 teamai 管理的 MCP server,并保留用户自建的', async () => {
const { homeDir, repoPath, teamaiHome } = await setupFixture(tmpDir);
vi.stubEnv('HOME', homeDir);
vi.stubEnv('SHELL', '/bin/zsh');

await fse.writeJson(path.join(homeDir, '.claude.json'), {
mcpServers: {
'team-mcp': { type: 'http', url: 'https://team.example/mcp' },
'my-own': { command: 'my-server' },
},
});
await fse.writeJson(path.join(teamaiHome, 'managed-mcp.json'), {
claude: [{ name: 'team-mcp', hash: 'abc' }],
});

const teamConfig = makeTeamConfig({
toolPaths: {
claude: {
skills: '.claude/skills',
rules: '.claude/rules',
settings: '.claude/settings.json',
claudemd: '.claude/CLAUDE.md',
mcp: '.claude.json',
mcpProject: '.mcp.json',
},
},
sharing: {
skills: {},
rules: { enforced: [] },
docs: { localDir: `${teamaiHome}/docs` },
env: { injectShellProfile: true },
},
});
mockAutoDetectInit.mockResolvedValue({
localConfig: makeLocalConfig(homeDir, repoPath),
teamConfig,
});

await uninstall({ force: true });

const after = await fse.readJson(path.join(homeDir, '.claude.json'));
expect(after.mcpServers['team-mcp']).toBeUndefined();
expect(after.mcpServers['my-own']).toEqual({ command: 'my-server' });
});

it('移除 OpenClaw 系 agent 的 HOOK.md 目录(无 settings 路径)', async () => {
const { homeDir, repoPath, teamaiHome } = await setupFixture(tmpDir);
vi.stubEnv('HOME', homeDir);
Expand Down
13 changes: 7 additions & 6 deletions src/uninstall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -541,19 +541,20 @@ export async function uninstall(opts: UninstallOptions): Promise<void> {
}
}

await executeRemoval(plan);

// MCP servers live in tool-owned config files tracked by their own manifest,
// so they are cleaned up through the reconcile engine rather than the plan.
// MCP cleanup must run before executeRemoval deletes ~/.teamai/: ownership is
// tracked in managed-mcp.json inside that directory. Hooks already do this
// inside executeRemoval for the same reason.
try {
const { reconcileMcpForConfig } = await import('./mcp-reconcile.js');
const { changes } = await reconcileMcpForConfig(teamConfig, localConfig, { removeAll: true });
const removed = changes.filter((c) => c.action === 'removed');
if (removed.length > 0) log.info(`移除 ${removed.length} teamai 管理的 MCP server`);
if (removed.length > 0) log.info(`Removed ${removed.length} teamai-managed MCP server(s)`);
} catch (e) {
log.warn(`移除 MCP server 失败: ${(e as Error).message}`);
log.warn(`Failed to remove MCP servers: ${(e as Error).message}`);
}

await executeRemoval(plan);

log.success('teamai 卸载完成');
} else {
// Minimal uninstall — just try to remove ~/.teamai/
Expand Down
Loading