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 while execute(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. Making execute required would also have meant editing 81 unrelated spec call sites.
  • gitlab_mcp_server is memoized on denied. mcp_tools_for_agent is already memoized and subtracting the withheld set allocates a new array, so without this the existing memoizes mcp_tools_for_agent example, which asserts object identity across two execute calls, fails. Keying on denied is covered by its own example.
  • Out of scope: rules configured on the three narrower tools still have no effect while MCP search is 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

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

  1. Enable the feature flags mcp_client and duo_mcp_tool_governance.

  2. In the group, go to Settings > GitLab Duo > Governance > Tool management and set search to Always Deny.

  3. Check the Rails side. This prints what goes into the x-gitlab-enabled-mcp-server-tools header, deriving the deny list the same way ConnectionConfigService does.

    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/)
    search gitlab_search
    Before 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.

  4. Open a new Duo Chat session. An existing session keeps the toolset it already fetched.

  5. Prompt one:

    List every tool you have whose name contains "search"
    Result
    Before Lists gitlab_semantic_search, gitlab_search_labels and gitlab_documentation_search. Notes that gitlab_search is blocked by an administrator.
    After Also lists gitlab_blob_search and gitlab_issue_search. Still notes that gitlab_search is 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.

  6. 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 used
    Result
    Before Finds nothing and falls back to list_work_items.
    After Answers that it used gitlab_issue_search and 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.

Edited by Rahul Barnwal

Merge request reports

Loading
Loading