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:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user