Skip to content

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.

Method + pathNotes
GET /workspaceslist; 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 /workspacescreate → 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}/unassignFromCapacitydetach → 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 + pathNotes
GET /v1/capacitieslist 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_URL names an arm-emulator origin that serves GET /_family/capacities. Capacities created over Microsoft.Fabric/capacities then 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. Empty FABRIC_ARM_URL is the standalone default — do not point compose at ARM until a released arm-emulator image carries this provider.
  • Default assignment: POST /workspaces with no capacityId auto-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 explicit capacityId to override; an unknown id is a 404 CapacityNotFound.
  • assignToCapacity / unassignFromCapacity are Admin-only 202 LROs (no result), setting/clearing workspace.capacityId.

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).

ScopeRuleOn conflict
Workspace displayNameunique tenant-wide409 WorkspaceNameAlreadyExists
Item displayNameunique per (workspace, type) — reusable across types409 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 + pathNotes
GET /workspaces/{id}/roleAssignmentslist 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:

LayerEmulated?
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 + pathNotes
GET /workspaces/{id}/itemslist; ?type= filter sync
POST /workspaces/{id}/itemscreate { 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}/getDefinitionreturns { definition:{ parts:[…] } }
POST /workspaces/{id}/items/{itemId}/updateDefinitionreplaces 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.

Method + pathNotes
POST /workspaces/{id}/items/{itemId}/jobs/instances?jobType=…schedule → operation
GET /workspaces/{id}/items/{itemId}/jobs/instancesList 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}/cancelcancel
POST /workspaces/{id}/items/{itemId}/jobs/instances/{jobId}/queryactivityrunsDataPipeline: 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}/notebookRunRunNotebook: parsed cells + run detail sync
POST /workspaces/{id}/items/{itemId}/jobs/instances/{jobId}/notebookRunResultengine → service callback: report per-cell results, finalise status
GET /workspaces/{id}/items/{itemId}/jobs/instances/{jobId}/sparkJobRunSparkJobDefinition: source, arguments, binding, and Environment run contract
POST /workspaces/{id}/items/{itemId}/jobs/instances/{jobId}/sparkJobRunResultengine callback: finalise Spark job output/status
GET /workspaces/{id}/lineageemulator extension: exact activity source/sink edges for governance ingestion
POST /workspaces/{id}/lineageemulator 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 Pending run. A Spark runner executes the cells and posts back to notebookRunResult, 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.

Method + pathNotes
POST /workspaces/{id}/items/{itemId}/jobs/{jobType}/schedulescreate → the ItemSchedule sync
GET /workspaces/{id}/items/{itemId}/jobs/{jobType}/scheduleslist, 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:

typeFields
Croninterval minutes, 1–5,270,400
Dailytimes[] as hh:mm, at most 100
Weeklytimes[] + weekdays[] (Monday…Sunday)
Monthlyoccurrence (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 reports scheduledJobsStarted and queuedJobsAdmitted, 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.

Method + pathNotes
POST /workspaces/{id}/eventstreams/{id}/destinationsbind a destination sync
GET /workspaces/{id}/eventstreams/{id}/destinationslist 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>"
}
Method + pathNotes
POST /workspaces/{id}/eventstreams/{id}/operatorsbind an operator sync
GET /workspaces/{id}/eventstreams/{id}/operatorslist 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.

Method + pathNotes
POST /workspaces/{id}/reflexes/{reflexId}/triggersbind a trigger sync
GET /workspaces/{id}/reflexes/{reflexId}/triggerslist, 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.

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 / .Source

Reading 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.

Method + pathNotes
POST /workspaces/{id}/git/connectattach a git provider (body below)
POST /workspaces/{id}/git/initializeConnectionfirst-sync direction
GET /workspaces/{id}/git/statusahead/behind + per-item change list sync
POST /workspaces/{id}/git/commitToGitpush workspace → git (writes item definitions)
POST /workspaces/{id}/git/updateFromGitpull git → workspace
POST /workspaces/{id}/git/disconnectdetach
GET /workspaces/{id}/git/myGitCredentialscredential 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).

Method + pathNotes
GET /workspaces/{id}/folderslist sync
POST /workspaces/{id}/folderscreate { 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.

Method + pathNotes
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.

Method + pathNotes
POST /mcp/coreStreamable 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/core405 — no SSE stream
DELETE /mcp/coreend 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.

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 + pathNotes
POST /mcp/fabriciqStreamable 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/fabriciq405 — no SSE stream
DELETE /mcp/fabriciqend session → 204
ToolArgumentsWhat it returns
DiscoverArtifactssearchQuery, 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
ResolveFabricItemfabricItemIdA 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
GetReportMetadatareportObjectId, 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
GetSemanticModelSchemaartifactId, 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
ValueSearchartifactId, 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
ExecuteQueryartifactId, 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.

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 + pathNotes
POST /mcp/dataPlane/sqlEndpointThe global endpoint: each call names its workspace and item
POST /mcp/dataPlane/workspaces/{workspaceId}/items/{itemId}/sqlEndpointBound to one item: the tool takes only query, and naming any other item is refused
GET either405, no SSE stream
DELETE eitherends the session, 204
ToolArgumentsWhat it returns
execute_query (also answers as executeSQL)workspaceId, itemId, queryThe 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.

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 + pathNotes
POST /mcp/dataPlane/kqlEndpointThe global endpoint: every call carries workspaceId and itemId
POST /mcp/dataPlane/workspaces/{workspaceId}/items/{itemId}/kqlEndpointBound to one KQL database; naming another is refused
GET either405, no SSE stream
DELETE eitherends the session, 204
ToolArgumentsWhat it returns
executeQuerykqlQuery, 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>'
getSchemareferenceTextThe 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
getGeneralKQLExamplesreferenceTextFive natural-language-to-KQL examples, as markdown, the ones sharing most words with referenceText first
getSpecificKQLExamplesreferenceTextExamples 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.

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 + pathNotes
{GET,POST,DELETE} /workspaces/{id}/lakehouses/{lid}/livyapi/versions/{ver}/{sessions|batches}/…classic Livy sessions + batches
POST /workspaces/{id}/lakehouses/{lid}/livyapi/versions/{ver}/highConcurrencySessionsFabric 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-url set: 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 (unmodified pylivy/sparkmagic clients work at the protocol layer).
  • --spark-livy-url set: the routes reverse-proxy to a real external Apache Livy backend.
  • Neither set: the routes 501 honestly — no faked sessions.
Method + pathNotes
GET /operations/{id}{ status: NotStarted|Running|Succeeded|Failed, … } sync
GET /operations/{id}/resultterminal 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}.

Method + pathNotes
GET /v1/connectionslist sync
POST /v1/connectionscreate

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.

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 return credentialType and 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) and servicePrincipalSecretReference (ServicePrincipal). Each is a KeyVaultSecretReference — {connectionId, secretName, version} — and connectionId names a connection of type AzureKeyVault, 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 a vaultUri. Measured against a real tenant on 2026-08-11, no such credentialType exists — and because a vaultUri requires 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.

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.