Add list_releases MCP server tool
What does this MR do and why?
This MR adds a new MCP server tool called list_releases. It lists a project's releases. The most recently released release comes first. The tool lives in app/services/mcp/tools/releases/list_releases_service.rb as Mcp::Tools::Releases::ListReleasesService. This class extends Base::CustomService. It is registered in app/services/mcp/tools/manager.rb under CUSTOM_TOOLS. The tool is marked readOnlyHint: true.
Callers identify the project with url or project_id. Exactly one of them is required. Paging uses page and per_page. The state parameter filters by release state. It accepts released, upcoming and all. The default is released. Each entry returns tag_name, name, released_at, upcoming and assets. assets holds the release asset links, each with a name and a url. Release notes and source archives are not returned. Notes belong to a future get_ tool. Source archives can be derived from the tag. The response also has a metadata object with page, per_page and has_more.
The tool reads through ReleasesFinder instead of wrapping the existing GET /projects/:id/releases REST endpoint. That endpoint's entity returns the full release payload, including description_html, commit, author, evidences, milestones, source archives, and _links, which is far more than an agent needs and would waste context. Going through the finder directly lets the tool return metadata only and also accept the url identifier, which the REST route does not support.
Authorization requires :read_release on the project. A project the caller cannot read and a project that does not exist both return the identical error message, so the tool can't be used to probe for the existence of private projects.
Docs are updated in doc/user/model_context_protocol/mcp_server_tools.md.
How to test locally
-
Check out the branch and restart Rails. The tool registry is memoized, so a running server will not advertise a newly added tool.
gdk restart rails-web -
Create a project with a couple of releases and a personal access token with the
mcpscope. This prints the token and the project path.bundle exec rails runner ' u = User.find_by_username("root") p = Project.find_by_full_path("#{u.namespace.full_path}/mcp-releases-demo") || Projects::CreateService.new(u, name: "mcp-releases-demo", namespace_id: u.namespace.id, initialize_with_readme: true).execute if p.releases.none? 3.times { |i| r = p.releases.create!(tag: "v#{i + 1}.0", name: "Release #{i + 1}.0", description: "notes #{i + 1}", author: u, released_at: (3 - i).days.ago) r.links.create!(name: "Binary", url: "https://example.com/v#{i + 1}.0/binary") if i == 2 } end t = u.personal_access_tokens.create!(name: "mcp-test-#{SecureRandom.hex(4)}", scopes: [:mcp], expires_at: 2.days.from_now, organization: u.organizations.first) raw = "glpat-" + SecureRandom.hex(20); t.set_token(raw); t.save! puts "TOKEN=#{raw}"; puts "PROJECT=#{p.full_path}" ' -
Confirm the tool is advertised. Look for
list_releasesin the response.curl -s -X POST http://gdk.test:3000/api/v4/mcp -H "Authorization: Bearer <TOKEN>" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq '.result.tools[] | select(.name=="list_releases")' -
Call the tool. Expect three releases newest first,
v3.0carrying aBinaryasset link, andmetadata.has_morefalse.curl -s -X POST http://gdk.test:3000/api/v4/mcp -H "Authorization: Bearer <TOKEN>" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_releases","arguments":{"project_id":"<PROJECT>"}}}' | jq -r '.result.structuredContent' -
Check pagination. Expect two releases and
has_moretrue.curl -s -X POST http://gdk.test:3000/api/v4/mcp -H "Authorization: Bearer <TOKEN>" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_releases","arguments":{"project_id":"<PROJECT>","per_page":2}}}' | jq -r '.result.structuredContent.metadata' -
Check the
statefilter. A project whose releases are all in the past returns nothing forupcoming.curl -s -X POST http://gdk.test:3000/api/v4/mcp -H "Authorization: Bearer <TOKEN>" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_releases","arguments":{"project_id":"<PROJECT>","state":"upcoming"}}}' | jq -c '.result.structuredContent.releases' -
Optionally, check the error paths: omitting both identifiers, or supplying both, returns "Provide exactly one of: url or project_id"; a
per_pageabove 100 is rejected by the schema; an unknown argument is rejected.
Substitute <TOKEN> and <PROJECT> in the above with the values printed by step 2.
If you'd rather skip the manual steps, run the specs:
bundle exec rspec spec/services/mcp/tools/releases/list_releases_service_spec.rb spec/requests/api/mcp/handlers/call_tool_spec.rb spec/requests/api/mcp/handlers/list_tools_spec.rb ee/spec/requests/api/mcp/handlers/list_tools_spec.rbQueries
This MR adds two scopes to app/models/release.rb, Release.released and Release.upcoming.
There is no migration and no new index. Both queries below use production values, from the plans
@terrichu ran in session 55350.
Full query for state: released, which is the default, page 1, per_page 20:
SELECT "releases".*
FROM "releases"
WHERE "releases"."project_id" = 278964
AND NOT (("releases"."tag" = '' OR "releases"."tag" IS NULL))
AND "releases"."released_at" <= '2026-08-25 19:04:52.368731'
ORDER BY "releases"."released_at" DESC
LIMIT 20Execution plan: https://postgres.ai/console/gitlab/gitlab-production-main/sessions/55350/commands/158990
Full query for state: upcoming:
SELECT "releases".*
FROM "releases"
WHERE "releases"."project_id" = 278964
AND NOT (("releases"."tag" = '' OR "releases"."tag" IS NULL))
AND "releases"."released_at" > '2026-08-25 19:04:52.368731'
ORDER BY "releases"."released_at" DESC
LIMIT 20Execution plan: https://postgres.ai/console/gitlab/gitlab-production-main/sessions/55350/commands/158991
state: all runs the same query without the released_at predicate, which is what the tool did
before this change.
Both queries filter on project_id and released_at, and sort by released_at. The existing
index index_releases_on_project_id_and_released_at_and_id already covers
(project_id, released_at, id).
Review question: does adding the released_at predicate change which index the planner chooses
for the existing sort? The two scopes select very different amounts of data. released matches
almost every row, and upcoming matches almost none.
References
- Closes #618494 (closed)