Let MCP tools declare which argument names their namespace
What does this MR do and why?
Split out of !252615 (closed), which was closed as too large. This is part 1 of 2 and adds no behaviour of its own.
Governing the GitLab MCP server means knowing which project or group a
tools/call acts on, and that has to be read out of the call's arguments. The
tools do not agree on what that argument is called. Of the 22 tools that carry a rule under their own name:
| Declared arguments | Tools | Example |
|---|---|---|
project_id / group_id |
11 | get_work_item_types |
id (REST convention) |
8 | manage_pipeline, get_job |
project_ids / group_ids (lists of Global IDs) |
1 | attach_scan_profile |
full_path (project or group) |
1 | search_labels |
| none | 1 | get_mcp_server_version |
A single central list of argument names would have to assert that id always
means a project. That is true of every tool today and is a guess about every
tool added after. Resolving the wrong container is worse than resolving none,
because then another namespace's rules decide the call. So each tool declares
its own argument, and Mcp::Tools::Concerns::GovernanceNamespaceResolver turns
that declaration into containers.
get_mcp_server_version is the one tool that cannot be governed — a version
string is not a namespaced resource — so it is declared ungovernable explicitly
rather than by omission.
Global IDs carry a class as well as a number. Projects and groups share a number
space, so the class is checked before the number is used: a
gid://gitlab/Group/1 passed to a project argument resolves to nothing, not to
project 1.
governable_tools_namespace_spec walks every governable tool and fails the
build if one declares no namespace argument, or declares one that is not in its
own input schema. Without it, a tool added later is served ungoverned silently,
and only in production.
Safe to merge on its own
Nothing calls resolve_governance_containers in this MR — the only caller
arrives in part 2. The concern is inert:
- No production code path reaches it.
- None of the added method names (
namespace_arguments,resolve_governance_containers) exist anywhere onmaster, so nothing is shadowed or overridden. Gitlab::ResourceLookupis already included by these classes elsewhere, so including it again is a no-op.
Verified by running the whole MCP and tool-rules suite — including the enforcement specs that are not part of this MR — against a branch containing only these 12 files: 1734 examples, 0 failures, 4 pending.
How to reproduce the issue
There is no user-visible defect to reproduce here; this MR is the substrate the enforcement in part 2 stands on. What can be observed today is the inconsistency it addresses:
-
Check out
master. -
In
rails console, list how the governable tools spell their scope argument:::Mcp::Tools::Registry.new.tools.each do |name, tool| puts "#{name}: #{tool.class.input_schema[:properties].keys.join(', ')}" end -
Observe that there is no argument name common to all of them —
manage_pipelineandget_jobuseid,search_labelsusesfull_path,attach_scan_profileusesproject_ids/group_ids. Any caller wanting the namespace of a call has nothing reliable to key on.
How to test the fix
-
Check out this branch.
-
In
rails console, ask a REST-shaped tool for its containers:tool = ::Mcp::Tools::Registry.new.tools['get_job'] tool.send(:resolve_governance_containers, { id: 'your-group/your-project' }) # => [#<Project id: …>] -
Check that a wrong-typed Global ID resolves nothing rather than the wrong record:
tool.send(:resolve_governance_containers, { id: 'gid://gitlab/Group/1' }) # => [] -
Confirm no behaviour changed — every MCP tool still answers as before:
bundle exec rspec spec/services/mcp spec/lib/api/mcp \ ee/spec/lib/ai/tool_rules ee/spec/services/ai/tool_rules \ ee/spec/lib/ee/api/mcp ee/spec/requests/api/mcp
Test coverage added
spec/services/mcp/tools/concerns/governance_namespace_resolver_spec.rb— each argument shape, Global ID class matching, blank and missing values.ee/spec/lib/ai/tool_rules/governable_tools_namespace_spec.rb— the build-failing guardrail over every governable tool.spec/services/mcp/tools/base/api_tool_spec.rb— deriving the container from the route path.
Database
lookup_projects / lookup_groups replace a find_by(id:) per identifier with
one id_in. Same index, same filters, same scope — one statement instead of N,
not a new access pattern.
SELECT projects.* FROM projects
WHERE projects.pending_delete = FALSE AND projects.hidden = FALSE
AND projects.id IN (1, 2, 1000000)Index Scan using projects_pkey on projects (cost=0.14..2.43 rows=3 width=1448)
Index Cond: (id = ANY ('{1,2,1000000}'::bigint[]))
Filter: ((NOT pending_delete) AND (NOT hidden))Full-path identifiers deliberately keep the per-value lookup: find_by_full_path
follows redirects after a rename, which a batched WHERE full_path IN (...)
would not. That is bounded because the only unbounded arguments
(attach_scan_profile's project_ids / group_ids) are declared as Global IDs;
the code comment records the assumption so a future list argument accepting paths
does not silently reintroduce the fan-out.
References
- https://gitlab.com/gitlab-org/gitlab/-/issues/623328
- Part 2, enforcement: !253147
- Closed original: !252615 (closed)
- https://gitlab.com/groups/gitlab-org/-/epics/21113