#!/usr/bin/env node // ncmcp.mjs - CLI wrapper for nextcloud-mcp (read .env automatically) // Usage: ncmcp [key=value ...] [positionalPath] [@file] [--curl] import { spawn } from "child_process"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; import { readFileSync, existsSync, statSync } from "fs"; import { createInterface } from "readline"; import { stdin } from "process"; const __dir = dirname(fileURLToPath(import.meta.url)); const serverPath = join(__dir, "build/index.js"); // Load .env const envFile = join(__dir, ".env"); const env = { ...process.env }; if (existsSync(envFile)) { for (const line of readFileSync(envFile, "utf8").split("\n")) { const m = line.match(/^([^#=]+)=(.*)$/); if (m) env[m[1].trim()] = m[2].trim(); } } // --- Helpers --- function isStdinPiped() { // stdin is a pipe (not a TTY) when used with echo ... | ncmcp ... return !process.stdin.isTTY; } async function readStdin() { const chunks = []; for await (const chunk of process.stdin) chunks.push(chunk); return Buffer.concat(chunks).toString("utf8"); } // All 21 file tools const FILE_TOOLS = [ "list_files", "get_file_info", "search_files", "list_favorites", "get_quota", "read_file", "download_file", "download_folder", "upload_file", "create_folder", "bulk_upload", "chunked_upload_start", "chunked_upload_chunk", "chunked_upload_finish", "move_file", "copy_file", "delete_file", "trash_list", "trash_restore", "trash_delete", "trash_empty", "set_favorite", "get_file_versions", "restore_file_version", ]; const ALL_TOOLS = [ "list_calendars", "get_calendar_events", "create_calendar_event", "get_tasks", "create_task", "update_task", "get_notes", "create_note", "get_note_content", "get_emails", ...FILE_TOOLS, ]; // --- Argument parsing --- const [,, tool, ...rawArgs] = process.argv; if (!tool || tool === "--help" || tool === "-h") { console.log(`Usage: ncmcp [key=value ...] [positionalPath] [@file] [--curl] Options: --help, -h Show this help message --curl (download_file only) Print curl command instead of calling server Special argument syntax: /path/to/file Positional path (treated as path=/path/to/file) @./local/file.txt Read local file as base64 content (for upload_file) @./files.json Read JSON file (for bulk_upload) Pipe content via stdin (for upload_file) Available tools: Calendar: list_calendars, get_calendar_events, create_calendar_event Tasks: get_tasks, create_task, update_task Notes: get_notes, create_note, get_note_content Email: get_emails Files: list_files, get_file_info, search_files, list_favorites, get_quota Read: read_file, download_file, download_folder Write: upload_file, create_folder, bulk_upload Chunked: chunked_upload_start, chunked_upload_chunk, chunked_upload_finish Modify: move_file, copy_file, delete_file Trash: trash_list, trash_restore, trash_delete, trash_empty Extras: set_favorite, get_file_versions, restore_file_version`); process.exit(0); } // Detect --curl flag const useCurl = rawArgs.includes("--curl"); const filteredArgs = rawArgs.filter(a => a !== "--curl"); // Parse key=value args, positional path, and @file references const args = {}; let positionalPath = null; let atFileContent = null; // for @file syntax on bulk_upload let atFileEncoding = null; // encoding override when @file is used for (const a of filteredArgs) { if (a.startsWith("@")) { // @file syntax: read local file const filePath = a.slice(1); if (!existsSync(filePath)) { console.error(`Error: file not found: ${filePath}`); process.exit(1); } if (tool === "bulk_upload") { // For bulk_upload: read as JSON try { atFileContent = JSON.parse(readFileSync(filePath, "utf8")); } catch (e) { console.error(`Error: invalid JSON in ${filePath}: ${e.message}`); process.exit(1); } } else { // For upload_file: read as binary → base64 const buf = readFileSync(filePath); atFileContent = buf.toString("base64"); atFileEncoding = "base64"; } } else if (!a.startsWith("-") && a.startsWith("/")) { // Positional path argument positionalPath = a; } else { const sep = a.indexOf("="); if (sep > 0) { const k = a.slice(0, sep); const v = a.slice(sep + 1); try { args[k] = JSON.parse(v); } catch { args[k] = v; } } } } // Apply positional path if (positionalPath && !args.path) { args.path = positionalPath; } // Apply @file content if (atFileContent !== null) { if (tool === "bulk_upload") { // bulk_upload: pass parsed JSON as "files" arg if (!args.files) args.files = atFileContent; } else { // upload_file: pass base64 content if (!args.content) args.content = atFileContent; if (atFileEncoding && !args.encoding) args.encoding = atFileEncoding; } } // --- download_file --curl mode --- if (tool === "download_file" && useCurl) { const ncUrl = env.NEXTCLOUD_URL || ""; const ncUser = env.NEXTCLOUD_USERNAME || ""; const ncPass = env.NEXTCLOUD_PASSWORD || ""; if (!ncUrl || !ncUser || !ncPass) { console.error("Error: NEXTCLOUD_URL, NEXTCLOUD_USERNAME, NEXTCLOUD_PASSWORD must be set in .env"); process.exit(1); } const filePath = args.path || "/"; const baseUrl = ncUrl.replace(/\/$/, ""); const url = `${baseUrl}/remote.php/dav/files/${ncUser}${filePath}`; // Escape single quotes in password for shell const escapedPass = ncPass.replace(/'/g, "'\''"); console.log(`curl -u '${ncUser}:${escapedPass}' '${url}' -o '${filePath.split("/").pop() || "file"}'`); process.exit(0); } // --- stdin content for upload_file --- async function maybeReadStdin() { // If tool accepts 'content' and stdin is piped and no content set yet if ("content" in args || atFileContent !== null) return; const toolsAcceptingContent = ["upload_file"]; if (toolsAcceptingContent.includes(tool) && isStdinPiped()) { const stdinData = await readStdin(); if (stdinData.length > 0) { args.content = stdinData; } } } await maybeReadStdin(); // --- MCP server call --- const child = spawn("node", [serverPath], { env, stdio: ["pipe", "pipe", "inherit"] }); const rl = createInterface({ input: child.stdout }); const send = (msg) => child.stdin.write(JSON.stringify(msg) + "\n"); rl.on("line", (line) => { try { const msg = JSON.parse(line); if (msg.id === 1) { send({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: tool, arguments: args } }); } else if (msg.id === 2) { const content = msg.result?.content?.[0]?.text; if (content) { try { console.log(JSON.stringify(JSON.parse(content), null, 2)); } catch { console.log(content); } } else if (msg.error) { console.error("Error:", msg.error.message); process.exit(1); } child.stdin.end(); process.exit(0); } } catch {} }); send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "ncmcp", version: "2.0" } } }); setTimeout(() => { console.error("Timeout"); process.exit(1); }, 15000);