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:
程序员阿江(Relakkes) 2026-05-25 20:28:00 +08:00
parent 21954e5f5c
commit c4890bdafc
5 changed files with 174 additions and 14 deletions

View File

@ -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',

View File

@ -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}`)

View File

@ -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}

View File

@ -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')

View File

@ -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()
}