Skip to content
Agent Integration14 min read·

Jira agents without the vendor middleware: REST API, OAuth 3LO and your own MCP server

Every page ranking for a Jira AI agent either sells a connector or documents Atlassian's own platform. Building it yourself comes down to four decisions: an API token or OAuth 2.0 3LO, which scopes, which issue actions become tools, and whether those tools sit behind your own MCP server. Each one, with the numbers that decide it.

AI AgentsAgent IntegrationJiraOAuthREST APIMCPRate Limits

Search for how to integrate Jira with an AI agent and page one splits two ways. Connector vendors, each offering a hosted layer that holds your Atlassian credentials and exposes Jira to your agent as a list of actions. And Atlassian's own documentation, for a platform feature that assumes you are building a Forge app. Both are legitimate. Neither tells you how to do it yourself, which for an agent inside one company is usually less work than evaluating the middleware.

Look at what the middleware actually does and it is four jobs: holding a credential, refreshing it, turning REST endpoints into tool definitions, and staying under the rate limits. Each is a few hundred lines. What the middleware costs you, beyond the subscription, is a third party holding a token that can read every issue the person who connected it can read, and a tool surface designed for every customer rather than for your agent.

So this is the build it yourself version, in the order you will make the decisions. It assumes Jira Cloud and an agent core that already exists, which is the setup in the channel by channel post. The comment loop from that post comes back here too, with a twist that only shows up under OAuth.

unused before an OAuth 3LO refresh token stops workingand every refresh rotates it, so the one you just used is already dead
90 days
points an hour in the default pool for a 3LO appshared across every tenant you serve until Atlassian moves you to a per tenant tier
65,000
lifetime of a webhook your app registers through the APIthen events stop arriving, with no error anywhere
30 days
points to read one issueidentity objects cost 2 each, so reading a group of 8 costs 17
2

Decision one: an API token or OAuth 2.0 3LO

An API token is the fast path. Basic auth with an email address and a token, an expiry you set between 1 and 365 days when you create it, and no renewal: when it lapses, somebody makes a new one and updates the secret. It acts as exactly one person, whoever created it. Its traffic is governed by Jira's burst limits, and Atlassian has said the newer points based quota does not apply to it.

OAuth 2.0 three legged authorisation, 3LO in Atlassian's docs, is the other path. Each person connects their own account on a consent screen, the agent acts on their behalf, and the Atlassian documentation is explicit that the user's own permissions bound what the app can do even when its scopes would allow more. It also states that apps which collect API tokens from users, or make customers create their own 3LO app, do not meet Atlassian's security requirements.

API tokenOAuth 2.0 3LO
Who the agent acts asThe one person who made the token, for everybody who talks to the agentEach person who connected, with their own permissions
SetupMinutes: generate it, put it in a secret managerAn app in the developer console, a callback route, a token store and a refresh path
ExpiryFixed at 1 to 365 days, then a manual rotationShort lived access tokens, and a rotating refresh token that dies after 90 days unused
Rate limitsThe existing burst limitsThe points quota enforced since 2 March 2026, plus burst and per issue write limits
What Jira's history showsEvery action by the token's owner, whoever askedEvery action by the person who actually asked
Right forOne internal automation with one job, owned by one team, on a dedicated accountAnything more than one person can ask, and anything you sell

My rule is short. A single purpose automation that one team owns, running on a dedicated account rather than a person's, can use an API token. The moment a second person can ask the agent to do something, it is 3LO, because otherwise every question is a privilege escalation that happens to be polite.

The 3LO flow, including the parts tutorials skip

Four requests get you connected. Send the user to auth.atlassian.com/authorize with the audience set to api.atlassian.com and prompt=consent. Exchange the code at auth.atlassian.com/oauth/token. Call accessible-resources to find which sites the token reaches. Then make every Jira call against api.atlassian.com/ex/jira/{cloudId}, not against the site's own domain.

ts
const AUTH = "https://auth.atlassian.com";

export function authorizeUrl(state: string) {
  const params = new URLSearchParams({
    audience: "api.atlassian.com",
    client_id: process.env.ATLASSIAN_CLIENT_ID!,
    // offline_access is what gets you a refresh token at all. Leave it out
    // and the user reconnects every time the access token expires.
    scope: "read:jira-work write:jira-work read:jira-user offline_access",
    redirect_uri: process.env.ATLASSIAN_REDIRECT_URI!,
    state, // unguessable, bound to this session, compared on the way back
    response_type: "code",
    prompt: "consent",
  });
  return `${AUTH}/authorize?${params}`;
}

