Jira MCP Server
Overview
Section titled “Overview”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
Project-level scope enforcement
Section titled “Project-level scope enforcement”Authgent supports hierarchical scopes that restrict access to specific Jira projects:
| Scope | Grants |
|---|---|
jira:read | Read all issues and projects |
jira:write | Create and update issues in all projects |
jira:project:ENG | Restrict to the ENG project only |
jira:project:ENG jira:project:OPS | Restrict 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:
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:*"] }'Complete server implementation
Section titled “Complete server implementation”// main.go — Jira MCP Server with Authgentpackage 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)}Available tools
Section titled “Available tools”| Tool | Scope | Description |
|---|---|---|
get_issues | jira:read | List issues for a project, filtered by status |
get_issue | jira:read | Get a single issue by key (e.g. ENG-123) |
create_issue | jira:write | Create a new issue with summary, type, priority |
update_issue | jira:write | Update issue fields and transition status |
add_comment | jira:write | Add a comment to an issue |
search_issues | jira:read | Search issues using JQL queries |
get_projects | jira:read | List all accessible projects |
Atlassian API token setup
Section titled “Atlassian API token setup”- Go to Atlassian API tokens
- Click Create API token
- Set the token as an environment variable:
export JIRA_EMAIL="your-email@company.com"export JIRA_API_TOKEN="ATATT3xFfGF0..."export JIRA_BASE_URL="https://yourcompany.atlassian.net"Token Vault integration
Section titled “Token Vault integration”For production deployments, use Authgent’s Token Vault instead of a shared API token:
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.
HIPAA and regulated industry notes
Section titled “HIPAA and regulated industry notes”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:KEYscope 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