
Connecting Claude Enterprise to Power BI semantic models
Written by Kevin Sherman & Jacob King
Recently, a client came to us with a compelling use case: they had invested in Claude Enterprise but didn’t have an easy way to connect it to their existing data platform in Power BI. The goal was straightforward: enable business users to write natural-language queries directly against data in Power BI, surfacing faster, more meaningful insights without requiring a deeper level of technical expertise.
While the ask was clear, the path to get there was not. At the time of the engagement, no native connection methods were available to connect Power BI to Claude Enterprise. Attempts to use a generic MCP connector encountered authentication hurdles that prevented a straightforward integration.
To make things more complex, the solution landscape in this space is young and rapidly evolving, which meant that simply finding a solution wasn’t enough we needed to find the right one, grounded in best practices that would hold up as the technology matures.
This post walks through our approach and the solution that emerged from it.
On paper, it sounded simple: Connect Claude to a Power BI Semantic Model to allow for users to ask natural language queries against hosted datasets. But how? This is where the Model Context Protocol (MCP) comes in.
MCP is an open standard developed by Anthropic that enables AI models to interface with external tools, data sources, and services through a standardized communication layer. Think of MCP as a USB connector for AI. Much as USB created a universal standard so that any device can plug into any computer, MCP creates a universal standard so that AI models like Claude can plug into virtually any data source or tool.
Power BI now exposes a hosted MCP server for semantic models. So, in theory, connecting the two should have been straightforward. In practice, it wasn’t.
We were able to integrate Claude querying Power BI semantic models locally using development tooling. That proved the concept quickly and convincingly. But the moment we attempted to turn that into something business users could rely on (a connector that works in Claude Web or Desktop, without local setup), things broke down.
The core issue wasn’t data access, the DAX query generation or even security itself.
It was how authentication was expected to work between Claude and the hosted Power BI MCP server.
How we connected Claude to Power BI via a remote MCP server
We enabled Power BI’s remote MCP server capability, placed Azure API Management (APIM) in front for OAuth/Conditional Access, and ran a lightweight Azure Container App as an MCP “bridge.” As a result, the server performs an On‑Behalf‑Of (OBO) token exchange and forwards MCP JSON‑RPC calls to Microsoft’s hosted Power BI MCP endpoint.
The result is a single, enterprise‑ready connector that works for Claude Web and Desktop, preserves user identity & RLS, and centralizes security and observability.