export async function sitesFor(accessToken: string) {
  const res = await fetch("https://api.atlassian.com/oauth/token/accessible-resources", {
    headers: { Authorization: `Bearer ${accessToken}` },
  });
  // A list, not a site. One grant can reach several, and the id alone does
  // not say which product it belongs to: check the url and the scopes, and
  // store the cloudId the user actually chose against the connection.
  return (await res.json()) as { id: string; name: string; url: string; scopes: string[] }[];
}

Note what accessible-resources does not tell you. It lists the sites the token can reach, not what the user may do on them, so it is never a permissions check. That job stays with Jira, on every call.

Refresh tokens rotate, so refreshing is a critical section

Every refresh returns a new refresh token and disables the one you sent. Atlassian allows a ten minute reuse interval, so two workers refreshing at once will not always fail, but whichever saves last wins, and if that is the older token your next refresh returns invalid_grant. The same error means the token sat unused for 90 days, or that the user changed their Atlassian password. None of those can be retried. All of them mean the person has to reconnect.

ts
export async function accessToken(connectionId: string) {
  return db.transaction(async (tx) => {
    // FOR UPDATE: one refresh per connection at a time. A worker that loses
    // the race waits here, then reads the token the winner just stored.
    const conn = await tx.one(
      "select * from jira_connections where id = $1 for update",
      [connectionId],
    );
    if (conn.expires_at > Date.now() + 60_000) return conn.access_token;

    const res = await fetch("https://auth.atlassian.com/oauth/token", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        grant_type: "refresh_token",
        client_id: process.env.ATLASSIAN_CLIENT_ID,
        client_secret: process.env.ATLASSIAN_CLIENT_SECRET,
        refresh_token: conn.refresh_token,
      }),
    });

    if (!res.ok) {
      // invalid_grant: idle for 90 days, superseded, or a password change.
      // Not retryable. Mark it and let the agent say so in plain words.
      await tx.none("update jira_connections set status = 'reconnect' where id = $1", [connectionId]);
      throw new ReconnectRequired(connectionId);
    }

    const t = await res.json();
    // The new refresh token replaces the old one in the same transaction.
    await tx.none(
      "update jira_connections set access_token = $2, refresh_token = $3, expires_at = $4 where id = $1",
      [connectionId, t.access_token, t.refresh_token, Date.now() + t.expires_in * 1000],
    );
    return t.access_token;
  });
}

Two consequences to design for. Somebody who connects, uses the agent for a quarterly process and then leaves it alone will find it disconnected next quarter, so the agent needs a graceful you need to reconnect, here is the link rather than a stack trace. And an admin cannot revoke one user's grant from Atlassian's connected apps screen, only uninstall the app for everyone, so give people a disconnect button that deletes their tokens from your side.

Decision two: scopes, and the ones to refuse

Scopes decide what the app may ask for. The user's Jira permissions decide what it gets. You need both to line up, and the scope list is the part a customer's admin reads before approving you, so it is worth the same discipline as the Slack scopes you should refuse to request. Atlassian also offers granular scopes that split these classic ones further, and when the agent's job is narrow they are worth the extra minutes in the developer console.

Classic scopeWhat it unlocksAsk for it when
read:jira-workIssues, comments, search and attachment metadataAlways. Even Atlassian's own remote agent connector will not run without it.
write:jira-workCreating, editing, commenting and transitioning issuesThe agent acts, rather than only answering.
read:jira-userUser lookups and display namesThe agent resolves people, such as assign this to Sara.
manage:jira-webhookRegistering your app's own webhooks through the APIThe agent reacts to events instead of polling for them.
offline_accessA refresh tokenThe agent works when the user is not sitting in front of the consent screen, which is always.
manage:jira-project, manage:jira-configurationProject settings, workflows, schemes and global configurationNever for an agent. A person with an admin role does this, on purpose.

The last row is the one people request because a demo once needed it. An agent that can edit a workflow or a permission scheme is a single successful prompt injection away from changing who can see what, across a project, for everybody. No product feature is worth that sentence on the consent screen.

Decision three: issue actions as tools

Do not hand the model the REST API. Give it a handful of verbs, each one wrapping a call with guards that live in your code rather than in the prompt. Five tools cover most of what a Jira agent does.

