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,204 @@
1
+ """
2
+ Advanced search tool for Alfresco MCP Server.
3
+ Each tool is self-contained with its own validation, business logic, and env handling.
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 advanced_search_impl(
16
+ query: str,
17
+ sort_field: str = "cm:modified",
18
+ sort_ascending: bool = False,
19
+ max_results: int = 25,
20
+ ctx: Optional[Context] = None
21
+ ) -> str:
22
+ """Advanced search with sorting and filtering capabilities.
23
+
24
+ Args:
25
+ query: Search query string (supports Alfresco Full Text Search syntax)
26
+ sort_field: Field to sort by (default: cm:modified)
27
+ sort_ascending: Sort order (default: False for descending)
28
+ max_results: Maximum number of results to return (default: 25)
29
+ ctx: MCP context for progress reporting
30
+
31
+ Returns:
32
+ Formatted search results with metadata, sorted as requested
33
+ """
34
+ # Parameter validation and extraction
35
+ try:
36
+ # Extract parameters with fallback handling
37
+ if hasattr(query, 'value'):
38
+ actual_query = str(query.value)
39
+ else:
40
+ actual_query = str(query)
41
+
42
+ if hasattr(sort_field, 'value'):
43
+ actual_sort_field = str(sort_field.value)
44
+ else:
45
+ actual_sort_field = str(sort_field)
46
+
47
+ if hasattr(sort_ascending, 'value'):
48
+ actual_sort_ascending = bool(sort_ascending.value)
49
+ else:
50
+ actual_sort_ascending = bool(sort_ascending)
51
+
52
+ if hasattr(max_results, 'value'):
53
+ actual_max_results = int(max_results.value)
54
+ else:
55
+ actual_max_results = int(max_results)
56
+
57
+ # Clean and normalize for display (preserve Unicode characters)
58
+ safe_query_display = str(actual_query)
59
+ safe_sort_field_display = str(actual_sort_field)
60
+
61
+ except Exception as e:
62
+ logger.error(f"Parameter extraction error: {e}")
63
+ return f"ERROR: Parameter error: {str(e)}"
64
+
65
+ if ctx:
66
+ await ctx.info(safe_format_output(f"Advanced search for '{safe_query_display}' with sorting..."))
67
+ await ctx.report_progress(0.0)
68
+
69
+ try:
70
+ # Get all clients that ensure_connection() already created
71
+ master_client = await ensure_connection()
72
+
73
+ # Import search_utils
74
+ from python_alfresco_api.utils import search_utils
75
+
76
+ # Access the search client that was already created
77
+ search_client = master_client.search
78
+
79
+ logger.debug(f"Advanced search for: '{safe_query_display}', sort: {safe_sort_field_display} ({'asc' if actual_sort_ascending else 'desc'})")
80
+
81
+ if ctx:
82
+ await ctx.report_progress(0.3)
83
+
84
+ # Use search_utils.advanced_search() utility with existing search_client
85
+ if ctx:
86
+ await ctx.report_progress(0.5)
87
+
88
+ try:
89
+ # Use search_utils.advanced_search() with existing search_client that has working authentication
90
+ search_results = search_utils.advanced_search(
91
+ search_client,
92
+ actual_query,
93
+ max_items=actual_max_results,
94
+ sort_by=actual_sort_field,
95
+ sort_ascending=actual_sort_ascending
96
+ )
97
+
98
+ if not search_results:
99
+ logger.debug("Advanced search returned None, attempting fallback to simple search")
100
+ search_results = search_utils.simple_search(search_client, actual_query, max_items=actual_max_results)
101
+
102
+ # Check for different possible SearchResult structures
103
+ if not search_results:
104
+ logger.error(f"No search results returned")
105
+ return safe_format_output(f"ERROR: Advanced search failed - no results returned")
106
+
107
+ # Try to get entries from different possible structures
108
+ entries = []
109
+
110
+ if hasattr(search_results, 'list') and search_results.list and hasattr(search_results.list, 'entries'):
111
+ entries = search_results.list.entries if search_results.list else []
112
+ logger.debug(f"Found entries using list attribute: {len(entries)}")
113
+ elif hasattr(search_results, 'list_') and search_results.list_ and hasattr(search_results.list_, 'entries'):
114
+ entries = search_results.list_.entries if search_results.list_ else []
115
+ logger.debug(f"Found entries using list_ attribute: {len(entries)}")
116
+ elif hasattr(search_results, 'entries'):
117
+ entries = search_results.entries
118
+ logger.debug(f"Found entries using direct entries attribute: {len(entries)}")
119
+ elif hasattr(search_results, 'results'):
120
+ entries = search_results.results
121
+ logger.debug(f"Found entries using results attribute: {len(entries)}")
122
+ else:
123
+ logger.error(f"SearchResult structure not recognized")
124
+ return safe_format_output(f"ERROR: Advanced search failed - unknown SearchResult structure")
125
+
126
+ except Exception as e:
127
+ logger.error(f"Advanced search failed: {e}")
128
+ # Try fallback to simple search
129
+ try:
130
+ logger.debug("Attempting fallback to simple search after advanced search error")
131
+ search_results = search_utils.simple_search(search_client, actual_query, max_items=actual_max_results)
132
+ if not search_results:
133
+ return safe_format_output(f"ERROR: Both advanced and simple search failed: {str(e)}")
134
+ # Extract entries from simple search result
135
+ entries = []
136
+ if hasattr(search_results, 'list_') and search_results.list_ and hasattr(search_results.list_, 'entries'):
137
+ entries = search_results.list_.entries if search_results.list_ else []
138
+ logger.debug(f"Fallback simple search found {len(entries)} results")
139
+ else:
140
+ return safe_format_output(f"ERROR: Both advanced and simple search failed: {str(e)}")
141
+ except Exception as fallback_error:
142
+ logger.error(f"Fallback simple search also failed: {fallback_error}")
143
+ return safe_format_output(f"ERROR: Advanced search failed: {str(e)}")
144
+
145
+ if ctx:
146
+ await ctx.report_progress(1.0)
147
+
148
+ # Process final results
149
+ if entries:
150
+ logger.info(f"Found {len(entries)} search results")
151
+ result_text = f"Found {len(entries)} item(s) matching '{safe_query_display}':\n\n"
152
+
153
+ for i, entry in enumerate(entries, 1):
154
+ # Handle different possible entry structures
155
+ node = None
156
+ if isinstance(entry, dict):
157
+ if 'entry' in entry:
158
+ node = entry['entry']
159
+ elif 'name' in entry: # Direct node structure
160
+ node = entry
161
+ else:
162
+ logger.debug(f"Unknown entry structure: {entry}")
163
+ continue
164
+ elif hasattr(entry, 'entry'): # ResultSetRowEntry object
165
+ node = entry.entry
166
+ else:
167
+ logger.debug(f"Entry is not a dict or ResultSetRowEntry: {type(entry)}")
168
+ continue
169
+
170
+ if node:
171
+ # Handle both dict and ResultNode objects
172
+ if isinstance(node, dict):
173
+ name = str(node.get('name', 'Unknown'))
174
+ node_id = str(node.get('id', 'Unknown'))
175
+ node_type_actual = str(node.get('nodeType', 'Unknown'))
176
+ created_at = str(node.get('createdAt', 'Unknown'))
177
+ else:
178
+ # ResultNode object - access attributes directly
179
+ name = str(getattr(node, 'name', 'Unknown'))
180
+ node_id = str(getattr(node, 'id', 'Unknown'))
181
+ node_type_actual = str(getattr(node, 'node_type', 'Unknown'))
182
+ created_at = str(getattr(node, 'created_at', 'Unknown'))
183
+
184
+ # Apply safe formatting to individual fields to prevent emoji encoding issues
185
+ safe_name = safe_format_output(name)
186
+ safe_node_id = safe_format_output(node_id)
187
+ safe_node_type = safe_format_output(node_type_actual)
188
+ safe_created_at = safe_format_output(created_at)
189
+
190
+ result_text += f"{i}. {safe_name}\n"
191
+ result_text += f" - ID: {safe_node_id}\n"
192
+ result_text += f" - Type: {safe_node_type}\n"
193
+ result_text += f" - Created: {safe_created_at}\n\n"
194
+
195
+ return safe_format_output(result_text)
196
+ else:
197
+ # Simple "0" for zero results as requested
198
+ return "0"
199
+
200
+ except Exception as e:
201
+ error_msg = f"ERROR: Advanced search failed: {str(e)}"
202
+ if ctx:
203
+ await ctx.error(safe_format_output(error_msg))
204
+ return safe_format_output(error_msg)
@@ -0,0 +1,180 @@
1
+ """
2
+ CMIS search tool for Alfresco MCP Server.
3
+ Each tool is self-contained with its own validation, business logic, and env handling.
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 cmis_search_impl(
16
+ cmis_query: str,
17
+ max_results: int = 25,
18
+ ctx: Optional[Context] = None
19
+ ) -> str:
20
+ """Search using CMIS SQL syntax.
21
+
22
+ Args:
23
+ cmis_query: CMIS SQL query string
24
+ max_results: Maximum number of results to return (default: 25)
25
+ ctx: MCP context for progress reporting
26
+
27
+ Returns:
28
+ Formatted search results from CMIS query
29
+ """
30
+ # Parameter validation and extraction
31
+ try:
32
+ # Extract parameters with fallback handling
33
+ if hasattr(cmis_query, 'value'):
34
+ actual_query = str(cmis_query.value)
35
+ else:
36
+ actual_query = str(cmis_query)
37
+
38
+ if hasattr(max_results, 'value'):
39
+ actual_max_results = int(max_results.value)
40
+ else:
41
+ actual_max_results = int(max_results)
42
+
43
+ # Clean and normalize for display (preserve Unicode characters)
44
+ safe_query_display = str(actual_query)
45
+
46
+ except Exception as e:
47
+ logger.error(f"Parameter extraction error: {e}")
48
+ return f"ERROR: Parameter error: {str(e)}"
49
+
50
+ if not actual_query.strip():
51
+ return """CMIS Search Tool
52
+
53
+ Usage: Provide a CMIS SQL query to search Alfresco repository.
54
+
55
+ Example CMIS queries:
56
+ - SELECT * FROM cmis:document WHERE cmis:name LIKE 'test%'
57
+ - SELECT * FROM cmis:folder WHERE CONTAINS('project')
58
+ - SELECT * FROM cmis:document WHERE cmis:creationDate > '2024-01-01T00:00:00.000Z'
59
+ - SELECT * FROM cmis:document WHERE cmis:contentStreamMimeType = 'application/pdf'
60
+
61
+ CMIS provides precise SQL queries for exact matching and filtering.
62
+ """
63
+
64
+ if ctx:
65
+ await ctx.info(safe_format_output(f"CMIS search for: '{safe_query_display}'"))
66
+ await ctx.report_progress(0.0)
67
+
68
+ try:
69
+ # Get all clients that ensure_connection() already created
70
+ master_client = await ensure_connection()
71
+
72
+ # Access the search client that was already created (same as other search tools)
73
+ search_client = master_client.search
74
+
75
+ logger.info(f"CMIS search for: '{safe_query_display}'")
76
+
77
+ if ctx:
78
+ await ctx.report_progress(0.3)
79
+
80
+ # Use same pattern as other search tools but with CMIS language
81
+ try:
82
+ # Import the SearchRequest model for CMIS queries
83
+ from python_alfresco_api.raw_clients.alfresco_search_client.search_client.models import SearchRequest, RequestQuery, RequestPagination, RequestQueryLanguage
84
+ from python_alfresco_api.raw_clients.alfresco_search_client.search_client.types import UNSET
85
+
86
+ # Create CMIS search request (same pattern as search_utils.simple_search but with CMIS language)
87
+ request_query = RequestQuery(
88
+ query=actual_query,
89
+ language=RequestQueryLanguage.CMIS # Use CMIS instead of AFTS
90
+ )
91
+
92
+ request_pagination = RequestPagination(
93
+ max_items=actual_max_results,
94
+ skip_count=0
95
+ )
96
+
97
+ search_request = SearchRequest(
98
+ query=request_query,
99
+ paging=request_pagination,
100
+ include=UNSET
101
+ )
102
+
103
+ # Use same pattern as search_utils.simple_search
104
+ search_results = search_client.search.search(search_request)
105
+
106
+ if search_results and hasattr(search_results, 'list_'):
107
+ entries_list = search_results.list_.entries if search_results.list_ else []
108
+ logger.info(f"Found {len(entries_list)} CMIS search results")
109
+
110
+ if ctx:
111
+ await ctx.report_progress(1.0)
112
+
113
+ if not entries_list:
114
+ return "0"
115
+
116
+ result_text = f"Found {len(entries_list)} item(s) matching the CMIS query:\n\n"
117
+
118
+ for i, entry in enumerate(entries_list, 1):
119
+ # Debug: Log the entry structure
120
+ logger.debug(f"Entry {i} type: {type(entry)}, content: {entry}")
121
+
122
+ # Handle different possible entry structures
123
+ node = None
124
+ if isinstance(entry, dict):
125
+ if 'entry' in entry:
126
+ node = entry['entry']
127
+ elif 'name' in entry: # Direct node structure
128
+ node = entry
129
+ else:
130
+ logger.warning(f"Unknown entry structure: {entry}")
131
+ continue
132
+ elif hasattr(entry, 'entry'): # ResultSetRowEntry object
133
+ node = entry.entry
134
+ else:
135
+ logger.warning(f"Entry is not a dict or ResultSetRowEntry: {type(entry)}")
136
+ continue
137
+
138
+ if node:
139
+ # Handle both dict and ResultNode objects
140
+ if isinstance(node, dict):
141
+ name = str(node.get('name', 'Unknown'))
142
+ node_id = str(node.get('id', 'Unknown'))
143
+ node_type = str(node.get('nodeType', 'Unknown'))
144
+ created_at = str(node.get('createdAt', 'Unknown'))
145
+ else:
146
+ # ResultNode object - access attributes directly
147
+ name = str(getattr(node, 'name', 'Unknown'))
148
+ node_id = str(getattr(node, 'id', 'Unknown'))
149
+ node_type = str(getattr(node, 'node_type', 'Unknown'))
150
+ created_at = str(getattr(node, 'created_at', 'Unknown'))
151
+
152
+ # Clean JSON-friendly formatting (no markdown syntax)
153
+ # Apply safe formatting to individual fields to prevent emoji encoding issues
154
+ safe_name = safe_format_output(name)
155
+ safe_node_id = safe_format_output(node_id)
156
+ safe_node_type = safe_format_output(node_type)
157
+ safe_created_at = safe_format_output(created_at)
158
+
159
+ result_text += f"{i}. {safe_name}\n"
160
+ result_text += f" - ID: {safe_node_id}\n"
161
+ result_text += f" - Type: {safe_node_type}\n"
162
+ result_text += f" - Created: {safe_created_at}\n\n"
163
+
164
+ return safe_format_output(result_text)
165
+ else:
166
+ return safe_format_output(f"ERROR: CMIS search failed - invalid response from Alfresco")
167
+
168
+ except Exception as e:
169
+ logger.error(f"CMIS search failed: {e}")
170
+ return safe_format_output(f"ERROR: CMIS search failed: {str(e)}")
171
+
172
+ except Exception as e:
173
+ # Preserve Unicode characters in error messages
174
+ error_msg = f"ERROR: CMIS search failed: {str(e)}"
175
+ if ctx:
176
+ await ctx.error(safe_format_output(error_msg))
177
+ return safe_format_output(error_msg)
178
+
179
+ if ctx:
180
+ await ctx.info(safe_format_output("CMIS search completed!"))
@@ -0,0 +1,183 @@
1
+ """
2
+ Search by metadata tool for Alfresco MCP Server.
3
+ Each tool is self-contained with its own validation, business logic, and env handling.
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 search_by_metadata_impl(
16
+ term: str = "",
17
+ creator: str = "",
18
+ content_type: str = "",
19
+ max_results: int = 25,
20
+ ctx: Optional[Context] = None
21
+ ) -> str:
22
+ """Search for content in Alfresco by metadata fields.
23
+
24
+ Args:
25
+ term: Search term to include (primary search parameter)
26
+ creator: Creator username to filter by
27
+ content_type: Content type to filter by (e.g., "cm:content", "cm:folder")
28
+ max_results: Maximum number of results to return (default: 25)
29
+ ctx: MCP context for progress reporting
30
+
31
+ Returns:
32
+ Formatted search results with metadata
33
+ """
34
+ # Parameter validation and extraction
35
+ try:
36
+ # Extract parameters with fallback handling
37
+ if hasattr(creator, 'value'):
38
+ actual_creator = str(creator.value)
39
+ else:
40
+ actual_creator = str(creator)
41
+
42
+ if hasattr(content_type, 'value'):
43
+ actual_content_type = str(content_type.value)
44
+ else:
45
+ actual_content_type = str(content_type)
46
+
47
+ if hasattr(term, 'value'):
48
+ actual_term = str(term.value)
49
+ else:
50
+ actual_term = str(term)
51
+
52
+ if hasattr(max_results, 'value'):
53
+ actual_max_results = int(max_results.value)
54
+ else:
55
+ actual_max_results = int(max_results)
56
+
57
+ # Clean and normalize for display (preserve Unicode characters)
58
+ safe_creator_display = str(actual_creator)
59
+ safe_content_type_display = str(actual_content_type)
60
+ safe_term_display = str(actual_term)
61
+
62
+ except Exception as e:
63
+ logger.error(f"Parameter extraction error: {e}")
64
+ return f"ERROR: Parameter error: {str(e)}"
65
+
66
+ if ctx:
67
+ await ctx.info(safe_format_output(f"Searching by metadata in Alfresco..."))
68
+ await ctx.report_progress(0.0)
69
+
70
+ try:
71
+ # Get all clients that ensure_connection() already created
72
+ master_client = await ensure_connection()
73
+
74
+ # Import search_utils
75
+ from python_alfresco_api.utils import search_utils
76
+
77
+ # Access the search client that was already created
78
+ search_client = master_client.search
79
+
80
+ logger.info(f"Searching Alfresco by metadata - creator: '{safe_creator_display}', type: '{safe_content_type_display}', term: '{safe_term_display}'")
81
+
82
+ if ctx:
83
+ await ctx.report_progress(0.3)
84
+
85
+ # Build query using search_utils.build_query() utility
86
+ search_query = search_utils.build_query(
87
+ term=actual_term if actual_term.strip() else None,
88
+ content_type=actual_content_type if actual_content_type.strip() else None,
89
+ creator=actual_creator if actual_creator.strip() else None
90
+ )
91
+
92
+ # If no parameters provided, search everything
93
+ if not search_query or search_query.strip() == "":
94
+ search_query = "*"
95
+
96
+ # Execute search using existing search client
97
+ if ctx:
98
+ await ctx.report_progress(0.5)
99
+
100
+ try:
101
+ # Use correct working pattern: search_utils.simple_search with existing search_client
102
+ search_results = search_utils.simple_search(search_client, search_query, max_items=actual_max_results)
103
+
104
+ if not search_results or not hasattr(search_results, 'list_'):
105
+ return safe_format_output(f"ERROR: Search failed - invalid response from Alfresco")
106
+
107
+ except Exception as e:
108
+ logger.error(f"Search failed: {e}")
109
+ return safe_format_output(f"ERROR: Search failed: {str(e)}")
110
+
111
+ # Process results using correct pattern
112
+ entries = []
113
+ if hasattr(search_results, 'list_') and search_results.list_ and hasattr(search_results.list_, 'entries'):
114
+ entries = search_results.list_.entries if search_results.list_ else []
115
+
116
+ if ctx:
117
+ await ctx.report_progress(1.0)
118
+
119
+ # Process final results
120
+ if entries:
121
+ logger.info(f"Found {len(entries)} search results")
122
+ result_text = f"Found {len(entries)} item(s) matching the metadata criteria:\n\n"
123
+
124
+ for i, entry in enumerate(entries, 1):
125
+ # Debug: Log the entry structure
126
+ logger.debug(f"Entry {i} type: {type(entry)}, content: {entry}")
127
+
128
+ # Handle different possible entry structures
129
+ node = None
130
+ if isinstance(entry, dict):
131
+ if 'entry' in entry:
132
+ node = entry['entry']
133
+ elif 'name' in entry: # Direct node structure
134
+ node = entry
135
+ else:
136
+ logger.warning(f"Unknown entry structure: {entry}")
137
+ continue
138
+ elif hasattr(entry, 'entry'): # ResultSetRowEntry object
139
+ node = entry.entry
140
+ else:
141
+ logger.warning(f"Entry is not a dict or ResultSetRowEntry: {type(entry)}")
142
+ continue
143
+
144
+ if node:
145
+ # Handle both dict and ResultNode objects
146
+ if isinstance(node, dict):
147
+ name = str(node.get('name', 'Unknown'))
148
+ node_id = str(node.get('id', 'Unknown'))
149
+ node_type_actual = str(node.get('nodeType', 'Unknown'))
150
+ created_at = str(node.get('createdAt', 'Unknown'))
151
+ else:
152
+ # ResultNode object - access attributes directly
153
+ name = str(getattr(node, 'name', 'Unknown'))
154
+ node_id = str(getattr(node, 'id', 'Unknown'))
155
+ node_type_actual = str(getattr(node, 'node_type', 'Unknown'))
156
+ created_at = str(getattr(node, 'created_at', 'Unknown'))
157
+
158
+ # Clean JSON-friendly formatting (no markdown syntax)
159
+ # Apply safe formatting to individual fields to prevent emoji encoding issues
160
+ safe_name = safe_format_output(name)
161
+ safe_node_id = safe_format_output(node_id)
162
+ safe_node_type = safe_format_output(node_type_actual)
163
+ safe_created_at = safe_format_output(created_at)
164
+
165
+ result_text += f"{i}. {safe_name}\n"
166
+ result_text += f" - ID: {safe_node_id}\n"
167
+ result_text += f" - Type: {safe_node_type}\n"
168
+ result_text += f" - Created: {safe_created_at}\n\n"
169
+
170
+ return safe_format_output(result_text)
171
+ else:
172
+ # Simple "0" for zero results as requested
173
+ return "0"
174
+
175
+ except Exception as e:
176
+ # Preserve Unicode characters in error messages
177
+ error_msg = f"ERROR: Metadata search failed: {str(e)}"
178
+ if ctx:
179
+ await ctx.error(safe_format_output(error_msg))
180
+ return safe_format_output(error_msg)
181
+
182
+ if ctx:
183
+ await ctx.info(safe_format_output("Metadata search completed!"))