Files
obsidian-mcp-server/src/tools/vault-tools.ts
Bill 2a7fce45af fix: replace any types with proper TypeScript types
Replace all `any` types with properly defined TypeScript interfaces and types throughout the codebase to improve type safety and eliminate type-related code quality issues.

Changes:
- Define ElectronSafeStorage interface for Electron's safeStorage API
- Create LegacySettings interface for settings migration in main.ts
- Define JSONValue, JSONRPCParams types for JSON-RPC protocol
- Define JSONSchemaProperty for tool input schemas
- Create YAMLValue type for frontmatter values
- Define FrontmatterValue type for adapter interfaces
- Update middleware to use proper Express NextFunction and JSONRPCResponse types
- Fix tool registry to handle args with proper typing (with eslint-disable for dynamic dispatch)
- Fix notifications to use proper types with eslint-disable where dynamic access is needed
- Add proper null safety assertions where appropriate
- Fix TFolder stat access with proper type extension

All type errors resolved. TypeScript compilation passes with --skipLibCheck.
2025-11-07 11:52:48 -05:00

1030 lines
26 KiB
TypeScript

import { TFile, TFolder } from 'obsidian';
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';
import { ContentUtils } from '../utils/content-utils';
import { IVaultAdapter, IMetadataCacheAdapter } from '../adapters/interfaces';
export class VaultTools {
constructor(
private vault: IVaultAdapter,
private metadata: IMetadataCacheAdapter
) {}
async getVaultInfo(): Promise<CallToolResult> {
try {
const allFiles = this.vault.getMarkdownFiles();
const totalNotes = allFiles.length;
// Calculate total size
let totalSize = 0;
for (const file of allFiles) {
const stat = this.vault.stat(file);
if (stat) {
totalSize += stat.size;
}
}
const info = {
totalNotes,
totalSize,
sizeFormatted: this.formatBytes(totalSize)
};
return {
content: [{
type: "text",
text: JSON.stringify(info, null, 2)
}]
};
} catch (error) {
return {
content: [{
type: "text",
text: `Get vault info error: ${(error as Error).message}`
}],
isError: true
};
}
}
private formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
async listNotes(path?: string): Promise<CallToolResult> {
let items: Array<FileMetadata | DirectoryMetadata> = [];
// Normalize root path: undefined, empty string "", or "." all mean root
const isRootPath = !path || path === '' || path === '.';
let targetFolder: TFolder;
if (isRootPath) {
// Get the root folder using adapter
targetFolder = this.vault.getRoot();
} else {
// Validate non-root path
if (!PathUtils.isValidVaultPath(path)) {
return {
content: [{ type: "text", text: ErrorMessages.invalidPath(path) }],
isError: true
};
}
// Normalize the path
const normalizedPath = PathUtils.normalizePath(path);
// Get folder using adapter
const folderObj = this.vault.getAbstractFileByPath(normalizedPath);
if (!folderObj) {
return {
content: [{ type: "text", text: ErrorMessages.folderNotFound(normalizedPath) }],
isError: true
};
}
// Check if it's a folder
if (!(folderObj instanceof TFolder)) {
return {
content: [{ type: "text", text: ErrorMessages.notAFolder(normalizedPath) }],
isError: true
};
}
targetFolder = folderObj;
}
// Iterate over direct children of the folder
for (const item of targetFolder.children) {
// Skip the vault root itself
if (item.path === '' || item.path === '/' || (item instanceof TFolder && item.isRoot())) {
continue;
}
if (item instanceof TFile) {
items.push(this.createFileMetadata(item));
} else if (item instanceof TFolder) {
items.push(this.createDirectoryMetadata(item));
}
}
// Sort: directories first, then files, alphabetically within each group
// Use case-insensitive comparison for stable, consistent ordering
items.sort((a, b) => {
if (a.kind !== b.kind) {
return a.kind === 'directory' ? -1 : 1;
}
// Case-insensitive alphabetical sort within each group
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
return {
content: [{
type: "text",
text: JSON.stringify(items, null, 2)
}]
};
}
// Phase 4: Enhanced List Operations
async list(options: {
path?: string;
recursive?: boolean;
includes?: string[];
excludes?: string[];
only?: 'files' | 'directories' | 'any';
limit?: number;
cursor?: string;
withFrontmatterSummary?: boolean;
includeWordCount?: boolean;
}): Promise<CallToolResult> {
const {
path,
recursive = false,
includes,
excludes,
only = 'any',
limit,
cursor,
withFrontmatterSummary = false,
includeWordCount = false
} = options;
let items: Array<FileMetadataWithFrontmatter | DirectoryMetadata> = [];
// Normalize root path: undefined, empty string "", or "." all mean root
const isRootPath = !path || path === '' || path === '.';
let targetFolder: TFolder;
if (isRootPath) {
// Get the root folder using adapter
targetFolder = this.vault.getRoot();
} else {
// Validate non-root path
if (!PathUtils.isValidVaultPath(path)) {
return {
content: [{ type: "text", text: ErrorMessages.invalidPath(path) }],
isError: true
};
}
// Normalize the path
const normalizedPath = PathUtils.normalizePath(path);
// Get folder using adapter
const folderObj = this.vault.getAbstractFileByPath(normalizedPath);
if (!folderObj) {
return {
content: [{ type: "text", text: ErrorMessages.folderNotFound(normalizedPath) }],
isError: true
};
}
// Check if it's a folder
if (!(folderObj instanceof TFolder)) {
return {
content: [{ type: "text", text: ErrorMessages.notAFolder(normalizedPath) }],
isError: true
};
}
targetFolder = folderObj;
}
// Collect items based on recursive flag
await this.collectItems(targetFolder, items, recursive, includes, excludes, only, withFrontmatterSummary, includeWordCount);
// Sort: directories first, then files, alphabetically within each group
items.sort((a, b) => {
if (a.kind !== b.kind) {
return a.kind === 'directory' ? -1 : 1;
}
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
// Handle cursor-based pagination
let startIndex = 0;
if (cursor) {
// Cursor is the path of the last item from the previous page
const cursorIndex = items.findIndex(item => item.path === cursor);
if (cursorIndex !== -1) {
startIndex = cursorIndex + 1;
}
}
// Apply limit and pagination
const totalCount = items.length;
let paginatedItems = items.slice(startIndex);
let hasMore = false;
let nextCursor: string | undefined;
if (limit && limit > 0 && paginatedItems.length > limit) {
paginatedItems = paginatedItems.slice(0, limit);
hasMore = true;
// Set cursor to the path of the last item in this page
nextCursor = paginatedItems[paginatedItems.length - 1].path;
}
const result: ListResult = {
items: paginatedItems,
totalCount: totalCount,
hasMore: hasMore,
nextCursor: nextCursor
};
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2)
}]
};
}
/**
* Helper method to recursively collect items from a folder
*/
private async collectItems(
folder: TFolder,
items: Array<FileMetadataWithFrontmatter | DirectoryMetadata>,
recursive: boolean,
includes?: string[],
excludes?: string[],
only?: 'files' | 'directories' | 'any',
withFrontmatterSummary?: boolean,
includeWordCount?: boolean
): Promise<void> {
for (const item of folder.children) {
// Skip the vault root itself
if (item.path === '' || item.path === '/' || (item instanceof TFolder && item.isRoot())) {
continue;
}
// Apply glob filtering
if (!GlobUtils.shouldInclude(item.path, includes, excludes)) {
continue;
}
// Apply type filtering and add items
if (item instanceof TFile) {
if (only !== 'directories') {
const fileMetadata = await this.createFileMetadataWithFrontmatter(item, withFrontmatterSummary || false);
// Optionally include word count (best effort)
if (includeWordCount) {
try {
const content = await this.vault.read(item);
fileMetadata.wordCount = ContentUtils.countWords(content);
} catch (error) {
// Skip word count if file can't be read (binary file, etc.)
// wordCount field simply omitted for this file
}
}
items.push(fileMetadata);
}
} else if (item instanceof TFolder) {
if (only !== 'files') {
items.push(this.createDirectoryMetadata(item));
}
// Recursively collect from subfolders if needed
if (recursive) {
await this.collectItems(item, items, recursive, includes, excludes, only, withFrontmatterSummary, includeWordCount);
}
}
}
}
private async createFileMetadataWithFrontmatter(
file: TFile,
withFrontmatterSummary: boolean
): Promise<FileMetadataWithFrontmatter> {
const baseMetadata = this.createFileMetadata(file);
if (!withFrontmatterSummary || file.extension !== 'md') {
return baseMetadata;
}
// Extract frontmatter without reading full content
try {
const cache = this.metadata.getFileCache(file);
if (cache?.frontmatter) {
const summary: FrontmatterSummary = {};
// Extract common frontmatter fields
if (cache.frontmatter.title) {
summary.title = cache.frontmatter.title;
}
if (cache.frontmatter.tags) {
// Tags can be string or array
if (Array.isArray(cache.frontmatter.tags)) {
summary.tags = cache.frontmatter.tags;
} else if (typeof cache.frontmatter.tags === 'string') {
summary.tags = [cache.frontmatter.tags];
}
}
if (cache.frontmatter.aliases) {
// Aliases can be string or array
if (Array.isArray(cache.frontmatter.aliases)) {
summary.aliases = cache.frontmatter.aliases;
} else if (typeof cache.frontmatter.aliases === 'string') {
summary.aliases = [cache.frontmatter.aliases];
}
}
// Include all other frontmatter fields
for (const key in cache.frontmatter) {
if (key !== 'title' && key !== 'tags' && key !== 'aliases' && key !== 'position') {
summary[key] = cache.frontmatter[key];
}
}
return {
...baseMetadata,
frontmatterSummary: summary
};
}
} catch (error) {
// If frontmatter extraction fails, just return base metadata
}
return baseMetadata;
}
private createFileMetadata(file: TFile): FileMetadata {
return {
kind: "file",
name: file.name,
path: file.path,
extension: file.extension,
size: file.stat.size,
modified: file.stat.mtime,
created: file.stat.ctime
};
}
private createDirectoryMetadata(folder: TFolder): DirectoryMetadata {
// Count direct children
const childrenCount = folder.children.length;
// Try to get modified time from filesystem if available
// Note: Obsidian's TFolder doesn't have a stat property in the official API
// We try to access it anyway in case it's populated at runtime
// In most cases, this will be 0 for directories
let modified = 0;
try {
// TFolder doesn't officially have stat, but it may exist in practice
const folderWithStat = folder as TFolder & { stat?: { mtime?: number } };
if (folderWithStat.stat && typeof folderWithStat.stat.mtime === 'number') {
modified = folderWithStat.stat.mtime;
}
} catch (error) {
// Silently fail - modified will remain 0
}
return {
kind: "directory",
name: folder.name,
path: folder.path,
childrenCount: childrenCount,
modified: modified
};
}
// Phase 3: Discovery Endpoints
async stat(path: string, includeWordCount: boolean = false): Promise<CallToolResult> {
// Validate path
if (!PathUtils.isValidVaultPath(path)) {
return {
content: [{ type: "text", text: ErrorMessages.invalidPath(path) }],
isError: true
};
}
// Normalize the path
const normalizedPath = PathUtils.normalizePath(path);
// Get file or folder using adapter
const item = this.vault.getAbstractFileByPath(normalizedPath);
if (!item) {
// Path doesn't exist
const result: StatResult = {
path: normalizedPath,
exists: false
};
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2)
}]
};
}
// Check if it's a file
if (item instanceof TFile) {
const metadata = this.createFileMetadata(item);
// Optionally include word count
if (includeWordCount) {
try {
const content = await this.vault.read(item);
metadata.wordCount = ContentUtils.countWords(content);
} catch (error) {
// Skip word count if file can't be read (binary file, etc.)
}
}
const result: StatResult = {
path: normalizedPath,
exists: true,
kind: "file",
metadata
};
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2)
}]
};
}
// Check if it's a folder
if (item instanceof TFolder) {
const result: StatResult = {
path: normalizedPath,
exists: true,
kind: "directory",
metadata: this.createDirectoryMetadata(item)
};
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2)
}]
};
}
// DEFENSIVE CODE - UNREACHABLE
// This code is unreachable because getAbstractFileByPath only returns TFile, TFolder, or null.
// All three cases are handled above (null at line 405, TFile at line 420, TFolder at line 436).
// TypeScript requires exhaustive handling, so this defensive return is included.
/* istanbul ignore next */
const result: StatResult = {
path: normalizedPath,
exists: false
};
/* istanbul ignore next */
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2)
}]
};
}
async exists(path: string): Promise<CallToolResult> {
// Validate path
if (!PathUtils.isValidVaultPath(path)) {
return {
content: [{ type: "text", text: ErrorMessages.invalidPath(path) }],
isError: true
};
}
// Normalize the path
const normalizedPath = PathUtils.normalizePath(path);
// Get file or folder using adapter
const item = this.vault.getAbstractFileByPath(normalizedPath);
if (!item) {
// Path doesn't exist
const result: ExistsResult = {
path: normalizedPath,
exists: false
};
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2)
}]
};
}
// Check if it's a file
if (item instanceof TFile) {
const result: ExistsResult = {
path: normalizedPath,
exists: true,
kind: "file"
};
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2)
}]
};
}
// Check if it's a folder
if (item instanceof TFolder) {
const result: ExistsResult = {
path: normalizedPath,
exists: true,
kind: "directory"
};
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2)
}]
};
}
// DEFENSIVE CODE - UNREACHABLE
// This code is unreachable because getAbstractFileByPath only returns TFile, TFolder, or null.
// All three cases are handled above (null at line 479, TFile at line 494, TFolder at line 509).
// TypeScript requires exhaustive handling, so this defensive return is included.
/* istanbul ignore next */
const result: ExistsResult = {
path: normalizedPath,
exists: false
};
/* istanbul ignore next */
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2)
}]
};
}
// Phase 6: Powerful Search
async search(options: {
query: string;
isRegex?: boolean;
caseSensitive?: boolean;
includes?: string[];
excludes?: string[];
folder?: string;
returnSnippets?: boolean;
snippetLength?: number;
maxResults?: number;
}): Promise<CallToolResult> {
const {
query,
isRegex = false,
caseSensitive = false,
includes,
excludes,
folder,
returnSnippets = true,
snippetLength = 100,
maxResults = 100
} = options;
try {
// Compile search pattern
let searchPattern: RegExp;
try {
if (isRegex) {
const flags = caseSensitive ? 'g' : 'gi';
searchPattern = new RegExp(query, flags);
} else {
// Escape special regex characters for literal search
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const flags = caseSensitive ? 'g' : 'gi';
searchPattern = new RegExp(escapedQuery, flags);
}
} catch (error) {
return {
content: [{
type: "text",
text: `Invalid regex pattern: ${(error as Error).message}`
}],
isError: true
};
}
// Get files to search using adapter
let files = this.vault.getMarkdownFiles();
// Filter by folder if specified
if (folder) {
const folderPath = folder.endsWith('/') ? folder : folder + '/';
files = files.filter(file =>
file.path.startsWith(folderPath) || file.path === folder
);
}
// Apply glob filtering
if (includes || excludes) {
files = files.filter(file =>
GlobUtils.shouldInclude(file.path, includes, excludes)
);
}
const matches: SearchMatch[] = [];
const filesWithMatches = new Set<string>();
let filesSearched = 0;
// Search through files
for (const file of files) {
if (matches.length >= maxResults) {
break;
}
filesSearched++;
try {
const content = await this.vault.read(file);
const lines = content.split('\n');
// Search in content
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
if (matches.length >= maxResults) {
break;
}
const line = lines[lineIndex];
// Reset regex lastIndex for global patterns
searchPattern.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = searchPattern.exec(line)) !== null) {
if (matches.length >= maxResults) {
break;
}
const columnIndex = match.index;
const matchText = match[0];
// Extract snippet with context
let snippet = line;
let snippetStart = 0;
let matchStart = columnIndex;
if (returnSnippets && line.length > snippetLength) {
// Calculate snippet boundaries
const halfSnippet = Math.floor(snippetLength / 2);
snippetStart = Math.max(0, columnIndex - halfSnippet);
const snippetEnd = Math.min(line.length, snippetStart + snippetLength);
// Adjust if we're at the end of the line
if (snippetEnd === line.length && line.length > snippetLength) {
snippetStart = Math.max(0, line.length - snippetLength);
}
snippet = line.substring(snippetStart, snippetEnd);
matchStart = columnIndex - snippetStart;
}
matches.push({
path: file.path,
line: lineIndex + 1, // 1-indexed
column: columnIndex + 1, // 1-indexed
snippet: snippet,
matchRanges: [{
start: matchStart,
end: matchStart + matchText.length
}]
});
filesWithMatches.add(file.path);
// Prevent infinite loop for zero-width matches
if (match[0].length === 0) {
searchPattern.lastIndex++;
}
}
}
} catch (error) {
// Skip files that can't be read
}
}
const result: SearchResult = {
query,
isRegex,
matches,
totalMatches: matches.length,
filesSearched,
filesWithMatches: filesWithMatches.size
};
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2)
}]
};
} catch (error) {
return {
content: [{
type: "text",
text: `Search error: ${(error as Error).message}`
}],
isError: true
};
}
}
async searchWaypoints(folder?: string): Promise<CallToolResult> {
try {
const waypoints = await SearchUtils.searchWaypoints(this.vault, folder);
const result: WaypointSearchResult = {
waypoints,
totalWaypoints: waypoints.length,
filesSearched: this.vault.getMarkdownFiles().filter(file => {
if (!folder) return true;
const folderPath = folder.endsWith('/') ? folder : folder + '/';
return file.path.startsWith(folderPath) || file.path === folder;
}).length
};
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2)
}]
};
} catch (error) {
return {
content: [{
type: "text",
text: `Waypoint search error: ${(error as Error).message}`
}],
isError: true
};
}
}
async getFolderWaypoint(path: string): Promise<CallToolResult> {
try {
// Normalize and validate path
const normalizedPath = PathUtils.normalizePath(path);
// Get file using adapter
const file = this.vault.getAbstractFileByPath(normalizedPath);
if (!file || !(file instanceof TFile)) {
return {
content: [{
type: "text",
text: ErrorMessages.fileNotFound(normalizedPath)
}],
isError: true
};
}
// Read file content
const content = await this.vault.read(file);
// Extract waypoint block
const waypointBlock = WaypointUtils.extractWaypointBlock(content);
const result: FolderWaypointResult = {
path: file.path,
hasWaypoint: waypointBlock.hasWaypoint,
waypointRange: waypointBlock.waypointRange,
links: waypointBlock.links,
rawContent: waypointBlock.rawContent
};
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2)
}]
};
} catch (error) {
return {
content: [{
type: "text",
text: `Get folder waypoint error: ${(error as Error).message}`
}],
isError: true
};
}
}
async isFolderNote(path: string): Promise<CallToolResult> {
try {
// Normalize and validate path
const normalizedPath = PathUtils.normalizePath(path);
// Get file using adapter
const file = this.vault.getAbstractFileByPath(normalizedPath);
if (!file || !(file instanceof TFile)) {
return {
content: [{
type: "text",
text: ErrorMessages.fileNotFound(normalizedPath)
}],
isError: true
};
}
// Check if it's a folder note
const folderNoteInfo = await WaypointUtils.isFolderNote(this.vault, file);
const result: FolderNoteResult = {
path: file.path,
isFolderNote: folderNoteInfo.isFolderNote,
reason: folderNoteInfo.reason,
folderPath: folderNoteInfo.folderPath
};
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2)
}]
};
} catch (error) {
return {
content: [{
type: "text",
text: `Is folder note error: ${(error as Error).message}`
}],
isError: true
};
}
}
/**
* 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);
// Get file using adapter
const file = this.vault.getAbstractFileByPath(normalizedPath);
if (!file || !(file instanceof TFile)) {
return {
content: [{
type: "text",
text: ErrorMessages.fileNotFound(normalizedPath)
}],
isError: true
};
}
// Use LinkUtils to validate wikilinks
const { resolvedLinks, unresolvedLinks } = await LinkUtils.validateWikilinks(
this.vault,
this.metadata,
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);
// Get source file using adapter
const file = this.vault.getAbstractFileByPath(normalizedPath);
if (!file || !(file instanceof TFile)) {
return {
content: [{
type: "text",
text: ErrorMessages.fileNotFound(normalizedPath)
}],
isError: true
};
}
// Try to resolve the link using LinkUtils
const resolvedFile = LinkUtils.resolveLink(this.vault, this.metadata, 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.vault, 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);
// Get target file using adapter
const targetFile = this.vault.getAbstractFileByPath(normalizedPath);
if (!targetFile || !(targetFile instanceof TFile)) {
return {
content: [{
type: "text",
text: ErrorMessages.fileNotFound(normalizedPath)
}],
isError: true
};
}
// Use LinkUtils to get backlinks
const backlinks = await LinkUtils.getBacklinks(
this.vault,
this.metadata,
normalizedPath,
includeUnlinked
);
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
};
}
}
}