DEV Community

Pavan Kumar Sadashiv
Pavan Kumar Sadashiv

Posted on Edited on

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::Tools::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::Tools::Base::Response.error(
      message, reason: ::Mcp::Tools::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::Tools::Base::Response.error(
      'Cannot create a pipeline for this merge request: no commits to build.',
      reason: ::Mcp::Tools::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::Tools::Base::Response.error(
      'Insufficient permissions to create pipeline',
      reason: ::Mcp::Tools::Base::Response::Reason::UNAUTHORIZED
    )
  end
  ...
Enter fullscreen mode Exit fullscreen mode

Look closely at the exact order of security checks:

  1. Resolve project (requiring exactly one of url or project_id).
  2. Lookup merge_request and gate with :read_merge_request—returning a non-leaky NOT_FOUND ("Merge request not found or inaccessible.").
  3. Verify granular OAuth token scope (authorize_granular_token!(project, [:create_merge_request_pipeline], ...)).
  4. 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.
  5. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!

2. 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::Tools::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])
Enter fullscreen mode Exit fullscreen mode

3. 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
Enter fullscreen mode Exit fullscreen mode

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

  1. Order domain state checks before permission checks when the domain helper overloads false: Check has_no_commits? (BAD_REQUEST) before CreatePipelineService#allowed? (UNAUTHORIZED).
  2. Compose from existing status constants (::Ci::HasStatus::ALIVE_STATUSES)—and always inspect whether scheduled belongs in "still running / non-terminal" behavior!
  3. Guard recursive serializers (pipeline_data(..., top_level: false)) when adding response-level control hints like poll_after_seconds.

Series Navigation

Top comments (0)