The permission model your MCP server is probably missing

The server has OAuth. The token is validated. The session is bound to a user. And every authenticated caller can invoke every tool with every argument, because nothing between "who are you" and "run this handler" ever asks "should you."

That gap is normal, and it's structural rather than careless. MCP gives you a transport-level authentication story and then stops. The protocol describes how a tool is invoked. It says nothing about whether the invocation is allowed, and the shape of a tool call — a name and a bag of arguments — actively discourages the question, because it looks like a function call rather than a permission request.

Most servers therefore ship with a permission model that consists of: is there a valid token, and is the tool in the list. In a single-tenant internal tool that's arguably enough. In anything holding customer data it's an incident waiting for someone to notice.

Authentication answers a different question

It's worth separating three questions that get collapsed into "auth."

Who is calling? Answered by the transport. An OAuth token identifies a client, and with the right flow, a user. This is the only part the protocol gives you conventions for.

What may they do? Answered by nothing, unless you build it. A tool list is not a permission set; it's a catalogue.

Which records may they touch? Answered by nothing, unless every query carries a predicate derived from the session. This is the layer that produces cross-tenant bugs, and it's the one people forget because the first two feel like enough.

A server can pass an audit on questions one and two and fail catastrophically on three. That's the specific failure this article is about.

🧠 The important part: a tool call is a request to perform an action on behalf of a principal. If your handler can't name the principal, the action, and the resource scope, it can't be authorized — only executed.

The four layers, and what each one catches

A permission model that holds up has four independent layers. They're redundant on purpose: each catches what the one above it misses.

Layer 1 — transport authentication. Token validation, expiry, signature, audience. Catches unauthenticated callers and misdirected tokens. Nothing about permissions.

Layer 2 — session identity. Resolve the token to a concrete principal with a tenant and a role set, at initialization. Catches callers whose token is valid but whose membership was revoked, and gives every subsequent check something to check against.

Layer 3 — tool authorization. Does this principal hold the capability this tool requires? Catches the broad class of "the model picked a tool the user shouldn't use," which is a real failure mode because tool lists are often assembled from a global registry rather than filtered per user.

Layer 4 — data authorization. Does this principal have access to the specific records this call would read or write? Catches the case where the tool is permitted but the argument points somewhere else.

Skipping layer 3 and relying on layer 4 is a common shortcut, and it costs you the ability to give a clean denial. Skipping layer 4 and relying on layer 3 is the dangerous one: the tool is allowed, the argument is not checked, and the response contains someone else's data.

@dataclass(frozen=True)
class Principal:
    user_id: str
    tenant_id: str
    roles: frozenset[str]
    scopes: frozenset[str]

    def can(self, capability: str) -> bool:
        return capability in self.scopes


def authorize_tool(tool: Tool, principal: Principal) -> None:
    missing = tool.required_scope - principal.scopes
    if missing:
        raise ToolError("forbidden", "You do not have access to this action")

Note the error: it names nothing. "You do not have access to this action" is true whether the user lacks a role or the tool doesn't exist for them, and it doesn't teach an attacker the shape of your permission model.

Scopes alone are too coarse

orders:read is a scope. It's also the scope that lets a support agent read every order in the tenant, including the ones belonging to a customer who has explicitly restricted access to their account after a dispute.

Real permission models need three dimensions, not one:

resource : action : constraint
orders   : read   : tenant=current
orders   : read   : customer=assigned
refunds  : create : amount<=200
refunds  : create : requires_approval(amount>200)

The constraint is where the actual policy lives, and it's the part that never appears in a scope string. A model that only has scopes will end up encoding constraints in tool names (read_own_orders vs read_all_orders), which multiplies tools and lets the model pick the wrong one.

@dataclass(frozen=True)
class Capability:
    resource: str
    action: str
    constraint: Callable[[Principal, dict], bool] | None = None

    def allows(self, principal: Principal, args: dict) -> bool:
        if f"{self.resource}:{self.action}" not in principal.scopes:
            return False
        return self.constraint(principal, args) if self.constraint else True


def assigned_customer_only(principal: Principal, args: dict) -> bool:
    customer_id = args.get("customer_id")
    if customer_id is None:
        return True
    return customer_id in assignments.for_agent(principal.user_id)


READ_ORDERS = Capability("orders", "read", assigned_customer_only)

Now the tool's permission requirement is a first-class object you can test, log, and explain — rather than a string buried in a decorator.

Where the check goes: order matters

The order of operations inside a handler determines which error a caller sees, and therefore what they learn.

The sequence that works:

  1. Resolve the principal from the session. Fail with unauthenticated.
  2. Check the tool capability. Fail with forbidden.
  3. Validate arguments against the schema. Fail with invalid_argument.
  4. Check the argument-level constraint. Fail with a generic denial.
  5. Execute with server-derived scope.
  6. Audit before returning.

