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.
Files changed (34) hide show
  1. alfresco_mcp_server/__init__.py +27 -0
  2. alfresco_mcp_server/config.py +104 -0
  3. alfresco_mcp_server/fastmcp_server.py +259 -0
  4. alfresco_mcp_server/prompts/__init__.py +7 -0
  5. alfresco_mcp_server/prompts/search_and_analyze.py +67 -0
  6. alfresco_mcp_server/resources/__init__.py +7 -0
  7. alfresco_mcp_server/resources/repository_resources.py +205 -0
  8. alfresco_mcp_server/tools/__init__.py +13 -0
  9. alfresco_mcp_server/tools/core/__init__.py +27 -0
  10. alfresco_mcp_server/tools/core/browse_repository.py +164 -0
  11. alfresco_mcp_server/tools/core/cancel_checkout.py +160 -0
  12. alfresco_mcp_server/tools/core/checkin_document.py +264 -0
  13. alfresco_mcp_server/tools/core/checkout_document.py +258 -0
  14. alfresco_mcp_server/tools/core/create_folder.py +105 -0
  15. alfresco_mcp_server/tools/core/delete_node.py +88 -0
  16. alfresco_mcp_server/tools/core/download_document.py +214 -0
  17. alfresco_mcp_server/tools/core/get_node_properties.py +195 -0
  18. alfresco_mcp_server/tools/core/update_node_properties.py +138 -0
  19. alfresco_mcp_server/tools/core/upload_document.py +244 -0
  20. alfresco_mcp_server/tools/search/__init__.py +15 -0
  21. alfresco_mcp_server/tools/search/advanced_search.py +204 -0
  22. alfresco_mcp_server/tools/search/cmis_search.py +180 -0
  23. alfresco_mcp_server/tools/search/search_by_metadata.py +183 -0
  24. alfresco_mcp_server/tools/search/search_content.py +186 -0
  25. alfresco_mcp_server/utils/__init__.py +34 -0
  26. alfresco_mcp_server/utils/connection.py +114 -0
  27. alfresco_mcp_server/utils/file_type_analysis.py +163 -0
  28. alfresco_mcp_server/utils/json_utils.py +135 -0
  29. python_alfresco_mcp_server-1.1.0.dist-info/METADATA +631 -0
  30. python_alfresco_mcp_server-1.1.0.dist-info/RECORD +34 -0
  31. python_alfresco_mcp_server-1.1.0.dist-info/WHEEL +5 -0
  32. python_alfresco_mcp_server-1.1.0.dist-info/entry_points.txt +2 -0
  33. python_alfresco_mcp_server-1.1.0.dist-info/licenses/LICENSE +201 -0
  34. 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)