Followed this architecture pattern:
Building Claude-Ready Entra ID-Protected MCP Servers with Azure API Management – Microsoft for Developers
Architecture at a glance
Flow
Claude → APIM (OAuth gateway) → Azure Container App (MCP bridge) → Microsoft Power BI Remote MCP server. APIM validates tokens; the bridge exchanges the user token via OBO for a Fabric/Power BI token and forwards MCP JSON‑RPC calls upstream.
Why APIM?
It’s the recommended OAuth 2.0 gateway for Claude‑ready MCP servers, giving us Entra ID auth, Conditional Access, token validation, and logging – with zero OAuth code in the bridge.
Prerequisites (Power BI & Permissions)
- Enable the tenant setting:
Users can use the Power BI Model Context Protocol server endpoint (Preview). Without this, all calls fail. - Use delegated user permissions:
Users need Build permission on the semantic model. RLS is enforced only under a delegated user context (not with service principals) for the remote MCP server.
Entra ID Application (Expose an API)
We created a resource API app representing the bridge:
- Name: powerbi-mcp-bridge-api
- Application ID URI: api://powerbi-mcp-bridge
- Delegated scope: user_impersonation (admin consent required)
Claude ultimately asks for this scope when calling our endpoint through APIM.
Note: Because APIM acts as the OAuth gateway, we didn’t need a second app registration for Claude.
Code Reference
# ==========================================
# MCP Bridge Deploy Script (Cloud Shell / PowerShell)
# – Builds in ACR using ACR Tasks (no Docker daemon required) [1](https://www.serverlessnotes.com/docs/getting-started-with-azure-container-apps)[2](https://developer.microsoft.com/blog/claude-ready-secure-mcp-apim)
# – Deploys to Azure Container Apps with external ingress
# – Implements MCP Streamable HTTP (/mcp GET+POST) [11](https://deepwiki.com/AzureAD/microsoft-authentication-library-for-python/3-authentication-flows)[12](https://docs.azure.cn/en-us/container-apps/manage-secrets)
# – Implements MCP Auth discovery via PRM and WWW-Authenticate resource_metadata [5](https://docs.azure.cn/en-us/container-apps/tutorial-deploy-first-app-cli)[7](https://learn.microsoft.com/en-us/cli/azure/containerapp/identity?view=azure-cli-latest)[6](https://learn.microsoft.com/en-us/cli/azure/containerapp/ingress?view=azure-cli-latest)
# ==========================================
# ===== EDIT THESE =====
$RG = “”
$APP = “”
$ACR = “” # your ACR name
$TAG = “” # bump tag each run (1.8, 1.9, etc.)
# Entra App Registration: powerbi-mcp-bridge-api
$TENANT_ID = “”
$CLIENT_ID = “”
$CLIENT_SECRET = “”
# Your existing Container App FQDN (optional; script will read it too)
# $FQDN = “powerbi-mcp-bridge.jollycoast-dd948f15.westus.azurecontainerapps.io”
# ======================
# Ensure we are in a persistent Cloud Shell folder
cd ~/clouddrive
mkdir mcpbridge -ErrorAction SilentlyContinue
cd mcpbridge
# ———————-
# Write app.py (FULL)
# ———————-
@”
import os
import json
import time
import secrets
import urllib.parse
from typing import Optional, Dict, Any, Tuple
import httpx
import msal
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import Response, RedirectResponse, JSONResponse
# —————————-
# Configuration
# —————————-
FABRIC_MCP_URL = “https://api.fabric.microsoft.com/v1/mcp/powerbi” # Power BI Remote MCP endpoint
TENANT_ID = os.environ[“AZURE_TENANT_ID”]
CLIENT_ID = os.environ[“AZURE_CLIENT_ID”]
CLIENT_SECRET = os.environ.get(“AZURE_CLIENT_SECRET”, “”)
AUTHORITY = f”https://login.microsoftonline.com/{TENANT_ID}”
ENTRA_AUTHORIZE = f”https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/authorize”
ENTRA_TOKEN = f”https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token”
MCP_SCOPE = f”api://{CLIENT_ID}/user_impersonation”
AUTH_SCOPES = f”{MCP_SCOPE} openid profile offline_access”
DOWNSTREAM_SCOPES = [“https://analysis.windows.net/powerbi/api/.default”]
LOG_LEVEL = os.environ.get(“LOG_LEVEL”, “info”).lower()
# Session fallback (PoC)
SESSION_COOKIE_NAME = “mcp_session”
SESSION_TTL_SECONDS = int(os.environ.get(“SESSION_TTL_SECONDS”, “3600”)) # 1 hour default
# In-memory session store: {session_id: (access_token, expires_at_epoch)}
SESSION_STORE: Dict[str, Tuple[str, float]] = {}
app = FastAPI()
# —————————-
# Helpers
# —————————-
def log(msg: str) -> None:
if LOG_LEVEL == “debug”:
print(f”DEBUG: {msg}”, flush=True)
def public_base_url(request: Request) -> str:
# ACA terminates TLS at ingress; force https for metadata URLs.
host = request.headers.get(“x-forwarded-host”) or request.headers.get(“host”)
proto = request.headers.get(“x-forwarded-proto”) or “https”
if proto != “https”:
proto = “https”
return f”{proto}://{host}”
def validate_redirect_uri(redirect_uri: str) -> None:
allowed = {
“https://claude.ai/api/mcp/auth_callback”,
“https://claude.com/api/mcp/auth_callback”,
}
if redirect_uri not in allowed:
raise HTTPException(status_code=400, detail=”redirect_uri not allowed”)
def parse_form_body_robust(body_bytes: bytes) -> Dict[str, str]:
raw = body_bytes.decode(“utf-8″, errors=”ignore”)
parsed = urllib.parse.parse_qs(raw)
return {k: v[0] for k, v in parsed.items()}
def unauthorized_with_prm(request: Request) -> Response:
# MCP auth discovery: 401 + WWW-Authenticate resource_metadata.
base = public_base_url(request)
prm_url = f”{base}/.well-known/oauth-protected-resource”
return Response(
status_code=401,
headers={“WWW-Authenticate”: f’Bearer realm=”mcp”, resource_metadata=”{prm_url}”‘},
)
def extract_access_token_from_request(request: Request, json_body: Optional[Dict[str, Any]] = None) -> Optional[str]:
# 1) Standard Authorization header
auth = request.headers.get(“authorization”)
if auth and auth.lower().startswith(“bearer “):
log(“Token channel=Authorization header”)
return auth.split(” “, 1)[1].strip()
# 2) Alternate headers (just in case)
for h in [“x-authorization”, “x-access-token”, “x-api-token”, “x-bearer-token”]:
v = request.headers.get(h)
if v:
log(f”Token channel=header:{h}”)
if v.lower().startswith(“bearer “):
return v.split(” “, 1)[1].strip()
return v.strip()
# 3) Query param
qtok = request.query_params.get(“access_token”) or request.query_params.get(“authorization_token”)
if qtok:
log(“Token channel=query”)
return qtok.strip()
# 4) Body field (if client uses body method)
if json_body and isinstance(json_body, dict):
tok = json_body.get(“access_token”) or json_body.get(“authorization_token”)
if isinstance(tok, str) and tok.strip():
log(“Token channel=json_body”)
return tok.strip()
params = json_body.get(“params”)
if isinstance(params, dict):
maybe = params.get(“access_token”) or params.get(“authorization_token”)
if isinstance(maybe, str) and maybe.strip():
log(“Token channel=json_params”)
return maybe.strip()
return None
def get_session_token(request: Request) -> Optional[str]:
sid = request.cookies.get(SESSION_COOKIE_NAME)
if not sid:
return None
entry = SESSION_STORE.get(sid)
if not entry:
return None
token, exp = entry
if time.time() > exp:
SESSION_STORE.pop(sid, None)
return None
log(“Token channel=cookie_session”)
return token
def put_session_token(access_token: str, expires_in: int) -> str:
sid = secrets.token_urlsafe(24)
# keep some buffer
exp = time.time() + max(60, int(expires_in) – 30)
# also cap with SESSION_TTL_SECONDS
exp = min(exp, time.time() + SESSION_TTL_SECONDS)
SESSION_STORE[sid] = (access_token, exp)
return sid
def get_obo_token(user_token: str) -> str:
if not CLIENT_SECRET:
raise HTTPException(status_code=500, detail=”AZURE_CLIENT_SECRET not set (needed for OBO)”)
cca = msal.ConfidentialClientApplication(
client_id=CLIENT_ID,
client_credential=CLIENT_SECRET,
authority=AUTHORITY,
)
result = cca.acquire_token_on_behalf_of(user_assertion=user_token, scopes=DOWNSTREAM_SCOPES)
if “access_token” not in result:
print(f”OBO FAILED: {json.dumps(result, indent=2)}”, flush=True) # ADD THIS
raise HTTPException(status_code=401, detail=result)
return result[“access_token”]
# —————————-
# PRM: Protected Resource Metadata
# Serve base + suffix (/mcp) because Claude probes it.
# —————————-
@app.get(“/.well-known/oauth-protected-resource”)
@app.get(“/.well-known/oauth-protected-resource/{_tail:path}”)
def prm_any(request: Request, _tail: str = “”):
base = public_base_url(request)
return {
“resource”: f”api://{CLIENT_ID}”,
“authorization_servers”: [base],
“bearer_methods_supported”: [“header”, “body”, “query”],
“scopes_supported”: [MCP_SCOPE, “openid”, “profile”, “offline_access”],
}
# —————————-
# OAuth Authorization Server Metadata (Claude calls this on your host)
# —————————-
@app.get(“/.well-known/oauth-authorization-server”)
def oauth_server_metadata(request: Request):
base = public_base_url(request)
return {
“issuer”: base,
“authorization_endpoint”: f”{base}/authorize”,
“token_endpoint”: f”{base}/token”,
“response_types_supported”: [“code”],
“grant_types_supported”: [“authorization_code”, “refresh_token”],
“code_challenge_methods_supported”: [“S256”],
“token_endpoint_auth_methods_supported”: [“none”, “client_secret_post”],
“scopes_supported”: [MCP_SCOPE, “openid”, “profile”, “offline_access”],
}
# —————————-
# /authorize shim -> redirect to Entra
# —————————-
@app.get(“/authorize”)
def authorize(
response_type: str = “code”,
client_id: str = “”,
redirect_uri: str = “”,
code_challenge: str = “”,
code_challenge_method: str = “S256”,
state: str = “”,
scope: str = “”,
prompt: str = “”,
):
validate_redirect_uri(redirect_uri)
params = {
“client_id”: CLIENT_ID,
“response_type”: “code”,
“redirect_uri”: redirect_uri,
“response_mode”: “query”,
“scope”: AUTH_SCOPES,
“code_challenge”: code_challenge,
“code_challenge_method”: code_challenge_method,
“state”: state,
}
if prompt:
params[“prompt”] = prompt
url = ENTRA_AUTHORIZE + “?” + urllib.parse.urlencode(params, quote_via=urllib.parse.quote)
return RedirectResponse(url, status_code=302)
# —————————-
# /token shim -> proxy to Entra (supports authorization_code + refresh_token)
# Normalizes response (token_type lowercase + no-store), and sets a session cookie fallback.
# —————————-
@app.post(“/token”)
async def token_proxy(request: Request):
data = parse_form_body_robust(await request.body())
grant_type = data.get(“grant_type”)
if not grant_type:
return JSONResponse({“error”: “invalid_request”, “error_description”: “grant_type missing”}, status_code=400)
token_data = {“client_id”: CLIENT_ID, “grant_type”: grant_type, “scope”: AUTH_SCOPES}
if grant_type == “authorization_code”:
code = data.get(“code”)
redirect_uri = data.get(“redirect_uri”)
code_verifier = data.get(“code_verifier”)
if not code or not redirect_uri or not code_verifier:
return JSONResponse({“error”: “invalid_request”, “error_description”: “Missing code/redirect_uri/code_verifier”}, status_code=400)
validate_redirect_uri(redirect_uri)
token_data.update({“code”: code, “redirect_uri”: redirect_uri, “code_verifier”: code_verifier})
elif grant_type == “refresh_token”:
refresh_token = data.get(“refresh_token”)
if not refresh_token:
return JSONResponse({“error”: “invalid_request”, “error_description”: “Missing refresh_token”}, status_code=400)
token_data.update({“refresh_token”: refresh_token})
else:
return JSONResponse({“error”: “unsupported_grant_type”}, status_code=400)
if CLIENT_SECRET:
token_data[“client_secret”] = CLIENT_SECRET
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(ENTRA_TOKEN, data=token_data, headers={“Content-Type”: “application/x-www-form-urlencoded”})
# Normalize JSON token response
ct = resp.headers.get(“content-type”, “”)
headers = {“Cache-Control”: “no-store”, “Pragma”: “no-cache”}
if “application/json” in ct:
payload = resp.json()
if isinstance(payload, dict):
# normalize token_type
if “token_type” in payload and isinstance(payload[“token_type”], str):
payload[“token_type”] = payload[“token_type”].lower()
if “access_token” in payload and “token_type” not in payload:
payload[“token_type”] = “bearer”
# Session cookie fallback
if “access_token” in payload and isinstance(payload[“access_token”], str):
expires_in = int(payload.get(“expires_in”, 3600))
sid = put_session_token(payload[“access_token”], expires_in)
headers[“Set-Cookie”] = (
f”{SESSION_COOKIE_NAME}={sid}; Path=/; Secure; HttpOnly; SameSite=None”
)
return JSONResponse(payload, status_code=resp.status_code, headers=headers)
# Fallback passthrough
return Response(content=resp.content, status_code=resp.status_code, media_type=”application/json”, headers=headers)
# —————————-
# MCP Streamable HTTP endpoint (/mcp supports GET + POST).
# —————————-
@app.get(“/mcp”)
def mcp_get():
# Reachability probe (keep 200)
return {}
@app.post(“/mcp”)
async def mcp_post(request: Request):
body_bytes = await request.body()
try:
body_json = json.loads(body_bytes)
except Exception:
body_json = None
# Try token via normal channels
user_token = extract_access_token_from_request(request, body_json if isinstance(body_json, dict) else None)
# Fallback to session cookie (PoC workaround)
if not user_token:
user_token = get_session_token(request)
if not user_token:
# This matches your logs: /token 200, then /mcp 401 because no token arrived
log(“POST /mcp: no token detected -> 401 with PRM discovery header”)
return unauthorized_with_prm(request)
# OBO to Fabric
fabric_token = get_obo_token(user_token)
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(
FABRIC_MCP_URL,
content=body_bytes,
headers={
“Authorization”: f”Bearer {fabric_token}”,
“Content-Type”: “application/json”,
“Accept”: request.headers.get(“accept”, “application/json, text/event-stream”),
},
)
return Response(content=resp.content, status_code=resp.status_code, media_type=resp.headers.get(“content-type”, “application/json”))
“@ | Out-File -Encoding utf8 app.py
# ———————-
# Write requirements.txt
# ———————-
@”
fastapi
uvicorn
httpx
msal
“@ | Out-File -Encoding utf8 requirements.txt
# ———————-
# Write Dockerfile (proxy headers on)
# ———————-
@”
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install –no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 8080
CMD [“uvicorn”,”app:app”,”–host”,”0.0.0.0″,”–port”,”8080″,”–proxy-headers”,”–forwarded-allow-ips”,”*”]
“@ | Out-File -Encoding utf8 Dockerfile
Write-Host “Files written to: $PWD” -ForegroundColor Green
Write-Host “Building image in ACR (no local Docker)…” -ForegroundColor Yellow
# ———————-
# Build & push using ACR Tasks (Cloud Shell compatible)
# ———————-
az acr build `
–registry $ACR `
–image “powerbi-mcp-bridge:$TAG” `
–file Dockerfile `
.
# ———————-
# Update Container App to new image
# ———————-
$ACR_SERVER = “$ACR.azurecr.io”
Write-Host “Updating Container App image…” -ForegroundColor Yellow
az containerapp update `
-g $RG `
-n $APP `
–image “$ACR_SERVER/powerbi-mcp-bridge:$TAG” `
–set-env-vars `
AZURE_TENANT_ID=$TENANT_ID `
AZURE_CLIENT_ID=$CLIENT_ID `
AZURE_CLIENT_SECRET=$CLIENT_SECRET
# ———————-
# Ensure ingress target port = 8080 (ingress uses targetPort)
# ———————-
Write-Host “Ensuring ingress target port is 8080…” -ForegroundColor Yellow
az containerapp ingress update -g $RG -n $APP –target-port 8080
# ———————-
# Read FQDN + validate endpoints
# ———————-
$FQDN = az containerapp show -g $RG -n $APP –query properties.configuration.ingress.fqdn -o tsv
$BASE = “https://$FQDN”
Write-Host “”
Write-Host “APP URL: $BASE” -ForegroundColor Cyan
Write-Host “”
Write-Host “Validation 1: PRM should return JSON (oauth-protected-resource)…” -ForegroundColor Yellow
curl “$BASE/.well-known/oauth-protected-resource”
Write-Host “”
Write-Host “Validation 2: POST /mcp without token should return 401 + WWW-Authenticate resource_metadata=https://…” -ForegroundColor Yellow
$BODY='{“jsonrpc”:”2.0″,”id”:”1″,”method”:”initialize”,”params”:{“protocolVersion”:”2025-03-26″,”clientInfo”:{“name”:”curl-test”,”version”:”1.0″},”capabilities”:{}}}’
curl -i -X POST “$BASE/mcp” -H “Content-Type: application/json” -d $BODY
Write-Host “”
Write-Host “Done. Use this URL in Claude connector:” -ForegroundColor Green
Write-Host “$BASE/mcp” -ForegroundColor Green
Why OBO?
This architecture hinges on a critical identity constraint: the token issued to our bridge API cannot be used directly against Microsoft Fabric. The incoming access token is scoped for our API (api://powerbi-mcp-bridge). Fabric requires a token issued specifically for its own resource. Simply forwarding the token would fail and, more importantly, violate proper token-scoping principles.
To solve this, we use the OAuth 2.0 On-Behalf-Of (OBO) flow. OBO exchanges the incoming user token for a new token targeting Fabric, while preserving the user’s identity. This ensures that every request to Power BI executes under the actual user context.
This is not just a technical requirement but also foundational to the security model:
- User identity is preserved end-to-end
- Row Level Security is enforced correctly
- Audit logs reflect real user activity
- Tokens remain properly scoped to their intended resources
Without OBO, the only alternative would be a service principal-based approach, which breaks user-level security and governance.
Why we built it this way
This architecture was driven by two constraints.
First, Claude Web can’t run local MCP servers, so we needed a remote connector that works across both Web and Desktop. Second, Power BI only enforces Row Level Security when queries run under a user’s identity, which rules out service account approaches. To solve both, we standardized on Entra ID authentication for delegated end-to-end. This allows us to host a single connector in the cloud while preserving user identity, ensuring RLS is enforced, and access is fully governed. The result is a centralized, secure integration that meets enterprise requirements such as Conditional Access, audit logging, and consistent access across all Claude clients.
Enterprise asks we met: centralized Entra ID authentication, Scalable Claude Integration, Conditional Access, RLS enforcement, audit logging, and one secure connector for Claude Web + Desktop.
Claude configuration & first run experience
In Claude Web or Desktop, add a custom connector pointing to the APIM front‑door URL (e.g., https://mcp.yourcompany.com). Claude auto-detects OAuth. The first call triggers a 401 from APIM, Claude launches Entra sign-in, and once complete, the calls proceed with a valid token.
From there, users can prompt: “Use semantic model; retrieve schema; then show top 10 customers by revenue last quarter.”
Behind the scenes, Claude lists tools, retrieves the schema, generates or accepts DAX, executes the query in the Power BI Service, and enforces RLS.
Security & governance wins
- Entra ID auth with Conditional Access (MFA/compliant devices) at the edge (APIM)
- RLS preserved via delegated user context
- Centralized logging/audit across APIM and Power BI
- Single MCP endpoint for both Claude Web and Desktop users
- No client secrets stored in Claude, and no custom OAuth code in the bridge
Operational checklist (what we actually did)
To ensure a secure and seamless integration between Claude.ai and Power BI, we followed a series of operational steps. Below is a checklist outlining the key actions taken to implement this architecture effectively.
- Power BI Admin: enable the Remote MCP server tenant setting.
- Entra App (Bridge API): Expose api://powerbi-mcp-bridge with user_impersonation (admin consent).
- APIM OAuth Server: add Entra v2 endpoints; configure the scope api://powerbi-mcp-bridge/user_impersonation.
- APIM Policy: require validated JWT with the scp=user_impersonation.
- Container App (Bridge): deploy FastAPI + MSAL app, expose /mcp, set env secrets, and restrict ingress to APIM’s IPs.
- Claude Connector: point to APIM URL; complete Entra sign‑in on first use.
Conclusion
By combining Power BI’s Remote MCP server with APIM and a lightweight OBO‑enabled bridge, we established a scalable, enterprise‑ready foundation for integrating Claude.ai with governed BI assets. This approach delivers secure, auditable, and RLS‑respecting access to semantic models through a single connector that supports both Claude Web and Desktop – making it well-suited for broad enterprise adoption and long‑term operational use.
Disclaimer:
AI platforms, integration patterns, and native analytics capabilities are evolving extremely quickly. The approach described here reflects what works today based on current platform capabilities and published guidance. As both Microsoft Fabric and AI platforms like Claude continue to mature, more direct or native integration options may emerge over time.






