Multi-Tenant Deployment¶
This page is for operators who serve several customers or teams (tenants) and need each one kept to its own rules. The way to do it in cMCP is to run one gateway per tenant, each with its own policy rules, its own list of approved tools (catalog) and its own log (audit chain). You end up with two example tenants whose signed records show, by fingerprint, which rules each one ran under.
What you'll learn¶
- The config-level approach to per-tenant isolation (no multi-tenant API exists)
- How separate policy bundle hashes identify tenant policies in TRACE records
- An example with two tenants using different tool allowlists
- How audit chain isolation works per session
- What verifiers see when comparing claims across tenants
Prerequisites¶
Understand the isolation model¶
cMCP has no built-in way to serve several tenants from one gateway. You keep tenants apart by running one gateway per tenant, each with its own configuration. Each one:
- Loads its own Cedar policy bundle (different
policy_bundle_path) - Loads its own tool catalog (different
catalog_path) - Has its own
CMCP_POLICY_HASHandCMCP_CATALOG_HASH - Listens on a separate port (different
listen_addr) - Maintains separate session state and audit chains
This is the only supported way to keep tenants apart. One gateway shared between tenants does not keep their policies separate.
Lay out the directory structure¶
cmcp-tenants/
tenant-a/
cmcp-config.yaml
policies/
allow.cedar
manifest.json
schema.cedarschema
catalog.json
tenant-b/
cmcp-config.yaml
policies/
allow.cedar
manifest.json
schema.cedarschema
catalog.json
Configure tenant A¶
Tenant A is a customer-facing workflow. It can call CRM and support tools only.
tenant-a/cmcp-config.yaml:
attestation:
provider: auto
enforcement_mode: enforcing
policy_bundle_path: ./policies/
catalog_path: ./catalog.json
listen_addr: "0.0.0.0:8443"
tenant-a/policies/allow.cedar:
permit (
principal,
action == cMCP::Action::"call_tool",
resource
)
when {
resource.tool_name in ["crm.get_customer", "support.create_ticket"]
};
forbid (
principal,
action == cMCP::Action::"call_tool",
resource
);
tenant-a/catalog.json: includes only the two tools tenant A is allowed to call:
[
{
"tool_name": "crm.get_customer",
"server": {
"display_name": "CRM Server",
"url": "https://crm.internal/mcp",
"tls_fingerprint": "SHA256:<crm-server-fingerprint>=",
"transport": "http-sse",
"rotation_mode": "key-pinned"
},
"approved_definition": { ... },
"definition_hash": "sha256:<hash>",
"sensitivity_level": "pii",
"added_at": "2026-06-01T00:00:00Z",
"approved_by": "[email protected]"
},
{
"tool_name": "support.create_ticket",
...
}
]
Configure tenant B¶
Tenant B is an internal analytics workflow. It can call data warehouse tools only.
tenant-b/cmcp-config.yaml:
attestation:
provider: auto
enforcement_mode: enforcing
policy_bundle_path: ./policies/
catalog_path: ./catalog.json
listen_addr: "0.0.0.0:8444"
tenant-b/policies/allow.cedar:
permit (
principal,
action == cMCP::Action::"call_tool",
resource
)
when {
resource.tool_name in ["snowflake.query", "bigquery.read"]
};
forbid (
principal,
action == cMCP::Action::"call_tool",
resource
);
tenant-b/catalog.json: includes only the analytics tools.
Start both instances¶
Each tenant gets its own set of environment variables:
# Tenant A
CMCP_BEARER_TOKEN="$(openssl rand -hex 32)" \
CMCP_POLICY_HASH="sha256:<tenant-a-bundle-hash>" \
CMCP_CATALOG_HASH="sha256:<tenant-a-catalog-hash>" \
cmcp start --config tenant-a/cmcp-config.yaml &
# Tenant B
CMCP_BEARER_TOKEN="$(openssl rand -hex 32)" \
CMCP_POLICY_HASH="sha256:<tenant-b-bundle-hash>" \
CMCP_CATALOG_HASH="sha256:<tenant-b-catalog-hash>" \
cmcp start --config tenant-b/cmcp-config.yaml &
Each gateway prints its own fingerprints (hashes) at startup:
[cmcp] policy bundle loaded: sha256:<tenant-a-bundle-hash>
[cmcp] catalog loaded: 2 tools, sha256:<tenant-a-catalog-hash>
[cmcp] listening on 0.0.0.0:8443
[cmcp] policy bundle loaded: sha256:<tenant-b-bundle-hash>
[cmcp] catalog loaded: 2 tools, sha256:<tenant-b-catalog-hash>
[cmcp] listening on 0.0.0.0:8444
The bundle hashes differ because the policy files differ.
Compare TRACE records across tenants¶
A TRACE claim from tenant A's gateway:
{
"trace": {
"policy": {
"bundle_hash": "sha256:<tenant-a-bundle-hash>",
"enforcement_mode": "enforcing"
}
},
"gateway": {
"catalog": {
"hash": "sha256:<tenant-a-catalog-hash>"
},
"call_summary": {
"tools_invoked": ["crm.get_customer"]
}
}
}
A TRACE claim from tenant B's gateway:
{
"trace": {
"policy": {
"bundle_hash": "sha256:<tenant-b-bundle-hash>",
"enforcement_mode": "enforcing"
}
},
"gateway": {
"catalog": {
"hash": "sha256:<tenant-b-catalog-hash>"
},
"call_summary": {
"tools_invoked": ["snowflake.query"]
}
}
}
When checking a claim, pass the approved hashes for that specific tenant:
from cmcp_verify import verify_trace_claim, ApprovedHashes
# Verifying a tenant A claim
approved_a = ApprovedHashes(
policy_bundle_hash="sha256:<tenant-a-bundle-hash>",
tool_catalog_hash="sha256:<tenant-a-catalog-hash>",
)
result = verify_trace_claim(tenant_a_claim, approved_a)
# Verifying a tenant B claim
approved_b = ApprovedHashes(
policy_bundle_hash="sha256:<tenant-b-bundle-hash>",
tool_catalog_hash="sha256:<tenant-b-catalog-hash>",
)
result = verify_trace_claim(tenant_b_claim, approved_b)
If you accidentally verify a tenant B claim with tenant A's approved hashes, policy_bundle.hash and tool_catalog.hash will be in unverified_fields and the result will be unverified.
Audit chain isolation¶
Each session gets its own log inside the gateway (an AuditChain instance). Sessions from different tenants run in different gateway processes, so their logs are completely separate. A session_id only means something within its own gateway, and no session state is shared between tenants.
When you export the log for a session (GET /audit/export), you get only that session's entries. The gateway.audit_chain.root and .tip in the TRACE claim (the first and last entries' fingerprints) refer to that session's log only.
Summary¶
In cMCP, keeping tenants apart means one gateway per tenant, each with its own config, policy files, catalog and port. The policy and catalog fingerprints differ per tenant and are written into the signed TRACE claims, so anyone checking a claim can tell which tenant's rules applied, and a mix-up shows. Each session has its own log, held in its own tenant's process.
Related tutorials: the Cedar policy walkthrough covers writing each tenant's rules, and Verify a TRACE claim covers checking a tenant's claims against the right approved hashes.