Controls · September 26, 2026

MCP Tool Filtering Should Be Required

MCP lets a server hide tools a token cannot use but never requires it. The case for mcp tool filtering by scope as a MUST, and the objections.

An MCP server that uses OAuth receives, on every request, the credential that decides what the caller may do. The protocol lets the server act on that credential when it answers tools/list, and lets it ignore the credential just as freely. A server can describe a delete tool to a token that could never call it, wait for the model to try, and refuse. Both behaviours conform to the current specification, and a client has no way to tell which one it is talking to.

This piece argues that MCP tool filtering by scope should be a requirement: when a grant cannot cover a tool, the server MUST leave that tool out of tools/list. It covers what the specification says as of 25 September 2026, what happened to each proposal to standardise filtering, what GitHub’s server does when filtering is optional, the objections that come closest to winning, and four clauses of proposed normative text. For how one server enforces the boundary on the wire, read the MCP tool surface as a permission boundary.

The specification permits filtering and requires nothing

As of the current revision, 2026-07-28, a server MAY filter its tool list by authorization. Nothing tells it to.

The permission is one sentence in the Capabilities section of the 2026-07-28 tools page. A server must answer tools/list “with the set of tools currently available to the requesting client”, and that set “MAY vary by the authorization presented on the request”, for example by “returning only the tools the caller’s granted scopes permit”, because “credentials are per-request input, not connection state.” That sentence is the only normative text on the page that connects authorization to tool discovery.

The sentence is new, and the reason it was written matters. The 2025-11-25 tools page has no equivalent: its Capabilities section describes listChanged and stops. The clause arrived with SEP-2567, which removed sessions from the protocol and ruled that list results “MUST NOT vary per-connection”. Peter Alexander’s commit adding the authorization clause gives the reason in its message: “MUST NOT vary per-connection” was “being read as forbidding auth-based filtering.” The clause exists so that a statelessness rule would not be misread as a ban. It was written to protect filtering, not to promote it.

The authorization specification does not close the gap from its side. We fetched the 2026-07-28 authorization page as markdown and counted: 59 lines contain the word “scope” and none contains the word “tool”. The 2025-11-25 authorization page also contains no line with “tool”. Scopes are specified in detail, down to how a client accumulates them during step-up. Which tools a scope unlocks, and whether the model should see a tool the token cannot use, is not addressed.

The tools page assumes enforcement happens at call time. Its Security Considerations tell servers they MUST “Implement proper access controls” without saying where. Its error handling splits failures in two. Protocol errors, whose first listed example is “Unknown tool”, come back as JSON-RPC errors, and clients “MAY provide protocol errors to language models”. “Unknown tool” is an example, not a rule: the page does not say how a server must answer a call to a tool it filtered out. Tool execution errors come back as results with isError: true, and clients “SHOULD provide tool execution errors to language models to enable self-correction.” That asymmetry is the mechanical core of the argument below.

Every proposal to standardise server-side filtering has closed

No proposal to standardise or require scope-filtered discovery by the server is open today. To check, we queried the modelcontextprotocol/modelcontextprotocol repository through the GitHub API on 25 September 2026 for SEP-1880, SEP-1881 and the proposals they cross-reference, and read each one’s body, comments and closing event. Running gh api repos/modelcontextprotocol/modelcontextprotocol/issues/<number> reproduces every row.

ProposalWhat it asked forState on 2026-09-25
SEP-1880, tool-level scope requirementsAn optional authorization.scopes field on each toolClosed by its author on 2025-11-24 in favour of SEP-1488
SEP-1881, scope-filtered tool discoveryDefine filtering as a named, interoperable discovery modeClosed 2026-06-24 when SEPs moved to pull requests
SEP-1488, securitySchemes in tool metadataMark each tool as open or OAuth-protected, with scopesOpen, labelled draft
SEP-2385, tool auth manifestA tools/authorizationManifest method with per-tool rulesClosed unmerged on 2026-09-22
SEP-2643, structured authorization denialsA standard envelope for refused requestsOpen, draft, sponsor listed as none
SEP-2487, execution requirementsOpaque per-tool preconditions, including auth: onesOpen, preamble status Draft, sponsor TBD
SEP-2567, sessionless MCPRemoved sessions; carried the authorization clauseMerged 2026-05-07

