Govern external MCP tool calls with ai_tool_rules
What does this MR do and why?
Governs the tools GitLab's own MCP server serves when an external client
(Claude Code, Cursor, …) calls them directly over POST /api/v4/mcp.
Today that endpoint authenticates and then executes. Nothing consults
ai_tool_rules in between, so a tool set to Always Deny in the AI
Governance UI still runs. This adds the check on the server, where it holds
regardless of how a client is configured.
Behind duo_mcp_external_governance, disabled by default.
Commits
- Governance surface and per-tool lookup —
ResolutionService#permission_foranswers for one tool by its bare name.executealready answers a related question, but emits prefixed spellings (gitlab_get_work_item_notes) that a caller holdingget_work_item_notescannot match. - Tools resolve their own namespace — nine of twenty-one governable tools
spell their project/group argument something other than
project_id, so each tool now declares it. Includes a spec that fails the build when a governable tool declares nothing or declares an argument it does not accept. - Enforcement —
tools/callrejects a denied tool before it runs, with JSON-RPC error-32002and HTTP 403.
What is governed
41 tools are served by the MCP server. 21 of them are governable — the rest are
either unlisted, in Registry::UNGOVERNED_TOOLS, or resolve to a Duo Agent
Platform catalog name that is governed under that name instead.
By action category, derived from each tool's own annotations:
| Category | Count | Examples |
|---|---|---|
| Read | 14 | 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 |
Tools do not agree on how they name a project or group
This is the reason for Mcp::Tools::Concerns::GovernanceNamespaceResolver.
Enforcement has to find the namespace in the call's arguments, and there is no
single argument name to look for:
| Declared arguments | Tools | Example |
|---|---|---|
project_id / group_id |
10 | get_work_item_types |
id (REST convention) |
8 | manage_pipeline, get_job |
project_ids / group_ids (lists of Global IDs) |
1 | attach_scan_profile |
full_path (project or group) |
1 | search_labels |
| none | 1 | get_mcp_server_version |
A central list of argument names would have to assert that id always means a
project, which holds today and is a guess about every tool added later.
Resolving the wrong namespace is worse than resolving none, because another
group's rules would then apply. So each tool declares its own, and
ee/spec/lib/ai/tool_rules/governable_tools_namespace_spec.rb fails the build
if a governable tool declares nothing or declares an argument it does not
accept.
get_mcp_server_version is the one tool that cannot be governed: a version
string is not a namespaced resource, so it is declared ungovernable and the
guardrail spec allows it explicitly.
How to reproduce the issue
Verified on GDK. Everything below is reproducible on a local instance.
Setup
-
Create a PAT with the
mcpscope (apiis not sufficient on its own). -
Enable the flags:
Feature.enable(:duo_mcp_tool_governance) # registry, from the parent MR Feature.enable(:duo_mcp_external_governance) # this MR -
The AI Governance page requires a top-level group with Duo features on. On self-managed the group Duo settings pages are 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 a tool to Always Deny.
Reproduce on master
On master, with a tool set to Always Deny, the call still succeeds:
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
On this branch, the same request is refused before the tool executes:
Use a tool that accepts a scope argument, and pass one — a call that names no project or group resolves no namespace and is served ungoverned by design.
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"}}}'Denied:
{"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.
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 |
| 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 |
Note the Flipper cache: the running Puma process can take up to a minute to see a flag change made from the console.
Run the tests
bundle exec rspec \
ee/spec/lib/ai/tool_rules ee/spec/services/ai/tool_rules \
ee/spec/lib/ee/api/mcp ee/spec/requests/api/mcp \
spec/lib/api/mcp spec/requests/api/mcp spec/services/mcp/toolsAutomated coverage
1847 examples, 0 failures across
ee/spec/lib/ai/tool_rules, ee/spec/services/ai/tool_rules,
ee/spec/lib/ee/api/mcp, ee/spec/requests/api/mcp, spec/lib/api/mcp,
spec/requests/api/mcp and spec/services/mcp/tools.
Beyond the per-case specs, governable_tools_namespace_spec walks every
governable tool and asserts it declares a namespace argument that exists in its
own input schema — so a tool added later cannot be served ungoverned without
failing the build.
QA
No end-to-end QA test added. The behaviour is an API-level policy decision with
no UI surface of its own: the request specs in
ee/spec/requests/api/mcp/base_spec.rb exercise the full stack through
POST /api/v4/mcp and assert the JSON-RPC error contract and status code, which
is the same boundary an E2E test would cover. The governance UI that sets these
rules is unchanged by this MR.
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 so cannot be governed. It is declared ungovernable and the guardrail spec allows it explicitly. tools/listis not filtered. That is https://gitlab.com/gitlab-org/gitlab/-/issues/623330. Note it carries no arguments at all, so it has no namespace source — worth settling before that issue is picked up.- A call naming several containers answers to all of them. Tools taking
project_ids/group_idslists resolve every entry and take the most restrictive verdict, 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 |
The consequence is that logging and governance can now disagree about which
namespace a call belongs to: for the nine tools that spell their scope argument
something other than project_id, tool_call_namespace still resolves nothing
and the log line carries no namespace, while governance resolves it correctly.
tool_call_namespace could be rewritten on top of
Mcp::Tools::Concerns::GovernanceNamespaceResolver, which would fix attribution
for those tools too. Left out of this MR because 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 the
existing tool_call_namespace(params) helper ... rather than duplicating
namespace resolution". This MR does not, for the reasons in the section 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 to
ask it for its scope arguments, and nothing uses current_user.