Let MCP start and answer foundational agent turns
What does this MR do and why?
Lets an MCP client start a turn against one of the instance's foundational agents and then
answer it. start_duo_session gains an agent parameter next to flow, flow_item_id and
ai_catalog_item_consumer_id, and send_duo_session_input can now resume those sessions.
Closes #617174 (closed).
why a foundational agent session could not be answered, and what changed
The route could already start these turns: start_workflow_params resolves a foundational
agent's flow config from its workflow_definition. Two things stopped the turn being
answered.
Ai::DuoWorkflows::WorkflowPolicy enables resume_duo_workflow only under true_duo_workflow,
which is !chat?, and Workflow#chat? is
FoundationalChatAgent.foundational_workflow_definition?(workflow_definition), so every
foundational agent is excluded by construction. Then POST .../resume derives its service
account and flow config from a catalog consumer via find_catalog_consumer_for_workflow!,
which such a session has none of.
- Policy.
resume_duo_workflowjoins the rule that already governs chat sessions. The existingrule { ~resumable }.preventkeeps interactive chat out:resumable?needs a completed workload pipeline, and a live conversation has no CI workload. The spec adds the positive case, a non-owner and a no-workload case. - Resume route. Branches on
workflow.chat?. Chat sessions take a consumer-less path that builds its params explicitly, from the workflow and the declared params only, resumes under the service account the session was started with, and hands them toAi::DuoWorkflows::ResumeWorkflowService, the shapeResumeWorkplanServicealready uses. The token half ofstart_workflow_paramsmoved intoworkflow_credential_paramsso both share it. The catalog path is untouched. list_duo_agents_and_flows. Marks a foundational chat agentcan_start_session: truewhen the instance has a flow configuration for it, read fromflowConfig, and says it is started by passing itsworkflow_definitionasagent. The agents without one stayfalse, since a session for them can only fail in CI.start_duo_session. Gainsagent, one more exclusive way to name what to run, forwarded asworkflow_definition. It accepts only foundational agents, andflownow refuses them, so each kind of thing has one parameter, the same rule !254746 (merged) applies to a GitLab flow's catalog item.
Worth a reviewer's attention
The human decision is mapped, not forwarded. Approving with a message sends the message as
the goal; rejecting sends --approval false --rejection-reason.
why, and a likely matching bug in the catalog path
ResumeWorkflowService#executor_cli_command passes human_message as --rejection-reason,
which the CLI accepts only with --approval false; omitting the decision flag makes the
executor treat goal as the reply. Forwarding human_approval: true, human_message: "..."
verbatim produced a ZodError and a failed job. An approval with no message sends
Approved. Continue., following ResumeWorkplanService::NO_REPLIES_MESSAGE.
The catalog path may have the same latent bug: Ai::Catalog::ExecuteWorkflowService does
params.merge!(@resume_context.slice(:human_approval, :human_message)), so approving a catalog
flow with a message looks like it would hit the same error. I have not confirmed it and it is
not this issue's code, so it is untouched here.
Six of the eleven foundational agents cannot run, and that is not fixable here. They start
and then fail in CI with Failed to load flow, so list_duo_agents_and_flows lists them as not
startable, and the parameter description and the docs say so.
which ones, and why
resolve_for_foundational_chat_agent returns {} without a flow_version, and only chat,
orbit_agent/v1, analytics_agent/v1, duo_permissions_assistant/v1 and flow_creator/v1
declare one in ee/lib/ai/foundational_chat_agents_definitions.rb, and chat is left out on
purpose. The rest fail with, for
example, Failed to load flow duo_planner/v1 (version 1.0.0): the flow config does not exist
on the Duo Workflow Service side. duo_planner/v1 is the example the issue leads with.
Composite identity enforcement stops agents over the API. With
enforce_composite_identity_for_api_started_workflows on, an agent start is refused, because
a foundational agent has no service account to offer. The tool says so instead of sending the
caller to the AI Catalog, which only fits flows.
Resuming checks what starting checks. A foundational agent session can be resumed only where
it can run in CI, and only while its agent is still enabled and licensed for the namespace, so
resumeDuoWorkflow and the Approve button agree with what the route will do.
what the resume missed before
The CI gate was enforced, just late: ResumeWorkflowService inherits StartWorkflowService#execute,
which refuses without execute_duo_workflow_in_ci, so the route answered 400 Can not execute workflow in CI while the permission said yes. Agent availability was checked only at start, in
CreateWorkflowService#check_agentic_chat_access. Resume now requires execute_duo_workflow_in_ci,
which a chat session loses when its agent is no longer available.
GitLab Duo itself cannot be started, although chat has a flow configuration.
list_duo_sessions leaves Duo Chat sessions out, so an agent could not find the session it had
started.
The approval controls now show for these sessions in the UI, see the screenshots section. Reject there stops the session; it does not send a rejection.
References
- Closes #617174 (closed)
- Part of &21395 (closed) and &23298 (closed)
- Builds on !256750 (merged), which added
list_duo_agents_and_flows. It listed foundational chat agents as never startable, which this MR makes untrue for the ones with a flow configuration. - Builds on !254746 (merged), which added
flow,flow_item_idandurltostart_duo_session.
Screenshots or screen recordings
The Agent sessions page shows Approve and Modify on a foundational agent session that is
waiting for input, because userPermissions.resumeDuoWorkflow is now true for it.
| Before | After |
|---|---|
resumeDuoWorkflow: false on session 994, no approval controls |
resumeDuoWorkflow: true on session 994, approval controls shown |
A session with no workload, such as a web chat, stays false on both.
How to set up and validate locally
steps and what I got
You need a runner that picks up duo_workflow pipelines, since the point is that the turn
really executes. I served this branch from a worktree beside GDK and ran every command below
with GDK_URL=http://gdk.test:3003, and the steps added after review with
http://gdk.test:3004; with the branch checked out in GDK, use http://gdk.test:3000.
- There is no checkbox for the
mcpscope in the UI, so make the tokens inrails console. The second one is for the REST call in step 10:
org = Organizations::Organization.default_organization
root = User.find_by_username('root')
PersonalAccessToken.create!(user: root, name: 'mcp', scopes: [:mcp], expires_at: 7.days.from_now, organization: org).token
PersonalAccessToken.create!(user: root, name: 'api', scopes: [:api], expires_at: 7.days.from_now, organization: org).tokenExport them as TOKEN and API_TOKEN. Use Authorization: Bearer for MCP, since
initialize refuses PRIVATE-TOKEN.
- Allow the MCP server for a top-level group you are a member of, otherwise every call 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)- The tool takes
agent:
curl -s -X POST $GDK_URL/api/v4/mcp \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}' \
| jq -c '.result.tools[] | select(.name=="start_duo_session")
| {required: .inputSchema.required, props: (.inputSchema.properties|keys)}'What I got:
{"required":["goal"],"props":["agent","ai_catalog_item_consumer_id","flow","flow_item_id","goal","project_id","url"]}- Each kind of thing has one parameter. These are the cheapest checks, since nothing starts:
curl -s -X POST $GDK_URL/api/v4/mcp \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"start_duo_session",
"arguments":{"project_id":"gitlab-duo/test","agent":"developer/v1","goal":"hi"}}}' \
| jq -r '.result.content[0].text'
curl -s -X POST $GDK_URL/api/v4/mcp \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"start_duo_session",
"arguments":{"project_id":"gitlab-duo/test","flow":"analytics_agent/v1","goal":"hi"}}}' \
| jq -r '.result.content[0].text'
curl -s -X POST $GDK_URL/api/v4/mcp \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/call","id":9,"params":{"name":"start_duo_session",
"arguments":{"project_id":"gitlab-duo/test","agent":"chat","goal":"hi"}}}' \
| jq -r '.result.content[0].text'What I got:
Validation error: 'developer/v1' is not a foundational agent. Pass a GitLab flow as flow instead
Validation error: This is a foundational agent, pass agent: 'analytics_agent/v1' instead of flow
Validation error: GitLab Duo itself cannot be started as a session, pick another agentlist_duo_agents_and_flowsnow offers the foundational chat agents that can run as startable, by the referenceagenttakes, and keeps GitLab Duo and the ones without a flow configuration unstartable:
curl -s -X POST $GDK_URL/api/v4/mcp \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/call","id":8,"params":{"name":"list_duo_agents_and_flows",
"arguments":{"project_id":"gitlab-duo/test"}}}' \
| jq -c '[.result.structuredContent.items[] | select(.workflow_definition != null)
| {(.workflow_definition): .can_start_session}] | add'What I got:
{"chat":false,"duo_planner/v1":false,"security_analyst_agent/v1":false,"analytics_agent/v1":true,"ci_expert_agent/v1":false,"duo_permissions_assistant/v1":true,"support_assistant/v1":false,"agentic_chat/v1":false,"flow_creator/v1":true}- Start a foundational agent turn. Use one of the five that have a
flow_version:
curl -s -X POST $GDK_URL/api/v4/mcp \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/call","id":4,"params":{"name":"start_duo_session",
"arguments":{"project_id":"gitlab-duo/test","agent":"analytics_agent/v1",
"goal":"Name one file in the repo root, then ask me whether to continue."}}}' \
| jq -c '.result.structuredContent'What I got:
{"workflow_id":1000,"status":"created","workload_id":530,"poll_after_seconds":30}- Poll until it asks you something. It took about 20 seconds this time:
curl -s -X POST $GDK_URL/api/v4/mcp \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/call","id":5,"params":{"name":"get_duo_session",
"arguments":{"workflow_id":1000}}}' \
| jq -c '.result.structuredContent | {status, turn_complete}'What I got: {"status":"input required","turn_complete":true}. On master this is where it
stops: answering returns 403, because the policy refuses every chat? session.
- Answer it:
curl -s -X POST $GDK_URL/api/v4/mcp \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/call","id":6,"params":{"name":"send_duo_session_input",
"arguments":{"workflow_id":1000,"human_approval":true,"human_message":"Yes, continue and then stop."}}}' \
| jq -c '.result.structuredContent'What I got:
{"workflow_id":1000,"status":"input_required","workload_id":531,"poll_after_seconds":120}- The check that the resume really ran, rather than just being accepted: a second workload, and its pipeline succeeded:
w = Ai::DuoWorkflows::Workflow.find(1000)
w.workloads.order(:id).map { |wl| [wl.id, wl.pipeline&.status] }
# => [[530, "success"], [531, "success"]]- A rejection takes the other branch, and the resume ignores parameters the route does not
declare. Call the route directly with a
goaland asource_branchit should not act on (9-add-hello-worldis atdd6e5118c2,mainat0e2d71183f):
curl -s -X POST "$GDK_URL/api/v4/ai/duo_workflows/workflows/1000/resume" \
-H "PRIVATE-TOKEN: $API_TOKEN" \
--data-urlencode "human_approval=false" --data-urlencode "human_message=Stop here." \
--data-urlencode "goal=Delete the README." --data-urlencode "source_branch=9-add-hello-world" \
| jq -c '{id, status, workload}'What I got: {"id":1000,"status":"input_required","workload":{"id":532,"message":null}}. Then:
b = Ci::Workloads::Workload.find(532).pipeline.builds.first
vars = b.yaml_variables.to_h { |v| [v[:key], v[:value]] }
[b.pipeline.sha[0, 10], vars["DUO_WORKFLOW_GOAL"], Array(b.options[:script]).join[/--approval \S+ --rejection-reason \S+ \S+/]]
# => ["0e2d71183f", "Yes, continue and then stop.", "--approval false --rejection-reason Stop\\ here.\""]The job was cut from main and kept the previous turn's goal. On the previous version of this
MR, which reused start_workflow_params, the same call gave workload 525 cut from dd6e5118c2
with DUO_WORKFLOW_GOAL set to Delete the README.
The metadata now names the project, so tool approval reads the project's setting rather than
its group's. Only the spec shows that: ResumeWorkflowService keeps the first job's
DUO_WORKFLOW_METADATA whenever that job exists, so no resumed job can show it.
- A bare rejection, with no message, is the one the UI never sends: its Reject button stops the session instead. Over MCP it still resumes, and the agent carries on:
curl -s -X POST $GDK_URL/api/v4/mcp \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/call","id":13,"params":{"name":"send_duo_session_input",
"arguments":{"workflow_id":1000,"human_approval":false}}}' \
| jq -c '.result.structuredContent'What I got: {"workflow_id":1000,"status":"input_required","workload_id":538,"poll_after_seconds":120}.
Then:
b = Ci::Workloads::Workload.find(538).pipeline.builds.first
p [b.pipeline.status, Array(b.options[:script]).join[/duo run [^"\n]*/]]
# => ["success", "duo run --existing-session-id 1000 --connection-type websocket --approval false"]get_duo_session then showed input required and a new reply asking what to look at next.
- Resuming re-checks the agent. Turn
analytics_agentoff for the organization:
Organizations::Organization.default_organization.foundational_agents_status_records.create!(reference: 'analytics_agent', enabled: false)curl -s -X POST $GDK_URL/api/v4/mcp \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/call","id":10,"params":{"name":"send_duo_session_input",
"arguments":{"workflow_id":1000,"human_approval":true}}}' \
| jq -r '.result.content[0].text'What I got:
Session 1000 is not waiting for input, or you cannot resume it. Call get_duo_session with workflow_id=1000 to check its status; only sessions waiting in input_required, plan_approval_required, or tool_call_approval_required, whose last CI job has finished, accept input.No workload was added and the permission is off. Then turn the agent back on:
w = Ai::DuoWorkflows::Workflow.find(1000)
p [w.workloads.count, Ability.allowed?(w.user, :resume_duo_workflow, w)]
# => [4, false]
Organizations::Organization.default_organization.foundational_agents_status_records.find_by(reference: 'analytics_agent').destroy!
p Ability.allowed?(w.user, :resume_duo_workflow, w)
# => trueWith the pushed policy the same session read resumeDuoWorkflow: true while the agent was off,
so the resume would have run another CI job.
- It re-checks CI too. Turn remote flows off for the project:
Project.find_by_full_path('gitlab-duo/test').update!(duo_remote_flows_enabled: false)curl -s -X POST $GDK_URL/api/v4/mcp \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/call","id":11,"params":{"name":"list_duo_agents_and_flows",
"arguments":{"project_id":"gitlab-duo/test","item_type":"agent"}}}' \
| jq -c '[.result.structuredContent.items[] | select(.can_start_session) | .workflow_definition]'
curl -s -X POST $GDK_URL/api/v4/mcp \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/call","id":12,"params":{"name":"send_duo_session_input",
"arguments":{"workflow_id":1000,"human_approval":true}}}' \
| jq -r '.result.content[0].text'What I got: [], and the same "not waiting for input, or you cannot resume it" answer, with the
workload count still 4. With the pushed policy the resume got as far as the service and answered
400 Bad request - Can not execute workflow in CI. Then turn remote flows back on:
Project.find_by_full_path('gitlab-duo/test').update!(duo_remote_flows_enabled: true)- With composite identity enforced, an agent start is refused with guidance that fits agents. Use a group nobody else is working in, since the flag applies to the whole group:
Feature.enable(:enforce_composite_identity_for_api_started_workflows, Group.find_by_full_path('free-self-managed-group'))curl -s -X POST $GDK_URL/api/v4/mcp \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/call","id":7,"params":{"name":"start_duo_session",
"arguments":{"project_id":"free-self-managed-group/project","agent":"analytics_agent/v1","goal":"hi"}}}' \
| jq -r '.result.content[0].text'What I got:
This group requires a composite identity service account to start a session, and foundational agents do not have one, so they cannot be started through the API here. No session was started.Do not contrast it with a flow call in the same group: a GitLab flow has its catalog
consumer's service account, so it is not refused, and it starts a real session that can push
commits. Mine did, as session 999, and it finished without changing anything. Then turn the
flag off again:
Feature.disable(:enforce_composite_identity_for_api_started_workflows, Group.find_by_full_path('free-self-managed-group'))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.