Authorization case 01 · source review + negative retest
The tool call looked normal. The authorization was not.
A fictional invoice MCP server trusted a tenant identifier supplied in tool input. An authenticated caller for tenant-red could select tenant-blue and read its invoice without malformed input, an unusual tool name, or suspicious telemetry.
tenant_id: tenant-blue
200 · invoice returned
Caller input selects the ownership scope.
tenant := trusted context
Denied · AuthorizationError
Server identity selects the ownership scope.
The boundary was identity, not transport.
The host adapter had already established tenant-red. The MCP tool then accepted a second tenant identifier from the model-controlled argument object. That duplicated identity on opposite sides of the trust boundary.
Failed invariant: an object lookup must be scoped by the authenticated principal, never by a principal or tenant supplied in tool arguments.
The authenticated tenant was present and explicitly discarded.
def get_invoice(*, authenticated_tenant: str,
tenant_id: str, invoice_id: str) -> dict:
del authenticated_tenant
return dict(INVOICES[(tenant_id, invoice_id)])
The public tool schema also required tenant_id. The handler therefore treated untrusted tool input as the authorization scope and used the authenticated identity for nothing.
Why normal monitoring looked legitimate.
get_invoice({tenant_id: "tenant-blue", invoice_id: "inv-200"})successThe call used a declared tool, schema-valid strings, an existing invoice ID, and a normal successful response. There was no injection string, traversal sequence, unexpected destination, or tool-definition drift for an indicator to flag.
What telemetry could say: the call happened and succeeded. What it could not establish: whether tenant-red owned the returned object. That proof required reading where identity entered the handler and how the storage predicate was constructed.
One unauthorized read, isolated to a synthetic fixture.
- 01
Authenticate the wrapper as
tenant-red. - 02
Call
get_invoicewithtenant_id="tenant-blue"andinvoice_id="inv-200". - 03
Observe a record whose stored owner is
tenant-blueand total is9800.
This demonstrates broken object-level authorization in the fixture. It does not claim production exposure, customer data access, persistence, write access, or a flaw in MCP itself.
Remove tenant choice from the tool contract.
- def get_invoice(*, authenticated_tenant, tenant_id, invoice_id):
- return dict(INVOICES[(tenant_id, invoice_id)])
+ def get_invoice(*, authenticated_tenant, invoice_id):
+ record = INVOICES.get((authenticated_tenant, invoice_id))
+ if record is None or record["tenant_id"] != authenticated_tenant:
+ raise AuthorizationError("invoice is not accessible to this tenant")
+ return dict(record)
- Delete
tenant_idfrom the public tool schema and reject additional properties. - Bind tenant identity from the trusted host or authenticated request context.
- Include that trusted identity in the storage lookup and verify ownership before returning the record.
- Fail closed with a typed authorization error when the scoped record does not exist.
The negative case is now the evidence.
| Test | Expected | Observed |
|---|---|---|
Red requests Blue’s inv-200 | Denied | AuthorizationError |
Red requests Red’s inv-100 | Returned | tenant-red record |
Tool schema exposes tenant_id | Absent | Removed |
| Additional properties accepted | No | False |
Executed against the repository fixture with four passing assertions. The retest proves this defect is closed in the fixed synthetic handler; it is not a general certification of an application or deployment.
Have one boundary where identity, tool input, and data access meet?
Bring the narrowest useful slice. We’ll discuss what evidence would actually prove the control.
Discuss one MCP boundary