Guide · MCP CI/CD Integration
MCP Server Jenkins — trigger builds, get logs, and manage jobs via REST API
Jenkins is the most widely deployed self-hosted CI/CD platform. This guide covers building TypeScript MCP tools that connect to Jenkins over its REST API: authenticating with a user + API token, handling CSRF crumb headers, triggering parameterized builds, polling for build status, retrieving console output, canceling running builds, and exposing a /health/jenkins endpoint so AliveMCP can detect Jenkins outages before agent-triggered deployments stall.
TL;DR
Authenticate Jenkins API calls with HTTP Basic auth using your username and an API token (not your password). Always fetch a CSRF crumb from /crumbIssuer/api/json and include it as a request header on every POST. Use /job/:name/buildWithParameters for parameterized builds; poll /job/:name/lastBuild/api/json until the result field is non-null for completion. Retrieve logs from /job/:name/:buildNumber/consoleText. Wire AliveMCP to /health/jenkins to catch the most common failure mode: a Jenkins restart that invalidates the CSRF crumb.
Authentication: API token and CSRF crumb
Jenkins uses HTTP Basic authentication with the user's login name and a separately generated API token. Never use the Jenkins password — API tokens can be revoked individually and don't change when the user changes their password. Generate a token at https://<jenkins>/user/<username>/configure under "API Token".
Jenkins's CSRF protection requires a "crumb" on every state-changing POST request. The crumb is a short-lived token tied to the authenticated session. Fetch it once at MCP server startup and cache it; re-fetch when a POST returns HTTP 403 (crumb expired).
import axios, { AxiosInstance } from 'axios';
const JENKINS_URL = process.env.JENKINS_URL ?? 'http://localhost:8080';
const JENKINS_USER = process.env.JENKINS_USER!;
const JENKINS_TOKEN = process.env.JENKINS_TOKEN!; // API token, not password
// Singleton axios instance with Basic auth pre-wired
const http: AxiosInstance = axios.create({
baseURL: JENKINS_URL,
auth: { username: JENKINS_USER, password: JENKINS_TOKEN },
timeout: 15_000
});
// Cache the CSRF crumb; re-fetch when Jenkins restarts
let crumbCache: { headerName: string; crumb: string } | null = null;
async function getCrumb(): Promise<{ headerName: string; crumb: string }> {
if (crumbCache) return crumbCache;
const res = await http.get('/crumbIssuer/api/json');
crumbCache = { headerName: res.data.crumbRequestField, crumb: res.data.crumb };
return crumbCache;
}
// Helper: POST with CSRF crumb header
async function jenkinsPost(path: string, params?: Record) {
const { headerName, crumb } = await getCrumb();
try {
return await http.post(path, null, {
params,
headers: { [headerName]: crumb }
});
} catch (err: unknown) {
// 403 may mean stale crumb — clear cache and retry once
if (axios.isAxiosError(err) && err.response?.status === 403) {
crumbCache = null;
const fresh = await getCrumb();
return http.post(path, null, {
params,
headers: { [fresh.headerName]: fresh.crumb }
});
}
throw err;
}
}
The crumb invalidation retry in jenkinsPost handles the most common real-world failure: Jenkins restarted or the session expired, generating a new crumb. Without this retry, the first POST after a Jenkins restart returns a cryptic 403 that looks like an auth failure.
trigger_build and trigger_parameterized_build tools
Jenkins exposes two build trigger endpoints: /job/:name/build for jobs with no parameters, and /job/:name/buildWithParameters for parameterized builds. The response to a trigger POST is HTTP 201 with a Location header pointing to a queue item — not to the build itself. Poll the queue item to get the eventual build number.
import { z } from 'zod';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
server.tool(
'trigger_jenkins_build',
{
job_name: z.string().min(1), // e.g. 'my-pipeline' or 'folder/my-job'
parameters: z.record(z.string()).optional(), // key-value build parameters
wait_for_start: z.boolean().default(true) // poll queue until build number is assigned
},
async ({ job_name, parameters, wait_for_start }) => {
const encodedJob = job_name.split('/').map(encodeURIComponent).join('/job/');
const path = parameters && Object.keys(parameters).length > 0
? `/job/${encodedJob}/buildWithParameters`
: `/job/${encodedJob}/build`;
const res = await jenkinsPost(path, parameters);
const queueUrl = res.headers['location']; // e.g. http://jenkins/queue/item/42/
if (!wait_for_start || !queueUrl) {
return {
content: [{
type: 'text',
text: JSON.stringify({ queued: true, queue_url: queueUrl ?? null })
}]
};
}
// Extract queue item number from Location header and poll for build number
const queueId = queueUrl.match(/\/queue\/item\/(\d+)\//)?.[1];
if (!queueId) {
return {
content: [{ type: 'text', text: JSON.stringify({ queued: true, queue_url: queueUrl }) }]
};
}
// Poll queue item until Jenkins assigns a build number (executor picks it up)
let buildNumber: number | null = null;
const deadline = Date.now() + 60_000;
while (!buildNumber && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 2000));
const queueItem = await http.get(`/queue/item/${queueId}/api/json`);
buildNumber = queueItem.data.executable?.number ?? null;
}
return {
content: [{
type: 'text',
text: JSON.stringify({
job_name,
build_number: buildNumber,
queue_id: queueId,
status: buildNumber ? 'started' : 'queued_no_executor'
}, null, 2)
}]
};
}
);
Nested jobs (in folders) use the path pattern /job/folder-name/job/inner-job/. The encoding above splits on / and joins with /job/ — this handles one level of folder nesting. For deeply nested pipelines, call job_name with the full path and the encoder handles it correctly.
get_build_status and get_build_logs tools
Build status comes from the /api/json sub-path of any build URL. The result field is null while the build is running and becomes SUCCESS, FAILURE, ABORTED, or UNSTABLE on completion. Console output (the full build log) is a plain-text endpoint at /consoleText.
server.tool(
'get_jenkins_build_status',
{
job_name: z.string().min(1),
build_number: z.union([z.number().int().positive(), z.literal('lastBuild'), z.literal('lastSuccessfulBuild')]).default('lastBuild')
},
async ({ job_name, build_number }) => {
const encodedJob = job_name.split('/').map(encodeURIComponent).join('/job/');
const res = await http.get(
`/job/${encodedJob}/${build_number}/api/json?tree=number,result,inProgress,duration,timestamp,url,displayName`
);
const build = res.data;
return {
content: [{
type: 'text',
text: JSON.stringify({
job: job_name,
number: build.number,
result: build.result ?? (build.inProgress ? 'IN_PROGRESS' : 'UNKNOWN'),
in_progress: build.inProgress,
duration_ms: build.duration,
started_at: new Date(build.timestamp).toISOString(),
url: build.url
}, null, 2)
}]
};
}
);
server.tool(
'get_jenkins_build_logs',
{
job_name: z.string().min(1),
build_number: z.union([z.number().int().positive(), z.literal('lastBuild')]).default('lastBuild'),
tail_lines: z.number().int().min(1).max(500).default(100)
},
async ({ job_name, build_number, tail_lines }) => {
const encodedJob = job_name.split('/').map(encodeURIComponent).join('/job/');
const res = await http.get(
`/job/${encodedJob}/${build_number}/consoleText`,
{ responseType: 'text' }
);
const lines: string[] = (res.data as string).split('\n');
const tail = lines.slice(-tail_lines).join('\n');
return {
content: [{
type: 'text',
text: JSON.stringify({
job: job_name,
build_number,
total_lines: lines.length,
returned_lines: Math.min(tail_lines, lines.length),
log: tail
}, null, 2)
}]
};
}
);
The ?tree= query parameter in Jenkins's API is a projection — it limits which fields are returned, reducing response size. For large pipelines with many stages, always use ?tree= to avoid receiving megabytes of stage detail JSON when you only need the top-level build result.
list_jobs and cancel_build tools
server.tool(
'list_jenkins_jobs',
{
folder: z.string().optional() // omit for top-level; 'my-folder' for folder contents
},
async ({ folder }) => {
const basePath = folder
? `/job/${encodeURIComponent(folder)}`
: '';
const res = await http.get(
`${basePath}/api/json?tree=jobs[name,color,url,lastBuild[number,result,timestamp]]`
);
const jobs = (res.data.jobs ?? []).map((j: Record) => ({
name: j.name,
status: j.color, // 'blue'=passing, 'red'=failing, 'grey'=never built, 'disabled'
url: j.url,
last_build: j.lastBuild
? {
number: (j.lastBuild as Record).number,
result: (j.lastBuild as Record).result,
ran_at: new Date((j.lastBuild as Record).timestamp as number).toISOString()
}
: null
}));
return {
content: [{ type: 'text', text: JSON.stringify({ jobs, count: jobs.length }, null, 2) }]
};
}
);
server.tool(
'cancel_jenkins_build',
{
job_name: z.string().min(1),
build_number: z.union([z.number().int().positive(), z.literal('lastBuild')]).default('lastBuild'),
confirm: z.literal(true)
},
async ({ job_name, build_number }) => {
const encodedJob = job_name.split('/').map(encodeURIComponent).join('/job/');
await jenkinsPost(`/job/${encodedJob}/${build_number}/stop`);
return {
content: [{ type: 'text', text: JSON.stringify({ cancelled: true, job: job_name, build_number }) }]
};
}
);
Jenkins's color field on jobs encodes both status and animation state. 'blue' is the last build was successful; 'blue_anime' means the last build was successful and a new build is currently running. 'red' = failing, 'red_anime' = failing with an in-progress run, 'grey' = never built or disabled.
Health endpoint: /health/jenkins
import express from 'express';
const app = express();
app.get('/health/jenkins', async (_req, res) => {
const start = Date.now();
try {
// GET / api/json with a minimal tree projection — verifies auth + connectivity
await http.get('/api/json?tree=mode,nodeDescription');
res.status(200).json({
status: 'ok',
latency_ms: Date.now() - start,
jenkins_url: JENKINS_URL
});
} catch (err) {
const axErr = err as { response?: { status?: number }; message?: string };
const httpStatus = axErr.response?.status;
res.status(503).json({
status: 'error',
http_status: httpStatus,
error: axErr.message,
latency_ms: Date.now() - start
});
}
});
app.listen(3001);
| Failure mode | HTTP status from /health | Root cause |
|---|---|---|
| Jenkins unreachable | 503 — ECONNREFUSED | Jenkins process down or VPN/firewall block |
| Bad credentials | 503 — HTTP 401 | API token revoked or username wrong |
| CSRF crumb stale | 503 — HTTP 403 | Jenkins restarted; crumb cache must be cleared |
| Jenkins starting up | 503 — HTTP 503 | Jenkins is booting; wait and retry |
Frequently asked questions
How do I connect to Jenkins behind a reverse proxy or VPN?
Set JENKINS_URL to the internal URL (e.g., http://jenkins.internal:8080). If Jenkins is behind a TLS-terminating reverse proxy, use the external HTTPS URL — but also set Jenkins URL in Manage Jenkins → System → Jenkins Location to the same public URL. Without this, the Location header in build trigger responses will contain the internal URL, breaking queue item polling when your MCP server is outside the VPN.
Do I need to disable CSRF protection to avoid crumb handling?
No — don't disable CSRF protection. It prevents cross-site request forgery attacks and is enabled by default for good reason. The crumb fetch-and-cache pattern in this guide adds at most one extra HTTP call at startup (and on restart). The retry on 403 handles the only real operational problem: crumb expiry. Disabling CSRF is a security regression for a problem that has a safe solution.
How do I trigger builds in a multibranch pipeline?
Multibranch pipeline jobs create sub-jobs for each branch. The branch job path is /job/<pipeline-name>/job/<branch-name>/. URL-encode the branch name (e.g., feature/my-thing becomes feature%2Fmy-thing). Pass this as job_name using the full slash-separated path — the encoder in the trigger_jenkins_build tool above handles it with the split('/').map(encodeURIComponent).join('/job/') pattern.
What are Jenkins API rate limits?
Jenkins has no built-in API rate limits — requests are bounded by the server's thread pool and JVM heap. However, aggressive polling (polling build status every second for many concurrent builds) can exhaust Jetty's thread pool and slow down Jenkins for other users. For MCP tools, poll at 2–5 second intervals and cap the polling window at 60–120 seconds. If a build takes longer than the cap, return the current status and let the caller re-invoke the status tool later.