MCP Tools for Obsidian

import fs from "fs"; import fsp from "fs/promises"; import https from "https"; import { Notice, Plugin } from "obsidian"; import os from "os"; import { Observable } from "rxjs"; import { logger } from "$/shared"; import { GITHUB_DOWNLOAD_URL, type Arch, type Platform } from "../constants"; import type { DownloadProgress, InstallPathInfo } from "../types"; import { getInstallPath } from "./status"; export function getPlatform(): Platform { const platform = os.platform(); switch (platform) { case "darwin": return "macos"; case "win32": return "windows"; default: return "linux"; } } export function getArch(): Arch { return os.arch() as Arch; } export function getDownloadUrl(platform: Platform, arch: Arch): string { return platform === "windows" ? `${GITHUB_DOWNLOAD_URL}/mcp-server-${platform}.exe` : `${GITHUB_DOWNLOAD_URL}/mcp-server-${platform}-${arch}`; } /** * Ensures that the specified directory path exists and is writable. * * If the directory does not exist, it will be created recursively. If the directory * exists but is not writable, an error will be thrown. * * @param dirpath - The real directory path to ensure exists and is writable. * @throws {Error} If the directory does not exist or is not writable. */ export async function ensureDirectory(dirpath: string) { try { if (!fs.existsSync(dirpath)) { await fsp.mkdir(dirpath, { recursive: true }); } // Verify directory was created and is writable try { await fsp.access(dirpath, fs.constants.W_OK); } catch (accessError) { throw new Error(`Directory exists but is not writable: ${dirpath}`); } } catch (error) { logger.error(`Failed to ensure directory:`, { error }); throw error; } } export function downloadFile( url: string, outputPath: string, redirects = 0, ): Observable<DownloadProgress> { return new Observable((subscriber) => { if (redirects > 5) { subscriber.error(new Error("Too many redirects")); return; } let fileStream: fs.WriteStream | undefined; const cleanup = (err?: unknown) => { if (err) { logger.debug("Cleaning up incomplete download:", { outputPath, writableFinished: JSON.stringify(fileStream?.writableFinished), error: err instanceof Error ? err.message : String(err), }); fileStream?.destroy(); fsp.unlink(outputPath).catch((unlinkError) => { logger.error("Failed to clean up incomplete download:", { outputPath, error: unlinkError instanceof Error ? unlinkError.message : String(unlinkError), }); }); } else { fileStream?.close(); fsp.chmod(outputPath, 0o755).catch((chmodError) => { logger.error("Failed to set executable permissions:", { outputPath, error: chmodError instanceof Error ? chmodError.message : String(chmodError), }); }); } }; https .get(url, (response) => { try { if (!response) { throw new Error("No response received"); } const statusCode = response.statusCode ?? 0; // Handle various HTTP status codes if (statusCode >= 400) { throw new Error( `HTTP Error ${statusCode}: ${response.statusMessage}`, ); } if (statusCode === 302 || statusCode === 301) { const redirectUrl = response.headers.location; if (!redirectUrl) { throw new Error( `Redirect (${statusCode}) received but no location header found`, ); } // Handle redirect by creating a new observable downloadFile(redirectUrl, outputPath, redirects + 1).subscribe( subscriber, ); return; } if (statusCode !== 200) { throw new Error(`Unexpected status code: ${statusCode}`); } // Validate content length const contentLength = response.headers["content-length"]; const totalBytes = contentLength ? parseInt(contentLength, 10) : 0; if (contentLength && isNaN(totalBytes)) { throw new Error("Invalid content-length header"); } try { fileStream = fs.createWriteStream(outputPath, { flags: "w", }); } catch (err) { throw new Error( `Failed to create write stream: ${err instanceof Error ? err.message : String(err)}`, ); } let downloadedBytes = 0; fileStream.on("error", (err) => { const fileStreamError = new Error( `File stream error: ${err.message}`, ); cleanup(fileStreamError); subscriber.error(fileStreamError); }); response.on("data", (chunk: Buffer) => { try { if (!Buffer.isBuffer(chunk)) { throw new Error("Received invalid data chunk"); } downloadedBytes += chunk.length; const percentage = totalBytes ? (downloadedBytes / totalBytes) * 100 : 0; subscriber.next({ bytesReceived: downloadedBytes, totalBytes, percentage: Math.round(percentage * 100) / 100, }); } catch (err) { cleanup(err); subscriber.error(err); } }); response.pipe(fileStream); fileStream.on("finish", () => { cleanup(); subscriber.complete(); }); response.on("error", (err) => { cleanup(err); subscriber.error(new Error(`Response error: ${err.message}`)); }); } catch (err) { cleanup(err); subscriber.error(err instanceof Error ? err : new Error(String(err))); } }) .on("error", (err) => { cleanup(err); subscriber.error(new Error(`Network error: ${err.message}`)); }); }); } export async function installMcpServer( plugin: Plugin, ): Promise<InstallPathInfo> { try { const platform = getPlatform(); const arch = getArch(); const downloadUrl = getDownloadUrl(platform, arch); const installPath = await getInstallPath(plugin); if ("error" in installPath) throw new Error(installPath.error); await ensureDirectory(installPath.dir); const progressNotice = new Notice("Downloading MCP server...", 0); logger.debug("Downloading MCP server:", { downloadUrl, installPath }); const download$ = downloadFile(downloadUrl, installPath.path); return new Promise((resolve, reject) => { download$.subscribe({ next: (progress: DownloadProgress) => { progressNotice.setMessage( `Downloading MCP server: ${progress.percentage}%`, ); }, error: (error: Error) => { progressNotice.hide(); new Notice(`Failed to download MCP server: ${error.message}`); logger.error("Download failed:", { error, installPath }); reject(error); }, complete: () => { progressNotice.hide(); new Notice("MCP server downloaded successfully!"); logger.info("MCP server downloaded", { installPath }); resolve(installPath); }, }); }); } catch (error) { new Notice( `Failed to install MCP server: ${error instanceof Error ? error.message : String(error)}`, ); throw error; } }