= {
+ connected: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20',
+ 'needs-auth': 'bg-amber-500/10 text-amber-600 border-amber-500/20',
+ failed: 'bg-rose-500/10 text-rose-600 border-rose-500/20',
+ disabled: 'bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] border-[var(--color-border)]',
+}
+
+function createId() {
+ if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
+ return `${Date.now()}-${Math.random().toString(16).slice(2)}`
+}
+
+function createStringRow(value = ''): StringRow {
+ return { id: createId(), value }
+}
+
+function createKeyValueRow(key = '', value = ''): KeyValueRow {
+ return { id: createId(), key, value }
+}
+
+function createEmptyDraft(): McpDraft {
+ return {
+ name: '',
+ transport: 'stdio',
+ command: '',
+ args: [createStringRow('')],
+ env: [createKeyValueRow()],
+ url: '',
+ headers: [createKeyValueRow()],
+ headersHelper: '',
+ oauthClientId: '',
+ oauthCallbackPort: '',
+ }
+}
+
+function isStdioConfig(config: McpServerRecord['config']): config is Extract
{
+ return config.type === 'stdio'
+}
+
+function isRemoteConfig(config: McpServerRecord['config']): config is Extract {
+ return config.type === 'http' || config.type === 'sse'
+}
+
+function draftFromServer(server: McpServerRecord): McpDraft {
+ const base = createEmptyDraft()
+ base.name = server.name
+
+ if (isStdioConfig(server.config)) {
+ return {
+ ...base,
+ transport: 'stdio',
+ command: server.config.command,
+ args: (server.config.args.length ? server.config.args : ['']).map((value) => createStringRow(value)),
+ env: Object.entries(server.config.env ?? {}).map(([key, value]) => createKeyValueRow(key, value)).concat(
+ Object.keys(server.config.env ?? {}).length === 0 ? [createKeyValueRow()] : [],
+ ),
+ }
+ }
+
+ if (isRemoteConfig(server.config)) {
+ return {
+ ...base,
+ transport: server.config.type,
+ url: server.config.url,
+ headers: Object.entries(server.config.headers ?? {}).map(([key, value]) => createKeyValueRow(key, value)).concat(
+ Object.keys(server.config.headers ?? {}).length === 0 ? [createKeyValueRow()] : [],
+ ),
+ headersHelper: server.config.headersHelper ?? '',
+ oauthClientId: server.config.oauth?.clientId ?? '',
+ oauthCallbackPort: server.config.oauth?.callbackPort ? String(server.config.oauth.callbackPort) : '',
+ }
+ }
+
+ return base
+}
+
+function rowsToRecord(rows: KeyValueRow[]) {
+ const entries: Array<[string, string]> = []
+ for (const row of rows) {
+ const key = row.key.trim()
+ if (!key) continue
+ entries.push([key, row.value])
+ }
+ return Object.fromEntries(entries)
+}
+
+function rowsToList(rows: StringRow[]) {
+ return rows.map((row) => row.value.trim()).filter(Boolean)
+}
+
+function buildPayload(draft: McpDraft): McpUpsertPayload {
+ if (draft.transport === 'stdio') {
+ return {
+ scope: 'user',
+ config: {
+ type: 'stdio',
+ command: draft.command.trim(),
+ args: rowsToList(draft.args),
+ env: rowsToRecord(draft.env),
+ },
+ }
+ }
+
+ const oauthCallbackPort = draft.oauthCallbackPort.trim()
+ const callbackPortNumber = oauthCallbackPort ? Number(oauthCallbackPort) : undefined
+ const oauthClientId = draft.oauthClientId.trim()
+
+ return {
+ scope: 'user',
+ config: {
+ type: draft.transport,
+ url: draft.url.trim(),
+ headers: rowsToRecord(draft.headers),
+ ...(draft.headersHelper.trim() ? { headersHelper: draft.headersHelper.trim() } : {}),
+ ...(oauthClientId || callbackPortNumber
+ ? {
+ oauth: {
+ ...(oauthClientId ? { clientId: oauthClientId } : {}),
+ ...(callbackPortNumber ? { callbackPort: callbackPortNumber } : {}),
+ },
+ }
+ : {}),
+ },
+ }
+}
+
+function isDraftValid(draft: McpDraft) {
+ if (!draft.name.trim()) return false
+ if (draft.transport === 'stdio') return draft.command.trim().length > 0
+ return draft.url.trim().length > 0
+}
+
+function transportLabel(transport: string, t: ReturnType) {
+ switch (transport) {
+ case 'stdio':
+ return 'STDIO'
+ case 'http':
+ return t('settings.mcp.transport.http')
+ case 'sse':
+ return 'SSE'
+ default:
+ return transport
+ }
+}
+
+function ToggleSwitch({
+ checked,
+ disabled,
+ onChange,
+}: {
+ checked: boolean
+ disabled?: boolean
+ onChange: () => void
+}) {
+ return (
+
+ )
+}
+
+function ArraySection({
+ title,
+ rows,
+ onChange,
+ onAdd,
+ onRemove,
+ keyPlaceholder,
+ valuePlaceholder,
+ singleValue = false,
+ addLabel,
+}: {
+ title: string
+ rows: KeyValueRow[] | StringRow[]
+ onChange: (id: string, field: 'key' | 'value', value: string) => void
+ onAdd: () => void
+ onRemove: (id: string) => void
+ keyPlaceholder?: string
+ valuePlaceholder: string
+ singleValue?: boolean
+ addLabel: string
+}) {
+ return (
+
+ )
+}
+
+function StatCard({ label, value, icon }: { label: string; value: number; icon: string }) {
+ return (
+
+
+ {icon}
+ {label}
+
+
{value}
+
+ )
+}
+
+function ServerRow({
+ server,
+ isBusy,
+ onOpen,
+ onToggle,
+ t,
+}: {
+ server: McpServerRecord
+ isBusy: boolean
+ onOpen: () => void
+ onToggle: () => void
+ t: ReturnType
+}) {
+ return (
+
+
+
+
{server.name}
+
+ {server.statusLabel}
+
+
+
+
+ {transportLabel(server.transport, t)}
+
+
+ {t('settings.mcp.scope.user')}
+
+ {server.summary}
+
+ {server.statusDetail && (
+
{server.statusDetail}
+ )}
+
+
+
+
+
+
+ )
+}
+
+export function McpSettings() {
+ const { servers, selectedServer, isLoading, error, fetchServers, createServer, updateServer, deleteServer, toggleServer, reconnectServer, selectServer } = useMcpStore()
+ const addToast = useUIStore((s) => s.addToast)
+ const t = useTranslation()
+ const [view, setView] = useState({ type: 'list' })
+ const [draft, setDraft] = useState(createEmptyDraft)
+ const [isSaving, setIsSaving] = useState(false)
+ const [isDeleting, setIsDeleting] = useState(false)
+ const [busyServerName, setBusyServerName] = useState(null)
+ const [pendingDeleteServer, setPendingDeleteServer] = useState(null)
+
+ useEffect(() => {
+ void fetchServers(undefined, undefined)
+ }, [fetchServers])
+
+ const globalServers = useMemo(
+ () => servers.filter((server) => server.scope === 'user'),
+ [servers],
+ )
+
+ const stats = useMemo(() => ({
+ total: globalServers.length,
+ connected: globalServers.filter((server) => server.status === 'connected').length,
+ attention: globalServers.filter((server) => server.status === 'failed' || server.status === 'needs-auth').length,
+ }), [globalServers])
+
+ const beginCreate = () => {
+ setDraft(createEmptyDraft())
+ setView({ type: 'create' })
+ }
+
+ const beginEdit = (server: McpServerRecord) => {
+ selectServer(server)
+ if (!server.canEdit) {
+ setView({ type: 'details', server })
+ return
+ }
+ setDraft(draftFromServer(server))
+ setView({ type: 'edit', server })
+ }
+
+ useEffect(() => {
+ if (!selectedServer) return
+ if (selectedServer.canEdit) {
+ setDraft(draftFromServer(selectedServer))
+ setView({ type: 'edit', server: selectedServer })
+ } else {
+ setView({ type: 'details', server: selectedServer })
+ }
+ }, [selectedServer])
+
+ const handleToggle = async (server: McpServerRecord) => {
+ setBusyServerName(server.name)
+ try {
+ const updated = await toggleServer(server.name, undefined)
+ addToast({
+ type: 'success',
+ message: updated.enabled ? t('settings.mcp.toast.enabled', { name: server.name }) : t('settings.mcp.toast.disabled', { name: server.name }),
+ })
+ } catch (error) {
+ addToast({
+ type: 'error',
+ message: error instanceof Error ? error.message : t('settings.mcp.toast.toggleFailed'),
+ })
+ } finally {
+ setBusyServerName(null)
+ }
+ }
+
+ const handleReconnect = async (server: McpServerRecord) => {
+ setBusyServerName(server.name)
+ try {
+ const updated = await reconnectServer(server.name, undefined)
+ addToast({
+ type: updated.status === 'connected' ? 'success' : 'warning',
+ message: updated.status === 'connected'
+ ? t('settings.mcp.toast.reconnected', { name: server.name })
+ : updated.statusDetail || updated.statusLabel,
+ })
+ if (view.type === 'edit') setView({ type: 'edit', server: updated })
+ if (view.type === 'details') setView({ type: 'details', server: updated })
+ } catch (error) {
+ addToast({
+ type: 'error',
+ message: error instanceof Error ? error.message : t('settings.mcp.toast.reconnectFailed'),
+ })
+ } finally {
+ setBusyServerName(null)
+ }
+ }
+
+ const handleDelete = (server: McpServerRecord) => {
+ setPendingDeleteServer(server)
+ }
+
+ const confirmDelete = async () => {
+ const server = pendingDeleteServer
+ if (!server) return
+ setIsDeleting(true)
+ try {
+ await deleteServer(server.name, server.scope, undefined)
+ addToast({
+ type: 'success',
+ message: t('settings.mcp.toast.deleted', { name: server.name }),
+ })
+ setView({ type: 'list' })
+ selectServer(null)
+ setPendingDeleteServer(null)
+ } catch (error) {
+ addToast({
+ type: 'error',
+ message: error instanceof Error ? error.message : t('settings.mcp.toast.deleteFailed'),
+ })
+ } finally {
+ setIsDeleting(false)
+ }
+ }
+
+ const handleSave = async () => {
+ if (!isDraftValid(draft)) return
+ setIsSaving(true)
+ try {
+ const payload = buildPayload(draft)
+ const saved = view.type === 'edit'
+ ? await updateServer(view.server.name, payload, undefined)
+ : await createServer(draft.name.trim(), payload, undefined)
+
+ addToast({
+ type: 'success',
+ message: view.type === 'edit'
+ ? t('settings.mcp.toast.saved', { name: saved.name })
+ : t('settings.mcp.toast.created', { name: saved.name }),
+ })
+ setView({ type: 'list' })
+ selectServer(null)
+ } catch (error) {
+ addToast({
+ type: 'error',
+ message: error instanceof Error ? error.message : t('settings.mcp.toast.saveFailed'),
+ })
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ const setDraftField = (key: K, value: McpDraft[K]) => {
+ setDraft((current) => ({ ...current, [key]: value }))
+ }
+
+ const updateStringRows = (key: 'args', id: string, value: string) => {
+ setDraft((current) => ({
+ ...current,
+ [key]: current[key].map((row) => (row.id === id ? { ...row, value } : row)),
+ }))
+ }
+
+ const updateKeyValueRows = (key: 'env' | 'headers', id: string, field: 'key' | 'value', value: string) => {
+ setDraft((current) => ({
+ ...current,
+ [key]: current[key].map((row) => (row.id === id ? { ...row, [field]: value } : row)),
+ }))
+ }
+
+ const addRow = (key: 'args' | 'env' | 'headers') => {
+ setDraft((current) => ({
+ ...current,
+ [key]: [...current[key], key === 'args' ? createStringRow() : createKeyValueRow()],
+ }))
+ }
+
+ const removeRow = (key: 'args' | 'env' | 'headers', id: string) => {
+ setDraft((current) => {
+ const next = current[key].filter((row) => row.id !== id)
+ return {
+ ...current,
+ [key]: next.length > 0 ? next : [key === 'args' ? createStringRow() : createKeyValueRow()],
+ }
+ })
+ }
+
+ if (view.type === 'details') {
+ const server = view.server
+ return (
+
+
+
+
+
+
{server.name}
+
{server.summary}
+
+ {server.canReconnect && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
{t('settings.mcp.form.rawConfig')}
+
+ {JSON.stringify(server.config, null, 2)}
+
+
+
+
+ )
+ }
+
+ if (view.type === 'create' || view.type === 'edit') {
+ const editing = view.type === 'edit'
+ const targetServer = editing ? view.server : null
+ const transportLocked = editing
+ const isBusy = isSaving || isDeleting
+
+ return (
+
+
+
+
+
+
+ {editing ? t('settings.mcp.form.editTitle', { name: targetServer!.name }) : t('settings.mcp.form.createTitle')}
+
+
+ {editing ? t('settings.mcp.form.editHint') : t('settings.mcp.form.createHint')}
+
+
+
+
+ {editing && targetServer?.canReconnect && (
+
+ )}
+ {editing && targetServer?.canRemove && (
+
+ )}
+
+
+
+
+
+
+
+
+ {t('settings.mcp.form.scope')}
+
+
+ {t('settings.mcp.globalOnlyHint')}
+
+
+
+
+
+ {(['stdio', 'http', 'sse'] as TransportKind[]).map((transport) => {
+ const active = draft.transport === transport
+ return (
+
+ )
+ })}
+
+
+
+ {editing && (
+
+ {t('settings.mcp.form.transportLocked')}
+
+ )}
+
+ {draft.transport === 'stdio' ? (
+ <>
+
+
+
updateStringRows('args', id, value)}
+ onAdd={() => addRow('args')}
+ onRemove={(id) => removeRow('args', id)}
+ singleValue
+ valuePlaceholder={t('settings.mcp.form.argumentPlaceholder')}
+ addLabel={t('settings.mcp.form.addArgument')}
+ />
+
+ updateKeyValueRows('env', id, field, value)}
+ onAdd={() => addRow('env')}
+ onRemove={(id) => removeRow('env', id)}
+ keyPlaceholder={t('settings.mcp.form.keyPlaceholder')}
+ valuePlaceholder={t('settings.mcp.form.valuePlaceholder')}
+ addLabel={t('settings.mcp.form.addEnv')}
+ />
+ >
+ ) : (
+ <>
+
+
+ updateKeyValueRows('headers', id, field, value)}
+ onAdd={() => addRow('headers')}
+ onRemove={(id) => removeRow('headers', id)}
+ keyPlaceholder={t('settings.mcp.form.keyPlaceholder')}
+ valuePlaceholder={t('settings.mcp.form.valuePlaceholder')}
+ addLabel={t('settings.mcp.form.addHeader')}
+ />
+
+
+ >
+ )}
+
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+ {t('settings.mcp.title')}
+
+
+ {t('settings.mcp.description')}
+
+
+ {t('settings.mcp.globalOnlyHint')}
+
+
+
+
+
+
+
+
+
+
+
+ {isLoading && globalServers.length === 0 ? (
+
+ ) : error ? (
+
+
error
+
{error}
+
+
+ ) : globalServers.length === 0 ? (
+
+
dns
+
{t('settings.mcp.empty')}
+
{t('settings.mcp.emptyHint')}
+
+ ) : (
+
+
+
+ {t('settings.mcp.scope.user')}
+
+
{globalServers.length}
+
+
+ {globalServers.map((server) => (
+ beginEdit(server)}
+ onToggle={() => void handleToggle(server)}
+ t={t}
+ />
+ ))}
+
+
+ )}
+
+
{
+ if (isDeleting) return
+ setPendingDeleteServer(null)
+ }}
+ title={t('settings.mcp.form.deleteTitle')}
+ footer={(
+ <>
+
+
+ >
+ )}
+ >
+
+ {pendingDeleteServer ? t('settings.mcp.form.deleteConfirmBody', { name: pendingDeleteServer.name }) : ''}
+
+
+
+ )
+}
+
+function InfoPair({ label, value }: { label: string; value: string }) {
+ return (
+
+ )
+}
diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx
index e53d0235..bbc81a43 100644
--- a/desktop/src/pages/Settings.tsx
+++ b/desktop/src/pages/Settings.tsx
@@ -19,6 +19,7 @@ import { useSkillStore } from '../stores/skillStore'
import { SkillList } from '../components/skills/SkillList'
import { SkillDetail } from '../components/skills/SkillDetail'
import { ComputerUseSettings } from './ComputerUseSettings'
+import { McpSettings } from './McpSettings'
import { useUIStore, type SettingsTab } from '../stores/uiStore'
import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
import { useUpdateStore } from '../stores/updateStore'
@@ -45,6 +46,7 @@ export function Settings() {
setActiveTab('permissions')} />
setActiveTab('general')} />
setActiveTab('adapters')} />
+ setActiveTab('mcp')} />
setActiveTab('agents')} />
setActiveTab('skills')} />
setActiveTab('computerUse')} />
@@ -60,6 +62,7 @@ export function Settings() {
{activeTab === 'permissions' && }
{activeTab === 'general' && }
{activeTab === 'adapters' && }
+ {activeTab === 'mcp' && }
{activeTab === 'agents' && }
{activeTab === 'skills' && }
{activeTab === 'computerUse' && }
diff --git a/desktop/src/stores/mcpStore.ts b/desktop/src/stores/mcpStore.ts
new file mode 100644
index 00000000..18770e42
--- /dev/null
+++ b/desktop/src/stores/mcpStore.ts
@@ -0,0 +1,125 @@
+import { create } from 'zustand'
+import { mcpApi } from '../api/mcp'
+import type { McpServerRecord, McpUpsertPayload } from '../types/mcp'
+
+type McpStore = {
+ servers: McpServerRecord[]
+ selectedServer: McpServerRecord | null
+ isLoading: boolean
+ error: string | null
+ fetchServers: (projectPaths?: string[], fallbackCwd?: string) => Promise
+ createServer: (name: string, payload: McpUpsertPayload, cwd?: string) => Promise
+ updateServer: (name: string, payload: McpUpsertPayload, cwd?: string) => Promise
+ deleteServer: (name: string, scope: string, cwd?: string) => Promise
+ toggleServer: (name: string, cwd?: string) => Promise
+ reconnectServer: (name: string, cwd?: string) => Promise
+ selectServer: (server: McpServerRecord | null) => void
+}
+
+function upsertByName(servers: McpServerRecord[], server: McpServerRecord) {
+ const index = servers.findIndex((item) => item.name === server.name)
+ if (index === -1) return [...servers, server]
+ return servers.map((item, itemIndex) => (itemIndex === index ? server : item))
+}
+
+export const useMcpStore = create((set) => ({
+ servers: [],
+ selectedServer: null,
+ isLoading: false,
+ error: null,
+
+ fetchServers: async (projectPaths, fallbackCwd) => {
+ 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 responses = await Promise.all(
+ (contexts.length > 0 ? contexts : [undefined]).map(async (cwd) => {
+ const response = await mcpApi.list(cwd)
+ return response.servers.map((server) => ({
+ ...server,
+ projectPath: server.scope === 'local' || server.scope === 'project' ? cwd : undefined,
+ }))
+ }),
+ )
+
+ const deduped = new Map()
+ for (const group of responses) {
+ for (const server of group) {
+ const key =
+ server.scope === 'local' || server.scope === 'project'
+ ? `${server.scope}:${server.projectPath}:${server.name}`
+ : `${server.scope}:${server.name}`
+ if (!deduped.has(key)) {
+ deduped.set(key, server)
+ }
+ }
+ }
+
+ set({ servers: [...deduped.values()], isLoading: false })
+ } catch (error) {
+ set({
+ isLoading: false,
+ error: error instanceof Error ? error.message : 'Failed to load MCP servers',
+ })
+ }
+ },
+
+ createServer: async (name, payload, cwd) => {
+ const response = await mcpApi.create(name, payload, cwd)
+ set((state) => ({
+ servers: [...state.servers, response.server],
+ selectedServer: response.server,
+ error: null,
+ }))
+ return response.server
+ },
+
+ updateServer: async (name, payload, cwd) => {
+ const response = await mcpApi.update(name, payload, cwd)
+ set((state) => ({
+ servers: upsertByName(
+ state.servers.filter((server) => server.name !== name),
+ response.server,
+ ),
+ selectedServer: response.server,
+ error: null,
+ }))
+ return response.server
+ },
+
+ deleteServer: async (name, scope, cwd) => {
+ await mcpApi.remove(name, scope, cwd)
+ set((state) => ({
+ servers: state.servers.filter((server) => !(server.name === name && server.scope === scope && (server.projectPath ?? '') === (cwd ?? ''))),
+ selectedServer:
+ state.selectedServer?.name === name && state.selectedServer?.scope === scope
+ ? null
+ : state.selectedServer,
+ error: null,
+ }))
+ },
+
+ toggleServer: async (name, cwd) => {
+ const response = await mcpApi.toggle(name, cwd)
+ set((state) => ({
+ servers: upsertByName(state.servers, response.server),
+ selectedServer: state.selectedServer?.name === name ? response.server : state.selectedServer,
+ error: null,
+ }))
+ return response.server
+ },
+
+ reconnectServer: async (name, cwd) => {
+ const response = await mcpApi.reconnect(name, cwd)
+ set((state) => ({
+ servers: upsertByName(state.servers, response.server),
+ selectedServer: state.selectedServer?.name === name ? response.server : state.selectedServer,
+ error: null,
+ }))
+ return response.server
+ },
+
+ selectServer: (server) => set({ selectedServer: server }),
+}))
diff --git a/desktop/src/stores/uiStore.ts b/desktop/src/stores/uiStore.ts
index 27903371..17b00750 100644
--- a/desktop/src/stores/uiStore.ts
+++ b/desktop/src/stores/uiStore.ts
@@ -28,7 +28,7 @@ export type Toast = {
duration?: number
}
-export type SettingsTab = 'providers' | 'permissions' | 'general' | 'adapters' | 'agents' | 'skills' | 'computerUse' | 'about'
+export type SettingsTab = 'providers' | 'permissions' | 'general' | 'adapters' | 'mcp' | 'agents' | 'skills' | 'computerUse' | 'about'
type ActiveView = 'code' | 'scheduled' | 'terminal' | 'history' | 'settings'
diff --git a/desktop/src/types/mcp.ts b/desktop/src/types/mcp.ts
new file mode 100644
index 00000000..cba6b4ff
--- /dev/null
+++ b/desktop/src/types/mcp.ts
@@ -0,0 +1,43 @@
+export type McpEditableConfig =
+ | {
+ type: 'stdio'
+ command: string
+ args: string[]
+ env: Record
+ }
+ | {
+ type: 'http' | 'sse'
+ url: string
+ headers: Record
+ headersHelper?: string
+ oauth?: {
+ clientId?: string
+ callbackPort?: number
+ }
+ }
+ | {
+ type: string
+ }
+
+export type McpServerRecord = {
+ name: string
+ scope: string
+ transport: string
+ enabled: boolean
+ status: 'connected' | 'needs-auth' | 'failed' | 'disabled'
+ statusLabel: string
+ statusDetail?: string
+ configLocation: string
+ summary: string
+ canEdit: boolean
+ canRemove: boolean
+ canReconnect: boolean
+ canToggle: boolean
+ config: McpEditableConfig
+ projectPath?: string
+}
+
+export type McpUpsertPayload = {
+ scope: string
+ config: McpEditableConfig
+}
diff --git a/src/server/__tests__/mcp.test.ts b/src/server/__tests__/mcp.test.ts
new file mode 100644
index 00000000..0ccb7f64
--- /dev/null
+++ b/src/server/__tests__/mcp.test.ts
@@ -0,0 +1,156 @@
+import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'
+import * as fs from 'fs/promises'
+import * as os from 'os'
+import * as path from 'path'
+import * as mcpClient from '../../services/mcp/client.js'
+import { handleMcpApi } from '../api/mcp.js'
+
+let tmpDir: string
+let projectRoot: string
+let originalConfigDir: string | undefined
+let connectSpy: ReturnType | undefined
+
+async function setup() {
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-mcp-test-'))
+ projectRoot = path.join(tmpDir, 'project')
+ await fs.mkdir(path.join(projectRoot, '.claude'), { recursive: true })
+
+ originalConfigDir = process.env.CLAUDE_CONFIG_DIR
+ process.env.CLAUDE_CONFIG_DIR = tmpDir
+}
+
+async function teardown() {
+ if (originalConfigDir !== undefined) {
+ process.env.CLAUDE_CONFIG_DIR = originalConfigDir
+ } else {
+ delete process.env.CLAUDE_CONFIG_DIR
+ }
+
+ await fs.rm(tmpDir, { recursive: true, force: true })
+}
+
+function makeRequest(
+ method: string,
+ urlStr: string,
+ body?: Record,
+): { req: Request; url: URL; segments: string[] } {
+ const url = new URL(urlStr, 'http://localhost:3456')
+ const init: RequestInit = { method }
+ if (body) {
+ init.headers = { 'Content-Type': 'application/json' }
+ init.body = JSON.stringify(body)
+ }
+ const req = new Request(url.toString(), init)
+ return {
+ req,
+ url,
+ segments: url.pathname.split('/').filter(Boolean),
+ }
+}
+
+describe('MCP API', () => {
+ beforeEach(async () => {
+ await setup()
+
+ connectSpy = spyOn(mcpClient, 'connectToServer').mockImplementation(async (name, config) => ({
+ name,
+ type: 'connected',
+ client: {} as never,
+ capabilities: {},
+ config,
+ cleanup: mock(async () => {}),
+ }))
+ })
+
+ afterEach(async () => {
+ connectSpy?.mockRestore()
+ connectSpy = undefined
+ await teardown()
+ })
+
+ it('creates and lists local MCP servers for the requested cwd', async () => {
+ const create = makeRequest('POST', '/api/mcp', {
+ cwd: projectRoot,
+ name: 'chrome-devtools',
+ scope: 'local',
+ config: {
+ type: 'stdio',
+ command: 'npx',
+ args: ['chrome-devtools-mcp@latest'],
+ env: {
+ DEBUG: '1',
+ },
+ },
+ })
+
+ const createRes = await handleMcpApi(create.req, create.url, create.segments)
+ expect(createRes.status).toBe(201)
+ const createdBody = await createRes.json()
+ expect(createdBody.server.name).toBe('chrome-devtools')
+ expect(createdBody.server.transport).toBe('stdio')
+
+ const list = makeRequest('GET', `/api/mcp?cwd=${encodeURIComponent(projectRoot)}`)
+ const listRes = await handleMcpApi(list.req, list.url, list.segments)
+ expect(listRes.status).toBe(200)
+ const listBody = await listRes.json()
+
+ expect(listBody.servers).toHaveLength(1)
+ expect(listBody.servers[0].name).toBe('chrome-devtools')
+ expect(listBody.servers[0].status).toBe('connected')
+ expect(listBody.servers[0].config.command).toBe('npx')
+ })
+
+ it('updates, toggles, and deletes MCP servers', async () => {
+ const create = makeRequest('POST', '/api/mcp', {
+ cwd: projectRoot,
+ name: 'context7',
+ scope: 'local',
+ config: {
+ type: 'stdio',
+ command: 'npx',
+ args: ['@upstash/context7-mcp'],
+ env: {},
+ },
+ })
+ await handleMcpApi(create.req, create.url, create.segments)
+
+ const update = makeRequest('PUT', '/api/mcp/context7', {
+ cwd: projectRoot,
+ scope: 'user',
+ config: {
+ type: 'http',
+ url: 'https://mcp.example.com/mcp',
+ headers: {
+ Authorization: 'Bearer demo',
+ },
+ },
+ })
+ const updateRes = await handleMcpApi(update.req, update.url, update.segments)
+ expect(updateRes.status).toBe(200)
+ const updatedBody = await updateRes.json()
+ expect(updatedBody.server.transport).toBe('http')
+ expect(updatedBody.server.scope).toBe('user')
+
+ const disable = makeRequest('POST', '/api/mcp/context7/toggle', { cwd: projectRoot })
+ const disableRes = await handleMcpApi(disable.req, disable.url, disable.segments)
+ expect(disableRes.status).toBe(200)
+ const disabledBody = await disableRes.json()
+ expect(disabledBody.server.enabled).toBe(false)
+ expect(disabledBody.server.status).toBe('disabled')
+
+ const enable = makeRequest('POST', '/api/mcp/context7/toggle', { cwd: projectRoot })
+ const enableRes = await handleMcpApi(enable.req, enable.url, enable.segments)
+ expect(enableRes.status).toBe(200)
+ const enabledBody = await enableRes.json()
+ expect(enabledBody.server.enabled).toBe(true)
+
+ const remove = makeRequest('DELETE', `/api/mcp/context7?scope=user&cwd=${encodeURIComponent(projectRoot)}`)
+ const removeRes = await handleMcpApi(remove.req, remove.url, remove.segments)
+ expect(removeRes.status).toBe(200)
+
+ const list = makeRequest('GET', `/api/mcp?cwd=${encodeURIComponent(projectRoot)}`)
+ const listRes = await handleMcpApi(list.req, list.url, list.segments)
+ const listBody = await listRes.json()
+ expect(listBody.servers.some((server: { name: string }) => server.name === 'context7')).toBe(false)
+ })
+})
diff --git a/src/server/api/mcp.ts b/src/server/api/mcp.ts
new file mode 100644
index 00000000..a5427268
--- /dev/null
+++ b/src/server/api/mcp.ts
@@ -0,0 +1,507 @@
+import {
+ clearMcpClientConfig,
+ clearServerTokensFromLocalStorage,
+} from '../../services/mcp/auth.js'
+import {
+ clearServerCache,
+ connectToServer,
+ getMcpServerConnectionBatchSize,
+ reconnectMcpServerImpl,
+} from '../../services/mcp/client.js'
+import {
+ addMcpConfig,
+ getClaudeCodeMcpConfigs,
+ getMcpConfigByName,
+ isMcpServerDisabled,
+ removeMcpConfig,
+ setMcpServerEnabled,
+} from '../../services/mcp/config.js'
+import type {
+ ConfigScope,
+ McpHTTPServerConfig,
+ McpSSEServerConfig,
+ McpServerConfig,
+ McpStdioServerConfig,
+ ScopedMcpServerConfig,
+} from '../../services/mcp/types.js'
+import { describeMcpConfigFilePath, ensureConfigScope } from '../../services/mcp/utils.js'
+import { enableConfigs } from '../../utils/config.js'
+import { getCwd, runWithCwdOverride } from '../../utils/cwd.js'
+import { ApiError, errorResponse } from '../middleware/errorHandler.js'
+
+type McpEditableConfigDto =
+ | {
+ type: 'stdio'
+ command: string
+ args: string[]
+ env: Record
+ }
+ | {
+ type: 'http' | 'sse'
+ url: string
+ headers: Record
+ headersHelper?: string
+ oauth?: {
+ clientId?: string
+ callbackPort?: number
+ }
+ }
+ | {
+ type: string
+ }
+
+type McpServerDto = {
+ name: string
+ scope: string
+ transport: string
+ enabled: boolean
+ status: 'connected' | 'needs-auth' | 'failed' | 'disabled'
+ statusLabel: string
+ statusDetail?: string
+ configLocation: string
+ summary: string
+ canEdit: boolean
+ canRemove: boolean
+ canReconnect: boolean
+ canToggle: boolean
+ config: McpEditableConfigDto
+}
+
+type McpMutationBody = {
+ cwd?: string
+ scope?: string
+ config?: unknown
+}
+
+const EDITABLE_SCOPES = new Set(['local', 'project', 'user'])
+
+function parseJsonBody(req: Request): Promise> {
+ return req
+ .json()
+ .then((body) => body as Record)
+ .catch(() => {
+ throw ApiError.badRequest('Invalid JSON body')
+ })
+}
+
+function resolveRequestCwd(url: URL, body?: Record): string {
+ const cwd = url.searchParams.get('cwd') || (typeof body?.cwd === 'string' ? body.cwd : undefined)
+ return cwd || getCwd()
+}
+
+function stripScope(config: ScopedMcpServerConfig): McpServerConfig {
+ const { scope: _scope, pluginSource: _pluginSource, ...rest } = config
+ return rest
+}
+
+function isVisibleServer(name: string, config: ScopedMcpServerConfig): boolean {
+ if (name === 'ide') return false
+ if (config.type === 'sse-ide' || config.type === 'ws-ide') return false
+ return true
+}
+
+function serializeEditableConfig(config: ScopedMcpServerConfig): McpEditableConfigDto {
+ if (!config.type || config.type === 'stdio') {
+ const stdioConfig = config as McpStdioServerConfig
+ return {
+ type: 'stdio',
+ command: stdioConfig.command,
+ args: Array.isArray(stdioConfig.args) ? stdioConfig.args : [],
+ env: stdioConfig.env ?? {},
+ }
+ }
+
+ if (config.type === 'http' || config.type === 'sse') {
+ const remoteConfig = config as McpHTTPServerConfig | McpSSEServerConfig
+ return {
+ type: config.type,
+ url: remoteConfig.url,
+ headers: remoteConfig.headers ?? {},
+ headersHelper: remoteConfig.headersHelper,
+ oauth: remoteConfig.oauth
+ ? {
+ clientId: remoteConfig.oauth.clientId,
+ callbackPort: remoteConfig.oauth.callbackPort,
+ }
+ : undefined,
+ }
+ }
+
+ return { type: config.type }
+}
+
+function getSummary(config: ScopedMcpServerConfig): string {
+ if (!config.type || config.type === 'stdio') {
+ const stdioConfig = config as McpStdioServerConfig
+ return [stdioConfig.command, ...(stdioConfig.args ?? [])].join(' ').trim()
+ }
+
+ if ('url' in config && typeof config.url === 'string') {
+ return config.url
+ }
+
+ return config.type
+}
+
+function getStatusLabel(status: McpServerDto['status']): string {
+ switch (status) {
+ case 'connected':
+ return 'Connected'
+ case 'needs-auth':
+ return 'Needs auth'
+ case 'failed':
+ return 'Unavailable'
+ case 'disabled':
+ return 'Disabled'
+ default:
+ return status
+ }
+}
+
+async function inspectServerStatus(
+ name: string,
+ config: ScopedMcpServerConfig,
+ enabled: boolean,
+): Promise> {
+ if (!enabled) {
+ return {
+ status: 'disabled',
+ statusLabel: getStatusLabel('disabled'),
+ statusDetail: 'Server disabled for the current project',
+ }
+ }
+
+ try {
+ const client = await connectToServer(name, config)
+ await clearServerCache(name, config).catch(() => {})
+
+ const status: McpServerDto['status'] =
+ client.type === 'connected'
+ ? 'connected'
+ : client.type === 'needs-auth'
+ ? 'needs-auth'
+ : 'failed'
+
+ return {
+ status,
+ statusLabel: getStatusLabel(status),
+ statusDetail: 'error' in client ? client.error : undefined,
+ }
+ } catch (error) {
+ await clearServerCache(name, config).catch(() => {})
+ return {
+ status: 'failed',
+ statusLabel: getStatusLabel('failed'),
+ statusDetail: error instanceof Error ? error.message : String(error),
+ }
+ }
+}
+
+async function serializeServer(
+ name: string,
+ config: ScopedMcpServerConfig,
+): Promise {
+ const enabled = !isMcpServerDisabled(name)
+ const status = await inspectServerStatus(name, config, enabled)
+ const transport = config.type ?? 'stdio'
+ const canEdit = EDITABLE_SCOPES.has(config.scope) && (transport === 'stdio' || transport === 'http' || transport === 'sse')
+
+ return {
+ name,
+ scope: config.scope,
+ transport,
+ enabled,
+ status: status.status,
+ statusLabel: status.statusLabel,
+ statusDetail: status.statusDetail,
+ configLocation: describeMcpConfigFilePath(config.scope),
+ summary: getSummary(config),
+ canEdit,
+ canRemove: EDITABLE_SCOPES.has(config.scope),
+ canReconnect: enabled,
+ canToggle: true,
+ config: serializeEditableConfig(config),
+ }
+}
+
+function buildServerConfig(config: unknown): McpServerConfig {
+ if (!config || typeof config !== 'object') {
+ throw ApiError.badRequest('Missing or invalid "config" in request body')
+ }
+
+ const raw = config as Record
+ const type = raw.type
+
+ if (!type || type === 'stdio') {
+ const command = typeof raw.command === 'string' ? raw.command.trim() : ''
+ if (!command) {
+ throw ApiError.badRequest('Command is required for stdio MCP servers')
+ }
+
+ const args = Array.isArray(raw.args)
+ ? raw.args.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
+ : []
+
+ const envEntries = raw.env && typeof raw.env === 'object'
+ ? Object.entries(raw.env as Record).filter(
+ (entry): entry is [string, string] => typeof entry[0] === 'string' && typeof entry[1] === 'string' && entry[0].trim().length > 0,
+ )
+ : []
+
+ return {
+ type: 'stdio',
+ command,
+ args,
+ ...(envEntries.length > 0 ? { env: Object.fromEntries(envEntries) } : {}),
+ }
+ }
+
+ if (type === 'http' || type === 'sse') {
+ const url = typeof raw.url === 'string' ? raw.url.trim() : ''
+ if (!url) {
+ throw ApiError.badRequest('URL is required for remote MCP servers')
+ }
+
+ const headersEntries = raw.headers && typeof raw.headers === 'object'
+ ? Object.entries(raw.headers as Record).filter(
+ (entry): entry is [string, string] => typeof entry[0] === 'string' && typeof entry[1] === 'string' && entry[0].trim().length > 0,
+ )
+ : []
+
+ const oauthRaw = raw.oauth && typeof raw.oauth === 'object' ? (raw.oauth as Record) : undefined
+ const clientId = typeof oauthRaw?.clientId === 'string' ? oauthRaw.clientId.trim() : ''
+ const callbackPort =
+ typeof oauthRaw?.callbackPort === 'number'
+ ? oauthRaw.callbackPort
+ : typeof oauthRaw?.callbackPort === 'string' && oauthRaw.callbackPort.trim()
+ ? Number(oauthRaw.callbackPort)
+ : undefined
+
+ return {
+ type,
+ url,
+ ...(headersEntries.length > 0 ? { headers: Object.fromEntries(headersEntries) } : {}),
+ ...(typeof raw.headersHelper === 'string' && raw.headersHelper.trim()
+ ? { headersHelper: raw.headersHelper.trim() }
+ : {}),
+ ...(clientId || callbackPort
+ ? {
+ oauth: {
+ ...(clientId ? { clientId } : {}),
+ ...(callbackPort ? { callbackPort } : {}),
+ },
+ }
+ : {}),
+ }
+ }
+
+ throw ApiError.badRequest(`Unsupported MCP transport: ${String(type)}`)
+}
+
+function cleanupSecureStorage(name: string, config: ScopedMcpServerConfig) {
+ if (config.type !== 'sse' && config.type !== 'http') return
+ clearServerTokensFromLocalStorage(name, config)
+ clearMcpClientConfig(name, config)
+}
+
+async function listServers(): Promise {
+ const { servers } = await getClaudeCodeMcpConfigs()
+ const visibleServers = Object.entries(servers)
+ .filter(([name, config]) => isVisibleServer(name, config))
+ .sort((a, b) => a[0].localeCompare(b[0]))
+
+ const concurrency = Math.max(1, getMcpServerConnectionBatchSize())
+ const items: McpServerDto[] = []
+
+ for (let index = 0; index < visibleServers.length; index += concurrency) {
+ const chunk = visibleServers.slice(index, index + concurrency)
+ const chunkItems = await Promise.all(
+ chunk.map(([name, config]) => serializeServer(name, config)),
+ )
+ items.push(...chunkItems)
+ }
+
+ return Response.json({ servers: items })
+}
+
+async function createServer(body: Record): Promise {
+ const name = typeof body.name === 'string' ? body.name.trim() : ''
+ if (!name) {
+ throw ApiError.badRequest('Missing or invalid "name" in request body')
+ }
+
+ const scope = ensureConfigScope(typeof body.scope === 'string' ? body.scope : undefined)
+ const config = buildServerConfig(body.config)
+
+ try {
+ await addMcpConfig(name, config, scope)
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ if (message.includes('already exists')) {
+ throw ApiError.conflict(message)
+ }
+ throw ApiError.badRequest(message)
+ }
+
+ const created = getMcpConfigByName(name)
+ if (!created) {
+ throw ApiError.internal(`Created MCP server "${name}" could not be reloaded`)
+ }
+
+ return Response.json({ server: await serializeServer(name, created) }, { status: 201 })
+}
+
+async function updateServer(name: string, body: Record): Promise {
+ const existing = getMcpConfigByName(name)
+ if (!existing) {
+ throw ApiError.notFound(`MCP server not found: ${name}`)
+ }
+
+ if (!EDITABLE_SCOPES.has(existing.scope)) {
+ throw ApiError.badRequest(`MCP server "${name}" cannot be edited from scope "${existing.scope}"`)
+ }
+
+ const nextScope = ensureConfigScope(typeof body.scope === 'string' ? body.scope : existing.scope)
+ const nextConfig = buildServerConfig(body.config)
+ const previousConfig = stripScope(existing)
+ const previousScope = existing.scope
+
+ try {
+ await removeMcpConfig(name, previousScope)
+ await addMcpConfig(name, nextConfig, nextScope)
+ } catch (error) {
+ try {
+ const restored = getMcpConfigByName(name)
+ if (!restored) {
+ await addMcpConfig(name, previousConfig, previousScope)
+ }
+ } catch {
+ // Preserve the original update error below.
+ }
+
+ const message = error instanceof Error ? error.message : String(error)
+ if (message.includes('already exists')) {
+ throw ApiError.conflict(message)
+ }
+ throw ApiError.badRequest(message)
+ }
+
+ const updated = getMcpConfigByName(name)
+ if (!updated) {
+ throw ApiError.internal(`Updated MCP server "${name}" could not be reloaded`)
+ }
+
+ return Response.json({ server: await serializeServer(name, updated) })
+}
+
+async function deleteServer(name: string, url: URL): Promise {
+ const scope = ensureConfigScope(url.searchParams.get('scope') || undefined)
+ const existing = getMcpConfigByName(name)
+ if (!existing) {
+ throw ApiError.notFound(`MCP server not found: ${name}`)
+ }
+
+ await removeMcpConfig(name, scope)
+ cleanupSecureStorage(name, existing)
+ await clearServerCache(name, existing).catch(() => {})
+
+ return Response.json({ ok: true })
+}
+
+async function toggleServer(name: string): Promise {
+ const existing = getMcpConfigByName(name)
+ if (!existing) {
+ throw ApiError.notFound(`MCP server not found: ${name}`)
+ }
+
+ const enabled = isMcpServerDisabled(name)
+ setMcpServerEnabled(name, enabled)
+
+ if (!enabled) {
+ await clearServerCache(name, existing).catch(() => {})
+ const updated = await serializeServer(name, existing)
+ return Response.json({ server: updated })
+ }
+
+ const result = await reconnectMcpServerImpl(name, existing)
+ await clearServerCache(name, existing).catch(() => {})
+
+ const updated = await serializeServer(name, existing)
+ const statusDetail =
+ result.client.type === 'failed' && 'error' in result.client ? result.client.error : undefined
+
+ return Response.json({
+ server: {
+ ...updated,
+ ...(statusDetail ? { statusDetail } : {}),
+ },
+ })
+}
+
+async function reconnectServer(name: string): Promise {
+ const existing = getMcpConfigByName(name)
+ if (!existing) {
+ throw ApiError.notFound(`MCP server not found: ${name}`)
+ }
+
+ const result = await reconnectMcpServerImpl(name, existing)
+ await clearServerCache(name, existing).catch(() => {})
+
+ const server = await serializeServer(name, existing)
+ const statusDetail =
+ result.client.type === 'failed' && 'error' in result.client ? result.client.error : undefined
+
+ return Response.json({
+ server: {
+ ...server,
+ ...(statusDetail ? { statusDetail } : {}),
+ },
+ })
+}
+
+export async function handleMcpApi(
+ req: Request,
+ url: URL,
+ segments: string[],
+): Promise {
+ try {
+ enableConfigs()
+
+ const serverName = segments[2] ? decodeURIComponent(segments[2]) : undefined
+ const action = segments[3]
+ const body =
+ req.method === 'POST' || req.method === 'PUT'
+ ? await parseJsonBody(req)
+ : undefined
+
+ return await runWithCwdOverride(resolveRequestCwd(url, body), async () => {
+ if (req.method === 'GET' && !serverName) {
+ return listServers()
+ }
+
+ if (req.method === 'POST' && !serverName) {
+ return createServer(body ?? {})
+ }
+
+ if (req.method === 'PUT' && serverName) {
+ return updateServer(serverName, body ?? {})
+ }
+
+ if (req.method === 'DELETE' && serverName && !action) {
+ return deleteServer(serverName, url)
+ }
+
+ if (req.method === 'POST' && serverName && action === 'toggle') {
+ return toggleServer(serverName)
+ }
+
+ if (req.method === 'POST' && serverName && action === 'reconnect') {
+ return reconnectServer(serverName)
+ }
+
+ throw new ApiError(405, `Method ${req.method} not allowed`, 'METHOD_NOT_ALLOWED')
+ })
+ } catch (error) {
+ return errorResponse(error)
+ }
+}
diff --git a/src/server/router.ts b/src/server/router.ts
index 472808d6..2e6c7b3d 100644
--- a/src/server/router.ts
+++ b/src/server/router.ts
@@ -17,6 +17,7 @@ import { handleAdaptersApi } from './api/adapters.js'
import { handleSkillsApi } from './api/skills.js'
import { handleComputerUseApi } from './api/computer-use.js'
import { handleHahaOAuthApi } from './api/haha-oauth.js'
+import { handleMcpApi } from './api/mcp.js'
export async function handleApiRequest(req: Request, url: URL): Promise {
const path = url.pathname
@@ -76,6 +77,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise