Skip to content
Closed
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
81 changes: 81 additions & 0 deletions desktop/src/__tests__/mcpStoreKnownProjects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ vi.mock('../api/mcp', async (importOriginal) => {
...actual.mcpApi,
projectPaths: vi.fn(),
list: vi.fn(),
toggle: vi.fn(),
},
}
})
Expand Down Expand Up @@ -50,6 +51,7 @@ describe('fetchServersForKnownProjects', () => {
vi.mocked(sessionsApi.getRecentProjects).mockReset()
vi.mocked(mcpApi.projectPaths).mockReset()
vi.mocked(mcpApi.list).mockReset()
vi.mocked(mcpApi.toggle).mockReset()
})

it('queries the union of current cwd, recent projects, and configured MCP paths', async () => {
Expand All @@ -68,6 +70,85 @@ describe('fetchServersForKnownProjects', () => {
expect(useMcpStore.getState().servers.map((s) => s.name)).toEqual(['shared-tools'])
})

it('deduplicates Windows separator variants while preserving the first path', async () => {
const sessionPath = 'C:\\UE\\StrangeAutumn'
vi.mocked(sessionsApi.getRecentProjects).mockResolvedValue({
projects: [{ realPath: sessionPath }],
} as Awaited<ReturnType<typeof sessionsApi.getRecentProjects>>)
vi.mocked(mcpApi.projectPaths).mockResolvedValue({
projectPaths: ['C:/UE/StrangeAutumn'],
})
vi.mocked(mcpApi.list).mockResolvedValue({
servers: [record('shared-tools', 'project')],
})

await useMcpStore.getState().fetchServersForKnownProjects(sessionPath)

expect(vi.mocked(mcpApi.list).mock.calls.map(([cwd]) => cwd)).toEqual([sessionPath])
expect(useMcpStore.getState().servers).toEqual([
expect.objectContaining({
name: 'shared-tools',
projectPath: sessionPath,
}),
])
})

it('replaces a server when an action uses the other Windows separator style', async () => {
const existing = {
...record('shared-tools', 'project'),
projectPath: 'C:\\UE\\StrangeAutumn',
}
const equivalent = {
...existing,
projectPath: 'C:/UE/StrangeAutumn',
}
useMcpStore.setState({ servers: [existing], selectedServer: existing })
vi.mocked(mcpApi.toggle).mockResolvedValue({
server: { ...record('shared-tools', 'project'), enabled: false },
})

await useMcpStore.getState().toggleServer(equivalent, equivalent.projectPath)

expect(useMcpStore.getState().servers).toEqual([
expect.objectContaining({
name: 'shared-tools',
enabled: false,
projectPath: equivalent.projectPath,
}),
])
expect(useMcpStore.getState().selectedServer).toEqual(
expect.objectContaining({
enabled: false,
projectPath: equivalent.projectPath,
}),
)
})

it('keeps same-named servers from different projects separate', async () => {
vi.mocked(mcpApi.list).mockResolvedValue({
servers: [record('shared-tools', 'project')],
})

await useMcpStore.getState().fetchServers(['C:/project-a', 'C:/project-b'])

expect(useMcpStore.getState().servers.map((server) => server.projectPath)).toEqual([
'C:/project-a',
'C:/project-b',
])
})

it('does not treat a backslash in a POSIX filename as a path separator', async () => {
const projectPaths = ['/tmp/project\\name', '/tmp/project/name']
vi.mocked(mcpApi.list).mockResolvedValue({
servers: [record('shared-tools', 'project')],
})

await useMcpStore.getState().fetchServers(projectPaths)

expect(vi.mocked(mcpApi.list).mock.calls.map(([cwd]) => cwd)).toEqual(projectPaths)
expect(useMcpStore.getState().servers.map((server) => server.projectPath)).toEqual(projectPaths)
})

it('does not collapse the list to a single-project view when discovery sources fail (GH #1126)', async () => {
// Both discovery calls fail — the refresh must still include the current
// cwd rather than silently fetching nothing.
Expand Down
23 changes: 19 additions & 4 deletions desktop/src/stores/mcpStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,21 @@ function isProjectScoped(server: Pick<McpServerRecord, 'scope'>) {
return server.scope === 'local' || server.scope === 'project'
}

function projectPathIdentity(projectPath?: string) {
const value = projectPath ?? ''
if (!/^(?:[A-Za-z]:[\\/]|\\\\)/.test(value)) return value
return value.replace(/\\/g, '/')
}

function dedupeProjectPaths(projectPaths: string[]) {
const uniquePaths = new Map<string, string>()
for (const projectPath of projectPaths) {
const identity = projectPathIdentity(projectPath)
if (!uniquePaths.has(identity)) uniquePaths.set(identity, projectPath)
}
return [...uniquePaths.values()]
}

function attachProjectPath(server: McpServerRecord, cwd?: string) {
if (!isProjectScoped(server)) {
return {
Expand All @@ -60,7 +75,7 @@ function attachProjectPath(server: McpServerRecord, cwd?: string) {
function isSameServer(a: Pick<McpServerRecord, 'name' | 'scope' | 'projectPath'>, b: Pick<McpServerRecord, 'name' | 'scope' | 'projectPath'>) {
if (a.name !== b.name || a.scope !== b.scope) return false
if (!isProjectScoped(a) && !isProjectScoped(b)) return true
return (a.projectPath ?? '') === (b.projectPath ?? '')
return projectPathIdentity(a.projectPath) === projectPathIdentity(b.projectPath)
}

function replaceServer(
Expand Down Expand Up @@ -88,8 +103,8 @@ export const useMcpStore = create<McpStore>((set, get) => ({
const requestId = ++fetchServersRequestId
set({ isLoading: true, error: null })
try {
const normalizedPaths = Array.from(new Set((projectPaths ?? []).filter(Boolean)))
const contexts = normalizedPaths.length > 0 ? normalizedPaths : [fallbackCwd].filter(Boolean)
const uniquePaths = dedupeProjectPaths((projectPaths ?? []).filter(Boolean))
const contexts = uniquePaths.length > 0 ? uniquePaths : [fallbackCwd].filter(Boolean)

const responses = await Promise.all(
(contexts.length > 0 ? contexts : [undefined]).map(async (cwd) => {
Expand All @@ -106,7 +121,7 @@ export const useMcpStore = create<McpStore>((set, get) => ({
for (const server of group) {
const key =
server.scope === 'local' || server.scope === 'project'
? `${server.scope}:${server.projectPath}:${server.name}`
? `${server.scope}:${projectPathIdentity(server.projectPath)}:${server.name}`
: `${server.scope}:${server.name}`
if (!deduped.has(key)) {
deduped.set(key, server)
Expand Down
Loading