Respond correctly when a protected tool is called
This check reminds you what a protected tool must return when it is called without a token, or with a token that lacks a scope, because a scan cannot call your tools to find out.
Check id call_time_guidance (call-time guidance). Results:
Why it matters
Section titled “Why it matters”On a server that lets agents list tools anonymously, sign-in starts when the user calls a protected tool. What your server answers to that call decides whether the user gets a sign-in prompt or an error message, and the two largest agents act on different signals.
- Claude starts sign-in only when the HTTP request for the
tools/callfails with401and aWWW-Authenticateheader. A200that carries a tool result withisError: trueis passed to the model as text, with no prompt. A tool handler cannot produce the401, because the MCP SDK wraps whatever it returns in a200, so the check has to run before the request reaches the SDK (Claude: Lazy authentication). After sign-in, Claude retries the same call. - ChatGPT shows its sign-in prompt for a tool when the tool declares
oauth2insecuritySchemesand the call returns an error result withisError: trueand_meta["mcp/www_authenticate"], aBearerchallenge that contains botherroranderror_description(OpenAI: Authentication). - The MCP authorization specification defines the HTTP signals:
401when a token is missing or invalid, and403witherror="insufficient_scope"when the token needs more scope. Other agents are expected to follow it; MCP Tower has not verified them.
The two signals do not fit in one response: a 401 has no tool result, and a tool result arrives
in a 200. Neither agent’s documentation describes a single response that triggers both prompts,
so MCP Tower does not suggest one. See Choose the response below.
What MCP Tower checks
Section titled “What MCP Tower checks”A scan reads tools/list and never calls a tool, so it cannot see what a call returns. The check
reports info when any tool needs, or may need, sign-in:
- it declares
oauth2insecuritySchemes, alone or withnoauth; - it declares scopes in
authorization.scopes; - it appears only after sign-in, or the whole server needs a token;
- it declares nothing on a server that offers OAuth while listing tools anonymously, so it may still enforce sign-in when called.
When every tool declares only noauth, or the server shows no sign of OAuth, the check is
skipped. It never fails or warns, and it has a docs link even though it is info.
Results
Section titled “Results”| Status | Message | Meaning | Fix |
|---|---|---|---|
| info | Some tools need, or may need, sign-in. The scan cannot verify what happens when they are called. | Check your server’s call-time responses yourself. | Answer without a token, Answer without enough scope |
| skipped | No tool needs sign-in. | Every tool works without a token. |
The next step shown with info is: “On a protected call without a token: return HTTP 401 with
WWW-Authenticate (Claude) and a tool error with _meta[‘mcp/www_authenticate’] (ChatGPT). For
missing scope: 403 insufficient_scope listing every needed scope.”
How to fix
Section titled “How to fix”Answer without a token
Section titled “Answer without a token”- In your HTTP handler, before the MCP SDK runs, parse the JSON-RPC body. If it is a
tools/callfor a protected tool and carries no valid token, stop there. - Answer
401 UnauthorizedwithWWW-Authenticate: Bearerand theresource_metadataURL, plusscopewith the scopes the tool needs. - Let
initialize,tools/listand calls to public tools through without a token.
HTTP/1.1 401 UnauthorizedWWW-Authenticate: Bearer error="invalid_token", error_description="Sign in to see your orders", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp", scope="orders:read"nginx cannot see which tool a JSON-RPC body calls, so it cannot make this decision. Do it in your
server code, as in the other tabs, and make sure nginx passes the 401 and its
WWW-Authenticate header through unchanged.
const PROTECTED = new Map([["get_order", "orders:read"]]);
app.post("/mcp", (req, res, next) => { const msg = req.body; const scope = msg?.method === "tools/call" ? PROTECTED.get(msg.params?.name) : undefined; if (scope && !isValidToken(req.get("authorization"))) { return res .status(401) .set( "WWW-Authenticate", `Bearer error="invalid_token", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp", scope="${scope}"`, ) .json({ error: "invalid_token" }); } next(); // the MCP SDK handles everything else});PROTECTED = {"get_order": "orders:read"}
@app.middleware("http")async def protect_tools(request: Request, call_next): if request.url.path == "/mcp" and request.method == "POST": msg = json.loads(await request.body() or b"{}") scope = PROTECTED.get((msg.get("params") or {}).get("name")) if msg.get("method") == "tools/call" and scope and not is_valid_token( request.headers.get("authorization") ): challenge = ( 'Bearer error="invalid_token", ' 'resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp", ' f'scope="{scope}"' ) return JSONResponse({"error": "invalid_token"}, status_code=401, headers={"WWW-Authenticate": challenge}) return await call_next(request)Answer without enough scope
Section titled “Answer without enough scope”When a signed-in user calls a tool their token cannot use, answer 403 Forbidden with
error="insufficient_scope" and a scope parameter that lists every scope the operation needs,
not only the missing one. The MCP specification asks for all required scopes in a single
challenge, and Claude recommends listing the scopes the user still needs, because scopes from an
earlier step-up are not reliably carried forward
(Claude: Ask for more scope with 403).
This is step-up.
HTTP/1.1 403 ForbiddenWWW-Authenticate: Bearer error="insufficient_scope", scope="orders:read orders:write", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"Choose the response
Section titled “Choose the response”The sources describe the HTTP 401 as the signal Claude and the MCP specification act on, and the
_meta tool error as the signal ChatGPT acts on. One call can return only one of them. Pick the
response for the agents your users rely on, and test it in each. For ChatGPT, the tool error looks
like this:
{ "jsonrpc": "2.0", "id": 2, "result": { "isError": true, "content": [{ "type": "text", "text": "Sign in to see your orders." }], "_meta": { "mcp/www_authenticate": [ "Bearer resource_metadata=\"https://mcp.example.com/.well-known/oauth-protected-resource/mcp\", error=\"invalid_token\", error_description=\"Sign in to see your orders\"" ] } }}Check it yourself
Section titled “Check it yourself”Call a protected tool without a token:
curl -si https://mcp.example.com/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_order","arguments":{"id":"1"}}}'For Claude, the problem is fixed when the response starts with HTTP/1.1 401 Unauthorized and a
WWW-Authenticate: Bearer header with resource_metadata. A 200 with isError: true means the
refusal came from the tool handler. Then send the same call with
-H 'Authorization: Bearer <access-token>' for a token without the tool’s scope, and look for
403 with error="insufficient_scope" and every needed scope. Public tools should still answer
200 without a token. Then select Scan again on the server’s page; the result stays info.
References
Section titled “References”- MCP authorization
2026-07-28: runtime insufficient scope errors and error handling - RFC 6750 §3 and
§3.1: the
Bearerchallenge and its error codes - Claude: Lazy authentication, “Answer a protected call with 401 before the MCP SDK runs” and “Ask for more scope with 403”
- OpenAI: Authentication, “Triggering authentication UI”