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.
| Proposal | What it asked for | State on 2026-09-25 |
|---|---|---|
| SEP-1880, tool-level scope requirements | An optional authorization.scopes field on each tool | Closed by its author on 2025-11-24 in favour of SEP-1488 |
| SEP-1881, scope-filtered tool discovery | Define filtering as a named, interoperable discovery mode | Closed 2026-06-24 when SEPs moved to pull requests |
| SEP-1488, securitySchemes in tool metadata | Mark each tool as open or OAuth-protected, with scopes | Open, labelled draft |
| SEP-2385, tool auth manifest | A tools/authorizationManifest method with per-tool rules | Closed unmerged on 2026-09-22 |
| SEP-2643, structured authorization denials | A standard envelope for refused requests | Open, draft, sponsor listed as none |
| SEP-2487, execution requirements | Opaque per-tool preconditions, including auth: ones | Open, preamble status Draft, sponsor TBD |
| SEP-2567, sessionless MCP | Removed sessions; carried the authorization clause | Merged 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.”
- Among access tokens, only classic PATs are scope-filtered. For a
ghp_token the server makes “a lightweight HTTP HEAD request” and reads theX-OAuth-Scopesheader. “Fine-grained PATs, GitHub App installation tokens, and server-to-server tokens don’t support scope detection and show all tools.” - Local OAuth login filters by the scopes requested. Narrowing
--oauth-scopes“filters out tools that would need a scope you didn’t request”. The default requests the full supported set, which “hides no tools”. - OAuth on the remote server does not hide tools by design. “Instead of hiding tools upfront, all tools are available, and the server requests additional scopes on-demand when you try to use a tool that requires them.”
- A failed lookup fails open. “If the server cannot fetch your token’s scopes (e.g.,
network issues, rate limiting), it logs a warning and continues without filtering.”
The code in
internal/ghmcp/server.gomatches, loggingfailed to fetch token scopes, continuing without scope filtering. - The scope set is read at startup. After editing a token’s scopes, the documentation says to “restart the MCP server to pick up the changes.”
- Read-only repository tools are always shown, because they work on public repositories. That is a reasoned exception, not a gap.
- Scope is not the only filter. Sam Morrow, who per his GitHub profile builds the server, wrote on SEP-1881 that “for S2S tokens we filter out user based tools as there is no user context” and “We always filter out tools based on policy”. Server-to-server tokens are not scope-filtered, but they do not see everything either.
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.
- A server that authenticates requests MUST omit from
tools/listevery tool whose authorization requirements the credential presented on that request cannot satisfy for any arguments. - 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.
- If a credential carries no authorization the server can evaluate before a call, the
server MUST mark its
tools/listresult as unfiltered. - A
tools/callnaming a tool omitted under clause 1 MUST be rejected before its arguments are processed, and atools/listresult that varies by authorization MUST carrycacheScope: "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:
- This is the easy case. Six tools and one credential type, since an API key is
rejected at
/mcpby design, for reasons in MCP or API key for agent email. GitHub’s problem, with several token types, is harder, and we have not solved it. - We offer no step-up. A call to a filtered tool returns HTTP 403 with no
insufficient_scopechallenge and no JSON-RPC envelope, so no client can step up from it. Widening an agent means a new authorization flow. - We do not announce shrinkage. Our
initializeresponse declarestools: {}withoutlistChanged, so when the authorising human is demoted and the list shrinks, the client learns on its nexttools/list. - We are behind the current revision. The server negotiates 2025-11-25, 2025-06-18 and
2025-03-26 through
initializeand does not emit the 2026-07-28 caching fieldsttlMsandcacheScope, so we do not meet the second half of clause 4. The caching answer above is the specification’s, not something our server demonstrates. - Our floor has a hole in it: the
drafts:writeversusdrafts:submitmismatch above.
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.
- MCP specification 2026-07-28, Tools: https://modelcontextprotocol.io/specification/2026-07-28/server/tools
- MCP specification 2025-11-25, Tools: https://modelcontextprotocol.io/specification/2025-11-25/server/tools
- MCP specification 2026-07-28, Authorization: https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization
- MCP specification 2025-11-25, Authorization: https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
- MCP specification 2026-07-28, Caching: https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching
- MCP versioning (current revision 2026-07-28): https://modelcontextprotocol.io/specification/versioning
- MCP security best practices, Scope Minimization: https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices
- SEP-2567 authorization clause, commit 8cb5b8bf: https://github.com/modelcontextprotocol/modelcontextprotocol/commit/8cb5b8bfc89dbf26c444b15db790f47e7f22cc69
- SEP-2567, Sessionless MCP via Explicit State Handles: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2567
- SEP-1880: https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1880
- SEP-1881 and its comments: https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1881
- SEP-1488: https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1488
- SEP-2385 and its closing comment: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2385
- SEP-2643: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2643
- SEP-2487: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2487
- Open SEPs labelled
auth(SEP-1488, SEP-1932, SEP-1933, SEP-2343), from the GitHub search queryrepo:modelcontextprotocol/modelcontextprotocol is:open label:auth SEP in:title - SEP-2127: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127
- GitHub MCP server, PAT scope filtering: https://github.com/github/github-mcp-server/blob/main/docs/scope-filtering.md
- GitHub MCP server,
internal/ghmcp/server.go: https://github.com/github/github-mcp-server/blob/main/internal/ghmcp/server.go - GitHub MCP server, OAuth login: https://github.com/github/github-mcp-server/blob/main/docs/oauth-login.md
- Sam Morrow, GitHub profile: https://github.com/SamMorrowDrums
- Sam Morrow on SEP-1881 (scope mapping, S2S and policy filtering): https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1881#issuecomment-3763006875
Keep reading
The MCP Tool Surface Is the Permission Boundary
How the MCP tool surface permission boundary is computed per request, what a filtered tools/call returns, and three ways the tool list shrinks under you.
Scoped Credentials for Agent Email: Read, Draft, Send
Give an agent the smallest useful email permission: project and inbox scoping, one-time secrets, draft-only grants, rotation, and revocation you can verify.
Prompt Injection by Email: What Actually Helps
Any stranger can email your agent. What an email-borne injection looks like, why message text must never grant permission, and the controls that limit damage.