Enforce Deny tool rules on the GitLab MCP server

What does this MR do and why?

This is the second of two merge requests split from !253147 (closed), which enforces Deny tool rules on the GitLab MCP server. See that MR for the full picture. The first of the two, !258207 (merged), has merged.

!258207 (merged) resolves the containers a call names. This MR reads those containers and enforces the Deny rules stored in ai_tool_rules.

Ai::ToolRules::McpGovernance takes the containers a call resolved to and returns one verdict. The most restrictive verdict across them wins, so a Deny on any one container refuses the call. A rule on a group applies to the projects underneath it.

  • tools/call refuses a denied tool before the tool runs, with a JSON-RPC error. The error names the denying container only when the caller can read it. A caller who cannot read it gets the refusal without the name, so a Deny cannot be used to discover that a private project exists.
  • tools/list hides denied tools.
  • tools/list annotates only tools an admin explicitly set to Always Ask. Tools on Default are unannotated, so external clients behave as before until someone sets a rule.
  • Enforcement at call time does not depend on tools/list. A client that skips the listing and calls a denied tool directly is still refused.
  • Tools that replaced other tools govern each other. A Deny on get_issue also denies get_work_item, and tool aliases resolve to the same catalog identity.

This sits behind the feature flag duo_mcp_server_governance_rule_enforcement (gitlab_com_derisk), off by default, with the container's root ancestor as the actor. Rules are read from the local access column, so enforcement also needs duo_workflow_local_tool_governance on for the same root ancestor. Rollout: https://gitlab.com/gitlab-org/gitlab/-/work_items/632906.

Governance and tools resolve the same container

Governance resolves a call's containers before the tool runs. The tool then finds its container with its own code. If the two disagreed, a call could run without its Deny rule.

I audited every MCP tool for this and found four disagreements:

  • Governance decoded a URL before parsing it, so a URL with %20 named nothing to governance but a project to the tool.
  • Governance matched merge request and commit URLs only at the start of the value. The tools match anywhere in the string.
  • get_commit and get_merge_request_notes passed the raw path to GraphQL, which also finds hidden projects that governance's lookup skips.
  • save_merge_request looked up target_project_id with an unscoped Project.find_by_id.

This MR fixes all four:

  • Governance parses the raw URL and decodes only the extracted path. It matches merge request and commit URLs anywhere.
  • get_merge_request, get_commit and get_merge_request_notes resolve their project with find_project! and pass its canonical path.
  • search_labels, list_vulnerabilities, add_commit (start_project) and attach_scan_profile resolve through the same lookups governance uses.
  • save_merge_request governs target_project_id with the same Project.find_by_id the merge request build service uses.

The resolver specs show that each audited URL input now resolves the same project the tool reads. They fail on the old resolver.

Governance and the tool still use separate code and look the container up twice. Two follow-ups cover that:

Feature flags

Enforcement needs two flags on for the root group:

duo_mcp_tool_governance (default on) must also stay on, because it powers alias and replacement-tool matching.

Governance does nothing when either of the first two flags is off for every actor.

How to reproduce the issue

On master, an MCP client can call a tool that a Deny rule blocks.

  1. In GDK with an Ultimate license, open a top-level group and select AI > Governance.
  2. Set get_issue to Always Deny for local access. MCP clients use that access type. Save the changes.
  3. Create a personal access token with the mcp scope.
  4. Send a JSON-RPC tools/call for get_issue to POST /api/v4/mcp with the project_id of a project in that group.
  5. The tool runs and returns the issue.

How to test the fix

  1. Check out this branch and enable both flags for the group in the Rails console: group = Group.find_by_full_path('<group>') Feature.enable(:duo_mcp_server_governance_rule_enforcement, group) Feature.enable(:duo_workflow_local_tool_governance, group)
  2. Repeat the tools/call from step 4 above. The server refuses it with HTTP 403 and JSON-RPC error -32002 "Tool denied". data.denied_by names the project, because the call named the project.
  3. Call get_work_item for the same project. It is refused too. get_work_item replaced get_issue.
  4. Disable either flag and repeat the call. The tool runs as it does on master.
  5. Run the specs: bin/rspec ee/spec/lib/ai/tool_rules/mcp_governance_spec.rb ee/spec/lib/ee/api/mcp/handlers/call_tool_spec.rb ee/spec/lib/ee/api/mcp/handlers/list_tools_spec.rb spec/services/mcp/tools/concerns/governance_container_resolver_spec.rb

