fangcloud-mcp 0.1.0__py3-none-any.whl → 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ """
2
+ FangCloud MCP - Model Context Protocol (MCP) 服务器实现,提供与 FangCloud 云存储服务的集成
3
+ """
4
+
5
+ __version__ = "0.1.1"
6
+
7
+ from .fangcloud import main
8
+ from .fangcloud_api import FangcloudAPI
9
+
10
+ __all__ = ["main", "FangcloudAPI"]
@@ -0,0 +1,391 @@
1
+ """
2
+ #!/usr/bin/env python3
3
+ FangCloud MCP Main Module
4
+
5
+ This is the main entry point for the FangCloud MCP server.
6
+ """
7
+
8
+ import logging
9
+ import argparse
10
+ import asyncio
11
+ import sys
12
+ import os
13
+ from typing import Dict, Any, Optional
14
+ from mcp.server.fastmcp import FastMCP
15
+ from .fangcloud_api import FangcloudAPI
16
+
17
+
18
+ # Configure logging
19
+ DEFAULT_LOG_FILE = "fangcloud_mcp.log"
20
+ logging.basicConfig(
21
+ level=logging.INFO,
22
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
23
+ handlers=[
24
+ logging.FileHandler(DEFAULT_LOG_FILE),
25
+ logging.StreamHandler()
26
+ ]
27
+ )
28
+ logger = logging.getLogger("FangCloud.Main")
29
+
30
+
31
+ # Initialize MCP server
32
+ mcp = FastMCP("fangcloud")
33
+ api = FangcloudAPI()
34
+
35
+ @mcp.tool()
36
+ async def get_file_info(file_id: str) -> Dict[str, Any]:
37
+ """
38
+ Get file information
39
+
40
+ Args:
41
+ file_id: File ID
42
+
43
+ Returns:
44
+ File information (JSON format) or error message
45
+ """
46
+
47
+ if not file_id:
48
+ return {"status": "error", "message": "file_id is required"}
49
+
50
+ try:
51
+ result = await api.get_file_info(file_id)
52
+ if result:
53
+ return {"status": "success", "data": result}
54
+ else:
55
+ return {"status": "error", "message": f"Failed to get file info for file ID {file_id}"}
56
+ except Exception as e:
57
+ error_msg = f"Get file info operation failed - {str(e)}"
58
+ logger.error(error_msg, exc_info=True)
59
+ return {"status": "error", "message": error_msg}
60
+
61
+ @mcp.tool()
62
+ async def get_folder_info(folder_id: str) -> Dict[str, Any]:
63
+ """
64
+ Get folder information
65
+
66
+ Args:
67
+ folder_id: Folder ID
68
+
69
+ Returns:
70
+ Folder information (JSON format) or error message
71
+ """
72
+ if not folder_id:
73
+ return {"status": "error", "message": "folder_id is required"}
74
+
75
+ try:
76
+ result = await api.get_folder_info(folder_id)
77
+ if result:
78
+ return {"status": "success", "data": result}
79
+ else:
80
+ return {"status": "error", "message": f"Failed to get folder info for folder ID {folder_id}"}
81
+ except Exception as e:
82
+ error_msg = f"Get folder info operation failed - {str(e)}"
83
+ logger.error(error_msg, exc_info=True)
84
+ return {"status": "error", "message": error_msg}
85
+
86
+ @mcp.tool()
87
+ async def create_folder(name: str, parent_id: str,
88
+ target_space_type: Optional[str] = None,
89
+ target_space_id: Optional[str] = None) -> Dict[str, Any]:
90
+ """
91
+ Create a new folder
92
+
93
+ Args:
94
+ name: Folder name (1-222 characters, cannot contain / ? : * " > <)
95
+ parent_id: Parent folder ID
96
+ target_space_type: Space type - "department" or "personal" (optional, effective when parent_id is 0)
97
+ target_space_id: Space ID (required when target_space_type is "department")
98
+
99
+ Returns:
100
+ Folder creation result (JSON format) or error message
101
+ """
102
+ if not name:
103
+ return {"status": "error", "message": "name is required"}
104
+
105
+ if not parent_id:
106
+ return {"status": "error", "message": "parent_id is required"}
107
+
108
+ try:
109
+ # Validate folder name length
110
+ if len(name) < 1 or len(name) > 222:
111
+ return {"status": "error", "message": "Folder name must be between 1 and 222 characters"}
112
+
113
+ # Check for invalid characters in folder name
114
+ invalid_chars = ['/', '?', ':', '*', '"', '>', '<']
115
+ if any(char in name for char in invalid_chars):
116
+ return {"status": "error", "message": "Folder name contains invalid characters (/ ? : * \" > <)"}
117
+
118
+ # Check if target_space_id is provided when needed
119
+ if parent_id == "0" or parent_id == 0:
120
+ if target_space_type == "department" and not target_space_id:
121
+ return {"status": "error", "message": "target_space_id is required when target_space_type is department"}
122
+
123
+ result = await api.create_folder(
124
+ name, parent_id, target_space_type, target_space_id
125
+ )
126
+ if result:
127
+ return {"status": "success", "data": result}
128
+ else:
129
+ return {"status": "error", "message": f"Failed to create folder '{name}' in parent folder {parent_id}"}
130
+ except Exception as e:
131
+ error_msg = f"Create folder operation failed - {str(e)}"
132
+ logger.error(error_msg, exc_info=True)
133
+ return {"status": "error", "message": error_msg}
134
+
135
+ @mcp.tool()
136
+ async def upload_file(parent_folder_id: str, local_file_path: str) -> Dict[str, Any]:
137
+ """
138
+ Upload file to FangCloud
139
+
140
+ Args:
141
+ parent_folder_id: Target folder ID in FangCloud
142
+ local_file_path: Local file path to upload
143
+
144
+ Returns:
145
+ Upload result message
146
+ """
147
+ if not parent_folder_id:
148
+ return {"status": "error", "message": "parent_folder_id is required"}
149
+
150
+ if not local_file_path:
151
+ return {"status": "error", "message": "local_file_path is required"}
152
+
153
+ try:
154
+ # 获取文件名
155
+ file_name = os.path.basename(local_file_path)
156
+
157
+ # 获取上传URL
158
+ upload_url = await api.get_file_upload_url(parent_folder_id, file_name)
159
+ if not upload_url:
160
+ return {"status": "error", "message": f"Failed to get upload URL for {file_name}"}
161
+
162
+ # 上传文件
163
+ result = await api.upload_file(upload_url, local_file_path)
164
+
165
+ if result:
166
+ return {
167
+ "status": "success",
168
+ "message": f"File uploaded successfully - {local_file_path} -> Folder ID {parent_folder_id}",
169
+ "file_name": file_name,
170
+ "parent_folder_id": parent_folder_id
171
+ }
172
+ else:
173
+ return {"status": "error", "message": f"File upload failed - {local_file_path}"}
174
+ except Exception as e:
175
+ error_msg = f"Upload operation failed - {str(e)}"
176
+ logger.error(error_msg, exc_info=True)
177
+ return {"status": "error", "message": error_msg}
178
+
179
+ @mcp.tool()
180
+ async def update_file(file_id: str, name: Optional[str] = None,
181
+ description: Optional[str] = None) -> Dict[str, Any]:
182
+ """
183
+ Update file name and/or description
184
+
185
+ Args:
186
+ file_id: File ID
187
+ name: New file name (optional)
188
+ description: New file description (optional)
189
+
190
+ Returns:
191
+ Update result message
192
+
193
+ Note:
194
+ At least one of name or description must be provided
195
+ """
196
+ if not file_id:
197
+ return {"status": "error", "message": "file_id is required"}
198
+
199
+ if not name and not description:
200
+ return {"status": "error", "message": "At least one of name or description must be provided"}
201
+
202
+ try:
203
+ result = await api.update_file(file_id, name, description)
204
+ if result:
205
+ updated_fields = []
206
+ if name:
207
+ updated_fields.append("name")
208
+ if description:
209
+ updated_fields.append("description")
210
+
211
+ fields_str = " and ".join(updated_fields)
212
+ return {
213
+ "status": "success",
214
+ "message": f"File {file_id} {fields_str} updated successfully",
215
+ "file_id": file_id,
216
+ "updated_fields": updated_fields
217
+ }
218
+ else:
219
+ return {"status": "error", "message": f"Failed to update file ID {file_id}"}
220
+ except Exception as e:
221
+ error_msg = f"Update operation failed - {str(e)}"
222
+ logger.error(error_msg, exc_info=True)
223
+ return {"status": "error", "message": error_msg}
224
+
225
+ @mcp.tool()
226
+ async def download_file(file_id: str, local_path: str) -> Dict[str, Any]:
227
+ """
228
+ Download file from FangCloud
229
+
230
+ Args:
231
+ file_id: File ID
232
+ local_path: Local path to save the file
233
+
234
+ Returns:
235
+ local file path or error message
236
+ """
237
+ if not file_id:
238
+ return {"status": "error", "message": "file_id is required"}
239
+
240
+ try:
241
+ # Download file to local path
242
+ result = await api.download_file_to_local(file_id, local_path)
243
+ if result:
244
+ return {
245
+ "status": "success",
246
+ "message": f"File downloaded to {result}",
247
+ "file_path": result
248
+ }
249
+ else:
250
+ return {"status": "error", "message": f"Failed to download file ID {file_id} to {local_path}"}
251
+ except Exception as e:
252
+ error_msg = f"Download operation failed - {str(e)}"
253
+ logger.error(error_msg, exc_info=True)
254
+ return {"status": "error", "message": error_msg}
255
+
256
+ @mcp.tool()
257
+ async def list_folder_contents(folder_id: str, page_id: Optional[int] = 0,
258
+ page_capacity: Optional[int] = 20, type_filter: Optional[str] = "all",
259
+ sort_by: Optional[str] = "date",
260
+ sort_direction: Optional[str] = "desc") -> Dict[str, Any]:
261
+ """
262
+ List folder contents (files and subfolders)
263
+
264
+ Args:
265
+ folder_id: Folder ID
266
+ page_id: Page number (optional, default 0)
267
+ page_capacity: Page capacity (optional, default 20)
268
+ type_filter: Filter by type - "file", "folder", or "all" (optional, default "all")
269
+ sort_by: Sort by - "name", "date", or "size" (optional, default "date")
270
+ sort_direction: Sort direction - "desc" or "asc" (optional, default "desc")
271
+
272
+ Returns:
273
+ Folder contents (JSON format) or error message
274
+ """
275
+ if not folder_id:
276
+ return {"status": "error", "message": "folder_id is required"}
277
+
278
+ try:
279
+ result = await api.get_folder_children(
280
+ folder_id, page_id, page_capacity, type_filter, sort_by, sort_direction
281
+ )
282
+ if result:
283
+ return {"status": "success", "data": result}
284
+ else:
285
+ return {"status": "error", "message": f"Failed to get contents for folder ID {folder_id}"}
286
+ except Exception as e:
287
+ error_msg = f"List folder contents operation failed - {str(e)}"
288
+ logger.error(error_msg, exc_info=True)
289
+ return {"status": "error", "message": error_msg}
290
+
291
+ @mcp.tool()
292
+ async def list_personal_items(page_id: Optional[int] = 0,
293
+ page_capacity: Optional[int] = 20, type_filter: Optional[str] = "all",
294
+ sort_by: Optional[str] = "date",
295
+ sort_direction: Optional[str] = "desc") -> Dict[str, Any]:
296
+ """
297
+ List personal items (files and folders in personal space)
298
+
299
+ Args:
300
+ page_id: Page number (optional, default 0)
301
+ page_capacity: Page capacity (optional, default 20)
302
+ type_filter: Filter by type - "file", "folder", or "all" (optional, default "all")
303
+ sort_by: Sort by - "name", "date", or "size" (optional, default "date")
304
+ sort_direction: Sort direction - "desc" or "asc" (optional, default "desc")
305
+
306
+ Returns:
307
+ Personal items (JSON format) or error message
308
+ """
309
+ try:
310
+ result = await api.get_personal_items(
311
+ page_id, page_capacity, type_filter, sort_by, sort_direction
312
+ )
313
+ if result:
314
+ return {"status": "success", "data": result}
315
+ else:
316
+ return {"status": "error", "message": "Failed to get personal items"}
317
+ except Exception as e:
318
+ error_msg = f"List personal items operation failed - {str(e)}"
319
+ logger.error(error_msg, exc_info=True)
320
+ return {"status": "error", "message": error_msg}
321
+
322
+ @mcp.tool()
323
+ async def search_items(query_words: str, search_type: Optional[str] = "all",
324
+ page_id: Optional[int] = 0, search_in_folder: Optional[str] = None,
325
+ query_filter: Optional[str] = "all",
326
+ updated_time_range: Optional[str] = None) -> Dict[str, Any]:
327
+ """
328
+ Search for files and folders
329
+
330
+ Args:
331
+ query_words: Search keywords (required)
332
+ search_type: Search type - "file", "folder", or "all" (optional, default "all")
333
+ page_id: Page number (optional, default 0)
334
+ search_in_folder: Parent folder ID to search within (optional)
335
+ query_filter: Search filter - "file_name", "content", "creator", or "all" (optional, default "all")
336
+ updated_time_range: Updated time range in format "start_timestamp,end_timestamp" (optional)
337
+ Both timestamps can be empty, but comma is required
338
+
339
+ Returns:
340
+ Search results (JSON format) or error message
341
+ """
342
+ if not query_words:
343
+ return {"status": "error", "message": "query_words is required"}
344
+
345
+ try:
346
+ result = await api.search_items(
347
+ query_words, search_type, page_id, search_in_folder, query_filter, updated_time_range
348
+ )
349
+ if result:
350
+ return {"status": "success", "data": result}
351
+ else:
352
+ return {"status": "error", "message": f"Failed to search for items with query: {query_words}"}
353
+ except Exception as e:
354
+ error_msg = f"Search operation failed - {str(e)}"
355
+ logger.error(error_msg, exc_info=True)
356
+ return {"status": "error", "message": error_msg}
357
+
358
+
359
+ def main():
360
+ """Main entry point for the FangCloud MCP server"""
361
+ try:
362
+ logger.info("=== Starting FangCloud MCP ===")
363
+
364
+ # Parse command line arguments
365
+ parser = argparse.ArgumentParser(description='FangCloud MCP')
366
+ parser.add_argument('--access_token', '-c', type=str, required=True, help='FangCloud API access token is required')
367
+ args = parser.parse_args()
368
+
369
+ # Validate access_token
370
+ if not args.access_token:
371
+ logger.error("Access token is required")
372
+ sys.exit(1)
373
+
374
+ logger.info(f"access_token: {args.access_token}")
375
+
376
+ # set access_token
377
+ api.set_access_token(args.access_token)
378
+
379
+ # Run MCP server
380
+ logger.info("Starting MCP server")
381
+ mcp.run(transport='stdio')
382
+
383
+ except Exception as e:
384
+ logger.error(f"Initialization failed: {str(e)}", exc_info=True)
385
+ sys.exit(1)
386
+ finally:
387
+ if 'api' in locals():
388
+ asyncio.run(api.cleanup())
389
+
390
+ if __name__ == "__main__":
391
+ main()
@@ -0,0 +1,629 @@
1
+ """
2
+ FangCloud API Module
3
+
4
+ This module provides API client functionality for interacting with FangCloud.
5
+ """
6
+
7
+ import logging
8
+ import uuid
9
+ import os
10
+ import aiohttp
11
+ from typing import Dict, Any, Optional
12
+
13
+ # Constants
14
+ TOKEN_EXPIRY_SECONDS = 59
15
+ HTTP_OK = 200
16
+ REQUEST_TIMEOUT = 60
17
+ OPEN_HOST = "https://open.fangcloud.com"
18
+ API_UPLOAD_PATH = "/api/v2/file/upload_by_path"
19
+ API_FILE_UPLOAD_PATH = "/api/v2/file/upload"
20
+ API_FILE_INFO_PATH = "/api/v2/file/{id}/info"
21
+ API_FILE_UPDATE_PATH = "/api/v2/file/{id}/update"
22
+ API_FILE_DOWNLOAD_PATH = "/api/v2/file/{id}/download"
23
+ API_FOLDER_INFO_PATH = "/api/v2/folder/{id}/info"
24
+ API_FOLDER_CHILDREN_PATH = "/api/v2/folder/{id}/children"
25
+ API_PERSONAL_ITEMS_PATH = "/api/v2/folder/personal_items"
26
+ API_SEARCH_PATH = "/api/v2/item/search"
27
+ API_FOLDER_CREATE_PATH = "/api/v2/folder/create"
28
+ OAUTH_TOKEN_PATH = "/oauth/token"
29
+ GRANT_TYPE = "jwt_simple"
30
+ UPLOAD_TYPE = "api"
31
+
32
+ logger = logging.getLogger("FangCloud.API")
33
+
34
+
35
+ class FangcloudAPI:
36
+ def __init__(self):
37
+ self.access_token = ""
38
+ self.open_host = OPEN_HOST
39
+
40
+ def set_access_token(self, access_token:str):
41
+ self.access_token = access_token
42
+
43
+ def _validate_file(self, file_path: str) -> bool:
44
+ logger.info(f"Starting file upload: {file_path}")
45
+
46
+ if not os.path.exists(file_path):
47
+ logger.error(f"File does not exist: {file_path}")
48
+ return False
49
+
50
+ file_size = os.path.getsize(file_path)
51
+ logger.info(f"File size: {file_size} bytes")
52
+ return True
53
+
54
+ async def _send_request(self, url: str, method: str = "POST", headers: Dict[str, str] = None,
55
+ data: Any = None, json_data: Any = None,
56
+ is_json_response: bool = True) -> Optional[Any]:
57
+ async with aiohttp.ClientSession() as session:
58
+ try:
59
+ request_method = getattr(session, method.lower())
60
+ async with request_method(url, headers=headers, data=data, json=json_data) as response:
61
+ if response.status != HTTP_OK:
62
+ error_text = await response.text()
63
+ logger.error(f"Request failed, status: {response.status}, error: {error_text}")
64
+ return None
65
+
66
+ if is_json_response:
67
+ return await response.json()
68
+ else:
69
+ return await response.text()
70
+ except Exception as e:
71
+ logger.error(f"Request error: {str(e)}", exc_info=True)
72
+ return None
73
+
74
+ async def _api_request(self, operation_name: str, url: str, method: str = "POST",
75
+ headers: Dict[str, str] = None, data: Any = None, json_data: Any = None,
76
+ is_json_response: bool = True, check_token: bool = True,
77
+ success_key: str = None, success_msg: str = None,
78
+ error_msg: str = None) -> Optional[Any]:
79
+ try:
80
+ # 检查访问令牌(如果需要)
81
+ if check_token and not self.access_token:
82
+ logger.error(f"Failed to {operation_name}: access_token is empty")
83
+ return None
84
+
85
+ # 添加认证头(如果需要且未提供)
86
+ if check_token and headers is None:
87
+ headers = {
88
+ "Authorization": f"Bearer {self.access_token}",
89
+ "Content-Type": "application/json"
90
+ }
91
+ elif check_token and "Authorization" not in headers:
92
+ headers["Authorization"] = f"Bearer {self.access_token}"
93
+
94
+ # 发送请求
95
+ logger.info(f"Sending {operation_name} request")
96
+ result = await self._send_request(
97
+ url=url,
98
+ method=method,
99
+ headers=headers,
100
+ data=data,
101
+ json_data=json_data,
102
+ is_json_response=is_json_response
103
+ )
104
+
105
+ if not result:
106
+ return None
107
+
108
+ # 检查成功响应中的关键字段
109
+ if success_key is not None:
110
+ if isinstance(result, dict) and success_key in result:
111
+ if success_msg:
112
+ logger.info(success_msg)
113
+ return result[success_key]
114
+ else:
115
+ if error_msg:
116
+ logger.error(f"{error_msg}: {result}")
117
+ else:
118
+ logger.error(f"Failed to {operation_name}, response missing {success_key}: {result}")
119
+ return None
120
+
121
+ # 如果没有指定关键字段,直接返回结果
122
+ if success_msg:
123
+ logger.info(success_msg)
124
+ return result
125
+
126
+ except Exception as e:
127
+ logger.error(f"Error in {operation_name}: {str(e)}", exc_info=True)
128
+ return None
129
+
130
+ async def get_upload_url_by_path(self, target_folder_path: str, file_name: str) -> Optional[str]:
131
+ logger.info(f"Getting file upload URL for {file_name}")
132
+ url = f"{self.open_host}{API_UPLOAD_PATH}"
133
+
134
+ params = {
135
+ "target_folder_path": target_folder_path,
136
+ "name": file_name,
137
+ "upload_type": UPLOAD_TYPE
138
+ }
139
+
140
+ return await self._api_request(
141
+ operation_name="get upload URL",
142
+ url=url,
143
+ json_data=params,
144
+ success_key="presign_url",
145
+ success_msg="Successfully obtained upload URL",
146
+ error_msg="Failed to get upload URL, response missing presign_url"
147
+ )
148
+
149
+ async def get_file_upload_url(self, parent_id: str, name: str, is_covered: Optional[bool] = None) -> Optional[str]:
150
+ try:
151
+ if not self.access_token:
152
+ logger.error("Failed to get file upload URL: access_token is empty")
153
+ return None
154
+
155
+ logger.info(f"Getting file upload URL for {name} in folder {parent_id}")
156
+ url = f"{self.open_host}{API_FILE_UPLOAD_PATH}"
157
+
158
+ # Prepare request body
159
+ params = {
160
+ "parent_id": parent_id,
161
+ "name": name,
162
+ "upload_type": UPLOAD_TYPE
163
+ }
164
+
165
+ # Add optional parameters if provided
166
+ if is_covered is not None:
167
+ params["is_covered"] = is_covered
168
+
169
+ return await self._api_request(
170
+ operation_name="get file upload URL",
171
+ url=url,
172
+ json_data=params,
173
+ success_key="presign_url",
174
+ success_msg="Successfully obtained file upload URL",
175
+ error_msg="Failed to get file upload URL, response missing presign_url"
176
+ )
177
+
178
+ except Exception as e:
179
+ logger.error(f"Error getting file upload URL: {str(e)}", exc_info=True)
180
+ return None
181
+
182
+ async def upload_file(self, url: str, file_path: str) -> Optional[str]:
183
+ try:
184
+ if not url:
185
+ logger.error("Failed to upload file: upload URL is empty")
186
+ return None
187
+
188
+ if not file_path:
189
+ logger.error("Failed to upload file: file_path is empty")
190
+ return None
191
+
192
+ # 验证文件
193
+ if not self._validate_file(file_path):
194
+ return None
195
+
196
+ # 发送文件上传请求
197
+ try:
198
+ with open(file_path, 'rb') as f:
199
+ form_data = aiohttp.FormData()
200
+ form_data.add_field('file', f, filename=os.path.basename(file_path))
201
+
202
+ logger.info("Sending file upload request")
203
+ result = await self._send_request(
204
+ url=url,
205
+ data=form_data,
206
+ is_json_response=False
207
+ )
208
+
209
+ if result:
210
+ logger.info("File upload successful")
211
+ return result
212
+ except FileNotFoundError:
213
+ logger.error(f"File not found or cannot be opened: {file_path}")
214
+ return None
215
+ except PermissionError:
216
+ logger.error(f"Permission denied when accessing file: {file_path}")
217
+ return None
218
+
219
+ except Exception as e:
220
+ logger.error(f"Error uploading file: {str(e)}", exc_info=True)
221
+ return None
222
+
223
+ async def get_file_info(self, file_id: str) -> Optional[Dict[str, Any]]:
224
+ try:
225
+ if not self.access_token:
226
+ logger.error("Failed to get file info: access_token is empty")
227
+ return None
228
+
229
+ logger.info(f"Getting file info for file ID {file_id}")
230
+ url = f"{self.open_host}{API_FILE_INFO_PATH.replace('{id}', file_id)}"
231
+
232
+ headers = {
233
+ "Authorization": f"Bearer {self.access_token}",
234
+ "Content-Type": "application/json"
235
+ }
236
+
237
+ # Send request to get file information
238
+ result = await self._send_request(url=url, method="GET", headers=headers)
239
+ if not result:
240
+ return None
241
+
242
+ logger.info(f"Successfully obtained file info for file ID {file_id}")
243
+ return result
244
+
245
+ except Exception as e:
246
+ logger.error(f"Error getting file info: {str(e)}", exc_info=True)
247
+ return None
248
+
249
+ async def update_file(self, file_id: str, name: Optional[str] = None,
250
+ description: Optional[str] = None) -> Optional[Dict[str, Any]]:
251
+ try:
252
+ if not self.access_token:
253
+ logger.error("Failed to update file: access_token is empty")
254
+ return None
255
+
256
+ if not name and not description:
257
+ logger.error("Failed to update file: both name and description are empty")
258
+ return None
259
+
260
+ logger.info(f"Updating file ID {file_id}")
261
+ url = f"{self.open_host}{API_FILE_UPDATE_PATH.replace('{id}', file_id)}"
262
+
263
+ headers = {
264
+ "Authorization": f"Bearer {self.access_token}",
265
+ "Content-Type": "application/json"
266
+ }
267
+
268
+ # Prepare request body
269
+ params = {}
270
+ if name:
271
+ params["name"] = name
272
+ if description:
273
+ params["description"] = description
274
+
275
+ # Send request to update file
276
+ result = await self._send_request(url=url, headers=headers, json_data=params)
277
+ if not result:
278
+ return None
279
+
280
+ logger.info(f"Successfully updated file ID {file_id}")
281
+ return result
282
+
283
+ except Exception as e:
284
+ logger.error(f"Error updating file: {str(e)}", exc_info=True)
285
+ return None
286
+
287
+ async def get_download_url(self, file_id: str) -> Optional[str]:
288
+ """
289
+ Get file download URL
290
+
291
+ Args:
292
+ file_id: File ID
293
+ Returns:
294
+ Download URL or None (if request fails)
295
+ """
296
+ try:
297
+ if not self.access_token:
298
+ logger.error("Failed to get download URL: access_token is empty")
299
+ return None
300
+
301
+ logger.info(f"Getting download URL for file ID {file_id}")
302
+ url = f"{self.open_host}{API_FILE_DOWNLOAD_PATH.replace('{id}', file_id)}"
303
+
304
+ headers = {
305
+ "Authorization": f"Bearer {self.access_token}",
306
+ "Content-Type": "application/json"
307
+ }
308
+
309
+ # Prepare query parameters
310
+ params = {}
311
+ # Add query parameters to URL if any
312
+ if params:
313
+ query_string = "&".join([f"{k}={v}" for k, v in params.items()])
314
+ url = f"{url}?{query_string}"
315
+
316
+ # Send request to get download URL
317
+ result = await self._send_request(url=url, method="GET", headers=headers)
318
+ if not result:
319
+ return None
320
+
321
+ if "download_url" in result:
322
+ logger.info(f"Successfully obtained download URL for file ID {file_id}")
323
+ return result["download_url"]
324
+ else:
325
+ logger.error(f"Failed to get download URL, response missing download_url: {result}")
326
+ return None
327
+
328
+ except Exception as e:
329
+ logger.error(f"Error getting download URL: {str(e)}", exc_info=True)
330
+ return None
331
+
332
+ async def download_file_to_local(self, file_id: str, local_path: str) -> Optional[str]:
333
+ try:
334
+ # Get file info
335
+ file_info = await self.get_file_info(file_id)
336
+ if not file_info:
337
+ logger.error(f"Failed to download file: could not get file info for file ID {file_id}")
338
+ return None
339
+ file_name = file_info["name"]
340
+ file_path = os.path.join(local_path, file_name)
341
+
342
+ # Get download URL
343
+ download_url = await self.get_download_url(file_id)
344
+ if not download_url:
345
+ logger.error(f"Failed to download file: could not get download URL for file ID {file_id}")
346
+ return None
347
+
348
+ # Ensure directory exists
349
+ os.makedirs(os.path.dirname(os.path.abspath(file_path)), exist_ok=True)
350
+
351
+ # Generate temporary file path with UUID
352
+ temp_dir = os.path.dirname(os.path.abspath(file_path))
353
+ temp_filename = str(uuid.uuid4())
354
+ temp_path = os.path.join(temp_dir, temp_filename)
355
+
356
+ # Download file to temporary path first
357
+ logger.info(f"Downloading file ID {file_id} to temporary file {temp_path}")
358
+ try:
359
+ async with aiohttp.ClientSession() as session:
360
+ async with session.get(download_url) as response:
361
+ if response.status != HTTP_OK:
362
+ error_text = await response.text()
363
+ logger.error(f"File download failed, status: {response.status}, error: {error_text}")
364
+ return None
365
+
366
+ # Save file to temporary path
367
+ with open(temp_path, 'wb') as f:
368
+ while True:
369
+ chunk = await response.content.read(8192) # Read in 8kb chunks
370
+ if not chunk:
371
+ break
372
+ f.write(chunk)
373
+
374
+ # If download successful, rename to the correct file name
375
+ os.replace(temp_path, file_path)
376
+ logger.info(f"File successfully downloaded and moved to {file_path}")
377
+ return file_path
378
+
379
+ except Exception as e:
380
+ # If any error occurs during download, delete the temporary file if it exists
381
+ if os.path.exists(temp_path):
382
+ try:
383
+ os.remove(temp_path)
384
+ logger.info(f"Deleted temporary file {temp_path} after download failure")
385
+ except Exception as del_err:
386
+ logger.error(f"Failed to delete temporary file {temp_path}: {str(del_err)}")
387
+ raise # Re-raise the original exception
388
+
389
+ except Exception as e:
390
+ logger.error(f"Error downloading file: {str(e)}", exc_info=True)
391
+ return None
392
+
393
+ async def get_folder_children(self, folder_id: str, page_id: Optional[int] = 0,
394
+ page_capacity: Optional[int] = 20, type_filter: Optional[str] = "all",
395
+ sort_by: Optional[str] = "date",
396
+ sort_direction: Optional[str] = "desc") -> Optional[Dict[str, Any]]:
397
+ try:
398
+ if not self.access_token:
399
+ logger.error("Failed to get folder children: access_token is empty")
400
+ return None
401
+
402
+ logger.info(f"Getting children for folder ID {folder_id}")
403
+ url = f"{self.open_host}{API_FOLDER_CHILDREN_PATH.replace('{id}', folder_id)}"
404
+
405
+ headers = {
406
+ "Authorization": f"Bearer {self.access_token}",
407
+ "Content-Type": "application/json"
408
+ }
409
+
410
+ # Prepare query parameters
411
+ params = {
412
+ "page_id": page_id,
413
+ "page_capacity": page_capacity,
414
+ "type": type_filter,
415
+ "sort_by": sort_by,
416
+ "sort_direction": sort_direction
417
+ }
418
+
419
+ # Add query parameters to URL
420
+ query_string = "&".join([f"{k}={v}" for k, v in params.items()])
421
+ url = f"{url}?{query_string}"
422
+
423
+ # Send request to get folder children
424
+ result = await self._send_request(url=url, method="GET", headers=headers)
425
+ if not result:
426
+ return None
427
+
428
+ logger.info(f"Successfully obtained children for folder ID {folder_id}")
429
+ return result
430
+
431
+ except Exception as e:
432
+ logger.error(f"Error getting folder children: {str(e)}", exc_info=True)
433
+ return None
434
+
435
+ async def get_personal_items(self, page_id: Optional[int] = 0,
436
+ page_capacity: Optional[int] = 20, type_filter: Optional[str] = "all",
437
+ sort_by: Optional[str] = "date",
438
+ sort_direction: Optional[str] = "desc") -> Optional[Dict[str, Any]]:
439
+ try:
440
+ if not self.access_token:
441
+ logger.error("Failed to get personal items: access_token is empty")
442
+ return None
443
+
444
+ logger.info("Getting personal items")
445
+ url = f"{self.open_host}{API_PERSONAL_ITEMS_PATH}"
446
+
447
+ headers = {
448
+ "Authorization": f"Bearer {self.access_token}",
449
+ "Content-Type": "application/json"
450
+ }
451
+
452
+ # Prepare query parameters
453
+ params = {
454
+ "page_id": page_id,
455
+ "page_capacity": page_capacity,
456
+ "type": type_filter,
457
+ "sort_by": sort_by,
458
+ "sort_direction": sort_direction
459
+ }
460
+
461
+ # Add query parameters to URL
462
+ query_string = "&".join([f"{k}={v}" for k, v in params.items()])
463
+ url = f"{url}?{query_string}"
464
+
465
+ # Send request to get personal items
466
+ result = await self._send_request(url=url, method="GET", headers=headers)
467
+ if not result:
468
+ return None
469
+
470
+ logger.info("Successfully obtained personal items")
471
+ return result
472
+
473
+ except Exception as e:
474
+ logger.error(f"Error getting personal items: {str(e)}", exc_info=True)
475
+ return None
476
+
477
+ async def get_folder_info(self, folder_id: str) -> Optional[Dict[str, Any]]:
478
+ """
479
+ Get folder information
480
+
481
+ Args:
482
+ folder_id: Folder ID (if 0, represents personal space directory)
483
+
484
+ Returns:
485
+ Folder information or None (if request fails)
486
+ """
487
+ try:
488
+ if not self.access_token:
489
+ logger.error("Failed to get folder info: access_token is empty")
490
+ return None
491
+
492
+ logger.info(f"Getting folder info for folder ID {folder_id}")
493
+ url = f"{self.open_host}{API_FOLDER_INFO_PATH.replace('{id}', folder_id)}"
494
+
495
+ headers = {
496
+ "Authorization": f"Bearer {self.access_token}",
497
+ "Content-Type": "application/json"
498
+ }
499
+
500
+ # Send request to get folder information
501
+ result = await self._send_request(url=url, method="GET", headers=headers)
502
+ if not result:
503
+ return None
504
+
505
+ logger.info(f"Successfully obtained folder info for folder ID {folder_id}")
506
+ return result
507
+
508
+ except Exception as e:
509
+ logger.error(f"Error getting folder info: {str(e)}", exc_info=True)
510
+ return None
511
+
512
+ async def search_items(self, query_words: str, search_type: Optional[str] = "all",
513
+ page_id: Optional[int] = 0, search_in_folder: Optional[str] = None,
514
+ query_filter: Optional[str] = "all",
515
+ updated_time_range: Optional[str] = None) -> Optional[Dict[str, Any]]:
516
+ try:
517
+ if not self.access_token:
518
+ logger.error("Failed to search items: access_token is empty")
519
+ return None
520
+
521
+ if not query_words:
522
+ logger.error("Failed to search items: query_words is empty")
523
+ return None
524
+
525
+ logger.info(f"Searching for items with query: {query_words}")
526
+ url = f"{self.open_host}{API_SEARCH_PATH}"
527
+
528
+ headers = {
529
+ "Authorization": f"Bearer {self.access_token}",
530
+ "Content-Type": "application/json"
531
+ }
532
+
533
+ # Prepare query parameters
534
+ params = {
535
+ "query_words": query_words,
536
+ "type": search_type,
537
+ "page_id": page_id,
538
+ "query_filter": query_filter
539
+ }
540
+
541
+ # Add optional parameters if provided
542
+ if search_in_folder:
543
+ params["search_in_folder"] = search_in_folder
544
+ if updated_time_range:
545
+ params["updated_time_range"] = updated_time_range
546
+
547
+ # Add query parameters to URL
548
+ query_string = "&".join([f"{k}={v}" for k, v in params.items()])
549
+ url = f"{url}?{query_string}"
550
+
551
+ # Send request to search items
552
+ result = await self._send_request(url=url, method="GET", headers=headers)
553
+ if not result:
554
+ return None
555
+
556
+ logger.info(f"Successfully completed search for query: {query_words}, result:{result}")
557
+ return result
558
+
559
+ except Exception as e:
560
+ logger.error(f"Error searching items: {str(e)}", exc_info=True)
561
+ return None
562
+
563
+ async def create_folder(self, name: str, parent_id: str,
564
+ target_space_type: Optional[str] = None,
565
+ target_space_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
566
+ try:
567
+ if not self.access_token:
568
+ logger.error("Failed to create folder: access_token is empty")
569
+ return None
570
+
571
+ if not name:
572
+ logger.error("Failed to create folder: name is empty")
573
+ return None
574
+
575
+ if not parent_id and parent_id != "0":
576
+ logger.error("Failed to create folder: parent_id is empty")
577
+ return None
578
+
579
+ # Validate folder name length
580
+ if len(name) < 1 or len(name) > 222:
581
+ logger.error(f"Failed to create folder: name length must be between 1 and 222 characters")
582
+ return None
583
+
584
+ # Check for invalid characters in folder name
585
+ invalid_chars = ['/', '?', ':', '*', '"', '>', '<']
586
+ if any(char in name for char in invalid_chars):
587
+ logger.error(f"Failed to create folder: name contains invalid characters")
588
+ return None
589
+
590
+ logger.info(f"Creating folder '{name}' in parent folder {parent_id}")
591
+ url = f"{self.open_host}{API_FOLDER_CREATE_PATH}"
592
+
593
+ # Prepare request body
594
+ params = {
595
+ "name": name,
596
+ "parent_id": parent_id
597
+ }
598
+
599
+ # Add target_space if parent_id is 0
600
+ if parent_id == "0" or parent_id == 0:
601
+ if target_space_type:
602
+ target_space = {"type": target_space_type}
603
+
604
+ # Add target_space_id if type is department
605
+ if target_space_type == "department":
606
+ if not target_space_id:
607
+ logger.error("Failed to create folder: target_space_id is required when target_space_type is department")
608
+ return None
609
+ target_space["id"] = target_space_id
610
+
611
+ params["target_space"] = target_space
612
+
613
+ # Send request to create folder
614
+ result = await self._api_request(
615
+ operation_name="create folder",
616
+ url=url,
617
+ json_data=params,
618
+ success_msg=f"Successfully created folder '{name}' in parent folder {parent_id}"
619
+ )
620
+
621
+ return result
622
+
623
+ except Exception as e:
624
+ logger.error(f"Error creating folder: {str(e)}", exc_info=True)
625
+ return None
626
+
627
+ async def cleanup(self) -> None:
628
+ logger.info("Cleaning up FangCloud client resources")
629
+ pass
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 Your Name
3
+ Copyright (c) 2025 FangCloud Developer
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,15 +1,24 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fangcloud-mcp
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: FangCloud MCP 是一个 Model Context Protocol (MCP) 服务器实现,提供与 FangCloud 云存储服务的集成
5
5
  Home-page: https://github.com/example/fangcloud-mcp
6
6
  Author: FangCloud Developer
7
7
  Author-email: dev@example.com
8
8
  License: UNKNOWN
9
+ Project-URL: Bug Reports, https://github.com/example/fangcloud-mcp/issues
10
+ Project-URL: Source, https://github.com/example/fangcloud-mcp
11
+ Project-URL: Documentation, https://github.com/example/fangcloud-mcp#readme
12
+ Keywords: fangcloud,mcp,cloud storage,api
9
13
  Platform: UNKNOWN
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Programming Language :: Python :: 3
10
17
  Classifier: Programming Language :: Python :: 3.12
11
18
  Classifier: License :: OSI Approved :: MIT License
12
19
  Classifier: Operating System :: OS Independent
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
13
22
  Requires-Python: >=3.12
14
23
  Description-Content-Type: text/markdown
15
24
  License-File: LICENSE
@@ -47,12 +56,28 @@ FangCloud MCP 提供以下功能:
47
56
 
48
57
  ## 安装方法
49
58
 
59
+ ### 从 PyPI 安装(推荐)
60
+
61
+ ```bash
62
+ pip install fangcloud-mcp
63
+ ```
64
+
65
+ ### 从源码安装
66
+
50
67
  ```bash
51
68
  # 克隆仓库
52
- git clone <repository-url>
69
+ git clone https://github.com/example/fangcloud-mcp.git
53
70
  cd fangcloud-mcp
54
71
 
55
- # 使用pip安装依赖
72
+ # 使用pip安装
73
+ pip install .
74
+ ```
75
+
76
+ ### 开发模式安装
77
+
78
+ ```bash
79
+ git clone https://github.com/example/fangcloud-mcp.git
80
+ cd fangcloud-mcp
56
81
  pip install -e .
57
82
  ```
58
83
 
@@ -0,0 +1,9 @@
1
+ fangcloud-mcp/__init__.py,sha256=fVFqgXkWSUiK1z-GKbZdCUY0b8zlv-jeQeOYHO5-R80,254
2
+ fangcloud-mcp/fangcloud.py,sha256=L5QPVBmY95QbVD68t_xCSHSkXw7B_5OHi_20jAx5qz0,14661
3
+ fangcloud-mcp/fangcloud_api.py,sha256=QQWB8neT-XKfc854yhhZ9WihyN-aI5D_MbV_RRjf-VM,26579
4
+ fangcloud_mcp-0.1.2.dist-info/LICENSE,sha256=PLXgT5nqTcwqGoalBG1tGoXtGUHFunt4gK0M-GxPl6o,1097
5
+ fangcloud_mcp-0.1.2.dist-info/METADATA,sha256=Op8laGdcgYEFCYv8esvQFta9D0089BGoI7wdzgWHK9w,4793
6
+ fangcloud_mcp-0.1.2.dist-info/WHEEL,sha256=G16H4A3IeoQmnOrYV4ueZGKSjhipXx8zc8nu9FGlvMA,92
7
+ fangcloud_mcp-0.1.2.dist-info/entry_points.txt,sha256=cLC2ygrgh8n0kfch8zaPrk5xvyOOVmOW0CRmnkD7KXw,64
8
+ fangcloud_mcp-0.1.2.dist-info/top_level.txt,sha256=7whH_Yk8uCosRXkTZto2fjFqIqLAoEWwVcfPXaMgAz4,14
9
+ fangcloud_mcp-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ fangcloud-mcp = fangcloud_mcp.fangcloud:main
3
+
@@ -0,0 +1 @@
1
+ fangcloud-mcp
@@ -1,5 +0,0 @@
1
- fangcloud_mcp-0.1.0.dist-info/LICENSE,sha256=l6425dmjnidzk4JQwhT01C8SpICmjSBMNREAdPnPA9E,1087
2
- fangcloud_mcp-0.1.0.dist-info/METADATA,sha256=3uhhtZ28666cOqQ85Y5OjG3w3PXtJta_66rr86BU-YU,4038
3
- fangcloud_mcp-0.1.0.dist-info/WHEEL,sha256=G16H4A3IeoQmnOrYV4ueZGKSjhipXx8zc8nu9FGlvMA,92
4
- fangcloud_mcp-0.1.0.dist-info/top_level.txt,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
5
- fangcloud_mcp-0.1.0.dist-info/RECORD,,
@@ -1 +0,0 @@
1
-