Engineering notesView as Markdown ↗

Making a multi-connector MCP setup safe to act on

When an agent holds several tenant connectors at once, the risk isn't data leakage (that's sealed server-side) — it's acting on the wrong tenant by mistake. The fix is layered: make the tenant visible, make it assertable, and gate the irreversible actions. Visibility alone is not enough.

An AI agent can hold several Biloh connectors at once — one per tenant, plus a platform-admin connector — and they look almost identical in the tool list. The danger is not that one connector reads another's data (that is sealed by a tenant-bound token, row-level security, and write gateways, independent of the agent). The danger is intent: calling the right tool on the wrong connector.

Biloh solves this in three layers, cheapest first: make the tenant visible, make it assertable, and gate the actions you can't undo. Visibility alone — the most common instinct — is not enough.

Layer 1 — make the tenant visible

Two signals, on by default:

  • Connector identity. Each connection advertises itself as biloh · <Tenant> (<MODE>), where mode is LIVE (a real tenant), TEST (a tenant flagged as test data), or PLATFORM (the cross-tenant admin surface). Two real tenants are both LIVE — so the name distinguishes them and the mode separates a tenant from the platform.
  • A per-response stamp. Every tool result carries meta.tenant { id, name, mode }. Because it is on every call, the last result an agent received always tells it which tenant it just touched.

A subtle implementation note: the MCP handler is built once per process, so the server's identifying info is otherwise static across connections. Per-connection identity is therefore applied by rewriting the protocol initialize response — fail-safe, so any anomaly returns the original handshake untouched rather than risking the connection.

Layer 2 — make the tenant assertable (a misroute should fail)

Visibility helps a careful reader. It does nothing for a mistake already in flight. So a write can carry an optional expected_tenant: the tenant you intend to act on, by name or id.

If it doesn't match the connector you're on, the call returns expected_tenant_mismatch and the handler never runs. A misroute stops instead of silently going through. The argument rides the same central schema-augmentation as cross-tenant routing and is enforced once, at the single wrapper every tool call passes through — so it covers the whole tool surface without per-tool code.

The principle: data isolation and intent safety are different problems. One is enforced by the database; the other has to be enforced by affordances the caller opts into.

Layer 3 — gate what you can't undo

Two-phase confirm-gates protect the irreversible actions. The first call returns a preview plus a deterministic confirm_token and mutates nothing; only a second call carrying the matching token executes.

The scoping rule matters more than the mechanism: gate by operation, not by keyword. Archiving a tenant is irreversible — gated. Creating a tenant is gated too (it completes the lifecycle pair). Minting an access token is revocable — so it is deliberately left ungated. "Sounds dangerous" is not the test; "can't be undone" is.

A gate is only safe to add after an audit confirms nothing automated calls the tool — otherwise you break provisioning. In this build the audit showed zero code-level callers, so the gate had zero blast radius.

What each layer is locked by

Every layer ships with a spec test written before the code, so the guarantee can't quietly regress:

  • The mismatch guard has a test asserting a wrong expected_tenant returns the error code and the handler does not run.
  • Each confirm-gate has a test asserting the no-token call previews and mutates nothing, a wrong token errors, and the matching token executes.
  • The identity rewrite is fail-safe by construction and tested against a malformed handshake.

Next steps

Last updated 2026-06-25