MCP server permissions need two checks: authenticate the caller, then authorize the exact tool and arguments they want to use. Transport-level OAuth or workload identity tells you who connected. Per-tool role-based access control (RBAC) decides whether that identity may call read_orders, refund_order, or delete_customer, and it must run again on every tools/call request.

Why MCP server permissions need a separate authorization layer

MCP server access control is not complete when the server accepts a valid token. The MCP tools specification defines tools/list for discovery and tools/call for invocation, but the server still has to enforce its own authorization policy for each call.

That distinction matters because a single MCP server often exposes tools with very different consequences:

Giving every authenticated client access to every tool turns authentication into a coarse allow-list. A useful RBAC design maps roles to tool capabilities and evaluates that map at the server boundary.

The rule is simple: identity proves who is asking; tool policy decides what that identity can do. Do not ask the language model to enforce this rule. The model chooses a tool, but your MCP server must make the final decision.

How to model per-tool RBAC

Start with a policy whose smallest permission is one MCP tool, not the entire server. A practical policy record includes the role, tool name, allowed action, and any constraints that narrow the call.

{
  "roles": {
    "support_readonly": {
      "tools": {
        "search_orders": { "effect": "allow" },
        "get_order": { "effect": "allow" },
        "refund_order": { "effect": "deny" }
      }
    },
    "support_lead": {
      "tools": {
        "search_orders": { "effect": "allow" },
        "get_order": { "effect": "allow" },
        "refund_order": {
          "effect": "allow",
          "max_amount": 500,
          "require_human_approval": true
        }
      }
    }
  }
}

Use deny by default. A role should receive access only to named tools, and a tool should receive only the arguments and records that the role needs. Keep the policy outside the prompt and outside tool descriptions. Those are inputs to the model, not security controls.

Keep roles small and purpose-based

Good roles describe a job function or bounded workload, such as support_readonly, finance_refunds, or inventory_writer. Avoid a role called admin as the default escape hatch. Broad roles become permanent exceptions once clients and automation depend on them.

For machine callers, bind roles to a stable workload identity rather than a shared API key. If the MCP endpoint uses OAuth, the server should validate the access token before authorization. The MCP authorization specification requires resource-server behavior for HTTP transports, including audience validation and 401 responses for invalid tokens. A valid token still needs a separate role-to-tool decision.

Implement the authorization check before tool execution

The server should evaluate authorization after parsing the JSON-RPC request and before it invokes any backend function. The check must use the authenticated principal from the verified token or connection identity, not a username supplied in tool arguments.

type Principal = {
  subject: string;
  roles: string[];
};

type ToolRequest = {
  name: string;
  arguments: Record<string, unknown>;
};

function authorizeTool(principal: Principal, request: ToolRequest) {
  const rule = findFirstMatchingRule(principal.roles, request.name);

  if (!rule || rule.effect !== "allow") {
    return { allowed: false, reason: "tool_not_permitted" };
  }

  if (!validateArguments(request.name, request.arguments)) {
    return { allowed: false, reason: "invalid_arguments" };
  }

  if (rule.max_amount !== undefined &&
      Number(request.arguments.amount) > rule.max_amount) {
    return { allowed: false, reason: "amount_limit_exceeded" };
  }

  return { allowed: true, requireHumanApproval: rule.require_human_approval };
}

async function handleToolCall(principal: Principal, request: ToolRequest) {
  const decision = authorizeTool(principal, request);

  if (!decision.allowed) {
    audit("tool_denied", { subject: principal.subject, tool: request.name, reason: decision.reason });
    return protocolError("Forbidden");
  }

  if (decision.requireHumanApproval && !(await approvalExists(principal, request))) {
    audit("tool_pending_approval", { subject: principal.subject, tool: request.name });
    return protocolError("Human approval required");
  }

  audit("tool_allowed", { subject: principal.subject, tool: request.name });
  return executeTool(request.name, request.arguments);
}

Return a consistent forbidden response for policy failures. Do not reveal whether a hidden tool exists, which role would grant access, or which internal rule failed. Log the reason internally so an operator can distinguish a missing role from an argument limit.

Filter tool discovery without treating it as enforcement

Per-tool permissions should also shape tools/list, but discovery filtering is a usability feature, not the enforcement point. Return only the tools that the caller is likely to use, so the model has less opportunity to select an inappropriate operation. Still authorize every later tools/call, because tool lists can be cached, clients can be buggy, and a caller can submit a method directly.

function listTools(principal: Principal, allTools: ToolDefinition[]) {
  return allTools.filter(tool =>
    authorizeTool(principal, { name: tool.name, arguments: {} }).allowed
  );
}

Do not hide a tool and assume it is protected. The security boundary is the invocation check. Also treat tool metadata as untrusted input on the client side. The MCP specification warns that clients should not trust annotations unless they come from a trusted server.