ToolREST callThe guard that lives in code
search_issuesPOST /rest/api/3/search/jqlProject allowlist around the model's JQL, a fixed field list, a cap on pages
get_issueGET /rest/api/3/issue/{key}Key format and project checked before the call, fields limited to what the agent uses
add_commentPOST /rest/api/3/issue/{key}/commentYour code builds the ADF, records the comment id, and enforces a per issue write budget
transition_issueGET then POST /rest/api/3/issue/{key}/transitionsAn allowlist of target statuses, resolved to a transition id at call time
create_issuePOST /rest/api/3/issueAllowed projects and issue types only. Under 3LO the reporter is the person who asked

JQL is model output, so treat it like any other

Two things changed under this tool recently. The old /rest/api/3/search endpoint is gone from Jira Cloud, and its replacement, /search/jql, pages with a nextPageToken and no longer returns a total. If your agent used to say there are 412 open bugs, it now has to count them or stop saying it.

The second thing is older and matters more. JQL written by a model is untrusted input. The obvious move is to wrap it in a project filter, and it is worth doing, but it narrows rather than secures, because a model can close your parenthesis for you. So filter the results again on the way out, and remember that under 3LO the real boundary is the user's own permission set.

ts
const ALLOWED_PROJECTS = ["OPS", "SUP"];

export function scopedJql(modelJql: string) {
  // ORDER BY has to stay at the end of the query, so split it off first.
  const [, where = "", orderBy = ""] =
    modelJql.match(/^(.*?)((?:^|\s+)order\s+by\s+.*)?$/is) ?? [];
  const projects = ALLOWED_PROJECTS.map((p) => `"${p}"`).join(", ");
  const filter = where.trim() ? ` AND (${where.trim()})` : "";
  return `project in (${projects})${filter} ${orderBy.trim()}`.trim();
}

export async function searchIssues(conn: Connection, modelJql: string) {
  const res = await jira(conn, "/rest/api/3/search/jql", {
    method: "POST",
    body: JSON.stringify({
      jql: scopedJql(modelJql),
      fields: ["summary", "status", "assignee", "priority", "project"],
      maxResults: 50,
    }),
  });
  const page = await res.json();
  // `x) OR (project = HR` gets straight through the wrapper above. This
  // second check does not care how the query was written.
  return page.issues.filter((i: Issue) => ALLOWED_PROJECTS.includes(i.fields.project.key));
}

Comments are ADF, and under 3LO they are not yours

Version 3 of the API wants comment bodies in Atlassian Document Format, a JSON tree, rather than markdown. Build it in code from the agent's plain text. Do not ask the model to produce ADF, because it will produce something close, and close fails validation.

Then the twist. The loop guard from the channel post drops any comment_created event whose author is the agent's own account. Under 3LO the agent does not have its own account. It comments as the person who asked, so its comments are indistinguishable by author from that person's, and the author check never fires. Recognise your own comments by id instead.

ts
export async function addComment(conn: Connection, key: string, text: string) {
  const res = await jira(conn, `/rest/api/3/issue/${key}/comment`, {
    method: "POST",
    body: JSON.stringify({
      body: {
        type: "doc",
        version: 1,
        content: text.split(/\n{2,}/).map((para) => ({
          type: "paragraph",
          content: [{ type: "text", text: para }],
        })),
      },
    }),
  });
  const comment = await res.json();
  // Authored by the user who asked, so the author check cannot see it is
  // ours. The webhook handler drops any comment id in this table.
  await ownComments.insert({ cloudId: conn.cloudId, commentId: comment.id });
  return comment;
}

Webhooks that quietly expire

A 3LO app registers its webhooks through POST /rest/api/3/webhook, each with a JQL filter that supports only a short list of clauses (project, issuetype, status, assignee and a few more) and only =, !=, IN and NOT IN. You get five per app per user on each site. And each one expires after 30 days unless you extend it with PUT /rest/api/3/webhook/refresh. When it lapses, events simply stop. Your agent does not error. It goes quiet, and the first report is somebody asking why it stopped answering tickets.

Deliveries to an OAuth app carry a bearer token signed with your client secret, so verify it on every request. Jira retries up to five times on a timeout or a 5xx, with each retry five to fifteen minutes after the last, which means a slow handler produces a duplicate answer a quarter of an hour later. Deduplicate on X-Atlassian-Webhook-Identifier.

ts
import { jwtVerify } from "jose";

const secret = new TextEncoder().encode(process.env.ATLASSIAN_CLIENT_SECRET!);

