Files
goodbuddy/src/renderer/src/McpSettingsSection.tsx
T
mesalogo 2713a81bd9 fix: restore consistent controls, usable overlays and narrow layouts
Resolve 15 shared-control, overlay and responsive-layout issues plus the MCP editor being hidden behind settings. Restore theme contrast, danger and disabled states, visible keyboard focus, and consistent Continue fields. Keep the workbar add button reachable and model protocol values readable in narrow containers.

Make nested MCP and image dialogs usable above their parents with focus restoration, keep project Escape handling within the active modal, release native browser views for overlapping help, and restore keyboard access and Escape in sandboxed HTML previews without relaxing sandbox permissions. Add unit and real Electron regression coverage and update design and validation documentation.

Release note: 修复深浅主题下控件边框、危险操作、禁用状态和键盘焦点显示不一致的问题。MCP 编辑器与图片预览可在父弹窗上正常操作,HTML 全屏预览支持键盘进入和 Escape 返回;窄工作栏的添加按钮保持可用,模型连接协议文字完整可读。

Validation: the full suite recorded 5309 passed, 3 failed and 83 skipped. The ProjectSwitcher initial-focus regression was then fixed, and App, ProjectSwitcher and overlay Electron tests passed all 331 cases. The other two HeartbeatCenter failures were reproduced on isolated HEAD 11facf0 and remain pre-existing. Typecheck and lint passed after the fix; staged whitespace checks passed. The full suite was not rerun after the focused fix.

Limits: Electron evidence is from Windows development runs, with fixtures for unrelated services and native occlusion throttling disabled in some drivers. No production build, packaging, macOS/Linux, remote Agent host or external MCP connection validation; skipped tests and the documented scaling and native-select coverage gaps remain.
2026-09-27 21:57:35 +08:00

1728 lines
62 KiB
TypeScript

