feat: Phase 9 - Linking & Backlinks

Implement three new tools for wikilink validation, resolution, and backlink queries:

New Tools:
- validate_wikilinks: Validate all wikilinks in a note with suggestions for broken links
- resolve_wikilink: Resolve a single wikilink to its target path
- backlinks: Get all backlinks to a note with optional unlinked mentions

New Files:
- src/utils/link-utils.ts: Complete wikilink parsing, resolution, and backlink utilities

Modified Files:
- src/tools/vault-tools.ts: Added 3 new methods for link operations
- src/tools/index.ts: Added 3 tool definitions and handlers
- src/types/mcp-types.ts: Added Phase 9 type definitions
- ROADMAP.md: Marked Phase 9 as complete
- CHANGELOG.md: Added v8.0.0 release notes

Key Features:
- Regex-based wikilink parsing with position tracking
- Uses MetadataCache.getFirstLinkpathDest() for accurate resolution
- Fuzzy matching suggestion engine for broken links
- Efficient backlink detection using MetadataCache.resolvedLinks
- Optional unlinked mentions with word-boundary matching
- Context snippet extraction for each occurrence
This commit is contained in:
2025-10-17 00:52:51 -04:00
parent 99e2ade3ca
commit 6017f879f4
6 changed files with 877 additions and 26 deletions

View File

