iflow-mcp_augustab-microsoft-fabric-mcp 0.1.4__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.
fabric_mcp.py ADDED
@@ -0,0 +1,2755 @@
1
+ import json
2
+ import logging
3
+ import requests
4
+ import uuid
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from typing import Dict, List, Optional, Tuple, Any
8
+ from urllib.parse import quote
9
+
10
+ from azure.identity import DefaultAzureCredential
11
+ from cachetools import TTLCache
12
+ from deltalake import DeltaTable
13
+ from mcp.server.fastmcp import FastMCP
14
+
15
+ # Global caches for name resolution (these never need clearing as name->ID mappings are permanent)
16
+ _global_workspace_cache = {}
17
+ _global_lakehouse_cache = {}
18
+
19
+ # Create MCP instance
20
+ mcp = FastMCP("fabric_schemas")
21
+
22
+ # Set up logging with more robust duplicate prevention
23
+ logger = logging.getLogger(__name__)
24
+ logger.setLevel(logging.INFO)
25
+
26
+ # Clear any existing handlers first
27
+ logger.handlers.clear()
28
+
29
+ # Add single handler
30
+ handler = logging.StreamHandler()
31
+ formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
32
+ handler.setFormatter(formatter)
33
+ logger.addHandler(handler)
34
+
35
+ # Prevent propagation to parent loggers to avoid duplicates
36
+ logger.propagate = False
37
+
38
+ # Global shared caches for all FabricApiClient instances
39
+ _WORKSPACE_CACHE = TTLCache(maxsize=1, ttl=120) # 2 min - single workspace list
40
+ _CONNECTIONS_CACHE = TTLCache(maxsize=1, ttl=600) # 10 min - single connections list
41
+ _CAPACITIES_CACHE = TTLCache(maxsize=1, ttl=900) # 15 min - single capacities list
42
+ _ITEMS_CACHE = TTLCache(maxsize=50, ttl=300) # 5 min - items per workspace
43
+ _SHORTCUTS_CACHE = TTLCache(maxsize=100, ttl=300) # 5 min - shortcuts per item
44
+ _JOB_INSTANCES_CACHE = TTLCache(maxsize=30, ttl=600) # 10 min - jobs per workspace/item
45
+ _SCHEDULES_CACHE = TTLCache(maxsize=30, ttl=300) # 5 min - schedules per item/workspace
46
+ _ENVIRONMENTS_CACHE = TTLCache(
47
+ maxsize=20, ttl=600
48
+ ) # 10 min - environments per workspace
49
+
50
+
51
+ @dataclass
52
+ class FabricApiConfig:
53
+ """Configuration for Fabric API"""
54
+
55
+ base_url: str = "https://api.fabric.microsoft.com/v1"
56
+ max_results: int = 100
57
+
58
+
59
+ class FabricApiClient:
60
+ """Client for communicating with the Fabric API"""
61
+
62
+ def __init__(self, credential=None, config: FabricApiConfig = None):
63
+ self.credential = credential or DefaultAzureCredential()
64
+ self.config = config or FabricApiConfig()
65
+
66
+ def _get_headers(self) -> Dict[str, str]:
67
+ """Get headers for Fabric API calls"""
68
+ return {
69
+ "Authorization": f"Bearer {self.credential.get_token('https://api.fabric.microsoft.com/.default').token}"
70
+ }
71
+
72
+ async def _make_request(
73
+ self, endpoint: str, params: Optional[Dict] = None, method: str = "GET"
74
+ ) -> Dict[str, Any]:
75
+ """Make an asynchronous call to the Fabric API"""
76
+ # If endpoint is a full URL, use it directly, otherwise add base_url
77
+ url = (
78
+ endpoint
79
+ if endpoint.startswith("http")
80
+ else f"{self.config.base_url}/{endpoint.lstrip('/')}"
81
+ )
82
+ params = params or {}
83
+
84
+ if "maxResults" not in params:
85
+ params["maxResults"] = self.config.max_results
86
+
87
+ try:
88
+ response = requests.request(
89
+ method=method, url=url, headers=self._get_headers(), params=params
90
+ )
91
+ response.raise_for_status()
92
+ return response.json()
93
+ except requests.RequestException as e:
94
+ logger.error(f"API call failed: {str(e)}")
95
+ return None
96
+
97
+ async def paginated_request(
98
+ self, endpoint: str, params: Optional[Dict] = None, data_key: str = "value"
99
+ ) -> List[Dict]:
100
+ """Make a paginated call to the Fabric API"""
101
+ results = []
102
+ params = params or {}
103
+ continuation_token = None
104
+
105
+ while True:
106
+ # Construct full URL with continuation token if available
107
+ url = f"{self.config.base_url}/{endpoint.lstrip('/')}"
108
+ if continuation_token:
109
+ separator = "&" if "?" in url else "?"
110
+ # URL-encode continuation token
111
+ encoded_token = quote(continuation_token)
112
+ url += f"{separator}continuationToken={encoded_token}"
113
+
114
+ # Use params without continuation token
115
+ request_params = params.copy()
116
+ if "continuationToken" in request_params:
117
+ del request_params["continuationToken"]
118
+
119
+ data = await self._make_request(url, request_params)
120
+ if not data:
121
+ break
122
+
123
+ results.extend(data[data_key])
124
+
125
+ continuation_token = data.get("continuationToken")
126
+ if not continuation_token:
127
+ break
128
+
129
+ return results
130
+
131
+ async def get_workspaces(self) -> List[Dict]:
132
+ """Get all available workspaces with caching"""
133
+ cache_key = "workspaces"
134
+ if cache_key in _WORKSPACE_CACHE:
135
+ logger.info(
136
+ f"🎯 CACHE HIT: Returning {len(_WORKSPACE_CACHE[cache_key])} workspaces from cache"
137
+ )
138
+ return _WORKSPACE_CACHE[cache_key]
139
+
140
+ logger.info("🔄 CACHE MISS: Fetching workspaces from Fabric API")
141
+ workspaces = await self.paginated_request("workspaces")
142
+ _WORKSPACE_CACHE[cache_key] = workspaces
143
+ logger.info(f"💾 CACHE STORE: Cached {len(workspaces)} workspaces")
144
+ return workspaces
145
+
146
+ async def get_lakehouses(self, workspace_id: str) -> List[Dict]:
147
+ """Get all lakehouses in a workspace"""
148
+ return await self.paginated_request(
149
+ f"workspaces/{workspace_id}/items", params={"type": "Lakehouse"}
150
+ )
151
+
152
+ async def get_tables(self, workspace_id: str, lakehouse_id: str) -> List[Dict]:
153
+ """Get all tables in a lakehouse"""
154
+ return await self.paginated_request(
155
+ f"workspaces/{workspace_id}/lakehouses/{lakehouse_id}/tables",
156
+ data_key="data",
157
+ )
158
+
159
+ async def resolve_workspace(self, workspace: str) -> str:
160
+ """Convert workspace name or ID to workspace ID with caching"""
161
+ # Check global cache first
162
+ if workspace in _global_workspace_cache:
163
+ return _global_workspace_cache[workspace]
164
+
165
+ # Resolve and cache the result
166
+ result = await self._resolve_workspace(workspace)
167
+ _global_workspace_cache[workspace] = result
168
+ return result
169
+
170
+ async def _resolve_workspace(self, workspace: str) -> str:
171
+ """Internal method to convert workspace name or ID to workspace ID"""
172
+ # If it's already a valid UUID, return it directly
173
+ if is_valid_uuid(workspace):
174
+ return workspace
175
+
176
+ # Otherwise, look up by name
177
+ workspaces = await self.get_workspaces()
178
+ matching_workspaces = [
179
+ w for w in workspaces if w["displayName"].lower() == workspace.lower()
180
+ ]
181
+
182
+ if not matching_workspaces:
183
+ raise ValueError(f"No workspaces found with name: {workspace}")
184
+ if len(matching_workspaces) > 1:
185
+ raise ValueError(f"Multiple workspaces found with name: {workspace}")
186
+
187
+ return matching_workspaces[0]["id"]
188
+
189
+ async def resolve_lakehouse(self, workspace_id: str, lakehouse: str) -> str:
190
+ """Convert lakehouse name or ID to lakehouse ID with caching"""
191
+ # Create cache key combining workspace_id and lakehouse name
192
+ cache_key = f"{workspace_id}:{lakehouse}"
193
+
194
+ # Check global cache first
195
+ if cache_key in _global_lakehouse_cache:
196
+ return _global_lakehouse_cache[cache_key]
197
+
198
+ # Resolve and cache the result
199
+ result = await self._resolve_lakehouse(workspace_id, lakehouse)
200
+ _global_lakehouse_cache[cache_key] = result
201
+ return result
202
+
203
+ async def _resolve_lakehouse(self, workspace_id: str, lakehouse: str) -> str:
204
+ """Internal method to convert lakehouse name or ID to lakehouse ID"""
205
+ if is_valid_uuid(lakehouse):
206
+ # Cache UUID mappings too (workspace_id:UUID -> UUID)
207
+ cache_key = f"{workspace_id}:{lakehouse}"
208
+ _global_lakehouse_cache[cache_key] = lakehouse
209
+ return lakehouse
210
+
211
+ lakehouses = await self.get_lakehouses(workspace_id)
212
+ matching_lakehouses = [
213
+ lh for lh in lakehouses if lh["displayName"].lower() == lakehouse.lower()
214
+ ]
215
+
216
+ if not matching_lakehouses:
217
+ raise ValueError(f"No lakehouse found with name: {lakehouse}")
218
+ if len(matching_lakehouses) > 1:
219
+ raise ValueError(f"Multiple lakehouses found with name: {lakehouse}")
220
+
221
+ return matching_lakehouses[0]["id"]
222
+
223
+ async def get_connections(self) -> List[Dict]:
224
+ """Get all connections user has access to with caching"""
225
+ cache_key = "connections"
226
+ if cache_key in _CONNECTIONS_CACHE:
227
+ logger.info(
228
+ f"🎯 CACHE HIT: Returning {len(_CONNECTIONS_CACHE[cache_key])} connections from cache"
229
+ )
230
+ return _CONNECTIONS_CACHE[cache_key]
231
+
232
+ logger.info("🔄 CACHE MISS: Fetching connections from Fabric API")
233
+ connections = await self.paginated_request("connections")
234
+ _CONNECTIONS_CACHE[cache_key] = connections
235
+ logger.info(f"💾 CACHE STORE: Cached {len(connections)} connections")
236
+ return connections
237
+
238
+ async def get_items(self, workspace_id: str, item_type: str = None) -> List[Dict]:
239
+ """Get all items in workspace, optionally filtered by type with caching"""
240
+ cache_key = f"{workspace_id}:{item_type or 'all'}"
241
+ if cache_key in _ITEMS_CACHE:
242
+ logger.info(
243
+ f"🎯 CACHE HIT: Returning {len(_ITEMS_CACHE[cache_key])} items from cache (key: {cache_key})"
244
+ )
245
+ return _ITEMS_CACHE[cache_key]
246
+
247
+ logger.info(f"🔄 CACHE MISS: Fetching items from Fabric API (key: {cache_key})")
248
+ params = {"type": item_type} if item_type else {}
249
+ items = await self.paginated_request(
250
+ f"workspaces/{workspace_id}/items", params=params
251
+ )
252
+ _ITEMS_CACHE[cache_key] = items
253
+ logger.info(f"💾 CACHE STORE: Cached {len(items)} items (key: {cache_key})")
254
+ return items
255
+
256
+ async def get_item(self, workspace_id: str, item_id: str) -> Dict:
257
+ """Get specific item details"""
258
+ return await self._make_request(f"workspaces/{workspace_id}/items/{item_id}")
259
+
260
+ async def get_workspace_details(self, workspace_id: str) -> Dict:
261
+ """Get detailed workspace information"""
262
+ return await self._make_request(f"workspaces/{workspace_id}")
263
+
264
+ async def get_capacities(self) -> List[Dict]:
265
+ """Get all capacities user has access to with caching"""
266
+ cache_key = "capacities"
267
+ if cache_key in _CAPACITIES_CACHE:
268
+ return _CAPACITIES_CACHE[cache_key]
269
+
270
+ capacities = await self.paginated_request("capacities")
271
+ _CAPACITIES_CACHE[cache_key] = capacities
272
+ return capacities
273
+
274
+ async def get_job_instances(
275
+ self, workspace_id: str, item_id: str = None, status: str = None
276
+ ) -> List[Dict]:
277
+ """Get job instances, optionally filtered by item and status"""
278
+ # Create cache key based on parameters
279
+ cache_key = f"{workspace_id}:{item_id or 'all'}:{status or 'all'}"
280
+ if cache_key in _JOB_INSTANCES_CACHE:
281
+ return _JOB_INSTANCES_CACHE[cache_key]
282
+
283
+ if not item_id:
284
+ # Get all items and collect their job instances
285
+ items = await self.get_items(workspace_id)
286
+ all_jobs = []
287
+ for item in items:
288
+ try:
289
+ jobs = await self.paginated_request(
290
+ f"workspaces/{workspace_id}/items/{item['id']}/jobs/instances"
291
+ )
292
+ for job in jobs:
293
+ job["itemName"] = item["displayName"]
294
+ job["itemType"] = item["type"]
295
+ if (
296
+ not status
297
+ or job.get("status", "").lower() == status.lower()
298
+ ):
299
+ all_jobs.append(job)
300
+ except Exception:
301
+ continue
302
+
303
+ # Cache the results
304
+ _JOB_INSTANCES_CACHE[cache_key] = all_jobs
305
+ return all_jobs
306
+ else:
307
+ jobs = await self.paginated_request(
308
+ f"workspaces/{workspace_id}/items/{item_id}/jobs/instances"
309
+ )
310
+ if status:
311
+ jobs = [
312
+ job
313
+ for job in jobs
314
+ if job.get("status", "").lower() == status.lower()
315
+ ]
316
+
317
+ # Cache the results
318
+ _JOB_INSTANCES_CACHE[cache_key] = jobs
319
+ return jobs
320
+
321
+ async def get_job_instance(
322
+ self, workspace_id: str, item_id: str, job_instance_id: str
323
+ ) -> Dict:
324
+ """Get specific job instance details"""
325
+ return await self._make_request(
326
+ f"workspaces/{workspace_id}/items/{item_id}/jobs/instances/{job_instance_id}"
327
+ )
328
+
329
+ async def get_item_schedules(self, workspace_id: str, item_id: str) -> List[Dict]:
330
+ """Get all schedules for a specific item"""
331
+ # Create cache key for item schedules
332
+ cache_key = f"item:{workspace_id}:{item_id}"
333
+ if cache_key in _SCHEDULES_CACHE:
334
+ return _SCHEDULES_CACHE[cache_key]
335
+
336
+ schedules = await self.paginated_request(
337
+ f"workspaces/{workspace_id}/items/{item_id}/schedules"
338
+ )
339
+
340
+ # Cache the results
341
+ _SCHEDULES_CACHE[cache_key] = schedules
342
+ return schedules
343
+
344
+ async def get_workspace_schedules(self, workspace_id: str) -> List[Dict]:
345
+ """Get all schedules across all items in workspace"""
346
+ # Create cache key for workspace schedules
347
+ cache_key = f"workspace:{workspace_id}"
348
+ if cache_key in _SCHEDULES_CACHE:
349
+ return _SCHEDULES_CACHE[cache_key]
350
+
351
+ items = await self.get_items(workspace_id)
352
+ all_schedules = []
353
+
354
+ for item in items:
355
+ try:
356
+ schedules = await self.paginated_request(
357
+ f"workspaces/{workspace_id}/items/{item['id']}/schedules"
358
+ )
359
+ for schedule in schedules:
360
+ schedule["itemName"] = item["displayName"]
361
+ schedule["itemType"] = item["type"]
362
+ schedule["itemId"] = item["id"]
363
+ all_schedules.append(schedule)
364
+ except Exception:
365
+ continue
366
+
367
+ # Cache the results
368
+ _SCHEDULES_CACHE[cache_key] = all_schedules
369
+ return all_schedules
370
+
371
+ async def get_environments(self, workspace_id: str = None) -> List[Dict]:
372
+ """Get environments, optionally filtered by workspace"""
373
+ # Create cache key based on workspace filter
374
+ cache_key = f"environments:{workspace_id or 'all'}"
375
+ if cache_key in _ENVIRONMENTS_CACHE:
376
+ return _ENVIRONMENTS_CACHE[cache_key]
377
+
378
+ if workspace_id:
379
+ environments = await self.paginated_request(
380
+ f"workspaces/{workspace_id}/items", params={"type": "Environment"}
381
+ )
382
+ else:
383
+ # Get all accessible workspaces and their environments
384
+ workspaces = await self.get_workspaces()
385
+ environments = []
386
+
387
+ for ws in workspaces:
388
+ try:
389
+ ws_environments = await self.paginated_request(
390
+ f"workspaces/{ws['id']}/items", params={"type": "Environment"}
391
+ )
392
+ for env in ws_environments:
393
+ env["workspaceName"] = ws["displayName"]
394
+ environments.append(env)
395
+ except Exception:
396
+ continue
397
+
398
+ # Cache the results
399
+ _ENVIRONMENTS_CACHE[cache_key] = environments
400
+ return environments
401
+
402
+ async def get_environment_details(
403
+ self, workspace_id: str, environment_id: str
404
+ ) -> Dict:
405
+ """Get detailed environment configuration"""
406
+ try:
407
+ environment = await self._make_request(
408
+ f"workspaces/{workspace_id}/items/{environment_id}"
409
+ )
410
+ sparkcompute = await self._make_request(
411
+ f"workspaces/{workspace_id}/environments/{environment_id}/sparkcompute"
412
+ )
413
+ libraries = await self._make_request(
414
+ f"workspaces/{workspace_id}/environments/{environment_id}/libraries"
415
+ )
416
+ return {
417
+ "environment": environment,
418
+ "sparkcompute": sparkcompute,
419
+ "libraries": libraries,
420
+ }
421
+ except Exception:
422
+ # Return partial data if some calls fail
423
+ environment = await self._make_request(
424
+ f"workspaces/{workspace_id}/items/{environment_id}"
425
+ )
426
+ return {"environment": environment, "sparkcompute": None, "libraries": None}
427
+
428
+ async def get_shortcuts(
429
+ self, workspace_id: str, item_id: str, parent_path: str = None
430
+ ) -> List[Dict]:
431
+ """Get OneLake shortcuts for a specific item"""
432
+ # Create cache key based on parameters
433
+ cache_key = f"{workspace_id}:{item_id}:{parent_path or 'root'}"
434
+ if cache_key in _SHORTCUTS_CACHE:
435
+ return _SHORTCUTS_CACHE[cache_key]
436
+
437
+ endpoint = f"workspaces/{workspace_id}/items/{item_id}/shortcuts"
438
+ params = {"path": parent_path} if parent_path else {}
439
+ shortcuts_response = await self._make_request(endpoint, params)
440
+ shortcuts = (
441
+ shortcuts_response.get("shortcuts", []) if shortcuts_response else []
442
+ )
443
+
444
+ # Cache the results
445
+ _SHORTCUTS_CACHE[cache_key] = shortcuts
446
+ return shortcuts
447
+
448
+ async def get_shortcut(
449
+ self,
450
+ workspace_id: str,
451
+ item_id: str,
452
+ shortcut_name: str,
453
+ parent_path: str = None,
454
+ ) -> Dict:
455
+ """Get specific shortcut details"""
456
+ path_segment = f"/{parent_path.strip('/')}" if parent_path else ""
457
+ endpoint = f"workspaces/{workspace_id}/items/{item_id}/shortcuts/{shortcut_name}{path_segment}"
458
+ return await self._make_request(endpoint)
459
+
460
+ async def get_workspace_shortcuts(self, workspace_id: str) -> List[Dict]:
461
+ """Get all shortcuts across all items in workspace"""
462
+ # Create cache key for workspace shortcuts
463
+ cache_key = f"workspace:{workspace_id}"
464
+ if cache_key in _SHORTCUTS_CACHE:
465
+ logger.info(
466
+ f"🎯 CACHE HIT: Returning {len(_SHORTCUTS_CACHE[cache_key])} workspace shortcuts from cache (key: {cache_key})"
467
+ )
468
+ return _SHORTCUTS_CACHE[cache_key]
469
+
470
+ logger.info(
471
+ f"🔄 CACHE MISS: Fetching workspace shortcuts from Fabric API (key: {cache_key})"
472
+ )
473
+ items = await self.get_items(workspace_id)
474
+ all_shortcuts = []
475
+
476
+ for item in items:
477
+ if item["type"] in ["Lakehouse", "KqlDatabase"]:
478
+ try:
479
+ shortcuts_response = await self._make_request(
480
+ f"workspaces/{workspace_id}/items/{item['id']}/shortcuts"
481
+ )
482
+ shortcuts = (
483
+ shortcuts_response.get("shortcuts", [])
484
+ if shortcuts_response
485
+ else []
486
+ )
487
+ for shortcut in shortcuts:
488
+ shortcut["itemName"] = item["displayName"]
489
+ shortcut["itemType"] = item["type"]
490
+ all_shortcuts.append(shortcut)
491
+ except Exception:
492
+ continue
493
+
494
+ # Cache the results
495
+ _SHORTCUTS_CACHE[cache_key] = all_shortcuts
496
+ logger.info(
497
+ f"💾 CACHE STORE: Cached {len(all_shortcuts)} workspace shortcuts (key: {cache_key})"
498
+ )
499
+ return all_shortcuts
500
+
501
+
502
+ def is_valid_uuid(value: str) -> bool:
503
+ """Check if a string is a valid UUID"""
504
+ try:
505
+ uuid.UUID(value)
506
+ return True
507
+ except ValueError:
508
+ return False
509
+
510
+
511
+ async def get_delta_schemas(
512
+ tables: List[Dict], credential: DefaultAzureCredential
513
+ ) -> List[Tuple[Dict, object, object]]:
514
+ """Get schema and metadata for each Delta table"""
515
+ delta_tables = []
516
+ logger.info(f"Starting schema extraction for {len(tables)} tables")
517
+
518
+ # Get token for Azure Storage (not Fabric API)
519
+ token = credential.get_token("https://storage.azure.com/.default").token
520
+ storage_options = {"bearer_token": token, "use_fabric_endpoint": "true"}
521
+
522
+ for table in tables:
523
+ if table["format"].lower() == "delta":
524
+ try:
525
+ table_path = table["location"]
526
+ logger.debug(f"Processing Delta table: {table['name']} at {table_path}")
527
+
528
+ # Create DeltaTable instance with storage options
529
+ delta_table = DeltaTable(table_path, storage_options=storage_options)
530
+
531
+ # Get both schema and metadata
532
+ delta_tables.append(
533
+ (table, delta_table.schema(), delta_table.metadata())
534
+ )
535
+ logger.info(f"Processed table: {table['name']}")
536
+
537
+ except Exception as e:
538
+ logger.error(f"Could not process table {table['name']}: {str(e)}")
539
+
540
+ return delta_tables
541
+
542
+
543
+ def format_metadata_to_markdown(metadata: object) -> str:
544
+ """Convert Delta table metadata to markdown format"""
545
+ markdown = "### Metadata\n\n"
546
+
547
+ markdown += f"**ID:** {metadata.id}\n\n"
548
+ if metadata.name:
549
+ markdown += f"**Name:** {metadata.name}\n\n"
550
+ if metadata.description:
551
+ markdown += f"**Description:** {metadata.description}\n\n"
552
+ if metadata.partition_columns:
553
+ markdown += (
554
+ f"**Partition Columns:** {', '.join(metadata.partition_columns)}\n\n"
555
+ )
556
+ if metadata.created_time:
557
+ created_time = datetime.fromtimestamp(metadata.created_time / 1000)
558
+ markdown += f"**Created:** {created_time.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
559
+
560
+ if metadata.configuration:
561
+ markdown += "**Configuration:**\n\n"
562
+ markdown += "```json\n"
563
+ markdown += json.dumps(metadata.configuration, indent=2)
564
+ markdown += "\n```\n"
565
+
566
+ return markdown
567
+
568
+
569
+ def format_schema_to_markdown(
570
+ table_info: Dict, schema: object, metadata: object
571
+ ) -> str:
572
+ """Convert a Delta table schema and metadata to markdown format"""
573
+ markdown = f"## Delta Table: `{table_info['name']}`\n\n"
574
+ markdown += f"**Type:** {table_info['type']}\n\n"
575
+ markdown += f"**Location:** `{table_info['location']}`\n\n"
576
+
577
+ # Add schema information
578
+ markdown += "### Schema\n\n"
579
+ markdown += "| Column Name | Data Type | Nullable |\n"
580
+ markdown += "|------------|-----------|----------|\n"
581
+
582
+ for field in schema.fields:
583
+ name = field.name
584
+ dtype = field.type
585
+ nullable = field.nullable
586
+ markdown += f"| {name} | {dtype} | {nullable} |\n"
587
+
588
+ markdown += "\n"
589
+
590
+ # Add metadata information
591
+ markdown += format_metadata_to_markdown(metadata)
592
+
593
+ return markdown + "\n"
594
+
595
+
596
+ @mcp.tool()
597
+ async def get_table_schema(workspace: str, lakehouse: str, table_name: str) -> str:
598
+ """Get schema for a specific table in a Fabric lakehouse.
599
+
600
+ Args:
601
+ workspace: Name or ID of the workspace
602
+ lakehouse: Name or ID of the lakehouse
603
+ table_name: Name of the table to retrieve
604
+ """
605
+ try:
606
+ credential = DefaultAzureCredential()
607
+ client = FabricApiClient(credential)
608
+
609
+ # Convert names to IDs
610
+ workspace_id = await client.resolve_workspace(workspace)
611
+ lakehouse_id = await client.resolve_lakehouse(workspace_id, lakehouse)
612
+
613
+ # Get all tables
614
+ tables = await client.get_tables(workspace_id, lakehouse_id)
615
+
616
+ # Find the specific table
617
+ matching_tables = [t for t in tables if t["name"].lower() == table_name.lower()]
618
+
619
+ if not matching_tables:
620
+ return (
621
+ f"No table found with name '{table_name}' in lakehouse '{lakehouse}'."
622
+ )
623
+
624
+ table = matching_tables[0]
625
+
626
+ # Check that it is a Delta table
627
+ if table["format"].lower() != "delta":
628
+ return f"The table '{table_name}' is not a Delta table (format: {table['format']})."
629
+
630
+ # Get schema
631
+ delta_tables = await get_delta_schemas([table], credential)
632
+
633
+ if not delta_tables:
634
+ return f"Could not retrieve schema for table '{table_name}'."
635
+
636
+ # Format result as markdown
637
+ table_info, schema, metadata = delta_tables[0]
638
+ markdown = format_schema_to_markdown(table_info, schema, metadata)
639
+
640
+ return markdown
641
+
642
+ except Exception as e:
643
+ return f"Error retrieving table schema: {str(e)}"
644
+
645
+
646
+ @mcp.tool()
647
+ async def get_all_schemas(workspace: str, lakehouse: str) -> str:
648
+ """Get schemas for all Delta tables in a Fabric lakehouse.
649
+
650
+ Args:
651
+ workspace: Name or ID of the workspace
652
+ lakehouse: Name or ID of the lakehouse
653
+ """
654
+ try:
655
+ credential = DefaultAzureCredential()
656
+ client = FabricApiClient(credential)
657
+
658
+ # Convert names to IDs
659
+ workspace_id = await client.resolve_workspace(workspace)
660
+ lakehouse_id = await client.resolve_lakehouse(workspace_id, lakehouse)
661
+
662
+ # Get all tables
663
+ tables = await client.get_tables(workspace_id, lakehouse_id)
664
+
665
+ if not tables:
666
+ return f"No tables found in lakehouse '{lakehouse}'."
667
+
668
+ # Filter to only Delta tables
669
+ delta_format_tables = [t for t in tables if t["format"].lower() == "delta"]
670
+
671
+ if not delta_format_tables:
672
+ return f"No Delta tables found in lakehouse '{lakehouse}'."
673
+
674
+ # Get schema for all tables
675
+ delta_tables = await get_delta_schemas(delta_format_tables, credential)
676
+
677
+ if not delta_tables:
678
+ return "Could not retrieve schemas for any tables."
679
+
680
+ # Format the result as markdown
681
+ markdown = f"# Delta Table Schemas\n\n"
682
+ markdown += f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
683
+ markdown += f"Workspace: {workspace}\n"
684
+ markdown += f"Lakehouse: {lakehouse}\n\n"
685
+
686
+ for table_info, schema, metadata in delta_tables:
687
+ markdown += format_schema_to_markdown(table_info, schema, metadata)
688
+
689
+ return markdown
690
+
691
+ except Exception as e:
692
+ return f"Error retrieving table schemas: {str(e)}"
693
+
694
+
695
+ @mcp.tool()
696
+ async def list_workspaces() -> str:
697
+ """List all available Fabric workspaces."""
698
+ try:
699
+ credential = DefaultAzureCredential()
700
+ client = FabricApiClient(credential)
701
+
702
+ workspaces = await client.get_workspaces()
703
+
704
+ if not workspaces:
705
+ return "No workspaces found."
706
+
707
+ markdown = "# Fabric Workspaces\n\n"
708
+ markdown += "| ID | Name | Capacity |\n"
709
+ markdown += "|-----|------|----------|\n"
710
+
711
+ for ws in workspaces:
712
+ markdown += f"| {ws['id']} | {ws['displayName']} | {ws.get('capacityId', 'N/A')} |\n"
713
+
714
+ return markdown
715
+
716
+ except Exception as e:
717
+ return f"Error listing workspaces: {str(e)}"
718
+
719
+
720
+ @mcp.tool()
721
+ async def list_lakehouses(workspace: str) -> str:
722
+ """List all lakehouses in a Fabric workspace.
723
+
724
+ Args:
725
+ workspace: Name or ID of the workspace
726
+ """
727
+ try:
728
+ credential = DefaultAzureCredential()
729
+ client = FabricApiClient(credential)
730
+
731
+ # Convert name to ID
732
+ workspace_id = await client.resolve_workspace(workspace)
733
+
734
+ lakehouses = await client.get_lakehouses(workspace_id)
735
+
736
+ if not lakehouses:
737
+ return f"No lakehouses found in workspace '{workspace}'."
738
+
739
+ markdown = f"# Lakehouses in workspace '{workspace}'\n\n"
740
+ markdown += "| ID | Name |\n"
741
+ markdown += "|-----|------|\n"
742
+
743
+ for lh in lakehouses:
744
+ markdown += f"| {lh['id']} | {lh['displayName']} |\n"
745
+
746
+ return markdown
747
+
748
+ except Exception as e:
749
+ return f"Error listing lakehouses: {str(e)}"
750
+
751
+
752
+ @mcp.tool()
753
+ async def list_tables(workspace: str, lakehouse: str) -> str:
754
+ """List all tables in a Fabric lakehouse.
755
+
756
+ Args:
757
+ workspace: Name or ID of the workspace
758
+ lakehouse: Name or ID of the lakehouse
759
+ """
760
+ try:
761
+ credential = DefaultAzureCredential()
762
+ client = FabricApiClient(credential)
763
+
764
+ # Convert names to IDs
765
+ workspace_id = await client.resolve_workspace(workspace)
766
+ lakehouse_id = await client.resolve_lakehouse(workspace_id, lakehouse)
767
+
768
+ tables = await client.get_tables(workspace_id, lakehouse_id)
769
+
770
+ if not tables:
771
+ return f"No tables found in lakehouse '{lakehouse}'."
772
+
773
+ markdown = f"# Tables in lakehouse '{lakehouse}'\n\n"
774
+ markdown += "| Name | Format | Type |\n"
775
+ markdown += "|------|--------|------|\n"
776
+
777
+ for table in tables:
778
+ markdown += f"| {table['name']} | {table['format']} | {table['type']} |\n"
779
+
780
+ return markdown
781
+
782
+ except Exception as e:
783
+ return f"Error listing tables: {str(e)}"
784
+
785
+
786
+ @mcp.tool()
787
+ async def list_connections() -> str:
788
+ """List all connections the user has permission for across the entire Fabric tenant (READ-ONLY).
789
+
790
+ ⚠️ SECURITY LIMITATION: This only returns connections the authenticated user/service principal
791
+ has permission for. To get ALL tenant connections, you need:
792
+ 1. Service Principal with broader workspace access, OR
793
+ 2. Admin-level API access (if available), OR
794
+ 3. Aggregate results from multiple users
795
+
796
+ This returns ALL connections the user can access, not limited to any specific workspace.
797
+ """
798
+ try:
799
+ credential = DefaultAzureCredential()
800
+ client = FabricApiClient(credential)
801
+
802
+ # Get connections from the connections endpoint (limited to user's permissions)
803
+ connections = await client.get_connections()
804
+
805
+ if not connections:
806
+ return "No connections found for the current user/service principal."
807
+
808
+ markdown = "# Fabric Connections (User-Scoped)\n\n"
809
+ markdown += f"**Total Connections Accessible:** {len(connections)}\n\n"
810
+
811
+ # Add security warning
812
+ markdown += "⚠️ **Security Note**: This list only includes connections you have permission to access.\n"
813
+ markdown += "To get ALL tenant connections, consider using a Service Principal with broader permissions.\n\n"
814
+
815
+ markdown += "| ID | Display Name | Type | Connectivity Type | Privacy Level |\n"
816
+ markdown += "|-----|--------------|------|------------------|---------------|\n"
817
+
818
+ for conn in connections:
819
+ conn_type = conn.get("connectionDetails", {}).get("type", "N/A")
820
+ connectivity_type = conn.get("connectivityType", "N/A")
821
+ privacy_level = conn.get("privacyLevel", "N/A")
822
+ markdown += f"| {conn['id']} | {conn['displayName']} | {conn_type} | {connectivity_type} | {privacy_level} |\n"
823
+
824
+ markdown += "\n## Getting More Connections\n\n"
825
+ markdown += "To access more connections across the tenant:\n\n"
826
+ markdown += "1. **Service Principal Approach**: Use a Service Principal with access to more workspaces\n"
827
+ markdown += "2. **Multi-User Aggregation**: Run this tool with different user credentials and combine results\n"
828
+ markdown += "3. **Admin Access**: Check if your organization has Admin APIs enabled for broader access\n"
829
+
830
+ return markdown
831
+
832
+ except Exception as e:
833
+ return f"Error listing connections: {str(e)}"
834
+
835
+
836
+ @mcp.tool()
837
+ async def list_items(workspace: str, item_type: str = None) -> str:
838
+ """List all items in a Fabric workspace (READ-ONLY).
839
+
840
+ Args:
841
+ workspace: Name or ID of the workspace
842
+ item_type: Optional filter by item type (e.g., 'Lakehouse', 'Notebook', 'DataPipeline')
843
+ """
844
+ try:
845
+ credential = DefaultAzureCredential()
846
+ client = FabricApiClient(credential)
847
+
848
+ # Convert name to ID
849
+ workspace_id = await client.resolve_workspace(workspace)
850
+
851
+ # Get items with optional type filter
852
+ items = await client.get_items(workspace_id, item_type)
853
+
854
+ if not items:
855
+ return f"No items found in workspace '{workspace}'."
856
+
857
+ markdown = f"# Items in workspace '{workspace}'\n\n"
858
+ if item_type:
859
+ markdown += f"Filtered by type: **{item_type}**\n\n"
860
+
861
+ markdown += "| ID | Display Name | Type | Description |\n"
862
+ markdown += "|-----|--------------|------|-------------|\n"
863
+
864
+ for item in items:
865
+ description = item.get("description", "N/A")
866
+ markdown += f"| {item['id']} | {item['displayName']} | {item['type']} | {description} |\n"
867
+
868
+ return markdown
869
+
870
+ except Exception as e:
871
+ return f"Error listing items: {str(e)}"
872
+
873
+
874
+ @mcp.tool()
875
+ async def get_item(workspace: str, item_id: str) -> str:
876
+ """Get details of a specific Fabric item (READ-ONLY).
877
+
878
+ Args:
879
+ workspace: Name or ID of the workspace
880
+ item_id: ID of the item to retrieve
881
+ """
882
+ try:
883
+ credential = DefaultAzureCredential()
884
+ client = FabricApiClient(credential)
885
+
886
+ # Convert name to ID
887
+ workspace_id = await client.resolve_workspace(workspace)
888
+
889
+ # Get item details
890
+ item = await client.get_item(workspace_id, item_id)
891
+
892
+ if not item:
893
+ return f"Item '{item_id}' not found in workspace '{workspace}'."
894
+
895
+ markdown = f"# Item Details\n\n"
896
+ markdown += f"**ID:** {item['id']}\n\n"
897
+ markdown += f"**Display Name:** {item['displayName']}\n\n"
898
+ markdown += f"**Type:** {item['type']}\n\n"
899
+ markdown += f"**Workspace ID:** {item['workspaceId']}\n\n"
900
+
901
+ if item.get("description"):
902
+ markdown += f"**Description:** {item['description']}\n\n"
903
+
904
+ return markdown
905
+
906
+ except Exception as e:
907
+ return f"Error getting item details: {str(e)}"
908
+
909
+
910
+ @mcp.tool()
911
+ async def get_workspace(workspace: str) -> str:
912
+ """Get details of a specific Fabric workspace (READ-ONLY).
913
+
914
+ Args:
915
+ workspace: Name or ID of the workspace
916
+ """
917
+ try:
918
+ credential = DefaultAzureCredential()
919
+ client = FabricApiClient(credential)
920
+
921
+ # Convert name to ID
922
+ workspace_id = await client.resolve_workspace(workspace)
923
+
924
+ # Get workspace details
925
+ workspace_details = await client._make_request(f"workspaces/{workspace_id}")
926
+
927
+ if not workspace_details:
928
+ return f"Workspace '{workspace}' not found."
929
+
930
+ markdown = f"# Workspace Details\n\n"
931
+ markdown += f"**ID:** {workspace_details['id']}\n\n"
932
+ markdown += f"**Display Name:** {workspace_details['displayName']}\n\n"
933
+ markdown += f"**Type:** {workspace_details.get('type', 'N/A')}\n\n"
934
+
935
+ if workspace_details.get("description"):
936
+ markdown += f"**Description:** {workspace_details['description']}\n\n"
937
+
938
+ if workspace_details.get("capacityId"):
939
+ markdown += f"**Capacity ID:** {workspace_details['capacityId']}\n\n"
940
+
941
+ return markdown
942
+
943
+ except Exception as e:
944
+ return f"Error getting workspace details: {str(e)}"
945
+
946
+
947
+ @mcp.tool()
948
+ async def list_capacities() -> str:
949
+ """List all Fabric capacities the user has access to (READ-ONLY)."""
950
+ try:
951
+ credential = DefaultAzureCredential()
952
+ client = FabricApiClient(credential)
953
+
954
+ # Get capacities
955
+ capacities = await client.get_capacities()
956
+
957
+ if not capacities:
958
+ return "No capacities found."
959
+
960
+ markdown = "# Fabric Capacities\n\n"
961
+ markdown += "| ID | Display Name | SKU | Region | State |\n"
962
+ markdown += "|-----|--------------|-----|--------|-------|\n"
963
+
964
+ for capacity in capacities:
965
+ sku = capacity.get("sku", "N/A")
966
+ region = capacity.get("region", "N/A")
967
+ state = capacity.get("state", "N/A")
968
+ markdown += f"| {capacity['id']} | {capacity['displayName']} | {sku} | {region} | {state} |\n"
969
+
970
+ return markdown
971
+
972
+ except Exception as e:
973
+ return f"Error listing capacities: {str(e)}"
974
+
975
+
976
+ @mcp.tool()
977
+ async def list_workspaces_with_identity() -> str:
978
+ """List workspaces that have workspace identities configured (READ-ONLY).
979
+
980
+ This identifies which workspaces have workspace identities for secure authentication.
981
+ """
982
+ try:
983
+ credential = DefaultAzureCredential()
984
+ client = FabricApiClient(credential)
985
+
986
+ # Get all workspaces
987
+ workspaces = await client.get_workspaces()
988
+
989
+ if not workspaces:
990
+ return "No workspaces found."
991
+
992
+ workspaces_with_identity = []
993
+
994
+ # Check each workspace for workspace identity
995
+ for workspace in workspaces:
996
+ try:
997
+ # Try to get workspace details which might include identity info
998
+ workspace_details = await client._make_request(
999
+ f"workspaces/{workspace['id']}"
1000
+ )
1001
+
1002
+ # Check if workspace has identity-related properties
1003
+ # Note: The exact property name may vary based on API response
1004
+ has_identity = False
1005
+ identity_info = {}
1006
+
1007
+ # Check for various possible identity indicators
1008
+ if workspace_details:
1009
+ # Look for identity-related fields
1010
+ if (
1011
+ workspace_details.get("hasWorkspaceIdentity")
1012
+ or workspace_details.get("workspaceIdentity")
1013
+ or workspace_details.get("identityId")
1014
+ ):
1015
+ has_identity = True
1016
+ identity_info = {
1017
+ "hasIdentity": workspace_details.get(
1018
+ "hasWorkspaceIdentity", True
1019
+ ),
1020
+ "identityId": workspace_details.get("identityId", "N/A"),
1021
+ "identityState": workspace_details.get(
1022
+ "identityState", "N/A"
1023
+ ),
1024
+ }
1025
+
1026
+ if has_identity:
1027
+ workspaces_with_identity.append(
1028
+ {"workspace": workspace, "identity": identity_info}
1029
+ )
1030
+
1031
+ except Exception as e:
1032
+ # Skip workspaces we can't access
1033
+ logger.debug(
1034
+ f"Could not check identity for workspace {workspace['displayName']}: {str(e)}"
1035
+ )
1036
+ continue
1037
+
1038
+ if not workspaces_with_identity:
1039
+ return "No workspaces with workspace identities found (or user lacks permission to view identity details)."
1040
+
1041
+ markdown = "# Workspaces with Workspace Identities\n\n"
1042
+ markdown += "| Workspace ID | Workspace Name | Capacity | Identity State |\n"
1043
+ markdown += "|--------------|----------------|----------|----------------|\n"
1044
+
1045
+ for item in workspaces_with_identity:
1046
+ workspace = item["workspace"]
1047
+ identity = item["identity"]
1048
+ capacity = workspace.get("capacityId", "N/A")
1049
+ identity_state = identity.get("identityState", "Active")
1050
+
1051
+ markdown += f"| {workspace['id']} | {workspace['displayName']} | {capacity} | {identity_state} |\n"
1052
+
1053
+ markdown += (
1054
+ f"\n**Total workspaces with identities:** {len(workspaces_with_identity)}\n"
1055
+ )
1056
+ markdown += "\n*Note: This tool identifies workspaces with workspace identities based on available API data. Some identity details may require admin permissions.*\n"
1057
+
1058
+ return markdown
1059
+
1060
+ except Exception as e:
1061
+ return f"Error listing workspaces with identities: {str(e)}"
1062
+
1063
+
1064
+ @mcp.tool()
1065
+ async def get_workspace_identity(workspace: str) -> str:
1066
+ """Get workspace identity details for a specific workspace (READ-ONLY).
1067
+
1068
+ Args:
1069
+ workspace: Name or ID of the workspace
1070
+ """
1071
+ try:
1072
+ credential = DefaultAzureCredential()
1073
+ client = FabricApiClient(credential)
1074
+
1075
+ # Convert name to ID
1076
+ workspace_id = await client.resolve_workspace(workspace)
1077
+
1078
+ # Get workspace details
1079
+ workspace_details = await client._make_request(f"workspaces/{workspace_id}")
1080
+
1081
+ if not workspace_details:
1082
+ return f"Workspace '{workspace}' not found."
1083
+
1084
+ markdown = f"# Workspace Identity Details for '{workspace}'\n\n"
1085
+ markdown += f"**Workspace ID:** {workspace_details['id']}\n\n"
1086
+ markdown += f"**Workspace Name:** {workspace_details['displayName']}\n\n"
1087
+
1088
+ # Check for workspace identity information
1089
+ has_identity = (
1090
+ workspace_details.get("hasWorkspaceIdentity")
1091
+ or workspace_details.get("workspaceIdentity")
1092
+ or workspace_details.get("identityId")
1093
+ )
1094
+
1095
+ if has_identity:
1096
+ markdown += "## ✅ Workspace Identity Configured\n\n"
1097
+
1098
+ if workspace_details.get("identityId"):
1099
+ markdown += f"**Identity ID:** {workspace_details['identityId']}\n\n"
1100
+
1101
+ if workspace_details.get("identityState"):
1102
+ markdown += (
1103
+ f"**Identity State:** {workspace_details['identityState']}\n\n"
1104
+ )
1105
+
1106
+ if workspace_details.get("workspaceIdentity"):
1107
+ identity = workspace_details["workspaceIdentity"]
1108
+ if isinstance(identity, dict):
1109
+ markdown += "**Identity Details:**\n\n"
1110
+ for key, value in identity.items():
1111
+ markdown += f"- **{key}:** {value}\n"
1112
+ markdown += "\n"
1113
+ else:
1114
+ markdown += "## ❌ No Workspace Identity Configured\n\n"
1115
+ markdown += (
1116
+ "This workspace does not have a workspace identity configured.\n\n"
1117
+ )
1118
+
1119
+ # Add information about workspace identity benefits
1120
+ markdown += "## About Workspace Identity\n\n"
1121
+ markdown += "Workspace identity provides:\n"
1122
+ markdown += "- Secure authentication without managing credentials\n"
1123
+ markdown += "- Trusted workspace access to firewall-enabled storage accounts\n"
1124
+ markdown += "- Integration with Microsoft Entra ID\n"
1125
+ markdown += (
1126
+ "- Support for OneLake shortcuts, data pipelines, and semantic models\n"
1127
+ )
1128
+
1129
+ return markdown
1130
+
1131
+ except Exception as e:
1132
+ return f"Error getting workspace identity details: {str(e)}"
1133
+
1134
+
1135
+ @mcp.tool()
1136
+ async def list_shortcuts(
1137
+ workspace: str, item_name: str, parent_path: str = None
1138
+ ) -> str:
1139
+ """List all OneLake shortcuts in a specific Fabric item (READ-ONLY).
1140
+
1141
+ Args:
1142
+ workspace: Name or ID of the workspace
1143
+ item_name: Name or ID of the item (Lakehouse, KQL Database, etc.)
1144
+ parent_path: Optional parent path to filter shortcuts (e.g., 'Files', 'Tables')
1145
+ """
1146
+ try:
1147
+ credential = DefaultAzureCredential()
1148
+ client = FabricApiClient(credential)
1149
+
1150
+ # Convert names to IDs
1151
+ workspace_id = await client.resolve_workspace(workspace)
1152
+
1153
+ # Get all items to find the specified item
1154
+ items = await client.paginated_request(f"workspaces/{workspace_id}/items")
1155
+
1156
+ # Find the target item
1157
+ target_item = None
1158
+ for item in items:
1159
+ if (
1160
+ item["displayName"].lower() == item_name.lower()
1161
+ or item["id"] == item_name
1162
+ ):
1163
+ target_item = item
1164
+ break
1165
+
1166
+ if not target_item:
1167
+ return f"Item '{item_name}' not found in workspace '{workspace}'."
1168
+
1169
+ # Build shortcuts endpoint
1170
+ endpoint = f"workspaces/{workspace_id}/items/{target_item['id']}/shortcuts"
1171
+ params = {}
1172
+ if parent_path:
1173
+ params["parentPath"] = parent_path
1174
+
1175
+ # Get shortcuts
1176
+ shortcuts_response = await client._make_request(endpoint, params)
1177
+
1178
+ if not shortcuts_response or not shortcuts_response.get("value"):
1179
+ return f"No shortcuts found in item '{item_name}'."
1180
+
1181
+ shortcuts = shortcuts_response["value"]
1182
+
1183
+ markdown = f"# OneLake Shortcuts in '{item_name}'\n\n"
1184
+ markdown += f"**Workspace:** {workspace}\n"
1185
+ markdown += f"**Item Type:** {target_item['type']}\n"
1186
+ if parent_path:
1187
+ markdown += f"**Parent Path:** {parent_path}\n"
1188
+ markdown += f"**Total Shortcuts:** {len(shortcuts)}\n\n"
1189
+
1190
+ markdown += "| Name | Path | Target Type | Target Details |\n"
1191
+ markdown += "|------|------|-------------|----------------|\n"
1192
+
1193
+ for shortcut in shortcuts:
1194
+ name = shortcut["name"]
1195
+ path = shortcut["path"]
1196
+ target = shortcut["target"]
1197
+ target_type = target["type"]
1198
+
1199
+ # Build target details based on type
1200
+ target_details = "N/A"
1201
+ if target_type == "OneLake" and target.get("oneLake"):
1202
+ onelake = target["oneLake"]
1203
+ target_details = f"Workspace: {onelake.get('workspaceId', 'N/A')}, Item: {onelake.get('itemId', 'N/A')}"
1204
+ elif target_type == "AmazonS3" and target.get("amazonS3"):
1205
+ s3 = target["amazonS3"]
1206
+ target_details = f"Location: {s3.get('location', 'N/A')}"
1207
+ elif target_type == "AdlsGen2" and target.get("adlsGen2"):
1208
+ adls = target["adlsGen2"]
1209
+ target_details = f"Location: {adls.get('location', 'N/A')}"
1210
+ elif target_type == "GoogleCloudStorage" and target.get(
1211
+ "googleCloudStorage"
1212
+ ):
1213
+ gcs = target["googleCloudStorage"]
1214
+ target_details = f"Location: {gcs.get('location', 'N/A')}"
1215
+ elif target_type == "AzureBlobStorage" and target.get("azureBlobStorage"):
1216
+ blob = target["azureBlobStorage"]
1217
+ target_details = f"Location: {blob.get('location', 'N/A')}"
1218
+
1219
+ markdown += f"| {name} | {path} | {target_type} | {target_details} |\n"
1220
+
1221
+ # Add information about shortcut types
1222
+ markdown += "\n## About OneLake Shortcuts\n\n"
1223
+ markdown += "OneLake shortcuts provide references to data stored in:\n"
1224
+ markdown += "- **OneLake**: Other Fabric items (Lakehouses, KQL Databases)\n"
1225
+ markdown += "- **External Storage**: Amazon S3, Azure Data Lake Gen2, Google Cloud Storage, etc.\n"
1226
+ markdown += "- **Shortcuts appear as folders** and can be accessed by Spark, SQL, and other services\n"
1227
+
1228
+ return markdown
1229
+
1230
+ except Exception as e:
1231
+ return f"Error listing shortcuts: {str(e)}"
1232
+
1233
+
1234
+ @mcp.tool()
1235
+ async def get_shortcut(
1236
+ workspace: str, item_name: str, shortcut_path: str, shortcut_name: str
1237
+ ) -> str:
1238
+ """Get detailed information about a specific OneLake shortcut (READ-ONLY).
1239
+
1240
+ Args:
1241
+ workspace: Name or ID of the workspace
1242
+ item_name: Name or ID of the item containing the shortcut
1243
+ shortcut_path: Path where the shortcut is located (e.g., 'Files', 'Tables/subfolder')
1244
+ shortcut_name: Name of the shortcut
1245
+ """
1246
+ try:
1247
+ credential = DefaultAzureCredential()
1248
+ client = FabricApiClient(credential)
1249
+
1250
+ # Convert names to IDs
1251
+ workspace_id = await client.resolve_workspace(workspace)
1252
+
1253
+ # Get all items to find the specified item
1254
+ items = await client.paginated_request(f"workspaces/{workspace_id}/items")
1255
+
1256
+ # Find the target item
1257
+ target_item = None
1258
+ for item in items:
1259
+ if (
1260
+ item["displayName"].lower() == item_name.lower()
1261
+ or item["id"] == item_name
1262
+ ):
1263
+ target_item = item
1264
+ break
1265
+
1266
+ if not target_item:
1267
+ return f"Item '{item_name}' not found in workspace '{workspace}'."
1268
+
1269
+ # Get shortcut details
1270
+ endpoint = f"workspaces/{workspace_id}/items/{target_item['id']}/shortcuts/{shortcut_path}/{shortcut_name}"
1271
+ shortcut = await client._make_request(endpoint)
1272
+
1273
+ if not shortcut:
1274
+ return f"Shortcut '{shortcut_name}' not found at path '{shortcut_path}' in item '{item_name}'."
1275
+
1276
+ markdown = f"# OneLake Shortcut Details\n\n"
1277
+ markdown += f"**Shortcut Name:** {shortcut['name']}\n\n"
1278
+ markdown += f"**Path:** {shortcut['path']}\n\n"
1279
+ markdown += f"**Workspace:** {workspace}\n\n"
1280
+ markdown += f"**Item:** {item_name} ({target_item['type']})\n\n"
1281
+
1282
+ # Target information
1283
+ target = shortcut["target"]
1284
+ target_type = target["type"]
1285
+ markdown += f"**Target Type:** {target_type}\n\n"
1286
+
1287
+ markdown += "## Target Configuration\n\n"
1288
+
1289
+ if target_type == "OneLake" and target.get("oneLake"):
1290
+ onelake = target["oneLake"]
1291
+ markdown += (
1292
+ f"**Target Workspace ID:** {onelake.get('workspaceId', 'N/A')}\n\n"
1293
+ )
1294
+ markdown += f"**Target Item ID:** {onelake.get('itemId', 'N/A')}\n\n"
1295
+ markdown += f"**Target Path:** {onelake.get('path', 'N/A')}\n\n"
1296
+ if onelake.get("connectionId"):
1297
+ markdown += f"**Connection ID:** {onelake['connectionId']}\n\n"
1298
+
1299
+ elif target_type == "AmazonS3" and target.get("amazonS3"):
1300
+ s3 = target["amazonS3"]
1301
+ markdown += f"**S3 Location:** {s3.get('location', 'N/A')}\n\n"
1302
+ markdown += f"**Subpath:** {s3.get('subpath', 'N/A')}\n\n"
1303
+ markdown += f"**Connection ID:** {s3.get('connectionId', 'N/A')}\n\n"
1304
+
1305
+ elif target_type == "AdlsGen2" and target.get("adlsGen2"):
1306
+ adls = target["adlsGen2"]
1307
+ markdown += f"**ADLS Location:** {adls.get('location', 'N/A')}\n\n"
1308
+ markdown += f"**Subpath:** {adls.get('subpath', 'N/A')}\n\n"
1309
+ markdown += f"**Connection ID:** {adls.get('connectionId', 'N/A')}\n\n"
1310
+
1311
+ elif target_type == "GoogleCloudStorage" and target.get("googleCloudStorage"):
1312
+ gcs = target["googleCloudStorage"]
1313
+ markdown += f"**GCS Location:** {gcs.get('location', 'N/A')}\n\n"
1314
+ markdown += f"**Subpath:** {gcs.get('subpath', 'N/A')}\n\n"
1315
+ markdown += f"**Connection ID:** {gcs.get('connectionId', 'N/A')}\n\n"
1316
+
1317
+ elif target_type == "AzureBlobStorage" and target.get("azureBlobStorage"):
1318
+ blob = target["azureBlobStorage"]
1319
+ markdown += f"**Blob Storage Location:** {blob.get('location', 'N/A')}\n\n"
1320
+ markdown += f"**Subpath:** {blob.get('subpath', 'N/A')}\n\n"
1321
+ markdown += f"**Connection ID:** {blob.get('connectionId', 'N/A')}\n\n"
1322
+
1323
+ elif target_type == "Dataverse" and target.get("dataverse"):
1324
+ dv = target["dataverse"]
1325
+ markdown += (
1326
+ f"**Environment Domain:** {dv.get('environmentDomain', 'N/A')}\n\n"
1327
+ )
1328
+ markdown += f"**Table Name:** {dv.get('tableName', 'N/A')}\n\n"
1329
+ markdown += f"**Delta Lake Folder:** {dv.get('deltaLakeFolder', 'N/A')}\n\n"
1330
+ markdown += f"**Connection ID:** {dv.get('connectionId', 'N/A')}\n\n"
1331
+
1332
+ # Transform information (if any)
1333
+ if shortcut.get("transform"):
1334
+ transform = shortcut["transform"]
1335
+ markdown += "## Transform Configuration\n\n"
1336
+ markdown += f"**Transform Type:** {transform.get('type', 'N/A')}\n\n"
1337
+
1338
+ if transform.get("properties"):
1339
+ props = transform["properties"]
1340
+ markdown += "**Transform Properties:**\n\n"
1341
+ for key, value in props.items():
1342
+ markdown += f"- **{key}:** {value}\n"
1343
+ markdown += "\n"
1344
+
1345
+ # Access information
1346
+ markdown += "## Access Information\n\n"
1347
+ markdown += "This shortcut can be accessed through:\n"
1348
+ markdown += "- **Apache Spark**: Use relative paths or SQL syntax\n"
1349
+ markdown += (
1350
+ "- **SQL Analytics Endpoint**: Query through T-SQL (if in Tables folder)\n"
1351
+ )
1352
+ markdown += "- **OneLake API**: Direct file access via REST API\n"
1353
+ markdown += "- **External Tools**: Any tool supporting ADLS Gen2 APIs\n"
1354
+
1355
+ return markdown
1356
+
1357
+ except Exception as e:
1358
+ return f"Error getting shortcut details: {str(e)}"
1359
+
1360
+
1361
+ @mcp.tool()
1362
+ async def list_workspace_shortcuts(workspace: str) -> str:
1363
+ """List all OneLake shortcuts across all items in a workspace (READ-ONLY).
1364
+
1365
+ This aggregates shortcuts from all Lakehouses and KQL Databases in the workspace.
1366
+
1367
+ Args:
1368
+ workspace: Name or ID of the workspace
1369
+ """
1370
+ try:
1371
+ credential = DefaultAzureCredential()
1372
+ client = FabricApiClient(credential)
1373
+
1374
+ # Convert name to ID
1375
+ workspace_id = await client.resolve_workspace(workspace)
1376
+
1377
+ # Get all items in workspace that can contain shortcuts
1378
+ items = await client.paginated_request(f"workspaces/{workspace_id}/items")
1379
+
1380
+ # Filter to items that can contain shortcuts (Lakehouse, KQLDatabase)
1381
+ shortcut_items = [
1382
+ item for item in items if item["type"] in ["Lakehouse", "KQLDatabase"]
1383
+ ]
1384
+
1385
+ if not shortcut_items:
1386
+ return (
1387
+ f"No items that can contain shortcuts found in workspace '{workspace}'."
1388
+ )
1389
+
1390
+ all_shortcuts = []
1391
+ total_shortcuts = 0
1392
+
1393
+ # Get shortcuts from each item
1394
+ for item in shortcut_items:
1395
+ try:
1396
+ endpoint = f"workspaces/{workspace_id}/items/{item['id']}/shortcuts"
1397
+ shortcuts_response = await client._make_request(endpoint)
1398
+
1399
+ if shortcuts_response and shortcuts_response.get("value"):
1400
+ item_shortcuts = shortcuts_response["value"]
1401
+ total_shortcuts += len(item_shortcuts)
1402
+
1403
+ for shortcut in item_shortcuts:
1404
+ shortcut["_item_name"] = item["displayName"]
1405
+ shortcut["_item_type"] = item["type"]
1406
+ shortcut["_item_id"] = item["id"]
1407
+
1408
+ all_shortcuts.extend(item_shortcuts)
1409
+
1410
+ except Exception as e:
1411
+ # Skip items we can't access
1412
+ logger.debug(
1413
+ f"Could not get shortcuts for item {item['displayName']}: {str(e)}"
1414
+ )
1415
+ continue
1416
+
1417
+ if not all_shortcuts:
1418
+ return f"No shortcuts found in any items in workspace '{workspace}'."
1419
+
1420
+ markdown = f"# OneLake Shortcuts in Workspace '{workspace}'\n\n"
1421
+ markdown += f"**Total Items with Shortcuts:** {len([item for item in shortcut_items if any(s.get('_item_id') == item['id'] for s in all_shortcuts)])}\n"
1422
+ markdown += f"**Total Shortcuts:** {total_shortcuts}\n\n"
1423
+
1424
+ # Group shortcuts by item
1425
+ from collections import defaultdict
1426
+
1427
+ shortcuts_by_item = defaultdict(list)
1428
+ for shortcut in all_shortcuts:
1429
+ item_name = shortcut.get("_item_name", "Unknown")
1430
+ shortcuts_by_item[item_name].append(shortcut)
1431
+
1432
+ for item_name, shortcuts in shortcuts_by_item.items():
1433
+ item_type = shortcuts[0].get("_item_type", "Unknown")
1434
+ markdown += f"## {item_name} ({item_type})\n\n"
1435
+ markdown += f"**Shortcuts in this item:** {len(shortcuts)}\n\n"
1436
+
1437
+ markdown += "| Name | Path | Target Type | Target Details |\n"
1438
+ markdown += "|------|------|-------------|----------------|\n"
1439
+
1440
+ for shortcut in shortcuts:
1441
+ name = shortcut["name"]
1442
+ path = shortcut["path"]
1443
+ target = shortcut["target"]
1444
+ target_type = target["type"]
1445
+
1446
+ # Build target details based on type
1447
+ target_details = "N/A"
1448
+ if target_type == "OneLake" and target.get("oneLake"):
1449
+ onelake = target["oneLake"]
1450
+ target_details = f"Item: {onelake.get('itemId', 'N/A')[:8]}..."
1451
+ elif target_type in [
1452
+ "AmazonS3",
1453
+ "AdlsGen2",
1454
+ "GoogleCloudStorage",
1455
+ "AzureBlobStorage",
1456
+ ]:
1457
+ # Get location from the appropriate target type
1458
+ target_obj = target.get(target_type.lower()) or target.get(
1459
+ target_type
1460
+ )
1461
+ if target_obj and target_obj.get("location"):
1462
+ target_details = (
1463
+ target_obj["location"][:50] + "..."
1464
+ if len(target_obj["location"]) > 50
1465
+ else target_obj["location"]
1466
+ )
1467
+
1468
+ markdown += f"| {name} | {path} | {target_type} | {target_details} |\n"
1469
+
1470
+ markdown += "\n"
1471
+
1472
+ # Summary by target type
1473
+ target_types = defaultdict(int)
1474
+ for shortcut in all_shortcuts:
1475
+ target_types[shortcut["target"]["type"]] += 1
1476
+
1477
+ markdown += "## Summary by Target Type\n\n"
1478
+ for target_type, count in sorted(target_types.items()):
1479
+ markdown += f"- **{target_type}**: {count} shortcuts\n"
1480
+
1481
+ markdown += "\n*Note: This aggregates shortcuts from all Lakehouses and KQL Databases in the workspace.*\n"
1482
+
1483
+ return markdown
1484
+
1485
+ except Exception as e:
1486
+ return f"Error listing workspace shortcuts: {str(e)}"
1487
+
1488
+
1489
+ @mcp.tool()
1490
+ async def list_job_instances(
1491
+ workspace: str, item_name: str = None, status: str = None
1492
+ ) -> str:
1493
+ """List all job instances for items in a workspace (READ-ONLY).
1494
+
1495
+ Shows what's running, queued, completed, or failed - perfect for monitoring!
1496
+
1497
+ Args:
1498
+ workspace: Name or ID of the workspace
1499
+ item_name: Optional item name to filter jobs for specific item
1500
+ status: Optional status filter (NotStarted, InProgress, Completed, Failed, Cancelled)
1501
+ """
1502
+ try:
1503
+ credential = DefaultAzureCredential()
1504
+ client = FabricApiClient(credential)
1505
+
1506
+ # Convert names to IDs
1507
+ workspace_id = await client.resolve_workspace(workspace)
1508
+
1509
+ # Resolve item_name to item_id if provided
1510
+ item_id = None
1511
+ if item_name:
1512
+ items = await client.get_items(workspace_id)
1513
+ matching_items = [
1514
+ item
1515
+ for item in items
1516
+ if item_name.lower() in item["displayName"].lower()
1517
+ ]
1518
+ if not matching_items:
1519
+ return (
1520
+ f"No items matching '{item_name}' found in workspace '{workspace}'."
1521
+ )
1522
+ item_id = matching_items[0]["id"]
1523
+
1524
+ # Get job instances using client method
1525
+ all_jobs = await client.get_job_instances(workspace_id, item_id, status)
1526
+
1527
+ if not all_jobs:
1528
+ return f"No job instances found in workspace '{workspace}'."
1529
+
1530
+ # Sort by start time (most recent first)
1531
+ all_jobs.sort(key=lambda x: x.get("startTimeUtc", ""), reverse=True)
1532
+
1533
+ markdown = f"# Job Instances in '{workspace}'\n\n"
1534
+ if status:
1535
+ markdown += f"**Status Filter**: {status}\n\n"
1536
+ if item_name:
1537
+ markdown += f"**Item Filter**: {item_name}\n\n"
1538
+
1539
+ markdown += "| Item | Type | Job Type | Status | Invoke Type | Start Time | Duration |\n"
1540
+ markdown += "|------|------|----------|--------|-------------|------------|----------|\n"
1541
+
1542
+ for job in all_jobs[:50]: # Limit to 50 most recent
1543
+ start_time = job.get("startTimeUtc", "N/A")
1544
+ end_time = job.get("endTimeUtc", "")
1545
+ duration = (
1546
+ "Running"
1547
+ if job.get("status") == "InProgress"
1548
+ else ("N/A" if not end_time else f"{end_time}")
1549
+ )
1550
+
1551
+ markdown += f"| {job['itemName']} | {job['itemType']} | {job.get('jobType', 'N/A')} | "
1552
+ markdown += (
1553
+ f"{job.get('status', 'N/A')} | {job.get('invokeType', 'N/A')} | "
1554
+ )
1555
+ markdown += f"{start_time} | {duration} |\n"
1556
+
1557
+ if len(all_jobs) > 50:
1558
+ markdown += (
1559
+ f"\n*Showing 50 most recent jobs out of {len(all_jobs)} total.*\n"
1560
+ )
1561
+
1562
+ return markdown
1563
+
1564
+ except Exception as e:
1565
+ return f"Error listing job instances: {str(e)}"
1566
+
1567
+
1568
+ @mcp.tool()
1569
+ async def get_job_instance(workspace: str, item_name: str, job_instance_id: str) -> str:
1570
+ """Get detailed information about a specific job instance (READ-ONLY).
1571
+
1572
+ Args:
1573
+ workspace: Name or ID of the workspace
1574
+ item_name: Name or ID of the item
1575
+ job_instance_id: ID of the job instance
1576
+ """
1577
+ try:
1578
+ credential = DefaultAzureCredential()
1579
+ client = FabricApiClient(credential)
1580
+
1581
+ # Convert names to IDs
1582
+ workspace_id = await client.resolve_workspace(workspace)
1583
+
1584
+ # Get all items to find the specified item
1585
+ items = await client.paginated_request(f"workspaces/{workspace_id}/items")
1586
+ item = None
1587
+ for i in items:
1588
+ if item_name.lower() in i["displayName"].lower() or i["id"] == item_name:
1589
+ item = i
1590
+ break
1591
+
1592
+ if not item:
1593
+ return f"Item '{item_name}' not found in workspace '{workspace}'."
1594
+
1595
+ # Get job instance details
1596
+ job = await client._make_request(
1597
+ f"workspaces/{workspace_id}/items/{item['id']}/jobs/instances/{job_instance_id}"
1598
+ )
1599
+
1600
+ markdown = f"# Job Instance Details\n\n"
1601
+ markdown += f"**Item**: {item['displayName']} ({item['type']})\n"
1602
+ markdown += f"**Workspace**: {workspace}\n\n"
1603
+
1604
+ markdown += "## Job Information\n"
1605
+ markdown += f"- **Job ID**: {job.get('id', 'N/A')}\n"
1606
+ markdown += f"- **Job Type**: {job.get('jobType', 'N/A')}\n"
1607
+ markdown += f"- **Status**: {job.get('status', 'N/A')}\n"
1608
+ markdown += f"- **Invoke Type**: {job.get('invokeType', 'N/A')}\n"
1609
+
1610
+ markdown += "\n## Timing\n"
1611
+ markdown += f"- **Start Time**: {job.get('startTimeUtc', 'N/A')}\n"
1612
+ markdown += f"- **End Time**: {job.get('endTimeUtc', 'N/A')}\n"
1613
+
1614
+ if job.get("failureReason"):
1615
+ markdown += f"\n## Error Details\n"
1616
+ markdown += f"- **Failure Reason**: {job['failureReason']}\n"
1617
+
1618
+ if job.get("rootActivityId"):
1619
+ markdown += f"\n## Technical Details\n"
1620
+ markdown += f"- **Root Activity ID**: {job['rootActivityId']}\n"
1621
+
1622
+ return markdown
1623
+
1624
+ except Exception as e:
1625
+ return f"Error getting job instance: {str(e)}"
1626
+
1627
+
1628
+ @mcp.tool()
1629
+ async def list_item_schedules(workspace: str, item_name: str) -> str:
1630
+ """List all schedules for a specific item - see what's scheduled to run! (READ-ONLY)
1631
+
1632
+ Args:
1633
+ workspace: Name or ID of the workspace
1634
+ item_name: Name or ID of the item to check schedules for
1635
+ """
1636
+ try:
1637
+ credential = DefaultAzureCredential()
1638
+ client = FabricApiClient(credential)
1639
+
1640
+ # Convert names to IDs
1641
+ workspace_id = await client.resolve_workspace(workspace)
1642
+
1643
+ # Get all items to find the specified item
1644
+ items = await client.paginated_request(f"workspaces/{workspace_id}/items")
1645
+ item = None
1646
+ for i in items:
1647
+ if item_name.lower() in i["displayName"].lower() or i["id"] == item_name:
1648
+ item = i
1649
+ break
1650
+
1651
+ if not item:
1652
+ return f"Item '{item_name}' not found in workspace '{workspace}'."
1653
+
1654
+ # Get schedules for the item
1655
+ schedules = await client.paginated_request(
1656
+ f"workspaces/{workspace_id}/items/{item['id']}/schedules"
1657
+ )
1658
+
1659
+ if not schedules:
1660
+ return f"No schedules found for item '{item['displayName']}'."
1661
+
1662
+ markdown = f"# Schedules for '{item['displayName']}'\n\n"
1663
+ markdown += f"**Item Type**: {item['type']}\n"
1664
+ markdown += f"**Workspace**: {workspace}\n\n"
1665
+
1666
+ markdown += "| Schedule ID | Job Type | Enabled | Frequency | Start Date | End Date | Timezone |\n"
1667
+ markdown += "|-------------|----------|---------|-----------|------------|----------|----------|\n"
1668
+
1669
+ for schedule in schedules:
1670
+ enabled = "✅ Yes" if schedule.get("enabled", False) else "❌ No"
1671
+ frequency = schedule.get("recurrence", {}).get("frequency", "N/A")
1672
+ start_date = schedule.get("recurrence", {}).get("startDate", "N/A")
1673
+ end_date = schedule.get("recurrence", {}).get("endDate", "N/A")
1674
+ timezone = schedule.get("recurrence", {}).get("timeZone", "N/A")
1675
+
1676
+ markdown += (
1677
+ f"| {schedule.get('id', 'N/A')} | {schedule.get('jobType', 'N/A')} | "
1678
+ )
1679
+ markdown += (
1680
+ f"{enabled} | {frequency} | {start_date} | {end_date} | {timezone} |\n"
1681
+ )
1682
+
1683
+ return markdown
1684
+
1685
+ except Exception as e:
1686
+ return f"Error listing item schedules: {str(e)}"
1687
+
1688
+
1689
+ @mcp.tool()
1690
+ async def list_workspace_schedules(workspace: str) -> str:
1691
+ """List ALL schedules across all items in a workspace - see everything that's scheduled to run! (READ-ONLY)
1692
+
1693
+ This aggregates schedules from all items in the workspace to give you a complete view
1694
+ of what's scheduled to run when.
1695
+
1696
+ Args:
1697
+ workspace: Name or ID of the workspace
1698
+ """
1699
+ try:
1700
+ credential = DefaultAzureCredential()
1701
+ client = FabricApiClient(credential)
1702
+
1703
+ # Convert names to IDs
1704
+ workspace_id = await client.resolve_workspace(workspace)
1705
+
1706
+ # Get all items in workspace
1707
+ items = await client.paginated_request(f"workspaces/{workspace_id}/items")
1708
+
1709
+ if not items:
1710
+ return f"No items found in workspace '{workspace}'."
1711
+
1712
+ all_schedules = []
1713
+
1714
+ # Get schedules for each item
1715
+ for item in items:
1716
+ try:
1717
+ schedules = await client.paginated_request(
1718
+ f"workspaces/{workspace_id}/items/{item['id']}/schedules"
1719
+ )
1720
+ if schedules:
1721
+ for schedule in schedules:
1722
+ schedule["itemName"] = item["displayName"]
1723
+ schedule["itemType"] = item["type"]
1724
+ schedule["itemId"] = item["id"]
1725
+ all_schedules.append(schedule)
1726
+ except Exception:
1727
+ # Skip items that don't support schedules
1728
+ continue
1729
+
1730
+ if not all_schedules:
1731
+ return f"No schedules found in workspace '{workspace}'."
1732
+
1733
+ # Sort by enabled status first, then by item name
1734
+ all_schedules.sort(key=lambda x: (not x.get("enabled", False), x["itemName"]))
1735
+
1736
+ markdown = f"# All Schedules in Workspace '{workspace}'\n\n"
1737
+ markdown += f"**Total Schedules**: {len(all_schedules)}\n\n"
1738
+
1739
+ # Count enabled vs disabled
1740
+ enabled_count = sum(1 for s in all_schedules if s.get("enabled", False))
1741
+ disabled_count = len(all_schedules) - enabled_count
1742
+ markdown += f"- ✅ **Enabled**: {enabled_count}\n"
1743
+ markdown += f"- ❌ **Disabled**: {disabled_count}\n\n"
1744
+
1745
+ markdown += (
1746
+ "| Item | Type | Job Type | Status | Frequency | Next Run | Timezone |\n"
1747
+ )
1748
+ markdown += (
1749
+ "|------|------|----------|--------|-----------|----------|----------|\n"
1750
+ )
1751
+
1752
+ for schedule in all_schedules:
1753
+ status = "✅ Enabled" if schedule.get("enabled", False) else "❌ Disabled"
1754
+ frequency = schedule.get("recurrence", {}).get("frequency", "N/A")
1755
+ next_run = schedule.get("recurrence", {}).get("startDate", "N/A")
1756
+ timezone = schedule.get("recurrence", {}).get("timeZone", "N/A")
1757
+
1758
+ markdown += f"| {schedule['itemName']} | {schedule['itemType']} | "
1759
+ markdown += f"{schedule.get('jobType', 'N/A')} | {status} | "
1760
+ markdown += f"{frequency} | {next_run} | {timezone} |\n"
1761
+
1762
+ return markdown
1763
+
1764
+ except Exception as e:
1765
+ return f"Error listing workspace schedules: {str(e)}"
1766
+
1767
+
1768
+ @mcp.tool()
1769
+ async def list_environments(workspace: str = None) -> str:
1770
+ """List all Fabric environments for compute and library management (READ-ONLY).
1771
+
1772
+ Args:
1773
+ workspace: Optional workspace name or ID to filter environments
1774
+ """
1775
+ try:
1776
+ credential = DefaultAzureCredential()
1777
+ client = FabricApiClient(credential)
1778
+
1779
+ if workspace:
1780
+ # Get environments for specific workspace
1781
+ workspace_id = await client.resolve_workspace(workspace)
1782
+ environments = await client.paginated_request(
1783
+ f"workspaces/{workspace_id}/items", params={"type": "Environment"}
1784
+ )
1785
+ workspace_filter = f" in workspace '{workspace}'"
1786
+ else:
1787
+ # Get all accessible workspaces and their environments
1788
+ workspaces = await client.paginated_request("workspaces")
1789
+ environments = []
1790
+
1791
+ for ws in workspaces:
1792
+ try:
1793
+ ws_environments = await client.paginated_request(
1794
+ f"workspaces/{ws['id']}/items", params={"type": "Environment"}
1795
+ )
1796
+ for env in ws_environments:
1797
+ env["workspaceName"] = ws["displayName"]
1798
+ environments.append(env)
1799
+ except Exception:
1800
+ # Skip workspaces we can't access
1801
+ continue
1802
+
1803
+ workspace_filter = " across all accessible workspaces"
1804
+
1805
+ if not environments:
1806
+ return f"No environments found{workspace_filter}."
1807
+
1808
+ markdown = f"# Fabric Environments{workspace_filter}\n\n"
1809
+ markdown += f"**Total Environments**: {len(environments)}\n\n"
1810
+
1811
+ markdown += "| Name | Workspace | Description | Created | Modified |\n"
1812
+ markdown += "|------|-----------|-------------|---------|----------|\n"
1813
+
1814
+ for env in environments:
1815
+ workspace_name = env.get("workspaceName", "Current")
1816
+ description = env.get("description", "No description")[:50] + (
1817
+ "..." if len(env.get("description", "")) > 50 else ""
1818
+ )
1819
+ created = env.get("createdDate", "N/A")
1820
+ modified = env.get("lastModifiedDate", "N/A")
1821
+
1822
+ markdown += f"| {env['displayName']} | {workspace_name} | {description} | {created} | {modified} |\n"
1823
+
1824
+ return markdown
1825
+
1826
+ except Exception as e:
1827
+ return f"Error listing environments: {str(e)}"
1828
+
1829
+
1830
+ @mcp.tool()
1831
+ async def get_environment_details(workspace: str, environment_name: str) -> str:
1832
+ """Get detailed configuration of a Fabric environment (READ-ONLY).
1833
+
1834
+ Args:
1835
+ workspace: Name or ID of the workspace
1836
+ environment_name: Name or ID of the environment
1837
+ """
1838
+ try:
1839
+ credential = DefaultAzureCredential()
1840
+ client = FabricApiClient(credential)
1841
+
1842
+ # Convert names to IDs
1843
+ workspace_id = await client.resolve_workspace(workspace)
1844
+
1845
+ # Find the environment
1846
+ environments = await client.paginated_request(
1847
+ f"workspaces/{workspace_id}/items", params={"type": "Environment"}
1848
+ )
1849
+ environment = None
1850
+ for env in environments:
1851
+ if (
1852
+ environment_name.lower() in env["displayName"].lower()
1853
+ or env["id"] == environment_name
1854
+ ):
1855
+ environment = env
1856
+ break
1857
+
1858
+ if not environment:
1859
+ return f"Environment '{environment_name}' not found in workspace '{workspace}'."
1860
+
1861
+ markdown = f"# Environment Details: {environment['displayName']}\n\n"
1862
+ markdown += f"**Workspace**: {workspace}\n"
1863
+ markdown += f"**Environment ID**: {environment['id']}\n"
1864
+ markdown += (
1865
+ f"**Description**: {environment.get('description', 'No description')}\n\n"
1866
+ )
1867
+
1868
+ # Get Spark compute configuration
1869
+ try:
1870
+ spark_config = await client._make_request(
1871
+ f"workspaces/{workspace_id}/environments/{environment['id']}/sparkcompute"
1872
+ )
1873
+
1874
+ markdown += "## Spark Compute Configuration\n"
1875
+ markdown += (
1876
+ f"- **Runtime Version**: {spark_config.get('runtimeVersion', 'N/A')}\n"
1877
+ )
1878
+ markdown += f"- **Pool Name**: {spark_config.get('poolName', 'N/A')}\n"
1879
+ markdown += (
1880
+ f"- **Driver Cores**: {spark_config.get('driverCores', 'N/A')}\n"
1881
+ )
1882
+ markdown += (
1883
+ f"- **Driver Memory**: {spark_config.get('driverMemory', 'N/A')}\n"
1884
+ )
1885
+ markdown += (
1886
+ f"- **Executor Cores**: {spark_config.get('executorCores', 'N/A')}\n"
1887
+ )
1888
+ markdown += (
1889
+ f"- **Executor Memory**: {spark_config.get('executorMemory', 'N/A')}\n"
1890
+ )
1891
+ markdown += f"- **Dynamic Executor Allocation**: {spark_config.get('dynamicExecutorAllocation', {}).get('enabled', 'N/A')}\n\n"
1892
+
1893
+ # Spark properties
1894
+ if spark_config.get("sparkProperties"):
1895
+ markdown += "### Spark Properties\n"
1896
+ for key, value in spark_config["sparkProperties"].items():
1897
+ markdown += f"- **{key}**: {value}\n"
1898
+ markdown += "\n"
1899
+
1900
+ except Exception:
1901
+ markdown += (
1902
+ "## Spark Compute Configuration\n*Not available or access denied*\n\n"
1903
+ )
1904
+
1905
+ # Get libraries
1906
+ try:
1907
+ libraries = await client._make_request(
1908
+ f"workspaces/{workspace_id}/environments/{environment['id']}/libraries"
1909
+ )
1910
+
1911
+ if libraries and libraries.get("libraries"):
1912
+ markdown += "## Installed Libraries\n"
1913
+
1914
+ public_libs = [
1915
+ lib for lib in libraries["libraries"] if lib.get("type") == "Public"
1916
+ ]
1917
+ custom_libs = [
1918
+ lib for lib in libraries["libraries"] if lib.get("type") == "Custom"
1919
+ ]
1920
+
1921
+ if public_libs:
1922
+ markdown += "### Public Libraries\n"
1923
+ for lib in public_libs[:20]: # Limit to first 20
1924
+ markdown += f"- **{lib.get('name', 'N/A')}**: {lib.get('version', 'N/A')}\n"
1925
+ if len(public_libs) > 20:
1926
+ markdown += f"- *... and {len(public_libs) - 20} more public libraries*\n"
1927
+ markdown += "\n"
1928
+
1929
+ if custom_libs:
1930
+ markdown += "### Custom Libraries\n"
1931
+ for lib in custom_libs:
1932
+ markdown += f"- **{lib.get('name', 'N/A')}** ({lib.get('size', 'N/A')} bytes)\n"
1933
+ markdown += "\n"
1934
+ else:
1935
+ markdown += (
1936
+ "## Installed Libraries\n*No additional libraries installed*\n\n"
1937
+ )
1938
+
1939
+ except Exception:
1940
+ markdown += "## Installed Libraries\n*Not available or access denied*\n\n"
1941
+
1942
+ return markdown
1943
+
1944
+ except Exception as e:
1945
+ return f"Error getting environment details: {str(e)}"
1946
+
1947
+
1948
+ @mcp.tool()
1949
+ async def list_compute_usage(workspace: str = None, time_range_hours: int = 24) -> str:
1950
+ """Monitor current compute resource consumption across Fabric workloads (READ-ONLY).
1951
+
1952
+ Shows active Spark jobs, resource allocation, and capacity utilization.
1953
+
1954
+ Args:
1955
+ workspace: Optional workspace name or ID to filter usage
1956
+ time_range_hours: Hours to look back for usage data (default: 24)
1957
+ """
1958
+ try:
1959
+ credential = DefaultAzureCredential()
1960
+ client = FabricApiClient(credential)
1961
+
1962
+ markdown = f"# Compute Usage Report\n\n"
1963
+ markdown += f"**Time Range**: Last {time_range_hours} hours\n"
1964
+
1965
+ if workspace:
1966
+ workspace_id = await client.resolve_workspace(workspace)
1967
+ markdown += f"**Workspace**: {workspace}\n\n"
1968
+ else:
1969
+ workspace_id = None
1970
+ markdown += f"**Scope**: All accessible workspaces\n\n"
1971
+
1972
+ # Get active job instances to show current compute usage
1973
+ active_jobs = []
1974
+ total_active_jobs = 0
1975
+
1976
+ if workspace_id:
1977
+ # Single workspace
1978
+ workspaces_to_check = [{"id": workspace_id, "displayName": workspace}]
1979
+ else:
1980
+ # All workspaces
1981
+ workspaces_to_check = await client.paginated_request("workspaces")
1982
+
1983
+ for ws in workspaces_to_check:
1984
+ try:
1985
+ # Get items in workspace
1986
+ items = await client.paginated_request(f"workspaces/{ws['id']}/items")
1987
+
1988
+ for item in items:
1989
+ try:
1990
+ # Get active job instances
1991
+ jobs = await client.paginated_request(
1992
+ f"workspaces/{ws['id']}/items/{item['id']}/jobs/instances",
1993
+ params={"status": "InProgress"}, # Only active jobs
1994
+ )
1995
+
1996
+ for job in jobs:
1997
+ if job.get("status") == "InProgress":
1998
+ job["workspaceName"] = ws["displayName"]
1999
+ job["itemName"] = item["displayName"]
2000
+ job["itemType"] = item["type"]
2001
+ active_jobs.append(job)
2002
+ total_active_jobs += 1
2003
+
2004
+ except Exception:
2005
+ continue
2006
+
2007
+ except Exception:
2008
+ continue
2009
+
2010
+ # Summary section
2011
+ markdown += "## Current Usage Summary\n"
2012
+ markdown += f"- **Active Jobs**: {total_active_jobs}\n"
2013
+
2014
+ # Estimate resource usage (this is approximate since we don't have direct CU API)
2015
+ if total_active_jobs > 0:
2016
+ # Rough estimation: assume average job uses 4-8 cores
2017
+ estimated_cores = total_active_jobs * 6 # Average estimate
2018
+ estimated_cus = estimated_cores / 2 # 1 CU = 2 Spark cores
2019
+ markdown += (
2020
+ f"- **Estimated Active Cores**: ~{estimated_cores} Spark vCores\n"
2021
+ )
2022
+ markdown += f"- **Estimated Capacity Usage**: ~{estimated_cus:.1f} CUs\n"
2023
+ else:
2024
+ markdown += f"- **Estimated Active Cores**: 0 Spark vCores\n"
2025
+ markdown += f"- **Estimated Capacity Usage**: 0 CUs\n"
2026
+
2027
+ markdown += "\n"
2028
+
2029
+ if active_jobs:
2030
+ markdown += "## Active Jobs Details\n"
2031
+ markdown += "| Workspace | Item | Type | Job Type | Status | Start Time | Duration |\n"
2032
+ markdown += "|-----------|------|------|----------|--------|------------|----------|\n"
2033
+
2034
+ for job in active_jobs[:30]: # Limit to 30 most recent
2035
+ start_time = job.get("startTimeUtc", "N/A")
2036
+ # Calculate rough duration if we have start time
2037
+ duration = "Running"
2038
+
2039
+ markdown += f"| {job['workspaceName']} | {job['itemName']} | {job['itemType']} | "
2040
+ markdown += (
2041
+ f"{job.get('jobType', 'N/A')} | {job.get('status', 'N/A')} | "
2042
+ )
2043
+ markdown += f"{start_time} | {duration} |\n"
2044
+
2045
+ if len(active_jobs) > 30:
2046
+ markdown += f"\n*Showing 30 most recent active jobs out of {len(active_jobs)} total.*\n"
2047
+ else:
2048
+ markdown += "## Active Jobs Details\n*No active jobs found.*\n"
2049
+
2050
+ # Resource recommendations
2051
+ markdown += "\n## Usage Insights\n"
2052
+ if total_active_jobs == 0:
2053
+ markdown += "✅ **Low Usage**: No active compute jobs detected.\n"
2054
+ elif total_active_jobs <= 5:
2055
+ markdown += "🟡 **Moderate Usage**: Few active jobs running.\n"
2056
+ else:
2057
+ markdown += (
2058
+ "🔴 **High Usage**: Many active jobs - consider capacity planning.\n"
2059
+ )
2060
+
2061
+ markdown += "\n*Note: This shows current job activity. For detailed capacity metrics and historical usage, use the Microsoft Fabric Capacity Metrics app.*\n"
2062
+
2063
+ return markdown
2064
+
2065
+ except Exception as e:
2066
+ return f"Error getting compute usage: {str(e)}"
2067
+
2068
+
2069
+ @mcp.tool()
2070
+ async def get_item_lineage(workspace: str, item_name: str) -> str:
2071
+ """Get data lineage information for a Fabric item (READ-ONLY).
2072
+
2073
+ Shows upstream and downstream dependencies, data flow relationships.
2074
+ Note: This provides item-level lineage based on workspace relationships.
2075
+
2076
+ Args:
2077
+ workspace: Name or ID of the workspace
2078
+ item_name: Name or ID of the item to analyze
2079
+ """
2080
+ try:
2081
+ credential = DefaultAzureCredential()
2082
+ client = FabricApiClient(credential)
2083
+
2084
+ # Convert names to IDs
2085
+ workspace_id = await client.resolve_workspace(workspace)
2086
+
2087
+ # Find the target item
2088
+ items = await client.paginated_request(f"workspaces/{workspace_id}/items")
2089
+ target_item = None
2090
+ for item in items:
2091
+ if (
2092
+ item_name.lower() in item["displayName"].lower()
2093
+ or item["id"] == item_name
2094
+ ):
2095
+ target_item = item
2096
+ break
2097
+
2098
+ if not target_item:
2099
+ return f"Item '{item_name}' not found in workspace '{workspace}'."
2100
+
2101
+ markdown = f"# Data Lineage: {target_item['displayName']}\n\n"
2102
+ markdown += f"**Item Type**: {target_item['type']}\n"
2103
+ markdown += f"**Workspace**: {workspace}\n"
2104
+ markdown += f"**Item ID**: {target_item['id']}\n\n"
2105
+
2106
+ # Analyze relationships based on item type and connections
2107
+ upstream_items = []
2108
+ downstream_items = []
2109
+
2110
+ # Get all shortcuts if this is a lakehouse
2111
+ if target_item["type"] == "Lakehouse":
2112
+ try:
2113
+ shortcuts = await client.paginated_request(
2114
+ f"workspaces/{workspace_id}/items/{target_item['id']}/shortcuts"
2115
+ )
2116
+ if shortcuts:
2117
+ markdown += "## Data Sources (via OneLake Shortcuts)\n"
2118
+ for shortcut in shortcuts:
2119
+ target_path = shortcut.get("target", {})
2120
+ if target_path.get("lakehouse"):
2121
+ upstream_items.append(
2122
+ {
2123
+ "name": f"Lakehouse: {target_path['lakehouse'].get('workspaceName', 'Unknown')}/{target_path['lakehouse'].get('itemName', 'Unknown')}",
2124
+ "type": "Lakehouse",
2125
+ "relationship": "Data Source",
2126
+ }
2127
+ )
2128
+ elif target_path.get("adlsGen2"):
2129
+ upstream_items.append(
2130
+ {
2131
+ "name": f"ADLS Gen2: {target_path['adlsGen2'].get('url', 'Unknown')}",
2132
+ "type": "External Storage",
2133
+ "relationship": "Data Source",
2134
+ }
2135
+ )
2136
+ except Exception:
2137
+ pass
2138
+
2139
+ # Find items that might depend on this item (basic analysis)
2140
+ for item in items:
2141
+ if item["id"] == target_item["id"]:
2142
+ continue
2143
+
2144
+ # Notebooks often depend on lakehouses
2145
+ if item["type"] == "Notebook" and target_item["type"] == "Lakehouse":
2146
+ downstream_items.append(
2147
+ {
2148
+ "name": item["displayName"],
2149
+ "type": item["type"],
2150
+ "relationship": "Likely Consumer",
2151
+ }
2152
+ )
2153
+
2154
+ # Reports might depend on semantic models/datasets
2155
+ elif item["type"] == "Report" and target_item["type"] in [
2156
+ "SemanticModel",
2157
+ "Dataset",
2158
+ ]:
2159
+ downstream_items.append(
2160
+ {
2161
+ "name": item["displayName"],
2162
+ "type": item["type"],
2163
+ "relationship": "Likely Consumer",
2164
+ }
2165
+ )
2166
+
2167
+ # Pipelines might depend on various items
2168
+ elif item["type"] == "DataPipeline":
2169
+ if target_item["type"] in ["Lakehouse", "Warehouse", "KqlDatabase"]:
2170
+ downstream_items.append(
2171
+ {
2172
+ "name": item["displayName"],
2173
+ "type": item["type"],
2174
+ "relationship": "Possible Consumer",
2175
+ }
2176
+ )
2177
+
2178
+ # Display upstream dependencies
2179
+ if upstream_items:
2180
+ markdown += "## Upstream Dependencies\n"
2181
+ markdown += "*Items that this item depends on for data*\n\n"
2182
+ markdown += "| Name | Type | Relationship |\n"
2183
+ markdown += "|------|------|-------------|\n"
2184
+ for item in upstream_items:
2185
+ markdown += (
2186
+ f"| {item['name']} | {item['type']} | {item['relationship']} |\n"
2187
+ )
2188
+ markdown += "\n"
2189
+ else:
2190
+ markdown += (
2191
+ "## Upstream Dependencies\n*No upstream dependencies detected*\n\n"
2192
+ )
2193
+
2194
+ # Display downstream dependencies
2195
+ if downstream_items:
2196
+ markdown += "## Downstream Dependencies\n"
2197
+ markdown += "*Items that likely depend on this item*\n\n"
2198
+ markdown += "| Name | Type | Relationship |\n"
2199
+ markdown += "|------|------|-------------|\n"
2200
+ for item in downstream_items[:20]: # Limit to 20
2201
+ markdown += (
2202
+ f"| {item['name']} | {item['type']} | {item['relationship']} |\n"
2203
+ )
2204
+ if len(downstream_items) > 20:
2205
+ markdown += f"\n*... and {len(downstream_items) - 20} more potential dependencies*\n"
2206
+ markdown += "\n"
2207
+ else:
2208
+ markdown += (
2209
+ "## Downstream Dependencies\n*No downstream dependencies detected*\n\n"
2210
+ )
2211
+
2212
+ # Impact analysis summary
2213
+ markdown += "## Impact Analysis Summary\n"
2214
+ total_dependencies = len(upstream_items) + len(downstream_items)
2215
+ if total_dependencies == 0:
2216
+ markdown += (
2217
+ "✅ **Low Impact**: This item appears to have minimal dependencies.\n"
2218
+ )
2219
+ elif total_dependencies <= 5:
2220
+ markdown += (
2221
+ "🟡 **Moderate Impact**: This item has some dependencies to consider.\n"
2222
+ )
2223
+ else:
2224
+ markdown += "🔴 **High Impact**: This item has many dependencies - changes should be carefully planned.\n"
2225
+
2226
+ markdown += "\n*Note: This provides basic lineage analysis based on item types and relationships. For detailed column-level lineage, use the Fabric Lineage view in the UI or Microsoft Purview integration.*\n"
2227
+
2228
+ return markdown
2229
+
2230
+ except Exception as e:
2231
+ return f"Error getting item lineage: {str(e)}"
2232
+
2233
+
2234
+ @mcp.tool()
2235
+ async def list_item_dependencies(workspace: str, item_type: str = None) -> str:
2236
+ """List dependencies between items in a workspace (READ-ONLY).
2237
+
2238
+ Shows which items depend on which other items for comprehensive dependency mapping.
2239
+
2240
+ Args:
2241
+ workspace: Name or ID of the workspace
2242
+ item_type: Optional item type filter (Lakehouse, Notebook, Report, etc.)
2243
+ """
2244
+ try:
2245
+ credential = DefaultAzureCredential()
2246
+ client = FabricApiClient(credential)
2247
+
2248
+ # Convert names to IDs
2249
+ workspace_id = await client.resolve_workspace(workspace)
2250
+
2251
+ # Get all items in workspace
2252
+ items = await client.paginated_request(f"workspaces/{workspace_id}/items")
2253
+
2254
+ if not items:
2255
+ return f"No items found in workspace '{workspace}'."
2256
+
2257
+ # Filter by item type if specified
2258
+ if item_type:
2259
+ items = [
2260
+ item for item in items if item["type"].lower() == item_type.lower()
2261
+ ]
2262
+ if not items:
2263
+ return (
2264
+ f"No items of type '{item_type}' found in workspace '{workspace}'."
2265
+ )
2266
+
2267
+ markdown = f"# Item Dependencies in '{workspace}'\n\n"
2268
+ if item_type:
2269
+ markdown += f"**Item Type Filter**: {item_type}\n"
2270
+ markdown += f"**Total Items**: {len(items)}\n\n"
2271
+
2272
+ # Analyze dependencies
2273
+ dependencies = []
2274
+
2275
+ for item in items:
2276
+ item_deps = {"item": item, "depends_on": [], "used_by": []}
2277
+
2278
+ # Check for shortcuts (data dependencies)
2279
+ if item["type"] == "Lakehouse":
2280
+ try:
2281
+ shortcuts = await client.paginated_request(
2282
+ f"workspaces/{workspace_id}/items/{item['id']}/shortcuts"
2283
+ )
2284
+ for shortcut in shortcuts:
2285
+ target = shortcut.get("target", {})
2286
+ if target.get("lakehouse"):
2287
+ item_deps["depends_on"].append(
2288
+ {
2289
+ "name": f"{target['lakehouse'].get('itemName', 'Unknown')}",
2290
+ "type": "Lakehouse",
2291
+ "relationship": "Data Source (Shortcut)",
2292
+ }
2293
+ )
2294
+ except Exception:
2295
+ pass
2296
+
2297
+ # Analyze potential relationships based on item types
2298
+ for other_item in items:
2299
+ if other_item["id"] == item["id"]:
2300
+ continue
2301
+
2302
+ # Common dependency patterns
2303
+ if item["type"] == "Notebook":
2304
+ if other_item["type"] in ["Lakehouse", "Warehouse", "KqlDatabase"]:
2305
+ item_deps["depends_on"].append(
2306
+ {
2307
+ "name": other_item["displayName"],
2308
+ "type": other_item["type"],
2309
+ "relationship": "Likely Data Source",
2310
+ }
2311
+ )
2312
+
2313
+ elif item["type"] == "Report":
2314
+ if other_item["type"] in [
2315
+ "SemanticModel",
2316
+ "Dataset",
2317
+ "Lakehouse",
2318
+ "Warehouse",
2319
+ ]:
2320
+ item_deps["depends_on"].append(
2321
+ {
2322
+ "name": other_item["displayName"],
2323
+ "type": other_item["type"],
2324
+ "relationship": "Likely Data Source",
2325
+ }
2326
+ )
2327
+
2328
+ elif item["type"] == "DataPipeline":
2329
+ if other_item["type"] in [
2330
+ "Lakehouse",
2331
+ "Warehouse",
2332
+ "Notebook",
2333
+ "DataFlow",
2334
+ ]:
2335
+ item_deps["depends_on"].append(
2336
+ {
2337
+ "name": other_item["displayName"],
2338
+ "type": other_item["type"],
2339
+ "relationship": "Pipeline Component",
2340
+ }
2341
+ )
2342
+
2343
+ dependencies.append(item_deps)
2344
+
2345
+ # Display dependency matrix
2346
+ markdown += "## Dependency Overview\n"
2347
+ markdown += "| Item | Type | Dependencies | Used By |\n"
2348
+ markdown += "|------|------|--------------|--------|\n"
2349
+
2350
+ for dep in dependencies:
2351
+ item = dep["item"]
2352
+ dep_count = len(dep["depends_on"])
2353
+ used_count = sum(
2354
+ 1
2355
+ for other_dep in dependencies
2356
+ for depends in other_dep["depends_on"]
2357
+ if depends["name"] == item["displayName"]
2358
+ )
2359
+
2360
+ markdown += f"| {item['displayName']} | {item['type']} | {dep_count} | {used_count} |\n"
2361
+
2362
+ # Detailed dependencies
2363
+ markdown += "\n## Detailed Dependencies\n"
2364
+
2365
+ for dep in dependencies:
2366
+ if dep["depends_on"]:
2367
+ item = dep["item"]
2368
+ markdown += f"\n### {item['displayName']} ({item['type']})\n"
2369
+ markdown += "**Depends on:**\n"
2370
+ for depends in dep["depends_on"]:
2371
+ markdown += f"- **{depends['name']}** ({depends['type']}) - {depends['relationship']}\n"
2372
+
2373
+ # Dependency insights
2374
+ high_dependency_items = [
2375
+ dep for dep in dependencies if len(dep["depends_on"]) > 3
2376
+ ]
2377
+ highly_used_items = []
2378
+
2379
+ for dep in dependencies:
2380
+ item = dep["item"]
2381
+ usage_count = sum(
2382
+ 1
2383
+ for other_dep in dependencies
2384
+ for depends in other_dep["depends_on"]
2385
+ if depends["name"] == item["displayName"]
2386
+ )
2387
+ if usage_count > 2:
2388
+ highly_used_items.append((item, usage_count))
2389
+
2390
+ markdown += "\n## Dependency Insights\n"
2391
+
2392
+ if high_dependency_items:
2393
+ markdown += "### High Dependency Items (>3 dependencies)\n"
2394
+ for dep in high_dependency_items:
2395
+ markdown += f"- **{dep['item']['displayName']}** ({dep['item']['type']}) - {len(dep['depends_on'])} dependencies\n"
2396
+ markdown += "\n"
2397
+
2398
+ if highly_used_items:
2399
+ markdown += "### Highly Used Items (>2 dependents)\n"
2400
+ for item, count in highly_used_items:
2401
+ markdown += f"- **{item['displayName']}** ({item['type']}) - Used by {count} items\n"
2402
+ markdown += "\n"
2403
+
2404
+ markdown += "*Note: Dependencies are inferred based on common patterns and item types. For exact lineage, use the Fabric Lineage view or detailed analysis.*\n"
2405
+
2406
+ return markdown
2407
+
2408
+ except Exception as e:
2409
+ return f"Error listing item dependencies: {str(e)}"
2410
+
2411
+
2412
+ @mcp.tool()
2413
+ async def get_data_source_usage(
2414
+ workspace: str = None, connection_name: str = None
2415
+ ) -> str:
2416
+ """Analyze where data sources and connections are used across Fabric items (READ-ONLY).
2417
+
2418
+ Args:
2419
+ workspace: Optional workspace name or ID to scope analysis
2420
+ connection_name: Optional connection name to focus on specific data source
2421
+ """
2422
+ try:
2423
+ credential = DefaultAzureCredential()
2424
+ client = FabricApiClient(credential)
2425
+
2426
+ markdown = f"# Data Source Usage Analysis\n\n"
2427
+
2428
+ # Get connections
2429
+ connections = await client.paginated_request("connections")
2430
+
2431
+ if connection_name:
2432
+ # Filter to specific connection
2433
+ connections = [
2434
+ conn
2435
+ for conn in connections
2436
+ if connection_name.lower() in conn["displayName"].lower()
2437
+ ]
2438
+ if not connections:
2439
+ return f"Connection '{connection_name}' not found."
2440
+ markdown += f"**Connection Filter**: {connection_name}\n"
2441
+
2442
+ if workspace:
2443
+ workspace_id = await client.resolve_workspace(workspace)
2444
+ workspaces_to_check = [{"id": workspace_id, "displayName": workspace}]
2445
+ markdown += f"**Workspace Filter**: {workspace}\n"
2446
+ else:
2447
+ workspaces_to_check = await client.paginated_request("workspaces")
2448
+ markdown += f"**Scope**: All accessible workspaces\n"
2449
+
2450
+ markdown += f"**Connections Found**: {len(connections)}\n\n"
2451
+
2452
+ usage_analysis = {}
2453
+
2454
+ # Analyze each connection
2455
+ for connection in connections:
2456
+ conn_usage = {
2457
+ "connection": connection,
2458
+ "used_in_items": [],
2459
+ "total_usage": 0,
2460
+ }
2461
+
2462
+ # Check usage across workspaces
2463
+ for ws in workspaces_to_check:
2464
+ try:
2465
+ items = await client.paginated_request(
2466
+ f"workspaces/{ws['id']}/items"
2467
+ )
2468
+
2469
+ for item in items:
2470
+ # Items that commonly use connections
2471
+ if item["type"] in [
2472
+ "DataPipeline",
2473
+ "Dataflow",
2474
+ "Notebook",
2475
+ "Report",
2476
+ "SemanticModel",
2477
+ ]:
2478
+ # This is a heuristic - in practice, we'd need to inspect item definitions
2479
+ # For now, we'll identify potential usage based on item types
2480
+ conn_usage["used_in_items"].append(
2481
+ {
2482
+ "workspace": ws["displayName"],
2483
+ "item": item["displayName"],
2484
+ "type": item["type"],
2485
+ "usage_type": "Potential Usage",
2486
+ }
2487
+ )
2488
+ conn_usage["total_usage"] += 1
2489
+
2490
+ except Exception:
2491
+ continue
2492
+
2493
+ if conn_usage["total_usage"] > 0 or not connection_name:
2494
+ usage_analysis[connection["id"]] = conn_usage
2495
+
2496
+ # Display results
2497
+ if not usage_analysis:
2498
+ return "No data source usage found with the specified filters."
2499
+
2500
+ # Summary
2501
+ markdown += "## Usage Summary\n"
2502
+ total_connections = len(usage_analysis)
2503
+ total_usages = sum(
2504
+ analysis["total_usage"] for analysis in usage_analysis.values()
2505
+ )
2506
+
2507
+ markdown += f"- **Connections Analyzed**: {total_connections}\n"
2508
+ markdown += f"- **Total Potential Usages**: {total_usages}\n\n"
2509
+
2510
+ # Top used connections
2511
+ sorted_connections = sorted(
2512
+ usage_analysis.values(), key=lambda x: x["total_usage"], reverse=True
2513
+ )
2514
+
2515
+ markdown += "## Connection Usage Details\n"
2516
+
2517
+ for analysis in sorted_connections[:20]: # Top 20
2518
+ connection = analysis["connection"]
2519
+ conn_type = connection.get("connectionDetails", {}).get("type", "Unknown")
2520
+
2521
+ markdown += f"\n### {connection['displayName']} ({conn_type})\n"
2522
+ markdown += f"**Connection ID**: {connection['id']}\n"
2523
+ markdown += f"**Privacy Level**: {connection.get('privacyLevel', 'N/A')}\n"
2524
+ markdown += f"**Total Potential Usages**: {analysis['total_usage']}\n"
2525
+
2526
+ if analysis["used_in_items"]:
2527
+ markdown += "\n**Used in Items:**\n"
2528
+ markdown += "| Workspace | Item | Type | Usage Type |\n"
2529
+ markdown += "|-----------|------|------|------------|\n"
2530
+
2531
+ for usage in analysis["used_in_items"][
2532
+ :10
2533
+ ]: # Limit to 10 per connection
2534
+ markdown += f"| {usage['workspace']} | {usage['item']} | {usage['type']} | {usage['usage_type']} |\n"
2535
+
2536
+ if len(analysis["used_in_items"]) > 10:
2537
+ markdown += f"\n*... and {len(analysis['used_in_items']) - 10} more usages*\n"
2538
+
2539
+ markdown += "\n"
2540
+
2541
+ # Usage insights
2542
+ markdown += "## Usage Insights\n"
2543
+
2544
+ unused_connections = [
2545
+ analysis
2546
+ for analysis in usage_analysis.values()
2547
+ if analysis["total_usage"] == 0
2548
+ ]
2549
+ highly_used = [
2550
+ analysis
2551
+ for analysis in usage_analysis.values()
2552
+ if analysis["total_usage"] > 5
2553
+ ]
2554
+
2555
+ if unused_connections:
2556
+ markdown += (
2557
+ f"### Potentially Unused Connections ({len(unused_connections)})\n"
2558
+ )
2559
+ for analysis in unused_connections[:5]:
2560
+ conn = analysis["connection"]
2561
+ markdown += f"- **{conn['displayName']}** ({conn.get('connectionDetails', {}).get('type', 'Unknown')})\n"
2562
+ if len(unused_connections) > 5:
2563
+ markdown += f"- *... and {len(unused_connections) - 5} more*\n"
2564
+ markdown += "\n"
2565
+
2566
+ if highly_used:
2567
+ markdown += f"### Highly Used Connections (>5 usages)\n"
2568
+ for analysis in highly_used:
2569
+ conn = analysis["connection"]
2570
+ markdown += f"- **{conn['displayName']}**: {analysis['total_usage']} potential usages\n"
2571
+ markdown += "\n"
2572
+
2573
+ markdown += "*Note: Usage analysis is based on item types and potential patterns. For exact connection usage, detailed item definition analysis would be required.*\n"
2574
+
2575
+ return markdown
2576
+
2577
+ except Exception as e:
2578
+ return f"Error analyzing data source usage: {str(e)}"
2579
+
2580
+
2581
+ @mcp.tool()
2582
+ async def clear_fabric_data_cache(show_stats: bool = True) -> str:
2583
+ """Clear Fabric data list caches to see newly created resources immediately (ADMIN).
2584
+
2585
+ Clears caches for: workspaces, connections, items, capacities, environments, jobs, shortcuts, schedules.
2586
+ Does NOT clear name→ID resolution caches (those never become invalid).
2587
+
2588
+ Use this after creating new workspaces, connections, or items to see them immediately.
2589
+
2590
+ Args:
2591
+ show_stats: Show cache statistics before clearing
2592
+ """
2593
+ try:
2594
+ markdown = "# 🗑️ Fabric Data Cache Management\n\n"
2595
+
2596
+ cleared_caches = []
2597
+ cache_stats = []
2598
+
2599
+ # Collect cache stats and clear TTL-based data caches
2600
+ cache_info = [
2601
+ ("Workspace list", _WORKSPACE_CACHE, "workspaces"),
2602
+ ("Connections list", _CONNECTIONS_CACHE, "connections"),
2603
+ ("Capacities list", _CAPACITIES_CACHE, "capacities"),
2604
+ ("Items lists", _ITEMS_CACHE, "workspace items"),
2605
+ ("Shortcuts lists", _SHORTCUTS_CACHE, "item shortcuts"),
2606
+ ("Job instances", _JOB_INSTANCES_CACHE, "job instances"),
2607
+ ("Schedules", _SCHEDULES_CACHE, "schedules"),
2608
+ ("Environments", _ENVIRONMENTS_CACHE, "environments"),
2609
+ ]
2610
+
2611
+ if show_stats:
2612
+ markdown += "## 📊 Cache Statistics (Before Clearing)\n\n"
2613
+ markdown += "| Cache Type | Entries | TTL (seconds) | Description |\n"
2614
+ markdown += "|------------|---------|---------------|-------------|\n"
2615
+
2616
+ for name, cache, desc in cache_info:
2617
+ entries = len(cache)
2618
+ ttl = cache.ttl if hasattr(cache, "ttl") else "N/A"
2619
+ markdown += f"| {name} | {entries} | {ttl} | {desc} |\n"
2620
+ cache_stats.append((name, entries))
2621
+
2622
+ markdown += "\n"
2623
+
2624
+ # Clear all TTL caches
2625
+ for name, cache, desc in cache_info:
2626
+ size = len(cache)
2627
+ cache.clear()
2628
+ if size > 0:
2629
+ cleared_caches.append(f"{name} ({size} entries)")
2630
+
2631
+ markdown += "## ✅ Caches Cleared\n\n"
2632
+
2633
+ if cleared_caches:
2634
+ for cache in cleared_caches:
2635
+ markdown += f"- 🗑️ {cache}\n"
2636
+ else:
2637
+ markdown += "- ℹ️ No cached data found to clear\n"
2638
+
2639
+ markdown += "\n## 🔒 Preserved Caches\n\n"
2640
+ markdown += "- ✅ Workspace name → ID mappings (never become invalid)\n"
2641
+ markdown += "- ✅ Lakehouse name → ID mappings (never become invalid)\n"
2642
+
2643
+ markdown += "\n## 🎯 Result\n\n"
2644
+ markdown += (
2645
+ "**Next API calls will fetch fresh data from Microsoft Fabric!**\n\n"
2646
+ )
2647
+ markdown += "⚠️ **Performance Impact:** Initial calls will be slower until caches rebuild.\n"
2648
+ markdown += "⚡ **Cache Rebuild:** Caches will automatically repopulate on next use with fresh data."
2649
+
2650
+ return markdown
2651
+
2652
+ except Exception as e:
2653
+ return f"Error clearing data cache: {str(e)}"
2654
+
2655
+
2656
+ @mcp.tool()
2657
+ async def clear_name_resolution_cache(show_stats: bool = True) -> str:
2658
+ """Clear global name→ID resolution caches for workspaces and lakehouses (ADMIN).
2659
+
2660
+ This clears the permanent name→ID mapping caches. Use this if:
2661
+ - A workspace was renamed or deleted/recreated with the same name
2662
+ - A lakehouse was renamed or deleted/recreated with the same name
2663
+ - You suspect stale name→ID mappings are causing issues
2664
+
2665
+ Note: These caches normally never need clearing as name→ID mappings are permanent.
2666
+
2667
+ Args:
2668
+ show_stats: Show cache statistics before clearing
2669
+ """
2670
+ try:
2671
+ markdown = "# 🔄 Name Resolution Cache Management\n\n"
2672
+
2673
+ if show_stats:
2674
+ workspace_count = len(_global_workspace_cache)
2675
+ lakehouse_count = len(_global_lakehouse_cache)
2676
+
2677
+ markdown += "## 📊 Current Cache Statistics\n\n"
2678
+ markdown += (
2679
+ f"- 🏢 Workspace name→ID mappings: **{workspace_count}** entries\n"
2680
+ )
2681
+ markdown += (
2682
+ f"- 🏠 Lakehouse name→ID mappings: **{lakehouse_count}** entries\n\n"
2683
+ )
2684
+
2685
+ if workspace_count > 0:
2686
+ markdown += "### Workspace Mappings\n"
2687
+ for name, workspace_id in list(_global_workspace_cache.items())[
2688
+ :10
2689
+ ]: # Show first 10
2690
+ display_name = name if len(name) <= 30 else f"{name[:27]}..."
2691
+ display_id = (
2692
+ workspace_id
2693
+ if len(workspace_id) <= 20
2694
+ else f"{workspace_id[:17]}..."
2695
+ )
2696
+ markdown += f"- `{display_name}` → `{display_id}`\n"
2697
+ if workspace_count > 10:
2698
+ markdown += f"- ... and {workspace_count - 10} more\n"
2699
+ markdown += "\n"
2700
+
2701
+ if lakehouse_count > 0:
2702
+ markdown += "### Lakehouse Mappings\n"
2703
+ for cache_key, lakehouse_id in list(_global_lakehouse_cache.items())[
2704
+ :10
2705
+ ]: # Show first 10
2706
+ display_key = (
2707
+ cache_key if len(cache_key) <= 40 else f"{cache_key[:37]}..."
2708
+ )
2709
+ display_id = (
2710
+ lakehouse_id
2711
+ if len(lakehouse_id) <= 20
2712
+ else f"{lakehouse_id[:17]}..."
2713
+ )
2714
+ markdown += f"- `{display_key}` → `{display_id}`\n"
2715
+ if lakehouse_count > 10:
2716
+ markdown += f"- ... and {lakehouse_count - 10} more\n"
2717
+ markdown += "\n"
2718
+
2719
+ # Clear the caches
2720
+ workspace_cleared = len(_global_workspace_cache)
2721
+ lakehouse_cleared = len(_global_lakehouse_cache)
2722
+
2723
+ _global_workspace_cache.clear()
2724
+ _global_lakehouse_cache.clear()
2725
+
2726
+ markdown += "## ✅ Caches Cleared\n\n"
2727
+ markdown += (
2728
+ f"- 🗑️ Workspace name→ID cache: **{workspace_cleared}** entries cleared\n"
2729
+ )
2730
+ markdown += (
2731
+ f"- 🗑️ Lakehouse name→ID cache: **{lakehouse_cleared}** entries cleared\n\n"
2732
+ )
2733
+
2734
+ markdown += "## 🎯 Result\n\n"
2735
+ if workspace_cleared > 0 or lakehouse_cleared > 0:
2736
+ markdown += "✅ **Name resolution caches cleared successfully!**\n\n"
2737
+ markdown += "Next workspace/lakehouse name lookups will make fresh API calls to resolve names to IDs.\n"
2738
+ else:
2739
+ markdown += "ℹ️ **No cached name mappings found to clear.**\n\n"
2740
+ markdown += "The name resolution caches were already empty.\n"
2741
+
2742
+ return markdown
2743
+
2744
+ except Exception as e:
2745
+ return f"Error clearing name resolution cache: {str(e)}"
2746
+
2747
+
2748
+ def main():
2749
+ """Main entry point for the MCP server."""
2750
+ # Initialize and run the server
2751
+ mcp.run(transport="stdio")
2752
+
2753
+
2754
+ if __name__ == "__main__":
2755
+ main()