d365fo-client 0.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 (51) hide show
  1. d365fo_client/__init__.py +305 -0
  2. d365fo_client/auth.py +93 -0
  3. d365fo_client/cli.py +700 -0
  4. d365fo_client/client.py +1454 -0
  5. d365fo_client/config.py +304 -0
  6. d365fo_client/crud.py +200 -0
  7. d365fo_client/exceptions.py +49 -0
  8. d365fo_client/labels.py +528 -0
  9. d365fo_client/main.py +502 -0
  10. d365fo_client/mcp/__init__.py +16 -0
  11. d365fo_client/mcp/client_manager.py +276 -0
  12. d365fo_client/mcp/main.py +98 -0
  13. d365fo_client/mcp/models.py +371 -0
  14. d365fo_client/mcp/prompts/__init__.py +43 -0
  15. d365fo_client/mcp/prompts/action_execution.py +480 -0
  16. d365fo_client/mcp/prompts/sequence_analysis.py +349 -0
  17. d365fo_client/mcp/resources/__init__.py +15 -0
  18. d365fo_client/mcp/resources/database_handler.py +555 -0
  19. d365fo_client/mcp/resources/entity_handler.py +176 -0
  20. d365fo_client/mcp/resources/environment_handler.py +132 -0
  21. d365fo_client/mcp/resources/metadata_handler.py +283 -0
  22. d365fo_client/mcp/resources/query_handler.py +135 -0
  23. d365fo_client/mcp/server.py +432 -0
  24. d365fo_client/mcp/tools/__init__.py +17 -0
  25. d365fo_client/mcp/tools/connection_tools.py +175 -0
  26. d365fo_client/mcp/tools/crud_tools.py +579 -0
  27. d365fo_client/mcp/tools/database_tools.py +813 -0
  28. d365fo_client/mcp/tools/label_tools.py +189 -0
  29. d365fo_client/mcp/tools/metadata_tools.py +766 -0
  30. d365fo_client/mcp/tools/profile_tools.py +706 -0
  31. d365fo_client/metadata_api.py +793 -0
  32. d365fo_client/metadata_v2/__init__.py +59 -0
  33. d365fo_client/metadata_v2/cache_v2.py +1372 -0
  34. d365fo_client/metadata_v2/database_v2.py +585 -0
  35. d365fo_client/metadata_v2/global_version_manager.py +573 -0
  36. d365fo_client/metadata_v2/search_engine_v2.py +423 -0
  37. d365fo_client/metadata_v2/sync_manager_v2.py +819 -0
  38. d365fo_client/metadata_v2/version_detector.py +439 -0
  39. d365fo_client/models.py +862 -0
  40. d365fo_client/output.py +181 -0
  41. d365fo_client/profile_manager.py +342 -0
  42. d365fo_client/profiles.py +178 -0
  43. d365fo_client/query.py +162 -0
  44. d365fo_client/session.py +60 -0
  45. d365fo_client/utils.py +196 -0
  46. d365fo_client-0.1.0.dist-info/METADATA +1084 -0
  47. d365fo_client-0.1.0.dist-info/RECORD +51 -0
  48. d365fo_client-0.1.0.dist-info/WHEEL +5 -0
  49. d365fo_client-0.1.0.dist-info/entry_points.txt +3 -0
  50. d365fo_client-0.1.0.dist-info/licenses/LICENSE +21 -0
  51. d365fo_client-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,555 @@
