# 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 |
```