python-alfresco-mcp-server 1.1.0__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.
- alfresco_mcp_server/__init__.py +27 -0
- alfresco_mcp_server/config.py +104 -0
- alfresco_mcp_server/fastmcp_server.py +259 -0
- alfresco_mcp_server/prompts/__init__.py +7 -0
- alfresco_mcp_server/prompts/search_and_analyze.py +67 -0
- alfresco_mcp_server/resources/__init__.py +7 -0
- alfresco_mcp_server/resources/repository_resources.py +205 -0
- alfresco_mcp_server/tools/__init__.py +13 -0
- alfresco_mcp_server/tools/core/__init__.py +27 -0
- alfresco_mcp_server/tools/core/browse_repository.py +164 -0
- alfresco_mcp_server/tools/core/cancel_checkout.py +160 -0
- alfresco_mcp_server/tools/core/checkin_document.py +264 -0
- alfresco_mcp_server/tools/core/checkout_document.py +258 -0
- alfresco_mcp_server/tools/core/create_folder.py +105 -0
- alfresco_mcp_server/tools/core/delete_node.py +88 -0
- alfresco_mcp_server/tools/core/download_document.py +214 -0
- alfresco_mcp_server/tools/core/get_node_properties.py +195 -0
- alfresco_mcp_server/tools/core/update_node_properties.py +138 -0
- alfresco_mcp_server/tools/core/upload_document.py +244 -0
- alfresco_mcp_server/tools/search/__init__.py +15 -0
- alfresco_mcp_server/tools/search/advanced_search.py +204 -0
- alfresco_mcp_server/tools/search/cmis_search.py +180 -0
- alfresco_mcp_server/tools/search/search_by_metadata.py +183 -0
- alfresco_mcp_server/tools/search/search_content.py +186 -0
- alfresco_mcp_server/utils/__init__.py +34 -0
- alfresco_mcp_server/utils/connection.py +114 -0
- alfresco_mcp_server/utils/file_type_analysis.py +163 -0
- alfresco_mcp_server/utils/json_utils.py +135 -0
- python_alfresco_mcp_server-1.1.0.dist-info/METADATA +631 -0
- python_alfresco_mcp_server-1.1.0.dist-info/RECORD +34 -0
- python_alfresco_mcp_server-1.1.0.dist-info/WHEEL +5 -0
- python_alfresco_mcp_server-1.1.0.dist-info/entry_points.txt +2 -0
- python_alfresco_mcp_server-1.1.0.dist-info/licenses/LICENSE +201 -0
- python_alfresco_mcp_server-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Checkout document tool implementation for Alfresco MCP Server.
|
|
3
|
+
Handles document checkout with lock management and local file download.
|
|
4
|
+
"""
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import pathlib
|
|
8
|
+
import json
|
|
9
|
+
import httpx
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from fastmcp import Context
|
|
12
|
+
from ...utils.connection import get_core_client
|
|
13
|
+
from ...config import config
|
|
14
|
+
from ...utils.json_utils import safe_format_output
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def checkout_document_impl(
|
|
21
|
+
node_id: str,
|
|
22
|
+
download_for_editing: bool = True,
|
|
23
|
+
ctx: Context = None
|
|
24
|
+
) -> str:
|
|
25
|
+
"""Check out a document for editing using Alfresco REST API.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
node_id: Document node ID to check out
|
|
29
|
+
download_for_editing: If True, downloads file to checkout folder for editing (default, AI-friendly).
|
|
30
|
+
If False, just creates working copy in Alfresco (testing mode)
|
|
31
|
+
ctx: MCP context for progress reporting
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Checkout confirmation with file path and editing instructions if download_for_editing=True,
|
|
35
|
+
or working copy details if False
|
|
36
|
+
"""
|
|
37
|
+
if ctx:
|
|
38
|
+
await ctx.info(safe_format_output(f">> Checking out document: {node_id}"))
|
|
39
|
+
await ctx.info(safe_format_output("Validating node ID..."))
|
|
40
|
+
await ctx.report_progress(0.1)
|
|
41
|
+
|
|
42
|
+
if not node_id.strip():
|
|
43
|
+
return safe_format_output("❌ Error: node_id is required")
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
logger.info(f"Starting checkout: node {node_id}")
|
|
47
|
+
core_client = await get_core_client()
|
|
48
|
+
|
|
49
|
+
if ctx:
|
|
50
|
+
await ctx.info(safe_format_output("Connecting to Alfresco..."))
|
|
51
|
+
await ctx.report_progress(0.2)
|
|
52
|
+
|
|
53
|
+
# Clean the node ID (remove any URL encoding or extra characters)
|
|
54
|
+
clean_node_id = node_id.strip()
|
|
55
|
+
if clean_node_id.startswith('alfresco://'):
|
|
56
|
+
# Extract node ID from URI format
|
|
57
|
+
clean_node_id = clean_node_id.split('/')[-1]
|
|
58
|
+
|
|
59
|
+
if ctx:
|
|
60
|
+
await ctx.info(safe_format_output("Getting node information..."))
|
|
61
|
+
await ctx.report_progress(0.3)
|
|
62
|
+
|
|
63
|
+
# Get node information first to validate it exists using high-level core client
|
|
64
|
+
node_response = core_client.nodes.get(node_id=clean_node_id)
|
|
65
|
+
|
|
66
|
+
if not hasattr(node_response, 'entry'):
|
|
67
|
+
return safe_format_output(f"❌ Failed to get node information for: {clean_node_id}")
|
|
68
|
+
|
|
69
|
+
node_info = node_response.entry
|
|
70
|
+
filename = getattr(node_info, 'name', f"document_{clean_node_id}")
|
|
71
|
+
|
|
72
|
+
if ctx:
|
|
73
|
+
await ctx.info(safe_format_output(">> Performing Alfresco lock using core client..."))
|
|
74
|
+
await ctx.report_progress(0.5)
|
|
75
|
+
|
|
76
|
+
# Use core client directly since it inherits from AuthenticatedClient
|
|
77
|
+
lock_status = "unlocked"
|
|
78
|
+
try:
|
|
79
|
+
logger.info(f"Attempting to lock document: {clean_node_id}")
|
|
80
|
+
|
|
81
|
+
# Use the high-level wrapper method that handles the body internally
|
|
82
|
+
logger.info(f"Using AlfrescoCoreClient versions.checkout method...")
|
|
83
|
+
|
|
84
|
+
# Use the hierarchical API: versions.checkout
|
|
85
|
+
lock_response = core_client.versions.checkout(
|
|
86
|
+
node_id=clean_node_id
|
|
87
|
+
)
|
|
88
|
+
logger.info(f"✅ Used lock_node_sync method successfully")
|
|
89
|
+
|
|
90
|
+
if lock_response and hasattr(lock_response, 'entry'):
|
|
91
|
+
lock_status = "locked"
|
|
92
|
+
else:
|
|
93
|
+
lock_status = "locked" # Assume success if no error
|
|
94
|
+
|
|
95
|
+
logger.info(f"Document locked successfully: {clean_node_id}")
|
|
96
|
+
|
|
97
|
+
except Exception as lock_error:
|
|
98
|
+
error_str = str(lock_error)
|
|
99
|
+
if "423" in error_str or "already locked" in error_str.lower():
|
|
100
|
+
logger.warning(f"Document already locked: {clean_node_id}")
|
|
101
|
+
return safe_format_output(f"❌ Document is already locked by another user: {error_str}")
|
|
102
|
+
elif "405" in error_str:
|
|
103
|
+
# Server doesn't support lock API - continue without locking
|
|
104
|
+
lock_status = "no-lock-api"
|
|
105
|
+
logger.warning(f"Server doesn't support lock API for {clean_node_id}")
|
|
106
|
+
if ctx:
|
|
107
|
+
await ctx.info(safe_format_output("WARNING: Server doesn't support lock API - proceeding without lock"))
|
|
108
|
+
elif "multiple values for keyword argument" in error_str:
|
|
109
|
+
logger.error(f"Parameter conflict in lock_node_sync: {error_str}")
|
|
110
|
+
return safe_format_output(f"❌ Internal client error - parameter conflict: {error_str}")
|
|
111
|
+
else:
|
|
112
|
+
logger.error(f"Failed to lock document {clean_node_id}: {error_str}")
|
|
113
|
+
return safe_format_output(f"❌ Document cannot be locked: {error_str}")
|
|
114
|
+
|
|
115
|
+
# Document is now locked, we'll download the current content
|
|
116
|
+
working_copy_id = clean_node_id # With lock API, we work with the same node ID
|
|
117
|
+
|
|
118
|
+
if ctx:
|
|
119
|
+
if lock_status == "locked":
|
|
120
|
+
await ctx.info(safe_format_output(f"SUCCESS: Document locked in Alfresco!"))
|
|
121
|
+
else:
|
|
122
|
+
await ctx.info(safe_format_output(f"Document prepared for editing (no lock support)"))
|
|
123
|
+
await ctx.report_progress(0.7)
|
|
124
|
+
|
|
125
|
+
if download_for_editing:
|
|
126
|
+
try:
|
|
127
|
+
if ctx:
|
|
128
|
+
await ctx.info(safe_format_output("Downloading current content..."))
|
|
129
|
+
await ctx.report_progress(0.8)
|
|
130
|
+
|
|
131
|
+
# Get content using authenticated HTTP client from core client (ensure initialization)
|
|
132
|
+
if not core_client.is_initialized:
|
|
133
|
+
return safe_format_output("❌ Error: Alfresco server unavailable")
|
|
134
|
+
# Use httpx_client property directly on AlfrescoCoreClient
|
|
135
|
+
http_client = core_client.httpx_client
|
|
136
|
+
|
|
137
|
+
# Build correct content URL based on config
|
|
138
|
+
if config.alfresco_url.endswith('/alfresco/api/-default-/public'):
|
|
139
|
+
# Full API path provided
|
|
140
|
+
content_url = f"{config.alfresco_url}/alfresco/versions/1/nodes/{clean_node_id}/content"
|
|
141
|
+
elif config.alfresco_url.endswith('/alfresco/api'):
|
|
142
|
+
# Base API path provided
|
|
143
|
+
content_url = f"{config.alfresco_url}/-default-/public/alfresco/versions/1/nodes/{clean_node_id}/content"
|
|
144
|
+
else:
|
|
145
|
+
# Base server URL provided
|
|
146
|
+
content_url = f"{config.alfresco_url}/alfresco/api/-default-/public/alfresco/versions/1/nodes/{clean_node_id}/content"
|
|
147
|
+
|
|
148
|
+
response = http_client.get(content_url)
|
|
149
|
+
response.raise_for_status()
|
|
150
|
+
|
|
151
|
+
# Save to Downloads/checkout folder
|
|
152
|
+
downloads_dir = pathlib.Path.home() / "Downloads"
|
|
153
|
+
checkout_dir = downloads_dir / "checkout"
|
|
154
|
+
checkout_dir.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
|
|
156
|
+
# Create unique filename with node ID
|
|
157
|
+
safe_filename = filename.replace(" ", "_").replace("/", "_").replace("\\", "_")
|
|
158
|
+
local_filename = f"{safe_filename}_{clean_node_id}"
|
|
159
|
+
local_path = checkout_dir / local_filename
|
|
160
|
+
|
|
161
|
+
with open(local_path, 'wb') as f:
|
|
162
|
+
f.write(response.content)
|
|
163
|
+
|
|
164
|
+
logger.info(f"Downloaded for editing: {filename} -> {local_path}")
|
|
165
|
+
# Update checkout tracking with actual working copy ID
|
|
166
|
+
checkout_manifest_path = checkout_dir / ".checkout_manifest.json"
|
|
167
|
+
checkout_data = {}
|
|
168
|
+
|
|
169
|
+
if checkout_manifest_path.exists():
|
|
170
|
+
try:
|
|
171
|
+
with open(checkout_manifest_path, 'r') as f:
|
|
172
|
+
checkout_data = json.load(f)
|
|
173
|
+
except:
|
|
174
|
+
checkout_data = {}
|
|
175
|
+
|
|
176
|
+
if 'checkouts' not in checkout_data:
|
|
177
|
+
checkout_data['checkouts'] = {}
|
|
178
|
+
|
|
179
|
+
checkout_time = datetime.now().isoformat()
|
|
180
|
+
|
|
181
|
+
checkout_data['checkouts'][clean_node_id] = {
|
|
182
|
+
'original_node_id': clean_node_id,
|
|
183
|
+
'locked_node_id': clean_node_id, # Same as original since we lock, not checkout
|
|
184
|
+
'local_file': local_filename,
|
|
185
|
+
'checkout_time': checkout_time,
|
|
186
|
+
'original_filename': filename
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
# Save manifest
|
|
190
|
+
with open(checkout_manifest_path, 'w') as f:
|
|
191
|
+
json.dump(checkout_data, f, indent=2)
|
|
192
|
+
|
|
193
|
+
if ctx:
|
|
194
|
+
await ctx.info(safe_format_output("SUCCESS: Checkout completed!"))
|
|
195
|
+
await ctx.report_progress(1.0)
|
|
196
|
+
|
|
197
|
+
# Format file size
|
|
198
|
+
file_size = len(response.content)
|
|
199
|
+
if file_size < 1024:
|
|
200
|
+
size_str = f"{file_size} bytes"
|
|
201
|
+
elif file_size < 1024 * 1024:
|
|
202
|
+
size_str = f"{file_size / 1024:.1f} KB"
|
|
203
|
+
else:
|
|
204
|
+
size_str = f"{file_size / (1024 * 1024):.1f} MB"
|
|
205
|
+
|
|
206
|
+
if lock_status == "locked":
|
|
207
|
+
result = f"🔒 Document Checked Out Successfully!\n\n"
|
|
208
|
+
result += f"📄 Name: {filename}\n"
|
|
209
|
+
result += f"🆔 Node ID: {clean_node_id}\n"
|
|
210
|
+
result += f"📏 Size: {size_str}\n"
|
|
211
|
+
result += f"💾 Downloaded to: {local_path}\n"
|
|
212
|
+
result += f"🔒 Lock Status: {lock_status}\n"
|
|
213
|
+
result += f"🕒 Checkout Time: {checkout_time}\n\n"
|
|
214
|
+
result += f"Next steps:\n"
|
|
215
|
+
result += f" 1. Edit the document at: {local_path}\n"
|
|
216
|
+
result += f" 2. Save your changes\n"
|
|
217
|
+
result += f" 3. Use checkin_document tool to upload changes\n\n"
|
|
218
|
+
result += f"The document is now locked in Alfresco to prevent conflicts.\n"
|
|
219
|
+
result += f"Other users cannot edit it until you check it back in or cancel the checkout."
|
|
220
|
+
|
|
221
|
+
return safe_format_output(result)
|
|
222
|
+
else:
|
|
223
|
+
result = f"📥 **Document downloaded for editing!**\n\n"
|
|
224
|
+
status_msg = "ℹ️ **Status**: Downloaded for editing (server doesn't support locks)"
|
|
225
|
+
important_msg = "ℹ️ **Note**: Server doesn't support locking - multiple users may edit simultaneously."
|
|
226
|
+
|
|
227
|
+
result += f">> **Downloaded to**: `{local_path}`\n"
|
|
228
|
+
result += f">> **Original**: {filename}\n"
|
|
229
|
+
result += f">> **Size**: {size_str}\n"
|
|
230
|
+
result += f"{status_msg}\n"
|
|
231
|
+
result += f"🔗 **Node ID**: {clean_node_id}\n"
|
|
232
|
+
result += f"🕒 **Downloaded at**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
|
|
233
|
+
result += f">> **Instructions**:\n"
|
|
234
|
+
result += f"1. Open the file in your preferred application (Word, Excel, etc.)\n"
|
|
235
|
+
result += f"2. Make your edits and save the file\n"
|
|
236
|
+
result += f"3. When finished, use `checkin_document` to upload your changes\n\n"
|
|
237
|
+
result += f"{important_msg}"
|
|
238
|
+
|
|
239
|
+
return safe_format_output(result)
|
|
240
|
+
except Exception as e:
|
|
241
|
+
error_msg = f"❌ Checkout failed: {str(e)}"
|
|
242
|
+
if ctx:
|
|
243
|
+
await ctx.error(safe_format_output(error_msg))
|
|
244
|
+
logger.error(f"Checkout failed: {e}")
|
|
245
|
+
return safe_format_output(error_msg)
|
|
246
|
+
else:
|
|
247
|
+
# Testing mode - just return lock status
|
|
248
|
+
if lock_status == "locked":
|
|
249
|
+
return f"SUCCESS: Document locked successfully for testing. Node ID: {clean_node_id}, Status: LOCKED"
|
|
250
|
+
else:
|
|
251
|
+
return f"WARNING: Document prepared for editing (no lock support). Node ID: {clean_node_id}"
|
|
252
|
+
|
|
253
|
+
except Exception as e:
|
|
254
|
+
error_msg = f"❌ Checkout failed: {str(e)}"
|
|
255
|
+
if ctx:
|
|
256
|
+
await ctx.error(safe_format_output(error_msg))
|
|
257
|
+
logger.error(f"Checkout failed: {e}")
|
|
258
|
+
return safe_format_output(error_msg)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Create folder tool for Alfresco MCP Server.
|
|
3
|
+
Self-contained tool for creating folders in Alfresco repository.
|
|
4
|
+
"""
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from fastmcp import Context
|
|
8
|
+
|
|
9
|
+
from ...utils.connection import ensure_connection
|
|
10
|
+
from ...utils.json_utils import safe_format_output
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def create_folder_impl(
|
|
16
|
+
folder_name: str,
|
|
17
|
+
parent_id: str = "-shared-",
|
|
18
|
+
description: str = "",
|
|
19
|
+
ctx: Optional[Context] = None
|
|
20
|
+
) -> str:
|
|
21
|
+
"""Create a new folder in Alfresco.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
folder_name: Name of the new folder
|
|
25
|
+
parent_id: Parent folder ID (default: shared folder)
|
|
26
|
+
description: Folder description
|
|
27
|
+
ctx: MCP context for progress reporting
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Folder creation confirmation with details
|
|
31
|
+
"""
|
|
32
|
+
if ctx:
|
|
33
|
+
await ctx.info(f">> Creating folder '{folder_name}' in {parent_id}")
|
|
34
|
+
await ctx.info("Validating folder parameters...")
|
|
35
|
+
await ctx.report_progress(0.0)
|
|
36
|
+
|
|
37
|
+
if not folder_name.strip():
|
|
38
|
+
return safe_format_output("❌ Error: folder_name is required")
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
# Ensure connection and get client factory (working pattern from test)
|
|
42
|
+
await ensure_connection()
|
|
43
|
+
from ...utils.connection import get_client_factory
|
|
44
|
+
|
|
45
|
+
# Get client factory and create core client (working pattern from test)
|
|
46
|
+
client_factory = await get_client_factory()
|
|
47
|
+
core_client = client_factory.create_core_client()
|
|
48
|
+
|
|
49
|
+
if ctx:
|
|
50
|
+
await ctx.info("Creating folder in Alfresco...")
|
|
51
|
+
await ctx.report_progress(0.5)
|
|
52
|
+
|
|
53
|
+
logger.info(f"Creating folder '{folder_name}' in parent {parent_id}")
|
|
54
|
+
|
|
55
|
+
# Prepare properties
|
|
56
|
+
properties = {"cm:title": folder_name}
|
|
57
|
+
if description:
|
|
58
|
+
properties["cm:description"] = description
|
|
59
|
+
|
|
60
|
+
logger.info(f"Using high-level API: core_client.nodes.create_folder()")
|
|
61
|
+
|
|
62
|
+
# Use the working high-level API pattern from test script
|
|
63
|
+
folder_response = core_client.nodes.create_folder(
|
|
64
|
+
name=folder_name,
|
|
65
|
+
parent_id=parent_id,
|
|
66
|
+
properties=properties
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if folder_response and hasattr(folder_response, 'entry'):
|
|
70
|
+
entry = folder_response.entry
|
|
71
|
+
logger.info("✅ Folder created successfully")
|
|
72
|
+
|
|
73
|
+
# Extract folder details from response
|
|
74
|
+
folder_id = getattr(entry, 'id', 'Unknown')
|
|
75
|
+
folder_name_response = getattr(entry, 'name', folder_name)
|
|
76
|
+
created_at = getattr(entry, 'createdAt', 'Unknown')
|
|
77
|
+
node_type = getattr(entry, 'nodeType', 'cm:folder')
|
|
78
|
+
else:
|
|
79
|
+
raise Exception(f"Failed to create folder - invalid response from core client")
|
|
80
|
+
|
|
81
|
+
if ctx:
|
|
82
|
+
await ctx.info("Processing folder creation response...")
|
|
83
|
+
await ctx.report_progress(0.9)
|
|
84
|
+
|
|
85
|
+
if ctx:
|
|
86
|
+
await ctx.info("Folder created!")
|
|
87
|
+
await ctx.report_progress(1.0)
|
|
88
|
+
await ctx.info(f"SUCCESS: Folder '{folder_name_response}' created successfully")
|
|
89
|
+
|
|
90
|
+
# Clean JSON-friendly formatting (no markdown syntax)
|
|
91
|
+
return safe_format_output(f"""✅ Folder Created Successfully!
|
|
92
|
+
|
|
93
|
+
📁 Name: {folder_name_response}
|
|
94
|
+
🆔 Folder ID: {folder_id}
|
|
95
|
+
📍 Parent: {parent_id}
|
|
96
|
+
📅 Created: {created_at}
|
|
97
|
+
🏷️ Type: {node_type}
|
|
98
|
+
📝 Description: {description or 'None'}""")
|
|
99
|
+
|
|
100
|
+
except Exception as e:
|
|
101
|
+
error_msg = f"❌ Folder creation failed: {str(e)}"
|
|
102
|
+
if ctx:
|
|
103
|
+
await ctx.error(error_msg)
|
|
104
|
+
logger.error(f"Folder creation failed: {e}")
|
|
105
|
+
return safe_format_output(error_msg)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Delete node tool for Alfresco MCP Server.
|
|
3
|
+
Self-contained tool for deleting documents or folders from Alfresco repository.
|
|
4
|
+
"""
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from fastmcp import Context
|
|
8
|
+
|
|
9
|
+
from ...utils.connection import ensure_connection
|
|
10
|
+
from ...utils.json_utils import safe_format_output
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def delete_node_impl(
|
|
16
|
+
node_id: str,
|
|
17
|
+
permanent: bool = False,
|
|
18
|
+
ctx: Optional[Context] = None
|
|
19
|
+
) -> str:
|
|
20
|
+
"""Delete a document or folder from Alfresco.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
node_id: Node ID to delete
|
|
24
|
+
permanent: Whether to permanently delete (bypass trash)
|
|
25
|
+
ctx: MCP context for progress reporting
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Deletion confirmation
|
|
29
|
+
"""
|
|
30
|
+
if ctx:
|
|
31
|
+
delete_type = "permanently delete" if permanent else "move to trash"
|
|
32
|
+
await ctx.info(f"Preparing to {delete_type}: {node_id}")
|
|
33
|
+
await ctx.info("Validating deletion request...")
|
|
34
|
+
await ctx.report_progress(0.1)
|
|
35
|
+
|
|
36
|
+
if not node_id.strip():
|
|
37
|
+
return safe_format_output("❌ Error: node_id is required")
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
await ensure_connection()
|
|
41
|
+
from ...utils.connection import get_client_factory
|
|
42
|
+
|
|
43
|
+
# Get client factory and create core client (working pattern from test)
|
|
44
|
+
client_factory = await get_client_factory()
|
|
45
|
+
core_client = client_factory.create_core_client()
|
|
46
|
+
|
|
47
|
+
# Clean the node ID (remove any URL encoding or extra characters)
|
|
48
|
+
clean_node_id = node_id.strip()
|
|
49
|
+
if clean_node_id.startswith('alfresco://'):
|
|
50
|
+
# Extract node ID from URI format
|
|
51
|
+
clean_node_id = clean_node_id.split('/')[-1]
|
|
52
|
+
|
|
53
|
+
logger.info(f"Attempting to delete node: {clean_node_id}")
|
|
54
|
+
|
|
55
|
+
if ctx:
|
|
56
|
+
await ctx.report_progress(0.7)
|
|
57
|
+
|
|
58
|
+
# Get node information first to validate it exists (working pattern from test)
|
|
59
|
+
node_response = core_client.nodes.get(clean_node_id)
|
|
60
|
+
|
|
61
|
+
if not hasattr(node_response, 'entry'):
|
|
62
|
+
return safe_format_output(f"❌ Failed to get node information for: {clean_node_id}")
|
|
63
|
+
|
|
64
|
+
node_info = node_response.entry
|
|
65
|
+
filename = getattr(node_info, 'name', f"document_{clean_node_id}")
|
|
66
|
+
|
|
67
|
+
# Use the working high-level API pattern from test script
|
|
68
|
+
core_client.nodes.delete(clean_node_id)
|
|
69
|
+
|
|
70
|
+
status = "permanently deleted" if permanent else "moved to trash"
|
|
71
|
+
logger.info(f"✅ Node {status}: {filename}")
|
|
72
|
+
|
|
73
|
+
if ctx:
|
|
74
|
+
await ctx.report_progress(1.0)
|
|
75
|
+
return safe_format_output(f"""✅ **Deletion Complete**
|
|
76
|
+
|
|
77
|
+
📄 **Node**: {node_info.name}
|
|
78
|
+
🗑️ **Status**: {status.title()}
|
|
79
|
+
{"⚠️ **WARNING**: This action cannot be undone" if permanent else "ℹ️ **INFO**: Can be restored from trash"}
|
|
80
|
+
|
|
81
|
+
🆔 **Node ID**: {clean_node_id}""")
|
|
82
|
+
|
|
83
|
+
except Exception as e:
|
|
84
|
+
error_msg = f"ERROR: Deletion failed: {str(e)}"
|
|
85
|
+
if ctx:
|
|
86
|
+
await ctx.error(error_msg)
|
|
87
|
+
logger.error(f"Deletion failed: {e}")
|
|
88
|
+
return error_msg
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Download document tool for Alfresco MCP Server.
|
|
3
|
+
Self-contained tool for downloading documents from Alfresco repository.
|
|
4
|
+
"""
|
|
5
|
+
import logging
|
|
6
|
+
import httpx
|
|
7
|
+
import base64
|
|
8
|
+
import os
|
|
9
|
+
import pathlib
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Optional
|
|
12
|
+
from fastmcp import Context
|
|
13
|
+
from ...utils.connection import get_core_client
|
|
14
|
+
from ...config import config
|
|
15
|
+
from ...utils.file_type_analysis import analyze_content_type
|
|
16
|
+
from ...utils.json_utils import safe_format_output
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def download_document_impl(
|
|
22
|
+
node_id: str,
|
|
23
|
+
save_to_disk: bool = True,
|
|
24
|
+
attachment: bool = True,
|
|
25
|
+
ctx: Optional[Context] = None
|
|
26
|
+
) -> str:
|
|
27
|
+
"""Download a document from Alfresco repository.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
node_id: Node ID of the document to download
|
|
31
|
+
save_to_disk: If True, saves file to Downloads folder (default, AI-friendly).
|
|
32
|
+
If False, returns base64 content (testing/debugging)
|
|
33
|
+
attachment: If True, downloads as attachment (default). If False, opens for preview in browser
|
|
34
|
+
ctx: MCP context for progress reporting
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
File path and confirmation if save_to_disk=True, or base64 content if False
|
|
38
|
+
"""
|
|
39
|
+
if ctx:
|
|
40
|
+
await ctx.info(f"Downloading document: {node_id}")
|
|
41
|
+
await ctx.report_progress(0.0)
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
logger.info(f"Starting download: node {node_id}")
|
|
45
|
+
core_client = await get_core_client()
|
|
46
|
+
|
|
47
|
+
if ctx:
|
|
48
|
+
await ctx.info("Getting node information...")
|
|
49
|
+
await ctx.report_progress(0.3)
|
|
50
|
+
|
|
51
|
+
# Clean the node ID (remove any URL encoding or extra characters)
|
|
52
|
+
clean_node_id = node_id.strip()
|
|
53
|
+
if clean_node_id.startswith('alfresco://'):
|
|
54
|
+
# Extract node ID from URI format
|
|
55
|
+
clean_node_id = clean_node_id.split('/')[-1]
|
|
56
|
+
|
|
57
|
+
# Get node information first to validate it exists and get filename
|
|
58
|
+
node_response = core_client.nodes.get(node_id=clean_node_id)
|
|
59
|
+
|
|
60
|
+
if not hasattr(node_response, 'entry'):
|
|
61
|
+
return safe_format_output(f"❌ Failed to get node information for: {clean_node_id}")
|
|
62
|
+
|
|
63
|
+
node_info = node_response.entry
|
|
64
|
+
filename = getattr(node_info, 'name', f"document_{clean_node_id}")
|
|
65
|
+
node_type = getattr(node_info, 'node_type', 'Unknown')
|
|
66
|
+
|
|
67
|
+
# Check if it's actually a file
|
|
68
|
+
is_file = getattr(node_info, 'is_file', False)
|
|
69
|
+
if not is_file:
|
|
70
|
+
return safe_format_output(f"❌ Node {clean_node_id} is not a file (it's a {node_type})")
|
|
71
|
+
|
|
72
|
+
# Clean filename - strip whitespace and remove invalid characters for file paths
|
|
73
|
+
filename = filename.strip()
|
|
74
|
+
# Remove newlines and other control characters
|
|
75
|
+
filename = filename.replace('\n', '').replace('\r', '').replace('\t', '')
|
|
76
|
+
# Remove or replace invalid Windows filename characters
|
|
77
|
+
invalid_chars = '<>:"|?*'
|
|
78
|
+
for char in invalid_chars:
|
|
79
|
+
filename = filename.replace(char, '_')
|
|
80
|
+
# Ensure filename is not empty
|
|
81
|
+
if not filename or filename == '_':
|
|
82
|
+
filename = f"document_{clean_node_id}"
|
|
83
|
+
|
|
84
|
+
if ctx:
|
|
85
|
+
await ctx.info(f"Downloading content for: {filename}")
|
|
86
|
+
await ctx.report_progress(0.7)
|
|
87
|
+
|
|
88
|
+
# Get file content using authenticated HTTP client from core client
|
|
89
|
+
# Build correct content URL based on config
|
|
90
|
+
if config.alfresco_url.endswith('/alfresco/api/-default-/public'):
|
|
91
|
+
# Full API path provided
|
|
92
|
+
content_url = f"{config.alfresco_url}/alfresco/versions/1/nodes/{clean_node_id}/content"
|
|
93
|
+
elif config.alfresco_url.endswith('/alfresco/api'):
|
|
94
|
+
# Base API path provided
|
|
95
|
+
content_url = f"{config.alfresco_url}/-default-/public/alfresco/versions/1/nodes/{clean_node_id}/content"
|
|
96
|
+
else:
|
|
97
|
+
# Base server URL provided
|
|
98
|
+
content_url = f"{config.alfresco_url}/alfresco/api/-default-/public/alfresco/versions/1/nodes/{clean_node_id}/content"
|
|
99
|
+
|
|
100
|
+
# Use the authenticated HTTP client from core client (ensure initialization)
|
|
101
|
+
if not core_client.is_initialized:
|
|
102
|
+
return safe_format_output("❌ Error: Alfresco server unavailable")
|
|
103
|
+
# Use httpx_client property directly on AlfrescoCoreClient
|
|
104
|
+
http_client = core_client.httpx_client
|
|
105
|
+
|
|
106
|
+
# Add attachment parameter if specified
|
|
107
|
+
params = {}
|
|
108
|
+
if not attachment:
|
|
109
|
+
params['attachment'] = 'false'
|
|
110
|
+
|
|
111
|
+
logger.debug(f"Downloading content from: {content_url}")
|
|
112
|
+
response = http_client.get(content_url, params=params)
|
|
113
|
+
response.raise_for_status()
|
|
114
|
+
content_bytes = response.content
|
|
115
|
+
logger.info(f"Downloaded {len(content_bytes)} bytes for {filename}")
|
|
116
|
+
|
|
117
|
+
if ctx:
|
|
118
|
+
await ctx.report_progress(0.9)
|
|
119
|
+
|
|
120
|
+
file_size = len(content_bytes)
|
|
121
|
+
# Fix: ContentInfo object doesn't have .get() method - access mime_type attribute directly
|
|
122
|
+
mime_type = 'application/octet-stream'
|
|
123
|
+
if hasattr(node_info, 'content') and node_info.content:
|
|
124
|
+
mime_type = getattr(node_info.content, 'mime_type', 'application/octet-stream')
|
|
125
|
+
|
|
126
|
+
if save_to_disk:
|
|
127
|
+
# AI-Client friendly: Save file to Downloads folder with content-aware handling
|
|
128
|
+
|
|
129
|
+
# Create Downloads directory if it doesn't exist
|
|
130
|
+
downloads_dir = pathlib.Path.home() / "Downloads"
|
|
131
|
+
downloads_dir.mkdir(exist_ok=True)
|
|
132
|
+
|
|
133
|
+
# Content-aware file handling
|
|
134
|
+
file_extension = pathlib.Path(filename).suffix.lower()
|
|
135
|
+
content_type_info = analyze_content_type(filename, mime_type, content_bytes)
|
|
136
|
+
|
|
137
|
+
# Create smart filename with content type organization
|
|
138
|
+
if content_type_info['category'] != 'other':
|
|
139
|
+
category_dir = downloads_dir / content_type_info['category']
|
|
140
|
+
category_dir.mkdir(exist_ok=True)
|
|
141
|
+
downloads_dir = category_dir
|
|
142
|
+
|
|
143
|
+
# Create unique filename with node ID to avoid conflicts
|
|
144
|
+
name_parts = filename.rsplit('.', 1)
|
|
145
|
+
if len(name_parts) == 2:
|
|
146
|
+
safe_filename = f"{name_parts[0]}_{clean_node_id}.{name_parts[1]}"
|
|
147
|
+
else:
|
|
148
|
+
safe_filename = f"{filename}_{clean_node_id}"
|
|
149
|
+
|
|
150
|
+
file_path = downloads_dir / safe_filename
|
|
151
|
+
|
|
152
|
+
# Write file to disk
|
|
153
|
+
with open(file_path, 'wb') as f:
|
|
154
|
+
f.write(content_bytes)
|
|
155
|
+
|
|
156
|
+
logger.info(f"File saved: {filename} -> {file_path}")
|
|
157
|
+
if ctx:
|
|
158
|
+
await ctx.info(f"File saved to: {file_path}")
|
|
159
|
+
await ctx.report_progress(1.0)
|
|
160
|
+
|
|
161
|
+
# Format file size
|
|
162
|
+
if file_size < 1024:
|
|
163
|
+
size_str = f"{file_size} bytes"
|
|
164
|
+
elif file_size < 1024 * 1024:
|
|
165
|
+
size_str = f"{file_size / 1024:.1f} KB"
|
|
166
|
+
else:
|
|
167
|
+
size_str = f"{file_size / (1024 * 1024):.1f} MB"
|
|
168
|
+
|
|
169
|
+
download_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
170
|
+
|
|
171
|
+
# Clean JSON-friendly formatting (no markdown syntax)
|
|
172
|
+
result = f"📥 Document Downloaded Successfully!\n\n"
|
|
173
|
+
result += f"📄 Name: {filename}\n"
|
|
174
|
+
result += f"🆔 Node ID: {clean_node_id}\n"
|
|
175
|
+
result += f"📏 Size: {size_str}\n"
|
|
176
|
+
result += f"📄 MIME Type: {mime_type}\n"
|
|
177
|
+
result += f"💾 Saved to: {file_path}\n"
|
|
178
|
+
result += f"📁 Directory: {downloads_dir}\n"
|
|
179
|
+
result += f"🕒 Downloaded: {download_time}\n\n"
|
|
180
|
+
result += f"File saved to your Downloads folder for easy access.\n"
|
|
181
|
+
result += f"You can now open, edit, or move the file as needed.\n"
|
|
182
|
+
|
|
183
|
+
if content_type_info['category'] and content_type_info['category'].strip():
|
|
184
|
+
result += f"📝 **Content Type**: {content_type_info['category']}\n"
|
|
185
|
+
|
|
186
|
+
# Content-aware suggestions
|
|
187
|
+
if content_type_info['suggestions']:
|
|
188
|
+
result += f"**Content-Aware Suggestions:**\n"
|
|
189
|
+
for suggestion in content_type_info['suggestions']:
|
|
190
|
+
result += f" {suggestion}\n"
|
|
191
|
+
result += "\n"
|
|
192
|
+
|
|
193
|
+
result += f"**Organized in**: {content_type_info['category']} folder\n"
|
|
194
|
+
result += f"**Tip**: File is automatically organized by content type for easier management!"
|
|
195
|
+
|
|
196
|
+
return safe_format_output(result)
|
|
197
|
+
else:
|
|
198
|
+
# Testing/debugging mode: Return base64 content
|
|
199
|
+
base64_content = base64.b64encode(content_bytes).decode('ascii')
|
|
200
|
+
|
|
201
|
+
result = f"**Downloaded: {filename}**\n\n"
|
|
202
|
+
result += f"- **Node ID**: {clean_node_id}\n"
|
|
203
|
+
result += f"- **Size**: {file_size} bytes\n"
|
|
204
|
+
result += f"- **MIME Type**: {mime_type}\n\n"
|
|
205
|
+
result += f"**Base64 Content**:\n```\n{base64_content[:200]}{'...' if len(base64_content) > 200 else ''}\n```\n"
|
|
206
|
+
result += f"\n*Note: Content is base64 encoded. Full content length: {len(base64_content)} characters*"
|
|
207
|
+
|
|
208
|
+
return safe_format_output(result)
|
|
209
|
+
|
|
210
|
+
except Exception as e:
|
|
211
|
+
error_msg = f"❌ Failed to download document {node_id}: {str(e)}"
|
|
212
|
+
if ctx:
|
|
213
|
+
await ctx.error(error_msg)
|
|
214
|
+
return safe_format_output(error_msg)
|