Add send_duo_session_input MCP tool

What does this MR do and why?

Adds the send_duo_session_input MCP tool: answer a Duo Agent Platform session that's waiting — approve or reject the step, or reply to its question. Part of #617171 (closed) and &21395 (closed).

Route-backed: route_setting :mcp on POST /ai/duo_workflows/workflows/:workflow_id/resume, plus a Base::AggregatedService, same as save_merge_request/semantic_search. Adds a tool-selection description, a real required list, and a message pointing at get_duo_session instead of a bare 403. No ApiTool changes; !254979 (merged) builds start_duo_session (#607619 (closed)) the same way, closing #627559 (closed).

  • Only input_required is resumable (Workflow#resumable?); other statuses get refused with a clear message. @bastirehm confirmed the CI executor can emit require_plan_approval and require_tool_call_approval, so answering a session in plan_approval_required or tool_call_approval_required needs a follow-up. That means this MR does not close #617171 (closed) on its own.

References

Related to #617171 (closed) (not closed by this MR, see the note above), #607619 (closed), !254979 (merged) and #627559 (closed).

Screenshots or screen recordings

Before After

How to set up and validate locally

  1. There's no checkbox for the mcp scope in the UI yet, 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. Confirm the tool is listed and the route tool underneath it is hidden:
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[].name' | grep duo

send_duo_session_input should show up

  1. Unknown session id, 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":"send_duo_session_input",
       "arguments":{"workflow_id":99999999,"human_approval":true}}}' | jq '.result'

What I got: text "404 Workflow Not Found" and structuredContent.error of {"message": "404 Not found"}.

  1. A session that isn't resumable, which is the error message this whole tool exists for. Make one first, in rails console:
Ai::DuoWorkflows::Workflow.create!(
  user: User.find_by_username('root'),
  project: Project.find_by_full_path('gitlab-duo/test'),
  goal: 'test', workflow_definition: 'software_development', status: 6
).id

Status 6 is input_required, but with no finished workload pipeline Workflow#resumable? is false, so the endpoint answers 403.

Then call it (964 was the id I got back):

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":"send_duo_session_input",
       "arguments":{"workflow_id":964,"human_approval":true,"human_message":"ga door"}}}' \
  | jq '.result.content[0].text'

What I got:

"Session 964 is not waiting for input, or you cannot resume it. Call get_duo_session with workflow_id=964 to check its status; only sessions with status input_required whose last CI job has finished accept input."

The bare 403 Forbidden is still there in structuredContent.error, so nothing is hidden from a caller that actually wants it.

  1. Happy path:
w = Ai::DuoWorkflows::Workflow.find(972)
w.update_column(:status, 6)
[w.reload.status_name, w.resumable?]
# => ["input_required", true]

Then send the input:

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":"send_duo_session_input",
       "arguments":{"workflow_id":972,"human_approval":true,"human_message":"looks good"}}}' \
  | jq '.result.structuredContent'

What I got:

{"workflow_id": 972, "status": "input_required", "workload_id": 501, "poll_after_seconds": 120}

The workload_id differs from the one the session already had, so a second duo_workflow pipeline was queued. The exact number varies per run; the count going up is the check:

Ai::DuoWorkflows::Workflow.find(972).workflows_workloads.count
# => 2

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