mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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
This commit is contained in:
parent
21954e5f5c
commit
c4890bdafc
@ -4,6 +4,7 @@ import '@testing-library/jest-dom'
|
||||
|
||||
import { McpSettings } from '../pages/McpSettings'
|
||||
import { sessionsApi } from '../api/sessions'
|
||||
import { mcpApi } from '../api/mcp'
|
||||
import { useMcpStore } from '../stores/mcpStore'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
@ -19,6 +20,17 @@ vi.mock('../api/sessions', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../api/mcp', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/mcp')>()
|
||||
return {
|
||||
...actual,
|
||||
mcpApi: {
|
||||
...actual.mcpApi,
|
||||
projectPaths: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('McpSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(sessionsApi.getRecentProjects).mockResolvedValue({
|
||||
@ -33,6 +45,9 @@ describe('McpSettings', () => {
|
||||
sessionCount: 1,
|
||||
}],
|
||||
})
|
||||
vi.mocked(mcpApi.projectPaths).mockResolvedValue({
|
||||
projectPaths: ['/workspace/config-project'],
|
||||
})
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useSessionStore.setState({
|
||||
sessions: [
|
||||
@ -73,13 +88,18 @@ describe('McpSettings', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('loads MCP servers for the active project on mount', () => {
|
||||
it('loads MCP servers for the active and recent projects on mount', async () => {
|
||||
const fetchServers = vi.fn()
|
||||
useMcpStore.setState({ fetchServers })
|
||||
|
||||
render(<McpSettings />)
|
||||
|
||||
expect(fetchServers).toHaveBeenCalledWith(undefined, '/workspace/project')
|
||||
await waitFor(() => {
|
||||
expect(fetchServers).toHaveBeenCalledWith(
|
||||
['/workspace/project', '/workspace/selected-project', '/workspace/config-project'],
|
||||
'/workspace/project',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the empty state and add button', () => {
|
||||
@ -134,6 +154,51 @@ describe('McpSettings', () => {
|
||||
expect(screen.getByText('global-user')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps same-name project MCP servers distinct by project path', () => {
|
||||
useMcpStore.setState({
|
||||
servers: [
|
||||
{
|
||||
name: 'context7',
|
||||
scope: 'local',
|
||||
transport: 'stdio',
|
||||
enabled: true,
|
||||
status: 'connected',
|
||||
statusLabel: 'Connected',
|
||||
configLocation: '/workspace/project-a/.claude.json',
|
||||
summary: 'npx @upstash/context7-mcp',
|
||||
canEdit: true,
|
||||
canRemove: true,
|
||||
canReconnect: true,
|
||||
canToggle: true,
|
||||
projectPath: '/workspace/project-a',
|
||||
config: { type: 'stdio', command: 'npx', args: ['@upstash/context7-mcp'], env: {} },
|
||||
},
|
||||
{
|
||||
name: 'context7',
|
||||
scope: 'local',
|
||||
transport: 'stdio',
|
||||
enabled: true,
|
||||
status: 'connected',
|
||||
statusLabel: 'Connected',
|
||||
configLocation: '/workspace/project-b/.claude.json',
|
||||
summary: 'npx @upstash/context7-mcp',
|
||||
canEdit: true,
|
||||
canRemove: true,
|
||||
canReconnect: true,
|
||||
canToggle: true,
|
||||
projectPath: '/workspace/project-b',
|
||||
config: { type: 'stdio', command: 'npx', args: ['@upstash/context7-mcp'], env: {} },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
render(<McpSettings />)
|
||||
|
||||
expect(screen.getAllByText('context7')).toHaveLength(2)
|
||||
expect(screen.getByText('/workspace/project-a')).toBeInTheDocument()
|
||||
expect(screen.getByText('/workspace/project-b')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('starts background status refresh after the fast list render', async () => {
|
||||
const server = {
|
||||
name: 'deepwiki',
|
||||
|
||||
@ -7,6 +7,10 @@ export const mcpApi = {
|
||||
return api.get<{ servers: McpServerRecord[] }>(`/api/mcp${query}`)
|
||||
},
|
||||
|
||||
projectPaths: () => {
|
||||
return api.get<{ projectPaths: string[] }>('/api/mcp/project-paths')
|
||||
},
|
||||
|
||||
status: (name: string, cwd?: string) => {
|
||||
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
|
||||
return api.get<{ server: McpServerRecord }>(`/api/mcp/${encodeURIComponent(name)}/status${query}`)
|
||||
|
||||
@ -7,6 +7,8 @@ import { useTranslation } from '../i18n'
|
||||
import { useUIStore } from '../stores/uiStore'
|
||||
import { useMcpStore } from '../stores/mcpStore'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
import { sessionsApi } from '../api/sessions'
|
||||
import { mcpApi } from '../api/mcp'
|
||||
import type { McpServerRecord, McpUpsertPayload, McpWritableScope } from '../types/mcp'
|
||||
|
||||
type EditorMode =
|
||||
@ -112,6 +114,10 @@ function scopeRequiresProject(scope: McpWritableScope) {
|
||||
return scope === 'local' || scope === 'project'
|
||||
}
|
||||
|
||||
function serverHasProjectContext(server: Pick<McpServerRecord, 'scope' | 'projectPath'>) {
|
||||
return (server.scope === 'local' || server.scope === 'project') && !!server.projectPath
|
||||
}
|
||||
|
||||
function isStdioConfig(config: McpServerRecord['config']): config is Extract<McpServerRecord['config'], { type: 'stdio' }> {
|
||||
return config.type === 'stdio'
|
||||
}
|
||||
@ -393,6 +399,14 @@ function ServerRow({
|
||||
<span className="rounded-full bg-[var(--color-surface-hover)] px-2 py-1 font-medium text-[var(--color-text-secondary)]">
|
||||
{scopeLabel(server, t)}
|
||||
</span>
|
||||
{serverHasProjectContext(server) && (
|
||||
<span
|
||||
className="max-w-full truncate rounded-full bg-[var(--color-surface-hover)] px-2 py-1 font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]"
|
||||
title={server.projectPath}
|
||||
>
|
||||
{server.projectPath}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{server.summary}</span>
|
||||
</div>
|
||||
{server.statusDetail && (
|
||||
@ -424,8 +438,9 @@ export function McpSettings() {
|
||||
const [draft, setDraft] = useState<McpDraft>(createEmptyDraft)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [busyServerName, setBusyServerName] = useState<string | null>(null)
|
||||
const [busyServerKey, setBusyServerKey] = useState<string | null>(null)
|
||||
const [pendingDeleteServer, setPendingDeleteServer] = useState<McpServerRecord | null>(null)
|
||||
const projectPathsForFetchRef = useRef<string[] | undefined>(undefined)
|
||||
const refreshInFlightRef = useRef(new Set<string>())
|
||||
|
||||
const activeSession = sessions.find((session) => session.id === activeSessionId)
|
||||
@ -433,7 +448,31 @@ export function McpSettings() {
|
||||
const resolveOperationCwd = (server?: McpServerRecord) => server?.projectPath ?? currentWorkDir
|
||||
|
||||
useEffect(() => {
|
||||
void fetchServers(undefined, currentWorkDir)
|
||||
let cancelled = false
|
||||
|
||||
Promise.all([
|
||||
sessionsApi.getRecentProjects()
|
||||
.then(({ projects }) => projects.map((project) => project.realPath))
|
||||
.catch(() => []),
|
||||
mcpApi.projectPaths()
|
||||
.then(({ projectPaths }) => projectPaths)
|
||||
.catch(() => []),
|
||||
])
|
||||
.then(([recentProjectPaths, privateMcpProjectPaths]) => {
|
||||
if (cancelled) return
|
||||
const paths = [
|
||||
currentWorkDir,
|
||||
...recentProjectPaths,
|
||||
...privateMcpProjectPaths,
|
||||
].filter((path): path is string => !!path)
|
||||
const projectPathsForFetch = Array.from(new Set(paths))
|
||||
projectPathsForFetchRef.current = projectPathsForFetch.length ? projectPathsForFetch : undefined
|
||||
void fetchServers(projectPathsForFetchRef.current, currentWorkDir)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [fetchServers, currentWorkDir])
|
||||
|
||||
const groupedServers = useMemo(() => {
|
||||
@ -522,7 +561,7 @@ export function McpSettings() {
|
||||
}, [servers, refreshServerStatus, currentWorkDir])
|
||||
|
||||
const handleToggle = async (server: McpServerRecord) => {
|
||||
setBusyServerName(server.name)
|
||||
setBusyServerKey(getServerIdentityKey(server))
|
||||
try {
|
||||
const updated = await toggleServer(server, resolveOperationCwd(server), activeSessionId ?? undefined)
|
||||
addToast({
|
||||
@ -535,7 +574,7 @@ export function McpSettings() {
|
||||
message: error instanceof Error ? error.message : t('settings.mcp.toast.toggleFailed'),
|
||||
})
|
||||
} finally {
|
||||
setBusyServerName(null)
|
||||
setBusyServerKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
@ -547,7 +586,7 @@ export function McpSettings() {
|
||||
statusDetail: undefined,
|
||||
}
|
||||
|
||||
setBusyServerName(server.name)
|
||||
setBusyServerKey(getServerIdentityKey(server))
|
||||
setView((current) => {
|
||||
if (current.type !== 'details' && current.type !== 'edit') return current
|
||||
if (getServerIdentityKey(current.server) !== getServerIdentityKey(server)) return current
|
||||
@ -574,7 +613,7 @@ export function McpSettings() {
|
||||
message: error instanceof Error ? error.message : t('settings.mcp.toast.reconnectFailed'),
|
||||
})
|
||||
} finally {
|
||||
setBusyServerName(null)
|
||||
setBusyServerKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
@ -719,7 +758,7 @@ export function McpSettings() {
|
||||
</div>
|
||||
</div>
|
||||
{server.canReconnect && (
|
||||
<Button variant="secondary" onClick={() => handleReconnect(server)} loading={busyServerName === server.name}>
|
||||
<Button variant="secondary" onClick={() => handleReconnect(server)} loading={busyServerKey === getServerIdentityKey(server)}>
|
||||
<span className="material-symbols-outlined text-[16px]">sync</span>
|
||||
{t('settings.mcp.form.reconnect')}
|
||||
</Button>
|
||||
@ -799,7 +838,7 @@ export function McpSettings() {
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{editing && targetServer?.canReconnect && (
|
||||
<Button variant="secondary" onClick={() => handleReconnect(targetServer)} loading={busyServerName === targetServer.name}>
|
||||
<Button variant="secondary" onClick={() => handleReconnect(targetServer)} loading={busyServerKey === getServerIdentityKey(targetServer)}>
|
||||
<span className="material-symbols-outlined text-[16px]">sync</span>
|
||||
{t('settings.mcp.form.reconnect')}
|
||||
</Button>
|
||||
@ -1038,7 +1077,7 @@ export function McpSettings() {
|
||||
<p className="text-sm text-[var(--color-error)] mb-3">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void fetchServers(undefined, currentWorkDir)}
|
||||
onClick={() => void fetchServers(projectPathsForFetchRef.current, currentWorkDir)}
|
||||
className="text-sm text-[var(--color-text-accent)] hover:underline"
|
||||
>
|
||||
{t('common.retry')}
|
||||
@ -1067,9 +1106,9 @@ export function McpSettings() {
|
||||
<div className="rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden">
|
||||
{groupServers.map((server) => (
|
||||
<ServerRow
|
||||
key={`${server.scope}:${server.name}`}
|
||||
key={getServerIdentityKey(server)}
|
||||
server={server}
|
||||
isBusy={busyServerName === server.name}
|
||||
isBusy={busyServerKey === getServerIdentityKey(server)}
|
||||
onOpen={() => beginEdit(server)}
|
||||
onToggle={() => void handleToggle(server)}
|
||||
t={t}
|
||||
|
||||
@ -197,6 +197,44 @@ describe('MCP API', () => {
|
||||
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')
|
||||
|
||||
@ -26,7 +26,7 @@ import type {
|
||||
ScopedMcpServerConfig,
|
||||
} from '../../services/mcp/types.js'
|
||||
import { describeMcpConfigFilePath, ensureConfigScope } from '../../services/mcp/utils.js'
|
||||
import { enableConfigs } from '../../utils/config.js'
|
||||
import { enableConfigs, getGlobalConfig } from '../../utils/config.js'
|
||||
import { getCwd, runWithCwdOverride } from '../../utils/cwd.js'
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
import { conversationService } from '../services/conversationService.js'
|
||||
@ -435,6 +435,16 @@ async function listServers(): Promise<Response> {
|
||||
})
|
||||
}
|
||||
|
||||
function listProjectPathsWithPrivateMcp(): Response {
|
||||
const projects = getGlobalConfig().projects ?? {}
|
||||
const projectPaths = Object.entries(projects)
|
||||
.filter(([, projectConfig]) => Object.keys(projectConfig.mcpServers ?? {}).length > 0)
|
||||
.map(([projectPath]) => projectPath)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
|
||||
return Response.json({ projectPaths })
|
||||
}
|
||||
|
||||
async function getServerStatus(name: string): Promise<Response> {
|
||||
const existing = await resolveServerForRuntimeAction(name)
|
||||
if (!existing) {
|
||||
@ -627,6 +637,10 @@ export async function handleMcpApi(
|
||||
: undefined
|
||||
|
||||
return await runWithCwdOverride(resolveRequestCwd(url, body), async () => {
|
||||
if (req.method === 'GET' && serverName === 'project-paths' && !action) {
|
||||
return listProjectPathsWithPrivateMcp()
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && !serverName) {
|
||||
return listServers()
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user