Database review

All plans were taken on gitlab-production-main in Database Lab with EXPLAIN ANALYZE. They use namespace 9970 (gitlab-org) and project 278964 (gitlab-org/gitlab). gitlab-org has no tool rules on production yet, so the rule plans return no rows. They show the index each query uses.

Ai::ToolRules::McpGovernance runs these queries only when governance applies to the call. It memoizes the results per root namespace, so each query runs at most once per root namespace per tools/call.

Rules for a call that names only projects

New scope Ai::ToolRule.for_namespace_and_projects. It loads the namespace rules plus the rules of the projects the call names.

SELECT "ai_tool_rules".* FROM "ai_tool_rules"
WHERE "ai_tool_rules"."namespace_id" = 9970
  AND ("ai_tool_rules"."project_id" = 278964 OR "ai_tool_rules"."project_id" IS NULL)
Plan (10.398 ms)
 Index Scan using idx_ai_tool_rules_ns_proj_tool_unique on public.ai_tool_rules  (cost=0.29..3.31 rows=1 width=101) (actual time=9.554..9.554 rows=0 loops=1)
   Index Cond: (ai_tool_rules.namespace_id = 9970)
   Filter: ((ai_tool_rules.project_id = 278964) OR (ai_tool_rules.project_id IS NULL))
   Buffers: shared hit=3 read=2
   I/O Timings: read=9.493 write=0.000

Rules for a call that names a group

Scope Ai::ToolRule.within_namespace. A group call needs the rules of every project under it, so this loads all rules stored under the root namespace.

SELECT "ai_tool_rules".* FROM "ai_tool_rules" WHERE "ai_tool_rules"."namespace_id" = 9970
Plan (0.618 ms)
 Index Scan using idx_ai_tool_rules_ns_proj_tool_unique on public.ai_tool_rules  (cost=0.29..3.30 rows=1 width=101) (actual time=0.012..0.013 rows=0 loops=1)
   Index Cond: (ai_tool_rules.namespace_id = 9970)
   Buffers: shared hit=5
   I/O Timings: read=0.000 write=0.000

Both rule queries use idx_ai_tool_rules_ns_proj_tool_unique on (namespace_id, project_id, tool_name), with an equality on the first column. Project rules are stored under their root namespace, so the rows are bounded by the number of rules one top-level group has set.

Projects that have rules

Ai::ToolRule.projects_for(rules) takes the project ids from the rules already loaded, so the earlier subquery is gone. It runs only for a group call that has project rules under it.

SELECT "projects".* FROM "projects" WHERE "projects"."id" IN (<PROJECT_IDS_FROM_LOADED_RULES>)
Plan with one id (7.243 ms)
 Index Scan using idx_projects_on_repository_storage_last_repository_updated_at on public.projects  (cost=0.56..3.58 rows=1 width=755) (actual time=3.564..3.566 rows=1 loops=1)
   Index Cond: (projects.id = 278964)
   Buffers: shared read=5
   I/O Timings: read=3.524 write=0.000

Namespaces of those projects

includes(:namespace) preloads each project's namespace. McpGovernance reads traversal_ids from it to keep only the projects under the named group.

SELECT "namespaces".* FROM "namespaces" WHERE "namespaces"."id" IN (<NAMESPACE_IDS_OF_THOSE_PROJECTS>)
Plan with one id (8.198 ms)
 Index Scan using namespaces_pkey on public.namespaces  (cost=0.57..3.58 rows=1 width=367) (actual time=6.021..6.023 rows=1 loops=1)
   Index Cond: (namespaces.id = 9970)
   Buffers: shared read=5
   I/O Timings: read=5.991 write=0.000

Both lookups are by primary key. They return at most one row per project that has a rule under the named group.

References

Edited by Raounak Sharma

Merge request reports

Loading
Loading