All articles

Policy

MCP permissions: how to decide what agents may read, write, or delete

Tier Two10 min read

Connect one MCP server and you inherit twenty or thirty tools you didn't write. Some are obviously reads, some obviously writes, and a few, like anything that takes a SQL string, are whatever the caller makes of them. 'Reads run, writes wait' is where nearly everyone starts, and it's a decent start. It's also where most teams stop, which is how an agent ends up with an Allow on a tool that can dump a customer table.

This is a method for going one step further: an inventory you can trust, four questions per tool, and a starter matrix with real tools from Linear, GitHub and Postgres that you can copy and then argue with.

The short answer

Work from the tool list the server actually returns, not from its docs. For each tool, decide what it can expose, what it can change, whether that change is reversible, and how far the credential behind it reaches. Assign one mode per tool, narrow by resource where the tool carries one, and write the reason down. Reads Allow and writes Ask is a starting posture, never a verdict.

Start from tools/list, not the documentation

The list a server returns from tools/list is the only inventory that counts, because it's the one the model sees. Docs lag: GitHub's official server replaced its create_issue tool with issue_write, which creates or updates issues and pull requests, and a policy written against the old name governs nothing. Servers also change shape by configuration. GitHub's remote server exposes toolsets you can turn on and off, with a read-only variant of each, and PostHog's server hands most clients a single exec tool that can do anything PostHog can, unless you connect with a mode parameter that splits it into individual tools. One meta-tool can't be split into reads and writes by any policy; the split has to happen at the server.

So pin the list. Tier Two reads a connection's tools once and stores them, and a server that adds a tool tomorrow doesn't silently widen what agents can do; you see the difference when you refresh, and new tools arrive with the connection's preset mode while existing tools keep the mode you set. If your gate doesn't pin, refresh the inventory on a schedule and diff it, because a new tool with a harmless name is exactly how an allow-everything preset gets a delete verb.

Three levels of control, and what each can't say

Permission decisions land at different levels, and confusion between them is the source of most overreach. A rule at the wrong level feels like control and isn't.

LevelExampleWhat it can enforceWhat it cannot express
CredentialA read-only endpoint, an OAuth read scope, a SELECT-only database roleWhat the account can reach at all, regardless of toolWhich of the reachable things this task should touch
ToolAllow, Ask or Block on linear__save_issueWhether a named operation runs, waits, or is hiddenAnything about the arguments
ResourceA different mode for database id 3 than for database id 7One declared argument, when the tool carries itFilters on arbitrary arguments or on the data returned
DestinationLinear project membership, a repository's branch protectionRow and object level access, with the system's own rulesWhether a person should have said yes to this call

Notice the gap in the third row. Neither Tier Two nor the host rule grammars in Claude Code and Cursor offer a general allow-list of arguments, and that's not an oversight; matching free-form arguments is how you end up with a regex as a security boundary. If a tool is safe with some inputs and dangerous with others, and a single resource argument can't separate the two, the honest choices are Ask, so a person sees the arguments, or Block. The argument-level control that does exist belongs to the destination, and you get it by narrowing the credential, which is the read-only access article's subject.

Four questions for every tool

Run each tool through the same four questions and record the answers. They take a minute apiece once you've done a few, and the record is what makes the policy reviewable later.

  • What's the broadest result it can return? Not the typical result; the worst one. A list_issues call returns whatever the credential can read, including the private project nobody meant to include.
  • What does it change, and for whom is the change visible? A comment is seen by the whole team within seconds; a branch push is seen by CI.
  • Can the change be undone, and by whom? An edited issue can be edited back. A deleted comment is gone unless the system keeps history. A merged pull request is a different conversation.
  • How far does the credential reach on its own? A personal sign-in inherits that person's limits. A shared workspace key inherits nothing, so the tool's mode becomes the only limit.

The fourth question is the one people skip, and it changes the answer to the other three. The same save_issue tool is a modest risk behind Dana's own Linear login and a large one behind a workspace admin token.

A starter policy matrix

These are real tool names from the official Linear and GitHub servers and a generic Postgres server, as they appear in tools/list at the time of writing. Modes assume a personal sign-in; with a shared key, move each write one notch stricter and reconsider the reads. Suggested modes are opinions to argue with, not a compliance table.

