Skip to content

feat: add wait_for_pipeline and wait_for_pipeline_job tools#392

Open
jannahopp wants to merge 1 commit into
zereight:mainfrom
jannahopp:feat/wait-for-pipeline
Open

feat: add wait_for_pipeline and wait_for_pipeline_job tools#392
jannahopp wants to merge 1 commit into
zereight:mainfrom
jannahopp:feat/wait-for-pipeline

Conversation

@jannahopp
Copy link
Copy Markdown
Contributor

Why

MCP clients (AI agents) that trigger or monitor CI pipelines currently have to poll get_pipeline in 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

  • Add wait_for_pipeline — polls GET /projects/:id/pipelines/:pid until the pipeline reaches a terminal state (success, failed, canceled, skipped, manual)
  • Add wait_for_pipeline_job — same pattern for individual jobs via GET /projects/:id/jobs/:jid

Both tools:

  • Send MCP progress notifications to keep the client connection alive during polling
  • Respect AbortSignal — polling stops immediately when the client disconnects or cancels (no leaked polling)
  • Accept configurable interval_seconds (5–60s, default 10) and timeout_seconds (1–3600s, default 600)
  • Support fail_on_error flag (default true) to return isError when the pipeline/job ends in failed/canceled status
  • Are read-only (GET requests only) and available in GITLAB_READ_ONLY_MODE
  • Belong to the pipeline toolset

Test plan

  • Existing test suite passes (npm test)
  • TypeScript builds cleanly (tsc)
  • Tested live against a real GitLab instance — wait_for_pipeline correctly polled a running pipeline and returned the final success state

🤖 Generated with Claude Code

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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_pipeline tool to poll a pipeline until it reaches a terminal state (or times out).
  • Introduces wait_for_pipeline_job tool 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.

Comment thread schemas.ts
Comment on lines +266 to +279
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)"),
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread index.ts
Comment on lines +8112 to +8117
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 =
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread index.ts
Comment on lines +5259 to +5271
if (signal?.aborted) {
reject(signal.reason);
return;
}
const timer = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(signal.reason);
},
{ once: true }
);
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 });
}

Copilot uses AI. Check for mistakes.
Comment thread index.ts
Comment on lines +5305 to +5306
await abortableSleep(intervalSeconds * 1000, signal);

Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment thread index.ts
Comment on lines +5352 to +5353
await abortableSleep(intervalSeconds * 1000, signal);

Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment thread index.ts
Comment on lines +5251 to +5252
const PIPELINE_TERMINAL_STATES = new Set(["success", "failed", "canceled", "skipped", "manual"]);
const JOB_TERMINAL_STATES = new Set(["success", "failed", "canceled", "skipped", "manual"]);
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment thread schemas.ts
Comment on lines +241 to +254
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)"),
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner

@zereight zereight left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

plz check comments!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants