powerbi-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
src/auth.py ADDED
@@ -0,0 +1,136 @@
1
+ """
2
+ Authentication module for PowerBI API
3
+
4
+ Handles Azure AD OAuth2 client credentials flow for service principal authentication.
5
+ """
6
+
7
+ import os
8
+ import logging
9
+ from datetime import datetime, timedelta
10
+ from typing import Optional
11
+
12
+ import httpx
13
+ from dotenv import load_dotenv
14
+
15
+ from .constants import AUTH_URL_TEMPLATE, AUTH_SCOPE, AUTH_TIMEOUT, TOKEN_REFRESH_BUFFER
16
+ from .exceptions import handle_http_error
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # Load environment variables from current working directory (where the MCP client is running)
21
+ # This allows .env files to be project-specific rather than server-specific
22
+ load_dotenv(dotenv_path=os.path.join(os.getcwd(), '.env'), override=False)
23
+ logger.info(f"Attempted to load .env from: {os.getcwd()}")
24
+
25
+
26
+ class PowerBIAuth:
27
+ """Handles PowerBI authentication using client credentials flow"""
28
+
29
+ def __init__(
30
+ self,
31
+ tenant_id: Optional[str] = None,
32
+ client_id: Optional[str] = None,
33
+ client_secret: Optional[str] = None
34
+ ):
35
+ """
36
+ Initialize authentication handler
37
+
38
+ Args:
39
+ tenant_id: Azure AD tenant ID (defaults to POWERBI_TENANT_ID env var)
40
+ client_id: Application client ID (defaults to POWERBI_CLIENT_ID env var)
41
+ client_secret: Application client secret (defaults to POWERBI_CLIENT_SECRET env var)
42
+
43
+ Note:
44
+ Credentials can be None at initialization. Validation happens when
45
+ authentication is attempted (when tools are actually used).
46
+ """
47
+ self.tenant_id = tenant_id or os.getenv("POWERBI_TENANT_ID")
48
+ self.client_id = client_id or os.getenv("POWERBI_CLIENT_ID")
49
+ self.client_secret = client_secret or os.getenv("POWERBI_CLIENT_SECRET")
50
+
51
+ self.access_token: Optional[str] = None
52
+ self.token_expires: Optional[datetime] = None
53
+ self.http_client = httpx.AsyncClient(timeout=AUTH_TIMEOUT)
54
+
55
+ if all([self.tenant_id, self.client_id, self.client_secret]):
56
+ logger.info("PowerBI authentication handler initialized with credentials")
57
+ else:
58
+ logger.warning(
59
+ "PowerBI authentication handler initialized without credentials. "
60
+ "Set POWERBI_TENANT_ID, POWERBI_CLIENT_ID, and POWERBI_CLIENT_SECRET "
61
+ "environment variables before using tools."
62
+ )
63
+
64
+ async def get_access_token(self) -> str:
65
+ """
66
+ Get valid access token, refreshing if necessary
67
+
68
+ Returns:
69
+ Valid access token string
70
+
71
+ Raises:
72
+ Exception: If authentication fails
73
+ """
74
+ if self.access_token and self.token_expires and datetime.now() < self.token_expires:
75
+ logger.debug("Using cached access token")
76
+ return self.access_token
77
+
78
+ logger.info("Access token expired or missing, refreshing...")
79
+ await self._authenticate()
80
+ return self.access_token
81
+
82
+ async def _authenticate(self) -> None:
83
+ """
84
+ Authenticate with Azure AD using client credentials
85
+
86
+ Raises:
87
+ Exception: If authentication fails with detailed error message
88
+ """
89
+ # Validate credentials before attempting authentication
90
+ if not all([self.tenant_id, self.client_id, self.client_secret]):
91
+ missing = []
92
+ if not self.tenant_id:
93
+ missing.append("POWERBI_TENANT_ID")
94
+ if not self.client_id:
95
+ missing.append("POWERBI_CLIENT_ID")
96
+ if not self.client_secret:
97
+ missing.append("POWERBI_CLIENT_SECRET")
98
+
99
+ raise Exception(
100
+ f"Missing required environment variables: {', '.join(missing)}. "
101
+ "Please set these variables in your .env file or environment configuration."
102
+ )
103
+
104
+ auth_url = AUTH_URL_TEMPLATE.format(tenant_id=self.tenant_id)
105
+
106
+ data = {
107
+ "client_id": self.client_id,
108
+ "client_secret": self.client_secret,
109
+ "scope": AUTH_SCOPE,
110
+ "grant_type": "client_credentials"
111
+ }
112
+
113
+ try:
114
+ logger.debug(f"Authenticating with Azure AD at {auth_url}")
115
+ response = await self.http_client.post(auth_url, data=data)
116
+ response.raise_for_status()
117
+
118
+ token_data = response.json()
119
+ self.access_token = token_data["access_token"]
120
+ expires_in = token_data.get("expires_in", 3600)
121
+ # Refresh before expiry to avoid race conditions
122
+ self.token_expires = datetime.now() + timedelta(seconds=expires_in - TOKEN_REFRESH_BUFFER)
123
+
124
+ logger.info(f"Successfully authenticated with PowerBI. Token expires in {expires_in}s")
125
+
126
+ except httpx.HTTPStatusError as e:
127
+ # Use centralized error handler
128
+ raise handle_http_error(e, "authentication") from e
129
+ except Exception as e:
130
+ logger.error(f"Authentication error: {e}")
131
+ raise Exception(f"Failed to authenticate with PowerBI: {str(e)}") from e
132
+
133
+ async def close(self) -> None:
134
+ """Close HTTP client and cleanup resources"""
135
+ await self.http_client.aclose()
136
+ logger.info("Authentication handler closed")
src/client.py ADDED
@@ -0,0 +1,259 @@
1
+ """
2
+ PowerBI REST API client
3
+
4
+ Handles all interactions with PowerBI REST API endpoints.
5
+ """
6
+
7
+ import logging
8
+ from typing import Any, Optional
9
+
10
+ import httpx
11
+
12
+ from .auth import PowerBIAuth
13
+ from .constants import API_BASE_URL, API_TIMEOUT
14
+ from .exceptions import handle_http_error
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class PowerBIClient:
20
+ """PowerBI REST API client"""
21
+
22
+ def __init__(self, auth: PowerBIAuth):
23
+ """
24
+ Initialize PowerBI API client
25
+
26
+ Args:
27
+ auth: PowerBIAuth instance for handling authentication
28
+ """
29
+ self.auth = auth
30
+ self.http_client = httpx.AsyncClient(timeout=API_TIMEOUT)
31
+ logger.info("PowerBI API client initialized")
32
+
33
+ async def _make_request(
34
+ self,
35
+ method: str,
36
+ endpoint: str,
37
+ **kwargs
38
+ ) -> dict[str, Any]:
39
+ """
40
+ Make authenticated request to PowerBI API
41
+
42
+ Args:
43
+ method: HTTP method (GET, POST, etc.)
44
+ endpoint: API endpoint path
45
+ **kwargs: Additional arguments for httpx request
46
+
47
+ Returns:
48
+ JSON response from API
49
+
50
+ Raises:
51
+ Exception: If API request fails with actionable error message
52
+ """
53
+ token = await self.auth.get_access_token()
54
+
55
+ headers = kwargs.get("headers", {})
56
+ headers["Authorization"] = f"Bearer {token}"
57
+ headers["Content-Type"] = "application/json"
58
+ kwargs["headers"] = headers
59
+
60
+ url = f"{API_BASE_URL}{endpoint}"
61
+
62
+ try:
63
+ logger.debug(f"{method} {url}")
64
+ response = await self.http_client.request(method, url, **kwargs)
65
+ response.raise_for_status()
66
+ return response.json()
67
+
68
+ except httpx.HTTPStatusError as e:
69
+ # Use centralized error handler
70
+ raise handle_http_error(e, "API request") from e
71
+
72
+ except httpx.TimeoutException as e:
73
+ logger.error(f"Request timeout for endpoint: {endpoint}")
74
+ raise Exception(
75
+ f"Request to PowerBI API timed out for endpoint: {endpoint}. "
76
+ "The operation took too long. Try reducing the scope of your query "
77
+ "or check PowerBI service status."
78
+ ) from e
79
+ except Exception as e:
80
+ if isinstance(e, Exception) and "PowerBI" in str(e):
81
+ raise
82
+ logger.error(f"Unexpected error: {e}")
83
+ raise Exception(f"Unexpected error while calling PowerBI API: {str(e)}") from e
84
+
85
+ async def get_workspaces(
86
+ self,
87
+ top: Optional[int] = None,
88
+ skip: Optional[int] = None
89
+ ) -> dict[str, Any]:
90
+ """
91
+ Get list of workspaces (groups)
92
+
93
+ Args:
94
+ top: Maximum number of workspaces to return (pagination)
95
+ skip: Number of workspaces to skip (pagination)
96
+
97
+ Returns:
98
+ Dictionary with 'value' key containing list of workspaces
99
+ """
100
+ params = {}
101
+ if top is not None:
102
+ params["$top"] = top
103
+ if skip is not None:
104
+ params["$skip"] = skip
105
+
106
+ logger.info(f"Fetching workspaces (top={top}, skip={skip})")
107
+ return await self._make_request("GET", "/groups", params=params)
108
+
109
+ async def get_datasets(
110
+ self,
111
+ workspace_id: Optional[str] = None
112
+ ) -> dict[str, Any]:
113
+ """
114
+ Get list of datasets from workspace or My workspace
115
+
116
+ Args:
117
+ workspace_id: Workspace (group) ID. If None, returns datasets from "My workspace"
118
+
119
+ Returns:
120
+ Dictionary with 'value' key containing list of datasets
121
+ """
122
+ if workspace_id:
123
+ endpoint = f"/groups/{workspace_id}/datasets"
124
+ logger.info(f"Fetching datasets from workspace {workspace_id}")
125
+ else:
126
+ endpoint = "/datasets"
127
+ logger.info("Fetching datasets from My workspace")
128
+
129
+ return await self._make_request("GET", endpoint)
130
+
131
+ async def get_dataset(
132
+ self,
133
+ dataset_id: str,
134
+ workspace_id: Optional[str] = None
135
+ ) -> dict[str, Any]:
136
+ """
137
+ Get details of a specific dataset
138
+
139
+ Args:
140
+ dataset_id: Dataset ID
141
+ workspace_id: Workspace (group) ID. If None, looks in "My workspace"
142
+
143
+ Returns:
144
+ Dataset details dictionary
145
+ """
146
+ if workspace_id:
147
+ endpoint = f"/groups/{workspace_id}/datasets/{dataset_id}"
148
+ logger.info(f"Fetching dataset {dataset_id} from workspace {workspace_id}")
149
+ else:
150
+ endpoint = f"/datasets/{dataset_id}"
151
+ logger.info(f"Fetching dataset {dataset_id} from My workspace")
152
+
153
+ return await self._make_request("GET", endpoint)
154
+
155
+ async def execute_queries(
156
+ self,
157
+ dataset_id: str,
158
+ queries: list[dict[str, str]],
159
+ workspace_id: Optional[str] = None
160
+ ) -> dict[str, Any]:
161
+ """
162
+ Execute DAX queries against a dataset
163
+
164
+ Args:
165
+ dataset_id: Dataset ID to query
166
+ queries: List of query dictionaries with 'query' key containing DAX expression
167
+ workspace_id: Workspace (group) ID. If None, uses "My workspace"
168
+
169
+ Returns:
170
+ Query results dictionary
171
+ """
172
+ if workspace_id:
173
+ endpoint = f"/groups/{workspace_id}/datasets/{dataset_id}/executeQueries"
174
+ logger.info(f"Executing {len(queries)} queries on dataset {dataset_id} in workspace {workspace_id}")
175
+ else:
176
+ endpoint = f"/datasets/{dataset_id}/executeQueries"
177
+ logger.info(f"Executing {len(queries)} queries on dataset {dataset_id} in My workspace")
178
+
179
+ payload = {"queries": queries}
180
+
181
+ return await self._make_request("POST", endpoint, json=payload)
182
+
183
+ async def get_refresh_history(
184
+ self,
185
+ dataset_id: str,
186
+ workspace_id: Optional[str] = None,
187
+ top: int = 5
188
+ ) -> dict[str, Any]:
189
+ """
190
+ Get refresh history for a dataset
191
+
192
+ Args:
193
+ dataset_id: Dataset ID
194
+ workspace_id: Workspace (group) ID. If None, uses "My workspace"
195
+ top: Number of refresh records to return (default: 5)
196
+
197
+ Returns:
198
+ Dictionary with 'value' containing refresh history records
199
+ """
200
+ if workspace_id:
201
+ endpoint = f"/groups/{workspace_id}/datasets/{dataset_id}/refreshes"
202
+ logger.info(f"Fetching refresh history for dataset {dataset_id} in workspace {workspace_id}")
203
+ else:
204
+ endpoint = f"/datasets/{dataset_id}/refreshes"
205
+ logger.info(f"Fetching refresh history for dataset {dataset_id} in My workspace")
206
+
207
+ params = {"$top": top}
208
+ return await self._make_request("GET", endpoint, params=params)
209
+
210
+ async def get_parameters(
211
+ self,
212
+ dataset_id: str,
213
+ workspace_id: Optional[str] = None
214
+ ) -> dict[str, Any]:
215
+ """
216
+ Get parameters for a dataset
217
+
218
+ Args:
219
+ dataset_id: Dataset ID
220
+ workspace_id: Workspace (group) ID. If None, uses "My workspace"
221
+
222
+ Returns:
223
+ Dictionary with 'value' containing parameter definitions
224
+ """
225
+ if workspace_id:
226
+ endpoint = f"/groups/{workspace_id}/datasets/{dataset_id}/parameters"
227
+ logger.info(f"Fetching parameters for dataset {dataset_id} in workspace {workspace_id}")
228
+ else:
229
+ endpoint = f"/datasets/{dataset_id}/parameters"
230
+ logger.info(f"Fetching parameters for dataset {dataset_id} in My workspace")
231
+
232
+ return await self._make_request("GET", endpoint)
233
+
234
+ async def get_reports(
235
+ self,
236
+ workspace_id: Optional[str] = None
237
+ ) -> dict[str, Any]:
238
+ """
239
+ Get reports from workspace or My workspace
240
+
241
+ Args:
242
+ workspace_id: Workspace (group) ID. If None, uses "My workspace"
243
+
244
+ Returns:
245
+ Dictionary with 'value' containing report list
246
+ """
247
+ if workspace_id:
248
+ endpoint = f"/groups/{workspace_id}/reports"
249
+ logger.info(f"Fetching reports from workspace {workspace_id}")
250
+ else:
251
+ endpoint = "/reports"
252
+ logger.info("Fetching reports from My workspace")
253
+
254
+ return await self._make_request("GET", endpoint)
255
+
256
+ async def close(self) -> None:
257
+ """Close HTTP client and cleanup resources"""
258
+ await self.http_client.aclose()
259
+ logger.info("PowerBI API client closed")
src/constants.py ADDED
@@ -0,0 +1,26 @@
1
+ """
2
+ PowerBI MCP Server Constants
3
+
4
+ Single source of truth for all configuration values, timeouts, and limits.
5
+ """
6
+
7
+ # API Configuration
8
+ API_BASE_URL = "https://api.powerbi.com/v1.0/myorg"
9
+ AUTH_URL_TEMPLATE = "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
10
+ AUTH_SCOPE = "https://analysis.windows.net/powerbi/api/.default"
11
+
12
+ # Timeouts (seconds)
13
+ AUTH_TIMEOUT = 30.0
14
+ API_TIMEOUT = 60.0
15
+ TOKEN_REFRESH_BUFFER = 60
16
+
17
+ # Response Limits
18
+ CHARACTER_LIMIT = 25000
19
+ MAX_ROWS_DISPLAY = 100
20
+ MAX_WORKSPACES_TOP = 5000
21
+ MIN_WORKSPACES_TOP = 1
22
+
23
+ # Refresh History Limits
24
+ REFRESH_HISTORY_MIN = 1
25
+ REFRESH_HISTORY_MAX = 60
26
+ REFRESH_HISTORY_DEFAULT = 5
src/exceptions.py ADDED
@@ -0,0 +1,96 @@
1
+ """
2
+ Exception handling for PowerBI MCP Server
3
+
4
+ Centralized error handling logic for HTTP errors and PowerBI-specific exceptions.
5
+ """
6
+
7
+ import logging
8
+ import httpx
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class PowerBIError(Exception):
14
+ """Base exception for PowerBI operations"""
15
+ pass
16
+
17
+
18
+ class AuthenticationError(PowerBIError):
19
+ """Authentication failures"""
20
+ pass
21
+
22
+
23
+ class APIError(PowerBIError):
24
+ """API request failures"""
25
+ pass
26
+
27
+
28
+ def handle_http_error(error: httpx.HTTPStatusError, context: str = "API request") -> PowerBIError:
29
+ """
30
+ Convert httpx.HTTPStatusError to PowerBIError with actionable messages
31
+
32
+ Consolidates error handling from client.py and auth.py to provide
33
+ consistent, user-friendly error messages.
34
+
35
+ Args:
36
+ error: The HTTP status error from httpx
37
+ context: Context of the error ("API request", "authentication", etc.)
38
+
39
+ Returns:
40
+ PowerBIError subclass with detailed error message
41
+ """
42
+ # Extract error detail
43
+ error_detail = ""
44
+ try:
45
+ error_detail = error.response.json()
46
+ except Exception:
47
+ error_detail = error.response.text
48
+
49
+ status_code = error.response.status_code
50
+
51
+ # Map status codes to specific errors
52
+ if status_code == 401:
53
+ logger.error("Authentication failed (401)")
54
+ if context == "authentication":
55
+ return AuthenticationError(
56
+ f"PowerBI authentication failed with status {status_code}. "
57
+ "Please verify your POWERBI_TENANT_ID, POWERBI_CLIENT_ID, and "
58
+ f"POWERBI_CLIENT_SECRET are correct. Error details: {error_detail}"
59
+ )
60
+ else:
61
+ return AuthenticationError(
62
+ "Authentication failed (401). Your access token may have expired or "
63
+ "your service principal may not have the required permissions. "
64
+ "Ensure the service principal is enabled in PowerBI admin settings."
65
+ )
66
+
67
+ elif status_code == 403:
68
+ logger.error("Access forbidden (403)")
69
+ return APIError(
70
+ "Access forbidden (403). Your service principal does not have "
71
+ "permission to access this resource. Check PowerBI admin portal "
72
+ "settings and ensure proper workspace access is granted."
73
+ )
74
+
75
+ elif status_code == 404:
76
+ endpoint = error.request.url.path if hasattr(error.request, 'url') else "unknown"
77
+ logger.error(f"Resource not found (404) at endpoint: {endpoint}")
78
+ return APIError(
79
+ f"Resource not found (404) at endpoint: {endpoint}. "
80
+ "Please verify the workspace ID, dataset ID, or other identifiers are correct."
81
+ )
82
+
83
+ elif status_code == 429:
84
+ retry_after = error.response.headers.get("Retry-After", "60")
85
+ logger.warning(f"Rate limit exceeded (429). Retry after {retry_after}s")
86
+ return APIError(
87
+ f"Rate limit exceeded (429). Too many requests. "
88
+ f"Please wait {retry_after} seconds before retrying."
89
+ )
90
+
91
+ else:
92
+ logger.error(f"{context} failed with status {status_code}")
93
+ return APIError(
94
+ f"PowerBI {context} failed with status {status_code}. "
95
+ f"Error details: {error_detail}"
96
+ )