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

Screenshots or screen recordings

Backend only, no UI.

How to set up and validate locally

Ten steps, against a running GDK
  1. There's no checkbox for the mcp scope in the UI, so make the token in rails 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
  1. 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::Base consults Ai::UserAuthorizable#any_group_with_mcp_server_enabled?, and being an instance admin doesn't count. Otherwise every call below answers 403 MCP server not enabled for any of your groups.
Group.find_by_full_path('gitlab-duo').namespace_settings.update!(mcp_server_enabled: true)
  1. 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"]}
  1. 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".

  1. List what the project actually has. gitlab:duo:setup seeds gitlab-duo/test with 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.

  1. The id it handed back is one start_duo_session will accept. find_item_consumer! requires the consumer's project to match, and ExecuteService requires a flow, so check both in rails console:
c = Ai::Catalog::ItemConsumer.find(723)
[c.project.full_path, c.item.item_type, c.enabled]
# => ["gitlab-duo/test", "flow", true]
  1. item_type narrows 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}
  1. first paginates. 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.

  1. 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.

  1. Round-trip: pass a can_start_session: true id to start_duo_session with 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
);

Plan on Postgres AI:

 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 ms

Primary-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;

Plan on Postgres AI:

 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=54

item_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.

Edited by Fred de Gier

Merge request reports

Loading
Loading