DEV Community

Discussion on: Your MCP server probably accepts tokens that were never meant for it

Collapse
 
izgorodin profile image
Edward Izgorodin •

Listing only the tools a token's scopes allow and challenging for a missing scope pull against each other. Step-up starts from a call that fails: the spec has the server answer insufficient_scope with the scopes the operation needs, and it describes scopes_supported as the minimal set, with more requested incrementally through that flow. If tools/list hides close_ticket and delete_ticket from a tickets:read token, the model never calls them, the challenge is never issued, and a user who would grant tickets:write is never asked. Hiding fits scopes a caller cannot get at all. A tool behind a scope the user can grant has to stay listed for the challenge, or the ToolError that names the scope, to reach anyone.

The filter can also empty the CI test. In mcp 2.3.0 the tools/list handler returns MCPServer.list_tools(), and the test enumerates through that same method outside call_as, with no token in the context. On a copy of the tickets server with require_scope removed from delete_ticket, the test fails on delete_ticket, as it should. Add a list_tools override that keeps only the tools whose scope the token holds, and the same test passes: the loop gets an empty list, while a tickets:read token calling delete_ticket directly still deletes. Written as a middleware, the filter would not reach that method. Written as an override, it does. Asserting that the loop called at least one tool, or enumerating the registry rather than the listing, keeps the test from passing on nothing.

Collapse
 
sampop3 profile image
sam ros •

You're right on both counts, and thanks for testing it. I reproduced the empty-loop case in CI: with a list_tools override that filters by the token's scopes, the old test passed while a tickets:read token could still delete. The test now lists tools with an all-scopes token, asserts the write tools it found are exactly close_ticket and delete_ticket, and a separate test builds your filtered server with the scope check removed and confirms the rule still catches it. I also rewrote the hiding advice: tools behind a scope the user can grant stay listed so the insufficient_scope challenge can reach them, and only tools behind scopes the caller can never get are hidden. The post is updated and credits you.

Collapse
 
izgorodin profile image
Edward Izgorodin •

Listing as an all-scopes caller fixes the empty loop, but it hands the test's coverage to ALL_SCOPES, a list someone has to keep current. A scope filter needs each tool's scope declared somewhere outside the tool body, because require_scope only runs inside it. Add export_tickets next year behind a new tickets:export scope, give it its entry in that map, and forget require_scope: the filter hides it from a token holding the three scopes in ALL_SCOPES, the loop checks close_ticket and delete_ticket and passes, and the assertion that the write tools are exactly those two passes with it. A tickets:read token calling export_tickets by name still runs it, since in mcp 2.3.0 call_tool never consults the listing.

On a copy of the tickets server with that filter written as a list_tools override and the test as the post now has it, under mcp 2.3.0, the test passes and the direct call with tickets:read goes through. Enumerating the registry instead, MCPServer.list_tools(mcp) past the override, the same loop fails on export_tickets because no ToolError is raised, and passes again once require_scope is back. The registry needs no scope list, so the test keeps testing the rule and not the scopes that existed when it was written.

Thread Thread
 
sampop3 profile image
sam ros •

You’re right, Edward. Listing as an all-scopes caller only moved the blind spot: the test now covers the scopes ALL_SCOPES held when it was written, so a tool behind a scope added later can be hidden by the filter and skipped, while call_tool still runs it because it never looks at the listing. Enumerating the registry directly, MCPServer.list_tools(mcp) past the override, needs no scope list, so it keeps testing the rule instead of a snapshot of the scopes. That’s the version I’d keep. Thanks for running it against a real server again.