export async function POST(req: Request) {
  const token = req.headers.get("authorization")?.replace(/^Bearer /, "");
  try {
    await jwtVerify(token ?? "", secret);
  } catch {
    return new Response(null, { status: 401 });
  }

  // A retry is the same delivery again. Claim the id or drop the request.
  const deliveryId = req.headers.get("x-atlassian-webhook-identifier");
  if (deliveryId && !(await deliveries.claim(deliveryId))) {
    return new Response(null, { status: 200 });
  }

  const event = await req.json();
  if (event.webhookEvent === "comment_created" && (await ownComments.has(event.comment.id))) {
    return new Response(null, { status: 200 }); // our own words, coming back
  }

  // Acknowledge now, work later. The payload is a summary of the issue,
  // so the worker fetches it fresh before reasoning about its state.
  await queue.push(event);
  return new Response(null, { status: 202 });
}

Pair that with a scheduled job that extends every registered webhook weekly and alerts when an extension fails. It is twenty lines, and it is the difference between an agent that works in month two and one that stopped on day thirty-one.

Rate limits are now a design input

Since 2 March 2026 Jira enforces a points based hourly quota on Forge, Connect and 3LO apps, alongside per second burst limits on each endpoint and a write limit per issue. A request costs one point plus its objects: an issue read costs two, identity objects such as users and groups cost two each, and writes cost one. The default tier is a global pool of 65,000 points an hour shared across every tenant your app serves. Per tenant pools, sized by plan and user count, come after a review.

For an agent that matters more than for a typical integration, because agents are chatty. A loop that re-reads the same issue on every step, or lists the members of a group to resolve one name, spends points fast, and in the shared pool one busy customer can spend everybody's hour. When the quota is gone there is no gradual slowdown: every request is refused until the top of the next UTC hour. The 429 carries a RateLimit-Reason header, and each reason wants a different response.

RateLimit-ReasonWhat happenedWhat the agent does
jira-quota-global-based or jira-quota-tenant-basedThe hourly points quota is spentStops every Jira call until the reset, and tells users the agent is paused rather than broken
jira-burst-basedToo many requests per second to one endpointSlows that endpoint only, honouring Retry-After
jira-per-issue-on-writeMore than 20 writes in 2 seconds, or 100 in 30, to one issueSpaces writes to that issue. Also check why the agent is writing that often, because it is usually a loop

The cheap defences are boring. Request explicit field lists. Cache issue reads within a single agent turn. Keep your own per tenant budget a little below what Jira gives you, so your code makes the decision before Jira does. And read the headers on successful responses too, because X-RateLimit-NearLimit tells you about the problem before it is a 429.

Decision four: your own MCP server, or Atlassian's

Atlassian runs a remote MCP server for Jira and Confluence, connected with OAuth 2.1. For a developer wiring Jira into their own coding assistant it is the right answer: it is personal, a human reads each tool call, and there is nothing to host. For an agent you ship to other people it is the wrong shape. Its tool surface is broad and general, because it is built for every client, and your guards cannot live inside it.

Your own MCP server is the five tools above, behind the protocol, holding each user's 3LO connection. The guards, the allowlists, the audit log and the rate limit budget all live in one place, and the same tools serve your agent core and any MCP client your team uses. It is about a day of work on top of the tools themselves.

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

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

server.registerTool(
  "transition_issue",
  {
    description:
      "Move an issue to one of the allowed statuses. Call get_issue first to see its current status.",
    inputSchema: {
      key: z.string().regex(/^[A-Z][A-Z0-9]+-\d+$/),
      // Done is deliberately missing. Closing work is a person's decision.
      to: z.enum(["In Progress", "In Review", "Blocked"]),
    },
  },
  async ({ key, to }, extra) => {
    const conn = await connectionFor(extra.authInfo); // the asking user's own grant
    assertAllowedProject(key);

    const path = `/rest/api/3/issue/${key}/transitions`;
    const { transitions } = await (await jira(conn, path)).json();
    const match = transitions.find((t: Transition) => t.to.name === to);
    if (!match) {
      // An error the model can act on, not a status code.
      const available = transitions.map((t: Transition) => t.to.name).join(", ");
      return {
        isError: true,
        content: [{ type: "text", text: `${key} cannot move to ${to} from its current status. Available: ${available}.` }],
      };
    }

    await jira(conn, path, { method: "POST", body: JSON.stringify({ transition: { id: match.id } }) });
    await audit.record({ tool: "transition_issue", key, to, accountId: conn.accountId });
    return { content: [{ type: "text", text: `${key} moved to ${to}.` }] };
  },
);