Add argument-level constraints to RBAC

Tool-level permission is necessary but often too broad. A support role may need refund_order for small corrections but not for unlimited refunds. Add conditions for amount, tenant, record owner, environment, and operation type.

Useful constraints include:

ConstraintExampleWhat it prevents
AmountRefunds up to 500High-value unauthorized actions
TenantOnly records in tenant_id from the tokenCross-customer access
EnvironmentRead-only in productionTest automation changing live data
Resource ownerCaller owns the orderHorizontal privilege abuse
ApprovalSecond person confirms deletionIrreversible one-person actions

Derive tenant and subject fields from verified identity claims or server-side session state. Never trust tenant_id, user_id, or approver_id just because they appear in the model-generated arguments.

Separate MCP authentication from downstream credentials

An MCP server often acts as a client to another API. Do not forward the caller’s MCP access token to that API unless the downstream system explicitly expects the same audience and trust boundary. The MCP authorization specification says the server must validate the inbound token and use a separate downstream token when it acts as an OAuth client.

This is where many implementations accidentally create a confused deputy. The caller is authorized to invoke one MCP tool, but the server reuses a broad backend credential that can do much more. Map the approved tool to a narrowly scoped backend credential or service identity, and enforce the same tenant and resource constraints downstream.

If the MCP server is exposed through a gateway, keep the responsibilities clear. The gateway can provide private reachability, client authentication, and coarse policy. The MCP server still owns the final tool and argument authorization. NetFoundry’s MCP Gateway documentation describes namespacing and tool filtering over an OpenZiti overlay, which is useful for reducing exposure without replacing application-level authorization.

Test MCP server access control as a matrix

Test permissions as a matrix of principals, tools, arguments, and environments. A happy-path test for each allowed tool is not enough.

principal          tool                  input                 expected
support_readonly   search_orders         tenant=own            allow
support_readonly   refund_order          amount=10             deny
support_lead       refund_order          amount=100             approval
support_lead       refund_order          amount=501             deny
support_lead       delete_customer       any                   deny
automation_prod    update_shipping       tenant=other          deny

Include tests for unknown tool names, missing arguments, malformed JSON, stale roles, expired tokens, wrong token audiences, and repeated calls after a role is revoked. Exercise the actual transport and JSON-RPC handler, not only the policy function. A secure policy that is bypassed by a second handler is not secure.

Log every decision, not just every success

An MCP audit record should answer who called what, when, with which decision, and against which policy version. Record the principal subject, role, tool name, request ID, tenant, result, denial reason, and approval reference. Redact secrets and sensitive argument values.

The MCP tools specification recommends logging tool usage and requires servers to validate inputs, apply access controls, rate-limit invocations, and sanitize outputs. Use those records to detect unusual tool sequences, repeated denied calls, and a role that suddenly invokes destructive tools at a new volume.

Common mistakes to avoid

The most common MCP server permission failures are design shortcuts:

  1. Checking roles only in the client. A client can be modified or bypassed. Enforce policy in the server.
  2. Authorizing the server, not the tool. A token for the MCP server is not permission for every tool it exposes.
  3. Trusting tool descriptions. Descriptions help the model choose. They do not grant or remove permission.
  4. Using an allow-list only in tools/list. A direct tools/call still needs a server-side decision.
  5. Passing through broad backend tokens. Use a downstream credential with the smallest required scope.
  6. Logging raw arguments. Audit enough to investigate, but redact tokens, secrets, and sensitive data.
  7. Treating denial as an application error. Return a clear forbidden result and record the policy decision separately.

FAQ

What are MCP server permissions?

MCP server permissions control which authenticated identities may discover and invoke individual MCP tools. The server should enforce them on every tools/call request, with deny-by-default behavior and optional argument-level limits.

How do I add role-based access control to an MCP server?

Verify the caller’s identity, map its roles to named tools, validate arguments, and check the policy immediately before execution. Filter tools/list for a better client experience, but treat the tools/call authorization check as the real security boundary.

Does MCP support per-tool authorization?

MCP defines the tool discovery and invocation messages, while authorization policy is implemented by the server. You can attach RBAC and conditional rules to each tool name without changing the JSON-RPC method shape.

Should MCP tools use OAuth scopes or roles?

Use OAuth for transport authentication and coarse resource access, then map verified claims to server-side roles and per-tool rules. Scopes can work as inputs to that mapping, but a server should still enforce tool-specific and argument-specific constraints.

How do I stop an MCP client from calling hidden tools?

Filter unauthorized tools from tools/list, then reject unauthorized names in tools/call. Hiding a tool alone is not a control because clients can cache an old list or submit a direct request.

How should MCP servers log denied tool calls?

Log the identity, role, tool name, request ID, policy version, timestamp, and internal denial reason. Redact access tokens and sensitive arguments, and return a generic forbidden response to the caller.

Sources & References