confluence-mcp
by zereight
- confluence-mcp
- build
#!/usr/bin/env node
/**
* Confluence MCP 서버
*
* 이 서버는 Confluence 통합을 위한 Model Context Protocol(MCP)을 구현합니다.
* Confluence에서 CQL 쿼리 실행과 페이지 콘텐츠 조회 기능을 제공합니다.
*
* 서버는 다음과 같은 MCP 클라이언트-서버 아키텍처를 따릅니다:
* - Confluence 기능을 제공하는 MCP 서버로 동작
* - Confluence를 데이터 소스로 연결
* - 표준화된 프로토콜을 통해 MCP 클라이언트와 통신
*
* @module ConfluenceMCPServer
*/
import axios 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";
/**
* Confluence 설정
*
* Confluence 인스턴스 연결을 위한 설정값들입니다.
* 서버 실행을 위해 필요한 항목:
* - Confluence 인스턴스의 기본 URL
* - API 인증 정보 (이메일과 API 키)
*/
const CONFLUENCE_URL = process.env.CONFLUENCE_URL;
const JIRA_URL = process.env.JIRA_URL;
const CONFLUENCE_API_MAIL = process.env.CONFLUENCE_API_MAIL;
const CONFLUENCE_API_KEY = process.env.CONFLUENCE_API_KEY;
// Validate required environment variables
if (!CONFLUENCE_URL ||
!JIRA_URL ||
!CONFLUENCE_API_MAIL ||
!CONFLUENCE_API_KEY) {
console.error("Missing required environment variables. Please check your .env file.");
process.exit(1);
}
/**
* 일반 텍스트를 Atlassian Document Format(ADF)으로 변환
*
* @param {string} text - 변환할 일반 텍스트
* @returns {Object} ADF 형식의 객체
*/
function convertToADF(text) {
if (!text) {
return null;
}
return {
version: 1,
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: text,
},
],
},
],
};
}
/**
* MCP 서버 초기화
*
* 다음 설정으로 새로운 MCP 서버 인스턴스를 생성합니다:
* - 서버 메타데이터 (이름과 버전)
* - 사용 가능한 도구들에 대한 기능 설정
*
* 이 서버는 MCP 프로토콜을 통해 Confluence 작업 도구들을 제공합니다.
*/
const server = new Server({
name: "Better Confluence communication server",
version: "0.1.0",
}, {
capabilities: {
tools: {},
},
});
/**
* 도구 정의 핸들러
*
* 사용 가능한 도구들을 정의하는 ListTools 요청 핸들러를 구현합니다:
* - execute_cql_search: Confluence CQL 쿼리 실행
* - get_page_content: 특정 Confluence 페이지 내용 조회
* - create_page: 새로운 Confluence 페이지 생성
* - update_page: 기존 Confluence 페이지 수정
* - execute_jql_search: Jira JQL 쿼리 실행
* - create_jira_issue: 새로운 Jira 이슈 생성
* - update_jira_issue: 기존 Jira 이슈 수정
* - transition_jira_issue: Jira 이슈 상태 변경
* - get_board_sprints: Jira 보드에서 모든 스프린트 가져오기
* - get_sprint_issues: 스프린트에서 모든 이슈 가져오기
* - get_current_sprint: 현재 활성 스프린트 조회
* - get_epic_issues: 에픽에 속한 모든 이슈 조회
* - get_user_issues: 특정 보드에서 특정 유저와 관련된 모든 이슈 조회
*
* 각 도구는 다음 정보를 포함합니다:
* - 이름과 설명
* - 필요한 매개변수를 정의하는 입력 스키마
*/
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "execute_cql_search",
description: "Execute a CQL query on Confluence to search pages",
inputSchema: {
type: "object",
properties: {
cql: {
type: "string",
description: "CQL query string",
},
limit: {
type: "integer",
description: "Number of results to return",
default: 10,
},
},
required: ["cql"],
},
},
{
name: "get_page_content",
description: "Get the content of a Confluence page",
inputSchema: {
type: "object",
properties: {
pageId: {
type: "string",
description: "Confluence Page ID",
},
},
required: ["pageId"],
},
},
{
name: "create_page",
description: "Create a new Confluence page",
inputSchema: {
type: "object",
properties: {
spaceKey: {
type: "string",
description: "Space key where the page will be created",
},
title: {
type: "string",
description: "Page title",
},
content: {
type: "string",
description: "Page content in storage format",
},
parentId: {
type: "string",
description: "Parent page ID (optional)",
},
},
required: ["spaceKey", "title", "content"],
},
},
{
name: "update_page",
description: "Update an existing Confluence page",
inputSchema: {
type: "object",
properties: {
pageId: {
type: "string",
description: "ID of the page to update",
},
content: {
type: "string",
description: "New page content in storage format",
},
title: {
type: "string",
description: "New page title (optional)",
},
},
required: ["pageId", "content"],
},
},
{
name: "execute_jql_search",
description: "Execute a JQL query on Jira to search issues",
inputSchema: {
type: "object",
properties: {
jql: {
type: "string",
description: "JQL query string",
},
limit: {
type: "integer",
description: "Number of results to return",
default: 10,
},
},
required: ["jql"],
},
},
{
name: "create_jira_issue",
description: "Create a new Jira issue",
inputSchema: {
type: "object",
properties: {
project: {
type: "string",
description: "Project key",
},
summary: {
type: "string",
description: "Issue summary",
},
description: {
type: "string",
description: "Issue description",
},
issuetype: {
type: "string",
description: "Issue type name",
},
assignee: {
type: "string",
description: "Assignee account ID",
},
priority: {
type: "string",
description: "Priority ID",
},
},
required: ["project", "summary", "issuetype"],
},
},
{
name: "update_jira_issue",
description: "Update an existing Jira issue",
inputSchema: {
type: "object",
properties: {
issueKey: {
type: "string",
description: "Issue key (e.g. PROJ-123)",
},
summary: {
type: "string",
description: "New issue summary",
},
description: {
type: "string",
description: "New issue description",
},
assignee: {
type: "string",
description: "New assignee account ID",
},
priority: {
type: "string",
description: "New priority ID",
},
},
required: ["issueKey"],
},
},
{
name: "transition_jira_issue",
description: "Change the status of a Jira issue",
inputSchema: {
type: "object",
properties: {
issueKey: {
type: "string",
description: "Issue key (e.g. PROJ-123)",
},
transitionId: {
type: "string",
description: "Transition ID to change the issue status",
},
},
required: ["issueKey", "transitionId"],
},
},
{
name: "get_board_sprints",
description: "Get all sprints from a Jira board",
inputSchema: {
type: "object",
properties: {
boardId: {
type: "string",
description: "Jira board ID",
},
state: {
type: "string",
description: "Filter sprints by state (active, future, closed)",
enum: ["active", "future", "closed"],
},
},
required: ["boardId"],
},
},
{
name: "get_sprint_issues",
description: "Get all issues from a sprint",
inputSchema: {
type: "object",
properties: {
sprintId: {
type: "string",
description: "Sprint ID",
},
fields: {
type: "array",
description: "List of fields to return for each issue",
items: {
type: "string",
},
},
},
required: ["sprintId"],
},
},
{
name: "get_current_sprint",
description: "Get current active sprint from a board with its issues",
inputSchema: {
type: "object",
properties: {
boardId: {
type: "string",
description: "Jira board ID",
},
includeIssues: {
type: "boolean",
description: "Whether to include sprint issues in the response",
default: true,
},
},
required: ["boardId"],
},
},
{
name: "get_epic_issues",
description: "Get all issues belonging to an epic",
inputSchema: {
type: "object",
properties: {
epicKey: {
type: "string",
description: "Epic issue key (e.g. CONNECT-1234)",
},
fields: {
type: "array",
description: "List of fields to return for each issue",
items: {
type: "string",
},
},
},
required: ["epicKey"],
},
},
{
name: "get_user_issues",
description: "Get all issues assigned to or reported by a specific user in a board",
inputSchema: {
type: "object",
properties: {
boardId: {
type: "string",
description: "Jira board ID",
},
username: {
type: "string",
description: "Username to search issues for",
},
type: {
type: "string",
description: "Type of user association with issues",
enum: ["assignee", "reporter"],
default: "assignee",
},
status: {
type: "string",
description: "Filter by issue status",
enum: ["open", "in_progress", "done", "all"],
default: "all",
},
},
required: ["boardId", "username"],
},
},
],
};
});
/**
* CQL 쿼리 실행기
*
* Confluence 인스턴스에 대해 CQL(Confluence Query Language) 쿼리를 실행합니다.
* 페이지네이션과 오류 케이스를 처리합니다.
*
* @param {string} cql - 실행할 CQL 쿼리 문자열
* @param {number} limit - 반환할 최대 결과 수
* @returns {Promise<any>} 쿼리 결과 또는 오류 정보
*/
async function executeCQL(cql, limit) {
try {
const params = {
cql,
limit,
};
const response = await axios.get(`${CONFLUENCE_URL}/wiki/rest/api/content/search`, {
// Updated URL
headers: getAuthHeaders().headers,
params,
});
return response.data;
}
catch (error) {
return {
error: error.response ? error.response.data : error.message,
};
}
}
/**
* 페이지 콘텐츠 조회기
*
* ID를 통해 특정 Confluence 페이지의 내용을 가져옵니다.
* 페이지의 body storage 형식을 포함합니다.
*
* @param {string} pageId - 조회할 Confluence 페이지 ID
* @returns {Promise<any>} 페이지 내용 또는 오류 정보
*/
async function getPageContent(pageId) {
try {
const response = await axios.get(`${CONFLUENCE_URL}/wiki/rest/api/content/${pageId}?expand=body.storage,version,space`, {
// Updated URL
headers: getAuthHeaders().headers,
});
return response.data;
}
catch (error) {
return {
error: error.response ? error.response.data : error.message,
};
}
}
/**
* 인증 헤더 생성기
*
* Confluence API 요청에 필요한 인증 헤더를 생성합니다.
* 설정된 인증 정보를 사용하여 Basic 인증을 구성합니다.
*
* @returns {AxiosRequestConfig} 인증 헤더가 포함된 설정 객체
*/
function getAuthHeaders() {
const authHeader = `Basic ${Buffer.from(`${CONFLUENCE_API_MAIL}:${CONFLUENCE_API_KEY}`).toString("base64")}`;
return {
headers: {
Authorization: authHeader,
"Content-Type": "application/json",
},
};
}
/**
* 도구 실행 핸들러
*
* 요청된 도구를 실행하는 CallTool 요청 핸들러를 구현합니다:
* - 도구 이름과 필수 매개변수 검증
* - 도구 이름에 따른 적절한 함수 실행
* - MCP 호환 형식으로 결과 반환
*/
server.setRequestHandler(CallToolRequestSchema, async (request) => {
switch (request.params.name) {
case "execute_cql_search": {
const cql = String(request.params.arguments?.cql);
const limit = Number(request.params.arguments?.limit ?? 10);
if (!cql) {
throw new Error("CQL query is required");
}
const response = await executeCQL(cql, limit);
return {
content: [
{
type: "text",
text: JSON.stringify(response, null, 2),
},
],
};
}
case "get_page_content": {
const pageId = String(request.params.arguments?.pageId);
if (!pageId) {
throw new Error("Page ID is required");
}
const response = await getPageContent(pageId);
return {
content: [
{
type: "text",
text: JSON.stringify(response, null, 2),
},
],
};
}
case "create_page": {
const spaceKey = String(request.params.arguments?.spaceKey);
const title = String(request.params.arguments?.title);
const content = String(request.params.arguments?.content);
const parentId = request.params.arguments?.parentId
? String(request.params.arguments.parentId)
: undefined;
if (!spaceKey || !title || !content) {
throw new Error("Space key, title, and content are required");
}
const response = await createPage(spaceKey, title, content, parentId);
return {
content: [
{
type: "text",
text: JSON.stringify(response, null, 2),
},
],
};
}
case "update_page": {
const pageId = String(request.params.arguments?.pageId);
const content = String(request.params.arguments?.content);
const title = request.params.arguments?.title
? String(request.params.arguments.title)
: undefined;
if (!pageId || !content) {
throw new Error("Page ID and content are required");
}
const response = await updatePage(pageId, content, title);
return {
content: [
{
type: "text",
text: JSON.stringify(response, null, 2),
},
],
};
}
case "execute_jql_search": {
const jql = String(request.params.arguments?.jql);
const limit = Number(request.params.arguments?.limit ?? 10);
if (!jql) {
throw new Error("JQL query is required");
}
const response = await executeJQL(jql, limit);
return {
content: [
{
type: "text",
text: JSON.stringify(response, null, 2),
},
],
};
}
case "create_jira_issue": {
const project = String(request.params.arguments?.project);
const summary = String(request.params.arguments?.summary);
const description = String(request.params.arguments?.description);
const issuetype = String(request.params.arguments?.issuetype);
const assignee = String(request.params.arguments?.assignee);
const priority = String(request.params.arguments?.priority);
if (!project || !summary || !issuetype) {
throw new Error("Project, summary, and issuetype are required");
}
const fields = {
project: { key: project },
summary,
description,
issuetype: { name: issuetype },
assignee: { id: assignee },
priority: { id: priority },
};
const response = await createJiraIssue(fields);
return {
content: [
{
type: "text",
text: JSON.stringify(response, null, 2),
},
],
};
}
case "update_jira_issue": {
const issueKey = String(request.params.arguments?.issueKey);
const summary = String(request.params.arguments?.summary);
const description = String(request.params.arguments?.description);
const assignee = String(request.params.arguments?.assignee);
const priority = String(request.params.arguments?.priority);
if (!issueKey) {
throw new Error("Issue key is required");
}
const fields = {
project: { key: summary },
summary,
description,
assignee: { id: assignee },
priority: { id: priority },
};
const response = await updateJiraIssue(issueKey, fields);
return {
content: [
{
type: "text",
text: JSON.stringify(response, null, 2),
},
],
};
}
case "transition_jira_issue": {
const issueKey = String(request.params.arguments?.issueKey);
const transitionId = String(request.params.arguments?.transitionId);
if (!issueKey || !transitionId) {
throw new Error("Issue key and transition ID are required");
}
const response = await transitionJiraIssue(issueKey, transitionId);
return {
content: [
{
type: "text",
text: JSON.stringify(response, null, 2),
},
],
};
}
case "get_board_sprints": {
const boardId = String(request.params.arguments?.boardId);
const state = request.params.arguments?.state;
if (!boardId) {
throw new Error("Board ID is required");
}
const response = await getBoardSprints(boardId, state);
return {
content: [
{
type: "text",
text: JSON.stringify(response, null, 2),
},
],
};
}
case "get_sprint_issues": {
const sprintId = String(request.params.arguments?.sprintId);
const fields = request.params.arguments?.fields;
if (!sprintId) {
throw new Error("Sprint ID is required");
}
const response = await getSprintIssues(sprintId, fields);
return {
content: [
{
type: "text",
text: JSON.stringify(response, null, 2),
},
],
};
}
case "get_current_sprint": {
const boardId = String(request.params.arguments?.boardId);
const includeIssues = request.params.arguments?.includeIssues !== false;
if (!boardId) {
throw new Error("Board ID is required");
}
// 1. 현재 활성 스프린트 조회
const sprintsResponse = await getBoardSprints(boardId, "active");
if (!sprintsResponse.success || !sprintsResponse.data.values.length) {
return {
content: [
{
type: "text",
text: JSON.stringify({ error: "No active sprint found" }, null, 2),
},
],
};
}
const currentSprint = sprintsResponse.data.values[0];
if (!includeIssues) {
return {
content: [
{
type: "text",
text: JSON.stringify({ sprint: currentSprint }, null, 2),
},
],
};
}
// 2. 스프린트의 이슈들 조회
const issuesResponse = await getSprintIssues(String(currentSprint.id));
return {
content: [
{
type: "text",
text: JSON.stringify({
sprint: currentSprint,
issues: issuesResponse.success ? issuesResponse.issues : [],
total: issuesResponse.success ? issuesResponse.total : 0,
}, null, 2),
},
],
};
}
case "get_epic_issues": {
const epicKey = String(request.params.arguments?.epicKey);
const fields = request.params.arguments?.fields;
if (!epicKey) {
throw new Error("Epic key is required");
}
try {
const params = {
jql: `"Epic Link" = ${epicKey}`,
fields: fields || [
"summary",
"status",
"assignee",
"priority",
"issuetype",
],
maxResults: 100,
};
const response = await axios.get(`${JIRA_URL}/rest/api/2/search`, {
headers: getAuthHeaders().headers,
params,
});
const issues = response.data.issues.map((issue) => ({
key: issue.key,
summary: issue.fields.summary,
status: issue.fields.status?.name,
assignee: issue.fields.assignee?.displayName,
priority: issue.fields.priority?.name,
issuetype: issue.fields.issuetype?.name,
}));
return {
content: [
{
type: "text",
text: JSON.stringify({
total: response.data.total,
issues,
}, null, 2),
},
],
};
}
catch (error) {
return {
content: [
{
type: "text",
text: JSON.stringify({
error: error.response?.data?.errorMessages?.[0] || error.message,
details: error.response?.data || {},
}, null, 2),
},
],
};
}
}
case "get_user_issues": {
const boardId = String(request.params.arguments?.boardId);
const username = String(request.params.arguments?.username);
const type = String(request.params.arguments?.type || "assignee");
const status = String(request.params.arguments?.status || "all");
if (!boardId || !username) {
throw new Error("Board ID and username are required");
}
// JQL 쿼리 구성
let jql = `${type} = '${username}' AND board = ${boardId}`;
// 상태 필터 추가
switch (status) {
case "open":
jql += " AND status = 'To Do'";
break;
case "in_progress":
jql += " AND status = '진행 중'";
break;
case "done":
jql += " AND status = 'Done'";
break;
}
// 이슈 조회
const response = await executeJQL(jql, 100);
return {
content: [
{
type: "text",
text: JSON.stringify(response, null, 2),
},
],
};
}
default:
throw new Error("Unknown tool");
}
});
/**
* 페이지 생성기
*
* 새로운 Confluence 페이지를 생성합니다.
*
* @param {string} spaceKey - 페이지가 생성될 공간의 키
* @param {string} title - 페이지 제목
* @param {string} content - 페이지 내용 (storage 형식)
* @param {string} [parentId] - 상위 페이지 ID (선택사항)
* @returns {Promise<any>} 생성된 페이지 정보 또는 오류 정보
*/
async function createPage(spaceKey, title, content, parentId) {
try {
const data = {
type: "page",
title,
space: { key: spaceKey },
body: {
storage: {
value: content,
representation: "storage",
},
},
};
if (parentId) {
data.ancestors = [{ id: parentId }];
}
const response = await axios.post(`${CONFLUENCE_URL}/wiki/rest/api/content`, data, getAuthHeaders());
return response.data;
}
catch (error) {
return {
error: error.response ? error.response.data : error.message,
};
}
}
/**
* 페이지 수정기
*
* 기존 Confluence 페이지의 내용을 수정합니다.
*
* @param {string} pageId - 수정할 페이지 ID
* @param {string} content - 새로운 페이지 내용 (storage 형식)
* @param {string} [title] - 새로운 페이지 제목 (선택사항)
* @returns {Promise<any>} 수정된 페이지 정보 또는 오류 정보
*/
async function updatePage(pageId, content, title) {
try {
// 현재 페이지 정보 조회
const currentPage = await getPageContent(pageId);
if (currentPage.error) {
return currentPage;
}
const data = {
id: pageId,
type: "page",
status: "current",
title: title || currentPage.title,
space: {
key: currentPage.space.key,
name: currentPage.space.name,
type: "global",
},
version: {
number: currentPage.version.number + 1,
message: "Updated via API",
minorEdit: false,
},
body: {
storage: {
value: content,
representation: "storage",
},
},
metadata: {
properties: {
"content-type": "page",
"update-type": "api",
},
},
};
if (currentPage.ancestors && currentPage.ancestors.length > 0) {
data.ancestors = currentPage.ancestors.map((ancestor) => ({
id: ancestor.id,
type: ancestor.type,
status: ancestor.status,
}));
}
const response = await axios.put(`${CONFLUENCE_URL}/wiki/rest/api/content/${pageId}`, data, getAuthHeaders());
return response.data;
}
catch (error) {
return {
error: error.response ? error.response.data : error.message,
};
}
}
/**
* JQL 쿼리 실행기
*
* Jira에서 JQL(Jira Query Language) 쿼리를 실행합니다.
*
* @param {string} jql - 실행할 JQL 쿼리 문자열
* @param {number} limit - 반환할 최대 결과 수
* @returns {Promise<any>} 쿼리 결과 또는 오류 정보
*/
async function executeJQL(jql, limit) {
try {
const defaultFields = [
"key",
"summary",
"description",
"status",
"issuetype",
"priority",
"assignee",
"updated",
];
const params = {
jql,
maxResults: limit,
fields: defaultFields,
validateQuery: "strict",
};
const response = await axios.get(`${JIRA_URL}/rest/api/3/search`, {
headers: getAuthHeaders().headers,
params,
});
// 응답 데이터를 가공하여 필요한 정보만 반환
const issues = response.data.issues.map((issue) => ({
key: issue.key,
summary: issue.fields.summary,
description: issue.fields.description,
status: issue.fields.status?.name,
issuetype: issue.fields.issuetype?.name,
priority: issue.fields.priority?.name,
assignee: issue.fields.assignee?.displayName,
updated: issue.fields.updated,
}));
return {
total: response.data.total,
issues,
};
}
catch (error) {
const errorMessage = error.response?.data?.errorMessages?.[0] || error.message;
return {
error: errorMessage,
details: error.response?.data || {},
status: error.response?.status,
};
}
}
/**
* Jira 이슈 생성기
*
* 새로운 Jira 이슈를 생성합니다.
*
* @param {JiraIssueFields} fields - 이슈 필드 데이터
* @returns {Promise<any>} 생성된 이슈 정보 또는 오류 정보
*/
async function createJiraIssue(fields) {
try {
// 필수 필드 유효성 검사
if (!fields.project?.key) {
throw new Error("Project key is required");
}
if (!fields.issuetype?.name) {
throw new Error("Issue type is required");
}
if (!fields.summary) {
throw new Error("Summary is required");
}
// description을 ADF 형식으로 변환
const description = fields.description
? convertToADF(fields.description)
: null;
const data = {
fields: {
...fields,
description,
// 기본값 설정
priority: fields.priority || { id: "3" }, // Medium priority
},
update: {},
};
const response = await axios.post(`${JIRA_URL}/rest/api/3/issue`, data, {
...getAuthHeaders(),
headers: {
...getAuthHeaders().headers,
"X-Atlassian-Token": "no-check",
},
});
return {
success: true,
data: response.data,
key: response.data.key,
id: response.data.id,
self: response.data.self,
};
}
catch (error) {
return {
success: false,
error: error.response?.data?.errorMessages?.[0] || error.message,
details: error.response?.data || {},
status: error.response?.status,
};
}
}
/**
* Jira 이슈 수정기
*
* 기존 Jira 이슈를 수정합니다.
*
* @param {string} issueKey - 수정할 이슈 키
* @param {JiraIssueFields} fields - 수정할 필드 데이터
* @returns {Promise<any>} 수정된 이슈 정보 또는 오류 정보
*/
async function updateJiraIssue(issueKey, fields) {
try {
if (!issueKey) {
throw new Error("Issue key is required");
}
// description만 업데이트하는 간단한 요청
const updateData = {
fields: {},
};
// description이 있을 때만 포함
if (fields.description) {
updateData.fields = {
description: convertToADF(fields.description),
};
}
const response = await axios.put(`${JIRA_URL}/rest/api/3/issue/${issueKey}`, updateData, {
headers: {
...getAuthHeaders().headers,
"X-Atlassian-Token": "no-check",
"Content-Type": "application/json",
},
});
return {
success: true,
status: response.status,
message: "Issue updated successfully",
};
}
catch (error) {
return {
success: false,
error: error.response?.data?.errorMessages?.[0] || error.message,
details: error.response?.data || {},
status: error.response?.status,
};
}
}
/**
* Jira 이슈 상태 변경기
*
* Jira 이슈의 상태를 변경합니다.
*
* @param {string} issueKey - 상태를 변경할 이슈 키
* @param {string} transitionId - 변경할 상태의 transition ID
* @returns {Promise<any>} 변경 결과 또는 오류 정보
*/
async function transitionJiraIssue(issueKey, transitionId) {
try {
if (!issueKey) {
throw new Error("Issue key is required");
}
if (!transitionId) {
throw new Error("Transition ID is required");
}
// 현재 가능한 transition 확인
const transitionsResponse = await axios.get(`${JIRA_URL}/rest/api/3/issue/${issueKey}/transitions`, getAuthHeaders());
const availableTransitions = transitionsResponse.data.transitions;
const isValidTransition = availableTransitions.some((t) => t.id === transitionId);
if (!isValidTransition) {
throw new Error(`Invalid transition ID: ${transitionId}. Available transitions: ${availableTransitions
.map((t) => `${t.id} (${t.name})`)
.join(", ")}`);
}
const data = {
transition: {
id: transitionId,
},
historyMetadata: {
type: "mcp",
description: "Status updated via MCP API",
activityDescription: "issue_transitioned",
actor: {
type: "application",
id: "mcp-server",
},
},
};
const response = await axios.post(`${JIRA_URL}/rest/api/3/issue/${issueKey}/transitions`, data, {
...getAuthHeaders(),
headers: {
...getAuthHeaders().headers,
"X-Atlassian-Token": "no-check",
},
});
return {
success: true,
status: response.status,
message: "Issue status updated successfully",
transition: availableTransitions.find((t) => t.id === transitionId),
};
}
catch (error) {
return {
success: false,
error: error.response?.data?.errorMessages?.[0] || error.message,
details: error.response?.data || {},
status: error.response?.status,
};
}
}
/**
* 보드의 스프린트 조회
*
* Jira 보드의 모든 스프린트를 조회합니다.
*
* @param {string} boardId - Jira 보드 ID
* @param {string} [state] - 스프린트 상태 필터 (active, future, closed)
* @returns {Promise<any>} 스프린트 목록 또는 오류 정보
*/
async function getBoardSprints(boardId, state) {
try {
const params = {};
if (state) {
params.state = state;
}
const response = await axios.get(`${JIRA_URL}/rest/agile/1.0/board/${boardId}/sprint`, {
headers: getAuthHeaders().headers,
params,
});
return {
success: true,
data: response.data,
};
}
catch (error) {
return {
success: false,
error: error.response?.data?.errorMessages?.[0] || error.message,
details: error.response?.data || {},
status: error.response?.status,
};
}
}
/**
* 스프린트의 이슈 조회
*
* 특정 스프린트의 모든 이슈를 조회합니다.
*
* @param {string} sprintId - 스프린트 ID
* @param {string[]} [fields] - 반환할 이슈 필드 목록
* @returns {Promise<any>} 이슈 목록 또는 오류 정보
*/
async function getSprintIssues(sprintId, fields) {
try {
const defaultFields = [
"key",
"summary",
"status",
"assignee",
"priority",
"issuetype",
];
const params = {
fields: fields || defaultFields,
};
const response = await axios.get(`${JIRA_URL}/rest/agile/1.0/sprint/${sprintId}/issue`, {
headers: getAuthHeaders().headers,
params,
});
// 응답 데이터를 가공하여 필요한 정보만 반환
const issues = response.data.issues.map((issue) => ({
key: issue.key,
summary: issue.fields.summary,
status: issue.fields.status?.name,
assignee: issue.fields.assignee?.displayName,
priority: issue.fields.priority?.name,
issuetype: issue.fields.issuetype?.name,
}));
return {
success: true,
total: response.data.total,
issues,
};
}
catch (error) {
return {
success: false,
error: error.response?.data?.errorMessages?.[0] || error.message,
details: error.response?.data || {},
status: error.response?.status,
};
}
}
/**
* 서버 진입점
*
* MCP 서버를 초기화하고 시작합니다:
* - MCP 통신을 위한 stdio 전송 계층 생성
* - 서버를 전송 계층에 연결
* - 시작 오류 처리
*/
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch((error) => {
console.error("Server error:", error);
process.exit(1);
});