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
150 changes: 150 additions & 0 deletions server/__tests__/mcp-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { loadFromFile, getMcpConfigPaths } from '../mcp-config.js';
import { writeFileSync, mkdirSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';

const TEST_DIR = join(tmpdir(), `mitzo-mcp-test-${Date.now()}`);

beforeEach(() => {
mkdirSync(TEST_DIR, { recursive: true });
});

afterEach(() => {
rmSync(TEST_DIR, { recursive: true, force: true });
});

function writeConfig(filename: string, content: object): string {
const path = join(TEST_DIR, filename);
writeFileSync(path, JSON.stringify(content));
return path;
}

describe('loadFromFile', () => {
it('loads stdio servers from a Cursor-format mcp.json', () => {
const path = writeConfig('mcp.json', {
mcpServers: {
atlassian: {
command: '/usr/local/bin/uvx',
args: ['mcp-atlassian', '--jira-url', 'https://example.atlassian.net'],
},
},
});

const servers = loadFromFile(path);
expect(Object.keys(servers)).toEqual(['atlassian']);
expect(servers.atlassian.command).toBe('/usr/local/bin/uvx');
expect(servers.atlassian.args).toEqual([
'mcp-atlassian',
'--jira-url',
'https://example.atlassian.net',
]);
});

it('excludes disabled servers', () => {
const path = writeConfig('mcp.json', {
mcpServers: {
active: { command: 'node', args: ['server.js'] },
disabled: { command: 'node', args: ['other.js'], disabled: true },
},
});

const servers = loadFromFile(path);
expect(Object.keys(servers)).toEqual(['active']);
});

it('excludes servers without a command', () => {
const path = writeConfig('mcp.json', {
mcpServers: {
noCommand: { args: ['something'] },
},
});

const servers = loadFromFile(path as string);
expect(Object.keys(servers)).toEqual([]);
});

it('excludes non-stdio server types', () => {
const path = writeConfig('mcp.json', {
mcpServers: {
httpServer: { type: 'http', url: 'http://localhost:8080' },
stdioServer: { command: 'node', args: ['server.js'] },
},
});

const servers = loadFromFile(path);
expect(Object.keys(servers)).toEqual(['stdioServer']);
});

it('loads multiple servers', () => {
const path = writeConfig('mcp.json', {
mcpServers: {
jira: { command: 'uvx', args: ['mcp-atlassian'] },
github: { command: 'npx', args: ['mcp-github'] },
gitlab: { command: 'node', args: ['gitlab-mcp.js'] },
},
});

const servers = loadFromFile(path);
expect(Object.keys(servers).sort()).toEqual(['github', 'gitlab', 'jira']);
});

it('returns empty object for missing file', () => {
const servers = loadFromFile('/nonexistent/path/mcp.json');
expect(servers).toEqual({});
});

it('returns empty object for malformed JSON', () => {
const path = join(TEST_DIR, 'bad.json');
writeFileSync(path, '{ not valid json');
const servers = loadFromFile(path);
expect(servers).toEqual({});
});

it('returns empty object for file without mcpServers key', () => {
const path = writeConfig('empty.json', { otherKey: 'value' });
const servers = loadFromFile(path);
expect(servers).toEqual({});
});

it('handles servers with command but no args', () => {
const path = writeConfig('mcp.json', {
mcpServers: {
simple: { command: '/usr/bin/my-server' },
},
});

const servers = loadFromFile(path);
expect(servers.simple.command).toBe('/usr/bin/my-server');
expect(servers.simple.args).toBeUndefined();
});
});

describe('getMcpConfigPaths', () => {
it('includes MCP_CONFIG_PATH when env var is set to existing file', () => {
const path = writeConfig('custom.json', { mcpServers: {} });
const original = process.env.MCP_CONFIG_PATH;
process.env.MCP_CONFIG_PATH = path;

try {
const paths = getMcpConfigPaths();
expect(paths).toContain(path);
} finally {
if (original === undefined) delete process.env.MCP_CONFIG_PATH;
else process.env.MCP_CONFIG_PATH = original;
}
});

it('does not include MCP_CONFIG_PATH when file does not exist', () => {
const original = process.env.MCP_CONFIG_PATH;
process.env.MCP_CONFIG_PATH = '/nonexistent/file.json';

try {
const paths = getMcpConfigPaths();
expect(paths).not.toContain('/nonexistent/file.json');
} finally {
if (original === undefined) delete process.env.MCP_CONFIG_PATH;
else process.env.MCP_CONFIG_PATH = original;
}
});
});
13 changes: 13 additions & 0 deletions server/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,21 @@ import { createWorktree } from './worktree.js';
import { SessionRegistry, type MitzoMode } from './session-registry.js';
import { summarizeToolInput } from './tool-summary.js';
import { parseContentBlocks, extractToolResultText } from './content-blocks.js';
import { loadMcpServers, type McpServerConfig } from './mcp-config.js';

export type { MitzoMode } from './session-registry.js';

let mcpServers: Record<string, McpServerConfig> = {};
try {
mcpServers = loadMcpServers();
} catch (err: unknown) {
console.error('[mcp] failed to load MCP servers:', err instanceof Error ? err.message : err);
}

export function getMcpServerNames(): string[] {
return Object.keys(mcpServers);
}

export const BASE_REPO = process.env.REPO_PATH || '';
const WORKTREE_ENABLED = process.env.WORKTREE_ENABLED !== 'false';

Expand Down Expand Up @@ -210,6 +222,7 @@ export async function startChat(
allowedTools: [...baseAllowed, ...extraTools],
...(options.model ? { model: options.model } : {}),
...(options.resume ? { resume: options.resume } : {}),
...(Object.keys(mcpServers).length > 0 ? { mcpServers } : {}),
canUseTool: buildPermissionHandler(clientId) as any, // SDK typing requires broad compat
},
});
Expand Down
5 changes: 4 additions & 1 deletion server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
clearHiddenSessions,
AVAILABLE_MODELS,
BASE_REPO,
getMcpServerNames,
registry,
} from './chat.js';
import { cleanupStaleWorktrees, listWorktrees } from './worktree.js';
Expand Down Expand Up @@ -97,7 +98,9 @@ app.get('/api/auth/check', (_req, res) => res.json({ ok: true }));
app.get('/api/models', (_req, res) => res.json(AVAILABLE_MODELS));

