Tell MCP callers why a Duo session refused to start, and pin the identity chain

What does this MR do and why?

start_duo_session refuses in two cases, and in both the agent gets the REST plumbing rather than something it can act on: a caller who cannot run the flow sees 400 Bad request - ["You have insufficient permissions"], and a flow without a composite identity service account sees a bare 403 Forbidden. This translates those two in the aggregator, the same way send_duo_session_input already handles its own bare 403, and passes every other error through untouched.

Closes #627562 (closed), which also asked whether the tool should refuse earlier when the caller cannot write to the project. It should not: the route already refuses before a session row or a CI job exists, so there is nothing an earlier check would prevent, and a pre-flight Ability check in the MCP layer would duplicate the route's authorization. That verification is what the rest of the diff is — specs pinning who runs each step of a session, so the answer fails when it stops being true rather than ageing quietly in an issue comment.

References

Closes #627562 (closed). Related: !254979 (merged) (the tool), !248733 (merged) (the composite identity requirement), #628266 (its rollout), #601901.

Screenshots or screen recordings

A read-only caller asking start_duo_session to run a flow:

Before After
400 Bad request - ["You have insufficient permissions"] You cannot run this flow in this project. That needs at least the Developer role on the project, and the flow must be available there: a flow can also be restricted by its version, by group settings, or by where it is used. No session was started.

How to set up and validate locally

Nothing starts on either path, so this needs no runner, no Duo Workflow Service and no AI gateway. The steps below are what I ran, against gitlab-duo/spring-3; any Duo-enabled project with an AI Catalog consumer works.

  1. Pick a user who has only Reporter access on that project, and give them a token with the mcp scope. Still no checkbox for it in the UI, so rails console:

    project = Project.find_by_full_path('gitlab-duo/spring-3')
    reporter = User.find_by_username('your-reporter-user')
    project.add_reporter(reporter) unless project.member?(reporter)
    
    PersonalAccessToken.create!(
      user: reporter, name: 'mcp', scopes: [:mcp],
      expires_at: 7.days.from_now,
      organization: Organizations::Organization.default_organization
    ).token
  2. Pick a consumer, and confirm the policy really does refuse this user:

    Ai::Catalog::ItemConsumer.where(project: project).map { |c| [c.id, c.item&.name] }
    # => [[769, "Code Review"], [770, "SAST False Positive Detection"], [771, "Resolve SAST Vulnerability"], ...]
    
    Ability.allowed?(reporter, :execute_ai_catalog_item, Ai::Catalog::ItemConsumer.find(773))
    # => false
  3. Ask for a session on master:

    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":1,"params":{"name":"start_duo_session",
           "arguments":{"project_id":"gitlab-duo/spring-3","ai_catalog_item_consumer_id":773,
                        "goal":"Add a line to README saying hello"}}}' \
      | jq -c '{isError: .result.isError, text: .result.content[0].text}'

    What I got:

    {"isError":true,"text":"400 Bad request - [\"You have insufficient permissions\"]"}
  4. Same call on this branch. If your checkout is busy on another branch, serve the branch from a worktree on a spare port against the same database rather than switching branches under gdk — RAILS_ENV=development bin/rails server -p 3001 -b 127.0.0.1 — and point the same curl at http://127.0.0.1:3001.

    What I got:

    {"isError":true,"text":"You cannot run this flow in this project. That needs at least the Developer role on the project, and the flow must be available there: a flow can also be restricted by its version, by group settings, or by where it is used. No session was started."}
  5. Confirm the refusal really is early, and that the REST body is still there for anything that wants it:

    Ai::DuoWorkflows::Workflow.count
    # => 985, unchanged before and after every call above
    # ... | jq -r '.result.structuredContent.error.message'
    # => 400 Bad request - ["You have insufficient permissions"]
For agents

Why the match is on message text

ERROR_GUIDANCE is keyed on Ai::Catalog::BaseService::INSUFFICIENT_PERMISSIONS_MESSAGE and API::Helpers::DuoWorkflowHelpers::COMPOSITE_IDENTITY_SERVICE_ACCOUNT_MESSAGE — constants on the classes that produce the refusals, so a rewording cannot silently stop matching. Matching on the status instead is not possible: the route answers ordinary validation failures with 400 as well, and error_no_permissions sets neither reason nor http_status, so by the time the aggregator sees the response only the serialized body survives.

That also means these two strings must stay untranslated. If either were wrapped in _(), the response would localize per request, the match would miss for every non-English locale, and the agent would get the raw error back. Localizing them properly means giving the service response a machine-readable reason first and matching on that.

Why the guidance names two things, not the role

execute_ai_catalog_item is denied for far more than a missing role. Ai::Catalog::ItemConsumerPolicy prevents it for an absent or draft pinned version, a foundational flow outside the allowlist, unavailable foundational or third-party flows, beta features off, a custom flow or agent disabled for the namespace, an Ultimate-only item without the licence, item_blocked_by_namespace_restriction, foundational_flow_feature_flag_disabled and outside_restricted_hierarchy; EE::ProjectPolicy prevents it when the AI Catalog is unavailable for the project or the user. All of them arrive as one message, so telling the caller to get the Developer role would often be wrong remediation. A spec fails if the wording goes back to blaming the role alone.

The identity chain (AC 2 on #627562 (closed))

Step Runs as Asserted in
The MCP call the token's owner ee/spec/requests/api/mcp/handlers/call_tool_spec.rb (existing)
Route dispatch same process; PATH_INFO stays /api/v4/mcp, so the session is tagged source_type: :mcp same file (existing)
Authorization the caller, at every check same file (new: a real Reporter, no Ability stubbing)
The session record owned by the caller, run by the service account ee/spec/services/ai/catalog/execute_workflow_service_spec.rb (new)
The job's token, composite enforced the service account, scoped to the caller with user:<id> ee/spec/services/ai/duo_workflows/workflow_context_generation_service_spec.rb (new)
The job's token, not enforced the caller, with their own privileges same file (new)
The CI job the service account ee/spec/services/ai/duo_workflows/start_workflow_service_spec.rb (existing)
Writes back git author the service account, git committer the session user same file (new)

The token pair is asserted on the token that comes out rather than on which branch ran: the existing examples mock the object under test and would survive a branch being rewired to a different owner.

The no-composite-identity example passes today. enforce_composite_identity_for_api_started_workflows is default-off and #628266 has not started, so a session in a namespace whose service account is not composite-enforced runs its CI job on a token owned by the human caller. ItemConsumers::CreateService provisions with the flag set, but ItemConsumer#validate_service_account only requires service_account?. Fixing that is #601901; this only pins the behaviour so its removal is deliberate.

The other refusal

The composite-identity 403 is covered by the service spec only. Reproducing it locally means enabling the feature flag for the root namespace and pointing the flow at a service account without composite identity, which is invasive on a shared GDK.

Token scopes

mcp is valid on a personal access token — Gitlab::Auth.all_available_scopes includes it, which is what the model validates against — it is just excluded from available_scopes_for, so no UI or API surface offers it. Hence creating the token in the console. An mcp-scoped OAuth token works identically.

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