obsidian_find_orphans
Find notes with missing connections in your Obsidian vault. Choose from three modes to detect notes with no backlinks, no outgoing links, or both.
Instructions
Find notes with no backlinks, no outgoing links, or neither.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| vault | No | Optional configured vault name. Defaults to the server default vault. | |
| mode | No | no-backlinks | |
| limit | No | ||
| offset | No |
Implementation Reference
- src/tools.ts:38-60 (registration)The registerObsidianTools function uses a local 'tool' helper that wraps server.tool() to register all tools, including obsidian_find_orphans.
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:780-787 (schema)Input schema for obsidian_find_orphans: vault (optional string), mode (enum: no-backlinks, no-outgoing, isolated), limit, and offset.
"obsidian_find_orphans", "Find notes with no backlinks, no outgoing links, or neither.", { vault: vaultArg, mode: z.enum(["no-backlinks", "no-outgoing", "isolated"]).optional().default("no-backlinks"), limit: z.number().int().min(1).max(1000).optional().default(200), offset: z.number().int().min(0).optional().default(0), }, - src/tools.ts:788-798 (handler)Handler logic for obsidian_find_orphans: builds the vault graph, filters nodes by inDegree/outDegree based on mode, and returns paginated results.
async (args) => { const graph = buildGraph(await loadNotes(vaults, args.vault)); const nodes = graph.nodes.filter((node) => { if (args.mode === "no-backlinks") return node.inDegree === 0; if (args.mode === "no-outgoing") return node.outDegree === 0; return node.inDegree === 0 && node.outDegree === 0; }); return { total: nodes.length, offset: args.offset, nodes: nodes.slice(args.offset, args.offset + args.limit) }; }, { readOnlyHint: true }, ); - src/graph.ts:28-65 (helper)buildGraph constructs the vault graph with nodes (tracking inDegree/outDegree) and edges. Used by the orphan-finding handler.
export function buildGraph(notes: NoteRecord[]): VaultGraph { const resolver = createResolver(notes); const edges: GraphEdge[] = []; for (const note of notes) { for (const link of extractWikiLinks(note.content)) { const target = resolver(link.target); edges.push({ source: note.path, target: target ?? link.target, rawTarget: link.target, kind: "wiki", unresolved: !target, line: link.line, }); } for (const link of extractMarkdownLinks(note.content)) { const target = resolver(link.target); edges.push({ source: note.path, target: target ?? link.target, rawTarget: link.target, kind: "markdown", unresolved: !target, line: link.line, }); } } const outCounts = countBy(edges.filter((e) => !e.unresolved), (edge) => edge.source); const inCounts = countBy(edges.filter((e) => !e.unresolved), (edge) => edge.target); const nodes = notes.map((note) => ({ path: note.path, title: note.title, tags: note.tags, outDegree: outCounts.get(note.path) ?? 0, inDegree: inCounts.get(note.path) ?? 0, })); return { nodes, edges, byPath: new Map(nodes.map((node) => [node.path, node])) }; } - src/graph.ts:5-195 (helper)GraphNode type definition with inDegree and outDegree fields used to determine orphan status.
export type GraphNode = { path: string; title: string; tags: string[]; outDegree: number; inDegree: number; }; export type GraphEdge = { source: string; target: string; rawTarget: string; kind: "wiki" | "markdown"; unresolved: boolean; line: number; }; export type VaultGraph = { nodes: GraphNode[]; edges: GraphEdge[]; byPath: Map<string, GraphNode>; }; export function buildGraph(notes: NoteRecord[]): VaultGraph { const resolver = createResolver(notes); const edges: GraphEdge[] = []; for (const note of notes) { for (const link of extractWikiLinks(note.content)) { const target = resolver(link.target); edges.push({ source: note.path, target: target ?? link.target, rawTarget: link.target, kind: "wiki", unresolved: !target, line: link.line, }); } for (const link of extractMarkdownLinks(note.content)) { const target = resolver(link.target); edges.push({ source: note.path, target: target ?? link.target, rawTarget: link.target, kind: "markdown", unresolved: !target, line: link.line, }); } } const outCounts = countBy(edges.filter((e) => !e.unresolved), (edge) => edge.source); const inCounts = countBy(edges.filter((e) => !e.unresolved), (edge) => edge.target); const nodes = notes.map((note) => ({ path: note.path, title: note.title, tags: note.tags, outDegree: outCounts.get(note.path) ?? 0, inDegree: inCounts.get(note.path) ?? 0, })); return { nodes, edges, byPath: new Map(nodes.map((node) => [node.path, node])) }; } export function backlinks(graph: VaultGraph, targetPath: string): GraphEdge[] { const target = resolveGraphPath(graph, targetPath); if (!target) return []; return graph.edges.filter((edge) => !edge.unresolved && edge.target === target.path); } export function outgoing(graph: VaultGraph, sourcePath: string): GraphEdge[] { const source = resolveGraphPath(graph, sourcePath); if (!source) return []; return graph.edges.filter((edge) => !edge.unresolved && edge.source === source.path); } export function traverseGraph( graph: VaultGraph, startPath: string, options: { depth: number; direction: "forward" | "backward" | "both"; limit: number }, ): { nodes: GraphNode[]; edges: GraphEdge[] } { const start = resolveGraphPath(graph, startPath); if (!start) return { nodes: [], edges: [] }; const seen = new Set<string>([start.path]); const edgeSeen = new Set<string>(); const queue: Array<{ path: string; depth: number }> = [{ path: start.path, depth: 0 }]; const resultEdges: GraphEdge[] = []; while (queue.length > 0 && seen.size < options.limit) { const current = queue.shift(); if (!current || current.depth >= options.depth) continue; const edges = graph.edges.filter((edge) => { if (edge.unresolved) return false; if (options.direction === "forward") return edge.source === current.path; if (options.direction === "backward") return edge.target === current.path; return edge.source === current.path || edge.target === current.path; }); for (const edge of edges) { const key = `${edge.source}->${edge.target}:${edge.line}`; if (!edgeSeen.has(key)) { edgeSeen.add(key); resultEdges.push(edge); } const next = edge.source === current.path ? edge.target : edge.source; if (!seen.has(next)) { seen.add(next); queue.push({ path: next, depth: current.depth + 1 }); } if (seen.size >= options.limit) break; } } return { nodes: [...seen].map((nodePath) => graph.byPath.get(nodePath)).filter((node): node is GraphNode => Boolean(node)), edges: resultEdges, }; } export function shortestPath( graph: VaultGraph, fromPath: string, toPath: string, options: { maxDepth: number; direction: "forward" | "backward" | "both" }, ): { path: string[]; edges: GraphEdge[] } | null { const from = resolveGraphPath(graph, fromPath); const to = resolveGraphPath(graph, toPath); if (!from || !to) return null; const queue: Array<{ path: string; route: string[]; edges: GraphEdge[] }> = [{ path: from.path, route: [from.path], edges: [] }]; const seen = new Set<string>([from.path]); while (queue.length > 0) { const current = queue.shift(); if (!current) continue; if (current.path === to.path) return { path: current.route, edges: current.edges }; if (current.route.length - 1 >= options.maxDepth) continue; const edges = graph.edges.filter((edge) => { if (edge.unresolved) return false; if (options.direction === "forward") return edge.source === current.path; if (options.direction === "backward") return edge.target === current.path; return edge.source === current.path || edge.target === current.path; }); for (const edge of edges) { const next = edge.source === current.path ? edge.target : edge.source; if (seen.has(next)) continue; seen.add(next); queue.push({ path: next, route: [...current.route, next], edges: [...current.edges, edge] }); } } return null; } export function resolveGraphPath(graph: VaultGraph, input: string): GraphNode | null { const clean = normalizeNoteTarget(input); const candidates = [ clean, clean.endsWith(".md") ? clean : `${clean}.md`, path.posix.basename(clean), `${path.posix.basename(clean)}.md`, ]; for (const candidate of candidates) { const direct = graph.byPath.get(candidate); if (direct) return direct; } const stem = path.posix.basename(clean).replace(/\.md$/i, "").toLowerCase(); const found = [...graph.byPath.values()].find((node) => noteStem(node.path).toLowerCase() === stem); return found ?? null; } export function createResolver(notes: NoteRecord[]): (target: string) => string | null { const byPath = new Map<string, string>(); const byStem = new Map<string, string[]>(); for (const note of notes) { byPath.set(note.path.toLowerCase(), note.path); byPath.set(note.path.replace(/\.md$/i, "").toLowerCase(), note.path); byPath.set(path.posix.basename(note.path).toLowerCase(), note.path); const stem = noteStem(note.path).toLowerCase(); const list = byStem.get(stem) ?? []; list.push(note.path); byStem.set(stem, list); } return (target: string) => { const clean = normalizeNoteTarget(target).toLowerCase(); const direct = byPath.get(clean) ?? byPath.get(`${clean}.md`); if (direct) return direct; const stem = path.posix.basename(clean).replace(/\.md$/i, ""); const matches = byStem.get(stem); return matches?.length === 1 ? matches[0] : null; }; } function countBy<T>(items: T[], key: (item: T) => string): Map<string, number> { const counts = new Map<string, number>(); for (const item of items) counts.set(key(item), (counts.get(key(item)) ?? 0) + 1); return counts; }