Declare the container for every MCP tool
What does this MR do and why?
This is the first of two merge requests split from !253147 (closed), which enforces Deny tool rules on the GitLab MCP server. See that MR for the full picture. This one changes how tools declare their containers and fixes the places where governance read a different container than the tool did.
Caller behaviour is unchanged for normal calls. One difference is visible. The attach_scan_profile description now says that project_ids and group_ids together can name at most 100 ids. Governance refuses more than 100 ids only after !258209 (merged) wires governed_containers into tools/call.
The MCP tool base classes already carried a class method namespace_arguments. It mapped a container kind to the argument that carries it, it supported project and group, and it was read for one purpose: percent-decoding path arguments before Grape sees them. This MR renames it to container_arguments and widens it.
Declaring containers
-
Two more kinds.
project_or_groupcovers an argument such asurlthat can resolve to either.recordcovers an argument that names something else, carrying its own lookup as a lambda:container_arguments record: { vulnerability_id: ->(id) { ::Vulnerability.find_by_id(id)&.project } } -
A default map,
{ project: :project_id, group: :group_id, project_or_group: :url }, which 28 of the 56 tools inherit unchanged. -
Every tool declares its arguments with the class macro
container_arguments project: :x, which merges with the default map. No tool overridesself.container_argumentsany more. -
ungovernable!for the 3 tools that have no project or group at all. -
One method,
governed_containers(arguments), on every tool. It reads the declared map and resolves the arguments toProjectandGrouprecords. It also counts how many containers the call named, so an identifier that resolves to nothing can be told apart from no identifier at all. Only two tools override it:link_work_items, which resolves work item GIDs, andattach_scan_profile, which applies the combined id cap and then callssuper.
Route-backed API tools
An API tool declares extra container arguments on its route:
route_setting :mcp, container_arguments: { record: { ... } }fork_repository declares namespace_id and namespace_path there, using the same Namespace.without_project_namespaces lookups the endpoint uses. create_issue gets epic_id from an EE helper. Helpers::IssuesHelpers.create_issue_mcp_container_arguments returns an empty hash in CE, and EE::API::Helpers::IssuesHelpers extends it with the epic lookup. ApiTool has no tool-name maps and there is no EE ApiTool module any more.
Governance fixes
- A record lookup may return a project, a group or a namespace. A personal namespace is not counted, because no tool rules apply to it. A lookup that returns nil still counts, so a call that names something that does not exist is refused instead of going ungoverned.
- URL arguments are governed by every project that the merge request reference pattern, the commit reference pattern and the generic path parser find in the url. Before, governance read one project and
get_commitcould read another, which allowed a decoy url to hide the real project. start_duo_sessionno longer governsflow_item_idby the project that published the catalog flow. The session is governed by the project it runs in (project_idorurl) and by the project or group ofai_catalog_item_consumer_id.attach_scan_profileuses the mutation'sMAX_IDS(100) instead of its own constant. Governance refuses more than 100 ids acrossproject_idsandgroup_idstogether, before it resolves any id.
Guard spec
A guard spec fails CI when a governable tool has an input property that is neither classified under some kind nor listed as an argument that never points at a project or group. That list is now kept per tool. The old global list had about 151 names.
Building the per-tool list found two arguments that were never governed. add_commit's start_project is now declared as a project argument. save_vulnerability's vulnerability_id is now a record argument resolved to the vulnerability's project, like get_vulnerability. A second example fails when a list entry names an argument the tool does not accept or already declares, so the list cannot go stale.
Tool counts
How the 56 tools break down:
| How the container is found | Tools | Examples |
|---|---|---|
| Inherits the default map | 28 | get_project, list_work_items, save_note, search |
| Declares its own map, because the argument has another name | 7 | get_pipeline and get_job use id, search_labels uses full_path, add_commit adds start_project |
Derived from the route's boundary_type |
7 | list_branches, get_issue, get_merge_request_diffs |
Declares a record: lookup |
6 | get_vulnerability via vulnerability_id, save_pipeline via pipeline_id, start_duo_session via ai_catalog_item_consumer_id |
Declares a map with record: inside it |
1 | save_vulnerability |
Route derived, plus a record: declared on the route |
2 | fork_repository, create_issue |
Overrides governed_containers |
2 | link_work_items, attach_scan_profile |
ungovernable! |
3 | get_mcp_server_version, get_user, send_duo_session_input |
doc/development/duo_agent_platform/mcp/_index.md and doc/development/duo_agent_platform/mcp/graphql_integration.md are updated to match.
How to reproduce the issue
This MR is mostly a refactor plus fixes to how governance resolves containers. The problems show up on the first commit of this MR, a079ae36, before the review fixes.
-
Check out a079ae36.
-
In a Rails console, call
get_commitgovernance with a url that holds a merge request url for project A followed by a commit url for project B:Mcp::Tools::Commits::GetCommitService.new(name: 'get_commit').governed_containers( url: "<base>/<projA>/-/merge_requests/1 <base>/<projB>/-/commit/<sha>" ) -
The result names only project A.
get_commitreads project B, so a Deny rule on project B never sees the call. -
Call
governed_containersonMcp::Tools::Manager.new.list_tools['fork_repository']with theidof a project and anamespace_idthat does not exist. -
The result has
named: 1, so the missing fork target is dropped and the call is treated as ungoverned for that argument.
How to test the fix
-
Check out this branch.
-
Repeat the
get_commitcall above. The result now holds both project A and project B. -
Repeat the
fork_repositorycall with the missingnamespace_id. The result now hasnamed: 2with only the project resolved, so !258209 (merged) refuses the call instead of serving it ungoverned. -
Run the specs:
bin/rspec spec/services/mcp ee/spec/services/mcp ee/spec/lib/ai/tool_rules spec/requests/api/mcp ee/spec/requests/api/mcpLocally this gives 2470 examples, 0 failures and 4 pending.
Manual check of the attach_scan_profile cap
Setup: GDK with an Ultimate license and the MCP server enabled. Create a root group with a security scan profile, 60 projects and 60 subgroups under it. Create a personal access token with the mcp scope. Send a JSON-RPC tools/call for attach_scan_profile to POST /api/v4/mcp, first with 60 project GIDs and 60 group GIDs, then with 50 and 50.
This branch (623329-mcp-container-declaration), where nothing calls governed_containers on tools/call yet:
| Call | Expected | Actual |
|---|---|---|
| 60 projects + 60 groups | The mutation rejects it | isError: true, "Too many ids (maximum: 100)" |
| 50 projects + 50 groups | Succeeds | isError: false, {"errors":[]} |
tools/list |
Description states the shared limit | "project_ids and group_ids together can name at most 100 ids.", maxItems 100 |
!258209 (merged) rebased on top of this branch:
| Call | Expected | Actual |
|---|---|---|
| 60 projects + 60 groups | Governance refuses it before the mutation runs | HTTP 400, JSON-RPC -32602 Invalid params: "project_ids and group_ids together cannot name more than 100 ids" |
| 50 projects + 50 groups | Succeeds | isError: false, {"errors":[]} |
References
- https://gitlab.com/gitlab-org/gitlab/-/issues/623329
- Parent MR: !253147 (closed)
- Follow-up MR: !258209 (merged)
MR acceptance checklist
This MR does not change any user-facing behaviour apart from the attach_scan_profile description text, so it is covered by unit and request specs plus the manual check above.