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.
163 lines
7.4 KiB
TypeScript
163 lines
7.4 KiB
TypeScript
// @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)
|