List protected tools before sign-in
This check verifies that tools/list returns the same tools with and without a token, so no tool
is visible only after sign-in.
Check id hidden_tools (hidden tools). Results:
Why it matters
Section titled “Why it matters”Agents decide what a server can do from the tool list they get when they connect, and on a server that lets anonymous clients in, that list is fetched before anyone signs in.
- Claude connects and lists tools without a token on a server that allows it, calls the public tools right away, and asks for sign-in only when it calls a protected tool (Claude: Lazy authentication). A tool that is not in that list is never called, so the user is never asked to sign in for it.
- ChatGPT learns from each listed tool’s
securitySchemeswhich tools need OAuth (OpenAI: Authentication). A tool it does not see cannot offer sign-in either.
The result is a feature nobody can reach: the tool exists, but users of these agents never see it.
Filtering tools/list by the token’s permissions was proposed as SEP-1881 (scope-filtered tool
discovery). The proposal was closed without being adopted; it was still closed in October 2026.
Servers that filter their tool list this way still exist, which is why MCP Tower checks for it.
What MCP Tower checks
Section titled “What MCP Tower checks”- MCP Tower lists your tools twice: once without a token (the anonymous pass) and once with the
token of the person who signed in (the signed-in pass). It follows
nextCursorin both. - A tool that appears only in the signed-in list is hidden. It is marked “Visible only when signed in” in the tools step and counted as needing sign-in.
- Any hidden tool gives
warn, naming the tools. When both lists hold the same tools, the result ispass. - The check needs both lists. It is
skippedwhen the anonymous pass could not list tools (your server needs a token for everything) or when no signed-in pass succeeded.
A server that needs a token for every request is not affected: agents sign in before listing tools there, and the check is skipped.
Results
Section titled “Results”| Status | Message | Meaning | Fix |
|---|---|---|---|
| pass | Every tool is listed before sign-in. | Agents see all your tools when they connect. | |
| warn | These tools are listed only to signed-in clients: <tools>. | Agents that list tools before sign-in never see these tools. | List every tool anonymously |
| skipped | Needs an anonymous and a signed-in tool list. | Your server needs a token for tools/list, or no signed-in scan has succeeded yet. | Select Sign in to scan, or see token accepted |
The next step for the warning is: “Claude and ChatGPT list tools before sign-in, so users never see these. List them anonymously and declare oauth2.”
How to fix
Section titled “How to fix”List every tool anonymously
Section titled “List every tool anonymously”- Find where your server builds the
tools/listresult and remove the filter on the caller’s token or scopes. Return every tool to every caller. - On each tool that needs sign-in, declare
securitySchemeswithoauth2and the scopes it needs, so agents know before calling it; see declared schemes. - Move the access check into the tool call: a call to a protected tool without a valid token gets the challenge described in call-time responses.
nginx does not build the tool list. If nginx routes tools/list to a different backend for
signed-in callers, for example with a map on $http_authorization, remove that routing so every
caller reaches the same handler.
// Before: tools were registered only when the request carried a valid token.// After: register every tool for every caller, and check the token in the tool.server.registerTool( "admin_export", { description: "Export every order as CSV.", inputSchema: { since: z.string() }, securitySchemes: [{ type: "oauth2", scopes: ["admin:export"] }], }, async (args, extra) => { requireScope(extra.authInfo, "admin:export"); return exportOrders(args.since); },);TOOLS = [SEARCH_PRODUCTS, GET_ORDER, ADMIN_EXPORT] # every tool, for every caller
def list_tools(request_id): # Before: [t for t in TOOLS if allowed(token, t)] return {"jsonrpc": "2.0", "id": request_id, "result": {"tools": TOOLS}}Check it yourself
Section titled “Check it yourself”List the tools without a token, then with one, and compare the names. Servers on protocol
2025-11-25 need an initialize first and the Mcp-Session-Id it returns.
curl -s https://mcp.example.com/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
curl -s https://mcp.example.com/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -H 'Authorization: Bearer <access-token>' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'Follow nextCursor in both until it is absent. The problem is fixed when both lists name the same
tools, for example:
anonymous: search_products, get_order, admin_exportsigned in: search_products, get_order, admin_exportThen select Scan again on the server’s page. The check needs a signed-in scan, so use Sign in to scan if the scan has no token yet.
References
Section titled “References”- SEP-1881: Scope-filtered tool discovery, closed
- SEP-1488: securitySchemes in tool metadata, open draft
- Claude: Lazy authentication, “See what the user experiences”
- OpenAI: Authentication, “Triggering authentication UI”
- MCP authorization
2026-07-28: scope challenge handling