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

  1. Governance surface and per-tool lookupResolutionService#permission_for answers for one tool by its bare name. execute already answers a related question, but emits prefixed spellings (gitlab_get_work_item_notes) that a caller holding get_work_item_notes cannot match.
  2. 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.
  3. Enforcementtools/call rejects a denied tool before it runs, with JSON-RPC error -32002 and 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

  1. Create a PAT with the mcp scope (api is not sufficient on its own).

  2. Enable the flags:

    Feature.enable(:duo_mcp_tool_governance)       # registry, from the parent MR
    Feature.enable(:duo_mcp_external_governance)   # this MR
  3. 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)
  4. 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/tools

Automated 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_version takes no arguments and so cannot be governed. It is declared ungovernable and the guardrail spec allows it explicitly.
  • tools/list is 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_ids lists 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

  1. fork_repository is 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.

References

Edited by Raounak Sharma

Merge request reports

Loading
Loading