Files
goodbuddy/src/main/obsidian/obsidian-service.ts
T
mesalogo 68cc0792e1 perf(startup): build the local tool environment in the background and gate its consumers
Runtime creation, MCP launches, Obsidian, native clients and the tool-environment IPC await the initialization promise instead of startup awaiting it before the window path. A failure still fails startup with the same diagnostic.
2026-10-03 16:56:13 +08:00

156 lines
6.9 KiB
TypeScript

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, LaunchEnvironmentReady } 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
/** Awaited before reading the provider; startup builds the environment in the background. */
launchEnvironmentReady?: LaunchEnvironmentReady
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 })
})
await this.options.launchEnvironmentReady?.()
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()
}
}
}