feat: connect local Obsidian vaults to conversations
Users can now read, search, and update existing local Obsidian notes through built-in tools without configuring an external MCP server or Obsidian plugin. Add vault discovery, an optional folder scope, and independent test, save, enablement, and runtime assignment controls in MCP settings. Bundle MCPVault 0.16.0 and its runtime dependencies, exposing 19 shared tools to direct models and managed local OpenCode and Continue conversations. Obsidian is disabled by default. Ask remains read-only; Execute permits writes. Scope and assignment changes revoke existing Obsidian grants. DeepSeek Harness and remote execution remain unsupported. Validate the isolated staged snapshot with its own dependency installation: typecheck, lint, production build, and 540 tests across 14 files passed. No paid model calls were repeated. Physical-offline, installer, and other platform acceptance remain pending as documented in the feature record. Release note: Connect local Obsidian vaults in MCP settings to find notes and write conversation results back to them. Test all registered vaults or a chosen folder before enabling access; no Obsidian plugin is required.
This commit is contained in:
@@ -731,39 +731,55 @@ const api: DesktopApi = {
|
||||
getSnapshot: vi.fn(async () => ({
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
obsidian: { vaultPath: "" },
|
||||
})),
|
||||
importSkill: vi.fn(async () => ({
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
obsidian: { vaultPath: "" },
|
||||
})),
|
||||
removeSkill: vi.fn(async () => ({
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
obsidian: { vaultPath: "" },
|
||||
})),
|
||||
setSkillEnabled: vi.fn(async () => ({
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
obsidian: { vaultPath: "" },
|
||||
})),
|
||||
setSkillAssignments: vi.fn(async () => ({
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
obsidian: { vaultPath: "" },
|
||||
})),
|
||||
setBuiltinMcpServerEnabled: vi.fn(async () => ({
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
obsidian: { vaultPath: "" },
|
||||
})),
|
||||
setBuiltinMcpServerAssignments: vi.fn(async () => ({
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
obsidian: { vaultPath: "" },
|
||||
})),
|
||||
saveMcpServer: vi.fn(async () => ({
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
obsidian: { vaultPath: "" },
|
||||
})),
|
||||
removeMcpServer: vi.fn(async () => ({
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
obsidian: { vaultPath: "" },
|
||||
})),
|
||||
updateObsidianSettings: vi.fn(async (obsidian) => ({
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
obsidian,
|
||||
})),
|
||||
testObsidianConnection: vi.fn(async () => ({ vaults: [], toolCount: 0 })),
|
||||
selectObsidianVault: vi.fn(async () => null),
|
||||
testMcpServer: vi.fn(async () => ({
|
||||
dynamicToolsSupported: false,
|
||||
toolCount: 0,
|
||||
|
||||
@@ -5,6 +5,149 @@ import i18n from './i18n'
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
|
||||
|
||||
it('defaults Obsidian off and tests draft scope without saving or enabling it', async () => {
|
||||
const snapshot = { mcpServers: [], skills: [], obsidian: { vaultPath: '' } }
|
||||
const getSnapshot = vi.fn().mockResolvedValue(snapshot)
|
||||
const updateObsidianSettings = vi.fn(async (input) => ({ ...snapshot, obsidian: input }))
|
||||
const testObsidianConnection = vi.fn().mockResolvedValue({
|
||||
vaults: [{ id: 'work', name: 'Work', path: '/notes/work' }], toolCount: 24
|
||||
})
|
||||
const selectObsidianVault = vi.fn().mockResolvedValue('/notes/work')
|
||||
const setBuiltinMcpServerEnabled = vi.fn()
|
||||
vi.stubGlobal('goodbuddy', { capabilities: {
|
||||
getSnapshot, updateObsidianSettings, testObsidianConnection,
|
||||
selectObsidianVault, setBuiltinMcpServerEnabled
|
||||
} })
|
||||
render(<McpSettingsSection onOpenImageModelSettings={vi.fn()} />)
|
||||
const testButton = screen.getByRole('button', { name: i18n.t('integrations:mcp.obsidian.test') })
|
||||
await waitFor(() => expect(testButton).toBeEnabled())
|
||||
expect(screen.getByRole('switch', { name: i18n.t('integrations:mcp.builtin.enableAriaLabel', { name: 'Obsidian' }) })).not.toBeChecked()
|
||||
fireEvent.click(testButton)
|
||||
await waitFor(() => {
|
||||
expect(testObsidianConnection).toHaveBeenCalledWith({ vaultPath: '' })
|
||||
expect(screen.getByText('/notes/work')).toBeVisible()
|
||||
expect(testButton).toBeEnabled()
|
||||
})
|
||||
expect(updateObsidianSettings).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: i18n.t('integrations:mcp.obsidian.select') }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name: i18n.t('integrations:mcp.obsidian.path') })).toHaveValue('/notes/work')
|
||||
expect(testButton).toBeEnabled()
|
||||
})
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: i18n.t('integrations:mcp.editor.save') }))
|
||||
await waitFor(() => {
|
||||
expect(updateObsidianSettings).toHaveBeenCalledWith({ vaultPath: '/notes/work' })
|
||||
expect(screen.getByText(i18n.t('integrations:mcp.obsidian.savedScope', { path: '/notes/work' }))).toBeVisible()
|
||||
expect(testButton).toBeEnabled()
|
||||
})
|
||||
fireEvent.change(screen.getByRole('combobox', { name: i18n.t('integrations:mcp.obsidian.scope') }), { target: { value: 'all' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: i18n.t('integrations:mcp.editor.save') }))
|
||||
await waitFor(() => {
|
||||
expect(updateObsidianSettings).toHaveBeenLastCalledWith({ vaultPath: '' })
|
||||
expect(testButton).toBeEnabled()
|
||||
})
|
||||
expect(setBuiltinMcpServerEnabled).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['loaded', 'saved'] as const)('keeps a %s specified vault selected when its draft path is cleared', async (source) => {
|
||||
const snapshot = { mcpServers: [], skills: [], obsidian: { vaultPath: '/notes/original' } }
|
||||
const updateObsidianSettings = vi.fn(async (input) => ({ ...snapshot, obsidian: input }))
|
||||
const testObsidianConnection = vi.fn().mockResolvedValue({
|
||||
vaults: [{ id: 'work', name: 'Work', path: '/notes/discovered' }], toolCount: 24
|
||||
})
|
||||
vi.stubGlobal('goodbuddy', { capabilities: {
|
||||
getSnapshot: vi.fn().mockResolvedValue(snapshot),
|
||||
updateObsidianSettings, testObsidianConnection
|
||||
} })
|
||||
render(<McpSettingsSection onOpenImageModelSettings={vi.fn()} />)
|
||||
const input = await screen.findByRole('textbox', { name: i18n.t('integrations:mcp.obsidian.path') })
|
||||
const scope = screen.getByRole('combobox', { name: i18n.t('integrations:mcp.obsidian.scope') })
|
||||
const saveButton = screen.getByRole('button', { name: i18n.t('integrations:mcp.editor.save') })
|
||||
const testButton = screen.getByRole('button', { name: i18n.t('integrations:mcp.obsidian.test') })
|
||||
const savedPath = source === 'saved' ? '/notes/saved' : '/notes/original'
|
||||
if (source === 'saved') {
|
||||
fireEvent.change(input, { target: { value: savedPath } })
|
||||
fireEvent.click(saveButton)
|
||||
await waitFor(() => {
|
||||
expect(updateObsidianSettings).toHaveBeenCalledWith({ vaultPath: savedPath })
|
||||
expect(screen.getByText(i18n.t('integrations:mcp.obsidian.savedScope', { path: savedPath }))).toBeVisible()
|
||||
expect(saveButton).toBeEnabled()
|
||||
})
|
||||
updateObsidianSettings.mockClear()
|
||||
}
|
||||
fireEvent.click(testButton)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('/notes/discovered')).toBeVisible()
|
||||
expect(testButton).toBeEnabled()
|
||||
})
|
||||
testObsidianConnection.mockClear()
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
expect(scope).toHaveValue('folder')
|
||||
expect(input).toBeVisible()
|
||||
expect(input).toHaveValue('')
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
for (const button of [saveButton, testButton]) {
|
||||
fireEvent.click(button)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(i18n.t('integrations:mcp.obsidian.folderRequired'))
|
||||
expect(scope).toHaveValue('folder')
|
||||
expect(input).toBeVisible()
|
||||
expect(input).toHaveValue('')
|
||||
expect(screen.getByText(i18n.t('integrations:mcp.obsidian.savedScope', { path: savedPath }))).toBeVisible()
|
||||
expect(button).toBeEnabled()
|
||||
})
|
||||
}
|
||||
expect(updateObsidianSettings).not.toHaveBeenCalled()
|
||||
expect(testObsidianConnection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears Obsidian connection results when changing scope', async () => {
|
||||
const testObsidianConnection = vi.fn().mockResolvedValue({
|
||||
vaults: [{ id: 'work', name: 'Work', path: '/notes/discovered' }], toolCount: 24
|
||||
})
|
||||
vi.stubGlobal('goodbuddy', { capabilities: {
|
||||
getSnapshot: vi.fn().mockResolvedValue({ mcpServers: [], skills: [], obsidian: { vaultPath: '/notes/work' } }),
|
||||
testObsidianConnection
|
||||
} })
|
||||
render(<McpSettingsSection onOpenImageModelSettings={vi.fn()} />)
|
||||
const testButton = screen.getByRole('button', { name: i18n.t('integrations:mcp.obsidian.test') })
|
||||
await waitFor(() => expect(testButton).toBeEnabled())
|
||||
fireEvent.click(testButton)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('/notes/discovered')).toBeVisible()
|
||||
expect(testButton).toBeEnabled()
|
||||
})
|
||||
fireEvent.change(screen.getByRole('combobox', { name: i18n.t('integrations:mcp.obsidian.scope') }), { target: { value: 'all' } })
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('textbox', { name: i18n.t('integrations:mcp.obsidian.path') })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('preserves the Obsidian draft after failures and folder-picker cancellation', async () => {
|
||||
const selectObsidianVault = vi.fn().mockResolvedValue(null)
|
||||
const updateObsidianSettings = vi.fn().mockRejectedValue(new Error('Disk unavailable'))
|
||||
vi.stubGlobal('goodbuddy', { capabilities: {
|
||||
getSnapshot: vi.fn().mockResolvedValue({ mcpServers: [], skills: [], obsidian: { vaultPath: '/notes/original' } }),
|
||||
selectObsidianVault, updateObsidianSettings
|
||||
} })
|
||||
render(<McpSettingsSection onOpenImageModelSettings={vi.fn()} />)
|
||||
const input = await screen.findByRole('textbox', { name: i18n.t('integrations:mcp.obsidian.path') })
|
||||
fireEvent.change(input, { target: { value: '/notes/draft' } })
|
||||
const selectButton = screen.getByRole('button', { name: i18n.t('integrations:mcp.obsidian.select') })
|
||||
fireEvent.click(selectButton)
|
||||
await waitFor(() => {
|
||||
expect(selectObsidianVault).toHaveBeenCalledOnce()
|
||||
expect(selectButton).toBeEnabled()
|
||||
expect(input).toHaveValue('/notes/draft')
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: i18n.t('integrations:mcp.editor.save') }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Disk unavailable')
|
||||
expect(input).toHaveValue('/notes/draft')
|
||||
expect(selectButton).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('removes the redundant MCP title and places the custom action in content', async () => {
|
||||
const getSnapshot = vi.fn().mockResolvedValue({ mcpServers: [], skills: [] })
|
||||
vi.stubGlobal('goodbuddy', { capabilities: { getSnapshot } })
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
Wrench,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from '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'
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
McpServerSummary,
|
||||
McpServerTestResult,
|
||||
McpTransport,
|
||||
ObsidianConnectionTestResult,
|
||||
RuntimeTarget,
|
||||
WebSearchTestResult
|
||||
} from '../../shared/capability-contracts'
|
||||
@@ -119,6 +120,12 @@ export function McpSettingsSection({
|
||||
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>()
|
||||
@@ -310,6 +317,40 @@ export function McpSettingsSection({
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -373,13 +414,13 @@ export function McpSettingsSection({
|
||||
trapTabFocus(event, editorDialogRef.current)
|
||||
}
|
||||
|
||||
const computerCapabilities = snapshot?.computerCapabilities ?? []
|
||||
const desktopCapabilities = computerCapabilities.filter(
|
||||
const computerCapabilities = useMemo(() => snapshot?.computerCapabilities ?? [], [snapshot?.computerCapabilities])
|
||||
const desktopCapabilities = useMemo(() => computerCapabilities.filter(
|
||||
(capability) => capability.id !== 'host-browser-control'
|
||||
)
|
||||
const builtinMcpStates = new Map(
|
||||
), [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,
|
||||
@@ -614,7 +655,7 @@ export function McpSettingsSection({
|
||||
const panelId = `mcp-server-tools-${server.id}`
|
||||
const state = builtinMcpStates.get(server.id) ?? {
|
||||
id: server.id,
|
||||
enabled: server.id !== 'builtin-browser',
|
||||
enabled: server.id !== 'builtin-browser' && server.id !== 'obsidian',
|
||||
assignments:
|
||||
[...server.supportedAssignments] as CapabilityAssignments
|
||||
}
|
||||
@@ -649,6 +690,8 @@ export function McpSettingsSection({
|
||||
? 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'
|
||||
@@ -681,7 +724,63 @@ export function McpSettingsSection({
|
||||
: t('mcp.builtin.disabled')}
|
||||
</span>
|
||||
</label>
|
||||
<p>{server.description}</p>
|
||||
<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} />
|
||||
|
||||
@@ -296,6 +296,7 @@ const capabilitySnapshot = {
|
||||
}
|
||||
],
|
||||
mcpServers: [] as CapabilitySnapshot['mcpServers'],
|
||||
obsidian: { vaultPath: '' },
|
||||
webSearch: {
|
||||
provider: 'exa' as const,
|
||||
enabled: true,
|
||||
@@ -952,6 +953,12 @@ describe('SettingsPanel runtime files', () => {
|
||||
})),
|
||||
setWebSearchEnabled,
|
||||
testWebSearch,
|
||||
updateObsidianSettings: vi.fn(async (obsidian) => ({
|
||||
...capabilitySnapshot,
|
||||
obsidian
|
||||
})),
|
||||
testObsidianConnection: vi.fn(async () => ({ vaults: [], toolCount: 0 })),
|
||||
selectObsidianVault: vi.fn(async () => null),
|
||||
setComputerCapabilityEnabled,
|
||||
setComputerCapabilityBrowserProfile: vi.fn(
|
||||
async () => capabilitySnapshot
|
||||
@@ -5751,7 +5758,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getAllByRole('button', {
|
||||
name: /(?:展开|收起)服务器 (?:知识库|笔记|GoodBuddy 配置|内置浏览器)/u
|
||||
name: /(?:展开|收起)服务器 (?:知识库|笔记|Obsidian|GoodBuddy 配置|内置浏览器)/u
|
||||
})
|
||||
).toHaveLength(builtinMcpServers.length)
|
||||
expect(
|
||||
|
||||
@@ -154,6 +154,22 @@ export const integrations = {
|
||||
save: 'Save channel settings'
|
||||
},
|
||||
mcp: {
|
||||
obsidian: {
|
||||
access: 'Read and write',
|
||||
description: 'Read, search, and manage notes, folders, tags, and attachments in local Obsidian vaults.',
|
||||
scope: 'Obsidian vault scope',
|
||||
all: 'All registered local vaults',
|
||||
folder: 'Specified folder',
|
||||
path: 'Obsidian vault folder',
|
||||
scopeHelp: 'All vaults uses the local Obsidian registry. A specified folder selects one vault. When multiple vaults are available, tools require an explicit vault ID.',
|
||||
savedScope: 'Saved scope: {{path}}',
|
||||
select: 'Choose vault folder',
|
||||
test: 'Test Obsidian connection',
|
||||
testing: 'Testing Obsidian connection…',
|
||||
folderRequired: 'Choose or enter a vault folder.',
|
||||
result: '{{vaults}} vaults discovered · {{tools}} tools available',
|
||||
empty: 'No local vaults found. Open a vault in Obsidian or choose its folder.'
|
||||
},
|
||||
title: 'MCP',
|
||||
description:
|
||||
'View built-in tools and MCP servers, and manage external MCP servers',
|
||||
|
||||
@@ -143,6 +143,22 @@ export const integrations = {
|
||||
save: '保存通道设置'
|
||||
},
|
||||
mcp: {
|
||||
obsidian: {
|
||||
access: '读取与写入',
|
||||
description: '读取、搜索和管理本机 Obsidian 仓库中的笔记、目录、标签与附件。',
|
||||
scope: 'Obsidian 仓库范围',
|
||||
all: '所有已注册的本机仓库',
|
||||
folder: '指定文件夹',
|
||||
path: 'Obsidian 仓库文件夹',
|
||||
scopeHelp: '所有仓库从本机 Obsidian 注册信息中发现;指定文件夹则仅使用该仓库。有多个仓库时,工具调用必须明确指定仓库 ID。',
|
||||
savedScope: '已保存范围:{{path}}',
|
||||
select: '选择仓库文件夹',
|
||||
test: '测试 Obsidian 连接',
|
||||
testing: '正在测试 Obsidian 连接…',
|
||||
folderRequired: '请选择或填写仓库文件夹。',
|
||||
result: '发现 {{vaults}} 个仓库 · {{tools}} 个可用工具',
|
||||
empty: '未找到本机仓库。请在 Obsidian 中打开仓库,或选择其文件夹。'
|
||||
},
|
||||
title: 'MCP',
|
||||
description: '查看内置工具、内置 MCP,并管理外部 MCP Server',
|
||||
runtimeLabels: {
|
||||
|
||||
@@ -11880,6 +11880,22 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.capability-diagnostic--obsidian {
|
||||
min-width: 0;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.capability-diagnostic--obsidian .capability-card__actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.capability-diagnostic--obsidian code {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.capability-diagnostic__result {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
|
||||
Reference in New Issue
Block a user