Filter denied tools and annotate Ask tools in the MCP tools/list response
What does this MR do and why?
Two changes to tools/list for external MCP clients, both behind duo_mcp_external_governance:
- a tool resolving to Always Deny is left out of the advertised toolset
- a tool resolving to Always Ask carries
_meta["anthropic/requiresUserInteraction"], which Claude Code reads to raise its own permission prompt even in a session that would auto-approve
Neither is an enforcement boundary. A client can call an unlisted tool directly, and Deny at call time refuses it — that check ships in !252615 (closed), which this is stacked on.
Where the namespace comes from
tools/call resolves rules against the project or group named in the call's arguments. tools/list carries no arguments at all — the whole request is {"method": "tools/list"} — so there is nothing to resolve from.
Rules here resolve against User#governing_namespace instead. That fallback is deliberately not used for tools/call, where a call naming project A must not be governed by group B's rules. For a list request there is no target at all, so "which namespace governs this user's AI usage" is the right question rather than the wrong one.
A caller with no default Duo namespace gets an unfiltered list. Discussed on https://gitlab.com/gitlab-org/gitlab/-/issues/623330.
How it is wired
lib/api/mcp/handlers/list_tools.rb had no prepend_mod, so the EE hook is added here. CE exposes one seam:
# Returns the entry to advertise, or nil to omit the tool.
def governed_tool_data(tool_data, _tool_name, _current_user)
tool_data
endBoth behaviours are one EE decision on the returned entry, so CE never references Ai::ToolRules::Permissions — which it could not, being under ee/.
How to reproduce the issue
Setup is the same as !252615 (closed): a PAT with the mcp scope, duo_mcp_tool_governance and duo_mcp_external_governance enabled, and a top-level group with duo_features_enabled. The calling user
also needs a default GitLab Duo namespace, because a list request names no
project or group of its own.
Set a tool to Always Deny and another to Always Ask in Group → Settings → GitLab Duo → Governance, then:
curl -sS -X POST http://gdk.test:3000/api/v4/mcp \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'Observed wrong result on the target branch: the denied tool is still advertised and the Ask tool carries no annotation, so a client offers both without distinction and only discovers the refusal when it calls.
How to test the fix
On this branch the same request returns a filtered, annotated list.
Verified on GDK:
| Rule | Tool | Result |
|---|---|---|
| Deny | get_work_item_types |
absent from the response |
| Ask | get_job |
present, _meta: {"anthropic/requiresUserInteraction": true} |
| Allow | get_commit |
present, no _meta |
| flag off | — | 41 tools, 0 annotated — identical to today |
Note the list only filters if the caller has a default Duo namespace; with none, every tool is advertised.
Run the tests
bundle exec rspec \
ee/spec/lib/ee/api/mcp ee/spec/requests/api/mcp \
spec/lib/api/mcp spec/requests/api/mcpAutomated coverage
ee/spec/lib/ee/api/mcp/handlers/list_tools_spec.rb — 6 examples covering deny, ask, allow, flag-off, no-namespace, and a rule written for another surface.
Tested at the handler rather than through a request spec on purpose: a request spec re-loads the user server-side, so stubbing governing_namespace on the spec's own object does nothing, and making the fallback resolve for real needs Duo add-on purchases because duo_default_namespace filters against duo_default_namespace_candidates. The handler spec covers the same logic without that fixture weight, and matches how call_tool_spec is laid out.
Full run across the MCP and tool-rules specs: 1853 examples, 0 failures.
QA
No end-to-end QA test. The change alters a JSON-RPC response shape with no UI of its own, and the governance UI that sets these rules is untouched.
Known limits
- Ask is advisory. Only Claude Code 2.1.199 and later honour the annotation. Older versions, Cursor, Windsurf and other clients ignore it, so Ask behaves as Allow there. Deny is unaffected everywhere because the server refuses the call. Documented with a per-client table.
- No default Duo namespace means no filtering, as above.
- Listing is not enforcement. An unlisted tool is callable and refused at call time.