Skip to main content
Guide4 min read

MCP permission scoping patterns: give tools the least power they need

Four scoping patterns — schema, row, verb, and time — that keep MCP servers from becoming silent superusers. With concrete Postgres, GitHub, and filesystem examples.

A badly scoped MCP server is a loaded gun pointed at production. Done right, scoping turns a Postgres server from "can DROP any table" into "can SELECT from one schema, read-only, for ten minutes." Here are the four patterns that matter.

Why scoping matters more for MCP than for traditional services

A regular microservice has one caller (another service) and one deploy-time config. An MCP server has many callers (every agent run is different), dynamic requests (the model invents arguments), and ambient trust (env-var credentials get shared across tools). The blast radius of an over-scoped MCP server is bigger than a normal service — and the model invents ways to exercise it you never considered.

If you need the broader security context first, read MCP security best practices for 2026 and Zero-trust MCP architecture alongside this post.

Pattern 1: schema scope

Grant the MCP server access to a named subset of data, not everything.

Postgres example

-- Bad: the default Postgres MCP setup
GRANT ALL ON DATABASE prod TO mcp_agent;

-- Good: read-only role bound to one schema
CREATE ROLE mcp_agent LOGIN;
GRANT CONNECT ON DATABASE prod TO mcp_agent;
GRANT USAGE ON SCHEMA analytics TO mcp_agent;
GRANT SELECT ON ALL TABLES IN SCHEMA analytics TO mcp_agent;
ALTER DEFAULT PRIVILEGES IN SCHEMA analytics
  GRANT SELECT ON TABLES TO mcp_agent;

GitHub example — scope a GitHub token to one repo and one permission set:

permissions:
  contents: read
  pull_requests: write
repositories:
  - myorg/docs

The model can still hallucinate DROP TABLE users in its arguments — the database refuses before the statement runs.

Pattern 2: row / resource scope

Schema scope is coarse. Real systems need filtering at the row level.

Postgres RLS

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY mcp_agent_tenant ON orders
  FOR SELECT TO mcp_agent
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

The MCP server sets app.tenant_id from the caller's context on each call. No matter what query the model writes, it only sees rows for that tenant.

Filesystem — the official filesystem MCP server supports allowed-path lists:

{
  "command": "npx",
  "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/projects/acme"]
}

The server refuses reads or writes outside the allowed root. Always pass a single, specific path — never $HOME.

Pattern 3: verb scope

Separate read from write from destructive actions. Most tool catalogues conflate them.

A practical tiering:

Verb tier Examples Default permission
read SELECT, GET, list always allowed with schema scope
write INSERT, UPDATE, POST, PR comment requires explicit user confirmation
destructive DELETE, DROP, force-push, payment requires dual approval + reason

Express this in the MCP server manifest (custom annotations until the spec formalises it) and enforce at the policy engine. The host UI can grey out destructive calls unless approval is present.

Pattern 4: time scope

Standing credentials are a liability. Scope by wall-clock time.

  • Short-lived tokens. Use STS-style credentials with 5–15 minute TTLs. AWS IAM, GCP Workload Identity, and HashiCorp Vault all issue these.
  • Connection-level expiry. Postgres statement_timeout and idle_in_transaction_session_timeout prevent a single tool call from holding a connection hostage.
  • Tool-level windows. Some actions (deploys, merges) should only be callable during business hours unless a human opens a window explicitly.

Rotation is the flip side of time scope — see MCP credential rotation strategy.

Composing the four

In practice you compose all four. A well-scoped Postgres MCP server for a customer-support agent looks like:

identity:   mcp_agent (mTLS cert, 10-minute TTL)
schema:     analytics.*           (SELECT only)
row:        tenant_id = caller    (RLS enforced)
verb:       read                  (no write grants exist)
time:       statement_timeout 5s; connection TTL 10m

The model can ask for anything. The database quietly refuses 99% of it without the server ever needing to parse the SQL.

Failure modes to watch

  • Scope creep. Last month's "just this once" grant becomes permanent. Automate revocation on a timer, not on a ticket.
  • Token proliferation. Ten MCP servers × one token each = ten blast radiuses. Use a secrets broker that mints per-call credentials.
  • Implicit admin. Some cloud SDKs fall back to a personal CLI login silently. Set AWS_PROFILE=none, pin credentials explicitly.
  • Logging gaps. Every refused call is a signal. If the policy engine denies a tool call, log it loudly — the model just tried something it shouldn't.

Where to go next

Loadout

Build your AI agent loadout

Directory
Contact
© 2026 Loadout. Built on Angular 21 SSR.