Every OdooConsole connector points at the same address. There is one hostname, one certificate, one resource identifier, and behind it as many isolated Odoo instances as there are customers. Adding an instance issues no new hostname and requires no change in anyone’s client.
That design has an obvious question attached: if the URL is identical, what stops a token minted for one instance from reading another’s invoices?
The answer is not the audience claim, which is where we expected it to be.
Why the audience cannot do this job
The MCP specification is unambiguous that tokens should be audience-bound.
Clients MUST implement Resource Indicators
(RFC 8707),
sending a resource parameter on both the authorization and token requests,
and servers MUST validate that a token was issued for them.
We do validate it. It just cannot separate tenants here, for a reason that has
nothing to do with client conformance: the canonical URI of our MCP server is
https://mcp.odooconsole.com/mcp, and that is one string. Every token, for
every instance, carries the same audience because they all address the same
resource server. Audience validation answers “was this token minted for this
deployment”, which is worth having and is not the question.
Per-instance hostnames would give each one its own resource identifier — and a certificate, a DNS record, and a Let’s Encrypt issuance every time somebody creates an instance. We tried that shape first. It makes provisioning slow and rate-limited in exchange for an isolation property we would still not rely on alone.
What actually separates them
Three mechanisms, and the order matters because each one assumes the previous may have failed.
The tenant claim. Consent is where the instance is chosen — not the client’s configuration, which is why the same URL is correct for everybody. Approving a connector records which instance it may reach, and the authorization server puts that in the token:
{
"iss": "https://auth.odooconsole.com",
"aud": ["https://mcp.odooconsole.com"],
"scp": ["odoo:read", "odoo:write"],
"ext": { "tenant": "acme", "console_user": 1 }
}
A router that resolves rather than interpolates. The claim is a key into a
registry, never a fragment pasted into a URL. An unknown or hostile value
finds no entry and gets a 403; a stopped instance gets a 503. The router
also checks the grant is still live, matched on the connector’s client_id —
revoking one connector must not leave its tokens working because another
connector on the same instance is still authorised.
A server that re-checks. Each instance runs its own MCP server inside its own network namespace, and that server knows which tenant it serves before any request arrives. It validates the token itself and refuses one whose claim names anything else. Bypassing the router entirely and posting a genuine, correctly-signed token for another instance directly at the sidecar gets:
HTTP/1.1 403 Forbidden
{"error":"invalid_token","error_description":"Token is not valid for this instance."}
That last point is the whole design. A single shared router is exactly the component whose bugs become cross-tenant data leaks, so we assume it will have them. The instance-level check costs one comparison and makes a router bug an availability problem rather than a breach — and even if that check were also wrong, the sidecar has no network route to any other instance’s database.
The other boundary: data and provisioning
There is a second separation that is less obvious and, in an agent context, just as important.
An OdooConsole token is one of two kinds. A tenant grant carries
odoo:read / odoo:write and reaches one instance’s records. An account
grant carries instances:read / instances:write and reaches the provisioning
tools — create, restart, logs, delete — with no route to any instance’s data
at all. They come from separate consent flows and are separate credentials.
The reason is prompt injection. Text inside an ERP record is untrusted input:
a supplier writes an invoice description, it lands in your database, and an
agent reads it while doing something ordinary. If the token that reads
invoices also carried instances:write, that text would be one sentence away
from delete_instance.
With the grants split, the connector that reads invoices has no provisioning tool in its list, and the connector that provisions cannot read a record to be injected by. It is safe to hand an agent both, because neither can be turned into the other.
The cost is real: two connectors to authorise instead of one. We think that is
the right trade for now, and the honest version of “one interface” is to merge
the read-only account tools into a tenant grant and keep instances:write
separate — the useful part of provisioning-in-the-same-conversation is seeing
whether your instance is up, not deleting it.
What to take from this
If you are building multi-tenant MCP, the useful lesson is that tenancy is not an authorization question you can answer once at the edge. Put the check where the data is, on a component that already knows which tenant it belongs to, and treat everything upstream as a hint.
And separate the tools that change infrastructure from the tools that read untrusted content. Scopes on one credential are not a boundary when a language model is choosing which tool to call.
The MCP reference documents the endpoint, both grant kinds and every error above; the console will show you the consent screen where the instance actually gets chosen.