Enforce Deny tool rules for external MCP clients
What does this MR do and why?
Split out of !252615 (closed), which was closed as too large. This is part 2 of 2 and carries the actual behaviour change. Part 1 is !253145 (merged), which this MR targets.
Tool rules set to Always Deny were enforced for Duo Agent Platform
workflows but not for external MCP clients. POST /api/v4/mcp authenticated and
then executed, with nothing consulting ai_tool_rules in between — so an
administrator who denied a tool in the AI Governance UI still saw Claude Code or
Cursor run it, including manage_pipeline, the one governable tool that can
delete a resource.
The check now happens on the server, where it holds regardless of how a client
is configured. Behind duo_mcp_external_governance, off by default.
How the verdict is decided
ResolutionService#permission_for answers for one tool by its bare name.
execute already answered a related question, but emitted prefixed spellings
(gitlab_get_work_item_notes) that a caller holding get_work_item_notes
cannot match.
Ai::ToolRules::McpGovernance takes the containers resolved from the call's
arguments (part 1), asks each for its verdict, and returns the most restrictive.
A call spanning two projects is denied if either denies it, so ordering the
arguments cannot skip a Deny.
Three decisions worth flagging to a reviewer:
- The flag is checked per resolved container, not once up front.
Feature.enabled?with no actor returnsfalsefor an actor-gated flag, so an early return would switch governance off for exactly the groups it was enabled for. - The verdict is looked up under the tool's canonical name. Aliases are not
settable in the UI, so without resolving them a rule could be routed around by
spelling the tool differently —
get_job_loginstead ofget_job. - A call that resolves no container is allowed. There is no namespace, so there are no rules to apply. Refusing every unscoped call would break tools that legitimately take no project.
The error class and the enforcement seam live in CE because Grape freezes
namespace handlers before prepend_mod runs, and CE cannot name an ee/
constant. CE's hook is a no-op.
A tool is served under one name and governed under another
Nine of the served tools carry their rule under a different name. The server
serves add_commit; the rule an administrator sets in the governance UI is
create_commit. GovernedMcpTools already computed that mapping — by
intersecting each tool's declared tool_aliases with the Duo catalog in
Registry.all_tool_names — but nothing consulted it when resolving a verdict.
This was caught in manual testing, not by the specs. A Deny set in the UI simply
did not apply, with nothing to indicate it: permission_for read the served
name, found no rule, and fell back to the tool's annotation-derived default.
Measured on a live instance before the fix, a deny on get_work_item_notes
left both spellings returning HTTP 200.
| Served as | Rule is stored under |
|---|---|
add_commit (destroy) |
create_commit |
add_branch |
create_branch |
save_merge_request |
create_merge_request, update_merge_request |
save_work_item |
create_work_item, update_work_item |
save_note |
create_merge_request_note, create_work_item_note |
get_workitem_notes |
get_work_item_notes |
get_merge_request_notes |
list_all_merge_request_notes |
get_merge_request_diffs |
list_merge_request_diffs |
list_merge_requests |
gitlab_merge_request_search |
That is most of the write surface, so Deny was unenforced exactly where it matters most.
Resolution now consults every name a tool answers to and takes the most
restrictive verdict, so either spelling reaches the rule. Alias resolution alone
could not have fixed this: resolve_alias maps an alias to the served name,
while the rule lives under the alias — it points away from the rule.
governed_tool_identity_spec walks all 12 mappings and asserts a Deny on the
governing name denies both spellings, so a tool added later cannot reopen the
hole silently.
What is governed
41 tools are served by the MCP server. 22 carry a rule under their own name — the rest are either
unlisted, in Registry::UNGOVERNED_TOOLS, or resolve to a Duo Agent Platform
catalog name and are governed under that name instead.
By action category, from each tool's own annotations:
| Category | Count | Examples |
|---|---|---|
| Read | 15 | get_pipeline, search_labels |
| Write | 4 | attach_scan_profile, fork_repository, link_work_items, save_merge_request_review |
| Delete | 3 | manage_pipeline, accept_merge_request, save_pipeline |
How to reproduce the issue
Verified on GDK.
Setup
-
Create a PAT with the
mcpscope (apialone is not sufficient). -
Enable the flags:
Feature.enable(:duo_mcp_tool_governance) # registry Feature.enable(:duo_mcp_external_governance) # this MR -
The AI Governance page needs a top-level group with Duo features on. On self-managed the group Duo settings page is SaaS-gated, so set it directly:
Group.find_by_full_path('your-group').namespace_settings.update!(duo_features_enabled: true) -
Open Group → Settings → GitLab Duo → Governance → Tool management and set
get_work_item_typesto Always Deny.
Reproduce on master
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/call",
"params":{"name":"get_work_item_types","arguments":{"group_id":"your-group"}}}'Observed wrong result — the denied tool runs and returns its payload:
{"jsonrpc":"2.0","result":{"content":[{"type":"text","text":"{\"workItemTypes\":[…]}"}],
"isError":false},"id":1}How to test the fix
Same request on this branch. Pass a scope argument — a call naming no project or group resolves no namespace and is served ungoverned by design.
{"jsonrpc":"2.0","error":{"code":-32002,"message":"Tool denied",
"data":{"tool":"get_work_item_types"}},"id":1}with HTTP 403. Set the tool back to Allow and the same call returns its result.
Note the Flipper cache: the running Puma process can take up to a minute to see a flag change made from the console.
Cases worth checking, each of which was a bug found in review
| Case | How to check | Expected |
|---|---|---|
| REST-shaped tool | Deny manage_pipeline, call with {"id":"group/project","pipeline_id":"1","name":"delete"} |
-32002 |
| Alias | Deny get_job, call it as get_job_log |
-32002 — an alias is not settable in the UI, so it must resolve to the canonical rule |
| Catalog name | Deny create_commit, call add_commit |
-32002 — the UI name and the wire name differ for 9 tools |
| Project overrides group | Allow on the group, Deny on one project, call naming that project | -32002 |
| List spanning namespaces | Deny on the second of two namespaces in project_ids |
-32002, regardless of order |
| Wrong-type Global ID | Pass gid://gitlab/Group/1 as a project_id |
ungoverned, not resolved as project 1 |
| Flag off | Feature.disable(:duo_mcp_external_governance) |
call executes as before |
| Ask | Set a tool to Always Ask | executes — only Deny stops a call on this path |
Run the tests
bundle exec rspec spec/services/mcp spec/lib/api/mcp \
ee/spec/lib/ai/tool_rules ee/spec/services/ai/tool_rules \
ee/spec/lib/ee/api/mcp ee/spec/requests/api/mcpQA
No end-to-end QA test added. This is an API-level policy decision with no UI
surface of its own — ee/spec/requests/api/mcp/base_spec.rb drives the full
stack through POST /api/v4/mcp and asserts the JSON-RPC error contract and
status code, which is the boundary an E2E test would cover. The governance UI
that sets these rules is unchanged.
Known limits
- Only Deny stops a call. Ask has no prompt to raise on this path, so it executes. Documented.
- A call must name a project or group.
get_mcp_server_versiontakes no arguments and cannot be governed; it is declared ungovernable explicitly. tools/listis not filtered. That is !252995 (merged).- A call naming several containers answers to all of them. Most restrictive wins, so a Deny cannot be skipped by argument ordering.
- Namespace resolution is not user-scoped. A caller can name a project they cannot access; governance resolves that namespace and may answer 403 where another error would otherwise surface. Not a bypass — the tool still enforces its own authorization — but it is a weak signal that a rule exists there.
Open questions for review
fork_repositoryis governed by the source project (id), not the fork target (namespace_id). For a write, the target may matter more.
Follow-up: tool_call_namespace now resolves less than governance does
ee/lib/ee/api/mcp/handlers/call_tool.rb still has tool_call_namespace, which
reads only project_id / group_id and returns a single root ancestor. It
feeds analytics attribution and expanded logging.
Governance deliberately stopped using it, because the two want different things:
tool_call_namespace |
governance | |
|---|---|---|
| Reads | project_id / group_id |
whatever argument the tool declares |
| Returns | root ancestor only | the container, so project rules apply |
| Returns | one | all of them, for tools taking lists |
| Cost of a miss | an analytics label | the enforcement boundary |
So logging and governance can now disagree about which namespace a call belongs
to: for the nine tools spelling their scope argument something other than
project_id, tool_call_namespace resolves nothing and the log line carries no
namespace, while governance resolves it correctly. Rewriting it on top of
GovernanceNamespaceResolver would fix attribution too, but it changes logging
behaviour and is not needed for enforcement.
Deviation from the issue
The implementation plan
(https://gitlab.com/gitlab-org/gitlab/-/issues/612377) says to reuse
tool_call_namespace(params) "rather than duplicating namespace resolution".
This MR does not, for the reason above: that helper misses nine of the
twenty-one governable tools, and a miss there is a silently ungoverned call
rather than a missing log field.
https://gitlab.com/gitlab-org/gitlab/-/issues/623329 specifies
check_tool_rule!(tool_name, current_user). It ended up as
check_tool_rule!(tool, tool_name, params): the EE override needs the tool in
order to ask it for its scope arguments, and nothing uses current_user.
References
- https://gitlab.com/gitlab-org/gitlab/-/issues/623329
- Part 1, namespace resolution: !253145 (merged)
- Closed original: !252615 (closed)
- https://gitlab.com/groups/gitlab-org/-/epics/21113