1
+ """Database resource handler for MCP server."""
2
+
3
+ import json
4
+ import logging
5
+ import time
6
+ from datetime import datetime
7
+ from typing import Any, Dict, List
8
+
9
+ from mcp.types import Resource
10
+
11
+ from ..client_manager import D365FOClientManager
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class DatabaseResourceHandler:
17
+ """Handles database resources for the MCP server."""
18
+
19
+ def __init__(self, client_manager: D365FOClientManager):
20
+ """Initialize the database resource handler.
21
+
22
+ Args:
23
+ client_manager: D365FO client manager instance
24
+ """
25
+ self.client_manager = client_manager
26
+
27
+ async def list_resources(self) -> List[Resource]:
28
+ """List available database resources.
29
+
30
+ Returns:
31
+ List of database resources
32
+ """
33
+ resources = [
34
+ Resource(
35
+ uri="d365fo://database/schema",
36
+ name="Database Schema",
37
+ description="Complete database schema with tables, columns, indexes, and relationships",
38
+ mimeType="application/json",
39
+ ),
40
+ Resource(
41
+ uri="d365fo://database/statistics",
42
+ name="Database Statistics",
43
+ description="Database performance statistics, table sizes, and utilization metrics",
44
+ mimeType="application/json",
45
+ ),
46
+ Resource(
47
+ uri="d365fo://database/tables",
48
+ name="Database Tables",
49
+ description="List of all database tables with basic information",
50
+ mimeType="application/json",
51
+ ),
52
+ Resource(
53
+ uri="d365fo://database/indexes",
54
+ name="Database Indexes",
55
+ description="All database indexes and their characteristics",
56
+ mimeType="application/json",
57
+ ),
58
+ Resource(
59
+ uri="d365fo://database/relationships",
60
+ name="Database Relationships",
61
+ description="Foreign key relationships between tables",
62
+ mimeType="application/json",
63
+ ),
64
+ ]
65
+
66
+ # Add individual table resources
67
+ try:
68
+ # Get list of tables from database
69
+ client = await self.client_manager.get_client()
70
+ if hasattr(client, 'metadata_cache') and client.metadata_cache:
71
+ table_names = await self._get_table_names(client.metadata_cache.db_path)
72
+
73
+ for table_name in table_names:
74
+ resources.append(
75
+ Resource(
76
+ uri=f"d365fo://database/tables/{table_name}",
77
+ name=f"Table: {table_name}",
78
+ description=f"Detailed schema and information for table {table_name}",
79
+ mimeType="application/json",
80
+ )
81
+ )
82
+ except Exception as e:
83
+ logger.warning(f"Could not load table list for resources: {e}")
84
+
85
+ logger.info(f"Listed {len(resources)} database resources")
86
+ return resources
87
+
88
+ async def read_resource(self, uri: str) -> str:
89
+ """Read specific database resource.
90
+
91
+ Args:
92
+ uri: Resource URI
93
+
94
+ Returns:
95
+ JSON string with database resource content
96
+ """
97
+ try:
98
+ if uri == "d365fo://database/schema":
99
+ return await self._get_complete_schema()
100
+ elif uri == "d365fo://database/statistics":
101
+ return await self._get_database_statistics()
102
+ elif uri == "d365fo://database/tables":
103
+ return await self._get_tables_list()
104
+ elif uri == "d365fo://database/indexes":
105
+ return await self._get_indexes_info()
106
+ elif uri == "d365fo://database/relationships":
107
+ return await self._get_relationships_info()
108
+ elif uri.startswith("d365fo://database/tables/"):
109
+ table_name = uri.split("/")[-1]
110
+ return await self._get_table_details(table_name)
111
+ else:
112
+ raise ValueError(f"Unknown database resource URI: {uri}")
113
+ except Exception as e:
114
+ logger.error(f"Failed to read database resource {uri}: {e}")
115
+ error_content = {
116
+ "error": str(e),
117
+ "uri": uri,
118
+ "timestamp": datetime.utcnow().isoformat(),
119
+ "resource_type": "database"
120
+ }
121
+ return json.dumps(error_content, indent=2)
122
+
123
+ async def _get_table_names(self, db_path: str) -> List[str]:
124
+ """Get list of table names from database."""
125
+ import aiosqlite
126
+
127
+ async with aiosqlite.connect(db_path) as db:
128
+ cursor = await db.execute(
129
+ "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
130
+ )
131
+ return [row[0] for row in await cursor.fetchall()]
132
+
133
+ async def _get_complete_schema(self) -> str:
134
+ """Get complete database schema resource."""
135
+ try:
136
+ client = await self.client_manager.get_client()
137
+ if not hasattr(client, 'metadata_cache') or not client.metadata_cache:
138
+ raise ValueError("No metadata database available")
139
+
140
+ db_path = str(client.metadata_cache.db_path)
141
+
142
+ import aiosqlite
143
+
144
+ async with aiosqlite.connect(db_path) as db:
145
+ schema_info = {
146
+ "database_path": db_path,
147
+ "generated_at": datetime.utcnow().isoformat(),
148
+ "tables": {},
149
+ "summary": {
150
+ "total_tables": 0,
151
+ "total_columns": 0,
152
+ "total_indexes": 0,
153
+ "total_foreign_keys": 0
154
+ }
155
+ }
156
+
157
+ # Get all tables
158
+ cursor = await db.execute(
159
+ "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
160
+ )
161
+ table_names = [row[0] for row in await cursor.fetchall()]
162
+ schema_info["summary"]["total_tables"] = len(table_names)
163
+
164
+ # Get detailed info for each table
165
+ for table_name in table_names:
166
+ table_info = await self._get_table_schema_info(db, table_name)
167
+ schema_info["tables"][table_name] = table_info
168
+
169
+ # Update summary counters
170
+ schema_info["summary"]["total_columns"] += len(table_info["columns"])
171
+ schema_info["summary"]["total_indexes"] += len(table_info["indexes"])
172
+ schema_info["summary"]["total_foreign_keys"] += len(table_info["foreign_keys"])
173
+
174
+ return json.dumps(schema_info, indent=2)
175
+
176
+ except Exception as e:
177
+ logger.error(f"Failed to get database schema: {e}")
178
+ raise
179
+
180
+ async def _get_table_schema_info(self, db, table_name: str) -> Dict[str, Any]:
181
+ """Get schema information for a specific table."""
182
+ table_info = {"name": table_name}
183
+
184
+ # Get column information
185
+ cursor = await db.execute(f"PRAGMA table_info({table_name})")
186
+ columns = await cursor.fetchall()
187
+ table_info["columns"] = [
188
+ {
189
+ "cid": col[0],
190
+ "name": col[1],
191
+ "type": col[2],
192
+ "not_null": bool(col[3]),
193
+ "default_value": col[4],
194
+ "primary_key": bool(col[5])
195
+ }
196
+ for col in columns
197
+ ]
198
+
199
+ # Get row count
200
+ try:
201
+ cursor = await db.execute(f"SELECT COUNT(*) FROM {table_name}")
202
+ table_info["row_count"] = (await cursor.fetchone())[0]
203
+ except Exception:
204
+ table_info["row_count"] = 0
205
+
206
+ # Get indexes
207
+ cursor = await db.execute(f"PRAGMA index_list({table_name})")
208
+ indexes = await cursor.fetchall()
209
+ table_info["indexes"] = []
210
+ for idx in indexes:
211
+ index_info = {
212
+ "name": idx[1],
213
+ "unique": bool(idx[2]),
214
+ "origin": idx[3]
215
+ }
216
+ # Get index columns
217
+ try:
218
+ cursor = await db.execute(f"PRAGMA index_info({idx[1]})")
219
+ index_columns = await cursor.fetchall()
220
+ index_info["columns"] = [col[2] for col in index_columns]
221
+ except Exception:
222
+ index_info["columns"] = []
223
+ table_info["indexes"].append(index_info)
224
+
225
+ # Get foreign keys
226
+ cursor = await db.execute(f"PRAGMA foreign_key_list({table_name})")
227
+ foreign_keys = await cursor.fetchall()
228
+ table_info["foreign_keys"] = [
229
+ {
230
+ "id": fk[0],
231
+ "seq": fk[1],
232
+ "table": fk[2],
233
+ "from": fk[3],
234
+ "to": fk[4],
235
+ "on_update": fk[5],
236
+ "on_delete": fk[6],
237
+ "match": fk[7]
238
+ }
239
+ for fk in foreign_keys
240
+ ]
241
+
242
+ return table_info
243
+
244
+ async def _get_database_statistics(self) -> str:
245
+ """Get database statistics resource."""
246
+ try:
247
+ client = await self.client_manager.get_client()
248
+ if not hasattr(client, 'metadata_cache') or not client.metadata_cache:
249
+ raise ValueError("No metadata database available")
250
+
251
+ # Use existing database statistics method
252
+ if hasattr(client.metadata_cache, 'database') and hasattr(client.metadata_cache.database, 'get_database_statistics'):
253
+ stats = await client.metadata_cache.database.get_database_statistics()
254
+ stats["generated_at"] = datetime.utcnow().isoformat()
255
+ stats["resource_type"] = "database_statistics"
256
+ return json.dumps(stats, indent=2)
257
+ else:
258
+ # Fallback to basic statistics
259
+ db_path = str(client.metadata_cache.db_path)
260
+ stats = await self._get_basic_statistics(db_path)
261
+ return json.dumps(stats, indent=2)
262
+
263
+ except Exception as e:
264
+ logger.error(f"Failed to get database statistics: {e}")
265
+ raise
266
+
267
+ async def _get_basic_statistics(self, db_path: str) -> Dict[str, Any]:
268
+ """Get basic database statistics."""
269
+ import aiosqlite
270
+ import os
271
+
272
+ stats = {
273
+ "generated_at": datetime.utcnow().isoformat(),
274
+ "database_path": db_path,
275
+ "resource_type": "database_statistics"
276
+ }
277
+
278
+ # File size
279
+ try:
280
+ stats["database_size_bytes"] = os.path.getsize(db_path)
281
+ stats["database_size_mb"] = round(stats["database_size_bytes"] / (1024 * 1024), 2)
282
+ except Exception:
283
+ stats["database_size_bytes"] = None
284
+ stats["database_size_mb"] = None
285
+
286
+ async with aiosqlite.connect(db_path) as db:
287
+ # Table counts
288
+ cursor = await db.execute(
289
+ "SELECT name FROM sqlite_master WHERE type='table'"
290
+ )
291
+ table_names = [row[0] for row in await cursor.fetchall()]
292
+ stats["table_count"] = len(table_names)
293
+
294
+ # Row counts by table
295
+ table_stats = {}
296
+ total_rows = 0
297
+ for table_name in table_names:
298
+ try:
299
+ cursor = await db.execute(f"SELECT COUNT(*) FROM {table_name}")
300
+ row_count = (await cursor.fetchone())[0]
301
+ table_stats[table_name] = row_count
302
+ total_rows += row_count
303
+ except Exception:
304
+ table_stats[table_name] = 0
305
+
306
+ stats["table_statistics"] = table_stats
307
+ stats["total_rows"] = total_rows
308
+
309
+ # Database page information
310
+ try:
311
+ cursor = await db.execute("PRAGMA page_count")
312
+ page_count = (await cursor.fetchone())[0]
313
+
314
+ cursor = await db.execute("PRAGMA page_size")
315
+ page_size = (await cursor.fetchone())[0]
316
+
317
+ stats["page_statistics"] = {
318
+ "page_count": page_count,
319
+ "page_size_bytes": page_size,
320
+ "calculated_size_bytes": page_count * page_size
321
+ }
322
+ except Exception:
323
+ stats["page_statistics"] = None
324
+
325
+ return stats
326
+
327
+ async def _get_tables_list(self) -> str:
328
+ """Get tables list resource."""
329
+ try:
330
+ client = await self.client_manager.get_client()
331
+ if not hasattr(client, 'metadata_cache') or not client.metadata_cache:
332
+ raise ValueError("No metadata database available")
333
+
334
+ db_path = str(client.metadata_cache.db_path)
335
+
336
+ import aiosqlite
337
+
338
+ async with aiosqlite.connect(db_path) as db:
339
+ tables_info = {
340
+ "generated_at": datetime.utcnow().isoformat(),
341
+ "resource_type": "tables_list",
342
+ "tables": []
343
+ }
344
+
345
+ # Get table information
346
+ cursor = await db.execute(
347
+ "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
348
+ )
349
+ table_names = [row[0] for row in await cursor.fetchall()]
350
+
351
+ for table_name in table_names:
352
+ table_info = {"name": table_name}
353
+
354
+ # Get row count
355
+ try:
356
+ cursor = await db.execute(f"SELECT COUNT(*) FROM {table_name}")
357
+ table_info["row_count"] = (await cursor.fetchone())[0]
358
+ except Exception:
359
+ table_info["row_count"] = 0
360
+
361
+ # Get column count
362
+ cursor = await db.execute(f"PRAGMA table_info({table_name})")
363
+ columns = await cursor.fetchall()
364
+ table_info["column_count"] = len(columns)
365
+
366
+ # Get primary key columns
367
+ pk_columns = [col[1] for col in columns if col[5]] # col[5] is primary key flag
368
+ table_info["primary_key_columns"] = pk_columns
369
+
370
+ tables_info["tables"].append(table_info)
371
+
372
+ tables_info["total_tables"] = len(tables_info["tables"])
373
+ return json.dumps(tables_info, indent=2)
374
+
375
+ except Exception as e:
376
+ logger.error(f"Failed to get tables list: {e}")
377
+ raise
378
+
379
+ async def _get_indexes_info(self) -> str:
380
+ """Get indexes information resource."""
381
+ try:
382
+ client = await self.client_manager.get_client()
383
+ if not hasattr(client, 'metadata_cache') or not client.metadata_cache:
384
+ raise ValueError("No metadata database available")
385
+
386
+ db_path = str(client.metadata_cache.db_path)
387
+
388
+ import aiosqlite
389
+
390
+ async with aiosqlite.connect(db_path) as db:
391
+ indexes_info = {
392
+ "generated_at": datetime.utcnow().isoformat(),
393
+ "resource_type": "indexes_info",
394
+ "indexes": []
395
+ }
396
+
397
+ # Get all tables
398
+ cursor = await db.execute(
399
+ "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
400
+ )
401
+ table_names = [row[0] for row in await cursor.fetchall()]
402
+
403
+ for table_name in table_names:
404
+ # Get indexes for this table
405
+ cursor = await db.execute(f"PRAGMA index_list({table_name})")
406
+ indexes = await cursor.fetchall()
407
+
408
+ for idx in indexes:
409
+ index_info = {
410
+ "table": table_name,
411
+ "name": idx[1],
412
+ "unique": bool(idx[2]),
413
+ "origin": idx[3]
414
+ }
415
+
416
+ # Get index columns
417
+ try:
418
+ cursor = await db.execute(f"PRAGMA index_info({idx[1]})")
419
+ index_columns = await cursor.fetchall()
420
+ index_info["columns"] = [
421
+ {"seq": col[0], "column": col[2]} for col in index_columns
422
+ ]
423
+ except Exception:
424
+ index_info["columns"] = []
425
+
426
+ indexes_info["indexes"].append(index_info)
427
+
428
+ indexes_info["total_indexes"] = len(indexes_info["indexes"])
429
+ return json.dumps(indexes_info, indent=2)
430
+
431
+ except Exception as e:
432
+ logger.error(f"Failed to get indexes info: {e}")
433
+ raise
434
+
435
+ async def _get_relationships_info(self) -> str:
436
+ """Get relationships information resource."""
437
+ try:
438
+ client = await self.client_manager.get_client()
439
+ if not hasattr(client, 'metadata_cache') or not client.metadata_cache:
440
+ raise ValueError("No metadata database available")
441
+
442
+ db_path = str(client.metadata_cache.db_path)
443
+
444
+ import aiosqlite
445
+
446
+ async with aiosqlite.connect(db_path) as db:
447
+ relationships_info = {
448
+ "generated_at": datetime.utcnow().isoformat(),
449
+ "resource_type": "relationships_info",
450
+ "foreign_keys": [],
451
+ "relationship_summary": {}
452
+ }
453
+
454
+ # Get all tables
455
+ cursor = await db.execute(
456
+ "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
457
+ )
458
+ table_names = [row[0] for row in await cursor.fetchall()]
459
+
460
+ relationship_summary = {}
461
+
462
+ for table_name in table_names:
463
+ # Get foreign keys for this table
464
+ cursor = await db.execute(f"PRAGMA foreign_key_list({table_name})")
465
+ foreign_keys = await cursor.fetchall()
466
+
467
+ table_relationships = {
468
+ "references": [], # Tables this table references
469
+ "referenced_by": [] # Will be filled in second pass
470
+ }
471
+
472
+ for fk in foreign_keys:
473
+ fk_info = {
474
+ "from_table": table_name,
475
+ "from_column": fk[3],
476
+ "to_table": fk[2],
477
+ "to_column": fk[4],
478
+ "on_update": fk[5],
479
+ "on_delete": fk[6]
480
+ }
481
+ relationships_info["foreign_keys"].append(fk_info)
482
+ table_relationships["references"].append({
483
+ "table": fk[2],
484
+ "via_column": fk[3]
485
+ })
486
+
487
+ relationship_summary[table_name] = table_relationships
488
+
489
+ # Second pass to find reverse relationships
490
+ for fk in relationships_info["foreign_keys"]:
491
+ to_table = fk["to_table"]
492
+ if to_table in relationship_summary:
493
+ relationship_summary[to_table]["referenced_by"].append({
494
+ "table": fk["from_table"],
495
+ "via_column": fk["to_column"]
496
+ })
497
+
498
+ relationships_info["relationship_summary"] = relationship_summary
499
+ relationships_info["total_foreign_keys"] = len(relationships_info["foreign_keys"])
500
+
501
+ return json.dumps(relationships_info, indent=2)
502
+
503
+ except Exception as e:
504
+ logger.error(f"Failed to get relationships info: {e}")
505
+ raise
506
+
507
+ async def _get_table_details(self, table_name: str) -> str:
508
+ """Get detailed information for a specific table."""
509
+ try:
510
+ client = await self.client_manager.get_client()
511
+ if not hasattr(client, 'metadata_cache') or not client.metadata_cache:
512
+ raise ValueError("No metadata database available")
513
+
514
+ db_path = str(client.metadata_cache.db_path)
515
+
516
+ import aiosqlite
517
+
518
+ async with aiosqlite.connect(db_path) as db:
519
+ # Verify table exists
520
+ cursor = await db.execute(
521
+ "SELECT name FROM sqlite_master WHERE type='table' AND name=?",
522
+ (table_name,)
523
+ )
524
+ if not await cursor.fetchone():
525
+ raise ValueError(f"Table '{table_name}' does not exist")
526
+
527
+ table_details = {
528
+ "generated_at": datetime.utcnow().isoformat(),
529
+ "resource_type": "table_details",
530
+ "table_name": table_name
531
+ }
532
+
533
+ # Get complete table schema info
534
+ table_info = await self._get_table_schema_info(db, table_name)
535
+ table_details.update(table_info)
536
+
537
+ # Add sample data (first 3 rows)
538
+ try:
539
+ cursor = await db.execute(f"SELECT * FROM {table_name} LIMIT 3")
540
+ sample_rows = await cursor.fetchall()
541
+ if sample_rows:
542
+ column_names = [desc[0] for desc in cursor.description]
543
+ table_details["sample_data"] = {
544
+ "columns": column_names,
545
+ "rows": [list(row) for row in sample_rows],
546
+ "note": "Limited to first 3 rows for preview"
547
+ }
548
+ except Exception as e:
549
+ table_details["sample_data_error"] = str(e)
550
+
551
+ return json.dumps(table_details, indent=2)
552
+
553
+ except Exception as e:
554
+ logger.error(f"Failed to get table details for {table_name}: {e}")
555
+ raise