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:
@@ -103,6 +103,13 @@ npm run build
|
||||
|
||||
中间构建输出位于 `out`。该目录为生成内容,应修改源文件后重新构建,不要直接编辑。
|
||||
|
||||
Obsidian 接入的构建钩子将锁定的 npm `@bitbonsai/mcpvault@0.16.0` 及递归运行依赖、
|
||||
许可证和原生辅助资源复制到 `out/main/obsidian-mcpvault`,桌面打包时解包到
|
||||
`app.asar.unpacked`。运行时复用现有工具执行环境的 Node,不执行在线 npm 安装。
|
||||
生产组件自动化、Electron 托管 Node、Windows 设置与聊天 UI,以及受限网络下的
|
||||
Windows x64 解包应用已验证;物理断网、安装器及其他系统验收待完成。
|
||||
验证记录见 [Obsidian 进度](./docs/features/obsidian/progress.md)。
|
||||
|
||||
## 图标生成
|
||||
|
||||
应用图标源文件位于 `icons`。修改源图或图标处理逻辑后运行:
|
||||
|
||||
+11
@@ -581,6 +581,17 @@ records are listed separately and do not introduce another feature status.
|
||||
DeepSeek Harness unsupported. Built-in MCP uses short-lived local authority
|
||||
for the current request, and user configuration cannot loosen Ask/Execute
|
||||
read/write boundaries.
|
||||
- **Obsidian integration (implemented in source, acceptance in progress)**:
|
||||
Disabled by default, with all locally registered vaults as the default scope
|
||||
and an optional folder that can be tested before saving or enabling.
|
||||
Bundled MCPVault 0.16.0 provides all upstream abilities through 19 shared tools.
|
||||
Automated production-component checks, Electron managed-Node tests, Windows
|
||||
settings and chat UI checks, and restricted-network Windows x64 unpacked-app
|
||||
checks passed. Physical-offline, installer, and other-platform acceptance
|
||||
remain pending. DeepSeek
|
||||
Harness and remote execution remain unavailable. See the
|
||||
[feature docs and validation record](./docs/features/obsidian/README.md);
|
||||
this is not a release claim.
|
||||
- [x] **MCP Tools**: Explicitly enabled custom MCP can be assigned to direct
|
||||
models, GoodBuddy-managed OpenCode, Continue Agent Execute, and DeepSeek
|
||||
Harness, and loads only in Execute. Agent child processes receive only
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
- [x] **本机工具执行环境源码链路**:在“能力与工具 > 工具执行环境”中为本机 Skills 与 stdio MCP 选择 GoodBuddy 托管 Node.js、按需安装的托管 Python,或经过真实验证的自定义解释器;提供独立的原生地址/OSS 镜像选择、诊断、安装进度、取消和删除。新的本机 Runtime 与 stdio MCP 获得不可变 PATH 快照,不修改普通终端、系统环境或远程 Host。Windows x64 托管 Node 与原生地址 Python 已通过真实安装验证;托管 Python 归档按目标文件系统验证,Linux 保留大小写不同的合法路径,所有目标仍拒绝完全重复路径和不安全条目。
|
||||
- **本机工具执行环境验证记录**:六个平台/架构的 OSS 镜像对象已完成字节、大小和 SHA-256 公开回读验证。托管 Python 保持按需下载,不向 Desktop 发行包额外带入许可证文件。每个标准打包任务使用目标架构原生 Runner,并在打包前真实安装托管 Python,验证 SSL、pip 与 venv。不可变的 `v0.12.0` 尝试在发布前通过 Windows 与 macOS,但暴露了 Linux TAR 大小写处理问题;`v0.12.1` 在同步 Agent 发布时于原生打包前取消。已发布的 `v0.12.2` 随后通过全部六个原生打包任务及其托管 Python 安装探针。现有记录未覆盖真实 Skill/MCP、自定义解释器和运行中进程协调。
|
||||
- [x] **内置 MCP 按需接入**:知识库、魔法笔记、GoodBuddy 配置与内置浏览器 MCP 可分别启停,并可分配给直连模型、GoodBuddy 管理的 OpenCode 和 Continue;DeepSeek Harness 在设置中明确显示为暂不支持。内置 MCP 仅通过当前请求的短期本机权限提供,Ask / Execute 读写边界不受用户配置放宽。
|
||||
- **Obsidian 接入(源码已实现,验收中)**:默认关闭,默认发现全部本机注册仓库,也可指定文件夹并在保存或启用前测试。随包提供 MCPVault 0.16.0,19 个共享工具保留全部上游能力;生产组件自动化、Electron 托管 Node、Windows 设置与聊天 UI,以及受限网络下的 Windows x64 解包应用已验证。物理断网、安装器及其他系统验收待完成。DeepSeek Harness 与远程执行保持不可用,详见[功能文档与验证记录](./docs/features/obsidian/README.md);此项不表示已发布。
|
||||
- [x] **MCP Tools**:显式启用的自定义 MCP 可按 Runtime 分配给直连模型、GoodBuddy 管理的 OpenCode、Continue Agent Execute 和 DeepSeek Harness,并仅在 Execute 加载;Agent 子进程只获得按请求签发的本机回环权限,MCP 地址、命令和凭据保留在 Main,动态工具仍经过发现、执行记录与权限边界。直连模型与 Harness 的健康调用复用当前请求已发现的工具清单,不在每次调用前重复发现。
|
||||
- [x] **MCP Prompts 与 Resources 元数据**:MCP 测试仅在 Server 声明对应能力时发现有界的 Prompt、参数与 Resource 元数据,不读取 Resource 内容;Runtime 支持的 Prompt 可填入聊天草稿后继续编辑。OpenCode 可报告实验性 Resource 清单,Continue 当前版本明确不支持 Resources。
|
||||
- [x] **本地知识库**:支持文件、目录和网页导入、SQLite FTS5 检索及来源追溯。知识范围按对话保存,新对话默认不选择知识库;创建与检索高级参数默认折叠。文档区分可用、处理中和失败,支持打开来源、失败重试及移除来源前确认。
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
const { dirname, join, resolve } = require('node:path')
|
||||
const { copyFileSync, mkdtempSync, mkdirSync, rmSync } = require('node:fs')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
if (!process.versions.electron || process.env.ELECTRON_RUN_AS_NODE === '1') {
|
||||
const { spawnSync } = require('node:child_process')
|
||||
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-obsidian-ui-'))
|
||||
const env = { ...process.env, GOODBUDDY_OBSIDIAN_ACCEPTANCE_ROOT: root }
|
||||
if (process.argv.includes('--live') || process.argv.includes('--chat')) {
|
||||
env.GOODBUDDY_OBSIDIAN_ACCEPTANCE_LIVE = process.argv.includes('--live') ? '1' : '0'
|
||||
env.GOODBUDDY_OBSIDIAN_ACCEPTANCE_CHAT = process.argv.includes('--chat') ? '1' : '0'
|
||||
env.GOODBUDDY_OBSIDIAN_ACCEPTANCE_CONTINUE_ONLY = process.argv.includes('--continue-only') ? '1' : '0'
|
||||
require('esbuild').buildSync({ entryPoints: ['tests/obsidian-runtime-acceptance.ts'],
|
||||
outfile: join(root, 'runtime.cjs'), bundle: true, external: ['electron', 'ssh2', 'node-pty', 'koffi'], platform: 'node', format: 'cjs',
|
||||
banner: { js: `require = require('node:module').createRequire(${JSON.stringify(resolve('package.json'))});` } })
|
||||
}
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
try {
|
||||
const result = spawnSync(require('electron'), [__filename], {
|
||||
env, stdio: 'inherit', timeout: 300_000
|
||||
})
|
||||
if (result.error) throw result.error
|
||||
process.exitCode = result.status ?? 1
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 })
|
||||
}
|
||||
} else {
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const root = process.env.GOODBUDDY_OBSIDIAN_ACCEPTANCE_ROOT
|
||||
assert(root)
|
||||
const profile = join(root, 'profile')
|
||||
mkdirSync(profile)
|
||||
if (process.env.GOODBUDDY_OBSIDIAN_ACCEPTANCE_LIVE === '1' || process.env.GOODBUDDY_OBSIDIAN_ACCEPTANCE_CHAT === '1') {
|
||||
const settings = process.env.GOODBUDDY_ACCEPTANCE_SETTINGS ?? join(app.getPath('appData'), 'goodbuddy', 'runtime-settings.json')
|
||||
copyFileSync(join(dirname(settings), 'Local State'), join(profile, 'Local State'))
|
||||
}
|
||||
mkdirSync(join(root, 'Temporary Vault'))
|
||||
app.setPath('userData', profile)
|
||||
app.setAppPath(resolve('.'))
|
||||
const timer = setTimeout(() => { console.error('Acceptance timed out'); app.exit(1) }, 280_000)
|
||||
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
;(async () => {
|
||||
const chat = process.env.GOODBUDDY_OBSIDIAN_ACCEPTANCE_CHAT === '1'
|
||||
? await require(join(root, 'runtime.cjs')).prepareChat(root, profile) : undefined
|
||||
await import(pathToFileURL(resolve('out/main/index.js')).href)
|
||||
let window
|
||||
for (let attempt = 0; attempt < 120; attempt++) {
|
||||
window = BrowserWindow.getAllWindows().find(item => item.webContents.getURL().includes('index.html'))
|
||||
if (window && await window.webContents.executeJavaScript('Boolean(document.querySelector("button"))')) break
|
||||
await wait(250)
|
||||
}
|
||||
assert(window, 'Production main must open its BrowserWindow')
|
||||
assert.equal(app.getPath('userData'), profile)
|
||||
const preferences = window.webContents.getLastWebPreferences()
|
||||
assert.equal(preferences.contextIsolation, true)
|
||||
assert.equal(preferences.nodeIntegration, false)
|
||||
const js = code => window.webContents.executeJavaScript(code).catch(error => { throw new Error(`${code}: ${error.message}`) })
|
||||
const click = async expression => {
|
||||
window.focus()
|
||||
for (let i=0;i<100;i++) { if (await js(`Boolean(${expression})`)) break; await wait(100) }
|
||||
const point = await js(`(() => { const element = ${expression}; if (!element) throw new Error('Missing control'); element.scrollIntoView({block:'center'}); const r=element.getBoundingClientRect(); return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)} })()`)
|
||||
window.webContents.sendInputEvent({ type: 'mouseDown', button: 'left', clickCount: 1, ...point })
|
||||
window.webContents.sendInputEvent({ type: 'mouseUp', button: 'left', clickCount: 1, ...point })
|
||||
await wait(500)
|
||||
}
|
||||
if (chat) {
|
||||
await js(`(async()=>{const p=(await window.goodbuddy.projects.list())[0];await window.goodbuddy.projects.update(p.id,{name:p.name,description:p.description,rootPath:${JSON.stringify(join(root,'chat-workspace'))},defaultWorkMode:'execute',runtimeSelection:{provider:'opencode'}})})()`)
|
||||
window.webContents.reload()
|
||||
await wait(1000)
|
||||
}
|
||||
await click(`document.querySelector('button[aria-label="\u8bbe\u7f6e"]')`)
|
||||
await click(`Array.from(document.querySelectorAll('button')).find(b=>b.innerText.startsWith('\u80fd\u529b\u4e0e\u5de5\u5177'))`)
|
||||
await click(`Array.from(document.querySelectorAll('[role="tab"]')).find(b=>b.innerText==='MCP' && b.getClientRects().length)`)
|
||||
const card = `Array.from(document.querySelectorAll('.capability-card')).find(c=>c.querySelector('strong')?.textContent==='Obsidian')`
|
||||
const poll = async (expression, attempts=100) => {
|
||||
for (let i=0;i<attempts;i++) { if (await js(expression)) return; await wait(200) }
|
||||
console.error('VISIBLE_TABS',await js(`JSON.stringify(Array.from(document.querySelectorAll('[role="tab"]')).map(e=>({text:e.innerText,selected:e.getAttribute('aria-selected')})))`))
|
||||
throw new Error(`Timed out: ${expression}`)
|
||||
}
|
||||
await poll(`Boolean(${card})`)
|
||||
assert.equal(await js(`(${card}).querySelector('[role="switch"]').checked`), false)
|
||||
await click(`(${card}).querySelector('select')`)
|
||||
window.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'End' })
|
||||
window.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Return' })
|
||||
window.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Return' })
|
||||
await poll(`Boolean((${card}).querySelector('input:not([type="checkbox"])'))`)
|
||||
const input = `(${card}).querySelector('input:not([type="checkbox"])')`
|
||||
const vault = join(root, 'Temporary Vault')
|
||||
await click(input)
|
||||
await window.webContents.insertText(vault)
|
||||
await click(`(${card}).querySelectorAll('.capability-card__actions button')[1]`)
|
||||
await poll(`Boolean((${card}).querySelector('[role="status"]'))`)
|
||||
assert.match(await js(`(${card}).querySelector('[role="status"]').textContent`), /18/)
|
||||
assert.equal((await js('window.goodbuddy.capabilities.getSnapshot()')).obsidian.vaultPath, '')
|
||||
assert.equal(await js(`(${card}).querySelector('[role="switch"]').checked`), false)
|
||||
await click(`(${card}).querySelectorAll('.capability-card__actions button')[2]`)
|
||||
await poll(`!(${card}).querySelector('.primary-button').disabled`)
|
||||
assert.equal((await js('window.goodbuddy.capabilities.getSnapshot()')).obsidian.vaultPath, vault)
|
||||
await click(input)
|
||||
window.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'A', modifiers: ['control'] })
|
||||
window.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'A', modifiers: ['control'] })
|
||||
await window.webContents.insertText(join(root, 'missing-vault'))
|
||||
await click(`(${card}).querySelectorAll('.capability-card__actions button')[1]`)
|
||||
await poll(`Boolean((${card}).querySelector('[role="alert"]'))`)
|
||||
assert.equal(await js(`(${input}).value`), join(root, 'missing-vault'))
|
||||
assert.equal((await js('window.goodbuddy.capabilities.getSnapshot()')).obsidian.vaultPath, vault)
|
||||
console.log('PASS: native UI draft test (18 tools), no implicit save/enable, save through IPC, missing-folder error preserves draft')
|
||||
if (process.platform === 'win32') {
|
||||
await click(`(${card}).querySelectorAll('.capability-card__actions button')[0]`)
|
||||
const user32 = require('koffi').load('user32.dll')
|
||||
const getWindow = user32.func('uintptr_t __stdcall GetWindow(uintptr_t hwnd, uint32_t command)')
|
||||
const getPid = user32.func('uint32_t __stdcall GetWindowThreadProcessId(uintptr_t hwnd, _Out_ uint32_t *pid)')
|
||||
const postMessage = user32.func('bool __stdcall PostMessageW(uintptr_t hwnd, uint32_t message, uintptr_t wparam, intptr_t lparam)')
|
||||
const handle = Number(window.getNativeWindowHandle().readBigUInt64LE())
|
||||
const popup = getWindow(handle, 6)
|
||||
assert(popup && Number(popup) !== handle, 'Native folder dialog must open')
|
||||
const pid = [0]
|
||||
getPid(popup, pid)
|
||||
assert.equal(pid[0], process.pid, 'Only close this acceptance process dialog')
|
||||
assert(postMessage(popup, 0x10, 0, 0))
|
||||
await poll(`!(${card}).querySelector('.primary-button').disabled`)
|
||||
assert.equal(await js(`(${input}).value`), join(root, 'missing-vault'))
|
||||
console.log('PASS: real Windows folder dialog opened and cancelled without changing draft')
|
||||
}
|
||||
window.setMinimumSize(640, 480)
|
||||
window.setSize(760, 720)
|
||||
await wait(500)
|
||||
const geometry = await js(`(() => {const c=${card};const r=c.getBoundingClientRect();return {width:innerWidth,left:r.left,right:r.right,scroll:c.scrollWidth,client:c.clientWidth}})()`)
|
||||
assert(geometry.scroll <= geometry.client + 1 && geometry.right <= geometry.width && geometry.left >= 0, JSON.stringify(geometry))
|
||||
console.log('PASS: narrow card has no horizontal overflow', JSON.stringify(geometry))
|
||||
await click(input)
|
||||
window.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'A', modifiers: ['control'] })
|
||||
window.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'A', modifiers: ['control'] })
|
||||
await window.webContents.insertText(vault)
|
||||
await click(`(${card}).querySelectorAll('.capability-card__actions button')[1]`)
|
||||
await poll(`Boolean((${card}).querySelector('[role="status"]'))`)
|
||||
await js(`(${card}).querySelector('[role="switch"]').focus()`)
|
||||
window.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Space' })
|
||||
window.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Space' })
|
||||
await poll(`(${card}).querySelector('[role="switch"]').checked && !(${card}).querySelector('[role="switch"]').disabled`)
|
||||
await click(`(${card}).querySelector('button[aria-expanded]')`)
|
||||
await poll(`(${card}).querySelectorAll('.mcp-server-tools li').length === 19`)
|
||||
console.log('PASS: keyboard switch enables Obsidian; expanded production UI lists 19 tools')
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await js(`document.documentElement.dataset.theme=${JSON.stringify(theme)}`)
|
||||
const bounds = await js(`(()=>{const c=${card};const r=c.getBoundingClientRect();return {right:r.right,width:innerWidth,scroll:c.scrollWidth,client:c.clientWidth,background:getComputedStyle(c).backgroundColor}})()`)
|
||||
assert(bounds.right <= bounds.width && bounds.scroll <= bounds.client + 1)
|
||||
console.log(`PASS: ${theme} narrow Obsidian bounds`, JSON.stringify(bounds))
|
||||
}
|
||||
if (process.env.GOODBUDDY_OBSIDIAN_ACCEPTANCE_LIVE === '1') {
|
||||
await require(join(root, 'runtime.cjs')).run(root, await js('window.goodbuddy.capabilities.getSnapshot()'))
|
||||
}
|
||||
if (chat) {
|
||||
await click(`document.querySelector('.settings-panel__header button')`)
|
||||
window.setSize(1280, 850)
|
||||
try {
|
||||
const providers = process.env.GOODBUDDY_OBSIDIAN_ACCEPTANCE_CONTINUE_ONLY === '1' ? ['Continue'] : ['OpenCode', 'Continue']
|
||||
for (const provider of providers) {
|
||||
if (provider === 'Continue') {
|
||||
await poll(`!document.querySelector('button[title^="Runtime \u548c\u6a21\u578b"]').disabled`)
|
||||
await js(`document.querySelector('button[title^="Runtime \u548c\u6a21\u578b"]').click()`)
|
||||
await click(`Array.from(document.querySelectorAll('.runtime-picker__menu strong')).find(b=>b.innerText==='Continue Runtime')?.nextElementSibling`)
|
||||
}
|
||||
await poll(`!document.querySelector('button[title^="Runtime \u548c\u6a21\u578b"]').disabled`)
|
||||
const mode = `document.querySelector('button[aria-label^="\u5de5\u4f5c\u6a21\u5f0f"]')`
|
||||
if (!(await js(`(${mode}).innerText.includes('Execute')`))) {
|
||||
await click(mode)
|
||||
await click(`Array.from(document.querySelectorAll('[role="menuitemradio"]')).find(b=>b.innerText.startsWith('Execute'))`)
|
||||
}
|
||||
const marker = `CHAT_${provider}_${require('node:crypto').randomUUID()}`
|
||||
const before = chat.observations.length
|
||||
await click(`document.querySelector('textarea')`)
|
||||
await window.webContents.insertText(`Use only Obsidian tools. Write ${provider}-chat.md containing ${marker} using obsidian_write_note, then read it using obsidian_read_note. Reply with the content read. Do not use other tools.`)
|
||||
await click(`document.querySelector('button[aria-label="\u53d1\u9001"]')`)
|
||||
await poll(`Array.from(document.querySelectorAll('.message--assistant')).some(e=>e.innerText.includes(${JSON.stringify(marker)}))`,450)
|
||||
await poll(`!document.querySelector('button[title^="Runtime \u548c\u6a21\u578b"]').disabled`,450)
|
||||
assert((require('node:fs').readFileSync(join(vault,`${provider}-chat.md`),'utf8')).includes(marker))
|
||||
console.log(JSON.stringify({ provider, fullChatUi: 'passed', realModelHttpRequests: chat.observations.length-before }))
|
||||
}
|
||||
} finally {
|
||||
console.log(JSON.stringify({ chatModelHttpRequests: chat.observations.length }))
|
||||
await chat.close()
|
||||
}
|
||||
}
|
||||
await wait(1000)
|
||||
clearTimeout(timer)
|
||||
app.exit(0)
|
||||
})().catch(error => { console.error(error); clearTimeout(timer); app.exit(1) })
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { spawn } = require('node:child_process')
|
||||
const { mkdtemp, mkdir, readFile, readdir, writeFile, rm, stat } = require('node:fs/promises')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { join, resolve } = require('node:path')
|
||||
|
||||
async function main() {
|
||||
const executable = resolve(process.argv[2])
|
||||
await stat(executable)
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-obsidian-packaged-'))
|
||||
const profile = join(root, 'profile')
|
||||
const vault = join(root, 'Packaged Vault')
|
||||
await mkdir(profile)
|
||||
await mkdir(vault)
|
||||
const audit = join(root, 'node-audit.jsonl')
|
||||
const guard = join(root, 'offline.cjs')
|
||||
await writeFile(guard, `
|
||||
const fs = require('node:fs');
|
||||
const net = require('node:net');
|
||||
const original = net.Socket.prototype.connect;
|
||||
net.Socket.prototype.connect = function(...args) {
|
||||
const options = net._normalizeArgs(args)[0];
|
||||
const host = options.host || 'localhost';
|
||||
if (!options.path && !['localhost','127.0.0.1','::1'].includes(host)) throw new Error('Offline acceptance blocked network');
|
||||
return original.apply(this,args);
|
||||
};
|
||||
globalThis.fetch = async () => { throw new Error('Offline acceptance blocked fetch'); };
|
||||
require('node:module').syncBuiltinESMExports();
|
||||
fs.appendFileSync(${JSON.stringify(audit)}, JSON.stringify({entry:process.argv[1],exe:process.execPath,electron:process.versions.electron})+'\\n');
|
||||
`)
|
||||
const env = { ...process.env, NODE_OPTIONS: `--require ${JSON.stringify(guard)}`,
|
||||
PATH: join(process.env.SystemRoot, 'System32') }
|
||||
delete env.Path
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
const child = spawn(executable, [`--user-data-dir=${profile}`, '--remote-debugging-port=0', '--inspect=0',
|
||||
'--proxy-server=http://127.0.0.1:9', '--proxy-bypass-list=<-loopback>'], { env, stdio: ['ignore', 'ignore', 'pipe'] })
|
||||
let diagnostic = ''
|
||||
child.stderr.on('data', chunk => { diagnostic += chunk.toString() })
|
||||
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
let socket
|
||||
let mainSocket
|
||||
try {
|
||||
let port
|
||||
for (let i=0;i<100;i++) {
|
||||
try { port = (await readFile(join(profile, 'DevToolsActivePort'), 'utf8')).split('\n')[0]; break } catch { await wait(200) }
|
||||
}
|
||||
assert(port, 'Packaged app must expose debugging only in the isolated profile')
|
||||
const mainEndpoint = diagnostic.match(/ws:\/\/127\.0\.0\.1:\d+\/[-a-f0-9]+/u)?.[0]
|
||||
assert(mainEndpoint, 'The isolated Main inspector must be available')
|
||||
mainSocket = new WebSocket(mainEndpoint)
|
||||
await new Promise((resolve, reject) => { mainSocket.onopen=resolve;mainSocket.onerror=reject })
|
||||
// Packaged Electron strips NODE_OPTIONS at startup. Restore it only for this
|
||||
// disposable Main so its real MCPVault child receives the offline guard.
|
||||
await new Promise((resolve,reject) => {
|
||||
const timer=setTimeout(()=>reject(new Error('Main inspector timed out')),10_000)
|
||||
mainSocket.onmessage=message=>{const value=JSON.parse(message.data);if(value.id===1){clearTimeout(timer);if(value.error || value.result?.exceptionDetails)reject(new Error('Unable to set child network guard'));else resolve()}}
|
||||
mainSocket.send(JSON.stringify({id:1,method:'Runtime.evaluate',params:{expression:`process.env.NODE_OPTIONS=${JSON.stringify(env.NODE_OPTIONS)}`}}))
|
||||
})
|
||||
mainSocket.close()
|
||||
let target
|
||||
for (let i=0;i<100;i++) {
|
||||
target = (await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()).find(item => item.type === 'page' && item.url.includes('index.html'))
|
||||
if (target) break
|
||||
await wait(200)
|
||||
}
|
||||
assert(target, 'Packaged production renderer must load')
|
||||
socket = new WebSocket(target.webSocketDebuggerUrl)
|
||||
await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject })
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
socket.onmessage = message => { const value = JSON.parse(message.data); if (pending.has(value.id)) { const {resolve,reject}=pending.get(value.id); pending.delete(value.id); if (value.error) reject(new Error(value.error.message)); else resolve(value.result) } }
|
||||
const send = (method, params={}) => new Promise((resolve,reject) => {
|
||||
const key=++id
|
||||
const timer=setTimeout(()=>{pending.delete(key);reject(new Error(`CDP timed out: ${method}`))},10_000)
|
||||
pending.set(key,{
|
||||
resolve: value=>{clearTimeout(timer);resolve(value)},
|
||||
reject: error=>{clearTimeout(timer);reject(error)}
|
||||
})
|
||||
socket.send(JSON.stringify({id:key,method,params}))
|
||||
})
|
||||
const js = async expression => { const result=await send('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});assert(!result.exceptionDetails,JSON.stringify(result.exceptionDetails));return result.result.value }
|
||||
const poll = async expression => { for(let i=0;i<100;i++){ if(await js(expression))return;await wait(200) } throw new Error(`Timeout: ${expression}`) }
|
||||
const click = async expression => {
|
||||
await js(`(()=>{const e=${expression};if(!e)throw new Error('Missing control');e.scrollIntoView({block:'center'});e.focus();e.click()})()`)
|
||||
await wait(500)
|
||||
}
|
||||
await poll('Boolean(window.goodbuddy && document.querySelector("button"))')
|
||||
await stat(join(profile,'assistant.sqlite'))
|
||||
assert.equal(await js('typeof require'), 'undefined')
|
||||
await click(`document.querySelector('button[aria-label="\u8bbe\u7f6e"]')`)
|
||||
await poll(`Array.from(document.querySelectorAll('button')).some(b=>b.innerText.startsWith('\u80fd\u529b\u4e0e\u5de5\u5177'))`)
|
||||
await click(`Array.from(document.querySelectorAll('button')).find(b=>b.innerText.startsWith('\u80fd\u529b\u4e0e\u5de5\u5177'))`)
|
||||
await click(`Array.from(document.querySelectorAll('[role="tab"]')).find(b=>b.innerText==='MCP' && b.getClientRects().length)`)
|
||||
const card=`Array.from(document.querySelectorAll('.capability-card')).find(c=>c.querySelector('strong')?.textContent==='Obsidian')`
|
||||
await poll(`Boolean(${card})`)
|
||||
await js(`{const e=(${card}).querySelector('select');e.value='folder';e.dispatchEvent(new Event('change',{bubbles:true}));}`)
|
||||
await poll(`Boolean((${card}).querySelector('input:not([type="checkbox"])'))`)
|
||||
await click(`(${card}).querySelector('input:not([type="checkbox"])')`)
|
||||
await send('Input.insertText',{text:vault})
|
||||
await click(`(${card}).querySelectorAll('.capability-card__actions button')[1]`)
|
||||
await poll(`Boolean((${card}).querySelector('[role="status"]'))`)
|
||||
assert.match(await js(`(${card}).querySelector('[role="status"]').textContent`),/18/)
|
||||
await click(`(${card}).querySelectorAll('.capability-card__actions button')[2]`)
|
||||
await poll(`!(${card}).querySelector('.primary-button').disabled`)
|
||||
assert.equal((await js('window.goodbuddy.capabilities.getSnapshot()')).obsidian.vaultPath,vault)
|
||||
const bin=join(profile,'tool-environment','bin')
|
||||
const shims=(await readdir(bin,{recursive:true})).filter(name=>name.endsWith('node.cmd'))
|
||||
assert(shims.length>0)
|
||||
assert((await readFile(join(bin,shims[0]),'utf8')).includes(executable))
|
||||
console.log('PASS: packaged app.asar UI -> real IPC -> packaged MCPVault (18 tools), save, managed packaged Electron shim, PATH=System32, Chromium dead proxy')
|
||||
const records=(await readFile(audit,'utf8')).trim().split('\n').map(line=>JSON.parse(line))
|
||||
assert(records.some(item=>item.entry?.includes('app.asar.unpacked') && item.entry.endsWith('server.js') && item.exe===executable && item.electron))
|
||||
console.log('PASS: packaged MCPVault child loaded Node network guards; audited packaged executable and unpacked server entry')
|
||||
console.log('Boundary: MCPVault Node API guards + Chromium proxy; no OS firewall, physical disconnection, or installer execution')
|
||||
} finally {
|
||||
socket?.close()
|
||||
mainSocket?.close()
|
||||
if (child.exitCode === null) {
|
||||
const killer=spawn('taskkill',['/PID',String(child.pid),'/T','/F'],{stdio:'ignore'})
|
||||
await new Promise(resolve=>killer.once('exit',resolve))
|
||||
}
|
||||
await rm(root,{recursive:true,force:true,maxRetries:10,retryDelay:200})
|
||||
}
|
||||
}
|
||||
main().catch(error=>{console.error(error);process.exitCode=1})
|
||||
@@ -0,0 +1,32 @@
|
||||
# Obsidian 本机仓库接入
|
||||
|
||||
GoodBuddy 通过内置 MCPVault 访问本机 Obsidian 仓库,供会话读取、搜索和修改笔记。功能默认关闭;默认范围为本机已注册的全部仓库,也可指定一个文件夹。接入直接访问本地文件,不要求 Obsidian 正在运行或安装插件。
|
||||
|
||||
当前源码已实现主服务、设置 IPC、HTTP 网关和直连工具提供器。自动化覆盖 19 个共享工具;Windows 真实 Electron 设置操作、原生目录对话框取消、键盘开关、浅深主题窄窗口,以及 OpenCode / Continue 从聊天输入框发起的实际笔记读写均已通过。Windows x64 解包应用也通过设置到随包 MCPVault 的连接与保存验证,测试限制了子进程 Node 网络 API 和 Chromium 代理。物理断网、安装器及其他目标系统仍待验收;命令、模型调用次数和全量检查阻塞见[进度](./progress.md)。
|
||||
|
||||
## 文档职责
|
||||
|
||||
| 文档 | 唯一职责 |
|
||||
| --- | --- |
|
||||
| [产品需求](./prd.md) | 用户问题、范围、稳定需求 ID 和产品验收 |
|
||||
| [用户故事](./user-stories.md) | 使用场景及 Given/When/Then 验收 |
|
||||
| [功能逻辑](./logic-design.md) | 范围决策、状态转换、失败和取消规则 |
|
||||
| [技术设计](./technical-design.md) | 模块、契约、打包、验证,以及独立章节中的 UI 设计 |
|
||||
| [进度](./progress.md) | 已核对的实现事实、剩余工作和验证记录 |
|
||||
|
||||
界面仅在现有 MCP 设置中增加一个卡片,因此不另设 `ui-design.md`。技术设计的“UI 设计”章节承担信息结构、控件、反馈、无障碍和窄窗口适配职责;全局视觉规范仍由 [UI-DESIGN.md](../../../UI-DESIGN.md)定义。
|
||||
|
||||
## 术语与依赖
|
||||
|
||||
| 术语 | 含义 |
|
||||
| --- | --- |
|
||||
| 仓库(vault) | 一个本机笔记文件夹;显示名称不作为调用身份 |
|
||||
| 注册仓库 | 本机 Obsidian 的 `obsidian.json` 中登记的仓库,包含当前未打开的仓库 |
|
||||
| `vaultPath` | 保存的范围设置;空字符串代表注册仓库集合,非空代表指定文件夹 |
|
||||
| `vaultId` | 一次工具调用选择的仓库标识,与文件相对路径分开传递 |
|
||||
| MCPVault | npm 包 `@bitbonsai/mcpvault`,本功能固定使用 `0.16.0` |
|
||||
| 草稿 | 设置页面尚未保存的范围,可单独测试 |
|
||||
|
||||
共享目录包含 `obsidian_list_vaults` 和 18 个加 `obsidian_` 前缀的上游工具。连接测试显示的上游工具数仍为 18。保存范围、切换开关或修改分配会撤销此前含 Obsidian 的请求授权,规则见[功能逻辑](./logic-design.md#授权撤销与清理)。
|
||||
|
||||
Node 启动环境复用[工具执行环境](../local-tool-environment/README.md),不建立第二套解释器安装或下载配置。接入目标为直连模型、GoodBuddy 管理的本机 OpenCode 和 Continue;DeepSeek Harness 与远程执行路径沿用现有不可用边界。本功能的仓库位于桌面所在设备。
|
||||
@@ -0,0 +1,60 @@
|
||||
# 功能逻辑
|
||||
|
||||
## 状态维度
|
||||
|
||||
启用状态、Runtime 分配、已保存范围和页面草稿分别管理。启用状态不表示服务已连接,测试成功也不表示会话可调用。会话使用已保存范围;测试使用本次提交的草稿。
|
||||
|
||||
测试状态为未测试、测试中、成功或失败。修改草稿使先前测试结果失效;测试结果只描述该次请求,不持久化为服务健康状态。
|
||||
|
||||
## 规则与追溯
|
||||
|
||||
| ID | 规则 | 需求 / 故事 |
|
||||
| --- | --- | --- |
|
||||
| LR-1 | 仅启用操作改变启用状态;仅保存操作改变持久化范围;测试不改变二者。 | FR-1、FR-7;US-1~US-3 |
|
||||
| LR-2 | 非空指定路径优先,完全绕过注册信息;空路径读取全部注册条目,忽略条目的打开状态。 | FR-2、FR-6;US-1、US-2、US-6 |
|
||||
| LR-3 | 先解析当前范围,再校验 `vaultId`,成功后才启动目标仓库的工具进程。 | FR-3;US-5 |
|
||||
| LR-4 | 上游能力完整保留;启用、分配和应用现有工作模式由既有会话入口执行,不另加 Obsidian 权限层。 | FR-4、FR-9;US-4、US-9~US-11 |
|
||||
| LR-5 | 范围解析失败使本次请求失败,不跳过失效注册条目,不选择替代仓库。 | FR-8;US-7 |
|
||||
| LR-6 | 测试仅发现仓库并列举工具,逐仓库连接;工具数按上游名称去重,不按仓库数相乘。 | FR-7;US-1、US-2 |
|
||||
| LR-7 | 操作结束或取消后关闭本次 MCP 连接和子进程;取消不承诺撤销已落盘写入。 | FR-8;US-8 |
|
||||
| LR-8 | 保存范围、切换开关和修改分配在输入校验后、持久化前撤销含 Obsidian 的旧请求授权并取消在途调用;网关销毁等待活动调用清理。 | FR-10;US-12 |
|
||||
|
||||
## 仓库决策
|
||||
|
||||
| 条件 | 结果 |
|
||||
| --- | --- |
|
||||
| 指定路径非空 | 验证为可访问的绝对目录,得到单仓库范围 |
|
||||
| 默认范围且注册信息有效 | 使用全部注册条目,保留各自注册 ID |
|
||||
| 注册文件不可读、无条目、条目无路径或任一目录不可访问 | 返回对应错误;用户可修复注册信息或选择文件夹 |
|
||||
| 范围只有一个仓库且请求省略 ID | 使用唯一仓库 |
|
||||
| 范围超过一个仓库且请求省略 ID | 拒绝调用,要求明确 ID |
|
||||
| 请求提供 ID 且属于当前范围 | 使用该 ID 对应仓库 |
|
||||
| 请求 ID 不属于当前范围 | 拒绝调用,不回退到唯一仓库或首个仓库 |
|
||||
|
||||
工具清单描述与实际文件操作分开。服务可用首个仓库读取固定版本的工具 schema,这不赋予首个仓库任何默认写入地位。跨仓库搜索或修改由调用方对明确的仓库 ID 分别发起请求,不把一次请求广播到全部仓库。
|
||||
|
||||
## 设置转换
|
||||
|
||||
| 动作 | 草稿与结果 | 持久化影响 |
|
||||
| --- | --- | --- |
|
||||
| 选择文件夹 | 更新草稿,切换为指定文件夹,清除旧测试结果 | 无 |
|
||||
| 取消文件夹选择 | 保留原草稿 | 无 |
|
||||
| 切换范围或编辑路径 | 清除旧结果和关联错误;指定文件夹模式空路径时报错 | 无 |
|
||||
| 测试 | 使用提交时的草稿;忙碌期间禁止重复提交,结束后显示结果或错误 | 无 |
|
||||
| 保存成功 | 使用返回快照显示已保存范围,结束当前草稿编辑状态 | 仅范围 |
|
||||
| 保存失败 | 保留草稿并显示错误,可修改后重试 | 已保存范围不变 |
|
||||
| 启用、关闭或调整分配 | 更新对应状态,草稿不自动保存 | 仅对应状态 |
|
||||
|
||||
上表仅列持久化影响;涉及授权的副作用按 LR-8 执行。连接测试不提供独立取消按钮,其临时连接不持有会话授权。
|
||||
|
||||
## 授权撤销与清理
|
||||
|
||||
Main 对范围保存、开关和分配请求先验证来源及输入,再撤销现存的含 Obsidian 授权,随后保存设置。提交相同值也触发撤销。输入无效时在撤销前返回错误;持久化失败时原设置保留,但旧授权已经失效。草稿编辑、文件夹选择和连接测试均不触发此撤销。
|
||||
|
||||
撤销按整个请求授权执行。若同一个授权同时含 Obsidian 和知识库等能力,该授权整体失效;独立的不含 Obsidian 授权保留。后续请求按当前保存的范围、开关、分配和工作模式重新取得授权;关闭后不再签发 Obsidian 能力。
|
||||
|
||||
撤销会立即移除旧授权并发出取消信号,设置响应不等待子进程完全退出。工具调用负责在结束路径关闭 transport 和 client;网关销毁则等待活动调用完成清理,包含先前已撤销但仍在关闭的调用。取消过程中已落盘的内容保留。
|
||||
|
||||
## 完整性检查
|
||||
|
||||
规则已覆盖首次使用、草稿测试、保存、范围恢复、单仓库、多仓库、失效路径、旧配置、工作模式,以及设置变更对旧授权和在途调用的影响。当前没有待定的产品决策。实现及各层自动化、Electron 与安装包验证状态见[进度](./progress.md)。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 产品需求
|
||||
|
||||
## 用户问题与目标
|
||||
|
||||
用户需要在会话中查找本机已有的 Obsidian 笔记,并将整理结果写回指定仓库。手动配置 Node、MCP 命令或 Obsidian 插件会增加首次使用步骤。接入应随应用提供依赖,支持先测试范围,再决定保存和启用。
|
||||
|
||||
## 功能需求
|
||||
|
||||
以下 ID 在后续实施中保持稳定。场景验收见[用户故事](./user-stories.md),行为决策见[功能逻辑](./logic-design.md)。
|
||||
|
||||
| ID | 产品要求 |
|
||||
| --- | --- |
|
||||
| FR-1 | 首次使用及旧设置缺少 Obsidian 字段时默认关闭。保存范围、测试连接和启用是独立操作,不互相隐式触发。 |
|
||||
| FR-2 | 默认 `vaultPath` 为空,访问本机已注册的全部仓库,包含未打开的仓库。用户可改选一个本机文件夹,也可恢复默认范围。 |
|
||||
| FR-3 | 工具调用使用仓库 ID 定位。可用仓库超过一个时必须明确指定 `vaultId`;缺失或无效时返回错误,不按顺序、名称或最近使用情况选择写入目标。 |
|
||||
| FR-4 | 提供 MCPVault 0.16.0 的全部 18 个上游工具及其参数能力。不得增加 Obsidian 专用只读设置、工具过滤或额外审批;应用现有 Ask/Execute 工作模式照常生效。 |
|
||||
| FR-5 | MCPVault 及完整运行依赖随安装包交付,使用现有托管 Node,首次调用无需联网安装 npm 包,也无需用户安装系统 Node。 |
|
||||
| FR-6 | 不依赖正在运行的 Obsidian、社区插件、REST 服务或 API 密钥。指定文件夹可绕过注册信息,不要求该文件夹已在 Obsidian 注册。 |
|
||||
| FR-7 | 功能关闭时也可测试未保存的草稿。测试返回仓库名称、路径和上游工具数,不修改笔记、不保存范围、不启用功能。 |
|
||||
| FR-8 | 范围无效、仓库不可访问、内置依赖缺失或工具执行失败时说明原因并保留草稿。取消文件夹选择不清空输入;会话调用取消后释放对应子进程。 |
|
||||
| FR-9 | 沿用内置 MCP 的启用与 Runtime 分配方式。设置范围属于桌面端全局配置,具体会话仍受既有能力分配和工作模式约束。 |
|
||||
| FR-10 | 提交保存范围、切换 Obsidian 开关或修改分配时,撤销此前含 Obsidian 的请求授权并取消其在途调用,避免继续使用旧范围或旧分配。应用关闭须等待活动工具清理;不回滚已经落盘的修改。 |
|
||||
|
||||
“离线”指本地 MCP 服务的启动和文件操作不需要下载依赖;模型服务是否联网由模型配置决定。工具返回的笔记内容会进入当前会话处理流程,不能将本地接入描述为模型推理也完全离线。
|
||||
|
||||
## 范围与非目标
|
||||
|
||||
本期接入原有仓库文件,不复制为 GoodBuddy 知识库,不提供同步服务、仓库管理器或 Obsidian 编辑器。附件相关能力按上游工具提供的范围开放,不承诺未包含在上游中的附件编辑能力。文件删除等参数沿用上游语义;上游参数校验不构成新增产品审批。
|
||||
|
||||
注册仓库发现不扫描整块磁盘,不推断最近活动仓库。接入支持直连模型、GoodBuddy 管理的本机 OpenCode 和 Continue;DeepSeek Harness 和远程执行路径保持不可用。远程 Host 仓库发现、跨设备同步和仓库迁移不在本期范围。
|
||||
|
||||
## 产品验收
|
||||
|
||||
发布前须完成[技术设计中的验证矩阵](./technical-design.md#验证与实施顺序),并在[进度](./progress.md)记录真实结果。验收至少覆盖:关闭状态测试草稿、保存后重新进入设置、恢复全部仓库、多仓库明确选择、实际读取和写回、工作模式约束,以及安装包断网启动。
|
||||
|
||||
18 个上游工具按名称和参数契约逐项比对;加上仓库发现,共有 19 个共享工具。自动化验证分别覆盖注册 IPC、HTTP 网关和直连工具提供器,安装包及完整 Electron 界面仍单独验收。配置提交时还须验证旧授权立即失效、在途调用收到取消,以及网关销毁等待子进程清理完成。
|
||||
@@ -0,0 +1,109 @@
|
||||
# 实施进度
|
||||
|
||||
## 当前状态
|
||||
|
||||
2026-09-27:主服务、设置 UI、Main IPC、HTTP 网关和直连工具提供器已实现,共享目录共 19 个工具。Windows 真实 Electron 设置操作与 OpenCode / Continue 聊天输入框读写已通过;Windows x64 解包应用通过受限网络下的设置连接和保存验证。物理断网、安装器及其他系统验收仍待完成,全量检查存在下述无关失败;当前状态不表示已发布。
|
||||
|
||||
| 阶段 | 已核对事实 | 剩余工作 |
|
||||
| --- | --- | --- |
|
||||
| TS-1 配置与服务 | 默认关闭、范围独立保存、注册发现及 ID 路由已实现;复制后的真实 MCPVault 已用于文件操作和错误传播测试 | 跨平台安装包验证归入 TS-4 |
|
||||
| TS-2 设置 UI | 真实 Electron 窗口中验证草稿测试不保存、不启用,保存和错误保留输入,Windows 原生目录对话框取消、键盘开关、19 工具展开及浅深主题窄窗口;修复表单横向撑宽 | 其他系统原生窗口验收 |
|
||||
| TS-3 网关与 IPC | 19 工具、Ask/Execute、撤销、取消及清理自动化通过;OpenCode / Continue 真实会话及聊天输入框读写通过 | 安装后的聊天读写与其他系统验收 |
|
||||
| TS-4 打包与交付 | 当前源码 `build:bundle` 和 Windows x64 `--dir` 打包通过;实际 `app.asar` UI 连接随包 MCPVault 并保存,子进程加载网络 API 限制,PATH 仅含 System32 | 物理断网或 OS 级隔离、安装器、macOS/Linux/其他架构原生资源验收 |
|
||||
|
||||
DeepSeek Harness 和远程执行保持既有不可用边界。本轮产品代码仅修改 Obsidian 表单布局,没有改变 Agent 或远程 Runtime 路径。
|
||||
|
||||
## 提交快照验证
|
||||
|
||||
提交前从 HEAD 与 Obsidian 暂存内容生成独立目录,排除并行开发的原生终端、DSH 升级、IPC 拆分与 Magic Notes 改动。锁文件从 HEAD 重新生成,仅添加固定版本 MCPVault 及其依赖,没有升级或删除既有依赖项;独立目录安装该锁文件后验证。
|
||||
|
||||
`npm run typecheck`、`npm run lint` 和 `npm run build` 均通过。聚焦服务、Electron、网关、直连工具、配置、Continue 适配器及共享 UI 的 11 个文件共 237 项通过;完整 IPC、设置面板与构建测试的 3 个文件共 303 项通过。合计 14 个文件、540 项测试通过。本次没有重跑全量套件、安装包或真实模型调用,新增模型调用为 0。下文全仓失败记录来自此前混合工作区,不代表此提交快照的类型检查或构建结果。
|
||||
|
||||
## 2026-09-27 验收补充
|
||||
|
||||
以下结果来自独立临时应用配置、项目和 vault。模型凭据通过临时复制的 `runtime-settings.json` 与配套 `Local State` 在 Electron Main 中解密;源配置和用户 vault 均未修改。测试结束删除临时配置、凭据副本和笔记;Windows 解包构建与检查日志保留在系统临时目录的 `opencode` 下。没有提交、发布、安装器或 portable 构建。
|
||||
|
||||
| 验证项 | 实际结果与边界 |
|
||||
| --- | --- |
|
||||
| 聚焦测试 | 8 文件、133 项通过,含 `obsidian-electron.test.ts` 和 Continue 适配器;IPC 定向 5 项通过、152 项跳过。布局修正后 MCP 设置、共享 UI 与 Electron 服务共 43 项通过 |
|
||||
| `npm run build` / `npm run typecheck` | `build` 停在类型检查;末次类型检查仍为 SSH pool、SSH directory browser 和 SSH terminal 测试替身缺少 `forwardLoopback`。本轮脚本类型错误已修复;未修改并行开发的 SSH 测试 |
|
||||
| `npm run build:bundle` | 当前源码通过,布局修改后再次通过;不能据此将 `npm run build` 记为通过 |
|
||||
| `npm run lint` | 当前工作区通过 |
|
||||
| 分项类型检查 | `npx tsc --noEmit -p tsconfig.web.json` 与 `npx tsc --noEmit -p tsconfig.agent.json` 均通过 |
|
||||
| `npm test` | 首次 200 秒超时;随后后台运行完整结束,691.25 秒,444 文件通过、3 失败、11 跳过;5,274 测试通过、3 失败、83 跳过 |
|
||||
| 全量失败定位 | `activity-history-io.test.ts`:迁移重复添加 `source_json`;`magic-notes-capture.electron.test.ts`:宽度 0、溢出 82 的布局断言;`build-release.test.ts`:fixture 缺少 `@deepseek-ai/dsh/package.json`。均属于并行改动范围,本轮保留原状 |
|
||||
| 真实设置 UI | [Electron runner](../../../build/run-obsidian-electron-acceptance.cjs) 导入生产 Main、使用生产 Preload/Renderer 和原生鼠标键盘,覆盖测试 18 个上游工具、保存、错误、目录对话框取消、Space 启用和展开 19 个工具。未替换 IPC 或 ObsidianService |
|
||||
| 布局缺陷与修复 | 原表单复用了横向 `.capability-diagnostic`;759 像素内容视口中卡片右边界达到 1116 像素。改为 Obsidian 纵向表单、按钮及路径换行后,普通状态右边界 691,含测试结果和工具展开时 732;浅深主题均无卡片内部横向溢出 |
|
||||
| 真实 Runtime 会话 | [Runtime runner](../../../tests/obsidian-runtime-acceptance.ts) 使用真实 OpenCode / Continue、生产 HTTP 网关和 MCPVault,断言工具事件、模型返回和实际文件。两轮各 Runtime 各 3 次模型 HTTP 请求,共 12 次;第二轮使用 UI 保存并启用的能力快照 |
|
||||
| 完整聊天输入框 | `--chat` 中 OpenCode 实际发送、读写及回复验证通过,计 4 次模型 HTTP 请求;该次随后在 Continue 菜单定位处失败。修正选择器后 `--chat --continue-only` 通过,同样 4 次请求。合计本轮 20 次真实模型 HTTP 请求,其他启动、菜单和凭据准备失败均为 0 次。聊天证据来自开发生产 bundle;没有将独立 Runtime harness 冒充聊天 UI |
|
||||
| Windows x64 解包应用 | 正常 electron-builder 配置以 `--dir --publish never` 生成 `GoodBuddy.exe` 和 `app.asar`,含解包 MCPVault 及 `windows-trash.exe`。[打包 runner](../../../build/run-obsidian-packaged-acceptance.cjs) 通过 CDP 驱动真实页面控件,设置连接返回 18 工具,保存快照匹配临时 vault;托管 node shim 指向该打包 EXE |
|
||||
| 网络限制 | 打包 EXE 的 PATH 仅含 System32,Chromium 指向不可用代理;Electron 启动会移除 `NODE_OPTIONS`,首次尝试未加载 guard。随后仅在测试 Main 的本机 inspector 中恢复该变量,审计确认真实 MCPVault 子进程加载 guard,入口为 `app.asar.unpacked/.../server.js`。Node guard 拒绝非 loopback socket 和 fetch;不覆盖 OS 全部联网方式,不算物理断网或防火墙隔离 |
|
||||
|
||||
复现命令如下。打包命令须使用真正的 Node 可执行文件;本机 PATH 中的 Electron Node shim 会使 electron-builder CLI 误读 argv。`--live` 和 `--chat` 会调用真实文本模型;默认读取当前用户 GoodBuddy 模型配置,也可用 `GOODBUDDY_ACCEPTANCE_SETTINGS` 指向含配套 `Local State` 的测试配置目录中的设置文件。
|
||||
|
||||
```powershell
|
||||
npx vitest run src/shared/obsidian-tools.test.ts src/main/obsidian/obsidian-service.test.ts src/main/obsidian/obsidian-electron.test.ts src/main/agent/knowledge-mcp-gateway.obsidian.test.ts src/main/agent/model-tool-provider.obsidian.test.ts src/main/capabilities/capability-service.test.ts src/renderer/src/McpSettingsSection.test.tsx src/main/agent/continue-host-adapter.test.ts
|
||||
npx vitest run src/main/ipc.test.ts -t Obsidian
|
||||
npx vitest run src/renderer/src/McpSettingsSection.test.tsx src/renderer/src/WorkspacePrimitives.test.tsx src/main/obsidian/obsidian-electron.test.ts
|
||||
npm run build:bundle
|
||||
node build/run-obsidian-electron-acceptance.cjs
|
||||
node build/run-obsidian-electron-acceptance.cjs --live
|
||||
node build/run-obsidian-electron-acceptance.cjs --chat
|
||||
node build/run-obsidian-electron-acceptance.cjs --chat --continue-only
|
||||
# 以下占位路径替换为本机 Node、临时构建目录及打包 EXE。
|
||||
& "<node.exe>" build/run-electron-builder.cjs --win --x64 --dir --publish never --config.directories.output="<temporary-output>"
|
||||
node build/run-obsidian-packaged-acceptance.cjs "<temporary-output>/win-unpacked/GoodBuddy.exe"
|
||||
```
|
||||
|
||||
本机打包输出为 `%TEMP%/opencode/obsidian-package-acceptance/win-unpacked`;全量日志为 `%TEMP%/opencode/obsidian-full-tests.log` 与 `.err`。以下保留 2026-09-26 的历史记录,不能用其中旧阻塞覆盖本节最新结果。
|
||||
|
||||
本轮文档 deai-writing 扫描 6 文件,0 个阻断项、12 个复核项;复核项均为测试条件、状态或能力范围限定,通读后保留。`git diff --check` 通过。
|
||||
|
||||
## 源码与测试证据
|
||||
|
||||
2026-09-26 核对以下源码与测试。本轮重新执行的结果见验证记录;此前构建结果保留原来源,不作为当前工作区构建通过的证明。
|
||||
|
||||
| 证据 | 可支持的结论 |
|
||||
| --- | --- |
|
||||
| [obsidian-service.ts](../../../src/main/obsidian/obsidian-service.ts) 与[服务测试](../../../src/main/obsidian/obsidian-service.test.ts) | 注册发现、真实复制包后的读写、许可证与资源、工具集合、多仓库 ID 以及取消后进程清理 |
|
||||
| [capability-service.test.ts](../../../src/main/capabilities/capability-service.test.ts) | 范围持久化、旧字段缺省和拒绝 `readOnly` 参数 |
|
||||
| [ipc.test.ts](../../../src/main/ipc.test.ts) | 设置组件调用正式注册的 IPC 处理器、真实配置服务及 MCPVault;Electron 传输和目录对话框由测试替代,不是安装后完整界面验证 |
|
||||
| [共享工具测试](../../../src/shared/obsidian-tools.test.ts) | 19 工具集合和 18 个上游工具的字段、默认值、必填项与约束对应 |
|
||||
| [HTTP 网关测试](../../../src/main/agent/knowledge-mcp-gateway.obsidian.test.ts) | 实际 HTTP MCP 发现 19 工具,真实多仓库读写、删除及错误返回;另覆盖撤销、取消和等待 transport 关闭 |
|
||||
| [直连提供器测试](../../../src/main/agent/model-tool-provider.obsidian.test.ts) | 通过实际服务执行全部 19 个工具并核验文件,包含笔记移动、二进制附件移动和删除;覆盖 Ask、失效令牌、原生错误及关闭清理 |
|
||||
| [index.ts](../../../src/main/index.ts) 与[ipc.ts](../../../src/main/ipc.ts) | 正式启动实例化、依赖注入和 IPC 注册;范围保存、开关及分配提交在持久化前撤销旧 Obsidian 请求授权 |
|
||||
| [package-mcpvault.ts](../../../src/main/obsidian/package-mcpvault.ts) 与[构建配置](../../../electron.vite.config.ts) | 构建期递归复制运行依赖,并固定上游版本 |
|
||||
|
||||
## 验证记录
|
||||
|
||||
| 验证项 | 2026-09-26 结果及范围 |
|
||||
| --- | --- |
|
||||
| 聚焦生产组件自动化 | 本轮 7 个文件、132 项测试通过,包含 Continue 适配器;另运行 IPC 的 Obsidian 用例,5 项通过、151 项跳过 |
|
||||
| `npm run build` | 此前主实施方反馈通过;本轮未重跑 |
|
||||
| `npm run typecheck` | 首次运行报原生终端依赖未列入项目的 TS6307;并行改动后复跑,该错误已消失,当前阻塞为 `native-terminal-client.test.ts:33` 的 TS2352 类型转换错误。未修改这部分并行开发代码 |
|
||||
| `npm run lint` | 本轮通过;最终测试文件改动另行定向检查 |
|
||||
| `npm test` | 本轮在 200 秒超时;结束前出现数据库迁移、Magic Notes 等失败。全量套件没有通过结论;Continue 适配器定向复跑通过 |
|
||||
| Electron 托管 Node 冒烟 | 将服务测试改为真实 Electron 可执行文件,以 `ELECTRON_RUN_AS_NODE=1` 启动复制后的包,10 项通过。Windows 子进程 PATH 仅保留测试 shim 与 System32;覆盖发现、读写、局部修改、属性、回收站和取消清理。没有隔离网络,物理断网安装包验收仍待完成 |
|
||||
| 安装后完整 Electron UI 与跨平台验收 | 待完成;组件自动化使用替代 Electron 传输,不能覆盖原生窗口及安装环境 |
|
||||
|
||||
2026-09-26 本次文档修订新增 FR-10、LR-8、US-12,保留原有 ID。交付检查结果:
|
||||
|
||||
| 文档检查 | 结果 |
|
||||
| --- | --- |
|
||||
| 相对链接及锚点 | 6 份功能文档的 53 个链接、4 份跨功能文档新增的 5 个引用通过 |
|
||||
| 稳定 ID 与工具清单 | FR-1~FR-10、US-1~US-12、LR-1~LR-8、TS-1~TS-4 唯一声明通过;18 个上游工具和 19 个共享工具映射一致 |
|
||||
| deai-writing 审校 | 功能文档 0 个阻断项、11 个复核项,逐项核对后保留状态和能力边界限定;跨功能新增段落单独扫描,0 个阻断项、0 个复核项 |
|
||||
| 差异与空白 | `git diff --check` 通过;临时脚本另检查未跟踪的功能文档,无行尾空白或旧网关接入状态 |
|
||||
|
||||
本轮修改服务测试和验证记录;测试使用独立临时仓库,未修改用户笔记或应用设置。
|
||||
|
||||
最终定向 ESLint 和 `git diff --check` 通过。README 与本进度文档经 deai-writing 扫描,0 个阻断项、3 个复核项;复核项均描述验收范围或草稿状态,保留事实限定。
|
||||
|
||||
聚焦测试复现入口如下;这些命令用于定位相关用例,不是全量通过证明:
|
||||
|
||||
```powershell
|
||||
npx vitest run src/shared/obsidian-tools.test.ts src/main/obsidian/obsidian-service.test.ts src/main/agent/knowledge-mcp-gateway.obsidian.test.ts src/main/agent/model-tool-provider.obsidian.test.ts src/main/capabilities/capability-service.test.ts src/renderer/src/McpSettingsSection.test.tsx
|
||||
npx vitest run src/main/ipc.test.ts -t Obsidian
|
||||
```
|
||||
|
||||
剩余验收以本文顶部阶段表为准,按[验证矩阵](./technical-design.md#验证与实施顺序)补充物理断网、安装后及其他目标系统证据。配置变更触发取消与销毁等待的具体语义以[功能逻辑](./logic-design.md#授权撤销与清理)为准。
|
||||
@@ -0,0 +1,103 @@
|
||||
# 技术设计
|
||||
|
||||
本文负责实现架构及 UI 设计,落实 [FR-1~FR-10](./prd.md) 和 [LR-1~LR-8](./logic-design.md)。实现与验证状态统一在[进度](./progress.md)记录。
|
||||
|
||||
## 模块边界
|
||||
|
||||
| 层 | 职责与源码 |
|
||||
| --- | --- |
|
||||
| Renderer | [McpSettingsSection.tsx](../../../src/renderer/src/McpSettingsSection.tsx) 编辑草稿、显示已保存范围和测试结果;不直接访问文件系统或启动 Node |
|
||||
| Shared / Preload | [capability-contracts.ts](../../../src/shared/capability-contracts.ts)、[contracts.ts](../../../src/shared/contracts.ts)、[ipc-channels.ts](../../../src/shared/ipc-channels.ts) 和 [preload/index.ts](../../../src/preload/index.ts) 定义窄接口 |
|
||||
| 配置服务 | [capability-service.ts](../../../src/main/capabilities/capability-service.ts) 保存范围,沿用内置 MCP 的启用和分配状态 |
|
||||
| Main 服务 | [obsidian-service.ts](../../../src/main/obsidian/obsidian-service.ts) 发现仓库、连接上游、列工具和调用工具 |
|
||||
| 会话网关 | [knowledge-mcp-gateway.ts](../../../src/main/agent/knowledge-mcp-gateway.ts) 提供 HTTP MCP、仓库发现、目标路由、请求授权撤销及活动调用清理 |
|
||||
| 共享目录与直连 | [obsidian-tools.ts](../../../src/shared/obsidian-tools.ts) 定义 19 个工具;[model-tool-provider.ts](../../../src/main/agent/model-tool-provider.ts) 发现并调用同一目录,保留上游错误详情 |
|
||||
| 构建 | [package-mcpvault.ts](../../../src/main/obsidian/package-mcpvault.ts) 复制依赖树,[electron.vite.config.ts](../../../electron.vite.config.ts) 调用打包函数,[package.json](../../../package.json) 配置版本及解包资源 |
|
||||
|
||||
正式链路为设置页面经 Preload、受信任的 Main IPC 处理器到配置服务或 ObsidianService;会话经既有能力入口到 ObsidianService,再通过 stdio 到 MCPVault。[index.ts](../../../src/main/index.ts) 创建服务并注入工具启动环境、网关及 IPC;[ipc.ts](../../../src/main/ipc.ts) 已注册设置、测试和目录选择处理器,并在会话入口按保存设置及工作模式签发请求授权。
|
||||
|
||||
## 配置与 IPC
|
||||
|
||||
`obsidianSettingsSchema` 是严格对象,仅有 `vaultPath`:字符串,去除首尾空白,最大 4096 字符,默认 `''`。不接收 `readOnly`。持久化范围为桌面全局,沿用 CapabilityService 的存储,不增加数据库或配置副本。
|
||||
|
||||
| 接口 | 输入与结果 | 副作用 |
|
||||
| --- | --- | --- |
|
||||
| `updateObsidianSettings` | `{ vaultPath }` → `CapabilitySnapshot` | 先撤销旧 Obsidian 请求授权,再保存范围;启用与分配值不变 |
|
||||
| `testObsidianConnection` | 草稿 `{ vaultPath }` → `{ vaults, toolCount }` | 临时连接,结束即清理,不保存设置 |
|
||||
| `selectObsidianVault` | 无输入 → 目录字符串或 `null` | Main 打开系统目录选择器,取消返回 `null` |
|
||||
|
||||
`vaults` 的元素为 `{ id, name, path }`。Main 处理器验证调用来源和共享 schema。范围保存只验证设置形状;目录存在性由发现或测试校验,保存成功不能显示为连接成功。已有配置缺少新字段时补默认值,其他能力设置保持原值。
|
||||
|
||||
设置更新、内置 MCP 开关及分配处理器在参数校验后调用 `knowledgeGateway.revokeObsidianCapabilities()`;后两者只在 `serverId === 'obsidian'` 时调用。撤销发生在持久化前,之后通过既有 `refreshCapabilities` 刷新配置;失败语义由 [LR-8](./logic-design.md#授权撤销与清理)定义。
|
||||
|
||||
## 仓库发现与路由
|
||||
|
||||
注册文件位置如下,优先使用相应环境变量:
|
||||
|
||||
| 系统 | 注册文件 |
|
||||
| --- | --- |
|
||||
| Windows | `%APPDATA%/obsidian/obsidian.json`,缺少变量时使用用户目录下 `AppData/Roaming` |
|
||||
| macOS | `~/Library/Application Support/obsidian/obsidian.json` |
|
||||
| Linux | `$XDG_CONFIG_HOME/obsidian/obsidian.json`,缺少变量时使用 `~/.config` |
|
||||
|
||||
注册条目的键直接用作 ID。指定文件夹经绝对路径规范化后生成 SHA-256 ID,Windows 对哈希输入转小写;这是路径身份,不是文件完整性校验或跨机器永久身份。目录移动或注册 ID 变化后,调用方须重新发现仓库。
|
||||
|
||||
`callTool` 接收 `{ vaultId?, name, arguments }`,按 LR-3 验证目标后转发。共享工具 `obsidian_list_vaults` 返回仓库列表,其余工具名称为 `obsidian_` 加上游名称,输入额外包含 `vaultId`。网关校验共享 schema,提取 `vaultId`,移除名称前缀后调用上游;其余参数原样保留。输入默认值保持可选,frontmatter 键不受外层严格对象限制。[共享契约测试](../../../src/shared/obsidian-tools.test.ts)逐项比对上游字段、必填项、默认值及约束。
|
||||
|
||||
## 上游工具契约
|
||||
|
||||
名称集合依据本地安装的 `@bitbonsai/mcpvault@0.16.0` 中 `dist/src/createServer.js` 核对。HTTP 网关和直连工具提供器共享 19 个工具:`obsidian_list_vaults` 加下表 18 个工具的带前缀映射。设置连接测试返回的 `toolCount` 为上游名称去重数,正常为 18,不包含仓库发现工具。
|
||||
|
||||
| 上游工具 | 能力 |
|
||||
| --- | --- |
|
||||
| `read_note` | 读取笔记 |
|
||||
| `write_note` | 写入笔记 |
|
||||
| `patch_note` | 局部替换笔记内容 |
|
||||
| `list_directory` | 列出目录 |
|
||||
| `delete_note` | 按上游删除参数处理笔记 |
|
||||
| `search_notes` | 搜索笔记 |
|
||||
| `move_note` | 移动或重命名笔记 |
|
||||
| `move_file` | 移动文件,包括上游支持的附件文件 |
|
||||
| `read_multiple_notes` | 批量读取笔记 |
|
||||
| `update_frontmatter` | 更新 frontmatter |
|
||||
| `get_notes_info` | 获取笔记信息 |
|
||||
| `get_frontmatter` | 获取 frontmatter |
|
||||
| `manage_tags` | 管理标签 |
|
||||
| `get_vault_stats` | 获取仓库统计 |
|
||||
| `list_all_tags` | 列出标签 |
|
||||
| `wiki_link` | 处理 wiki 链接 |
|
||||
| `get_note_outline` | 获取笔记大纲 |
|
||||
| `read_note_lines` | 按行读取笔记 |
|
||||
|
||||
服务分页读取上游工具,不设置功能白名单。读写属性供应用既有工作模式使用,不能据此增加 Obsidian 专用过滤。保留上游的错误结果与参数规则,例如删除参数 `confirmPath`;不把参数要求改造成额外确认弹窗。
|
||||
|
||||
## 离线打包与生命周期
|
||||
|
||||
构建固定 npm 版本为 `0.16.0`,复制包及递归运行依赖,保留许可证、模块相对资源和 `trash` 的原生辅助程序。资源位于 `out/main/obsidian-mcpvault/node_modules`,生产安装包将其放在 `app.asar.unpacked`。缺包或版本不符必须明确失败,不在运行时执行 `npx` 或回退到在线安装。
|
||||
|
||||
服务将 `app.asar` 路径映射到解包目录,检查 `@bitbonsai/mcpvault/dist/server.js` 存在,使用现有 `LaunchEnvironmentProvider` 注入命令环境后运行 `node <server.js> <vault.path>`,工作目录为目标仓库。托管 Node 的准备和用户自定义解释器规则由[工具执行环境技术设计](../local-tool-environment/technical-design.md)负责。Obsidian 不新增下载源;已有工具下载源不控制随包交付的 MCPVault。
|
||||
|
||||
每次列举或调用创建 stdio 连接,在 `finally` 中关闭 transport 和 client,初始化失败也清理。`callTool` 传递 `AbortSignal`,启动前已取消时不启动进程。连接测试逐仓库列工具,不调用写入工具;它没有独立取消 IPC。网关沿用现有 MCP 结果大小约束,保留上游原生内容和错误详情,超时沿用 SDK 与既有会话路径。
|
||||
|
||||
`callObsidianTool` 合并请求、授权撤销及单次调用的取消信号,并在结果返回前再次检查取消。`revokeObsidianCapabilities` 对含 Obsidian 的授权调用既有 `revoke`,移除令牌并中止其 controller;混合授权整体失效。网关以 `activeObsidianCalls` 跟踪调用 Promise,`dispose()` 先撤销授权,再等待这些 Promise 完成,随后关闭下游会话和 HTTP 服务。由于服务 Promise 包含 `finally` 中的 transport 关闭,等待范围包含真实子进程清理,也包含先前撤销后尚未关闭的调用。
|
||||
|
||||
## UI 设计
|
||||
|
||||
此章节独立承担功能 UI 设计职责,服从[统一界面设计系统](../../../UI-DESIGN.md)。入口为“能力与工具 → MCP → 内置 MCP”的 Obsidian 卡片,复用 `PageTabs`、卡片、表单、按钮、Switch 及 Runtime 分配 Checkbox。
|
||||
|
||||
卡片顺序为名称及状态、启用开关、能力说明、仓库范围、可选路径输入、已保存范围、选择/测试/保存操作、关联反馈、Runtime 分配和可展开工具列表。主按钮为“保存”,测试与选择文件夹为次按钮。范围选项为“所有本机仓库”和“指定文件夹”;指定模式才显示路径输入框。页面明确区分草稿与“已保存范围”。
|
||||
|
||||
关闭状态仍允许选择、测试和保存。快照尚未载入或操作忙碌时禁用相关提交,测试按钮显示进度。修改范围或路径清除旧结果;目录选择取消保留原值。连接结果在卡片内显示仓库名称、路径和工具数,作为可复查的诊断内容;字段错误及可立即重试的测试错误就近显示,保留输入。普通保存提示如需增加,使用应用通知,不重复显示同一事件。
|
||||
|
||||
启用控件使用 `role="switch"`;范围、路径和按钮具备可访问名称,路径错误使用 `aria-describedby`,错误用 `role="alert"`,测试结果用 `role="status"`。工具展开按钮提供 `aria-expanded` 和 `aria-controls`。沿用原生键盘行为和共享焦点样式。Obsidian 的多字段诊断表单采用纵向布局,操作按钮换行,结果中的长路径允许折行;浅色和深色主题均复用语义令牌。Windows 760 像素宽窗口的实际测量及原生输入结果见[进度](./progress.md)。
|
||||
|
||||
## 验证与实施顺序
|
||||
|
||||
| 阶段 | 验证内容 | 追溯 |
|
||||
| --- | --- | --- |
|
||||
| TS-1 配置与服务 | 缺省字段、持久化独立性、各平台注册路径、ID 路由、真实上游读写、取消清理 | LR-1~LR-3、LR-5~LR-7 |
|
||||
| TS-2 设置 UI | 草稿测试不保存不启用、切换范围、取消选择、失败保留输入、键盘与窄窗口 | US-1~US-3、US-7、US-8 |
|
||||
| TS-3 网关与 IPC | 正式 Main 处理器、19 个共享工具、分配、Ask/Execute、配置提交撤销旧授权、在途取消与销毁等待;核对 DeepSeek Harness 和远程执行不可用边界 | LR-3、LR-4、LR-7、LR-8;US-4、US-5、US-9~US-12 |
|
||||
| TS-4 安装包与端到端 | 在实际支持系统上断网且无系统 Node 测试;Obsidian 关闭;许可证及原生资源完整;从设置到真实会话读写 | FR-5、FR-6;US-6 |
|
||||
|
||||
当前分配目标为 `model`、`opencode`、`continue`;[DeepSeek Harness](../../../src/main/agent/deepseek-harness-runtime.ts)和[远程 ACP Runtime](../../../src/main/agent/acp-remote-runtime.ts)的 `supportsScopedDataTools` 为 `false`,沿用既有不可用边界。已通过的生产组件自动化包含设置组件到注册 IPC、HTTP 网关,以及直连工具提供器全部 19 个工具的实际执行。真实聊天会话、Electron 托管 Node、Windows 解包应用及网络限制的不同验证范围见[进度](./progress.md)。
|
||||
@@ -0,0 +1,20 @@
|
||||
# 用户故事
|
||||
|
||||
本期角色为使用本机笔记的桌面用户,以及替用户调用工具的会话助手。需求定义见[PRD](./prd.md)。下表描述验收条件,不表示场景已经通过。
|
||||
|
||||
| ID | 场景与需求 | Given(前提) | When(动作) | Then(预期结果) |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| US-1 | 首次检查,FR-1、FR-2、FR-7 | 本机有已注册仓库,功能尚未启用 | 用户进入 MCP 设置并测试默认范围 | 开关保持关闭;返回注册仓库列表和工具数;未保存配置,也未改动笔记 |
|
||||
| US-2 | 指定草稿范围,FR-2、FR-6、FR-7 | 用户选择一个未注册文件夹,尚未保存 | 用户测试该草稿 | 仅探测此文件夹;已保存范围及启用状态不变 |
|
||||
| US-3 | 保存与恢复默认,FR-1、FR-2 | 用户已测试指定文件夹 | 保存后重新进入设置,再切换为全部仓库并保存 | 保存结果可重新加载;恢复默认时保存空 `vaultPath`;两次保存均不自动启用 |
|
||||
| US-4 | 单仓库日常操作,FR-4、FR-9 | 功能已启用并分配给当前会话,范围只有一个仓库 | 助手在允许写入的既有工作模式下读取、修改并再次读取笔记 | 写回当前唯一仓库,结果可从文件核验;无需额外 Obsidian 审批 |
|
||||
| US-5 | 多仓库同名文件,FR-3 | 有两个仓库,都包含 `daily.md` | 助手先取得仓库 ID,再指定其中一个修改;另行尝试省略 ID 或传入无效 ID | 明确 ID 的请求仅作用于对应仓库;另两次调用返回错误,两个仓库均不因失败请求改变 |
|
||||
| US-6 | 离线与关闭 Obsidian,FR-5、FR-6 | 安装包已有依赖,Obsidian 未运行,系统未安装 Node,网络断开 | 使用内置服务测试、读取和写入测试仓库 | 本地操作通过现有托管 Node 完成,不请求 npm,也不要求插件;模型联网另行验证 |
|
||||
| US-7 | 范围失败恢复,FR-8 | 注册文件缺失或含失效路径,或者草稿路径不可访问 | 用户测试后改为有效文件夹并重试 | 显示具体原因,保留输入;重试使用新草稿,不偷偷跳过坏仓库或切换目标 |
|
||||
| US-8 | 取消与失败保留,FR-8 | 用户正在编辑草稿或执行会话工具 | 取消文件夹选择、遇到保存失败,或取消会话工具 | 选择取消和保存失败均保留草稿;工具取消关闭对应进程,不报告成功,也不宣称回滚已发生的写入 |
|
||||
| US-9 | 既有权限规则,FR-4、FR-9 | 功能启用且已分配,用户分别使用 Ask 和 Execute | 助手请求写入 | Ask 按应用现有规则保持只读;Execute 沿用正常执行规则,不增加 Obsidian 专用开关、过滤或审批 |
|
||||
| US-10 | 旧配置与分配,FR-1、FR-9 | 旧配置没有 Obsidian 字段,已有其他 MCP 配置 | 加载设置并修改 Obsidian 分配 | Obsidian 默认关闭,其他配置保留;修改分配不改写仓库范围;未分配的会话不可调用 |
|
||||
| US-11 | 上游能力完整,FR-4 | 在允许完整调用的工作模式下启用接入 | 获取工具集合并对照 MCPVault 0.16.0 | 18 个上游工具均有可调用映射,参数能力保留;仓库发现辅助工具不计入这 18 个 |
|
||||
| US-12 | 设置变更与旧调用,FR-10 | 有含 Obsidian 的旧请求授权和正在执行的工具 | 提交保存范围、切换开关或修改分配,随后关闭网关 | 旧授权失效,在途调用收到取消;网关等待工具关闭。保存失败也不恢复旧授权;后续请求按实际保存状态重新取得授权 |
|
||||
|
||||
跨平台注册路径和安装包测试分别取证。通过模拟平台字符串测试注册路径,不等于在对应操作系统上完成安装包验收。
|
||||
+11
-1
@@ -3,6 +3,7 @@ import { resolve } from 'node:path'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
import type { Plugin } from 'vite'
|
||||
import { packageObsidianMcpVault } from './src/main/obsidian/package-mcpvault'
|
||||
|
||||
type ProjectPackage = {
|
||||
dependencies?: Record<string, string>
|
||||
@@ -106,7 +107,16 @@ export default defineConfig({
|
||||
'zod'
|
||||
]
|
||||
}),
|
||||
deepSeekHarnessBundleManifestPlugin()
|
||||
deepSeekHarnessBundleManifestPlugin(),
|
||||
{
|
||||
name: 'obsidian-mcpvault-package',
|
||||
async writeBundle(options) {
|
||||
await packageObsidianMcpVault(
|
||||
resolve('.'),
|
||||
resolve(options.dir ?? 'out/main', 'obsidian-mcpvault')
|
||||
)
|
||||
}
|
||||
}
|
||||
],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
|
||||
Generated
+855
File diff suppressed because it is too large
Load Diff
@@ -58,6 +58,7 @@
|
||||
"beforePack": "build/runtime-hooks.cjs",
|
||||
"asar": true,
|
||||
"asarUnpack": [
|
||||
"out/main/obsidian-mcpvault/**/*",
|
||||
"out/main/package.json",
|
||||
"out/main/deepseek-harness-*",
|
||||
"out/main/embedding-inference-bootstrap.js",
|
||||
@@ -366,6 +367,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antv/g6": "^5.1.1",
|
||||
"@bitbonsai/mcpvault": "0.16.0",
|
||||
"@continuedev/cli": "1.5.47",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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({
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -731,39 +731,55 @@ const api: DesktopApi = {
|
||||
getSnapshot: vi.fn(async () => ({
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
obsidian: { vaultPath: "" },
|
||||
})),
|
||||
importSkill: vi.fn(async () => ({
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
obsidian: { vaultPath: "" },
|
||||
})),
|
||||
removeSkill: vi.fn(async () => ({
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
obsidian: { vaultPath: "" },
|
||||
})),
|
||||
setSkillEnabled: vi.fn(async () => ({
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
obsidian: { vaultPath: "" },
|
||||
})),
|
||||
setSkillAssignments: vi.fn(async () => ({
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
obsidian: { vaultPath: "" },
|
||||
})),
|
||||
setBuiltinMcpServerEnabled: vi.fn(async () => ({
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
obsidian: { vaultPath: "" },
|
||||
})),
|
||||
setBuiltinMcpServerAssignments: vi.fn(async () => ({
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
obsidian: { vaultPath: "" },
|
||||
})),
|
||||
saveMcpServer: vi.fn(async () => ({
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
obsidian: { vaultPath: "" },
|
||||
})),
|
||||
removeMcpServer: vi.fn(async () => ({
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
obsidian: { vaultPath: "" },
|
||||
})),
|
||||
updateObsidianSettings: vi.fn(async (obsidian) => ({
|
||||
skills: [],
|
||||
mcpServers: [],
|
||||
obsidian,
|
||||
})),
|
||||
testObsidianConnection: vi.fn(async () => ({ vaults: [], toolCount: 0 })),
|
||||
selectObsidianVault: vi.fn(async () => null),
|
||||
testMcpServer: vi.fn(async () => ({
|
||||
dynamicToolsSupported: false,
|
||||
toolCount: 0,
|
||||
|
||||
@@ -5,6 +5,149 @@ import i18n from './i18n'
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
|
||||
|
||||
it('defaults Obsidian off and tests draft scope without saving or enabling it', async () => {
|
||||
const snapshot = { mcpServers: [], skills: [], obsidian: { vaultPath: '' } }
|
||||
const getSnapshot = vi.fn().mockResolvedValue(snapshot)
|
||||
const updateObsidianSettings = vi.fn(async (input) => ({ ...snapshot, obsidian: input }))
|
||||
const testObsidianConnection = vi.fn().mockResolvedValue({
|
||||
vaults: [{ id: 'work', name: 'Work', path: '/notes/work' }], toolCount: 24
|
||||
})
|
||||
const selectObsidianVault = vi.fn().mockResolvedValue('/notes/work')
|
||||
const setBuiltinMcpServerEnabled = vi.fn()
|
||||
vi.stubGlobal('goodbuddy', { capabilities: {
|
||||
getSnapshot, updateObsidianSettings, testObsidianConnection,
|
||||
selectObsidianVault, setBuiltinMcpServerEnabled
|
||||
} })
|
||||
render(<McpSettingsSection onOpenImageModelSettings={vi.fn()} />)
|
||||
const testButton = screen.getByRole('button', { name: i18n.t('integrations:mcp.obsidian.test') })
|
||||
await waitFor(() => expect(testButton).toBeEnabled())
|
||||
expect(screen.getByRole('switch', { name: i18n.t('integrations:mcp.builtin.enableAriaLabel', { name: 'Obsidian' }) })).not.toBeChecked()
|
||||
fireEvent.click(testButton)
|
||||
await waitFor(() => {
|
||||
expect(testObsidianConnection).toHaveBeenCalledWith({ vaultPath: '' })
|
||||
expect(screen.getByText('/notes/work')).toBeVisible()
|
||||
expect(testButton).toBeEnabled()
|
||||
})
|
||||
expect(updateObsidianSettings).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: i18n.t('integrations:mcp.obsidian.select') }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name: i18n.t('integrations:mcp.obsidian.path') })).toHaveValue('/notes/work')
|
||||
expect(testButton).toBeEnabled()
|
||||
})
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: i18n.t('integrations:mcp.editor.save') }))
|
||||
await waitFor(() => {
|
||||
expect(updateObsidianSettings).toHaveBeenCalledWith({ vaultPath: '/notes/work' })
|
||||
expect(screen.getByText(i18n.t('integrations:mcp.obsidian.savedScope', { path: '/notes/work' }))).toBeVisible()
|
||||
expect(testButton).toBeEnabled()
|
||||
})
|
||||
fireEvent.change(screen.getByRole('combobox', { name: i18n.t('integrations:mcp.obsidian.scope') }), { target: { value: 'all' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: i18n.t('integrations:mcp.editor.save') }))
|
||||
await waitFor(() => {
|
||||
expect(updateObsidianSettings).toHaveBeenLastCalledWith({ vaultPath: '' })
|
||||
expect(testButton).toBeEnabled()
|
||||
})
|
||||
expect(setBuiltinMcpServerEnabled).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['loaded', 'saved'] as const)('keeps a %s specified vault selected when its draft path is cleared', async (source) => {
|
||||
const snapshot = { mcpServers: [], skills: [], obsidian: { vaultPath: '/notes/original' } }
|
||||
const updateObsidianSettings = vi.fn(async (input) => ({ ...snapshot, obsidian: input }))
|
||||
const testObsidianConnection = vi.fn().mockResolvedValue({
|
||||
vaults: [{ id: 'work', name: 'Work', path: '/notes/discovered' }], toolCount: 24
|
||||
})
|
||||
vi.stubGlobal('goodbuddy', { capabilities: {
|
||||
getSnapshot: vi.fn().mockResolvedValue(snapshot),
|
||||
updateObsidianSettings, testObsidianConnection
|
||||
} })
|
||||
render(<McpSettingsSection onOpenImageModelSettings={vi.fn()} />)
|
||||
const input = await screen.findByRole('textbox', { name: i18n.t('integrations:mcp.obsidian.path') })
|
||||
const scope = screen.getByRole('combobox', { name: i18n.t('integrations:mcp.obsidian.scope') })
|
||||
const saveButton = screen.getByRole('button', { name: i18n.t('integrations:mcp.editor.save') })
|
||||
const testButton = screen.getByRole('button', { name: i18n.t('integrations:mcp.obsidian.test') })
|
||||
const savedPath = source === 'saved' ? '/notes/saved' : '/notes/original'
|
||||
if (source === 'saved') {
|
||||
fireEvent.change(input, { target: { value: savedPath } })
|
||||
fireEvent.click(saveButton)
|
||||
await waitFor(() => {
|
||||
expect(updateObsidianSettings).toHaveBeenCalledWith({ vaultPath: savedPath })
|
||||
expect(screen.getByText(i18n.t('integrations:mcp.obsidian.savedScope', { path: savedPath }))).toBeVisible()
|
||||
expect(saveButton).toBeEnabled()
|
||||
})
|
||||
updateObsidianSettings.mockClear()
|
||||
}
|
||||
fireEvent.click(testButton)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('/notes/discovered')).toBeVisible()
|
||||
expect(testButton).toBeEnabled()
|
||||
})
|
||||
testObsidianConnection.mockClear()
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
expect(scope).toHaveValue('folder')
|
||||
expect(input).toBeVisible()
|
||||
expect(input).toHaveValue('')
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
for (const button of [saveButton, testButton]) {
|
||||
fireEvent.click(button)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(i18n.t('integrations:mcp.obsidian.folderRequired'))
|
||||
expect(scope).toHaveValue('folder')
|
||||
expect(input).toBeVisible()
|
||||
expect(input).toHaveValue('')
|
||||
expect(screen.getByText(i18n.t('integrations:mcp.obsidian.savedScope', { path: savedPath }))).toBeVisible()
|
||||
expect(button).toBeEnabled()
|
||||
})
|
||||
}
|
||||
expect(updateObsidianSettings).not.toHaveBeenCalled()
|
||||
expect(testObsidianConnection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears Obsidian connection results when changing scope', async () => {
|
||||
const testObsidianConnection = vi.fn().mockResolvedValue({
|
||||
vaults: [{ id: 'work', name: 'Work', path: '/notes/discovered' }], toolCount: 24
|
||||
})
|
||||
vi.stubGlobal('goodbuddy', { capabilities: {
|
||||
getSnapshot: vi.fn().mockResolvedValue({ mcpServers: [], skills: [], obsidian: { vaultPath: '/notes/work' } }),
|
||||
testObsidianConnection
|
||||
} })
|
||||
render(<McpSettingsSection onOpenImageModelSettings={vi.fn()} />)
|
||||
const testButton = screen.getByRole('button', { name: i18n.t('integrations:mcp.obsidian.test') })
|
||||
await waitFor(() => expect(testButton).toBeEnabled())
|
||||
fireEvent.click(testButton)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('/notes/discovered')).toBeVisible()
|
||||
expect(testButton).toBeEnabled()
|
||||
})
|
||||
fireEvent.change(screen.getByRole('combobox', { name: i18n.t('integrations:mcp.obsidian.scope') }), { target: { value: 'all' } })
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('textbox', { name: i18n.t('integrations:mcp.obsidian.path') })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('preserves the Obsidian draft after failures and folder-picker cancellation', async () => {
|
||||
const selectObsidianVault = vi.fn().mockResolvedValue(null)
|
||||
const updateObsidianSettings = vi.fn().mockRejectedValue(new Error('Disk unavailable'))
|
||||
vi.stubGlobal('goodbuddy', { capabilities: {
|
||||
getSnapshot: vi.fn().mockResolvedValue({ mcpServers: [], skills: [], obsidian: { vaultPath: '/notes/original' } }),
|
||||
selectObsidianVault, updateObsidianSettings
|
||||
} })
|
||||
render(<McpSettingsSection onOpenImageModelSettings={vi.fn()} />)
|
||||
const input = await screen.findByRole('textbox', { name: i18n.t('integrations:mcp.obsidian.path') })
|
||||
fireEvent.change(input, { target: { value: '/notes/draft' } })
|
||||
const selectButton = screen.getByRole('button', { name: i18n.t('integrations:mcp.obsidian.select') })
|
||||
fireEvent.click(selectButton)
|
||||
await waitFor(() => {
|
||||
expect(selectObsidianVault).toHaveBeenCalledOnce()
|
||||
expect(selectButton).toBeEnabled()
|
||||
expect(input).toHaveValue('/notes/draft')
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: i18n.t('integrations:mcp.editor.save') }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Disk unavailable')
|
||||
expect(input).toHaveValue('/notes/draft')
|
||||
expect(selectButton).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('removes the redundant MCP title and places the custom action in content', async () => {
|
||||
const getSnapshot = vi.fn().mockResolvedValue({ mcpServers: [], skills: [] })
|
||||
vi.stubGlobal('goodbuddy', { capabilities: { getSnapshot } })
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
Wrench,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { builtinMcpServers } from '../../shared/builtin-mcp-servers'
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
McpServerSummary,
|
||||
McpServerTestResult,
|
||||
McpTransport,
|
||||
ObsidianConnectionTestResult,
|
||||
RuntimeTarget,
|
||||
WebSearchTestResult
|
||||
} from '../../shared/capability-contracts'
|
||||
@@ -119,6 +120,12 @@ export function McpSettingsSection({
|
||||
disabled: t('mcp.diagnosticStatuses.disabled')
|
||||
}
|
||||
const [snapshot, setSnapshot] = useState<CapabilitySnapshot>()
|
||||
const [obsidianPath, setObsidianPath] = useState<string>()
|
||||
const [obsidianResult, setObsidianResult] = useState<ObsidianConnectionTestResult>()
|
||||
const [obsidianError, setObsidianError] = useState<string>()
|
||||
const [obsidianSpecified, setObsidianSpecified] = useState<boolean>()
|
||||
const vaultPath = obsidianPath ?? snapshot?.obsidian?.vaultPath ?? ''
|
||||
const specifiedVault = obsidianSpecified ?? Boolean(snapshot?.obsidian?.vaultPath)
|
||||
const [editor, setEditor] = useState<McpEditor>()
|
||||
const [busy, setBusy] = useState<string>()
|
||||
const [error, setError] = useState<string>()
|
||||
@@ -310,6 +317,40 @@ export function McpSettingsSection({
|
||||
}
|
||||
}
|
||||
|
||||
const configureObsidian = async (action: 'save' | 'test' | 'select'): Promise<void> => {
|
||||
setBusy(`obsidian:${action}`)
|
||||
setObsidianError(undefined)
|
||||
try {
|
||||
const api = window.goodbuddy.capabilities
|
||||
if (action === 'select') {
|
||||
const path = await api.selectObsidianVault()
|
||||
if (path !== null) {
|
||||
setObsidianPath(path)
|
||||
setObsidianSpecified(true)
|
||||
setObsidianResult(undefined)
|
||||
}
|
||||
} else {
|
||||
const input = { vaultPath: specifiedVault ? vaultPath.trim() : '' }
|
||||
if (specifiedVault && !input.vaultPath) {
|
||||
throw new Error(t('mcp.obsidian.folderRequired'))
|
||||
}
|
||||
if (action === 'save') {
|
||||
const next = await api.updateObsidianSettings(input)
|
||||
setSnapshot(next)
|
||||
setObsidianPath(undefined)
|
||||
setObsidianSpecified(undefined)
|
||||
} else {
|
||||
setObsidianResult(undefined)
|
||||
setObsidianResult(await api.testObsidianConnection(input))
|
||||
}
|
||||
}
|
||||
} catch (reason) {
|
||||
setObsidianError(reason instanceof Error ? reason.message : t('mcp.errors.operation'))
|
||||
} finally {
|
||||
setBusy(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const updateAssignment = (
|
||||
target: RuntimeTarget,
|
||||
checked: boolean
|
||||
@@ -373,13 +414,13 @@ export function McpSettingsSection({
|
||||
trapTabFocus(event, editorDialogRef.current)
|
||||
}
|
||||
|
||||
const computerCapabilities = snapshot?.computerCapabilities ?? []
|
||||
const desktopCapabilities = computerCapabilities.filter(
|
||||
const computerCapabilities = useMemo(() => snapshot?.computerCapabilities ?? [], [snapshot?.computerCapabilities])
|
||||
const desktopCapabilities = useMemo(() => computerCapabilities.filter(
|
||||
(capability) => capability.id !== 'host-browser-control'
|
||||
)
|
||||
const builtinMcpStates = new Map(
|
||||
), [computerCapabilities])
|
||||
const builtinMcpStates = useMemo(() => new Map(
|
||||
snapshot?.builtinMcpServers?.map((server) => [server.id, server])
|
||||
)
|
||||
), [snapshot?.builtinMcpServers])
|
||||
const webSearch = snapshot?.webSearch ?? {
|
||||
provider: 'exa' as const,
|
||||
enabled: true,
|
||||
@@ -614,7 +655,7 @@ export function McpSettingsSection({
|
||||
const panelId = `mcp-server-tools-${server.id}`
|
||||
const state = builtinMcpStates.get(server.id) ?? {
|
||||
id: server.id,
|
||||
enabled: server.id !== 'builtin-browser',
|
||||
enabled: server.id !== 'builtin-browser' && server.id !== 'obsidian',
|
||||
assignments:
|
||||
[...server.supportedAssignments] as CapabilityAssignments
|
||||
}
|
||||
@@ -649,6 +690,8 @@ export function McpSettingsSection({
|
||||
? t('mcp.browser.unsupported')
|
||||
: !featureAvailable
|
||||
? t('mcp.builtin.serverSummaryDisabled')
|
||||
: server.id === 'obsidian'
|
||||
? t('mcp.obsidian.access')
|
||||
: server.id === 'builtin-browser'
|
||||
? t('mcp.builtin.serverSummaryExecuteOnly')
|
||||
: server.access === 'mixed'
|
||||
@@ -681,7 +724,63 @@ export function McpSettingsSection({
|
||||
: t('mcp.builtin.disabled')}
|
||||
</span>
|
||||
</label>
|
||||
<p>{server.description}</p>
|
||||
<p>{server.id === 'obsidian' ? t('mcp.obsidian.description') : server.description}</p>
|
||||
{server.id === 'obsidian' && (
|
||||
<div className="capability-diagnostic capability-diagnostic--obsidian">
|
||||
<label className="field">
|
||||
<span>{t('mcp.obsidian.scope')}</span>
|
||||
<select
|
||||
disabled={Boolean(busy) || !snapshot}
|
||||
value={specifiedVault ? 'folder' : 'all'}
|
||||
onChange={(event) => {
|
||||
setObsidianSpecified(event.target.value === 'folder')
|
||||
setObsidianResult(undefined)
|
||||
setObsidianError(undefined)
|
||||
}}
|
||||
>
|
||||
<option value="all">{t('mcp.obsidian.all')}</option>
|
||||
<option value="folder">{t('mcp.obsidian.folder')}</option>
|
||||
</select>
|
||||
</label>
|
||||
{specifiedVault && (
|
||||
<label className="field">
|
||||
<span>{t('mcp.obsidian.path')}</span>
|
||||
<input
|
||||
aria-describedby={obsidianError ? 'obsidian-settings-error' : undefined}
|
||||
disabled={Boolean(busy)}
|
||||
maxLength={4096}
|
||||
value={vaultPath}
|
||||
onChange={(event) => {
|
||||
setObsidianPath(event.target.value)
|
||||
setObsidianResult(undefined)
|
||||
setObsidianError(undefined)
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<p>{t('mcp.obsidian.scopeHelp')}</p>
|
||||
<p>{t('mcp.obsidian.savedScope', { path: snapshot?.obsidian?.vaultPath || t('mcp.obsidian.all') })}</p>
|
||||
<div className="capability-card__actions">
|
||||
<button className="secondary-button" disabled={Boolean(busy) || !snapshot} onClick={() => void configureObsidian('select')} type="button">
|
||||
{t('mcp.obsidian.select')}
|
||||
</button>
|
||||
<button className="secondary-button" disabled={Boolean(busy) || !snapshot} onClick={() => void configureObsidian('test')} type="button">
|
||||
{busy === 'obsidian:test' ? t('mcp.obsidian.testing') : t('mcp.obsidian.test')}
|
||||
</button>
|
||||
<button className="primary-button" disabled={Boolean(busy) || !snapshot} onClick={() => void configureObsidian('save')} type="button">
|
||||
{busy === 'obsidian:save' ? t('mcp.editor.saving') : t('mcp.editor.save')}
|
||||
</button>
|
||||
</div>
|
||||
{obsidianError && <p className="settings-warning" id="obsidian-settings-error" role="alert">{obsidianError}</p>}
|
||||
{obsidianResult && (
|
||||
<div className="capability-diagnostic__result" role="status">
|
||||
<strong>{t('mcp.obsidian.result', { vaults: obsidianResult.vaults.length, tools: obsidianResult.toolCount })}</strong>
|
||||
{obsidianResult.vaults.length === 0 && <p>{t('mcp.obsidian.empty')}</p>}
|
||||
{obsidianResult.vaults.map((vault) => <p key={vault.id}>{vault.name}: <code>{vault.path}</code></p>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!featureAvailable && (
|
||||
<p className="computer-capability-risk">
|
||||
<CircleAlert aria-hidden="true" size={13} />
|
||||
|
||||
@@ -296,6 +296,7 @@ const capabilitySnapshot = {
|
||||
}
|
||||
],
|
||||
mcpServers: [] as CapabilitySnapshot['mcpServers'],
|
||||
obsidian: { vaultPath: '' },
|
||||
webSearch: {
|
||||
provider: 'exa' as const,
|
||||
enabled: true,
|
||||
@@ -952,6 +953,12 @@ describe('SettingsPanel runtime files', () => {
|
||||
})),
|
||||
setWebSearchEnabled,
|
||||
testWebSearch,
|
||||
updateObsidianSettings: vi.fn(async (obsidian) => ({
|
||||
...capabilitySnapshot,
|
||||
obsidian
|
||||
})),
|
||||
testObsidianConnection: vi.fn(async () => ({ vaults: [], toolCount: 0 })),
|
||||
selectObsidianVault: vi.fn(async () => null),
|
||||
setComputerCapabilityEnabled,
|
||||
setComputerCapabilityBrowserProfile: vi.fn(
|
||||
async () => capabilitySnapshot
|
||||
@@ -5751,7 +5758,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getAllByRole('button', {
|
||||
name: /(?:展开|收起)服务器 (?:知识库|笔记|GoodBuddy 配置|内置浏览器)/u
|
||||
name: /(?:展开|收起)服务器 (?:知识库|笔记|Obsidian|GoodBuddy 配置|内置浏览器)/u
|
||||
})
|
||||
).toHaveLength(builtinMcpServers.length)
|
||||
expect(
|
||||
|
||||
@@ -154,6 +154,22 @@ export const integrations = {
|
||||
save: 'Save channel settings'
|
||||
},
|
||||
mcp: {
|
||||
obsidian: {
|
||||
access: 'Read and write',
|
||||
description: 'Read, search, and manage notes, folders, tags, and attachments in local Obsidian vaults.',
|
||||
scope: 'Obsidian vault scope',
|
||||
all: 'All registered local vaults',
|
||||
folder: 'Specified folder',
|
||||
path: 'Obsidian vault folder',
|
||||
scopeHelp: 'All vaults uses the local Obsidian registry. A specified folder selects one vault. When multiple vaults are available, tools require an explicit vault ID.',
|
||||
savedScope: 'Saved scope: {{path}}',
|
||||
select: 'Choose vault folder',
|
||||
test: 'Test Obsidian connection',
|
||||
testing: 'Testing Obsidian connection…',
|
||||
folderRequired: 'Choose or enter a vault folder.',
|
||||
result: '{{vaults}} vaults discovered · {{tools}} tools available',
|
||||
empty: 'No local vaults found. Open a vault in Obsidian or choose its folder.'
|
||||
},
|
||||
title: 'MCP',
|
||||
description:
|
||||
'View built-in tools and MCP servers, and manage external MCP servers',
|
||||
|
||||
@@ -143,6 +143,22 @@ export const integrations = {
|
||||
save: '保存通道设置'
|
||||
},
|
||||
mcp: {
|
||||
obsidian: {
|
||||
access: '读取与写入',
|
||||
description: '读取、搜索和管理本机 Obsidian 仓库中的笔记、目录、标签与附件。',
|
||||
scope: 'Obsidian 仓库范围',
|
||||
all: '所有已注册的本机仓库',
|
||||
folder: '指定文件夹',
|
||||
path: 'Obsidian 仓库文件夹',
|
||||
scopeHelp: '所有仓库从本机 Obsidian 注册信息中发现;指定文件夹则仅使用该仓库。有多个仓库时,工具调用必须明确指定仓库 ID。',
|
||||
savedScope: '已保存范围:{{path}}',
|
||||
select: '选择仓库文件夹',
|
||||
test: '测试 Obsidian 连接',
|
||||
testing: '正在测试 Obsidian 连接…',
|
||||
folderRequired: '请选择或填写仓库文件夹。',
|
||||
result: '发现 {{vaults}} 个仓库 · {{tools}} 个可用工具',
|
||||
empty: '未找到本机仓库。请在 Obsidian 中打开仓库,或选择其文件夹。'
|
||||
},
|
||||
title: 'MCP',
|
||||
description: '查看内置工具、内置 MCP,并管理外部 MCP Server',
|
||||
runtimeLabels: {
|
||||
|
||||
@@ -11880,6 +11880,22 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.capability-diagnostic--obsidian {
|
||||
min-width: 0;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.capability-diagnostic--obsidian .capability-card__actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.capability-diagnostic--obsidian code {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.capability-diagnostic__result {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
|
||||
@@ -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: '知识库',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { app, safeStorage } from 'electron'
|
||||
import { RuntimeSettingsStore } from '../src/main/runtime-settings-store'
|
||||
import { ObsidianService } from '../src/main/obsidian/obsidian-service'
|
||||
import { KnowledgeMcpGateway } from '../src/main/agent/knowledge-mcp-gateway'
|
||||
import { OpenCodeRuntime } from '../src/main/agent/opencode-runtime'
|
||||
import { ContinueAgentRuntime } from '../src/main/agent/continue-runtime'
|
||||
import { createLocalToolEnvironment } from '../src/main/local-tool-environment/local-tool-environment'
|
||||
import { createModelRequestProbe } from './support/model-request-probe'
|
||||
import { createAnthropicMessagesUrl } from '../src/main/agent/anthropic-endpoint'
|
||||
import { createOpenAIChatCompletionsUrl, createOpenAIResponsesUrl } from '../src/main/agent/openai-endpoint'
|
||||
import type { CapabilitySnapshot } from '../src/shared/capability-contracts'
|
||||
|
||||
export async function prepareChat(root: string, profileDirectory: string) {
|
||||
const source = process.env.GOODBUDDY_ACCEPTANCE_SETTINGS ?? join(app.getPath('appData'), 'goodbuddy', 'runtime-settings.json')
|
||||
const saved = JSON.parse(await readFile(source, 'utf8'))
|
||||
const profile = saved.modelProfiles.find((item: { id: string }) => item.id === saved.defaultModelProfileId)
|
||||
assert(profile && profile.protocol !== 'openai-images-generations')
|
||||
const upstreamUrl = profile.protocol === 'anthropic-messages' ? createAnthropicMessagesUrl(profile.baseUrl)
|
||||
: profile.protocol === 'openai-responses' ? createOpenAIResponsesUrl(profile.baseUrl)
|
||||
: createOpenAIChatCompletionsUrl(profile.baseUrl)
|
||||
const probe = await createModelRequestProbe({ upstreamUrl, headerName: 'x-goodbuddy-obsidian-chat' })
|
||||
profile.baseUrl = probe.baseUrl
|
||||
saved.provider = 'opencode'
|
||||
saved.workspacePath = join(root, 'chat-workspace')
|
||||
saved.opencodeBaseUrl = ''
|
||||
saved.opencodeEmbedded = true
|
||||
saved.opencodeBinaryPath = ''
|
||||
saved.opencodeConfigPath = ''
|
||||
saved.continueBinaryPath = ''
|
||||
saved.continueConfigPath = ''
|
||||
saved.opencodeModelSource = { kind: 'default' }
|
||||
saved.continueModelSource = { kind: 'default' }
|
||||
await mkdir(saved.workspacePath)
|
||||
await writeFile(join(profileDirectory, 'runtime-settings.json'), JSON.stringify(saved))
|
||||
return probe
|
||||
}
|
||||
|
||||
export async function run(root: string, snapshot: CapabilitySnapshot): Promise<void> {
|
||||
// Resolve credentials only in Main from a disposable copy. Never update the source profile.
|
||||
const source = process.env.GOODBUDDY_ACCEPTANCE_SETTINGS ?? join(app.getPath('appData'), 'goodbuddy', 'runtime-settings.json')
|
||||
const copy = join(root, 'model-settings.json')
|
||||
await copyFile(source, copy)
|
||||
const settings = await new RuntimeSettingsStore(copy, {
|
||||
isAvailable: () => safeStorage.isEncryptionAvailable(),
|
||||
encrypt: value => safeStorage.encryptString(value),
|
||||
decrypt: value => safeStorage.decryptString(value)
|
||||
}).getResolvedSettings()
|
||||
const profile = settings.modelProfiles.find(item => item.id === settings.defaultModelProfileId)
|
||||
console.log('MODEL_AVAILABILITY', JSON.stringify(settings.modelProfiles.map(item => ({
|
||||
protocol: item.protocol, authentication: item.authentication, credentialAvailable: Boolean(item.apiKey),
|
||||
isDefault: item.id === settings.defaultModelProfileId
|
||||
}))))
|
||||
assert(profile, 'A saved default text model is required')
|
||||
assert(profile.authentication === 'none' || profile.apiKey, 'The saved model credential must decrypt')
|
||||
const prepared = await createLocalToolEnvironment({
|
||||
binDirectory: join(root, 'managed-bin'), nodeSelection: { source: 'managed' },
|
||||
pythonSelection: { source: 'managed' },
|
||||
packagedNpmCliPath: resolve('node_modules/npm/bin/npm-cli.js'),
|
||||
packagedNpxCliPath: resolve('node_modules/npm/bin/npx-cli.js')
|
||||
}, { electronExecutablePath: process.execPath })
|
||||
const vaultPath = snapshot.obsidian.vaultPath
|
||||
assert.equal(vaultPath, join(root, 'Temporary Vault'), 'Use the scope saved through the production UI')
|
||||
const assignment = snapshot.builtinMcpServers?.find(item => item.id === 'obsidian')
|
||||
assert(assignment?.enabled, 'Obsidian must be enabled through the production UI')
|
||||
const service = new ObsidianService({ appPath: resolve('.'), launchEnvironmentProvider: () => prepared.environment })
|
||||
let failures = 0
|
||||
for (const provider of ['opencode', 'continue'] as const) {
|
||||
assert(assignment.assignments.includes(provider), `Obsidian must be assigned to ${provider}`)
|
||||
const workspace = join(root, provider)
|
||||
await mkdir(workspace)
|
||||
const upstreamUrl = profile.protocol === 'anthropic-messages' ? createAnthropicMessagesUrl(profile.baseUrl)
|
||||
: profile.protocol === 'openai-responses' ? createOpenAIResponsesUrl(profile.baseUrl)
|
||||
: createOpenAIChatCompletionsUrl(profile.baseUrl)
|
||||
const probe = await createModelRequestProbe({ upstreamUrl, headerName: 'x-goodbuddy-obsidian-acceptance' })
|
||||
const gateway = new KnowledgeMcpGateway({} as never, { obsidianService: service })
|
||||
await gateway.start()
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 90_000)
|
||||
const requestId = crypto.randomUUID()
|
||||
const token = gateway.grant(requestId, [], controller.signal, 'none', undefined, undefined, undefined, undefined,
|
||||
{ settings: { vaultPath }, access: 'write' })!
|
||||
const common = { modelProfile: { ...profile, baseUrl: probe.baseUrl }, defaultWorkspace: workspace, knowledgeGateway: gateway }
|
||||
const runtime = provider === 'opencode'
|
||||
? new OpenCodeRuntime({ embedded: true, binaryPath: '', configPath: '',
|
||||
bundledBinaryPath: resolve('.runtime-resources', process.arch, process.platform === 'win32' ? 'opencode.exe' : 'opencode'), ...common })
|
||||
: new ContinueAgentRuntime({ binaryPath: '', configPath: '',
|
||||
bundledBinaryPath: resolve('node_modules/@continuedev/cli/dist/cn.js'), hostCacheRoot: join(root, 'continue-host'), ...common })
|
||||
const marker = `OBSIDIAN_${provider}_${crypto.randomUUID()}`
|
||||
try {
|
||||
let output = ''
|
||||
const tools: string[] = []
|
||||
for await (const event of runtime.run({ requestId, conversationId: crypto.randomUUID(), workMode: 'execute',
|
||||
knowledgeCapabilityToken: token,
|
||||
prompt: `Use only the assigned Obsidian tools. Call obsidian_write_note to write ${provider}.md with content ${marker}, then obsidian_read_note to read it. Reply with the content read. Do not use shell or other tools.`
|
||||
}, controller.signal)) {
|
||||
if (event.type === 'text') output += event.delta
|
||||
if (event.type === 'tool') tools.push(JSON.stringify(event))
|
||||
}
|
||||
assert((await readFile(join(vaultPath, `${provider}.md`), 'utf8')).includes(marker), 'Real vault must contain model-requested note')
|
||||
assert(output.includes(marker), 'Model must return the read marker')
|
||||
assert(tools.some(item => item.includes('obsidian_write_note')) && tools.some(item => item.includes('obsidian_read_note')), 'Runtime events must show both Obsidian tools')
|
||||
console.log(`PASS: real ${provider} Obsidian write/read session`)
|
||||
} catch (error) {
|
||||
failures++
|
||||
console.error(`FAIL: ${provider}: ${error instanceof Error ? error.message : 'Unknown failure'}`)
|
||||
} finally {
|
||||
console.log(JSON.stringify({ provider, realModelHttpRequests: probe.observations.length }))
|
||||
clearTimeout(timeout)
|
||||
controller.abort()
|
||||
await runtime.dispose()
|
||||
await gateway.dispose()
|
||||
await probe.close()
|
||||
}
|
||||
}
|
||||
assert.equal(failures, 0, 'Both real runtime sessions must pass')
|
||||
}
|
||||
Reference in New Issue
Block a user