SEP-1881 was never a proposal to require anything. Its abstract says it “does not require servers to filter tools”, and its aim was to make filtering a recognisable mode in which clients “MUST treat the tool list as non-exhaustive”. GitHub records the issue as closed with the reason “completed”, which reads like acceptance and is not. The closing comment explains that SEPs had moved to pull requests and invites the author to open one. SEP-2385 was closed three days before this was written because, per its closing comment, every new SEP should now be developed with a Working Group.

The open proposals show the direction of travel. A GitHub search on 25 September 2026 for open issues and pull requests labelled auth with “SEP” in the title returned four: SEP-1488 above, SEP-1932 (a DPoP profile), SEP-1933 (workload identity federation) and SEP-2343 (elicitation requires authorization on remote servers). None addresses tool discovery. SEP-2643, not labelled auth but written on behalf of the Fine-Grained Authorization Working Group, standardises how a server refuses: a denial envelope with a failure classification and remediation hints. That is useful work, entirely on the call-time path.

SEP-2487 comes closest to the question and answers it from the other side. It lets a tool declare opaque requirement strings such as auth:oauth2, says servers “MUST enforce all declared requirements at execution time”, and lets clients use them for “filtering, ranking, or avoiding failures”, but only when “the client can determine that at least one requirement cannot be satisfied.” The server still lists the tool, and any filtering happens in a client that must be able to evaluate the requirement itself. Whether the model should see the tool in the first place has no proposal that requires servers to act on it.

What GitHub’s server does when filtering is optional

GitHub’s MCP server filters by scope for classic tokens and for its local OAuth login, applies no scope filtering for other credentials, and continues unfiltered when it cannot tell what a token allows. It deserves credit for filtering at all, and its details show what “optional” produces in practice.

Everything below comes from the server’s scope filtering documentation, its OAuth login documentation and the source they describe, all read today. The stated purpose is “reducing clutter and preventing errors from attempting operations your token can’t perform.”

Be fair about the stakes. None of this lets a token do more than it could, because the GitHub API enforces permission on every call. The remote OAuth choice is a deliberate use of the specification’s own step-up mechanism, and it is the strongest counterargument to this piece, answered below.

The pattern is what matters to a standards question. Scope filtering is on where the server already holds a scope list, off for three credential types where it would need another way to learn permissions, off for remote OAuth on purpose, and off whenever one HEAD request fails. The troubleshooting table lists “All tools visible despite limited PAT”, cause “Scope detection failed”, remedy “Check logs for warnings”. The client cannot see those logs, and a filtered tools/list response looks the same as an unfiltered one.

The same table contains the strongest sentence in favour of this piece. For “Insufficient permissions” errors on a visible tool, the documented response is: “This shouldn’t happen with scope filtering; report as bug.” Where GitHub does filter, it treats a listed tool the token cannot use as a defect. The same page says fine-grained tokens show every tool and “you’ll get errors if you try to use tools your token doesn’t have permission for”, so outside filtering the same outcome is expected behaviour. The specification treats it as a choice everywhere. Filtering left optional is filtering left off, in exactly the cases nobody is watching. A MAY gives server authors permission and gives clients nothing, because a client cannot rely on a behaviour it has no way to detect.

Why MCP tool filtering by scope should be a MUST

A listed tool is an offer to the model, and the specification’s error design routes the refusal of that offer back to the model. Four consequences follow.

The model plans on the list. A tool the grant cannot cover is a false statement about what the agent can do. SEP-1881 named the outcome: agents “would plan using tools that will always fail with 403 insufficient_scope.”

A refusal is an input. If a server answers a call to an unlisted tool as an “Unknown tool” protocol error, a client MAY pass it to the model. A listed tool refused inside the tool layer can instead come back as isError: true, which a client SHOULD pass on so the model can “self-correct”. Self-correction is the wrong affordance at a permission boundary. The model reads the refusal and routes around it: another tool, another argument, a request to the user. When the text that prompted the call came from an email or a web page, the refusal is feedback to whoever wrote that text, which is the attack class set out in prompt injection by email.

