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_workflow joins the rule that already governs chat sessions. The existing rule { ~resumable }.prevent keeps 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 to Ai::DuoWorkflows::ResumeWorkflowService, the shape ResumeWorkplanService already uses. The token half of start_workflow_params moved into workflow_credential_params so both share it. The catalog path is untouched.
  • list_duo_agents_and_flows. Marks a foundational chat agent can_start_session: true when the instance has a flow configuration for it, read from flowConfig, and says it is started by passing its workflow_definition as agent. The agents without one stay false, since a session for them can only fail in CI.
  • start_duo_session. Gains agent, one more exclusive way to name what to run, forwarded as workflow_definition. It accepts only foundational agents, and flow now 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

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.

  1. There is no checkbox for the mcp scope in the UI, so make the tokens in rails 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).token

Export them as TOKEN and API_TOKEN. Use Authorization: Bearer for MCP, since initialize refuses PRIVATE-TOKEN.

  1. 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)
  1. 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"]}
  1. 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 agent
  1. list_duo_agents_and_flows now offers the foundational chat agents that can run as startable, by the reference agent takes, 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}
  1. 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}
  1. 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.

  1. 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}
  1. 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"]]
  1. A rejection takes the other branch, and the resume ignores parameters the route does not declare. Call the route directly with a goal and a source_branch it should not act on (9-add-hello-world is at dd6e5118c2, main at 0e2d71183f):
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.

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

  1. Resuming re-checks the agent. Turn analytics_agent off 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)
# => true

With the pushed policy the same session read resumeDuoWorkflow: true while the agent was off, so the resume would have run another CI job.

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

🤖 Generated with Claude Code

Edited by Fred de Gier

Merge request reports

Loading
Loading