Skip to content

Jira MCP Server

The Jira MCP server exposes Atlassian Jira’s issue tracking capabilities as MCP tools for AI agents. Built in Go with Authgent JWT verification, it provides enterprise-grade access control with project-level scope enforcement.

What agents can do:

  • Read issues, projects, and search with JQL
  • Create and update issues
  • Add comments to issues
  • All operations enforced by Authgent scopes, including per-project restrictions

Authgent supports hierarchical scopes that restrict access to specific Jira projects:

ScopeGrants
jira:readRead all issues and projects
jira:writeCreate and update issues in all projects
jira:project:ENGRestrict to the ENG project only
jira:project:ENG jira:project:OPSRestrict to ENG and OPS projects

The jira:project:KEY pattern enables fine-grained access. An agent with jira:read jira:project:ENG can only read issues from the ENG project. Without any jira:project:* scope, the agent has access to all projects.

Register scopes in Authgent:

Terminal window
curl -X POST http://localhost:8080/admin/resources \
-H "Content-Type: application/json" \
-d '{
"resource": "http://localhost:8001",
"name": "Jira MCP Server",
"allowed_scopes": ["jira:read", "jira:write", "jira:admin", "jira:project:*"]
}'
// main.go — Jira MCP Server with Authgent
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"github.com/authgent/authgent-go/verifier"
)
// ── Configuration ───────────────────────────────────────
type Config struct {
AuthgentIssuer string
MCPAudience string
JiraBaseURL string
JiraEmail string
JiraAPIToken string
Port string
}
func loadConfig() Config {
return Config{
AuthgentIssuer: envOrDefault("AUTHGENT_ISSUER", "http://localhost:8080"),
MCPAudience: envOrDefault("MCP_AUDIENCE", "http://localhost:8001"),
JiraBaseURL: envOrDefault("JIRA_BASE_URL", "https://yourcompany.atlassian.net"),
JiraEmail: os.Getenv("JIRA_EMAIL"),
JiraAPIToken: os.Getenv("JIRA_API_TOKEN"),
Port: envOrDefault("PORT", "8001"),
}
}
func envOrDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// ── Jira API client ─────────────────────────────────────
type JiraClient struct {
baseURL string
email string
apiToken string
client *http.Client
}
func NewJiraClient(baseURL, email, apiToken string) *JiraClient {
return &JiraClient{
baseURL: strings.TrimRight(baseURL, "/"),
email: email,
apiToken: apiToken,
client: &http.Client{},
}
}
func (j *JiraClient) request(ctx context.Context, method, path string, body io.Reader) ([]byte, error) {
url := fmt.Sprintf("%s/rest/api/3%s", j.baseURL, path)
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, err
}
req.SetBasicAuth(j.email, j.apiToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := j.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("jira API error %d: %s", resp.StatusCode, string(data))
}
return data, nil
}
// ── Scope enforcement ───────────────────────────────────
type Claims struct {
Sub string `json:"sub"`
Scope string `json:"scope"`
Email string `json:"email"`
Name string `json:"name"`
Iss string `json:"iss"`
Aud string `json:"aud"`
}
func getScopes(claims *Claims) []string {
if claims.Scope == "" {
return nil
}
return strings.Split(claims.Scope, " ")
}
func hasScope(claims *Claims, required string) bool {
for _, s := range getScopes(claims) {
if s == required {
return true
}
}
return false
}
func allowedProjects(claims *Claims) []string {
var projects []string
for _, s := range getScopes(claims) {
if strings.HasPrefix(s, "jira:project:") {
projects = append(projects, strings.TrimPrefix(s, "jira:project:"))
}
}
return projects
}
func hasProjectAccess(claims *Claims, projectKey string) bool {
projects := allowedProjects(claims)
if len(projects) == 0 {
// No project restrictions — access all
return true
}
for _, p := range projects {
if strings.EqualFold(p, projectKey) {
return true
}
}
return false
}
func requireScope(claims *Claims, scope string) error {
if !hasScope(claims, scope) {
return fmt.Errorf("insufficient_scope: required %q", scope)
}
return nil
}
func requireProjectAccess(claims *Claims, projectKey string) error {
if !hasProjectAccess(claims, projectKey) {
return fmt.Errorf("insufficient_scope: no access to project %q", projectKey)
}
return nil
}
// ── MCP Tool definitions ────────────────────────────────
type Tool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema interface{} `json:"inputSchema"`
}
var tools = []Tool{
{
Name: "get_issues",
Description: "List issues for a project. Requires jira:read.",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"project_key": map[string]interface{}{"type": "string", "description": "Project key (e.g. ENG)"},
"status": map[string]interface{}{"type": "string", "description": "Filter by status (e.g. To Do, In Progress, Done)"},
"limit": map[string]interface{}{"type": "number", "description": "Max results (default 25)"},
},
"required": []string{"project_key"},
},
},
{
Name: "get_issue",
Description: "Get a single issue by key (e.g. ENG-123). Requires jira:read.",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"issue_key": map[string]interface{}{"type": "string", "description": "Issue key (e.g. ENG-123)"},
},
"required": []string{"issue_key"},
},
},
{
Name: "create_issue",
Description: "Create a new issue. Requires jira:write.",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"project_key": map[string]interface{}{"type": "string", "description": "Project key"},
"summary": map[string]interface{}{"type": "string", "description": "Issue summary"},
"description": map[string]interface{}{"type": "string", "description": "Issue description"},
"issue_type": map[string]interface{}{"type": "string", "description": "Issue type (Task, Bug, Story)"},
"priority": map[string]interface{}{"type": "string", "description": "Priority (Highest, High, Medium, Low, Lowest)"},
},
"required": []string{"project_key", "summary", "issue_type"},
},
},
{
Name: "update_issue",
Description: "Update an existing issue. Requires jira:write.",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"issue_key": map[string]interface{}{"type": "string", "description": "Issue key (e.g. ENG-123)"},
"summary": map[string]interface{}{"type": "string", "description": "New summary"},
"description": map[string]interface{}{"type": "string", "description": "New description"},
"status": map[string]interface{}{"type": "string", "description": "Transition to status"},
},
"required": []string{"issue_key"},
},
},
{
Name: "add_comment",
Description: "Add a comment to an issue. Requires jira:write.",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"issue_key": map[string]interface{}{"type": "string", "description": "Issue key (e.g. ENG-123)"},
"body": map[string]interface{}{"type": "string", "description": "Comment text"},
},
"required": []string{"issue_key", "body"},
},
},
{
Name: "search_issues",
Description: "Search issues using JQL. Requires jira:read.",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"jql": map[string]interface{}{"type": "string", "description": "JQL query string"},
"limit": map[string]interface{}{"type": "number", "description": "Max results (default 25)"},
},
"required": []string{"jql"},
},
},
{
Name: "get_projects",
Description: "List all accessible projects. Requires jira:read.",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
},
}
// ── Tool handlers ───────────────────────────────────────
func handleTool(ctx context.Context, jira *JiraClient, name string, args map[string]interface{}, claims *Claims) (interface{}, error) {
switch name {
case "get_issues":
if err := requireScope(claims, "jira:read"); err != nil {
return nil, err
}
projectKey := args["project_key"].(string)
if err := requireProjectAccess(claims, projectKey); err != nil {
return nil, err
}
limit := 25
if l, ok := args["limit"].(float64); ok {
limit = int(l)
}
jql := fmt.Sprintf("project = %s", projectKey)
if status, ok := args["status"].(string); ok && status != "" {
jql += fmt.Sprintf(" AND status = \"%s\"", status)
}
jql += " ORDER BY updated DESC"
path := fmt.Sprintf("/search?jql=%s&maxResults=%d", jql, limit)
data, err := jira.request(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
var result map[string]interface{}
json.Unmarshal(data, &result)
return result["issues"], nil
case "get_issue":
if err := requireScope(claims, "jira:read"); err != nil {
return nil, err
}
issueKey := args["issue_key"].(string)
parts := strings.SplitN(issueKey, "-", 2)
if len(parts) == 2 {
if err := requireProjectAccess(claims, parts[0]); err != nil {
return nil, err
}
}
data, err := jira.request(ctx, "GET", fmt.Sprintf("/issue/%s", issueKey), nil)
if err != nil {
return nil, err
}
var result map[string]interface{}
json.Unmarshal(data, &result)
return result, nil
case "create_issue":
if err := requireScope(claims, "jira:write"); err != nil {
return nil, err
}
projectKey := args["project_key"].(string)
if err := requireProjectAccess(claims, projectKey); err != nil {
return nil, err
}
payload := map[string]interface{}{
"fields": map[string]interface{}{
"project": map[string]interface{}{"key": projectKey},
"summary": args["summary"],
"issuetype": map[string]interface{}{"name": args["issue_type"]},
},
}
if desc, ok := args["description"].(string); ok && desc != "" {
payload["fields"].(map[string]interface{})["description"] = map[string]interface{}{
"type": "doc",
"version": 1,
"content": []interface{}{
map[string]interface{}{
"type": "paragraph",
"content": []interface{}{
map[string]interface{}{"type": "text", "text": desc},
},
},
},
}
}
if priority, ok := args["priority"].(string); ok && priority != "" {
payload["fields"].(map[string]interface{})["priority"] = map[string]interface{}{"name": priority}
}
body, _ := json.Marshal(payload)
data, err := jira.request(ctx, "POST", "/issue", strings.NewReader(string(body)))
if err != nil {
return nil, err
}
var result map[string]interface{}
json.Unmarshal(data, &result)
return result, nil
case "update_issue":
if err := requireScope(claims, "jira:write"); err != nil {
return nil, err
}
issueKey := args["issue_key"].(string)
parts := strings.SplitN(issueKey, "-", 2)
if len(parts) == 2 {
if err := requireProjectAccess(claims, parts[0]); err != nil {
return nil, err
}
}
fields := map[string]interface{}{}
if summary, ok := args["summary"].(string); ok && summary != "" {
fields["summary"] = summary
}
if desc, ok := args["description"].(string); ok && desc != "" {
fields["description"] = map[string]interface{}{
"type": "doc",
"version": 1,
"content": []interface{}{
map[string]interface{}{
"type": "paragraph",
"content": []interface{}{
map[string]interface{}{"type": "text", "text": desc},
},
},
},
}
}
if len(fields) > 0 {
payload := map[string]interface{}{"fields": fields}
body, _ := json.Marshal(payload)
_, err := jira.request(ctx, "PUT", fmt.Sprintf("/issue/%s", issueKey), strings.NewReader(string(body)))
if err != nil {
return nil, err
}
}
if status, ok := args["status"].(string); ok && status != "" {
transData, err := jira.request(ctx, "GET", fmt.Sprintf("/issue/%s/transitions", issueKey), nil)
if err != nil {
return nil, err
}
var transitions struct {
Transitions []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"transitions"`
}
json.Unmarshal(transData, &transitions)
for _, t := range transitions.Transitions {
if strings.EqualFold(t.Name, status) {
transPayload, _ := json.Marshal(map[string]interface{}{
"transition": map[string]interface{}{"id": t.ID},
})
jira.request(ctx, "POST", fmt.Sprintf("/issue/%s/transitions", issueKey), strings.NewReader(string(transPayload)))
break
}
}
}
return map[string]interface{}{"key": issueKey, "updated": true}, nil
case "add_comment":
if err := requireScope(claims, "jira:write"); err != nil {
return nil, err
}
issueKey := args["issue_key"].(string)
parts := strings.SplitN(issueKey, "-", 2)
if len(parts) == 2 {
if err := requireProjectAccess(claims, parts[0]); err != nil {
return nil, err
}
}
commentBody := args["body"].(string)
payload := map[string]interface{}{
"body": map[string]interface{}{
"type": "doc",
"version": 1,
"content": []interface{}{
map[string]interface{}{
"type": "paragraph",
"content": []interface{}{
map[string]interface{}{"type": "text", "text": commentBody},
},
},
},
},
}
body, _ := json.Marshal(payload)
data, err := jira.request(ctx, "POST", fmt.Sprintf("/issue/%s/comment", issueKey), strings.NewReader(string(body)))
if err != nil {
return nil, err
}
var result map[string]interface{}
json.Unmarshal(data, &result)
return result, nil
case "search_issues":
if err := requireScope(claims, "jira:read"); err != nil {
return nil, err
}
jql := args["jql"].(string)
limit := 25
if l, ok := args["limit"].(float64); ok {
limit = int(l)
}
// Enforce project-level scope in JQL
projects := allowedProjects(claims)
if len(projects) > 0 {
projectFilter := fmt.Sprintf("project IN (%s)", strings.Join(projects, ", "))
jql = fmt.Sprintf("(%s) AND %s", jql, projectFilter)
}
path := fmt.Sprintf("/search?jql=%s&maxResults=%d", jql, limit)
data, err := jira.request(ctx, "GET", path, nil)
if err != nil {
return nil, err
}
var result map[string]interface{}
json.Unmarshal(data, &result)
return result["issues"], nil
case "get_projects":
if err := requireScope(claims, "jira:read"); err != nil {
return nil, err
}
data, err := jira.request(ctx, "GET", "/project", nil)
if err != nil {
return nil, err
}
var projects []map[string]interface{}
json.Unmarshal(data, &projects)
// Filter by allowed projects if scoped
allowed := allowedProjects(claims)
if len(allowed) > 0 {
var filtered []map[string]interface{}
for _, p := range projects {
key, _ := p["key"].(string)
for _, a := range allowed {
if strings.EqualFold(key, a) {
filtered = append(filtered, p)
break
}
}
}
return filtered, nil
}
return projects, nil
default:
return nil, fmt.Errorf("unknown tool: %s", name)
}
}
// ── HTTP Server ─────────────────────────────────────────
func main() {
cfg := loadConfig()
jira := NewJiraClient(cfg.JiraBaseURL, cfg.JiraEmail, cfg.JiraAPIToken)
v, err := verifier.New(verifier.Config{
Issuer: cfg.AuthgentIssuer,
Audience: cfg.MCPAudience,
JWKSURL: fmt.Sprintf("%s/.well-known/jwks.json", cfg.AuthgentIssuer),
})
if err != nil {
log.Fatalf("Failed to create verifier: %v", err)
}
mux := http.NewServeMux()
// OAuth Protected Resource Metadata (RFC 9728)
mux.HandleFunc("GET /.well-known/oauth-protected-resource", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"resource": cfg.MCPAudience,
"authorization_servers": []string{cfg.AuthgentIssuer},
})
})
// MCP endpoint — protected by Authgent JWT verification
mux.Handle("POST /mcp", v.RequireAuth()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claimsRaw := r.Context().Value(verifier.ClaimsKey{})
if claimsRaw == nil {
http.Error(w, `{"error":"missing_claims"}`, http.StatusUnauthorized)
return
}
claimsBytes, _ := json.Marshal(claimsRaw)
var claims Claims
json.Unmarshal(claimsBytes, &claims)
var body struct {
Method string `json:"method"`
Params map[string]interface{} `json:"params"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, `{"error":"invalid_request"}`, http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
if body.Method == "tools/list" {
json.NewEncoder(w).Encode(map[string]interface{}{"tools": tools})
return
}
if body.Method == "tools/call" {
name, _ := body.Params["name"].(string)
args, _ := body.Params["arguments"].(map[string]interface{})
result, err := handleTool(r.Context(), jira, name, args, &claims)
if err != nil {
status := http.StatusInternalServerError
if strings.Contains(err.Error(), "insufficient_scope") {
status = http.StatusForbidden
}
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]interface{}{"error": err.Error()})
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"content": []map[string]interface{}{
{"type": "text", "text": mustMarshal(result)},
},
})
return
}
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]interface{}{"error": "unknown_method"})
})))
log.Printf("Jira MCP server running on http://localhost:%s", cfg.Port)
log.Printf("Authgent issuer: %s", cfg.AuthgentIssuer)
log.Fatal(http.ListenAndServe(":"+cfg.Port, mux))
}
func mustMarshal(v interface{}) string {
data, _ := json.MarshalIndent(v, "", " ")
return string(data)
}
ToolScopeDescription
get_issuesjira:readList issues for a project, filtered by status
get_issuejira:readGet a single issue by key (e.g. ENG-123)
create_issuejira:writeCreate a new issue with summary, type, priority
update_issuejira:writeUpdate issue fields and transition status
add_commentjira:writeAdd a comment to an issue
search_issuesjira:readSearch issues using JQL queries
get_projectsjira:readList all accessible projects
  1. Go to Atlassian API tokens
  2. Click Create API token
  3. Set the token as an environment variable:
