Extend tool governance to GitLab MCP server tools

What does this MR do and why?

Tools served by GitLab's own MCP server are invisible to ai_tool_rules. Governance emits bare catalog names (get_work_item_notes), while Workhorse advertises MCP tools as gitlab_<tool> (mcp.go:291), so the two halves of the tool_access_policies claim never intersect.

An admin Deny therefore never reaches an MCP tool, Ask cannot be set on one at all, Allow cannot pre-approve a write or destructive one, and none of them appear in the governance UI.

This is not a single-tool edge case. On a live instance, 20 capabilities are served over both transports: 11 exact-name twins (get_issue, create_issue, get_merge_request, get_work_item, get_commit, get_repository_file, list_work_items, list_repository_tree, list_commits, create_merge_request_note, get_merge_request_conflicts) plus 9 served under a different name.

Behind duo_mcp_tool_governance, disabled by default.

Behaviour changes

Flag OFF: governance changes nothing

Surface Before After
tool_access_policies claim governance names + prefixed MCP baseline identical
Governance UI 93 rows, all gitlab source identical
ai_tool_rules validation catalog names only identical
agent_privileges unchanged unchanged
MCP server config PreApprovedTools 30 entries 33 entries

GovernedMcpTools.for returns an empty set, and every helper early-returns on it, so the new code is not reached. The 50 pre-existing resolution examples are pinned to this state.

The last row is the one thing that does move, and it comes from the alias declarations rather than from governance. McpConfigService already pre-approves the aliases of a read-only tool, so declaring a catalog name on one adds that name to the server config. The three additions are get_work_item_notes, list_all_merge_request_notes and list_merge_request_diffs. They reach no further: gitlab_claim_preapproved_tools strips aliases, so the JWT claim is byte-identical, and tools/list still advertises canonical names only, so an agent has no reason to call them. Each one also names a tool that was already pre-approved under its canonical spelling, so no access widens.

Flag ON, no rule configured

Surface Before After
Claim allow list baseline entries identical count, identical byte size
Claim ask / deny [] []
Governance UI 93 rows 114 rows (+21 MCP tools)

A capability served under a different name gets no row of its own. save_merge_request, save_work_item, add_branch, add_commit and list_merge_requests are governed by the rule on their catalog name, so the admin sets one rule rather than hunting for the MCP spelling.

Flag ON, with rules

Rule Before After
Deny get_work_item_notes (spelling twin) DAP blocked, MCP transport still ran both blocked
Deny get_merge_request (exact twin) DAP blocked, MCP transport still ran both blocked
Deny create_merge_request (served as save_merge_request) DAP blocked, MCP transport still ran both blocked
Deny search (MCP-only) rule could not be saved tool removed from the toolset
Ask search rule could not be saved approval card
Allow manage_pipeline (destructive) always prompted pre-approved; RUN_MCP_TOOLS privilege still not granted
Allow create_merge_request_note (write twin) DAP allowed, MCP still prompted both allowed

Deliberately unchanged

agent_privileges, resolve_group's unanimity quorum, the run_mcp_tools group (stays empty and reserved for external MCP servers), the Duo Workflow Service, the GraphQL schema, and the frontend.

How it works

Each served tool's action type comes from the readOnlyHint / destructiveHint annotations it already declares, so a newly served tool is governed with no follow-up change, and the answer stays correct per instance. semantic_code_search is served on GitLab.com but its EE route is not mounted on a plain GDK.

destructiveHint is checked before readOnlyHint, so a tool declaring both resolves to the restrictive category, matching the fallback used when a tool declares neither. No served tool declares both today.

Which catalog capability a served tool answers for comes from the catalog names it declares as tool_aliases, read back through Mcp::Tools::Manager#alias_map. A tool declaring none stands as its own identity, which is what keeps the 11 tools served under a catalog name reachable by the rule on that name. Nothing here is hand-maintained, so a tool added later is governed the day it lands.

Three decisions keep the blast radius small:

MCP names stay out of PRIVILEGE_GROUP_MAPPING. governed_tool_names_for intersects with that mapping, so an MCP rule can neither swing a group verdict nor count towards its quorum. No new AgentPrivileges integer, no DWS change. This also answers the open question carried by the previous revision and by the external-client governance issue: manage_pipeline does not need to reinterpret run_mcp_tools.

Governance filters the annotation-derived list rather than replacing it. With no rule configured nothing is added or withdrawn, so parity is structural rather than asserted.

The capability map is the MCP server's own, not a second copy. Tools declare their catalog name through the existing tool_aliases mechanism, so the mapping lives next to the tool and a new tool carries it in the same merge request that adds the tool.

How to set up and validate locally

Governance is a no-op unless the Duo Workflow Service actually enforces the claim. Set DUO_WORKFLOW_AUTH__ENABLED=true in gitlab-ai-gateway/.env, run gdk restart duo-workflow-service, and wait for JWKS refreshed. The GDK default of false hides both the bug and the fix.

