Skip to main content
Glama

WebDAV MCP Server

by masx200
webdav-service.ts28.6 kB
import { WebDAVClient } from "webdav"; import { webdavConnectionPool } from "./webdav-connection-pool.js"; import { createLogger } from "../utils/logger.js"; import { minimatch } from "minimatch"; const logger = createLogger("WebDAVService"); // Define our own FileStat interface to match what we use in the application export interface FileStat { filename: string; basename: string; lastmod?: string; size?: number; type: "file" | "directory"; mime?: string; [key: string]: any; } // Define interfaces for response types interface ResponseData { status?: number; data?: any; [key: string]: any; } export interface WebDAVConfig { rootUrl: string; rootPath: string; username?: string; password?: string; authEnabled?: boolean; } export class WebDAVService { private client: WebDAVClient; private rootPath: string; constructor(config: WebDAVConfig) { logger.debug("Initializing WebDAV service", { rootUrl: config.rootUrl, rootPath: config.rootPath, }); // Determine if auth is enabled const authEnabled = Boolean(config.authEnabled) || Boolean(config.username && config.password); // Get connection options const connectionOptions: any = { rootUrl: config.rootUrl, authEnabled, username: config.username, password: config.password, }; // Get connection from pool this.client = webdavConnectionPool.getConnection(connectionOptions); this.rootPath = config.rootPath; logger.info("WebDAV service initialized", { rootUrl: config.rootUrl, rootPath: config.rootPath, authEnabled: authEnabled, }); } /** * List files and directories at the specified path */ async list(path: string = "/"): Promise<FileStat[]> { const fullPath = this.getFullPath(path); logger.debug(`Listing directory: ${fullPath}`); try { // In v5.x we need to handle the response differently const result = await this.client.getDirectoryContents(fullPath); // Convert the result to our FileStat interface const fileStats = Array.isArray(result) ? result.map((item) => this.convertToFileStat(item)) : this.isResponseData(result) && Array.isArray(result.data) ? result.data.map((item) => this.convertToFileStat(item)) : []; logger.debug( `Listed ${fileStats.length} items in directory: ${fullPath}`, ); return fileStats; } catch (error) { logger.error(`Error listing directory ${fullPath}:`, error); throw new Error(`Failed to list directory: ${(error as Error).message}`); } } /** * Get file stats for a specific path */ async stat(path: string): Promise<FileStat> { const fullPath = this.getFullPath(path); logger.debug(`Getting stats for: ${fullPath}`); try { const result = await this.client.stat(fullPath); // Convert the result to our FileStat interface const stats = this.convertToFileStat( this.isResponseData(result) ? result.data : result, ); logger.debug(`Got stats for: ${fullPath}`, { type: stats.type }); return stats; } catch (error) { logger.error(`Error getting stats for ${fullPath}:`, error); throw new Error(`Failed to get file stats: ${(error as Error).message}`); } } /** * Read file content as text */ async readFile(path: string): Promise<string> { const fullPath = this.getFullPath(path); logger.debug(`Reading file: ${fullPath}`); try { // v5.x returns buffer by default, need to use format: 'text' const content = await this.client.getFileContents(fullPath, { format: "text", }); // Handle both direct string response and detailed response let result: string; if (typeof content === "string") { result = content; } else if (this.isResponseData(content)) { result = String(content.data); } else { throw new Error("Unexpected response format from server"); } const contentLength = result.length; logger.debug(`Read file: ${fullPath}`, { contentLength }); return result; } catch (error) { logger.error(`Error reading file ${fullPath}:`, error); throw new Error(`Failed to read file: ${(error as Error).message}`); } } /** * Read file content as text with head/tail options */ async readFileWithOptions( path: string, options: { head?: number; tail?: number } = {}, ): Promise<string> { const fullPath = this.getFullPath(path); logger.debug(`Reading file with options: ${fullPath}`, options); try { // Get the full file content first const content = await this.readFile(path); // If no head or tail specified, return full content if (!options.head && !options.tail) { return content; } // Cannot specify both head and tail if (options.head && options.tail) { throw new Error( "Cannot specify both head and tail parameters simultaneously", ); } // Split content into lines const lines = content.split("\n"); if (options.head) { // Return first N lines const headLines = lines.slice(0, options.head); const result = headLines.join("\n"); logger.debug(`Read head of file: ${fullPath}`, { lines: headLines.length, requestedLines: options.head, }); return result; } if (options.tail) { // Return last N lines const tailLines = lines.slice(-options.tail); const result = tailLines.join("\n"); logger.debug(`Read tail of file: ${fullPath}`, { lines: tailLines.length, requestedLines: options.tail, }); return result; } // This should never be reached due to earlier checks return content; } catch (error) { logger.error(`Error reading file with options ${fullPath}:`, error); throw new Error( `Failed to read file with options: ${(error as Error).message}`, ); } } /** * Write content to a file */ async writeFile(path: string, content: string | Buffer): Promise<void> { const fullPath = this.getFullPath(path); const contentLength = typeof content === "string" ? content.length : content.length; logger.debug(`Writing file: ${fullPath}`, { contentLength }); try { // putFileContents in v5.x returns a boolean indicating success const result = await this.client.putFileContents(fullPath, content); // Check result based on type if (typeof result === "boolean" && !result) { throw new Error("Failed to write file: server returned failure status"); } else if ( this.isResponseData(result) && result.status !== undefined && result.status !== 201 && result.status !== 204 ) { throw new Error( `Failed to write file: server returned status ${result.status}`, ); } logger.debug(`Successfully wrote file: ${fullPath}`); } catch (error) { logger.error(`Error writing to file ${fullPath}:`, error); throw new Error(`Failed to write file: ${(error as Error).message}`); } } /** * Apply intelligent edits to a file */ async editFile( path: string, edits: Array<{ oldText: string; newText: string }>, dryRun: boolean = false, ): Promise<string> { const fullPath = this.getFullPath(path); logger.debug(`Editing file: ${fullPath}`, { numEdits: edits.length, dryRun, }); try { // Read original content const originalContent = await this.readFile(path); let modifiedContent = originalContent; // Apply each edit for (const edit of edits) { const { oldText, newText } = edit; // Find the exact occurrence of oldText const index = modifiedContent.indexOf(oldText); if (index === -1) { throw new Error( `Text not found: "${oldText.substring(0, 50)}${ oldText.length > 50 ? "..." : "" }"`, ); } // Check for multiple matches const additionalMatches = modifiedContent.indexOf(oldText, index + 1); if (additionalMatches !== -1) { throw new Error( `Multiple matches found for text: "${oldText.substring(0, 50)}${ oldText.length > 50 ? "..." : "" }"`, ); } // Apply the edit modifiedContent = modifiedContent.substring(0, index) + newText + modifiedContent.substring(index + oldText.length); } // Generate diff const diff = this.generateDiff(originalContent, modifiedContent, path); if (!dryRun) { await this.writeFile(path, modifiedContent); logger.debug(`Successfully applied edits to file: ${fullPath}`); } return diff; } catch (error) { logger.error(`Error editing file ${fullPath}:`, error); throw new Error(`Failed to edit file: ${(error as Error).message}`); } } /** * Generate a git-style diff between original and modified content */ private generateDiff( originalContent: string, modifiedContent: string, filePath: string, ): string { const originalLines = originalContent.split("\n"); const modifiedLines = modifiedContent.split("\n"); const diff: string[] = []; diff.push(`--- ${filePath}`); diff.push(`+++ ${filePath}`); // Simple diff implementation - find the first and last different lines let startLine = 0; let endLine = Math.max(originalLines.length, modifiedLines.length); // Find first different line while ( startLine < Math.min(originalLines.length, modifiedLines.length) && originalLines[startLine] === modifiedLines[startLine] ) { startLine++; } // Find last different line while ( endLine > startLine && endLine <= Math.min(originalLines.length, modifiedLines.length) && originalLines[endLine - 1] === modifiedLines[endLine - 1] ) { endLine--; } const originalLength = Math.max( 0, originalLines.length - startLine - Math.max(0, originalLines.length - endLine), ); const modifiedLength = Math.max( 0, modifiedLines.length - startLine - Math.max(0, modifiedLines.length - endLine), ); if (originalLength > 0 || modifiedLength > 0) { diff.push( `@@ -${startLine + 1},${originalLength} +${ startLine + 1 },${modifiedLength} @@`, ); // Add context and changes for (let i = startLine; i < endLine; i++) { if (i < originalLines.length && i < modifiedLines.length) { if (originalLines[i] === modifiedLines[i]) { diff.push(` ${originalLines[i]}`); } else { if (i < originalLines.length) { diff.push(`-${originalLines[i]}`); } if (i < modifiedLines.length) { diff.push(`+${modifiedLines[i]}`); } } } else if (i < originalLines.length) { diff.push(`-${originalLines[i]}`); } else if (i < modifiedLines.length) { diff.push(`+${modifiedLines[i]}`); } } } else { diff.push( `@@ -${originalLines.length + 1},0 +${modifiedLines.length + 1},0 @@`, ); } return diff.join("\n"); } /** * Create a directory */ async createDirectory(path: string): Promise<void> { const fullPath = this.getFullPath(path); logger.debug(`Creating directory: ${fullPath}`); try { // createDirectory in v5.x returns a boolean indicating success const result = await this.client.createDirectory(fullPath); // Check result based on type if (typeof result === "boolean" && !result) { throw new Error( "Failed to create directory: server returned failure status", ); } else if ( this.isResponseData(result) && result.status !== undefined && result.status !== 201 && result.status !== 204 ) { throw new Error( `Failed to create directory: server returned status ${result.status}`, ); } logger.debug(`Successfully created directory: ${fullPath}`); } catch (error) { logger.error(`Error creating directory ${fullPath}:`, error); throw new Error( `Failed to create directory: ${(error as Error).message}`, ); } } /** * Delete a file or directory */ async delete(path: string): Promise<void> { const fullPath = this.getFullPath(path); logger.debug(`Deleting: ${fullPath}`); try { // Get type before deleting for better logging const stat = await this.stat(fullPath).catch(() => null); const itemType = stat?.type || "item"; // deleteFile in v5.x returns a boolean indicating success const result = await this.client.deleteFile(fullPath); // Check result based on type if (typeof result === "boolean" && !result) { throw new Error("Failed to delete: server returned failure status"); } else if ( this.isResponseData(result) && result.status !== undefined && result.status !== 204 ) { throw new Error( `Failed to delete: server returned status ${result.status}`, ); } logger.debug(`Successfully deleted ${itemType}: ${fullPath}`); } catch (error) { logger.error(`Error deleting ${fullPath}:`, error); throw new Error(`Failed to delete: ${(error as Error).message}`); } } /** * Move/rename a file or directory */ async move(fromPath: string, toPath: string): Promise<void> { const fullFromPath = this.getFullPath(fromPath); const fullToPath = this.getFullPath(toPath); logger.debug(`Moving from ${fullFromPath} to ${fullToPath}`); try { // Get type before moving for better logging const stat = await this.stat(fromPath).catch(() => null); const itemType = stat?.type || "item"; // moveFile in v5.x returns a boolean indicating success const result = await this.client.moveFile(fullFromPath, fullToPath); // Check result based on type if (typeof result === "boolean" && !result) { throw new Error("Failed to move: server returned failure status"); } else if ( this.isResponseData(result) && result.status !== undefined && result.status !== 201 && result.status !== 204 ) { throw new Error( `Failed to move: server returned status ${result.status}`, ); } logger.debug( `Successfully moved ${itemType} from ${fullFromPath} to ${fullToPath}`, ); } catch (error) { logger.error( `Error moving from ${fullFromPath} to ${fullToPath}:`, error, ); throw new Error(`Failed to move: ${(error as Error).message}`); } } /** * Copy a file or directory */ async copy(fromPath: string, toPath: string): Promise<void> { const fullFromPath = this.getFullPath(fromPath); const fullToPath = this.getFullPath(toPath); logger.debug(`Copying from ${fullFromPath} to ${fullToPath}`); try { // Get type before copying for better logging const stat = await this.stat(fromPath).catch(() => null); const itemType = stat?.type || "item"; // copyFile in v5.x returns a boolean indicating success const result = await this.client.copyFile(fullFromPath, fullToPath); // Check result based on type if (typeof result === "boolean" && !result) { throw new Error("Failed to copy: server returned failure status"); } else if ( this.isResponseData(result) && result.status !== undefined && result.status !== 201 && result.status !== 204 ) { throw new Error( `Failed to copy: server returned status ${result.status}`, ); } logger.debug( `Successfully copied ${itemType} from ${fullFromPath} to ${fullToPath}`, ); } catch (error) { logger.error( `Error copying from ${fullFromPath} to ${fullToPath}:`, error, ); throw new Error(`Failed to copy: ${(error as Error).message}`); } } /** * Check if a file or directory exists */ async exists(path: string): Promise<boolean> { const fullPath = this.getFullPath(path); logger.debug(`Checking if exists: ${fullPath}`); try { const result = await this.client.exists(fullPath); // Handle both boolean and object responses let exists = false; if (typeof result === "boolean") { exists = result; } else if (result && typeof result === "object") { // Use type guard for better type safety const responseData = result as ResponseData; if (responseData.status !== undefined) { exists = responseData.status < 400; // If status is less than 400, the resource exists } } logger.debug(`Exists check for ${fullPath}: ${exists}`); return exists; } catch (error) { logger.error(`Error checking existence of ${fullPath}:`, error); return false; } } /** * Type guard to check if an object is a ResponseData */ private isResponseData(value: any): value is ResponseData { return value !== null && typeof value === "object" && "status" in value; } /** * Get the full path by combining root path with the provided path */ private getFullPath(path: string): string { // Make sure path starts with / but not with // const normalizedPath = path.startsWith("/") ? path : `/${path}`; // Combine root path with the provided path if (this.rootPath === "/") { return normalizedPath; } const rootWithoutTrailingSlash = this.rootPath.endsWith("/") ? this.rootPath.slice(0, -1) : this.rootPath; return `${rootWithoutTrailingSlash}${normalizedPath}`; } /** * Convert a WebDAV response to our FileStat interface */ private convertToFileStat(item: any): FileStat { if (!item) { return { filename: "", basename: "", type: "file", }; } return { filename: item.filename || item.href || "", basename: item.basename || this.getBasenameFromPath(item.filename || item.href || ""), type: item.type || (item.mime?.includes("directory") ? "directory" : "file"), size: item.size, lastmod: item.lastmod, mime: item.mime, ...item, }; } /** * Extract basename from a path */ private getBasenameFromPath(path: string): string { if (!path) return ""; const parts = path.split("/").filter(Boolean); return parts[parts.length - 1] || ""; } /** * Search for files and directories matching a pattern */ async searchFiles( path: string = "/", pattern: string, excludePatterns: string[] = [], ): Promise<string[]> { const fullPath = this.getFullPath(path); logger.debug(`Searching for files: ${fullPath}`, { pattern, excludePatterns, }); try { const results: string[] = []; await this._searchRecursive( fullPath, pattern, path, excludePatterns, results, ); logger.debug(`Search completed: ${fullPath}`, { results: results.length, }); return results; } catch (error) { logger.error(`Error searching files in ${fullPath}:`, error); throw new Error(`Failed to search files: ${(error as Error).message}`); } } /** * Recursive helper for searchFiles */ private async _searchRecursive( currentPath: string, pattern: string, basePath: string, excludePatterns: string[], results: string[], ): Promise<void> { try { const items = await this.list(currentPath); for (const item of items) { const relativePath = item.filename.replace( this.rootPath === "/" ? "" : this.rootPath, "", ); // Check if this item should be excluded const shouldExclude = excludePatterns.some((excludePattern) => minimatch(relativePath, excludePattern, { dot: true }) ); if (shouldExclude) { continue; } // Check if this item matches the search pattern if (minimatch(relativePath, pattern, { dot: true })) { results.push(relativePath); } // If it's a directory, search recursively if (item.type === "directory") { await this._searchRecursive( item.filename, pattern, basePath, excludePatterns, results, ); } } } catch (error) { logger.warn(`Warning: Could not access directory ${currentPath}:`, error); } } /** * Get directory tree structure */ async getDirectoryTree( path: string = "/", excludePatterns: string[] = [], ): Promise<any> { const fullPath = this.getFullPath(path); logger.debug(`Getting directory tree: ${fullPath}`, { excludePatterns }); try { const tree = await this._buildDirectoryTree( fullPath, path, excludePatterns, ); logger.debug(`Directory tree built: ${fullPath}`); return tree; } catch (error) { logger.error(`Error getting directory tree ${fullPath}:`, error); throw new Error( `Failed to get directory tree: ${(error as Error).message}`, ); } } /** * Recursive helper for building directory tree */ private async _buildDirectoryTree( currentPath: string, basePath: string, excludePatterns: string[], ): Promise<any[]> { try { const items = await this.list(currentPath); const tree: any[] = []; for (const item of items) { const relativePath = item.filename.replace( this.rootPath === "/" ? "" : this.rootPath, "", ); // Check if this item should be excluded const shouldExclude = excludePatterns.some((excludePattern) => minimatch(relativePath, excludePattern, { dot: true }) ); if (shouldExclude) { continue; } const node: any = { name: item.basename, type: item.type, }; if (item.type === "directory") { node.children = await this._buildDirectoryTree( item.filename, basePath, excludePatterns, ); } tree.push(node); } return tree; } catch (error) { logger.warn(`Warning: Could not access directory ${currentPath}:`, error); return []; } } /** * Read multiple files at once */ async readMultipleFiles( paths: string[], ): Promise<{ path: string; content?: string; error?: string }[]> { logger.debug(`Reading multiple files`, { count: paths.length }); const results = await Promise.allSettled( paths.map(async (path) => { try { const content = await this.readFile(path); return { path, content }; } catch (error) { return { path, error: (error as Error).message, }; } }), ); const formattedResults = results.map((result) => result.status === "fulfilled" ? result.value : { path: "", error: "Unknown error" } ); logger.debug(`Multiple files read completed`, { success: formattedResults.filter((r) => !r.error).length, failed: formattedResults.filter((r) => r.error).length, }); return formattedResults; } /** * Read file content with range request support using createReadStream */ async readFileWithRange( path: string, range: string, ): Promise<{ content: string; contentRange: string; acceptRanges: boolean; totalSize: number; }> { const fullPath = this.getFullPath(path); logger.debug(`Reading file with range: ${fullPath}`, { range }); try { // Parse the range header const parsedRange = this.parseRangeHeader(range); if (!parsedRange) { throw new Error("Invalid range format"); } // Get file stats first to check total size const stats = await this.stat(fullPath); const totalSize = stats.size || 0; // Validate range against file size if (parsedRange.start >= totalSize) { throw new Error( `Range start (${parsedRange.start}) is beyond file size (${totalSize})`, ); } // Calculate actual end position const end = parsedRange.end === undefined ? totalSize - 1 : Math.min(parsedRange.end, totalSize - 1); // Use createReadStream with range options const stream = this.client.createReadStream(fullPath, { range: { start: parsedRange.start, end: parsedRange.end, }, }); // Convert stream to string const chunks: Buffer[] = []; return new Promise((resolve, reject) => { stream.on("data", (chunk: Buffer) => { chunks.push(chunk); }); stream.on("end", () => { try { const content = Buffer.concat(chunks).toString("utf8"); const contentRange = `bytes ${parsedRange.start}-${end}/${totalSize}`; logger.debug(`Range request completed: ${fullPath}`, { range, contentLength: content.length, totalSize, }); resolve({ content, contentRange, acceptRanges: true, totalSize, }); } catch (error) { reject( new Error( `Failed to process stream content: ${(error as Error).message}`, ), ); } }); stream.on("error", (error) => { logger.error(`Stream error for ${fullPath}:`, error); reject(new Error(`Stream error: ${error.message}`)); }); }); } catch (error) { logger.error(`Error reading file with range ${fullPath}:`, error); throw new Error( `Failed to read file with range: ${(error as Error).message}`, ); } } /** * Parse HTTP Range header */ private parseRangeHeader(range: string): { start: number; end?: number; } | null { // Remove "bytes=" prefix if present const rangeValue = range.replace(/^bytes=/i, "").trim(); // Parse different range formats: // - "0-499": first 500 bytes // - "500-": bytes 500 to end // - "-500": last 500 bytes if (rangeValue.includes("-")) { const parts = rangeValue.split("-"); const startPart = parts[0]?.trim(); const endPart = parts[1]?.trim(); // Case: "500-" (from byte 500 to end) if (startPart && !endPart) { const start = parseInt(startPart, 10); if (isNaN(start) || start < 0) return null; return { start }; } // Case: "-500" (last 500 bytes) if (!startPart && endPart) { const suffixLength = parseInt(endPart, 10); if (isNaN(suffixLength) || suffixLength < 0) return null; // We can't handle suffix ranges without knowing file size // This will be handled at the caller level return null; } // Case: "0-499" (range from start to end) if (startPart && endPart) { const start = parseInt(startPart, 10); const end = parseInt(endPart, 10); if (isNaN(start) || isNaN(end) || start < 0 || end < 0 || start > end) { return null; } return { start, end }; } } return null; } /** * Check if the server supports range requests */ async supportsRangeRequests(path: string = "/"): Promise<boolean> { const fullPath = this.getFullPath(path); logger.debug(`Checking range request support for: ${fullPath}`); try { // For WebDAV servers, we'll assume range requests are supported // if we can successfully read file metadata const stats = await this.stat(fullPath); return true; } catch (error) { logger.debug(`Range request support check failed for ${fullPath}`, error); return false; } } }

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/masx200/mcp-webdav-server'

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