Terminal window
export JIRA_EMAIL="your-email@company.com"
export JIRA_API_TOKEN="ATATT3xFfGF0..."
export JIRA_BASE_URL="https://yourcompany.atlassian.net"

For production deployments, use Authgent’s Token Vault instead of a shared API token:

Terminal window
curl -X POST http://localhost:8080/admin/token-vault/providers \
-H "Content-Type: application/json" \
-d '{
"provider": "atlassian",
"client_id": "YOUR_ATLASSIAN_OAUTH_CLIENT_ID",
"client_secret": "YOUR_ATLASSIAN_OAUTH_CLIENT_SECRET",
"authorization_url": "https://auth.atlassian.com/authorize",
"token_url": "https://auth.atlassian.com/oauth/token",
"scopes": ["read:jira-work", "write:jira-work", "read:jira-user"]
}'

Token Vault manages per-user Atlassian OAuth tokens, so each user’s actions in Jira are attributed to their own identity.

Authgent is designed for environments where data residency and audit trails matter:

  • All tokens stay in your perimeter. Authgent runs in your infrastructure — JWTs are signed with your keys, stored in your database, and never transit third-party servers.
  • Jira data never leaves your network. The MCP server calls Jira’s API directly from your infrastructure. No data is routed through external auth providers.
  • Audit trail. Every token Authgent issues includes a jti (JWT ID) that can be correlated with your MCP server’s access logs. You know exactly which user accessed which Jira issues and when.
  • Project-level scoping. The jira:project:KEY scope pattern ensures agents only access the projects they’re authorized for — critical for multi-tenant environments where different teams have different compliance requirements.
  • Token expiration. Authgent issues short-lived tokens (5 minutes by default). Even if a token is intercepted, the blast radius is limited.

For HIPAA deployments:

  • Run Authgent with PostgreSQL (not SQLite) for durable audit logs
  • Enable TLS between all services
  • Configure Authgent’s signing keys with HSM or KMS integration
  • Set token TTL to the minimum acceptable for your use case
  • Enable token revocation and monitor the revocation endpoint