Jira MCP Server

  • src
#!/usr/bin/env node import axios, {AxiosRequestConfig} from 'axios'; import {Server} from "@modelcontextprotocol/sdk/server/index.js"; import {StdioServerTransport} from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; /** * Configure your Jira instance credentials and URL. */ const JIRA_URL = process.env.JIRA_URL; const JIRA_API_MAIL = process.env.JIRA_API_MAIL; const JIRA_API_KEY = process.env.JIRA_API_KEY; /** * Create an MCP server to handle JQL queries. */ const server = new Server( { name: "Jira communication server", version: "0.1.0", }, { capabilities: { tools: {}, }, } ); /** * Handler for listing available tools. * Provides a tool to execute a JQL query against Jira. */ server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "execute_jql", description: "Execute a JQL query on Jira on the api /rest/api/3/search", inputSchema: { type: "object", properties: { jql: { type: "string", description: "JQL query string" }, number_of_results: { type: "integer", description: "Number of results to return", default: 1, } }, required: ["jql"] } }, //as the previous tool gets everything in the ticket, we can create a new tool to get only the ticket name and description to fit more in the context of the assistant { name: "get_only_ticket_name_and_description", description: "Get the name and description of the requested tickets on the api /rest/api/3/search", inputSchema: { type: "object", properties: { jql: { type: "string", description: "JQL query string" }, number_of_results: { type: "integer", description: "Number of results to return", default: 1, } }, required: ["jql"] } }, { name: 'create_ticket', description: 'Create a ticket on Jira on the api /rest/api/3/issue', inputSchema: { type: 'object', properties: { project: { type: 'object', properties: { key: { type: 'string', description: 'The project key' } }, required: ['key'] }, summary: { type: 'string', description: 'The summary of the ticket' }, description: { type: 'string', description: 'The description of the ticket' }, issuetype: { type: 'object', properties: { name: { type: 'string', description: 'The name of the issue type' } }, required: ['name'] }, parent: { type: 'string', description: 'The key of the parent ticket (the epic)' } }, required: ['project', 'summary', 'description', 'issuetype'] } }, //liste les projets { name: 'list_projects', description: 'List all the projects on Jira on the api /rest/api/3/project', inputSchema: { type: 'object', properties: { number_of_results: { type: 'integer', description: 'Number of results to return', default: 1 } } } }, //delete a ticket { name: 'delete_ticket', description: 'Delete a ticket on Jira on the api /rest/api/3/issue/{issueIdOrKey}', inputSchema: { type: 'object', properties: { issueIdOrKey: { type: 'string', description: 'The issue id or key' } }, required: ['issueIdOrKey'] } }, //edit ticket : name, description, assignee, priority, labels, components, custom fields { name: 'edit_ticket', description: 'Edit a ticket on Jira on the api /rest/api/3/issue/{issueIdOrKey}', inputSchema: { type: 'object', properties: { issueIdOrKey: { type: 'string', description: 'The issue id or key' }, summary: { type: 'string', description: 'The summary of the ticket' }, description: { type: 'string', description: 'The description of the ticket' }, labels: { type: 'array', items: { type: 'string' }, description: 'The labels of the ticket' }, parent: { type: 'string', description: 'The key of the parent ticket (the epic)' } }, required: ['issueIdOrKey'] } }, //get all status { name: 'get_all_statuses', description: 'Get all the status on Jira on the api /rest/api/3/status', inputSchema: { type: 'object', properties: { number_of_results: { type: 'integer', description: 'Number of results to return', default: 1 } } } }, //assign ticket { name: 'assign_ticket', description: 'Assign a ticket on Jira on the api /rest/api/3/issue/{issueIdOrKey}/assignee', inputSchema: { type: 'object', properties: { accountId: { type: 'string', description: 'The account id of the assignee' }, issueIdOrKey: { type: 'string', description: 'The issue id or key' } }, required: ['accountId', 'issueIdOrKey'] } }, //query assignables to ticket { name: 'query_assignable', description: 'Query assignables to a ticket on Jira on the api /rest/api/3/user/assignable/search?project={project-name}', inputSchema: { type: 'object', properties: { project_key: { type: 'string', description: 'The id of the project to search' } }, required: ['project_key'] } }, { name: 'add_attachment_from_public_url', description: 'Add an attachment from a public url to a ticket on Jira on the api /rest/api/3/issue/{issueIdOrKey}/attachments', inputSchema: { type: 'object', properties: { issueIdOrKey: { type: 'string', description: 'The issue id or key' }, imageUrl: { type: 'string', description: 'The URL of the image to attach' } }, required: ['issueIdOrKey', 'imageUrl'] } }, { name: 'add_attachment_from_confluence', description: 'Add an attachment to a ticket on Jira from a Confluence page by its name on the api /rest/api/3/issue/{issueIdOrKey}/attachments', inputSchema: { type: 'object', properties: { issueIdOrKey: { type: 'string', description: 'The issue id or key' }, pageId: { type: 'string', description: 'The page id' }, attachmentName: { type: 'string', description: 'The name of the attachment' } }, required: ['issueIdOrKey', 'pageId', 'attachmentName'] } } ] }; }); /** * Function to add an attachment to a Jira issue from a Confluence page. * @param issueIdOrKey * @param pageId * @param attachmentName * @returns {Promise<any>} */ async function addAttachmentFromConfluence(issueIdOrKey: string, pageId: string, attachmentName: string): Promise<any> { try { // Récupérer l'attachement depuis Confluence const response = await axios.get(`${JIRA_URL}/wiki/rest/api/content/${pageId}/child/attachment`, { headers: getAuthHeaders().headers, }); // Trouver l'attachement spécifique const attachment = response.data.results.find((attachment: any) => attachment.title === attachmentName); if (!attachment) { return { error: 'Attachment not found' }; } // Télécharger l'attachement const attachmentResponse = await axios.get(`${JIRA_URL}/wiki${attachment._links.download}`, { headers: getAuthHeaders().headers, responseType: 'arraybuffer' }); // Créer un FormData et ajouter le fichier const formData = new FormData(); const blob = new Blob([attachmentResponse.data], {type: attachment.mediaType}); formData.append('file', blob, attachmentName); // Headers spéciaux pour l'upload de fichiers const headers = { ...getAuthHeaders().headers, 'X-Atlassian-Token': 'no-check', 'Content-Type': 'multipart/form-data' }; // Uploader l'attachement sur le ticket Jira const uploadResponse = await axios.post( `${JIRA_URL}/rest/api/3/issue/${issueIdOrKey}/attachments`, formData, {headers} ); return uploadResponse.data; } catch (error: any) { return { error: error.response?.data || error.message }; } } /** * Function to add an attachment to a Jira issue. * @param issueIdOrKey * @param imageUrl * @returns {Promise<any>} */ async function addAttachment(issueIdOrKey: string, imageUrl: string): Promise<any> { try { // Télécharger l'image depuis l'URL const imageResponse = await axios.get(imageUrl, {responseType: 'arraybuffer'}); const formData = new FormData(); formData.append('file', new Blob([imageResponse.data]), 'image.png'); // Headers spéciaux pour l'upload de fichiers const headers = { ...getAuthHeaders().headers, 'X-Atlassian-Token': 'no-check', 'Content-Type': 'multipart/form-data' }; const response = await axios.post( `${JIRA_URL}/rest/api/3/issue/${issueIdOrKey}/attachments`, formData, {headers} ); return response.data; } catch (error: any) { return { error: error.response?.data || error.message }; } } /** * Function to query assignable users for a project. * @param {string} project_key - The project key to query assignable users for. * @returns {Promise<any>} */ async function queryAssignable(project_key: string): Promise<any> { try { const params = { project: project_key, // JQL query string }; const response = await axios.get(`${JIRA_URL}/rest/api/3/user/assignable/search`, { headers: getAuthHeaders().headers, params }); return response.data; } catch (error: any) { //return the error in a json return { error: error.response.data }; } } /** * Function to execute a JQL query against Jira. * @param {string} jql - JQL query string * @param maxResults * @returns {Promise<any>} */ async function executeJQL(jql: string, maxResults: number): Promise<any> { try { const params = { jql, // JQL query string maxResults, // Adjust as needed }; const response = await axios.get(`${JIRA_URL}/rest/api/3/search`, { headers: getAuthHeaders().headers, params }); return response.data; } catch (error: any) { //return the error in a json return { error: error.response.data }; } } /** * Function to create a ticket on Jira. * @param project * @param summary * @param description * @param issuetype * @param parentID */ async function createTicket(project: string, summary: string, description: string, issuetype: string, parentID?: string): Promise<any> { try { const jiraDescription = { "type": "doc", "version": 1, "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": description } ] } ] } //parent is somethng like "parent": {"key": "SCRUM-19"} const parent = parentID ? {key: parentID} : undefined; const response = await axios.post(`${JIRA_URL}/rest/api/3/issue`, { fields: { project: { key: project }, summary, description: description ? jiraDescription : undefined, issuetype: { name: issuetype }, parent } }, { headers: getAuthHeaders().headers, }); return response.data; } catch (error: any) { return { error: error.response.data }; } } /** * Function to get the authentication headers. * @returns {AxiosRequestConfig} */ function getAuthHeaders(): AxiosRequestConfig<any> { const authHeader = `Basic ${Buffer.from(`${JIRA_API_MAIL}:${JIRA_API_KEY}`).toString('base64')}`; return { headers: { 'Authorization': authHeader, 'Content-Type': 'application/json', } }; } /** Function to list all projects on Jira. * @returns {Promise<any>} * @param number_of_results */ async function listProjects(number_of_results: number): Promise<any> { try { const params = { maxResults: number_of_results, // Adjust as needed }; const response = await axios.get(`${JIRA_URL}/rest/api/3/project`, { headers: getAuthHeaders().headers, params }); return response.data; } catch (error: any) { //return the error in a json return { error: error.response.data }; } } /** * Function to delete a ticket on Jira. * @param issueIdOrKey * @returns {Promise<any>} */ async function deleteTicket(issueIdOrKey: string): Promise<any> { try { const response = await axios.delete(`${JIRA_URL}/rest/api/3/issue/${issueIdOrKey}`, { headers: getAuthHeaders().headers, }); return response.data; } catch (error: any) { return { error: error.response.data }; } } /** * Function to edit a ticket on Jira. * @param issueIdOrKey * @param summary * @param description * @param labels * @param parent * @returns {Promise<any>} */ async function editTicket(issueIdOrKey?: string, summary?: string, description?: string, labels?: string[], parent?: string): Promise<any> { try { const descriptionToSend = description || 'No description provided'; const jiraDescription = description === null ? undefined : { "type": "doc", "version": 1, "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": descriptionToSend } ] } ] } const parentToSend = parent ? {key: parent} : undefined; //we create the fields object with only the present fields let fields: any = { summary: summary, labels: labels, parent: parentToSend } if (description) { fields['description'] = jiraDescription; } const response = await axios.put(`${JIRA_URL}/rest/api/3/issue/${issueIdOrKey}`, { fields: fields }, { headers: getAuthHeaders().headers, }); return response.data; } catch (error: any) { return { error: error.response.data }; } } /** * Function to get all the statuses on Jira. * @param number_of_results * @returns {Promise<any>} */ async function getAllStatus(number_of_results: number): Promise<any> { try { const params = { maxResults: number_of_results, // Adjust as needed }; const response = await axios.get(`${JIRA_URL}/rest/api/3/status`, { headers: getAuthHeaders().headers, params }); return response.data; } catch (error: any) { //return the error in a json return { error: error.response.data }; } } /** * Function to assign a ticket to a user. * @param accountId * @param issueIdOrKey * @returns {Promise<any>} */ async function assignTicket(accountId: string, issueIdOrKey: string): Promise<any> { try { const response = await axios.put(`${JIRA_URL}/rest/api/3/issue/${issueIdOrKey}/assignee`, { accountId }, { headers: getAuthHeaders().headers, }); return response.data; } catch (error: any) { return { error: error.response.data }; } } /** * Handler for the execute_jql tool. * Executes a JQL query and returns the full response. */ server.setRequestHandler(CallToolRequestSchema, async (request) => { switch (request.params.name) { case "execute_jql": { const jql = String(request.params.arguments?.jql); const number_of_results = Number(request.params.arguments?.number_of_results ?? 1); if (!jql) { throw new Error("JQL query is required"); } const response = await executeJQL(jql, number_of_results); // Return the entire data from the response return { content: [{ type: "text", text: JSON.stringify(response, null, 2) // Pretty print JSON }] }; } case "get_only_ticket_name_and_description": { const jql = String(request.params.arguments?.jql); const number_of_results = Number(request.params.arguments?.number_of_results ?? 1); if (!jql) { throw new Error("JQL query is required"); } const response = await executeJQL(jql, number_of_results); // Return only the ticket name and description const tickets = response.issues.map((issue: any) => { return { key: issue.key, summary: issue.fields.summary, description: issue.fields.description }; }); return { content: [{ type: "text", text: JSON.stringify(tickets, null, 2) // Pretty print JSON }] }; } case 'create_ticket': { const project: any = request.params.arguments?.project; const summary: any = request.params.arguments?.summary; const description: any = request.params.arguments?.description; const issuetype: any = request.params.arguments?.issuetype; const parent: any = request.params.arguments?.parent; if (!project || !summary || !description || !issuetype) { throw new Error('Project, summary, description and issuetype are required'); } try { const response = await createTicket(project.key, summary, description, issuetype.name, parent); return { content: [{ type: 'text', text: JSON.stringify(response, null, 2) }] }; } catch (error: any) { return { content: [{ type: 'text', text: JSON.stringify(error.response.data, null, 2) }] }; } } case 'list_projects': { const number_of_results = Number(request.params.arguments?.number_of_results ?? 1); const response = await listProjects(number_of_results); return { content: [{ type: 'text', text: JSON.stringify(response, null, 2) }] }; } case 'delete_ticket': { const issueIdOrKey: any = request.params.arguments?.issueIdOrKey; if (!issueIdOrKey) { throw new Error('Issue id or key is required'); } const response = await deleteTicket(issueIdOrKey); return { content: [{ type: 'text', text: JSON.stringify(response, null, 2) }] }; } case 'edit_ticket': const issueIdOrKey: any = request.params.arguments?.issueIdOrKey; const summary: any = request.params.arguments?.summary; const description: any = request.params.arguments?.description; const labels: any = request.params.arguments?.labels; const parent: any = request.params.arguments?.parent; if (!issueIdOrKey) { throw new Error('Issue id or key is required'); } const response = await editTicket(issueIdOrKey, summary, description, labels, parent); return { content: [{ type: 'text', text: JSON.stringify(response, null, 2) }] }; case 'get_all_statuses': { const number_of_results = Number(request.params.arguments?.number_of_results ?? 50); const response = await getAllStatus(number_of_results); return { content: [{ type: 'text', text: JSON.stringify(response, null, 2) }] }; } case 'assign_ticket': { const accountId: any = request.params.arguments?.accountId; const issueIdOrKey: any = request.params.arguments?.issueIdOrKey; if (!accountId || !issueIdOrKey) { throw new Error('Account id and issue id or key are required'); } const response = await assignTicket(accountId, issueIdOrKey); return { content: [{ type: 'text', text: "Ticket assigned : " + JSON.stringify(response, null, 2) }] }; } case 'query_assignable': { const project_key: any = request.params.arguments?.project_key; if (!project_key) { throw new Error('Query is required'); } const response = await queryAssignable(project_key); return { content: [{ type: 'text', text: JSON.stringify(response, null, 2) }] }; } case 'add_attachment_from_public_url': { const issueIdOrKey: any = request.params.arguments?.issueIdOrKey; const imageUrl: any = request.params.arguments?.imageUrl; if (!issueIdOrKey || !imageUrl) { throw new Error('Issue id or key and image URL are required'); } const response = await addAttachment(issueIdOrKey, imageUrl); return { content: [{ type: 'text', text: JSON.stringify(response, null, 2) }] }; } case 'add_attachment_from_confluence': { const issueIdOrKey: any = request.params.arguments?.issueIdOrKey; const pageId: any = request.params.arguments?.pageId; const attachmentName: any = request.params.arguments?.attachmentName; if (!issueIdOrKey || !pageId || !attachmentName) { throw new Error('Issue id or key, page id and attachment name are required'); } const response = await addAttachmentFromConfluence(issueIdOrKey, pageId, attachmentName); return { content: [{ type: 'text', text: JSON.stringify(response, null, 2) }] }; } default: throw new Error("Unknown tool"); } }); /** * Start the server using stdio transport. */ async function main() { const transport = new StdioServerTransport(); await server.connect(transport); } main().catch((error) => { console.error("Server error:", error); process.exit(1); });