DEV Community

Part 8: Consolidating SavePipelineService & Scoping Active-Pipeline Polling Hints in GitLab MCP

Series Context: Part 8 of 10 in GitLab Core Engineering: Architecture, MCP & CI/CD Deep Dives. Featured MRs: !264858 (Merged), !264863 (Reviewed & Ready for Maintainer)

Resisting Tool Sprawl in the CI Toolset

When an MCP tool suite grows, every duplicate tool name (run_pipeline vs create_merge_request_pipeline vs save_pipeline) increases prompt bloat and splits LLM tool-selection accuracy.

Rather than introducing a brand-new tool to run merge request pipelines, !264858 consolidated MR pipeline creation directly into Mcp:🛠️:Pipelines::SavePipelineService via an optional merge_request_iid parameter.

Meanwhile, !264863 upgraded its sibling read tool, get_pipeline, so agents monitoring live builds know when - and when not - to poll again.

Upfront Permission Gates vs Fragile Error-String Matching (!264858)

Compare two ways to authorize creating a pipeline for a merge request:

  • Fragile approach: Call MergeRequests::CreatePipelineService#execute(merge_request), inspect response.message, and run response.message.include?('permission') to map HTTP/MCP error codes (UNAUTHORIZED vs BAD_REQUEST). If another engineer edits that copy next month, authorization status codes break silently.
  • Explicit upfront gates (shipped in SavePipelineService#perform_create_for_merge_request):
def perform_create_for_merge_request(arguments)
  project = resolve_project(arguments)
  message = 'Merge request not found or inaccessible.'
  merge_request = project.merge_requests.find_by_iid(arguments[:merge_request_iid])
  unless merge_request && Ability.allowed?(current_user, :read_merge_request, merge_request)
    return ::Mcp:🛠️:Base::Response.error(
      message, reason: ::Mcp:🛠️:Base::Response::Reason::NOT_FOUND
    )
  end
  authorize_granular_token!(project, [:create_merge_request_pipeline], not_found_message: message)
  if merge_request.has_no_commits?
    return ::Mcp:🛠️:Base::Response.error(
      'Cannot create a pipeline for this merge request: no commits to build.',
      reason: ::Mcp:🛠️:Base::Response::Reason::BAD_REQUEST
    )
  end
  create_pipeline_service = ::MergeRequests::CreatePipelineService.new(
    project: project, current_user: current_user, params: { allow_duplicate: true }
  )
  unless create_pipeline_service.allowed?(merge_request)
    return ::Mcp:🛠️:Base::Response.error(
      'Insufficient permissions to create pipeline',
      reason: ::Mcp:🛠️:Base::Response::Reason::UNAUTHORIZED
    )
  end

Look closely at the exact order of security checks:

  • Resolve project (requiring exactly one of url or project_id).
  • Lookup merge_request and gate with :read_merge_request - returning a non-leaky NOT_FOUND ("Merge request not found or inaccessible.").
  • Verify granular OAuth token scope (authorize_granular_token!(project, [:create_merge_request_pipeline], ...)).
  • Reject empty MRs (merge_request.has_no_commits?) as BAD_REQUEST before checking create_pipeline_service.allowed?, because CreatePipelineService#allowed? itself returns false on empty MRs even for maintainers! Ordering step 4 ahead of step 5 avoids misreporting a no-commits 400 as a permissions 401/403.
  • Reuse existing pipeline_response(pipeline, action: 'create') cleanly - sharing output formatting with perform_update.

And before any DB lookup starts, perform_v0_1_0 enforces strict parameter mutual exclusivity:

%i[pipeline_id ref action name variables inputs].each do |incompatible_param|
  if arguments[incompatible_param].present?
    raise ArgumentError, "Cannot combine merge_request_iid with #{incompatible_param}"
  end
end

Teaching get_pipeline Smart Polling Without Polluting Bridge Pipelines (!264863)

When an agent kicks off a pipeline with save_pipeline, its next step is checking build progress with get_pipeline. Without server-side guidance, agents either hammer get_pipeline in tight 1-second loops or give up prematurely.

!264863 adds poll_after_seconds: 30 to GetPipelineTool (app/services/mcp/tools/pipelines/get_pipeline_tool.rb) when a pipeline is still in flight - and reviewer feedback from @hbakergitlab, @john-slaughter, and @duo-developer-gitlab-org sharpened two critical edge cases:

  1. Reuse ::Ci::HasStatus::ALIVE_STATUSES - Plus scheduled

Instead of hardcoding a hand-rolled status array that drifts when CI adds a state, derive from GitLab's canonical status constant and explicitly include scheduled:

POLL_AFTER_SECONDS = 30
ACTIVE_STATUSES = (::Ci::HasStatus::ALIVE_STATUSES + %w[scheduled]).freeze

Why add %w[scheduled]? Because ::Ci::HasStatus::ALIVE_STATUSES includes created, canceling, preparing, pending, running, waiting_for_callback, and waiting_for_resource - but omits scheduled, even though a scheduled delayed-job pipeline is non-terminal and transitions automatically to pending once its timer expires!

  1. Only Annotate the Top-Level Pipeline (top_level: true)

Notice GetPipelineTool#pipeline_data is also called recursively for downstream_pipelines and bridge trigger sub-pipelines. During review, @hbakergitlab caught that attaching poll_after_seconds: 30 onto nested downstream pipeline objects creates noise (and potentially conflicting hints if downstream finishes after parent or vice-versa). Adding top_level: false keeps poll_after_seconds strictly on the root requested pipeline:

def process_result(result)
  processed_result = super
  return processed_result if processed_result[:isError]
  data = pipeline_data(processed_result[:structuredContent]['pipeline'], top_level: true)
  formatted_content = [{ type: 'text', text: Gitlab::Json.dump(data) }]
  ::Mcp:🛠️:Base::Response.success(formatted_content, data)
end
def pipeline_data(pipeline, top_level: false)
  data = { ... }
  data[:poll_after_seconds] = POLL_AFTER_SECONDS if top_level && ACTIVE_STATUSES.include?(data[:status])
  1. Pin All 13 CI Statuses With RSpec::Parameterized::TableSyntax

Rather than sampling 2 or 3 statuses, we pinned the entire boundary in spec/services/mcp/tools/pipelines/get_pipeline_tool_spec.rb:

where(:status, :hint_expected) do
  :created | true
  :canceling | true
  :scheduled | true
  :preparing | true
  :pending | true
  :running | true
  :waiting_for_callback | true
  :waiting_for_resource | true
  :success | false
  :failed | false
  :canceled | false
  :skipped | false
  :manual | false
end

And remember additive schema compatibility: because poll_after_seconds is an optional additive response field (just like project_full_path was earlier), existing v0.1.0 callers remain 100% backward-compatible without breaking input schemas.

Takeaways for GitLab Engineers

Order domain state checks before permission checks when the domain helper overloads false: Check has_no_commits? (BAD_REQUEST) before CreatePipelineService#allowed? (UNAUTHORIZED).

Compose from existing status constants (::Ci::HasStatus::ALIVE_STATUSES) - and always inspect whether scheduled belongs in "still running / non-terminal" behavior!

Guard recursive serializers (pipeline_data(..., top_level: false)) when adding response-level control hints like poll_after_seconds.

Series Navigation

  • Part 1: Taming EE::Project & Extracting Ai::Catalog (!269060)
  • Part 2: Decoupling EE::Group Routing via Ai::DuoSettings::Paths (!269059)
  • Part 3: Escaping Non-ASCII Git Refs in Workspaces - AppSec CWE-78 & CWE-116 (!262103)
  • Part 4: Exposing Grape Routes to MCP - list_pipeline_schedules & Auth Traps (!264856)
  • Part 5: Vacuous RSpec Mocks & Live Upstream Flag Rebases (!269061)
  • Part 6: Sequential description_edits & Quick-Action Defense in save_work_item (!264862)
  • Part 7: Self-Correcting MCP Errors & JSON Schema Fixes (!264852, !264853, !264864)
  • Part 8 (This Article): Consolidating SavePipelineService & Active Polling Hints (!264858, !264863)
  • Part 9: Surviving Dangerfile, Commit Trailers & Predictive CI Pipelines
  • Part 10: Security Cleanups, Organization empty? & Review Etiquette (!261178, !261030, !264859)
Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.