Guide · SaaS Integration

MCP server Jira integration

Jira is one of the most requested MCP tool targets in enterprise environments — agents that can search issues with JQL, create tickets from conversation context, transition issues through workflows, and query Sprint state save significant manual work. The Jira Cloud REST API v3 is well-documented but has several MCP-specific challenges: custom field IDs are workspace-specific, JQL syntax differs from SQL, workflow transitions require knowing available transitions per-issue, and the OAuth2 flow differs from standard providers.

TL;DR

Use HTTP Basic Auth with an Atlassian API token for single-user MCP servers; use Atlassian OAuth2 for multi-user SaaS deployments. JQL is Jira's query language for issue search — learn the key operators (project, assignee, status, sprint, ORDER BY) and expose a JQL tool rather than trying to map every filter to tool parameters. Custom field IDs (customfield_10014) are workspace-specific — build a field-discovery tool or hardcode the mapping per deployment. Jira Cloud REST API v3 is the right target (not v2); the base URL is https://your-domain.atlassian.net/rest/api/3/. Wire AliveMCP to monitor your MCP server's uptime independently of Jira's status.

Setup and authentication

npm install node-fetch zod
# Or use the axios/got HTTP client of your choice — no official Jira JS SDK

API token authentication (simplest — single-user)

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

const JIRA_BASE_URL = process.env.JIRA_BASE_URL!;  // e.g. https://acme.atlassian.net
const JIRA_EMAIL = process.env.JIRA_EMAIL!;          // your Atlassian account email
const JIRA_API_TOKEN = process.env.JIRA_API_TOKEN!;  // from id.atlassian.com/manage-profile/security

const jiraAuth = Buffer.from(`${JIRA_EMAIL}:${JIRA_API_TOKEN}`).toString("base64");

async function jiraRequest(
  method: string,
  path: string,
  body?: unknown,
  params?: Record
): Promise {
  const url = new URL(`${JIRA_BASE_URL}/rest/api/3${path}`);
  if (params) {
    for (const [k, v] of Object.entries(params)) {
      url.searchParams.set(k, String(v));
    }
  }

  const response = await fetch(url.toString(), {
    method,
    headers: {
      Authorization: `Basic ${jiraAuth}`,
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: body ? JSON.stringify(body) : undefined,
  });

  if (!response.ok) {
    const error = await response.json().catch(() => ({ message: response.statusText }));
    throw Object.assign(new Error((error as { message?: string; errorMessages?: string[] }).errorMessages?.[0] ?? (error as { message?: string }).message ?? response.statusText), {
      status: response.status,
      body: error,
    });
  }

  return response.json() as Promise;
}

const server = new McpServer({ name: "jira-tools", version: "1.0.0" });

OAuth2 (multi-user SaaS)

Atlassian's OAuth2 uses the standard authorization code flow, but the token endpoint and scopes are Atlassian-specific:

// OAuth2 authorization URL (send user here)
const authUrl = `https://auth.atlassian.com/authorize?${new URLSearchParams({
  audience: "api.atlassian.com",
  client_id: process.env.ATLASSIAN_CLIENT_ID!,
  scope: "read:jira-work write:jira-work offline_access",
  redirect_uri: process.env.ATLASSIAN_REDIRECT_URI!,
  state: generateCsrfToken(),
  response_type: "code",
  prompt: "consent",
})}`;

// Token exchange
async function exchangeAtlassianCode(code: string) {
  const response = await fetch("https://auth.atlassian.com/oauth/token", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      grant_type: "authorization_code",
      client_id: process.env.ATLASSIAN_CLIENT_ID,
      client_secret: process.env.ATLASSIAN_CLIENT_SECRET,
      code,
      redirect_uri: process.env.ATLASSIAN_REDIRECT_URI,
    }),
  });
  // Returns: { access_token, refresh_token, expires_in, scope, token_type }
  return response.json();
}

