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:
@@ -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.
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ObsidianService } from './obsidian-service'
|
||||
export type { ObsidianSettings, ObsidianVault, ObsidianServiceOptions } from './obsidian-service'
|
||||
@@ -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)
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user