cc-haha/src/server/__tests__/mcp.test.ts
程序员阿江(Relakkes) c4890bdafc fix: keep project MCP entries visible in settings
The settings page could create project-scoped MCP servers for an explicit
target path, but subsequent reloads only fetched the active session cwd.
That made newly-added local or project MCP entries disappear when the
target project differed from the active session or was only represented in
the user config.

Load MCP settings from the active project, recent projects, and project
keys that already contain user-private MCP config. Project-scoped rows now
render their project path and use project-aware identity keys so same-name
servers in different projects stay distinct.

Constraint: MCP local scope is keyed by project path in the user config, while project scope still resolves through the request cwd
Rejected: Filesystem scan for every possible .mcp.json | unbounded and too expensive for settings load
Confidence: high
Scope-risk: moderate
Directive: Do not collapse settings MCP fetches back to a single cwd; project/local scopes are cwd-sensitive
Tested: bun test src/server/__tests__/mcp.test.ts
Tested: cd desktop && bun run test -- mcpSettings.test.tsx
Tested: bun run check:server
Tested: bun run check:desktop
Tested: git diff --check
Not-tested: Discovery of project-shared .mcp.json files in directories that are neither recent projects nor active workdirs
2026-05-25 20:28:00 +08:00

578 lines
19 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'
import * as fs from 'fs/promises'
import * as os from 'os'
import * as path from 'path'
import { getOriginalCwd, setCwdState, setOriginalCwd } from '../../bootstrap/state.js'
import * as mcpClient from '../../services/mcp/client.js'
import * as mcpConfig from '../../services/mcp/config.js'
import { _setGlobalConfigCacheForTesting, getProjectPathForConfig } from '../../utils/config.js'
import { getGlobalClaudeFile } from '../../utils/env.js'
import * as mcpHostPreflight from '../services/mcpHostPreflight.js'
import { handleMcpApi } from '../api/mcp.js'
import { conversationService } from '../services/conversationService.js'
let tmpDir: string
let projectRoot: string
let originalConfigDir: string | undefined
let connectSpy: ReturnType<typeof spyOn> | undefined
let getClaudeCodeMcpConfigsSpy: ReturnType<typeof spyOn> | undefined
let getAllMcpConfigsSpy: ReturnType<typeof spyOn> | undefined
let reconnectSpy: ReturnType<typeof spyOn> | undefined
let hostPreflightSpy: ReturnType<typeof spyOn> | undefined
let originalRequestControl: typeof conversationService.requestControl
let originalHasSession: typeof conversationService.hasSession
function clearConfigPathCaches() {
getGlobalClaudeFile.cache.clear?.()
getProjectPathForConfig.cache.clear?.()
_setGlobalConfigCacheForTesting(null)
}
async function setup() {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-mcp-test-'))
projectRoot = path.join(tmpDir, 'project')
await fs.mkdir(path.join(projectRoot, '.claude'), { recursive: true })
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = tmpDir
clearConfigPathCaches()
}
async function teardown() {
if (originalConfigDir !== undefined) {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
} else {
delete process.env.CLAUDE_CONFIG_DIR
}
clearConfigPathCaches()
await fs.rm(tmpDir, { recursive: true, force: true })
}
function makeRequest(
method: string,
urlStr: string,
body?: Record<string, unknown>,
): { req: Request; url: URL; segments: string[] } {
const url = new URL(urlStr, 'http://localhost:3456')
const init: RequestInit = { method }
if (body) {
init.headers = { 'Content-Type': 'application/json' }
init.body = JSON.stringify(body)
}
const req = new Request(url.toString(), init)
return {
req,
url,
segments: url.pathname.split('/').filter(Boolean),
}
}
describe('MCP API', () => {
beforeEach(async () => {
await setup()
hostPreflightSpy = spyOn(mcpHostPreflight, 'inspectMcpHostCommand').mockResolvedValue({
ok: true,
resolvedCommand: '/usr/bin/mock-command',
})
originalRequestControl = conversationService.requestControl.bind(conversationService)
originalHasSession = conversationService.hasSession.bind(conversationService)
connectSpy = spyOn(mcpClient, 'connectToServer').mockImplementation(async (name, config) => ({
name,
type: 'connected',
client: {} as never,
capabilities: {},
config,
cleanup: mock(async () => {}),
}))
})
afterEach(async () => {
connectSpy?.mockRestore()
connectSpy = undefined
getClaudeCodeMcpConfigsSpy?.mockRestore()
getClaudeCodeMcpConfigsSpy = undefined
getAllMcpConfigsSpy?.mockRestore()
getAllMcpConfigsSpy = undefined
reconnectSpy?.mockRestore()
reconnectSpy = undefined
hostPreflightSpy?.mockRestore()
hostPreflightSpy = undefined
conversationService.requestControl = originalRequestControl
conversationService.hasSession = originalHasSession
await teardown()
})
it('writes local MCP config and disabled state to the requested cwd project', async () => {
const previousNodeEnv = process.env.NODE_ENV
const previousOriginalCwd = getOriginalCwd()
const projectA = path.join(tmpDir, 'project-a')
const projectB = path.join(tmpDir, 'project-b')
await fs.mkdir(projectA, { recursive: true })
await fs.mkdir(projectB, { recursive: true })
process.env.NODE_ENV = 'development'
clearConfigPathCaches()
setOriginalCwd(projectA)
setCwdState(projectA)
try {
const create = makeRequest('POST', '/api/mcp', {
cwd: projectB,
name: 'scoped-server',
scope: 'local',
config: {
type: 'stdio',
command: 'node',
args: ['server.js'],
env: {},
},
})
const createRes = await handleMcpApi(create.req, create.url, create.segments)
expect(createRes.status).toBe(201)
const disable = makeRequest('POST', '/api/mcp/scoped-server/toggle', {
cwd: projectB,
})
const disableRes = await handleMcpApi(disable.req, disable.url, disable.segments)
expect(disableRes.status).toBe(200)
const rawConfig = JSON.parse(
await fs.readFile(path.join(tmpDir, '.claude.json'), 'utf8'),
)
expect(rawConfig.projects?.[projectA]?.mcpServers?.['scoped-server']).toBeUndefined()
expect(rawConfig.projects?.[projectA]?.disabledMcpServers ?? []).not.toContain('scoped-server')
expect(rawConfig.projects?.[projectB]?.mcpServers?.['scoped-server']).toMatchObject({
type: 'stdio',
command: 'node',
})
expect(rawConfig.projects?.[projectB]?.disabledMcpServers).toContain('scoped-server')
} finally {
if (previousNodeEnv === undefined) {
delete process.env.NODE_ENV
} else {
process.env.NODE_ENV = previousNodeEnv
}
setOriginalCwd(previousOriginalCwd)
setCwdState(previousOriginalCwd)
clearConfigPathCaches()
}
})
it('creates and lists local MCP servers for the requested cwd', async () => {
const create = makeRequest('POST', '/api/mcp', {
cwd: projectRoot,
name: 'chrome-devtools',
scope: 'local',
config: {
type: 'stdio',
command: 'npx',
args: ['chrome-devtools-mcp@latest'],
env: {
DEBUG: '1',
},
},
})
const createRes = await handleMcpApi(create.req, create.url, create.segments)
expect(createRes.status).toBe(201)
const createdBody = await createRes.json()
expect(createdBody.server.name).toBe('chrome-devtools')
expect(createdBody.server.transport).toBe('stdio')
expect(createdBody.server.status).toBe('checking')
const list = makeRequest('GET', `/api/mcp?cwd=${encodeURIComponent(projectRoot)}`)
const listRes = await handleMcpApi(list.req, list.url, list.segments)
expect(listRes.status).toBe(200)
const listBody = await listRes.json()
expect(listBody.servers).toHaveLength(1)
expect(listBody.servers[0].name).toBe('chrome-devtools')
expect(listBody.servers[0].status).toBe('checking')
expect(listBody.servers[0].config.command).toBe('npx')
expect(connectSpy).not.toHaveBeenCalled()
})
it('lists project paths that contain user-private MCP servers', async () => {
const previousNodeEnv = process.env.NODE_ENV
const projectB = path.join(tmpDir, 'project-b')
await fs.mkdir(projectB, { recursive: true })
process.env.NODE_ENV = 'development'
clearConfigPathCaches()
try {
const create = makeRequest('POST', '/api/mcp', {
cwd: projectB,
name: 'private-context7',
scope: 'local',
config: {
type: 'stdio',
command: 'npx',
args: ['@upstash/context7-mcp'],
env: {},
},
})
const createRes = await handleMcpApi(create.req, create.url, create.segments)
expect(createRes.status).toBe(201)
const projectPaths = makeRequest('GET', '/api/mcp/project-paths')
const projectPathsRes = await handleMcpApi(projectPaths.req, projectPaths.url, projectPaths.segments)
expect(projectPathsRes.status).toBe(200)
const body = await projectPathsRes.json()
expect(body.projectPaths).toEqual([projectB])
} finally {
if (previousNodeEnv === undefined) {
delete process.env.NODE_ENV
} else {
process.env.NODE_ENV = previousNodeEnv
}
clearConfigPathCaches()
}
})
it('updates project MCP servers from their previous cwd into the selected target cwd', async () => {
const projectA = path.join(tmpDir, 'project-a')
const projectB = path.join(tmpDir, 'project-b')
await fs.mkdir(projectA, { recursive: true })
await fs.mkdir(projectB, { recursive: true })
const create = makeRequest('POST', '/api/mcp', {
cwd: projectA,
name: 'shared-tools',
scope: 'project',
config: {
type: 'stdio',
command: 'npx',
args: ['old-tools'],
env: {},
},
})
const createRes = await handleMcpApi(create.req, create.url, create.segments)
expect(createRes.status).toBe(201)
const update = makeRequest('PUT', '/api/mcp/shared-tools', {
cwd: projectB,
previousCwd: projectA,
scope: 'project',
config: {
type: 'stdio',
command: 'npx',
args: ['new-tools'],
env: {},
},
})
const updateRes = await handleMcpApi(update.req, update.url, update.segments)
expect(updateRes.status).toBe(200)
const projectAConfig = JSON.parse(await fs.readFile(path.join(projectA, '.mcp.json'), 'utf8'))
const projectBConfig = JSON.parse(await fs.readFile(path.join(projectB, '.mcp.json'), 'utf8'))
expect(projectAConfig.mcpServers?.['shared-tools']).toBeUndefined()
expect(projectBConfig.mcpServers?.['shared-tools']).toMatchObject({
type: 'stdio',
command: 'npx',
args: ['new-tools'],
})
})
it('checks a single server status on demand', async () => {
const create = makeRequest('POST', '/api/mcp', {
cwd: projectRoot,
name: 'deepwiki',
scope: 'user',
config: {
type: 'http',
url: 'https://mcp.example.com/mcp',
headers: {},
},
})
await handleMcpApi(create.req, create.url, create.segments)
const status = makeRequest('GET', `/api/mcp/deepwiki/status?cwd=${encodeURIComponent(projectRoot)}`)
const statusRes = await handleMcpApi(status.req, status.url, status.segments)
expect(statusRes.status).toBe(200)
const body = await statusRes.json()
expect(body.server.name).toBe('deepwiki')
expect(body.server.status).toBe('connected')
expect(connectSpy).toHaveBeenCalled()
})
it('lists runtime-visible claude.ai MCP connectors as read-only settings entries', async () => {
getAllMcpConfigsSpy = spyOn(mcpConfig, 'getAllMcpConfigs').mockResolvedValue({
servers: {
'claude.ai Docs': {
type: 'claudeai-proxy',
url: 'https://mcp.example.com/docs',
id: 'srv_docs',
scope: 'claudeai',
},
},
errors: [],
})
const list = makeRequest('GET', `/api/mcp?cwd=${encodeURIComponent(projectRoot)}`)
const listRes = await handleMcpApi(list.req, list.url, list.segments)
expect(listRes.status).toBe(200)
const listBody = await listRes.json()
expect(listBody.servers).toContainEqual(
expect.objectContaining({
name: 'claude.ai Docs',
scope: 'claudeai',
transport: 'claudeai-proxy',
canEdit: false,
canRemove: false,
}),
)
expect(connectSpy).not.toHaveBeenCalled()
})
it('rejects stdio MCP creation when the host command is unavailable', async () => {
hostPreflightSpy?.mockResolvedValueOnce({
ok: false,
message: 'Host command "npx" is not available in PATH.',
})
const create = makeRequest('POST', '/api/mcp', {
cwd: projectRoot,
name: 'chrome-devtools',
scope: 'local',
config: {
type: 'stdio',
command: 'npx',
args: ['chrome-devtools-mcp@latest'],
env: {},
},
})
const createRes = await handleMcpApi(create.req, create.url, create.segments)
expect(createRes.status).toBe(400)
await expect(createRes.json()).resolves.toMatchObject({
message: 'Host command "npx" is not available in PATH.',
})
})
it('surfaces host preflight failures in live status checks without connecting', async () => {
const create = makeRequest('POST', '/api/mcp', {
cwd: projectRoot,
name: 'chrome-devtools',
scope: 'local',
config: {
type: 'stdio',
command: 'npx',
args: ['chrome-devtools-mcp@latest'],
env: {},
},
})
await handleMcpApi(create.req, create.url, create.segments)
hostPreflightSpy?.mockResolvedValueOnce({
ok: false,
message: 'Host command "npx" is not available in PATH.',
})
const status = makeRequest('GET', `/api/mcp/chrome-devtools/status?cwd=${encodeURIComponent(projectRoot)}`)
const statusRes = await handleMcpApi(status.req, status.url, status.segments)
expect(statusRes.status).toBe(200)
await expect(statusRes.json()).resolves.toMatchObject({
server: {
name: 'chrome-devtools',
status: 'failed',
statusDetail: 'Host command "npx" is not available in PATH.',
},
})
expect(connectSpy).not.toHaveBeenCalled()
})
it('updates, toggles, and deletes MCP servers', async () => {
const create = makeRequest('POST', '/api/mcp', {
cwd: projectRoot,
name: 'context7',
scope: 'local',
config: {
type: 'stdio',
command: 'npx',
args: ['@upstash/context7-mcp'],
env: {},
},
})
await handleMcpApi(create.req, create.url, create.segments)
const update = makeRequest('PUT', '/api/mcp/context7', {
cwd: projectRoot,
scope: 'user',
config: {
type: 'http',
url: 'https://mcp.example.com/mcp',
headers: {
Authorization: 'Bearer demo',
},
},
})
const updateRes = await handleMcpApi(update.req, update.url, update.segments)
expect(updateRes.status).toBe(200)
const updatedBody = await updateRes.json()
expect(updatedBody.server.transport).toBe('http')
expect(updatedBody.server.scope).toBe('user')
const disable = makeRequest('POST', '/api/mcp/context7/toggle', { cwd: projectRoot })
const disableRes = await handleMcpApi(disable.req, disable.url, disable.segments)
expect(disableRes.status).toBe(200)
const disabledBody = await disableRes.json()
expect(disabledBody.server.enabled).toBe(false)
expect(disabledBody.server.status).toBe('disabled')
const enable = makeRequest('POST', '/api/mcp/context7/toggle', { cwd: projectRoot })
const enableRes = await handleMcpApi(enable.req, enable.url, enable.segments)
expect(enableRes.status).toBe(200)
const enabledBody = await enableRes.json()
expect(enabledBody.server.enabled).toBe(true)
const remove = makeRequest('DELETE', `/api/mcp/context7?scope=user&cwd=${encodeURIComponent(projectRoot)}`)
const removeRes = await handleMcpApi(remove.req, remove.url, remove.segments)
expect(removeRes.status).toBe(200)
const list = makeRequest('GET', `/api/mcp?cwd=${encodeURIComponent(projectRoot)}`)
const listRes = await handleMcpApi(list.req, list.url, list.segments)
const listBody = await listRes.json()
expect(listBody.servers.some((server: { name: string }) => server.name === 'context7')).toBe(false)
})
it('syncs MCP toggles into the active CLI session control channel', async () => {
const create = makeRequest('POST', '/api/mcp', {
cwd: projectRoot,
name: 'session-sync',
scope: 'local',
config: {
type: 'stdio',
command: 'npx',
args: ['session-sync-mcp'],
env: {},
},
})
await handleMcpApi(create.req, create.url, create.segments)
const requestControl = mock(async () => ({}))
conversationService.hasSession = ((sessionId: string) => sessionId === 'session-1') as typeof conversationService.hasSession
conversationService.requestControl = requestControl as typeof conversationService.requestControl
const disable = makeRequest('POST', '/api/mcp/session-sync/toggle', {
cwd: projectRoot,
sessionId: 'session-1',
})
const disableRes = await handleMcpApi(disable.req, disable.url, disable.segments)
expect(disableRes.status).toBe(200)
expect(requestControl).toHaveBeenCalledWith(
'session-1',
{ subtype: 'mcp_toggle', serverName: 'session-sync', enabled: false },
120_000,
)
})
it('reconnects plugin-scoped MCP servers exposed via the merged server list', async () => {
const pluginServerName = 'plugin:telegram:telegram'
const pluginServerConfig = {
scope: 'dynamic',
type: 'stdio',
command: 'bun',
args: ['run', 'start'],
env: {
CLAUDE_PLUGIN_ROOT: '/tmp/telegram-plugin',
},
pluginSource: 'telegram@claude-plugins-official',
} as const
getClaudeCodeMcpConfigsSpy = spyOn(mcpConfig, 'getClaudeCodeMcpConfigs').mockResolvedValue({
servers: {
[pluginServerName]: pluginServerConfig,
},
errors: [],
})
reconnectSpy = spyOn(mcpClient, 'reconnectMcpServerImpl').mockResolvedValue({
name: pluginServerName,
client: {
name: pluginServerName,
type: 'connected',
client: {} as never,
capabilities: {},
config: pluginServerConfig,
cleanup: mock(async () => {}),
},
})
const reconnect = makeRequest('POST', `/api/mcp/${encodeURIComponent(pluginServerName)}/reconnect`, {
cwd: projectRoot,
})
const reconnectRes = await handleMcpApi(reconnect.req, reconnect.url, reconnect.segments)
expect(reconnectRes.status).toBe(200)
expect(reconnectSpy).toHaveBeenCalledWith(pluginServerName, pluginServerConfig)
const body = await reconnectRes.json()
expect(body.server.name).toBe(pluginServerName)
expect(body.server.scope).toBe('dynamic')
})
it('returns a failed server state when reconnect preflight fails on the host machine', async () => {
const pluginServerName = 'plugin:telegram:telegram'
const pluginServerConfig = {
scope: 'dynamic',
type: 'stdio',
command: 'npx',
args: ['telegram-mcp'],
env: {},
pluginSource: 'telegram@claude-plugins-official',
} as const
getClaudeCodeMcpConfigsSpy = spyOn(mcpConfig, 'getClaudeCodeMcpConfigs').mockResolvedValue({
servers: {
[pluginServerName]: pluginServerConfig,
},
errors: [],
})
hostPreflightSpy?.mockResolvedValueOnce({
ok: false,
message: 'Host command "npx" is not available in PATH.',
})
reconnectSpy = spyOn(mcpClient, 'reconnectMcpServerImpl').mockResolvedValue({
name: pluginServerName,
client: {
name: pluginServerName,
type: 'connected',
client: {} as never,
capabilities: {},
config: pluginServerConfig,
cleanup: mock(async () => {}),
},
})
const reconnect = makeRequest('POST', `/api/mcp/${encodeURIComponent(pluginServerName)}/reconnect`, {
cwd: projectRoot,
})
const reconnectRes = await handleMcpApi(reconnect.req, reconnect.url, reconnect.segments)
expect(reconnectRes.status).toBe(200)
expect(reconnectSpy).not.toHaveBeenCalled()
await expect(reconnectRes.json()).resolves.toMatchObject({
server: {
name: pluginServerName,
status: 'failed',
statusDetail: 'Host command "npx" is not available in PATH.',
},
})
})
})