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)
Top comments (0)