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:
mesalogo
2026-09-27 00:37:46 +08:00
parent 5116613615
commit 55efca3ee1
47 changed files with 4060 additions and 76 deletions
+14
View File
@@ -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: '知识库',
+19 -2
View File
@@ -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
+5
View File
@@ -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>
+3
View File
@@ -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',
+137
View File
@@ -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')
}
})
})
+265
View File
@@ -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)
+2
View File
@@ -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