MCP Server for Jira: Search and Create Issues from Claude or Cursor
Updated 2026-10-15
There are two routes to Jira over MCP:
- Atlassian’s official remote MCP server (part of Atlassian’s Rovo offering), which you add to a supported client and authorize with OAuth in the browser. Easiest if your client and Atlassian plan support it; check Atlassian’s current docs for the endpoint.
- A small self-hosted server using a Jira API token, which works with any MCP client and gives you full control over what the agent can do. That’s what this guide builds.
Prerequisites
- A Jira Cloud site (
https://your-site.atlassian.net) - An API token from id.atlassian.com/manage-profile/security/api-tokens
- Python 3.10+ and
uv
Steps
1. Create the project
uv init jira-mcp && cd jira-mcp
uv add "mcp[cli]" httpx
2. Write the server
# server.py
import os, httpx
from mcp.server.fastmcp import FastMCP
BASE = os.environ["JIRA_URL"].rstrip("/") # https://your-site.atlassian.net
AUTH = (os.environ["JIRA_EMAIL"], os.environ["JIRA_API_TOKEN"])
mcp = FastMCP("jira")
def api(method: str, path: str, **kw):
r = httpx.request(method, f"{BASE}/rest/api/3{path}", auth=AUTH,
headers={"Accept": "application/json"}, timeout=20, **kw)
r.raise_for_status()
return r.json() if r.content else {}
@mcp.tool()
def search_issues(jql: str, max_results: int = 20) -> str:
"""Search Jira with JQL, e.g. 'project = WEB AND status != Done ORDER BY updated DESC'."""
data = api("GET", "/search/jql", params={"jql": jql, "maxResults": max_results,
"fields": "summary,status,assignee,priority"})
lines = []
for i in data.get("issues", []):
f = i["fields"]
who = (f.get("assignee") or {}).get("displayName", "unassigned")
lines.append(f'{i["key"]} [{f["status"]["name"]}] {f["summary"]} ({who})')
return "\n".join(lines) or "no issues"
@mcp.tool()
def get_issue(key: str) -> str:
"""Get one issue's summary, status and description as text."""
f = api("GET", f"/issue/{key}", params={"fields": "summary,status,description"})["fields"]
def text(node):
if not node: return ""
if node.get("type") == "text": return node.get("text", "")
return "".join(text(c) for c in node.get("content", [])) + ("\n" if node.get("type") == "paragraph" else "")
return f'{key}: {f["summary"]}\nStatus: {f["status"]["name"]}\n\n{text(f.get("description"))}'
@mcp.tool()
def create_issue(project_key: str, summary: str, description: str, issue_type: str = "Task") -> str:
"""Create a Jira issue and return its key."""
body = {"fields": {
"project": {"key": project_key}, "summary": summary, "issuetype": {"name": issue_type},
"description": {"type": "doc", "version": 1, "content": [
{"type": "paragraph", "content": [{"type": "text", "text": description}]}]}}}
return api("POST", "/issue", json=body)["key"]
if __name__ == "__main__":
mcp.run()
Jira Cloud API v3 uses Atlassian Document Format (ADF) for descriptions, which is why create_issue wraps the text in a doc node. Search uses the /rest/api/3/search/jql endpoint, which replaced the older /rest/api/3/search.
3. Register it
{
"mcpServers": {
"jira": {
"command": "uv",
"args": ["--directory", "/ABSOLUTE/PATH/jira-mcp", "run", "server.py"],
"env": {
"JIRA_URL": "https://your-site.atlassian.net",
"JIRA_EMAIL": "[email protected]",
"JIRA_API_TOKEN": "your-token"
}
}
}
}
4. Try it
“List my open bugs in project WEB, then create a task to add tests for the top one.”
Common errors
401 Unauthorized: wrong email/token pair, or you used your password instead of an API token.
400 with "Operation value must be an Atlassian Document": you sent a plain string as description. Use ADF as shown.
Search returns an error saying the endpoint was removed: you are calling the removed /rest/api/3/search endpoint. Switch to /search/jql.
issuetype: Specify a valid issue type: issue type names vary by project (“Story”, “Bug”, “Task”). Ask the agent to search an existing issue first, or list types via /issue/createmeta.