Step 3 before step 4 matters: a schema error on a forbidden tool tells the caller the tool's argument shape, which is information they shouldn't get. Step 4's generic denial is what prevents an existence oracle at the argument level.

def call_tool(name: str, args: dict, session) -> dict:
    principal = resolve_principal(session)             # 1
    tool = TOOLS.get(name)
    if tool is None or not tool.capability.allows_base(principal):
        raise ToolError("forbidden", "You do not have access to this action")   # 2

    validated = tool.schema.validate(args)               # 3
    if not tool.capability.allows(principal, validated):
        raise ToolError("forbidden", "You do not have access to this action")   # 4

    result = tool.handler(**validated, principal=principal)                     # 5
    audit.record(principal, name, validated, result)                            # 6
    return result

The handler signature taking principal rather than tenant_id is deliberate. A handler that receives an ID can be called with the wrong one; a handler that receives a principal has to derive everything from it.

Constraints belong to attributes, not to role names

Role names accumulate exceptions. support_agent starts as "can read orders," and within a year has five branches in the handler testing if user.role == "support_agent" and order.region == user.region.

The fix is to express constraints over attributes of the principal and the resource, so the same check covers every role:

def can_read_order(principal: Principal, order: dict) -> bool:
    if order["tenant_id"] != principal.tenant_id:
        return False
    if "support:all" in principal.scopes:
        return True
    # Regional agents see only their region's orders.
    if order["region"] not in principal.attributes.get("regions", set()):
        return False
    # Restricted accounts are visible only to the assigned owner.
    if order["restricted"] and order["owner_id"] != principal.user_id:
        return False
    return True

That function is testable in isolation, has no database access, and expresses the policy in one place. Compare it with the version scattered across handlers, where the restricted-account rule is implemented in three of the four read paths and forgotten in the fourth.

⚠️ Gotcha: attribute-based checks that consult the database are authorization decisions in disguise and must not be cached for long. A cached "can read" result that outlives a permission change is how a revoked user keeps access until they disconnect.

Delegation: the agent acts for the user, not as a superuser

The most consequential design question in an MCP server is whose permissions the tool call runs under.

Three options, and only one is usually correct:

Service account. The server holds a service identity with broad access and performs actions on behalf of whoever asked. Simple, and it means your permission model is decorative — the service account can do anything, and the only gate is whether the tool exists.

User delegation. The server derives the principal from the user's session and every check runs against that principal's permissions. The agent can never do more than the user could do through the UI. This is the default you want.

Escalation with approval. The tool needs a permission the user lacks, and a human with that permission approves the specific action. This is a real pattern, and it must be an explicit, recorded handoff — not a service account quietly filling the gap.

def requires_approval(principal: Principal, args: dict) -> bool:
    amount = args.get("amount_cents", 0)
    if amount <= AUTO_LIMIT:
        return False
    return "refunds:approve" not in principal.scopes


def create_refund(args: dict, principal: Principal) -> dict:
    if requires_approval(principal, args):
        approval = approvals.request(
            actor=principal.user_id,
            tenant=principal.tenant_id,
            action="refund",
            args=args,
        )
        return {"status": "pending_approval", "approval_id": approval.id}
    return payments.refund(**args, tenant_id=principal.tenant_id)

The pending_approval result is a first-class outcome, not an error. It tells the model what happened, gives it something to report to the user, and doesn't invite a retry — which an error would.

Denials are part of the API

Every denial needs three properties: it's logged with the principal, the tool, and the reason; it's identical to the caller regardless of the underlying cause; and it's countable so you can see a spike.

The count matters more than it sounds. A sudden increase in forbidden on one tool is either a role change, a tool description that's attracting the wrong requests, or someone probing. All three are worth knowing about, and none of them surface from logs nobody aggregates.

def deny(principal: Principal, tool: str, reason: str) -> ToolError:
    audit.record_denial(principal=principal.user_id, tenant=principal.tenant_id,
                        tool=tool, reason=reason)
    metrics.incr("mcp.denied", tags={"tool": tool, "reason": reason})
    return ToolError("forbidden", "You do not have access to this action")

The reason is for your logs — "no capability", "constraint failed: region", "restricted account" — and never for the response body.

The permission table as code, with a fail-closed default

The practical way to make this stick in a codebase that will keep growing: a table, one entry per tool, and a startup assertion that every tool has one.

PERMISSIONS: dict[str, Capability] = {
    "get_order": Capability("orders", "read", order_scope),
    "list_orders": Capability("orders", "read", order_list_scope),
    "refund_order": Capability("refunds", "create", refund_scope),
    "search_documents": Capability("documents", "read", document_scope),
    "get_customer": Capability("customers", "read", customer_scope),
}


