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), inspectresponse.message, and runresponse.message.include?('permission')to map HTTP/MCP error codes (UNAUTHORIZEDvsBAD_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:
- Resolve
project(requiring exactly one ofurlorproject_id). - Lookup
merge_requestand gate with:read_merge_request—returning a non-leakyNOT_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?) asBAD_REQUESTbefore checkingcreate_pipeline_service.allowed?, becauseCreatePipelineService#allowed?itself returnsfalseon empty MRs even for maintainers! Ordering step 4 ahead of step 5 avoids misreporting a no-commits400as a permissions401/403. - Reuse existing
pipeline_response(pipeline, action: 'create')cleanly—sharing output formatting withperform_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
- Order domain state checks before permission checks when the domain helper overloads
false: Checkhas_no_commits?(BAD_REQUEST) beforeCreatePipelineService#allowed?(UNAUTHORIZED). - Compose from existing status constants (
::Ci::HasStatus::ALIVE_STATUSES)—and always inspect whetherscheduledbelongs in "still running / non-terminal" behavior! - Guard recursive serializers (
pipeline_data(..., top_level: false)) when adding response-level control hints likepoll_after_seconds.
Series Navigation
- Part 1: Taming
EE::Project& ExtractingAi::Catalog(!269060) - Part 2: Decoupling
EE::GroupRouting viaAi::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 insave_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)