@@ -385,6 +385,60 @@ export class ToolRegistry {
},
required: ["path"]
}
},
{
name: "validate_wikilinks",
description: "Validate all wikilinks in a note and report unresolved links. Parses all [[wikilinks]] in the file, resolves them using Obsidian's link resolution rules, and provides suggestions for broken links. Returns structured JSON with total link count, arrays of resolved links (with targets) and unresolved links (with suggestions). Use this to identify and fix broken links in your notes.",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Vault-relative path to the note to validate (e.g., 'projects/project.md'). Paths are case-sensitive on macOS/Linux. Do not use leading or trailing slashes."
}
},
required: ["path"]
}
},
{
name: "resolve_wikilink",
description: "Resolve a single wikilink from a source note to its target path. Uses Obsidian's link resolution rules including shortest path matching, relative paths, and aliases. Returns structured JSON with resolution status, target path if found, or suggestions if not found. Supports links with headings ([[note#heading]]) and aliases ([[note|alias]]). Use this to programmatically resolve links before following them.",
inputSchema: {
type: "object",
properties: {
sourcePath: {
type: "string",
description: "Vault-relative path to the source note containing the link (e.g., 'projects/project.md'). Paths are case-sensitive on macOS/Linux. Do not use leading or trailing slashes."
},
linkText: {
type: "string",
description: "The wikilink text to resolve (without brackets). Examples: 'target note', 'folder/note', 'note#heading', 'note|alias'. Can include heading references and aliases."
}
},
required: ["sourcePath", "linkText"]
}
},
{
name: "backlinks",
description: "Get all backlinks to a note. Returns all notes that link to the target note, with optional unlinked mentions (text references without wikilinks). Uses Obsidian's MetadataCache for accurate backlink detection. Returns structured JSON with array of backlinks, each containing source path, type (linked/unlinked), and occurrences with line numbers and context snippets. Use this to explore note connections and build knowledge graphs.",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Vault-relative path to the target note (e.g., 'concepts/important-concept.md'). Paths are case-sensitive on macOS/Linux. Do not use leading or trailing slashes."
},
includeUnlinked: {
type: "boolean",
description: "If true, include unlinked mentions (text references without [[brackets]]). If false (default), only include wikilinks. Default: false. Warning: enabling this can be slow for large vaults."
},
includeSnippets: {
type: "boolean",
description: "If true (default), include context snippets for each backlink occurrence. If false, omit snippets to reduce response size. Default: true"
}
},
required: ["path"]
}
}
];
}
@@ -474,6 +528,16 @@ export class ToolRegistry {
return await this.vaultTools.getFolderWaypoint(args.path);
case "is_folder_note":
return await this.vaultTools.isFolderNote(args.path);
case "validate_wikilinks":
return await this.vaultTools.validateWikilinks(args.path);
case "resolve_wikilink":
return await this.vaultTools.resolveWikilink(args.sourcePath, args.linkText);
case "backlinks":
return await this.vaultTools.getBacklinks(
args.path,
args.includeUnlinked ?? false,
args.includeSnippets ?? true
);
default:
return {
content: [{ type: "text", text: `Unknown tool: ${name}` }],

View File

@@ -1,10 +1,11 @@
import { App, TFile, TFolder } from 'obsidian';
import { CallToolResult, FileMetadata, DirectoryMetadata, VaultInfo, SearchResult, SearchMatch, StatResult, ExistsResult, ListResult, FileMetadataWithFrontmatter, FrontmatterSummary, WaypointSearchResult, FolderWaypointResult, FolderNoteResult } from '../types/mcp-types';
import { CallToolResult, FileMetadata, DirectoryMetadata, VaultInfo, SearchResult, SearchMatch, StatResult, ExistsResult, ListResult, FileMetadataWithFrontmatter, FrontmatterSummary, WaypointSearchResult, FolderWaypointResult, FolderNoteResult, ValidateWikilinksResult, ResolveWikilinkResult, BacklinksResult } from '../types/mcp-types';
import { PathUtils } from '../utils/path-utils';
import { ErrorMessages } from '../utils/error-messages';
import { GlobUtils } from '../utils/glob-utils';
import { SearchUtils } from '../utils/search-utils';
import { WaypointUtils } from '../utils/waypoint-utils';
import { LinkUtils } from '../utils/link-utils';
export class VaultTools {
constructor(private app: App) {}
@@ -689,4 +690,172 @@ export class VaultTools {
};
}
}
/**
* Validate all wikilinks in a note
* Reports resolved and unresolved links with suggestions
*/
async validateWikilinks(path: string): Promise<CallToolResult> {
try {
// Normalize and validate path
const normalizedPath = PathUtils.normalizePath(path);
// Resolve file
const file = PathUtils.resolveFile(this.app, normalizedPath);
if (!file) {
return {
content: [{
type: "text",
text: ErrorMessages.fileNotFound(normalizedPath)
}],
isError: true
};
}
// Validate wikilinks
const { resolvedLinks, unresolvedLinks } = await LinkUtils.validateWikilinks(
this.app,
normalizedPath
);
const result: ValidateWikilinksResult = {
path: normalizedPath,
totalLinks: resolvedLinks.length + unresolvedLinks.length,
resolvedLinks,
unresolvedLinks
};
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2)
}]
};
} catch (error) {
return {
content: [{
type: "text",
text: `Validate wikilinks error: ${(error as Error).message}`
}],
isError: true
};
}
}
/**
* Resolve a single wikilink from a source note
* Returns the target path if resolvable, or suggestions if not
*/
async resolveWikilink(sourcePath: string, linkText: string): Promise<CallToolResult> {
try {
// Normalize and validate source path
const normalizedPath = PathUtils.normalizePath(sourcePath);
// Resolve source file
const file = PathUtils.resolveFile(this.app, normalizedPath);
if (!file) {
return {
content: [{
type: "text",
text: ErrorMessages.fileNotFound(normalizedPath)
}],
isError: true
};
}
// Try to resolve the link
const resolvedFile = LinkUtils.resolveLink(this.app, normalizedPath, linkText);
const result: ResolveWikilinkResult = {
sourcePath: normalizedPath,
linkText,
resolved: resolvedFile !== null,
targetPath: resolvedFile?.path
};
// If not resolved, provide suggestions
if (!resolvedFile) {
result.suggestions = LinkUtils.findSuggestions(this.app, linkText);
}
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2)
}]
};
} catch (error) {
return {
content: [{
type: "text",
text: `Resolve wikilink error: ${(error as Error).message}`
}],
isError: true
};
}
}
/**
* Get all backlinks to a note
* Optionally includes unlinked mentions
*/
async getBacklinks(
path: string,
includeUnlinked: boolean = false,
includeSnippets: boolean = true
): Promise<CallToolResult> {
try {
// Normalize and validate path
const normalizedPath = PathUtils.normalizePath(path);
// Resolve file
const file = PathUtils.resolveFile(this.app, normalizedPath);
if (!file) {
return {
content: [{
type: "text",
text: ErrorMessages.fileNotFound(normalizedPath)
}],
isError: true
};
}
// Get backlinks
const backlinks = await LinkUtils.getBacklinks(
this.app,
normalizedPath,
includeUnlinked
);
// If snippets not requested, remove them
if (!includeSnippets) {
for (const backlink of backlinks) {
for (const occurrence of backlink.occurrences) {
occurrence.snippet = '';
}
}
}
const result: BacklinksResult = {
path: normalizedPath,
backlinks,
totalBacklinks: backlinks.length
};
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2)
}]
};
} catch (error) {
return {
content: [{
type: "text",
text: `Get backlinks error: ${(error as Error).message}`
}],
isError: true
};
}
}
}