Skip to main content
Glama
index.js26.1 kB
#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { Version3Client } from "jira.js"; import express from "express"; import cors from "cors"; import dotenv from "dotenv"; // Store original stdout.write for later use const originalStdoutWrite = process.stdout.write.bind(process.stdout); let transportStarted = false; // Check early if we're likely to use stdio (stdin is piped) // If so, set up stdout interception immediately to catch any early writes const isStdinLikelyPiped = !process.stdin.isTTY; if (isStdinLikelyPiped) { // Intercept stdout immediately to prevent any early non-JSON-RPC output process.stdout.write = function (chunk, encoding, callback) { if (!transportStarted) { // Check if it looks like JSON-RPC const str = chunk?.toString() || ""; if (str.trim().startsWith("{") && str.includes('"jsonrpc"')) { // Allow JSON-RPC messages return originalStdoutWrite(chunk, encoding, callback); } // Discard non-JSON-RPC output if (typeof callback === "function") { callback(); } return true; } return originalStdoutWrite(chunk, encoding, callback); }; } dotenv.config(); const JIRA_URL = process.env.JIRA_URL; const JIRA_EMAIL = process.env.JIRA_EMAIL; const JIRA_API_TOKEN = process.env.JIRA_API_TOKEN; if (!JIRA_URL || !JIRA_EMAIL || !JIRA_API_TOKEN) { console.error("Error: Missing required environment variables:"); console.error("- JIRA_URL (e.g., https://your-domain.atlassian.net)"); console.error("- JIRA_EMAIL"); console.error("- JIRA_API_TOKEN"); process.exit(1); } const client = new Version3Client({ host: JIRA_URL, authentication: { basic: { email: JIRA_EMAIL, apiToken: JIRA_API_TOKEN, }, }, }); // Function to create a new server instance function createServer() { const server = new Server( { name: "jira-mcp-server", version: "1.0.0", }, { capabilities: { resources: {}, tools: {}, }, } ); server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "jira_search", description: "Search for JIRA issues using JQL (JIRA Query Language). Returns a list of issues matching the query.", inputSchema: { type: "object", properties: { jql: { type: "string", description: "JQL query string (e.g., 'project = PROJ AND status = Open')", }, maxResults: { type: "number", description: "Maximum number of results to return (default: 50)", default: 50, }, startAt: { type: "number", description: "Starting index for pagination (default: 0)", default: 0, }, fields: { type: "array", items: { type: "string" }, description: "Array of field names to return (e.g., ['summary', 'status', 'assignee'])", }, }, required: ["jql"], }, }, { name: "jira_get_issue", description: "Get detailed information about a specific JIRA issue by its key (e.g., PROJ-123)", inputSchema: { type: "object", properties: { issueKey: { type: "string", description: "JIRA issue key (e.g., 'PROJ-123')", }, fields: { type: "array", items: { type: "string" }, description: "Array of field names to return (optional)", }, }, required: ["issueKey"], }, }, { name: "jira_create_issue", description: "Create a new JIRA issue", inputSchema: { type: "object", properties: { projectKey: { type: "string", description: "Project key (e.g., 'PROJ')", }, summary: { type: "string", description: "Issue summary/title", }, description: { type: "string", description: "Issue description", }, issueType: { type: "string", description: "Issue type (e.g., 'Task', 'Bug', 'Story')", }, priority: { type: "string", description: "Priority (e.g., 'High', 'Medium', 'Low') - optional", }, assignee: { type: "string", description: "Assignee account ID - optional", }, labels: { type: "array", items: { type: "string" }, description: "Array of labels - optional", }, }, required: ["projectKey", "summary", "issueType"], }, }, { name: "jira_update_issue", description: "Update an existing JIRA issue", inputSchema: { type: "object", properties: { issueKey: { type: "string", description: "JIRA issue key (e.g., 'PROJ-123')", }, summary: { type: "string", description: "New summary/title - optional", }, description: { type: "string", description: "New description - optional", }, priority: { type: "string", description: "New priority - optional", }, assignee: { type: "string", description: "New assignee account ID - optional", }, labels: { type: "array", items: { type: "string" }, description: "New labels array - optional", }, status: { type: "string", description: "New status/transition name (e.g., 'In Progress', 'Done') - optional", }, }, required: ["issueKey"], }, }, ], }; }); server.setRequestHandler(ListResourcesRequestSchema, async () => { return { resources: [ { uri: "jira://search?jql=", name: "JIRA Search", description: "Search JIRA issues using JQL query. Append your JQL query after 'jql='", mimeType: "application/json", }, { uri: "jira://issue/", name: "JIRA Issue", description: "Get a specific JIRA issue. Append issue key after 'issue/' (e.g., jira://issue/PROJ-123)", mimeType: "application/json", }, ], }; }); server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const uri = request.params.uri; try { if (uri.startsWith("jira://search?jql=")) { const jql = decodeURIComponent(uri.replace("jira://search?jql=", "")); const searchResults = await client.issueSearch.searchForIssuesUsingJqlEnhancedSearch({ jql, maxResults: 100, startAt: 0, fields: ["summary", "status", "assignee", "created", "updated", "priority", "issuetype", "description", "labels", "components"], }); return { contents: [ { uri, mimeType: "application/json", text: JSON.stringify( { total: searchResults.total, issues: searchResults.issues.map((issue) => ({ key: issue.key, id: issue.id, summary: issue.fields.summary, status: issue.fields.status?.name, assignee: issue.fields.assignee?.displayName || "Unassigned", priority: issue.fields.priority?.name, type: issue.fields.issuetype?.name, created: issue.fields.created, updated: issue.fields.updated, description: issue.fields.description, labels: issue.fields.labels, components: issue.fields.components?.map((c) => c.name), url: `${JIRA_URL}/browse/${issue.key}`, })), }, null, 2 ), }, ], }; } else if (uri.startsWith("jira://issue/")) { const issueKey = uri.replace("jira://issue/", ""); const issue = await client.issues.getIssue({ issueIdOrKey: issueKey, }); return { contents: [ { uri, mimeType: "application/json", text: JSON.stringify( { key: issue.key, id: issue.id, summary: issue.fields.summary, description: issue.fields.description, status: issue.fields.status?.name, assignee: issue.fields.assignee?.displayName || "Unassigned", reporter: issue.fields.reporter?.displayName, priority: issue.fields.priority?.name, type: issue.fields.issuetype?.name, created: issue.fields.created, updated: issue.fields.updated, labels: issue.fields.labels, components: issue.fields.components?.map((c) => c.name), comments: issue.fields.comment?.comments?.map((c) => ({ author: c.author?.displayName, body: c.body, created: c.created, })), url: `${JIRA_URL}/browse/${issue.key}`, }, null, 2 ), }, ], }; } else { throw new Error(`Unsupported resource URI: ${uri}`); } } catch (error) { return { contents: [ { uri, mimeType: "application/json", text: JSON.stringify( { error: error.message, details: error.response?.data || error.stack, }, null, 2 ), }, ], }; } }); server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { switch (name) { case "jira_search": { const { jql, maxResults = 50, startAt = 0, fields } = args; const searchResults = await client.issueSearch.searchForIssuesUsingJqlEnhancedSearch({ jql, maxResults, startAt, fields: fields || ["summary", "status", "issuetype", "issuelinks", "comment", "updated"], expand: "renderedFields", }); return { content: [ { type: "text", text: JSON.stringify( { issues: searchResults.issues.map((issue) => { // Helper function to extract text from document structure const extractTextFromNode = (node) => { if (!node) return ""; if (node.type === "text") { let text = node.text || ""; // Check for hyperlink marks if (node.marks && Array.isArray(node.marks)) { const linkMark = node.marks.find((mark) => mark.type === "link"); if (linkMark && linkMark.attrs && linkMark.attrs.href) { // Include hyperlink URL in the text const url = linkMark.attrs.href; // Format as "text [URL]" if text exists, otherwise just show URL return text ? `${text} [${url}]` : `[${url}]`; } } return text; } if (node.type === "hardBreak") return "\n"; if (node.type === "paragraph" && node.content) { return node.content.map(extractTextFromNode).join(""); } // Handle bullet lists if (node.type === "bulletList" && node.content) { return node.content.map((item) => extractTextFromNode(item)).join("\n"); } // Handle ordered lists if (node.type === "orderedList" && node.content) { return node.content.map((item) => extractTextFromNode(item)).join("\n"); } // Handle list items - prefix with bullet or number if (node.type === "listItem" && node.content) { const itemText = node.content.map(extractTextFromNode).join(""); return `• ${itemText}`; } if (node.content && Array.isArray(node.content)) { return node.content.map(extractTextFromNode).join(""); } return ""; }; const extractCommentText = (body) => { if (!body || !body.content) return ""; return body.content .map((node) => extractTextFromNode(node)) .join("\n") .trim(); }; // Sort comments by updated time (most recent first) and extract text content const comments = (issue.fields.comment?.comments || []) .sort((a, b) => new Date(b.updated) - new Date(a.updated)) .map((comment) => extractCommentText(comment.body)); // Filter issuelinks to only include inwardIssue with minimal fields const issuelinks = (issue.fields.issuelinks || []) .filter((link) => link.inwardIssue) .map((link) => ({ inwardIssue: { key: link.inwardIssue.key, fields: { status: link.inwardIssue.fields?.status?.name, issuetype: link.inwardIssue.fields?.issuetype?.name, }, }, })); return { key: issue.key, fields: { summary: issue.fields.summary, issuelinks: issuelinks, comment: { comments: comments, }, status: issue.fields.status?.name, issuetype: issue.fields.issuetype?.name, }, }; }), }, null, 2 ), }, ], }; } case "jira_get_issue": { const { issueKey, fields } = args; const issue = await client.issues.getIssue({ issueIdOrKey: issueKey, fields: fields, }); return { content: [ { type: "text", text: JSON.stringify( { key: issue.key, id: issue.id, self: issue.self, fields: issue.fields, }, null, 2 ), }, ], }; } case "jira_create_issue": { const { projectKey, summary, description, issueType, priority, assignee, labels } = args; const fields = { project: { key: projectKey }, summary, issuetype: { name: issueType }, }; if (description) { fields.description = { type: "doc", version: 1, content: [ { type: "paragraph", content: [ { type: "text", text: description, }, ], }, ], }; } if (priority) { fields.priority = { name: priority }; } if (assignee) { fields.assignee = { id: assignee }; } if (labels) { fields.labels = labels; } const issue = await client.issues.createIssue({ fields, }); return { content: [ { type: "text", text: JSON.stringify( { key: issue.key, id: issue.id, self: issue.self, message: "Issue created successfully", }, null, 2 ), }, ], }; } case "jira_update_issue": { const { issueKey, summary, description, priority, assignee, labels, status } = args; const updateData = { fields: {} }; if (summary) { updateData.fields.summary = summary; } if (description) { updateData.fields.description = { type: "doc", version: 1, content: [ { type: "paragraph", content: [ { type: "text", text: description, }, ], }, ], }; } if (priority) { updateData.fields.priority = { name: priority }; } if (assignee) { updateData.fields.assignee = { id: assignee }; } if (labels) { updateData.fields.labels = labels; } await client.issues.editIssue({ issueIdOrKey: issueKey, ...updateData, }); if (status) { const transitions = await client.issues.getTransitions({ issueIdOrKey: issueKey, }); const transition = transitions.transitions.find((t) => t.name.toLowerCase() === status.toLowerCase()); if (transition) { await client.issues.doTransition({ issueIdOrKey: issueKey, transition: { id: transition.id }, }); } } return { content: [ { type: "text", text: JSON.stringify( { issueKey, message: "Issue updated successfully", }, null, 2 ), }, ], }; } default: throw new Error(`Unknown tool: ${name}`); } } catch (error) { return { content: [ { type: "text", text: JSON.stringify( { error: error.message, details: error.response?.data || error.stack, }, null, 2 ), }, ], isError: true, }; } }); return server; } async function main() { // Determine transport mode: // Priority order: // 1. If MCP_TRANSPORT is explicitly set, use that // 2. If stdin is not a TTY (piped from MCP client), use stdio (this takes precedence over PORT) // 3. Otherwise, use SSE if PORT is set or stdin is a TTY (for manual testing) const transportMode = process.env.MCP_TRANSPORT; // Check if stdin is NOT a TTY (piped) - this handles both false and undefined const isStdinPiped = !process.stdin.isTTY; let useStdio = false; let useSSE = false; if (transportMode === "stdio") { useStdio = true; } else if (transportMode === "sse") { useSSE = true; } else if (isStdinPiped) { // stdin is piped (not a TTY) - ALWAYS use stdio for MCP clients // This takes precedence over everything else except explicit transport mode useStdio = true; } else if (process.env.PORT && process.stdin.isTTY === true) { // Only use SSE if PORT is explicitly set AND stdin is a TTY useSSE = true; } else { // Default to stdio if we can't determine (safer for MCP clients) useStdio = true; } if (useStdio) { // Stdio mode - for MCP clients like Claude Desktop // IMPORTANT: All logging must go to stderr, not stdout, in stdio mode // Handle EPIPE errors gracefully (broken pipe when client disconnects) process.stdout.on("error", (err) => { if (err.code === "EPIPE") { // Client disconnected, this is normal - exit gracefully process.exit(0); } else { console.error("stdout error:", err); } }); // If we didn't intercept stdout earlier (when stdin wasn't piped), // do it now (handles MCP_TRANSPORT=stdio explicit setting) // Note: if isStdinLikelyPiped was true, we already intercepted above if (!isStdinLikelyPiped) { // Check if stdout.write was already intercepted const currentWrite = process.stdout.write; if (currentWrite === originalStdoutWrite || currentWrite.toString().includes("transportStarted")) { // Not yet intercepted, or it's our interceptor - set it up process.stdout.write = function (chunk, encoding, callback) { if (!transportStarted) { const str = chunk?.toString() || ""; if (str.trim().startsWith("{") && str.includes('"jsonrpc"')) { return originalStdoutWrite(chunk, encoding, callback); } if (typeof callback === "function") { callback(); } return true; } return originalStdoutWrite(chunk, encoding, callback); }; } } console.error("Starting JIRA MCP Server in stdio mode..."); const server = createServer(); const transport = new StdioServerTransport(); // Enable stdout writes now so transport can write JSON-RPC messages transportStarted = true; await server.connect(transport); console.error("JIRA MCP Server connected via stdio"); } else { // SSE mode - for HTTP-based clients const app = express(); const PORT = process.env.PORT || 3000; // Enable CORS for all origins (adjust as needed for production) app.use( cors({ origin: "*", methods: ["GET", "POST", "OPTIONS"], allowedHeaders: ["Content-Type", "Authorization"], }) ); app.use(express.json()); // Store active transports and servers by session ID const transports = new Map(); const servers = new Map(); // Health check endpoint app.get("/health", (req, res) => { res.json({ status: "ok", server: "jira-mcp-server" }); }); // SSE endpoint for MCP app.get("/sse", async (req, res) => { console.error("New SSE connection established"); // Create a new server instance for this connection const server = createServer(); // Create transport - it will generate its own sessionId const transport = new SSEServerTransport("/message", res); // Store transport and server by sessionId transports.set(transport.sessionId, transport); servers.set(transport.sessionId, server); console.error("Created transport with sessionId:", transport.sessionId); await server.connect(transport); // Handle client disconnect req.on("close", () => { console.error("SSE connection closed for session:", transport.sessionId); transports.delete(transport.sessionId); servers.delete(transport.sessionId); }); }); // Message endpoint for client requests app.post("/message", async (req, res) => { const sessionId = req.query.sessionId; console.error("Received message for session:", sessionId); const transport = transports.get(sessionId); if (!transport) { console.error("No transport found for session:", sessionId); return res.status(404).json({ error: "Session not found" }); } // Let the transport handle the message // Note: We pass the raw request body (already parsed by express.json()) await transport.handlePostMessage(req, res, req.body); }); app.listen(PORT, () => { console.error(`JIRA MCP Server running on http://localhost:${PORT}`); console.error(`SSE endpoint: http://localhost:${PORT}/sse`); }); } } main().catch((error) => { console.error("Fatal error:", error); process.exit(1); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/longmaba/jira-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server