import {
ChevronDown,
CircleAlert,
Database,
FlaskConical,
MonitorCog,
Network,
Pencil,
Plus,
RefreshCw,
Trash2,
Wrench,
X
} from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { builtinMcpServers } from '../../shared/builtin-mcp-servers'
import { builtinModelToolGroups } from '../../shared/builtin-model-tools'
import type {
BuiltinMcpServerId,
CapabilityDiagnosticReport,
CapabilityAssignments,
CapabilitySnapshot,
ComputerCapabilityId,
McpServerInput,
McpServerSummary,
McpServerTestResult,
McpTransport,
ObsidianConnectionTestResult,
RuntimeTarget,
WebSearchTestResult
} from '../../shared/capability-contracts'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { PageTabs } from './WorkspacePrimitives'
const configurableMcpTargets: RuntimeTarget[] = [
'model',
'opencode',
'continue',
'deepseek-harness'
]
type McpSettingsTab =
| 'builtin'
| 'custom'
| 'model-tools'
| 'computer'
type McpEditor = {
id?: string
name: string
description: string
enabled: boolean
allowDynamicTools: boolean
assignments: CapabilityAssignments
transport: McpTransport
command: string
args: string
url: string
token: string
clearToken: boolean
}
const emptyEditor: McpEditor = {
name: '',
description: '',
enabled: true,
allowDynamicTools: false,
assignments: ['model'],
transport: 'stdio',
command: '',
args: '',
url: '',
token: '',
clearToken: false
}
function editorFromServer(server: McpServerSummary): McpEditor {
return {
id: server.id,
name: server.name,
description: server.description,
enabled: server.enabled,
allowDynamicTools: server.allowDynamicTools,
assignments: server.assignments,
transport: server.transport,
command: server.transport === 'stdio' ? server.command : '',
args: server.transport === 'stdio' ? server.args.join('\n') : '',
url: server.transport === 'stdio' ? '' : server.url,
token: '',
clearToken: false
}
}
export function McpSettingsSection({
onOpenImageModelSettings,
magicNotesEnabled = false
}: {
onOpenImageModelSettings: () => void
magicNotesEnabled?: boolean
}): React.JSX.Element {
const { t } = useTranslation('integrations')
const tRef = useRef(t)
useEffect(() => {
tRef.current = t
}, [t])
const runtimeLabels: Record<RuntimeTarget, string> = {
model: t('mcp.runtimeLabels.model'),
opencode: t('mcp.runtimeLabels.opencode'),
continue: t('mcp.runtimeLabels.continue'),
'deepseek-harness': 'DeepSeek Harness'
}
const diagnosticStatusLabels: Record<
CapabilityDiagnosticReport['status'],
string
> = {
available: t('mcp.diagnosticStatuses.available'),
degraded: t('mcp.diagnosticStatuses.degraded'),
unavailable: t('mcp.diagnosticStatuses.unavailable'),
disabled: t('mcp.diagnosticStatuses.disabled')
}
const [snapshot, setSnapshot] = useState<CapabilitySnapshot>()
const [obsidianPath, setObsidianPath] = useState<string>()
const [obsidianResult, setObsidianResult] = useState<ObsidianConnectionTestResult>()
const [obsidianError, setObsidianError] = useState<string>()
const [obsidianSpecified, setObsidianSpecified] = useState<boolean>()
const vaultPath = obsidianPath ?? snapshot?.obsidian?.vaultPath ?? ''
const specifiedVault = obsidianSpecified ?? Boolean(snapshot?.obsidian?.vaultPath)
const [editor, setEditor] = useState<McpEditor>()
const [busy, setBusy] = useState<string>()
const [error, setError] = useState<string>()
const [testResults, setTestResults] = useState<
Record<string, McpServerTestResult>
>({})
const [webSearchTestResult, setWebSearchTestResult] =
useState<WebSearchTestResult>()
const [expandedItemIds, setExpandedItemIds] = useState<Set<string>>(
() => new Set()
)
const [diagnostics, setDiagnostics] = useState<
Partial<Record<ComputerCapabilityId, CapabilityDiagnosticReport>>
>({})
const [activeTab, setActiveTab] =
useState<McpSettingsTab>('builtin')
const editorDialogRef = useRef<HTMLDivElement>(null)
const editorNameRef = useRef<HTMLInputElement>(null)
const editorTriggerRef = useRef<HTMLButtonElement | undefined>(
undefined
)
const editorOpen = Boolean(editor)
const toggleItem = (itemId: string): void => {
setExpandedItemIds((current) => {
const next = new Set(current)
if (next.has(itemId)) {
next.delete(itemId)
} else {
next.add(itemId)
}
return next
})
}
useEffect(() => {
void window.goodbuddy.capabilities
.getSnapshot()
.then(setSnapshot)
.catch((reason: unknown) => {
setError(
reason instanceof Error
? reason.message
: tRef.current('mcp.errors.load')
)
})
}, [])
useEffect(() => {
if (!editorOpen) {
return
}
return activateModalFocus(
() => editorNameRef.current,
() => editorTriggerRef.current ?? null
)
}, [editorOpen])
const run = async (
key: string,
operation: () => Promise<CapabilitySnapshot>
): Promise<boolean> => {
setBusy(key)
setError(undefined)
try {
setSnapshot(await operation())
return true
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: t('mcp.errors.operation')
)
return false
} finally {
setBusy(undefined)
}
}
const diagnose = async (
capabilityId: ComputerCapabilityId
): Promise<void> => {
setBusy(`diagnose:${capabilityId}`)
setError(undefined)
try {
const diagnoseCapability =
window.goodbuddy.capabilities.diagnoseComputerCapability
if (!diagnoseCapability) {
throw new Error(t('mcp.errors.unsupportedDiagnostics'))
}
const report = await diagnoseCapability(capabilityId)
setDiagnostics((current) => ({
...current,
[capabilityId]: report
}))
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: t('mcp.errors.diagnostics')
)
} finally {
setBusy(undefined)
}
}
const save = async (): Promise<void> => {
if (!editor) {
return
}
const secret: McpServerInput['secret'] = editor.clearToken
? { action: 'clear' }
: editor.token.trim()
? { action: 'replace', value: editor.token }
: { action: 'keep' }
const common = {
name: editor.name,
description: editor.description,
enabled: editor.enabled,
allowDynamicTools: editor.allowDynamicTools,
assignments: editor.assignments,
secret
}
const input: McpServerInput =
editor.transport === 'stdio'
? {
...common,
transport: 'stdio',
command: editor.command,
args: editor.args
.split(/\r?\n/u)
.map((value) => value.trim())
.filter(Boolean)
}
: {
...common,
transport: editor.transport,
url: editor.url
}
const saved = await run('save', () =>
window.goodbuddy.capabilities.saveMcpServer(editor.id, input)
)
if (saved) {
closeEditor()
}
}
const test = async (server: McpServerSummary): Promise<void> => {
setBusy(`test:${server.id}`)
setError(undefined)
try {
const result =
await window.goodbuddy.capabilities.testMcpServer(server.id)
setTestResults((current) => ({
...current,
[server.id]: result
}))
setExpandedItemIds((current) => {
const next = new Set(current)
next.add(`custom:${server.id}`)
return next
})
} catch (reason) {
setError(
reason instanceof Error ? reason.message : t('mcp.errors.test')
)
} finally {
setBusy(undefined)
}
}
const testDirectModelWebSearch = async (): Promise<void> => {
setBusy('test:web-search')
setError(undefined)
try {
const testCapability =
window.goodbuddy.capabilities.testWebSearch
if (!testCapability) {
throw new Error(t('mcp.webSearch.unsupported'))
}
setWebSearchTestResult(await testCapability())
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: t('mcp.webSearch.testFailed')
)
} finally {
setBusy(undefined)
}
}
const configureObsidian = async (action: 'save' | 'test' | 'select'): Promise<void> => {
setBusy(`obsidian:${action}`)
setObsidianError(undefined)
try {
const api = window.goodbuddy.capabilities
if (action === 'select') {
const path = await api.selectObsidianVault()
if (path !== null) {
setObsidianPath(path)
setObsidianSpecified(true)
setObsidianResult(undefined)
}
} else {
const input = { vaultPath: specifiedVault ? vaultPath.trim() : '' }
if (specifiedVault && !input.vaultPath) {
throw new Error(t('mcp.obsidian.folderRequired'))
}
if (action === 'save') {
const next = await api.updateObsidianSettings(input)
setSnapshot(next)
setObsidianPath(undefined)
setObsidianSpecified(undefined)
} else {
setObsidianResult(undefined)
setObsidianResult(await api.testObsidianConnection(input))
}
}
} catch (reason) {
setObsidianError(reason instanceof Error ? reason.message : t('mcp.errors.operation'))
} finally {
setBusy(undefined)
}
}
const updateAssignment = (
target: RuntimeTarget,
checked: boolean
): void => {
if (!editor) {
return
}
setEditor({
...editor,
assignments: checked
? [...editor.assignments, target]
: editor.assignments.filter((item) => item !== target)
})
}
const updateBuiltinAssignment = (
serverId: BuiltinMcpServerId,
assignments: CapabilityAssignments,
target: RuntimeTarget,
checked: boolean
): void => {
const next = checked
? [...assignments, target]
: assignments.filter((item) => item !== target)
void run(`builtin:assign:${serverId}`, () =>
window.goodbuddy.capabilities.setBuiltinMcpServerAssignments(
serverId,
next
)
)
}
const openEditor = (
nextEditor: McpEditor,
trigger: HTMLButtonElement
): void => {
editorTriggerRef.current = trigger
trigger.focus()
setError(undefined)
setEditor(nextEditor)
}
const closeEditor = (): void => {
if (busy === 'save') {
return
}
setError(undefined)
setEditor(undefined)
}
const handleEditorKeyDown = (
event: React.KeyboardEvent<HTMLDivElement>
): void => {
event.stopPropagation()
if (event.key === 'Escape') {
event.preventDefault()
closeEditor()
return
}
trapTabFocus(event, editorDialogRef.current)
}
const computerCapabilities = useMemo(() => snapshot?.computerCapabilities ?? [], [snapshot?.computerCapabilities])
const desktopCapabilities = useMemo(() => computerCapabilities.filter(
(capability) => capability.id !== 'host-browser-control'
), [computerCapabilities])
const builtinMcpStates = useMemo(() => new Map(
snapshot?.builtinMcpServers?.map((server) => [server.id, server])
), [snapshot?.builtinMcpServers])
const webSearch = snapshot?.webSearch ?? {
provider: 'exa' as const,
enabled: true,
availableIn: ['ask', 'execute'] as const,
tools: ['web_search', 'web_fetch'] as const
}
return (
<>
<div className="mcp-settings__tabs">
<PageTabs
ariaLabel={t('mcp.tabs.ariaLabel')}
idPrefix="mcp-settings"
onChange={(tab) => {
setError(undefined)
setActiveTab(tab)
}}
tabs={[
{
id: 'builtin',
icon: <Database size={14} />,
label: t('mcp.tabs.builtin')
},
{
id: 'model-tools',
icon: <Wrench size={14} />,
label: t('mcp.tabs.modelTools')
},
{
id: 'custom',
icon: <Network size={14} />,
label: t('mcp.tabs.custom')
},
{
id: 'computer',
icon: <MonitorCog size={14} />,
label: t('mcp.tabs.computer')
}
]}
value={activeTab}
variant="segmented"
/>
</div>
<section
aria-labelledby={`mcp-settings-tab-${activeTab}`}
className="settings-section mcp-settings__panel"
id={`mcp-settings-panel-${activeTab}`}
role="tabpanel"
>
{activeTab === 'custom' && (
<>
<div className="settings-content-actions">
<button
className="secondary-button"
disabled={Boolean(busy) || Boolean(editor)}
onClick={(event) =>
openEditor({ ...emptyEditor }, event.currentTarget)
}
type="button"
>
<Plus aria-hidden="true" size={14} />
{t('mcp.addServer')}
</button>
</div>
{error && !editor && (
<p className="settings-warning" role="alert">{error}</p>
)}
<p className="settings-notice">{t('mcp.customNotice')}</p>
</>
)}
{activeTab === 'computer' && (
<section
aria-labelledby="computer-capabilities-heading"
className="mcp-tool-section"
>
<div className="mcp-subsection-heading">
<div>
<MonitorCog size={15} />
<strong id="computer-capabilities-heading">
{t('mcp.computer.title')}
</strong>
</div>
</div>
<div className="capability-list">
{desktopCapabilities.map((capability) => {
const report = diagnostics[capability.id]
return (
<article className="capability-card" key={capability.id}>
<div className="capability-card__header">
<div>
<strong>{capability.name}</strong>
<small>
{capability.supported
? t('mcp.computer.supported')
: t('mcp.computer.unsupported')}
</small>
</div>
</div>
<label className="toggle-row">
<input
aria-label={t('mcp.computer.enableAriaLabel', {
name: capability.name
})}
checked={capability.enabled}
disabled={Boolean(busy) || !capability.supported}
onChange={(event) =>
void run(`computer:${capability.id}`, () =>
window.goodbuddy.capabilities.setComputerCapabilityEnabled?.(
capability.id,
event.target.checked
) ??
Promise.reject(
new Error(
t('mcp.errors.unsupportedComputerControl')
)
)
)
}
role="switch"
type="checkbox"
/>
<span>{t('mcp.computer.enable')}</span>
</label>
<p>{capability.description}</p>
<p className="computer-capability-risk">
<CircleAlert aria-hidden="true" size={13} />
{capability.riskSummary}
</p>
<div className="capability-diagnostic">
<button
aria-label={t('mcp.computer.diagnoseAriaLabel', {
name: capability.name
})}
className="secondary-button"
disabled={Boolean(busy)}
onClick={() => void diagnose(capability.id)}
type="button"
>
<RefreshCw size={13} />
{busy === `diagnose:${capability.id}`
? t('mcp.computer.diagnosing')
: t('mcp.computer.diagnose')}
</button>
{report && (
<div aria-live="polite" className="capability-diagnostic__result">
<strong>
{t('mcp.computer.result', {
status: diagnosticStatusLabels[report.status]
})}
</strong>
{report.checks.map((check) => (
<p key={check.id}>
{check.summary}
{check.remedy
? t('mcp.computer.remedy', {
remedy: check.remedy
})
: ''}
</p>
))}
</div>
)}
</div>
</article>
)
})}
</div>
</section>
)}
{activeTab === 'builtin' && (
<section
aria-labelledby="builtin-mcp-heading"
className="mcp-tool-section"
>
<div className="mcp-subsection-heading">
<div>
<Database size={15} />
<strong id="builtin-mcp-heading">
{t('mcp.builtin.title')}
</strong>
</div>
<small>
{t('mcp.profiles.count', {
count: builtinMcpServers.length + 1
})}
</small>
</div>
<div className="mcp-server-list">
<article
className="capability-card builtin-mcp-card"
aria-label={t('conversationImages.title')}
>
<div className="capability-card__header">
<div>
<strong>{t('conversationImages.title')}</strong>
<small>{t('conversationImages.managed')}</small>
</div>
</div>
<p id="image-generation-management">
{t('conversationImages.description')}
</p>
<p>{t('conversationImages.runtimeScope')}</p>
<div className="runtime-assignments">
<small>{t('mcp.builtin.assignedTo')}</small>
{configurableMcpTargets.map((target) => (
<label key={target}>
<input
aria-describedby="image-generation-management"
checked={
snapshot?.imageGeneration?.assignments.includes(target) ?? false
}
disabled
type="checkbox"
/>
{runtimeLabels[target]}
</label>
))}
</div>
<button
className="secondary-button"
onClick={onOpenImageModelSettings}
type="button"
>
{t('conversationImages.openSettings')}
</button>
</article>
{builtinMcpServers.map((server) => {
const expansionId = `builtin:${server.id}`
const expanded = expandedItemIds.has(expansionId)
const panelId = `mcp-server-tools-${server.id}`
const state = builtinMcpStates.get(server.id) ?? {
id: server.id,
enabled: server.id !== 'builtin-browser' && server.id !== 'obsidian',
assignments:
[...server.supportedAssignments] as CapabilityAssignments
}
const computerCapability =
'computerCapabilityId' in server
? computerCapabilities.find(
(capability) =>
capability.id === server.computerCapabilityId
)
: undefined
const featureAvailable =
(!('requiresFeature' in server) ||
magicNotesEnabled === true) &&
(!computerCapability || computerCapability.supported)
return (
<article
className={`capability-card builtin-mcp-card${
state.enabled && featureAvailable
? ''
: ' capability-card--disabled'
}`}
key={server.id}
>
<div className="capability-card__header">
<div>
<strong>{server.name}</strong>
<small>
{!state.enabled
? t('mcp.builtin.disabled')
: computerCapability &&
!computerCapability.supported
? t('mcp.browser.unsupported')
: !featureAvailable
? t('mcp.builtin.serverSummaryDisabled')
: server.id === 'obsidian'
? t('mcp.obsidian.access')
: server.id === 'builtin-browser'
? t('mcp.builtin.serverSummaryExecuteOnly')
: server.access === 'mixed'
? t('mcp.builtin.serverSummaryMixed')
: t('mcp.builtin.serverSummaryReadOnly')}
</small>
</div>
</div>
<label className="toggle-row">
<input
aria-label={t('mcp.builtin.enableAriaLabel', {
name: server.name
})}
checked={state.enabled}
disabled={Boolean(busy) || !featureAvailable}
onChange={(event) =>
void run(`builtin:toggle:${server.id}`, () =>
window.goodbuddy.capabilities.setBuiltinMcpServerEnabled(
server.id,
event.target.checked
)
)
}
role="switch"
type="checkbox"
/>
<span>
{state.enabled
? t('mcp.builtin.enabled')
: t('mcp.builtin.disabled')}
</span>
</label>
<p>{server.id === 'obsidian' ? t('mcp.obsidian.description') : server.description}</p>
{server.id === 'obsidian' && (
<div className="capability-diagnostic capability-diagnostic--obsidian">
<label className="field">
<span>{t('mcp.obsidian.scope')}</span>
<select
disabled={Boolean(busy) || !snapshot}
value={specifiedVault ? 'folder' : 'all'}
onChange={(event) => {
setObsidianSpecified(event.target.value === 'folder')
setObsidianResult(undefined)
setObsidianError(undefined)
}}
>
<option value="all">{t('mcp.obsidian.all')}</option>
<option value="folder">{t('mcp.obsidian.folder')}</option>
</select>
</label>
{specifiedVault && (
<label className="field">
<span>{t('mcp.obsidian.path')}</span>
<input
aria-describedby={obsidianError ? 'obsidian-settings-error' : undefined}
disabled={Boolean(busy)}
maxLength={4096}
value={vaultPath}
onChange={(event) => {
setObsidianPath(event.target.value)
setObsidianResult(undefined)
setObsidianError(undefined)
}}
/>
</label>
)}
<p>{t('mcp.obsidian.scopeHelp')}</p>
<p>{t('mcp.obsidian.savedScope', { path: snapshot?.obsidian?.vaultPath || t('mcp.obsidian.all') })}</p>
<div className="capability-card__actions">
<button className="secondary-button" disabled={Boolean(busy) || !snapshot} onClick={() => void configureObsidian('select')} type="button">
{t('mcp.obsidian.select')}
</button>
<button className="secondary-button" disabled={Boolean(busy) || !snapshot} onClick={() => void configureObsidian('test')} type="button">
{busy === 'obsidian:test' ? t('mcp.obsidian.testing') : t('mcp.obsidian.test')}
</button>
<button className="primary-button" disabled={Boolean(busy) || !snapshot} onClick={() => void configureObsidian('save')} type="button">
{busy === 'obsidian:save' ? t('mcp.editor.saving') : t('mcp.editor.save')}
</button>
</div>
{obsidianError && <p className="settings-warning" id="obsidian-settings-error" role="alert">{obsidianError}</p>}
{obsidianResult && (
<div className="capability-diagnostic__result" role="status">
<strong>{t('mcp.obsidian.result', { vaults: obsidianResult.vaults.length, tools: obsidianResult.toolCount })}</strong>
{obsidianResult.vaults.length === 0 && <p>{t('mcp.obsidian.empty')}</p>}
{obsidianResult.vaults.map((vault) => <p key={vault.id}>{vault.name}: <code>{vault.path}</code></p>)}
</div>
)}
</div>
)}
{!featureAvailable && (
<p className="computer-capability-risk">
<CircleAlert aria-hidden="true" size={13} />
{computerCapability
? t('mcp.browser.unsupported')
: t('mcp.builtin.featureDisabled')}
</p>
)}
{computerCapability && (
<>
<p className="computer-capability-risk">
<CircleAlert aria-hidden="true" size={13} />
{computerCapability.riskSummary}
</p>
<div className="capability-diagnostic">
<button
aria-label={t('mcp.browser.diagnoseAriaLabel')}
className="secondary-button"
disabled={Boolean(busy)}
onClick={() =>
void diagnose(computerCapability.id)
}
type="button"
>
<RefreshCw size={13} />
{busy ===
`diagnose:${computerCapability.id}`
? t('mcp.computer.diagnosing')
: t('mcp.computer.diagnose')}
</button>
{diagnostics[computerCapability.id] && (
<div
aria-live="polite"
className="capability-diagnostic__result"
>
<strong>
{t('mcp.computer.result', {
status:
diagnosticStatusLabels[
diagnostics[computerCapability.id]!
.status
]
})}
</strong>
{diagnostics[
computerCapability.id
]!.checks.map((check) => (
<p key={check.id}>
{check.summary}
{check.remedy
? t('mcp.computer.remedy', {
remedy: check.remedy
})
: ''}
</p>
))}
</div>
)}
</div>
</>
)}
<div className="runtime-assignments">
<small>{t('mcp.builtin.assignedTo')}</small>
{configurableMcpTargets.map((target) => {
const unsupported =
!server.supportedAssignments.some(
(supportedTarget) =>
supportedTarget === target
)
return (
<label
className={
unsupported
? 'runtime-assignment--unsupported'
: undefined
}
key={target}
title={
unsupported
? t('mcp.builtin.runtimeUnsupported')
: undefined
}
>
<input
aria-label={
unsupported
? t(
'mcp.builtin.runtimeAssignmentUnsupportedAriaLabel',
{
name: server.name,
runtime: runtimeLabels[target]
}
)
: undefined
}
checked={
!unsupported &&
state.assignments.includes(target)
}
disabled={Boolean(busy) || unsupported}
onChange={(event) =>
updateBuiltinAssignment(
server.id,
state.assignments,
target,
event.target.checked
)
}
type="checkbox"
/>
{runtimeLabels[target]}
{unsupported
? t('mcp.builtin.unsupportedSuffix')
: ''}
</label>
)
})}
</div>
<button
aria-controls={panelId}
aria-expanded={expanded}
aria-label={t(
expanded
? 'mcp.builtin.collapseServer'
: 'mcp.builtin.expandServer',
{ name: server.name }
)}
className="secondary-button builtin-mcp-card__details"
onClick={() => toggleItem(expansionId)}
type="button"
>
{t('mcp.builtin.toolCount', {
count: server.tools.length
})}
<ChevronDown
aria-hidden="true"
className={
expanded
? 'mcp-server-card__chevron mcp-server-card__chevron--expanded'
: 'mcp-server-card__chevron'
}
size={15}
/>
</button>
{expanded && (
<div
className="builtin-mcp-card__tools"
id={panelId}
>
<section
aria-label={t('mcp.builtin.toolsAriaLabel', {
name: server.name
})}
className="mcp-server-tools"
>
<div className="mcp-server-tools__heading">
<strong>{t('mcp.builtin.tools')}</strong>
<small>
{t('mcp.profiles.count', {
count: server.tools.length
})}
</small>
</div>
<ul>
{server.tools.map((tool) => (
<li key={tool.name}>
<div>
<code>{tool.name}</code>
<span className="builtin-tool-badge">
{tool.access === 'write'
? t('mcp.builtin.write')
: t('mcp.builtin.readOnly')}
</span>
</div>
<p>{tool.description}</p>
</li>
))}
</ul>
</section>
</div>
)}
</article>
)
})}
</div>
</section>
)}
{activeTab === 'model-tools' && (
<div className="mcp-tool-section">
<div className="mcp-subsection-heading">
<div>
<Wrench size={15} />
<strong>{t('mcp.modelTools.title')}</strong>
</div>
<small>
{t('mcp.modelTools.groupCount', {
count: builtinModelToolGroups.length
})}
</small>
</div>
<div className="mcp-server-list">
{builtinModelToolGroups
.filter((group) => group.id === 'web')
.map((group) => {
const expansionId = `model-tools:${group.id}`
const expanded = expandedItemIds.has(expansionId)
const panelId = `model-tool-group-${group.id}`
return (
<article className="mcp-server-card" key={group.id}>
<button
aria-controls={panelId}
aria-expanded={expanded}
aria-label={t(
expanded
? 'mcp.modelTools.collapseGroup'
: 'mcp.modelTools.expandGroup',
{ name: t('mcp.webSearch.title') }
)}
className="mcp-server-card__toggle"
onClick={() => toggleItem(expansionId)}
type="button"
>
<div>
<strong>{t('mcp.webSearch.title')}</strong>
<small>{t('mcp.webSearch.subtitle')}</small>
</div>
<span className="mcp-server-card__summary">
{t('mcp.builtin.toolCount', {
count: group.tools.length
})}
<ChevronDown
aria-hidden="true"
className={
expanded
? 'mcp-server-card__chevron mcp-server-card__chevron--expanded'
: 'mcp-server-card__chevron'
}
size={15}
/>
</span>
</button>
{expanded && (
<div className="mcp-server-card__body" id={panelId}>
<label className="toggle-row">
<input
aria-label={t('mcp.webSearch.enableAriaLabel')}
checked={webSearch.enabled}
disabled={Boolean(busy)}
onChange={(event) =>
void run('web-search:toggle', () =>
window.goodbuddy.capabilities.setWebSearchEnabled?.(
event.target.checked
) ??
Promise.reject(
new Error(t('mcp.webSearch.unsupported'))
)
)
}
role="switch"
type="checkbox"
/>
<span>
{webSearch.enabled
? t('mcp.webSearch.enabled')
: t('mcp.webSearch.disabled')}
</span>
</label>
<p>{t('mcp.webSearch.description')}</p>
<p className="computer-capability-risk">
<CircleAlert aria-hidden="true" size={13} />
{t('mcp.webSearch.privacy')}
</p>
<div className="capability-card__actions">
<button
className="secondary-button"
disabled={Boolean(busy)}
onClick={() => void testDirectModelWebSearch()}
type="button"
>
<FlaskConical aria-hidden="true" size={13} />
{busy === 'test:web-search'
? t('mcp.webSearch.testing')
: t('mcp.webSearch.test')}
</button>
</div>
{webSearchTestResult && (
<div
aria-label={t(
'mcp.webSearch.resultAriaLabel'
)}
className="capability-diagnostic__result"
>
<strong>
{t('mcp.webSearch.result', {
duration:
webSearchTestResult.durationMs
})}
</strong>
<p>{webSearchTestResult.preview}</p>
</div>
)}
<section
aria-label={t(
'mcp.webSearch.toolsAriaLabel'
)}
className="mcp-server-tools"
>
<div className="mcp-server-tools__heading">
<strong>{t('mcp.builtin.tools')}</strong>
<small>
{t('mcp.profiles.count', {
count: group.tools.length
})}
</small>
</div>
<ul>
{group.tools.map((tool) => (
<li key={tool.name}>
<div>
<span className="mcp-server-tool__identity">
<strong>{tool.displayName}</strong>
<code>{tool.name}</code>
</span>
<span className="builtin-tool-badge">
{t('mcp.builtin.readOnly')}
</span>
</div>
<p>{tool.description}</p>
</li>
))}
</ul>
</section>
</div>
)}
</article>
)
})}
{builtinModelToolGroups
.filter((group) => group.id !== 'web')
.map((group) => {
const expansionId = `model-tools:${group.id}`
const expanded = expandedItemIds.has(expansionId)
const panelId = `model-tool-group-${group.id}`
const groupName = group.name
const groupDescription = group.description
return (
<article className="mcp-server-card" key={group.id}>
<button
aria-controls={panelId}
aria-expanded={expanded}
aria-label={t(
expanded
? 'mcp.modelTools.collapseGroup'
: 'mcp.modelTools.expandGroup',
{ name: groupName }
)}
className="mcp-server-card__toggle"
onClick={() => toggleItem(expansionId)}
type="button"
>
<div>
<strong>{groupName}</strong>
<small>
{t('mcp.modelTools.summary')}
</small>
</div>
<span className="mcp-server-card__summary">
{t('mcp.builtin.toolCount', {
count: group.tools.length
})}
<ChevronDown
aria-hidden="true"
className={
expanded
? 'mcp-server-card__chevron mcp-server-card__chevron--expanded'
: 'mcp-server-card__chevron'
}
size={15}
/>
</span>
</button>
{expanded && (
<div className="mcp-server-card__body" id={panelId}>
<p>{groupDescription}</p>
<section
aria-label={t('mcp.builtin.toolsAriaLabel', {
name: groupName
})}
className="mcp-server-tools"
>
<div className="mcp-server-tools__heading">
<strong>{t('mcp.builtin.tools')}</strong>
<small>
{t('mcp.profiles.count', {
count: group.tools.length
})}
</small>
</div>
<ul>
{group.tools.map((tool) => (
<li key={tool.name}>
<div>
<span className="mcp-server-tool__identity">
<strong>{tool.displayName}</strong>
<code>{tool.name}</code>
</span>
<span className="builtin-tool-badge">
{tool.access === 'write'
? t('mcp.builtin.write')
: t('mcp.builtin.readOnly')}
</span>
</div>
<p>{tool.description}</p>
</li>
))}
</ul>
</section>
</div>
)}
</article>
)
})}
</div>
</div>
)}
{editor &&
createPortal(
<div
className="mcp-editor-backdrop"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
event.preventDefault()
closeEditor()
}
}}
>
<div
aria-labelledby="mcp-editor-title"
aria-modal="true"
className="mcp-editor"
onKeyDown={handleEditorKeyDown}
ref={editorDialogRef}
role="dialog"
>
<div className="mcp-editor__header">
<strong id="mcp-editor-title">
{editor.id
? t('mcp.editor.editTitle')
: t('mcp.editor.addTitle')}
</strong>
<button
aria-label={t('mcp.editor.closeAriaLabel')}
className="icon-button"
disabled={busy === 'save'}
onClick={closeEditor}
type="button"
>
<X size={16} />
</button>
</div>
{error && (
<p className="settings-warning" role="alert">
{error}
</p>
)}
<label className="field">
<span>{t('mcp.editor.name')}</span>
<input
onChange={(event) =>
setEditor({ ...editor, name: event.target.value })
}
ref={editorNameRef}
value={editor.name}
/>
</label>
<label className="field">
<span>{t('mcp.editor.description')}</span>
<input
onChange={(event) =>
setEditor({
...editor,
description: event.target.value
})
}
value={editor.description}
/>
</label>
<label className="field">
<span>{t('mcp.editor.transport')}</span>
<select
onChange={(event) =>
setEditor({
...editor,
transport: event.target.value as McpTransport
})
}
value={editor.transport}
>
<option value="stdio">{t('mcp.editor.stdio')}</option>
<option value="http">Streamable HTTP</option>
<option value="sse">{t('mcp.editor.sse')}</option>
</select>
</label>
{editor.transport === 'stdio' ? (
<>
<label className="field">
<span>{t('mcp.editor.command')}</span>
<input
aria-label={t('mcp.editor.commandAriaLabel')}
onChange={(event) =>
setEditor({
...editor,
command: event.target.value
})
}
placeholder={t('mcp.editor.commandPlaceholder')}
value={editor.command}
/>
</label>
<label className="field">
<span>{t('mcp.editor.args')}</span>
<textarea
aria-label={t('mcp.editor.argsAriaLabel')}
onChange={(event) =>
setEditor({ ...editor, args: event.target.value })
}
placeholder={'-y\n@modelcontextprotocol/server-filesystem\nC:\\Workspace'}
rows={4}
value={editor.args}
/>
</label>
</>
) : (
<>
<label className="field">
<span>Server URL</span>
<input
inputMode="url"
onChange={(event) =>
setEditor({ ...editor, url: event.target.value })
}
placeholder="https://mcp.example.com/mcp"
value={editor.url}
/>
</label>
<label className="field">
<span>Bearer Token</span>
<input
autoComplete="off"
onChange={(event) =>
setEditor({
...editor,
token: event.target.value,
clearToken: false
})
}
placeholder={
editor.id
? t('mcp.editor.savedTokenPlaceholder')
: t('mcp.editor.optional')
}
type="password"
value={editor.token}
/>
</label>
{editor.id && (
<label className="check-field">
<input
checked={editor.clearToken}
onChange={(event) =>
setEditor({
...editor,
token: '',
clearToken: event.target.checked
})
}
type="checkbox"
/>
<span>{t('mcp.editor.clearToken')}</span>
</label>
)}
</>
)}
<label className="toggle-row">
<input
checked={editor.enabled}
onChange={(event) =>
setEditor({
...editor,
enabled: event.target.checked
})
}
role="switch"
type="checkbox"
/>
<span>{t('mcp.editor.enable')}</span>
</label>
<label className="toggle-row">
<input
aria-label={t('mcp.editor.allowDynamicTools')}
checked={editor.allowDynamicTools}
onChange={(event) =>
setEditor({
...editor,
allowDynamicTools: event.target.checked
})
}
role="switch"
type="checkbox"
/>
<span>{t('mcp.editor.allowDynamicTools')}</span>
</label>
<p className="settings-notice">
{t('mcp.editor.allowDynamicToolsDescription')}
</p>
<div className="runtime-assignments">
<small>{t('mcp.editor.assignTo')}</small>
{configurableMcpTargets.map(
(target) => (
<label key={target}>
<input
checked={editor.assignments.includes(target)}
onChange={(event) =>
updateAssignment(target, event.target.checked)
}
type="checkbox"
/>
{runtimeLabels[target]}
</label>
)
)}
</div>
<div className="mcp-editor__actions">
<button
className="secondary-button"
disabled={busy === 'save'}
onClick={closeEditor}
type="button"
>
{t('mcp.editor.cancel')}
</button>
<button
className="primary-button"
disabled={busy === 'save'}
onClick={() => void save()}
type="button"
>
{busy === 'save'
? t('mcp.editor.saving')
: t('mcp.editor.save')}
</button>
</div>
</div>
</div>,
document.body
)}
{activeTab === 'custom' && (
<>
<div className="mcp-subsection-heading">
<div>
<Network size={15} />
<strong>{t('mcp.custom.title')}</strong>
</div>
<small>
{t('mcp.custom.count', {
count: snapshot?.mcpServers.length ?? 0
})}
</small>
</div>
<div className="capability-list">
{snapshot?.mcpServers.length === 0 && !editor && (
<p className="settings-empty">{t('mcp.custom.empty')}</p>
)}
{snapshot?.mcpServers.map((server) => {
const result = testResults[server.id]
const expansionId = `custom:${server.id}`
const expanded = expandedItemIds.has(expansionId)
const panelId = `mcp-custom-server-${server.id}`
return (
<article className="mcp-server-card" key={server.id}>
<div className="mcp-server-card__header">
<button
aria-controls={panelId}
aria-expanded={expanded}
aria-label={t(
expanded
? 'mcp.custom.collapseServer'
: 'mcp.custom.expandServer',
{ name: server.name }
)}
className="mcp-server-card__toggle"
onClick={() => toggleItem(expansionId)}
type="button"
>
<div>
<strong>{server.name}</strong>
<small>
{server.transport.toUpperCase()} MCP Server ·{' '}
{server.enabled
? t('mcp.custom.enabled')
: t('mcp.custom.disabled')}
{server.secretConfigured
? t('mcp.custom.encryptedToken')
: ''}
{server.allowDynamicTools
? t('mcp.custom.dynamicToolsEnabled')
: ''}
</small>
</div>
<span className="mcp-server-card__summary">
{result
? t('mcp.custom.contentCounts', {
tools: result.toolCount,
prompts: result.promptCount ?? 0,
resources: result.resourceCount ?? 0
})
: t('mcp.custom.toolsUndetected')}
<ChevronDown
aria-hidden="true"
className={
expanded
? 'mcp-server-card__chevron mcp-server-card__chevron--expanded'
: 'mcp-server-card__chevron'
}
size={15}
/>
</span>
</button>
<div className="capability-card__actions">
<button
aria-label={t('mcp.custom.testAriaLabel', {
name: server.name
})}
disabled={Boolean(busy)}
onClick={() => void test(server)}
type="button"
>
<FlaskConical size={13} />
{t('mcp.custom.test')}
</button>
<button
aria-label={t('mcp.custom.editAriaLabel', {
name: server.name
})}
disabled={Boolean(busy) || Boolean(editor)}
onClick={(event) =>
openEditor(
editorFromServer(server),
event.currentTarget
)
}
type="button"
>
<Pencil size={13} />
{t('mcp.custom.edit')}
</button>
<button
aria-label={t('mcp.custom.deleteAriaLabel', {
name: server.name
})}
disabled={Boolean(busy)}
onClick={() =>
void run(`remove:${server.id}`, () =>
window.goodbuddy.capabilities.removeMcpServer(
server.id
)
)
}
type="button"
>
<Trash2 size={13} />
{t('mcp.custom.delete')}
</button>
</div>
</div>
{expanded && (
<div className="mcp-server-card__body" id={panelId}>
{server.description && <p>{server.description}</p>}
<code>
{server.transport === 'stdio'
? [server.command, ...server.args].join(' ')
: server.url}
</code>
<div className="runtime-assignments">
<small>{t('mcp.custom.assigned')}</small>
<span>
{server.assignments
.map((target) => runtimeLabels[target])
.join(t('mcp.custom.assignmentSeparator')) ||
t('mcp.custom.none')}
</span>
</div>
{result ? (
<>
<p>
{result.dynamicToolsSupported
? t('mcp.custom.dynamicToolsSupported')
: t('mcp.custom.dynamicToolsUnsupported')}
</p>
<section
aria-label={t('mcp.builtin.toolsAriaLabel', {
name: server.name
})}
className="mcp-server-tools"
>
<div className="mcp-server-tools__heading">
<strong>
{result.serverName || server.name}
{result.serverVersion
? ` ${result.serverVersion}`
: ''}
</strong>
<small>
{t('mcp.builtin.toolCount', {
count: result.toolCount
})}
</small>
</div>
{result.tools.length > 0 ? (
<ul>
{result.tools.map((tool) => (
<li key={tool.name}>
<div>
<code>{tool.name}</code>
</div>
{tool.description && (
<p>{tool.description}</p>
)}
</li>
))}
</ul>
) : (
<p className="settings-empty">
{t('mcp.custom.noTools')}
</p>
)}
</section>
<section
aria-label={t('mcp.custom.promptsAriaLabel', {
name: server.name
})}
className="mcp-server-tools"
>
<div className="mcp-server-tools__heading">
<strong>{t('mcp.custom.prompts')}</strong>
<small>
{result.promptsSupported
? t('mcp.custom.promptCount', {
count: result.promptCount ?? 0
})
: t('mcp.custom.notSupported')}
</small>
</div>
{result.prompts?.length ? (
<ul>
{result.prompts.map((prompt) => (
<li key={prompt.name}>
<div>
<code>{prompt.name}</code>
</div>
{prompt.description && (
<p>{prompt.description}</p>
)}
{prompt.arguments.length > 0 && (
<small>
{t('mcp.custom.promptArguments', {
names: prompt.arguments
.map((argument) =>
argument.required
? `${argument.name}*`
: argument.name
)
.join(', ')
})}
</small>
)}
</li>
))}
</ul>
) : result.promptsSupported ? (
<p className="settings-empty">
{t('mcp.custom.noPrompts')}
</p>
) : null}
</section>
<section
aria-label={t('mcp.custom.resourcesAriaLabel', {
name: server.name
})}
className="mcp-server-tools"
>
<div className="mcp-server-tools__heading">
<strong>{t('mcp.custom.resources')}</strong>
<small>
{result.resourcesSupported
? t('mcp.custom.resourceCount', {
count: result.resourceCount ?? 0
})
: t('mcp.custom.notSupported')}
</small>
</div>
{result.resources?.length ? (
<ul>
{result.resources.map((resource) => (
<li key={`${resource.uri}\0${resource.name}`}>
<div>
<strong>{resource.name}</strong>
{resource.mimeType && (
<small>{resource.mimeType}</small>
)}
</div>
<code>{resource.uri}</code>
{resource.description && (
<p>{resource.description}</p>
)}
</li>
))}
</ul>
) : result.resourcesSupported ? (
<p className="settings-empty">
{t('mcp.custom.noResources')}
</p>
) : null}
</section>
</>
) : (
<p className="settings-empty">
{t('mcp.custom.testHelp')}
</p>
)}
</div>
)}
</article>
)
})}
</div>
</>
)}
</section>
</>
)
}