bipixie-mcp 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,57 @@
1
+ """
2
+ bipixie_mcp — BI Pixie MCP Server package.
3
+
4
+ Exposes the stable public surface for tests and embedders. Import-side-effect free:
5
+ no Settings are instantiated, no credential is built, and no network I/O is performed
6
+ at import time. This keeps stdout clean for the JSON-RPC stream (stdio transport) and
7
+ allows tests to patch environment variables before any config object is constructed.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ __version__: str = "0.3.0"
13
+
14
+ # Re-export the stable public surface. Names are imported lazily via __getattr__
15
+ # below so that the actual modules (and their transitive imports such as fastmcp,
16
+ # azure-identity, msal, httpx) are NOT loaded until the caller explicitly requests
17
+ # them. This is critical for the stdio transport: importing this package in a test
18
+ # that patches os.environ must not trigger Settings() construction or MSAL cache
19
+ # initialisation.
20
+
21
+ __all__ = [
22
+ "__version__",
23
+ "Settings",
24
+ "get_settings",
25
+ "build_server",
26
+ "main",
27
+ ]
28
+
29
+ # Populated on first access through __getattr__; never pre-loaded.
30
+ _LAZY_MAP: dict[str, str] = {
31
+ "Settings": "bipixie_mcp.config",
32
+ "get_settings": "bipixie_mcp.config",
33
+ "build_server": "bipixie_mcp.server",
34
+ "main": "bipixie_mcp.server",
35
+ }
36
+
37
+
38
+ def __getattr__(name: str) -> object:
39
+ """
40
+ Lazy loader for the public API.
41
+
42
+ Importing a public name (e.g. ``from bipixie_mcp import Settings``) triggers
43
+ this function, which imports the relevant sub-module on first access and caches
44
+ the result in the package namespace so subsequent accesses are O(1).
45
+ """
46
+ if name not in _LAZY_MAP:
47
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
48
+
49
+ module_name = _LAZY_MAP[name]
50
+ import importlib
51
+
52
+ module = importlib.import_module(module_name)
53
+ obj = getattr(module, name)
54
+
55
+ # Cache in the package namespace to avoid repeated __getattr__ calls.
56
+ globals()[name] = obj
57
+ return obj
bipixie_mcp/auth.py ADDED
@@ -0,0 +1,446 @@
1
+ """Token acquisition for the BI Pixie MCP server.
2
+
3
+ This module is the single auth boundary for every outbound call the server makes
4
+ to the Power BI REST API. It maps the configured ``BIPIXIE_MCP_AUTH_MODE`` onto a
5
+ concrete ``azure.core.credentials.TokenCredential`` and hands back a cached bearer
6
+ token scoped to ``settings.powerbi_scope``
7
+ (``https://analysis.windows.net/powerbi/api/.default``).
8
+
9
+ Auth modes are selected by how the server is HOSTED, not by row visibility: the BI
10
+ Pixie semantic model ships no row-level security, so every mode sees the same full
11
+ usage data. Each Enterprise team installs the BI Pixie Dashboard under its own
12
+ license key / container and points its own MCP server at its own model, so a caller
13
+ who can run the server is already entitled to that model's usage data. The same
14
+ package serves both multi-tenant cloud and self-hosted enterprise deployments:
15
+
16
+ * LOCAL (stdio / Claude Code / Codex on a developer machine)
17
+ - ``device_code`` (default): :class:`azure.identity.DeviceCodeCredential`
18
+ - ``interactive``: :class:`azure.identity.InteractiveBrowserCredential`
19
+ Both are *public-client* flows (no client secret) and persist an MSAL token
20
+ cache at ``settings.token_cache_path`` so the user is not re-prompted on every
21
+ restart.
22
+ - ``azure_cli``: :class:`azure.identity.AzureCliCredential` — reuses an existing
23
+ ``az login`` with no app registration; the convenient local-dev / validation
24
+ mode.
25
+
26
+ * HOSTED (streamable-http inside the customer's Azure)
27
+ - ``service_principal``: :class:`azure.identity.ClientSecretCredential`
28
+ (mirrors ``control_plane_app/shared/template_app_client.py``)
29
+ - ``managed_identity``: :class:`azure.identity.DefaultAzureCredential`
30
+ (zero-secret; the MI is granted Member/Build on the workspace)
31
+
32
+ ``ResponsePiiFilter`` + ``BIPIXIE_MCP_PII_COLUMNS_ALLOWED`` remain the privacy
33
+ control for user-identifying columns, independent of auth mode.
34
+
35
+ Security notes
36
+ --------------
37
+ * This module performs READ-ONLY data access only -- it never requests a
38
+ ``ReadWrite`` scope; the resolved ``.default`` scope should map to the app reg's
39
+ ``Dataset.Read.All``.
40
+ * Secret values and raw bearer tokens are NEVER logged. A redacting
41
+ :class:`logging.Filter` (:class:`SecretRedactingFilter`) is attached to this
42
+ module's logger and masks anything that looks like a GUID, a long hex/base64url
43
+ blob, or a JWT. Per project convention all log output is emitted to stderr;
44
+ configuration of handlers/levels happens in ``server.py``.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import logging
50
+ import os
51
+ import re
52
+ import threading
53
+ import time
54
+ from pathlib import Path
55
+ from typing import TYPE_CHECKING, Final
56
+
57
+ from azure.core.credentials import AccessToken, TokenCredential
58
+ from azure.core.exceptions import ClientAuthenticationError
59
+ from azure.identity import (
60
+ AzureCliCredential,
61
+ ClientSecretCredential,
62
+ DefaultAzureCredential,
63
+ DeviceCodeCredential,
64
+ InteractiveBrowserCredential,
65
+ )
66
+
67
+ if TYPE_CHECKING: # avoid an import cycle / hard dependency at module-load time
68
+ from .config import Settings
69
+
70
+ __all__ = [
71
+ "AuthError",
72
+ "TokenProvider",
73
+ "build_credential",
74
+ "get_powerbi_token",
75
+ "SecretRedactingFilter",
76
+ ]
77
+
78
+
79
+ # --------------------------------------------------------------------------- #
80
+ # Logging (stderr only; handlers configured in server.py). Secrets redacted.
81
+ # --------------------------------------------------------------------------- #
82
+
83
+
84
+ class SecretRedactingFilter(logging.Filter):
85
+ """Mask secret-shaped substrings in any record routed through this logger.
86
+
87
+ Defense-in-depth: even if a future code path accidentally interpolates a token,
88
+ secret, or GUID into a log message, this filter scrubs it before the record
89
+ reaches a handler. We intentionally over-redact (GUIDs, long hex/base64url runs,
90
+ JWTs) rather than risk leaking an Entra credential or bearer token.
91
+ """
92
+
93
+ # GUID (tenant/client IDs), 40+ char hex (token fragments / client secrets),
94
+ # 20+ char base64url runs, and three-segment JWTs.
95
+ _PATTERNS: Final[tuple[re.Pattern[str], ...]] = (
96
+ re.compile(r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
97
+ r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"),
98
+ re.compile(r"\beyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+"),
99
+ re.compile(r"\b[0-9a-fA-F]{40,}\b"),
100
+ re.compile(r"\b[A-Za-z0-9_\-]{20,}\b"),
101
+ )
102
+ _MASK: Final[str] = "***REDACTED***"
103
+
104
+ def _scrub(self, text: str) -> str:
105
+ for pattern in self._PATTERNS:
106
+ text = pattern.sub(self._MASK, text)
107
+ return text
108
+
109
+ def filter(self, record: logging.LogRecord) -> bool: # noqa: A003 - stdlib name
110
+ try:
111
+ message = record.getMessage()
112
+ except Exception: # pragma: no cover - never let logging break auth
113
+ return True
114
+ scrubbed = self._scrub(message)
115
+ if scrubbed != message:
116
+ record.msg = scrubbed
117
+ record.args = ()
118
+ return True
119
+
120
+
121
+ logger = logging.getLogger(__name__)
122
+ logger.addFilter(SecretRedactingFilter())
123
+
124
+
125
+ # --------------------------------------------------------------------------- #
126
+ # Errors
127
+ # --------------------------------------------------------------------------- #
128
+
129
+
130
+ class AuthError(Exception):
131
+ """Raised when a bearer token cannot be acquired for the Power BI API.
132
+
133
+ Messages are deliberately non-secret and setup-oriented (e.g. a hint that the
134
+ 'Allow service principals to use Power BI APIs' tenant setting is required, or
135
+ that the workspace role is missing) so they are safe to surface to an operator
136
+ or an MCP client.
137
+ """
138
+
139
+
140
+ # How long before the real expiry we proactively refresh, so a token never expires
141
+ # mid-flight on a slow Power BI query. Power BI tokens live ~1h; 5 min is generous.
142
+ _REFRESH_SKEW_SECONDS: Final[int] = 300
143
+
144
+ # Per-credential-construction hint appended to AuthError messages for 403/consent
145
+ # style failures. Kept terse and secret-free.
146
+ _SP_DISABLED_HINT: Final[str] = (
147
+ "If using service_principal/managed_identity auth, confirm the Power BI tenant "
148
+ "setting 'Allow service principals to use Power BI APIs' is enabled and that the "
149
+ "identity has at least the Member role on the target workspace."
150
+ )
151
+
152
+
153
+ # --------------------------------------------------------------------------- #
154
+ # Credential construction
155
+ # --------------------------------------------------------------------------- #
156
+
157
+
158
+ def _load_token_cache_seed(token_cache_path: str) -> str | None:
159
+ """Return the serialized MSAL cache blob for a public-client credential.
160
+
161
+ azure-identity's public-client credentials accept a ``cache_persistence_options``
162
+ arg in newer SDKs, but the broadly-portable contract here is to seed the
163
+ in-memory MSAL cache from a previously serialized blob and let the caller re-serialize
164
+ on shutdown. We read the blob if the file exists; absence is not an error
165
+ (first run). Read failures are downgraded to a warning -- a missing cache only
166
+ costs the user one extra interactive prompt, never correctness.
167
+ """
168
+ path = Path(os.path.expanduser(token_cache_path))
169
+ if not path.is_file():
170
+ return None
171
+ try:
172
+ return path.read_text(encoding="utf-8")
173
+ except OSError as exc:
174
+ # Logs only the cache file PATH + exception class name, never token bytes;
175
+ # SecretRedactingFilter scrubs defensively. Semgrep heuristic false positive:
176
+ logger.warning( # nosemgrep: python.lang.security.audit.logging.logger-credential-leak.python-logger-credential-disclosure
177
+ "Could not read MSAL token cache at %s (%s); an interactive prompt "
178
+ "may be required.",
179
+ path,
180
+ exc.__class__.__name__,
181
+ )
182
+ return None
183
+
184
+
185
+ def _build_public_client_credential(
186
+ settings: "Settings",
187
+ ) -> TokenCredential:
188
+ """Construct a DeviceCode/InteractiveBrowser credential for LOCAL stdio use.
189
+
190
+ Both are public-client flows -- no client secret. A persistent MSAL token cache
191
+ at ``settings.token_cache_path`` is enabled where the installed azure-identity
192
+ version supports ``TokenCachePersistenceOptions`` so the refresh token survives
193
+ process restarts; otherwise we fall back to an in-memory cache (one prompt per
194
+ process) without failing.
195
+ """
196
+ if not settings.client_id:
197
+ raise AuthError(
198
+ f"auth_mode='{settings.auth_mode}' requires BIPIXIE_MCP_CLIENT_ID "
199
+ "(a public-client app registration in the configured tenant)."
200
+ )
201
+
202
+ common_kwargs: dict[str, object] = {
203
+ "tenant_id": settings.tenant_id,
204
+ "client_id": settings.client_id,
205
+ }
206
+
207
+ # Best-effort persistent cache. Import lazily because the class name/availability
208
+ # varies across azure-identity releases; a failure here must not break auth.
209
+ cache_options = None
210
+ try: # pragma: no cover - environment-dependent
211
+ from azure.identity import TokenCachePersistenceOptions
212
+
213
+ cache_dir = Path(os.path.expanduser(settings.token_cache_path)).parent
214
+ cache_dir.mkdir(parents=True, exist_ok=True)
215
+ cache_options = TokenCachePersistenceOptions(
216
+ name="bipixie-mcp",
217
+ allow_unencrypted_storage=True,
218
+ )
219
+ except Exception as exc: # noqa: BLE001 - persistence is optional
220
+ # Logs only the exception class name, never secrets. Semgrep false positive:
221
+ logger.debug( # nosemgrep: python.lang.security.audit.logging.logger-credential-leak.python-logger-credential-disclosure
222
+ "Persistent token cache unavailable (%s); using in-memory cache.",
223
+ exc.__class__.__name__,
224
+ )
225
+
226
+ if cache_options is not None:
227
+ common_kwargs["cache_persistence_options"] = cache_options
228
+
229
+ try:
230
+ if settings.auth_mode == "interactive":
231
+ return InteractiveBrowserCredential(**common_kwargs) # type: ignore[arg-type]
232
+ # default LOCAL mode
233
+ return DeviceCodeCredential(**common_kwargs) # type: ignore[arg-type]
234
+ except TypeError:
235
+ # Older azure-identity that does not accept cache_persistence_options.
236
+ common_kwargs.pop("cache_persistence_options", None)
237
+ if settings.auth_mode == "interactive":
238
+ return InteractiveBrowserCredential(**common_kwargs) # type: ignore[arg-type]
239
+ return DeviceCodeCredential(**common_kwargs) # type: ignore[arg-type]
240
+
241
+
242
+ def build_credential(settings: "Settings") -> TokenCredential:
243
+ """Map ``settings.auth_mode`` onto a concrete azure-identity credential.
244
+
245
+ This is the single place that knows how each mode is wired:
246
+
247
+ * ``device_code`` -> :class:`DeviceCodeCredential` (public client)
248
+ * ``interactive`` -> :class:`InteractiveBrowserCredential` (public client)
249
+ * ``azure_cli`` -> :class:`AzureCliCredential` (reuses ``az login``; local dev)
250
+ * ``service_principal``-> :class:`ClientSecretCredential` (app token)
251
+ * ``managed_identity`` -> :class:`DefaultAzureCredential` (app token)
252
+
253
+ Construction is side-effect-light: no token is acquired here. :class:`TokenProvider`
254
+ drives the actual ``get_token`` call lazily. Raises :class:`AuthError` for an
255
+ unknown mode or missing required configuration (mirroring, but not replacing,
256
+ ``Settings.validate_runtime``).
257
+ """
258
+ mode = settings.auth_mode
259
+
260
+ if mode in ("device_code", "interactive"):
261
+ return _build_public_client_credential(settings)
262
+
263
+ if mode == "service_principal":
264
+ if not (settings.client_id and settings.client_secret):
265
+ raise AuthError(
266
+ "auth_mode='service_principal' requires both "
267
+ "BIPIXIE_MCP_CLIENT_ID and BIPIXIE_MCP_CLIENT_SECRET."
268
+ )
269
+ return ClientSecretCredential(
270
+ tenant_id=settings.tenant_id,
271
+ client_id=settings.client_id,
272
+ client_secret=settings.client_secret,
273
+ )
274
+
275
+ if mode == "azure_cli":
276
+ # Reuse the developer's existing `az login` — no app registration, no
277
+ # secret. AzureCliCredential shells out to `az account get-access-token`.
278
+ # Ideal for local validation/dev. Pass tenant_id when set (newer
279
+ # azure-identity); fall back gracefully on older versions that lack it.
280
+ try:
281
+ return (
282
+ AzureCliCredential(tenant_id=settings.tenant_id)
283
+ if settings.tenant_id
284
+ else AzureCliCredential()
285
+ )
286
+ except TypeError:
287
+ return AzureCliCredential()
288
+
289
+ if mode == "managed_identity":
290
+ # DefaultAzureCredential resolves the Azure-hosted managed identity (and
291
+ # honors AZURE_CLIENT_ID for a user-assigned MI). No secret needed.
292
+ return DefaultAzureCredential()
293
+
294
+ raise AuthError(
295
+ f"Unknown BIPIXIE_MCP_AUTH_MODE '{mode}'. Expected one of: "
296
+ "device_code, interactive, azure_cli, service_principal, managed_identity."
297
+ )
298
+
299
+
300
+ # --------------------------------------------------------------------------- #
301
+ # Token provider (cached + auto-refresh)
302
+ # --------------------------------------------------------------------------- #
303
+
304
+
305
+ class TokenProvider:
306
+ """Acquire and cache a Power BI bearer token for the configured identity.
307
+
308
+ A single :class:`TokenProvider` is created once at server startup (in
309
+ ``server.build_server``) and shared by :class:`powerbi_client.PowerBIClient`.
310
+ The underlying azure-identity credential is constructed lazily on first use and
311
+ then reused process-wide; tokens are cached and proactively refreshed about
312
+ five minutes before expiry, so the credential is *not* invoked per request.
313
+
314
+ Thread-safety: ``get_token`` is guarded by a lock because the streamable-http
315
+ transport may dispatch tool calls concurrently. The hot path (valid cached
316
+ token) takes the lock only briefly to read the cached value.
317
+ """
318
+
319
+ def __init__(self, settings: "Settings") -> None:
320
+ self._settings = settings
321
+ self._scope: str = settings.powerbi_scope
322
+ self._lock = threading.Lock()
323
+ self._credential: TokenCredential | None = None
324
+ self._cached_token: AccessToken | None = None
325
+
326
+ # -- public API ------------------------------------------------------- #
327
+
328
+ def get_token(self) -> str:
329
+ """Return a valid bearer token for ``settings.powerbi_scope``.
330
+
331
+ Reuses the cached token until it is within ``_REFRESH_SKEW_SECONDS`` of
332
+ expiry, then transparently refreshes. Raises :class:`AuthError` (never a
333
+ raw azure exception, and never a message containing a secret) on failure.
334
+ """
335
+ with self._lock:
336
+ cached = self._cached_token
337
+ if cached is not None and not self._is_expiring(cached):
338
+ return cached.token
339
+
340
+ credential = self._ensure_credential()
341
+ token = self._acquire(credential)
342
+ self._cached_token = token
343
+ return token.token
344
+
345
+ # -- internals -------------------------------------------------------- #
346
+
347
+ @staticmethod
348
+ def _is_expiring(token: AccessToken) -> bool:
349
+ """True if the token is unset, already expired, or inside the refresh skew."""
350
+ return token.expires_on - _REFRESH_SKEW_SECONDS <= int(time.time())
351
+
352
+ def _ensure_credential(self) -> TokenCredential:
353
+ if self._credential is None:
354
+ self._credential = build_credential(self._settings)
355
+ # Logs only the auth-mode enum (device_code/azure_cli/...), not a secret.
356
+ logger.info( # nosemgrep: python.lang.security.audit.logging.logger-credential-leak.python-logger-credential-disclosure
357
+ "Initialized Power BI credential (auth_mode=%s).",
358
+ self._settings.auth_mode,
359
+ )
360
+ return self._credential
361
+
362
+ def _acquire(self, credential: TokenCredential) -> AccessToken:
363
+ try:
364
+ token = credential.get_token(self._scope)
365
+ except ClientAuthenticationError as exc:
366
+ raise AuthError(self._explain(exc)) from exc
367
+ except Exception as exc: # noqa: BLE001 - normalize to AuthError
368
+ raise AuthError(
369
+ f"Failed to acquire a Power BI token ({exc.__class__.__name__}). "
370
+ f"{_SP_DISABLED_HINT}"
371
+ ) from exc
372
+
373
+ if not token or not getattr(token, "token", None):
374
+ raise AuthError(
375
+ "Token acquisition returned an empty bearer for scope "
376
+ f"'{self._scope}'. {_SP_DISABLED_HINT}"
377
+ )
378
+ # Logs only the token EXPIRY timestamp, never the token itself. False positive:
379
+ logger.debug("Acquired Power BI token (expires_on=%s).", token.expires_on) # nosemgrep: python.lang.security.audit.logging.logger-credential-leak.python-logger-credential-disclosure
380
+ return token
381
+
382
+ def _explain(self, exc: ClientAuthenticationError) -> str:
383
+ """Build a non-secret, setup-oriented message from an auth failure.
384
+
385
+ We surface the credential type and a remediation hint rather than the raw
386
+ exception text (which can embed correlation IDs / claims). Service-principal
387
+ and managed-identity failures get the tenant-setting/workspace-role hint.
388
+ """
389
+ mode = self._settings.auth_mode
390
+ base = (
391
+ f"Could not authenticate to Power BI using auth_mode='{mode}' "
392
+ f"for scope '{self._scope}'."
393
+ )
394
+ if mode in ("service_principal", "managed_identity"):
395
+ return f"{base} {_SP_DISABLED_HINT}"
396
+ if mode in ("device_code", "interactive"):
397
+ return (
398
+ f"{base} Confirm BIPIXIE_MCP_CLIENT_ID is a public-client app "
399
+ "registration in the configured tenant and that you completed the "
400
+ "sign-in prompt."
401
+ )
402
+ if mode == "azure_cli":
403
+ return (
404
+ f"{base} Confirm you have run 'az login' and that the active Azure "
405
+ "CLI account can reach the Power BI API in this tenant."
406
+ )
407
+ return base
408
+
409
+
410
+ # --------------------------------------------------------------------------- #
411
+ # Module-level async convenience
412
+ # --------------------------------------------------------------------------- #
413
+
414
+ # Process-wide TokenProvider cache keyed by the identity-relevant config tuple, so
415
+ # repeated get_powerbi_token() calls reuse one credential + token cache. Keyed on the
416
+ # fields that determine the identity/scope so a re-configured Settings yields a fresh
417
+ # provider rather than a stale token.
418
+ _PROVIDER_LOCK = threading.Lock()
419
+ _PROVIDERS: dict[tuple, TokenProvider] = {}
420
+
421
+
422
+ def _provider_key(settings: "Settings") -> tuple:
423
+ return (
424
+ settings.tenant_id,
425
+ settings.auth_mode,
426
+ settings.client_id,
427
+ settings.powerbi_scope,
428
+ )
429
+
430
+
431
+ async def get_powerbi_token(settings: "Settings") -> str:
432
+ """Async-friendly accessor returning a cached Power BI bearer token.
433
+
434
+ Thin coroutine wrapper over :class:`TokenProvider` for callers (and tools) that
435
+ operate in an async context. The MSAL/azure-identity token acquisition itself is
436
+ synchronous and almost always served from cache; the rare network refresh is
437
+ brief, so it runs inline. Providers are cached per identity-config so concurrent
438
+ callers share one credential and one token.
439
+ """
440
+ key = _provider_key(settings)
441
+ with _PROVIDER_LOCK:
442
+ provider = _PROVIDERS.get(key)
443
+ if provider is None:
444
+ provider = TokenProvider(settings)
445
+ _PROVIDERS[key] = provider
446
+ return provider.get_token()