> cat fabric-iq-mcp-gateway.md

Putting a $4 gateway in front of Microsoft's Power BI MCP server

Microsoft shipped an MCP server for Power BI called Fabric IQ. Six read-only tools: find a report or semantic model, pull its schema, search for a value, run DAX. Wire it into Claude and the sales team can ask “how did we do against budget last quarter” and get an answer straight from the model the finance team already trusts. No new data pipeline, no second source of truth, row-level security still applies because every call runs as the signed-in user.

It works beautifully in claude.ai on the web. It is broken in Claude Desktop, Cowork and Claude Code, and it can’t even complete sign-in from most third-party clients. I spent a day figuring out why and then fixed both problems with a small proxy on the cheapest droplet DigitalOcean sells.

Problem one: Entra hates the resource parameter

Fabric IQ authenticates with Microsoft Entra. Claude follows the MCP spec and sends an RFC 8707 resource parameter on every token request, set to the server’s URL. Entra’s v2 endpoint has rejected that combination since March 2026:

Entra token endpoint
AADSTS9010010: The resource parameter provided in the request doesn't match with the requested scopes.

Anthropic’s documented fix is to register the MCP URL as an Application ID URI on the app that owns the API. Fine when you own the API. Useless when the API is Microsoft’s and the host is fabriciq.svc.cloud.microsoft. There are half a dozen GitHub issues about exactly this, none resolved: claude-ai-mcp #851 and #632 for the hosted apps, claude-code #76096 and #73460 for the CLI (both closed as not planned), plus the same failure reported against Microsoft’s own Power BI modeling and Azure DevOps MCP servers.

Problem two: the data is in the wrong envelope

This one took longer. In the desktop app every schema or query call came back as “only a link to the item, no data.” Even EVALUATE ROW("Test", 1) returned nothing. Same connector, same account, same prompt on the web: full results.

So I called the server directly and compared the six tools:

tools/call result shapes
DiscoverArtifacts text=1707 chars structuredContent={artifacts:[...]} works everywhere
ResolveFabricItem text= 274 chars structuredContent=(none) works everywhere
GetSemanticModelSchema text=25995 chars structuredContent={artifact_citation} link only on desktop
GetReportMetadata text=7540 chars structuredContent={artifact_citation} link only on desktop
ValueSearch text=1181 chars structuredContent={artifact_citation} link only on desktop
ExecuteQuery text= 464 chars structuredContent={artifact_citation} link only on desktop

The correlation is exact. Fabric IQ puts the real payload in the text block and only a citation link in structuredContent. The desktop clients hand the model structuredContent when it exists and throw the text away. The web client reads the text. Neither side is strictly wrong, and neither side is going to fix it this week.

The gateway

Two hundred lines of Node, no framework, no database, no secrets. It pretends to be the OAuth server, strips the one parameter Entra chokes on, forwards everything else to Entra untouched, and passes MCP traffic through to Microsoft with the user’s own bearer token. On the way back it fixes the envelope:

rewrite.mjs
export function rewriteMessage(msg) {
const result = msg?.result;
if (!Array.isArray(result?.content) || result.structuredContent == null) return msg;
const text = result.content.find((c) => c.type === 'text')?.text;
const parsed = parseJsonText(text);
if (parsed === undefined) {
const { structuredContent, ...rest } = result; // prose, not JSON: drop the stub
return { ...msg, result: rest };
}
const next = Array.isArray(parsed) ? { items: parsed } : { ...parsed };
if (result.structuredContent.artifact_citation && !next.artifact_citation) {
next.artifact_citation = result.structuredContent.artifact_citation;
}
return { ...msg, result: { ...result, structuredContent: next } };
}

The OAuth side is almost insultingly small. Claude asks the gateway to register a client; the gateway hands back the pre-registered public Entra app ID. Claude redirects the user to the gateway’s authorize endpoint; the gateway redirects to Entra minus resource and minus prompt=consent, because the tenant admin has already consented for everyone and nobody needs to click Accept on a permissions screen they can’t evaluate:

oauth.mjs
for (const [k, v] of Object.entries(query)) {
if (k === 'resource' || k === 'client_id' || k === 'scope') continue;
if (k === 'prompt' && String(v) === 'consent') continue;
url.searchParams.set(k, String(v));
}
url.searchParams.set('client_id', clientId);
url.searchParams.set('scope', normalizeScope(query.scope));

Token requests get the same treatment. Entra issues the token, the gateway never sees anything it stores, and the Power BI permissions are still the user’s own.

The boring parts, which are the point

Caddy for TLS. A systemd unit with a memory cap. A five-minute cron that deep-probes the gateway (which in turn probes Microsoft) and pings Healthchecks, with an Axiom run event hourly and on every failure, so the thing shows up as one more row on our internal AI status page next to every other job we run. Fifteen tests. All of it on a 512 MB droplet that idles at about twenty megabytes of RAM.

The org connector in claude.ai now points at the gateway instead of Microsoft. Nobody on the team knows the difference, except that Power BI answers now come back with numbers in them.

The take

Every “AI connector” story eventually becomes a story about two vendors each following a spec slightly differently and you standing in the middle. The fix is almost never clever. It’s a proxy that knows one thing about each side and translates. Own that proxy and you stop waiting on release notes.

<- back to archive