def assert_complete() -> None:
    missing = set(TOOLS) - set(PERMISSIONS)
    if missing:
        raise RuntimeError(f"tools without a permission entry: {sorted(missing)}")

Fail closed at startup, not at call time. A tool that ships without a permission entry should prevent the process from booting, because the alternative is a tool that silently allows everything until someone notices.

The migration path from "no model"

Most servers that need this don't have it, and retrofitting is a project rather than a patch. The sequence that avoids a big-bang rewrite:

Week one: inventory. For each tool, write down the resource it touches, the action, and the predicate that decides which rows. You'll find two or three tools where nobody can say — those are the ones to disable first.

Week two: principal resolution. Add a Principal object resolved at session initialization. Thread it through every handler even if the handlers don't use it yet. This is mechanical and it's the step that makes the rest possible.

Week three: tool-level checks. Add the capability table with constraints stubbed to True. Now every tool has an entry, and the framework is in place. Ship it; behavior is unchanged.

Week four onward: one constraint at a time. Enable the real predicate for one tool, run the two-user test matrix, then the next. Each is small and independently reversible.

The payoff at the end isn't just security. It's that "why can't I do this" becomes a question with an answer, and the answer is a single function you can read.

Tools that span many resources break single-resource checks

A tool like search_records or list_recent_activity doesn't touch one resource; it touches whatever the query returns. A permission check written for get_order(id) doesn't transfer, because there's no single object to check against before the query runs.

Two patterns work, and they're worth choosing deliberately.

Predicate injection. The handler builds the query with a filter derived from the principal, so the database never returns rows the principal can't see. This is the only approach that scales, because it pushes the decision into the query planner instead of application memory.

VISIBILITY_SQL = {
    "support:all": "TRUE",
    "support:regional": "o.region = ANY(%(regions)s)",
    "customer": "o.customer_id = %(customer_id)s",
}


def visibility_clause(principal: Principal) -> tuple[str, dict]:
    for scope, clause in VISIBILITY_SQL.items():
        if scope in principal.scopes:
            return clause, {"regions": list(principal.attributes.get("regions", [])),
                            "customer_id": principal.attributes.get("customer_id")}
    # No matching scope: return nothing, rather than everything.
    return "FALSE", {}

The final return is the important one. A default of TRUE in a permission helper is the single most common way cross-tenant reads get introduced, because the helper is written when everyone has full access and the fallback is chosen for convenience.

Post-filtering with a count. Where the predicate can't be pushed into the query — a third-party search API, for example — fetch with an over-fetch factor and filter before returning, then report how many were filtered. Post-filtering is weaker because it can't guarantee a full page, but it's better than returning rows the principal shouldn't see.

def filtered_search(query: str, principal: Principal, limit: int = 20) -> dict:
    raw = index.search(query, limit=limit * 4)
    visible = [r for r in raw if can_read_record(principal, r)]
    return {"items": visible[:limit], "truncated": len(visible) > limit,
            "filtered_out": len(raw) - len(visible)}

filtered_out is a diagnostic. A consistently high number means your index is returning mostly invisible results — usually because the index isn't tenant-scoped, which is a performance problem with a security workaround on top of it.

Permission checks need their own test matrix

The tests that matter for a permission model are the negative ones, and they're the ones that don't get written because a passing test that asserts "denied" feels like testing the absence of a feature.

A matrix that catches the real bugs, per tool:

Case Setup Expected
Own tenant, own record Principal A, record A Success
Own tenant, other user's record Principal A, record B (A's tenant) Denied identically to nonexistent
Other tenant Principal A, record in tenant C Denied identically to nonexistent
Revoked membership Valid token, revoked row unauthenticated
Deleted record Record soft-deleted Not found
Read-only role calling a write tool viewer principal forbidden
Constraint breach Refund above limit, no approval scope pending_approval, not success
@pytest.mark.parametrize("principal,record,expected", [
    (A, RECORD_A, "ok"),
    (A, RECORD_B_SAME_TENANT, "denied"),
    (A, RECORD_C_OTHER_TENANT, "denied"),
])
def test_get_order_matrix(principal, record, expected, server):
    result = server.call("get_order", {"order_id": record.id}, principal=principal)
    assert classify(result) == expected

classify should collapse the denial cases to one bucket. If "denied" and "not found" are different buckets in your test helper, you'll write tests that distinguish them and then build a system that does the same.

🚨 Production warning: run the matrix against a warm process, not a fresh one per case. A permission cache that's populated in case one and read in case three is exactly the bug the matrix is supposed to find, and a fresh process per test hides it.

Caching permission decisions without outliving a revocation

Every server that gets traffic ends up caching something about permissions, because checking a role table on every tool call is wasteful. The question is whether the cache can outlive a revocation.

