Add list_merge_requests MCP server tool
What does this MR do and why?
Adds list_merge_requests, a GraphQL-backed MCP server tool that lists or searches merge
requests in a project, filtered by author, assignee, reviewer, state, milestone, labels, or free
text, with cursor pagination. It is the collection reader that pairs with the single-object
get_merge_request. A scope filter resolves "me" to the authenticated user server-side, so an
agent can answer "my open merge requests here" without knowing the caller's username.
gitlab_merge_request_search is registered as a tool_aliases entry.
Design decisions
- GraphQL, not REST. The issue's example sketched a REST wrapper (
page/per_page), and the MCP pagination guidelines listedlist_merge_requestsunder REST-backed tools. This MR implements it against the GraphQLmergeRequestsconnection for stable cursor pagination (first/after, returningpageInfo { endCursor hasNextPage }) and native relational filters, executed ascurrent_userso permissions are enforced byGitlabSchema. The pagination guidelines doc is updated to move the tool into the GraphQL-backed list. Needs ratification. scopeis emulated. The connection has noscopeargument, socreated_by_me,assigned_to_meandreview_requestedmap onto the username filters usingcurrent_user. Precedence is per field:scope: assigned_to_mewithauthor_username: alicereturns merge requests authored by alice and assigned to you, rather than one filter discarding the other.- No
countin the output. The issue's output schema asks only for ahas_moresignal, whichpageInfo.hasNextPageprovides.Types::CountableConnectionType#countwith nolimitruns an exactCOUNT(*)on every call. - Exactly-one-of is enforced in Ruby, not JSON Schema. A
oneOfwould express it declaratively, butSchemaDefaults.with_additional_propertiesskips applyingadditionalProperties: falsewhen a composition key is present, and hand-setting it is against convention. Validating inresolve_projectkeeps the strictness guard and also rejects the "both supplied" case. The rule is stated once in the tool description, where the model reads it while choosing a tool, rather than repeated in each property. labelsis a comma-separated string.BaseLabelforbids commas in label titles (format: { with: /\A[^,]+\z/ }), so splitting is lossless, and the string form matches the tool being replaced.- Compact output. Each node returns
iid,title,state,author.username, source/target branches, andwebUrl, pluspageInfo. Theidselections are required by the@graphql-eslint/require-selectionslint rule, not payload padding.
Considered and deliberately cut
Each of these was built and then removed, to keep the tool to what the issue specifies. An MCP
input schema is versioned (_index.md:358), so a parameter is far cheaper to add later than to
remove — removal is a breaking change needing a new version plus multi-version support.
- Group scope (
group_id). The issue's schema is project-only, its implementation plan says wrapGET /api/v4/projects/{id}/merge_requests, and it states "Follow-ups: none". The shippedgitlab_merge_request_searchis also project-only — its input derives fromProjectResourceInput. (The catalog entry anddoc/user/duo_agent_platform/agents/tools.mddescribe it as "project or group", which is inaccurate.) There is a real argument for group scope — the issue's motivating examples, "my open MRs" and "MRs awaiting my review", are not project-scoped questions — but that is a capability worth proposing on its own merits rather than bundling here. Filed as #606934. include_subgroups. Only meaningful with group scope. Also a bad thing to expose: the GraphQL resolver defaults it tofalse, so the wrong choice returns an empty list with no error, andissuable_collections.rb:68-69shows the product answer is alwaystrue. Covered in the follow-up.author_id/assignee_id/reviewer_id. Added for schema compatibility with the tool being aliased, then cut. The connection filters people by username only, so these existed purely to be converted back into usernames — and an agent cannot know a numeric user ID without an extra lookup. Nothing in the codebase constructs them for merge request search. Because both tools are currently listed to agents (see below), the Python tool remains the path for any ID-based caller.scope: all. A no-op identical to omittingscope; it cost the model a decision and bought nothing.
Scope of the alias, and what it does and does not protect
gitlab_merge_request_search has never been advertised by the GitLab MCP server — it appears only
in the Duo Agent Platform catalogs (ee/lib/ai/catalog/built_in_tool_definitions.rb) and in
duo_workflow_service. So unlike the gitlab_search → search rename, no MCP client can have
the old name cached from our tools/list. The alias is forward-looking: it covers a future
migration where Duo agents are repointed at the MCP server while still carrying the Python tool's
descriptions. Called with the old name today it resolves and works (verified below).
Related: ai-assist has no mechanism to drop the Python tool when an equivalent MCP tool exists.
supersedes only swaps Python tools by client capability, and denied_tools is a runtime
governance policy. So until gitlab_merge_request_search is removed from the workflow tool lists
in ai-assist, agents will see both. That removal is tracked separately by @terrichu.
Compared with the tool it replaces
Every field on ListMergeRequestInput / ListMergeRequest
(name = "gitlab_merge_request_search", backed by MERGE_REQUESTS_API_PATH),
mapped onto this tool:
| Old field | Old definition | This tool | Notes |
|---|---|---|---|
url |
Optional[str] |
url |
Project URLs |
project_id |
Optional[Union[int, str]] |
project_id |
Numeric ID or full path, as a string — matches all 10 existing MCP tools |
author_username |
Optional[str] |
author_username |
|
assignee_username |
Optional[str] |
assignee_username |
|
reviewer_username |
Optional[str] |
reviewer_username |
|
state |
opened|closed|locked|merged|all |
state |
Same five values, now an enum |
milestone |
Optional[str] |
milestone |
Kept this name rather than milestone_title |
labels |
Optional[str], comma-separated |
labels |
Same form; split server-side |
search |
Optional[str] |
search |
Title and description |
scope |
created_by_me|assigned_to_me|all |
scope |
Keeps the first two, adds review_requested, drops all (a no-op) |
author_id |
Optional[int] |
not supported | The connection filters by username only. See "Considered and deliberately cut". |
assignee_id |
Optional[int] |
not supported | as above |
reviewer_id |
Optional[int] |
not supported | as above |
updated_after |
In optional_params but never declared on the input model |
not supported | Unreachable in the old tool too |
page / per_page |
Not exposed by the old tool | first / after |
Cursor pagination |
Callers of the old tool are the chat, software_development and issue_to_merge_request workflows. All of them keep working: the old tool is still registered, and the alias covers the new name.
Verified against a running GDK
Real POST /api/v4/mcp round trips with a PAT scoped to mcp — not just a spec run.
1. Advertised by tools/list:
params (12): after, assignee_username, author_username, first, labels, milestone,
project_id, reviewer_username, scope, search, state, url
size: 2008 chars (~502 tokens)
additionalProperties: false readOnlyHint: true2. A real call returns merge requests, with pageInfo and no count:
isError=false keys=["pageInfo", "nodes"]
!13 [merged] https://gdk.test:3443/toolbox/gitlab-smoke-tests/-/merge_requests/13
!12 [merged] https://gdk.test:3443/toolbox/gitlab-smoke-tests/-/merge_requests/123. All three identification paths work, including the alias:
project_id (numeric) isError=false 3 node(s)
project_id (full path) isError=false 2 node(s)
url isError=false 2 node(s)
gitlab_merge_request_search (alias) isError=false 2 node(s)4. Filters work, and validation rejects what it should:
scope=created_by_me isError=false state=merged isError=false 8 node(s)
labels "bug,urgent" isError=false milestone isError=false
no identifier isError=true Provide exactly one of: url or project_id
both identifiers isError=true Provide exactly one of: url or project_id
group_id isError=true group_id is invalid
author_id isError=true author_id is invalid
scope: all isError=true Invalid scope: 'all'. Must be one of: created_by_me, ...Known gap: the search rate limiter does not apply
SearchArguments#validate_search_rate_limit! returns early when context[:request] is nil, and
Base::GraphqlTool#execution_context sets only current_user and is_sessionless_user. So the
search argument here is not throttled the way it is over /api/graphql. Threading the request
into the execution context is a base-class change affecting all GraphQL MCP tools, so it is
deliberately left out and needs its own issue.
References
- Issue: #605879 (closed)
- Epic: &22781 (Merge Request MCP tools)
- Follow-up for group scope: #606934
- Follow-up for not-found handling in the base class: #606924
- Replaces the shipped
gitlab_merge_request_search(ListMergeRequestinduo_workflow_service/tools/merge_request.py), kept working throughtool_aliases. - Follows the tool-proposal process reviewed by the interim
mcp-tool-review-board.
Screenshots or screen recordings
No UI changes.
How to set up and validate locally
-
using mcp inspector manually
npx -y @modelcontextprotocol/inspector -- env NODE_TLS_REJECT_UNAUTHORIZED=0 mise x -- npx -y mcp-remote https://gdk.test:3443/api/v4/mcp --debug -
using a PAT (created through rails console)
token = User.find_by_username('root').personal_access_tokens.create!( name: 'mcp-verify', scopes: [:api, :mcp], expires_at: 7.days.from_now) token.set_token('someknownvalue'); token.save! # token.revoke! when done -
confirm the tool is advertised:
curl -sk -X POST https://gdk.test:3443/api/v4/mcp \ -H "Authorization: Bearer <PAT>" -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \ | jq '.result.tools[] | select(.name=="list_merge_requests")' -
Call it, and confirm
pageInfois present andcountis absent:curl -sk -X POST https://gdk.test:3443/api/v4/mcp \ -H "Authorization: Bearer <PAT>" -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_merge_requests","arguments":{"project_id":"<project>","first":3}}}' \ | jq '.result.structuredContent | {keys: keys, pageInfo}' -
Confirm the alias resolves, so the old name keeps working:
curl -sk -X POST https://gdk.test:3443/api/v4/mcp \ -H "Authorization: Bearer <PAT>" -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"gitlab_merge_request_search","arguments":{"project_id":"<project>","state":"opened"}}}' \ | jq '.result.isError, (.result.structuredContent.nodes | length)' -
Confirm ambiguous input is rejected rather than silently resolved one way:
curl -sk -X POST https://gdk.test:3443/api/v4/mcp \ -H "Authorization: Bearer <PAT>" -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_merge_requests","arguments":{"project_id":"<project>","url":"<project url>"}}}' \ | jq -r '.result.content[0].text'
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.