Step-up turns an injected attempt into a consent prompt. A server following the authorization page answers an under-scoped call with HTTP 403 and WWW-Authenticate: Bearer error="insufficient_scope", which reaches the client rather than the model. That is better than an isError result. But the security best practices recommend “Incremental elevation via targeted WWW-Authenticate scope="..." challenges when privileged operations are first attempted”, and the attempt is the model’s. A model talked into calling a send tool it cannot use produces a real consent screen, from a real server, asking a human to grant send. Omission closes that path: a model cannot attempt what it was never told exists.

Only a requirement gives absence a meaning. The MAY already obliges clients to cope with lists that differ by credential, so a MUST adds semantics, not client burden. Under a MUST, absence means “not permitted for this credential” and presence means “permitted, subject to argument and policy checks”. Under a MAY, neither means anything. The tools page tells applications they SHOULD “Provide UI that makes clear which tools are being exposed to the AI model”. That UI is only accurate if the list is.

The objection that nearly wins: step-up needs a visible tool

The strongest case against a requirement is that MCP’s authorization model is progressive by design. A client starts with minimal scopes and widens them when challenged. Hide the tools that need more, and nothing ever challenges.

The objection has real support. On SEP-1881, Simon Russell asked how a client would “discover tools that it doesn’t have access to, so that it can start the process of gaining the extra scopes required”. GitHub’s remote server chose visible tools plus challenges for OAuth because it “provides a smoother user experience for OAuth users since you only grant permissions as needed.” SEP-1488 exists because “clients only discover auth needs by trying and failing, or by reading docs”, and its fix, labelling each tool with the authorization it needs, assumes a caller sees tools it cannot yet use. A user who asks an IDE agent to open a pull request is better served by a consent prompt than by an agent with no such tool.

That cost is real. Three things answer it.

There are two audiences, and one list is serving both. A host needs a catalogue of what could be granted. A model needs the list of what it can do now. tools/list is the second. The Server Cards proposal, SEP-2127, co-authored by Sam Morrow, excludes tools from its static document on the grounds that “there is currently no viable substitute for runtime listing via the protocol’s standard operations (tools/list, resources/list, prompts/list) with the logged-in user’s identity”. In January, Morrow had argued on SEP-1881 for the server card “to include all potential functionality”, which is the catalogue this section asks for. The catalogue of what else exists belongs in a surface the model does not execute from, which is roughly what SEP-1880 and SEP-1488 reached for.

No such surface is standardised today. SEP-1880 closed and SEP-1488 is an open draft, so the requirement should land with a non-executable catalogue or after one.

The trigger for elevation should not be the model. For read-only tools, an attempt that triggers a challenge costs little. For tools with side effects (send, delete, merge, pay) it is the injection-to-consent path described above. The requirement does not forbid step-up. It moves the start of step-up from a model’s attempt to a host’s catalogue, where a person asks for the capability on purpose. The user who wants a pull request still gets the prompt, before the task rather than in the middle of it.

Scope mapping, caching, stable lists and the cost to servers

Scopes do not map one-to-one onto tools. Sam Morrow wrote on SEP-1881 that scope “doesn’t map 1:1 with tools” and that “unless arguments are factored in, it’s impossible to get that correct for all tools.” GitHub’s documentation shows a live case: “File writes request workflow in addition to repo only when the call changes a workflow file.” He is right, and the requirement should be a floor. Omit a tool when the credential cannot satisfy its requirements for any arguments; leave argument-dependent checks at call time, as SEP-1881 already allowed: “A tool may still fail at call time if additional dynamic or contextual checks fail.” Our own server shows the gap. The draft tool becomes visible on drafts:write while the service behind it requires drafts:submit for a draft-only principal, so a hand-built grant can see a tool it cannot use, as described in the tool surface walkthrough. A floor still removes the tools that can never work, which are the ones that should never enter a plan.

