Skip to main content
Glama

obsidian-mcp

path.ts17.2 kB
import path from "path"; import fs from "fs/promises"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import os from "os"; import { exec as execCallback } from "child_process"; import { promisify } from "util"; // Promisify exec for cleaner async/await usage const exec = promisify(execCallback); /** * Checks if a path contains any problematic characters or patterns * @param vaultPath - The path to validate * @returns Error message if invalid, null if valid */ export function checkPathCharacters(vaultPath: string): string | null { // Platform-specific path length limits const maxPathLength = process.platform === 'win32' ? 260 : 4096; if (vaultPath.length > maxPathLength) { return `Path exceeds maximum length (${maxPathLength} characters)`; } // Check component length (individual parts between separators) const components = vaultPath.split(/[\/\\]/); const maxComponentLength = process.platform === 'win32' ? 255 : 255; const longComponent = components.find(c => c.length > maxComponentLength); if (longComponent) { return `Directory/file name too long: "${longComponent.slice(0, 50)}..."`; } // Check for root-only paths if (process.platform === 'win32') { if (/^[A-Za-z]:\\?$/.test(vaultPath)) { return 'Cannot use drive root directory'; } } else { if (vaultPath === '/') { return 'Cannot use filesystem root directory'; } } // Check for relative path components if (components.includes('..') || components.includes('.')) { return 'Path cannot contain relative components (. or ..)'; } // Check for non-printable characters if (/[\x00-\x1F\x7F]/.test(vaultPath)) { return 'Contains non-printable characters'; } // Platform-specific checks if (process.platform === 'win32') { // Windows-specific checks const winReservedNames = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i; const pathParts = vaultPath.split(/[\/\\]/); if (pathParts.some(part => winReservedNames.test(part))) { return 'Contains Windows reserved names (CON, PRN, etc.)'; } // Windows invalid characters (allowing : for drive letters) // First check if this is a Windows path with a drive letter if (/^[A-Za-z]:[\/\\]/.test(vaultPath)) { // Skip the drive letter part and check the rest of the path const pathWithoutDrive = vaultPath.slice(2); const components = pathWithoutDrive.split(/[\/\\]/); for (const part of components) { if (/[<>:"|?*]/.test(part)) { return 'Contains characters not allowed on Windows (<>:"|?*)'; } } } else { // No drive letter, check all components normally const components = vaultPath.split(/[\/\\]/); for (const part of components) { if (/[<>:"|?*]/.test(part)) { return 'Contains characters not allowed on Windows (<>:"|?*)'; } } } // Windows device paths if (/^\\\\.\\/.test(vaultPath)) { return 'Device paths are not allowed'; } } else { // Unix-specific checks const unixInvalidChars = /[\x00]/; // Only check for null character const pathComponents = vaultPath.split('/'); for (const component of pathComponents) { if (unixInvalidChars.test(component)) { return 'Contains invalid characters for Unix paths'; } } } // Check for Unicode replacement character if (vaultPath.includes('\uFFFD')) { return 'Contains invalid Unicode characters'; } // Check for leading/trailing whitespace if (vaultPath !== vaultPath.trim()) { return 'Contains leading or trailing whitespace'; } // Check for consecutive separators if (/[\/\\]{2,}/.test(vaultPath)) { return 'Contains consecutive path separators'; } return null; } /** * Checks if a path is on a local filesystem * @param vaultPath - The path to check * @returns Error message if invalid, null if valid */ export async function checkLocalPath(vaultPath: string): Promise<string | null> { try { // Get real path (resolves symlinks) const realPath = await fs.realpath(vaultPath); // Check if path changed significantly after resolving symlinks if (path.dirname(realPath) !== path.dirname(vaultPath)) { return 'Path contains symlinks that point outside the parent directory'; } // Check for network paths if (process.platform === 'win32') { // Windows UNC paths and mapped drives if (realPath.startsWith('\\\\') || /^[a-zA-Z]:\\$/.test(realPath.slice(0, 3))) { // Check Windows drive type const drive = realPath[0].toUpperCase(); // Helper functions for drive type checking async function checkWithWmic() { const cmd = `wmic logicaldisk where "DeviceID='${drive}:'" get DriveType /value`; return await exec(cmd, { timeout: 5000 }); } async function checkWithPowershell() { const cmd = `powershell -Command "(Get-WmiObject -Class Win32_LogicalDisk | Where-Object { $_.DeviceID -eq '${drive}:' }).DriveType"`; const { stdout, stderr } = await exec(cmd, { timeout: 5000 }); return { stdout: `DriveType=${stdout.trim()}`, stderr }; } try { let result: { stdout: string; stderr: string }; try { result = await checkWithWmic(); } catch (wmicError) { // Fallback to PowerShell if WMIC fails result = await checkWithPowershell(); } const { stdout, stderr } = result; if (stderr) { console.error(`Warning: Drive type check produced errors:`, stderr); } // DriveType: 2 = Removable, 3 = Local, 4 = Network, 5 = CD-ROM, 6 = RAM disk const match = stdout.match(/DriveType=(\d+)/); const driveType = match ? match[1] : '0'; // Consider removable drives and unknown types as potentially network-based if (driveType === '0' || driveType === '2' || driveType === '4') { return 'Network, removable, or unknown drive type is not supported'; } } catch (error: unknown) { if ((error as Error & { code?: string }).code === 'ETIMEDOUT') { return 'Network, removable, or unknown drive type is not supported'; } console.error(`Error checking drive type:`, error); // Fail safe: treat any errors as potential network drives return 'Unable to verify if drive is local'; } } } else { // Unix network mounts (common mount points) const networkPaths = ['/net/', '/mnt/', '/media/', '/Volumes/']; if (networkPaths.some(prefix => realPath.startsWith(prefix))) { // Check if it's a network mount using df // Check Unix mount type const cmd = `df -P "${realPath}" | tail -n 1`; try { const { stdout, stderr } = await exec(cmd, { timeout: 5000 }) .catch((error: Error & { code?: string }) => { if (error.code === 'ETIMEDOUT') { // Timeout often indicates a network mount return { stdout: 'network', stderr: '' }; } throw error; }); if (stderr) { console.error(`Warning: Mount type check produced errors:`, stderr); } // Check for common network filesystem indicators const isNetwork = stdout.match(/^(nfs|cifs|smb|afp|ftp|ssh|davfs)/i) || stdout.includes(':') || stdout.includes('//') || stdout.includes('type fuse.') || stdout.includes('network'); if (isNetwork) { return 'Network or remote filesystem is not supported'; } } catch (error: unknown) { console.error(`Error checking mount type:`, error); // Fail safe: treat any errors as potential network mounts return 'Unable to verify if filesystem is local'; } } } return null; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ELOOP') { return 'Contains circular symlinks'; } return null; // Other errors will be caught by the main validation } } /** * Checks if a path contains any suspicious patterns * @param vaultPath - The path to check * @returns Error message if suspicious, null if valid */ export async function checkSuspiciousPath(vaultPath: string): Promise<string | null> { // Check for hidden directories (except .obsidian) if (vaultPath.split(path.sep).some(part => part.startsWith('.') && part !== '.obsidian')) { return 'Contains hidden directories'; } // Check for system directories const systemDirs = [ '/bin', '/sbin', '/usr/bin', '/usr/sbin', '/etc', '/var', '/tmp', '/dev', '/sys', 'C:\\Windows', 'C:\\Program Files', 'C:\\System32', 'C:\\Users\\All Users', 'C:\\ProgramData' ]; if (systemDirs.some(dir => vaultPath.toLowerCase().startsWith(dir.toLowerCase()))) { return 'Points to a system directory'; } // Check for home directory root (too broad access) if (vaultPath === os.homedir()) { return 'Points to home directory root'; } // Check for path length if (vaultPath.length > 255) { return 'Path is too long (maximum 255 characters)'; } // Check for problematic characters const charIssue = checkPathCharacters(vaultPath); if (charIssue) { return charIssue; } return null; } /** * Normalizes and resolves a path consistently * @param inputPath - The path to normalize * @returns The normalized and resolved absolute path * @throws {McpError} If the input path is empty or invalid */ export function normalizePath(inputPath: string): string { if (!inputPath || typeof inputPath !== "string") { throw new McpError( ErrorCode.InvalidRequest, `Invalid path: ${inputPath}` ); } try { // Handle Windows paths let normalized = inputPath; // Only validate filename portion for invalid Windows characters, allowing : for drive letters const filename = normalized.split(/[\\/]/).pop() || ''; if (/[<>"|?*]/.test(filename) || (/:/.test(filename) && !/^[A-Za-z]:$/.test(filename))) { throw new McpError( ErrorCode.InvalidRequest, `Filename contains invalid characters: ${filename}` ); } // Preserve UNC paths if (normalized.startsWith('\\\\')) { // Convert to forward slashes but preserve exactly two leading slashes normalized = '//' + normalized.slice(2).replace(/\\/g, '/'); return normalized; } // Handle Windows drive letters if (/^[a-zA-Z]:[\\/]/.test(normalized)) { // Normalize path while preserving drive letter normalized = path.normalize(normalized); // Convert to forward slashes for consistency normalized = normalized.replace(/\\/g, '/'); return normalized; } // Only restrict critical system directories const restrictedDirs = [ 'C:\\Windows', 'C:\\Program Files', 'C:\\Program Files (x86)', 'C:\\ProgramData' ]; if (restrictedDirs.some(dir => normalized.toLowerCase().startsWith(dir.toLowerCase()))) { throw new McpError( ErrorCode.InvalidRequest, `Path points to restricted system directory: ${normalized}` ); } // Handle relative paths if (normalized.startsWith('./') || normalized.startsWith('../')) { normalized = path.normalize(normalized); return path.resolve(normalized); } // Default normalization for other paths normalized = normalized.replace(/\\/g, '/'); if (normalized.startsWith('./') || normalized.startsWith('../')) { return path.resolve(normalized); } return normalized; } catch (error) { throw new McpError( ErrorCode.InvalidRequest, `Failed to normalize path: ${error instanceof Error ? error.message : String(error)}` ); } } /** * Checks if a target path is safely contained within a base path * @param basePath - The base directory path * @param targetPath - The target path to check * @returns True if target is within base path, false otherwise */ export async function checkPathSafety(basePath: string, targetPath: string): Promise<boolean> { const resolvedPath = normalizePath(targetPath); const resolvedBasePath = normalizePath(basePath); try { // Check real path for symlinks const realPath = await fs.realpath(resolvedPath); const normalizedReal = normalizePath(realPath); // Check if real path is within base path if (!normalizedReal.startsWith(resolvedBasePath)) { return false; } // Check if original path is within base path return resolvedPath.startsWith(resolvedBasePath); } catch (error) { // For new files that don't exist yet, verify parent directory const parentDir = path.dirname(resolvedPath); try { const realParentPath = await fs.realpath(parentDir); const normalizedParent = normalizePath(realParentPath); return normalizedParent.startsWith(resolvedBasePath); } catch { return false; } } } /** * Ensures a path has .md extension and is valid * @param filePath - The file path to check * @returns The path with .md extension * @throws {McpError} If the path is invalid */ export function ensureMarkdownExtension(filePath: string): string { const normalized = normalizePath(filePath); return normalized.endsWith('.md') ? normalized : `${normalized}.md`; } /** * Validates that a path is within the vault directory * @param vaultPath - The vault directory path * @param targetPath - The target path to validate * @throws {McpError} If path is outside vault or invalid */ export function validateVaultPath(vaultPath: string, targetPath: string): void { if (!checkPathSafety(vaultPath, targetPath)) { throw new McpError( ErrorCode.InvalidRequest, `Path must be within the vault directory. Path: ${targetPath}, Vault: ${vaultPath}` ); } } /** * Safely joins paths and ensures result is within vault * @param vaultPath - The vault directory path * @param segments - Path segments to join * @returns The joined and validated path * @throws {McpError} If resulting path would be outside vault */ export function safeJoinPath(vaultPath: string, ...segments: string[]): string { const joined = path.join(vaultPath, ...segments); const resolved = normalizePath(joined); validateVaultPath(vaultPath, resolved); return resolved; } /** * Sanitizes a vault name to be filesystem-safe * @param name - The raw vault name * @returns The sanitized vault name */ export function sanitizeVaultName(name: string): string { return name .toLowerCase() // Replace spaces and special characters with hyphens .replace(/[^a-z0-9]+/g, '-') // Remove leading/trailing hyphens .replace(/^-+|-+$/g, '') // Ensure name isn't empty || 'unnamed-vault'; } /** * Checks if one path is a parent of another * @param parent - The potential parent path * @param child - The potential child path * @returns True if parent contains child, false otherwise */ export function isParentPath(parent: string, child: string): boolean { const relativePath = path.relative(parent, child); return !relativePath.startsWith('..') && !path.isAbsolute(relativePath); } /** * Checks if paths overlap or are duplicates * @param paths - Array of paths to check * @throws {McpError} If paths overlap or are duplicates */ export function checkPathOverlap(paths: string[]): void { // First normalize all paths to handle . and .. and symlinks const normalizedPaths = paths.map(p => { // Remove trailing slashes and normalize separators return path.normalize(p).replace(/[\/\\]+$/, ''); }); // Check for exact duplicates using normalized paths const uniquePaths = new Set<string>(); normalizedPaths.forEach((normalizedPath, index) => { if (uniquePaths.has(normalizedPath)) { throw new McpError( ErrorCode.InvalidRequest, `Duplicate vault path provided:\n` + ` Original paths:\n` + ` 1: ${paths[index]}\n` + ` 2: ${paths[normalizedPaths.indexOf(normalizedPath)]}\n` + ` Both resolve to: ${normalizedPath}` ); } uniquePaths.add(normalizedPath); }); // Then check for overlapping paths using normalized paths for (let i = 0; i < normalizedPaths.length; i++) { for (let j = i + 1; j < normalizedPaths.length; j++) { if (isParentPath(normalizedPaths[i], normalizedPaths[j]) || isParentPath(normalizedPaths[j], normalizedPaths[i])) { throw new McpError( ErrorCode.InvalidRequest, `Vault paths cannot overlap:\n` + ` Path 1: ${paths[i]}\n` + ` Path 2: ${paths[j]}\n` + ` (One vault directory cannot be inside another)\n` + ` Normalized paths:\n` + ` 1: ${normalizedPaths[i]}\n` + ` 2: ${normalizedPaths[j]}` ); } } } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/StevenStavrakis/obsidian-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server