Withhold denied MCP tools from the header and server config
What does this MR do and why?
Rails makes three statements about each GitLab MCP server tool, and only one of them was governance-aware.
| What Rails says | Who receives it | Governed before? |
|---|---|---|
tool_access_policies JWT claim |
Duo Workflow Service | Yes |
x-gitlab-enabled-mcp-server-tools gRPC header |
Duo Workflow Service | No |
McpServers.gitlab (PreApprovedTools, and Tools for catalog agents) |
Workhorse | No |
Duo Chat drops gitlab_issue_search, gitlab_blob_search and gitlab_merge_request_search whenever
that header names search, because MCP search supersedes them. So denying search removed the tool
through the claim while the header kept advertising it, and the three narrower tools were dropped with
nothing left in their place. The session ended up with no issue search and no code search at all.
This subtracts the denied set from all three statements, so one deny rule reaches every one of them. Enforcement already held through the claim's allow list and the service's own deny stripping, so this closes an inconsistency rather than a bypass.
Measured on a local GDK
| Scenario | Header search |
Header gitlab_search |
Native search tools |
|---|---|---|---|
| No rule | present | present | superseded |
search = Always Deny, before |
present | present | gone, nothing replaces them |
search = Always Deny, after |
absent | absent | restored |
search = Always Ask |
present | present | superseded |
Step by step: how a chat session gets its tools, and where this broke
Four components are involved. Rails knows the policy, Workhorse fetches and serves the tools, and the Duo Workflow Service decides which tools the agent actually gets.
1. Client opens a WebSocket. Workhorse pre-authorizes with Rails.
2. Rails returns two separate things. Service.Headers is for the Duo Workflow Service and carries
the JWT claim plus the enabled-tools header. McpServers is for Workhorse and carries an address, an
auth token, and a list of names.
3. Workhorse fetches the tool catalog. It calls tools/list on Rails' own MCP endpoint, then
renames every tool to <server>_<tool>, so search becomes gitlab_search. This is a separate
request from step 2, for the reason in the next section.
4 and 5. Workhorse opens the gRPC stream to the service and appends its tool list to
StartWorkflowRequest.
At this point the service holds three things: the real tool list (with descriptions and schemas), the header (names only), and the JWT claim (the rules).
6 and 7. The service picks its native tools, then reads the header. The header has exactly one job here:
# duo_workflow_service/workflows/chat/workflow.py:847
enabled_mcp_tools = get_enabled_mcp_server_tools()
if "search" in enabled_mcp_tools:
read_only_tools = [t for t in read_only_tools if t not in _SEARCH_TOOLS]8 and 9. The service assembles the toolset and strips anything the claim denies.
With no rule configured
| Tool | Outcome |
|---|---|
gitlab_search |
present, built in step 3 |
| the three native search tools | dropped by the swap in step 7 |
One tool replaced three. Correct.
With search set to Always Deny, before this MR
Only step 2 and step 8 changed. The claim became deny: ["gitlab_search"], but the header was byte
for byte identical, so step 7 still ran the swap.
| Tool | Outcome |
|---|---|
gitlab_search |
removed by the claim in step 8, correct |
gitlab_issue_search |
removed by the header in step 7, wrong |
gitlab_blob_search |
removed by the header in step 7, wrong |
gitlab_merge_request_search |
removed by the header in step 7, wrong |
Step 8 removed the tool that should go. Step 7 also removed the three that were its replacement, because step 7 had no way to know the replacement was gone.
After this MR
The header no longer names search, so step 7 does not run the swap, and step 8 still removes
gitlab_search through the claim. The three narrower tools come back.
Worth noting: this state was unreachable before
!247822 (merged), because a rule on search could not be
saved at all. "No search" was a new state that governance coverage introduced as a side effect.
Why Workhorse fetches the tool catalog separately from `McpServers`
A reasonable question when reading this diff is why Workhorse goes back to Rails in step 3 when Rails
already returned McpServers.gitlab in step 2. It is because that config is not a tool list.
// workhorse/internal/api/api.go
type McpServerConfig struct {
URL string
Headers map[string]string
Tools *[]string // names only
PreApprovedTools *[]string // names only
Trusted bool
}There are no tool definitions in it. No descriptions, no argument schemas. But the service needs those to let a model call anything:
// workhorse/internal/ai_assist/duoworkflow/mcp.go:294
mcpTool := &pb.McpTool{
Name: prefixedName,
Description: tool.Description, // not in the config
InputSchema: string(schemaBytes), // not in the config
Trusted: proto.Bool(s.cfg.Trusted),
}So McpServers.gitlab is an address, a token, and a short list of names. tools/list is the catalog.
The second reason is that McpServers is generic. It also carries Orbit, AI Catalog MCP servers, and
third party ones such as context7, whose tools Rails cannot know. Workhorse is an MCP client, and the
protocol says a client discovers tools through tools/list. GitLab's own server happens to be Rails,
but the path is the same one any external server takes.
This matters for the diff because the three statements in the table above go to two different consumers. Filtering only the header would have left Workhorse still pre-approving a tool the claim denies.
The two name traps, and why `withheld_server_tools` has two guards
Trap 1: gitlab_search is one string produced two different ways. It is a declared tool_alias of
search (app/services/mcp/tools/search/search_service.rb:26) and it is also the Workhorse prefixed
spelling of search. So the header carries both search and gitlab_search, and removing only one
leaves the other. Aliases are expanded through Manager#aliases_for, which is the same helper
ee/lib/ai/tool_rules/governed_mcp_tools.rb:53 already uses.
Trap 2: the claim and the header spell MCP tools differently. The claim says gitlab_search, the
header says search, so the prefix has to come off. But gitlab_ also prefixes catalog names, and
stripping it from a denied catalog tool could match an unrelated served tool. Measured on a local GDK:
twelve catalog names start with gitlab_, and none of their stripped forms is a served MCP tool today.
Hence two narrowings, each for a different reason:
| Guard | What it prevents |
|---|---|
start_with?(MCP_SERVER_TOOL_PREFIX) |
With duo_mcp_tool_governance off, governance emits bare catalog names. Without this check they would match served tools and this change would not be inert in that state. |
& all_mcp_tools |
all_mcp_tools is canonical names only, so a stripped name that matches an alias is never withheld. The drift guard at ee/spec/lib/ai/tool_rules/registry_spec.rb:419 covers the canonical direction only. |
Each guard has its own spec, and removing either one fails exactly that spec and nothing else.
Does this affect tools other than `search`?
The filter applies to every denied MCP tool, but the only behaviour that changes is for search.
Measured with a mixed deny list:
deny : ["gitlab_search", "run_command", "gitlab_get_merge_request"]
canonical : ["search", "get_merge_request"] # run_command is not an MCP tool, dropped
withheld : ["search", "get_merge_request", "gitlab_search"]
header 47 -> 44| Tool | Leaves the header? | Effect on the agent |
|---|---|---|
search |
yes | yes, the swap stops and three tools return |
get_merge_request |
yes | none |
| any other MCP tool | yes | none |
duo_workflow_service/workflows/chat/workflow.py:848 is the only consumer of this header in that
repo, and it matches the literal string search. No other name is read.
The server config filter has no effect on the final toolset either. When governance is active the service replaces client pre-approvals with the claim's allow list, which already has the denied set subtracted, and strips denied tools from the toolset. A denied tool was unusable before and is unusable now. What changes is that Rails stops saying two contradictory things about it.
Three things are deliberately unchanged, each with its own spec:
ask verdicts |
nothing leaves the header. The tool is still served, so it must keep superseding the narrower tools. |
duo_mcp_tool_governance off |
fully inert, which is what the prefix check is for. No new feature flag is introduced. |
non-MCP tools such as run_command and read_file |
untouched. |
Notes for reviewers
gitlab_enabled_tools(denied:)is required whileexecute(denied: [])defaults. Omitting it on the header silently removes three unrelated tools, which is this bug. Omitting it on the server config only leaves Workhorse pre-approving a tool the claim already denies and the service strips, so the asymmetry matches the consequence. Makingexecuterequired would also have meant editing 81 unrelated spec call sites.gitlab_mcp_serveris memoized ondenied.mcp_tools_for_agentis already memoized and subtracting the withheld set allocates a new array, so without this the existingmemoizes mcp_tools_for_agentexample, which asserts object identity across twoexecutecalls, fails. Keying ondeniedis covered by its own example.- Out of scope: rules configured on the three narrower tools still have no effect while MCP
searchis available, including an explicit Always Allow that the settings page shows as allowed. That predates this change and is documented as a known issue.
Verification
| Check | Result |
|---|---|
mcp_config_service_spec + connection_config_service_spec |
155 examples, 0 failures |
workflows_spec, MCP governance context |
6 examples, 0 failures |
| RuboCop, 5 files | 0 offenses |
| Vale, changed doc | 0 errors |
Mutation testing, each guard reverted one at a time:
| Reverted | Specs that failed |
|---|---|
| the header subtraction | the deny specs, 2 service and 1 request |
& all_mcp_tools |
only the alias collision spec |
start_with? guard |
only the bare catalog names spec |
PreApprovedTools and Tools filtering |
only the two new #execute specs |
Every guard has exactly one falsifying spec, and no spec is unfalsifiable.
References
- Resolves https://gitlab.com/gitlab-org/gitlab/-/work_items/627109 (confidential, visible to GitLab team members)
- Made reachable by !247822 (merged), which first allowed a rule on an MCP tool
- Same failure shape fixed in this file by !257167 (merged)
Screenshots or screen recordings
Not applicable. There is no UI change. The user-visible effect is which tools an agent can call, and the documentation change is text only.
How to set up and validate locally
-
Enable the feature flags
mcp_clientandduo_mcp_tool_governance. -
In the group, go to Settings > GitLab Duo > Governance > Tool management and set
searchto Always Deny. -
Check the Rails side. This prints what goes into the
x-gitlab-enabled-mcp-server-toolsheader, deriving the deny list the same wayConnectionConfigServicedoes.user = User.find_by_username('root') group = Group.find_by_full_path('your-group') svc = Ai::DuoWorkflows::McpConfigService.new(user, 'token', workflow_definition: 'chat', namespace: group) denied = Ai::ToolRules::GovernedMcpTools.for(group).spellings_for('search') denied # => ["gitlab_search"] svc.gitlab_enabled_tools(denied: denied).grep(/search/)searchgitlab_searchBefore present present After absent absent The header carries MCP server tool names only, so the three narrower native tools never appear here either way. They are restored on the Duo Workflow Service side once the header stops naming
search, which the next two steps check. -
Open a new Duo Chat session. An existing session keeps the toolset it already fetched.
-
Prompt one:
List every tool you have whose name contains "search"Result Before Lists gitlab_semantic_search,gitlab_search_labelsandgitlab_documentation_search. Notes thatgitlab_searchis blocked by an administrator.After Also lists gitlab_blob_searchandgitlab_issue_search. Still notes thatgitlab_searchis blocked.The model's enumeration is not exhaustive. Across runs it omitted different tools, including ones this MR does not touch. A missing name is weak evidence, so rely on prompt two.
-
Prompt two. This is the decisive check. It needs an issue in the group whose title contains the word "governance".
Search the issues for the word governance, and tell me exactly which tool you usedResult Before Finds nothing and falls back to list_work_items.After Answers that it used gitlab_issue_searchand returns the matching issue.
The catalog MCP server name collision this MR also closes has no manual steps. Creating a catalog
MCP server requires organization owner, so it is covered by specs in
ee/spec/services/ai/duo_workflows/mcp_config_service_spec.rb.
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.