# rails console
g = Group.find_by_full_path('<group>')
g.ai_settings.update!(duo_workflow_mcp_enabled: true)
Feature.enable(:mcp_client, User.find_by_username('root'))
Feature.disable(:duo_mcp_tool_governance, g)     # start with the shipped default

Claim inspector used below:

# /tmp/claim.rb
g = Group.find_by_full_path('<group>'); u = User.find_by_username('root')
Gitlab::SafeRequestStore.ensure_request_store do
  r = Ai::ToolRules::ResolutionService.new(namespace: g, surface: :web).execute.payload
  b = Ai::DuoWorkflows::McpConfigService.new(u, 't', workflow_definition: 'chat').preapproved_tool_names
  a = ((r[:pre_approved_tools] + b) - r[:ask_tools] - r[:denied_tools]).uniq
  puts "flag  : #{Feature.enabled?(:duo_mcp_tool_governance, g)}"
  puts "allow : #{a.size}"
  puts "ask   : #{r[:ask_tools].sort}"
  puts "deny  : #{r[:denied_tools].sort}"
end

Start a new chat after any flag or rule change: the claim is minted once at session start, so an open session keeps the old one. Hard reload the governance page, because Apollo caches the tool list.

Rules are set at Settings > GitLab Duo > Governance > Tool management. The chat prompt used throughout is:

Use the gitlab_search tool to search for "test" in this group
# Set up Then Expected
1 Flag off, no rules Run the inspector Note the allow count. ask: [], deny: []
2 Flag off Governance UI 93 rows, no mcp source, no plain search row
3 Flag off Try to save a rule on search Rejected with search is not a known tool name. No admin control exists today
4 Flag off, Deny get_work_item_notes Inspector deny: ["get_work_item_notes"] only, and gitlab_get_workitem_notes still in allow. This is the bug
5 Flag on, no rules Inspector Allow count identical to step 1
6 Flag on Governance UI, hard reload 114 rows; search is Read/mcp/Always allow, manage_pipeline is Destroy/mcp/Always ask
7 Flag on, Deny get_work_item_notes Inspector deny: ["get_work_item_notes", "gitlab_get_workitem_notes"], MCP name gone from allow
8 Flag on, no rules New chat, run the prompt Tool runs, no approval card
9 Flag on, Deny search New chat, run the prompt Tool is not offered
10 Flag on, Ask search New chat, run the prompt Approval card
11 Flag on, Allow manage_pipeline Inspector gitlab_manage_pipeline pre-approved; pre_approved_agent_privileges still excludes RUN_MCP_TOOLS (6)
12 Keep the search Deny, flag off Inspector, UI, new chat Row survives in the database, claim drops it, UI hides it, tool runs again
13 Flag on again Inspector, new chat Rule active again with no further action
14 Flag on, Deny list_issues (native read) Inspector Catalog read tools collapse to ask, MCP read tools keep their pre-approval
15 Group Allow search, project Deny search Inspector with project: Project wins
16 Group Deny search, project Allow search Save Rejected: cannot be less restrictive
17 Feature.disable(:mcp_client) (global, not per-actor) Inspector, UI, new chat Baseline and allow drop to 0, no exception, page still renders

Read steps 8 through 10 together: the same prompt runs, prompts, or is refused purely from the rule set on search.

Use search rather than get_work_item_notes when judging a chat session by eye. The denied-tool list is injected into the model's prompt, so the model refuses tools whose names merely resemble a denied one, which makes the twin cases ambiguous to read. search has no catalog twin.

Test coverage

Registry and catalog, resolution, model, resolver, both mutations, the claim-minting request spec, and McpConfigService, 838 examples locally. Three drift guards run against the live Mcp::Tools::Manager: no served catalog tool also declares a catalog name as a tool_alias, every served tool declares annotations, and no gitlab_-prefixed emission can collide with an existing catalog name such as gitlab_api_get.

Notes for reviewers

  • GovernedMcpTools is not Ai::Catalog::McpTool. That model backs the agent builder picker and cannot carry governance: FixedItemsModel#fixed_items takes no arguments, so it cannot be scoped to a namespace or gated on a flag, and it exposes no annotations. Both discover tools from Mcp::Tools::Manager, which costs a second route sweep on the /ws path, measured at 1.3 ms. Small enough to leave alone.
  • MCP rows append after the catalog rows rather than interleaving. The existing list is already in definition order rather than sorted, and the table has a search filter and a Source column, so re-sorting the whole list under a flag looked like the worse trade.
  • An MCP rule is inert but not editable while the flag is off. Re-enabling restores editing. Weakening the validator instead would let stale names persist with the flag off.
  • Path B gets deny and ask, not allow. Appending the MCP baseline in WorkflowContextGenerationService would newly pre-approve MCP tools on a path that never had them.
  • get_job is deliberately not aliased to get_job_logs. It returns job metadata as well as the trace, so it is the broader capability. Aliasing would let a Deny on get_job_logs block plain job reads.

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Edited by Rahul Barnwal

Merge request reports

Loading
Loading