// With OAuth2, use Bearer token and the accessible-resources endpoint to get cloud ID
async function getCloudId(accessToken: string): Promise {
  const res = await fetch("https://api.atlassian.com/oauth/token/accessible-resources", {
    headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" },
  });
  const sites = await res.json() as Array<{ id: string; name: string }>;
  return sites[0].id;  // The cloudId for API calls
}

// OAuth2 base URL format: https://api.atlassian.com/ex/jira/{cloudId}/rest/api/3/

JQL search tool

JQL (Jira Query Language) is the most powerful way to expose issue search in an MCP tool. Rather than mapping every filter combination to named parameters, expose a JQL tool and a set of JQL template helpers:

server.tool(
  "search_jira_issues",
  "Search Jira issues using JQL (Jira Query Language)",
  {
    jql: z.string().describe(
      "JQL query string. Examples: " +
      "'project = PROJ AND status = \"In Progress\" AND assignee = currentUser()' | " +
      "'sprint in openSprints() AND status != Done ORDER BY priority DESC' | " +
      "'created >= -7d AND issuetype = Bug'"
    ),
    max_results: z.number().int().min(1).max(50).default(20),
    fields: z.array(z.string()).optional()
      .describe("Fields to return — defaults to key, summary, status, assignee, priority, updated"),
  },
  async ({ jql, max_results, fields }) => {
    const defaultFields = ["key", "summary", "status", "assignee", "priority", "issuetype", "updated", "created"];
    const requestedFields = fields ?? defaultFields;

    try {
      const result = await jiraRequest<{
        issues: Array<{
          id: string;
          key: string;
          fields: Record;
        }>;
        total: number;
        maxResults: number;
      }>("POST", "/search", {
        jql,
        maxResults: max_results,
        startAt: 0,
        fields: requestedFields,
      });

      const issues = result.issues.map(issue => ({
        key: issue.key,
        summary: (issue.fields.summary as string) ?? "(no title)",
        status: (issue.fields.status as { name: string })?.name,
        assignee: (issue.fields.assignee as { displayName: string } | null)?.displayName ?? "Unassigned",
        priority: (issue.fields.priority as { name: string })?.name,
        issue_type: (issue.fields.issuetype as { name: string })?.name,
        updated: issue.fields.updated as string,
        url: `${JIRA_BASE_URL}/browse/${issue.key}`,
      }));

      return {
        content: [{
          type: "text",
          text: JSON.stringify({ issues, total: result.total, returned: issues.length }),
        }],
      };
    } catch (err) {
      return jiraErrorToToolResult(err);
    }
  }
);

Key JQL operators for agent use cases:

JQL operatorExampleUse case
project =project = PROJScope to a specific project
status in ()status in ("In Progress", "In Review")Multi-status filter
assignee = currentUser()Issues assigned to the API user
sprint in openSprints()Issues in active sprints
created >= -7dcreated >= -30dRelative date filtering
labels = "bug"Label-based filtering
ORDER BYORDER BY priority DESC, updated DESCSort results

Issue creation with custom fields

Custom field IDs in Jira are workspace-specific — the same field (e.g., "Story Points") has a different ID in every Jira instance. To find field IDs, call GET /rest/api/3/field and look for the field by name:

server.tool(
  "get_jira_fields",
  "List all Jira field IDs — use this to find custom field IDs for your workspace",
  {},
  async () => {
    const fields = await jiraRequest>(
      "GET", "/field"
    );

    const customFields = fields
      .filter(f => f.id.startsWith("customfield_"))
      .map(f => ({ id: f.id, name: f.name, type: f.schema?.type }));

    return {
      content: [{ type: "text", text: JSON.stringify({ custom_fields: customFields }) }],
    };
  }
);

