> ## Documentation Index
> Fetch the complete documentation index at: https://developers.nexspace365.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Tenant Isolation

> The fail-closed facility-scope guard that rewrites or rejects every registry tool call

# Tenant isolation

Every registry tool call over MCP passes through a **facility-scope boundary
guard** before it reaches the tool. The guard either rewrites your arguments to
your accessible facilities or rejects the whole call. It is fail-closed: an
argument the guard cannot prove is in scope is never passed through.

This is the layer that makes `facilityId` behave differently over MCP than it
does for the in-app assistant, and the difference surprises people. Read the
[four behaviours](#the-four-behaviours) before you hardcode a facility id.

## What it applies to

| Surface | Guarded? |
| - | - |
| Registry tools (`get_facility_kpis`, `get_shifts`, `create_shift`, …) | **Yes** |
| Connected-app tools (the `apps:*` lane) | **Yes** — same dispatch path |
| Named verbs (`fillShift`, `searchStaff`, `getFacilityCoverage`, …) | **No** — verbs scope themselves from the caller's context in their own handlers |

On the registry path the guard runs **after** your arguments have been validated
against the tool's `inputSchema` and **before** the
[org guardrails](/concepts/governance) and the idempotency reservation. A call
rejected on the boundary therefore never evaluates a guardrail and never
consumes an `Idempotency-Key`.

## Your accessible set

The guard resolves an accessible facility set from the credential's **bound
user** — the user the API key or OAuth token belongs to — using their role, org
unit, and facility:

| Bound user's context | Accessible set |
| - | - |
| Has a facility | exactly that facility (a super admin drilled into one facility is scoped to it too) |
| No facility, super-admin role | `all` — the guard passes every call through untouched |
| No facility, belongs to an org unit | every facility in that org unit's tree |
| No facility, no org unit | **empty** |

An empty set is not a pass-through. Every guarded call fails with:

```
No facilities are within your access scope.
```

If you see that, the credential's bound user has no facility and no org unit to
resolve — an operator has to fix the user's assignment; nothing in the request
can work around it.

<Note>
  API keys carry their own facility binding, which operators use to key the
  per-facility [autonomy](/concepts/governance#resolution-order) and
  [tier-equivalence](/concepts/model-tier-gate#resolution-order) dials. The
  *accessible facility set* on this page comes from the bound user, so a key
  without a bound user cannot call a guarded tool at all — governed tools reject
  org-only credentials with "This tool requires an authenticated user identity".
</Note>

## The four behaviours

### (a) You omit `facilityId` → NexSpace injects your accessible facilities

Omitting the argument can never mean "all tenants". The guard injects your
accessible set in whatever shape the tool's `facilityId` field accepts: the
**array** when the field takes one, or the **single id** when the field takes a
number and you have exactly one accessible facility.

```json theme={null}
{
  "jsonrpc": "2.0", "id": 21, "method": "tools/call",
  "params": { "name": "get_facility_kpis", "arguments": { "dateRange": "last_30_days" } }
}
```

A tool whose input schema has no `facilityId` field at all is treated as
tenant-agnostic (a catalog or metadata listing) and its arguments pass through
unchanged.

### (b) You pass a facility you can access → honored

```json theme={null}
{ "name": "get_shifts", "arguments": { "facilityId": 12, "status": "open" } }
```

Your value is used as-is. No narrowing, no injection.

### (c) You pass **any** id outside your scope → the whole call is rejected

<Warning>
  Arrays are **not silently narrowed**. If you pass `[3, 4, 99]` and `99` is
  outside your scope, the entire call is rejected — you do not get results for
  `3` and `4`. This differs from the in-app assistant, whose tool adapter clamps
  the request down to the in-scope ids and continues. The headless boundary never
  silently narrows, because a partially-narrowed result is indistinguishable from
  a complete one to a caller who cannot see the clamp.
</Warning>

```json theme={null}
{
  "jsonrpc": "2.0",
  "id": 22,
  "error": {
    "code": -32003,
    "message": "facilityId [99] is outside your accessible facilities (3, 4).",
    "data": {
      "suggestion": "Omit facilityId to use your accessible facilities, or pass one your credential can access.",
      "retryable": false
    }
  }
}
```

The message lists the **offending ids** (as a JSON array) and your full
accessible set in parentheses — enough to correct the call without another
round-trip. A non-numeric value that slipped past schema validation is treated
as out of scope by definition and appears in the same list.

### (d) You can access several facilities but the tool takes a single `facilityId` → hard error

There is no way to represent "facilities 3 and 4" in a `number` field, and the
guard will not pick one for you or widen the query:

```json theme={null}
{
  "jsonrpc": "2.0",
  "id": 23,
  "error": {
    "code": -32003,
    "message": "Multiple facilities are within your scope; specify a facilityId (one of: 3, 4).",
    "data": {
      "suggestion": "Omit facilityId to use your accessible facilities, or pass one your credential can access.",
      "retryable": false
    }
  }
}
```

Pick one of the listed ids and retry — or fan out one call per facility.

## Recognizing a boundary rejection

All four rejections come back as `-32003` (FORBIDDEN) with `retryable: false`,
and all of them carry this exact `suggestion`:

```
Omit facilityId to use your accessible facilities, or pass one your credential can access.
```

That string is the reliable discriminator between a tenant-boundary rejection
and the other `-32003` causes — a scope gap carries `required` / `granted`, a
[guardrail](/concepts/governance) carries `guardrail`, and a
[tier floor](/concepts/model-tier-gate) carries `requiredTier` /
`tierEquivalence`. Boundary rejections carry none of those.

## Practical guidance

1. **Prefer omitting `facilityId`.** Injection (behaviour a) is the safe
   default, works for single- and multi-facility credentials, and needs no
   knowledge of ids.
2. **Discover ids before you use them.** The `search_facilities` registry tool
   (`facility:read`) and the `listFacilities` verb (`facilities:read`) return
   facilities you can reach; a hardcoded id from another environment is
   behaviour (c) waiting to happen.
3. **Do not retry.** `retryable: false` is accurate for all four — the call
   cannot succeed until the arguments or the credential change.
4. **Expect (d) on single-facility tools** if your bound user spans an org.
   Either bind the credential's user to one facility or always pass an explicit
   id.

## See also

* [MCP](/concepts/mcp#error-codes) — the full `-32003` decision tree.
* [Governance](/concepts/governance) — the guardrail layer that runs
  immediately after this guard.
* [Tool catalog](/api-reference/tool-catalog) — which tools take a `facilityId`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.