@nyxrux62/synol-mcp
Version:
MCP server for Synology NAS file operations with upload request functionality
669 lines (668 loc) • 27.6 kB
JavaScript
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema, ListToolsRequestSchema, ToolSchema, } from "@modelcontextprotocol/sdk/types.js";
import path from "path";
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
import axios from 'axios';
import https from 'https';
import FormData from 'form-data';
// Command line argument parsing
const args = process.argv.slice(2);
if (args.length < 3) {
console.error("Usage: synolink <synology-url> <username> <password> [api-version]");
console.error("Current arguments:", args);
process.exit(1);
}
const [synoUrl, synoUsername, synoPassword] = args;
const apiVersion = args[3] || '7'; // 기본값을 최신 DSM 버전으로 업데이트
// Synology DSM API configuration
const dsm = {
baseUrl: synoUrl,
apiVersion: apiVersion,
account: synoUsername,
passwd: synoPassword,
sid: '', // Session ID will be set after login
httpsAgent: new https.Agent({
rejectUnauthorized: false // Allow self-signed certificates
})
};
// Synology API utilities
async function synoLogin() {
try {
console.error(`Attempting to login to ${dsm.baseUrl} with user ${dsm.account}...`);
// 새로운 로그인 방식 (더 일반적인 DSM 7.x 양식)
const params = new URLSearchParams();
params.append('api', 'SYNO.API.Auth');
params.append('version', '3');
params.append('method', 'login');
params.append('account', dsm.account);
params.append('passwd', dsm.passwd);
params.append('session', 'FileStation');
params.append('format', 'sid');
const loginUrl = `${dsm.baseUrl}/webapi/auth.cgi`;
console.error(`Sending login request to ${loginUrl}`);
const response = await axios.post(loginUrl, params, {
httpsAgent: dsm.httpsAgent
});
console.error("Login response:", JSON.stringify(response.data));
if (response.data && response.data.success) {
dsm.sid = response.data.data.sid;
console.error(`Login successful, got SID: ${dsm.sid.substring(0, 5)}...`);
return true;
}
else {
console.error("Login failed:", response.data);
return false;
}
}
catch (error) {
console.error("Login error:", error.message);
if (error.response) {
console.error("Error response data:", error.response.data);
console.error("Error response status:", error.response.status);
}
return false;
}
}
async function synoLogout() {
try {
if (!dsm.sid)
return true;
const params = new URLSearchParams();
params.append('api', 'SYNO.API.Auth');
params.append('version', '6');
params.append('method', 'logout');
params.append('session', 'FileStation');
params.append('_sid', dsm.sid);
const logoutUrl = `${dsm.baseUrl}/webapi/entry.cgi`;
console.error(`Attempting to logout from ${dsm.baseUrl}...`);
const response = await axios.get(`${logoutUrl}?${params.toString()}`, {
httpsAgent: dsm.httpsAgent
});
if (response.data && response.data.success) {
console.error("Logout successful");
dsm.sid = '';
return true;
}
else {
console.error("Logout failed:", response.data);
return false;
}
}
catch (error) {
console.error("Logout error:", error.message);
return false;
}
}
async function refreshSession() {
try {
// 먼저 간단히 현재 세션이 유효한지 확인
console.error("Checking if session is still valid...");
const infoUrl = `${dsm.baseUrl}/webapi/entry.cgi`;
const params = new URLSearchParams();
params.append('api', 'SYNO.API.Info');
params.append('version', '1');
params.append('method', 'query');
params.append('query', 'SYNO.API.Auth');
params.append('_sid', dsm.sid);
const response = await axios.get(`${infoUrl}?${params.toString()}`, {
httpsAgent: dsm.httpsAgent
});
// If the request fails due to session timeout, login again
if (response.data && !response.data.success && response.data.error && response.data.error.code === 119) {
console.error("Session expired, logging in again...");
return await synoLogin();
}
console.error("Session is still valid");
return true;
}
catch (error) {
console.error("Session refresh error:", error.message);
console.error("Attempting to login again...");
return await synoLogin();
}
}
// Path utilities
function formatSynoPath(filePath) {
// Ensure path starts with /
if (!filePath.startsWith('/')) {
filePath = '/' + filePath;
}
// Remove trailing slash except for root
if (filePath !== '/' && filePath.endsWith('/')) {
filePath = filePath.slice(0, -1);
}
return filePath;
}
function normalizeLineEndings(text) {
return text.replace(/\r\n/g, '\n');
}
// Schema definitions
const ListSharesArgsSchema = z.object({});
const ListDirectoryArgsSchema = z.object({
path: z.string().describe('Path to list, must be absolute path with leading slash'),
});
const ReadFileArgsSchema = z.object({
path: z.string().describe('Path to file, must be absolute path with leading slash'),
});
const WriteFileArgsSchema = z.object({
path: z.string().describe('Path to file, must be absolute path with leading slash'),
content: z.string().describe('Content to write to the file'),
});
const CreateDirectoryArgsSchema = z.object({
path: z.string().describe('Path to create, must be absolute path with leading slash'),
});
const SearchFilesArgsSchema = z.object({
path: z.string().describe('Path to search in, must be absolute path with leading slash'),
pattern: z.string().describe('Search pattern'),
});
const GetFileInfoArgsSchema = z.object({
path: z.string().describe('Path to file, must be absolute path with leading slash'),
});
const CreateUploadRequestArgsSchema = z.object({
path: z.string().describe('Absolute path of the folder to create upload request (must start with slash)')
});
const ToolInputSchema = ToolSchema.shape.inputSchema;
// Server setup
const server = new Server({
name: "synolink",
version: "1.0.0",
}, {
capabilities: {
tools: {},
},
});
// API Wrappers
async function syno_listShares() {
try {
await refreshSession();
const url = `${dsm.baseUrl}/webapi/entry.cgi?api=SYNO.FileStation.List&version=2&method=list_share&_sid=${dsm.sid}`;
const response = await axios.get(url, {
httpsAgent: dsm.httpsAgent
});
if (response.data && response.data.success) {
return response.data.data.shares;
}
else {
throw new Error(`Failed to list shares: ${response.data.error.code}`);
}
}
catch (error) {
console.error("List shares error:", error);
throw error;
}
}
async function syno_listDirectory(dirPath) {
try {
await refreshSession();
const formattedPath = formatSynoPath(dirPath);
const additionalParams = "time,size";
const url = `${dsm.baseUrl}/webapi/entry.cgi?api=SYNO.FileStation.List&version=2&method=list&folder_path=${encodeURIComponent(formattedPath)}&additional=${encodeURIComponent(additionalParams)}&_sid=${dsm.sid}`;
const response = await axios.get(url, {
httpsAgent: dsm.httpsAgent
});
if (response.data && response.data.success) {
return response.data.data.files;
}
else {
throw new Error(`Failed to list directory: ${response.data?.error?.code || 'Unknown error'}`);
}
}
catch (error) {
console.error("List directory error:", error);
throw error;
}
}
async function syno_readFile(filePath) {
try {
await refreshSession();
const formattedPath = formatSynoPath(filePath);
// Get download info
const infoUrl = `${dsm.baseUrl}/webapi/entry.cgi?api=SYNO.FileStation.Download&version=2&method=download&path=${encodeURIComponent(formattedPath)}&mode=download&_sid=${dsm.sid}`;
const response = await axios.get(infoUrl, {
httpsAgent: dsm.httpsAgent,
responseType: 'arraybuffer'
});
// Convert to text
return new TextDecoder('utf-8').decode(response.data);
}
catch (error) {
console.error("Read file error:", error);
throw error;
}
}
async function syno_writeFile(filePath, content) {
try {
await refreshSession();
const formattedPath = formatSynoPath(path.dirname(filePath));
const fileName = path.basename(filePath);
// Create FormData object
const form = new FormData();
form.append('path', formattedPath);
form.append('create_parents', 'true');
form.append('overwrite', 'true');
// Add file content as a buffer with the filename
const buffer = Buffer.from(content, 'utf-8');
form.append('file', buffer, { filename: fileName });
// Upload URL
const uploadUrl = `${dsm.baseUrl}/webapi/entry.cgi?api=SYNO.FileStation.Upload&version=2&method=upload&_sid=${dsm.sid}`;
const response = await axios.post(uploadUrl, form, {
httpsAgent: dsm.httpsAgent,
headers: form.getHeaders()
});
if (response.data && response.data.success) {
return true;
}
else {
throw new Error(`Failed to write file: ${response.data.error.code}`);
}
}
catch (error) {
console.error("Write file error:", error);
throw error;
}
}
async function syno_createDirectory(dirPath) {
try {
await refreshSession();
const formattedPath = formatSynoPath(dirPath);
const parentPath = path.dirname(formattedPath);
const folderName = path.basename(formattedPath);
const url = `${dsm.baseUrl}/webapi/entry.cgi`;
const params = new URLSearchParams();
params.append('api', 'SYNO.FileStation.CreateFolder');
params.append('version', '2');
params.append('method', 'create');
params.append('folder_path', parentPath);
params.append('name', folderName);
params.append('create_parents', 'true');
params.append('_sid', dsm.sid);
const response = await axios.post(url, params, {
httpsAgent: dsm.httpsAgent
});
if (response.data && response.data.success) {
return true;
}
else {
throw new Error(`Failed to create directory: ${response.data.error.code}`);
}
}
catch (error) {
console.error("Create directory error:", error);
throw error;
}
}
async function syno_searchFiles(searchPath, pattern) {
try {
await refreshSession();
const formattedPath = formatSynoPath(searchPath);
const url = `${dsm.baseUrl}/webapi/entry.cgi`;
const params = new URLSearchParams();
params.append('api', 'SYNO.FileStation.Search');
params.append('version', '2');
params.append('method', 'start');
params.append('folder_path', formattedPath);
params.append('pattern', pattern);
params.append('_sid', dsm.sid);
// Start search
const startResponse = await axios.post(url, params, {
httpsAgent: dsm.httpsAgent
});
if (!startResponse.data || !startResponse.data.success) {
throw new Error(`Failed to start search: ${startResponse.data.error.code}`);
}
const taskId = startResponse.data.data.taskid;
// Function to check search status
const checkStatus = async () => {
const statusParams = new URLSearchParams();
statusParams.append('api', 'SYNO.FileStation.Search');
statusParams.append('version', '2');
statusParams.append('method', 'status');
statusParams.append('taskid', taskId);
statusParams.append('_sid', dsm.sid);
const statusResponse = await axios.get(`${url}?${statusParams.toString()}`, {
httpsAgent: dsm.httpsAgent
});
return statusResponse.data;
};
// Wait for search to complete
let isFinished = false;
while (!isFinished) {
const status = await checkStatus();
if (!status.success) {
throw new Error(`Search status check failed: ${status.error.code}`);
}
isFinished = status.data.finished;
if (!isFinished) {
// Wait before checking again
await new Promise(resolve => setTimeout(resolve, 500));
}
}
// Get search results
const resultParams = new URLSearchParams();
resultParams.append('api', 'SYNO.FileStation.Search');
resultParams.append('version', '2');
resultParams.append('method', 'list');
resultParams.append('taskid', taskId);
resultParams.append('_sid', dsm.sid);
const resultResponse = await axios.get(`${url}?${resultParams.toString()}`, {
httpsAgent: dsm.httpsAgent
});
if (!resultResponse.data || !resultResponse.data.success) {
throw new Error(`Failed to get search results: ${resultResponse.data.error.code}`);
}
// Clean up the task
const cleanupParams = new URLSearchParams();
cleanupParams.append('api', 'SYNO.FileStation.Search');
cleanupParams.append('version', '2');
cleanupParams.append('method', 'stop');
cleanupParams.append('taskid', taskId);
cleanupParams.append('_sid', dsm.sid);
await axios.post(url, cleanupParams, {
httpsAgent: dsm.httpsAgent
});
return resultResponse.data.data.files;
}
catch (error) {
console.error("Search files error:", error);
throw error;
}
}
async function syno_getFileInfo(filePath) {
try {
await refreshSession();
const formattedPath = formatSynoPath(filePath);
const url = `${dsm.baseUrl}/webapi/entry.cgi?api=SYNO.FileStation.List&version=2&method=getinfo&path=${encodeURIComponent(formattedPath)}&additional=time,size,owner,perm&_sid=${dsm.sid}`;
const response = await axios.get(url, {
httpsAgent: dsm.httpsAgent
});
if (response.data && response.data.success) {
return response.data.data.files[0];
}
else {
throw new Error(`Failed to get file info: ${response.data.error.code}`);
}
}
catch (error) {
console.error("Get file info error:", error);
throw error;
}
}
// Tool handlers
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "syno_list_shares",
description: "List all available shares on the Synology NAS. " +
"Returns information about each share including name, path, and description.",
inputSchema: zodToJsonSchema(ListSharesArgsSchema),
},
{
name: "syno_list_directory",
description: "List all files and folders in the specified directory on the Synology NAS. " +
"Returns detailed information about each item including name, type, size, " +
"and modification time. The path must be absolute with a leading slash.",
inputSchema: zodToJsonSchema(ListDirectoryArgsSchema),
},
{
name: "syno_read_file",
description: "Read the contents of a file from the Synology NAS. " +
"The path must be absolute with a leading slash.",
inputSchema: zodToJsonSchema(ReadFileArgsSchema),
},
{
name: "syno_write_file",
description: "Write content to a file on the Synology NAS. Creates the file if it " +
"doesn't exist, otherwise overwrites it. The path must be absolute with a leading slash.",
inputSchema: zodToJsonSchema(WriteFileArgsSchema),
},
{
name: "syno_create_directory",
description: "Create a new directory on the Synology NAS. Will create parent directories " +
"if they don't exist. The path must be absolute with a leading slash.",
inputSchema: zodToJsonSchema(CreateDirectoryArgsSchema),
},
{
name: "syno_search_files",
description: "Search for files and directories on the Synology NAS. " +
"The search is performed recursively starting from the specified path. " +
"Returns all files and directories matching the pattern. " +
"The path must be absolute with a leading slash.",
inputSchema: zodToJsonSchema(SearchFilesArgsSchema),
},
{
name: "syno_get_file_info",
description: "Get detailed information about a file or directory on the Synology NAS. " +
"Returns information such as size, permissions, creation time, " +
"and modification time. The path must be absolute with a leading slash.",
inputSchema: zodToJsonSchema(GetFileInfoArgsSchema),
},
{
name: "syno_create_upload_request",
description: "Create or return a file upload request link for the specified folder on the Synology NAS. " +
"If the folder doesn't exist, it will be created first. " +
"The link can be used by others to upload files to this folder. " +
"Returns the URL of the upload request link.",
inputSchema: zodToJsonSchema(CreateUploadRequestArgsSchema),
},
],
};
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const { name, arguments: args } = request.params;
switch (name) {
case "syno_list_shares": {
const shares = await syno_listShares();
const formatted = shares.map((share) => ({
name: share.name,
path: share.path,
description: share.desc
}));
return {
content: [{ type: "text", text: JSON.stringify(formatted, null, 2) }],
};
}
case "syno_list_directory": {
const parsed = ListDirectoryArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for list_directory: ${parsed.error}`);
}
const files = await syno_listDirectory(parsed.data.path);
const formatted = files.map((file) => ({
name: file.name,
type: file.isdir ? "directory" : "file",
size: file.additional?.size,
modified: file.additional?.time?.mtime
}));
return {
content: [{ type: "text", text: JSON.stringify(formatted, null, 2) }],
};
}
case "syno_read_file": {
const parsed = ReadFileArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for read_file: ${parsed.error}`);
}
const content = await syno_readFile(parsed.data.path);
return {
content: [{ type: "text", text: content }],
};
}
case "syno_write_file": {
const parsed = WriteFileArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for write_file: ${parsed.error}`);
}
await syno_writeFile(parsed.data.path, parsed.data.content);
return {
content: [{ type: "text", text: `Successfully wrote to ${parsed.data.path}` }],
};
}
case "syno_create_directory": {
const parsed = CreateDirectoryArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for create_directory: ${parsed.error}`);
}
await syno_createDirectory(parsed.data.path);
return {
content: [{ type: "text", text: `Successfully created directory ${parsed.data.path}` }],
};
}
case "syno_search_files": {
const parsed = SearchFilesArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for search_files: ${parsed.error}`);
}
const results = await syno_searchFiles(parsed.data.path, parsed.data.pattern);
const formatted = results.map((file) => ({
name: file.name,
path: file.path,
type: file.isdir ? "directory" : "file",
size: file.size
}));
return {
content: [{ type: "text", text: JSON.stringify(formatted, null, 2) }],
};
}
case "syno_get_file_info": {
const parsed = GetFileInfoArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for get_file_info: ${parsed.error}`);
}
const info = await syno_getFileInfo(parsed.data.path);
const formatted = {
name: info.name,
path: info.path,
type: info.isdir ? "directory" : "file",
size: info.size,
owner: info.additional?.owner?.user || "unknown",
group: info.additional?.owner?.group || "unknown",
permissions: info.additional?.perm?.posix || "unknown",
created: info.additional?.time?.crtime || "unknown",
modified: info.additional?.time?.mtime || "unknown",
accessed: info.additional?.time?.atime || "unknown"
};
return {
content: [{ type: "text", text: JSON.stringify(formatted, null, 2) }],
};
}
case "syno_create_upload_request": {
const parsed = CreateUploadRequestArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for create_upload_request: ${parsed.error}`);
}
const url = await syno_createUploadRequest(parsed.data.path);
return {
content: [{ type: "text", text: url }],
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
content: [{ type: "text", text: `Error: ${errorMessage}` }],
isError: true,
};
}
});
// Process termination handling
process.on('SIGINT', async () => {
console.error("Received SIGINT signal, cleaning up...");
await synoLogout();
process.exit(0);
});
process.on('SIGTERM', async () => {
console.error("Received SIGTERM signal, cleaning up...");
await synoLogout();
process.exit(0);
});
// Start server
async function runServer() {
try {
console.error("SynoLink MCP Server starting...");
console.error(`Connecting to Synology NAS at ${dsm.baseUrl}`);
console.error(`Using username: ${dsm.account}`);
console.error(`API version: ${dsm.apiVersion}`);
// Login to Synology DSM
const loginSuccess = await synoLogin();
if (!loginSuccess) {
console.error("Failed to log in to Synology NAS. Check credentials and network connectivity.");
process.exit(1);
}
console.error("Successfully logged in to Synology NAS");
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("SynoLink MCP Server running on stdio");
console.error(`Connected to Synology NAS at ${dsm.baseUrl}`);
}
catch (error) {
console.error("Fatal error running server:", error);
if (error.response) {
console.error("Error response data:", error.response.data);
console.error("Error response status:", error.response.status);
console.error("Error response headers:", error.response.headers);
}
await synoLogout();
process.exit(1);
}
}
runServer().catch((error) => {
console.error("Fatal error running server:", error);
process.exit(1);
});
// 3. 실제 구현 함수
async function syno_createUploadRequest(folderPath) {
await refreshSession();
try {
await syno_createDirectory(folderPath);
}
catch (e) { }
const url = `${dsm.baseUrl}/webapi/entry.cgi/SYNO.FileStation.Sharing`;
const headers = {
"X-Requested-With": "XMLHttpRequest",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"
};
const paramsList = new URLSearchParams();
paramsList.append('api', 'SYNO.FileStation.Sharing');
paramsList.append('method', 'list');
paramsList.append('version', '3');
paramsList.append('check_link_history', 'true');
paramsList.append('path', folderPath);
paramsList.append('sort_by', 'id');
paramsList.append('sort_direction', 'dec');
paramsList.append('filter_type', 'SYNO.SDS.App.SharingUpload.Application');
paramsList.append('_sid', dsm.sid);
let resp = await axios.post(url, paramsList, {
httpsAgent: dsm.httpsAgent,
headers
});
let data = resp.data;
if (data?.data?.links?.length > 0) {
return data.data.links[0].url;
}
const paramsCreate = new URLSearchParams();
paramsCreate.append('api', 'SYNO.FileStation.Sharing');
paramsCreate.append('method', 'create');
paramsCreate.append('version', '3');
paramsCreate.append('path', folderPath);
paramsCreate.append('file_request', 'true');
paramsCreate.append('request_name', 'File Request');
paramsCreate.append('request_info', 'File upload request');
paramsCreate.append('_sid', dsm.sid);
resp = await axios.post(url, paramsCreate, {
httpsAgent: dsm.httpsAgent,
headers
});
data = resp.data;
if (!data.success || !data.data?.links?.length) {
throw new Error("Failed to create upload request link: " + JSON.stringify(data));
}
return data.data.links[0].url;
}