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 a 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 a boundary with Rasheed