Control-plane API
The surface is grounded in an endpoint-frequency scan of fabric-docs: the
handful of routes below are what SDKs, fabric-cicd, git integration, and
deployment-pipeline automation actually call. Typed item collections
(/notebooks, /lakehouses, /warehouses, /dataPipelines, …) are thin
aliases over the generic item shape, so one implementation covers dozens of
item types. Eventstream destination bind, Reflex triggers, and Fabric Core
MCP (POST /v1/mcp/core) are on this plane too. The OneLake data plane has
its own page: 08-onelake.md.
All routes are under https://api.fabric.microsoft.com/v1 unless noted.
application/json. Bearer required. Mutations are async (see LRO below)
unless marked sync.
Core — workspaces
Section titled “Core — workspaces”| Method + path | Notes |
|---|---|
GET /workspaces | list; opt-in ?maxPageSize=N paginates with a continuationToken + continuationUri (omit it for the full set); ?roles= filter is REST-reference-only, not shown in fabric-docs sync |
POST /workspaces | create → 201 { id, displayName, capacityId } |
GET /workspaces/{id} | get sync — WorkspaceInfo: capacityAssignmentProgress (always Completed; assignment is not a poll here), capacityRegion when assigned, production-shaped oneLakeEndpoints |
PATCH /workspaces/{id} | rename / describe |
DELETE /workspaces/{id} | delete (cascades items + role assignments) |
POST /workspaces/{id}/assignToCapacity | { capacityId } → 202; see the capacity model below |
POST /workspaces/{id}/unassignFromCapacity | detach → 202 |
Capacities (the model behind assignToCapacity)
Section titled “Capacities (the model behind assignToCapacity)”Wire shapes are REST-reference-only (/rest/api/fabric/core/capacities;
fabric-docs covers capacity portal-side). The emulator does not model SKUs or
billing. It does model concurrent-job admission (36-capacity-job-queueing.md):
each capacity has a ceiling (default 999, overridable so a test can set it to 1).
Manual submits against a full capacity are 430 CapacityNotAvailable with
Retry-After; scheduled and event-triggered jobs enter Queued and are
admitted FIFO when a slot frees. Same-item jobs are not serialised. A capacity
is otherwise an assignable object — it exists because real tooling checks
it: fabric-cicd refuses to publish into a workspace whose capacityId is empty.
| Method + path | Notes |
|---|---|
GET /v1/capacities | list capacities the caller can see sync |
- Seed: every instance boots with one deterministic capacity —
{ id: <fixed GUID>, displayName: "Emulator Capacity", sku: "F64", region: "West Europe", state: "Active" }. - ARM consume (on by default in this repo’s compose; opt-out with an
explicitly empty value):
FABRIC_ARM_URLnames an arm-emulator origin that servesGET /_family/capacities. Capacities created overMicrosoft.Fabric/capacitiesthen appear on this list under the Fabric REST GUID ARM assigned at create (ARM’s public resource document does not carry that GUID; the family feed does). ARM rows come and go with the feed; the seeded default is never deleted. EmptyFABRIC_ARM_URLis the standalone default — do not point compose at ARM until a released arm-emulator image carries this provider. - Default assignment:
POST /workspaceswith nocapacityIdauto-assigns the seeded capacity (mirrors a tenant whose workspaces land on a trial/default capacity, and keeps fabric-cicd working out of the box). Pass an explicitcapacityIdto override; an unknown id is a 404CapacityNotFound. assignToCapacity/unassignFromCapacityare Admin-only 202 LROs (no result), setting/clearingworkspace.capacityId.
Display-name uniqueness (409)
Section titled “Display-name uniqueness (409)”Real Fabric rejects duplicate names, and so does the emulator — every
name-addressed contract here depends on it (OneLake name.Type paths, git
logical ids, the FABRIC_TARGET toggle’s
name-based workspace resolution, catalog ingest).
| Scope | Rule | On conflict |
|---|---|---|
Workspace displayName | unique tenant-wide | 409 WorkspaceNameAlreadyExists |
Item displayName | unique per (workspace, type) — reusable across types | 409 ItemDisplayNameAlreadyInUse |
Both comparisons are case-insensitive, and both apply to renames as well as
creates (renaming an entity to its own name is a no-op, not a conflict).
Item names being reusable across types is deliberate and documented:
“you can reuse item names across multiple item types” — which is exactly why
OneLake addresses an item as name.Type (onelake-access-api.md).
Every error response also carries the code in an x-ms-public-api-error-code
header alongside the body’s errorCode, because documented Fabric client
code branches on that header.
Core — RBAC (the decision Entra does not make)
Section titled “Core — RBAC (the decision Entra does not make)”| Method + path | Notes |
|---|---|
GET /workspaces/{id}/roleAssignments | list sync |
GET /workspaces/{id}/roleAssignments/{raId} | get one sync |
POST /workspaces/{id}/roleAssignments | { principal:{id,type}, role } |
PATCH /workspaces/{id}/roleAssignments/{raId} | change role |
DELETE /workspaces/{id}/roleAssignments/{raId} | revoke |
Roles: Admin | Member | Contributor | Viewer. Enforcement maps the
caller’s token oid/appid → role → allowed operations. A missing/insufficient
role yields Fabric-shaped 401/403.
RBAC fidelity map. Fabric’s permission model has four layers
(security/permission-model.md); the emulator covers them as follows:
| Layer | Emulated? |
|---|---|
| Workspace roles | ✅ Per the roles-workspaces.md matrix: workspace delete/rename + role management = Admin (Member may grant ≤ Member); item CRUD, definitions, git sync, job start/cancel = Contributor+; item/metadata reads + job status = Viewer+; git connect and workspace-identity provisioning = Admin only (both explicit matrix rows). |
| OneLake API access (ReadAll) | ✅ Contributor+ by role, as in the matrix — or ReadAll granted on the item, which admits a Viewer, or a principal with no workspace role, to that one item and nothing above it. When the item has OneLake security roles, those decide instead, and ReadAll reads through DefaultReader. (Viewers read via the SQL analytics endpoint instead, which is modelled: warehouseRoute hands a Viewer a read-only session on the real SQL Server — 55-tsql-security.md.) |
| Item permissions (per-item sharing: Read / ReadData / ReadAll / Reshare, and Explore on semantic models) | ✅ Granted, validated, reported and enforced on every data surface. Effective access is what the workspace role implies unioned with a direct grant, so a revoke never takes away what the role gives. Three surfaces over one store: Power BI’s documented GET/POST/PUT /v1.0/myorg/datasets/{id}/users for semantic models, with its own rules (Post needs ReadReshare, Get and Put ReadWriteReshare, Write can be neither added nor removed, no App targets); an authenticated emulator-native …/items/{iid}/_emulator/access for every other item, since Fabric’s Core REST has no item-permissions operation and the portal shares through undocumented calls; and the documented admin GET /v1/admin/workspaces/{wid}/items/{iid}/users, reporting effective access (an inference, graded as one). A grantor shares at most what they hold. Only permissions something enforces are accepted — Write and Execute are refused by name. Enforced on OneLake (DFS, Blob, listings and principalAccess, through one shared decision), on Direct Lake, on executeQueries, which needs Read and Build, and at the SQL endpoint, where Read connects, ReadData reads, and a revoke takes CONNECT away. 57-item-permissions.md |
OneLake security / data access roles (dataAccessRoles, DefaultReader, folder-scoped) | ⚠️ Authoring, DFS read enforcement, and engine enforcement under the two-context split. GET/PUT …/items/{id}/dataAccessRoles round-trip roles verbatim, PUT replaces the whole set as the reference specifies, and only Admin/Member may write — witnessed by Microsoft’s own fab (ci:fabric-cli). The rules are evaluated by pkg/onelakesec (deny-by-default, both membership kinds, row and column narrowing), and the DFS surface enforces them for Viewers: a Viewer has no ReadAll and is refused by default, and a role grants specific paths — the product’s one documented effect here, since Admin/Member/Contributor “override any OneLake security Read permissions” and are never narrowed. Read only; a granted Viewer still cannot write. Listing is filtered, not refused: an engine enumerates a table before reading it, and an ungranted table’s name is withheld rather than shown. Witnessed by Microsoft’s Azure Blob SDK (ci:adls-sdk) and by real delta-rs (ci:delta-rs), each asserting the refusal as well as the grant. The engine-facing securityPolicy/principalAccess serves effective access — including row filters as SQL text — to an engine whose identity holds the workspace Member role (the bar the integration guide sets: a Contributor can read the data and still not the policy), reachable on both OneLake spellings, with ETags for conditional refetch. Witnessed by DuckDB as a third-party engine (ci:duckdb) performing the documented authorized-engine sequence: privileged read, fetch policy for a user, filter in its own query layer. The Spark path consumes it too: a notebook running as a Viewer sees only their rows AND only their columns, witnessed by ci:livy-native with the owner’s session asserted unnarrowed on both axes in the same run. The two-context model landed, so the catalog-filtering caveat this row used to carry is gone: a statement against an item that has roles runs in a child process holding a token minted for the CALLER with the service credential scrubbed, so spark.read.load(path) arrives as that caller and is refused rather than walking around the filter. FABRIC_TWO_CONTEXT defaults on and engages only where there is policy. The item-type rule is the product’s, and enforced: a role may be written to a Lakehouse, a MirroredDatabase or a MirroredAzureDatabricksCatalog, and a PUT anywhere else — a Warehouse above all, secured by T-SQL and nothing else (55) — is DataAccessRolesNotSupported. The GET is deliberately not gated: the docs say which items may carry a role and not what a read against one that may not returns, so the write is refused and the read keeps answering (empty, since nothing can put a role there). Direct Lake consults this plane too, since it reads OneLake rather than a SQL endpoint and so has nothing upstream that already filtered: a table no role grants will not resolve, and a column outside the projection is reported missing by name. A row filter is applied there too, evaluated over the rows it read by the same parser the SQL analytics endpoint renders into SQL Server — and a gated witness requires both to admit the same rows. Both are graded on their own rows in parity.md rather than folded in here. Stage 5 and its boundary in 54-onelake-security. |
Compute permissions — T-SQL (GRANT SELECT ON t(col), CREATE SECURITY POLICY, MASKED WITH) | ✅ Shipped, and the engine’s own. This sat here as a non-goal on the reasoning that it needs a real SQL engine; the answer turned out to be that the repo already runs one. What was missing was never the enforcement but the principal — every client’s T-SQL ran as the relay’s DSN account, so a policy had nobody to restrict. Each caller now connects as its own database principal, keyed on the Entra object id rather than a display name, and SQL Server applies all three. Witnessed in the load-bearing shape — two callers, one query, different answers — by ci:warehouse-tds. Boundary: the Direct Lake read path does not go through it, fetching warehouse rows on the pooled service connection instead, so a policy on the table does not reach a DAX query. 55-tsql-security.md. |
Compute permissions — semantic model (TMSL roles / tablePermissions, OLS) | ✅ RLS and OLS applied. Roles are read from TMSL and TMDL and applied in the loader REST executeQueries and every XMLA route share, to principals without Write: membership by memberId or UPN; a bounded DAX row-predicate subset, additive roles, no role no rows, filters one → many along active relationships; hidden tables, columns and dependent measures removed from what queries and TMSCHEMA rowsets read, with hidden keys still joining. Refused by name: filters outside the subset, bothDirections and many-to-many relationships, service principals below Write, impersonatedUserName, relaying a restricted caller to msmdsrv, row and object security from different roles, and a secured table between two others. 58 |
Core — items (generic; typed aliases reuse this)
Section titled “Core — items (generic; typed aliases reuse this)”| Method + path | Notes |
|---|---|
GET /workspaces/{id}/items | list; ?type= filter sync |
POST /workspaces/{id}/items | create { displayName, type, definition? } |
GET /workspaces/{id}/items/{itemId} | get sync |
PATCH /workspaces/{id}/items/{itemId} | rename / describe |
DELETE /workspaces/{id}/items/{itemId} | delete |
POST /workspaces/{id}/items/{itemId}/move | { targetFolderId } — empty is the workspace root sync |
POST /workspaces/{id}/items/bulkMove | { items[], targetFolderId? } — at most 50; all-or-nothing sync |
POST /workspaces/{id}/items/{itemId}/getDefinition | returns { definition:{ parts:[…] } } |
POST /workspaces/{id}/items/{itemId}/updateDefinition | replaces parts |
Item definition (the CI/CD source format):
{ "definition": { "parts": [ { "path": "notebook-content.py", "payload": "<base64>", "payloadType": "InlineBase64" }, { "path": ".platform", "payload": "<base64>", "payloadType": "InlineBase64" } ] }}Stored verbatim so getDefinition round-trips exactly what updateDefinition /
git wrote. This is what makes fabric-cicd and deployment pipelines testable.
Jobs (trigger, state, and real execution)
Section titled “Jobs (trigger, state, and real execution)”| Method + path | Notes |
|---|---|
POST /workspaces/{id}/items/{itemId}/jobs/instances?jobType=… | schedule → operation |
GET /workspaces/{id}/items/{itemId}/jobs/instances | List Item Job Instances — paged, newest first sync |
GET /workspaces/{id}/items/{itemId}/jobs/instances/{jobId} | status sync |
POST /workspaces/{id}/items/{itemId}/jobs/instances/{jobId}/cancel | cancel |
POST /workspaces/{id}/items/{itemId}/jobs/instances/{jobId}/queryactivityruns | DataPipeline: the recorded activity runs sync; a queued or running job answers Queued/InProgress with the activities so far, never a 404 |
GET /workspaces/{id}/items/{itemId}/jobs/instances/{jobId}/notebookRun | RunNotebook: parsed cells + run detail sync |
POST /workspaces/{id}/items/{itemId}/jobs/instances/{jobId}/notebookRunResult | engine → service callback: report per-cell results, finalise status |
GET /workspaces/{id}/items/{itemId}/jobs/instances/{jobId}/sparkJobRun | SparkJobDefinition: source, arguments, binding, and Environment run contract |
POST /workspaces/{id}/items/{itemId}/jobs/instances/{jobId}/sparkJobRunResult | engine callback: finalise Spark job output/status |
GET /workspaces/{id}/lineage | emulator extension: exact activity source/sink edges for governance ingestion |
POST /workspaces/{id}/lineage | emulator extension: an engine reports its own read/write set for work with no job to hang it on — an interactive Spark session or a plain script. Body is a step name plus moves, each a real reads→writes group (a flat reads × writes cross product would invent derivations that never happened). Recorded as producer: Reported — a claim by the caller, distinct from what the emulator observed itself (31-flow-observability.md) |
Jobs transition NotStarted → InProgress → Completed/Failed on the controllable
clock (Queued while waiting for a capacity slot), and — for the executing job
types — actually do work at trigger. A Manual POST against a saturated capacity
is 430 CapacityNotAvailable rather than a job instance.
- DataPipeline jobs run the pipeline interpreter now: the definition’s
control flow executes and the activity runs are recorded (queryable via
queryactivityruns). A pipeline failure sets the job’s terminal status, overriding fault injection. - RunNotebook jobs parse the notebook into cells with the real Go parser and
resolve default-lakehouse/Environment metadata into a
Pendingrun. A Spark runner executes the cells and posts back tonotebookRunResult, which merges per-cell results and finalises the job’s status from the real outcome. - SparkJobDefinition jobs parse V1 source/arguments/libraries, resolve the same compute binding, and use an independent Pending→Completed/Failed callback.
Cancelled is implemented (the cancel path sets it); Deduped (from the REST
reference) is the only state not yet emulated.
Job scheduler (Fabric’s own, per item)
Section titled “Job scheduler (Fabric’s own, per item)”| Method + path | Notes |
|---|---|
POST /workspaces/{id}/items/{itemId}/jobs/{jobType}/schedules | create → the ItemSchedule sync |
GET /workspaces/{id}/items/{itemId}/jobs/{jobType}/schedules | list, paged sync |
GET /workspaces/{id}/items/{itemId}/jobs/{jobType}/schedules/{id} | read one sync |
PATCH /workspaces/{id}/items/{itemId}/jobs/{jobType}/schedules/{id} | replace enabled + configuration sync |
DELETE /workspaces/{id}/items/{itemId}/jobs/{jobType}/schedules/{id} | remove |
This is Fabric’s native scheduler, distinct from the ApacheAirflowJob
item, which hands scheduling to a real Airflow sidecar (docs/14).
configuration is the documented ScheduleConfig union, discriminated on
type. All four members carry startDateTime, endDateTime and
localTimeZoneId:
type | Fields |
|---|---|
Cron | interval minutes, 1–5,270,400 |
Daily | times[] as hh:mm, at most 100 |
Weekly | times[] + weekdays[] (Monday…Sunday) |
Monthly | occurrence (DayOfMonth with dayOfMonth, or OrdinalWeekday with weekIndex First…Fifth + weekday), recurrence 1–12 months, times[] |
An item accepts at most 20 schedules per job type; the 21st is
ScheduleExceedsLimit. An invalid configuration is refused at write time
rather than stored to silently never fire.
localTimeZoneId takes a Windows zone id (Pacific Standard Time) as real
Fabric does, or an IANA name (America/Los_Angeles). Times are real local wall
times: a daily 09:00 stays 09:00 across a daylight-saving change rather than
drifting an hour. Ids outside the mapped set are rejected — a schedule that
fires at the wrong hour is worse than one that refuses to be created.
How a schedule fires without a background worker
Section titled “How a schedule fires without a background worker”The emulator’s defining property is a controllable clock; a goroutine ticking on wall time would make a job’s outcome depend on how long a test took. So schedules are evaluated on demand, at every moment a caller could observe the result:
POST /_emulator/clock— the deterministic lever. The response reportsscheduledJobsStartedandqueuedJobsAdmitted, and{"advance": 0}is a plain “tick now”.- listing an item’s job instances, or its schedules;
- creating or updating a schedule — which is what makes the documented “if the
start time is in the past, it will trigger a job instantly” true, with no
special case: the first window simply opens at
startDateTime.
Each evaluation materialises every occurrence in the half-open window
(last fired, now], so nothing fires twice and nothing in between is missed.
Firing goes through the same path a manual run takes — a scheduled
DataPipeline really executes the interpreter — and only
invokeType: "Scheduled" distinguishes it from an on-demand run.
One boundary, stated: catch-up is capped at 100 occurrences per evaluation, keeping the newest. Real Fabric never needs the cap because its clock advances one second per second; here a caller can advance a year against a one-minute Cron, and half a million job instances is not a useful answer.
Eventstream destinations
Section titled “Eventstream destinations”| Method + path | Notes |
|---|---|
POST /workspaces/{id}/eventstreams/{id}/destinations | bind a destination sync |
GET /workspaces/{id}/eventstreams/{id}/destinations | list bindings sync |
This binding surface is emulator-native. Fabric’s Eventstream topology
(sources → operators → destinations) is assembled in the portal and has no
public REST — the same situation as Reflex triggers. Bindings are persisted
on the Eventstream (item_properties) and also appear on
properties.destinations so a GET eventstream is not silent.
{ "type": "Lakehouse", "itemId": "<lakehouse-id>", "table": "clicks", "workspaceId": "<optional; defaults to the Eventstream workspace>"}type is Lakehouse, Reflex, or Eventhouse. For Lakehouse, table
is a single Tables/<name> segment (no slashes). For Eventhouse, table
is a Kusto identifier ([A-Za-z_][A-Za-z0-9_]*); optional database is
the child KQL Database display name (default: the eventhouse’s own child).
After a successful Custom HTTP produce, operators run on the batch, then
a Lakehouse dest appends the (possibly filtered/aggregated) values as a
real Delta table; a Reflex dest fires triggers on that Reflex whose
eventType is Microsoft.Fabric.Eventstream.EventReceived and whose
source.itemId is the Eventstream — each event starts the action job with
@pipeline()?.TriggerEvent?.Key / .Value; an Eventhouse dest ingests
via .create-merge + .ingest inline (direct ingest, not Fabric
streaming ingest). Produce reports produced (Kafka count) and drained
(post-operator count).
{ "type": "Eventhouse", "itemId": "<eventhouse-id>", "table": "clicks", "database": "<optional KQL Database display name>", "workspaceId": "<optional; defaults to the Eventstream workspace>"}Eventstream operators
Section titled “Eventstream operators”| Method + path | Notes |
|---|---|
POST /workspaces/{id}/eventstreams/{id}/operators | bind an operator sync |
GET /workspaces/{id}/eventstreams/{id}/operators | list operators sync |
Same emulator-native surface as destinations — Fabric’s topology has no
public REST. Bindings persist on the Eventstream and appear on
properties.operators.
{"type": "Filter", "condition": {"field": "n", "op": "gte", "value": 3}}{"type": "GroupBy", "keys": ["src"], "aggregates": [{"fn": "count", "as": "n"}]}{"type": "Window", "kind": "tumbling", "duration": "1h", "on": "ts"}Filter ops: eq, ne, gt, gte, lt, lte, contains, exists.
GroupBy aggregates: count, sum, min, max, avg. Window is
tumbling on this produce batch only (stamps _window_start); hopping and
sliding are refused. Join, Union, and Expand are refused — they need more
than one stream. Kafka / DefaultStream stays the raw source; destinations
see the operator output.
Event triggers (Reflex / Data Activator)
Section titled “Event triggers (Reflex / Data Activator)”| Method + path | Notes |
|---|---|
POST /workspaces/{id}/reflexes/{reflexId}/triggers | bind a trigger sync |
GET /workspaces/{id}/reflexes/{reflexId}/triggers | list, paged sync |
GET /workspaces/{id}/reflexes/{reflexId}/triggers/{tid} | read one sync |
PATCH /workspaces/{id}/reflexes/{reflexId}/triggers/{tid} | update (absent fields keep their value) |
DELETE /workspaces/{id}/reflexes/{reflexId}/triggers/{tid} | unbind |
This binding surface is emulator-native. In Fabric, adding a Trigger to a
pipeline creates a Reflex fed by an Eventstream, assembled in the portal — there
is no public REST for it, so there is no contract here to be faithful to. What
is faithful is everything downstream of the binding: the filter, the
invocation, the TriggerEvent fields, and a real pipeline really running.
{ "displayName": "on-landing", "eventType": "Microsoft.Fabric.OneLake.FileCreated", "source": { "itemId": "<lakehouse-id>", "pathPrefix": "Files/landing" }, "action": { "itemId": "<pipeline-id>", "jobType": "Pipeline" }}eventType is one of Microsoft.Fabric.OneLake.FileCreated, …FileDeleted,
…FileRenamed. source is the subject filter: which item’s OneLake storage
to watch, and optionally which folder within it (empty watches the whole item).
action names the item job to start; its workspaceId defaults to the Reflex’s
own. A trigger whose action does not resolve is refused at bind time rather
than discovered to be inert later.
No broker, because none is needed
Section titled “No broker, because none is needed”Every byte written to OneLake passes through this emulator’s own storage layer,
so a file event is observable at the source, whoever wrote it — an ADLS
client, azcopy, delta-rs, a Copy activity, the mirror writer. That is what
makes the trigger a genuine emulation of Data Activator’s OneLake source rather
than a stub that only notices writes made through one API.
A match starts a job with invokeType: "EventTriggered", and the event is bound
into the run:
@pipeline()?.TriggerEvent?.FileName orders.csv@pipeline()?.TriggerEvent?.FolderPath Files/landing@pipeline()?.TriggerEvent?.Subject Files/landing/orders.csv@pipeline()?.TriggerEvent?.EventType Microsoft.Fabric.OneLake.FileCreated@pipeline()?.TriggerEvent?.WorkspaceId / .ItemId / .SourceReading those is what the expression language’s safe navigation (?.) is
for, and why Fabric’s own samples are written that way: the same definition
must also run when started by hand, where there is no trigger event at all.
With ?. the chain yields null; with a plain . it would fail. Plain . still
fails loudly on a missing member, so safe navigation is opt-in and a typo in an
ordinary expression is still an error.
Chains and cycles. Dispatch is synchronous and reentrant: a triggered pipeline writes files, which emit events, which may fire further triggers — that is how a bronze→silver→gold chain behaves, and it works. A cycle would recurse forever, so a trigger already on the dispatch stack does not fire again; any cycle is cut at its first repeat while genuine chains still run.
Git integration (unlocks CI/CD testing)
Section titled “Git integration (unlocks CI/CD testing)”| Method + path | Notes |
|---|---|
POST /workspaces/{id}/git/connect | attach a git provider (body below) |
POST /workspaces/{id}/git/initializeConnection | first-sync direction |
GET /workspaces/{id}/git/status | ahead/behind + per-item change list sync |
POST /workspaces/{id}/git/commitToGit | push workspace → git (writes item definitions) |
POST /workspaces/{id}/git/updateFromGit | pull git → workspace |
POST /workspaces/{id}/git/disconnect | detach |
GET /workspaces/{id}/git/myGitCredentials | credential config sync |
Connect body (per git-automation.md — note it is not a flat org/repo
object, and the SP path requires a connection):
{ "gitProviderDetails": { "gitProviderType": "AzureDevOps", "organizationName": "…", "projectName": "…", "repositoryName": "…", "branchName": "…", "directoryName": "…" }, "myGitCredentials": { "source": "ConfiguredConnection", "connectionId": "…" }}myGitCredentials.source is Automatic (SSO) or ConfiguredConnection;
service principals must use ConfiguredConnection, whose connectionId comes
from the shipped GET/POST /v1/connections (see Connections below).
The emulator’s “git remote” is a local store of item definitions per branch — no real GitHub/AzDO needed for the happy path (a real provider can be wired later).
Folders (workspace item organization)
Section titled “Folders (workspace item organization)”| Method + path | Notes |
|---|---|
GET /workspaces/{id}/folders | list sync |
POST /workspaces/{id}/folders | create { displayName, parentFolderId? } → 201 sync |
GET /workspaces/{id}/folders/{folderId} | get sync |
PATCH /workspaces/{id}/folders/{folderId} | { displayName } sync |
DELETE /workspaces/{id}/folders/{folderId} | empty folders only; otherwise FolderNotEmpty sync |
POST /workspaces/{id}/folders/{folderId}/move | { targetFolderId } — empty is the workspace root; a cycle is InfiniteFolderHierarchyLoop sync |
Folders organize items within a workspace (nesting via parentFolderId); the
folder tree is a plain metadata store.
Catalog Search
Section titled “Catalog Search”| Method + path | Notes |
|---|---|
POST /catalog/search | { search, filter?, pageSize?, continuationToken? } — items in workspaces the caller can see; Dashboard and Dataflow excluded sync |
search matches item display name, description, and workspace display name.
filter is Type eq/ne with or/and and parentheses, as the REST
reference documents. Results are ItemCatalogEntry objects
(catalogEntryType: FabricItem). This is metadata discovery only — it does
not grant data-plane access.
Fabric Core MCP Server
Section titled “Fabric Core MCP Server”| Method + path | Notes |
|---|---|
POST /mcp/core | Streamable HTTP JSON-RPC (initialize, ping, tools/list, tools/call). Bearer is the same Fabric control-plane token as the REST surface. Tools wrap the handlers above, so RBAC and LRO are not a second implementation. Mcp-Session-Id is issued on initialize |
GET /mcp/core | 405 — no SSE stream |
DELETE /mcp/core | end session → 204 |
Does not execute notebooks or write lakehouse tables (Microsoft’s published
limitation). Distinct from the local Fabric.Mcp.Server VS Code package and
from pbix-mcp.
Fabric IQ MCP
Section titled “Fabric IQ MCP”Microsoft’s read-only MCP server over Power BI reports and semantic models
(learn.microsoft.com/fabric/iq/connectors/fabric-iq-mcp). Microsoft serves it
at https://fabriciq.svc.cloud.microsoft/v1/mcp/fabriciq, or
https://api.fabric.microsoft.com/v1/mcp/fabriciq behind private links; the
emulator serves the same path, on the same Streamable HTTP transport as Core
MCP.
| Method + path | Notes |
|---|---|
POST /mcp/fabriciq | Streamable HTTP JSON-RPC (initialize, ping, tools/list, tools/call). Delegated tokens only: a service principal is refused with 403 ServicePrincipalNotSupported, as Microsoft documents. A Fabric or a Power BI audience token is accepted. X-Variants: Fabric.Routing.FabricIQ.V1 is served; no header is served V1, and any other variant is refused with 400 UnsupportedVariant (code and text ours) |
GET /mcp/fabriciq | 405 — no SSE stream |
DELETE /mcp/fabriciq | end session → 204 |
| Tool | Arguments | What it returns |
|---|---|---|
DiscoverArtifacts | searchQuery, artifactTypes?, maxResults? (≤ 50) | Reports and semantic models the caller can read — through a workspace role or shared with them directly — matched by name. An exact name ranks first, then a name containing the query, then every word across name, description and workspace; reports before models. A report carries SemanticModelId |
ResolveFabricItem | fabricItemId | A GUID or a browser URL (…/groups/<ws>/reports/<id>, …/datasets/<id>, …/semanticmodels/<id>, groups/me) resolved to fabricItemId, itemType, workspaceId and next-step instructions. Workspace-app URLs and share links are refused by name |
GetReportMetadata | reportObjectId, queries? | ReportMetadata (pages, visuals with their fields and title, filters at report, page and visual level, report measures) and semanticModel, the bound model’s id — from definition.pbir’s byConnection semanticmodelid, or its byPath name in the report’s workspace, or null with a warning |
GetSemanticModelSchema | artifactId, queries? | schema.Tables[].{Columns, Measures}, schema.ActiveRelationships[].{PK, FK}, and CustomInstructions / VerifiedAnswers, which are not modelled and so are present and empty. Object-level security applies |
ValueSearch | artifactId, searchTerms, scope? | For each term, up to 10 stored text values that equal it (first) or contain it, ignoring case, with the column. Reads only the rows the caller’s roles admit |
ExecuteQuery | artifactId, daxQueries (1–4), maxRows? (default 250, ≤ 1000) | Per query, Rows, RowCount and Truncated, or Error. MDX, DMV and INFO functions are refused. The call is an error only when every query failed |
Every tool needs Read on the item, not Build: Microsoft’s page says “you
don’t need a workspace role or Build permission on the semantic model”, which is
where this differs from executeQueries. The rows are the caller’s own, through
the loader executeQueries uses, so row- and object-level security apply the
same way. The DAX is the emulator’s bounded subset
(19), including ORDER BY, or the attached
FABRIC_DAX_URL engine (52).
queries takes JMESPath expressions over the full response document, each
returned with its result under Results. regex_match(subject, pattern) is
added, because the Fabric IQ skill’s example queries use it; it is not standard
JMESPath, and here it ignores case.
What is inferred. Microsoft publishes the tool names and tells clients to
“call tools/list at runtime” rather than publishing schemas. The argument
names above come from Microsoft’s Fabric IQ skill
(microsoft/skills-for-fabric, skills/fabriciq/SKILL.md), and the response
documents follow the paths that skill queries (ReportMetadata.Pages[].Visuals,
schema.Tables[].Measures, schema.ActiveRelationships[].{PK,FK}). The ranking,
the per-query error shape and the 10-match cap are ours.
Not modelled. Verified answers and AI instructions (Power BI’s “prep data for AI” objects), workspace apps, the embedded CSV resource the real server returns for large results, and OAuth discovery: a client sends the bearer itself.
Fabric Data Warehouse MCP
Section titled “Fabric Data Warehouse MCP”Microsoft’s MCP server for T-SQL on a Warehouse or a lakehouse’s SQL analytics
endpoint (learn.microsoft.com/fabric/data-warehouse/data-warehouse-mcp-server).
It “uses the signed-in user’s identity and respects Fabric permissions”, and has
one tool and no separate schema tools: an agent discovers tables by querying
INFORMATION_SCHEMA. The emulator serves both of Microsoft’s endpoints on the
same Streamable HTTP transport as Core MCP.
| Method + path | Notes |
|---|---|
POST /mcp/dataPlane/sqlEndpoint | The global endpoint: each call names its workspace and item |
POST /mcp/dataPlane/workspaces/{workspaceId}/items/{itemId}/sqlEndpoint | Bound to one item: the tool takes only query, and naming any other item is refused |
GET either | 405, no SSE stream |
DELETE either | ends the session, 204 |
| Tool | Arguments | What it returns |
|---|---|---|
execute_query (also answers as executeSQL) | workspaceId, itemId, query | The batch’s last result set as an embedded text/csv resource (RFC 4180, header first, CRLF), then the text Query returned N rows.. At most 10,000 rows, and the server does not say when it truncated: exactly 10,000 is the signal. A SQL error is a tool error, Error -32002: <SQL Server's message> |
What runs where. itemId is a Warehouse, or a lakehouse’s SQL analytics
endpoint (properties.sqlEndpointProperties.id). The lakehouse’s own id is
refused with that pointer, because Microsoft’s skills say Fabric refuses it. The
batch takes the same path a TDS client’s would
(55). It is routed and access-checked as the caller,
which needs Read on the item. Then it is refused or adapted by the wire’s rules:
a Viewer’s session and the endpoint’s data are read-only, and Fabric’s dialect
and time travel apply. It runs logged in as the caller, so grants, row-, column-
and object-level security are SQL Server’s own. A write the engine accepts is
recorded for lineage and versioning, as one sent over TDS is. An item the
caller cannot read is reported as not found, without its type. With no
WAREHOUSE_MSSQL_DSN there is no engine, and the tool says so.
Where the contract comes from. The Learn page names the tool executeSQL.
The live server says execute_query, as captured by a third party
(iemejia/fabio, .agents/API-BEHAVIORS-DISCOVERED.md), and Microsoft’s
skills-for-fabric calls and allow-lists that name. So tools/list publishes
execute_query, and executeSQL is accepted as the same tool. From that
capture: serverInfo microsoft.fabric.sqlEndpoint 0.1.0 with its
description, the tool’s title (Execute T-SQL Query), its required arguments
and its annotations (destructiveHint, idempotentHint), the CSV as an
embedded resource with the row-count text after it, and the error text. The
skills record 10,000 rows, a 300-second timeout and 20 requests a minute as
“observed defaults, not a documented contract”. The emulator applies the first
two and does not rate-limit. Ours, because nothing captured them: the resource
URI between fabric:// and /query-results/, and the text for a batch that
returns no result set. The item-scoped endpoint publishes the same schema but
also accepts a call that omits the ids.
Eventhouse MCP
Section titled “Eventhouse MCP”Fabric’s remote MCP server over one KQL database
(learn.microsoft.com/fabric/real-time-intelligence/mcp-remote-eventhouse). The
emulator serves both of its endpoints on the same Streamable HTTP transport as
Core MCP. The item is the KQL database, not the eventhouse; an eventhouse id
is refused, with a pointer to properties.databasesItemIds.
| Method + path | Notes |
|---|---|
POST /mcp/dataPlane/kqlEndpoint | The global endpoint: every call carries workspaceId and itemId |
POST /mcp/dataPlane/workspaces/{workspaceId}/items/{itemId}/kqlEndpoint | Bound to one KQL database; naming another is refused |
GET either | 405, no SSE stream |
DELETE either | ends the session, 204 |
| Tool | Arguments | What it returns |
|---|---|---|
executeQuery | kqlQuery, maxRecords, activityTitle?, activityDescription? | A Kusto document, {"Tables":[…]}, one table per result, named by its kind (QueryProperties, PrimaryResult, QueryCompletionInformation), with at most maxRecords rows of PrimaryResult and never more than 1,000, silently. A failed query is a tool error: Error in executing KQL query. cluster='<queryServiceUri>', database='<name>', Exception='<category>: <the engine's error>' |
getSchema | referenceText | The database’s tables (columns, docstring, row count and five sample rows for the first 20), materialized views and functions, the tables sharing most words with referenceText first |
getGeneralKQLExamples | referenceText | Five natural-language-to-KQL examples, as markdown, the ones sharing most words with referenceText first |
getSpecificKQLExamples | referenceText | Examples curated for this database: none, since the emulator learns nothing |
Every tool also takes clusterUrl and databaseName, which together run it
against another database. The emulator hosts no Azure Data Explorer cluster, so
clusterUrl must be one of its own eventhouses’ query URIs (any host). The
three grounding tools refuse a database with no tables: Database is empty.
The caller needs Read on the KQL database, from a workspace role or a
direct share, and a database they cannot read is reported as not found. That is
a share-aware check; the Kusto REST endpoint itself checks the workspace role
only. Queries run on the attached engine (FABRIC_KQL_URL,
25), in the database’s own engine database, whose name is
mapped back to the display name in every answer.
Where the contract comes from. Microsoft documents the endpoints and the
optional clusterUrl and databaseName, but names no tools. Two third parties
captured the live server’s initialize and tools/list:
iemejia/fabio (.agents/API-BEHAVIORS-DISCOVERED.md) gives
KustoMCP 1.0.0, the four tool names, referenceText, and
Database is empty. adindabudi/enterprise-data-analyst-agent
(apps/api/…/fabric_auth/eventhouse.py) gives executeQuery’s input schema,
the 1,000-row cap, the Kusto document read through PrimaryResult, and a failed
query’s text. Ours: every description, the grounding tools’ schemas beyond
referenceText, the documents getSchema and the example tools return, and
their ranking. Fabric grounds with Copilot, and the emulator has no model, so it
ranks by shared words and its general examples are its own.
Livy / Spark data plane
Section titled “Livy / Spark data plane”Fabric exposes Spark through the Apache Livy REST API at a lakehouse-scoped
endpoint. The emulator validates the bearer token and workspace RBAC (like every
/v1 route — session/job submission needs Contributor, status reads Viewer),
then serves the Livy contract:
| Method + path | Notes |
|---|---|
{GET,POST,DELETE} /workspaces/{id}/lakehouses/{lid}/livyapi/versions/{ver}/{sessions|batches}/… | classic Livy sessions + batches |
POST /workspaces/{id}/lakehouses/{lid}/livyapi/versions/{ver}/highConcurrencySessions | Fabric high-concurrency session (acquire) |
{GET,DELETE} …/highConcurrencySessions/{hcid} | get / release an HC session |
{POST,GET} …/highConcurrencySessions/{sid}/repls/{replid}/statements[/{stid}] | submit / poll HC statements |
Execution mode depends on how the server is launched:
--spark-agent-urlset: native Livy termination — the emulator implements the Livy session/statement contract itself and drives a Spark statement-executor agent. The default agent computes through Sail over Spark Connect; a JVM-backed agent can be supplied separately (unmodifiedpylivy/sparkmagicclients work at the protocol layer).--spark-livy-urlset: the routes reverse-proxy to a real external Apache Livy backend.- Neither set: the routes
501honestly — no faked sessions.
Long-running operations
Section titled “Long-running operations”| Method + path | Notes |
|---|---|
GET /operations/{id} | { status: NotStarted|Running|Succeeded|Failed, … } sync |
GET /operations/{id}/result | terminal payload when Succeeded (REST-reference-only; fabric-docs scripts poll /operations/{id} and read Location for the result) |
Async mutations respond 202 with both an x-ms-operation-id header (what
the documented automation scripts actually read) and Location: /v1/operations/{id}, plus Retry-After. Clients poll while status ∈
{NotStarted, Running}.
Connections (shipped) and admin (later)
Section titled “Connections (shipped) and admin (later)”| Method + path | Notes |
|---|---|
GET /v1/connections | list sync |
POST /v1/connections | create |
GET/POST /v1/connections is shipped — git connect with a service principal
requires a connectionId (see git section above). /admin/* (tenant settings,
workspace listing) is added as demand warrants.
Connection credentials
Section titled “Connection credentials”connectionDetails on create is {type, creationMethod, parameters[]}, and
all three are required — path belongs to the read shape and is composed from
the parameters. Creation methods and their parameter names come from
GET /v1/connections/supportedConnectionTypes (321 types on a measured tenant;
WebForPipeline.Contents takes baseUrl, AzureKeyVault.Actions takes
accountName, Sql takes server and database).
Connections carry credentialDetails with a credentialType, validated per
type. The enum is Fabric’s own: Anonymous, Basic, Key, KeyPair,
OAuth2, ServicePrincipal, SharedAccessSignature, Windows,
WindowsWithoutImpersonation, WorkspaceIdentity.
-
Write-only secrets. Credential material (
password,secret, keys) is accepted on create/update and never echoed back — reads returncredentialTypeand non-secret fields only, as real Fabric does. -
ServicePrincipal:{ tenantId, servicePrincipalClientId, secret }, probed against entra-emulator via a client-credentials validation at create (Fabric’s “test connection”), so a wrong secret fails connection creation the way it does in production. -
WorkspaceIdentity: no credential material at all (workspace-identity-authenticate.md— “no need to manage keys, secrets, and certificates”); valid only when the owning workspace has a provisioned identity. Deprovisioning breaks the connection, as documented. -
Vault-backed credentials are the reference twin of an inline field, never a credentialType of their own:
keyReference(Key),passwordReference(Basic),tokenReference(SharedAccessSignature) andservicePrincipalSecretReference(ServicePrincipal). Each is aKeyVaultSecretReference—{connectionId, secretName, version}— andconnectionIdnames a connection of typeAzureKeyVault, whose own credentials reach the vault. A field and its reference are alternatives; sending both is refused. The emulator resolves at create (Fabric’s “test connection”) with a vault-audience token, and stores only the pointer.This replaced an invented shape in v0.22.0. The emulator used to accept
credentialType: "AzureKeyVaultReference"carrying avaultUri. Measured against a real tenant on 2026-08-11, no such credentialType exists — and because avaultUrirequires nothing to exist, the old form looked valid while addressing nothing. It is now rejected by name, with a message naming the replacement. -
Identity material itself (app registrations, SP secrets) stays in entra-emulator — connections reference principals, never own them.
Scope note
Section titled “Scope note”fabric-docs samples overwhelmingly acquire tokens with scope
https://analysis.windows.net/powerbi/api/.default (the legacy Power BI
first-party resource), not https://api.fabric.microsoft.com/.default. The
emulator accepts both audiences — matching what entra-emulator already
mints for either resource form.