obsidian_search
Search notes by text or regex with ranked snippets and optional tag and folder filters.
Instructions
Search notes by text or regex. Returns ranked snippets and supports tag and folder filters.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| vault | No | Optional configured vault name. Defaults to the server default vault. | |
| query | No | ||
| tag | No | ||
| pathPrefix | No | ||
| regex | No | ||
| caseSensitive | No | ||
| includeContent | No | ||
| contextChars | No | ||
| limit | No | ||
| offset | No |
Implementation Reference
- src/tools.ts:38-60 (registration)The registerObsidianTools function that registers all Obsidian tools, including obsidian_search, onto the MCP server.
export function registerObsidianTools(server: McpServer, vaults: VaultManager, config: ObsidianMcpConfig): void { vaults.onInvalidate = invalidateNotesCache; const pretty = config.pretty; const tool = <S extends ToolShape>( name: string, description: string, schema: S, handler: (args: z.objectOutputType<S, z.ZodTypeAny>) => Promise<unknown> | unknown, annotations: { readOnlyHint?: boolean; destructiveHint?: boolean; idempotentHint?: boolean } = {}, ) => { (server.tool as any)( name, description, schema, { readOnlyHint: annotations.readOnlyHint ?? false, destructiveHint: annotations.destructiveHint ?? false, idempotentHint: annotations.idempotentHint ?? false, openWorldHint: false, }, async (args: unknown) => jsonResult(await handler(args as z.objectOutputType<S, z.ZodTypeAny>), pretty), ); }; - src/tools.ts:181-195 (schema)Zod schema for the obsidian_search tool: defines inputs like vault, query, tag, pathPrefix, regex, caseSensitive, includeContent, contextChars, limit, offset.
tool( "obsidian_search", "Search notes by text or regex. Returns ranked snippets and supports tag and folder filters.", { vault: vaultArg, query: z.string().default(""), tag: z.string().optional(), pathPrefix: z.string().optional(), regex: z.boolean().optional().default(false), caseSensitive: z.boolean().optional().default(false), includeContent: z.boolean().optional().default(false), contextChars: z.number().int().min(20).max(2000).optional().default(160), limit: z.number().int().min(1).max(500).optional(), offset: z.number().int().min(0).optional().default(0), }, - src/tools.ts:196-203 (handler)Handler function for obsidian_search: loads notes, applies search/filter logic via searchNotes(), paginates results with limit/offset.
async (args) => { const notes = await loadNotes(vaults, args.vault); const pageLimit = Math.min(args.limit ?? config.maxSearchResults, config.maxSearchResults); const all = searchNotes(notes, { ...args, limit: args.offset + pageLimit }); return { total: all.length, offset: args.offset, hits: all.slice(args.offset, args.offset + pageLimit) }; }, { readOnlyHint: true }, ); - src/search.ts:34-56 (helper)loadNotes() helper: caches and loads all markdown notes from a vault, used by the obsidian_search handler.
export async function loadNotes(vaults: VaultManager, vault?: string | null): Promise<NoteRecord[]> { const vaultName = vaults.getVault(vault).name; const cached = notesCache.get(vaultName); if (cached && Date.now() - cached.timestamp < NOTES_CACHE_TTL) return cached.notes; const files = await vaults.markdownFiles(vault); const records: NoteRecord[] = []; for (const file of files) { const read = await vaults.readText(file, vault); const parsed = parseFrontmatter(read.text); records.push({ path: read.path, title: titleFor(read.path, read.text, parsed.data), content: read.text, frontmatter: parsed.data, tags: extractAllTags(read.text), headings: extractHeadings(read.text), mtime: read.stat.mtime.toISOString(), size: read.stat.size, }); } notesCache.set(vaultName, { notes: records, timestamp: Date.now() }); return records; } - src/search.ts:58-106 (helper)searchNotes() helper: filters notes by tag/pathPrefix, builds matcher for regex or tokenized text search, returns ranked SearchHit results with snippets.
export function searchNotes( notes: NoteRecord[], options: { query: string; limit: number; contextChars: number; caseSensitive?: boolean; regex?: boolean; tag?: string; pathPrefix?: string; includeContent?: boolean; }, ): SearchHit[] { const tag = options.tag?.replace(/^#/, "").toLowerCase(); const prefix = options.pathPrefix?.replace(/^\/+/, "").toLowerCase(); const filtered = notes.filter((note) => { if (tag && !note.tags.some((t) => t.toLowerCase() === tag || t.toLowerCase().startsWith(`${tag}/`))) return false; if (prefix && !note.path.toLowerCase().startsWith(prefix)) return false; return true; }); const query = options.query.trim(); if (!query && tag) { return filtered.slice(0, options.limit).map((note) => ({ path: note.path, title: note.title, score: 1, matches: 1, tags: note.tags, snippet: note.content.slice(0, options.contextChars * 2), mtime: note.mtime, })); } const matcher = buildMatcher(query, options); const hits: SearchHit[] = []; for (const note of filtered) { const result = matcher(note); if (!result) continue; hits.push({ path: note.path, title: note.title, score: result.score, matches: result.matches, tags: note.tags, snippet: options.includeContent ? note.content : result.snippet, mtime: note.mtime, }); } return hits.sort((a, b) => b.score - a.score || b.matches - a.matches || a.path.localeCompare(b.path)).slice(0, options.limit); }