mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
fix: align desktop MCP scopes with CLI
Desktop MCP settings were using the process startup directory for local project config and treated new custom servers as user-global. The desktop API now carries the active cwd into local MCP reads, writes, and enablement state, and the settings page exposes the same local/project/user scopes that the CLI supports. Constraint: Claude Code MCP scope semantics are local, project, and user. Constraint: Desktop sessions can switch projects inside one long-lived server process. Rejected: Only clear getProjectPathForConfig cache | still leaves request-scoped desktop API calls tied to getOriginalCwd Confidence: high Scope-risk: moderate Directive: Do not route MCP local config or disabledMcpServers through ambient original cwd in desktop APIs. 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 Not-tested: Live provider smoke; the change is config/control-channel scoped.
This commit is contained in:
parent
562523aea6
commit
3d2b219eda
@ -49,7 +49,7 @@ describe('McpSettings', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('loads only global MCP servers on mount', () => {
|
||||
it('loads MCP servers for the active project on mount', () => {
|
||||
const fetchServers = vi.fn()
|
||||
useMcpStore.setState({ fetchServers })
|
||||
|
||||
@ -219,7 +219,59 @@ describe('McpSettings', () => {
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
})
|
||||
|
||||
expect(toggleServer).toHaveBeenCalledWith(server, '/workspace/project')
|
||||
expect(toggleServer).toHaveBeenCalledWith(server, '/workspace/project', 'session-1')
|
||||
})
|
||||
|
||||
it('creates local MCP servers by default to match the CLI', async () => {
|
||||
const createdServer = {
|
||||
name: 'context7',
|
||||
scope: 'local',
|
||||
transport: 'stdio',
|
||||
enabled: true,
|
||||
status: 'checking' as const,
|
||||
statusLabel: 'Checking',
|
||||
configLocation: '/workspace/project/.claude.json',
|
||||
summary: 'npx @upstash/context7-mcp',
|
||||
canEdit: true,
|
||||
canRemove: true,
|
||||
canReconnect: true,
|
||||
canToggle: true,
|
||||
projectPath: '/workspace/project',
|
||||
config: { type: 'stdio' as const, command: 'npx', args: ['@upstash/context7-mcp'], env: {} },
|
||||
}
|
||||
const createServer = vi.fn().mockResolvedValue(createdServer)
|
||||
|
||||
useMcpStore.setState({ createServer })
|
||||
|
||||
render(<McpSettings />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /add server/i }))
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'context7' } })
|
||||
fireEvent.change(screen.getByLabelText(/Command to launch/), { target: { value: 'npx' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('chrome-devtools-mcp@latest'), {
|
||||
target: { value: '@upstash/context7-mcp' },
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
})
|
||||
|
||||
expect(createServer).toHaveBeenCalledWith(
|
||||
'context7',
|
||||
{
|
||||
scope: 'local',
|
||||
config: {
|
||||
type: 'stdio',
|
||||
command: 'npx',
|
||||
args: ['@upstash/context7-mcp'],
|
||||
env: {},
|
||||
},
|
||||
},
|
||||
'/workspace/project',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows reconnecting status immediately in the detail view', async () => {
|
||||
|
||||
@ -33,8 +33,14 @@ export const mcpApi = {
|
||||
return api.delete<{ ok: true }>(`/api/mcp/${encodeURIComponent(name)}?${query.toString()}`)
|
||||
},
|
||||
|
||||
toggle: (name: string, cwd?: string) => {
|
||||
return api.post<{ server: McpServerRecord }>(`/api/mcp/${encodeURIComponent(name)}/toggle`, cwd ? { cwd } : {})
|
||||
toggle: (name: string, cwd?: string, sessionId?: string) => {
|
||||
return api.post<{ server: McpServerRecord }>(
|
||||
`/api/mcp/${encodeURIComponent(name)}/toggle`,
|
||||
{
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(sessionId ? { sessionId } : {}),
|
||||
},
|
||||
)
|
||||
},
|
||||
|
||||
reconnect: (name: string, cwd?: string) => {
|
||||
|
||||
@ -467,7 +467,7 @@ export const en = {
|
||||
|
||||
// Settings > MCP
|
||||
'settings.mcp.title': 'MCP servers',
|
||||
'settings.mcp.description': 'Connect external tools and data sources for Claude Code. Add, edit, reconnect, and disable servers without leaving the desktop app.',
|
||||
'settings.mcp.description': 'Connect external tools and data sources for Claude Code. Local, project, and user scopes follow the same behavior as the CLI.',
|
||||
'settings.mcp.addServer': 'Add server',
|
||||
'settings.mcp.empty': 'No MCP servers configured yet',
|
||||
'settings.mcp.emptyHint': 'Add a custom stdio, HTTP, or SSE MCP server to start extending tool access.',
|
||||
@ -486,7 +486,8 @@ export const en = {
|
||||
'settings.mcp.scope.claudeai': 'Claude.ai',
|
||||
'settings.mcp.scope.managed': 'Managed',
|
||||
'settings.mcp.transport.http': 'Streamable HTTP',
|
||||
'settings.mcp.globalOnlyHint': 'This page manages only user-global MCP servers for speed. Project-specific MCP will move into the chat slash-command experience.',
|
||||
'settings.mcp.globalOnlyHint': 'Choose Local for one project, Project for `.mcp.json`, or User for all projects.',
|
||||
'settings.mcp.currentProjectHint': 'Selected project: {path}',
|
||||
'settings.mcp.form.back': 'Back to servers',
|
||||
'settings.mcp.form.createTitle': 'Connect to a custom MCP',
|
||||
'settings.mcp.form.createHint': 'Set up a custom MCP server with the fields supported by Claude Code today.',
|
||||
|
||||
@ -469,7 +469,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
|
||||
// Settings > MCP
|
||||
'settings.mcp.title': 'MCP 服务',
|
||||
'settings.mcp.description': '在桌面端直接管理外部工具与数据源。你可以添加、编辑、重连或禁用 MCP 服务。',
|
||||
'settings.mcp.description': '在桌面端直接管理外部工具与数据源。Local、Project、User 三种范围与 CLI 保持一致。',
|
||||
'settings.mcp.addServer': '添加服务',
|
||||
'settings.mcp.empty': '还没有配置 MCP 服务',
|
||||
'settings.mcp.emptyHint': '先添加一个自定义的 STDIO、HTTP 或 SSE MCP 服务。',
|
||||
@ -488,7 +488,8 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.mcp.scope.claudeai': 'Claude.ai',
|
||||
'settings.mcp.scope.managed': '受管控',
|
||||
'settings.mcp.transport.http': 'Streamable HTTP',
|
||||
'settings.mcp.globalOnlyHint': '这个页面只管理全局用户 MCP,以保证速度和清晰度。项目级 MCP 将放到聊天页的斜杠命令体验里。',
|
||||
'settings.mcp.globalOnlyHint': '选择项目私有、项目共享或全局用户范围。',
|
||||
'settings.mcp.currentProjectHint': '当前项目:{path}',
|
||||
'settings.mcp.form.back': '返回服务列表',
|
||||
'settings.mcp.form.createTitle': '连接自定义 MCP',
|
||||
'settings.mcp.form.createHint': '按当前 Claude Code 支持的字段添加一个自定义 MCP 服务。',
|
||||
|
||||
@ -6,7 +6,7 @@ import { useTranslation } from '../i18n'
|
||||
import { useUIStore } from '../stores/uiStore'
|
||||
import { useMcpStore } from '../stores/mcpStore'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
import type { McpServerRecord, McpUpsertPayload } from '../types/mcp'
|
||||
import type { McpServerRecord, McpUpsertPayload, McpWritableScope } from '../types/mcp'
|
||||
|
||||
type EditorMode =
|
||||
| { type: 'list' }
|
||||
@ -29,6 +29,7 @@ type KeyValueRow = {
|
||||
|
||||
type McpDraft = {
|
||||
name: string
|
||||
scope: McpWritableScope
|
||||
transport: TransportKind
|
||||
command: string
|
||||
args: StringRow[]
|
||||
@ -61,6 +62,8 @@ const MCP_GROUP_ORDER: McpGroupKey[] = [
|
||||
'dynamic',
|
||||
]
|
||||
|
||||
const WRITABLE_SCOPES: McpWritableScope[] = ['local', 'project', 'user']
|
||||
|
||||
const STATUS_TONE: Record<McpServerRecord['status'], string> = {
|
||||
connected: 'bg-[var(--color-inspector-success-bg)] text-[var(--color-inspector-success)] border-[var(--color-border)]',
|
||||
checking: 'bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] border-[var(--color-border)]',
|
||||
@ -85,6 +88,7 @@ function createKeyValueRow(key = '', value = ''): KeyValueRow {
|
||||
function createEmptyDraft(): McpDraft {
|
||||
return {
|
||||
name: '',
|
||||
scope: 'local',
|
||||
transport: 'stdio',
|
||||
command: '',
|
||||
args: [createStringRow('')],
|
||||
@ -97,6 +101,10 @@ function createEmptyDraft(): McpDraft {
|
||||
}
|
||||
}
|
||||
|
||||
function asWritableScope(scope: string): McpWritableScope {
|
||||
return scope === 'project' || scope === 'user' ? scope : 'local'
|
||||
}
|
||||
|
||||
function isStdioConfig(config: McpServerRecord['config']): config is Extract<McpServerRecord['config'], { type: 'stdio' }> {
|
||||
return config.type === 'stdio'
|
||||
}
|
||||
@ -108,6 +116,7 @@ function isRemoteConfig(config: McpServerRecord['config']): config is Extract<Mc
|
||||
function draftFromServer(server: McpServerRecord): McpDraft {
|
||||
const base = createEmptyDraft()
|
||||
base.name = server.name
|
||||
base.scope = asWritableScope(server.scope)
|
||||
|
||||
if (isStdioConfig(server.config)) {
|
||||
return {
|
||||
@ -155,7 +164,7 @@ function rowsToList(rows: StringRow[]) {
|
||||
function buildPayload(draft: McpDraft): McpUpsertPayload {
|
||||
if (draft.transport === 'stdio') {
|
||||
return {
|
||||
scope: 'user',
|
||||
scope: draft.scope,
|
||||
config: {
|
||||
type: 'stdio',
|
||||
command: draft.command.trim(),
|
||||
@ -170,7 +179,7 @@ function buildPayload(draft: McpDraft): McpUpsertPayload {
|
||||
const oauthClientId = draft.oauthClientId.trim()
|
||||
|
||||
return {
|
||||
scope: 'user',
|
||||
scope: draft.scope,
|
||||
config: {
|
||||
type: draft.transport,
|
||||
url: draft.url.trim(),
|
||||
@ -506,7 +515,7 @@ export function McpSettings() {
|
||||
const handleToggle = async (server: McpServerRecord) => {
|
||||
setBusyServerName(server.name)
|
||||
try {
|
||||
const updated = await toggleServer(server, resolveOperationCwd(server))
|
||||
const updated = await toggleServer(server, resolveOperationCwd(server), activeSessionId ?? undefined)
|
||||
addToast({
|
||||
type: 'success',
|
||||
message: updated.enabled ? t('settings.mcp.toast.enabled', { name: server.name }) : t('settings.mcp.toast.disabled', { name: server.name }),
|
||||
@ -800,9 +809,33 @@ export function McpSettings() {
|
||||
<div className="text-sm font-semibold text-[var(--color-text-primary)] mb-2">
|
||||
{t('settings.mcp.form.scope')}
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-[var(--color-text-tertiary)]">
|
||||
{t('settings.mcp.globalOnlyHint')}
|
||||
</p>
|
||||
<div className="grid gap-2 md:grid-cols-3">
|
||||
{WRITABLE_SCOPES.map((scope) => {
|
||||
const active = draft.scope === scope
|
||||
return (
|
||||
<button
|
||||
key={scope}
|
||||
type="button"
|
||||
onClick={() => setDraftField('scope', scope)}
|
||||
className={`rounded-[var(--radius-md)] border p-3 text-left transition-colors ${
|
||||
active
|
||||
? 'border-[var(--color-border-focus)] bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<span className="block text-sm font-semibold">{t(`settings.mcp.scope.${scope}`)}</span>
|
||||
<span className="mt-1 block text-xs leading-5 text-[var(--color-text-tertiary)]">
|
||||
{t(`settings.mcp.scopeDesc.${scope}`)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{currentWorkDir && (draft.scope === 'local' || draft.scope === 'project') && (
|
||||
<p className="mt-3 break-all text-xs leading-5 text-[var(--color-text-tertiary)]">
|
||||
{t('settings.mcp.currentProjectHint', { path: currentWorkDir })}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden">
|
||||
|
||||
@ -11,7 +11,7 @@ type McpStore = {
|
||||
createServer: (name: string, payload: McpUpsertPayload, cwd?: string) => Promise<McpServerRecord>
|
||||
updateServer: (server: McpServerRecord, payload: McpUpsertPayload, cwd?: string) => Promise<McpServerRecord>
|
||||
deleteServer: (server: McpServerRecord, cwd?: string) => Promise<void>
|
||||
toggleServer: (server: McpServerRecord, cwd?: string) => Promise<McpServerRecord>
|
||||
toggleServer: (server: McpServerRecord, cwd?: string, sessionId?: string) => Promise<McpServerRecord>
|
||||
reconnectServer: (server: McpServerRecord, cwd?: string) => Promise<McpServerRecord>
|
||||
refreshServerStatus: (server: McpServerRecord, cwd?: string) => Promise<McpServerRecord>
|
||||
selectServer: (server: McpServerRecord | null) => void
|
||||
@ -132,8 +132,8 @@ export const useMcpStore = create<McpStore>((set) => ({
|
||||
}))
|
||||
},
|
||||
|
||||
toggleServer: async (server, cwd) => {
|
||||
const response = await mcpApi.toggle(server.name, cwd)
|
||||
toggleServer: async (server, cwd, sessionId) => {
|
||||
const response = await mcpApi.toggle(server.name, cwd, sessionId)
|
||||
const updated = attachProjectPath(response.server, cwd ?? server.projectPath)
|
||||
set((state) => ({
|
||||
servers: replaceServer(state.servers, server, updated, cwd ?? server.projectPath),
|
||||
|
||||
@ -37,7 +37,9 @@ export type McpServerRecord = {
|
||||
projectPath?: string
|
||||
}
|
||||
|
||||
export type McpWritableScope = 'local' | 'project' | 'user'
|
||||
|
||||
export type McpUpsertPayload = {
|
||||
scope: string
|
||||
scope: McpWritableScope
|
||||
config: McpEditableConfig
|
||||
}
|
||||
|
||||
@ -2,10 +2,14 @@ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:te
|
||||
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
|
||||
@ -15,6 +19,14 @@ 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-'))
|
||||
@ -23,6 +35,7 @@ async function setup() {
|
||||
|
||||
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
clearConfigPathCaches()
|
||||
}
|
||||
|
||||
async function teardown() {
|
||||
@ -32,6 +45,7 @@ async function teardown() {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
}
|
||||
|
||||
clearConfigPathCaches()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
@ -62,6 +76,8 @@ describe('MCP API', () => {
|
||||
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,
|
||||
@ -84,9 +100,69 @@ describe('MCP API', () => {
|
||||
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,
|
||||
@ -288,6 +364,38 @@ describe('MCP API', () => {
|
||||
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 = {
|
||||
|
||||
@ -29,6 +29,7 @@ import { describeMcpConfigFilePath, ensureConfigScope } from '../../services/mcp
|
||||
import { enableConfigs } from '../../utils/config.js'
|
||||
import { getCwd, runWithCwdOverride } from '../../utils/cwd.js'
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
import { conversationService } from '../services/conversationService.js'
|
||||
|
||||
type McpEditableConfigDto =
|
||||
| {
|
||||
@ -71,9 +72,16 @@ type McpServerDto = {
|
||||
type McpMutationBody = {
|
||||
cwd?: string
|
||||
scope?: string
|
||||
sessionId?: string
|
||||
config?: unknown
|
||||
}
|
||||
|
||||
type McpSessionSyncDto = {
|
||||
applied: boolean
|
||||
reason?: 'not_running' | 'failed'
|
||||
error?: string
|
||||
}
|
||||
|
||||
const EDITABLE_SCOPES = new Set<ConfigScope>(['local', 'project', 'user'])
|
||||
|
||||
function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
|
||||
@ -85,6 +93,36 @@ function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
|
||||
})
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
async function syncMcpToggleToSession(
|
||||
sessionId: string | undefined,
|
||||
serverName: string,
|
||||
enabled: boolean,
|
||||
): Promise<McpSessionSyncDto | undefined> {
|
||||
if (!sessionId) return undefined
|
||||
if (!conversationService.hasSession(sessionId)) {
|
||||
return { applied: false, reason: 'not_running' }
|
||||
}
|
||||
|
||||
try {
|
||||
await conversationService.requestControl(
|
||||
sessionId,
|
||||
{ subtype: 'mcp_toggle', serverName, enabled },
|
||||
120_000,
|
||||
)
|
||||
return { applied: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
applied: false,
|
||||
reason: 'failed',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRequestCwd(url: URL, body?: Record<string, unknown>): string {
|
||||
const cwd = url.searchParams.get('cwd') || (typeof body?.cwd === 'string' ? body.cwd : undefined)
|
||||
return cwd || getCwd()
|
||||
@ -500,7 +538,7 @@ async function deleteServer(name: string, url: URL): Promise<Response> {
|
||||
return Response.json({ ok: true })
|
||||
}
|
||||
|
||||
async function toggleServer(name: string): Promise<Response> {
|
||||
async function toggleServer(name: string, sessionId?: string): Promise<Response> {
|
||||
const existing = await resolveServerForRuntimeAction(name)
|
||||
if (!existing) {
|
||||
throw ApiError.notFound(`MCP server not found: ${name}`)
|
||||
@ -508,11 +546,12 @@ async function toggleServer(name: string): Promise<Response> {
|
||||
|
||||
const enabled = isMcpServerDisabled(name)
|
||||
setMcpServerEnabled(name, enabled)
|
||||
const sessionSync = await syncMcpToggleToSession(sessionId, name, enabled)
|
||||
|
||||
if (!enabled) {
|
||||
await clearServerCache(name, existing).catch(() => {})
|
||||
const updated = serializeServerSnapshot(name, existing)
|
||||
return Response.json({ server: updated })
|
||||
return Response.json({ server: updated, ...(sessionSync ? { sessionSync } : {}) })
|
||||
}
|
||||
|
||||
const hostPreflightStatus = await getHostPreflightStatus(existing, true)
|
||||
@ -535,6 +574,7 @@ async function toggleServer(name: string): Promise<Response> {
|
||||
...updated,
|
||||
...(statusDetail ? { statusDetail } : {}),
|
||||
},
|
||||
...(sessionSync ? { sessionSync } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
@ -604,7 +644,7 @@ export async function handleMcpApi(
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && serverName && action === 'toggle') {
|
||||
return toggleServer(serverName)
|
||||
return toggleServer(serverName, optionalString(body?.sessionId))
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && serverName && action === 'reconnect') {
|
||||
|
||||
@ -56,6 +56,16 @@ import {
|
||||
} from './types.js'
|
||||
import { getProjectMcpServerStatus } from './utils.js'
|
||||
|
||||
function getMcpProjectConfig() {
|
||||
return getCurrentProjectConfig(getCwd())
|
||||
}
|
||||
|
||||
function saveMcpProjectConfig(
|
||||
updater: Parameters<typeof saveCurrentProjectConfig>[0],
|
||||
): void {
|
||||
saveCurrentProjectConfig(updater, getCwd())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the managed MCP configuration file
|
||||
*/
|
||||
@ -693,7 +703,7 @@ export async function addMcpConfig(
|
||||
break
|
||||
}
|
||||
case 'local': {
|
||||
const projectConfig = getCurrentProjectConfig()
|
||||
const projectConfig = getMcpProjectConfig()
|
||||
if (projectConfig.mcpServers?.[name]) {
|
||||
throw new Error(`MCP server ${name} already exists in local config`)
|
||||
}
|
||||
@ -743,7 +753,7 @@ export async function addMcpConfig(
|
||||
}
|
||||
|
||||
case 'local': {
|
||||
saveCurrentProjectConfig(current => ({
|
||||
saveMcpProjectConfig(current => ({
|
||||
...current,
|
||||
mcpServers: {
|
||||
...current.mcpServers,
|
||||
@ -812,11 +822,11 @@ export async function removeMcpConfig(
|
||||
|
||||
case 'local': {
|
||||
// Check if server exists before updating
|
||||
const config = getCurrentProjectConfig()
|
||||
const config = getMcpProjectConfig()
|
||||
if (!config.mcpServers?.[name]) {
|
||||
throw new Error(`No project-local MCP server found with name: ${name}`)
|
||||
}
|
||||
saveCurrentProjectConfig(current => {
|
||||
saveMcpProjectConfig(current => {
|
||||
const { [name]: _, ...restMcpServers } = current.mcpServers ?? {}
|
||||
return {
|
||||
...current,
|
||||
@ -975,7 +985,7 @@ export function getMcpConfigsByScope(
|
||||
}
|
||||
}
|
||||
case 'local': {
|
||||
const mcpServers = getCurrentProjectConfig().mcpServers
|
||||
const mcpServers = getMcpProjectConfig().mcpServers
|
||||
if (!mcpServers) {
|
||||
return { servers: {}, errors: [] }
|
||||
}
|
||||
@ -1522,7 +1532,7 @@ function isDefaultDisabledBuiltin(_name: string): boolean {
|
||||
* @returns true if the server is disabled
|
||||
*/
|
||||
export function isMcpServerDisabled(name: string): boolean {
|
||||
const projectConfig = getCurrentProjectConfig()
|
||||
const projectConfig = getMcpProjectConfig()
|
||||
if (isDefaultDisabledBuiltin(name)) {
|
||||
const enabledServers = projectConfig.enabledMcpServers || []
|
||||
return !enabledServers.includes(name)
|
||||
@ -1550,7 +1560,7 @@ export function setMcpServerEnabled(name: string, enabled: boolean): void {
|
||||
const isBuiltinStateChange =
|
||||
isDefaultDisabledBuiltin(name) && isMcpServerDisabled(name) === enabled
|
||||
|
||||
saveCurrentProjectConfig(current => {
|
||||
saveMcpProjectConfig(current => {
|
||||
if (isDefaultDisabledBuiltin(name)) {
|
||||
const prev = current.enabledMcpServers || []
|
||||
const next = toggleMembership(prev, name, enabled)
|
||||
|
||||
@ -1584,10 +1584,8 @@ function getConfig<A>(
|
||||
}
|
||||
}
|
||||
|
||||
// Memoized function to get the project path for config lookup
|
||||
export const getProjectPathForConfig = memoize((): string => {
|
||||
const originalCwd = getOriginalCwd()
|
||||
const gitRoot = findCanonicalGitRoot(originalCwd)
|
||||
function computeProjectPathForConfig(cwd: string): string {
|
||||
const gitRoot = findCanonicalGitRoot(cwd)
|
||||
|
||||
if (gitRoot) {
|
||||
// Normalize for consistent JSON keys (forward slashes on all platforms)
|
||||
@ -1596,16 +1594,26 @@ export const getProjectPathForConfig = memoize((): string => {
|
||||
}
|
||||
|
||||
// Not in a git repo
|
||||
return normalizePathForConfigKey(resolve(originalCwd))
|
||||
})
|
||||
return normalizePathForConfigKey(resolve(cwd))
|
||||
}
|
||||
|
||||
export function getCurrentProjectConfig(): ProjectConfig {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
// Memoized function to get the project path for config lookup.
|
||||
// Callers that operate on an explicit workspace, such as the desktop MCP API,
|
||||
// must pass cwd so local project config is keyed to the requested project.
|
||||
export const getProjectPathForConfig = memoize(
|
||||
(cwd?: string): string => computeProjectPathForConfig(cwd ?? getOriginalCwd()),
|
||||
(cwd?: string): string => normalizePathForConfigKey(resolve(cwd ?? getOriginalCwd())),
|
||||
)
|
||||
|
||||
export function getCurrentProjectConfig(cwd?: string): ProjectConfig {
|
||||
if (process.env.NODE_ENV === 'test' && cwd === undefined) {
|
||||
return TEST_PROJECT_CONFIG_FOR_TESTING
|
||||
}
|
||||
|
||||
const absolutePath = getProjectPathForConfig()
|
||||
const config = getGlobalConfig()
|
||||
const absolutePath = getProjectPathForConfig(cwd)
|
||||
const config = process.env.NODE_ENV === 'test'
|
||||
? getConfig(getGlobalClaudeFile(), createDefaultGlobalConfig)
|
||||
: getGlobalConfig()
|
||||
|
||||
if (!config.projects) {
|
||||
return DEFAULT_PROJECT_CONFIG
|
||||
@ -1624,8 +1632,9 @@ export function getCurrentProjectConfig(): ProjectConfig {
|
||||
|
||||
export function saveCurrentProjectConfig(
|
||||
updater: (currentConfig: ProjectConfig) => ProjectConfig,
|
||||
cwd?: string,
|
||||
): void {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
if (process.env.NODE_ENV === 'test' && cwd === undefined) {
|
||||
const config = updater(TEST_PROJECT_CONFIG_FOR_TESTING)
|
||||
// Skip if no changes (same reference returned)
|
||||
if (config === TEST_PROJECT_CONFIG_FOR_TESTING) {
|
||||
@ -1634,7 +1643,7 @@ export function saveCurrentProjectConfig(
|
||||
Object.assign(TEST_PROJECT_CONFIG_FOR_TESTING, config)
|
||||
return
|
||||
}
|
||||
const absolutePath = getProjectPathForConfig()
|
||||
const absolutePath = getProjectPathForConfig(cwd)
|
||||
|
||||
let written: GlobalConfig | null = null
|
||||
try {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user