# Directory Structure ``` ├── .gitignore ├── package-lock.json ├── package.json ├── README.md ├── src │ ├── backlog-client.ts │ ├── config.ts │ ├── handlers │ │ ├── prompt-handlers.ts │ │ ├── resource-handlers.ts │ │ └── tool-handlers.ts │ ├── index.ts │ └── types.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | node_modules/ 2 | build/ 3 | *.log 4 | .env* ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "outDir": "./build", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true 12 | }, 13 | "include": ["src/**/*"], 14 | "exclude": ["node_modules"] 15 | } 16 | ``` -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Configuration for the Backlog MCP server 3 | */ 4 | 5 | import { AuthConfig } from './types.js'; 6 | 7 | /** 8 | * Load configuration from environment variables 9 | */ 10 | export function loadConfig(): AuthConfig { 11 | const apiKey = process.env.BACKLOG_API_KEY; 12 | const spaceUrl = process.env.BACKLOG_SPACE_URL; 13 | 14 | if (!apiKey) { 15 | throw new Error('BACKLOG_API_KEY environment variable is required'); 16 | } 17 | 18 | if (!spaceUrl) { 19 | throw new Error('BACKLOG_SPACE_URL environment variable is required'); 20 | } 21 | 22 | return { apiKey, spaceUrl }; 23 | } 24 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "mcp-backlog-server", 3 | "version": "0.1.0", 4 | "description": "Backlog MCP Server", 5 | "private": true, 6 | "type": "module", 7 | "bin": { 8 | "mcp-backlog-server": "./build/index.js" 9 | }, 10 | "files": [ 11 | "build" 12 | ], 13 | "scripts": { 14 | "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"", 15 | "prepare": "npm run build", 16 | "watch": "tsc --watch", 17 | "inspector": "npx @modelcontextprotocol/inspector build/index.js" 18 | }, 19 | "dependencies": { 20 | "@modelcontextprotocol/sdk": "0.6.0" 21 | }, 22 | "devDependencies": { 23 | "@types/node": "^20.11.24", 24 | "typescript": "^5.3.3" 25 | } 26 | } 27 | ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Backlog MCP server 5 | * 6 | * This server implements a Backlog integration with Model Context Protocol. 7 | * It provides resources for viewing recent projects and tools for interactions. 8 | */ 9 | 10 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 11 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 12 | import { 13 | CallToolRequestSchema, 14 | ListResourcesRequestSchema, 15 | ListToolsRequestSchema, 16 | ReadResourceRequestSchema, 17 | ListPromptsRequestSchema, 18 | GetPromptRequestSchema, 19 | } from "@modelcontextprotocol/sdk/types.js"; 20 | 21 | import { loadConfig } from './config.js'; 22 | import { BacklogClient } from './backlog-client.js'; 23 | import { listRecentProjects, readProject } from './handlers/resource-handlers.js'; 24 | import { listTools, executeTools } from './handlers/tool-handlers.js'; 25 | import { listPrompts, getPrompt } from './handlers/prompt-handlers.js'; 26 | 27 | /** 28 | * Create an MCP server with capabilities for resources, tools, and prompts. 29 | */ 30 | const server = new Server( 31 | { 32 | name: "mcp-backlog-server", 33 | version: "0.1.0", 34 | }, 35 | { 36 | capabilities: { 37 | resources: {}, 38 | tools: {}, 39 | prompts: {}, 40 | }, 41 | } 42 | ); 43 | 44 | /** 45 | * Initialize the Backlog client 46 | */ 47 | const config = loadConfig(); 48 | const backlogClient = new BacklogClient(config); 49 | 50 | /** 51 | * Handler for listing available Backlog resources (recently viewed projects) 52 | */ 53 | server.setRequestHandler(ListResourcesRequestSchema, async () => { 54 | return await listRecentProjects(backlogClient); 55 | }); 56 | 57 | /** 58 | * Handler for reading the contents of a specific Backlog resource 59 | */ 60 | server.setRequestHandler(ReadResourceRequestSchema, async (request) => { 61 | return await readProject(backlogClient, request.params.uri); 62 | }); 63 | 64 | /** 65 | * Handler that lists available tools 66 | */ 67 | server.setRequestHandler(ListToolsRequestSchema, async () => { 68 | return listTools(); 69 | }); 70 | 71 | /** 72 | * Handler for executing tools 73 | */ 74 | server.setRequestHandler(CallToolRequestSchema, async (request) => { 75 | return await executeTools( 76 | backlogClient, 77 | request.params.name, 78 | request.params.arguments 79 | ); 80 | }); 81 | 82 | /** 83 | * Handler that lists available prompts 84 | */ 85 | server.setRequestHandler(ListPromptsRequestSchema, async () => { 86 | return listPrompts(); 87 | }); 88 | 89 | /** 90 | * Handler for generating prompts 91 | */ 92 | server.setRequestHandler(GetPromptRequestSchema, async (request) => { 93 | return await getPrompt(backlogClient, request.params.name); 94 | }); 95 | 96 | /** 97 | * Start the server using stdio transport 98 | */ 99 | async function main() { 100 | try { 101 | console.error("Starting Backlog MCP server..."); 102 | const transport = new StdioServerTransport(); 103 | await server.connect(transport); 104 | } catch (error) { 105 | console.error("Server initialization error:", error); 106 | process.exit(1); 107 | } 108 | } 109 | 110 | main().catch((error) => { 111 | console.error("Server error:", error); 112 | process.exit(1); 113 | }); 114 | ``` -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Types for the Backlog MCP server 3 | */ 4 | 5 | // Auth configuration 6 | export interface AuthConfig { 7 | apiKey: string; 8 | spaceUrl: string; 9 | } 10 | 11 | // Backlog Project type 12 | export interface BacklogProject { 13 | id: number; 14 | projectKey: string; 15 | name: string; 16 | chartEnabled: boolean; 17 | useResolvedForChart: boolean; 18 | subtaskingEnabled: boolean; 19 | projectLeaderCanEditProjectLeader: boolean; 20 | useWiki: boolean; 21 | useFileSharing: boolean; 22 | useWikiTreeView: boolean; 23 | useSubversion: boolean; 24 | useGit: boolean; 25 | useOriginalImageSizeAtWiki: boolean; 26 | textFormattingRule: string; 27 | archived: boolean; 28 | displayOrder: number; 29 | useDevAttributes: boolean; 30 | } 31 | 32 | // Recently viewed project response 33 | export interface RecentlyViewedProject { 34 | project: BacklogProject; 35 | updated: string; 36 | } 37 | 38 | // Backlog Error response 39 | export interface BacklogError { 40 | errors: Array<{ 41 | message: string; 42 | code: number; 43 | moreInfo: string; 44 | }>; 45 | } 46 | 47 | // Backlog user information 48 | export interface BacklogUser { 49 | id: number; 50 | userId: string; 51 | name: string; 52 | roleType: number; 53 | lang: string; 54 | mailAddress: string; 55 | nulabAccount: { 56 | nulabId: string; 57 | name: string; 58 | uniqueId: string; 59 | }; 60 | } 61 | 62 | // Backlog space information 63 | export interface BacklogSpace { 64 | spaceKey: string; 65 | name: string; 66 | ownerId: number; 67 | lang: string; 68 | timezone: string; 69 | reportSendTime: string; 70 | textFormattingRule: string; 71 | created: string; 72 | updated: string; 73 | } 74 | 75 | // Backlog issue information 76 | export interface BacklogIssue { 77 | id: number; 78 | projectId: number; 79 | issueKey: string; 80 | keyId: number; 81 | issueType: { 82 | id: number; 83 | projectId: number; 84 | name: string; 85 | color: string; 86 | displayOrder: number; 87 | }; 88 | summary: string; 89 | description: string; 90 | priority: { 91 | id: number; 92 | name: string; 93 | }; 94 | status: { 95 | id: number; 96 | name: string; 97 | }; 98 | assignee: { 99 | id: number; 100 | name: string; 101 | roleType: number; 102 | userId: string; 103 | } | null; 104 | category: { 105 | id: number; 106 | name: string; 107 | }[]; 108 | versions: { 109 | id: number; 110 | name: string; 111 | }[]; 112 | milestone: { 113 | id: number; 114 | name: string; 115 | }[]; 116 | startDate: string | null; 117 | dueDate: string | null; 118 | estimatedHours: number | null; 119 | actualHours: number | null; 120 | parentIssueId: number | null; 121 | createdUser: { 122 | id: number; 123 | userId: string; 124 | name: string; 125 | }; 126 | created: string; 127 | updatedUser: { 128 | id: number; 129 | userId: string; 130 | name: string; 131 | }; 132 | updated: string; 133 | customFields: any[]; 134 | attachments: any[]; 135 | sharedFiles: any[]; 136 | stars: any[]; 137 | } 138 | 139 | // Backlog comment information 140 | export interface BacklogComment { 141 | id: number; 142 | projectId: number; 143 | issueId: number; 144 | content: string; 145 | changeLog: any[] | null; 146 | createdUser: { 147 | id: number; 148 | userId: string; 149 | name: string; 150 | roleType: number; 151 | lang: string; 152 | nulabAccount?: { 153 | nulabId: string; 154 | name: string; 155 | uniqueId: string; 156 | }; 157 | mailAddress?: string; 158 | lastLoginTime?: string; 159 | }; 160 | created: string; 161 | updated: string; 162 | stars: any[]; 163 | notifications: any[]; 164 | } 165 | 166 | // Backlog comment detail information 167 | export interface BacklogCommentDetail extends BacklogComment { 168 | // 追加のフィールドがある場合はここに定義 169 | } 170 | 171 | // Backlog comment count response 172 | export interface BacklogCommentCount { 173 | count: number; 174 | } 175 | 176 | // Backlog issue detail with comments 177 | export interface BacklogIssueDetail extends BacklogIssue { 178 | comments: BacklogComment[]; 179 | } 180 | 181 | // Backlog Wiki page 182 | export interface BacklogWikiPage { 183 | id: number; 184 | projectId: number; 185 | name: string; 186 | content?: string; 187 | tags: BacklogWikiTag[]; 188 | attachments?: any[]; 189 | sharedFiles?: any[]; 190 | stars?: any[]; 191 | createdUser: { 192 | id: number; 193 | userId: string; 194 | name: string; 195 | roleType: number; 196 | lang: string; 197 | nulabAccount: { 198 | nulabId: string; 199 | name: string; 200 | uniqueId: string; 201 | }; 202 | mailAddress: string; 203 | lastLoginTime: string; 204 | }; 205 | created: string; 206 | updatedUser: { 207 | id: number; 208 | userId: string; 209 | name: string; 210 | roleType: number; 211 | lang: string; 212 | nulabAccount: { 213 | nulabId: string; 214 | name: string; 215 | uniqueId: string; 216 | }; 217 | mailAddress: string; 218 | lastLoginTime: string; 219 | }; 220 | updated: string; 221 | } 222 | 223 | // Backlog Wiki tag 224 | export interface BacklogWikiTag { 225 | id: number; 226 | name: string; 227 | } 228 | ``` -------------------------------------------------------------------------------- /src/handlers/resource-handlers.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Resource handlers for the Backlog MCP server 3 | */ 4 | 5 | import { BacklogClient } from '../backlog-client.js'; 6 | import { RecentlyViewedProject, BacklogIssue, BacklogWikiPage } from '../types.js'; 7 | 8 | /** 9 | * Extract the project ID from a backlog URI 10 | */ 11 | function extractProjectId(uri: string): string { 12 | const url = new URL(uri); 13 | return url.pathname.replace(/^\/project\//, ''); 14 | } 15 | 16 | /** 17 | * Extract the issue ID from a backlog issue URI 18 | */ 19 | function extractIssueId(uri: string): string { 20 | const url = new URL(uri); 21 | return url.pathname.replace(/^\/issue\//, ''); 22 | } 23 | 24 | /** 25 | * Extract project key from issue key (e.g., "PROJECT-123" -> "PROJECT") 26 | */ 27 | function extractProjectKeyFromIssueKey(issueKey: string): string { 28 | const match = issueKey.match(/^([A-Z0-9_]+)-\d+$/); 29 | return match ? match[1] : ''; 30 | } 31 | 32 | /** 33 | * Extract the wiki ID from a backlog wiki URI 34 | */ 35 | function extractWikiId(uri: string): string { 36 | const url = new URL(uri); 37 | return url.pathname.replace(/^\/wiki\//, ''); 38 | } 39 | 40 | /** 41 | * Handler for listing recent projects 42 | */ 43 | export async function listRecentProjects(client: BacklogClient) { 44 | try { 45 | const projects = await client.getRecentlyViewedProjects({ count: 20 }); 46 | 47 | // Create resources for projects 48 | const projectResources = projects.map(item => ({ 49 | uri: `backlog://project/${item.project.id}`, 50 | mimeType: "application/json", 51 | name: item.project.name, 52 | description: `Backlog project: ${item.project.name} (${item.project.projectKey})` 53 | })); 54 | 55 | // For the first project, also list its issues and wikis 56 | if (projects.length > 0) { 57 | try { 58 | const firstProject = projects[0].project; 59 | const issues = await client.getIssues(firstProject.id.toString(), { count: 10 }); 60 | 61 | // Create resources for issues 62 | const issueResources = issues.map(issue => ({ 63 | uri: `backlog://issue/${issue.id}`, 64 | mimeType: "application/json", 65 | name: issue.summary, 66 | description: `Issue: ${issue.issueKey} - ${issue.summary}` 67 | })); 68 | 69 | // Try to get wiki pages for the first project 70 | try { 71 | const wikiPages = await client.getWikiPageList(firstProject.projectKey); 72 | 73 | // Create resources for wiki pages (limit to 10) 74 | const wikiResources = wikiPages.slice(0, 10).map(wiki => ({ 75 | uri: `backlog://wiki/${wiki.id}`, 76 | mimeType: "application/json", 77 | name: wiki.name, 78 | description: `Wiki: ${wiki.name}` 79 | })); 80 | 81 | return { 82 | resources: [...projectResources, ...issueResources, ...wikiResources] 83 | }; 84 | } catch (wikiError) { 85 | console.error('Error fetching wikis for first project:', wikiError); 86 | // Fall back to just returning projects and issues if wiki fetch fails 87 | return { resources: [...projectResources, ...issueResources] }; 88 | } 89 | } catch (error) { 90 | console.error('Error fetching issues for first project:', error); 91 | // Fall back to just returning projects if issues fetch fails 92 | return { resources: projectResources }; 93 | } 94 | } 95 | 96 | return { resources: projectResources }; 97 | } catch (error) { 98 | console.error('Error listing recent projects:', error); 99 | throw error; 100 | } 101 | } 102 | 103 | /** 104 | * Handler for reading a project, issue, or wiki resource 105 | */ 106 | export async function readProject(client: BacklogClient, uri: string) { 107 | try { 108 | if (uri.startsWith('backlog://project/')) { 109 | // Handle project resource 110 | const projectId = extractProjectId(uri); 111 | 112 | try { 113 | const project = await client.getProject(projectId); 114 | 115 | // Return the project data as a JSON resource 116 | return { 117 | contents: [{ 118 | uri, 119 | mimeType: "application/json", 120 | text: JSON.stringify(project, null, 2) 121 | }] 122 | }; 123 | } catch (e) { 124 | // Fallback: if direct project fetch fails, try to find it in recent projects 125 | const recentProjects = await client.getRecentlyViewedProjects({ count: 100 }); 126 | const projectData = recentProjects.find(item => item.project.id.toString() === projectId); 127 | 128 | if (!projectData) { 129 | throw new Error(`Project ${projectId} not found`); 130 | } 131 | 132 | return { 133 | contents: [{ 134 | uri, 135 | mimeType: "application/json", 136 | text: JSON.stringify(projectData.project, null, 2) 137 | }] 138 | }; 139 | } 140 | } else if (uri.startsWith('backlog://issue/')) { 141 | // Handle issue resource 142 | const issueId = extractIssueId(uri); 143 | 144 | try { 145 | const issue = await client.getIssue(issueId); 146 | 147 | // Return the issue data as a JSON resource 148 | return { 149 | contents: [{ 150 | uri, 151 | mimeType: "application/json", 152 | text: JSON.stringify(issue, null, 2) 153 | }] 154 | }; 155 | } catch (error) { 156 | console.error('Error fetching issue:', error); 157 | throw new Error(`Issue ${issueId} not found`); 158 | } 159 | } else if (uri.startsWith('backlog://wiki/')) { 160 | // Handle wiki resource 161 | const wikiId = extractWikiId(uri); 162 | 163 | try { 164 | const wiki = await client.getWikiPage(wikiId); 165 | 166 | // Return the wiki data as a JSON resource 167 | return { 168 | contents: [{ 169 | uri, 170 | mimeType: "application/json", 171 | text: JSON.stringify(wiki, null, 2) 172 | }] 173 | }; 174 | } catch (e) { 175 | console.error(`Error fetching wiki ${wikiId}:`, e); 176 | throw new Error(`Wiki not found: ${wikiId}`); 177 | } 178 | } else { 179 | throw new Error(`Unsupported resource URI: ${uri}`); 180 | } 181 | } catch (error) { 182 | console.error(`Error reading resource ${uri}:`, error); 183 | throw error; 184 | } 185 | } 186 | ``` -------------------------------------------------------------------------------- /src/handlers/prompt-handlers.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Prompt handlers for the Backlog MCP server 3 | */ 4 | 5 | import { BacklogClient } from '../backlog-client.js'; 6 | 7 | /** 8 | * List the available prompts 9 | */ 10 | export function listPrompts() { 11 | return { 12 | prompts: [ 13 | { 14 | name: "summarize_projects", 15 | description: "Summarize recently viewed Backlog projects", 16 | }, 17 | { 18 | name: "analyze_backlog_usage", 19 | description: "Analyze your Backlog usage patterns", 20 | }, 21 | { 22 | name: "summarize_wiki_pages", 23 | description: "Summarize Wiki pages from a Backlog project", 24 | } 25 | ] 26 | }; 27 | } 28 | 29 | /** 30 | * Handle prompt generation 31 | */ 32 | export async function getPrompt(client: BacklogClient, promptName: string) { 33 | try { 34 | switch (promptName) { 35 | case "summarize_projects": { 36 | // Get recent projects 37 | const recentProjects = await client.getRecentlyViewedProjects({ count: 10 }); 38 | 39 | // Create embedded resources for each project 40 | const embeddedProjects = recentProjects.map(item => ({ 41 | type: "resource" as const, 42 | resource: { 43 | uri: `backlog://project/${item.project.id}`, 44 | mimeType: "application/json", 45 | text: JSON.stringify(item.project, null, 2) 46 | } 47 | })); 48 | 49 | // Construct the prompt 50 | return { 51 | messages: [ 52 | { 53 | role: "user", 54 | content: { 55 | type: "text", 56 | text: "Please review the following recent Backlog projects:" 57 | } 58 | }, 59 | ...embeddedProjects.map(project => ({ 60 | role: "user" as const, 61 | content: project 62 | })), 63 | { 64 | role: "user", 65 | content: { 66 | type: "text", 67 | text: "Provide a concise summary of these recent projects, highlighting any patterns or important activities." 68 | } 69 | } 70 | ] 71 | }; 72 | } 73 | 74 | case "analyze_backlog_usage": { 75 | // Get user data and space data 76 | const userData = await client.getMyself(); 77 | const spaceData = await client.getSpace(); 78 | const recentProjects = await client.getRecentlyViewedProjects({ count: 20 }); 79 | 80 | // User data as resource 81 | const userResource = { 82 | type: "resource" as const, 83 | resource: { 84 | uri: "backlog://user/myself", 85 | mimeType: "application/json", 86 | text: JSON.stringify(userData, null, 2) 87 | } 88 | }; 89 | 90 | // Space data as resource 91 | const spaceResource = { 92 | type: "resource" as const, 93 | resource: { 94 | uri: "backlog://space", 95 | mimeType: "application/json", 96 | text: JSON.stringify(spaceData, null, 2) 97 | } 98 | }; 99 | 100 | // Projects summary as resource 101 | const projectsResource = { 102 | type: "resource" as const, 103 | resource: { 104 | uri: "backlog://projects/summary", 105 | mimeType: "application/json", 106 | text: JSON.stringify({ 107 | totalProjects: recentProjects.length, 108 | projectNames: recentProjects.map(p => p.project.name), 109 | lastUpdated: recentProjects.map(p => p.updated) 110 | }, null, 2) 111 | } 112 | }; 113 | 114 | // Construct the prompt 115 | return { 116 | messages: [ 117 | { 118 | role: "user", 119 | content: { 120 | type: "text", 121 | text: "I'd like to understand my Backlog usage patterns. Please analyze the following information about my Backlog account, space, and recent projects:" 122 | } 123 | }, 124 | { 125 | role: "user", 126 | content: userResource 127 | }, 128 | { 129 | role: "user", 130 | content: spaceResource 131 | }, 132 | { 133 | role: "user", 134 | content: projectsResource 135 | }, 136 | { 137 | role: "user", 138 | content: { 139 | type: "text", 140 | text: "Based on this data, please provide insights about how I'm using Backlog, which projects I'm focusing on recently, and any suggestions for improving my workflow." 141 | } 142 | } 143 | ] 144 | }; 145 | } 146 | 147 | case "summarize_wiki_pages": { 148 | // Get recent projects to select one 149 | const recentProjects = await client.getRecentlyViewedProjects({ count: 5 }); 150 | 151 | if (recentProjects.length === 0) { 152 | throw new Error("No recent projects found"); 153 | } 154 | 155 | // Use the first project 156 | const firstProject = recentProjects[0].project; 157 | 158 | // Get wiki pages for the project 159 | const wikiPages = await client.getWikiPageList(firstProject.projectKey); 160 | 161 | // Limit to 10 wiki pages 162 | const limitedWikiPages = wikiPages.slice(0, 10); 163 | 164 | // Create embedded resources for each wiki page 165 | const embeddedWikiPages = await Promise.all( 166 | limitedWikiPages.map(async (wiki) => { 167 | // Get full wiki content 168 | const fullWiki = await client.getWikiPage(wiki.id.toString()); 169 | 170 | return { 171 | type: "resource" as const, 172 | resource: { 173 | uri: `backlog://wiki/${wiki.id}`, 174 | mimeType: "application/json", 175 | text: JSON.stringify(fullWiki, null, 2) 176 | } 177 | }; 178 | }) 179 | ); 180 | 181 | // Construct the prompt 182 | return { 183 | messages: [ 184 | { 185 | role: "user", 186 | content: { 187 | type: "text", 188 | text: `Please review the following Wiki pages from the "${firstProject.name}" project:` 189 | } 190 | }, 191 | ...embeddedWikiPages.map(wiki => ({ 192 | role: "user" as const, 193 | content: wiki 194 | })), 195 | { 196 | role: "user", 197 | content: { 198 | type: "text", 199 | text: "Provide a concise summary of these Wiki pages, highlighting the key information and how they relate to each other." 200 | } 201 | } 202 | ] 203 | }; 204 | } 205 | 206 | default: 207 | throw new Error(`Unknown prompt: ${promptName}`); 208 | } 209 | } catch (error) { 210 | console.error(`Error generating prompt ${promptName}:`, error); 211 | throw error; 212 | } 213 | } 214 | ``` -------------------------------------------------------------------------------- /src/backlog-client.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Backlog API client for the MCP server 3 | */ 4 | 5 | import { AuthConfig, RecentlyViewedProject, BacklogProject, BacklogError, BacklogIssue, BacklogIssueDetail, BacklogComment, BacklogCommentDetail, BacklogCommentCount, BacklogWikiPage } from './types.js'; 6 | 7 | /** 8 | * Backlog API client for making API calls 9 | */ 10 | export class BacklogClient { 11 | private config: AuthConfig; 12 | 13 | constructor(config: AuthConfig) { 14 | this.config = config; 15 | } 16 | 17 | /** 18 | * Get the full API URL with API key parameter 19 | */ 20 | private getUrl(path: string, queryParams: Record<string, string> = {}): string { 21 | const url = new URL(`${this.config.spaceUrl}/api/v2${path}`); 22 | 23 | // Add API key 24 | url.searchParams.append('apiKey', this.config.apiKey); 25 | 26 | // Add any additional query parameters 27 | Object.entries(queryParams).forEach(([key, value]) => { 28 | url.searchParams.append(key, value); 29 | }); 30 | 31 | return url.toString(); 32 | } 33 | 34 | /** 35 | * Make an API request to Backlog 36 | */ 37 | private async request<T>(path: string, options: RequestInit = {}, queryParams: Record<string, string> = {}): Promise<T> { 38 | const url = this.getUrl(path, queryParams); 39 | 40 | try { 41 | const response = await fetch(url, { 42 | ...options, 43 | headers: { 44 | 'Content-Type': 'application/json', 45 | ...options.headers, 46 | }, 47 | }); 48 | 49 | const data = await response.json(); 50 | 51 | if (!response.ok) { 52 | const error = data as BacklogError; 53 | throw new Error(`Backlog API Error: ${error.errors?.[0]?.message || 'Unknown error'} (Code: ${error.errors?.[0]?.code})`); 54 | } 55 | 56 | return data as T; 57 | } catch (error) { 58 | console.error(`Error in Backlog API request to ${path}:`, error); 59 | throw error; 60 | } 61 | } 62 | 63 | /** 64 | * Make a POST request with form data to Backlog 65 | */ 66 | private async postFormData<T>(path: string, formData: Record<string, string | number | boolean>): Promise<T> { 67 | const url = this.getUrl(path); 68 | const formBody = new URLSearchParams(); 69 | 70 | // Add form parameters 71 | Object.entries(formData).forEach(([key, value]) => { 72 | formBody.append(key, value.toString()); 73 | }); 74 | 75 | try { 76 | const response = await fetch(url, { 77 | method: 'POST', 78 | headers: { 79 | 'Content-Type': 'application/x-www-form-urlencoded', 80 | }, 81 | body: formBody, 82 | }); 83 | 84 | const data = await response.json(); 85 | 86 | if (!response.ok) { 87 | const error = data as BacklogError; 88 | throw new Error(`Backlog API Error: ${error.errors?.[0]?.message || 'Unknown error'} (Code: ${error.errors?.[0]?.code})`); 89 | } 90 | 91 | return data as T; 92 | } catch (error) { 93 | console.error(`Error in Backlog API POST request to ${path}:`, error); 94 | throw error; 95 | } 96 | } 97 | 98 | /** 99 | * Get recently viewed projects for the current user 100 | */ 101 | async getRecentlyViewedProjects(params: { order?: 'asc' | 'desc', offset?: number, count?: number } = {}): Promise<RecentlyViewedProject[]> { 102 | const queryParams: Record<string, string> = {}; 103 | 104 | if (params.order) queryParams.order = params.order; 105 | if (params.offset !== undefined) queryParams.offset = params.offset.toString(); 106 | if (params.count !== undefined) queryParams.count = params.count.toString(); 107 | 108 | return this.request<RecentlyViewedProject[]>('/users/myself/recentlyViewedProjects', {}, queryParams); 109 | } 110 | 111 | /** 112 | * Get information about a specific project 113 | */ 114 | async getProject(projectId: string): Promise<BacklogProject> { 115 | return this.request<BacklogProject>(`/projects/${projectId}`); 116 | } 117 | 118 | /** 119 | * Get information about the current user 120 | */ 121 | async getMyself() { 122 | return this.request('/users/myself'); 123 | } 124 | 125 | /** 126 | * Get space information 127 | */ 128 | async getSpace() { 129 | return this.request('/space'); 130 | } 131 | 132 | /** 133 | * Get issues from a project 134 | * @param projectIdOrKey Project ID or project key 135 | * @param params Query parameters for filtering issues 136 | */ 137 | async getIssues(projectIdOrKey: string, params: { 138 | statusId?: number[] | number; 139 | assigneeId?: number[] | number; 140 | categoryId?: number[] | number; 141 | priorityId?: number[] | number; 142 | offset?: number; 143 | count?: number; 144 | sort?: string; 145 | order?: 'asc' | 'desc'; 146 | } = {}): Promise<BacklogIssue[]> { 147 | const queryParams: Record<string, string> = {}; 148 | 149 | // Convert parameters to the format expected by the Backlog API 150 | Object.entries(params).forEach(([key, value]) => { 151 | if (Array.isArray(value)) { 152 | value.forEach(v => { 153 | queryParams[`${key}[]`] = v.toString(); 154 | }); 155 | } else if (value !== undefined) { 156 | queryParams[key] = value.toString(); 157 | } 158 | }); 159 | 160 | return this.request<BacklogIssue[]>(`/projects/${projectIdOrKey}/issues`, {}, queryParams); 161 | } 162 | 163 | /** 164 | * Get detailed information about a specific issue 165 | * @param issueIdOrKey Issue ID or issue key 166 | */ 167 | async getIssue(issueIdOrKey: string): Promise<BacklogIssueDetail> { 168 | return this.request<BacklogIssueDetail>(`/issues/${issueIdOrKey}`); 169 | } 170 | 171 | /** 172 | * Get comments from an issue 173 | * @param issueIdOrKey Issue ID or issue key 174 | * @param params Query parameters for filtering comments 175 | */ 176 | async getComments(issueIdOrKey: string, params: { 177 | minId?: number; 178 | maxId?: number; 179 | count?: number; 180 | order?: 'asc' | 'desc'; 181 | } = {}): Promise<BacklogComment[]> { 182 | const queryParams: Record<string, string> = {}; 183 | 184 | if (params.minId !== undefined) queryParams.minId = params.minId.toString(); 185 | if (params.maxId !== undefined) queryParams.maxId = params.maxId.toString(); 186 | if (params.count !== undefined) queryParams.count = params.count.toString(); 187 | if (params.order) queryParams.order = params.order; 188 | 189 | return this.request<BacklogComment[]>(`/issues/${issueIdOrKey}/comments`, {}, queryParams); 190 | } 191 | 192 | /** 193 | * Add a comment to an issue 194 | * @param issueIdOrKey Issue ID or issue key 195 | * @param content Comment content 196 | */ 197 | async addComment(issueIdOrKey: string, content: string): Promise<BacklogComment> { 198 | return this.postFormData<BacklogComment>(`/issues/${issueIdOrKey}/comments`, { 199 | content 200 | }); 201 | } 202 | 203 | /** 204 | * Get the count of comments in an issue 205 | * @param issueIdOrKey Issue ID or issue key 206 | */ 207 | async getCommentCount(issueIdOrKey: string): Promise<BacklogCommentCount> { 208 | return this.request<BacklogCommentCount>(`/issues/${issueIdOrKey}/comments/count`); 209 | } 210 | 211 | /** 212 | * Get detailed information about a specific comment 213 | * @param issueIdOrKey Issue ID or issue key 214 | * @param commentId Comment ID 215 | */ 216 | async getComment(issueIdOrKey: string, commentId: number): Promise<BacklogCommentDetail> { 217 | return this.request<BacklogCommentDetail>(`/issues/${issueIdOrKey}/comments/${commentId}`); 218 | } 219 | 220 | /** 221 | * Get Wiki page list 222 | */ 223 | async getWikiPageList(projectIdOrKey?: string, keyword?: string): Promise<BacklogWikiPage[]> { 224 | const queryParams: Record<string, string> = {}; 225 | 226 | if (projectIdOrKey) { 227 | queryParams.projectIdOrKey = projectIdOrKey; 228 | } 229 | 230 | if (keyword) { 231 | queryParams.keyword = keyword; 232 | } 233 | 234 | return this.request<BacklogWikiPage[]>('/wikis', {}, queryParams); 235 | } 236 | 237 | /** 238 | * Get Wiki page detail 239 | */ 240 | async getWikiPage(wikiId: string): Promise<BacklogWikiPage> { 241 | return this.request<BacklogWikiPage>(`/wikis/${wikiId}`); 242 | } 243 | 244 | /** 245 | * Update Wiki page 246 | */ 247 | async updateWikiPage( 248 | wikiId: string, 249 | params: { 250 | name?: string; 251 | content?: string; 252 | mailNotify?: boolean; 253 | } 254 | ): Promise<BacklogWikiPage> { 255 | const formData: Record<string, string | number | boolean> = {}; 256 | 257 | if (params.name !== undefined) { 258 | formData.name = params.name; 259 | } 260 | 261 | if (params.content !== undefined) { 262 | formData.content = params.content; 263 | } 264 | 265 | if (params.mailNotify !== undefined) { 266 | formData.mailNotify = params.mailNotify; 267 | } 268 | 269 | return this.postFormData<BacklogWikiPage>(`/wikis/${wikiId}`, formData); 270 | } 271 | } 272 | ``` -------------------------------------------------------------------------------- /src/handlers/tool-handlers.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Tool handlers for the Backlog MCP server 3 | */ 4 | 5 | import { BacklogClient } from '../backlog-client.js'; 6 | 7 | /** 8 | * List the available tools for Backlog operations 9 | */ 10 | export function listTools() { 11 | return { 12 | tools: [ 13 | { 14 | name: "get_backlog_user", 15 | description: "Get current Backlog user information", 16 | inputSchema: { 17 | type: "object", 18 | properties: {}, 19 | required: [] 20 | } 21 | }, 22 | { 23 | name: "get_backlog_space", 24 | description: "Get Backlog space information", 25 | inputSchema: { 26 | type: "object", 27 | properties: {}, 28 | required: [] 29 | } 30 | }, 31 | { 32 | name: "list_recent_projects", 33 | description: "List recently viewed Backlog projects", 34 | inputSchema: { 35 | type: "object", 36 | properties: { 37 | count: { 38 | type: "number", 39 | description: "Number of projects to retrieve (1-100, default 20)" 40 | }, 41 | order: { 42 | type: "string", 43 | description: "Sorting order (asc or desc, default desc)", 44 | enum: ["asc", "desc"] 45 | } 46 | }, 47 | required: [] 48 | } 49 | }, 50 | { 51 | name: "get_project_issues", 52 | description: "Get issues from a Backlog project", 53 | inputSchema: { 54 | type: "object", 55 | properties: { 56 | projectIdOrKey: { 57 | type: "string", 58 | description: "Project ID or project key" 59 | }, 60 | statusId: { 61 | type: "array", 62 | items: { 63 | type: "number" 64 | }, 65 | description: "Filter by status IDs" 66 | }, 67 | assigneeId: { 68 | type: "array", 69 | items: { 70 | type: "number" 71 | }, 72 | description: "Filter by assignee IDs" 73 | }, 74 | count: { 75 | type: "number", 76 | description: "Number of issues to retrieve (1-100, default 20)" 77 | }, 78 | offset: { 79 | type: "number", 80 | description: "Offset for pagination" 81 | }, 82 | sort: { 83 | type: "string", 84 | description: "Sort field (e.g., 'created', 'updated')" 85 | }, 86 | order: { 87 | type: "string", 88 | description: "Sorting order (asc or desc, default desc)", 89 | enum: ["asc", "desc"] 90 | } 91 | }, 92 | required: ["projectIdOrKey"] 93 | } 94 | }, 95 | { 96 | name: "get_issue_detail", 97 | description: "Get detailed information about a specific Backlog issue", 98 | inputSchema: { 99 | type: "object", 100 | properties: { 101 | issueIdOrKey: { 102 | type: "string", 103 | description: "Issue ID or issue key" 104 | } 105 | }, 106 | required: ["issueIdOrKey"] 107 | } 108 | }, 109 | { 110 | name: "get_issue_comments", 111 | description: "Get comments from a specific Backlog issue", 112 | inputSchema: { 113 | type: "object", 114 | properties: { 115 | issueIdOrKey: { 116 | type: "string", 117 | description: "Issue ID or issue key" 118 | }, 119 | minId: { 120 | type: "number", 121 | description: "Minimum comment ID" 122 | }, 123 | maxId: { 124 | type: "number", 125 | description: "Maximum comment ID" 126 | }, 127 | count: { 128 | type: "number", 129 | description: "Number of comments to retrieve (1-100, default 20)" 130 | }, 131 | order: { 132 | type: "string", 133 | description: "Sorting order (asc or desc, default desc)", 134 | enum: ["asc", "desc"] 135 | } 136 | }, 137 | required: ["issueIdOrKey"] 138 | } 139 | }, 140 | { 141 | name: "add_issue_comment", 142 | description: "Add a comment to a specific Backlog issue", 143 | inputSchema: { 144 | type: "object", 145 | properties: { 146 | issueIdOrKey: { 147 | type: "string", 148 | description: "Issue ID or issue key" 149 | }, 150 | content: { 151 | type: "string", 152 | description: "Comment content" 153 | } 154 | }, 155 | required: ["issueIdOrKey", "content"] 156 | } 157 | }, 158 | { 159 | name: "get_issue_comment_count", 160 | description: "Get the count of comments in a specific Backlog issue", 161 | inputSchema: { 162 | type: "object", 163 | properties: { 164 | issueIdOrKey: { 165 | type: "string", 166 | description: "Issue ID or issue key" 167 | } 168 | }, 169 | required: ["issueIdOrKey"] 170 | } 171 | }, 172 | { 173 | name: "get_issue_comment", 174 | description: "Get detailed information about a specific comment in a Backlog issue", 175 | inputSchema: { 176 | type: "object", 177 | properties: { 178 | issueIdOrKey: { 179 | type: "string", 180 | description: "Issue ID or issue key" 181 | }, 182 | commentId: { 183 | type: "number", 184 | description: "Comment ID" 185 | } 186 | }, 187 | required: ["issueIdOrKey", "commentId"] 188 | } 189 | }, 190 | { 191 | name: "get_wiki_page_list", 192 | description: "Get a list of Wiki pages from Backlog", 193 | inputSchema: { 194 | type: "object", 195 | properties: { 196 | projectIdOrKey: { 197 | type: "string", 198 | description: "Project ID or project key (optional)" 199 | }, 200 | keyword: { 201 | type: "string", 202 | description: "Keyword to search for in Wiki pages (optional)" 203 | } 204 | }, 205 | required: [] 206 | } 207 | }, 208 | { 209 | name: "get_wiki_page", 210 | description: "Get detailed information about a specific Wiki page", 211 | inputSchema: { 212 | type: "object", 213 | properties: { 214 | wikiId: { 215 | type: "string", 216 | description: "Wiki page ID" 217 | } 218 | }, 219 | required: ["wikiId"] 220 | } 221 | }, 222 | { 223 | name: "update_wiki_page", 224 | description: "Update a Wiki page in Backlog", 225 | inputSchema: { 226 | type: "object", 227 | properties: { 228 | wikiId: { 229 | type: "string", 230 | description: "Wiki page ID" 231 | }, 232 | name: { 233 | type: "string", 234 | description: "New name for the Wiki page (optional)" 235 | }, 236 | content: { 237 | type: "string", 238 | description: "New content for the Wiki page (optional)" 239 | }, 240 | mailNotify: { 241 | type: "boolean", 242 | description: "Whether to send notification emails (optional)" 243 | } 244 | }, 245 | required: ["wikiId"] 246 | } 247 | } 248 | ] 249 | }; 250 | } 251 | 252 | /** 253 | * Format data for display in tool response 254 | */ 255 | function formatToolResponse(title: string, data: any): any { 256 | return { 257 | content: [ 258 | { 259 | type: "text", 260 | text: `# ${title}\n\n${JSON.stringify(data, null, 2)}` 261 | } 262 | ] 263 | }; 264 | } 265 | 266 | /** 267 | * Handle tool execution 268 | */ 269 | export async function executeTools(client: BacklogClient, toolName: string, args: any) { 270 | try { 271 | switch (toolName) { 272 | case "get_backlog_user": { 273 | const userData = await client.getMyself(); 274 | return formatToolResponse("Backlog User Information", userData); 275 | } 276 | 277 | case "get_backlog_space": { 278 | const spaceData = await client.getSpace(); 279 | return formatToolResponse("Backlog Space Information", spaceData); 280 | } 281 | 282 | case "list_recent_projects": { 283 | const count = args?.count && Number(args.count) > 0 && Number(args.count) <= 100 284 | ? Number(args.count) 285 | : 20; 286 | 287 | const order = args?.order === 'asc' ? 'asc' : 'desc'; 288 | 289 | const projects = await client.getRecentlyViewedProjects({ 290 | count, 291 | order 292 | }); 293 | 294 | return formatToolResponse("Recently Viewed Projects", projects); 295 | } 296 | 297 | case "get_project_issues": { 298 | if (!args?.projectIdOrKey) { 299 | throw new Error("Project ID or key is required"); 300 | } 301 | 302 | const projectIdOrKey = args.projectIdOrKey; 303 | const count = args?.count && Number(args.count) > 0 && Number(args.count) <= 100 304 | ? Number(args.count) 305 | : 20; 306 | const offset = args?.offset && Number(args.offset) >= 0 307 | ? Number(args.offset) 308 | : 0; 309 | const sort = args?.sort || 'created'; 310 | const order = args?.order === 'asc' ? 'asc' : 'desc'; 311 | 312 | // Handle array parameters 313 | const statusId = args?.statusId; 314 | const assigneeId = args?.assigneeId; 315 | 316 | const issues = await client.getIssues(projectIdOrKey, { 317 | statusId, 318 | assigneeId, 319 | count, 320 | offset, 321 | sort, 322 | order 323 | }); 324 | 325 | return formatToolResponse("Project Issues", issues); 326 | } 327 | 328 | case "get_issue_detail": { 329 | if (!args?.issueIdOrKey) { 330 | throw new Error("Issue ID or key is required"); 331 | } 332 | 333 | const issueIdOrKey = args.issueIdOrKey; 334 | const issueDetail = await client.getIssue(issueIdOrKey); 335 | 336 | return formatToolResponse("Issue Detail", issueDetail); 337 | } 338 | 339 | case "get_issue_comments": { 340 | if (!args?.issueIdOrKey) { 341 | throw new Error("Issue ID or key is required"); 342 | } 343 | 344 | const issueIdOrKey = args.issueIdOrKey; 345 | const minId = args?.minId !== undefined ? Number(args.minId) : undefined; 346 | const maxId = args?.maxId !== undefined ? Number(args.maxId) : undefined; 347 | const count = args?.count && Number(args.count) > 0 && Number(args.count) <= 100 348 | ? Number(args.count) 349 | : 20; 350 | const order = args?.order === 'asc' ? 'asc' : 'desc'; 351 | 352 | const comments = await client.getComments(issueIdOrKey, { 353 | minId, 354 | maxId, 355 | count, 356 | order 357 | }); 358 | 359 | return formatToolResponse("Issue Comments", comments); 360 | } 361 | 362 | case "add_issue_comment": { 363 | if (!args?.issueIdOrKey) { 364 | throw new Error("Issue ID or key is required"); 365 | } 366 | 367 | if (!args?.content) { 368 | throw new Error("Comment content is required"); 369 | } 370 | 371 | const issueIdOrKey = args.issueIdOrKey; 372 | const content = args.content; 373 | 374 | const comment = await client.addComment(issueIdOrKey, content); 375 | 376 | return formatToolResponse("Added Comment", comment); 377 | } 378 | 379 | case "get_issue_comment_count": { 380 | if (!args?.issueIdOrKey) { 381 | throw new Error("Issue ID or key is required"); 382 | } 383 | 384 | const issueIdOrKey = args.issueIdOrKey; 385 | const commentCount = await client.getCommentCount(issueIdOrKey); 386 | 387 | return formatToolResponse("Issue Comment Count", commentCount); 388 | } 389 | 390 | case "get_issue_comment": { 391 | if (!args?.issueIdOrKey) { 392 | throw new Error("Issue ID or key is required"); 393 | } 394 | 395 | if (!args?.commentId) { 396 | throw new Error("Comment ID is required"); 397 | } 398 | 399 | const issueIdOrKey = args.issueIdOrKey; 400 | const commentId = Number(args.commentId); 401 | 402 | const comment = await client.getComment(issueIdOrKey, commentId); 403 | 404 | return formatToolResponse("Issue Comment", comment); 405 | } 406 | 407 | case "get_wiki_page_list": { 408 | const { projectIdOrKey, keyword } = args; 409 | const wikiPages = await client.getWikiPageList(projectIdOrKey, keyword); 410 | return formatToolResponse("Wiki Pages", wikiPages); 411 | } 412 | 413 | case "get_wiki_page": { 414 | const { wikiId } = args; 415 | if (!wikiId) { 416 | throw new Error("Wiki ID is required"); 417 | } 418 | const wikiPage = await client.getWikiPage(wikiId); 419 | return formatToolResponse("Wiki Page", wikiPage); 420 | } 421 | 422 | case "update_wiki_page": { 423 | const { wikiId, name, content, mailNotify } = args; 424 | if (!wikiId) { 425 | throw new Error("Wiki ID is required"); 426 | } 427 | const updatedWikiPage = await client.updateWikiPage(wikiId, { 428 | name, 429 | content, 430 | mailNotify 431 | }); 432 | return formatToolResponse("Updated Wiki Page", updatedWikiPage); 433 | } 434 | 435 | default: 436 | throw new Error("Unknown tool"); 437 | } 438 | } catch (error) { 439 | console.error(`Error executing tool ${toolName}:`, error); 440 | throw error; 441 | } 442 | } 443 | ```