Files
goodbuddy/src/renderer/src/McpSettingsSection.tsx
T

1792 lines
65 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,
builtinModelTools
} 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'
]
const imageTools = builtinModelTools.filter((tool) => tool.group === 'image')
const imageExpansionId = 'builtin:image-generation'
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>
<button
aria-controls="mcp-server-tools-image-generation"
aria-expanded={expandedItemIds.has(imageExpansionId)}
aria-label={t(
expandedItemIds.has(imageExpansionId)
? 'mcp.builtin.collapseServer'
: 'mcp.builtin.expandServer',
{ name: t('conversationImages.title') }
)}
className="secondary-button builtin-mcp-card__details"
onClick={() => toggleItem(imageExpansionId)}
type="button"
>
{t('mcp.builtin.toolCount', { count: imageTools.length })}
<ChevronDown
aria-hidden="true"
className={
expandedItemIds.has(imageExpansionId)
? 'mcp-server-card__chevron mcp-server-card__chevron--expanded'
: 'mcp-server-card__chevron'
}
size={15}
/>
</button>
{expandedItemIds.has(imageExpansionId) && (
<div
className="builtin-mcp-card__tools"
id="mcp-server-tools-image-generation"
>
<section
aria-label={t('mcp.builtin.toolsAriaLabel', {
name: t('conversationImages.title')
})}
className="mcp-server-tools"
>
<div className="mcp-server-tools__heading">
<strong>{t('mcp.builtin.tools')}</strong>
<small>
{t('mcp.profiles.count', { count: imageTools.length })}
</small>
</div>
<ul>
{imageTools.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>{t(`conversationImages.tools.${tool.name}`)}</p>
</li>
))}
</ul>
</section>
</div>
)}
</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 !== '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>
</>
)
}