Two details in there carry most of the reliability. Transitions are resolved by target status name at call time, because transition ids differ between workflows and a hardcoded id works on one project and fails on the next. And the failure returns a sentence listing what is possible, which is the errors that teach pattern: the model corrects itself on the next turn instead of retrying the same call.

Flowchart: a Jira event, from webhook to an action the user could have taken
  1. A webhook delivery arrives

    Verify the bearer token against your client secret before reading the body.

  2. Seen this delivery id, or this comment id, before?

    ↳ yes? acknowledge and drop: a retry, or the agent's own words

  3. Acknowledge, then queue

    A slow handler earns a retry fifteen minutes later.

  4. Does the asking user have a live 3LO connection?

    ↳ no? reply with a reconnect link, not an error

  5. Fetch the issue fresh, with an explicit field list

    The webhook payload is a summary. Two points, and worth them.

  6. Tools run as the user, inside the allowlists

    Search wrapped and filtered, transitions by name, comments recorded by id.

  7. Audit: who asked, which tools ran, what changed

  8. Never: act with more access than the person who asked

    A shared token makes every request an escalation, however politely it is phrased.

Every guard on this path is in your code. None of them depends on the model behaving.

What it costs to build

PieceTypical effort
3LO flow, token store and locked refresh2 days
Five tools with allowlists, ADF and an audit record2 to 3 days
Webhook registration, verification, dedupe and the extension job1 day
Rate limit handling by reason, with a per tenant budgetHalf a day
MCP server around the tools1 day

Call it two weeks with testing, which matches the estimate in the channel post, and none of it waits on anybody else's approval unless you distribute the app beyond your own organisation. Against that, the middleware saves you perhaps the first three rows, and charges you every month for holding the token that the rest of this post was about protecting.

If what you want is for people to assign work to your agent the way they assign it to a colleague, Atlassian now has a platform feature for that, and it is the subject of the next post. It sits on top of everything here rather than replacing it: you still need the permission model, the tools and the rate limit discipline.

A Jira agent should be able to do exactly what the person asking could do, through that person's grant, with an audit line for every change. The middleware sells you the connection. The value is in everything around it.

If you want a Jira agent that triages, comments and moves work without a vendor holding your credentials, tell me what it has to do and on which projects. I will come back with the scope list, the tool design and a fixed price. Start that conversation here, or see the wider picture on the AI capabilities page.

Questions people ask

How do I integrate Jira with an AI agent without third party middleware?

Make four decisions yourself: OAuth 2.0 3LO instead of a shared API token, a minimal scope list, a handful of issue actions wrapped as guarded tools, and optionally your own MCP server around those tools. The build is roughly two weeks with testing: two days for the 3LO flow and token store, two to three for the tools, one for webhooks, half a day for rate limits and one for the MCP server. See AI agent integration by channel for the wider architecture.

Which Jira OAuth scopes does an AI agent need?

Most Jira agents need read:jira-work, plus write:jira-work if the agent acts, read:jira-user to resolve people, manage:jira-webhook to react to events, and offline_access to receive a refresh token at all. Never request manage:jira-project or manage:jira-configuration for an agent, because one successful prompt injection could change workflows or permission schemes for a whole project. Atlassian's granular scopes are worth using when the agent's job is narrow.

Should I use Atlassian's remote MCP server or build my own Jira MCP server?

Use Atlassian's remote MCP server for a developer connecting Jira to their own coding assistant, where a human reads each tool call and nothing needs hosting. Build your own for an agent you ship to other people, because Atlassian's tool surface is broad and your guards cannot live inside it. Your own server wraps your guarded tools, holds each user's 3LO connection, and keeps allowlists, audit logging and rate limit budgets in one place, for about a day of extra work.

How should a Jira AI agent handle 429 rate limit errors?

Read the RateLimit-Reason header and respond per reason. For jira-quota-global-based or jira-quota-tenant-based, stop all Jira calls until the next UTC hour and tell users the agent is paused. For jira-burst-based, slow that endpoint and honour Retry-After. For jira-per-issue-on-write, meaning over 20 writes in 2 seconds or 100 in 30 to one issue, space the writes and check for a loop. Watch X-RateLimit-NearLimit on successful responses too.

Building something like this?

I design and ship these systems for clients: retrieval over private data, agents that complete real tasks, and the Laravel platforms underneath them.

Keep reading