Three rules, and the third is the one that gets broken.

Cache scope resolution, not data access. Caching Principal (roles, scopes, attributes) is fine and saves a query. Caching "principal X can read order 8812" is not, because the answer depends on the record's current state — a record that becomes restricted should stop being readable immediately.

Bound the cache by the revocation path, not by a timer. A five-minute TTL means a revoked user keeps access for up to five minutes. For a support tool that may be acceptable; for anything touching payment data it usually isn't. Invalidate on the revocation event, and use the TTL as a backstop rather than as the mechanism.

Include the tenant in the key. A cache keyed by user ID alone is correct only if a user belongs to exactly one tenant. The moment someone is added to a second workspace, the cached principal from the first is served for the second, and every downstream check runs against the wrong scope.

PRINCIPAL_CACHE: dict[tuple[str, str], Principal] = {}


def resolve_principal(session) -> Principal:
    key = (session.user_id, session.tenant_id)
    cached = PRINCIPAL_CACHE.get(key)
    if cached and cached.fetched_at > utcnow() - timedelta(seconds=60):
        return cached
    principal = load_principal(session.user_id, session.tenant_id)
    PRINCIPAL_CACHE[key] = principal
    return principal


def on_membership_change(user_id: str, tenant_id: str) -> None:
    PRINCIPAL_CACHE.pop((user_id, tenant_id), None)

That's a per-process cache, which is fine for a single instance and wrong for a fleet — an invalidation event reaches one process. If you run more than one replica, either move the cache to a shared store with a version stamp, or shorten the TTL to the point where you're comfortable with the staleness window. The second option is usually the honest one for a small deployment.

What an audit record needs to answer

A year after launch, someone will ask: did employee X access customer Y's data, and when? If the answer takes a week of log archaeology, the permission model is incomplete regardless of how correct the checks are.

Four fields make that query a lookup rather than an investigation: the acting principal, the resolved tenant, the resource identifiers the call touched, and the outcome. Everything else is convenience.

def audit_record(principal: Principal, tool: str, args: dict, outcome: str,
                 touches: list[str]) -> dict:
    return {
        "at": utcnow_iso(),
        "actor": principal.user_id,
        "tenant": principal.tenant_id,
        "tool": tool,
        "args_hash": sha256(json.dumps(args, sort_keys=True))[:16],
        "touches": touches,          # e.g. ["order:ord_8812", "customer:cus_4"]
        "outcome": outcome,          # ok | denied | error | pending_approval
    }

touches is the field people omit and the one that makes the difference. "Alex called list_orders" is not an access record; "Alex's call returned orders 8812 and 8813" is. Recording touched identifiers turns an audit question into an index lookup, and it's cheap when the handler already knows which records it read.

Storing an args hash rather than the args keeps the record useful for correlation (same call repeated, retry detection) without turning your audit table into a copy of the data. When the arguments themselves matter for an investigation, pull them from the trace store under separate access control.

The failure modes this actually prevents

Three failures, each of which shows up in real incident write-ups rather than being hypothetical:

The trustworthy endpoint. A single tool takes a workspace_id, the handler passes it to the query, and any authenticated caller reads any workspace. Every layer above four is intact; layer four is missing. The fix is a session-derived predicate, and the reason it gets missed is that the tool's signature looks correct.

The invisible new tool. A tool is added in a small PR, gets a description and a handler, and has no permission entry. It's available to everyone, including roles that shouldn't have it, and nothing in the diff looks wrong because the diff adds a feature rather than removing a check. The startup assertion catches it before deployment.

The service-account shortcut. Tool calls run under a shared identity because per-user execution was hard to wire. The permission model becomes decorative: every user has the service account's access, and the only real gate is whether a tool is listed. This one is insidious because it works perfectly in testing and fails the first audit.

All three are preventable with the same four-layer structure, and none of them are preventable by better authentication. That's the whole argument: authentication tells you who asked. Permission is a separate system, and it has to be built rather than assumed.

What this prevents, concretely

Three failures, all of which I've seen described in incident write-ups rather than invented:

The cross-tenant read. A tool takes a workspace_id, the handler trusts it, and any authenticated user can read any workspace. Layer 4 with a session-derived predicate is the fix, and it has to be in the query rather than the handler's memory.

The forgotten scope. A new tool ships without a permission entry and is therefore available to everyone, including read-only roles. The startup assertion is the fix, and it's three lines.

The escalation through delegation. The server holds a service account with write access so that tool calls work. Any user who can reach the tool can write, regardless of their own permissions. Layer 2 plus per-principal execution is the fix.

None of these require complicated infrastructure. All three require that someone decided, before shipping, that a tool call is a permission request rather than a function call. That decision is the model, and it's the part that has to be made in the design rather than discovered in an audit.

Post a Comment

Previous Post Next Post