d365fo-client 0.2.4__py3-none-any.whl → 0.3.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.
- d365fo_client/__init__.py +7 -1
- d365fo_client/auth.py +9 -21
- d365fo_client/cli.py +25 -13
- d365fo_client/client.py +8 -4
- d365fo_client/config.py +52 -30
- d365fo_client/credential_sources.py +5 -0
- d365fo_client/main.py +1 -1
- d365fo_client/mcp/__init__.py +3 -1
- d365fo_client/mcp/auth_server/__init__.py +5 -0
- d365fo_client/mcp/auth_server/auth/__init__.py +30 -0
- d365fo_client/mcp/auth_server/auth/auth.py +372 -0
- d365fo_client/mcp/auth_server/auth/oauth_proxy.py +989 -0
- d365fo_client/mcp/auth_server/auth/providers/__init__.py +0 -0
- d365fo_client/mcp/auth_server/auth/providers/azure.py +325 -0
- d365fo_client/mcp/auth_server/auth/providers/bearer.py +25 -0
- d365fo_client/mcp/auth_server/auth/providers/jwt.py +547 -0
- d365fo_client/mcp/auth_server/auth/redirect_validation.py +65 -0
- d365fo_client/mcp/auth_server/dependencies.py +136 -0
- d365fo_client/mcp/client_manager.py +16 -67
- d365fo_client/mcp/fastmcp_main.py +358 -0
- d365fo_client/mcp/fastmcp_server.py +598 -0
- d365fo_client/mcp/fastmcp_utils.py +431 -0
- d365fo_client/mcp/main.py +40 -13
- d365fo_client/mcp/mixins/__init__.py +24 -0
- d365fo_client/mcp/mixins/base_tools_mixin.py +55 -0
- d365fo_client/mcp/mixins/connection_tools_mixin.py +50 -0
- d365fo_client/mcp/mixins/crud_tools_mixin.py +311 -0
- d365fo_client/mcp/mixins/database_tools_mixin.py +685 -0
- d365fo_client/mcp/mixins/label_tools_mixin.py +87 -0
- d365fo_client/mcp/mixins/metadata_tools_mixin.py +565 -0
- d365fo_client/mcp/mixins/performance_tools_mixin.py +109 -0
- d365fo_client/mcp/mixins/profile_tools_mixin.py +713 -0
- d365fo_client/mcp/mixins/sync_tools_mixin.py +321 -0
- d365fo_client/mcp/prompts/action_execution.py +1 -1
- d365fo_client/mcp/prompts/sequence_analysis.py +1 -1
- d365fo_client/mcp/tools/crud_tools.py +3 -3
- d365fo_client/mcp/tools/sync_tools.py +1 -1
- d365fo_client/mcp/utilities/__init__.py +1 -0
- d365fo_client/mcp/utilities/auth.py +34 -0
- d365fo_client/mcp/utilities/logging.py +58 -0
- d365fo_client/mcp/utilities/types.py +426 -0
- d365fo_client/metadata_v2/sync_manager_v2.py +2 -0
- d365fo_client/metadata_v2/sync_session_manager.py +7 -7
- d365fo_client/models.py +139 -139
- d365fo_client/output.py +2 -2
- d365fo_client/profile_manager.py +62 -27
- d365fo_client/profiles.py +118 -113
- d365fo_client/settings.py +355 -0
- d365fo_client/sync_models.py +85 -2
- d365fo_client/utils.py +2 -1
- {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.0.dist-info}/METADATA +273 -18
- d365fo_client-0.3.0.dist-info/RECORD +84 -0
- d365fo_client-0.3.0.dist-info/entry_points.txt +4 -0
- d365fo_client-0.2.4.dist-info/RECORD +0 -56
- d365fo_client-0.2.4.dist-info/entry_points.txt +0 -3
- {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.0.dist-info}/WHEEL +0 -0
- {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,321 @@
|
|
1
|
+
"""Sync tools mixin for FastMCP server."""
|
2
|
+
|
3
|
+
import logging
|
4
|
+
from typing import Optional
|
5
|
+
|
6
|
+
from .base_tools_mixin import BaseToolsMixin
|
7
|
+
from ...sync_models import SyncStrategy, SyncStatus
|
8
|
+
|
9
|
+
logger = logging.getLogger(__name__)
|
10
|
+
|
11
|
+
|
12
|
+
class SyncToolsMixin(BaseToolsMixin):
|
13
|
+
"""Metadata synchronization tools for FastMCP server."""
|
14
|
+
|
15
|
+
def register_sync_tools(self):
|
16
|
+
"""Register all sync tools with FastMCP."""
|
17
|
+
|
18
|
+
@self.mcp.tool()
|
19
|
+
async def d365fo_start_sync(
|
20
|
+
strategy: str = "full_without_labels",
|
21
|
+
global_version_id: Optional[int] = None,
|
22
|
+
profile: str = "default",
|
23
|
+
) -> dict:
|
24
|
+
"""Start a metadata synchronization session and return a session ID for tracking progress.
|
25
|
+
|
26
|
+
This downloads and caches metadata from D365 F&O including entities, schemas, enumerations, and labels.
|
27
|
+
|
28
|
+
Args:
|
29
|
+
strategy: Sync strategy to use. 'full' downloads all metadata, 'entities_only' downloads just entities for quick refresh,
|
30
|
+
'labels_only' downloads only labels, 'full_without_labels' downloads all metadata except labels,
|
31
|
+
'sharing_mode' copies from compatible versions, 'incremental' updates only changes (fallback to full).
|
32
|
+
global_version_id: Specific global version ID to sync. If not provided, will detect current version automatically.
|
33
|
+
profile: Configuration profile to use (optional - uses default profile if not specified)
|
34
|
+
|
35
|
+
Returns:
|
36
|
+
Dictionary with sync session details
|
37
|
+
"""
|
38
|
+
try:
|
39
|
+
client = await self._get_client(profile)
|
40
|
+
|
41
|
+
# Initialize metadata first to ensure all components are available
|
42
|
+
await client.initialize_metadata()
|
43
|
+
|
44
|
+
if not hasattr(client, 'sync_session_manager'):
|
45
|
+
error_response = {
|
46
|
+
"success": False,
|
47
|
+
"error": "Sync session management not available in this client version",
|
48
|
+
"message": "Upgrade to session-based sync manager to access sync functionality"
|
49
|
+
}
|
50
|
+
return error_response
|
51
|
+
|
52
|
+
strategy_enum = SyncStrategy(strategy)
|
53
|
+
sync_needed = True
|
54
|
+
session_id = None
|
55
|
+
|
56
|
+
# Auto-detect version if not provided
|
57
|
+
if global_version_id is None:
|
58
|
+
if not hasattr(client, 'metadata_cache') or client.metadata_cache is None:
|
59
|
+
error_response = {
|
60
|
+
"success": False,
|
61
|
+
"error": "Metadata cache not available in this client version",
|
62
|
+
"message": "Cannot auto-detect version without metadata cache"
|
63
|
+
}
|
64
|
+
return error_response
|
65
|
+
|
66
|
+
sync_needed, detected_version_id = await client.metadata_cache.check_version_and_sync()
|
67
|
+
if detected_version_id is None:
|
68
|
+
raise ValueError("Could not detect global version ID")
|
69
|
+
global_version_id = detected_version_id
|
70
|
+
|
71
|
+
if sync_needed or strategy_enum == SyncStrategy.LABELS_ONLY:
|
72
|
+
# Start sync session
|
73
|
+
session_id = await client.sync_session_manager.start_sync_session(
|
74
|
+
global_version_id=global_version_id,
|
75
|
+
strategy=strategy_enum,
|
76
|
+
initiated_by="mcp"
|
77
|
+
)
|
78
|
+
|
79
|
+
response = {
|
80
|
+
"success": True,
|
81
|
+
"session_id": session_id if sync_needed or strategy_enum == SyncStrategy.LABELS_ONLY else None,
|
82
|
+
"global_version_id": global_version_id,
|
83
|
+
"strategy": strategy,
|
84
|
+
"message": f"Sync session {session_id} started successfully" if sync_needed or strategy_enum == SyncStrategy.LABELS_ONLY else f"Metadata already up to date at version {global_version_id}, no sync needed",
|
85
|
+
"instructions": f"Use d365fo_get_sync_progress with session_id '{session_id}' to monitor progress" if sync_needed or strategy_enum == SyncStrategy.LABELS_ONLY else None
|
86
|
+
}
|
87
|
+
|
88
|
+
return response
|
89
|
+
|
90
|
+
except Exception as e:
|
91
|
+
logger.error(f"Start sync failed: {e}")
|
92
|
+
error_response = {
|
93
|
+
"success": False,
|
94
|
+
"error": str(e),
|
95
|
+
"tool": "d365fo_start_sync",
|
96
|
+
"arguments": {
|
97
|
+
"strategy": strategy,
|
98
|
+
"global_version_id": global_version_id,
|
99
|
+
"profile": profile
|
100
|
+
}
|
101
|
+
}
|
102
|
+
return error_response
|
103
|
+
|
104
|
+
@self.mcp.tool()
|
105
|
+
async def d365fo_get_sync_progress(
|
106
|
+
session_id: str, profile: str = "default"
|
107
|
+
) -> dict:
|
108
|
+
"""Get detailed progress information for a specific sync session including current phase, completion percentage, items processed, and estimated time remaining.
|
109
|
+
|
110
|
+
Args:
|
111
|
+
session_id: Session ID of the sync operation to check progress for
|
112
|
+
profile: Configuration profile to use (optional - uses default profile if not specified)
|
113
|
+
|
114
|
+
Returns:
|
115
|
+
Dictionary with sync progress
|
116
|
+
"""
|
117
|
+
try:
|
118
|
+
client = await self._get_client(profile)
|
119
|
+
|
120
|
+
# Initialize metadata to ensure sync session manager is available
|
121
|
+
await client.initialize_metadata()
|
122
|
+
|
123
|
+
if not hasattr(client, 'sync_session_manager'):
|
124
|
+
error_response = {
|
125
|
+
"success": False,
|
126
|
+
"error": "Sync session management not available in this client version",
|
127
|
+
"session_id": session_id
|
128
|
+
}
|
129
|
+
return error_response
|
130
|
+
|
131
|
+
# Get session details
|
132
|
+
session = client.sync_session_manager.get_sync_session(session_id)
|
133
|
+
|
134
|
+
if not session:
|
135
|
+
error_response = {
|
136
|
+
"success": False,
|
137
|
+
"error": f"Session {session_id} not found",
|
138
|
+
"session_id": session_id
|
139
|
+
}
|
140
|
+
return error_response
|
141
|
+
|
142
|
+
# Convert session to detailed progress response
|
143
|
+
response = {
|
144
|
+
"success": True,
|
145
|
+
"session": session.to_dict(),
|
146
|
+
"summary": {
|
147
|
+
"status": session.status,
|
148
|
+
"progress_percent": round(session.progress_percent, 1),
|
149
|
+
"current_phase": session.current_phase,
|
150
|
+
"current_activity": session.current_activity,
|
151
|
+
"estimated_remaining_seconds": session.estimate_remaining_time(),
|
152
|
+
"is_running": session.status == SyncStatus.RUNNING,
|
153
|
+
"can_cancel": session.can_cancel
|
154
|
+
}
|
155
|
+
}
|
156
|
+
|
157
|
+
return response
|
158
|
+
|
159
|
+
except Exception as e:
|
160
|
+
logger.error(f"Get sync progress failed: {e}")
|
161
|
+
error_response = {
|
162
|
+
"success": False,
|
163
|
+
"error": str(e),
|
164
|
+
"tool": "d365fo_get_sync_progress",
|
165
|
+
"arguments": {"session_id": session_id, "profile": profile}
|
166
|
+
}
|
167
|
+
return error_response
|
168
|
+
|
169
|
+
@self.mcp.tool()
|
170
|
+
async def d365fo_cancel_sync(session_id: str, profile: str = "default") -> dict:
|
171
|
+
"""Cancel a running sync session. Only sessions that are currently running and marked as cancellable can be cancelled.
|
172
|
+
|
173
|
+
Args:
|
174
|
+
session_id: Session ID of the sync operation to cancel
|
175
|
+
profile: Configuration profile to use (optional - uses default profile if not specified)
|
176
|
+
|
177
|
+
Returns:
|
178
|
+
Dictionary with cancellation result
|
179
|
+
"""
|
180
|
+
try:
|
181
|
+
client = await self._get_client(profile)
|
182
|
+
|
183
|
+
# Initialize metadata to ensure sync session manager is available
|
184
|
+
await client.initialize_metadata()
|
185
|
+
|
186
|
+
if not hasattr(client, 'sync_session_manager'):
|
187
|
+
error_response = {
|
188
|
+
"success": False,
|
189
|
+
"error": "Sync session management not available in this client version",
|
190
|
+
"session_id": session_id
|
191
|
+
}
|
192
|
+
return error_response
|
193
|
+
|
194
|
+
# Cancel session
|
195
|
+
cancelled = await client.sync_session_manager.cancel_sync_session(session_id)
|
196
|
+
|
197
|
+
response = {
|
198
|
+
"success": cancelled,
|
199
|
+
"session_id": session_id,
|
200
|
+
"message": f"Session {session_id} {'cancelled' if cancelled else 'could not be cancelled'}",
|
201
|
+
"details": "Session may not be cancellable if already completed or failed"
|
202
|
+
}
|
203
|
+
|
204
|
+
return response
|
205
|
+
|
206
|
+
except Exception as e:
|
207
|
+
logger.error(f"Cancel sync failed: {e}")
|
208
|
+
error_response = {
|
209
|
+
"success": False,
|
210
|
+
"error": str(e),
|
211
|
+
"tool": "d365fo_cancel_sync",
|
212
|
+
"arguments": {"session_id": session_id, "profile": profile}
|
213
|
+
}
|
214
|
+
return error_response
|
215
|
+
|
216
|
+
@self.mcp.tool()
|
217
|
+
async def d365fo_list_sync_sessions(profile: str = "default") -> dict:
|
218
|
+
"""Get a list of all currently active sync sessions with their status, progress, and details.
|
219
|
+
|
220
|
+
Args:
|
221
|
+
profile: Configuration profile to use (optional - uses default profile if not specified)
|
222
|
+
|
223
|
+
Returns:
|
224
|
+
Dictionary with active sync sessions
|
225
|
+
"""
|
226
|
+
try:
|
227
|
+
client = await self._get_client(profile)
|
228
|
+
|
229
|
+
# Initialize metadata to ensure sync session manager is available
|
230
|
+
await client.initialize_metadata()
|
231
|
+
|
232
|
+
if not hasattr(client, 'sync_session_manager'):
|
233
|
+
error_response = {
|
234
|
+
"success": False,
|
235
|
+
"error": "Sync session management not available in this client version",
|
236
|
+
"message": "Upgrade to session-based sync manager to access session listing"
|
237
|
+
}
|
238
|
+
return error_response
|
239
|
+
|
240
|
+
# Get active sessions
|
241
|
+
active_sessions = client.sync_session_manager.get_active_sessions()
|
242
|
+
|
243
|
+
response = {
|
244
|
+
"success": True,
|
245
|
+
"active_sessions": [session.to_dict() for session in active_sessions],
|
246
|
+
"total_count": len(active_sessions),
|
247
|
+
"running_count": len([s for s in active_sessions if s.status == SyncStatus.RUNNING]),
|
248
|
+
"summary": {
|
249
|
+
"has_running_sessions": any(s.status == SyncStatus.RUNNING for s in active_sessions),
|
250
|
+
"latest_session": active_sessions[-1].to_dict() if active_sessions else None
|
251
|
+
}
|
252
|
+
}
|
253
|
+
|
254
|
+
return response
|
255
|
+
|
256
|
+
except Exception as e:
|
257
|
+
logger.error(f"List sync sessions failed: {e}")
|
258
|
+
error_response = {
|
259
|
+
"success": False,
|
260
|
+
"error": str(e),
|
261
|
+
"tool": "d365fo_list_sync_sessions",
|
262
|
+
"arguments": {"profile": profile}
|
263
|
+
}
|
264
|
+
return error_response
|
265
|
+
|
266
|
+
@self.mcp.tool()
|
267
|
+
async def d365fo_get_sync_history(
|
268
|
+
limit: int = 20, profile: str = "default"
|
269
|
+
) -> dict:
|
270
|
+
"""Get the history of completed sync sessions including success/failure status, duration, and statistics.
|
271
|
+
|
272
|
+
Args:
|
273
|
+
limit: Maximum number of historical sessions to return (default: 20, max: 100)
|
274
|
+
profile: Configuration profile to use (optional - uses default profile if not specified)
|
275
|
+
|
276
|
+
Returns:
|
277
|
+
Dictionary with sync history
|
278
|
+
"""
|
279
|
+
try:
|
280
|
+
# Validate limit parameter
|
281
|
+
limit = max(1, min(limit, 100)) # Clamp between 1 and 100
|
282
|
+
|
283
|
+
client = await self._get_client(profile)
|
284
|
+
|
285
|
+
# Initialize metadata to ensure sync session manager is available
|
286
|
+
await client.initialize_metadata()
|
287
|
+
|
288
|
+
if not hasattr(client, 'sync_session_manager'):
|
289
|
+
error_response = {
|
290
|
+
"success": False,
|
291
|
+
"error": "Sync session management not available in this client version",
|
292
|
+
"message": "Upgrade to session-based sync manager to access history"
|
293
|
+
}
|
294
|
+
return error_response
|
295
|
+
|
296
|
+
# Get session history
|
297
|
+
history = client.sync_session_manager.get_session_history(limit)
|
298
|
+
|
299
|
+
response = {
|
300
|
+
"success": True,
|
301
|
+
"history": [session.to_dict() for session in history],
|
302
|
+
"total_count": len(history),
|
303
|
+
"summary": {
|
304
|
+
"successful_syncs": len([s for s in history if s.status == SyncStatus.COMPLETED]),
|
305
|
+
"failed_syncs": len([s for s in history if s.status == SyncStatus.FAILED]),
|
306
|
+
"cancelled_syncs": len([s for s in history if s.status == SyncStatus.CANCELLED]),
|
307
|
+
"average_duration": sum(s.duration_seconds for s in history if s.duration_seconds) / len([s for s in history if s.duration_seconds]) if history else 0
|
308
|
+
}
|
309
|
+
}
|
310
|
+
|
311
|
+
return response
|
312
|
+
|
313
|
+
except Exception as e:
|
314
|
+
logger.error(f"Get sync history failed: {e}")
|
315
|
+
error_response = {
|
316
|
+
"success": False,
|
317
|
+
"error": str(e),
|
318
|
+
"tool": "d365fo_get_sync_history",
|
319
|
+
"arguments": {"limit": limit, "profile": profile}
|
320
|
+
}
|
321
|
+
return error_response
|
@@ -75,7 +75,7 @@ Help users discover, understand, and execute D365 Finance & Operations OData act
|
|
75
75
|
|
76
76
|
**Supporting Tools:**
|
77
77
|
- `d365fo_search_entities` - Find entities that may have actions
|
78
|
-
- `d365fo_query_entities` - Query entity data
|
78
|
+
- `d365fo_query_entities` - Query entity data with simplified 'eq' filtering
|
79
79
|
- `d365fo_get_entity_record` - Get specific entity records for bound actions
|
80
80
|
- `d365fo_test_connection` - Test connection to D365FO environment
|
81
81
|
- `d365fo_get_environment_info` - Get D365FO version and environment details
|
@@ -94,7 +94,7 @@ Analyze D365 Finance & Operations number sequences to identify configuration iss
|
|
94
94
|
## Available MCP Tools
|
95
95
|
|
96
96
|
**Entity Query Tools:**
|
97
|
-
- `d365fo_query_entities` - Query D365FO entities (
|
97
|
+
- `d365fo_query_entities` - Query D365FO entities (simplified 'eq' filtering with wildcards only)
|
98
98
|
- `d365fo_get_entity_by_key` - Get specific entity record by key
|
99
99
|
- `d365fo_search_entities` - Search for entities by name pattern
|
100
100
|
- `d365fo_get_entity_schema` - Get entity metadata and schema information
|
@@ -44,13 +44,13 @@ class CrudTools:
|
|
44
44
|
"""Get query entities tool definition."""
|
45
45
|
return Tool(
|
46
46
|
name="d365fo_query_entities",
|
47
|
-
description="Query and retrieve multiple records from D365 Finance & Operations data entities using OData
|
47
|
+
description="Query and retrieve multiple records from D365 Finance & Operations data entities using simplified OData filtering. Supports field selection, sorting, and pagination, but uses simplified 'eq' filtering with wildcard patterns only (no complex OData operators). Ideal for basic searches and bulk data retrieval. For complex filtering requirements, retrieve data first and filter programmatically. Returns structured JSON data with optional metadata like record counts and pagination links.",
|
48
48
|
inputSchema={
|
49
49
|
"type": "object",
|
50
50
|
"properties": {
|
51
51
|
"entityName": {
|
52
52
|
"type": "string",
|
53
|
-
"description": "The name of the D365FO data entity to query. This
|
53
|
+
"description": "The name of the D365FO data entity to query. This must be the entity's public collection name or entity set name (e.g., 'CustomersV3', 'SalesOrderHeadersV2', 'DataManagementEntities'). Use metadata discovery tools first to find the correct entity name and verify it supports OData operations (data_service_enabled=true).",
|
54
54
|
},
|
55
55
|
"profile": {
|
56
56
|
"type": "string",
|
@@ -64,7 +64,7 @@ class CrudTools:
|
|
64
64
|
},
|
65
65
|
"filter": {
|
66
66
|
"type": "string",
|
67
|
-
"description": '
|
67
|
+
"description": 'Simplified filter expression using only "eq" (equals) operation with wildcard support. Supported patterns: Basic equality: "FieldName eq \'value\'"; Starts with: "FieldName eq \'value*\'"; Ends with: "FieldName eq \'*value\'"; Contains: "FieldName eq \'*value*\'"; Enum values require full namespace: "StatusField eq Microsoft.Dynamics.DataEntities.EnumType\'EnumValue\'". Examples: "CustomerAccount eq \'CUST001\'", "Name eq \'*Corp*\'", "SalesOrderStatus eq Microsoft.Dynamics.DataEntities.SalesStatus\'OpenOrder\'". Note: Standard OData operators (gt, lt, and, or) are NOT supported.',
|
68
68
|
},
|
69
69
|
"expand": {
|
70
70
|
"type": "array",
|
@@ -8,7 +8,7 @@ from mcp import Tool
|
|
8
8
|
from mcp.types import TextContent
|
9
9
|
|
10
10
|
from ..client_manager import D365FOClientManager
|
11
|
-
from ...
|
11
|
+
from ...sync_models import SyncStrategy
|
12
12
|
from ...sync_models import SyncStatus
|
13
13
|
|
14
14
|
logger = logging.getLogger(__name__)
|
@@ -0,0 +1 @@
|
|
1
|
+
"""FastMCP utility modules."""
|
@@ -0,0 +1,34 @@
|
|
1
|
+
"""Authentication utility helpers."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import json
|
6
|
+
from typing import Any
|
7
|
+
|
8
|
+
|
9
|
+
def parse_scopes(value: Any) -> list[str] | None:
|
10
|
+
"""Parse scopes from environment variables or settings values.
|
11
|
+
|
12
|
+
Accepts either a JSON array string, a comma- or space-separated string,
|
13
|
+
a list of strings, or ``None``. Returns a list of scopes or ``None`` if
|
14
|
+
no value is provided.
|
15
|
+
"""
|
16
|
+
if value is None or value == "":
|
17
|
+
return None if value is None else []
|
18
|
+
if isinstance(value, list):
|
19
|
+
return [str(v).strip() for v in value if str(v).strip()]
|
20
|
+
if isinstance(value, str):
|
21
|
+
value = value.strip()
|
22
|
+
if not value:
|
23
|
+
return []
|
24
|
+
# Try JSON array first
|
25
|
+
if value.startswith("["):
|
26
|
+
try:
|
27
|
+
data = json.loads(value)
|
28
|
+
if isinstance(data, list):
|
29
|
+
return [str(v).strip() for v in data if str(v).strip()]
|
30
|
+
except Exception:
|
31
|
+
pass
|
32
|
+
# Fallback to comma/space separated list
|
33
|
+
return [s.strip() for s in value.replace(",", " ").split() if s.strip()]
|
34
|
+
return value
|
@@ -0,0 +1,58 @@
|
|
1
|
+
"""Logging utilities for FastMCP."""
|
2
|
+
|
3
|
+
import logging
|
4
|
+
from typing import Any, Literal
|
5
|
+
|
6
|
+
from rich.console import Console
|
7
|
+
from rich.logging import RichHandler
|
8
|
+
|
9
|
+
|
10
|
+
def get_logger(name: str) -> logging.Logger:
|
11
|
+
"""Get a logger nested under FastMCP namespace.
|
12
|
+
|
13
|
+
Args:
|
14
|
+
name: the name of the logger, which will be prefixed with 'FastMCP.'
|
15
|
+
|
16
|
+
Returns:
|
17
|
+
a configured logger instance
|
18
|
+
"""
|
19
|
+
return logging.getLogger(f"FastMCP.{name}")
|
20
|
+
|
21
|
+
|
22
|
+
def configure_logging(
|
23
|
+
level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | int = "INFO",
|
24
|
+
logger: logging.Logger | None = None,
|
25
|
+
enable_rich_tracebacks: bool = True,
|
26
|
+
**rich_kwargs: Any,
|
27
|
+
) -> None:
|
28
|
+
"""
|
29
|
+
Configure logging for FastMCP.
|
30
|
+
|
31
|
+
Args:
|
32
|
+
logger: the logger to configure
|
33
|
+
level: the log level to use
|
34
|
+
rich_kwargs: the parameters to use for creating RichHandler
|
35
|
+
"""
|
36
|
+
|
37
|
+
if logger is None:
|
38
|
+
logger = logging.getLogger("FastMCP")
|
39
|
+
|
40
|
+
# Only configure the FastMCP logger namespace
|
41
|
+
handler = RichHandler(
|
42
|
+
console=Console(stderr=True),
|
43
|
+
rich_tracebacks=enable_rich_tracebacks,
|
44
|
+
**rich_kwargs,
|
45
|
+
)
|
46
|
+
formatter = logging.Formatter("%(message)s")
|
47
|
+
handler.setFormatter(formatter)
|
48
|
+
|
49
|
+
logger.setLevel(level)
|
50
|
+
|
51
|
+
# Remove any existing handlers to avoid duplicates on reconfiguration
|
52
|
+
for hdlr in logger.handlers[:]:
|
53
|
+
logger.removeHandler(hdlr)
|
54
|
+
|
55
|
+
logger.addHandler(handler)
|
56
|
+
|
57
|
+
# Don't propagate to the root logger
|
58
|
+
logger.propagate = False
|