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

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 

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!

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]) 

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 

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