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:
mesalogo
2026-09-27 00:37:46 +08:00
parent 5116613615
commit 55efca3ee1
47 changed files with 4060 additions and 76 deletions
+2 -16
View File
@@ -12,6 +12,7 @@ import { createServer } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { scopedReadToolNames } from '../../shared/scoped-data-tools'
import {
ContinueHostAdapter,
inspectContinueNativeConfiguration,
@@ -777,22 +778,7 @@ describe('ContinueHostAdapter', () => {
expect(args).toEqual([
'--config',
expect.stringContaining('knowledge-config-'),
'--allow',
'knowledge_list',
'--allow',
'knowledge_search',
'--allow',
'note_list',
'--allow',
'note_get',
'--allow',
'note_search',
'--allow',
'goodbuddy_config_capabilities',
'--allow',
'goodbuddy_config_get',
'--allow',
'goodbuddy_config_plan',
...scopedReadToolNames.flatMap((name) => ['--allow', name]),
'--exclude',
'*',
'serve',
@@ -0,0 +1,226 @@
// @vitest-environment node
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { delimiter, join, resolve } from 'node:path'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { CallToolResultSchema, type CallToolResult } from '@modelcontextprotocol/sdk/types.js'
import { obsidianReadToolNames, obsidianScopedDataTools, obsidianWriteToolNames } from '../../shared/obsidian-tools'
import type { KnowledgeService } from '../knowledge/knowledge-service'
import { ObsidianService, type ObsidianSettings } from '../obsidian/obsidian-service'
import { packageObsidianMcpVault } from '../obsidian/package-mcpvault'
import { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
let root: string
let vaultPath: string
let service: ObsidianService
const gateways: KnowledgeMcpGateway[] = []
const clients: Client[] = []
const provider = vi.fn<() => NodeJS.ProcessEnv>()
beforeAll(async () => {
root = await mkdtemp(join(tmpdir(), 'goodbuddy-obsidian-gateway-'))
vaultPath = join(root, 'First Vault')
const second = join(root, 'Second Vault')
const config = join(root, 'config')
const bin = join(root, 'managed-bin')
await Promise.all([vaultPath, second, bin, join(config, 'obsidian')].map((path) => mkdir(path, { recursive: true })))
await writeFile(join(config, 'obsidian', 'obsidian.json'), JSON.stringify({ vaults: {
first: { path: vaultPath }, second: { path: second }
} }))
await writeFile(join(bin, process.platform === 'win32' ? 'node.cmd' : 'node'),
process.platform === 'win32'
? `@echo off\r\nset "ELECTRON_RUN_AS_NODE=1"\r\n"${process.execPath}" %*\r\n`
: `#!/bin/sh\nELECTRON_RUN_AS_NODE=1 exec '${process.execPath.replaceAll("'", "'\\''")}' "$@"\n`,
{ mode: 0o755 })
provider.mockImplementation(() => ({ PATH: `${bin}${delimiter}${process.env.PATH ?? process.env.Path ?? ''}` }))
const appPath = join(root, 'app')
await packageObsidianMcpVault(resolve('.'), join(appPath, 'out', 'main', 'obsidian-mcpvault'))
service = new ObsidianService({ appPath, launchEnvironmentProvider: provider,
platform: 'linux', environment: { ...process.env, XDG_CONFIG_HOME: config } })
}, 60_000)
afterEach(async () => {
await Promise.all(clients.splice(0).map((client) => client.close()))
await Promise.all(gateways.splice(0).map((gateway) => gateway.dispose()))
vi.restoreAllMocks()
})
afterAll(async () => { await rm(root, { recursive: true, force: true }) })
function grant(access: 'read' | 'write', settings: ObsidianSettings = {}, signal = new AbortController().signal) {
const gateway = new KnowledgeMcpGateway({} as KnowledgeService, { obsidianService: service })
gateways.push(gateway)
const token = gateway.grant('obsidian-test', [], signal, 'none', undefined, undefined, undefined, undefined, { settings, access })!
return { gateway, token }
}
async function connect(gateway: KnowledgeMcpGateway, token: string) {
await gateway.start()
const client = new Client({ name: 'obsidian-gateway-test', version: '1.0.0' })
clients.push(client)
await client.connect(new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), {
requestInit: { headers: { Authorization: `Bearer ${token}` } }
}))
return client
}
function text(result: CallToolResult) {
return result.content.map((item) => item.type === 'text' ? item.text : '').join('\n')
}
describe('Obsidian gateway', () => {
it('uses the shared Ask classification and exposes every tool for Execute', async () => {
const read = grant('read')
const write = grant('write')
expect(read.gateway.getAvailableToolNames(read.token)).toEqual(obsidianReadToolNames)
expect(write.gateway.getAvailableToolNames(write.token)).toEqual([...obsidianReadToolNames, ...obsidianWriteToolNames])
const client = await connect(write.gateway, write.token)
const tools = (await client.listTools()).tools
expect(tools).toHaveLength(19)
expect(tools.map((tool) => tool.name).sort()).toEqual(obsidianScopedDataTools.map((tool) => tool.name).sort())
for (const definition of obsidianScopedDataTools) {
expect(tools.find((tool) => tool.name === definition.name)?.annotations?.readOnlyHint).toBe(definition.access === 'read')
}
await expect(read.gateway.callObsidianTool(read.token, 'obsidian_write_note', { path: 'blocked.md', content: '' })).rejects.toThrow('capability is unavailable')
const absent = new KnowledgeMcpGateway({} as KnowledgeService)
gateways.push(absent)
expect(absent.grant('absent', [], new AbortController().signal, 'none', undefined, undefined, undefined, undefined,
{ settings: {}, access: 'write' })).toBeUndefined()
})
it('writes, reads, lists and deletes through HTTP using the real service and explicit vault targets', async () => {
const { gateway, token } = grant('write')
const client = await connect(gateway, token)
const call = async (name: string, args: Record<string, unknown> = {}) =>
CallToolResultSchema.parse(await client.callTool({ name, arguments: args }, CallToolResultSchema))
const vaults = JSON.parse(text(await call('obsidian_list_vaults'))).vaults
expect(vaults.map((vault: { id: string }) => vault.id)).toEqual(['first', 'second'])
await expect(call('obsidian_write_note', { path: 'gateway.md', content: 'ambiguous' })).rejects.toThrow('vaultId is required')
await expect(call('obsidian_read_note', { vaultId: 'missing', path: 'gateway.md' })).rejects.toThrow('Unknown Obsidian vaultId')
for (const vaultId of ['first', 'second']) {
expect((await call('obsidian_write_note', { vaultId, path: 'gateway.md', content: `# ${vaultId}` })).isError).not.toBe(true)
expect(text(await call('obsidian_read_note', { vaultId, path: 'gateway.md' }))).toContain(`# ${vaultId}`)
expect(text(await call('obsidian_list_directory', { vaultId }))).toContain('gateway.md')
}
expect(await readFile(join(vaultPath, 'gateway.md'), 'utf8')).toContain('# first')
const failedDelete = await call('obsidian_delete_note', { vaultId: 'first', path: 'gateway.md', confirmPath: 'wrong.md' })
expect(failedDelete.isError).toBe(true)
expect(text(failedDelete)).toContain('confirmPath')
expect(text(failedDelete)).not.toContain('"content":[')
for (const vaultId of ['first', 'second']) {
expect((await call('obsidian_delete_note', { vaultId, path: 'gateway.md', confirmPath: 'gateway.md' })).isError).not.toBe(true)
expect(text(await call('obsidian_list_directory', { vaultId }))).not.toContain('gateway.md')
}
expect(provider).toHaveBeenCalled()
}, 30_000)
it('allows an omitted vaultId for an explicit single vault and returns native errors directly', async () => {
const { gateway, token } = grant('write', { vaultPath })
const result = await gateway.callObsidianTool(token, 'obsidian_read_note', { path: 'absent.md' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('absent.md')
expect(JSON.parse(text(await gateway.callObsidianTool(token, 'obsidian_list_vaults', {}))).vaults).toHaveLength(1)
})
it('preserves native content and enforces the existing MCP result bound in HTTP and direct calls', async () => {
const { gateway, token } = grant('read')
const native: CallToolResult = { isError: true, content: [
{ type: 'text', text: 'upstream detail' },
{ type: 'image', data: 'aGVsbG8=', mimeType: 'image/png' }
], structuredContent: { detail: 'upstream detail' } }
const call = vi.spyOn(service, 'callTool').mockResolvedValue(native)
const client = await connect(gateway, token)
expect(await gateway.callObsidianTool(token, 'obsidian_read_note', { vaultId: 'first', path: 'test.md' })).toEqual(native)
expect(call).toHaveBeenLastCalledWith({}, { vaultId: 'first', name: 'read_note', arguments: { path: 'test.md', prettyPrint: false } }, expect.any(AbortSignal))
expect(await client.callTool({ name: 'obsidian_read_note', arguments: { path: 'test.md' } }, CallToolResultSchema)).toEqual(native)
call.mockResolvedValue({ content: [{ type: 'text', text: 'x'.repeat(256 * 1024) }] })
await expect(gateway.callObsidianTool(token, 'obsidian_read_note', { path: 'large.md' })).rejects.toThrow('256KB')
await expect(client.callTool({ name: 'obsidian_read_note', arguments: { path: 'large.md' } })).rejects.toThrow('256KB')
})
it('revokes all Obsidian grants while retaining unrelated capabilities and allowing new grants', () => {
const { gateway, token } = grant('read')
const signal = new AbortController().signal
const writeToken = gateway.grant('write', [], signal, 'none', undefined, undefined, undefined, undefined,
{ settings: { vaultPath }, access: 'write' })!
const mixedToken = gateway.grant('mixed', ['library'], signal, 'none', undefined, undefined, undefined, undefined,
{ settings: {}, access: 'write' })!
const knowledgeToken = gateway.grant('knowledge', ['library'], signal)!
gateway.revokeObsidianCapabilities()
gateway.revokeObsidianCapabilities()
for (const revoked of [token, writeToken, mixedToken]) {
expect(() => gateway.getAvailableToolNames(revoked)).toThrow('unavailable or expired')
}
expect(gateway.getAvailableToolNames(knowledgeToken)).toEqual(['knowledge_list', 'knowledge_search'])
const replacement = gateway.grant('replacement', [], signal, 'none', undefined, undefined, undefined, undefined,
{ settings: { vaultPath }, access: 'write' })!
expect(gateway.getAvailableToolNames(replacement)).toEqual([...obsidianReadToolNames, ...obsidianWriteToolNames])
})
it.each([false, true])('awaits transport close during disposal, including previously revoked calls (%s)', async (revokeFirst) => {
const { gateway, token } = grant('read', { vaultPath })
let markStarted!: () => void
const started = new Promise<void>((resolve) => { markStarted = resolve })
let markClosing!: () => void
const closing = new Promise<void>((resolve) => { markClosing = resolve })
let releaseClose!: () => void
const closeGate = new Promise<void>((resolve) => { releaseClose = resolve })
let closed = false
const close = StdioClientTransport.prototype.close
vi.spyOn(StdioClientTransport.prototype, 'close').mockImplementation(async function (this: StdioClientTransport) {
markClosing()
await closeGate
await close.call(this)
closed = true
})
const send = StdioClientTransport.prototype.send
vi.spyOn(StdioClientTransport.prototype, 'send').mockImplementation(function (this: StdioClientTransport, message) {
const result = send.call(this, message)
if ('method' in message && message.method === 'tools/call') markStarted()
return result
})
const pending = gateway.callObsidianTool(token, 'obsidian_list_directory', {})
.then(() => undefined, (error: unknown) => error)
await Promise.race([started, pending.then(() => { throw new Error('Call ended before upstream dispatch') })])
if (revokeFirst) gateway.revokeObsidianCapabilities()
let disposed = false
const disposal = gateway.dispose().then(() => { disposed = true })
try {
await closing
await new Promise<void>((resolve) => setImmediate(resolve))
expect(disposed).toBe(false)
expect(closed).toBe(false)
} finally {
releaseClose()
await disposal
await pending
}
expect(closed).toBe(true)
expect(await pending).toBeInstanceOf(Error)
})
it.each(['revoke', 'settings', 'request', 'call', 'dispose'] as const)('cancels and closes the actual upstream child on %s', async (cancellation) => {
const controller = new AbortController()
const callController = new AbortController()
const { gateway, token } = grant('read', { vaultPath }, controller.signal)
const transports = new Set<StdioClientTransport>()
const send = StdioClientTransport.prototype.send
vi.spyOn(StdioClientTransport.prototype, 'send').mockImplementation(function (this: StdioClientTransport, message) {
transports.add(this)
const result = send.call(this, message)
if ('method' in message && message.method === 'tools/call') {
if (cancellation === 'revoke') gateway.revoke(token)
else if (cancellation === 'settings') gateway.revokeObsidianCapabilities()
else if (cancellation === 'request') controller.abort(new Error('Request cancelled'))
else if (cancellation === 'call') callController.abort(new Error('Call cancelled'))
else void gateway.dispose()
}
return result
})
await expect(gateway.callObsidianTool(token, 'obsidian_list_directory', {}, callController.signal)).rejects.toThrow(/revoked|cancelled/)
expect(transports.size).toBe(1)
for (const transport of transports) expect(transport.pid).toBeNull()
})
})
+98 -10
View File
@@ -40,6 +40,8 @@ import {
type ScopedDataToolName
} from '../../shared/scoped-data-tools'
import type { KnowledgeService } from '../knowledge/knowledge-service'
import type { ObsidianService, ObsidianSettings } from '../obsidian/obsidian-service'
import { obsidianReadToolNames, obsidianWriteToolNames } from '../../shared/obsidian-tools'
import type {
MagicNoteDetail,
MagicNoteEntry,
@@ -199,6 +201,7 @@ type Capability = {
browserConversationId?: string
browserTabId?: BrowserTabId
browserUsageLease?: BrowserTabUsageLease
obsidian?: { settings: ObsidianSettings; access: 'read' | 'write' }
expiresAt: number
signal: AbortSignal
brokerController: AbortController
@@ -244,6 +247,7 @@ export type KnowledgeMcpGatewayOptions = {
now?: () => number
magicNotesDatabase?: MagicNotesDatabase
configService?: GoodBuddyConfigService
obsidianService?: ObsidianService
browserService?: BrowserToolService & {
createTab: BrowserService['createTab']
listTabs(conversationId: string): BrowserTabSummary[]
@@ -367,11 +371,13 @@ export class KnowledgeMcpGateway {
>()
private readonly downstreamMcpCleanups = new Set<Promise<void>>()
private readonly customMcpCleanups = new Set<Promise<void>>()
private readonly activeObsidianCalls = new Set<Promise<CallToolResult>>()
private readonly now: () => number
private readonly capabilityTtlMs: number
private readonly maximumBodyBytes: number
private readonly magicNotesDatabase?: MagicNotesDatabase
private readonly configService?: GoodBuddyConfigService
private readonly obsidianService?: ObsidianService
private readonly browserService?: BrowserToolService & {
createTab: BrowserService['createTab']
listTabs(conversationId: string): BrowserTabSummary[]
@@ -403,6 +409,7 @@ export class KnowledgeMcpGateway {
this.now = options.now ?? Date.now
this.magicNotesDatabase = options.magicNotesDatabase
this.configService = options.configService
this.obsidianService = options.obsidianService
this.browserService = options.browserService
this.launchEnvironmentProvider = options.launchEnvironmentProvider
}
@@ -458,7 +465,8 @@ export class KnowledgeMcpGateway {
},
browserConversationId?: string,
browserTabId?: BrowserTabId,
browserUsageLease?: BrowserTabUsageLease
browserUsageLease?: BrowserTabUsageLease,
obsidian?: { settings: ObsidianSettings; access: 'read' | 'write' }
): string | undefined {
const effectiveMagicNotesAccess = this.magicNotesDatabase
? magicNotesAccess
@@ -466,6 +474,7 @@ export class KnowledgeMcpGateway {
const effectiveConfigAccess = this.configService
? config?.access ?? 'none'
: 'none'
const effectiveObsidian = this.obsidianService ? obsidian : undefined
const effectiveBrowserConversationId = this.browserService
? browserConversationId
: undefined
@@ -506,7 +515,8 @@ export class KnowledgeMcpGateway {
authorizedLibraryIds.length === 0 &&
effectiveMagicNotesAccess === 'none' &&
effectiveConfigAccess === 'none' &&
!effectiveBrowserConversationId
!effectiveBrowserConversationId &&
!effectiveObsidian
) {
return undefined
}
@@ -518,6 +528,7 @@ export class KnowledgeMcpGateway {
libraryIds: Object.freeze([...new Set(authorizedLibraryIds)]),
magicNotesAccess: effectiveMagicNotesAccess,
configAccess: effectiveConfigAccess,
obsidian: effectiveObsidian,
browserConversationId: effectiveBrowserConversationId,
browserTabId: effectiveBrowserTabId,
browserUsageLease: effectiveBrowserUsageLease,
@@ -640,6 +651,14 @@ export class KnowledgeMcpGateway {
})
}
revokeObsidianCapabilities(): void {
for (const [token, capability] of this.capabilities) {
if (capability.obsidian) {
this.revoke(token)
}
}
}
private closeDownstreamMcpSession(
session: DownstreamMcpSession
): Promise<void> {
@@ -796,6 +815,8 @@ export class KnowledgeMcpGateway {
...(capability.configAccess === 'write'
? goodbuddyConfigWriteToolNames
: []),
...(capability.obsidian ? obsidianReadToolNames : []),
...(capability.obsidian?.access === 'write' ? obsidianWriteToolNames : []),
...(capability.browserConversationId && capability.browserTabId
? browserToolNames
: [])
@@ -1228,6 +1249,50 @@ export class KnowledgeMcpGateway {
)
}
async callObsidianTool(
token: string,
name: Extract<ScopedDataToolName, `obsidian_${string}`>,
input: unknown,
signal?: AbortSignal
): Promise<CallToolResult> {
const capability = this.getCapability(token)
const definition = scopedDataToolByName.get(name)
if (
!this.obsidianService ||
!capability.obsidian ||
!definition ||
!name.startsWith('obsidian_') ||
(definition.access === 'write' && capability.obsidian.access !== 'write')
) {
throw new Error('Obsidian capability is unavailable')
}
const effectiveSignal = AbortSignal.any([
capability.signal,
capability.brokerController.signal,
...(signal ? [signal] : [])
])
effectiveSignal.throwIfAborted()
const parsed = definition.inputSchema.parse(input) as Record<string, unknown>
const { vaultId, ...argumentsValue } = parsed
const pending = name === 'obsidian_list_vaults'
? this.obsidianService.listVaults(capability.obsidian.settings).then((vaults) => ({
content: [{ type: 'text' as const, text: JSON.stringify({ vaults }) }]
}))
: this.obsidianService.callTool(capability.obsidian.settings, {
vaultId: vaultId as string | undefined,
name: name.slice('obsidian_'.length),
arguments: argumentsValue
}, effectiveSignal)
this.activeObsidianCalls.add(pending)
try {
const result = await pending
effectiveSignal.throwIfAborted()
return ensureBoundedCustomMcpResult(result)
} finally {
this.activeObsidianCalls.delete(pending)
}
}
private requireConfig(
token: string,
requiredAccess: Exclude<MagicNotesCapabilityAccess, 'none'>
@@ -1513,6 +1578,26 @@ export class KnowledgeMcpGateway {
case 'goodbuddy_config_plan':
case 'goodbuddy_config_apply':
return this.callGoodBuddyConfigTool(token, name, input)
case 'obsidian_list_vaults':
case 'obsidian_read_note':
case 'obsidian_write_note':
case 'obsidian_patch_note':
case 'obsidian_list_directory':
case 'obsidian_delete_note':
case 'obsidian_search_notes':
case 'obsidian_move_note':
case 'obsidian_move_file':
case 'obsidian_read_multiple_notes':
case 'obsidian_update_frontmatter':
case 'obsidian_get_notes_info':
case 'obsidian_get_frontmatter':
case 'obsidian_manage_tags':
case 'obsidian_get_vault_stats':
case 'obsidian_list_all_tags':
case 'obsidian_wiki_link':
case 'obsidian_get_note_outline':
case 'obsidian_read_note_lines':
return this.callObsidianTool(token, name, input, signal)
}
}
@@ -1662,18 +1747,20 @@ export class KnowledgeMcpGateway {
}]
}
}
const result = await this.callScopedTool(
token,
name as ScopedDataToolName,
parsedInput.data,
extra.signal
)
if (name.startsWith('obsidian_')) {
return result as CallToolResult
}
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(
await this.callScopedTool(
token,
name as ScopedDataToolName,
parsedInput.data,
extra.signal
)
)
text: JSON.stringify(result)
}
]
}
@@ -1913,6 +2000,7 @@ export class KnowledgeMcpGateway {
for (const token of [...this.capabilities.keys()]) {
this.revoke(token)
}
await Promise.allSettled([...this.activeObsidianCalls])
await Promise.allSettled([...this.downstreamMcpCleanups])
await Promise.allSettled([...this.customMcpCleanups])
const server = this.server
@@ -0,0 +1,223 @@
// @vitest-environment node
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { delimiter, join, resolve } from 'node:path'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { z } from 'zod'
import { obsidianReadToolNames, obsidianScopedDataTools } from '../../shared/obsidian-tools'
import type { ResolvedMcpServer } from '../capabilities/capability-service'
import type { KnowledgeService } from '../knowledge/knowledge-service'
import { ObsidianService } from '../obsidian/obsidian-service'
import { packageObsidianMcpVault } from '../obsidian/package-mcpvault'
import { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
import { ModelToolProvider, type ModelToolCallContext, type ModelToolResult } from './model-tool-provider'
let root: string
let vaultPath: string
let secondVaultPath: string
let service: ObsidianService
const fixtures: { provider: ModelToolProvider; gateway: KnowledgeMcpGateway }[] = []
beforeAll(async () => {
root = await mkdtemp(join(tmpdir(), 'goodbuddy-obsidian-model-'))
vaultPath = join(root, 'First Vault')
secondVaultPath = join(root, 'Second Vault')
const config = join(root, 'config')
const bin = join(root, 'managed-bin')
await Promise.all([vaultPath, secondVaultPath, bin, join(config, 'obsidian')]
.map((path) => mkdir(path, { recursive: true })))
await writeFile(join(config, 'obsidian', 'obsidian.json'), JSON.stringify({ vaults: {
first: { path: vaultPath }, second: { path: secondVaultPath }
} }))
await writeFile(join(bin, process.platform === 'win32' ? 'node.cmd' : 'node'),
process.platform === 'win32'
? `@echo off\r\nset "ELECTRON_RUN_AS_NODE=1"\r\n"${process.execPath}" %*\r\n`
: `#!/bin/sh\nELECTRON_RUN_AS_NODE=1 exec '${process.execPath.replaceAll("'", "'\\''")}' "$@"\n`,
{ mode: 0o755 })
const appPath = join(root, 'app')
await packageObsidianMcpVault(resolve('.'), join(appPath, 'out', 'main', 'obsidian-mcpvault'))
service = new ObsidianService({
appPath,
launchEnvironmentProvider: () => ({ PATH: `${bin}${delimiter}${process.env.PATH ?? process.env.Path ?? ''}` }),
platform: 'linux', environment: { ...process.env, XDG_CONFIG_HOME: config }
})
}, 60_000)
afterEach(async () => {
await Promise.all(fixtures.splice(0).map(async ({ provider, gateway }) => {
await provider.dispose()
await gateway.dispose()
}))
vi.restoreAllMocks()
})
afterAll(async () => { await rm(root, { recursive: true, force: true }) })
function fixture(access: 'read' | 'write' = 'write') {
const gateway = new KnowledgeMcpGateway({} as KnowledgeService, { obsidianService: service })
const signal = new AbortController().signal
const token = gateway.grant('obsidian-model-test', [], signal, 'none', undefined,
undefined, undefined, undefined, { settings: {}, access })!
const provider = new ModelToolProvider(root, [], undefined, gateway)
fixtures.push({ provider, gateway })
const context: ModelToolCallContext = {
conversationId: 'obsidian-model-test', runtimeTarget: 'model',
workMode: 'execute', knowledgeCapabilityToken: token
}
return { gateway, provider, token, signal, context,
call: (name: string, args: Record<string, unknown> = {}) => provider.callTool(name, args, signal, context) }
}
function text(result: ModelToolResult): string {
expect(result.contextBytes).toBe(result.parts.reduce((sum, part) =>
sum + Buffer.byteLength(part.type === 'text' ? part.text : part.data), 0))
return result.parts.map((part) => part.type === 'text' ? part.text : '').join('\n')
}
describe('ModelToolProvider with bundled Obsidian service', () => {
it('discovers and executes all 19 tools through a scoped token, with real filesystem effects', async () => {
const { provider, context, signal, call } = fixture()
const tools = (await provider.listTools(context, signal)).filter((tool) => tool.name.startsWith('obsidian_'))
expect(tools).toHaveLength(19)
expect(tools.map((tool) => tool.name).sort()).toEqual(obsidianScopedDataTools.map((tool) => tool.name).sort())
const executed = new Set<string>()
const run = async (name: string, args: Record<string, unknown> = {}) => {
const result = text(await call(name, args))
executed.add(name)
return result
}
expect(JSON.parse(await run('obsidian_list_vaults')).vaults).toEqual([
{ id: 'first', name: 'First Vault', path: vaultPath },
{ id: 'second', name: 'Second Vault', path: secondVaultPath }
])
await expect(call('obsidian_write_note', { path: 'model.md', content: 'ambiguous' }))
.rejects.toThrow('vaultId is required')
await expect(call('obsidian_read_note', { vaultId: 'unknown', path: 'model.md' }))
.rejects.toThrow('Unknown Obsidian vaultId')
const target = { vaultId: 'first', path: 'model.md' }
await run('obsidian_write_note', { ...target, content: '# Original\nBody', frontmatter: { title: 'Model' } })
expect(await readFile(join(vaultPath, 'model.md'), 'utf8')).toContain('# Original')
await expect(readFile(join(secondVaultPath, 'model.md'))).rejects.toMatchObject({ code: 'ENOENT' })
expect(JSON.parse(await run('obsidian_read_note', target)).content).toContain('# Original')
expect(await run('obsidian_list_directory', { vaultId: 'first' })).toContain('model.md')
await run('obsidian_patch_note', { ...target, oldString: 'Original', newString: 'Patched' })
await run('obsidian_update_frontmatter', { ...target, frontmatter: { status: 'verified' } })
await run('obsidian_manage_tags', { ...target, operation: 'add', tags: ['integration'] })
expect(await run('obsidian_get_frontmatter', target)).toContain('verified')
expect(await run('obsidian_search_notes', { vaultId: 'first', query: 'Patched' })).toContain('model.md')
expect(await run('obsidian_read_multiple_notes', { vaultId: 'first', paths: ['model.md'] })).toContain('Patched')
expect(await run('obsidian_get_notes_info', { vaultId: 'first', paths: ['model.md'] })).toContain('model.md')
expect(await run('obsidian_get_vault_stats', { vaultId: 'first' })).toContain('model.md')
expect(await run('obsidian_list_all_tags', { vaultId: 'first' })).toContain('integration')
expect(await run('obsidian_wiki_link', { vaultId: 'first', document: '[[model]]' })).toContain('Patched')
expect(await run('obsidian_get_note_outline', target)).toContain('Patched')
expect(await run('obsidian_read_note_lines', { ...target, startLine: 1, endLine: 20 })).toContain('Patched')
await run('obsidian_move_note', { vaultId: 'first', oldPath: 'model.md', newPath: 'renamed.md' })
expect(await readFile(join(vaultPath, 'renamed.md'), 'utf8')).toContain('# Patched')
await writeFile(join(vaultPath, 'attachment.bin'), Buffer.from([0, 1, 255]))
await run('obsidian_move_file', { vaultId: 'first', oldPath: 'attachment.bin', newPath: 'moved.bin',
confirmOldPath: 'attachment.bin', confirmNewPath: 'moved.bin' })
expect(await readFile(join(vaultPath, 'moved.bin'))).toEqual(Buffer.from([0, 1, 255]))
await run('obsidian_delete_note', { vaultId: 'first', path: 'renamed.md', confirmPath: 'renamed.md' })
await expect(readFile(join(vaultPath, 'renamed.md'))).rejects.toMatchObject({ code: 'ENOENT' })
expect(await run('obsidian_list_directory', { vaultId: 'first' })).not.toContain('renamed.md')
expect([...executed].sort()).toEqual(tools.map((tool) => tool.name).sort())
}, 60_000)
it('enforces Ask, read-only grants, missing tokens and revoked tokens at the provider boundary', async () => {
const { provider, gateway, context, signal, token, call } = fixture()
const ask = { ...context, workMode: 'ask' as const }
expect((await provider.listTools(ask, signal)).filter((tool) => tool.name.startsWith('obsidian_'))
.map((tool) => tool.name).sort()).toEqual([...obsidianReadToolNames].sort())
const args = { vaultId: 'first', path: 'blocked.md', content: 'blocked' }
await expect(provider.callTool('obsidian_write_note', args, signal, ask)).rejects.toThrow('Ask')
await expect(fixture('read').call('obsidian_write_note', args)).rejects.toThrow('capability is unavailable')
const unscoped = { ...context, knowledgeCapabilityToken: undefined }
expect((await provider.listTools(unscoped, signal)).some((tool) => tool.name.startsWith('obsidian_'))).toBe(false)
await expect(provider.callTool('obsidian_list_vaults', {}, signal, unscoped)).rejects.toThrow()
gateway.revoke(token)
await expect(call('obsidian_list_vaults')).rejects.toThrow()
await expect(readFile(join(vaultPath, 'blocked.md'))).rejects.toMatchObject({ code: 'ENOENT' })
})
it('awaits the active bundled child cleanup when the gateway is disposed', async () => {
const { gateway, call } = fixture()
let markStarted!: (transport: StdioClientTransport) => void
const started = new Promise<StdioClientTransport>((resolve) => { markStarted = resolve })
const closed = new Set<StdioClientTransport>()
const close = StdioClientTransport.prototype.close
vi.spyOn(StdioClientTransport.prototype, 'close').mockImplementation(async function (this: StdioClientTransport) {
await close.call(this)
closed.add(this)
})
const send = StdioClientTransport.prototype.send
vi.spyOn(StdioClientTransport.prototype, 'send').mockImplementation(function (this: StdioClientTransport, message) {
const result = send.call(this, message)
if ('method' in message && message.method === 'tools/call') markStarted(this)
return result
})
const pending = call('obsidian_list_directory', { vaultId: 'first' })
.then(() => undefined, (error: unknown) => error)
const transport = await Promise.race([
started,
pending.then(() => { throw new Error('Tool call ended before upstream dispatch') })
])
expect(transport.pid).not.toBeNull()
try {
await gateway.dispose()
// The SDK clears pid at the start of close(), before awaiting process exit.
expect(closed.has(transport), 'dispose must await the actual transport close promise').toBe(true)
} finally {
expect(await pending).toBeInstanceOf(Error)
expect(closed.has(transport)).toBe(true)
expect(transport.pid).toBeNull()
}
})
it('keeps upstream defaulted arguments optional in model discovery', async () => {
const { provider, context, signal } = fixture()
const tools = await provider.listTools(context, signal)
for (const definition of obsidianScopedDataTools) {
const expected = z.toJSONSchema(definition.inputSchema, { io: 'input' })
expect.soft(tools.find((tool) => tool.name === definition.name)?.inputSchema.required ?? [], definition.name)
.toEqual(expected.required ?? [])
}
})
it('does not spend the custom MCP budget on disabled Obsidian tools', async () => {
const gateway = new KnowledgeMcpGateway({} as KnowledgeService, { obsidianService: service })
const servers: ResolvedMcpServer[] = Array.from({ length: 4 }, (_, index) => ({
id: `00000000-0000-4000-8000-00000000000${index}`, name: `Vault ${index}`,
description: '', enabled: true, allowDynamicTools: false, assignments: ['model'],
secretConfigured: false, transport: 'stdio', command: 'node',
args: [join(root, 'app', 'out', 'main', 'obsidian-mcpvault', 'node_modules',
'@bitbonsai', 'mcpvault', 'dist', 'server.js'), vaultPath]
}))
const provider = new ModelToolProvider(root, servers, undefined, gateway, false, {}, () => ({
PATH: `${join(root, 'managed-bin')}${delimiter}${process.env.PATH ?? process.env.Path ?? ''}`
}))
fixtures.push({ provider, gateway })
// No scoped grant: 72 real upstream tools plus two workspace tools fit below 100.
const tools = await provider.listTools({ conversationId: 'disabled-obsidian', workMode: 'execute' },
new AbortController().signal)
expect(tools.filter((tool) => tool.source === 'mcp')).toHaveLength(72)
expect(tools.some((tool) => tool.name.startsWith('obsidian_'))).toBe(false)
expect(tools).toHaveLength(74)
}, 30_000)
it.each(['read_note', 'delete_note'] as const)('preserves the native %s error detail for the model', async (name) => {
const { call } = fixture()
const args = name === 'read_note' ? { path: 'missing.md' }
: { path: 'protected.md', confirmPath: 'wrong.md' }
if (name === 'delete_note') await call('obsidian_write_note', { vaultId: 'first', path: args.path, content: 'keep me' })
// Establish the actual bundled server error, without injecting a service or gateway double.
const native = await service.callTool({}, { vaultId: 'first', name, arguments: args })
expect(native.isError).toBe(true)
const detail = native.content.map((part) => part.type === 'text' ? part.text : '').join('\n')
expect(detail).toContain(name === 'read_note' ? 'missing.md' : 'confirmPath')
const failure = await call(`obsidian_${name}`, { vaultId: 'first', ...args }).catch((error: unknown) => error)
if (name === 'delete_note') expect(await readFile(join(vaultPath, args.path), 'utf8')).toContain('keep me')
expect(failure).toBeInstanceOf(Error)
expect((failure as Error).message).toContain(detail)
}, 30_000)
})
+105 -21
View File
@@ -19,6 +19,7 @@ import {
type WorkspaceAccess
} from '../workspace'
import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
import { scopedDataToolByName } from '../../shared/scoped-data-tools'
import {
LocalDirectModelProcessService,
type DirectModelProcessService,
@@ -1008,29 +1009,13 @@ describe('ModelToolProvider', () => {
})
})
it('reserves all scoped data tool slots for Execute', async () => {
it('reserves the granted scoped data tool slots for Execute', async () => {
const workspace = await createWorkspace()
const gateway = {
listLibraries: vi.fn(() => []),
search: vi.fn(async () => []),
searchMagicNotes: vi.fn(() => []),
getAvailableToolNames: vi.fn(() => [
'knowledge_list',
'knowledge_search',
'note_list',
'note_get',
'note_search',
'note_create',
'note_update',
'note_entry_create',
'note_entry_update',
'note_entry_delete',
'note_delete',
'goodbuddy_config_capabilities',
'goodbuddy_config_get',
'goodbuddy_config_plan',
'goodbuddy_config_apply'
])
getAvailableToolNames: vi.fn(() => [...scopedDataToolByName.keys()])
} as unknown as KnowledgeMcpGateway
const context = {
conversationId: 'knowledge-capacity',
@@ -1050,7 +1035,7 @@ describe('ModelToolProvider', () => {
}))
mocks.client.listTools.mockResolvedValueOnce({
tools: createTools(81)
tools: createTools(100 - 4 - scopedDataToolByName.size)
})
const validProvider = new ModelToolProvider(
workspace,
@@ -1066,7 +1051,7 @@ describe('ModelToolProvider', () => {
await validProvider.dispose()
mocks.client.listTools.mockResolvedValueOnce({
tools: createTools(82)
tools: createTools(100 - 4 - scopedDataToolByName.size + 1)
})
const overflowingProvider = new ModelToolProvider(
workspace,
@@ -1081,10 +1066,77 @@ describe('ModelToolProvider', () => {
context,
new AbortController().signal
)
).resolves.toHaveLength(19)
).resolves.toHaveLength(4 + scopedDataToolByName.size)
await overflowingProvider.dispose()
})
it('rechecks cached and refreshed MCP catalogs against each request scope without shrinking the cache', async () => {
const gateway = {
getAvailableToolNames: vi.fn(() => [...scopedDataToolByName.keys()])
} as unknown as KnowledgeMcpGateway
const provider = new ModelToolProvider(await createWorkspace(), [createMcpServer(true)],
undefined, gateway, false, { ripgrepExecutablePath: rgPath })
const unscoped = { ...toolContext, runtimeTarget: 'model' as const }
const scoped = { ...unscoped, knowledgeCapabilityToken: 'scoped' }
const signal = new AbortController().signal
const catalog = (count: number) => ({ tools: Array.from({ length: count }, (_, index) => ({
name: `remote_${index}`, inputSchema: { type: 'object' }
})) })
mocks.client.getServerCapabilities.mockReturnValue({ tools: { listChanged: true } })
mocks.client.listTools.mockResolvedValue(catalog(90))
try {
// Start with the narrower request to ensure it cannot poison later discovery.
await expect(provider.listTools(scoped, signal)).resolves.toHaveLength(4 + scopedDataToolByName.size)
const tools = await provider.listTools(unscoped, signal)
expect(tools).toHaveLength(94)
const name = tools.find((tool) => tool.source === 'mcp')!.name
await expect(provider.listTools(scoped, signal)).resolves.toHaveLength(4 + scopedDataToolByName.size)
await expect(provider.callTool(name, {}, signal, scoped)).rejects.toThrow('未知工具')
await expect(provider.listTools(unscoped, signal)).resolves.toHaveLength(94)
await expect(provider.callTool(name, {}, signal, unscoped)).resolves.toBeDefined()
expect(mocks.client.connect).toHaveBeenCalledOnce()
expect(mocks.client.listTools).toHaveBeenCalledOnce()
const options = mocks.Client.mock.calls[0]![1] as {
listChanged: { tools: { onChanged(error: Error | null): void } }
}
mocks.client.listTools.mockResolvedValue(catalog(96))
options.listChanged.tools.onChanged(null)
await expect(provider.listTools(scoped, signal)).resolves.toHaveLength(4 + scopedDataToolByName.size)
await expect(provider.listTools(unscoped, signal)).resolves.toHaveLength(100)
expect(mocks.client.listTools).toHaveBeenCalledTimes(2)
mocks.client.listTools.mockResolvedValue(catalog(100 - 4 - scopedDataToolByName.size))
options.listChanged.tools.onChanged(null)
await expect(provider.listTools(scoped, signal)).resolves.toHaveLength(100)
expect(mocks.client.connect).toHaveBeenCalledOnce()
} finally {
await provider.dispose()
}
})
it('enforces the aggregate MCP limit when a cached catalog gains scoped tools', async () => {
const gateway = {
getAvailableToolNames: vi.fn(() => [...scopedDataToolByName.keys()])
} as unknown as KnowledgeMcpGateway
mocks.client.listTools.mockResolvedValue({ tools: Array.from({ length: 40 }, (_, index) => ({
name: `remote_${index}`, inputSchema: { type: 'object' }
})) })
const provider = new ModelToolProvider(await createWorkspace(), [
createMcpServer(), { ...createMcpServer(), id: 'second', name: 'Second' }
], undefined, gateway)
const signal = new AbortController().signal
try {
await expect(provider.listTools(toolContext, signal)).resolves.toHaveLength(82)
await expect(provider.listTools({ ...toolContext, knowledgeCapabilityToken: 'scoped' }, signal))
.rejects.toThrow('100')
await expect(provider.listTools(toolContext, signal)).resolves.toHaveLength(82)
expect(mocks.client.listTools).toHaveBeenCalledTimes(2)
} finally {
await provider.dispose()
}
})
it('loads every paginated MCP tool before exposing the catalog', async () => {
const workspace = await createWorkspace()
mocks.client.listTools.mockImplementation(
@@ -2151,6 +2203,38 @@ describe('ModelToolProvider', () => {
).rejects.toThrow(message)
})
it.each([false, true])('preserves bounded native MCP error text (wrapped: %s)', async (wrapped) => {
mocks.client.listTools.mockResolvedValue({ tools: [{ name: 'read', inputSchema: { type: 'object' } }] })
const provider = new ModelToolProvider(await createWorkspace(), [createMcpServer()])
const signal = new AbortController().signal
try {
const tools = await provider.listTools(toolContext, signal)
const name = tools.find((tool) => tool.source === 'mcp')!.name
for (const detail of ['File not found: note.md', '\u4e2d'.repeat(100_000), '']) {
const result = { isError: true, content: [
{ type: 'text', text: detail }, { type: 'text', text: 'Use list_directory.' }
] }
mocks.client.callTool.mockResolvedValue(wrapped ? { toolResult: result } : result)
const error = await provider.callTool(name, {}, signal, toolContext).catch((reason: unknown) => reason)
expect(error).toBeInstanceOf(Error)
const message = (error as Error).message
expect(message).toContain('MCP Server')
expect(Buffer.byteLength(message)).toBeLessThanOrEqual(256 * 1024)
if (Buffer.byteLength(detail) > 256 * 1024) {
expect(message).toContain(detail.slice(0, 100))
expect(message).toContain('[GoodBuddy result truncated]')
expect(message).not.toContain('\ufffd')
} else {
expect(message).toContain(`${detail}\nUse list_directory.`)
}
}
mocks.client.callTool.mockResolvedValue({ isError: true, content: [] })
await expect(provider.callTool(name, {}, signal, toolContext)).rejects.toThrow('MCP Server')
} finally {
await provider.dispose()
}
})
it('counts encoded and decoded image data against the MCP result budget', async () => {
const workspace = await createWorkspace()
mocks.client.listTools.mockResolvedValue({
+30 -7
View File
@@ -6,7 +6,6 @@ import { builtinModelTools } from '../../shared/builtin-model-tools'
import {
goodbuddyConfigWriteToolNames,
magicNoteWriteToolNames,
maximumScopedToolCount,
scopedDataToolByName,
scopedReadToolNames,
type ScopedDataToolName
@@ -121,7 +120,7 @@ const scopedToolJsonSchemas = new Map(
[...scopedDataToolByName].map(([name, definition]) => {
const schema = z.toJSONSchema(
definition.inputSchema,
{ target: 'draft-7' }
{ target: 'draft-7', io: 'input' }
) as Record<string, unknown>
Reflect.deleteProperty(schema, '$schema')
return [name, schema] as const
@@ -539,7 +538,15 @@ function normalizeMcpResult(result: unknown): ModelToolResult {
}
const record = result as Record<string, unknown>
if (record.isError === true) {
throw new Error('MCP Server 报告工具执行失败')
const detail = Array.isArray(record.content)
? record.content.slice(0, MAX_MCP_CONTENT_BLOCKS).flatMap((item) =>
item?.type === 'text' && typeof item.text === 'string' ? [item.text] : []
).join('\n')
: ''
const message = `MCP Server 报告工具执行失败${detail ? `\n${detail}` : ''}`
throw new Error(Buffer.byteLength(message) > MAX_TOOL_RESULT_BYTES
? utf8Prefix(message, MAX_TOOL_RESULT_BYTES - Buffer.byteLength(TOOL_RESULT_TRUNCATION_MARKER)) + TOOL_RESULT_TRUNCATION_MARKER
: message)
}
if ('toolResult' in record) {
if (
@@ -734,7 +741,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
return tools
}
private getReservedToolCount(): number {
private getReservedToolCount(scopedToolCount = 0): number {
return (
this.getBuiltinTools().length +
(this.browserService ? browserToolNames.length : 0) +
@@ -743,7 +750,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
(this.programming.subagentService ? 1 : 0) +
(this.programming.ripgrepExecutablePath || this.programming.processService || this.programming.subagentService
? 1 : 0) +
(this.knowledgeGateway ? maximumScopedToolCount : 0)
scopedToolCount
)
}
@@ -1097,6 +1104,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
private async getMcpBindings(
signal: AbortSignal,
scopedToolCount: number,
refreshDynamic = false
): Promise<Map<string, McpToolBinding>> {
signal.throwIfAborted()
@@ -1162,8 +1170,12 @@ export class ModelToolProvider implements ModelToolProviderLike {
}
signal.throwIfAborted()
const bindings = new Map<string, McpToolBinding>()
const reservedToolCount = this.getReservedToolCount()
const reservedToolCount = this.getReservedToolCount(scopedToolCount)
for (const connection of connections) {
// Cached catalogs are shared; a request's grant must not shrink the cache.
if (connection.tools.length > MAX_MODEL_TOOLS - reservedToolCount) {
continue
}
for (const binding of connection.tools) {
if (bindings.size + reservedToolCount >= MAX_MODEL_TOOLS) {
throw new Error('直连模型工具总数超过 100 个安全限制')
@@ -1260,7 +1272,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
]
}
const processTools = await this.getProcessTool(context, signal)
const bindings = await this.getMcpBindings(signal, true)
const bindings = await this.getMcpBindings(signal, scopedTools.length, true)
const browserTools = this.getBrowserTools(context)
return [
...workspaceTools,
@@ -1394,6 +1406,17 @@ export class ModelToolProvider implements ModelToolProviderLike {
): Promise<ModelToolResult> {
signal.throwIfAborted()
assertToolAuthorizedForWorkMode(name, context)
if (name.startsWith('obsidian_') && scopedDataToolByName.has(name as ScopedDataToolName)) {
if (!this.knowledgeGateway || !context.knowledgeCapabilityToken) {
throw new Error('Obsidian 工具授权不可用')
}
return normalizeMcpResult(await this.knowledgeGateway.callObsidianTool(
context.knowledgeCapabilityToken,
name as Extract<ScopedDataToolName, `obsidian_${string}`>,
argumentsValue,
signal
))
}
if (name === 'generate_image') {
if (!context.imageToolBinding || !context.toolCallId) throw new Error('Image tool request binding is unavailable')
return createTextToolResult(JSON.stringify(await context.imageToolBinding.call(argumentsValue, context.toolCallId, signal)))
@@ -137,6 +137,54 @@ afterEach(async () => {
})
describe('CapabilityService', () => {
it('persists Obsidian scope independently of enablement and assignments', async () => {
const { service, filePath, builtinRoot, importedRoot } = await createService()
expect(await service.getObsidianSettings()).toEqual({ vaultPath: '' })
const saved = await service.updateObsidianSettings({ vaultPath: ' /notes/work ' })
expect(saved.obsidian).toEqual({ vaultPath: '/notes/work' })
expect(saved.builtinMcpServers).toContainEqual({
id: 'obsidian', enabled: false, assignments: ['model', 'opencode', 'continue']
})
await service.setBuiltinMcpServerEnabled('obsidian', true)
await service.setBuiltinMcpServerAssignments('obsidian', ['continue'])
const reloaded = new CapabilityService(filePath, builtinRoot, importedRoot, cipher)
expect(await reloaded.getObsidianSettings()).toEqual({ vaultPath: '/notes/work' })
expect((await reloaded.getSnapshot()).builtinMcpServers).toContainEqual({
id: 'obsidian', enabled: true, assignments: ['continue']
})
const settings = await reloaded.getObsidianSettings()
settings.vaultPath = '/mutated'
expect(await reloaded.getObsidianSettings()).toEqual({ vaultPath: '/notes/work' })
await expect(reloaded.updateObsidianSettings({ vaultPath: '', readOnly: true } as never)).rejects.toThrow()
expect((await reloaded.updateObsidianSettings({ vaultPath: ' ' })).obsidian).toEqual({ vaultPath: '' })
})
it.each([1, 5, 6])('loads version %i without enabling Obsidian or losing existing settings', async (version) => {
const { service, filePath, builtinRoot, importedRoot } = await createService()
await service.setBuiltinMcpServerEnabled('knowledge-base', false)
const stored = JSON.parse(await readFile(filePath, 'utf8'))
delete stored.obsidian
delete stored.builtinMcpServers.obsidian
stored.version = version
if (version === 5) delete stored.builtinMcpServers['builtin-browser']
if (version === 1) {
delete stored.builtinMcpServers
delete stored.webSearch
delete stored.computerCapabilities
}
await writeFile(filePath, JSON.stringify(stored))
const reloaded = new CapabilityService(filePath, builtinRoot, importedRoot, cipher)
const snapshot = await reloaded.getSnapshot()
expect(snapshot.obsidian).toEqual({ vaultPath: '' })
expect(snapshot.warnings).toBeUndefined()
expect(snapshot.builtinMcpServers).toContainEqual({
id: 'obsidian', enabled: false, assignments: ['model', 'opencode', 'continue']
})
if (version !== 1) expect(snapshot.builtinMcpServers).toContainEqual({
id: 'knowledge-base', enabled: false, assignments: ['model', 'opencode', 'continue']
})
})
it('derives image assignments from saved profiles without persisting or exposing connection data', async () => {
const image = { id: '00000000-0000-4000-8000-000000000001', name: 'Image', protocol: 'openai-images-generations', allowConversationInvocation: true, apiKey: 'must-not-leak' }
let profiles = [image]
@@ -293,6 +341,11 @@ describe('CapabilityService', () => {
id: 'builtin-browser',
enabled: false,
assignments: ['model', 'opencode', 'continue']
},
{
id: 'obsidian',
enabled: false,
assignments: ['model', 'opencode', 'continue']
}
]
})
+31 -2
View File
@@ -28,6 +28,8 @@ import {
mcpServerIdSchema,
mcpServerInputSchema,
mcpServerSummarySchema,
obsidianSettingsSchema,
type ObsidianSettings,
runtimeTargetSchema,
skillIdSchema,
skillSummarySchema,
@@ -132,7 +134,11 @@ const legacyBuiltinMcpServerStatesSchema = z
const builtinMcpServerStatesSchema =
legacyBuiltinMcpServerStatesSchema.extend({
'builtin-browser': builtinMcpServerStateSchema
'builtin-browser': builtinMcpServerStateSchema,
obsidian: builtinMcpServerStateSchema.default({
enabled: false,
assignments: ['model', 'opencode', 'continue']
})
})
const encryptedSecretSchema =
@@ -234,7 +240,8 @@ const storedCapabilitiesV5Schema = storedCapabilitiesV4Schema.extend({
const storedCapabilitiesSchema = storedCapabilitiesV5Schema.extend({
version: z.literal(6),
builtinMcpServers: builtinMcpServerStatesSchema
builtinMcpServers: builtinMcpServerStatesSchema,
obsidian: obsidianSettingsSchema.default({ vaultPath: '' })
})
type StoredCapabilitiesV1 = z.infer<typeof storedCapabilitiesV1Schema>
@@ -312,6 +319,7 @@ function defaultBuiltinMcpServerStates(
'knowledge-base': defaultState(),
'magic-notes': defaultState(),
'goodbuddy-config': defaultState(),
obsidian: { ...defaultState(), enabled: false },
'builtin-browser': {
enabled: browserEnabled,
assignments: ['model', 'opencode', 'continue']
@@ -324,6 +332,7 @@ function emptyStoredCapabilities(
): StoredCapabilities {
return {
version: 6,
obsidian: { vaultPath: '' },
skills: {},
builtinMcpServers: defaultBuiltinMcpServerStates(),
mcpServers: [],
@@ -822,6 +831,7 @@ export class CapabilityService {
storedCapabilitiesV1Schema.parse(raw)
loaded = {
version: 6,
obsidian: { vaultPath: '' },
skills: legacy.skills,
builtinMcpServers: defaultBuiltinMcpServerStates(),
mcpServers: legacy.mcpServers,
@@ -834,6 +844,7 @@ export class CapabilityService {
loaded = {
...legacy,
version: 6,
obsidian: { vaultPath: '' },
builtinMcpServers: defaultBuiltinMcpServerStates(
legacy.computerCapabilities['host-browser-control'].enabled
),
@@ -845,6 +856,7 @@ export class CapabilityService {
loaded = {
...legacy,
version: 6,
obsidian: { vaultPath: '' },
builtinMcpServers: defaultBuiltinMcpServerStates(
legacy.computerCapabilities['host-browser-control'].enabled
)
@@ -855,6 +867,7 @@ export class CapabilityService {
loaded = {
...legacy,
version: 6,
obsidian: { vaultPath: '' },
builtinMcpServers: defaultBuiltinMcpServerStates(
legacy.computerCapabilities['host-browser-control'].enabled
)
@@ -865,7 +878,9 @@ export class CapabilityService {
loaded = {
...legacy,
version: 6,
obsidian: { vaultPath: '' },
builtinMcpServers: {
obsidian: defaultBuiltinMcpServerStates().obsidian,
...legacy.builtinMcpServers,
'builtin-browser': {
enabled:
@@ -999,6 +1014,7 @@ export class CapabilityService {
? ['model', 'opencode', 'continue', 'deepseek-harness']
: []
},
obsidian: { ...state.obsidian },
skills: catalog
.map((skill) => ({
...skill,
@@ -1068,6 +1084,19 @@ export class CapabilityService {
.digest('hex')
}
async getObsidianSettings(): Promise<ObsidianSettings> {
return { ...(await this.load()).obsidian }
}
updateObsidianSettings(input: ObsidianSettings): Promise<CapabilitySnapshot> {
return this.queue(async () => {
const obsidian = obsidianSettingsSchema.parse(input)
const state = await this.load()
await this.persistUserChange({ ...state, obsidian })
return this.getSnapshot()
})
}
async getWebSearchCapabilityStatus(): Promise<{ enabled: boolean }> {
const state = await this.load()
return { enabled: state.webSearch.enabled }
+9 -1
View File
@@ -26,6 +26,7 @@ import type { AgentRuntime } from './agent/runtime'
import { SelectedRuntimeManager } from './agent/selected-runtime-manager'
import { LocalRuntimeRegistry } from './agent/local-runtime-registry'
import { KnowledgeMcpGateway } from './agent/knowledge-mcp-gateway'
import { ObsidianService } from './obsidian'
import {
applyRuntimeSelection,
getConfiguredRuntimeTarget,
@@ -965,11 +966,17 @@ if (hasSingleInstanceLock) {
applicationSettingsStore,
capabilityService
)
const obsidianService = new ObsidianService({
appPath: app.getAppPath(),
launchEnvironmentProvider:
startupLocalToolEnvironmentService.launchEnvironmentProvider
})
const startupKnowledgeGateway = new KnowledgeMcpGateway(
startupKnowledgeService,
{
magicNotesDatabase: startupAssistantDatabase,
configService: goodbuddyConfigService,
obsidianService,
browserService,
launchEnvironmentProvider:
startupLocalToolEnvironmentService.launchEnvironmentProvider
@@ -1477,7 +1484,8 @@ if (hasSingleInstanceLock) {
startupRemoteAgentServices.connectionManager,
terminalSessionManager,
startupLocalToolEnvironmentService,
imageGenerationService
imageGenerationService,
obsidianService
)
removeFeedbackIpcHandler = registerFeedbackIpcHandler(
mainWindow,
+182 -2
View File
@@ -1,14 +1,17 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type {} from '@testing-library/jest-dom/vitest'
import { EventEmitter } from 'node:events'
import {
mkdtemp,
mkdir,
readFile,
readdir,
realpath,
rm,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { delimiter, dirname, join } from 'node:path'
import { ipcChannels } from '../shared/ipc-channels'
import type {
AssistantProject,
@@ -36,6 +39,9 @@ import { KnowledgeService } from './knowledge/knowledge-service'
import { KnowledgeDatabase } from './knowledge/knowledge-database'
import { BrowserNavigationStoppedError } from './browser/browser-service'
import { RemotePromptCancelledError } from './agent/acp-remote-runtime'
import { CapabilityService } from './capabilities/capability-service'
import { ObsidianService } from './obsidian'
import { packageObsidianMcpVault } from './obsidian/package-mcpvault'
import {
registerIpcHandlers,
sendRemoteEnvironmentUpdateProgress,
@@ -5109,7 +5115,8 @@ describe('registerIpcHandlers agent terminal state', () => {
browserControl?: Record<string, unknown>,
imageService?: ImageGenerationService,
imageDatabase?: AssistantDatabase,
heartbeatEnabled?: boolean
heartbeatEnabled?: boolean,
obsidianService?: ObsidianService
) {
const assistantDatabase = {
createTask: vi.fn(),
@@ -5340,6 +5347,7 @@ describe('registerIpcHandlers agent terminal state', () => {
goodbuddyConfigService as never
]
args[43] = imageService
args[44] = obsidianService
const dispose = registerIpcHandlers(...args)
return {
approvalBroker,
@@ -8629,6 +8637,178 @@ describe('registerIpcHandlers agent terminal state', () => {
}
})
it('tests an unsaved Obsidian UI draft through registered IPC and bundled MCPVault, then saves and refreshes', async () => {
const { createElement } = await import('react')
const { render, fireEvent, waitFor, cleanup } = await import('@testing-library/react/pure')
// Load the renderer at test runtime without pulling its JSX project into the Node typecheck.
const rendererModule = '../renderer/src/McpSettingsSection'
const { McpSettingsSection } = await import(rendererModule) as {
McpSettingsSection: import('react').ComponentType<{ onOpenImageModelSettings: () => void }>
}
const { default: i18n } = await import('i18next')
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-ipc-obsidian-'))
let harness: ReturnType<typeof createHarness> | undefined
try {
const vaultPath = join(root, 'Draft Vault')
await mkdir(vaultPath)
await writeFile(join(vaultPath, 'existing.md'), '# Keep this note\n')
const appPath = join(root, 'app.asar')
await packageObsidianMcpVault(process.cwd(), join(root, 'app.asar.unpacked', 'out', 'main', 'obsidian-mcpvault'))
const service = new ObsidianService({
appPath,
launchEnvironmentProvider: () => ({ PATH: `${dirname(process.execPath)}${delimiter}${process.env.PATH ?? process.env.Path ?? ''}` })
})
const capabilities = new CapabilityService(
join(root, 'capabilities.json'), join(root, 'builtin'), join(root, 'imported'),
{ isAvailable: () => true, encrypt: (value) => Buffer.from(value), decrypt: (value) => value.toString() }
)
harness = createHarness(
{ capability: 'chat' }, undefined, 'always', undefined, false,
undefined, undefined, undefined, false, undefined,
{
getSnapshot: capabilities.getSnapshot.bind(capabilities),
updateObsidianSettings: capabilities.updateObsidianSettings.bind(capabilities)
},
undefined, undefined, undefined, undefined, service
)
const event = trustedEvent(harness.webContents)
const test = electronMocks.handlers.get(ipcChannels.capabilitiesTestObsidianConnection)!
const save = electronMocks.handlers.get(ipcChannels.capabilitiesUpdateObsidianSettings)!
const pick = electronMocks.handlers.get(ipcChannels.capabilitiesSelectObsidianVault)!
expect(test).toBeTypeOf('function')
expect(save).toBeTypeOf('function')
expect(pick).toBeTypeOf('function')
// Replace only Electron transport; UI, handlers, persistence and MCPVault are real.
const invoke = async (channel: string, input?: unknown) =>
electronMocks.handlers.get(channel)!(event, input)
vi.stubGlobal('goodbuddy', { capabilities: {
getSnapshot: () => invoke(ipcChannels.capabilitiesSnapshot),
testObsidianConnection: (input: unknown) => invoke(ipcChannels.capabilitiesTestObsidianConnection, input),
updateObsidianSettings: (input: unknown) => invoke(ipcChannels.capabilitiesUpdateObsidianSettings, input),
selectObsidianVault: () => invoke(ipcChannels.capabilitiesSelectObsidianVault)
} })
const view = render(createElement(McpSettingsSection, { onOpenImageModelSettings: vi.fn() }))
const testButton = view.getByRole('button', { name: i18n.t('integrations:mcp.obsidian.test') })
await waitFor(() => expect(testButton).toBeEnabled())
fireEvent.change(view.getByRole('combobox', { name: i18n.t('integrations:mcp.obsidian.scope') }), { target: { value: 'folder' } })
const pathInput = view.getByRole('textbox', { name: i18n.t('integrations:mcp.obsidian.path') })
fireEvent.change(pathInput, { target: { value: vaultPath } })
fireEvent.click(testButton)
await waitFor(() => {
expect(view.getByText(vaultPath)).toBeVisible()
expect(testButton).toBeEnabled()
}, { timeout: 10_000 })
expect(await capabilities.getObsidianSettings()).toEqual({ vaultPath: '' })
expect(harness.onRuntimeSettingsChanged).not.toHaveBeenCalled()
expect(await readdir(vaultPath)).toEqual(['existing.md'])
expect(await readFile(join(vaultPath, 'existing.md'), 'utf8')).toBe('# Keep this note\n')
electronMocks.showOpenDialog.mockResolvedValueOnce({ canceled: true, filePaths: [join(root, 'Cancelled Vault')] })
const pickButton = view.getByRole('button', { name: i18n.t('integrations:mcp.obsidian.select') })
fireEvent.click(pickButton)
await waitFor(() => {
expect(electronMocks.showOpenDialog).toHaveBeenCalledOnce()
expect(pickButton).toBeEnabled()
expect(pathInput).toHaveValue(vaultPath)
})
electronMocks.showOpenDialog.mockResolvedValueOnce({ canceled: false, filePaths: [vaultPath] })
await expect(pick(event)).resolves.toBe(vaultPath)
expect(electronMocks.showOpenDialog).toHaveBeenLastCalledWith(
expect.objectContaining({ webContents: harness.webContents }), { properties: ['openDirectory'] }
)
expect(await capabilities.getObsidianSettings()).toEqual({ vaultPath: '' })
fireEvent.click(view.getByRole('button', { name: i18n.t('integrations:mcp.editor.save') }))
await waitFor(() => {
expect(view.getByText(i18n.t('integrations:mcp.obsidian.savedScope', { path: vaultPath }))).toBeVisible()
expect(testButton).toBeEnabled()
})
expect(harness.onRuntimeSettingsChanged).toHaveBeenCalledOnce()
harness.onRuntimeSettingsChanged.mockClear()
await expect(save(event, { vaultPath: ` ${vaultPath} ` })).resolves.toMatchObject({
obsidian: { vaultPath },
builtinMcpServers: expect.arrayContaining([expect.objectContaining({ id: 'obsidian', enabled: false })])
})
expect(await capabilities.getObsidianSettings()).toEqual({ vaultPath })
expect(harness.onRuntimeSettingsChanged).toHaveBeenCalledOnce()
await expect(test(event, { vaultPath: join(root, 'missing') })).rejects.toThrow('missing or inaccessible')
expect(await capabilities.getObsidianSettings()).toEqual({ vaultPath })
expect(harness.onRuntimeSettingsChanged).toHaveBeenCalledOnce()
expect(() => save(event, { vaultPath: 42 })).toThrow()
await expect(test(event, { vaultPath: 42 })).rejects.toThrow()
const untrustedEvent = { ...event, sender: { ...harness.webContents, id: 99 } }
expect(() => save(untrustedEvent, { vaultPath })).toThrow('拒绝来自未知窗口的 IPC 请求')
await expect(test(untrustedEvent, { vaultPath })).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
await expect(pick(untrustedEvent)).rejects.toThrow('拒绝来自未知窗口的 IPC 请求')
} finally {
cleanup()
vi.unstubAllGlobals()
await harness?.dispose()
await rm(root, { recursive: true, force: true })
}
}, 60_000)
it.each([
{ enabled: false, assigned: true, workMode: 'ask' as const, access: undefined },
{ enabled: true, assigned: false, workMode: 'execute' as const, access: undefined },
{ enabled: true, assigned: true, workMode: 'ask' as const, access: 'read' },
{ enabled: true, assigned: true, workMode: 'execute' as const, access: 'write' }
])('routes Obsidian request grants with saved settings: %j', async ({ enabled, assigned, workMode, access }) => {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-ipc-obsidian-grant-'))
let harness: ReturnType<typeof createHarness> | undefined
try {
const capabilities = new CapabilityService(
join(root, 'capabilities.json'), join(root, 'builtin'), join(root, 'imported'),
{ isAvailable: () => true, encrypt: (value) => Buffer.from(value), decrypt: (value) => value.toString() }
)
const settings = { vaultPath: join(root, 'Saved Vault') }
await capabilities.updateObsidianSettings(settings)
if (enabled) await capabilities.setBuiltinMcpServerEnabled('obsidian', true)
if (!assigned) await capabilities.setBuiltinMcpServerAssignments('obsidian', ['continue'])
const received: AgentExecutionRequest[] = []
const gateway = {
grant: vi.fn(() => 'obsidian-capability'),
getAvailableToolNames: vi.fn(() => ['obsidian_list_vaults']),
drainReferences: vi.fn(() => []),
revoke: vi.fn()
}
const getObsidianSettings = vi.fn(() => capabilities.getObsidianSettings())
harness = createHarness({
runtimeId: 'model', capability: 'chat', supportsToolExecution: true,
async *run(request: AgentExecutionRequest) {
received.push(request)
yield { requestId: request.requestId, type: 'done' }
}
}, undefined, 'always', undefined, false, undefined, undefined, gateway, false, undefined, {
getEnabledBuiltinMcpServerIds: capabilities.getEnabledBuiltinMcpServerIds.bind(capabilities),
getObsidianSettings
})
const requestId = '00000000-0000-4000-8000-000000000026'
await harness.handler!(trustedEvent(harness.webContents), {
requestId, conversationId: 'obsidian-grant', prompt: 'List my vaults', workMode, knowledgeLibraryIds: []
})
await vi.waitFor(() => expect(harness!.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith(requestId, 'completed'))
expect(received).toHaveLength(1)
if (access) {
expect(gateway.grant).toHaveBeenCalledExactlyOnceWith(
requestId, [], expect.any(AbortSignal), 'none', undefined, undefined, undefined, undefined,
{ settings, access }
)
expect(received[0]?.knowledgeCapabilityToken).toBe('obsidian-capability')
expect(getObsidianSettings).toHaveBeenCalledOnce()
expect(gateway.revoke).toHaveBeenCalledWith('obsidian-capability')
} else {
expect(gateway.grant).not.toHaveBeenCalled()
expect(getObsidianSettings).not.toHaveBeenCalled()
expect(received[0]?.knowledgeCapabilityToken).toBeUndefined()
}
} finally {
await harness?.dispose()
await rm(root, { recursive: true, force: true })
}
})
it('grants read-only Magic Notes tools in Ask and write tools in Execute', async () => {
const runtime = {
runtimeId: 'model',
+74 -5
View File
@@ -127,6 +127,8 @@ import {
computerCapabilityToggleInputSchema,
mcpServerIdSchema,
mcpServerInputSchema,
obsidianSettingsSchema,
obsidianConnectionTestResultSchema,
skillAssignmentsInputSchema,
skillIdSchema,
skillImportKindSchema,
@@ -136,6 +138,7 @@ import {
type CapabilitySnapshot,
type CapabilityDiagnosticReport,
type McpServerTestResult,
type ObsidianSettings,
type WebSearchTestResult
} from '../shared/capability-contracts'
import {
@@ -158,6 +161,7 @@ import {
agentPackageInventorySchema
} from '../shared/agent-package-contracts'
import { assertTrustedSender } from './trusted-ipc-sender'
import type { ObsidianService } from './obsidian'
import { BrowserNavigationStoppedError } from './browser/browser-service'
import type { TerminalSessionManager } from './terminal/terminal-session-manager'
import { releaseNotesAcknowledgeSchema } from '../shared/release-notes-contracts'
@@ -576,6 +580,7 @@ async function grantScopedDataCapability(input: {
libraryIds: readonly string[]
magicNotesAccess: MagicNotesCapabilityAccess
configAccess?: MagicNotesCapabilityAccess
obsidian?: { settings: ObsidianSettings; access: 'read' | 'write' }
workspacePath?: string
browserConversationId?: string
browserTabId?: BrowserTabId
@@ -587,6 +592,7 @@ async function grantScopedDataCapability(input: {
signal: AbortSignal
}): Promise<ScopedDataCapability> {
const enabledServers = new Set(input.enabledServers)
const obsidian = enabledServers.has('obsidian') ? input.obsidian : undefined
const libraryIds = enabledServers.has('knowledge-base')
? input.libraryIds
: []
@@ -604,7 +610,8 @@ async function grantScopedDataCapability(input: {
(libraryIds.length === 0 &&
magicNotesAccess === 'none' &&
configAccess === 'none' &&
!browserConversationId)
!browserConversationId &&
!obsidian)
) {
return { toolNames: [] }
}
@@ -630,7 +637,19 @@ async function grantScopedDataCapability(input: {
authorizeApply: input.authorizeConfigApply
}
: undefined
const token = browserConversationId
const token = obsidian
? input.gateway.grant(
input.requestId,
libraryIds,
input.signal,
magicNotesAccess,
config,
browserConversationId,
browserTabId,
browserUsageLease,
obsidian
)
: browserConversationId
? input.gateway.grant(
input.requestId,
libraryIds,
@@ -1363,7 +1382,8 @@ export function registerIpcHandlers(
>,
terminalSessionManager?: TerminalSessionManager,
localToolEnvironmentService?: LocalToolEnvironmentService,
imageGenerationService?: ImageGenerationService
imageGenerationService?: ImageGenerationService,
obsidianService?: ObsidianService
): () => Promise<void> {
type ActiveRequestLease = {
controller: AbortController
@@ -2594,10 +2614,16 @@ export function registerIpcHandlers(
requestRuntimeTarget
)
: builtinMcpServerIdSchema.options.filter(
(id) => id !== 'builtin-browser'
(id): boolean => id !== 'builtin-browser' && id !== 'obsidian'
)
: []
const notesCapability = await grantScopedDataCapability({
obsidian: enabledBuiltinMcpServers.includes('obsidian')
? {
settings: await capabilityService.getObsidianSettings(),
access: schedule.workMode === 'execute' ? 'write' : 'read'
}
: undefined,
gateway: knowledgeGateway,
browserControl: browserControl as BrowserCapabilityControl | undefined,
runtime: requestRuntime,
@@ -4178,7 +4204,7 @@ export function registerIpcHandlers(
selectedRuntimeTarget
)
: builtinMcpServerIdSchema.options.filter(
(id) => id !== 'builtin-browser'
(id): boolean => id !== 'builtin-browser' && id !== 'obsidian'
)
: []
])
@@ -4211,6 +4237,12 @@ export function registerIpcHandlers(
: undefined
const imageToolAvailable = Boolean(await imageToolBinding?.describe())
const scopedCapability = await grantScopedDataCapability({
obsidian: enabledBuiltinMcpServers.includes('obsidian')
? {
settings: await capabilityService.getObsidianSettings(),
access: enrichedRequest.workMode === 'execute' ? 'write' : 'read'
}
: undefined,
gateway: knowledgeGateway,
browserControl: browserControl as BrowserCapabilityControl | undefined,
runtime: selectedRuntime,
@@ -8131,11 +8163,47 @@ export function registerIpcHandlers(
}
)
registerHandler(
ipcChannels.capabilitiesUpdateObsidianSettings,
(event, input: unknown): Promise<CapabilitySnapshot> => {
assertTrustedSender(event, window)
const settings = obsidianSettingsSchema.parse(input)
knowledgeGateway?.revokeObsidianCapabilities()
return refreshCapabilities(
capabilityService.updateObsidianSettings(settings)
)
}
)
registerHandler(
ipcChannels.capabilitiesTestObsidianConnection,
async (event, input: unknown) => {
assertTrustedSender(event, window)
const settings = obsidianSettingsSchema.parse(input)
if (!obsidianService) throw new Error('Obsidian 服务不可用')
return obsidianConnectionTestResultSchema.parse(
await obsidianService.testConnection(settings)
)
}
)
registerHandler(
ipcChannels.capabilitiesSelectObsidianVault,
async (event): Promise<string | null> => {
assertTrustedSender(event, window)
const result = await dialog.showOpenDialog(window, {
properties: ['openDirectory']
})
return result.canceled ? null : result.filePaths[0] ?? null
}
)
registerHandler(
ipcChannels.capabilitiesToggleBuiltinMcp,
(event, input: unknown): Promise<CapabilitySnapshot> => {
assertTrustedSender(event, window)
const value = builtinMcpServerToggleInputSchema.parse(input)
if (value.serverId === 'obsidian') knowledgeGateway?.revokeObsidianCapabilities()
return refreshCapabilities(
capabilityService.setBuiltinMcpServerEnabled(
value.serverId,
@@ -8150,6 +8218,7 @@ export function registerIpcHandlers(
(event, input: unknown): Promise<CapabilitySnapshot> => {
assertTrustedSender(event, window)
const value = builtinMcpServerAssignmentsInputSchema.parse(input)
if (value.serverId === 'obsidian') knowledgeGateway?.revokeObsidianCapabilities()
return refreshCapabilities(
capabilityService.setBuiltinMcpServerAssignments(
value.serverId,
+55
View File
@@ -0,0 +1,55 @@
# Obsidian Service
Construct `ObsidianService` from `./obsidian` with:
```ts
const obsidian = new ObsidianService({
appPath: app.getAppPath(),
launchEnvironmentProvider: localToolEnvironmentService.launchEnvironmentProvider
})
```
Optional `homePath`, `platform`, and `environment` overrides support isolated tests.
The provider is read on every launch. Its prepared PATH selects the managed `node`
shim, including Electron-as-Node; the service never invokes npm or npx.
## API
- `listVaults(settings): Promise<ObsidianVault[]>` returns `{ id, name, path }`.
- `testConnection(settings): Promise<{ vaults: ObsidianVault[]; toolCount: number }>`
opens each vault and lists its tools without writing. `toolCount` is the count
of unique upstream tool names, not the count multiplied by the number of vaults.
- `listTools(settings): Promise<Tool[]>` returns the complete upstream schemas
from the first vault. The gateway can wrap each tool with `vaultId`.
- `callTool(settings, { vaultId?, name, arguments }, signal?): Promise<CallToolResult>`
forwards the request unchanged. Omitting `vaultId` requires exactly one vault.
Settings accept `{ vaultPath?: string }`. A nonempty explicit absolute folder
bypasses discovery and receives a SHA-256 path ID. Empty settings discover all
registered vaults, retaining their registry IDs, regardless of their `open` flag.
Discovery reads `obsidian/obsidian.json` beneath `%APPDATA%` on Windows,
`~/Library/Application Support` on macOS, or `$XDG_CONFIG_HOME` (default
`~/.config`) on Linux. Missing registries, empty registries, invalid folders, and
unknown IDs produce explicit errors. Ordinary folders are accepted, as upstream
does not require an `.obsidian` directory.
Each operation creates and closes its own stdio client. Cancellation reaches both
initialization and tool requests, and cleanup is awaited even when the SDK also
initiates cleanup. No read-only option or tool filtering is added.
## Packaging
`@bitbonsai/mcpvault` is pinned to `0.16.0` as a build dependency. The Electron Vite
main build copies its production dependency closure to
`out/main/obsidian-mcpvault/node_modules`, preserving package-relative assets and
all included license files. Electron Builder unpacks that directory; the service
maps `app.asar` to `app.asar.unpacked` before launching the upstream `dist/server.js`.
This preserves `trash`'s Windows and macOS binaries and Linux implementation.
Runtime installation and network access are unnecessary.
`obsidian-service.test.ts` copies the same production package to an isolated
temporary app directory, launches it via a managed Node shim, and exercises
registry discovery, every advertised schema, connection probes, note read/write,
patches, frontmatter changes, listing, local-trash deletion, and cancellation.
These tests never discover or modify the user's real vaults. Cross-platform
discovery is tested with fixtures; native runtime execution follows the host OS.
+2
View File
@@ -0,0 +1,2 @@
export { ObsidianService } from './obsidian-service'
export type { ObsidianSettings, ObsidianVault, ObsidianServiceOptions } from './obsidian-service'
+162
View File
@@ -0,0 +1,162 @@
// @vitest-environment node
import { mkdtemp, mkdir, readFile, readdir, realpath, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { delimiter, isAbsolute, join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import spawn from 'cross-spawn'
import { expect, it } from 'vitest'
import { createLocalToolEnvironment } from '../local-tool-environment/local-tool-environment'
import { ObsidianService } from './obsidian-service'
import { packageObsidianMcpVault } from './package-mcpvault'
it('runs copied MCPVault over stdio using real managed Electron without npm or network connections', async (context) => {
const projectRoot = fileURLToPath(new URL('../../../', import.meta.url))
const electronPackage = join(projectRoot, 'node_modules', 'electron')
let electronExecutablePath: string
try {
const executable = (await readFile(join(electronPackage, 'path.txt'), 'utf8')).trim()
electronExecutablePath = await realpath(join(electronPackage, 'dist', executable))
expect((await stat(electronExecutablePath)).isFile()).toBe(true)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
context.skip('The node_modules/electron executable is not installed')
return
}
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-obsidian-electron-'))
try {
const location = relative(projectRoot, root)
expect(isAbsolute(location) || location === '..' || location.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)).toBe(true)
const appPath = join(root, 'app.asar.unpacked')
const vaultPath = join(root, 'Temporary Vault')
const binDirectory = join(root, 'managed bin')
await mkdir(vaultPath)
const output = join(appPath, 'out', 'main', 'obsidian-mcpvault')
await packageObsidianMcpVault(projectRoot, output)
const manifest = JSON.parse(await readFile(join(output, 'node_modules', '@bitbonsai', 'mcpvault', 'package.json'), 'utf8'))
expect(manifest.version).toBe('0.16.0')
// Only Windows shell/process utilities are needed alongside the managed shim.
const baseEnvironment: NodeJS.ProcessEnv = { PATH: '' }
if (process.platform === 'win32') {
const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT
expect(systemRoot).toBeTruthy()
baseEnvironment.SystemRoot = systemRoot
baseEnvironment.PATH = join(systemRoot!, 'System32')
baseEnvironment.COMSPEC = join(systemRoot!, 'System32', 'cmd.exe')
baseEnvironment.PATHEXT = '.COM;.EXE;.BAT;.CMD'
}
const prepared = await createLocalToolEnvironment({
binDirectory,
nodeSelection: { source: 'managed' },
pythonSelection: { source: 'managed' },
packagedNpmCliPath: join(root, 'absent-npm-cli.js'),
packagedNpxCliPath: join(root, 'absent-npx-cli.js'),
baseEnvironment,
probeTimeoutMs: 10_000
}, { electronExecutablePath })
expect(prepared.diagnostics.tools.node).toMatchObject({
available: true, source: 'managed', executablePath: electronExecutablePath,
shimPath: join(binDirectory, process.platform === 'win32' ? 'node.cmd' : 'node')
})
expect(prepared.environment.PATH?.split(delimiter)).toEqual([
binDirectory, ...(baseEnvironment.PATH ? [baseEnvironment.PATH] : [])
])
for (const name of ['npm', 'npx'] as const) {
expect(prepared.diagnostics.tools[name].available).toBe(false)
const probe = spawn.sync(name, ['--version'], {
cwd: vaultPath, env: { ...baseEnvironment, ...prepared.environment },
encoding: 'utf8', timeout: 5_000, windowsHide: true
})
expect((probe.error as NodeJS.ErrnoException | undefined)?.code).toBe('ENOENT')
}
const auditPath = join(root, 'children.jsonl')
const attemptsPath = join(root, 'network-attempts')
const preload = join(root, 'offline preload.cjs')
await writeFile(attemptsPath, '')
// NODE_OPTIONS is passed through the real service filter, not a mocked launcher.
// These Node API guards cover MCPVault's process, not an OS-wide network sandbox.
await writeFile(preload, `
const fs = require('node:fs');
const block = () => {
fs.appendFileSync(${JSON.stringify(attemptsPath)}, 'blocked\\n');
throw new Error('Network connections are forbidden in the Electron MCPVault test');
};
for (const name of ['node:http', 'node:https']) {
const module = require(name);
module.request = block;
module.get = block;
}
const net = require('node:net');
net.connect = net.createConnection = net.Socket.prototype.connect = block;
require('node:tls').connect = block;
globalThis.fetch = block;
require('node:module').syncBuiltinESMExports();
fs.appendFileSync(${JSON.stringify(auditPath)}, JSON.stringify({
pid: process.pid,
executable: fs.realpathSync(process.execPath),
electron: process.versions.electron,
node: process.versions.node,
runAsNode: process.env.ELECTRON_RUN_AS_NODE,
path: process.env.PATH,
cwd: process.cwd(),
entry: process.argv[1]
}) + '\\n');
`)
const service = new ObsidianService({
appPath: join(root, 'app.asar'),
homePath: root,
environment: { ...baseEnvironment, NODE_OPTIONS: `--require ${JSON.stringify(preload)}` },
launchEnvironmentProvider: () => prepared.environment
})
const settings = { vaultPath }
const connection = await service.testConnection(settings)
expect(connection).toEqual({ vaults: await service.listVaults(settings), toolCount: 18 })
expect(connection.vaults).toHaveLength(1)
expect(connection.vaults[0]?.path).toBe(vaultPath)
expect(await readdir(vaultPath)).toEqual([])
const content = '# Electron integration\nWritten through the real MCP stdio transport.'
const frontmatter = { title: 'Temporary Electron note', tags: ['integration'] }
const written = await service.callTool(settings, {
name: 'write_note', arguments: { path: 'electron.md', content, frontmatter }
})
expect(written.isError, JSON.stringify(written)).not.toBe(true)
const read = await service.callTool(settings, {
name: 'read_note', arguments: { path: 'electron.md' }
})
expect(read.isError, JSON.stringify(read)).not.toBe(true)
const note = JSON.parse(read.content.map((item) => item.type === 'text' ? item.text : '').join('\n'))
expect(note.content.trim()).toBe(content)
expect(note.fm).toEqual(frontmatter)
expect(await readFile(join(vaultPath, 'electron.md'), 'utf8')).toContain(content)
const children = (await readFile(auditPath, 'utf8')).trim().split('\n').map((line) => JSON.parse(line))
expect(children).toHaveLength(3)
for (const child of children) {
expect(child).toMatchObject({
executable: electronExecutablePath,
electron: expect.stringMatching(/^\d+\.\d+\.\d+/u),
node: prepared.diagnostics.tools.node.version,
runAsNode: '1',
path: prepared.environment.PATH,
cwd: vaultPath,
entry: join(output, 'node_modules', '@bitbonsai', 'mcpvault', 'dist', 'server.js')
})
await expect.poll(() => {
try {
process.kill(child.pid, 0)
return true
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false
throw error
}
}, { timeout: 5_000 }).toBe(false)
}
expect(await readFile(attemptsPath, 'utf8')).toBe('')
} finally {
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
await expect(stat(root)).rejects.toMatchObject({ code: 'ENOENT' })
}
}, 60_000)
+159
View File
@@ -0,0 +1,159 @@
// @vitest-environment node
import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { delimiter, join, resolve } from 'node:path'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { packageObsidianMcpVault } from './package-mcpvault'
import { ObsidianService } from './obsidian-service'
let root: string
let appPath: string
let vaultPath: string
let bin: string
let service: ObsidianService
const electronExecutable = createRequire(import.meta.url)('electron') as string
const provider = vi.fn(() => ({ PATH: process.platform === 'win32'
? `${bin}${delimiter}${join(process.env.SystemRoot ?? 'C:\\Windows', 'System32')}`
: `${bin}:/usr/bin:/bin` }))
beforeAll(async () => {
root = await mkdtemp(join(tmpdir(), 'goodbuddy-obsidian-'))
appPath = join(root, 'app.asar.unpacked')
vaultPath = join(root, 'Test Vault')
bin = join(root, 'managed-bin')
await mkdir(vaultPath)
await mkdir(bin)
const probe = join(root, 'child-pid.cjs')
await writeFile(probe, `if (!process.versions.electron) throw new Error('Expected Electron managed Node'); require('node:fs').appendFileSync(${JSON.stringify(join(root, 'pids'))}, process.pid + '\\n')`)
// Run the copied package through real Electron rather than the test runner's Node.
await writeFile(join(bin, process.platform === 'win32' ? 'node.cmd' : 'node'),
process.platform === 'win32'
? `@echo off\r\nset "ELECTRON_RUN_AS_NODE=1"\r\n"${electronExecutable}" --require "${probe}" %*\r\n`
: `#!/bin/sh\nELECTRON_RUN_AS_NODE=1 exec '${electronExecutable.replaceAll("'", "'\\''")}' --require '${probe.replaceAll("'", "'\\''")}' "$@"\n`,
{ mode: 0o755 })
await packageObsidianMcpVault(resolve('.'), join(appPath, 'out', 'main', 'obsidian-mcpvault'))
service = new ObsidianService({ appPath: join(root, 'app.asar'), launchEnvironmentProvider: provider })
}, 60_000)
afterEach(() => vi.restoreAllMocks())
afterAll(async () => { await rm(root, { recursive: true, force: true }) })
describe('Obsidian vault discovery', () => {
it.each(['win32', 'darwin', 'linux'] as const)('discovers all registered vaults on %s, including closed ones', async (platform) => {
const home = join(root, platform)
const config = platform === 'win32' ? join(home, 'roaming')
: platform === 'darwin' ? join(home, 'Library', 'Application Support') : join(home, 'xdg')
await mkdir(join(config, 'obsidian'), { recursive: true })
await writeFile(join(config, 'obsidian', 'obsidian.json'), JSON.stringify({ vaults: {
first: { path: vaultPath, open: true }, second: { path: bin, open: false }
} }))
const discovery = new ObsidianService({ appPath, launchEnvironmentProvider: provider, platform, homePath: home,
environment: { APPDATA: config, XDG_CONFIG_HOME: config } })
expect(await discovery.listVaults({})).toEqual([
{ id: 'first', name: 'Test Vault', path: vaultPath }, { id: 'second', name: 'managed-bin', path: bin }
])
await expect(discovery.callTool({}, { name: 'list_directory', arguments: {} })).rejects.toThrow('vaultId is required')
await expect(discovery.callTool({}, { vaultId: 'missing', name: 'list_directory', arguments: {} })).rejects.toThrow('Unknown Obsidian vaultId')
const probe = await discovery.testConnection({})
expect(probe.vaults).toHaveLength(2)
expect(probe.toolCount).toBeGreaterThan(15)
const result = await discovery.callTool({}, { vaultId: 'first', name: 'list_directory', arguments: {} })
expect(result.isError).not.toBe(true)
})
it('bypasses registry for an explicit folder and returns stable path IDs', async () => {
const explicit = new ObsidianService({ appPath, launchEnvironmentProvider: provider, homePath: join(root, 'missing'), environment: {} })
const first = await explicit.listVaults({ vaultPath })
expect(await explicit.listVaults({ vaultPath: join(vaultPath, '.') })).toEqual(first)
await expect(explicit.listVaults({})).rejects.toThrow('Cannot read Obsidian vault registry')
await expect(explicit.listVaults({ vaultPath: join(root, 'absent') })).rejects.toThrow('missing or inaccessible')
await expect(explicit.listVaults({ vaultPath: 'relative' })).rejects.toThrow('must be absolute')
const file = join(root, 'not-a-folder')
await writeFile(file, '')
await expect(explicit.listVaults({ vaultPath: file })).rejects.toThrow('not a folder')
})
it('reports empty and invalid registries explicitly', async () => {
const config = join(root, 'empty-config')
await mkdir(join(config, 'obsidian'), { recursive: true })
const registry = join(config, 'obsidian', 'obsidian.json')
const discovery = new ObsidianService({ appPath, launchEnvironmentProvider: provider, platform: 'linux', environment: { XDG_CONFIG_HOME: config } })
await writeFile(registry, '{}')
await expect(discovery.listVaults({ vaultPath: '' })).rejects.toThrow('No Obsidian vaults')
await writeFile(registry, '{"vaults":{"bad":{}}}')
await expect(discovery.listVaults({})).rejects.toThrow('Invalid Obsidian vault registry entry: bad')
})
})
describe('offline packaged MCPVault', () => {
it('ships exact upstream version, licenses, and native trash assets outside asar', async () => {
const modules = join(appPath, 'out', 'main', 'obsidian-mcpvault', 'node_modules')
expect(JSON.parse(await readFile(join(modules, '@bitbonsai', 'mcpvault', 'package.json'), 'utf8')).version).toBe('0.16.0')
expect(await readFile(join(modules, '@bitbonsai', 'mcpvault', 'LICENSE'), 'utf8')).toContain('MIT')
expect((await stat(join(modules, 'trash', 'lib', 'windows-trash.exe'))).size).toBeGreaterThan(0)
expect((await stat(join(modules, 'trash', 'lib', 'macos-trash'))).size).toBeGreaterThan(0)
})
it('lists every upstream tool and probes without modifying the vault', async () => {
const before = await readdir(vaultPath)
const tools = await service.listTools({ vaultPath })
expect(tools.map((tool) => tool.name)).toEqual(expect.arrayContaining([
'read_note', 'write_note', 'patch_note', 'delete_note', 'move_note', 'move_file',
'update_frontmatter', 'manage_tags', 'search_notes', 'list_directory'
]))
const source = await readFile(resolve('node_modules/@bitbonsai/mcpvault/dist/src/createServer.js'), 'utf8')
const names = [...source.matchAll(/name: "([a-z_]+)"/gu)].map((match) => match[1])
expect(tools.map((tool) => tool.name)).toEqual(names)
expect(await service.testConnection({ vaultPath })).toEqual({ vaults: await service.listVaults({ vaultPath }), toolCount: tools.length })
expect(await readdir(vaultPath)).toEqual(before)
expect(provider).toHaveBeenCalled()
})
it('writes, reads, patches, updates frontmatter and lists notes using only the copied package', async () => {
const call = async (name: string, args: Record<string, unknown>) => {
const result = await service.callTool({ vaultPath }, { name, arguments: args })
expect(result.isError, JSON.stringify(result)).not.toBe(true)
return result.content.map((item) => item.type === 'text' ? item.text : '').join('\n')
}
await call('write_note', { path: 'smoke.md', content: '# Original\nBody', frontmatter: { title: 'Smoke', tags: ['test'] } })
expect(await call('read_note', { path: 'smoke.md' })).toContain('# Original')
await call('patch_note', { path: 'smoke.md', oldString: 'Original', newString: 'Patched' })
await call('update_frontmatter', { path: 'smoke.md', frontmatter: { status: 'verified' } })
const note = JSON.parse(await call('read_note', { path: 'smoke.md' }))
expect(note.content).toContain('# Patched')
expect(note.fm).toEqual({ title: 'Smoke', tags: ['test'], status: 'verified' })
expect(await call('list_directory', {})).toContain('smoke.md')
expect(await readFile(join(vaultPath, 'smoke.md'), 'utf8')).toContain('status: verified')
await call('delete_note', { path: 'smoke.md', confirmPath: 'smoke.md', trashMode: 'local' })
})
it('does not launch for an already cancelled call', async () => {
const start = vi.spyOn(StdioClientTransport.prototype, 'start')
await expect(service.callTool({ vaultPath }, { name: 'list_directory', arguments: {} }, AbortSignal.abort())).rejects.toThrow()
expect(start).not.toHaveBeenCalled()
})
it('closes the actual child when cancelled during initialization or a tool request', async () => {
for (const method of ['initialize', 'tools/call']) {
const controller = new AbortController()
const send = StdioClientTransport.prototype.send
const transports = new Set<StdioClientTransport>()
const spy = vi.spyOn(StdioClientTransport.prototype, 'send').mockImplementation(function (this: StdioClientTransport, message) {
transports.add(this)
const result = send.call(this, message)
if ('method' in message && message.method === method) controller.abort(new Error('Cancelled test call'))
return result
})
await expect(service.callTool({ vaultPath }, { name: 'list_directory', arguments: {} }, controller.signal)).rejects.toThrow('Cancelled test call')
expect(transports.size).toBe(1)
for (const transport of transports) expect(transport.pid).toBeNull()
spy.mockRestore()
}
const pids = (await readFile(join(root, 'pids'), 'utf8')).trim().split('\n').map(Number)
await expect.poll(() => pids.filter((pid) => {
try { process.kill(pid, 0); return true } catch { return false }
})).toEqual([])
})
})
+152
View File
@@ -0,0 +1,152 @@
import { createHash } from 'node:crypto'
import { readFile, stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import { basename, isAbsolute, join, resolve } from 'node:path'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { CallToolResultSchema, type CallToolResult, type Tool } from '@modelcontextprotocol/sdk/types.js'
import { applyLaunchEnvironmentPath, buildCredentialFilteredUserEnvironment } from '../agent/process-environment'
import type { LaunchEnvironmentProvider } from '../local-tool-environment/launch-environment-provider'
export type ObsidianSettings = { vaultPath?: string }
export type ObsidianVault = { id: string; name: string; path: string }
export type ObsidianServiceOptions = {
/** Electron app.getAppPath(), or the project root in development. */
appPath: string
launchEnvironmentProvider: LaunchEnvironmentProvider
homePath?: string
platform?: NodeJS.Platform
environment?: NodeJS.ProcessEnv
}
export class ObsidianService {
constructor(private readonly options: ObsidianServiceOptions) {}
async listVaults(settings: ObsidianSettings): Promise<ObsidianVault[]> {
const platform = this.options.platform ?? process.platform
const vault = async (path: string, id?: string): Promise<ObsidianVault> => {
if (!isAbsolute(path)) throw new Error(`Obsidian vault path must be absolute: ${path}`)
const normalized = resolve(path)
let directory: boolean
try {
directory = (await stat(normalized)).isDirectory()
} catch (cause) {
throw new Error(`Obsidian vault folder is missing or inaccessible: ${normalized}`, { cause })
}
if (!directory) throw new Error(`Obsidian vault path is not a folder: ${normalized}`)
return {
id: id ?? createHash('sha256').update(platform === 'win32' ? normalized.toLowerCase() : normalized).digest('hex'),
name: basename(normalized) || normalized,
path: normalized
}
}
if (settings.vaultPath?.trim()) return [await vault(settings.vaultPath)]
const home = this.options.homePath ?? homedir()
const env = this.options.environment ?? process.env
const configRoot = platform === 'win32'
? env.APPDATA || join(home, 'AppData', 'Roaming')
: platform === 'darwin'
? join(home, 'Library', 'Application Support')
: env.XDG_CONFIG_HOME || join(home, '.config')
const configPath = join(configRoot, 'obsidian', 'obsidian.json')
let config: { vaults?: Record<string, { path?: unknown }> }
try {
config = JSON.parse(await readFile(configPath, 'utf8'))
} catch (cause) {
throw new Error(`Cannot read Obsidian vault registry: ${configPath}. Open a vault in Obsidian or set vaultPath.`, { cause })
}
const entries = Object.entries(config?.vaults ?? {})
if (!entries.length) throw new Error('No Obsidian vaults registered. Open a vault in Obsidian or set vaultPath.')
return Promise.all(entries.map(([id, entry]) => {
if (typeof entry?.path !== 'string' || !entry.path) {
throw new Error(`Invalid Obsidian vault registry entry: ${id}`)
}
return vault(entry.path, id)
}))
}
async testConnection(settings: ObsidianSettings): Promise<{ vaults: ObsidianVault[]; toolCount: number }> {
const vaults = await this.listVaults(settings)
const names = new Set<string>()
for (const vault of vaults) {
const tools = await this.withClient(vault, (client) => this.readTools(client))
for (const tool of tools) names.add(tool.name)
}
return { vaults, toolCount: names.size }
}
/** Unmodified upstream schemas; callers can wrap every tool with a vaultId argument. */
async listTools(settings: ObsidianSettings): Promise<Tool[]> {
const [vault] = await this.listVaults(settings)
return this.withClient(vault!, (client) => this.readTools(client))
}
async callTool(
settings: ObsidianSettings,
request: { vaultId?: string; name: string; arguments: Record<string, unknown> },
signal?: AbortSignal
): Promise<CallToolResult> {
signal?.throwIfAborted()
const vaults = await this.listVaults(settings)
const vault = request.vaultId === undefined
? vaults.length === 1 ? vaults[0] : undefined
: vaults.find((candidate) => candidate.id === request.vaultId)
if (!vault) {
throw new Error(request.vaultId === undefined
? 'Multiple Obsidian vaults are available; vaultId is required.'
: `Unknown Obsidian vaultId: ${request.vaultId}`)
}
return this.withClient(vault, async (client) => CallToolResultSchema.parse(await client.callTool(
{ name: request.name, arguments: request.arguments },
CallToolResultSchema,
{ signal }
)), signal)
}
private async readTools(client: Client): Promise<Tool[]> {
const tools: Tool[] = []
let cursor: string | undefined
do {
const page = await client.listTools({ cursor })
tools.push(...page.tools)
cursor = page.nextCursor
} while (cursor)
return tools
}
private async withClient<T>(vault: ObsidianVault, action: (client: Client) => Promise<T>, signal?: AbortSignal): Promise<T> {
signal?.throwIfAborted()
const appPath = this.options.appPath.replace(/\.asar(?=[\\/]|$)/u, '.asar.unpacked')
const serverPath = join(appPath, 'out', 'main', 'obsidian-mcpvault', 'node_modules', '@bitbonsai', 'mcpvault', 'dist', 'server.js')
await stat(serverPath).catch((cause) => {
throw new Error(`Bundled Obsidian MCPVault server is missing: ${serverPath}`, { cause })
})
const env = applyLaunchEnvironmentPath(
buildCredentialFilteredUserEnvironment(this.options.environment ?? process.env),
this.options.launchEnvironmentProvider
)
const client = new Client({ name: 'goodbuddy-obsidian', version: '1.0.0' })
const transport = new StdioClientTransport({
command: 'node',
args: [serverPath, vault.path],
cwd: vault.path,
env: Object.fromEntries(Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined)),
stderr: 'ignore'
})
// SDK initialization failures also close asynchronously; await that same cleanup.
const closeTransport = transport.close.bind(transport)
let closing: Promise<void> | undefined
transport.close = () => closing ??= closeTransport()
try {
signal?.throwIfAborted()
await client.connect(transport, { signal })
signal?.throwIfAborted()
return await action(client)
} finally {
// Close the transport even if initialization failed before Client took ownership.
await transport.close()
await client.close()
}
}
}
+41
View File
@@ -0,0 +1,41 @@
import { cp, mkdir, readFile, rm, stat } from 'node:fs/promises'
import { dirname, join, relative, resolve } from 'node:path'
/** Keep upstream module-relative assets and licenses, including trash's native helpers. */
export async function packageObsidianMcpVault(projectRoot: string, output: string): Promise<void> {
const modules = resolve(projectRoot, 'node_modules')
const visited = new Set<string>()
await rm(output, { recursive: true, force: true })
await mkdir(output, { recursive: true })
async function copyDependency(name: string, from: string): Promise<void> {
let directory = from
let source: string
for (;;) {
source = join(directory, 'node_modules', name)
if (await stat(join(source, 'package.json')).then(() => true, () => false)) break
const parent = dirname(directory)
if (parent === directory) throw new Error(`Missing MCPVault dependency: ${name}`)
directory = parent
}
if (visited.has(source)) return
visited.add(source)
const target = join(output, 'node_modules', relative(modules, source))
const manifest = JSON.parse(await readFile(join(source, 'package.json'), 'utf8')) as {
version: string
dependencies?: Record<string, string>
}
if (name === '@bitbonsai/mcpvault' && manifest.version !== '0.16.0') {
throw new Error(`Expected MCPVault 0.16.0, found ${manifest.version}`)
}
await cp(source, target, {
recursive: true,
filter: (path) => path === source || !relative(source, path).split(/[\\/]/u).includes('node_modules')
})
for (const dependency of Object.keys(manifest.dependencies ?? {})) {
await copyDependency(dependency, source)
}
}
await copyDependency('@bitbonsai/mcpvault', projectRoot)
}
+7
View File
@@ -53,6 +53,7 @@ import type {
CapabilitySnapshot,
ComputerCapabilityId,
McpServerTestResult,
ObsidianConnectionTestResult,
WebSearchTestResult
} from '../shared/capability-contracts'
import type {
@@ -1450,6 +1451,12 @@ const desktopApi: DesktopApi = {
serverId,
enabled
}) as Promise<CapabilitySnapshot>,
updateObsidianSettings: (input) =>
ipcRenderer.invoke(ipcChannels.capabilitiesUpdateObsidianSettings, input) as Promise<CapabilitySnapshot>,
testObsidianConnection: (input) =>
ipcRenderer.invoke(ipcChannels.capabilitiesTestObsidianConnection, input) as Promise<ObsidianConnectionTestResult>,
selectObsidianVault: () =>
ipcRenderer.invoke(ipcChannels.capabilitiesSelectObsidianVault) as Promise<string | null>,
setBuiltinMcpServerAssignments: (
serverId: BuiltinMcpServerId,
assignments
+16
View File
@@ -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 } })
+107 -8
View File
@@ -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} />
+8 -1
View File
@@ -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: {
+16
View File
@@ -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;
+14
View File
@@ -9,6 +9,7 @@ import {
} from './scoped-data-tools'
import { goodbuddyConfigTools } from './goodbuddy-config-tools'
import { builtinModelTools } from './builtin-model-tools'
import { obsidianScopedDataTools } from './obsidian-tools'
export type BuiltinMcpServerSummary = {
id: BuiltinMcpServerId
@@ -27,6 +28,19 @@ export type BuiltinMcpServerSummary = {
}
export const builtinMcpServers = [
{
id: 'obsidian',
name: 'Obsidian',
description: '读取、搜索和管理本机 Obsidian 仓库中的笔记、目录、标签与附件。',
tools: obsidianScopedDataTools.map(({ name, summary, access }) => ({
name,
description: summary,
access
})),
supportedAssignments: ['model', 'opencode', 'continue'],
access: 'mixed',
authorization: 'conversation-scoped'
},
{
id: 'knowledge-base',
name: '知识库',
+19 -2
View File
@@ -39,7 +39,8 @@ export const builtinMcpServerIdSchema = z.enum([
'knowledge-base',
'magic-notes',
'goodbuddy-config',
'builtin-browser'
'builtin-browser',
'obsidian'
])
export type BuiltinMcpServerId = z.infer<
typeof builtinMcpServerIdSchema
@@ -365,6 +366,21 @@ export type WebSearchCapability = z.infer<
typeof webSearchCapabilitySchema
>
export const obsidianSettingsSchema = z
.object({ vaultPath: z.string().trim().max(4096).default('') })
.strict()
export type ObsidianSettings = z.infer<typeof obsidianSettingsSchema>
export const obsidianConnectionTestResultSchema = z.object({
vaults: z.array(z.object({
id: z.string(),
name: z.string(),
path: z.string()
}).strict()),
toolCount: z.number().int().min(0)
}).strict()
export type ObsidianConnectionTestResult = z.infer<typeof obsidianConnectionTestResultSchema>
export const capabilitySnapshotSchema = z
.object({
skills: z.array(skillSummarySchema).max(256),
@@ -380,8 +396,9 @@ export const capabilitySnapshotSchema = z
.optional(),
builtinMcpServers: z
.array(builtinMcpServerStateSummarySchema)
.max(4)
.max(5)
.optional(),
obsidian: obsidianSettingsSchema.default({ vaultPath: '' }),
mcpServers: z.array(mcpServerSummarySchema).max(64),
webSearch: webSearchCapabilitySchema.optional(),
computerCapabilities: z
+5
View File
@@ -20,6 +20,8 @@ import type {
ComputerCapabilityId,
McpServerInput,
McpServerTestResult,
ObsidianSettings,
ObsidianConnectionTestResult,
SkillImportKind,
WebSearchTestResult
} from './capability-contracts'
@@ -2220,6 +2222,9 @@ export type DesktopApi = {
remove: (expertId: string) => Promise<void>
}
capabilities: {
updateObsidianSettings: (input: ObsidianSettings) => Promise<CapabilitySnapshot>
testObsidianConnection: (input: ObsidianSettings) => Promise<ObsidianConnectionTestResult>
selectObsidianVault: () => Promise<string | null>
getSnapshot: () => Promise<CapabilitySnapshot>
importSkill: (kind: SkillImportKind) => Promise<CapabilitySnapshot>
removeSkill: (skillId: string) => Promise<CapabilitySnapshot>
+3
View File
@@ -286,6 +286,9 @@ export const ipcChannels = {
expertsUpdate: 'experts:update',
expertsRemove: 'experts:remove',
capabilitiesSnapshot: 'capabilities:snapshot',
capabilitiesUpdateObsidianSettings: 'capabilities:obsidian:update',
capabilitiesTestObsidianConnection: 'capabilities:obsidian:test',
capabilitiesSelectObsidianVault: 'capabilities:obsidian:select-vault',
capabilitiesImportSkill: 'capabilities:skill:import',
capabilitiesRemoveSkill: 'capabilities:skill:remove',
capabilitiesToggleSkill: 'capabilities:skill:toggle',
+137
View File
@@ -0,0 +1,137 @@
// @vitest-environment node
import { readFileSync } from 'node:fs'
import { runInNewContext } from 'node:vm'
import { describe, expect, it } from 'vitest'
import { z } from 'zod'
import {
obsidianReadToolNames,
obsidianScopedDataTools,
obsidianWriteToolNames
} from './obsidian-tools'
import { scopedDataToolByName, scopedDataTools, scopedReadToolNames } from './scoped-data-tools'
type JsonSchema = {
type?: string
properties?: Record<string, JsonSchema>
required?: string[]
default?: unknown
[key: string]: unknown
}
// Read the actual tools/list catalog without starting stdio or touching a vault.
const upstreamSource = readFileSync(new URL(
'../../node_modules/@bitbonsai/mcpvault/dist/src/createServer.js', import.meta.url
), 'utf8')
const catalogSource = upstreamSource.match(/const tools = (\[[\s\S]*?\n\s*\]);/)
if (!catalogSource?.[1]) throw new Error('MCPVault tools/list catalog not found')
const upstreamTools = runInNewContext(catalogSource[1]) as {
name: string
inputSchema: JsonSchema
}[]
function schemaContract(schema: unknown): unknown {
if (Array.isArray(schema)) return schema.map(schemaContract)
if (schema === null || typeof schema !== 'object') return schema
return Object.fromEntries(Object.entries(schema)
// These differ intentionally: model-facing wording and strict outer envelopes.
.filter(([key, value]) => !['description', '$schema', 'additionalProperties', 'propertyNames'].includes(key)
&& !(key === 'required' && Array.isArray(value) && value.length === 0))
.map(([key, value]) => [key, schemaContract(value)]))
}
describe('Obsidian scoped tools', () => {
it('includes every upstream tool plus vault discovery in the shared provider catalog', () => {
expect(upstreamTools).toHaveLength(18)
expect(obsidianScopedDataTools.map((tool) => tool.name).sort()).toEqual([
'obsidian_list_vaults', ...upstreamTools.map((tool) => `obsidian_${tool.name}`)
].sort())
expect(new Set(obsidianScopedDataTools.map((tool) => tool.name)).size).toBe(19)
for (const tool of obsidianScopedDataTools) {
expect(scopedDataTools).toContain(tool)
expect(scopedDataToolByName.get(tool.name)).toBe(tool)
}
})
it.each(upstreamTools)('mirrors $name input fields, requiredness, defaults, and constraints', (upstream) => {
const tool = obsidianScopedDataTools.find((entry) => entry.name === `obsidian_${upstream.name}`)!
const local = z.toJSONSchema(tool.inputSchema, { io: 'input' }) as JsonSchema
expect(local.additionalProperties).toBe(false)
expect(local.properties?.vaultId).toMatchObject({ type: 'string' })
expect(local.required ?? []).not.toContain('vaultId')
const properties = { ...local.properties }
delete properties.vaultId
expect(schemaContract({ ...local, properties })).toEqual(schemaContract(upstream.inputSchema))
const minimal = Object.fromEntries((upstream.inputSchema.required ?? []).map((key) => {
const field = upstream.inputSchema.properties![key]!
return [key, Array.isArray(field.enum) ? field.enum[0]
: field.type === 'array' ? [] : field.type === 'object' ? {}
: field.type === 'number' ? 1 : 'note.md']
}))
const defaults = Object.fromEntries(Object.entries(upstream.inputSchema.properties ?? {})
.filter(([, field]) => 'default' in field)
.map(([key, field]) => [key, field.default]))
expect(tool.inputSchema.parse(minimal)).toEqual({ ...minimal, ...defaults })
expect(tool.inputSchema.parse({ ...minimal, vaultId: 'work' })).toEqual({
...minimal, ...defaults, vaultId: 'work'
})
expect(tool.inputSchema.safeParse({ ...minimal, readOnly: true }).success).toBe(false)
})
it.each([
['obsidian_list_vaults', {}],
['obsidian_list_directory', { path: '/', prettyPrint: false }],
['obsidian_list_all_tags', { prettyPrint: false }],
['obsidian_get_vault_stats', { recentCount: 5, prettyPrint: false }]
] as const)('allows %s without arguments', (name, expected) => {
expect(scopedDataToolByName.get(name)!.inputSchema.parse({})).toEqual(expected)
})
it('preserves explicit values and arbitrary nested frontmatter', () => {
const frontmatter = { title: ' Keep spaces ', nested: { tags: ['one'], enabled: false }, count: 0 }
expect(scopedDataToolByName.get('obsidian_write_note')!.inputSchema.parse({
path: 'note.md', content: '', frontmatter, mode: 'append'
})).toEqual({ path: 'note.md', content: '', frontmatter, mode: 'append' })
expect(scopedDataToolByName.get('obsidian_update_frontmatter')!.inputSchema.parse({
path: 'note.md', frontmatter, merge: false
})).toEqual({ path: 'note.md', frontmatter, merge: false })
expect(scopedDataToolByName.get('obsidian_search_notes')!.inputSchema.parse({
query: ' x ', limit: 0, searchContent: false, searchFrontmatter: true,
caseSensitive: true, pathPrefix: 'Projects', excludePaths: ['Archive'], prettyPrint: true
})).toEqual({
query: ' x ', limit: 0, searchContent: false, searchFrontmatter: true,
caseSensitive: true, pathPrefix: 'Projects', excludePaths: ['Archive'], prettyPrint: true
})
for (const trashMode of ['none', 'local', 'system']) {
expect(scopedDataToolByName.get('obsidian_delete_note')!.inputSchema.parse({
path: 'note.md', confirmPath: 'note.md', trashMode
})).toEqual({ path: 'note.md', confirmPath: 'note.md', trashMode })
}
})
it('enforces the upstream batch limit without imposing it on metadata requests', () => {
const paths = Array.from({ length: 11 }, (_, index) => `${index}.md`)
expect(scopedDataToolByName.get('obsidian_read_multiple_notes')!.inputSchema.safeParse({ paths }).success).toBe(false)
expect(scopedDataToolByName.get('obsidian_get_notes_info')!.inputSchema.safeParse({ paths }).success).toBe(true)
expect(scopedDataToolByName.get('obsidian_manage_tags')!.inputSchema.parse({
path: 'note.md', operation: 'list'
})).toEqual({ path: 'note.md', operation: 'list' })
})
it('classifies mixed tag management as write and pure listing as read', () => {
expect(obsidianWriteToolNames).toEqual([
'obsidian_write_note', 'obsidian_patch_note', 'obsidian_delete_note',
'obsidian_move_note', 'obsidian_move_file', 'obsidian_update_frontmatter', 'obsidian_manage_tags'
])
expect(obsidianReadToolNames).toHaveLength(12)
expect(obsidianReadToolNames).toEqual(expect.arrayContaining([
'obsidian_list_vaults', 'obsidian_list_directory', 'obsidian_list_all_tags'
]))
expect(scopedReadToolNames).toEqual(expect.arrayContaining(obsidianReadToolNames))
expect(scopedReadToolNames).not.toContain('obsidian_manage_tags')
for (const tool of obsidianScopedDataTools) {
if (tool.name !== 'obsidian_list_vaults') expect(tool.description).toContain('obsidian_list_vaults')
if (tool.access === 'write') expect(tool.description).toContain('explicit target vaultId')
}
})
})
+265
View File
@@ -0,0 +1,265 @@
import { z } from 'zod'
import type { ScopedDataToolDefinition } from './scoped-data-tools'
const vaultId = z.string().optional().describe(
'Vault ID returned by obsidian_list_vaults. May be omitted when only one vault is available.'
)
const path = z.string().describe('Path to the note relative to vault root')
const prettyPrint = z.boolean().default(false).describe('Format JSON response with indentation')
const vaultSelection = ' When multiple vaults are available, first call obsidian_list_vaults and select the intended vault using vaultId.'
const writeVaultSelection = vaultSelection + ' Writes require an explicit target vaultId when multiple vaults are available; ask the user if the target is unclear.'
// Mirror MCPVault 0.16.0 tools/list inputs. Defaults remain optional on input;
// strictness applies only to the argument envelope, not frontmatter keys.
export const obsidianScopedDataTools = [
{
name: 'obsidian_list_vaults',
displayName: 'List Obsidian vaults',
title: 'List available Obsidian vaults',
description: 'List available Obsidian vaults and their IDs. Call this first when multiple vaults are available, then pass the intended vaultId to other Obsidian tools. Writes require an explicit target vaultId when multiple vaults are available; ask the user if the target is unclear.',
summary: 'Discover available vaults and select a target.',
access: 'read',
inputSchema: z.object({}).strict()
},
{
name: 'obsidian_read_note',
displayName: 'Read Obsidian note',
title: 'Read an Obsidian note',
description: 'Read a note and its frontmatter from the vault.' + vaultSelection,
summary: 'Read note content and frontmatter.',
access: 'read',
inputSchema: z.object({ vaultId, path, prettyPrint }).strict()
},
{
name: 'obsidian_write_note',
displayName: 'Write Obsidian note',
title: 'Write an Obsidian note',
description: 'Create or write a note. mode defaults to overwrite; use append or prepend to add content. Supply frontmatter when needed.' + writeVaultSelection,
summary: 'Write, append, or prepend note content.',
access: 'write',
inputSchema: z.object({
vaultId, path,
content: z.string().describe('Content of the note'),
frontmatter: z.record(z.string(), z.unknown()).optional().describe('Frontmatter object'),
mode: z.enum(['overwrite', 'append', 'prepend']).default('overwrite')
}).strict()
},
{
name: 'obsidian_patch_note',
displayName: 'Patch Obsidian note',
title: 'Replace text in an Obsidian note',
description: 'Replace an exact string, including whitespace and line breaks, without rewriting the entire note. Multiple matches fail unless replaceAll is true.' + writeVaultSelection,
summary: 'Replace exact text in a note.',
access: 'write',
inputSchema: z.object({
vaultId, path,
oldString: z.string().describe('Exact string to replace, including whitespace and line breaks'),
newString: z.string().describe('Replacement string'),
replaceAll: z.boolean().default(false)
}).strict()
},
{
name: 'obsidian_list_directory',
displayName: 'List Obsidian directory',
title: 'List files and directories in an Obsidian vault',
description: 'List files and directories, including non-note filenames. Omit path to list the vault root.' + vaultSelection,
summary: 'Browse vault files and directories.',
access: 'read',
inputSchema: z.object({
vaultId,
path: z.string().default('/').describe('Path relative to vault root'),
prettyPrint
}).strict()
},
{
name: 'obsidian_delete_note',
displayName: 'Delete Obsidian note',
title: 'Delete an Obsidian note',
description: 'Delete a note. confirmPath must exactly match path. trashMode defaults to none (permanent deletion); local moves to the vault .trash and system moves to OS trash.' + writeVaultSelection,
summary: 'Delete a note using the selected trash mode.',
access: 'write',
inputSchema: z.object({
vaultId, path,
confirmPath: z.string().describe('Must exactly match path to confirm deletion'),
trashMode: z.enum(['none', 'local', 'system']).default('none')
}).strict()
},
{
name: 'obsidian_search_notes',
displayName: 'Search Obsidian notes',
title: 'Search Obsidian note content or frontmatter',
description: 'Search notes by text, optionally including frontmatter, restricting to pathPrefix, or excluding subtrees with excludePaths. Results default to 5; upstream caps results at 20.' + vaultSelection,
summary: 'Search notes in a vault or subtree.',
access: 'read',
inputSchema: z.object({
vaultId,
query: z.string().describe('Search query text'),
limit: z.number().default(5).describe('Maximum results (default 5, upstream maximum 20)'),
searchContent: z.boolean().default(true),
searchFrontmatter: z.boolean().default(false),
caseSensitive: z.boolean().default(false),
pathPrefix: z.string().optional().describe('Restrict search to this directory prefix, e.g. Projects/2026'),
excludePaths: z.array(z.string()).optional().describe('Directory prefixes to exclude, e.g. Archive or meta'),
prettyPrint
}).strict()
},
{
name: 'obsidian_move_note',
displayName: 'Move Obsidian note',
title: 'Move or rename an Obsidian note',
description: 'Move or rename a note within the vault. Existing destinations are not overwritten unless overwrite is true.' + writeVaultSelection,
summary: 'Move or rename a note.',
access: 'write',
inputSchema: z.object({
vaultId,
oldPath: z.string().describe('Current path of the note'),
newPath: z.string().describe('New path for the note'),
overwrite: z.boolean().default(false)
}).strict()
},
{
name: 'obsidian_move_file',
displayName: 'Move Obsidian file',
title: 'Move or rename any file in an Obsidian vault',
description: 'Move or rename any file, including binary attachments, within the vault. Files only, not directories. confirmOldPath and confirmNewPath must exactly match oldPath and newPath.' + writeVaultSelection,
summary: 'Move or rename a file with path confirmation.',
access: 'write',
inputSchema: z.object({
vaultId,
oldPath: z.string().describe('Current path of the file'),
newPath: z.string().describe('New path for the file'),
confirmOldPath: z.string().describe('Must exactly match oldPath'),
confirmNewPath: z.string().describe('Must exactly match newPath'),
overwrite: z.boolean().default(false)
}).strict()
},
{
name: 'obsidian_read_multiple_notes',
displayName: 'Read multiple Obsidian notes',
title: 'Read up to ten Obsidian notes',
description: 'Read up to 10 notes in a batch. Content and frontmatter are included by default; disable either when only the other is needed.' + vaultSelection,
summary: 'Read a batch of notes.',
access: 'read',
inputSchema: z.object({
vaultId,
paths: z.array(z.string()).max(10).describe('Note paths to read'),
includeContent: z.boolean().default(true),
includeFrontmatter: z.boolean().default(true),
prettyPrint
}).strict()
},
{
name: 'obsidian_update_frontmatter',
displayName: 'Update Obsidian frontmatter',
title: 'Update note frontmatter without changing content',
description: 'Update frontmatter while preserving note content. merge defaults to true; set false to replace existing frontmatter.' + writeVaultSelection,
summary: 'Merge or replace note frontmatter.',
access: 'write',
inputSchema: z.object({
vaultId, path,
frontmatter: z.record(z.string(), z.unknown()).describe('Frontmatter object to update'),
merge: z.boolean().default(true)
}).strict()
},
{
name: 'obsidian_get_notes_info',
displayName: 'Get Obsidian note metadata',
title: 'Get metadata for Obsidian notes',
description: 'Get metadata for multiple notes without reading their full content.' + vaultSelection,
summary: 'Read metadata for note paths.',
access: 'read',
inputSchema: z.object({
vaultId,
paths: z.array(z.string()).describe('Note paths to get metadata for'),
prettyPrint
}).strict()
},
{
name: 'obsidian_get_frontmatter',
displayName: 'Get Obsidian frontmatter',
title: 'Read an Obsidian note\'s frontmatter',
description: 'Extract frontmatter from a note without returning its content.' + vaultSelection,
summary: 'Read only note frontmatter.',
access: 'read',
inputSchema: z.object({ vaultId, path, prettyPrint }).strict()
},
{
name: 'obsidian_manage_tags',
displayName: 'Manage Obsidian tags',
title: 'Add, remove, or list tags on an Obsidian note',
description: 'Add, remove, or list tags on one note. Supply tags for add and remove; omit tags for list.' + writeVaultSelection,
summary: 'Manage tags on a note.',
access: 'write',
inputSchema: z.object({
vaultId, path,
operation: z.enum(['add', 'remove', 'list']),
tags: z.array(z.string()).optional().describe('Tags, required by upstream for add and remove operations')
}).strict()
},
{
name: 'obsidian_get_vault_stats',
displayName: 'Get Obsidian vault statistics',
title: 'Get Obsidian vault statistics and recent files',
description: 'Get total notes, folders, size, and recently modified files to understand vault scope before batch operations. recentCount defaults to 5; upstream caps it at 20.' + vaultSelection,
summary: 'Read vault totals and recent files.',
access: 'read',
inputSchema: z.object({
vaultId,
recentCount: z.number().default(5).describe('Recent files to return (default 5, upstream maximum 20)'),
prettyPrint
}).strict()
},
{
name: 'obsidian_list_all_tags',
displayName: 'List Obsidian tags',
title: 'List all tags across an Obsidian vault',
description: 'List frontmatter tags and inline hashtags with occurrence counts, deduplicated and sorted by frequency. Use this to discover existing tags before organizing notes.' + vaultSelection,
summary: 'Discover vault tags and their counts.',
access: 'read',
inputSchema: z.object({ vaultId, prettyPrint }).strict()
},
{
name: 'obsidian_wiki_link',
displayName: 'Read Obsidian wiki link',
title: 'Resolve and read an Obsidian wiki link',
description: 'Read the note referenced by a wiki link, e.g. [[Document Name|Display]] or [[folder/Document Name]]. Display text and #fragments are ignored. Do not include the .md extension. Duplicate basenames resolve vault-root first, then alphabetically by path, with alternatives reported.' + vaultSelection,
summary: 'Resolve a wiki link to note content.',
access: 'read',
inputSchema: z.object({
vaultId,
document: z.string().describe('Document name or wiki link; omit .md because upstream appends it'),
prettyPrint
}).strict()
},
{
name: 'obsidian_get_note_outline',
displayName: 'Get Obsidian note outline',
title: 'Get an Obsidian note\'s heading structure',
description: 'Get headings with levels, text, and line numbers. Use this first for large notes, then obsidian_read_note_lines to read the section needed.' + vaultSelection,
summary: 'Read heading structure and line numbers.',
access: 'read',
inputSchema: z.object({ vaultId, path, prettyPrint }).strict()
},
{
name: 'obsidian_read_note_lines',
displayName: 'Read Obsidian note lines',
title: 'Read a line range from an Obsidian note',
description: 'Read an inclusive, 1-indexed line range. Use after obsidian_get_note_outline to retrieve only the section needed.' + vaultSelection,
summary: 'Read selected lines of a note.',
access: 'read',
inputSchema: z.object({
vaultId, path,
startLine: z.number().describe('First line to read (1-indexed, inclusive)'),
endLine: z.number().describe('Last line to read (1-indexed, inclusive)'),
prettyPrint
}).strict()
}
] as const satisfies readonly ScopedDataToolDefinition[]
export const obsidianReadToolNames = obsidianScopedDataTools
.filter((tool) => tool.access === 'read')
.map((tool) => tool.name)
export const obsidianWriteToolNames = obsidianScopedDataTools
.filter((tool) => tool.access === 'write')
.map((tool) => tool.name)
+2
View File
@@ -1,4 +1,5 @@
import { z } from 'zod'
import { obsidianScopedDataTools } from './obsidian-tools'
import {
goodbuddyConfigTools,
type GoodBuddyConfigToolName
@@ -236,6 +237,7 @@ export const magicNoteScopedDataTools = [
export const scopedDataTools = [
...knowledgeScopedDataTools,
...magicNoteScopedDataTools,
...obsidianScopedDataTools,
...goodbuddyConfigTools
] as const