server.tool(
  "create_jira_issue",
  "Create a Jira issue in a project",
  {
    project_key: z.string().describe("Jira project key (e.g. PROJ, MYAPP)"),
    summary: z.string().max(255),
    issue_type: z.string().default("Task").describe("Issue type name: Bug, Task, Story, Epic, Subtask"),
    description_text: z.string().max(32767).optional().describe("Issue description as plain text"),
    priority: z.enum(["Highest", "High", "Medium", "Low", "Lowest"]).optional(),
    assignee_account_id: z.string().optional()
      .describe("Atlassian account ID of the assignee (get from search_jira_users)"),
    labels: z.array(z.string()).optional(),
    story_points: z.number().optional()
      .describe("Story points — stored in customfield_10016 in most Jira instances"),
    sprint_id: z.number().int().optional()
      .describe("Sprint ID to assign to — get from list_jira_sprints"),
  },
  async ({ project_key, summary, issue_type, description_text, priority, assignee_account_id, labels, story_points, sprint_id }) => {
    const fields: Record = {
      project: { key: project_key },
      summary,
      issuetype: { name: issue_type },
    };

    if (description_text) {
      // Jira Cloud v3 uses Atlassian Document Format (ADF) for descriptions
      fields.description = {
        version: 1,
        type: "doc",
        content: description_text
          .split("\n\n")
          .filter(para => para.trim())
          .map(para => ({
            type: "paragraph",
            content: [{ type: "text", text: para.trim() }],
          })),
      };
    }

    if (priority) fields.priority = { name: priority };
    if (assignee_account_id) fields.assignee = { accountId: assignee_account_id };
    if (labels?.length) fields.labels = labels;
    if (story_points !== undefined) fields.customfield_10016 = story_points;
    if (sprint_id !== undefined) fields.customfield_10020 = { id: sprint_id };

    try {
      const issue = await jiraRequest<{ id: string; key: string; self: string }>(
        "POST", "/issue", { fields }
      );

      return {
        content: [{
          type: "text",
          text: JSON.stringify({
            key: issue.key,
            id: issue.id,
            url: `${JIRA_BASE_URL}/browse/${issue.key}`,
          }),
        }],
      };
    } catch (err) {
      return jiraErrorToToolResult(err);
    }
  }
);

The common custom field IDs that are consistent across most Jira Cloud instances: customfield_10016 (Story Points), customfield_10020 (Sprint), customfield_10014 (Epic Link, deprecated in favor of parent). Always verify field IDs with the get_jira_fields tool for a new workspace.

Workflow transition tools

Moving an issue from "In Progress" to "Done" requires knowing the available transitions for that issue's current state — transitions are workflow-specific and vary per project and issue type:

server.tool(
  "get_jira_transitions",
  "Get available workflow transitions for a Jira issue",
  { issue_key: z.string().describe("Issue key, e.g. PROJ-123") },
  async ({ issue_key }) => {
    const result = await jiraRequest<{ transitions: Array<{ id: string; name: string; to: { name: string } }> }>(
      "GET", `/issue/${issue_key}/transitions`
    );

    return {
      content: [{
        type: "text",
        text: JSON.stringify({
          transitions: result.transitions.map(t => ({
            id: t.id,
            name: t.name,
            to_status: t.to.name,
          })),
        }),
      }],
    };
  }
);

server.tool(
  "transition_jira_issue",
  "Move a Jira issue to a new status via a workflow transition",
  {
    issue_key: z.string(),
    transition_id: z.string().describe("Transition ID from get_jira_transitions"),
    comment: z.string().optional().describe("Comment to add with the transition"),
  },
  async ({ issue_key, transition_id, comment }) => {
    const body: Record = {
      transition: { id: transition_id },
    };

    if (comment) {
      body.update = {
        comment: [{
          add: {
            body: {
              version: 1, type: "doc",
              content: [{ type: "paragraph", content: [{ type: "text", text: comment }] }],
            },
          },
        }],
      };
    }

    try {
      await jiraRequest("POST", `/issue/${issue_key}/transitions`, body);

      return {
        content: [{ type: "text", text: `Transitioned ${issue_key} via transition ${transition_id}` }],
      };
    } catch (err) {
      return jiraErrorToToolResult(err);
    }
  }
);