// Config (exposes non-sensitive settings to frontend)
app.get('/api/config', (_req, res) => res.json({ repoPath: BASE_REPO }));
app.get('/api/config', (_req, res) =>
res.json({ repoPath: BASE_REPO, mcpServers: getMcpServerNames() }),
);

// Session routes
app.get('/api/sessions', async (_req, res) => {
Expand Down
98 changes: 98 additions & 0 deletions server/mcp-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';

interface CursorMcpEntry {
command: string;
args?: string[];
disabled?: boolean;
type?: string;
url?: string;
headers?: Record<string, string>;
}

interface CursorMcpFile {
mcpServers?: Record<string, CursorMcpEntry>;
}

export interface McpServerConfig {
type?: 'stdio';
command: string;
args?: string[];
}

/**
* Load MCP server configurations from Cursor-format JSON files.
* Reads from:
* 1. MCP_CONFIG_PATH env var (if set)
* 2. ~/.cursor/mcp.json (user-level Cursor config)
*
* Only stdio-type servers are supported (command + args).
* Disabled servers are excluded.
*/
export function loadMcpServers(): Record<string, McpServerConfig> {
const configs: Record<string, McpServerConfig> = {};
const paths = getMcpConfigPaths();

for (const configPath of paths) {
const loaded = loadFromFile(configPath);
for (const [name, config] of Object.entries(loaded)) {
configs[name] = config;
}
}

const count = Object.keys(configs).length;
if (count > 0) {
console.log(`[mcp] loaded ${count} server(s): ${Object.keys(configs).join(', ')}`);
}

return configs;
}

export function getMcpConfigPaths(): string[] {
const paths: string[] = [];

const envPath = process.env.MCP_CONFIG_PATH;
if (envPath && existsSync(envPath)) {
paths.push(envPath);
}

const cursorPath = join(homedir(), '.cursor', 'mcp.json');
if (existsSync(cursorPath)) {
paths.push(cursorPath);
}

return paths;
}

export function loadFromFile(configPath: string): Record<string, McpServerConfig> {
const configs: Record<string, McpServerConfig> = {};

try {
const raw = readFileSync(configPath, 'utf-8');
const parsed: CursorMcpFile = JSON.parse(raw);

if (!parsed.mcpServers) return configs;

for (const [name, entry] of Object.entries(parsed.mcpServers)) {
if (entry.disabled) continue;
if (!entry.command) continue;

// Only support stdio servers (command + args)
if (entry.type && entry.type !== 'stdio') {
console.log(`[mcp] skipping ${name}: unsupported type '${entry.type}'`);
continue;
}

configs[name] = {
command: entry.command,
...(entry.args ? { args: entry.args } : {}),
};
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error(`[mcp] failed to load ${configPath}: ${message}`);
}

return configs;
}
Loading