Jira MCP Server
by George5562
- src
#!/usr/bin/env node
/**
* IMPORTANT: Check README.md first for project configuration, team structure, and usage examples
*
* PaddockPal Jira MCP Server
*
* Available Tools:
* 1. list_issue_types: List all available issue types in Jira
* - No parameters required
*
* 2. get_user: Get a user's account ID by email address
* - Required: email (string)
*
* 3. create_project: Create a new Jira project
* - Required: key (string), name (string), projectTypeKey (string), leadAccountId (string)
* - Optional: description (string), projectTemplateKey (string)
*
* 4. create_issue: Create a new Jira issue or subtask
* - Required: projectKey (string), summary (string), issueType (string)
* - Optional: description (string), assignee (string), labels (string[]),
* components (string[]), priority (string), parent (string)
*
*/
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ErrorCode,
ListToolsRequestSchema,
McpError,
} from "@modelcontextprotocol/sdk/types.js";
import JiraClient from "jira-client";
/**
* Default project configuration from README.md
*/
const DEFAULT_PROJECT = {
KEY: "CPG",
ID: "10000",
NAME: "Website MVP",
TYPE: "software",
ENTITY_ID: "e01e939e-8442-4967-835d-362886c653e3",
};
/**
* Default project manager configuration from README.md
*/
const DEFAULT_MANAGER = {
EMAIL: "ghsstephens@gmail.com",
ACCOUNT_ID: "712020:dc572395-3fef-4ee3-a31c-2e1b288c72d6",
NAME: "George",
};
interface JiraField {
id: string;
name: string;
required: boolean;
schema: {
type: string;
system?: string;
custom?: string;
customId?: number;
};
}
interface JiraIssueType {
id: string;
name: string;
fields: Record<string, JiraField>;
}
/**
* Environment variables required for Jira API authentication:
* - JIRA_HOST: Jira instance hostname (e.g., paddock.atlassian.net)
* - JIRA_EMAIL: User's email address for authentication
* - JIRA_API_TOKEN: API token from https://id.atlassian.com/manage-profile/security/api-tokens
*/
const JIRA_HOST = process.env.JIRA_HOST;
const JIRA_EMAIL = process.env.JIRA_EMAIL;
const JIRA_API_TOKEN = process.env.JIRA_API_TOKEN;
if (!JIRA_HOST || !JIRA_EMAIL || !JIRA_API_TOKEN) {
throw new Error(
"Missing required environment variables: JIRA_HOST, JIRA_EMAIL, and JIRA_API_TOKEN are required"
);
}
/**
* Interface definitions for Jira API requests
*/
/**
* Arguments for creating a new Jira issue or subtask
* @property projectKey - Key of the project to create the issue in
* @property summary - Issue title/summary
* @property issueType - Type of issue (e.g., "Task", "Story", "Subtask")
* @property description - Optional detailed description
* @property assignee - Optional email of user to assign
* @property labels - Optional array of labels to apply
* @property components - Optional array of component names
* @property priority - Optional priority level
* @property parent - Optional parent issue key (required for subtasks)
*/
interface CreateIssueArgs {
projectKey: string;
summary: string;
issueType: string;
description?: string;
assignee?: string;
labels?: string[];
components?: string[];
priority?: string;
parent?: string;
}
interface GetIssuesArgs {
projectKey: string;
jql?: string;
}
interface UpdateIssueArgs {
issueKey: string;
summary?: string;
description?: string;
assignee?: string;
status?: string;
priority?: string;
}
interface CreateIssueLinkArgs {
inwardIssueKey: string;
outwardIssueKey: string;
linkType: string;
}
/**
* Arguments for getting a user's account ID
* @property email - Email address of the user to look up
*/
interface GetUserArgs {
email: string;
}
/**
* Represents a Jira issue type with its properties
* @property id - Unique identifier for the issue type
* @property name - Display name of the issue type
* @property description - Optional description of when to use this type
* @property subtask - Whether this is a subtask type
*/
interface IssueType {
id: string;
name: string;
description?: string;
subtask: boolean;
}
/**
* Converts plain text to Atlassian Document Format (ADF)
* Used for formatting issue descriptions in Jira's rich text format
* @param text - Plain text to convert to ADF
* @returns ADF document object with the text content
*/
function convertToADF(text: string) {
const lines = text.split("\n");
const content: any[] = [];
let currentList: any = null;
let currentListType: "bullet" | "ordered" | null = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const nextLine = lines[i + 1] || "";
// Skip empty lines between paragraphs
if (line.trim() === "") {
currentList = null;
currentListType = null;
continue;
}
// Handle bullet points
if (line.trim().startsWith("- ")) {
const listItem = line.trim().substring(2);
if (currentListType !== "bullet") {
currentList = {
type: "bulletList",
content: [],
};
content.push(currentList);
currentListType = "bullet";
}
currentList.content.push({
type: "listItem",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: listItem,
},
],
},
],
});
continue;
}
// Handle numbered lists
if (/^\d+\.\s/.test(line.trim())) {
const listItem = line.trim().replace(/^\d+\.\s/, "");
if (currentListType !== "ordered") {
currentList = {
type: "orderedList",
content: [],
};
content.push(currentList);
currentListType = "ordered";
}
currentList.content.push({
type: "listItem",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: listItem,
},
],
},
],
});
continue;
}
// Handle headings (lines ending with ":")
if (line.trim().endsWith(":") && nextLine.trim() === "") {
content.push({
type: "heading",
attrs: { level: 3 },
content: [
{
type: "text",
text: line.trim(),
},
],
});
continue;
}
// Regular paragraph
currentList = null;
currentListType = null;
content.push({
type: "paragraph",
content: [
{
type: "text",
text: line,
},
],
});
}
return {
version: 1,
type: "doc",
content,
};
}
class JiraServer {
private readonly server: Server;
private readonly jira: JiraClient;
private readonly toolDefinitions = {
delete_issue: {
description: "Delete a Jira issue or subtask",
inputSchema: {
type: "object",
properties: {
issueKey: {
type: "string",
description: "Key of the issue to delete",
},
},
required: ["issueKey"],
},
},
get_issues: {
description: "Get all issues and subtasks for a project",
inputSchema: {
type: "object",
properties: {
projectKey: {
type: "string",
description: 'Project key (e.g., "PP")',
},
jql: {
type: "string",
description: "Optional JQL to filter issues",
},
},
required: ["projectKey"],
},
},
update_issue: {
description: "Update an existing Jira issue",
inputSchema: {
type: "object",
properties: {
issueKey: {
type: "string",
description: "Key of the issue to update",
},
summary: {
type: "string",
description: "New summary/title",
},
description: {
type: "string",
description: "New description",
},
assignee: {
type: "string",
description: "Email of new assignee",
},
status: {
type: "string",
description: "New status",
},
priority: {
type: "string",
description: "New priority",
},
},
required: ["issueKey"],
},
},
list_fields: {
description: "List all available Jira fields",
inputSchema: {
type: "object",
properties: {},
required: [],
},
},
list_issue_types: {
description: "List all available issue types",
inputSchema: {
type: "object",
properties: {},
required: [],
},
},
list_link_types: {
description: "List all available issue link types",
inputSchema: {
type: "object",
properties: {},
required: [],
},
},
get_user: {
description: "Get a user's account ID by email address",
inputSchema: {
type: "object",
properties: {
email: {
type: "string",
description: "User's email address",
},
},
required: ["email"],
},
},
create_issue: {
description: "Create a new Jira issue",
inputSchema: {
type: "object",
properties: {
projectKey: {
type: "string",
description: 'Project key (e.g., "PP")',
},
summary: {
type: "string",
description: "Issue summary/title",
},
issueType: {
type: "string",
description: 'Type of issue (e.g., "Task", "Bug", "Story")',
},
description: {
type: "string",
description: "Detailed description of the issue",
},
assignee: {
type: "string",
description: "Email of the assignee",
},
labels: {
type: "array",
items: {
type: "string",
},
description: "Array of labels to apply",
},
components: {
type: "array",
items: {
type: "string",
},
description: "Array of component names",
},
priority: {
type: "string",
description: "Issue priority",
},
},
required: ["projectKey", "summary", "issueType"],
},
},
create_issue_link: {
description: "Create a link between two issues",
inputSchema: {
type: "object",
properties: {
inwardIssueKey: {
type: "string",
description: "Key of the inward issue (e.g., blocked issue)",
},
outwardIssueKey: {
type: "string",
description: "Key of the outward issue (e.g., blocking issue)",
},
linkType: {
type: "string",
description: "Type of link (e.g., 'blocks')",
},
},
required: ["inwardIssueKey", "outwardIssueKey", "linkType"],
},
},
};
constructor() {
this.server = new Server(
{
name: "jira-server",
version: "0.1.0",
},
{
capabilities: {
tools: this.toolDefinitions,
},
}
);
// Initialize Jira client
this.jira = new JiraClient({
protocol: "https",
host: JIRA_HOST as string,
username: JIRA_EMAIL as string,
password: JIRA_API_TOKEN as string,
apiVersion: "3",
strictSSL: true,
});
this.setupToolHandlers();
// Error handling
this.server.onerror = (error) => console.error("[MCP Error]", error);
process.on("SIGINT", async () => {
await this.server.close();
process.exit(0);
});
}
private validateDeleteIssueArgs(args: unknown): args is { issueKey: string } {
if (typeof args !== "object" || args === null) {
throw new McpError(
ErrorCode.InvalidParams,
"Arguments must be an object"
);
}
const { issueKey } = args as { issueKey?: string };
if (typeof issueKey !== "string" || issueKey.length === 0) {
throw new McpError(
ErrorCode.InvalidParams,
"Issue key is required and must be a string"
);
}
return true;
}
private validateCreateIssueArgs(args: unknown): args is CreateIssueArgs {
if (typeof args !== "object" || args === null) {
throw new McpError(
ErrorCode.InvalidParams,
"Arguments must be an object"
);
}
const { projectKey, summary, issueType } = args as Partial<CreateIssueArgs>;
if (typeof projectKey !== "string" || projectKey.length === 0) {
throw new McpError(
ErrorCode.InvalidParams,
"Project key is required and must be a string"
);
}
if (typeof summary !== "string" || summary.length === 0) {
throw new McpError(
ErrorCode.InvalidParams,
"Summary is required and must be a string"
);
}
if (typeof issueType !== "string" || issueType.length === 0) {
throw new McpError(
ErrorCode.InvalidParams,
"Issue type is required and must be a string"
);
}
return true;
}
private validateGetUserArgs(args: unknown): args is GetUserArgs {
if (typeof args !== "object" || args === null) {
throw new McpError(
ErrorCode.InvalidParams,
"Arguments must be an object"
);
}
const { email } = args as Partial<GetUserArgs>;
if (typeof email !== "string" || email.length === 0) {
throw new McpError(
ErrorCode.InvalidParams,
"Email is required and must be a string"
);
}
return true;
}
private validateGetIssuesArgs(args: unknown): args is GetIssuesArgs {
if (typeof args !== "object" || args === null) {
throw new McpError(
ErrorCode.InvalidParams,
"Arguments must be an object"
);
}
const { projectKey } = args as Partial<GetIssuesArgs>;
if (typeof projectKey !== "string" || projectKey.length === 0) {
throw new McpError(
ErrorCode.InvalidParams,
"Project key is required and must be a string"
);
}
return true;
}
private validateUpdateIssueArgs(args: unknown): args is UpdateIssueArgs {
if (typeof args !== "object" || args === null) {
throw new McpError(
ErrorCode.InvalidParams,
"Arguments must be an object"
);
}
const { issueKey } = args as Partial<UpdateIssueArgs>;
if (typeof issueKey !== "string" || issueKey.length === 0) {
throw new McpError(
ErrorCode.InvalidParams,
"Issue key is required and must be a string"
);
}
return true;
}
private validateCreateIssueLinkArgs(
args: unknown
): args is CreateIssueLinkArgs {
if (typeof args !== "object" || args === null) {
throw new McpError(
ErrorCode.InvalidParams,
"Arguments must be an object"
);
}
const { inwardIssueKey, outwardIssueKey, linkType } =
args as Partial<CreateIssueLinkArgs>;
if (typeof inwardIssueKey !== "string" || inwardIssueKey.length === 0) {
throw new McpError(
ErrorCode.InvalidParams,
"Inward issue key is required and must be a string"
);
}
if (typeof outwardIssueKey !== "string" || outwardIssueKey.length === 0) {
throw new McpError(
ErrorCode.InvalidParams,
"Outward issue key is required and must be a string"
);
}
if (typeof linkType !== "string" || linkType.length === 0) {
throw new McpError(
ErrorCode.InvalidParams,
"Link type is required and must be a string"
);
}
return true;
}
private setupToolHandlers(): void {
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: Object.entries(this.toolDefinitions).map(([name, def]) => ({
name,
...def,
})),
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
switch (request.params.name) {
case "list_link_types": {
const response = await this.jira.listIssueLinkTypes();
return {
content: [
{
type: "text",
text: JSON.stringify(response, null, 2),
},
],
};
}
case "list_issue_types": {
const response = await this.jira.listIssueTypes();
return {
content: [
{
type: "text",
text: JSON.stringify(
response.map((type: IssueType) => ({
id: type.id,
name: type.name,
description: type.description,
subtask: type.subtask,
})),
null,
2
),
},
],
};
}
case "get_issues": {
if (
!request.params.arguments ||
typeof request.params.arguments !== "object"
) {
throw new McpError(
ErrorCode.InvalidParams,
"Arguments are required"
);
}
const unknownArgs = request.params.arguments as unknown;
this.validateGetIssuesArgs(unknownArgs);
const args = unknownArgs as GetIssuesArgs;
const jql = args.jql
? `project = ${args.projectKey} AND ${args.jql}`
: `project = ${args.projectKey}`;
const response = await this.jira.searchJira(jql, {
maxResults: 100,
fields: [
"summary",
"description",
"status",
"priority",
"assignee",
"issuetype",
"parent",
"subtasks",
],
});
return {
content: [
{
type: "text",
text: JSON.stringify(response.issues, null, 2),
},
],
};
}
case "update_issue": {
if (
!request.params.arguments ||
typeof request.params.arguments !== "object"
) {
throw new McpError(
ErrorCode.InvalidParams,
"Arguments are required"
);
}
const unknownArgs = request.params.arguments as unknown;
this.validateUpdateIssueArgs(unknownArgs);
const args = unknownArgs as UpdateIssueArgs;
const updateFields: any = {};
if (args.summary) {
updateFields.summary = args.summary;
}
if (args.description) {
updateFields.description = convertToADF(args.description);
}
if (args.assignee) {
const users = await this.jira.searchUsers({
query: args.assignee,
includeActive: true,
maxResults: 1,
});
if (users && users.length > 0) {
updateFields.assignee = { accountId: users[0].accountId };
}
}
if (args.status) {
const transitions = await this.jira.listTransitions(
args.issueKey
);
const transition = transitions.transitions.find(
(t: any) => t.name.toLowerCase() === args.status?.toLowerCase()
);
if (transition) {
await this.jira.transitionIssue(args.issueKey, {
transition: { id: transition.id },
});
}
}
if (args.priority) {
updateFields.priority = { name: args.priority };
}
if (Object.keys(updateFields).length > 0) {
await this.jira.updateIssue(args.issueKey, {
fields: updateFields,
});
}
return {
content: [
{
type: "text",
text: JSON.stringify(
{
message: "Issue updated successfully",
issue: {
key: args.issueKey,
url: `https://${JIRA_HOST}/browse/${args.issueKey}`,
},
},
null,
2
),
},
],
};
}
case "get_user": {
if (
!request.params.arguments ||
typeof request.params.arguments !== "object"
) {
throw new McpError(
ErrorCode.InvalidParams,
"Arguments are required"
);
}
const unknownArgs = request.params.arguments as unknown;
this.validateGetUserArgs(unknownArgs);
const args = unknownArgs as GetUserArgs;
const response = await this.jira.searchUsers({
query: args.email,
includeActive: true,
maxResults: 1,
});
if (!response || response.length === 0) {
return {
content: [
{
type: "text",
text: `No user found with email: ${args.email}`,
},
],
isError: true,
};
}
return {
content: [
{
type: "text",
text: JSON.stringify(
{
accountId: response[0].accountId,
displayName: response[0].displayName,
emailAddress: response[0].emailAddress,
},
null,
2
),
},
],
};
}
case "create_issue": {
if (
!request.params.arguments ||
typeof request.params.arguments !== "object"
) {
throw new McpError(
ErrorCode.InvalidParams,
"Arguments are required"
);
}
const unknownArgs = request.params.arguments as unknown;
this.validateCreateIssueArgs(unknownArgs);
const args = unknownArgs as CreateIssueArgs;
const projectKey = args.projectKey || DEFAULT_PROJECT.KEY;
const assignee = args.assignee || DEFAULT_MANAGER.EMAIL;
const response = await this.jira.addNewIssue({
fields: {
project: { key: projectKey },
summary: args.summary,
issuetype: { name: args.issueType },
description: args.description
? convertToADF(args.description)
: undefined,
assignee: { accountId: assignee },
labels: args.labels,
components: args.components?.map((name) => ({ name })),
priority: args.priority ? { name: args.priority } : undefined,
parent: args.parent ? { key: args.parent } : undefined,
},
});
return {
content: [
{
type: "text",
text: JSON.stringify(
{
message: "Issue created successfully",
issue: {
id: response.id,
key: response.key,
url: `https://${JIRA_HOST}/browse/${response.key}`,
},
},
null,
2
),
},
],
};
}
case "delete_issue": {
if (
!request.params.arguments ||
typeof request.params.arguments !== "object"
) {
throw new McpError(
ErrorCode.InvalidParams,
"Arguments are required"
);
}
const unknownArgs = request.params.arguments as unknown;
this.validateDeleteIssueArgs(unknownArgs);
const { issueKey } = unknownArgs as { issueKey: string };
await this.jira.deleteIssue(issueKey);
return {
content: [
{
type: "text",
text: JSON.stringify(
{
message: "Issue deleted successfully",
issueKey,
},
null,
2
),
},
],
};
}
case "create_issue_link": {
if (
!request.params.arguments ||
typeof request.params.arguments !== "object"
) {
throw new McpError(
ErrorCode.InvalidParams,
"Arguments are required"
);
}
const unknownArgs = request.params.arguments as unknown;
this.validateCreateIssueLinkArgs(unknownArgs);
const args = unknownArgs as CreateIssueLinkArgs;
await this.jira.issueLink({
inwardIssue: { key: args.inwardIssueKey },
outwardIssue: { key: args.outwardIssueKey },
type: { name: args.linkType },
});
return {
content: [
{
type: "text",
text: JSON.stringify(
{
message: "Issue link created successfully",
link: {
inward: args.inwardIssueKey,
outward: args.outwardIssueKey,
type: args.linkType,
},
},
null,
2
),
},
],
};
}
default:
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${request.params.name}`
);
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error occurred";
return {
content: [
{ type: "text", text: `Operation failed: ${errorMessage}` },
],
isError: true,
};
}
});
}
public async run(): Promise<void> {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error("Jira MCP server running on stdio");
}
}
const jiraServer = new JiraServer();
jiraServer.run().catch((error: Error) => console.error(error));