The correct agent pattern for transitions: call get_jira_transitions first to find available transitions and their IDs, then call transition_jira_issue with the correct transition ID. Don't hardcode transition IDs — they differ between projects and Jira instances.

Sprint management tools

server.tool(
  "list_jira_sprints",
  "List sprints for a Jira board",
  {
    board_id: z.number().int().describe("Jira Agile board ID"),
    state: z.enum(["active", "future", "closed"]).default("active"),
  },
  async ({ board_id, state }) => {
    // Sprint API is in the Agile (Software) API, not the main REST API
    const result = await jiraRequest<{
      values: Array<{
        id: number;
        name: string;
        state: string;
        startDate?: string;
        endDate?: string;
        goal?: string;
      }>;
    }>("GET", `/board/${board_id}/sprint`, undefined, {
      state,
      maxResults: 10,
      startAt: 0,
    });
    // Note: board API uses /rest/agile/1.0/ path, not /rest/api/3/

    return {
      content: [{
        type: "text",
        text: JSON.stringify({
          sprints: result.values.map(s => ({
            id: s.id,
            name: s.name,
            state: s.state,
            start_date: s.startDate,
            end_date: s.endDate,
            goal: s.goal,
          })),
        }),
      }],
    };
  }
);

Sprint and board endpoints use a different base path: /rest/agile/1.0/ instead of /rest/api/3/. Adjust your jiraRequest helper to accept the base path, or create a separate jiraAgileRequest function for Agile API calls.

Error handling and Jira error formats

import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";

function jiraErrorToToolResult(err: unknown): CallToolResult {
  if (err instanceof Error && "status" in err) {
    const status = (err as { status: number }).status;
    const body = (err as { body?: { errorMessages?: string[]; errors?: Record } }).body;

    // Jira returns errors in two formats: errorMessages[] or errors{}
    const errorMessages = body?.errorMessages ?? [];
    const fieldErrors = Object.entries(body?.errors ?? {}).map(([k, v]) => `${k}: ${v}`);
    const allErrors = [...errorMessages, ...fieldErrors];
    const message = allErrors.length > 0 ? allErrors.join("; ") : err.message;

    if (status === 400) {
      return { isError: true, content: [{ type: "text", text: `Jira validation error: ${message}` }] };
    }
    if (status === 401) {
      return { isError: true, content: [{ type: "text", text: "Jira authentication failed — check API token or OAuth2 credentials" }] };
    }
    if (status === 403) {
      return { isError: true, content: [{ type: "text", text: `Jira permission denied: ${message}. The user may not have edit access to this project.` }] };
    }
    if (status === 404) {
      return { isError: true, content: [{ type: "text", text: `Jira resource not found: ${message}. Check the issue key, project key, and board ID.` }] };
    }
    if (status === 429) {
      return { isError: true, content: [{ type: "text", text: "Jira rate limit exceeded — retry after a moment" }] };
    }
    return { isError: true, content: [{ type: "text", text: `Jira error (${status}): ${message}` }] };
  }
  throw err;
}

Jira's 400 errors are the most useful to surface to the LLM — they often indicate bad field values or schema mismatches (wrong issue type name, invalid project key, custom field format error). The errors object maps field names to error messages, which the LLM can use to correct the tool arguments.

Pagination in Jira search

Jira search uses offset-based pagination with startAt and maxResults parameters, plus a total field in the response. The maximum maxResults per request is 100:

