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 defaultClaim 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}"
endStart 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
GovernedMcpToolsis notAi::Catalog::McpTool. That model backs the agent builder picker and cannot carry governance:FixedItemsModel#fixed_itemstakes no arguments, so it cannot be scoped to a namespace or gated on a flag, and it exposes no annotations. Both discover tools fromMcp::Tools::Manager, which costs a second route sweep on the/wspath, 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
WorkflowContextGenerationServicewould newly pre-approve MCP tools on a path that never had them. get_jobis deliberately not aliased toget_job_logs. It returns job metadata as well as the trace, so it is the broader capability. Aliasing would let a Deny onget_job_logsblock plain job reads.
Related
- Feature: https://gitlab.com/gitlab-org/gitlab/-/work_items/606073
- Rollout: https://gitlab.com/gitlab-org/gitlab/-/work_items/607499
- Claim ceiling this works within: https://gitlab.com/gitlab-org/gitlab/-/issues/604720
- External-client governance, out of scope here: https://gitlab.com/gitlab-org/gitlab/-/work_items/612377
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.