index.ts•15.7 kB
#!/usr/bin/env node
/**
* Atlassian MCP server for JIRA and Confluence integration.
* This server provides tools to interact with JIRA tickets and Confluence pages.
*/
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListResourcesRequestSchema,
ListToolsRequestSchema,
ReadResourceRequestSchema,
ErrorCode,
McpError,
} from "@modelcontextprotocol/sdk/types.js";
import axios from "axios";
import fs from "fs";
import path from "path";
// Configuration handling
let config: {
atlassian: {
baseUrl: string;
email: string;
token: string;
};
server: {
name: string;
version: string;
};
};
// Try to load config from file
const configPath = process.env.ATLASSIAN_CONFIG_PATH || path.join(process.cwd(), "config", "config.json");
try {
if (fs.existsSync(configPath)) {
console.error(`Loading config from ${configPath}`);
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
} else {
// Fallback to environment variables
console.error(`Config file not found at ${configPath}, using environment variables`);
config = {
atlassian: {
baseUrl: process.env.ATLASSIAN_BASE_URL || "",
email: process.env.ATLASSIAN_EMAIL || "",
token: process.env.ATLASSIAN_TOKEN || "",
},
server: {
name: process.env.SERVER_NAME || "atlassian-server",
version: process.env.SERVER_VERSION || "0.1.0",
},
};
}
} catch (error) {
console.error(`Error loading config: ${error}`);
process.exit(1);
}
// Validate required configuration
if (!config.atlassian.baseUrl) {
throw new Error("Atlassian base URL is required in config or ATLASSIAN_BASE_URL environment variable");
}
if (!config.atlassian.email) {
throw new Error("Atlassian email is required in config or ATLASSIAN_EMAIL environment variable");
}
if (!config.atlassian.token) {
throw new Error("Atlassian token is required in config or ATLASSIAN_TOKEN environment variable");
}
/**
* Create an Axios instance with authentication headers
* Using Basic authentication with the token as password and email as username
*/
const atlassianApi = axios.create({
baseURL: config.atlassian.baseUrl,
headers: {
// Using API token as password with Basic auth (email:token)
Authorization: `Basic ${Buffer.from(`${config.atlassian.email}:${config.atlassian.token}`).toString('base64')}`,
Accept: "application/json",
"Content-Type": "application/json",
},
});
// Add request/response logging for debugging
atlassianApi.interceptors.request.use(request => {
console.error('Request:', {
method: request.method,
url: request.url,
headers: {
...request.headers,
Authorization: 'Basic [REDACTED]' // Don't log the actual token
},
data: request.data
});
return request;
});
atlassianApi.interceptors.response.use(
response => {
console.error('Response:', {
status: response.status,
statusText: response.statusText,
headers: response.headers,
data: response.data
});
return response;
},
error => {
console.error('Error:', {
message: error.message,
response: error.response ? {
status: error.response.status,
statusText: error.response.statusText,
data: error.response.data
} : 'No response'
});
return Promise.reject(error);
}
);
/**
* Create an MCP server with capabilities for resources and tools
*/
const server = new Server(
{
name: config.server.name,
version: config.server.version,
},
{
capabilities: {
resources: {},
tools: {},
},
}
);
/**
* Handler for listing available JIRA tickets as resources
*/
server.setRequestHandler(ListResourcesRequestSchema, async () => {
try {
// Get recent JIRA tickets
const response = await atlassianApi.get("/rest/api/3/search", {
params: {
maxResults: 10,
fields: "summary,status,created,updated",
},
});
const tickets = response.data.issues || [];
return {
resources: [
...tickets.map((ticket: any) => ({
uri: `jira://ticket/${ticket.key}`,
mimeType: "application/json",
name: `JIRA Ticket: ${ticket.key}`,
description: `${ticket.fields.summary} (${ticket.fields.status.name})`,
})),
{
uri: "confluence://spaces",
mimeType: "application/json",
name: "Confluence Spaces",
description: "List of available Confluence spaces",
},
],
};
} catch (error) {
console.error("Error listing resources:", error);
return { resources: [] };
}
});
/**
* Handler for reading JIRA tickets and Confluence resources
*/
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
try {
const uri = request.params.uri;
// Handle JIRA ticket resources
if (uri.startsWith("jira://ticket/")) {
const ticketKey = uri.replace("jira://ticket/", "");
const response = await atlassianApi.get(`/rest/api/3/issue/${ticketKey}`, {
params: {
fields: "summary,description,status,created,updated,assignee,reporter,priority,issuetype",
},
});
return {
contents: [{
uri: request.params.uri,
mimeType: "application/json",
text: JSON.stringify(response.data, null, 2),
}],
};
}
// Handle Confluence spaces resource
if (uri === "confluence://spaces") {
const response = await atlassianApi.get("/wiki/rest/api/space", {
params: {
limit: 25,
},
});
return {
contents: [{
uri: request.params.uri,
mimeType: "application/json",
text: JSON.stringify(response.data, null, 2),
}],
};
}
// Handle Confluence page resources
if (uri.startsWith("confluence://page/")) {
const pageId = uri.replace("confluence://page/", "");
const response = await atlassianApi.get(`/wiki/rest/api/content/${pageId}`, {
params: {
expand: "body.storage,version,space",
},
});
return {
contents: [{
uri: request.params.uri,
mimeType: "application/json",
text: JSON.stringify(response.data, null, 2),
}],
};
}
throw new McpError(
ErrorCode.InvalidRequest,
`Unsupported resource URI: ${uri}`
);
} catch (error) {
console.error("Error reading resource:", error);
if (axios.isAxiosError(error)) {
throw new McpError(
ErrorCode.InternalError,
`Atlassian API error: ${error.response?.data?.message || error.message}`
);
}
throw error;
}
});
/**
* Handler that lists available tools for JIRA and Confluence
*/
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "get_jira_ticket",
description: "Get details of a JIRA ticket by key",
inputSchema: {
type: "object",
properties: {
ticket_key: {
type: "string",
description: "JIRA ticket key (e.g., CPDEV-3371)"
}
},
required: ["ticket_key"]
}
},
{
name: "search_jira_tickets",
description: "Search for JIRA tickets using JQL",
inputSchema: {
type: "object",
properties: {
jql: {
type: "string",
description: "JQL query string"
},
max_results: {
type: "number",
description: "Maximum number of results to return",
default: 10
}
},
required: ["jql"]
}
},
{
name: "create_jira_ticket",
description: "Create a new JIRA ticket",
inputSchema: {
type: "object",
properties: {
project_key: {
type: "string",
description: "Project key (e.g., CPDEV)"
},
summary: {
type: "string",
description: "Ticket summary/title"
},
description: {
type: "string",
description: "Ticket description"
},
issue_type: {
type: "string",
description: "Issue type (e.g., Bug, Task, Story)",
default: "Task"
}
},
required: ["project_key", "summary", "description"]
}
},
{
name: "add_comment_to_jira_ticket",
description: "Add a comment to a JIRA ticket",
inputSchema: {
type: "object",
properties: {
ticket_key: {
type: "string",
description: "JIRA ticket key (e.g., CPDEV-3371)"
},
comment: {
type: "string",
description: "Comment text"
}
},
required: ["ticket_key", "comment"]
}
},
{
name: "get_confluence_page",
description: "Get a Confluence page by ID",
inputSchema: {
type: "object",
properties: {
page_id: {
type: "string",
description: "Confluence page ID"
}
},
required: ["page_id"]
}
},
{
name: "search_confluence",
description: "Search for content in Confluence",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Search query"
},
limit: {
type: "number",
description: "Maximum number of results",
default: 10
}
},
required: ["query"]
}
}
]
};
});
/**
* Handler for executing JIRA and Confluence tools
*/
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
switch (request.params.name) {
case "get_jira_ticket": {
const ticketKey = String(request.params.arguments?.ticket_key);
if (!ticketKey) {
throw new McpError(ErrorCode.InvalidParams, "Ticket key is required");
}
const response = await atlassianApi.get(`/rest/api/3/issue/${ticketKey}`, {
params: {
fields: "summary,description,status,created,updated,assignee,reporter,priority,issuetype",
},
});
return {
content: [{
type: "text",
text: JSON.stringify(response.data, null, 2)
}]
};
}
case "search_jira_tickets": {
const jql = String(request.params.arguments?.jql);
const maxResults = Number(request.params.arguments?.max_results || 10);
if (!jql) {
throw new McpError(ErrorCode.InvalidParams, "JQL query is required");
}
const response = await atlassianApi.get("/rest/api/3/search", {
params: {
jql,
maxResults,
fields: "summary,status,created,updated",
},
});
return {
content: [{
type: "text",
text: JSON.stringify(response.data, null, 2)
}]
};
}
case "create_jira_ticket": {
const projectKey = String(request.params.arguments?.project_key);
const summary = String(request.params.arguments?.summary);
const description = String(request.params.arguments?.description);
const issueType = String(request.params.arguments?.issue_type || "Task");
if (!projectKey || !summary || !description) {
throw new McpError(ErrorCode.InvalidParams, "Project key, summary, and description are required");
}
const response = await atlassianApi.post("/rest/api/3/issue", {
fields: {
project: {
key: projectKey
},
summary,
description: {
type: "doc",
version: 1,
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: description
}
]
}
]
},
issuetype: {
name: issueType
}
}
});
return {
content: [{
type: "text",
text: `Created JIRA ticket: ${response.data.key}`
}]
};
}
case "add_comment_to_jira_ticket": {
const ticketKey = String(request.params.arguments?.ticket_key);
const comment = String(request.params.arguments?.comment);
if (!ticketKey || !comment) {
throw new McpError(ErrorCode.InvalidParams, "Ticket key and comment are required");
}
const response = await atlassianApi.post(`/rest/api/3/issue/${ticketKey}/comment`, {
body: {
type: "doc",
version: 1,
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: comment
}
]
}
]
}
});
return {
content: [{
type: "text",
text: `Added comment to ${ticketKey}`
}]
};
}
case "get_confluence_page": {
const pageId = String(request.params.arguments?.page_id);
if (!pageId) {
throw new McpError(ErrorCode.InvalidParams, "Page ID is required");
}
const response = await atlassianApi.get(`/wiki/rest/api/content/${pageId}`, {
params: {
expand: "body.storage,version,space",
},
});
return {
content: [{
type: "text",
text: JSON.stringify(response.data, null, 2)
}]
};
}
case "search_confluence": {
const query = String(request.params.arguments?.query);
const limit = Number(request.params.arguments?.limit || 10);
if (!query) {
throw new McpError(ErrorCode.InvalidParams, "Search query is required");
}
const response = await atlassianApi.get("/wiki/rest/api/content/search", {
params: {
cql: `text ~ "${query}"`,
limit,
expand: "space",
},
});
return {
content: [{
type: "text",
text: JSON.stringify(response.data, null, 2)
}]
};
}
default:
throw new McpError(ErrorCode.MethodNotFound, "Unknown tool");
}
} catch (error) {
console.error("Error executing tool:", error);
if (axios.isAxiosError(error)) {
return {
content: [{
type: "text",
text: `Atlassian API error: ${error.response?.data?.message || error.message}`
}],
isError: true
};
}
throw error;
}
});
/**
* Start the server using stdio transport.
*/
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(`Atlassian MCP server running on stdio (connected to ${config.atlassian.baseUrl})`);
}
main().catch((error) => {
console.error("Server error:", error);
process.exit(1);
});