Per-credential lists defeat caching. SEP-2567 made lists session-independent so they could be cached, and the tools page asks for deterministic ordering because it “improves LLM prompt cache hit rates”. The caching page has already priced this in: "private" is appropriate “for filtered list results that vary per user”, and private caches “MUST NOT be shared across authorization contexts (e.g. a different access token requires a different cache)”. A requirement changes which cache scope authenticated servers set; it does not invent a new mode. The price is real. An MCP response cache for a filtered list is per token, the (deployment, auth) granularity SEP-2567 already names. The LLM prompt cache is different, because it sees tool text rather than credentials, so its fragmentation is bounded by the number of distinct lists, not users. Our six tools key on five distinct scopes, so at most 32 distinct lists can exist however many users there are, and our three presets produce three of them.

The detail that should worry server authors runs the other way. The specification’s own example tools/list response carries "cacheScope": "public". A server that filters and copies that example has told every shared cache to serve one caller’s list to the next.

Clients assume a stable tool set. A client that fetches the list once and keeps it is confused when the list shrinks after a role change. But the current tools page already says the set “MAY change over time”, and notifications/tools/list_changed exists for that reason. A client that assumes stability is already wrong against any server using the MAY. A list that does not shrink when authority does is stale, not stable.

Filtering costs server authors. Declaring per-tool requirements and computing them per request is work, and some credentials cannot be introspected: GitHub’s fine-grained tokens and App installation tokens are the concrete case. The requirement should not force GitHub to reject those tokens. It should force honesty: a server that cannot evaluate a credential before the call must say its list is unfiltered, and must never degrade from filtered to unfiltered because a lookup failed.

Hiding is not security. Correct. SEP-1880 put it well: declared requirements “MUST NOT replace server-side authorization checks.” Filtering does not replace the call-time check. It removes the tool from the model’s plan and the refusal from the model’s inputs: two controls, two jobs.

What the requirement should say

Four clauses would do it, with one permission alongside. This is a proposal, written in the specification’s register so it can be argued with line by line.

  1. A server that authenticates requests MUST omit from tools/list every tool whose authorization requirements the credential presented on that request cannot satisfy for any arguments.
  2. If the server’s lookup of a credential’s authorization fails, it MUST reject the request or return only tools that need no authorization, and MUST NOT fall back to the full list.
  3. If a credential carries no authorization the server can evaluate before a call, the server MUST mark its tools/list result as unfiltered.
  4. A tools/call naming a tool omitted under clause 1 MUST be rejected before its arguments are processed, and a tools/list result that varies by authorization MUST carry cacheScope: "private".

Servers MAY describe tools that need additional authorization through a separate, non-executable surface intended for hosts to present to a person.

Clause 1 is the floor from the scope-mapping objection. Clause 2 is the one the GitHub evidence argues for: a timeout is not a reason to show everything. Clause 3 is a concession to credentials like fine-grained tokens, and it is weaker than the rest on purpose. It does not force filtering, but it gives the client the one fact it lacks today, which is whether absence means anything. No such marker exists in the specification; it would need a field. Clause 4 restates what the caching page implies. The permission is the answer to step-up, and no standard surface for it exists yet.

Where our own implementation falls short

We implement clause 1, as an existence proof rather than a model to copy. Our source is private, so take the implementation on trust and check the behaviour: a read grant’s tools/list has no send_message. The filter itself is one line over a static table of six tools: a tool is visible when the grant holds every scope it declares. tools/list maps over the result, tools/call checks membership before reading any argument, and an integration test asserts that a read grant’s list has no send_message. Authority is re-read on every request, so a failed lookup is a failed request, which satisfies clause 2 by construction. The grants themselves are described in scoped credentials for agent email. Where we fall short:

If you run an MCP server, check two things this week: what tools/list returns to your narrowest credential, and what it returns when your scope lookup fails. The controls behind our own grants are summarised in the agent controls overview.

Sources

Every external page below was read on 2026-09-25.


Keep reading

All guides · Documentation · Controls