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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"lint": "pnpm -r run lint",
"typecheck": "pnpm -r run typecheck",
"qa": "pnpm typecheck && pnpm lint && pnpm test",
"prepare": "husky"
"prepare": "husky",
"clear": "./scripts/clean-dev-env.sh"
},
"devDependencies": {
"@commitlint/cli": "^21.2.3",
Expand Down
7 changes: 2 additions & 5 deletions packages/cli/src/features/mcp/status.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import type { IdeId } from '@spotify-confidence/shared-kernel';
import { getIntegration, type McpServerName, type McpServerStatus } from '@spotify-confidence/core';
import { getIntegration, type McpStatusMap } from '@spotify-confidence/core';

export async function getMcpStatuses(
ideId: IdeId,
projectDir: string,
): Promise<Record<McpServerName, McpServerStatus>> {
export async function getMcpStatuses(ideId: IdeId, projectDir: string): Promise<McpStatusMap> {
const integration = getIntegration(ideId);
return integration.detectMcpStatuses(projectDir);
}
68 changes: 24 additions & 44 deletions packages/core/__tests__/integrations/mcp-disconnect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,27 +21,29 @@ beforeEach(() => {

describe('claude disconnectMcpServer', () => {
async function loadDisconnect() {
const mod = await import('@integrations/claude/mcp.js');
const mod = await import('@integrations/claude/mcp/index.js');
return mod.disconnectMcpServer;
}

it('calls claude mcp remove with the server name', async () => {
const sut = await loadDisconnect();
it.each(['local', 'project', 'user'] as const)(
'calls claude mcp remove with %s scope',
async (scope) => {
const sut = await loadDisconnect();

await sut({ serverName: 'confidence-flags', projectDir: '/project' });
await sut({ serverName: 'confidence-flags', projectDir: '/project' });

expect(execFile).toHaveBeenCalledWith(
'claude',
['mcp', 'remove', '--scope', 'project', 'confidence-flags'],
{ cwd: '/project' },
);
});
expect(execFile).toHaveBeenCalledWith(
'claude',
['mcp', 'remove', '--scope', scope, 'confidence-flags'],
{ cwd: '/project' },
);
},
);

it('removes the tool permission from settings.local.json', async () => {
using project = createProjectDir('empty');
writeClaudeSettings(project.path, {
permissions: { allow: ['mcp__confidence-flags__*', 'mcp__other__*'] },
enabledMcpjsonServers: ['confidence-flags', 'other-server'],
});
const sut = await loadDisconnect();

Expand All @@ -51,7 +53,6 @@ describe('claude disconnectMcpServer', () => {
readFileSync(join(project.path, '.claude', 'settings.local.json'), 'utf-8'),
);
expect(settings.permissions.allow).toEqual(['mcp__other__*']);
expect(settings.enabledMcpjsonServers).toEqual(['other-server']);
});

it('does not fail when settings.local.json does not exist', async () => {
Expand All @@ -67,32 +68,30 @@ describe('claude disconnectMcpServer', () => {
using project = createProjectDir('empty');
writeClaudeSettings(project.path, {
permissions: { allow: ['mcp__confidence-flags__*'] },
enabledMcpjsonServers: ['confidence-flags'],
});
execFile.mockRejectedValueOnce(new Error('claude not found'));
execFile.mockRejectedValue(new Error('claude not found'));
const sut = await loadDisconnect();

await expect(sut({ serverName: 'confidence-flags', projectDir: project.path })).rejects.toThrow(
'claude not found',
);
await sut({ serverName: 'confidence-flags', projectDir: project.path });

const settings = JSON.parse(
readFileSync(join(project.path, '.claude', 'settings.local.json'), 'utf-8'),
);
expect(settings.permissions.allow).toEqual([]);
expect(settings.enabledMcpjsonServers).toEqual([]);
});
});

describe('cursor disconnectMcpServer', () => {
async function loadDisconnect() {
const mod = await import('@integrations/cursor/mcp.js');
const mod = await import('@integrations/cursor/mcp/index.js');
return mod.disconnectMcpServer;
}

it('removes the server entry from project config and CLI permissions', async () => {
it('removes the server from global config and CLI permissions', async () => {
using project = createProjectDir('empty');
writeCursorMcpConfig(project.path, {
using home = createProjectDir('empty');
vi.stubEnv('HOME', home.path);
writeCursorMcpConfig(home.path, {
mcpServers: {
'confidence-flags': { type: 'http', url: 'https://example.com' },
'other-server': { type: 'http', url: 'https://other.com' },
Expand All @@ -106,33 +105,14 @@ describe('cursor disconnectMcpServer', () => {

await sut({ serverName: 'confidence-flags', projectDir: project.path });

const updatedMcp = JSON.parse(readFileSync(join(project.path, '.cursor', 'mcp.json'), 'utf-8'));
expect(updatedMcp.mcpServers).not.toHaveProperty('confidence-flags');
expect(updatedMcp.mcpServers).toHaveProperty('other-server');
const globalMcp = JSON.parse(readFileSync(join(home.path, '.cursor', 'mcp.json'), 'utf-8'));
expect(globalMcp.mcpServers).not.toHaveProperty('confidence-flags');
expect(globalMcp.mcpServers).toHaveProperty('other-server');

const updatedCli = JSON.parse(readFileSync(join(project.path, '.cursor', 'cli.json'), 'utf-8'));
expect(updatedCli.permissions.allow).toEqual(['Mcp(other:*)']);
});

it('does not remove the server from global config', async () => {
using project = createProjectDir('empty');
using home = createProjectDir('empty');
vi.stubEnv('HOME', home.path);
writeCursorMcpConfig(project.path, {
mcpServers: { 'confidence-flags': { type: 'http', url: 'https://example.com' } },
});
writeCursorMcpConfig(home.path, {
mcpServers: { 'confidence-flags': { type: 'http', url: 'https://example.com' } },
});

const sut = await loadDisconnect();

await sut({ serverName: 'confidence-flags', projectDir: project.path });

const globalMcp = JSON.parse(readFileSync(join(home.path, '.cursor', 'mcp.json'), 'utf-8'));
expect(globalMcp.mcpServers).toHaveProperty('confidence-flags');
});

it('does not fail when config files do not exist', async () => {
using project = createProjectDir('empty');
const sut = await loadDisconnect();
Expand All @@ -145,7 +125,7 @@ describe('cursor disconnectMcpServer', () => {

describe('codex disconnectMcpServer', () => {
async function loadDisconnect() {
const mod = await import('@integrations/codex/mcp.js');
const mod = await import('@integrations/codex/mcp/index.js');
return mod.disconnectMcpServer;
}

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/integrations/claude/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { IdeIntegration } from '../types.js';
import { launchChat } from './chat.js';
import { detectPlugin, installPlugin, updatePlugin, uninstallPlugin } from './plugins.js';
import { skillsDir } from './paths.js';
import { detectMcpStatuses, connectMcpServer, disconnectMcpServer } from './mcp.js';
import { detectMcpStatuses, connectMcpServer, disconnectMcpServer } from './mcp/index.js';
import { runOnboarding } from './onboarding.js';
import { prepare } from './prepare.js';

Expand Down
128 changes: 0 additions & 128 deletions packages/core/src/integrations/claude/mcp.ts

This file was deleted.

48 changes: 48 additions & 0 deletions packages/core/src/integrations/claude/mcp/connect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { execFile } from '../../../exec/exec.js';
import type { McpConnectOpts, McpDisconnectOpts } from '../../types.js';
import { allowMcpToolsInSettings, removeMcpToolsFromSettings } from './settings.js';

export async function connectMcpServer(opts: McpConnectOpts): Promise<void> {
await unregisterServer(opts);
await registerServer(opts);
allowMcpToolsInSettings(opts);
}

export async function disconnectMcpServer(opts: McpDisconnectOpts): Promise<void> {
removeMcpToolsFromSettings(opts);
await unregisterServer(opts);
}

async function registerServer(opts: McpConnectOpts): Promise<void> {
const headers = { ...opts.serverHeaders };
const args = [
'mcp',
'add',
'--transport',
'http',
'--scope',
'local',
opts.serverName,
opts.serverUrl,
];

if (opts.accessToken) {
headers['Authorization'] = `Bearer ${opts.accessToken}`;
}

for (const [key, value] of Object.entries(headers)) {
args.push('--header', `${key}: ${value}`);
}

await execFile('claude', args, { cwd: opts.projectDir });
}

async function unregisterServer(opts: McpDisconnectOpts): Promise<void> {
await Promise.allSettled(
(['local', 'project', 'user'] as const).map((scope) =>
execFile('claude', ['mcp', 'remove', '--scope', scope, opts.serverName], {
cwd: opts.projectDir,
}),
),
);
}
45 changes: 45 additions & 0 deletions packages/core/src/integrations/claude/mcp/detect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { realpathSync, readFileSync } from 'node:fs';
import type { McpStatusMap } from '../../mcp/servers.js';
import { type McpServerName, detectMcpStatuses as detectShared } from '../../mcp/servers.js';
import { onlyKnownServerNames } from '../../mcp/config.js';
import { globalConfigPath } from '../paths.js';

type McpServerEntry = {
headers?: Record<string, string>;
};

type ClaudeProjectConfig = {
mcpServers?: Record<string, McpServerEntry>;
};

type ClaudeGlobalConfig = {
projects?: Record<string, ClaudeProjectConfig>;
};

function readProjectMcpServers(projectDir: string): Record<string, McpServerEntry> {
try {
const resolved = realpathSync(projectDir);
const config = JSON.parse(readFileSync(globalConfigPath(), 'utf-8')) as ClaudeGlobalConfig;
return (
config.projects?.[resolved]?.mcpServers ?? config.projects?.[projectDir]?.mcpServers ?? {}
);
} catch {
return {};
}
}

function getLocalScopeMcpNames(projectDir: string): string[] {
return onlyKnownServerNames(Object.keys(readProjectMcpServers(projectDir)));
}

function getLocalScopeAuthToken(projectDir: string, serverName: McpServerName): string | null {
const bearer = readProjectMcpServers(projectDir)[serverName]?.headers?.['Authorization'];
return bearer?.startsWith('Bearer ') ? bearer.slice(7) : null;
}

export function detectMcpStatuses(projectDir: string): Promise<McpStatusMap> {
return detectShared({
getRegisteredNames: () => getLocalScopeMcpNames(projectDir),
getAuthToken: (name) => getLocalScopeAuthToken(projectDir, name),
});
}
2 changes: 2 additions & 0 deletions packages/core/src/integrations/claude/mcp/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { detectMcpStatuses } from './detect.js';
export { connectMcpServer, disconnectMcpServer } from './connect.js';
Loading
Loading