SystemToolWhat it can touchSide effect and reversibilitySuggested modeWhy
Linearlist_issues, get_issueEvery issue, description and comment the account can readNoneAllow, or Ask on workspaces with confidential projectsA read, but one that returns private discussion; decide by what it can return
Linearsave_issueCreates or updates issuesNotifies watchers; edits are reversible by another editAsk, scoped by project where the tool carries a project idThe arguments (title, project, assignee) are the decision
Linearsave_commentPosts a commentVisible to the team immediately; can be edited or deleted but not unseenAskA wrong comment is a small incident with a wide audience
Lineardelete_commentRemoves a commentDestructiveBlock until a workflow needs itNo agent workflow in most teams needs deletion
GitHubget_file_contents, search_codeSource of every repository the token can read, including secrets committed by mistakeNoneAllow for public repositories; Ask or a read-only toolset for private onesThe same tool name spans very different data
GitHubcreate_or_update_file, push_filesCommits to a branchReversible through git, but CI and reviewers react instantlyAskThe branch name is the argument that matters, and it isn't a declared scope
GitHubcreate_pull_requestOpens a PRTriggers CI, notifies reviewers; closableAskCheap to undo, expensive in attention
GitHubdelete_fileRemoves a file in a commitRevertible by another commitBlock, or Ask on a sandbox repositoryRarely what the task needs
Postgresexecute_sql, query, or any tool taking a SQL stringAnything the database role can SELECT; writes if the role allows themDepends entirely on the role and the statementAsk. Allow only behind a SELECT-only role on a non-production replicaThe name says nothing; the role and the statement say everything
PostHogexec (the single meta-tool most clients receive)All of PostHogMixed reads and writes in one toolBlock until the server is connected in the mode that exposes individual toolsOne tool can't be split by policy

Two rows deserve a second look. The GitHub read row shows why 'read' isn't a mode by itself: the identical tool is harmless on an open-source repository and a leak on the monorepo, and only the credential or the resource tells them apart. The Postgres row is the general case of a tool you can't classify from its name, and any tool whose input is a query language belongs in it, including the ones with friendly names.

How Tier Two's Recommended preset guesses, exactly

When you add a connection, Tier Two applies the Recommended preset, and it's worth knowing the rule rather than trusting the label. The classifier checks, in order: does the tool name end in execute_sql, run_sql, execute_query or run_query? Then it's SQL, and gets Ask. Does the server set readOnlyHint to true? Then it's a read, and gets Allow. Does the name start with get, list, search, read, describe, fetch, find, show, view, query, construct, count, check, lookup or browse? Then it's a read. Otherwise it's a write, and gets Ask. Open sets everything to Allow; Locked sets everything to Block until you allow a tool by hand.

The name check sits after the hint check on purpose, and the hint is not trusted on its own. The MCP spec says clients must treat annotations as untrusted unless the server is trusted, and in practice servers get them wrong in both directions; Metabase marks one of its plain reads as not read-only, and the name rule catches it. The guess is the starting point and the mode is the enforcement. Nothing about a tool being classified as a read makes it safe to Allow if its broadest result is a customer list.

Presets are per connection, and changing one resets every tool on that connection, including the modes you set by hand. The dashboard asks you to confirm, and the change lands in the governance ledger with your name and a before-and-after diff, so a preset switch is a fine way to establish a baseline and a poor way to make a small adjustment later. The policy documentation has the current preset wording.

Scope by resource, and know where it stops

Some tools carry the resource in their arguments: a database id, a project id, a repository. Tier Two lets a tool declare one such argument as its scope, using a dotted path into the call, and the mode can then differ per resource. Remembered approvals key on the same scope, so 'always for Dana' can mean always for Dana on database id 3 and still Ask on database id 7. A call whose declared scope can't be resolved from the arguments is refused rather than guessed, and the refusal shows in Activity as 'couldn't scope the call', which is the behavior you want when the alternative is falling through to Allow.

Modes are workspace-wide. The only per-person difference is a remembered approval, and there is no 'always for everyone'; if everyone should run a tool, set it to Allow so the decision is visible. That constraint sounds limiting and is mostly a relief, because two policy layers that can disagree is how nobody can answer 'why did this run'.

Test the policy before an agent does

A written matrix is a hypothesis until a call goes through it. Before turning agents loose on a connection, run one deliberate call per mode from a real host and check the record.

  • Call an Allow tool and confirm Activity shows it as Allowed, with the person, agent and host attached.
  • Call an Ask tool, watch the request appear, deny it, and confirm the agent reports the denial rather than trying another path.
  • List tools from the host and confirm a Blocked tool is absent, then call it by name anyway and confirm the refusal is recorded.
  • For a scoped tool, send a call with the scope argument missing and confirm it's refused rather than allowed.
  • Grant a one-hour approval, revoke it, and confirm the next call asks again.

Then put the matrix somewhere people can edit it, with a column for the last date each row was checked. Policies that nobody re-reads decay in the same direction: toward Allow.

Fewer Allows than you started with

Done properly this review ends with a shorter Allow list, a handful of Asks on the tools whose arguments are the decision, and a Block or two on verbs no workflow needs. That's a policy you can explain to the person whose data it protects, which is a better test than whether it feels convenient during the demo.

Tier Two is built around exactly this matrix: one mode per tool, resource scoping where the tool carries one, and a pinned tool list so a server can't widen your policy overnight. Connect one system, keep the Recommended guess or override it row by row, and see every decision in Activity.

Sign up for Tier Two