mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-26 15:03:34 +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()
|
const fetchServers = vi.fn()
|
||||||
useMcpStore.setState({ fetchServers })
|
useMcpStore.setState({ fetchServers })
|
||||||
|
|
||||||
@ -219,7 +219,59 @@ describe('McpSettings', () => {
|
|||||||
fireEvent.click(screen.getByRole('switch'))
|
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 () => {
|
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()}`)
|
return api.delete<{ ok: true }>(`/api/mcp/${encodeURIComponent(name)}?${query.toString()}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
toggle: (name: string, cwd?: string) => {
|
toggle: (name: string, cwd?: string, sessionId?: string) => {
|
||||||
return api.post<{ server: McpServerRecord }>(`/api/mcp/${encodeURIComponent(name)}/toggle`, cwd ? { cwd } : {})
|
return api.post<{ server: McpServerRecord }>(
|
||||||
|
`/api/mcp/${encodeURIComponent(name)}/toggle`,
|
||||||
|
{
|
||||||
|
...(cwd ? { cwd } : {}),
|
||||||
|
...(sessionId ? { sessionId } : {}),
|
||||||
|
},
|
||||||
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
reconnect: (name: string, cwd?: string) => {
|
reconnect: (name: string, cwd?: string) => {
|
||||||
|
|||||||
@ -467,7 +467,7 @@ export const en = {
|
|||||||
|
|
||||||
// Settings > MCP
|
// Settings > MCP
|
||||||
'settings.mcp.title': 'MCP servers',
|
'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.addServer': 'Add server',
|
||||||
'settings.mcp.empty': 'No MCP servers configured yet',
|
'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.',
|
'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.claudeai': 'Claude.ai',
|
||||||
'settings.mcp.scope.managed': 'Managed',
|
'settings.mcp.scope.managed': 'Managed',
|
||||||
'settings.mcp.transport.http': 'Streamable HTTP',
|
'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.back': 'Back to servers',
|
||||||
'settings.mcp.form.createTitle': 'Connect to a custom MCP',
|
'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.',
|
'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
|
||||||
'settings.mcp.title': 'MCP 服务',
|
'settings.mcp.title': 'MCP 服务',
|
||||||
'settings.mcp.description': '在桌面端直接管理外部工具与数据源。你可以添加、编辑、重连或禁用 MCP 服务。',
|
'settings.mcp.description': '在桌面端直接管理外部工具与数据源。Local、Project、User 三种范围与 CLI 保持一致。',
|
||||||
'settings.mcp.addServer': '添加服务',
|
'settings.mcp.addServer': '添加服务',
|
||||||
'settings.mcp.empty': '还没有配置 MCP 服务',
|
'settings.mcp.empty': '还没有配置 MCP 服务',
|
||||||
'settings.mcp.emptyHint': '先添加一个自定义的 STDIO、HTTP 或 SSE 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.claudeai': 'Claude.ai',
|
||||||
'settings.mcp.scope.managed': '受管控',
|
'settings.mcp.scope.managed': '受管控',
|
||||||
'settings.mcp.transport.http': 'Streamable HTTP',
|
'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.back': '返回服务列表',
|
||||||
'settings.mcp.form.createTitle': '连接自定义 MCP',
|
'settings.mcp.form.createTitle': '连接自定义 MCP',
|
||||||
'settings.mcp.form.createHint': '按当前 Claude Code 支持的字段添加一个自定义 MCP 服务。',
|
'settings.mcp.form.createHint': '按当前 Claude Code 支持的字段添加一个自定义 MCP 服务。',
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { useTranslation } from '../i18n'
|
|||||||
import { useUIStore } from '../stores/uiStore'
|
import { useUIStore } from '../stores/uiStore'
|
||||||
import { useMcpStore } from '../stores/mcpStore'
|
import { useMcpStore } from '../stores/mcpStore'
|
||||||
import { useSessionStore } from '../stores/sessionStore'
|
import { useSessionStore } from '../stores/sessionStore'
|
||||||
import type { McpServerRecord, McpUpsertPayload } from '../types/mcp'
|
import type { McpServerRecord, McpUpsertPayload, McpWritableScope } from '../types/mcp'
|
||||||
|
|
||||||
type EditorMode =
|
type EditorMode =
|
||||||
| { type: 'list' }
|
| { type: 'list' }
|
||||||
@ -29,6 +29,7 @@ type KeyValueRow = {
|
|||||||
|
|
||||||
type McpDraft = {
|
type McpDraft = {
|
||||||
name: string
|
name: string
|
||||||
|
scope: McpWritableScope
|
||||||
transport: TransportKind
|
transport: TransportKind
|
||||||
command: string
|
command: string
|
||||||
args: StringRow[]
|
args: StringRow[]
|
||||||
@ -61,6 +62,8 @@ const MCP_GROUP_ORDER: McpGroupKey[] = [
|
|||||||
'dynamic',
|
'dynamic',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const WRITABLE_SCOPES: McpWritableScope[] = ['local', 'project', 'user']
|
||||||
|
|
||||||
const STATUS_TONE: Record<McpServerRecord['status'], string> = {
|
const STATUS_TONE: Record<McpServerRecord['status'], string> = {
|
||||||
connected: 'bg-[var(--color-inspector-success-bg)] text-[var(--color-inspector-success)] border-[var(--color-border)]',
|
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)]',
|
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 {
|
function createEmptyDraft(): McpDraft {
|
||||||
return {
|
return {
|
||||||
name: '',
|
name: '',
|
||||||
|
scope: 'local',
|
||||||
transport: 'stdio',
|
transport: 'stdio',
|
||||||
command: '',
|
command: '',
|
||||||
args: [createStringRow('')],
|
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' }> {
|
function isStdioConfig(config: McpServerRecord['config']): config is Extract<McpServerRecord['config'], { type: 'stdio' }> {
|
||||||
return 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 {
|
function draftFromServer(server: McpServerRecord): McpDraft {
|
||||||
const base = createEmptyDraft()
|
const base = createEmptyDraft()
|
||||||
base.name = server.name
|
base.name = server.name
|
||||||
|
base.scope = asWritableScope(server.scope)
|
||||||
|
|
||||||
if (isStdioConfig(server.config)) {
|
if (isStdioConfig(server.config)) {
|
||||||
return {
|
return {
|
||||||
@ -155,7 +164,7 @@ function rowsToList(rows: StringRow[]) {
|
|||||||
function buildPayload(draft: McpDraft): McpUpsertPayload {
|
function buildPayload(draft: McpDraft): McpUpsertPayload {
|
||||||
if (draft.transport === 'stdio') {
|
if (draft.transport === 'stdio') {
|
||||||
return {
|
return {
|
||||||
scope: 'user',
|
scope: draft.scope,
|
||||||
config: {
|
config: {
|
||||||
type: 'stdio',
|
type: 'stdio',
|
||||||
command: draft.command.trim(),
|
command: draft.command.trim(),
|
||||||
@ -170,7 +179,7 @@ function buildPayload(draft: McpDraft): McpUpsertPayload {
|
|||||||
const oauthClientId = draft.oauthClientId.trim()
|
const oauthClientId = draft.oauthClientId.trim()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scope: 'user',
|
scope: draft.scope,
|
||||||
config: {
|
config: {
|
||||||
type: draft.transport,
|
type: draft.transport,
|
||||||
url: draft.url.trim(),
|
url: draft.url.trim(),
|
||||||
@ -506,7 +515,7 @@ export function McpSettings() {
|
|||||||
const handleToggle = async (server: McpServerRecord) => {
|
const handleToggle = async (server: McpServerRecord) => {
|
||||||
setBusyServerName(server.name)
|
setBusyServerName(server.name)
|
||||||
try {
|
try {
|
||||||
const updated = await toggleServer(server, resolveOperationCwd(server))
|
const updated = await toggleServer(server, resolveOperationCwd(server), activeSessionId ?? undefined)
|
||||||
addToast({
|
addToast({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
message: updated.enabled ? t('settings.mcp.toast.enabled', { name: server.name }) : t('settings.mcp.toast.disabled', { name: server.name }),
|
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">
|
<div className="text-sm font-semibold text-[var(--color-text-primary)] mb-2">
|
||||||
{t('settings.mcp.form.scope')}
|
{t('settings.mcp.form.scope')}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs leading-5 text-[var(--color-text-tertiary)]">
|
<div className="grid gap-2 md:grid-cols-3">
|
||||||
{t('settings.mcp.globalOnlyHint')}
|
{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>
|
</p>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden">
|
<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>
|
createServer: (name: string, payload: McpUpsertPayload, cwd?: string) => Promise<McpServerRecord>
|
||||||
updateServer: (server: McpServerRecord, payload: McpUpsertPayload, cwd?: string) => Promise<McpServerRecord>
|
updateServer: (server: McpServerRecord, payload: McpUpsertPayload, cwd?: string) => Promise<McpServerRecord>
|
||||||
deleteServer: (server: McpServerRecord, cwd?: string) => Promise<void>
|
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>
|
reconnectServer: (server: McpServerRecord, cwd?: string) => Promise<McpServerRecord>
|
||||||
refreshServerStatus: (server: McpServerRecord, cwd?: string) => Promise<McpServerRecord>
|
refreshServerStatus: (server: McpServerRecord, cwd?: string) => Promise<McpServerRecord>
|
||||||
selectServer: (server: McpServerRecord | null) => void
|
selectServer: (server: McpServerRecord | null) => void
|
||||||
@ -132,8 +132,8 @@ export const useMcpStore = create<McpStore>((set) => ({
|
|||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleServer: async (server, cwd) => {
|
toggleServer: async (server, cwd, sessionId) => {
|
||||||
const response = await mcpApi.toggle(server.name, cwd)
|
const response = await mcpApi.toggle(server.name, cwd, sessionId)
|
||||||
const updated = attachProjectPath(response.server, cwd ?? server.projectPath)
|
const updated = attachProjectPath(response.server, cwd ?? server.projectPath)
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
servers: replaceServer(state.servers, server, updated, cwd ?? server.projectPath),
|
servers: replaceServer(state.servers, server, updated, cwd ?? server.projectPath),
|
||||||
|
|||||||
@ -37,7 +37,9 @@ export type McpServerRecord = {
|
|||||||
projectPath?: string
|
projectPath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type McpWritableScope = 'local' | 'project' | 'user'
|
||||||
|
|
||||||
export type McpUpsertPayload = {
|
export type McpUpsertPayload = {
|
||||||
scope: string
|
scope: McpWritableScope
|
||||||
config: McpEditableConfig
|
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 fs from 'fs/promises'
|
||||||
import * as os from 'os'
|
import * as os from 'os'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
|
import { getOriginalCwd, setCwdState, setOriginalCwd } from '../../bootstrap/state.js'
|
||||||
import * as mcpClient from '../../services/mcp/client.js'
|
import * as mcpClient from '../../services/mcp/client.js'
|
||||||
import * as mcpConfig from '../../services/mcp/config.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 * as mcpHostPreflight from '../services/mcpHostPreflight.js'
|
||||||
import { handleMcpApi } from '../api/mcp.js'
|
import { handleMcpApi } from '../api/mcp.js'
|
||||||
|
import { conversationService } from '../services/conversationService.js'
|
||||||
|
|
||||||
let tmpDir: string
|
let tmpDir: string
|
||||||
let projectRoot: string
|
let projectRoot: string
|
||||||
@ -15,6 +19,14 @@ let getClaudeCodeMcpConfigsSpy: ReturnType<typeof spyOn> | undefined
|
|||||||
let getAllMcpConfigsSpy: ReturnType<typeof spyOn> | undefined
|
let getAllMcpConfigsSpy: ReturnType<typeof spyOn> | undefined
|
||||||
let reconnectSpy: ReturnType<typeof spyOn> | undefined
|
let reconnectSpy: ReturnType<typeof spyOn> | undefined
|
||||||
let hostPreflightSpy: 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() {
|
async function setup() {
|
||||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-mcp-test-'))
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-mcp-test-'))
|
||||||
@ -23,6 +35,7 @@ async function setup() {
|
|||||||
|
|
||||||
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||||
|
clearConfigPathCaches()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function teardown() {
|
async function teardown() {
|
||||||
@ -32,6 +45,7 @@ async function teardown() {
|
|||||||
delete process.env.CLAUDE_CONFIG_DIR
|
delete process.env.CLAUDE_CONFIG_DIR
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearConfigPathCaches()
|
||||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,6 +76,8 @@ describe('MCP API', () => {
|
|||||||
ok: true,
|
ok: true,
|
||||||
resolvedCommand: '/usr/bin/mock-command',
|
resolvedCommand: '/usr/bin/mock-command',
|
||||||
})
|
})
|
||||||
|
originalRequestControl = conversationService.requestControl.bind(conversationService)
|
||||||
|
originalHasSession = conversationService.hasSession.bind(conversationService)
|
||||||
|
|
||||||
connectSpy = spyOn(mcpClient, 'connectToServer').mockImplementation(async (name, config) => ({
|
connectSpy = spyOn(mcpClient, 'connectToServer').mockImplementation(async (name, config) => ({
|
||||||
name,
|
name,
|
||||||
@ -84,9 +100,69 @@ describe('MCP API', () => {
|
|||||||
reconnectSpy = undefined
|
reconnectSpy = undefined
|
||||||
hostPreflightSpy?.mockRestore()
|
hostPreflightSpy?.mockRestore()
|
||||||
hostPreflightSpy = undefined
|
hostPreflightSpy = undefined
|
||||||
|
conversationService.requestControl = originalRequestControl
|
||||||
|
conversationService.hasSession = originalHasSession
|
||||||
await teardown()
|
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 () => {
|
it('creates and lists local MCP servers for the requested cwd', async () => {
|
||||||
const create = makeRequest('POST', '/api/mcp', {
|
const create = makeRequest('POST', '/api/mcp', {
|
||||||
cwd: projectRoot,
|
cwd: projectRoot,
|
||||||
@ -288,6 +364,38 @@ describe('MCP API', () => {
|
|||||||
expect(listBody.servers.some((server: { name: string }) => server.name === 'context7')).toBe(false)
|
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 () => {
|
it('reconnects plugin-scoped MCP servers exposed via the merged server list', async () => {
|
||||||
const pluginServerName = 'plugin:telegram:telegram'
|
const pluginServerName = 'plugin:telegram:telegram'
|
||||||
const pluginServerConfig = {
|
const pluginServerConfig = {
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import { describeMcpConfigFilePath, ensureConfigScope } from '../../services/mcp
|
|||||||
import { enableConfigs } from '../../utils/config.js'
|
import { enableConfigs } from '../../utils/config.js'
|
||||||
import { getCwd, runWithCwdOverride } from '../../utils/cwd.js'
|
import { getCwd, runWithCwdOverride } from '../../utils/cwd.js'
|
||||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||||
|
import { conversationService } from '../services/conversationService.js'
|
||||||
|
|
||||||
type McpEditableConfigDto =
|
type McpEditableConfigDto =
|
||||||
| {
|
| {
|
||||||
@ -71,9 +72,16 @@ type McpServerDto = {
|
|||||||
type McpMutationBody = {
|
type McpMutationBody = {
|
||||||
cwd?: string
|
cwd?: string
|
||||||
scope?: string
|
scope?: string
|
||||||
|
sessionId?: string
|
||||||
config?: unknown
|
config?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type McpSessionSyncDto = {
|
||||||
|
applied: boolean
|
||||||
|
reason?: 'not_running' | 'failed'
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
const EDITABLE_SCOPES = new Set<ConfigScope>(['local', 'project', 'user'])
|
const EDITABLE_SCOPES = new Set<ConfigScope>(['local', 'project', 'user'])
|
||||||
|
|
||||||
function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
|
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 {
|
function resolveRequestCwd(url: URL, body?: Record<string, unknown>): string {
|
||||||
const cwd = url.searchParams.get('cwd') || (typeof body?.cwd === 'string' ? body.cwd : undefined)
|
const cwd = url.searchParams.get('cwd') || (typeof body?.cwd === 'string' ? body.cwd : undefined)
|
||||||
return cwd || getCwd()
|
return cwd || getCwd()
|
||||||
@ -500,7 +538,7 @@ async function deleteServer(name: string, url: URL): Promise<Response> {
|
|||||||
return Response.json({ ok: true })
|
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)
|
const existing = await resolveServerForRuntimeAction(name)
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
throw ApiError.notFound(`MCP server not found: ${name}`)
|
throw ApiError.notFound(`MCP server not found: ${name}`)
|
||||||
@ -508,11 +546,12 @@ async function toggleServer(name: string): Promise<Response> {
|
|||||||
|
|
||||||
const enabled = isMcpServerDisabled(name)
|
const enabled = isMcpServerDisabled(name)
|
||||||
setMcpServerEnabled(name, enabled)
|
setMcpServerEnabled(name, enabled)
|
||||||
|
const sessionSync = await syncMcpToggleToSession(sessionId, name, enabled)
|
||||||
|
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
await clearServerCache(name, existing).catch(() => {})
|
await clearServerCache(name, existing).catch(() => {})
|
||||||
const updated = serializeServerSnapshot(name, existing)
|
const updated = serializeServerSnapshot(name, existing)
|
||||||
return Response.json({ server: updated })
|
return Response.json({ server: updated, ...(sessionSync ? { sessionSync } : {}) })
|
||||||
}
|
}
|
||||||
|
|
||||||
const hostPreflightStatus = await getHostPreflightStatus(existing, true)
|
const hostPreflightStatus = await getHostPreflightStatus(existing, true)
|
||||||
@ -535,6 +574,7 @@ async function toggleServer(name: string): Promise<Response> {
|
|||||||
...updated,
|
...updated,
|
||||||
...(statusDetail ? { statusDetail } : {}),
|
...(statusDetail ? { statusDetail } : {}),
|
||||||
},
|
},
|
||||||
|
...(sessionSync ? { sessionSync } : {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -604,7 +644,7 @@ export async function handleMcpApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'POST' && serverName && action === 'toggle') {
|
if (req.method === 'POST' && serverName && action === 'toggle') {
|
||||||
return toggleServer(serverName)
|
return toggleServer(serverName, optionalString(body?.sessionId))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'POST' && serverName && action === 'reconnect') {
|
if (req.method === 'POST' && serverName && action === 'reconnect') {
|
||||||
|
|||||||
@ -56,6 +56,16 @@ import {
|
|||||||
} from './types.js'
|
} from './types.js'
|
||||||
import { getProjectMcpServerStatus } from './utils.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
|
* Get the path to the managed MCP configuration file
|
||||||
*/
|
*/
|
||||||
@ -693,7 +703,7 @@ export async function addMcpConfig(
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'local': {
|
case 'local': {
|
||||||
const projectConfig = getCurrentProjectConfig()
|
const projectConfig = getMcpProjectConfig()
|
||||||
if (projectConfig.mcpServers?.[name]) {
|
if (projectConfig.mcpServers?.[name]) {
|
||||||
throw new Error(`MCP server ${name} already exists in local config`)
|
throw new Error(`MCP server ${name} already exists in local config`)
|
||||||
}
|
}
|
||||||
@ -743,7 +753,7 @@ export async function addMcpConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'local': {
|
case 'local': {
|
||||||
saveCurrentProjectConfig(current => ({
|
saveMcpProjectConfig(current => ({
|
||||||
...current,
|
...current,
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
...current.mcpServers,
|
...current.mcpServers,
|
||||||
@ -812,11 +822,11 @@ export async function removeMcpConfig(
|
|||||||
|
|
||||||
case 'local': {
|
case 'local': {
|
||||||
// Check if server exists before updating
|
// Check if server exists before updating
|
||||||
const config = getCurrentProjectConfig()
|
const config = getMcpProjectConfig()
|
||||||
if (!config.mcpServers?.[name]) {
|
if (!config.mcpServers?.[name]) {
|
||||||
throw new Error(`No project-local MCP server found with name: ${name}`)
|
throw new Error(`No project-local MCP server found with name: ${name}`)
|
||||||
}
|
}
|
||||||
saveCurrentProjectConfig(current => {
|
saveMcpProjectConfig(current => {
|
||||||
const { [name]: _, ...restMcpServers } = current.mcpServers ?? {}
|
const { [name]: _, ...restMcpServers } = current.mcpServers ?? {}
|
||||||
return {
|
return {
|
||||||
...current,
|
...current,
|
||||||
@ -975,7 +985,7 @@ export function getMcpConfigsByScope(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'local': {
|
case 'local': {
|
||||||
const mcpServers = getCurrentProjectConfig().mcpServers
|
const mcpServers = getMcpProjectConfig().mcpServers
|
||||||
if (!mcpServers) {
|
if (!mcpServers) {
|
||||||
return { servers: {}, errors: [] }
|
return { servers: {}, errors: [] }
|
||||||
}
|
}
|
||||||
@ -1522,7 +1532,7 @@ function isDefaultDisabledBuiltin(_name: string): boolean {
|
|||||||
* @returns true if the server is disabled
|
* @returns true if the server is disabled
|
||||||
*/
|
*/
|
||||||
export function isMcpServerDisabled(name: string): boolean {
|
export function isMcpServerDisabled(name: string): boolean {
|
||||||
const projectConfig = getCurrentProjectConfig()
|
const projectConfig = getMcpProjectConfig()
|
||||||
if (isDefaultDisabledBuiltin(name)) {
|
if (isDefaultDisabledBuiltin(name)) {
|
||||||
const enabledServers = projectConfig.enabledMcpServers || []
|
const enabledServers = projectConfig.enabledMcpServers || []
|
||||||
return !enabledServers.includes(name)
|
return !enabledServers.includes(name)
|
||||||
@ -1550,7 +1560,7 @@ export function setMcpServerEnabled(name: string, enabled: boolean): void {
|
|||||||
const isBuiltinStateChange =
|
const isBuiltinStateChange =
|
||||||
isDefaultDisabledBuiltin(name) && isMcpServerDisabled(name) === enabled
|
isDefaultDisabledBuiltin(name) && isMcpServerDisabled(name) === enabled
|
||||||
|
|
||||||
saveCurrentProjectConfig(current => {
|
saveMcpProjectConfig(current => {
|
||||||
if (isDefaultDisabledBuiltin(name)) {
|
if (isDefaultDisabledBuiltin(name)) {
|
||||||
const prev = current.enabledMcpServers || []
|
const prev = current.enabledMcpServers || []
|
||||||
const next = toggleMembership(prev, name, enabled)
|
const next = toggleMembership(prev, name, enabled)
|
||||||
|
|||||||
@ -1584,10 +1584,8 @@ function getConfig<A>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memoized function to get the project path for config lookup
|
function computeProjectPathForConfig(cwd: string): string {
|
||||||
export const getProjectPathForConfig = memoize((): string => {
|
const gitRoot = findCanonicalGitRoot(cwd)
|
||||||
const originalCwd = getOriginalCwd()
|
|
||||||
const gitRoot = findCanonicalGitRoot(originalCwd)
|
|
||||||
|
|
||||||
if (gitRoot) {
|
if (gitRoot) {
|
||||||
// Normalize for consistent JSON keys (forward slashes on all platforms)
|
// Normalize for consistent JSON keys (forward slashes on all platforms)
|
||||||
@ -1596,16 +1594,26 @@ export const getProjectPathForConfig = memoize((): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Not in a git repo
|
// Not in a git repo
|
||||||
return normalizePathForConfigKey(resolve(originalCwd))
|
return normalizePathForConfigKey(resolve(cwd))
|
||||||
})
|
}
|
||||||
|
|
||||||
export function getCurrentProjectConfig(): ProjectConfig {
|
// Memoized function to get the project path for config lookup.
|
||||||
if (process.env.NODE_ENV === 'test') {
|
// 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
|
return TEST_PROJECT_CONFIG_FOR_TESTING
|
||||||
}
|
}
|
||||||
|
|
||||||
const absolutePath = getProjectPathForConfig()
|
const absolutePath = getProjectPathForConfig(cwd)
|
||||||
const config = getGlobalConfig()
|
const config = process.env.NODE_ENV === 'test'
|
||||||
|
? getConfig(getGlobalClaudeFile(), createDefaultGlobalConfig)
|
||||||
|
: getGlobalConfig()
|
||||||
|
|
||||||
if (!config.projects) {
|
if (!config.projects) {
|
||||||
return DEFAULT_PROJECT_CONFIG
|
return DEFAULT_PROJECT_CONFIG
|
||||||
@ -1624,8 +1632,9 @@ export function getCurrentProjectConfig(): ProjectConfig {
|
|||||||
|
|
||||||
export function saveCurrentProjectConfig(
|
export function saveCurrentProjectConfig(
|
||||||
updater: (currentConfig: ProjectConfig) => ProjectConfig,
|
updater: (currentConfig: ProjectConfig) => ProjectConfig,
|
||||||
|
cwd?: string,
|
||||||
): void {
|
): void {
|
||||||
if (process.env.NODE_ENV === 'test') {
|
if (process.env.NODE_ENV === 'test' && cwd === undefined) {
|
||||||
const config = updater(TEST_PROJECT_CONFIG_FOR_TESTING)
|
const config = updater(TEST_PROJECT_CONFIG_FOR_TESTING)
|
||||||
// Skip if no changes (same reference returned)
|
// Skip if no changes (same reference returned)
|
||||||
if (config === TEST_PROJECT_CONFIG_FOR_TESTING) {
|
if (config === TEST_PROJECT_CONFIG_FOR_TESTING) {
|
||||||
@ -1634,7 +1643,7 @@ export function saveCurrentProjectConfig(
|
|||||||
Object.assign(TEST_PROJECT_CONFIG_FOR_TESTING, config)
|
Object.assign(TEST_PROJECT_CONFIG_FOR_TESTING, config)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const absolutePath = getProjectPathForConfig()
|
const absolutePath = getProjectPathForConfig(cwd)
|
||||||
|
|
||||||
let written: GlobalConfig | null = null
|
let written: GlobalConfig | null = null
|
||||||
try {
|
try {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user