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,13 @@
1
+ """
2
+ Tools module for Alfresco MCP Server.
3
+ Contains core and search tools organized hierarchically.
4
+ """
5
+
6
+ # Import subdirectories
7
+ from . import core
8
+ from . import search
9
+
10
+ __all__ = [
11
+ "core",
12
+ "search",
13
+ ]
@@ -0,0 +1,27 @@
1
+ # Core tools module
2
+
3
+ from . import (
4
+ browse_repository,
5
+ cancel_checkout,
6
+ checkin_document,
7
+ checkout_document,
8
+ create_folder,
9
+ delete_node,
10
+ download_document,
11
+ get_node_properties,
12
+ update_node_properties,
13
+ upload_document,
14
+ )
15
+
16
+ __all__ = [
17
+ "browse_repository",
18
+ "cancel_checkout",
19
+ "checkin_document",
20
+ "checkout_document",
21
+ "create_folder",
22
+ "delete_node",
23
+ "download_document",
24
+ "get_node_properties",
25
+ "update_node_properties",
26
+ "upload_document",
27
+ ]
@@ -0,0 +1,164 @@
1
+ """
2
+ Browse repository tool for Alfresco MCP Server.
3
+ Self-contained tool for browsing Alfresco repository structure.
4
+ """
5
+ import logging
6
+ from typing import Optional
7
+ from fastmcp import Context
8
+
9
+ from ...utils.connection import ensure_connection
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ async def browse_repository_impl(
15
+ parent_id: str = "-my-",
16
+ max_items: int = 25,
17
+ ctx: Optional[Context] = None
18
+ ) -> str:
19
+ """Browse the Alfresco repository structure.
20
+
21
+ Args:
22
+ parent_id: Parent node ID to browse (default: user's personal space)
23
+ max_items: Maximum number of items to return (default: 25)
24
+ ctx: MCP context for progress reporting
25
+
26
+ Returns:
27
+ Formatted listing of repository contents
28
+ """
29
+ # Parameter validation and extraction
30
+ try:
31
+ # Extract parameters with fallback handling
32
+ if hasattr(parent_id, 'value'):
33
+ actual_parent_id = str(parent_id.value)
34
+ else:
35
+ actual_parent_id = str(parent_id)
36
+
37
+ if hasattr(max_items, 'value'):
38
+ actual_max_items = int(max_items.value)
39
+ else:
40
+ actual_max_items = int(max_items)
41
+
42
+ # Clean and normalize for display (preserve Unicode characters)
43
+ safe_parent_id_display = str(actual_parent_id)
44
+
45
+ except Exception as e:
46
+ logger.error(f"Parameter extraction error: {e}")
47
+ return f"ERROR: Parameter error: {str(e)}"
48
+
49
+ if ctx:
50
+ await ctx.info(f"Browsing repository node: {safe_parent_id_display}")
51
+ await ctx.report_progress(0.0)
52
+
53
+ try:
54
+ # Get all clients that ensure_connection() already created
55
+ master_client = await ensure_connection()
56
+
57
+ # Access the core client that was already created
58
+ core_client = master_client.core
59
+
60
+ # Try high-level API first, then use raw client property (NEW: cleaner access)
61
+ # Check if we can use high-level nodes.get_children()
62
+ try:
63
+ # Use high-level API for browsing (preferred approach)
64
+ children_result = core_client.nodes.get_children(actual_parent_id, max_items=actual_max_items)
65
+ if children_result and hasattr(children_result, 'list') and hasattr(children_result.list, 'entries'):
66
+ entries = children_result.list.entries
67
+ logger.info(f"Browse response via high-level API: {len(entries)} entries found")
68
+ else:
69
+ raise Exception("High-level API returned unexpected format")
70
+ except Exception as high_level_error:
71
+ logger.info(f"High-level API failed, using raw client: {high_level_error}")
72
+ # Fallback to raw client (ensure initialization)
73
+ if not core_client.is_initialized:
74
+ return safe_format_output("❌ Error: Alfresco server unavailable")
75
+ # Use httpx_client property directly on AlfrescoCoreClient
76
+ core_httpx = core_client.httpx_client
77
+
78
+ logger.info(f"Browsing repository node: {safe_parent_id_display}")
79
+ logger.info(f"Max items: {actual_max_items}")
80
+ logger.info(f"Using URL: /nodes/{actual_parent_id}/children")
81
+
82
+ if ctx:
83
+ await ctx.report_progress(0.3)
84
+
85
+ # If high-level API didn't work, use HTTPx fallback
86
+ if 'entries' not in locals():
87
+ if ctx:
88
+ await ctx.report_progress(0.5)
89
+
90
+ try:
91
+ # Use HTTPx client as fallback
92
+ url = f"/nodes/{actual_parent_id}/children"
93
+ if actual_max_items != 25:
94
+ url += f"?maxItems={actual_max_items}"
95
+
96
+ response = core_httpx.get(url)
97
+
98
+ if response.status_code == 200:
99
+ result_data = response.json()
100
+ entries = result_data.get("list", {}).get("entries", [])
101
+ logger.info(f"Browse response via HTTPx fallback: {len(entries)} entries found")
102
+
103
+ else:
104
+ error_text = response.text if hasattr(response, 'text') else str(response)
105
+ raise Exception(f"Browse failed with status {response.status_code}: {error_text}")
106
+
107
+ except Exception as browse_error:
108
+ raise Exception(f"Repository browse operation failed: {str(browse_error)}")
109
+
110
+ # Check if we have entries
111
+ if not entries:
112
+ return f"Repository Browse Results\n\nNode: {safe_parent_id_display}\n\nNo child items found in this location."
113
+
114
+ if ctx:
115
+ await ctx.report_progress(1.0)
116
+
117
+ # Process final results
118
+ if entries:
119
+ logger.info(f"Found {len(entries)} repository items")
120
+
121
+ # Clean JSON-friendly formatting (no markdown syntax)
122
+ result_text = f"Repository Browse Results\n\nNode: {safe_parent_id_display}\n\n"
123
+ result_text += f"Parent Node: {safe_parent_id_display}\n"
124
+ result_text += f"Found {len(entries)} item(s):\n\n"
125
+
126
+ for i, entry_wrapper in enumerate(entries, 1):
127
+ # Handle JSON response structure correctly
128
+ if isinstance(entry_wrapper, dict) and 'entry' in entry_wrapper:
129
+ entry = entry_wrapper['entry']
130
+ else:
131
+ entry = entry_wrapper
132
+
133
+ # Extract values from dictionary
134
+ name = str(entry.get('name', 'Unknown'))
135
+ node_id = str(entry.get('id', 'Unknown'))
136
+ node_type = str(entry.get('nodeType', 'Unknown'))
137
+ is_folder = entry.get('isFolder', False)
138
+ created_at = str(entry.get('createdAt', 'Unknown'))
139
+
140
+ # Choose icon based on type
141
+ icon = "[FOLDER]" if is_folder else "[FILE]"
142
+
143
+ result_text += f"{i}. {icon} {name}\n"
144
+ result_text += f" - ID: {node_id}\n"
145
+ result_text += f" - Type: {node_type}\n"
146
+ result_text += f" - Created: {created_at}\n\n"
147
+
148
+ result_text += f"Navigation help:\n"
149
+ result_text += "• Use the node ID to browse deeper: browse_repository(parent_id=\"<node_id>\")\n"
150
+ result_text += "• Common parent IDs: -root- (repository root), -shared- (shared folder), -my- (my files)\n"
151
+
152
+ return result_text
153
+ else:
154
+ return f"Repository Browse Results\n\nNode: {safe_parent_id_display}\n\nNo child items found in this location."
155
+
156
+ except Exception as e:
157
+ # Preserve Unicode characters in error messages
158
+ error_msg = f"ERROR: Repository browse failed: {str(e)}"
159
+ if ctx:
160
+ await ctx.error(error_msg)
161
+ return error_msg
162
+
163
+ if ctx:
164
+ await ctx.info("Repository browse completed!")
@@ -0,0 +1,160 @@
1
+ """
2
+ Cancel checkout tool implementation for Alfresco MCP Server.
3
+ Handles canceling document checkout with cleanup and unlock management.
4
+ """
5
+ import logging
6
+ import pathlib
7
+ import json
8
+ from datetime import datetime
9
+ from fastmcp import Context
10
+
11
+ from ...utils.connection import get_core_client
12
+ from ...utils.json_utils import safe_format_output
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ async def cancel_checkout_impl(
18
+ node_id: str,
19
+ ctx: Context = None
20
+ ) -> str:
21
+ """Cancel checkout of a document, discarding any working copy.
22
+
23
+ Args:
24
+ node_id: Original node ID that was checked out
25
+ ctx: MCP context for progress reporting
26
+
27
+ Returns:
28
+ Cancellation confirmation and cleanup status
29
+ """
30
+ if ctx:
31
+ await ctx.info(f"Cancelling checkout for: {node_id}")
32
+ await ctx.report_progress(0.1)
33
+
34
+ if not node_id.strip():
35
+ return safe_format_output("❌ Error: node_id is required")
36
+
37
+ try:
38
+ logger.info(f"Starting cancel checkout: node {node_id}")
39
+ core_client = await get_core_client()
40
+
41
+ # Clean the node ID
42
+ clean_node_id = node_id.strip()
43
+ if clean_node_id.startswith('alfresco://'):
44
+ clean_node_id = clean_node_id.split('/')[-1]
45
+
46
+ if ctx:
47
+ await ctx.info("Checking node status...")
48
+ await ctx.report_progress(0.3)
49
+
50
+ # Get node information to validate using high-level core client
51
+ node_response = core_client.nodes.get(node_id=clean_node_id)
52
+
53
+ if not hasattr(node_response, 'entry'):
54
+ return f"ERROR: Failed to get node information for: {clean_node_id}"
55
+
56
+ node_info = node_response.entry
57
+ filename = getattr(node_info, 'name', f"document_{clean_node_id}")
58
+
59
+ if ctx:
60
+ await ctx.info(">> Performing Alfresco unlock using high-level client...")
61
+ await ctx.report_progress(0.5)
62
+
63
+ # Use high-level core client unlock method
64
+ try:
65
+ logger.info(f"Attempting to unlock document: {clean_node_id}")
66
+ unlock_response = core_client.versions.cancel_checkout(node_id=clean_node_id)
67
+ if unlock_response and hasattr(unlock_response, 'entry'):
68
+ api_status = "✅ Document unlocked in Alfresco"
69
+ else:
70
+ api_status = "✅ Document unlocked in Alfresco"
71
+ logger.info(f"Document unlocked successfully: {clean_node_id}")
72
+ except Exception as unlock_error:
73
+ error_str = str(unlock_error)
74
+ if "404" in error_str:
75
+ # Document might not be locked
76
+ api_status = "ℹ️ Document was not locked in Alfresco"
77
+ logger.info(f"Document was not locked: {clean_node_id}")
78
+ elif "405" in error_str:
79
+ # Server doesn't support lock/unlock APIs
80
+ api_status = "WARNING: Server doesn't support lock/unlock APIs (treating as unlocked)"
81
+ logger.warning(f"Server doesn't support unlock API for {clean_node_id}")
82
+ else:
83
+ api_status = f"WARNING: Alfresco unlock failed: {error_str}"
84
+ logger.error(f"Failed to unlock document {clean_node_id}: {error_str}")
85
+
86
+ if ctx:
87
+ await ctx.info("Cleaning up local files...")
88
+ await ctx.report_progress(0.7)
89
+
90
+ # Clean up local checkout tracking
91
+ downloads_dir = pathlib.Path.home() / "Downloads"
92
+ checkout_dir = downloads_dir / "checkout"
93
+ checkout_manifest_path = checkout_dir / ".checkout_manifest.json"
94
+
95
+ checkout_data = {}
96
+ cleanup_status = [api_status]
97
+
98
+ if checkout_manifest_path.exists():
99
+ try:
100
+ with open(checkout_manifest_path, 'r') as f:
101
+ checkout_data = json.load(f)
102
+ except:
103
+ checkout_data = {}
104
+
105
+ # Check if this node is tracked in local checkouts
106
+ if 'checkouts' in checkout_data and clean_node_id in checkout_data['checkouts']:
107
+ checkout_info = checkout_data['checkouts'][clean_node_id]
108
+ checkout_filename = checkout_info['local_file']
109
+ checkout_file_path = checkout_dir / checkout_filename
110
+
111
+ # Remove local checkout file
112
+ try:
113
+ if checkout_file_path.exists():
114
+ checkout_file_path.unlink()
115
+ cleanup_status.append("🗑️ Local checkout file removed")
116
+ logger.info(f"Removed local checkout file: {checkout_file_path}")
117
+ else:
118
+ cleanup_status.append("ℹ️ Local checkout file already removed")
119
+ except Exception as e:
120
+ cleanup_status.append(f"WARNING: Could not remove local file: {e}")
121
+ logger.warning(f"Failed to remove local file {checkout_file_path}: {e}")
122
+
123
+ # Remove from tracking
124
+ del checkout_data['checkouts'][clean_node_id]
125
+
126
+ # Update manifest
127
+ try:
128
+ with open(checkout_manifest_path, 'w') as f:
129
+ json.dump(checkout_data, f, indent=2)
130
+ cleanup_status.append(">> Checkout tracking updated")
131
+ except Exception as e:
132
+ cleanup_status.append(f"WARNING: Could not update tracking: {e}")
133
+ else:
134
+ cleanup_status.append("ℹ️ No local checkout tracking found")
135
+
136
+ if ctx:
137
+ await ctx.info("Document unlocked!")
138
+ await ctx.report_progress(1.0)
139
+
140
+ # Clean JSON-friendly formatting (no markdown syntax)
141
+ result = f"🔓 Document Unlocked\n\n"
142
+ result += f">> Document: {filename}\n"
143
+ result += f"ID: Node ID: {clean_node_id}\n"
144
+ result += f"🕒 Unlocked: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
145
+ result += f"🧹 Cleanup Status:\n"
146
+
147
+ for status in cleanup_status:
148
+ result += f" {status}\n"
149
+
150
+ result += f"\nINFO: Note: Document is now available for others to edit."
151
+ result += f"\nWARNING: Important: Any unsaved changes in the local file have been discarded."
152
+
153
+ return safe_format_output(result)
154
+
155
+ except Exception as e:
156
+ error_msg = f"❌ Cancel checkout failed: {str(e)}"
157
+ if ctx:
158
+ await ctx.error(error_msg)
159
+ logger.error(f"Cancel checkout failed: {e}")
160
+ return safe_format_output(error_msg)
@@ -0,0 +1,264 @@
1
+ """
2
+ Checkin document tool implementation for Alfresco MCP Server.
3
+ Handles document checkin with versioning and cleanup management.
4
+ """
5
+ import logging
6
+ import os
7
+ import pathlib
8
+ import json
9
+ import urllib.parse
10
+ from io import BytesIO
11
+ from datetime import datetime
12
+ from fastmcp import Context
13
+
14
+ from ...utils.connection import get_core_client
15
+ from ...config import config
16
+ from ...utils.json_utils import safe_format_output
17
+ from python_alfresco_api.raw_clients.alfresco_core_client.core_client.types import File
18
+ from python_alfresco_api.raw_clients.alfresco_core_client.core_client.api.nodes.update_node_content import sync as update_node_content_sync
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ async def checkin_document_impl(
24
+ node_id: str,
25
+ comment: str = "",
26
+ major_version: bool = False,
27
+ file_path: str = "",
28
+ new_name: str = "",
29
+ ctx: Context = None
30
+ ) -> str:
31
+ """Check in a document after editing using Alfresco REST API.
32
+
33
+ Args:
34
+ node_id: Original node ID to check in (not working copy)
35
+ comment: Check-in comment (default: empty)
36
+ major_version: Whether to create a major version (default: False = minor version)
37
+ file_path: Specific file path to upload (if empty, auto-detects from checkout folder)
38
+ new_name: Optional new name for the file during checkin (default: keep original name)
39
+ ctx: MCP context for progress reporting
40
+
41
+ Returns:
42
+ Check-in confirmation with version details and cleanup status
43
+ """
44
+ if ctx:
45
+ await ctx.info(f"Checking in document: {node_id}")
46
+ await ctx.info("Validating parameters...")
47
+ await ctx.report_progress(0.1)
48
+
49
+ if not node_id.strip():
50
+ return safe_format_output("❌ Error: node_id is required")
51
+
52
+ try:
53
+ logger.info(f"Starting checkin: node {node_id}")
54
+ core_client = await get_core_client()
55
+
56
+ # Clean the node ID
57
+ clean_node_id = node_id.strip()
58
+ if clean_node_id.startswith('alfresco://'):
59
+ clean_node_id = clean_node_id.split('/')[-1]
60
+
61
+ if ctx:
62
+ await ctx.info("Finding checkout file...")
63
+ await ctx.report_progress(0.2)
64
+
65
+ # Find the file to upload
66
+ checkout_file_path = None
67
+ checkout_data = {}
68
+ working_copy_id = None
69
+
70
+ if file_path:
71
+ # Use specific file path provided - handle quotes and path expansion
72
+ cleaned_file_path = file_path.strip().strip('"').strip("'")
73
+
74
+ # Handle macOS/Unix path expansion (~/Documents, etc.)
75
+ if cleaned_file_path.startswith('~'):
76
+ cleaned_file_path = os.path.expanduser(cleaned_file_path)
77
+
78
+ checkout_file_path = pathlib.Path(cleaned_file_path)
79
+ if not checkout_file_path.exists():
80
+ return safe_format_output(f"❌ Specified file not found: {cleaned_file_path} (cleaned from: {file_path})")
81
+
82
+ # Linux-specific: Check file permissions
83
+ if not os.access(checkout_file_path, os.R_OK):
84
+ return safe_format_output(f"❌ File not readable (permission denied): {cleaned_file_path}")
85
+ else:
86
+ # Auto-detect from checkout folder
87
+ downloads_dir = pathlib.Path.home() / "Downloads"
88
+ checkout_dir = downloads_dir / "checkout"
89
+ checkout_manifest_path = checkout_dir / ".checkout_manifest.json"
90
+
91
+ if checkout_manifest_path.exists():
92
+ try:
93
+ with open(checkout_manifest_path, 'r') as f:
94
+ checkout_data = json.load(f)
95
+ except:
96
+ checkout_data = {}
97
+
98
+ if 'checkouts' in checkout_data and clean_node_id in checkout_data['checkouts']:
99
+ checkout_info = checkout_data['checkouts'][clean_node_id]
100
+ checkout_filename = checkout_info['local_file']
101
+ locked_node_id = checkout_info.get('locked_node_id', clean_node_id) # Updated for lock API
102
+ checkout_file_path = checkout_dir / checkout_filename
103
+
104
+ if not checkout_file_path.exists():
105
+ return safe_format_output(f"❌ Checkout file not found: {checkout_file_path}. File may have been moved or deleted.")
106
+ else:
107
+ return safe_format_output(f"❌ No locked document found for node {clean_node_id}. Use checkout_document first, or specify file_path manually.")
108
+
109
+ if ctx:
110
+ await ctx.info(f"Uploading file: {checkout_file_path.name}")
111
+ await ctx.report_progress(0.4)
112
+
113
+ # Read the file content
114
+ with open(checkout_file_path, 'rb') as f:
115
+ file_content = f.read()
116
+
117
+ logger.info(f"Checkin file: {checkout_file_path.name} ({len(file_content)} bytes)")
118
+ # Get original node info using high-level core client
119
+ node_response = core_client.nodes.get(node_id=clean_node_id)
120
+ if not hasattr(node_response, 'entry'):
121
+ return safe_format_output(f"❌ Failed to get node information for: {clean_node_id}")
122
+
123
+ node_info = node_response.entry
124
+ original_filename = getattr(node_info, 'name', f"document_{clean_node_id}")
125
+
126
+ if ctx:
127
+ await ctx.info("Uploading new content with versioning using high-level API...")
128
+ await ctx.report_progress(0.7)
129
+
130
+ # **USE HIGH-LEVEL API: update_node_content.sync()**
131
+ # Use new name if provided, otherwise keep original filename
132
+ final_filename = new_name.strip() if new_name.strip() else original_filename
133
+
134
+ # Create File object with content
135
+ file_obj = File(
136
+ payload=BytesIO(file_content),
137
+ file_name=final_filename,
138
+ mime_type="application/octet-stream"
139
+ )
140
+
141
+ # Use high-level update_node_content API instead of manual httpx
142
+ try:
143
+ version_type = "major" if major_version else "minor"
144
+ logger.info(f"Updating content for {clean_node_id} ({version_type} version)")
145
+ # Ensure raw client is initialized before using it
146
+ if not core_client.is_initialized:
147
+ return safe_format_output("❌ Error: Alfresco server unavailable")
148
+ # Use high-level update_node_content API
149
+ content_response = update_node_content_sync(
150
+ node_id=clean_node_id,
151
+ client=core_client.raw_client,
152
+ body=file_obj,
153
+ major_version=major_version,
154
+ comment=comment if comment else None,
155
+ name=new_name.strip() if new_name.strip() else None
156
+ )
157
+
158
+ if not content_response:
159
+ return safe_format_output(f"❌ Failed to update document content using high-level API")
160
+
161
+ logger.info(f"Content updated successfully for {clean_node_id}")
162
+
163
+ # CRITICAL: Unlock the document after successful content update to complete checkin
164
+ try:
165
+ logger.info(f"Unlocking document after successful checkin: {clean_node_id}")
166
+ unlock_response = core_client.versions.cancel_checkout(node_id=clean_node_id)
167
+ logger.info(f"Document unlocked successfully after checkin: {clean_node_id}")
168
+ except Exception as unlock_error:
169
+ error_str = str(unlock_error)
170
+ if "404" in error_str:
171
+ logger.info(f"Document was not locked (already unlocked): {clean_node_id}")
172
+ elif "405" in error_str:
173
+ logger.warning(f"Server doesn't support unlock APIs: {clean_node_id}")
174
+ else:
175
+ logger.error(f"Failed to unlock document after checkin: {clean_node_id} - {error_str}")
176
+ # Don't fail the entire checkin if unlock fails - content was updated successfully
177
+
178
+ except Exception as api_error:
179
+ return safe_format_output(f"❌ Failed to update document content: {str(api_error)}")
180
+
181
+ # Get updated node info to show version details using high-level core client
182
+ updated_node_response = core_client.nodes.get(node_id=clean_node_id)
183
+ updated_node = updated_node_response.entry if hasattr(updated_node_response, 'entry') else {}
184
+
185
+ # Extract version using multiple access methods (same as get_node_properties)
186
+ new_version = 'Unknown'
187
+ if hasattr(updated_node, 'properties') and updated_node.properties:
188
+ try:
189
+ # Try to_dict() method first
190
+ if hasattr(updated_node.properties, 'to_dict'):
191
+ props_dict = updated_node.properties.to_dict()
192
+ new_version = props_dict.get('cm:versionLabel', 'Unknown')
193
+ logger.info(f"Version found via to_dict(): {new_version}")
194
+ # Try direct attribute access
195
+ elif hasattr(updated_node.properties, 'cm_version_label') or hasattr(updated_node.properties, 'cm:versionLabel'):
196
+ new_version = getattr(updated_node.properties, 'cm_version_label', getattr(updated_node.properties, 'cm:versionLabel', 'Unknown'))
197
+ logger.info(f"Version found via attributes: {new_version}")
198
+ # Try dict-like access
199
+ elif hasattr(updated_node.properties, '__getitem__'):
200
+ new_version = updated_node.properties.get('cm:versionLabel', 'Unknown') if hasattr(updated_node.properties, 'get') else updated_node.properties['cm:versionLabel'] if 'cm:versionLabel' in updated_node.properties else 'Unknown'
201
+ logger.info(f"Version found via dict access: {new_version}")
202
+ else:
203
+ logger.warning(f"Version properties - type: {type(updated_node.properties)}, methods: {dir(updated_node.properties)}")
204
+ except Exception as version_error:
205
+ logger.error(f"Error extracting version: {version_error}")
206
+ new_version = 'Unknown'
207
+ else:
208
+ logger.warning("No properties found for version extraction")
209
+
210
+ if ctx:
211
+ await ctx.info("Cleaning up checkout tracking...")
212
+ await ctx.report_progress(0.9)
213
+
214
+ # Clean up checkout tracking
215
+ cleanup_status = "ℹ️ No checkout tracking to clean up"
216
+ if checkout_data and 'checkouts' in checkout_data and clean_node_id in checkout_data['checkouts']:
217
+ del checkout_data['checkouts'][clean_node_id]
218
+
219
+ checkout_manifest_path = pathlib.Path.home() / "Downloads" / "checkout" / ".checkout_manifest.json"
220
+ with open(checkout_manifest_path, 'w') as f:
221
+ json.dump(checkout_data, f, indent=2)
222
+
223
+ # Optionally remove the checkout file
224
+ try:
225
+ checkout_file_path.unlink()
226
+ cleanup_status = "🗑️ Local checkout file cleaned up"
227
+ except:
228
+ cleanup_status = "WARNING: Local checkout file cleanup failed"
229
+
230
+ if ctx:
231
+ await ctx.info("Checkin completed: Content updated + Document unlocked + Version created!")
232
+ await ctx.report_progress(1.0)
233
+
234
+ # Format file size
235
+ file_size = len(file_content)
236
+ if file_size < 1024:
237
+ size_str = f"{file_size} bytes"
238
+ elif file_size < 1024 * 1024:
239
+ size_str = f"{file_size / 1024:.1f} KB"
240
+ else:
241
+ size_str = f"{file_size / (1024 * 1024):.1f} MB"
242
+
243
+ # Clean JSON-friendly formatting (no markdown syntax)
244
+ return safe_format_output(f"""✅ Document checked in successfully!
245
+
246
+ 📄 Document: {final_filename}
247
+ 🔢 New Version: {new_version} ({version_type})
248
+ 📝 Comment: {comment if comment else '(no comment)'}
249
+ 📊 File Size: {size_str}
250
+ 🔗 Node ID: {clean_node_id}
251
+ {f"📝 Renamed: {original_filename} → {final_filename}" if new_name.strip() else ""}
252
+
253
+ {cleanup_status}
254
+
255
+ Next Steps:
256
+ 🔓 Document is now UNLOCKED and available for others to edit
257
+ ✅ New version has been created with your changes
258
+ ✅ You can continue editing by using checkout_document again
259
+
260
+ Status: Content updated → Document unlocked → Checkin complete!""")
261
+
262
+ except Exception as e:
263
+ logger.error(f"Checkin failed: {str(e)}")
264
+ return safe_format_output(f"❌ Checkin failed: {str(e)}")