Skip to main content
Glama
index.js10.5 kB
#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { exec } from "child_process"; import { promisify } from "util"; import * as fs from "fs"; import * as path from "path"; const execAsync = promisify(exec); // NAS Configuration - configurable via environment variables const NAS_CONFIG = { ip: process.env.NAS_IP || "10.0.0.50", protocol: "nfs", localMountBase: process.env.NAS_MOUNT_BASE || "/mnt/nas", volumePrefix: process.env.NAS_VOLUME_PREFIX || "/volume1", }; /** * Discover available NFS exports from the NAS */ async function discoverNfsExports() { try { const { stdout } = await execAsync(`showmount -e ${NAS_CONFIG.ip}`); const lines = stdout.trim().split("\n").slice(1); // Skip header line return lines.map((line) => { const regex = new RegExp(`^(${NAS_CONFIG.volumePrefix}/[^\\s]+)`); const match = line.match(regex); if (match) { const fullPath = match[1]; const shareName = fullPath.replace(`${NAS_CONFIG.volumePrefix}/`, ""); return { exportPath: fullPath, shareName: shareName, // Normalize to how it appears locally (spaces become dashes typically) localName: shareName.replace(/ /g, "-"), }; } return null; }).filter(Boolean); } catch (error) { throw new Error(`Failed to discover NFS exports: ${error.message}`); } } /** * Get currently mounted NAS shares */ async function getMountedShares() { try { const { stdout } = await execAsync(`mount | grep ${NAS_CONFIG.ip}`); const mounts = {}; for (const line of stdout.trim().split("\n")) { if (!line) continue; // Parse: <NAS_IP>:/volume1/ShareName on /mnt/nas/LocalName type nfs ... const escapedIp = NAS_CONFIG.ip.replace(/\./g, "\\."); const regex = new RegExp(`^${escapedIp}:(${NAS_CONFIG.volumePrefix}/[^\\s]+)\\s+on\\s+([^\\s]+)`); const match = line.match(regex); if (match) { const exportPath = match[1]; const localPath = match[2]; const shareName = exportPath.replace(`${NAS_CONFIG.volumePrefix}/`, ""); mounts[shareName] = { exportPath, localPath, shareName, }; } } return mounts; } catch (error) { // No mounts found is not an error return {}; } } /** * Find the local mount point for a share (fuzzy matching) */ async function findShareMount(shareName) { const mounts = await getMountedShares(); // Exact match first if (mounts[shareName]) { return mounts[shareName].localPath; } // Try case-insensitive match const lowerName = shareName.toLowerCase(); for (const [name, mount] of Object.entries(mounts)) { if (name.toLowerCase() === lowerName) { return mount.localPath; } } // Try matching with spaces vs dashes const normalizedName = shareName.replace(/-/g, " "); for (const [name, mount] of Object.entries(mounts)) { if (name.toLowerCase() === normalizedName.toLowerCase()) { return mount.localPath; } } // Check if it's mounted under /mnt/nas with normalized name const possiblePaths = [ path.join(NAS_CONFIG.localMountBase, shareName), path.join(NAS_CONFIG.localMountBase, shareName.replace(/ /g, "-")), path.join(NAS_CONFIG.localMountBase, shareName.replace(/-/g, " ")), ]; for (const p of possiblePaths) { if (fs.existsSync(p)) { return p; } } return null; } /** * Mount a share temporarily if not already mounted */ async function mountShare(shareName) { const exports = await discoverNfsExports(); const share = exports.find( (e) => e.shareName.toLowerCase() === shareName.toLowerCase() || e.localName.toLowerCase() === shareName.toLowerCase() ); if (!share) { throw new Error(`Share "${shareName}" not found on NAS. Use list_nas_shares to see available shares.`); } const mountPoint = path.join(NAS_CONFIG.localMountBase, share.localName); // Create mount point if it doesn't exist if (!fs.existsSync(mountPoint)) { await execAsync(`sudo mkdir -p "${mountPoint}"`); } // Mount the share try { await execAsync( `sudo mount -t nfs ${NAS_CONFIG.ip}:"${share.exportPath}" "${mountPoint}"` ); return mountPoint; } catch (error) { throw new Error(`Failed to mount share: ${error.message}`); } } /** * Copy files/folders to the destination */ async function copyToNas(sourcePath, destPath, options = {}) { const resolvedSource = path.resolve(sourcePath); if (!fs.existsSync(resolvedSource)) { throw new Error(`Source path does not exist: ${resolvedSource}`); } const stats = fs.statSync(resolvedSource); const flags = options.recursive !== false && stats.isDirectory() ? "-r" : ""; const preserveFlags = options.preserveAttributes !== false ? "-p" : ""; try { await execAsync(`cp ${flags} ${preserveFlags} "${resolvedSource}" "${destPath}"`); return { success: true, source: resolvedSource, destination: destPath, isDirectory: stats.isDirectory(), }; } catch (error) { throw new Error(`Failed to copy: ${error.message}`); } } // Create MCP server const server = new Server( { name: "save-to-nas", version: "1.0.0", }, { capabilities: { tools: {}, }, } ); // List available tools server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "save_to_nas", description: `Save a file or folder to the Synology NAS at ${NAS_CONFIG.ip}. Automatically discovers if the target share is mounted locally and uses the appropriate path. If the share isn't mounted, it will attempt to mount it via NFS.`, inputSchema: { type: "object", properties: { source: { type: "string", description: "Path to the local file or folder to save (can be relative or absolute)", }, share: { type: "string", description: "Name of the NAS share to save to (e.g., 'Daniel-Desktop-Overflow', 'Documents', 'AI_Art')", }, destination_subfolder: { type: "string", description: "Optional subfolder within the share to save to", }, }, required: ["source", "share"], }, }, { name: "list_nas_shares", description: `List all available NFS shares on the Synology NAS at ${NAS_CONFIG.ip}. Shows which shares are currently mounted locally and their mount points.`, inputSchema: { type: "object", properties: { filter: { type: "string", description: "Optional filter to search for shares by name (case-insensitive)", }, }, }, }, ], }; }); // Handle tool calls server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; if (name === "list_nas_shares") { try { const exports = await discoverNfsExports(); const mounts = await getMountedShares(); let results = exports.map((share) => { const mounted = mounts[share.shareName]; return { name: share.shareName, exportPath: share.exportPath, mounted: !!mounted, localPath: mounted?.localPath || null, }; }); // Apply filter if provided if (args?.filter) { const filter = args.filter.toLowerCase(); results = results.filter((r) => r.name.toLowerCase().includes(filter)); } const mountedCount = results.filter((r) => r.mounted).length; return { content: [ { type: "text", text: JSON.stringify( { nas_ip: NAS_CONFIG.ip, total_shares: results.length, mounted_shares: mountedCount, shares: results, }, null, 2 ), }, ], }; } catch (error) { return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true, }; } } if (name === "save_to_nas") { try { const { source, share, destination_subfolder } = args; if (!source || !share) { throw new Error("Both 'source' and 'share' are required"); } // Find or create mount point let mountPath = await findShareMount(share); if (!mountPath) { // Try to mount the share mountPath = await mountShare(share); } // Construct destination path let destPath = mountPath; if (destination_subfolder) { destPath = path.join(mountPath, destination_subfolder); // Create subfolder if it doesn't exist if (!fs.existsSync(destPath)) { fs.mkdirSync(destPath, { recursive: true }); } } // Determine final destination (include source name in dest if copying to a directory) const sourceName = path.basename(source); const finalDest = path.join(destPath, sourceName); // Copy the files const result = await copyToNas(source, finalDest); return { content: [ { type: "text", text: JSON.stringify( { success: true, message: `Successfully saved to NAS`, source: result.source, destination: result.destination, share: share, nas_ip: NAS_CONFIG.ip, }, null, 2 ), }, ], }; } catch (error) { return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true, }; } } return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true, }; }); // Start the server async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error("Save-to-NAS MCP server running on stdio"); } main().catch((error) => { console.error("Fatal error:", error); process.exit(1); });

Latest Blog Posts

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/danielrosehill/Save-To-NAS-MCP'

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