Add list_duo_agents_and_flows MCP tool
What does this MR do and why?
Adds the list_duo_agents_and_flows MCP tool: discovery for the Duo Agent Platform toolset.
Which agents and flows a project offers is configured per project, so an MCP client had no way to find the ai_catalog_item_consumer_id that start_duo_session needs without guessing at it.
The issue left open whether foundational flows need a second query; they do not. They are ordinary Ai::Catalog::Item FLOW records with a foundational_flow_reference, and CascadeSyncFoundationalFlowsWorker gives them real ItemConsumer rows, so one query covers both them and custom flows. The tool reads aiCatalogConfiguredItems for one project and returns its enabled agent, flow and external agent consumers, alongside the project's foundational chat agents from aiFoundationalChatAgents.
Startability is a permission question rather than a type question. Only a flow the user may execute can be started, and a flow whose pinned version is unreleased or still a draft cannot; agents and external agents never can. Ai::Catalog::ItemConsumerPolicy already answered all of that, so the MR exposes it as a new execute_ai_catalog_item field on AiCatalogItemConsumerPermissions and reads it here. Every entry therefore carries can_start_session, and only a startable one carries the ai_catalog_item_consumer_id; the rest have it set to null.
Worth a reviewer's attention
Foundational chat agents come from a second root field, and are first-page only. aiCatalogConfiguredItems can only surface them through synthesized consumer rows, which ItemConsumersFinder gates behind Gitlab.com_except_jh? — so that route works on GitLab.com and not on GitLab Self-Managed. The query now also selects aiFoundationalChatAgents, which FoundationalChatAgentsFinder answers on both, per organization on Self-Managed and per namespace on GitLab.com. includeFoundationalConsumers is gone, and any consumer row for a foundational agent is dropped, so each agent is listed once from one source.
They are a fixed list rather than part of the paginated project results, so they ride along with the first page only, and they are omitted unless agents are being asked for — both item_type: "flow" and item_type: "third_party_flow" drop them. They also carry a workflow_definition: two of them are both named "GitLab Duo", so without it the rows are indistinguishable. Rows configured in the project have workflow_definition set to null.
References
- Closes #617173 (closed)
- Part of &21395 (closed) and &23298 (closed)
- Feeds
start_duo_session(#607619 (closed)), whoseai_catalog_item_consumer_idparameter description now points at this tool. - Follow-up #630684 batches the pinned-version lookup that
execute_ai_catalog_itemreaches.
Screenshots or screen recordings
Backend only, no UI.
How to set up and validate locally
Ten steps, against a running GDK
- There's no checkbox for the
mcpscope in the UI, so make the token inrails console:
PersonalAccessToken.create!(
user: User.find_by_username('root'), name: 'mcp', scopes: [:mcp],
expires_at: 7.days.from_now,
organization: Organizations::Organization.default_organization
).token- Allow the MCP server for a top-level group you're a member of. On a GDK simulating SaaS the group setting is the authority, not the instance one:
API::Mcp::BaseconsultsAi::UserAuthorizable#any_group_with_mcp_server_enabled?, and being an instance admin doesn't count. Otherwise every call below answers403 MCP server not enabled for any of your groups.
Group.find_by_full_path('gitlab-duo').namespace_settings.update!(mcp_server_enabled: true)- Confirm the tool is listed, read-only, and asks for one required argument:
curl -s -X POST http://gdk.test:3000/api/v4/mcp \
-H "PRIVATE-TOKEN: $TOKEN" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}' \
| jq '.result.tools[] | select(.name=="list_duo_agents_and_flows")
| {annotations, required: .inputSchema.required}'What I got:
{"annotations": {"readOnlyHint": true}, "required": ["project_id"]}- Bad arguments first, the cheapest end-to-end check:
curl -s -X POST http://gdk.test:3000/api/v4/mcp \
-H "PRIVATE-TOKEN: $TOKEN" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"list_duo_agents_and_flows",
"arguments":{"project_id":"no/such-project"}}}' | jq '.result.content[0].text'What I got: "Tool execution failed: Project 'no/such-project' not found or inaccessible", and omitting project_id entirely gives "Validation error: project_id is missing".
- List what the project actually has.
gitlab:duo:setupseedsgitlab-duo/testwith the foundational flows:
curl -s -X POST http://gdk.test:3000/api/v4/mcp \
-H "PRIVATE-TOKEN: $TOKEN" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"list_duo_agents_and_flows",
"arguments":{"project_id":"gitlab-duo/test"}}}' | jq '.result.structuredContent.items'What I got, abridged to two of the 17 rows:
[
{"name": "Developer", "description": "Turn feedback into actionable merge requests or issues.",
"type": "flow", "foundational": true, "workflow_definition": null,
"can_start_session": true, "ai_catalog_item_consumer_id": 723},
{"name": "GitLab Duo", "description": "Your general development assistant. …",
"type": "agent", "foundational": true, "workflow_definition": "chat",
"can_start_session": false, "ai_catalog_item_consumer_id": null}
]The agent row is the contract in miniature: listed so a client can see it exists, but no consumer id, because it is not something start_duo_session can run.
- The id it handed back is one
start_duo_sessionwill accept.find_item_consumer!requires the consumer's project to match, andExecuteServicerequires a flow, so check both inrails console:
c = Ai::Catalog::ItemConsumer.find(723)
[c.project.full_path, c.item.item_type, c.enabled]
# => ["gitlab-duo/test", "flow", true]item_typenarrows the list. Asking for flows should drop the agents:
curl -s -X POST http://gdk.test:3000/api/v4/mcp \
-H "PRIVATE-TOKEN: $TOKEN" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/call","id":4,"params":{"name":"list_duo_agents_and_flows",
"arguments":{"project_id":"gitlab-duo/test","item_type":"flow"}}}' \
| jq -c '{types: ([.result.structuredContent.items[].type] | unique),
rows: (.result.structuredContent.items | length)}'What I got, down from the 17 rows of step 5:
{"types":["flow"],"rows":7}firstpaginates. Ask for two:
curl -s -X POST http://gdk.test:3000/api/v4/mcp \
-H "PRIVATE-TOKEN: $TOKEN" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/call","id":5,"params":{"name":"list_duo_agents_and_flows",
"arguments":{"project_id":"gitlab-duo/test","item_type":"flow","first":2}}}' \
| jq '{names: [.result.structuredContent.items[].name],
pageInfo: .result.structuredContent.pageInfo}'What I got:
{
"names": ["Secret Detection False Positive Detection", "Convert to GitLab CI/CD"],
"pageInfo": {"hasNextPage": true, "endCursor": "eyJjb25zdW1lcl9wcmlvcml0eSI6IjMiLCJpZCI6IjcyNSJ9"}
}Then paste that endCursor straight back as after for the next page:
curl -s -X POST http://gdk.test:3000/api/v4/mcp \
-H "PRIVATE-TOKEN: $TOKEN" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/call","id":6,"params":{"name":"list_duo_agents_and_flows",
"arguments":{"project_id":"gitlab-duo/test","item_type":"flow","first":2,
"after":"eyJjb25zdW1lcl9wcmlvcml0eSI6IjMiLCJpZCI6IjcyNSJ9"}}}' \
| jq '{names: [.result.structuredContent.items[].name],
pageInfo: .result.structuredContent.pageInfo}'What I got, two different flows and a cursor that moved on:
{
"names": ["Fix CI/CD Pipeline", "Developer"],
"pageInfo": {"hasNextPage": true, "endCursor": "eyJjb25zdW1lcl9wcmlvcml0eSI6IjMiLCJpZCI6IjcyMyJ9"}
}Your cursor values will differ; the check is that the names change and endCursor advances.
- Disabled items drop out, which is the one behaviour the GraphQL layer can't do for us. Flip one and watch the list shrink:
Ai::Catalog::ItemConsumer.find(723).update!(enabled: false)Re-running step 5 with item_type: "flow" went from 7 rows to 6, and 723 was the one missing. Set it back to true afterwards.
- Round-trip: pass a
can_start_session: trueid tostart_duo_sessionwith a goal. That starts a real CI job, so use a throwaway project.
Database review
Queries, plans and what changed
The only database-relevant change is an added preload. Adding the execute_ai_catalog_item
permission to AiCatalogItemConsumerPermissions made the policy resolve item.latest_version
once per consumer, which configured_items_resolver_spec.rb caught as an N+1 at 187 queries
against a control of 186. A new scope preloads it, and ConfiguredItemsResolver uses that
instead of with_items:
scope :with_items, -> { includes(:item) }
+ scope :with_items_and_latest_versions, -> { includes(item: :latest_version) }It is a separate scope rather than a widening of with_items because the two remaining callers
of with_items do not need the versions. Widening the shared scope charged them all an extra query, which
ai_catalog_item_consumer_spec.rb caught in a tier 3 run: its N+1 guard allows four extra
queries for aiCatalogItemConsumerForItem and the preload made a fifth.
No filter, row set or index usage changes. The change replaces N single-row lookups with one batched load.
The preload covers the unpinned branch of Item#resolve_version only, and that is the rare
branch: pinning is close to universal, so most rows still run versions.find_by(version: ...)
one at a time. Counting ai_catalog_item_versions queries while resolving the permission across
one project's consumers (8 rows, 7 pinned) gives 8 before this MR and 7 after — the preload buys
exactly the unpinned row. The pinned branch pre-dates this MR and is why the N+1 spec excludes
pinnedItemVersion, but that field is capped at 20 resolutions per request by FieldCallCount
and expose_permissions is not, so this MR does widen the exposure to a full page. Tracked in
#630684, with the numbers and the batching that EffectiveVersionBatchLoader already provides.
Query added
Thirty ids, the flows actually configured in gitlab-org/gitlab:
SELECT "ai_catalog_item_versions".*
FROM "ai_catalog_item_versions"
WHERE "ai_catalog_item_versions"."id" IN (
1027537, 1026179, 1019973, 1022805, 1019293, 1005532, 2516, 2515, 2745, 2518,
2517, 2514, 1030180, 1028278, 1028279, 1029870, 1028280, 1029244, 1027071,
1026359, 1024795, 1025433, 1023616, 1021115, 1019665, 1019987, 1024366,
1028970, 1028672, 1025076
); Index Scan using ai_catalog_item_versions_pkey on public.ai_catalog_item_versions
(cost=0.29..51.66 rows=30 width=559) (actual time=1.678..44.829 rows=30 loops=1)
Index Cond: (ai_catalog_item_versions.id = ANY ('{1027537,...,1025076}'::bigint[]))
Buffers: shared hit=20 read=46
I/O Timings: read=44.644 write=0.000
Time: 45.938 ms
- planning: 1.065 ms
- execution: 44.873 ms
- I/O read: 44.644 msPrimary-key index scan, 66 buffers for the whole batch. Of the 45.9 ms, 44.6 ms is cold I/O read, so a warm buffer pool serves this in well under a millisecond.
Query removed
The same read previously ran once per consumer, thirty times for the list above, each with its own planning cost and its own index descent:
SELECT "ai_catalog_item_versions".*
FROM "ai_catalog_item_versions"
WHERE "ai_catalog_item_versions"."id" = 1027537
LIMIT 1;The IN list is bounded by the page size: 20 by default for the GraphQL connection, 100 at
most through the MCP tool.
Query changed: the driving query's filter widened
Listing external agents widened one filter on the existing connection query, from
item_type IN (1, 2) to IN (1, 2, 3). Nothing else about it changed.
SELECT "ai_catalog_item_consumers".*,
CASE WHEN "item"."verification_level" = 100 THEN 3
WHEN "project_authorizations"."access_level" IS NOT NULL THEN 4
ELSE 5 END AS consumer_priority
FROM "ai_catalog_item_consumers"
INNER JOIN "ai_catalog_items" "item" ON "item"."id" = "ai_catalog_item_consumers"."ai_catalog_item_id"
LEFT OUTER JOIN "project_authorizations" ON "project_authorizations"."project_id" = "item"."project_id"
AND "project_authorizations"."user_id" = 11001664
WHERE "ai_catalog_item_consumers"."project_id" = 278964
AND "item"."item_type" IN (1, 2, 3)
ORDER BY consumer_priority ASC, "ai_catalog_item_consumers"."id" DESC
LIMIT 20; Limit (cost=41.67..41.68 rows=5 width=86) (actual time=237.410..237.415 rows=20 loops=1)
-> Sort (actual time=237.408..237.411 rows=20 loops=1)
Sort Method: top-N heapsort Memory: 30kB
-> Nested Loop Left Join (cost=1.30..41.61 rows=5) (actual rows=59 loops=1)
-> Nested Loop (cost=0.72..30.20 rows=5) (actual rows=59 loops=1)
-> Index Scan using index_ai_catalog_item_consumers_on_project_id
Index Cond: (project_id = 278964) (actual rows=59 loops=1)
-> Index Scan using ai_catalog_items_pkey (loops=59)
Index Cond: (item.id = ai_catalog_item_consumers.ai_catalog_item_id)
Filter: (item.item_type = ANY ('{1,2,3}'::integer[]))
-> Index Only Scan using project_authorizations_pkey (loops=59)
Heap Fetches: 13
Time: 242.488 ms
- planning: 4.965 ms
- execution: 237.523 ms
- I/O read: 233.877 ms
Buffers: shared hit=320 read=171 dirtied=54item_type is a Filter, not an Index Cond. It is applied to rows the join has already
fetched by primary key, so widening the list cannot change the access path — it only lets more
of the already-fetched rows survive, which is the intent. The extra rows are the project's
external agents, and the sort stays a 30 kB top-N heapsort under LIMIT 20.
Two things in this plan that are database-lab artifacts rather than query behaviour: 233.9 ms
of the 242.5 ms is cold I/O read on a clone with an empty buffer pool, and the dirtied=54
with WAL records=54 is hint-bit setting on first touch, not writes from a SELECT.
Worth a reviewer's eye even though it predates this MR: the consumers index scan is estimated
at 6 rows and returns 59, and all of them are fetched and sorted before LIMIT 20 applies. That
is existing behaviour and grows with the number of items configured in a project, not with this
change.
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.