feat: add wait_for_pipeline and wait_for_pipeline_job tools#392
feat: add wait_for_pipeline and wait_for_pipeline_job tools#392jannahopp wants to merge 1 commit into
Conversation
Add two new tools that poll GitLab pipelines/jobs until they reach a terminal state (success, failed, canceled, skipped, manual). This saves MCP clients from having to implement their own polling loops. Key design decisions: - Server-side polling with MCP progress notifications to keep connection alive - AbortSignal support: polling stops immediately on client disconnect/cancel - Configurable interval (5-60s, default 10s) and timeout (1-3600s, default 600s) - fail_on_error flag to return isError on failed/canceled status - Both tools are read-only and included in the pipeline toolset Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds server-side “wait” tools so MCP clients can block on GitLab CI completion without implementing their own polling loops, while supporting progress notifications and cancellation.
Changes:
- Introduces
wait_for_pipelinetool to poll a pipeline until it reaches a terminal state (or times out). - Introduces
wait_for_pipeline_jobtool to poll an individual job until it reaches a terminal state (or times out). - Wires both tools into the pipelines toolset and read-only mode, and threads MCP per-request “extra” context into tool execution for AbortSignal/progress notifications.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
| schemas.ts | Adds Zod input schemas for the two new wait tools (interval/timeout/fail_on_error). |
| index.ts | Registers new tools and implements polling + abortable sleep + progress notification handling. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| interval_seconds: z | ||
| .number() | ||
| .min(5) | ||
| .max(60) | ||
| .optional() | ||
| .default(10) | ||
| .describe("Polling interval in seconds (default: 10, min: 5, max: 60)"), | ||
| timeout_seconds: z | ||
| .number() | ||
| .min(1) | ||
| .max(3600) | ||
| .optional() | ||
| .default(600) | ||
| .describe("Maximum time to wait in seconds (default: 600, max: 3600)"), |
There was a problem hiding this comment.
Same as above for the job variant: consider z.coerce.number() (and potentially .int()) for interval_seconds and timeout_seconds so callers that pass numeric strings don't fail Zod validation.
| case "wait_for_pipeline": { | ||
| const { project_id, pipeline_id, interval_seconds, timeout_seconds, fail_on_error } = | ||
| WaitForPipelineSchema.parse(params.arguments); | ||
| const progressToken = params._meta?.progressToken; | ||
| const signal = extra?.signal as AbortSignal | undefined; | ||
| const sendProgress = |
There was a problem hiding this comment.
New tool behavior (polling, timeout, fail_on_error, and AbortSignal cancellation) isn't covered by the existing mock-tool tests. Adding a small mock-based test suite for wait_for_pipeline/wait_for_pipeline_job (including a status progression and a timeout case) would help prevent regressions.
| if (signal?.aborted) { | ||
| reject(signal.reason); | ||
| return; | ||
| } | ||
| const timer = setTimeout(resolve, ms); | ||
| signal?.addEventListener( | ||
| "abort", | ||
| () => { | ||
| clearTimeout(timer); | ||
| reject(signal.reason); | ||
| }, | ||
| { once: true } | ||
| ); |
There was a problem hiding this comment.
abortableSleep adds an abort event listener but never removes it when the timeout resolves, so repeated polling iterations can accumulate listeners until the signal aborts (and can also invoke the abort handler after the promise has already resolved). Consider registering an onAbort handler and removing it on both resolve and abort; also reject with a normalized AbortError (rather than signal.reason) so the callers' e?.name === "AbortError" handling is reliable even if the abort reason is a string/undefined.
| if (signal?.aborted) { | |
| reject(signal.reason); | |
| return; | |
| } | |
| const timer = setTimeout(resolve, ms); | |
| signal?.addEventListener( | |
| "abort", | |
| () => { | |
| clearTimeout(timer); | |
| reject(signal.reason); | |
| }, | |
| { once: true } | |
| ); | |
| let timer: NodeJS.Timeout; | |
| const onAbort = () => { | |
| if (!signal) return; | |
| clearTimeout(timer); | |
| signal.removeEventListener("abort", onAbort); | |
| const error = new Error("The operation was aborted."); | |
| error.name = "AbortError"; | |
| reject(error); | |
| }; | |
| if (signal?.aborted) { | |
| onAbort(); | |
| return; | |
| } | |
| timer = setTimeout(() => { | |
| if (signal) { | |
| signal.removeEventListener("abort", onAbort); | |
| } | |
| resolve(); | |
| }, ms); | |
| if (signal) { | |
| signal.addEventListener("abort", onAbort, { once: true }); | |
| } |
| await abortableSleep(intervalSeconds * 1000, signal); | ||
|
|
There was a problem hiding this comment.
The polling loop can exceed timeout_seconds by up to one full interval_seconds because it always sleeps the full interval even when close to the deadline. To keep the timeout semantics accurate, compute remaining time and sleep min(interval, remaining) (and break without sleeping when remaining <= 0).
| await abortableSleep(intervalSeconds * 1000, signal); | |
| const remainingMs = deadline - Date.now(); | |
| if (remainingMs <= 0) { | |
| break; | |
| } | |
| const sleepMs = Math.min(intervalSeconds * 1000, remainingMs); | |
| await abortableSleep(sleepMs, signal); |
| await abortableSleep(intervalSeconds * 1000, signal); | ||
|
|
There was a problem hiding this comment.
Same timeout overshoot issue here: the loop can run longer than timeout_seconds because it sleeps a full interval_seconds without considering the remaining time until the deadline. Sleeping for min(interval, remaining) keeps the upper bound respected.
| await abortableSleep(intervalSeconds * 1000, signal); | |
| const now = Date.now(); | |
| const remainingMs = deadline - now; | |
| if (remainingMs <= 0) { | |
| break; | |
| } | |
| const sleepMs = Math.min(intervalSeconds * 1000, remainingMs); | |
| await abortableSleep(sleepMs, signal); |
| const PIPELINE_TERMINAL_STATES = new Set(["success", "failed", "canceled", "skipped", "manual"]); | ||
| const JOB_TERMINAL_STATES = new Set(["success", "failed", "canceled", "skipped", "manual"]); |
There was a problem hiding this comment.
PIPELINE_TERMINAL_STATES and JOB_TERMINAL_STATES are currently identical. Consider using a single shared constant (or helper) to avoid drift if the terminal-state set is adjusted later.
| const PIPELINE_TERMINAL_STATES = new Set(["success", "failed", "canceled", "skipped", "manual"]); | |
| const JOB_TERMINAL_STATES = new Set(["success", "failed", "canceled", "skipped", "manual"]); | |
| const TERMINAL_STATE_VALUES = ["success", "failed", "canceled", "skipped", "manual"] as const; | |
| const PIPELINE_TERMINAL_STATES = new Set(TERMINAL_STATE_VALUES); | |
| const JOB_TERMINAL_STATES = new Set(TERMINAL_STATE_VALUES); |
| interval_seconds: z | ||
| .number() | ||
| .min(5) | ||
| .max(60) | ||
| .optional() | ||
| .default(10) | ||
| .describe("Polling interval in seconds (default: 10, min: 5, max: 60)"), | ||
| timeout_seconds: z | ||
| .number() | ||
| .min(1) | ||
| .max(3600) | ||
| .optional() | ||
| .default(600) | ||
| .describe("Maximum time to wait in seconds (default: 600, max: 3600)"), |
There was a problem hiding this comment.
interval_seconds/timeout_seconds are defined as z.number(). Many MCP clients may serialize numeric inputs as strings; using z.coerce.number() (and potentially .int()) would make the tool input more forgiving while still enforcing min/max bounds.
Why
MCP clients (AI agents) that trigger or monitor CI pipelines currently have to poll
get_pipelinein a loop, burning tool calls and requiring every client to implement the same retry logic. A server-side wait tool lets the client fire a single tool call and get back the final result.Summary
wait_for_pipeline— pollsGET /projects/:id/pipelines/:piduntil the pipeline reaches a terminal state (success,failed,canceled,skipped,manual)wait_for_pipeline_job— same pattern for individual jobs viaGET /projects/:id/jobs/:jidBoth tools:
interval_seconds(5–60s, default 10) andtimeout_seconds(1–3600s, default 600)fail_on_errorflag (default true) to returnisErrorwhen the pipeline/job ends infailed/canceledstatusGITLAB_READ_ONLY_MODEpipelinetoolsetTest plan
npm test)tsc)wait_for_pipelinecorrectly polled a running pipeline and returned the finalsuccessstate🤖 Generated with Claude Code