async function* paginateJiraSearch(jql: string, maxTotal = 200) {
  let startAt = 0;
  const maxResults = 50;
  let fetched = 0;

  while (true) {
    const result = await jiraRequest<{ issues: unknown[]; total: number }>(
      "POST", "/search",
      { jql, maxResults, startAt, fields: ["key", "summary", "status"] }
    );

    for (const issue of result.issues) {
      yield issue;
      fetched++;
      if (fetched >= maxTotal) return;
    }

    startAt += result.issues.length;
    if (startAt >= result.total || result.issues.length === 0) break;
  }
}

For MCP tools, avoid auto-paginating in a single tool call — instead, return a page of results with total and start_at in the response, and provide a start_at parameter so the agent can request the next page explicitly if needed.

User search tool

Jira Cloud uses Atlassian account IDs (not usernames) for assignee fields. Expose a user search tool so the agent can look up account IDs by display name:

server.tool(
  "search_jira_users",
  "Search for Jira users by name or email to get their account ID",
  {
    query: z.string().describe("Name or email to search"),
    max_results: z.number().int().min(1).max(20).default(10),
  },
  async ({ query, max_results }) => {
    const users = await jiraRequest>("GET", "/user/search", undefined, {
      query,
      maxResults: max_results,
    });

    return {
      content: [{
        type: "text",
        text: JSON.stringify({
          users: users
            .filter(u => u.active)
            .map(u => ({
              account_id: u.accountId,
              display_name: u.displayName,
              email: u.emailAddress,
            })),
        }),
      }],
    };
  }
);

Frequently asked questions

How do I find the board ID for sprint queries?

Call GET /rest/agile/1.0/board?projectKeyOrId=PROJ to list boards for a project. Each board has an id field. For most projects there's one board per project. If there are multiple boards (e.g., a Scrum board and a Kanban board), list them and let the agent pick the right one by name. Store the board ID per project in your configuration rather than querying it on every tool call.

What's the difference between Jira Cloud REST API v2 and v3?

API v3 is the current version and uses Atlassian Document Format (ADF) for text fields (description, comments). ADF is a JSON structure with doc, paragraph, and text nodes rather than plain strings or Markdown. API v2 uses plain text for descriptions. Use v3 for all new integrations — it's the only version with guaranteed long-term support. The key difference for MCP tools is that you must build ADF documents for description and comment fields, not pass plain strings.

How do I handle Jira's rate limits?

Jira Cloud doesn't publish hard rate limit numbers, but it enforces limits based on request volume per user and per organization. If you hit a rate limit, the response will be 429 with a Retry-After header. Use exponential backoff starting at the Retry-After value. In practice, single-user MCP tools rarely hit Jira rate limits unless running batch operations. For high-volume batch operations, add 100–200ms delays between requests. Jira Server/Data Center has different (usually more permissive) rate limits configured by the instance admin.

Can I attach files to Jira issues from an MCP tool?

Yes — use POST /rest/api/3/issue/{issueKey}/attachments with multipart form data. Set the X-Atlassian-Token: no-check header (required for file uploads — disables CSRF protection for this endpoint). The file content must be base64-decoded from the tool argument and sent as a binary multipart part. Be careful about file size — Jira Cloud has a default attachment size limit of 10MB configurable by admins. For large files, upload to a cloud storage service and attach the URL in the description instead.

How do I distinguish between Jira Cloud and Jira Server/Data Center in my MCP server?

The base URL tells you: Jira Cloud instances are at *.atlassian.net, while Jira Server/Data Center instances have custom domains. The API is largely compatible between versions for basic CRUD operations, but there are differences: Cloud uses Atlassian account IDs, Server uses username strings for assignees. ADF (API v3) is Cloud-only — Server uses API v2 with plain text. OAuth2 flows differ (Cloud uses auth.atlassian.com; Server uses the instance-local OAuth). If you need to support both, branch on the base URL pattern.

Further reading

Know when your MCP server is down — before users do

AliveMCP probes your server's MCP endpoint every minute, detects protocol errors and transport failures, and pages you before users notice.

Start monitoring free