mcporter/tests/config-imports.test.ts
Loveacup 6f3f42ca42
Some checks failed
CI / build (${{ matrix.os }}) (macos-15) (push) Has been cancelled
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Has been cancelled
CI / build (${{ matrix.os }}) (windows-latest) (push) Has been cancelled
fix: skip imported servers with unresolvable env placeholders (#209)
* fix: skip imported servers with unresolvable env placeholders

When importing MCP server configs from external sources (Cursor, Claude),
some entries reference editor-specific variables like ${workspaceFolder}
that are not available as environment variables outside the editor.

Previously, normalizeServerEntry would throw on unresolvable placeholders,
crashing the entire config loading. Now imported servers that fail to
resolve are silently skipped; locally-defined servers still fail fast.

Fixes: Cursor mcp.json with codegraph server using ${workspaceFolder}
prevents mcporter from loading any servers.

* fix(config): fall back from invalid imported duplicates

---------

Co-authored-by: Loveacup <loveacup@users.noreply.github.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-17 17:32:49 +08:00

465 lines
18 KiB
TypeScript

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { loadServerDefinitions } from '../src/config.js';
const FIXTURE_ROOT = path.resolve(__dirname, 'fixtures', 'imports');
let homedirSpy: { mockRestore(): void } | undefined;
let fakeHomeDir: string | undefined;
function ensureFakeHomeDir(): string {
if (!fakeHomeDir) {
throw new Error('fakeHomeDir not initialized');
}
return fakeHomeDir;
}
beforeEach(() => {
fakeHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-home-'));
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(fakeHomeDir);
process.env.HOME = fakeHomeDir;
process.env.USERPROFILE = fakeHomeDir;
process.env.APPDATA = path.join(fakeHomeDir, 'AppData', 'Roaming');
process.env.XDG_CONFIG_HOME = path.join(fakeHomeDir, '.config');
fs.mkdirSync(process.env.APPDATA, { recursive: true });
const sourceCodex = path.join(FIXTURE_ROOT, '.codex', 'config.toml');
const targetCodex = path.join(fakeHomeDir, '.codex', 'config.toml');
fs.mkdirSync(path.dirname(targetCodex), { recursive: true });
fs.copyFileSync(sourceCodex, targetCodex);
const sourceWindsurf = path.join(FIXTURE_ROOT, '.codeium', 'windsurf', 'mcp_config.json');
const targetWindsurf = path.join(fakeHomeDir, '.codeium', 'windsurf', 'mcp_config.json');
fs.mkdirSync(path.dirname(targetWindsurf), { recursive: true });
fs.copyFileSync(sourceWindsurf, targetWindsurf);
const sourceOpencode = path.join(FIXTURE_ROOT, '.config', 'opencode', 'opencode.jsonc');
const targetOpencode = path.join(fakeHomeDir, '.config', 'opencode', 'opencode.jsonc');
fs.mkdirSync(path.dirname(targetOpencode), { recursive: true });
fs.copyFileSync(sourceOpencode, targetOpencode);
const sourceClaudeSettings = path.join(FIXTURE_ROOT, 'home', '.claude', 'settings.json');
const targetClaudeSettings = path.join(fakeHomeDir, '.claude', 'settings.json');
fs.mkdirSync(path.dirname(targetClaudeSettings), { recursive: true });
fs.copyFileSync(sourceClaudeSettings, targetClaudeSettings);
const sourceVscode = path.join(FIXTURE_ROOT, 'Library', 'Application Support', 'Code', 'User', 'mcp.json');
const vscodeTargets = [
path.join(fakeHomeDir, 'Library', 'Application Support', 'Code', 'User', 'mcp.json'),
path.join(fakeHomeDir, '.config', 'Code', 'User', 'mcp.json'),
path.join(process.env.APPDATA ?? fakeHomeDir, 'Code', 'User', 'mcp.json'),
];
for (const target of vscodeTargets) {
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(sourceVscode, target);
}
});
afterEach(() => {
homedirSpy?.mockRestore();
process.env.HOME = undefined;
process.env.USERPROFILE = undefined;
process.env.APPDATA = undefined;
process.env.XDG_CONFIG_HOME = undefined;
process.env.OPENCODE_CONFIG = undefined;
process.env.OPENCODE_CONFIG_DIR = undefined;
if (fakeHomeDir) {
fs.rmSync(fakeHomeDir, { recursive: true, force: true });
fakeHomeDir = undefined;
}
});
describe('config imports', () => {
it('merges external configs with first-wins precedence', async () => {
const configPath = path.join(FIXTURE_ROOT, 'config', 'mcporter.json');
const servers = await loadServerDefinitions({
configPath,
rootDir: FIXTURE_ROOT,
});
const homeDir = ensureFakeHomeDir();
const names = servers.map((server) => server.name).toSorted();
expect(names).toEqual([
'claude-home',
'claude-local',
'claude-only',
'claude-overridden',
'claude-shared',
'codex-only',
'cursor-only',
'local-only',
'opencode-only',
'opencode-user-only',
'shared',
'vscode-only',
'windsurf-only',
]);
const shared = servers.find((server) => server.name === 'shared');
expect(shared?.command.kind).toBe('http');
expect(shared?.command.kind === 'http' ? shared.command.url.toString() : undefined).toBe(
'https://cursor.local/mcp'
);
expect(shared?.httpFetch).toBe('node-http1');
expect(shared?.refresh).toEqual({
tokenEndpoint: 'https://auth.cursor.local/token',
accessTokenEnv: 'CURSOR_ACCESS_TOKEN',
});
expect(shared?.source).toEqual({
kind: 'import',
path: path.join(FIXTURE_ROOT, '.cursor', 'mcp.json'),
importKind: 'cursor',
});
const cursorOnly = servers.find((server) => server.name === 'cursor-only');
expect(cursorOnly?.command.kind).toBe('http');
expect(cursorOnly?.command.kind === 'http' ? cursorOnly.command.url.toString() : undefined).toBe(
'https://local.override/cursor'
);
expect(cursorOnly?.source).toEqual({
kind: 'local',
path: configPath,
});
const codexOnly = servers.find((server) => server.name === 'codex-only');
expect(codexOnly?.command.kind).toBe('stdio');
expect(codexOnly?.command.kind === 'stdio' ? codexOnly.command.command : undefined).toBe('codex-cli');
expect(codexOnly?.command.kind === 'stdio' ? codexOnly.command.args : undefined).toEqual(['--run']);
const codexSourcePaths = [
path.join(homeDir, '.codex', 'config.toml'),
path.join(FIXTURE_ROOT, '.codex', 'config.toml'),
];
expect(codexOnly?.source?.kind).toBe('import');
expect(codexSourcePaths).toContain(codexOnly?.source?.path);
const windsurfOnly = servers.find((server) => server.name === 'windsurf-only');
expect(windsurfOnly?.command.kind).toBe('stdio');
expect(windsurfOnly?.command.kind === 'stdio' ? windsurfOnly.command.command : undefined).toBe('windsurf-cli');
expect(windsurfOnly?.source).toEqual({
kind: 'import',
path: path.join(homeDir, '.codeium', 'windsurf', 'mcp_config.json'),
importKind: 'windsurf',
});
const claudeLocal = servers.find((server) => server.name === 'claude-local');
expect(claudeLocal?.command.kind).toBe('stdio');
expect(claudeLocal?.command.kind === 'stdio' ? claudeLocal.command.command : undefined).toBe('claude-local-cli');
expect(claudeLocal?.source).toEqual({
kind: 'import',
path: path.join(FIXTURE_ROOT, '.claude', 'settings.local.json'),
importKind: 'claude-code',
});
const claudeOverridden = servers.find((server) => server.name === 'claude-overridden');
expect(claudeOverridden?.command.kind).toBe('stdio');
expect(claudeOverridden?.command.kind === 'stdio' ? claudeOverridden.command.command : undefined).toBe(
'claude-local-cli'
);
expect(claudeOverridden?.source).toEqual({
kind: 'import',
path: path.join(FIXTURE_ROOT, '.claude', 'settings.local.json'),
importKind: 'claude-code',
});
const claudeShared = servers.find((server) => server.name === 'claude-shared');
expect(claudeShared?.command.kind).toBe('stdio');
expect(claudeShared?.command.kind === 'stdio' ? claudeShared.command.command : undefined).toBe('claude-shared-cli');
expect(claudeShared?.source).toEqual({
kind: 'import',
path: path.join(FIXTURE_ROOT, '.claude', 'settings.json'),
importKind: 'claude-code',
});
const claudeHome = servers.find((server) => server.name === 'claude-home');
expect(claudeHome?.command.kind).toBe('stdio');
expect(claudeHome?.command.kind === 'stdio' ? claudeHome.command.command : undefined).toBe('claude-home-cli');
expect(claudeHome?.source).toEqual({
kind: 'import',
path: path.join(homeDir, '.claude', 'settings.json'),
importKind: 'claude-code',
});
const opencodeOnly = servers.find((server) => server.name === 'opencode-only');
expect(opencodeOnly?.command.kind).toBe('stdio');
expect(opencodeOnly?.command.kind === 'stdio' ? opencodeOnly.command.command : undefined).toBe('opencode-cli');
expect(opencodeOnly?.source).toEqual({
kind: 'import',
path: path.join(FIXTURE_ROOT, 'opencode.jsonc'),
importKind: 'opencode',
});
const vscodeOnly = servers.find((server) => server.name === 'vscode-only');
expect(vscodeOnly?.command.kind).toBe('stdio');
expect(vscodeOnly?.command.kind === 'stdio' ? vscodeOnly.command.command : undefined).toBe('code-mcp');
const expectedVscodePaths = [
path.join(homeDir, 'Library', 'Application Support', 'Code', 'User', 'mcp.json'),
path.join(homeDir, '.config', 'Code', 'User', 'mcp.json'),
path.join(process.env.APPDATA ?? homeDir, 'Code', 'User', 'mcp.json'),
];
expect(vscodeOnly?.source?.kind).toBe('import');
expect(expectedVscodePaths).toContain(vscodeOnly?.source?.path);
});
it('falls back to user-level Claude settings when the project lacks .claude files', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-claude-home-'));
try {
const tempConfigDir = path.join(tempRoot, 'config');
fs.mkdirSync(tempConfigDir, { recursive: true });
fs.copyFileSync(path.join(FIXTURE_ROOT, 'config', 'mcporter.json'), path.join(tempConfigDir, 'mcporter.json'));
const servers = await loadServerDefinitions({
configPath: path.join(tempConfigDir, 'mcporter.json'),
rootDir: tempRoot,
});
const claudeHome = servers.find((server) => server.name === 'claude-home');
expect(claudeHome?.source).toEqual({
kind: 'import',
path: path.join(ensureFakeHomeDir(), '.claude', 'settings.json'),
importKind: 'claude-code',
});
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it('falls back to a later imported duplicate when an earlier import has unresolved placeholders', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-import-fallback-'));
try {
const configPath = path.join(tempRoot, 'config', 'mcporter.json');
const cursorPath = path.join(tempRoot, '.cursor', 'mcp.json');
const claudePath = path.join(ensureFakeHomeDir(), '.claude', 'settings.json');
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.mkdirSync(path.dirname(cursorPath), { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({ mcpServers: {}, imports: ['cursor', 'claude-code'] }));
fs.writeFileSync(
cursorPath,
JSON.stringify({
mcpServers: {
shared: { command: 'cursor-mcp', args: ['${workspaceFolder}'] },
},
})
);
fs.writeFileSync(
claudePath,
JSON.stringify({
mcpServers: {
shared: { command: 'claude-mcp', args: ['--usable'] },
},
})
);
const servers = await loadServerDefinitions({ configPath, rootDir: tempRoot });
const shared = servers.find((server) => server.name === 'shared');
expect(shared?.command.kind).toBe('stdio');
expect(shared?.command.kind === 'stdio' ? shared.command.command : undefined).toBe('claude-mcp');
expect(shared?.source).toEqual({
kind: 'import',
path: claudePath,
importKind: 'claude-code',
});
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it('loads Claude project-scoped servers without treating metadata as servers', async () => {
const homeDir = ensureFakeHomeDir();
const claudeDir = path.join(homeDir, '.claude');
fs.rmSync(claudeDir, { recursive: true, force: true });
const claudeJsonPath = path.join(homeDir, '.claude.json');
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-claude-project-'));
const projectConfigDir = path.join(projectRoot, 'config');
fs.mkdirSync(projectConfigDir, { recursive: true });
fs.writeFileSync(
path.join(projectConfigDir, 'mcporter.json'),
JSON.stringify({
mcpServers: {},
imports: ['claude-code'],
})
);
fs.writeFileSync(
claudeJsonPath,
JSON.stringify(
{
tipsHistory: { foo: 1 },
cachedStatsigGates: { example: false },
projects: {
[projectRoot]: {
mcpServers: {
'project-only': {
baseUrl: 'https://project.local/mcp',
},
},
},
'/other/project': {
mcpServers: {
ignored: { command: 'echo' },
},
},
},
},
null,
2
)
);
try {
const servers = await loadServerDefinitions({ rootDir: projectRoot });
const projectServer = servers.find((server) => server.name === 'project-only');
expect(projectServer).toBeDefined();
expect(projectServer?.command.kind).toBe('http');
expect(projectServer?.command.kind === 'http' ? projectServer.command.url.toString() : undefined).toBe(
'https://project.local/mcp'
);
expect(projectServer?.source).toEqual({
kind: 'import',
path: claudeJsonPath,
importKind: 'claude-code',
});
const serverNames = servers.map((server) => server.name);
expect(serverNames).not.toContain('tipsHistory');
expect(serverNames).not.toContain('cachedStatsigGates');
expect(serverNames).not.toContain('ignored');
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true });
}
});
it('loads Codex servers from the user config when the project lacks a .codex directory', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-imports-'));
try {
const tempConfigDir = path.join(tempRoot, 'config');
fs.mkdirSync(tempConfigDir, { recursive: true });
fs.copyFileSync(path.join(FIXTURE_ROOT, 'config', 'mcporter.json'), path.join(tempConfigDir, 'mcporter.json'));
const servers = await loadServerDefinitions({
configPath: path.join(tempConfigDir, 'mcporter.json'),
rootDir: tempRoot,
});
const homeDir = ensureFakeHomeDir();
const codexOnly = servers.find((server) => server.name === 'codex-only');
expect(codexOnly).toBeDefined();
expect(codexOnly?.source).toEqual({
kind: 'import',
path: path.join(homeDir, '.codex', 'config.toml'),
importKind: 'codex',
});
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it('uses default imports even when config/mcporter.json is missing', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-no-config-'));
try {
const servers = await loadServerDefinitions({ rootDir: tempRoot });
const codexOnly = servers.find((server) => server.name === 'codex-only');
expect(codexOnly).toBeDefined();
expect(codexOnly?.source).toEqual({
kind: 'import',
path: path.join(ensureFakeHomeDir(), '.codex', 'config.toml'),
importKind: 'codex',
});
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it('falls back to the user OpenCode config when no project file is present', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-opencode-home-'));
try {
const configDir = path.join(tempRoot, 'config');
fs.mkdirSync(configDir, { recursive: true });
fs.copyFileSync(path.join(FIXTURE_ROOT, 'config', 'mcporter.json'), path.join(configDir, 'mcporter.json'));
const servers = await loadServerDefinitions({
configPath: path.join(configDir, 'mcporter.json'),
rootDir: tempRoot,
});
const opencodeHomeOnly = servers.find((server) => server.name === 'opencode-user-only');
expect(opencodeHomeOnly).toBeDefined();
const homeDir = ensureFakeHomeDir();
const expectedPath = path.join(homeDir, '.config', 'opencode', 'opencode.jsonc');
expect(opencodeHomeOnly?.source).toEqual({
kind: 'import',
path: expectedPath,
importKind: 'opencode',
});
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it('honors the OPENCODE_CONFIG override', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-opencode-env-'));
const tempConfig = path.join(tempRoot, 'custom-opencode.jsonc');
fs.mkdirSync(tempRoot, { recursive: true });
fs.writeFileSync(
tempConfig,
JSON.stringify(
{
mcp: {
'opencode-env-only': {
command: 'env-cli',
args: ['--stdio'],
},
},
},
null,
2
)
);
process.env.OPENCODE_CONFIG = tempConfig;
try {
const servers = await loadServerDefinitions({ rootDir: FIXTURE_ROOT });
const envServer = servers.find((server) => server.name === 'opencode-env-only');
expect(envServer).toBeDefined();
expect(envServer?.source).toEqual({
kind: 'import',
path: tempConfig,
importKind: 'opencode',
});
} finally {
process.env.OPENCODE_CONFIG = undefined;
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it('honors the OPENCODE_CONFIG_DIR override', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-opencode-dir-'));
const dirConfigPath = path.join(tempDir, 'opencode.jsonc');
fs.mkdirSync(tempDir, { recursive: true });
fs.writeFileSync(
dirConfigPath,
JSON.stringify(
{
mcp: {
'opencode-dir-only': {
command: 'dir-cli',
args: ['--stdio'],
},
},
},
null,
2
)
);
process.env.OPENCODE_CONFIG_DIR = tempDir;
try {
const servers = await loadServerDefinitions({ rootDir: FIXTURE_ROOT });
const dirServer = servers.find((server) => server.name === 'opencode-dir-only');
expect(dirServer).toBeDefined();
expect(dirServer?.source).toEqual({
kind: 'import',
path: dirConfigPath,
importKind: 'opencode',
});
} finally {
process.env.OPENCODE_CONFIG_DIR = undefined;
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
});