This is page 3 of 4. Use http://codebase.md/phuc-nt/mcp-atlassian-server?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .env.example
├── .gitignore
├── .npmignore
├── assets
│ ├── atlassian_logo_icon.png
│ └── atlassian_logo_icon.webp
├── CHANGELOG.md
├── dev_mcp-atlassian-test-client
│ ├── package-lock.json
│ ├── package.json
│ ├── src
│ │ ├── list-mcp-inventory.ts
│ │ ├── test-confluence-pages.ts
│ │ ├── test-confluence-spaces.ts
│ │ ├── test-jira-issues.ts
│ │ ├── test-jira-projects.ts
│ │ ├── test-jira-users.ts
│ │ └── tool-test.ts
│ └── tsconfig.json
├── docker-compose.yml
├── Dockerfile
├── docs
│ ├── dev-guide
│ │ ├── advance-resource-tool-2.md
│ │ ├── advance-resource-tool-3.md
│ │ ├── advance-resource-tool.md
│ │ ├── confluence-migrate-to-v2.md
│ │ ├── github-community-exchange.md
│ │ ├── marketplace-publish-application-template.md
│ │ ├── marketplace-publish-guideline.md
│ │ ├── mcp-client-for-testing.md
│ │ ├── mcp-overview.md
│ │ ├── migrate-api-v2-to-v3.md
│ │ ├── mini-plan-refactor-tools.md
│ │ ├── modelcontextprotocol-architecture.md
│ │ ├── modelcontextprotocol-introduction.md
│ │ ├── modelcontextprotocol-resources.md
│ │ ├── modelcontextprotocol-tools.md
│ │ ├── one-click-setup.md
│ │ ├── prompts.md
│ │ ├── release-with-prebuild-bundle.md
│ │ ├── resource-metadata-schema-guideline.md
│ │ ├── resources.md
│ │ ├── sampling.md
│ │ ├── schema-metadata.md
│ │ ├── stdio-transport.md
│ │ ├── tool-vs-resource.md
│ │ ├── tools.md
│ │ └── workflow-examples.md
│ ├── introduction
│ │ ├── marketplace-submission.md
│ │ └── resources-and-tools.md
│ ├── knowledge
│ │ ├── 01-mcp-overview-architecture.md
│ │ ├── 02-mcp-tools-resources.md
│ │ ├── 03-mcp-prompts-sampling.md
│ │ ├── building-mcp-server.md
│ │ └── client-development-guide.md
│ ├── plan
│ │ ├── history.md
│ │ ├── roadmap.md
│ │ └── todo.md
│ └── test-reports
│ ├── cline-installation-test-2025-05-04.md
│ └── cline-test-2025-04-20.md
├── jest.config.js
├── LICENSE
├── llms-install-bundle.md
├── llms-install.md
├── package-lock.json
├── package.json
├── README.md
├── RELEASE_NOTES.md
├── smithery.yaml
├── src
│ ├── index.ts
│ ├── resources
│ │ ├── confluence
│ │ │ ├── index.ts
│ │ │ ├── pages.ts
│ │ │ └── spaces.ts
│ │ ├── index.ts
│ │ └── jira
│ │ ├── boards.ts
│ │ ├── dashboards.ts
│ │ ├── filters.ts
│ │ ├── index.ts
│ │ ├── issues.ts
│ │ ├── projects.ts
│ │ ├── sprints.ts
│ │ └── users.ts
│ ├── schemas
│ │ ├── common.ts
│ │ ├── confluence.ts
│ │ └── jira.ts
│ ├── tests
│ │ ├── confluence
│ │ │ └── create-page.test.ts
│ │ └── e2e
│ │ └── mcp-server.test.ts
│ ├── tools
│ │ ├── confluence
│ │ │ ├── add-comment.ts
│ │ │ ├── create-page.ts
│ │ │ ├── delete-footer-comment.ts
│ │ │ ├── delete-page.ts
│ │ │ ├── update-footer-comment.ts
│ │ │ ├── update-page-title.ts
│ │ │ └── update-page.ts
│ │ ├── index.ts
│ │ └── jira
│ │ ├── add-gadget-to-dashboard.ts
│ │ ├── add-issue-to-sprint.ts
│ │ ├── add-issues-to-backlog.ts
│ │ ├── assign-issue.ts
│ │ ├── close-sprint.ts
│ │ ├── create-dashboard.ts
│ │ ├── create-filter.ts
│ │ ├── create-issue.ts
│ │ ├── create-sprint.ts
│ │ ├── delete-filter.ts
│ │ ├── get-gadgets.ts
│ │ ├── rank-backlog-issues.ts
│ │ ├── remove-gadget-from-dashboard.ts
│ │ ├── start-sprint.ts
│ │ ├── transition-issue.ts
│ │ ├── update-dashboard.ts
│ │ ├── update-filter.ts
│ │ └── update-issue.ts
│ └── utils
│ ├── atlassian-api-base.ts
│ ├── confluence-interfaces.ts
│ ├── confluence-resource-api.ts
│ ├── confluence-tool-api.ts
│ ├── error-handler.ts
│ ├── jira-interfaces.ts
│ ├── jira-resource-api.ts
│ ├── jira-tool-api-agile.ts
│ ├── jira-tool-api-v3.ts
│ ├── jira-tool-api.ts
│ ├── logger.ts
│ ├── mcp-core.ts
│ └── mcp-helpers.ts
├── start-docker.sh
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/llms-install.md:
--------------------------------------------------------------------------------
```markdown
1 | # MCP Atlassian Server (by phuc-nt) Installation Guide for AI
2 |
3 | > **Important Note:** MCP Atlassian Server (by phuc-nt) is primarily developed and optimized for use with the Cline AI assistant. While it follows the MCP standard and can work with other compatible MCP clients, the best performance and experience are achieved with Cline.
4 |
5 | > **Version Note:** This guide is for MCP Atlassian Server v2.0.1. For detailed documentation on architecture, development, and usage, refer to the new documentation series in the `/docs/knowledge/` directory.
6 |
7 | ## System Requirements
8 | - macOS 10.15+ or Windows 10+
9 | - Atlassian Cloud account and API token
10 | - Cline AI assistant (main supported client)
11 |
12 | ## Installation Options
13 |
14 | You have two ways to install MCP Atlassian Server:
15 |
16 | 1. **[Install from npm](#option-1-install-from-npm)** (recommended, easier) - Install directly from npm registry
17 | 2. **[Clone & Build from source](#option-2-clone-and-build-from-source)** - Clone the GitHub repository and build locally
18 |
19 | ## Option 1: Install from npm
20 |
21 | This is the recommended method as it's simpler and lets you easily update to new versions.
22 |
23 | ### Install the package globally
24 |
25 | ```bash
26 | npm install -g @phuc-nt/mcp-atlassian-server
27 | ```
28 |
29 | Or install in your project:
30 |
31 | ```bash
32 | npm install @phuc-nt/mcp-atlassian-server
33 | ```
34 |
35 | ### Find the installation path
36 |
37 | After installation, you'll need to know the path to the package for Cline configuration:
38 |
39 | ```bash
40 | # For global installation, find the global node_modules directory
41 | npm root -g
42 | # Output will be something like: /usr/local/lib/node_modules
43 |
44 | # For local installation, the path will be in your project directory
45 | # e.g., /your/project/node_modules/@phuc-nt/mcp-atlassian-server
46 | ```
47 |
48 | The full path to the executable will be: `<npm_modules_path>/@phuc-nt/mcp-atlassian-server/dist/index.js`
49 |
50 | Skip to [Configure Cline section](#configure-cline) after installing from npm.
51 |
52 | ## Option 2: Clone and Build from Source
53 |
54 | ### Prerequisite Tools Check & Installation
55 |
56 | ### Check Installed Tools
57 |
58 | Verify that Git, Node.js, and npm are installed:
59 |
60 | ```bash
61 | git --version
62 | node --version
63 | npm --version
64 | ```
65 |
66 | If the above commands show version numbers, you have the required tools. If not, follow the steps below:
67 |
68 | ### Install Git
69 |
70 | #### macOS
71 | **Method 1**: Using Homebrew (recommended)
72 | ```bash
73 | # Install Homebrew if not available
74 | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
75 |
76 | # Install Git
77 | brew install git
78 | ```
79 |
80 | **Method 2**: Install Xcode Command Line Tools
81 | ```bash
82 | xcode-select --install
83 | ```
84 |
85 | #### Windows
86 | 1. Download the Git installer from [git-scm.com](https://git-scm.com/download/win)
87 | 2. Run the installer with default options
88 | 3. After installation, open Command Prompt or PowerShell and check: `git --version`
89 |
90 | ### Install Node.js and npm
91 |
92 | #### macOS
93 | **Method 1**: Using Homebrew (recommended)
94 | ```bash
95 | brew install node
96 | ```
97 |
98 | **Method 2**: Using nvm (Node Version Manager)
99 | ```bash
100 | # Install nvm
101 | curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
102 |
103 | # Install Node.js LTS
104 | nvm install --lts
105 | ```
106 |
107 | #### Windows
108 | 1. Download Node.js installer from [nodejs.org](https://nodejs.org/) (choose LTS version)
109 | 2. Run the installer with default options
110 | 3. After installation, open Command Prompt or PowerShell and check:
111 | ```
112 | node --version
113 | npm --version
114 | ```
115 |
116 | ### Step 1: Clone the Repository
117 | ```bash
118 | # macOS/Linux
119 | git clone https://github.com/phuc-nt/mcp-atlassian-server.git
120 | cd mcp-atlassian-server
121 |
122 | # Windows
123 | git clone https://github.com/phuc-nt/mcp-atlassian-server.git
124 | cd mcp-atlassian-server
125 | ```
126 |
127 | ### Step 2: Install Dependencies
128 | ```bash
129 | npm install
130 | ```
131 |
132 | ### Step 3: Build the Project
133 | ```bash
134 | npm run build
135 | ```
136 |
137 | ## Configure Cline
138 |
139 | MCP Atlassian Server is specifically designed for seamless integration with Cline. Below is the guide to configure Cline to connect to the server:
140 |
141 | ### Determine the Full Path
142 |
143 | #### For npm installation
144 | If you installed the package via npm, you need the path to the installed package:
145 |
146 | ```bash
147 | # For global npm installation
148 | echo "$(npm root -g)/@phuc-nt/mcp-atlassian-server/dist/index.js"
149 |
150 | # For local npm installation (run from your project directory)
151 | echo "$(pwd)/node_modules/@phuc-nt/mcp-atlassian-server/dist/index.js"
152 | ```
153 |
154 | #### For source code installation
155 |
156 | First, determine the full path to your project directory:
157 |
158 | ```bash
159 | # macOS/Linux
160 | pwd
161 |
162 | # Windows (PowerShell)
163 | (Get-Location).Path
164 |
165 | # Windows (Command Prompt)
166 | cd
167 | ```
168 |
169 | Then, add the following configuration to your `cline_mcp_settings.json` file:
170 |
171 | ```json
172 | {
173 | "mcpServers": {
174 | "phuc-nt/mcp-atlassian-server": {
175 | "disabled": false,
176 | "timeout": 60,
177 | "command": "node",
178 | "args": [
179 | "/path/to/mcp-atlassian-server/dist/index.js"
180 | ],
181 | "env": {
182 | "ATLASSIAN_SITE_NAME": "your-site.atlassian.net",
183 | "ATLASSIAN_USER_EMAIL": "[email protected]",
184 | "ATLASSIAN_API_TOKEN": "your-api-token"
185 | },
186 | "transportType": "stdio"
187 | }
188 | }
189 | }
190 | ```
191 |
192 | Replace:
193 | - For **npm installation**: Use the path to the npm package:
194 | - Global install: `/path/to/global/node_modules/@phuc-nt/mcp-atlassian-server/dist/index.js`
195 | - Local install: `/path/to/your/project/node_modules/@phuc-nt/mcp-atlassian-server/dist/index.js`
196 | - For **source installation**: Use the path you just obtained with `pwd` command
197 | - `your-site.atlassian.net` with your Atlassian site name
198 | - `[email protected]` with your Atlassian email
199 | - `your-api-token` with your Atlassian API token
200 |
201 | > **Note for global npm installs**: You can find the global node_modules path by running: `npm root -g`
202 |
203 | > **Note for Windows**: The path on Windows may look like `C:\\Users\\YourName\\AppData\\Roaming\\npm\\node_modules\\@phuc-nt\\mcp-atlassian-server\\dist\\index.js` (use `\\` instead of `/`).
204 |
205 | ## Step 5: Get Atlassian API Token
206 | 1. Go to https://id.atlassian.com/manage-profile/security/api-tokens
207 | 2. Click "Create API token", name it (e.g., "MCP Server")
208 | 3. Copy the token immediately (it will not be shown again)
209 |
210 | ### Note on API Token Permissions
211 |
212 | - **The API token inherits all permissions of the account that created it** – there is no separate permission mechanism for the token itself.
213 | - **To use all features of MCP Server**, the account creating the token must have appropriate permissions:
214 | - **Jira**: Needs Browse Projects, Edit Issues, Assign Issues, Transition Issues, Create Issues, etc.
215 | - **Confluence**: Needs View Spaces, Add Pages, Add Comments, Edit Pages, etc.
216 | - **If the token is read-only**, you can only use read resources (view issues, projects) but cannot create/update.
217 | - **Recommendations**:
218 | - For personal use: You can use your main account's token
219 | - For team/long-term use: Create a dedicated service account with appropriate permissions
220 | - Do not share your token; if you suspect it is compromised, revoke and create a new one
221 | - **If you get a "permission denied" error**, check the permissions of the account that created the token on the relevant projects/spaces
222 |
223 | > **Summary**: MCP Atlassian Server works best when using an API token from an account with all the permissions needed for the actions you want the AI to perform on Jira/Confluence.
224 |
225 | ### Security Warning When Using LLMs
226 |
227 | - **Security risk**: If you or the AI in Cline ask an LLM to read/analyze the `cline_mcp_settings.json` file, **your Atlassian token will be sent to a third-party server** (OpenAI, Anthropic, etc.).
228 | - **How it works**:
229 | - Cline does **NOT** automatically send config files to the cloud
230 | - However, if you ask to "check the config file" or similar, the file content (including API token) will be sent to the LLM endpoint for processing
231 | - **Safety recommendations**:
232 | - Do not ask the LLM to read/check config files containing tokens
233 | - If you need support, remove sensitive information before sending to the LLM
234 | - Treat your API token like a password – never share it in LLM prompts
235 |
236 | > **Important**: If you do not ask the LLM to read the config file, your API token will only be used locally and will not be sent anywhere.
237 |
238 | ## Documentation Resources
239 |
240 | MCP Atlassian Server (by phuc-nt) now includes a comprehensive documentation series:
241 |
242 | 1. [MCP Overview & Architecture](./docs/knowledge/01-mcp-overview-architecture.md): Core concepts, architecture, and design principles
243 | 2. [MCP Tools & Resources Development](./docs/knowledge/02-mcp-tools-resources.md): How to develop and extend MCP resources and tools
244 | 3. [MCP Prompts & Sampling](./docs/knowledge/03-mcp-prompts-sampling.md): Guide for prompt engineering and sampling with MCP
245 |
246 | These documents provide deeper insights into the server's functionality and are valuable for both users and developers.
247 |
248 | ## Verify Installation
249 |
250 | ### Test the MCP Server directly
251 |
252 | You can test that the MCP Server runs correctly by executing it directly:
253 |
254 | ```bash
255 | # For npm global install
256 | node $(npm root -g)/@phuc-nt/mcp-atlassian-server/dist/index.js
257 |
258 | # For npm local install
259 | node ./node_modules/@phuc-nt/mcp-atlassian-server/dist/index.js
260 |
261 | # For source code install
262 | node ./dist/index.js
263 | ```
264 |
265 | You should see output indicating that the server has started and registered resources and tools.
266 |
267 | ### Test with Cline
268 |
269 | After configuration, test the connection by asking Cline a question related to Jira or Confluence, for example:
270 | - "List all projects in Jira"
271 | - "Search for Confluence pages about [topic]"
272 | - "Create a new issue in project DEMO"
273 |
274 | Cline is optimized to work with this MCP Atlassian Server (by phuc-nt) and will automatically use the most appropriate resources and tools for your queries.
```
--------------------------------------------------------------------------------
/src/utils/atlassian-api-base.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Version3Client } from "jira.js";
2 | import { Logger } from "./logger.js";
3 | import { ApiError, ApiErrorType } from "./error-handler.js";
4 | import fetch from "cross-fetch";
5 |
6 | export interface AtlassianConfig {
7 | baseUrl: string;
8 | apiToken: string;
9 | email: string;
10 | }
11 |
12 | // Initialize logger
13 | export const logger = Logger.getLogger("AtlassianAPI");
14 |
15 | // Cache for Atlassian clients to reuse
16 | export const clientCache = new Map<string, Version3Client>();
17 |
18 | /**
19 | * Create basic headers for API request
20 | * @param email User email
21 | * @param apiToken User API token
22 | * @returns Object containing basic headers
23 | */
24 | export const createBasicHeaders = (email: string, apiToken: string) => {
25 | // Remove whitespace and newlines from API token
26 | const cleanedToken = apiToken.replace(/\s+/g, "");
27 | // Always use Basic Authentication as per API docs
28 | const auth = Buffer.from(`${email}:${cleanedToken}`).toString("base64");
29 | // Log headers for debugging
30 | logger.debug(
31 | "Creating headers with User-Agent:",
32 | "MCP-Atlassian-Server/1.0.0"
33 | );
34 | return {
35 | Authorization: `Basic ${auth}`,
36 | "Content-Type": "application/json",
37 | Accept: "application/json",
38 | // Add User-Agent to help Cloudfront identify the request
39 | "User-Agent": "MCP-Atlassian-Server/1.0.0",
40 | };
41 | };
42 |
43 | // Helper: Create or get Jira client from cache
44 | export function getJiraClient(config: AtlassianConfig): Version3Client {
45 | const cacheKey = `jira:${config.baseUrl}:${config.email}`;
46 | if (clientCache.has(cacheKey)) {
47 | return clientCache.get(cacheKey) as Version3Client;
48 | }
49 | logger.debug(`Creating new Jira client for ${config.baseUrl}`);
50 | // Normalize baseUrl
51 | let baseUrl = config.baseUrl;
52 | if (baseUrl.startsWith("http://")) {
53 | baseUrl = baseUrl.replace("http://", "https://");
54 | } else if (!baseUrl.startsWith("https://")) {
55 | baseUrl = `https://${baseUrl}`;
56 | }
57 | if (!baseUrl.includes(".atlassian.net")) {
58 | baseUrl = `${baseUrl}.atlassian.net`;
59 | }
60 | if (baseUrl.match(/\.atlassian\.net\.atlassian\.net/)) {
61 | baseUrl = baseUrl.replace(".atlassian.net.atlassian.net", ".atlassian.net");
62 | }
63 | const client = new Version3Client({
64 | host: baseUrl,
65 | authentication: {
66 | basic: {
67 | email: config.email,
68 | apiToken: config.apiToken,
69 | },
70 | },
71 | baseRequestConfig: {
72 | headers: {
73 | "User-Agent": "MCP-Atlassian-Server/1.0.0",
74 | },
75 | },
76 | });
77 | clientCache.set(cacheKey, client);
78 | return client;
79 | }
80 |
81 | // Helper: Normalize baseUrl for Atlassian API
82 | export function normalizeAtlassianBaseUrl(baseUrl: string): string {
83 | let normalizedUrl = baseUrl;
84 | if (normalizedUrl.startsWith("http://")) {
85 | normalizedUrl = normalizedUrl.replace("http://", "https://");
86 | } else if (!normalizedUrl.startsWith("https://")) {
87 | normalizedUrl = `https://${normalizedUrl}`;
88 | }
89 | if (!normalizedUrl.includes(".atlassian.net")) {
90 | normalizedUrl = `${normalizedUrl}.atlassian.net`;
91 | }
92 | if (normalizedUrl.match(/\.atlassian\.net\.atlassian\.net/)) {
93 | normalizedUrl = normalizedUrl.replace(
94 | ".atlassian.net.atlassian.net",
95 | ".atlassian.net"
96 | );
97 | }
98 | return normalizedUrl;
99 | }
100 |
101 | // Helper: Call Jira API using jira.js (throw by default)
102 | export async function callJiraApi<T>(
103 | config: AtlassianConfig,
104 | endpoint: string,
105 | method: "GET" | "POST" | "PUT" | "DELETE" = "GET",
106 | data: any = null,
107 | params: Record<string, any> = {}
108 | ): Promise<T> {
109 | try {
110 | const client = getJiraClient(config);
111 | logger.debug(`Calling Jira API with jira.js: ${method} ${endpoint}`);
112 | throw new ApiError(
113 | ApiErrorType.UNKNOWN_ERROR,
114 | "This API call method is not implemented with jira.js. Please use specific methods.",
115 | 501
116 | );
117 | } catch (error: any) {
118 | logger.error(`Jira API error with jira.js:`, error);
119 | if (error instanceof ApiError) {
120 | throw error;
121 | }
122 | const statusCode = error.response?.status || 500;
123 | const errorMessage = error.message || "Unknown error";
124 | throw new ApiError(
125 | ApiErrorType.SERVER_ERROR,
126 | `Jira API error: ${errorMessage}`,
127 | statusCode,
128 | error
129 | );
130 | }
131 | }
132 |
133 | // Helper: Call Confluence API using fetch
134 | export async function callConfluenceApi<T>(
135 | config: AtlassianConfig,
136 | endpoint: string,
137 | method: "GET" | "POST" | "PUT" | "DELETE" = "GET",
138 | data: any = null,
139 | params: Record<string, any> = {}
140 | ): Promise<T> {
141 | try {
142 | const headers = createBasicHeaders(config.email, config.apiToken);
143 | let baseUrl = config.baseUrl;
144 | if (baseUrl.startsWith("http://")) {
145 | baseUrl = baseUrl.replace("http://", "https://");
146 | } else if (!baseUrl.startsWith("https://")) {
147 | baseUrl = `https://${baseUrl}`;
148 | }
149 | if (!baseUrl.includes(".atlassian.net")) {
150 | baseUrl = `${baseUrl}.atlassian.net`;
151 | }
152 | if (baseUrl.match(/\.atlassian\.net\.atlassian\.net/)) {
153 | baseUrl = baseUrl.replace(
154 | ".atlassian.net.atlassian.net",
155 | ".atlassian.net"
156 | );
157 | }
158 | let url = `${baseUrl}/wiki${endpoint}`;
159 | if (params && Object.keys(params).length > 0) {
160 | const queryParams = new URLSearchParams();
161 | Object.entries(params).forEach(([key, value]) => {
162 | queryParams.append(key, String(value));
163 | });
164 | url += `?${queryParams.toString()}`;
165 | }
166 | logger.debug(`Calling Confluence API: ${method} ${url}`);
167 | logger.debug(`With Auth: ${config.email}:*****`);
168 | logger.debug(`Token length: ${config.apiToken?.length || 0} characters`);
169 | logger.debug(
170 | "Full request headers:",
171 | JSON.stringify(
172 | headers,
173 | (key, value) => (key === "Authorization" ? "Basic ***" : value),
174 | 2
175 | )
176 | );
177 | const curlCmd = `curl -X ${method} -H "Content-Type: application/json" -H "Accept: application/json" -H "User-Agent: MCP-Atlassian-Server/1.0.0" -u "${
178 | config.email
179 | }:${config.apiToken.substring(0, 5)}..." "${url}"$${
180 | data && (method === "POST" || method === "PUT")
181 | ? ` -d '${JSON.stringify(data)}'`
182 | : ""
183 | }`;
184 | logger.info(`Debug with curl: ${curlCmd}`);
185 | const fetchOptions: RequestInit = {
186 | method,
187 | headers,
188 | credentials: "omit",
189 | };
190 | if (data && (method === "POST" || method === "PUT")) {
191 | fetchOptions.body = JSON.stringify(data);
192 | }
193 | logger.debug("Fetch options:", {
194 | ...fetchOptions,
195 | headers: { ...headers, Authorization: "Basic ***" },
196 | });
197 | const response = await fetch(url, fetchOptions);
198 | if (!response.ok) {
199 | const statusCode = response.status;
200 | const responseText = await response.text();
201 | logger.error(`Confluence API error (${statusCode}):`, responseText);
202 | throw new ApiError(
203 | ApiErrorType.SERVER_ERROR,
204 | `Confluence API error: ${responseText}`,
205 | statusCode,
206 | new Error(responseText)
207 | );
208 | }
209 | if (method === 'DELETE') {
210 | const text = await response.text();
211 | if (!text) return true as any;
212 | try {
213 | return JSON.parse(text) as T;
214 | } catch {
215 | return true as any;
216 | }
217 | }
218 | const responseData = await response.json();
219 | return responseData as T;
220 | } catch (error: any) {
221 | if (error instanceof ApiError) {
222 | throw error;
223 | }
224 | logger.error("Unhandled error in Confluence API call:", error);
225 | throw new ApiError(
226 | ApiErrorType.UNKNOWN_ERROR,
227 | `Unknown error: ${
228 | error instanceof Error ? error.message : String(error)
229 | }`,
230 | 500
231 | );
232 | }
233 | }
234 |
235 | // Helper: Convert Atlassian Document Format to simple Markdown
236 | export function adfToMarkdown(content: any): string {
237 | if (!content || !content.content) return "";
238 | let markdown = "";
239 | const processNode = (node: any): string => {
240 | if (!node) return "";
241 | switch (node.type) {
242 | case "paragraph":
243 | return node.content
244 | ? node.content.map(processNode).join("") + "\n\n"
245 | : "\n\n";
246 | case "text":
247 | let text = node.text || "";
248 | if (node.marks) {
249 | node.marks.forEach((mark: any) => {
250 | switch (mark.type) {
251 | case "strong":
252 | text = `**${text}**`;
253 | break;
254 | case "em":
255 | text = `*${text}*`;
256 | break;
257 | case "code":
258 | text = `\`${text}\``;
259 | break;
260 | case "link":
261 | text = `[${text}](${mark.attrs.href})`;
262 | break;
263 | }
264 | });
265 | }
266 | return text;
267 | case "heading":
268 | const level = node.attrs.level;
269 | const headingContent = node.content
270 | ? node.content.map(processNode).join("")
271 | : "";
272 | return "#".repeat(level) + " " + headingContent + "\n\n";
273 | case "bulletList":
274 | return node.content ? node.content.map(processNode).join("") : "";
275 | case "listItem":
276 | return (
277 | "- " +
278 | (node.content ? node.content.map(processNode).join("") : "") +
279 | "\n"
280 | );
281 | case "orderedList":
282 | return node.content
283 | ? node.content
284 | .map((item: any, index: number) => {
285 | return `${index + 1}. ${processNode(item)}`;
286 | })
287 | .join("")
288 | : "";
289 | case "codeBlock":
290 | const code = node.content ? node.content.map(processNode).join("") : "";
291 | const language =
292 | node.attrs && node.attrs.language ? node.attrs.language : "";
293 | return `
294 |
295 |
296 | ${language}\n${code}\n
297 |
298 | `;
299 | default:
300 | return node.content ? node.content.map(processNode).join("") : "";
301 | }
302 | };
303 | content.content.forEach((node: any) => {
304 | markdown += processNode(node);
305 | });
306 | return markdown;
307 | }
```
--------------------------------------------------------------------------------
/src/schemas/jira.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Schema definitions for Jira resources
3 | */
4 | import { standardMetadataSchema } from './common.js';
5 |
6 | /**
7 | * Schema for Jira issue
8 | */
9 | export const issueSchema = {
10 | type: "object",
11 | properties: {
12 | id: { type: "string", description: "Issue ID" },
13 | key: { type: "string", description: "Issue key (e.g., PROJ-123)" },
14 | summary: { type: "string", description: "Issue title/summary" },
15 | description: { anyOf: [
16 | { type: "string", description: "Issue description as plain text" },
17 | { type: "object", description: "Issue description in ADF format" }
18 | ], nullable: true },
19 | rawDescription: { anyOf: [
20 | { type: "string", description: "Issue description as plain text" },
21 | { type: "object", description: "Issue description in ADF format" }
22 | ], nullable: true },
23 | status: {
24 | type: "object",
25 | properties: {
26 | name: { type: "string", description: "Status name" },
27 | id: { type: "string", description: "Status ID" }
28 | }
29 | },
30 | assignee: {
31 | type: "object",
32 | properties: {
33 | displayName: { type: "string", description: "Assignee's display name" },
34 | accountId: { type: "string", description: "Assignee's account ID" }
35 | },
36 | nullable: true
37 | },
38 | reporter: {
39 | type: "object",
40 | properties: {
41 | displayName: { type: "string", description: "Reporter's display name" },
42 | accountId: { type: "string", description: "Reporter's account ID" }
43 | },
44 | nullable: true
45 | },
46 | priority: {
47 | type: "object",
48 | properties: {
49 | name: { type: "string", description: "Priority name" },
50 | id: { type: "string", description: "Priority ID" }
51 | },
52 | nullable: true
53 | },
54 | labels: {
55 | type: "array",
56 | items: { type: "string" },
57 | description: "List of labels attached to the issue"
58 | },
59 | created: { type: "string", format: "date-time", description: "Creation date" },
60 | updated: { type: "string", format: "date-time", description: "Last update date" },
61 | issueType: {
62 | type: "object",
63 | properties: {
64 | name: { type: "string", description: "Issue type name" },
65 | id: { type: "string", description: "Issue type ID" }
66 | }
67 | },
68 | projectKey: { type: "string", description: "Project key" },
69 | projectName: { type: "string", description: "Project name" }
70 | },
71 | required: ["id", "key", "summary", "status", "issueType", "projectKey"]
72 | };
73 |
74 | /**
75 | * Schema for Jira issues list
76 | */
77 | export const issuesListSchema = {
78 | type: "object",
79 | properties: {
80 | metadata: standardMetadataSchema,
81 | issues: {
82 | type: "array",
83 | items: issueSchema
84 | }
85 | },
86 | required: ["metadata", "issues"]
87 | };
88 |
89 | /**
90 | * Schema for Jira transitions
91 | */
92 | export const transitionSchema = {
93 | type: "object",
94 | properties: {
95 | id: { type: "string", description: "Transition ID" },
96 | name: { type: "string", description: "Transition name" },
97 | to: {
98 | type: "object",
99 | properties: {
100 | id: { type: "string", description: "Status ID after transition" },
101 | name: { type: "string", description: "Status name after transition" }
102 | }
103 | }
104 | },
105 | required: ["id", "name"]
106 | };
107 |
108 | /**
109 | * Schema for Jira transitions list
110 | */
111 | export const transitionsListSchema = {
112 | type: "object",
113 | properties: {
114 | issueKey: { type: "string", description: "Issue key" },
115 | transitions: {
116 | type: "array",
117 | items: transitionSchema
118 | }
119 | },
120 | required: ["issueKey", "transitions"]
121 | };
122 |
123 | /**
124 | * Schema for Jira project
125 | */
126 | export const projectSchema = {
127 | type: "object",
128 | properties: {
129 | id: { type: "string", description: "Project ID" },
130 | key: { type: "string", description: "Project key" },
131 | name: { type: "string", description: "Project name" },
132 | projectTypeKey: { type: "string", description: "Project type" },
133 | url: { type: "string", description: "Project URL" },
134 | lead: {
135 | type: "object",
136 | properties: {
137 | displayName: { type: "string", description: "Project lead's display name" },
138 | accountId: { type: "string", description: "Project lead's account ID" }
139 | },
140 | nullable: true
141 | }
142 | },
143 | required: ["id", "key", "name"]
144 | };
145 |
146 | /**
147 | * Schema for Jira projects list
148 | */
149 | export const projectsListSchema = {
150 | type: "object",
151 | properties: {
152 | metadata: standardMetadataSchema,
153 | projects: {
154 | type: "array",
155 | items: projectSchema
156 | }
157 | },
158 | required: ["metadata", "projects"]
159 | };
160 |
161 | /**
162 | * Schema for Jira user
163 | */
164 | export const userSchema = {
165 | type: "object",
166 | properties: {
167 | accountId: { type: "string", description: "User account ID" },
168 | displayName: { type: "string", description: "User display name" },
169 | emailAddress: { type: "string", description: "User email address", nullable: true },
170 | active: { type: "boolean", description: "Whether the user is active" },
171 | avatarUrl: { type: "string", description: "URL to user avatar" },
172 | timeZone: { type: "string", description: "User timezone", nullable: true },
173 | locale: { type: "string", description: "User locale", nullable: true }
174 | },
175 | required: ["accountId", "displayName", "active"]
176 | };
177 |
178 | /**
179 | * Schema for Jira users list
180 | */
181 | export const usersListSchema = {
182 | type: "object",
183 | properties: {
184 | metadata: standardMetadataSchema,
185 | users: {
186 | type: "array",
187 | items: userSchema
188 | }
189 | },
190 | required: ["metadata", "users"]
191 | };
192 |
193 | /**
194 | * Schema for Jira comment
195 | */
196 | export const commentSchema = {
197 | type: "object",
198 | properties: {
199 | id: { type: "string", description: "Comment ID" },
200 | body: { anyOf: [
201 | { type: "string", description: "Comment body as plain text" },
202 | { type: "object", description: "Comment body in ADF format" }
203 | ] },
204 | rawBody: { anyOf: [
205 | { type: "string", description: "Comment body as plain text" },
206 | { type: "object", description: "Comment body in ADF format" }
207 | ] },
208 | author: {
209 | type: "object",
210 | properties: {
211 | displayName: { type: "string", description: "Author's display name" },
212 | accountId: { type: "string", description: "Author's account ID" }
213 | }
214 | },
215 | created: { type: "string", format: "date-time", description: "Creation date" },
216 | updated: { type: "string", format: "date-time", description: "Last update date" }
217 | },
218 | required: ["id", "body", "author", "created"]
219 | };
220 |
221 | /**
222 | * Schema for Jira comments list
223 | */
224 | export const commentsListSchema = {
225 | type: "object",
226 | properties: {
227 | metadata: standardMetadataSchema,
228 | comments: {
229 | type: "array",
230 | items: commentSchema
231 | },
232 | issueKey: { type: "string", description: "Issue key" }
233 | },
234 | required: ["metadata", "comments", "issueKey"]
235 | };
236 |
237 | // Filter schemas
238 | export const filterSchema = {
239 | type: "object",
240 | properties: {
241 | id: { type: "string", description: "Filter ID" },
242 | name: { type: "string", description: "Filter name" },
243 | jql: { type: "string", description: "JQL query" },
244 | description: { type: "string", description: "Filter description" },
245 | owner: {
246 | type: "object",
247 | properties: {
248 | displayName: { type: "string" },
249 | accountId: { type: "string" }
250 | }
251 | },
252 | favourite: { type: "boolean", description: "Whether the filter is favorited" },
253 | sharePermissions: { type: "array", description: "Share permissions" }
254 | }
255 | };
256 |
257 | export const filterListSchema = {
258 | type: "object",
259 | properties: {
260 | filters: {
261 | type: "array",
262 | items: filterSchema
263 | },
264 | metadata: standardMetadataSchema
265 | }
266 | };
267 |
268 | // Board schemas
269 | export const boardSchema = {
270 | type: "object",
271 | properties: {
272 | id: { type: "number", description: "Board ID" },
273 | name: { type: "string", description: "Board name" },
274 | type: { type: "string", description: "Board type (scrum, kanban)" },
275 | location: {
276 | type: "object",
277 | properties: {
278 | projectId: { type: "string" },
279 | displayName: { type: "string" },
280 | projectKey: { type: "string" },
281 | projectName: { type: "string" }
282 | }
283 | }
284 | }
285 | };
286 |
287 | export const boardListSchema = {
288 | type: "object",
289 | properties: {
290 | boards: {
291 | type: "array",
292 | items: boardSchema
293 | },
294 | metadata: standardMetadataSchema
295 | }
296 | };
297 |
298 | // Sprint schemas
299 | export const sprintSchema = {
300 | type: "object",
301 | properties: {
302 | id: { type: "number", description: "Sprint ID" },
303 | name: { type: "string", description: "Sprint name" },
304 | state: { type: "string", description: "Sprint state (future, active, closed)" },
305 | startDate: { type: "string", description: "Start date" },
306 | endDate: { type: "string", description: "End date" },
307 | goal: { type: "string", description: "Sprint goal" },
308 | originBoardId: { type: "number", description: "Board ID" }
309 | }
310 | };
311 |
312 | export const sprintListSchema = {
313 | type: "object",
314 | properties: {
315 | sprints: {
316 | type: "array",
317 | items: sprintSchema
318 | },
319 | metadata: standardMetadataSchema
320 | }
321 | };
322 |
323 | // Dashboard schemas
324 | export const dashboardSchema = {
325 | type: "object",
326 | properties: {
327 | id: { type: "string", description: "Dashboard ID" },
328 | name: { type: "string", description: "Dashboard name" },
329 | description: { type: "string", description: "Dashboard description", nullable: true },
330 | owner: {
331 | type: "object",
332 | properties: {
333 | displayName: { type: "string" },
334 | accountId: { type: "string" }
335 | },
336 | nullable: true
337 | },
338 | sharePermissions: { type: "array", description: "Share permissions", items: { type: "object" }, nullable: true },
339 | gadgets: { type: "array", items: { type: "object" }, nullable: true },
340 | isFavourite: { type: "boolean", description: "Is favourite", nullable: true },
341 | view: { type: "string", description: "View type", nullable: true },
342 | url: { type: "string", description: "Dashboard URL", nullable: true }
343 | },
344 | required: ["id", "name"]
345 | };
346 |
347 | export const dashboardListSchema = {
348 | type: "object",
349 | properties: {
350 | dashboards: { type: "array", items: dashboardSchema },
351 | total: { type: "number", description: "Total dashboards" },
352 | maxResults: { type: "number", description: "Max results per page" },
353 | startAt: { type: "number", description: "Start offset" },
354 | metadata: standardMetadataSchema
355 | },
356 | required: ["dashboards", "total"]
357 | };
358 |
359 | // Gadget schemas
360 | export const gadgetSchema = {
361 | type: "object",
362 | properties: {
363 | id: { type: "string", description: "Gadget ID" },
364 | title: { type: "string", description: "Gadget title" },
365 | color: { type: "string", description: "Gadget color", nullable: true },
366 | position: { type: "object", description: "Gadget position", nullable: true },
367 | uri: { type: "string", description: "Gadget URI", nullable: true },
368 | properties: { type: "object", description: "Gadget properties", nullable: true }
369 | },
370 | required: ["id", "title"]
371 | };
372 |
373 | export const gadgetListSchema = {
374 | type: "object",
375 | properties: {
376 | gadgets: { type: "array", items: gadgetSchema },
377 | metadata: standardMetadataSchema
378 | },
379 | required: ["gadgets"]
380 | };
```
--------------------------------------------------------------------------------
/src/resources/jira/users.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
2 | import { Logger } from '../../utils/logger.js';
3 | import { AtlassianConfig } from '../../utils/atlassian-api-base.js';
4 | import fetch from 'cross-fetch';
5 | import { usersListSchema, userSchema } from '../../schemas/jira.js';
6 | import { Config, Resources } from '../../utils/mcp-helpers.js';
7 |
8 | const logger = Logger.getLogger('JiraResource:Users');
9 |
10 | /**
11 | * Helper function to get the list of users from Jira (supports pagination)
12 | */
13 | async function getUsers(config: AtlassianConfig, startAt = 0, maxResults = 20, accountId?: string, username?: string): Promise<any[]> {
14 | try {
15 | const auth = Buffer.from(`${config.email}:${config.apiToken}`).toString('base64');
16 | const headers = {
17 | 'Authorization': `Basic ${auth}`,
18 | 'Content-Type': 'application/json',
19 | 'Accept': 'application/json',
20 | 'User-Agent': 'MCP-Atlassian-Server/1.0.0'
21 | };
22 | let baseUrl = config.baseUrl;
23 | if (!baseUrl.startsWith('https://')) {
24 | baseUrl = `https://${baseUrl}`;
25 | }
26 | // Only filter by accountId or username
27 | let url = `${baseUrl}/rest/api/2/user/search?startAt=${startAt}&maxResults=${maxResults}`;
28 | if (accountId && accountId.trim()) {
29 | url += `&accountId=${encodeURIComponent(accountId.trim())}`;
30 | } else if (username && username.trim()) {
31 | url += `&username=${encodeURIComponent(username.trim())}`;
32 | }
33 | logger.debug(`Getting Jira users: ${url}`);
34 | const response = await fetch(url, { method: 'GET', headers, credentials: 'omit' });
35 | if (!response.ok) {
36 | const statusCode = response.status;
37 | const responseText = await response.text();
38 | logger.error(`Jira API error (${statusCode}):`, responseText);
39 | throw new Error(`Jira API error: ${responseText}`);
40 | }
41 | const users = await response.json();
42 | return users;
43 | } catch (error) {
44 | logger.error(`Error getting Jira users:`, error);
45 | throw error;
46 | }
47 | }
48 |
49 | /**
50 | * Helper function to get user details from Jira
51 | */
52 | async function getUser(config: AtlassianConfig, accountId: string): Promise<any> {
53 | try {
54 | const auth = Buffer.from(`${config.email}:${config.apiToken}`).toString('base64');
55 | const headers = {
56 | 'Authorization': `Basic ${auth}`,
57 | 'Content-Type': 'application/json',
58 | 'Accept': 'application/json',
59 | 'User-Agent': 'MCP-Atlassian-Server/1.0.0'
60 | };
61 | let baseUrl = config.baseUrl;
62 | if (!baseUrl.startsWith('https://')) {
63 | baseUrl = `https://${baseUrl}`;
64 | }
65 | // API get user: /rest/api/3/user?accountId=...
66 | const url = `${baseUrl}/rest/api/3/user?accountId=${encodeURIComponent(accountId)}`;
67 | logger.debug(`Getting Jira user: ${url}`);
68 | const response = await fetch(url, { method: 'GET', headers, credentials: 'omit' });
69 | if (!response.ok) {
70 | const statusCode = response.status;
71 | const responseText = await response.text();
72 | logger.error(`Jira API error (${statusCode}):`, responseText);
73 | throw new Error(`Jira API error: ${responseText}`);
74 | }
75 | const user = await response.json();
76 | return user;
77 | } catch (error) {
78 | logger.error(`Error getting Jira user:`, error);
79 | throw error;
80 | }
81 | }
82 |
83 | /**
84 | * Register Jira user-related resources
85 | * @param server MCP Server instance
86 | */
87 | export function registerUserResources(server: McpServer) {
88 | logger.info('Registering Jira user resources...');
89 |
90 | // Resource: Root users resource
91 | server.resource(
92 | 'jira-users-root',
93 | new ResourceTemplate('jira://users', {
94 | list: async (_extra) => ({
95 | resources: [
96 | {
97 | uri: 'jira://users',
98 | name: 'Jira Users',
99 | description: 'List and search all Jira users (use filters)',
100 | mimeType: 'application/json'
101 | }
102 | ]
103 | })
104 | }),
105 | async (uri, _params, _extra) => {
106 | const uriString = typeof uri === 'string' ? uri : uri.href;
107 | return {
108 | contents: [{
109 | uri: uriString,
110 | mimeType: 'application/json',
111 | text: JSON.stringify({
112 | message: "Please use a more specific user resource. The Jira API requires parameters to search users.",
113 | suggestedResources: [
114 | "jira://users/{accountId} - Get details for a specific user",
115 | "jira://users/assignable/{projectKey} - Get users who can be assigned in a project",
116 | "jira://users/role/{projectKey}/{roleId} - Get users with specific role in a project"
117 | ]
118 | })
119 | }]
120 | };
121 | }
122 | );
123 |
124 | // Resource: User details
125 | server.resource(
126 | 'jira-user-details',
127 | new ResourceTemplate('jira://users/{accountId}', {
128 | list: async (_extra) => ({
129 | resources: [
130 | {
131 | uri: 'jira://users/{accountId}',
132 | name: 'Jira User Details',
133 | description: 'Get details for a specific Jira user by accountId. Replace {accountId} with the user accountId.',
134 | mimeType: 'application/json'
135 | }
136 | ]
137 | })
138 | }),
139 | async (uri, params, _extra) => {
140 | let normalizedAccountId = '';
141 | try {
142 | const config = Config.getAtlassianConfigFromEnv();
143 | if (!params.accountId) {
144 | throw new Error('Missing accountId in URI');
145 | }
146 | normalizedAccountId = Array.isArray(params.accountId) ? params.accountId[0] : params.accountId;
147 | logger.info(`Getting details for Jira user: ${normalizedAccountId}`);
148 | const user = await getUser(config, normalizedAccountId);
149 | // Format returned data
150 | const formattedUser = {
151 | accountId: user.accountId,
152 | displayName: user.displayName,
153 | emailAddress: user.emailAddress,
154 | active: user.active,
155 | avatarUrl: user.avatarUrls?.['48x48'] || '',
156 | timeZone: user.timeZone,
157 | locale: user.locale
158 | };
159 |
160 | const uriString = typeof uri === 'string' ? uri : uri.href;
161 | // Chuẩn hóa metadata/schema cho resource chi tiết user
162 | return Resources.createStandardResource(
163 | uriString,
164 | [formattedUser],
165 | 'user',
166 | userSchema,
167 | 1,
168 | 1,
169 | 0,
170 | user.self || ''
171 | );
172 | } catch (error) {
173 | logger.error(`Error getting Jira user ${normalizedAccountId}:`, error);
174 | throw error;
175 | }
176 | }
177 | );
178 |
179 | // Resource: List of assignable users for a project
180 | server.resource(
181 | 'jira-users-assignable',
182 | new ResourceTemplate('jira://users/assignable/{projectKey}', {
183 | list: async (_extra) => ({
184 | resources: [
185 | {
186 | uri: 'jira://users/assignable/{projectKey}',
187 | name: 'Jira Assignable Users',
188 | description: 'List assignable users for a Jira project. Replace {projectKey} with the project key.',
189 | mimeType: 'application/json'
190 | }
191 | ]
192 | })
193 | }),
194 | async (uri, params, _extra) => {
195 | try {
196 | const config = Config.getAtlassianConfigFromEnv();
197 | const projectKey = Array.isArray(params.projectKey) ? params.projectKey[0] : params.projectKey;
198 | if (!projectKey) throw new Error('Missing projectKey');
199 | const auth = Buffer.from(`${config.email}:${config.apiToken}`).toString('base64');
200 | const headers = {
201 | 'Authorization': `Basic ${auth}`,
202 | 'Content-Type': 'application/json',
203 | 'Accept': 'application/json',
204 | 'User-Agent': 'MCP-Atlassian-Server/1.0.0'
205 | };
206 | let baseUrl = config.baseUrl;
207 | if (!baseUrl.startsWith('https://')) baseUrl = `https://${baseUrl}`;
208 | const url = `${baseUrl}/rest/api/3/user/assignable/search?project=${encodeURIComponent(projectKey)}`;
209 | logger.info(`Getting assignable users for project ${projectKey}: ${url}`);
210 | const response = await fetch(url, { method: 'GET', headers, credentials: 'omit' });
211 | if (!response.ok) {
212 | const statusCode = response.status;
213 | const responseText = await response.text();
214 | logger.error(`Jira API error (${statusCode}):`, responseText);
215 | throw new Error(`Jira API error: ${responseText}`);
216 | }
217 | const users = await response.json();
218 | const formattedUsers = (users || []).map((user: any) => ({
219 | accountId: user.accountId,
220 | displayName: user.displayName,
221 | emailAddress: user.emailAddress,
222 | active: user.active,
223 | avatarUrl: user.avatarUrls?.['48x48'] || '',
224 | }));
225 |
226 | const uriString = typeof uri === 'string' ? uri : uri.href;
227 | // Chuẩn hóa metadata/schema
228 | return Resources.createStandardResource(
229 | uriString,
230 | formattedUsers,
231 | 'users',
232 | usersListSchema,
233 | formattedUsers.length,
234 | formattedUsers.length,
235 | 0,
236 | `${config.baseUrl}/jira/people`
237 | );
238 | } catch (error) {
239 | logger.error(`Error getting assignable users for project:`, error);
240 | throw error;
241 | }
242 | }
243 | );
244 |
245 | // Resource: List of users by role in a project
246 | server.resource(
247 | 'jira-users-role',
248 | new ResourceTemplate('jira://users/role/{projectKey}/{roleId}', {
249 | list: async (_extra) => ({
250 | resources: [
251 | {
252 | uri: 'jira://users/role/{projectKey}/{roleId}',
253 | name: 'Jira Users by Role',
254 | description: 'List users by role in a Jira project. Replace {projectKey} and {roleId} with the project key and role ID.',
255 | mimeType: 'application/json'
256 | }
257 | ]
258 | })
259 | }),
260 | async (uri, params, _extra) => {
261 | try {
262 | const config = Config.getAtlassianConfigFromEnv();
263 | const projectKey = Array.isArray(params.projectKey) ? params.projectKey[0] : params.projectKey;
264 | const roleId = Array.isArray(params.roleId) ? params.roleId[0] : params.roleId;
265 | if (!projectKey || !roleId) throw new Error('Missing projectKey or roleId');
266 | const auth = Buffer.from(`${config.email}:${config.apiToken}`).toString('base64');
267 | const headers = {
268 | 'Authorization': `Basic ${auth}`,
269 | 'Content-Type': 'application/json',
270 | 'Accept': 'application/json',
271 | 'User-Agent': 'MCP-Atlassian-Server/1.0.0'
272 | };
273 | let baseUrl = config.baseUrl;
274 | if (!baseUrl.startsWith('https://')) baseUrl = `https://${baseUrl}`;
275 | const url = `${baseUrl}/rest/api/3/project/${encodeURIComponent(projectKey)}/role/${encodeURIComponent(roleId)}`;
276 | logger.info(`Getting users in role for project ${projectKey}, role ${roleId}: ${url}`);
277 | const response = await fetch(url, { method: 'GET', headers, credentials: 'omit' });
278 | if (!response.ok) {
279 | const statusCode = response.status;
280 | const responseText = await response.text();
281 | logger.error(`Jira API error (${statusCode}):`, responseText);
282 | throw new Error(`Jira API error: ${responseText}`);
283 | }
284 | const roleData = await response.json();
285 | const formattedUsers = (roleData.actors || [])
286 | .filter((actor: any) => actor.actorUser && actor.actorUser.accountId)
287 | .map((actor: any) => ({
288 | accountId: actor.actorUser.accountId,
289 | displayName: actor.displayName,
290 | type: 'atlassian-user-role-actor',
291 | roleId: roleId
292 | }));
293 |
294 | const uriString = typeof uri === 'string' ? uri : uri.href;
295 | return Resources.createStandardResource(
296 | uriString,
297 | formattedUsers,
298 | 'users',
299 | usersListSchema,
300 | formattedUsers.length,
301 | formattedUsers.length,
302 | 0,
303 | `${config.baseUrl}/jira/projects/${projectKey}/people`
304 | );
305 | } catch (error) {
306 | logger.error(`Error getting users by role:`, error);
307 | throw error;
308 | }
309 | }
310 | );
311 |
312 | logger.info('Jira user resources registered successfully');
313 | }
```
--------------------------------------------------------------------------------
/src/utils/jira-tool-api-agile.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { AtlassianConfig, logger, createBasicHeaders } from './atlassian-api-base.js';
2 | import { normalizeAtlassianBaseUrl } from './atlassian-api-base.js';
3 | import { ApiError, ApiErrorType } from './error-handler.js';
4 |
5 | // Add issues to backlog (support both /backlog/issue and /backlog/{boardId}/issue)
6 | export async function addIssuesToBacklog(config: AtlassianConfig, issueKeys: string[], boardId?: string): Promise<any> {
7 | try {
8 | const headers = createBasicHeaders(config.email, config.apiToken);
9 | const baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
10 | const url = boardId
11 | ? `${baseUrl}/rest/agile/1.0/backlog/${boardId}/issue`
12 | : `${baseUrl}/rest/agile/1.0/backlog/issue`;
13 | const data = { issues: issueKeys };
14 | logger.debug(`Adding issues to backlog${boardId ? ` for board ${boardId}` : ''}: ${issueKeys.join(', ')}`);
15 | const response = await fetch(url, {
16 | method: 'POST',
17 | headers,
18 | body: JSON.stringify(data),
19 | credentials: 'omit',
20 | });
21 | if (!response.ok) {
22 | const responseText = await response.text();
23 | logger.error(`Jira API error (${response.status}):`, responseText);
24 | throw new Error(`Jira API error: ${response.status} ${responseText}`);
25 | }
26 | // Xử lý response rỗng
27 | const contentLength = response.headers.get('content-length');
28 | if (contentLength === '0' || response.status === 204) {
29 | return { success: true };
30 | }
31 | const text = await response.text();
32 | if (!text) return { success: true };
33 | try {
34 | return JSON.parse(text);
35 | } catch (e) {
36 | return { success: true };
37 | }
38 | } catch (error) {
39 | logger.error(`Error adding issues to backlog:`, error);
40 | throw error;
41 | }
42 | }
43 |
44 | /**
45 | * Di chuyển issues vào sprint (POST /rest/agile/1.0/sprint/{sprintId}/issue)
46 | * Sprint đích phải là future hoặc active. API trả về response rỗng khi thành công.
47 | * @param config cấu hình Atlassian
48 | * @param sprintId ID của sprint đích
49 | * @param issueKeys mảng issue key cần di chuyển
50 | */
51 | export async function addIssueToSprint(config: AtlassianConfig, sprintId: string, issueKeys: string[]): Promise<any> {
52 | try {
53 | const headers = createBasicHeaders(config.email, config.apiToken);
54 | const baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
55 | const url = `${baseUrl}/rest/agile/1.0/sprint/${sprintId}/issue`;
56 | const data = { issues: issueKeys };
57 | logger.debug(`Adding issues to sprint ${sprintId}: ${issueKeys.join(', ')}`);
58 | const response = await fetch(url, {
59 | method: 'POST',
60 | headers,
61 | body: JSON.stringify(data),
62 | credentials: 'omit',
63 | });
64 | if (!response.ok) {
65 | const responseText = await response.text();
66 | logger.error(`Jira API error (${response.status}):`, responseText);
67 | throw new Error(`Jira API error: ${response.status} ${responseText}`);
68 | }
69 | // Xử lý response rỗng
70 | const contentLength = response.headers.get('content-length');
71 | if (contentLength === '0' || response.status === 204) {
72 | return { success: true };
73 | }
74 | const text = await response.text();
75 | if (!text) return { success: true };
76 | try {
77 | return JSON.parse(text);
78 | } catch (e) {
79 | return { success: true };
80 | }
81 | } catch (error) {
82 | logger.error(`Error adding issues to sprint:`, error);
83 | throw error;
84 | }
85 | }
86 |
87 | // Sắp xếp thứ tự backlog
88 | export async function rankBacklogIssues(config: AtlassianConfig, boardId: string, issueKeys: string[], options: { rankBeforeIssue?: string, rankAfterIssue?: string } = {}): Promise<any> {
89 | try {
90 | const headers = createBasicHeaders(config.email, config.apiToken);
91 | const baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
92 | const url = `${baseUrl}/rest/agile/1.0/issue/rank`;
93 | const data: any = { issues: issueKeys };
94 | if (options.rankBeforeIssue) data.rankBeforeIssue = options.rankBeforeIssue;
95 | if (options.rankAfterIssue) data.rankAfterIssue = options.rankAfterIssue;
96 | logger.debug(`Ranking issues in backlog: ${issueKeys.join(', ')}`);
97 | const response = await fetch(url, {
98 | method: 'PUT',
99 | headers,
100 | body: JSON.stringify(data),
101 | credentials: 'omit',
102 | });
103 | if (!response.ok) {
104 | const responseText = await response.text();
105 | logger.error(`Jira API error (${response.status}):`, responseText);
106 | throw new Error(`Jira API error: ${response.status} ${responseText}`);
107 | }
108 | // Xử lý response rỗng
109 | const contentLength = response.headers.get('content-length');
110 | if (contentLength === '0' || response.status === 204) {
111 | return { success: true };
112 | }
113 | const text = await response.text();
114 | if (!text) return { success: true };
115 | try {
116 | return JSON.parse(text);
117 | } catch (e) {
118 | return { success: true };
119 | }
120 | } catch (error) {
121 | logger.error(`Error ranking backlog issues:`, error);
122 | throw error;
123 | }
124 | }
125 |
126 | // Bắt đầu sprint
127 | export async function startSprint(config: AtlassianConfig, sprintId: string, startDate: string, endDate: string, goal?: string): Promise<any> {
128 | try {
129 | const headers = createBasicHeaders(config.email, config.apiToken);
130 | const baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
131 | const url = `${baseUrl}/rest/agile/1.0/sprint/${sprintId}`;
132 | const data: any = {
133 | state: 'active',
134 | startDate,
135 | endDate
136 | };
137 | if (goal) data.goal = goal;
138 | logger.debug(`Starting sprint ${sprintId}`);
139 | const response = await fetch(url, {
140 | method: 'POST',
141 | headers,
142 | body: JSON.stringify(data),
143 | credentials: 'omit',
144 | });
145 | if (!response.ok) {
146 | const responseText = await response.text();
147 | logger.error(`Jira API error (${response.status}):`, responseText);
148 | throw new Error(`Jira API error: ${response.status} ${responseText}`);
149 | }
150 | return await response.json();
151 | } catch (error) {
152 | logger.error(`Error starting sprint ${sprintId}:`, error);
153 | throw error;
154 | }
155 | }
156 |
157 | // Đóng sprint
158 | export async function closeSprint(config: AtlassianConfig, sprintId: string, options: { completeDate?: string } = {}): Promise<any> {
159 | try {
160 | const headers = createBasicHeaders(config.email, config.apiToken);
161 | const baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
162 | const url = `${baseUrl}/rest/agile/1.0/sprint/${sprintId}`;
163 | // Chỉ build payload với các trường hợp lệ
164 | const data: any = { state: 'closed' };
165 | if (options.completeDate) data.completeDate = options.completeDate;
166 | // (Không gửi moveToSprintId, createNewSprint vì API không hỗ trợ)
167 | logger.debug(`Closing sprint ${sprintId} with payload:`, JSON.stringify(data));
168 | const response = await fetch(url, {
169 | method: 'POST',
170 | headers,
171 | body: JSON.stringify(data),
172 | credentials: 'omit',
173 | });
174 | if (!response.ok) {
175 | const responseText = await response.text();
176 | logger.error(`Jira API error (${response.status}):`, responseText);
177 | throw new Error(`Jira API error: ${response.status} ${responseText}`);
178 | }
179 | return await response.json();
180 | } catch (error) {
181 | logger.error(`Error closing sprint ${sprintId}:`, error);
182 | throw error;
183 | }
184 | }
185 |
186 | // Di chuyển issues giữa các sprint
187 | export async function moveIssuesBetweenSprints(config: AtlassianConfig, fromSprintId: string, toSprintId: string, issueKeys: string[]): Promise<any> {
188 | try {
189 | const headers = createBasicHeaders(config.email, config.apiToken);
190 | const baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
191 | logger.debug(`Moving issues from sprint ${fromSprintId} to sprint ${toSprintId}: ${issueKeys.join(', ')}`);
192 | // Remove from old sprint
193 | const removeUrl = `${baseUrl}/rest/agile/1.0/sprint/${fromSprintId}/issue`;
194 | const removeResponse = await fetch(removeUrl, {
195 | method: 'POST',
196 | headers,
197 | body: JSON.stringify({ issues: issueKeys, remove: true }),
198 | credentials: 'omit',
199 | });
200 | if (!removeResponse.ok) {
201 | const responseText = await removeResponse.text();
202 | logger.error(`Jira API error (${removeResponse.status}):`, responseText);
203 | throw new Error(`Jira API error: ${removeResponse.status} ${responseText}`);
204 | }
205 | // Add to new sprint
206 | const addUrl = `${baseUrl}/rest/agile/1.0/sprint/${toSprintId}/issue`;
207 | const addResponse = await fetch(addUrl, {
208 | method: 'POST',
209 | headers,
210 | body: JSON.stringify({ issues: issueKeys }),
211 | credentials: 'omit',
212 | });
213 | if (!addResponse.ok) {
214 | const responseText = await addResponse.text();
215 | logger.error(`Jira API error (${addResponse.status}):`, responseText);
216 | throw new Error(`Jira API error: ${addResponse.status} ${responseText}`);
217 | }
218 | return await addResponse.json();
219 | } catch (error) {
220 | logger.error(`Error moving issues between sprints:`, error);
221 | throw error;
222 | }
223 | }
224 |
225 | // Thêm issue vào board
226 | export async function addIssueToBoard(config: AtlassianConfig, boardId: string, issueKey: string | string[]): Promise<any> {
227 | try {
228 | const headers = createBasicHeaders(config.email, config.apiToken);
229 | const baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
230 | const url = `${baseUrl}/rest/agile/1.0/backlog/issue`;
231 | const issues = Array.isArray(issueKey) ? issueKey : [issueKey];
232 | const data = { issues };
233 | logger.debug(`Adding issue(s) to board ${boardId}: ${Array.isArray(issueKey) ? issueKey.join(', ') : issueKey}`);
234 | const response = await fetch(url, {
235 | method: 'POST',
236 | headers,
237 | body: JSON.stringify(data),
238 | credentials: 'omit',
239 | });
240 | if (!response.ok) {
241 | const responseText = await response.text();
242 | logger.error(`Jira API error (${response.status}):`, responseText);
243 | throw new Error(`Jira API error: ${response.status} ${responseText}`);
244 | }
245 | // Xử lý response rỗng
246 | const contentLength = response.headers.get('content-length');
247 | if (contentLength === '0' || response.status === 204) {
248 | return { success: true };
249 | }
250 | const text = await response.text();
251 | if (!text) return { success: true };
252 | try {
253 | return JSON.parse(text);
254 | } catch (e) {
255 | return { success: true };
256 | }
257 | } catch (error) {
258 | logger.error(`Error adding issue to board:`, error);
259 | throw error;
260 | }
261 | }
262 |
263 | // Cấu hình cột board
264 | export async function configureBoardColumns(config: AtlassianConfig, boardId: string, columns: any[]): Promise<any> {
265 | try {
266 | const headers = createBasicHeaders(config.email, config.apiToken);
267 | const baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
268 | const url = `${baseUrl}/rest/agile/1.0/board/${boardId}/configuration`;
269 | logger.debug(`Configuring columns for board ${boardId}`);
270 | // Get current config to merge
271 | const currentRes = await fetch(url, { method: 'GET', headers, credentials: 'omit' });
272 | if (!currentRes.ok) {
273 | const responseText = await currentRes.text();
274 | logger.error(`Jira API error (${currentRes.status}):`, responseText);
275 | throw new Error(`Jira API error: ${currentRes.status} ${responseText}`);
276 | }
277 | const currentConfig = await currentRes.json();
278 | const data = { ...currentConfig, columnConfig: { columns } };
279 | const response = await fetch(url, {
280 | method: 'PUT',
281 | headers,
282 | body: JSON.stringify(data),
283 | credentials: 'omit',
284 | });
285 | if (!response.ok) {
286 | const responseText = await response.text();
287 | logger.error(`Jira API error (${response.status}):`, responseText);
288 | throw new Error(`Jira API error: ${response.status} ${responseText}`);
289 | }
290 | return await response.json();
291 | } catch (error) {
292 | logger.error(`Error configuring board columns:`, error);
293 | throw error;
294 | }
295 | }
296 |
297 | // Tạo sprint mới
298 | export async function createSprint(
299 | config: AtlassianConfig,
300 | boardId: string,
301 | name: string,
302 | startDate?: string,
303 | endDate?: string,
304 | goal?: string
305 | ): Promise<any> {
306 | try {
307 | const headers = createBasicHeaders(config.email, config.apiToken);
308 | const baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
309 | const url = `${baseUrl}/rest/agile/1.0/sprint`;
310 | const data: any = {
311 | name,
312 | originBoardId: boardId
313 | };
314 | if (startDate) data.startDate = startDate;
315 | if (endDate) data.endDate = endDate;
316 | if (goal) data.goal = goal;
317 | logger.debug(`Creating new sprint "${name}" for board ${boardId}`);
318 | const response = await fetch(url, {
319 | method: 'POST',
320 | headers,
321 | body: JSON.stringify(data),
322 | credentials: 'omit',
323 | });
324 | if (!response.ok) {
325 | const responseText = await response.text();
326 | logger.error(`Jira API error (${response.status}):`, responseText);
327 | throw new Error(`Jira API error: ${response.status} ${responseText}`);
328 | }
329 | return await response.json();
330 | } catch (error) {
331 | logger.error(`Error creating sprint:`, error);
332 | throw error;
333 | }
334 | }
335 |
336 | // ... existing code ...
337 | // (To be filled with the full code of the above functions, keeping their implementation unchanged)
```
--------------------------------------------------------------------------------
/src/utils/jira-resource-api.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { AtlassianConfig, logger, createBasicHeaders } from './atlassian-api-base.js';
2 | import { normalizeAtlassianBaseUrl } from './atlassian-api-base.js';
3 |
4 | // Get list of Jira dashboards (all)
5 | export async function getDashboards(config: AtlassianConfig, startAt = 0, maxResults = 50): Promise<any> {
6 | const headers = createBasicHeaders(config.email, config.apiToken);
7 | let baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
8 | const url = `${baseUrl}/rest/api/3/dashboard?startAt=${startAt}&maxResults=${maxResults}`;
9 | const response = await fetch(url, { method: 'GET', headers, credentials: 'omit' });
10 | if (!response.ok) throw new Error(`Jira API error: ${response.status} ${await response.text()}`);
11 | return await response.json();
12 | }
13 |
14 | // Get list of Jira dashboards owned by current user (my dashboards)
15 | export async function getMyDashboards(config: AtlassianConfig, startAt = 0, maxResults = 50): Promise<any> {
16 | const headers = createBasicHeaders(config.email, config.apiToken);
17 | let baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
18 | // Atlassian API: filter=my
19 | const url = `${baseUrl}/rest/api/3/dashboard/search?filter=my&startAt=${startAt}&maxResults=${maxResults}`;
20 | const response = await fetch(url, { method: 'GET', headers, credentials: 'omit' });
21 | if (!response.ok) throw new Error(`Jira API error: ${response.status} ${await response.text()}`);
22 | return await response.json();
23 | }
24 |
25 | // Get Jira dashboard by ID
26 | export async function getDashboardById(config: AtlassianConfig, dashboardId: string): Promise<any> {
27 | const headers = createBasicHeaders(config.email, config.apiToken);
28 | let baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
29 | const url = `${baseUrl}/rest/api/3/dashboard/${dashboardId}`;
30 | const response = await fetch(url, { method: 'GET', headers, credentials: 'omit' });
31 | if (!response.ok) throw new Error(`Jira API error: ${response.status} ${await response.text()}`);
32 | return await response.json();
33 | }
34 |
35 | // Get gadgets (widgets) of a Jira dashboard
36 | export async function getDashboardGadgets(config: AtlassianConfig, dashboardId: string): Promise<any> {
37 | const headers = createBasicHeaders(config.email, config.apiToken);
38 | let baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
39 | const url = `${baseUrl}/rest/api/3/dashboard/${dashboardId}/gadget`;
40 | const response = await fetch(url, { method: 'GET', headers, credentials: 'omit' });
41 | if (!response.ok) throw new Error(`Jira API error: ${response.status} ${await response.text()}`);
42 | const data = await response.json();
43 | return data.gadgets || [];
44 | }
45 |
46 | // Get Jira issue by key or id
47 | export async function getIssue(config: AtlassianConfig, issueIdOrKey: string): Promise<any> {
48 | try {
49 | const headers = createBasicHeaders(config.email, config.apiToken);
50 | let baseUrl = config.baseUrl;
51 | if (baseUrl.startsWith("http://")) {
52 | baseUrl = baseUrl.replace("http://", "https://");
53 | } else if (!baseUrl.startsWith("https://")) {
54 | baseUrl = `https://${baseUrl}`;
55 | }
56 | if (!baseUrl.includes(".atlassian.net")) {
57 | baseUrl = `${baseUrl}.atlassian.net`;
58 | }
59 | if (baseUrl.match(/\.atlassian\.net\.atlassian\.net/)) {
60 | baseUrl = baseUrl.replace(
61 | ".atlassian.net.atlassian.net",
62 | ".atlassian.net"
63 | );
64 | }
65 | const url = `${baseUrl}/rest/api/3/issue/${issueIdOrKey}?expand=renderedFields,names,schema,operations`;
66 | logger.debug(`Getting issue with direct fetch: ${url}`);
67 | logger.debug(`With Auth: ${config.email}:*****`);
68 | const curlCmd = `curl -X GET -H "Content-Type: application/json" -H "Accept: application/json" -H "User-Agent: MCP-Atlassian-Server/1.0.0" -u "${
69 | config.email
70 | }:${config.apiToken.substring(0, 5)}..." "${url}"`;
71 | logger.info(`Debug with curl: ${curlCmd}`);
72 | const response = await fetch(url, {
73 | method: "GET",
74 | headers,
75 | credentials: "omit",
76 | });
77 | if (!response.ok) {
78 | const statusCode = response.status;
79 | const responseText = await response.text();
80 | logger.error(`Jira API error (${statusCode}):`, responseText);
81 | throw new Error(`Jira API error (${statusCode}): ${responseText}`);
82 | }
83 | const issue = await response.json();
84 | return issue;
85 | } catch (error: any) {
86 | logger.error(`Error getting issue ${issueIdOrKey}:`, error);
87 | throw error;
88 | }
89 | }
90 |
91 | // Search issues by JQL
92 | export async function searchIssues(config: AtlassianConfig, jql: string, maxResults: number = 50): Promise<any> {
93 | try {
94 | const headers = createBasicHeaders(config.email, config.apiToken);
95 | let baseUrl = config.baseUrl;
96 | if (baseUrl.startsWith("http://")) {
97 | baseUrl = baseUrl.replace("http://", "https://");
98 | } else if (!baseUrl.startsWith("https://")) {
99 | baseUrl = `https://${baseUrl}`;
100 | }
101 | if (!baseUrl.includes(".atlassian.net")) {
102 | baseUrl = `${baseUrl}.atlassian.net`;
103 | }
104 | if (baseUrl.match(/\.atlassian\.net\.atlassian\.net/)) {
105 | baseUrl = baseUrl.replace(
106 | ".atlassian.net.atlassian.net",
107 | ".atlassian.net"
108 | );
109 | }
110 | const url = `${baseUrl}/rest/api/3/search`;
111 | logger.debug(`Searching issues with JQL: ${jql}`);
112 | logger.debug(`With Auth: ${config.email}:*****`);
113 | const data = {
114 | jql,
115 | maxResults,
116 | expand: ["names", "schema", "operations"],
117 | };
118 | const curlCmd = `curl -X POST -H "Content-Type: application/json" -H "Accept: application/json" -H "User-Agent: MCP-Atlassian-Server/1.0.0" -u "${
119 | config.email
120 | }:${config.apiToken.substring(0, 5)}..." "${url}" -d '${JSON.stringify(
121 | data
122 | )}'`;
123 | logger.info(`Debug with curl: ${curlCmd}`);
124 | const response = await fetch(url, {
125 | method: "POST",
126 | headers,
127 | body: JSON.stringify(data),
128 | credentials: "omit",
129 | });
130 | if (!response.ok) {
131 | const statusCode = response.status;
132 | const responseText = await response.text();
133 | logger.error(`Jira API error (${statusCode}):`, responseText);
134 | throw new Error(`Jira API error (${statusCode}): ${responseText}`);
135 | }
136 | const searchResults = await response.json();
137 | return searchResults;
138 | } catch (error: any) {
139 | logger.error(`Error searching issues:`, error);
140 | throw error;
141 | }
142 | }
143 |
144 | // Get list of Jira filters
145 | export async function getFilters(config: AtlassianConfig, startAt = 0, maxResults = 50): Promise<any> {
146 | const headers = createBasicHeaders(config.email, config.apiToken);
147 | let baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
148 | const url = `${baseUrl}/rest/api/3/filter/search?startAt=${startAt}&maxResults=${maxResults}`;
149 | logger.debug(`GET Jira filters: ${url}`);
150 | const response = await fetch(url, { method: 'GET', headers, credentials: 'omit' });
151 | if (!response.ok) throw new Error(`Jira API error: ${response.status} ${await response.text()}`);
152 | return await response.json();
153 | }
154 |
155 | // Get Jira filter by ID
156 | export async function getFilterById(config: AtlassianConfig, filterId: string): Promise<any> {
157 | const headers = createBasicHeaders(config.email, config.apiToken);
158 | let baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
159 | const url = `${baseUrl}/rest/api/3/filter/${filterId}`;
160 | logger.debug(`GET Jira filter by ID: ${url}`);
161 | const response = await fetch(url, { method: 'GET', headers, credentials: 'omit' });
162 | if (!response.ok) throw new Error(`Jira API error: ${response.status} ${await response.text()}`);
163 | return await response.json();
164 | }
165 |
166 | // Get filters owned by or shared with the current user
167 | export async function getMyFilters(config: AtlassianConfig): Promise<any[]> {
168 | const headers = createBasicHeaders(config.email, config.apiToken);
169 | let baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
170 | const url = `${baseUrl}/rest/api/3/filter/my`;
171 | logger.debug(`GET Jira my filters: ${url}`);
172 | const response = await fetch(url, { method: 'GET', headers, credentials: 'omit' });
173 | if (!response.ok) throw new Error(`Jira API error: ${response.status} ${await response.text()}`);
174 | return await response.json();
175 | }
176 |
177 | // Get list of Jira boards (Agile)
178 | export async function getBoards(config: AtlassianConfig, startAt = 0, maxResults = 50): Promise<any> {
179 | const headers = createBasicHeaders(config.email, config.apiToken);
180 | let baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
181 | const url = `${baseUrl}/rest/agile/1.0/board?startAt=${startAt}&maxResults=${maxResults}`;
182 | logger.debug(`GET Jira boards: ${url}`);
183 | const response = await fetch(url, { method: 'GET', headers, credentials: 'omit' });
184 | if (!response.ok) throw new Error(`Jira API error: ${response.status} ${await response.text()}`);
185 | return await response.json();
186 | }
187 |
188 | // Get Jira board by ID (Agile)
189 | export async function getBoardById(config: AtlassianConfig, boardId: string): Promise<any> {
190 | const headers = createBasicHeaders(config.email, config.apiToken);
191 | let baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
192 | const url = `${baseUrl}/rest/agile/1.0/board/${boardId}`;
193 | logger.debug(`GET Jira board by ID: ${url}`);
194 | const response = await fetch(url, { method: 'GET', headers, credentials: 'omit' });
195 | if (!response.ok) throw new Error(`Jira API error: ${response.status} ${await response.text()}`);
196 | return await response.json();
197 | }
198 |
199 | // Get issues in a Jira board (Agile)
200 | export async function getBoardIssues(config: AtlassianConfig, boardId: string, startAt = 0, maxResults = 50): Promise<any> {
201 | const headers = createBasicHeaders(config.email, config.apiToken);
202 | let baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
203 | const url = `${baseUrl}/rest/agile/1.0/board/${boardId}/issue?startAt=${startAt}&maxResults=${maxResults}`;
204 | logger.debug(`GET Jira board issues: ${url}`);
205 | const response = await fetch(url, { method: 'GET', headers, credentials: 'omit' });
206 | if (!response.ok) throw new Error(`Jira API error: ${response.status} ${await response.text()}`);
207 | return await response.json();
208 | }
209 |
210 | // Get list of sprints in a Jira board (Agile)
211 | export async function getSprintsByBoard(config: AtlassianConfig, boardId: string, startAt = 0, maxResults = 50): Promise<any> {
212 | const headers = createBasicHeaders(config.email, config.apiToken);
213 | let baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
214 | const url = `${baseUrl}/rest/agile/1.0/board/${boardId}/sprint?startAt=${startAt}&maxResults=${maxResults}`;
215 | logger.debug(`GET Jira sprints by board: ${url}`);
216 | const response = await fetch(url, { method: 'GET', headers, credentials: 'omit' });
217 | if (!response.ok) throw new Error(`Jira API error: ${response.status} ${await response.text()}`);
218 | return await response.json();
219 | }
220 |
221 | // Get Jira sprint by ID (Agile)
222 | export async function getSprintById(config: AtlassianConfig, sprintId: string): Promise<any> {
223 | const headers = createBasicHeaders(config.email, config.apiToken);
224 | let baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
225 | const url = `${baseUrl}/rest/agile/1.0/sprint/${sprintId}`;
226 | logger.debug(`GET Jira sprint by ID: ${url}`);
227 | const response = await fetch(url, { method: 'GET', headers, credentials: 'omit' });
228 | if (!response.ok) throw new Error(`Jira API error: ${response.status} ${await response.text()}`);
229 | return await response.json();
230 | }
231 |
232 | // Get issues in a Jira sprint (Agile)
233 | export async function getSprintIssues(config: AtlassianConfig, sprintId: string, startAt = 0, maxResults = 50): Promise<any> {
234 | const headers = createBasicHeaders(config.email, config.apiToken);
235 | let baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
236 | const url = `${baseUrl}/rest/agile/1.0/sprint/${sprintId}/issue?startAt=${startAt}&maxResults=${maxResults}`;
237 | logger.debug(`GET Jira sprint issues: ${url}`);
238 | const response = await fetch(url, { method: 'GET', headers, credentials: 'omit' });
239 | if (!response.ok) throw new Error(`Jira API error: ${response.status} ${await response.text()}`);
240 | return await response.json();
241 | }
242 |
243 | // Get list of Jira projects (API v3)
244 | export async function getProjects(config: AtlassianConfig): Promise<any[]> {
245 | const headers = createBasicHeaders(config.email, config.apiToken);
246 | const baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
247 | const url = `${baseUrl}/rest/api/3/project`;
248 | const response = await fetch(url, { method: 'GET', headers, credentials: 'omit' });
249 | if (!response.ok) throw new Error(`Jira API error: ${response.status} ${await response.text()}`);
250 | return await response.json();
251 | }
252 |
253 | // Get Jira project details (API v3)
254 | export async function getProject(config: AtlassianConfig, projectKey: string): Promise<any> {
255 | const headers = createBasicHeaders(config.email, config.apiToken);
256 | const baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
257 | const url = `${baseUrl}/rest/api/3/project/${projectKey}`;
258 | const response = await fetch(url, { method: 'GET', headers, credentials: 'omit' });
259 | if (!response.ok) throw new Error(`Jira API error: ${response.status} ${await response.text()}`);
260 | return await response.json();
261 | }
```
--------------------------------------------------------------------------------
/src/resources/jira/issues.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
2 | import { Logger } from '../../utils/logger.js';
3 | import { getIssue as getIssueApi, searchIssues as searchIssuesApi } from '../../utils/jira-resource-api.js';
4 | import { issueSchema, issuesListSchema, transitionsListSchema, commentsListSchema } from '../../schemas/jira.js';
5 | import { Config, Resources } from '../../utils/mcp-helpers.js';
6 |
7 | const logger = Logger.getLogger('JiraResource:Issues');
8 |
9 | /**
10 | * Helper function to get issue details from Jira
11 | */
12 | async function getIssue(config: any, issueKey: string): Promise<any> {
13 | return await getIssueApi(config, issueKey);
14 | }
15 |
16 | /**
17 | * Helper function to get a list of issues from Jira (supports pagination)
18 | */
19 | async function getIssues(config: any, startAt = 0, maxResults = 20, jql = ''): Promise<any> {
20 | const jqlQuery = jql && jql.trim() ? jql.trim() : '';
21 | return await searchIssuesApi(config, jqlQuery, maxResults);
22 | }
23 |
24 | /**
25 | * Helper function to search issues by JQL from Jira (supports pagination)
26 | */
27 | async function searchIssuesByJql(config: any, jql: string, startAt = 0, maxResults = 20): Promise<any> {
28 | return await searchIssuesApi(config, jql, maxResults);
29 | }
30 |
31 | /**
32 | * Helper function to get a list of transitions for an issue from Jira
33 | */
34 | async function getIssueTransitions(config: any, issueKey: string): Promise<any> {
35 | try {
36 | const auth = Buffer.from(`${config.email}:${config.apiToken}`).toString('base64');
37 | const headers = {
38 | 'Authorization': `Basic ${auth}`,
39 | 'Content-Type': 'application/json',
40 | 'Accept': 'application/json',
41 | 'User-Agent': 'MCP-Atlassian-Server/1.0.0'
42 | };
43 | let baseUrl = config.baseUrl;
44 | if (!baseUrl.startsWith('https://')) {
45 | baseUrl = `https://${baseUrl}`;
46 | }
47 | const url = `${baseUrl}/rest/api/3/issue/${issueKey}/transitions`;
48 | logger.debug(`Getting Jira issue transitions: ${url}`);
49 | const response = await fetch(url, { method: 'GET', headers, credentials: 'omit' });
50 | if (!response.ok) {
51 | const statusCode = response.status;
52 | const responseText = await response.text();
53 | logger.error(`Jira API error (${statusCode}):`, responseText);
54 | throw new Error(`Jira API error: ${responseText}`);
55 | }
56 | const data = await response.json();
57 | return data.transitions || [];
58 | } catch (error) {
59 | logger.error(`Error getting Jira issue transitions:`, error);
60 | throw error;
61 | }
62 | }
63 |
64 | /**
65 | * Helper function to get a list of comments for an issue from Jira
66 | */
67 | async function getIssueComments(config: any, issueKey: string, startAt = 0, maxResults = 20): Promise<any> {
68 | try {
69 | const auth = Buffer.from(`${config.email}:${config.apiToken}`).toString('base64');
70 | const headers = {
71 | 'Authorization': `Basic ${auth}`,
72 | 'Content-Type': 'application/json',
73 | 'Accept': 'application/json',
74 | 'User-Agent': 'MCP-Atlassian-Server/1.0.0'
75 | };
76 | let baseUrl = config.baseUrl;
77 | if (!baseUrl.startsWith('https://')) {
78 | baseUrl = `https://${baseUrl}`;
79 | }
80 | const url = `${baseUrl}/rest/api/3/issue/${issueKey}/comment?startAt=${startAt}&maxResults=${maxResults}`;
81 | logger.debug(`Getting Jira issue comments: ${url}`);
82 | const response = await fetch(url, { method: 'GET', headers, credentials: 'omit' });
83 | if (!response.ok) {
84 | const statusCode = response.status;
85 | const responseText = await response.text();
86 | logger.error(`Jira API error (${statusCode}):`, responseText);
87 | throw new Error(`Jira API error: ${responseText}`);
88 | }
89 | const data = await response.json();
90 | return data;
91 | } catch (error) {
92 | logger.error(`Error getting Jira issue comments:`, error);
93 | throw error;
94 | }
95 | }
96 |
97 | /**
98 | * Hàm chuyển ADF sang text thuần
99 | */
100 | function extractTextFromADF(adf: any): string {
101 | if (!adf || typeof adf === 'string') return adf || '';
102 | let text = '';
103 | if (adf.content) {
104 | adf.content.forEach((node: any) => {
105 | if (node.type === 'paragraph' && node.content) {
106 | node.content.forEach((inline: any) => {
107 | if (inline.type === 'text') {
108 | text += inline.text;
109 | }
110 | });
111 | text += '\n';
112 | }
113 | });
114 | }
115 | return text.trim();
116 | }
117 |
118 | /**
119 | * Format Jira issue data to standardized format
120 | */
121 | function formatIssueData(issue: any, baseUrl: string): any {
122 | return {
123 | id: issue.id,
124 | key: issue.key,
125 | summary: issue.fields?.summary || '',
126 | description: extractTextFromADF(issue.fields?.description),
127 | rawDescription: issue.fields?.description || null,
128 | status: {
129 | name: issue.fields?.status?.name || 'Unknown',
130 | id: issue.fields?.status?.id || '0'
131 | },
132 | assignee: issue.fields?.assignee ? {
133 | displayName: issue.fields.assignee.displayName,
134 | accountId: issue.fields.assignee.accountId
135 | } : null,
136 | reporter: issue.fields?.reporter ? {
137 | displayName: issue.fields.reporter.displayName,
138 | accountId: issue.fields.reporter.accountId
139 | } : null,
140 | priority: issue.fields?.priority ? {
141 | name: issue.fields.priority.name,
142 | id: issue.fields.priority.id
143 | } : null,
144 | labels: issue.fields?.labels || [],
145 | created: issue.fields?.created || null,
146 | updated: issue.fields?.updated || null,
147 | issueType: {
148 | name: issue.fields?.issuetype?.name || 'Unknown',
149 | id: issue.fields?.issuetype?.id || '0'
150 | },
151 | projectKey: issue.fields?.project?.key || '',
152 | projectName: issue.fields?.project?.name || '',
153 | url: `${baseUrl}/browse/${issue.key}`
154 | };
155 | }
156 |
157 | /**
158 | * Format Jira comment data to standardized format
159 | */
160 | function formatCommentData(comment: any): any {
161 | return {
162 | id: comment.id,
163 | body: extractTextFromADF(comment.body),
164 | rawBody: comment.body || '',
165 | author: comment.author ? {
166 | displayName: comment.author.displayName,
167 | accountId: comment.author.accountId
168 | } : null,
169 | created: comment.created || null,
170 | updated: comment.updated || null
171 | };
172 | }
173 |
174 | /**
175 | * Register resources related to Jira issues
176 | * @param server MCP Server instance
177 | */
178 | export function registerIssueResources(server: McpServer) {
179 | logger.info('Registering Jira issue resources...');
180 |
181 | // Resource: Issues list (with pagination and JQL support)
182 | server.resource(
183 | 'jira-issues-list',
184 | new ResourceTemplate('jira://issues', {
185 | list: async (_extra) => {
186 | return {
187 | resources: [
188 | {
189 | uri: 'jira://issues',
190 | name: 'Jira Issues',
191 | description: 'List and search all Jira issues',
192 | mimeType: 'application/json'
193 | }
194 | ]
195 | };
196 | }
197 | }),
198 | async (uri, params, _extra) => {
199 | try {
200 | const config = Config.getAtlassianConfigFromEnv();
201 | const { limit, offset } = Resources.extractPagingParams(params);
202 | const jql = params.jql ? Array.isArray(params.jql) ? params.jql[0] : params.jql : '';
203 | const project = params.project ? Array.isArray(params.project) ? params.project[0] : params.project : '';
204 | const status = params.status ? Array.isArray(params.status) ? params.status[0] : params.status : '';
205 |
206 | // Build JQL query based on parameters
207 | let jqlQuery = jql;
208 | if (project && !jqlQuery.includes('project=')) {
209 | jqlQuery = jqlQuery ? `${jqlQuery} AND project = ${project}` : `project = ${project}`;
210 | }
211 | if (status && !jqlQuery.includes('status=')) {
212 | jqlQuery = jqlQuery ? `${jqlQuery} AND status = "${status}"` : `status = "${status}"`;
213 | }
214 |
215 | logger.info(`Searching Jira issues with JQL: ${jqlQuery || 'All issues'}`);
216 | const response = await searchIssuesApi(config, jqlQuery, limit);
217 |
218 | // Format issues data
219 | const formattedIssues = response.issues.map((issue: any) => formatIssueData(issue, config.baseUrl));
220 |
221 | const uriString = typeof uri === 'string' ? uri : uri.href;
222 | return Resources.createStandardResource(
223 | uriString,
224 | formattedIssues,
225 | 'issues',
226 | issuesListSchema,
227 | response.total,
228 | limit,
229 | offset,
230 | `${config.baseUrl}/issues/?jql=${encodeURIComponent(jqlQuery)}`
231 | );
232 | } catch (error) {
233 | logger.error('Error getting Jira issues:', error);
234 | throw error;
235 | }
236 | }
237 | );
238 |
239 | // Resource: Issue details
240 | server.resource(
241 | 'jira-issue-details',
242 | new ResourceTemplate('jira://issues/{issueKey}', {
243 | list: async (_extra) => {
244 | return {
245 | resources: [
246 | {
247 | uri: 'jira://issues/{issueKey}',
248 | name: 'Jira Issue Details',
249 | description: 'Get details for a specific Jira issue by key. Replace {issueKey} with the issue key.',
250 | mimeType: 'application/json'
251 | }
252 | ]
253 | };
254 | }
255 | }),
256 | async (uri, params, _extra) => {
257 | try {
258 | const config = Config.getAtlassianConfigFromEnv();
259 | let normalizedIssueKey = Array.isArray(params.issueKey) ? params.issueKey[0] : params.issueKey;
260 |
261 | if (!normalizedIssueKey) {
262 | throw new Error('Missing issueKey in URI');
263 | }
264 |
265 | logger.info(`Getting details for Jira issue: ${normalizedIssueKey}`);
266 | const issue = await getIssue(config, normalizedIssueKey);
267 | const formattedIssue = formatIssueData(issue, config.baseUrl);
268 |
269 | const uriString = typeof uri === 'string' ? uri : uri.href;
270 | return Resources.createStandardResource(
271 | uriString,
272 | [formattedIssue],
273 | 'issue',
274 | issueSchema,
275 | 1,
276 | 1,
277 | 0,
278 | `${config.baseUrl}/browse/${normalizedIssueKey}`
279 | );
280 | } catch (error) {
281 | logger.error(`Error getting Jira issue details:`, error);
282 | throw error;
283 | }
284 | }
285 | );
286 |
287 | // Resource: Issue transitions (available actions/status changes)
288 | server.resource(
289 | 'jira-issue-transitions',
290 | new ResourceTemplate('jira://issues/{issueKey}/transitions', {
291 | list: async (_extra) => {
292 | return {
293 | resources: [
294 | {
295 | uri: 'jira://issues/{issueKey}/transitions',
296 | name: 'Jira Issue Transitions',
297 | description: 'List available transitions for a Jira issue. Replace {issueKey} with the issue key.',
298 | mimeType: 'application/json'
299 | }
300 | ]
301 | };
302 | }
303 | }),
304 | async (uri, params, _extra) => {
305 | try {
306 | const config = Config.getAtlassianConfigFromEnv();
307 | let normalizedIssueKey = Array.isArray(params.issueKey) ? params.issueKey[0] : params.issueKey;
308 |
309 | if (!normalizedIssueKey) {
310 | throw new Error('Missing issueKey in URI');
311 | }
312 |
313 | logger.info(`Getting transitions for Jira issue: ${normalizedIssueKey}`);
314 | const transitions = await getIssueTransitions(config, normalizedIssueKey);
315 |
316 | // Format transitions data
317 | const formattedTransitions = transitions.map((t: any) => ({
318 | id: t.id,
319 | name: t.name,
320 | to: {
321 | id: t.to.id,
322 | name: t.to.name
323 | }
324 | }));
325 |
326 | const uriString = typeof uri === 'string' ? uri : uri.href;
327 | return Resources.createStandardResource(
328 | uriString,
329 | formattedTransitions,
330 | 'transitions',
331 | transitionsListSchema,
332 | formattedTransitions.length,
333 | formattedTransitions.length,
334 | 0,
335 | `${config.baseUrl}/browse/${normalizedIssueKey}`
336 | );
337 | } catch (error) {
338 | logger.error(`Error getting Jira issue transitions:`, error);
339 | throw error;
340 | }
341 | }
342 | );
343 |
344 | // Resource: Issue comments
345 | server.resource(
346 | 'jira-issue-comments',
347 | new ResourceTemplate('jira://issues/{issueKey}/comments', {
348 | list: async (_extra) => {
349 | return {
350 | resources: [
351 | {
352 | uri: 'jira://issues/{issueKey}/comments',
353 | name: 'Jira Issue Comments',
354 | description: 'List comments for a Jira issue. Replace {issueKey} with the issue key.',
355 | mimeType: 'application/json'
356 | }
357 | ]
358 | };
359 | }
360 | }),
361 | async (uri, params, _extra) => {
362 | try {
363 | const config = Config.getAtlassianConfigFromEnv();
364 | let normalizedIssueKey = Array.isArray(params.issueKey) ? params.issueKey[0] : params.issueKey;
365 |
366 | if (!normalizedIssueKey) {
367 | throw new Error('Missing issueKey in URI');
368 | }
369 |
370 | const { limit, offset } = Resources.extractPagingParams(params);
371 | logger.info(`Getting comments for Jira issue: ${normalizedIssueKey}`);
372 | const commentData = await getIssueComments(config, normalizedIssueKey, offset, limit);
373 |
374 | // Format comments data
375 | const formattedComments = (commentData.comments || []).map((c: any) => formatCommentData(c));
376 |
377 | const uriString = typeof uri === 'string' ? uri : uri.href;
378 | return Resources.createStandardResource(
379 | uriString,
380 | formattedComments,
381 | 'comments',
382 | commentsListSchema,
383 | commentData.total || formattedComments.length,
384 | limit,
385 | offset,
386 | `${config.baseUrl}/browse/${normalizedIssueKey}`
387 | );
388 | } catch (error) {
389 | logger.error(`Error getting Jira issue comments:`, error);
390 | throw error;
391 | }
392 | }
393 | );
394 |
395 | logger.info('Jira issue resources registered successfully');
396 | }
397 |
```
--------------------------------------------------------------------------------
/docs/dev-guide/modelcontextprotocol-tools.md:
--------------------------------------------------------------------------------
```markdown
1 | https://modelcontextprotocol.io/docs/concepts/tools
2 |
3 | # Tools
4 |
5 | > Enable LLMs to perform actions through your server
6 |
7 | Tools are a powerful primitive in the Model Context Protocol (MCP) that enable servers to expose executable functionality to clients. Through tools, LLMs can interact with external systems, perform computations, and take actions in the real world.
8 |
9 | <Note>
10 | Tools are designed to be **model-controlled**, meaning that tools are exposed from servers to clients with the intention of the AI model being able to automatically invoke them (with a human in the loop to grant approval).
11 | </Note>
12 |
13 | ## Overview
14 |
15 | Tools in MCP allow servers to expose executable functions that can be invoked by clients and used by LLMs to perform actions. Key aspects of tools include:
16 |
17 | * **Discovery**: Clients can list available tools through the `tools/list` endpoint
18 | * **Invocation**: Tools are called using the `tools/call` endpoint, where servers perform the requested operation and return results
19 | * **Flexibility**: Tools can range from simple calculations to complex API interactions
20 |
21 | Like [resources](/docs/concepts/resources), tools are identified by unique names and can include descriptions to guide their usage. However, unlike resources, tools represent dynamic operations that can modify state or interact with external systems.
22 |
23 | ## Tool definition structure
24 |
25 | Each tool is defined with the following structure:
26 |
27 | ```typescript
28 | {
29 | name: string; // Unique identifier for the tool
30 | description?: string; // Human-readable description
31 | inputSchema: { // JSON Schema for the tool's parameters
32 | type: "object",
33 | properties: { ... } // Tool-specific parameters
34 | },
35 | annotations?: { // Optional hints about tool behavior
36 | title?: string; // Human-readable title for the tool
37 | readOnlyHint?: boolean; // If true, the tool does not modify its environment
38 | destructiveHint?: boolean; // If true, the tool may perform destructive updates
39 | idempotentHint?: boolean; // If true, repeated calls with same args have no additional effect
40 | openWorldHint?: boolean; // If true, tool interacts with external entities
41 | }
42 | }
43 | ```
44 |
45 | ## Implementing tools
46 |
47 | Here's an example of implementing a basic tool in an MCP server:
48 |
49 | <Tabs>
50 | <Tab title="TypeScript">
51 | ```typescript
52 | const server = new Server({
53 | name: "example-server",
54 | version: "1.0.0"
55 | }, {
56 | capabilities: {
57 | tools: {}
58 | }
59 | });
60 |
61 | // Define available tools
62 | server.setRequestHandler(ListToolsRequestSchema, async () => {
63 | return {
64 | tools: [{
65 | name: "calculate_sum",
66 | description: "Add two numbers together",
67 | inputSchema: {
68 | type: "object",
69 | properties: {
70 | a: { type: "number" },
71 | b: { type: "number" }
72 | },
73 | required: ["a", "b"]
74 | }
75 | }]
76 | };
77 | });
78 |
79 | // Handle tool execution
80 | server.setRequestHandler(CallToolRequestSchema, async (request) => {
81 | if (request.params.name === "calculate_sum") {
82 | const { a, b } = request.params.arguments;
83 | return {
84 | content: [
85 | {
86 | type: "text",
87 | text: String(a + b)
88 | }
89 | ]
90 | };
91 | }
92 | throw new Error("Tool not found");
93 | });
94 | ```
95 | </Tab>
96 |
97 | <Tab title="Python">
98 | ```python
99 | app = Server("example-server")
100 |
101 | @app.list_tools()
102 | async def list_tools() -> list[types.Tool]:
103 | return [
104 | types.Tool(
105 | name="calculate_sum",
106 | description="Add two numbers together",
107 | inputSchema={
108 | "type": "object",
109 | "properties": {
110 | "a": {"type": "number"},
111 | "b": {"type": "number"}
112 | },
113 | "required": ["a", "b"]
114 | }
115 | )
116 | ]
117 |
118 | @app.call_tool()
119 | async def call_tool(
120 | name: str,
121 | arguments: dict
122 | ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
123 | if name == "calculate_sum":
124 | a = arguments["a"]
125 | b = arguments["b"]
126 | result = a + b
127 | return [types.TextContent(type="text", text=str(result))]
128 | raise ValueError(f"Tool not found: {name}")
129 | ```
130 | </Tab>
131 | </Tabs>
132 |
133 | ## Example tool patterns
134 |
135 | Here are some examples of types of tools that a server could provide:
136 |
137 | ### System operations
138 |
139 | Tools that interact with the local system:
140 |
141 | ```typescript
142 | {
143 | name: "execute_command",
144 | description: "Run a shell command",
145 | inputSchema: {
146 | type: "object",
147 | properties: {
148 | command: { type: "string" },
149 | args: { type: "array", items: { type: "string" } }
150 | }
151 | }
152 | }
153 | ```
154 |
155 | ### API integrations
156 |
157 | Tools that wrap external APIs:
158 |
159 | ```typescript
160 | {
161 | name: "github_create_issue",
162 | description: "Create a GitHub issue",
163 | inputSchema: {
164 | type: "object",
165 | properties: {
166 | title: { type: "string" },
167 | body: { type: "string" },
168 | labels: { type: "array", items: { type: "string" } }
169 | }
170 | }
171 | }
172 | ```
173 |
174 | ### Data processing
175 |
176 | Tools that transform or analyze data:
177 |
178 | ```typescript
179 | {
180 | name: "analyze_csv",
181 | description: "Analyze a CSV file",
182 | inputSchema: {
183 | type: "object",
184 | properties: {
185 | filepath: { type: "string" },
186 | operations: {
187 | type: "array",
188 | items: {
189 | enum: ["sum", "average", "count"]
190 | }
191 | }
192 | }
193 | }
194 | }
195 | ```
196 |
197 | ## Best practices
198 |
199 | When implementing tools:
200 |
201 | 1. Provide clear, descriptive names and descriptions
202 | 2. Use detailed JSON Schema definitions for parameters
203 | 3. Include examples in tool descriptions to demonstrate how the model should use them
204 | 4. Implement proper error handling and validation
205 | 5. Use progress reporting for long operations
206 | 6. Keep tool operations focused and atomic
207 | 7. Document expected return value structures
208 | 8. Implement proper timeouts
209 | 9. Consider rate limiting for resource-intensive operations
210 | 10. Log tool usage for debugging and monitoring
211 |
212 | ## Security considerations
213 |
214 | When exposing tools:
215 |
216 | ### Input validation
217 |
218 | * Validate all parameters against the schema
219 | * Sanitize file paths and system commands
220 | * Validate URLs and external identifiers
221 | * Check parameter sizes and ranges
222 | * Prevent command injection
223 |
224 | ### Access control
225 |
226 | * Implement authentication where needed
227 | * Use appropriate authorization checks
228 | * Audit tool usage
229 | * Rate limit requests
230 | * Monitor for abuse
231 |
232 | ### Error handling
233 |
234 | * Don't expose internal errors to clients
235 | * Log security-relevant errors
236 | * Handle timeouts appropriately
237 | * Clean up resources after errors
238 | * Validate return values
239 |
240 | ## Tool discovery and updates
241 |
242 | MCP supports dynamic tool discovery:
243 |
244 | 1. Clients can list available tools at any time
245 | 2. Servers can notify clients when tools change using `notifications/tools/list_changed`
246 | 3. Tools can be added or removed during runtime
247 | 4. Tool definitions can be updated (though this should be done carefully)
248 |
249 | ## Error handling
250 |
251 | Tool errors should be reported within the result object, not as MCP protocol-level errors. This allows the LLM to see and potentially handle the error. When a tool encounters an error:
252 |
253 | 1. Set `isError` to `true` in the result
254 | 2. Include error details in the `content` array
255 |
256 | Here's an example of proper error handling for tools:
257 |
258 | <Tabs>
259 | <Tab title="TypeScript">
260 | ```typescript
261 | try {
262 | // Tool operation
263 | const result = performOperation();
264 | return {
265 | content: [
266 | {
267 | type: "text",
268 | text: `Operation successful: ${result}`
269 | }
270 | ]
271 | };
272 | } catch (error) {
273 | return {
274 | isError: true,
275 | content: [
276 | {
277 | type: "text",
278 | text: `Error: ${error.message}`
279 | }
280 | ]
281 | };
282 | }
283 | ```
284 | </Tab>
285 |
286 | <Tab title="Python">
287 | ```python
288 | try:
289 | # Tool operation
290 | result = perform_operation()
291 | return types.CallToolResult(
292 | content=[
293 | types.TextContent(
294 | type="text",
295 | text=f"Operation successful: {result}"
296 | )
297 | ]
298 | )
299 | except Exception as error:
300 | return types.CallToolResult(
301 | isError=True,
302 | content=[
303 | types.TextContent(
304 | type="text",
305 | text=f"Error: {str(error)}"
306 | )
307 | ]
308 | )
309 | ```
310 | </Tab>
311 | </Tabs>
312 |
313 | This approach allows the LLM to see that an error occurred and potentially take corrective action or request human intervention.
314 |
315 | ## Tool annotations
316 |
317 | Tool annotations provide additional metadata about a tool's behavior, helping clients understand how to present and manage tools. These annotations are hints that describe the nature and impact of a tool, but should not be relied upon for security decisions.
318 |
319 | ### Purpose of tool annotations
320 |
321 | Tool annotations serve several key purposes:
322 |
323 | 1. Provide UX-specific information without affecting model context
324 | 2. Help clients categorize and present tools appropriately
325 | 3. Convey information about a tool's potential side effects
326 | 4. Assist in developing intuitive interfaces for tool approval
327 |
328 | ### Available tool annotations
329 |
330 | The MCP specification defines the following annotations for tools:
331 |
332 | | Annotation | Type | Default | Description |
333 | | ----------------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------ |
334 | | `title` | string | - | A human-readable title for the tool, useful for UI display |
335 | | `readOnlyHint` | boolean | false | If true, indicates the tool does not modify its environment |
336 | | `destructiveHint` | boolean | true | If true, the tool may perform destructive updates (only meaningful when `readOnlyHint` is false) |
337 | | `idempotentHint` | boolean | false | If true, calling the tool repeatedly with the same arguments has no additional effect (only meaningful when `readOnlyHint` is false) |
338 | | `openWorldHint` | boolean | true | If true, the tool may interact with an "open world" of external entities |
339 |
340 | ### Example usage
341 |
342 | Here's how to define tools with annotations for different scenarios:
343 |
344 | ```typescript
345 | // A read-only search tool
346 | {
347 | name: "web_search",
348 | description: "Search the web for information",
349 | inputSchema: {
350 | type: "object",
351 | properties: {
352 | query: { type: "string" }
353 | },
354 | required: ["query"]
355 | },
356 | annotations: {
357 | title: "Web Search",
358 | readOnlyHint: true,
359 | openWorldHint: true
360 | }
361 | }
362 |
363 | // A destructive file deletion tool
364 | {
365 | name: "delete_file",
366 | description: "Delete a file from the filesystem",
367 | inputSchema: {
368 | type: "object",
369 | properties: {
370 | path: { type: "string" }
371 | },
372 | required: ["path"]
373 | },
374 | annotations: {
375 | title: "Delete File",
376 | readOnlyHint: false,
377 | destructiveHint: true,
378 | idempotentHint: true,
379 | openWorldHint: false
380 | }
381 | }
382 |
383 | // A non-destructive database record creation tool
384 | {
385 | name: "create_record",
386 | description: "Create a new record in the database",
387 | inputSchema: {
388 | type: "object",
389 | properties: {
390 | table: { type: "string" },
391 | data: { type: "object" }
392 | },
393 | required: ["table", "data"]
394 | },
395 | annotations: {
396 | title: "Create Database Record",
397 | readOnlyHint: false,
398 | destructiveHint: false,
399 | idempotentHint: false,
400 | openWorldHint: false
401 | }
402 | }
403 | ```
404 |
405 | ### Integrating annotations in server implementation
406 |
407 | <Tabs>
408 | <Tab title="TypeScript">
409 | ```typescript
410 | server.setRequestHandler(ListToolsRequestSchema, async () => {
411 | return {
412 | tools: [{
413 | name: "calculate_sum",
414 | description: "Add two numbers together",
415 | inputSchema: {
416 | type: "object",
417 | properties: {
418 | a: { type: "number" },
419 | b: { type: "number" }
420 | },
421 | required: ["a", "b"]
422 | },
423 | annotations: {
424 | title: "Calculate Sum",
425 | readOnlyHint: true,
426 | openWorldHint: false
427 | }
428 | }]
429 | };
430 | });
431 | ```
432 | </Tab>
433 |
434 | <Tab title="Python">
435 | ```python
436 | from mcp.server.fastmcp import FastMCP
437 |
438 | mcp = FastMCP("example-server")
439 |
440 | @mcp.tool(
441 | annotations={
442 | "title": "Calculate Sum",
443 | "readOnlyHint": True,
444 | "openWorldHint": False
445 | }
446 | )
447 | async def calculate_sum(a: float, b: float) -> str:
448 | """Add two numbers together.
449 |
450 | Args:
451 | a: First number to add
452 | b: Second number to add
453 | """
454 | result = a + b
455 | return str(result)
456 | ```
457 | </Tab>
458 | </Tabs>
459 |
460 | ### Best practices for tool annotations
461 |
462 | 1. **Be accurate about side effects**: Clearly indicate whether a tool modifies its environment and whether those modifications are destructive.
463 |
464 | 2. **Use descriptive titles**: Provide human-friendly titles that clearly describe the tool's purpose.
465 |
466 | 3. **Indicate idempotency properly**: Mark tools as idempotent only if repeated calls with the same arguments truly have no additional effect.
467 |
468 | 4. **Set appropriate open/closed world hints**: Indicate whether a tool interacts with a closed system (like a database) or an open system (like the web).
469 |
470 | 5. **Remember annotations are hints**: All properties in ToolAnnotations are hints and not guaranteed to provide a faithful description of tool behavior. Clients should never make security-critical decisions based solely on annotations.
471 |
472 | ## Testing tools
473 |
474 | A comprehensive testing strategy for MCP tools should cover:
475 |
476 | * **Functional testing**: Verify tools execute correctly with valid inputs and handle invalid inputs appropriately
477 | * **Integration testing**: Test tool interaction with external systems using both real and mocked dependencies
478 | * **Security testing**: Validate authentication, authorization, input sanitization, and rate limiting
479 | * **Performance testing**: Check behavior under load, timeout handling, and resource cleanup
480 | * **Error handling**: Ensure tools properly report errors through the MCP protocol and clean up resources
481 |
```
--------------------------------------------------------------------------------
/src/resources/confluence/pages.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
2 | import { Logger } from '../../utils/logger.js';
3 | import { pagesListSchema, pageSchema, commentsListSchema, attachmentListSchema, versionListSchema, labelListSchema } from '../../schemas/confluence.js';
4 | import { getConfluencePagesV2, getConfluencePageV2, getConfluencePageBodyV2, getConfluencePageAncestorsV2, getConfluencePageChildrenV2, getConfluencePageLabelsV2, getConfluencePageAttachmentsV2, getConfluencePageVersionsV2, getConfluencePagesWithFilters } from '../../utils/confluence-resource-api.js';
5 | import { getConfluencePageFooterCommentsV2, getConfluencePageInlineCommentsV2 } from '../../utils/confluence-resource-api.js';
6 | import { Config, Resources } from '../../utils/mcp-helpers.js';
7 |
8 | const logger = Logger.getLogger('ConfluenceResource:Pages');
9 |
10 | export function registerPageResources(server: McpServer) {
11 | logger.info('Registering Confluence page resources...');
12 |
13 | // Resource: Page details (API v2, tách call metadata và body)
14 | server.resource(
15 | 'confluence-page-details-v2',
16 | new ResourceTemplate('confluence://pages/{pageId}', {
17 | list: async (_extra) => ({
18 | resources: [
19 | {
20 | uri: 'confluence://pages/{pageId}',
21 | name: 'Confluence Page Details',
22 | description: 'Get details for a specific Confluence page by ID. Replace {pageId} with the page ID.',
23 | mimeType: 'application/json'
24 | }
25 | ]
26 | })
27 | }),
28 | async (uri, { pageId }, extra) => {
29 | let normalizedPageId = Array.isArray(pageId) ? pageId[0] : pageId;
30 | try {
31 | let config = (extra && typeof extra === 'object' && 'context' in extra && extra.context && (extra.context as any).atlassianConfig)
32 | ? (extra.context as any).atlassianConfig
33 | : Config.getAtlassianConfigFromEnv();
34 | if (!normalizedPageId) {
35 | throw new Error('Missing pageId in URI');
36 | }
37 | logger.info(`Getting details for Confluence page (v2): ${normalizedPageId}`);
38 | const page = await getConfluencePageV2(config, normalizedPageId);
39 | let body = {};
40 | try {
41 | body = await getConfluencePageBodyV2(config, normalizedPageId);
42 | } catch (e) {
43 | body = {};
44 | }
45 | const formattedPage = {
46 | ...page,
47 | body: (body && typeof body === 'object' && 'value' in body) ? body.value : '',
48 | bodyType: (body && typeof body === 'object' && 'representation' in body) ? body.representation : 'storage',
49 | };
50 | const uriString = typeof uri === 'string' ? uri : uri.href;
51 | return Resources.createStandardResource(
52 | uriString,
53 | [formattedPage],
54 | 'page',
55 | pageSchema,
56 | 1,
57 | 1,
58 | 0,
59 | `${config.baseUrl}/wiki/pages/${normalizedPageId}`
60 | );
61 | } catch (error) {
62 | logger.error(`Error getting Confluence page details (v2) for ${normalizedPageId}:`, error);
63 | throw error;
64 | }
65 | }
66 | );
67 |
68 | // Resource: List of children pages
69 | server.resource(
70 | 'confluence-page-children',
71 | new ResourceTemplate('confluence://pages/{pageId}/children', {
72 | list: async (_extra) => ({
73 | resources: [
74 | {
75 | uri: 'confluence://pages/{pageId}/children',
76 | name: 'Confluence Page Children',
77 | description: 'List all children for a Confluence page. Replace {pageId} với ID trang.',
78 | mimeType: 'application/json'
79 | }
80 | ]
81 | })
82 | }),
83 | async (uri, { pageId }, extra) => {
84 | let normalizedPageId = Array.isArray(pageId) ? pageId[0] : pageId;
85 | try {
86 | let config = (extra && typeof extra === 'object' && 'context' in extra && extra.context && (extra.context as any).atlassianConfig)
87 | ? (extra.context as any).atlassianConfig
88 | : Config.getAtlassianConfigFromEnv();
89 | if (!normalizedPageId) {
90 | throw new Error('Missing pageId in URI');
91 | }
92 | logger.info(`Getting children for Confluence page (v2): ${normalizedPageId}`);
93 | const data = await getConfluencePageChildrenV2(config, normalizedPageId);
94 | const formattedChildren = (data.results || []).map((child: any) => ({
95 | id: child.id,
96 | title: child.title,
97 | status: child.status,
98 | url: `${config.baseUrl}/wiki/pages/${child.id}`
99 | }));
100 | const childrenSchema = { type: 'array', items: pageSchema };
101 | return Resources.createStandardResource(
102 | typeof uri === 'string' ? uri : uri.href,
103 | formattedChildren,
104 | 'children',
105 | childrenSchema,
106 | formattedChildren.length,
107 | formattedChildren.length,
108 | 0,
109 | `${config.baseUrl}/wiki/pages/${normalizedPageId}`
110 | );
111 | } catch (error) {
112 | logger.error(`Error getting Confluence page children for ${normalizedPageId}:`, error);
113 | throw error;
114 | }
115 | }
116 | );
117 |
118 | // Resource: List of comments for a page (API v2, gộp cả footer và inline)
119 | server.resource(
120 | 'confluence-page-comments',
121 | new ResourceTemplate('confluence://pages/{pageId}/comments', {
122 | list: async (_extra) => ({
123 | resources: [
124 | {
125 | uri: 'confluence://pages/{pageId}/comments',
126 | name: 'Confluence Page Comments',
127 | description: 'List comments for a Confluence page. Replace {pageId} with the page ID.',
128 | mimeType: 'application/json'
129 | }
130 | ]
131 | })
132 | }),
133 | async (uri, { pageId }, extra) => {
134 | let normalizedPageId = Array.isArray(pageId) ? pageId[0] : pageId;
135 | try {
136 | let config = (extra && typeof extra === 'object' && 'context' in extra && extra.context && (extra.context as any).atlassianConfig)
137 | ? (extra.context as any).atlassianConfig
138 | : Config.getAtlassianConfigFromEnv();
139 | if (!normalizedPageId) {
140 | throw new Error('Missing pageId in URI');
141 | }
142 | logger.info(`Getting comments for Confluence page (v2): ${normalizedPageId}`);
143 | const footerComments = await getConfluencePageFooterCommentsV2(config, normalizedPageId);
144 | const inlineComments = await getConfluencePageInlineCommentsV2(config, normalizedPageId);
145 | const allComments = [...(footerComments.results || []), ...(inlineComments.results || [])];
146 | return Resources.createStandardResource(
147 | typeof uri === 'string' ? uri : uri.href,
148 | allComments,
149 | 'comments',
150 | commentsListSchema,
151 | allComments.length,
152 | allComments.length,
153 | 0,
154 | `${config.baseUrl}/wiki/pages/${normalizedPageId}`
155 | );
156 | } catch (error) {
157 | logger.error(`Error getting Confluence page comments for ${normalizedPageId}:`, error);
158 | throw error;
159 | }
160 | }
161 | );
162 |
163 | // Resource: List of ancestors for a page
164 | server.resource(
165 | 'confluence-page-ancestors',
166 | new ResourceTemplate('confluence://pages/{pageId}/ancestors', {
167 | list: async (_extra) => ({
168 | resources: [
169 | {
170 | uri: 'confluence://pages/{pageId}/ancestors',
171 | name: 'Confluence Page Ancestors',
172 | description: 'List all ancestors for a Confluence page. Replace {pageId} with the page ID.',
173 | mimeType: 'application/json'
174 | }
175 | ]
176 | })
177 | }),
178 | async (uri, { pageId }, extra) => {
179 | let normalizedPageId = Array.isArray(pageId) ? pageId[0] : pageId;
180 | try {
181 | let config = (extra && typeof extra === 'object' && 'context' in extra && extra.context && (extra.context as any).atlassianConfig)
182 | ? (extra.context as any).atlassianConfig
183 | : Config.getAtlassianConfigFromEnv();
184 | if (!normalizedPageId) {
185 | throw new Error('Missing pageId in URI');
186 | }
187 | logger.info(`Getting ancestors for Confluence page (v2): ${normalizedPageId}`);
188 | const data = await getConfluencePageAncestorsV2(config, normalizedPageId);
189 | const ancestors = Array.isArray(data?.results) ? data.results : [];
190 | return Resources.createStandardResource(
191 | typeof uri === 'string' ? uri : uri.href,
192 | ancestors,
193 | 'ancestors',
194 | { type: 'array', items: pageSchema },
195 | ancestors.length,
196 | ancestors.length,
197 | 0,
198 | `${config.baseUrl}/wiki/pages/${normalizedPageId}`
199 | );
200 | } catch (error) {
201 | logger.error(`Error getting Confluence page ancestors for ${normalizedPageId}:`, error);
202 | throw error;
203 | }
204 | }
205 | );
206 |
207 | // Resource: List of attachments for a page
208 | server.resource(
209 | 'confluence-page-attachments',
210 | new ResourceTemplate('confluence://pages/{pageId}/attachments', {
211 | list: async (_extra) => ({
212 | resources: [
213 | {
214 | uri: 'confluence://pages/{pageId}/attachments',
215 | name: 'Confluence Page Attachments',
216 | description: 'List all attachments for a Confluence page. Replace {pageId} with the page ID.',
217 | mimeType: 'application/json'
218 | }
219 | ]
220 | })
221 | }),
222 | async (uri, params, extra) => {
223 | let normalizedPageId = Array.isArray(params.pageId) ? params.pageId[0] : params.pageId;
224 | try {
225 | let config = (extra && typeof extra === 'object' && 'context' in extra && extra.context && (extra.context as any).atlassianConfig)
226 | ? (extra.context as any).atlassianConfig
227 | : Config.getAtlassianConfigFromEnv();
228 | if (!normalizedPageId) {
229 | throw new Error('Missing pageId in URI');
230 | }
231 | logger.info(`Getting attachments for Confluence page (v2): ${normalizedPageId}`);
232 | const data = await getConfluencePageAttachmentsV2(config, normalizedPageId);
233 | return Resources.createStandardResource(
234 | typeof uri === 'string' ? uri : uri.href,
235 | data.results || [],
236 | 'attachments',
237 | attachmentListSchema,
238 | data.size || (data.results || []).length,
239 | data.limit || (data.results || []).length,
240 | 0,
241 | undefined
242 | );
243 | } catch (error) {
244 | logger.error(`Error getting Confluence page attachments for ${normalizedPageId}:`, error);
245 | throw error;
246 | }
247 | }
248 | );
249 |
250 | // Resource: List of versions for a page
251 | server.resource(
252 | 'confluence-page-versions',
253 | new ResourceTemplate('confluence://pages/{pageId}/versions', {
254 | list: async (_extra) => ({
255 | resources: [
256 | {
257 | uri: 'confluence://pages/{pageId}/versions',
258 | name: 'Confluence Page Versions',
259 | description: 'List all versions for a Confluence page. Replace {pageId} with the page ID.',
260 | mimeType: 'application/json'
261 | }
262 | ]
263 | })
264 | }),
265 | async (uri, params, extra) => {
266 | let normalizedPageId = Array.isArray(params.pageId) ? params.pageId[0] : params.pageId;
267 | try {
268 | let config = (extra && typeof extra === 'object' && 'context' in extra && extra.context && (extra.context as any).atlassianConfig)
269 | ? (extra.context as any).atlassianConfig
270 | : Config.getAtlassianConfigFromEnv();
271 | if (!normalizedPageId) {
272 | throw new Error('Missing pageId in URI');
273 | }
274 | logger.info(`Getting versions for Confluence page (v2): ${normalizedPageId}`);
275 | const data = await getConfluencePageVersionsV2(config, normalizedPageId);
276 | return Resources.createStandardResource(
277 | typeof uri === 'string' ? uri : uri.href,
278 | data.results || [],
279 | 'versions',
280 | versionListSchema,
281 | data.size || (data.results || []).length,
282 | data.limit || (data.results || []).length,
283 | 0,
284 | undefined
285 | );
286 | } catch (error) {
287 | logger.error(`Error getting Confluence page versions for ${normalizedPageId}:`, error);
288 | throw error;
289 | }
290 | }
291 | );
292 |
293 | // Resource: List of pages (search/filter)
294 | server.resource(
295 | 'confluence-pages-list',
296 | new ResourceTemplate('confluence://pages', {
297 | list: async (_extra) => ({
298 | resources: [
299 | {
300 | uri: 'confluence://pages',
301 | name: 'Confluence Pages',
302 | description: 'List and search all Confluence pages',
303 | mimeType: 'application/json'
304 | }
305 | ]
306 | })
307 | }),
308 | async (uri, params, extra) => {
309 | let config = (extra && typeof extra === 'object' && 'context' in extra && extra.context && (extra.context as any).atlassianConfig)
310 | ? (extra.context as any).atlassianConfig
311 | : Config.getAtlassianConfigFromEnv();
312 | const filterParams = { ...params };
313 | const data = await getConfluencePagesWithFilters(config, filterParams);
314 | const formattedPages = (data.results || []).map((page: any) => ({
315 | id: page.id,
316 | title: page.title,
317 | status: page.status,
318 | url: `${config.baseUrl}/wiki/pages/${page.id}`
319 | }));
320 | const uriString = typeof uri === 'string' ? uri : uri.href;
321 | return Resources.createStandardResource(
322 | uriString,
323 | formattedPages,
324 | 'pages',
325 | pagesListSchema,
326 | data.size || formattedPages.length,
327 | filterParams.limit || formattedPages.length,
328 | 0,
329 | undefined
330 | );
331 | }
332 | );
333 |
334 | // Resource: List of labels for a page
335 | server.resource(
336 | 'confluence-page-labels',
337 | new ResourceTemplate('confluence://pages/{pageId}/labels', {
338 | list: async (_extra) => ({
339 | resources: [
340 | {
341 | uri: 'confluence://pages/{pageId}/labels',
342 | name: 'Confluence Page Labels',
343 | description: 'List all labels for a Confluence page. Replace {pageId} with the page ID.',
344 | mimeType: 'application/json'
345 | }
346 | ]
347 | })
348 | }),
349 | async (uri, { pageId }, extra) => {
350 | let normalizedPageId = Array.isArray(pageId) ? pageId[0] : pageId;
351 | try {
352 | let config = (extra && typeof extra === 'object' && 'context' in extra && extra.context && (extra.context as any).atlassianConfig)
353 | ? (extra.context as any).atlassianConfig
354 | : Config.getAtlassianConfigFromEnv();
355 | if (!normalizedPageId) {
356 | throw new Error('Missing pageId in URI');
357 | }
358 | logger.info(`Getting labels for Confluence page (v2): ${normalizedPageId}`);
359 | const data = await getConfluencePageLabelsV2(config, normalizedPageId);
360 | const formattedLabels = (data.results || []).map((label: any) => ({
361 | id: label.id,
362 | name: label.name,
363 | prefix: label.prefix
364 | }));
365 | return Resources.createStandardResource(
366 | typeof uri === 'string' ? uri : uri.href,
367 | formattedLabels,
368 | 'labels',
369 | labelListSchema,
370 | data.size || formattedLabels.length,
371 | data.limit || formattedLabels.length,
372 | 0,
373 | undefined
374 | );
375 | } catch (error) {
376 | logger.error(`Error getting Confluence page labels for ${normalizedPageId}:`, error);
377 | throw error;
378 | }
379 | }
380 | );
381 | }
382 |
```
--------------------------------------------------------------------------------
/dev_mcp-atlassian-test-client/src/tool-test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3 | import path from "path";
4 | import { fileURLToPath } from "url";
5 | import fs from "fs";
6 |
7 | // Get current file path
8 | const __filename = fileURLToPath(import.meta.url);
9 | const __dirname = path.dirname(__filename);
10 |
11 | // Load environment variables from .env
12 | function loadEnv(): Record<string, string> {
13 | try {
14 | const envFile = path.resolve(process.cwd(), '.env');
15 | const envContent = fs.readFileSync(envFile, 'utf8');
16 | const envVars: Record<string, string> = {};
17 | envContent.split('\n').forEach(line => {
18 | if (line.trim().startsWith('#') || !line.trim()) return;
19 | const [key, ...valueParts] = line.split('=');
20 | if (key && valueParts.length > 0) {
21 | const value = valueParts.join('=');
22 | envVars[key.trim()] = value.trim();
23 | }
24 | });
25 | return envVars;
26 | } catch (error) {
27 | console.error("Error loading .env file:", error);
28 | return {};
29 | }
30 | }
31 |
32 | async function main() {
33 | try {
34 | console.log("=== MCP Atlassian Tool Test (Refactored) ===");
35 | const envVars = loadEnv();
36 | const client = new Client({ name: "mcp-atlassian-test-client", version: "1.0.0" });
37 | const serverPath = "/Users/phucnt/Workspace/mcp-atlassian-server/dist/index.js";
38 | const processEnv: Record<string, string> = {};
39 | Object.keys(process.env).forEach(key => {
40 | if (process.env[key] !== undefined) {
41 | processEnv[key] = process.env[key] as string;
42 | }
43 | });
44 | const transport = new StdioClientTransport({
45 | command: "node",
46 | args: [serverPath],
47 | env: {
48 | ...processEnv,
49 | ...envVars
50 | }
51 | });
52 | await client.connect(transport);
53 | console.log("Connected to MCP server\n");
54 |
55 | // === Jira Tools ===
56 | console.log("--- Jira Tool Tests ---");
57 | const jiraProjectKey = "XDEMO2";
58 | // 1. createIssue
59 | const newIssueSummary = `Test Issue ${new Date().toLocaleString()}`;
60 | const createIssueResult = await client.callTool({
61 | name: "createIssue",
62 | arguments: {
63 | projectKey: jiraProjectKey,
64 | summary: newIssueSummary,
65 | description: "Test issue created by MCP tool-test",
66 | issueType: "Task"
67 | }
68 | });
69 | console.log("createIssueResult (raw):", createIssueResult);
70 | let createIssueObj = createIssueResult;
71 | if (
72 | createIssueObj.content &&
73 | Array.isArray(createIssueObj.content) &&
74 | typeof createIssueObj.content[0]?.text === 'string'
75 | ) {
76 | createIssueObj = JSON.parse(createIssueObj.content[0].text);
77 | console.log("createIssueResult (parsed):", createIssueObj);
78 | }
79 | console.log("createIssue:", createIssueObj.key ? "✅" : "❌", createIssueObj.key || "Unknown");
80 | const newIssueKey = createIssueObj.key;
81 |
82 | // 2. updateIssue
83 | if (newIssueKey) {
84 | const updateIssueResult = await client.callTool({
85 | name: "updateIssue",
86 | arguments: {
87 | issueIdOrKey: newIssueKey,
88 | summary: `${newIssueSummary} (Updated)`
89 | }
90 | });
91 | console.log("updateIssueResult (raw):", updateIssueResult);
92 | let updateIssueObj = updateIssueResult;
93 | if (
94 | updateIssueObj.content &&
95 | Array.isArray(updateIssueObj.content) &&
96 | typeof updateIssueObj.content[0]?.text === 'string'
97 | ) {
98 | updateIssueObj = JSON.parse(updateIssueObj.content[0].text);
99 | console.log("updateIssueResult (parsed):", updateIssueObj);
100 | }
101 | console.log("updateIssue:", updateIssueObj.success ? "✅" : "❌");
102 | }
103 |
104 | // 3. assignIssue
105 | if (newIssueKey) {
106 | const assignIssueResult = await client.callTool({
107 | name: "assignIssue",
108 | arguments: {
109 | issueIdOrKey: newIssueKey,
110 | accountId: ""
111 | }
112 | });
113 | console.log("assignIssueResult (raw):", assignIssueResult);
114 | let assignIssueObj = assignIssueResult;
115 | if (
116 | assignIssueObj.content &&
117 | Array.isArray(assignIssueObj.content) &&
118 | typeof assignIssueObj.content[0]?.text === 'string'
119 | ) {
120 | assignIssueObj = JSON.parse(assignIssueObj.content[0].text);
121 | console.log("assignIssueResult (parsed):", assignIssueObj);
122 | }
123 | console.log("assignIssue:", assignIssueObj.success ? "✅" : "❌");
124 | }
125 |
126 | // 4. transitionIssue
127 | if (newIssueKey) {
128 | const transitionIssueResult = await client.callTool({
129 | name: "transitionIssue",
130 | arguments: {
131 | issueIdOrKey: newIssueKey,
132 | transitionId: "11",
133 | comment: "Test transition"
134 | }
135 | });
136 | console.log("transitionIssueResult (raw):", transitionIssueResult);
137 | let transitionIssueObj = transitionIssueResult;
138 | if (
139 | transitionIssueObj.content &&
140 | Array.isArray(transitionIssueObj.content) &&
141 | typeof transitionIssueObj.content[0]?.text === 'string'
142 | ) {
143 | transitionIssueObj = JSON.parse(transitionIssueObj.content[0].text);
144 | console.log("transitionIssueResult (parsed):", transitionIssueObj);
145 | }
146 | console.log("transitionIssue:", transitionIssueObj.success ? "✅" : "❌");
147 | }
148 |
149 | // 5. createSprint (nếu có boardId)
150 | let boardId = null;
151 | try {
152 | const boardsResult = await client.readResource({ uri: `jira://boards` });
153 | if (boardsResult.contents && boardsResult.contents[0].text) {
154 | const boardsData = JSON.parse(String(boardsResult.contents[0].text));
155 | if (boardsData && boardsData.boards && boardsData.boards.length > 0) {
156 | for (const board of boardsData.boards) {
157 | if (board.type === "scrum") {
158 | boardId = board.id;
159 | break;
160 | }
161 | }
162 | }
163 | }
164 | } catch {}
165 | let newSprintId = null;
166 | if (boardId) {
167 | try {
168 | const createSprintResult = await client.callTool({
169 | name: "createSprint",
170 | arguments: {
171 | boardId: String(boardId),
172 | name: `Sprint-${Date.now()}`.substring(0, 25),
173 | goal: "Test sprint created by MCP tool-test"
174 | }
175 | });
176 | console.log("createSprintResult (raw):", createSprintResult);
177 | let createSprintObj = createSprintResult;
178 | if (
179 | createSprintObj.content &&
180 | Array.isArray(createSprintObj.content) &&
181 | typeof createSprintObj.content[0]?.text === 'string'
182 | ) {
183 | createSprintObj = JSON.parse(createSprintObj.content[0].text);
184 | console.log("createSprintResult (parsed):", createSprintObj);
185 | }
186 | console.log("createSprint:", createSprintObj.id ? "✅" : "❌", createSprintObj.id || "Unknown");
187 | newSprintId = createSprintObj.id;
188 | } catch (e) {
189 | console.log("createSprint: ❌", e instanceof Error ? e.message : String(e));
190 | }
191 | }
192 |
193 | // 6. createFilter
194 | const createFilterResult = await client.callTool({
195 | name: "createFilter",
196 | arguments: {
197 | name: `Test Filter ${Date.now()}`,
198 | jql: "project = XDEMO2 ORDER BY created DESC",
199 | description: "Test filter created by MCP tool-test",
200 | favourite: false
201 | }
202 | });
203 | console.log("createFilterResult (raw):", createFilterResult);
204 | let createFilterObj = createFilterResult;
205 | if (
206 | createFilterObj.content &&
207 | Array.isArray(createFilterObj.content) &&
208 | typeof createFilterObj.content[0]?.text === 'string'
209 | ) {
210 | createFilterObj = JSON.parse(createFilterObj.content[0].text);
211 | console.log("createFilterResult (parsed):", createFilterObj);
212 | }
213 | console.log("createFilter:", createFilterObj.id ? "✅" : "❌", createFilterObj.id || "Unknown");
214 |
215 | // 7. createDashboard
216 | const createDashboardResult = await client.callTool({
217 | name: "createDashboard",
218 | arguments: {
219 | name: `Dashboard-${Date.now()}`,
220 | description: "Test dashboard created by MCP tool-test"
221 | }
222 | });
223 | console.log("createDashboardResult (raw):", createDashboardResult);
224 | let createDashboardObj = createDashboardResult;
225 | if (
226 | createDashboardObj.content &&
227 | Array.isArray(createDashboardObj.content) &&
228 | typeof createDashboardObj.content[0]?.text === 'string'
229 | ) {
230 | createDashboardObj = JSON.parse(createDashboardObj.content[0].text);
231 | console.log("createDashboardResult (parsed):", createDashboardObj);
232 | }
233 | console.log("createDashboard:", createDashboardObj.id ? "✅" : "❌", createDashboardObj.id || "Unknown");
234 |
235 | // === Confluence Tools ===
236 | console.log("\n--- Confluence Tool Tests ---");
237 | // const confluenceSpaceKey = "AWA1";
238 | // let spaceId: string | null = null;
239 | // let parentId: string | null = null;
240 | // Lấy đúng spaceId (số) từ resource confluence://spaces/AWA1
241 | // try {
242 | // const spaceResult = await client.readResource({ uri: `confluence://spaces/${confluenceSpaceKey}` });
243 | // if (spaceResult.contents && spaceResult.contents[0].text) {
244 | // const data = JSON.parse(String(spaceResult.contents[0].text));
245 | // console.log("spaceResult data:", data);
246 | // spaceId = data.id || data.spaceId || (data.space && data.space.id) || null;
247 | // console.log(`Using spaceId for createPage: ${spaceId}`);
248 | // }
249 | // } catch (e) {
250 | // console.log("Error fetching spaceId:", e instanceof Error ? e.message : String(e));
251 | // }
252 | // Sử dụng trực tiếp spaceId số
253 | const confluenceSpaceId = "19464200";
254 | let spaceId: string | null = confluenceSpaceId;
255 | let parentId: string | null = null;
256 | // Lấy parentId là page đầu tiên trong resource confluence://spaces/19464200/pages
257 | try {
258 | const pagesResult = await client.readResource({ uri: `confluence://spaces/${confluenceSpaceId}/pages` });
259 | if (pagesResult.contents && pagesResult.contents[0].text) {
260 | const data = JSON.parse(String(pagesResult.contents[0].text));
261 | if (data.pages && data.pages.length > 0) {
262 | parentId = data.pages[0].id;
263 | console.log(`Using parentId for createPage: ${parentId}`);
264 | }
265 | }
266 | } catch (e) {
267 | console.log("Error fetching parentId:", e instanceof Error ? e.message : String(e));
268 | }
269 | const newPageTitle = `Test Page ${new Date().toLocaleString()}`;
270 | let newPageId: string | null = null;
271 | if (spaceId && parentId) {
272 | try {
273 | const createPageResult = await client.callTool({
274 | name: "createPage",
275 | arguments: {
276 | spaceId: spaceId,
277 | parentId: parentId,
278 | title: newPageTitle,
279 | content: "<p>This is a test page created by MCP tool-test</p>"
280 | }
281 | });
282 | console.log("createPageResult (raw):", createPageResult);
283 | let createPageObj = createPageResult;
284 | if (
285 | createPageObj.content &&
286 | Array.isArray(createPageObj.content) &&
287 | typeof createPageObj.content[0]?.text === 'string'
288 | ) {
289 | createPageObj = JSON.parse(createPageObj.content[0].text);
290 | console.log("createPageResult (parsed):", createPageObj);
291 | }
292 | console.log("createPage:", createPageObj.id ? "✅" : "❌", createPageObj.id || "Unknown");
293 | if (createPageObj && createPageObj.id) newPageId = String(createPageObj.id);
294 | } catch (e) {
295 | console.log("createPage: ❌", e instanceof Error ? e.message : String(e));
296 | }
297 | } else {
298 | console.log("Skip createPage: No spaceId or parentId available");
299 | }
300 | // 2. updatePage
301 | if (newPageId) {
302 | try {
303 | const updatePageResult = await client.callTool({
304 | name: "updatePage",
305 | arguments: {
306 | pageId: newPageId,
307 | title: `${newPageTitle} (Updated)`,
308 | content: "<p>This page has been updated by MCP tool-test</p>",
309 | version: 1
310 | }
311 | });
312 | console.log("updatePageResult (raw):", updatePageResult);
313 | let updatePageObj = updatePageResult;
314 | if (
315 | updatePageObj.content &&
316 | Array.isArray(updatePageObj.content) &&
317 | typeof updatePageObj.content[0]?.text === 'string'
318 | ) {
319 | updatePageObj = JSON.parse(updatePageObj.content[0].text);
320 | console.log("updatePageResult (parsed):", updatePageObj);
321 | }
322 | console.log("updatePage:", updatePageObj.success ? "✅" : "❌");
323 | } catch (e) {
324 | console.log("updatePage: ❌", e instanceof Error ? e.message : String(e));
325 | }
326 | }
327 | // 3. addComment
328 | if (newPageId) {
329 | try {
330 | const addCommentResult = await client.callTool({
331 | name: "addComment",
332 | arguments: {
333 | pageId: newPageId,
334 | content: "<p>This is a test comment added by MCP tool-test</p>"
335 | }
336 | });
337 | console.log("addCommentResult (raw):", addCommentResult);
338 | let addCommentObj = addCommentResult;
339 | if (
340 | addCommentObj.content &&
341 | Array.isArray(addCommentObj.content) &&
342 | typeof addCommentObj.content[0]?.text === 'string'
343 | ) {
344 | addCommentObj = JSON.parse(addCommentObj.content[0].text);
345 | console.log("addCommentResult (parsed):", addCommentObj);
346 | }
347 | console.log("addComment:", addCommentObj.id ? "✅" : "❌");
348 | } catch (e) {
349 | console.log("addComment: ❌", e instanceof Error ? e.message : String(e));
350 | }
351 | }
352 | // 4. updatePageTitle
353 | if (newPageId) {
354 | try {
355 | const updatePageTitleResult = await client.callTool({
356 | name: "updatePageTitle",
357 | arguments: {
358 | pageId: newPageId,
359 | title: `${newPageTitle} (Title Updated)`,
360 | version: 2
361 | }
362 | });
363 | console.log("updatePageTitleResult (raw):", updatePageTitleResult);
364 | let updatePageTitleObj = updatePageTitleResult;
365 | if (
366 | updatePageTitleObj.content &&
367 | Array.isArray(updatePageTitleObj.content) &&
368 | typeof updatePageTitleObj.content[0]?.text === 'string'
369 | ) {
370 | updatePageTitleObj = JSON.parse(updatePageTitleObj.content[0].text);
371 | console.log("updatePageTitleResult (parsed):", updatePageTitleObj);
372 | }
373 | console.log("updatePageTitle:", updatePageTitleObj.success ? "✅" : "❌");
374 | } catch (e) {
375 | console.log("updatePageTitle: ❌", e instanceof Error ? e.message : String(e));
376 | }
377 | }
378 | // 5. deletePage
379 | if (newPageId) {
380 | try {
381 | const deletePageResult = await client.callTool({
382 | name: "deletePage",
383 | arguments: {
384 | pageId: newPageId
385 | }
386 | });
387 | console.log("deletePageResult (raw):", deletePageResult);
388 | let deletePageObj = deletePageResult;
389 | if (
390 | deletePageObj.content &&
391 | Array.isArray(deletePageObj.content) &&
392 | typeof deletePageObj.content[0]?.text === 'string'
393 | ) {
394 | deletePageObj = JSON.parse(deletePageObj.content[0].text);
395 | console.log("deletePageResult (parsed):", deletePageObj);
396 | }
397 | console.log("deletePage:", deletePageObj.success ? "✅" : "❌");
398 | } catch (e) {
399 | console.log("deletePage: ❌", e instanceof Error ? e.message : String(e));
400 | }
401 | }
402 |
403 | // === Resource Test ===
404 | console.log("\n--- Resource Test ---");
405 | // Jira resource
406 | try {
407 | const issuesResult = await client.readResource({ uri: "jira://issues" });
408 | if (issuesResult.contents && issuesResult.contents[0].text) {
409 | const data = JSON.parse(String(issuesResult.contents[0].text));
410 | console.log("jira://issues response: total issues:", data.metadata?.total ?? data.issues?.length ?? "?");
411 | } else {
412 | console.log("No content returned for jira://issues");
413 | }
414 | } catch (e) {
415 | console.log("Error reading jira://issues:", e instanceof Error ? e.message : String(e));
416 | }
417 | // Confluence resource
418 | try {
419 | const pagesResult = await client.readResource({ uri: `confluence://spaces/${confluenceSpaceId}/pages` });
420 | if (pagesResult.contents && pagesResult.contents[0].text) {
421 | const data = JSON.parse(String(pagesResult.contents[0].text));
422 | console.log("confluence://spaces/19464200/pages response: total pages:", data.metadata?.total ?? data.pages?.length ?? "?");
423 | } else {
424 | console.log("No content returned for confluence://spaces/19464200/pages");
425 | }
426 | } catch (e) {
427 | console.log("Error reading confluence://spaces/19464200/pages:", e instanceof Error ? e.message : String(e));
428 | }
429 |
430 | // Summary
431 | console.log("\n=== Tool Test Summary ===");
432 | console.log("All important tools and resources have been tested!");
433 | await client.close();
434 | console.log("Connection closed successfully");
435 | } catch (error) {
436 | console.error("Error:", error);
437 | }
438 | }
439 |
440 | main();
```
--------------------------------------------------------------------------------
/src/utils/jira-tool-api-v3.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { AtlassianConfig, logger, createBasicHeaders } from './atlassian-api-base.js';
2 | import { normalizeAtlassianBaseUrl } from './atlassian-api-base.js';
3 | import { ApiError, ApiErrorType } from './error-handler.js';
4 |
5 | // Helper: Fetch Jira create metadata for a project/issueType
6 | export async function fetchJiraCreateMeta(
7 | config: AtlassianConfig,
8 | projectKey: string,
9 | issueType: string
10 | ): Promise<Record<string, any>> {
11 | const headers = createBasicHeaders(config.email, config.apiToken);
12 | const baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
13 | // Lấy metadata cho project và issueType (dùng name hoặc id)
14 | const url = `${baseUrl}/rest/api/3/issue/createmeta?projectKeys=${encodeURIComponent(projectKey)}&issuetypeNames=${encodeURIComponent(issueType)}&expand=projects.issuetypes.fields`;
15 | const response = await fetch(url, { headers, credentials: 'omit' });
16 | if (!response.ok) {
17 | const responseText = await response.text();
18 | logger.error(`Jira API error (createmeta, ${response.status}):`, responseText);
19 | throw new Error(`Jira API error (createmeta): ${response.status} ${responseText}`);
20 | }
21 | const meta = await response.json();
22 | // Trả về object các trường hợp lệ
23 | try {
24 | const fields = meta.projects?.[0]?.issuetypes?.[0]?.fields || {};
25 | return fields;
26 | } catch (e) {
27 | logger.error('Cannot parse createmeta fields', e);
28 | return {};
29 | }
30 | }
31 |
32 | // Create a new Jira issue (fix: chỉ gửi các trường có trong createmeta)
33 | export async function createIssue(
34 | config: AtlassianConfig,
35 | projectKey: string,
36 | summary: string,
37 | description?: string,
38 | issueType: string = "Task",
39 | additionalFields: Record<string, any> = {}
40 | ): Promise<any> {
41 | try {
42 | const headers = createBasicHeaders(config.email, config.apiToken);
43 | const baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
44 | const url = `${baseUrl}/rest/api/3/issue`;
45 |
46 | // Lấy metadata các trường hợp lệ
47 | const createmetaFields = await fetchJiraCreateMeta(config, projectKey, issueType);
48 |
49 | // Chỉ map các trường có trong createmeta
50 | const safeFields: Record<string, any> = {};
51 | let labelsToUpdate: string[] | undefined = undefined;
52 | for (const key of Object.keys(additionalFields)) {
53 | if (createmetaFields[key]) {
54 | safeFields[key] = additionalFields[key];
55 | } else {
56 | logger.warn(`[createIssue] Field '${key}' is not available on create screen for project ${projectKey} / issueType ${issueType}, will be ignored.`);
57 | // Nếu là labels thì lưu lại để update sau
58 | if (key === 'labels') {
59 | labelsToUpdate = additionalFields[key];
60 | }
61 | }
62 | }
63 |
64 | const data: {
65 | fields: {
66 | project: { key: string };
67 | summary: string;
68 | issuetype: { name: string };
69 | description?: any;
70 | [key: string]: any;
71 | };
72 | } = {
73 | fields: {
74 | project: { key: projectKey },
75 | summary: summary,
76 | issuetype: { name: issueType },
77 | ...safeFields,
78 | },
79 | };
80 |
81 | if (description && createmetaFields['description']) {
82 | data.fields.description = {
83 | type: "doc",
84 | version: 1,
85 | content: [
86 | {
87 | type: "paragraph",
88 | content: [
89 | {
90 | type: "text",
91 | text: description,
92 | },
93 | ],
94 | },
95 | ],
96 | };
97 | }
98 |
99 | logger.debug(`Creating issue in project ${projectKey}`);
100 | const curlCmd = `curl -X POST -H \"Content-Type: application/json\" -H \"Accept: application/json\" -H \"User-Agent: MCP-Atlassian-Server/1.0.0\" -u \"${config.email}:${config.apiToken.substring(0, 5)}...\" \"${url}\" -d '${JSON.stringify(data)}'`;
101 | logger.info(`Debug with curl: ${curlCmd}`);
102 |
103 | const response = await fetch(url, {
104 | method: "POST",
105 | headers,
106 | body: JSON.stringify(data),
107 | credentials: "omit",
108 | });
109 |
110 | if (!response.ok) {
111 | const statusCode = response.status;
112 | const responseText = await response.text();
113 | logger.error(`Jira API error (${statusCode}):`, responseText);
114 | if (statusCode === 400) {
115 | throw new ApiError(
116 | ApiErrorType.VALIDATION_ERROR,
117 | "Invalid issue data",
118 | statusCode,
119 | new Error(responseText)
120 | );
121 | } else if (statusCode === 401) {
122 | throw new ApiError(
123 | ApiErrorType.AUTHENTICATION_ERROR,
124 | "Unauthorized. Check your credentials.",
125 | statusCode,
126 | new Error(responseText)
127 | );
128 | } else if (statusCode === 403) {
129 | throw new ApiError(
130 | ApiErrorType.AUTHORIZATION_ERROR,
131 | "No permission to create issue",
132 | statusCode,
133 | new Error(responseText)
134 | );
135 | } else if (statusCode === 429) {
136 | throw new ApiError(
137 | ApiErrorType.RATE_LIMIT_ERROR,
138 | "API rate limit exceeded",
139 | statusCode,
140 | new Error(responseText)
141 | );
142 | } else {
143 | throw new ApiError(
144 | ApiErrorType.SERVER_ERROR,
145 | `Jira API error: ${responseText}`,
146 | statusCode,
147 | new Error(responseText)
148 | );
149 | }
150 | }
151 |
152 | const newIssue = await response.json();
153 |
154 | // Nếu không tạo được labels khi tạo issue, update lại ngay sau khi tạo
155 | if (labelsToUpdate && newIssue && newIssue.key) {
156 | logger.info(`[createIssue] Updating labels for issue ${newIssue.key} ngay sau khi tạo (do không khả dụng trên màn hình tạo issue)`);
157 | const updateUrl = `${baseUrl}/rest/api/3/issue/${newIssue.key}`;
158 | const updateData = { fields: { labels: labelsToUpdate } };
159 | const updateResponse = await fetch(updateUrl, {
160 | method: "PUT",
161 | headers,
162 | body: JSON.stringify(updateData),
163 | credentials: "omit",
164 | });
165 | if (!updateResponse.ok) {
166 | const updateText = await updateResponse.text();
167 | logger.error(`[createIssue] Failed to update labels for issue ${newIssue.key}:`, updateText);
168 | } else {
169 | logger.info(`[createIssue] Labels updated for issue ${newIssue.key}`);
170 | }
171 | }
172 |
173 | return newIssue;
174 | } catch (error) {
175 | logger.error(`Error creating issue:`, error);
176 | if (error instanceof ApiError) {
177 | throw error;
178 | }
179 | throw new ApiError(
180 | ApiErrorType.UNKNOWN_ERROR,
181 | `Error creating issue: ${error instanceof Error ? error.message : String(error)}`,
182 | 500,
183 | error instanceof Error ? error : new Error(String(error))
184 | );
185 | }
186 | }
187 |
188 | // Update a Jira issue
189 | export async function updateIssue(
190 | config: AtlassianConfig,
191 | issueIdOrKey: string,
192 | fields: Record<string, any>
193 | ): Promise<any> {
194 | try {
195 | const headers = createBasicHeaders(config.email, config.apiToken);
196 | const baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
197 | const url = `${baseUrl}/rest/api/3/issue/${issueIdOrKey}`;
198 | const data = { fields };
199 | logger.debug(`Updating issue ${issueIdOrKey}`);
200 | const curlCmd = `curl -X PUT -H "Content-Type: application/json" -H "Accept: application/json" -H "User-Agent: MCP-Atlassian-Server/1.0.0" -u "${config.email}:${config.apiToken.substring(0, 5)}..." "${url}" -d '${JSON.stringify(data)}'`;
201 | logger.info(`Debug with curl: ${curlCmd}`);
202 | const response = await fetch(url, {
203 | method: "PUT",
204 | headers,
205 | body: JSON.stringify(data),
206 | credentials: "omit",
207 | });
208 | if (!response.ok) {
209 | const statusCode = response.status;
210 | const responseText = await response.text();
211 | logger.error(`Jira API error (${statusCode}):`, responseText);
212 | if (statusCode === 400) {
213 | throw new ApiError(
214 | ApiErrorType.VALIDATION_ERROR,
215 | "Invalid update data",
216 | statusCode,
217 | new Error(responseText)
218 | );
219 | } else if (statusCode === 401) {
220 | throw new ApiError(
221 | ApiErrorType.AUTHENTICATION_ERROR,
222 | "Unauthorized. Check your credentials.",
223 | statusCode,
224 | new Error(responseText)
225 | );
226 | } else if (statusCode === 403) {
227 | throw new ApiError(
228 | ApiErrorType.AUTHORIZATION_ERROR,
229 | "No permission to update issue",
230 | statusCode,
231 | new Error(responseText)
232 | );
233 | } else if (statusCode === 404) {
234 | throw new ApiError(
235 | ApiErrorType.NOT_FOUND_ERROR,
236 | `Issue ${issueIdOrKey} does not exist`,
237 | statusCode,
238 | new Error(responseText)
239 | );
240 | } else if (statusCode === 429) {
241 | throw new ApiError(
242 | ApiErrorType.RATE_LIMIT_ERROR,
243 | "API rate limit exceeded",
244 | statusCode,
245 | new Error(responseText)
246 | );
247 | } else {
248 | throw new ApiError(
249 | ApiErrorType.SERVER_ERROR,
250 | `Jira API error: ${responseText}`,
251 | statusCode,
252 | new Error(responseText)
253 | );
254 | }
255 | }
256 | return {
257 | success: true,
258 | message: `Issue ${issueIdOrKey} updated successfully`,
259 | };
260 | } catch (error: any) {
261 | logger.error(`Error updating issue ${issueIdOrKey}:`, error);
262 | if (error instanceof ApiError) {
263 | throw error;
264 | }
265 | throw new ApiError(
266 | ApiErrorType.UNKNOWN_ERROR,
267 | `Error updating issue: ${error instanceof Error ? error.message : String(error)}`,
268 | 500,
269 | error instanceof Error ? error : new Error(String(error))
270 | );
271 | }
272 | }
273 |
274 | // Change issue status
275 | export async function transitionIssue(
276 | config: AtlassianConfig,
277 | issueIdOrKey: string,
278 | transitionId: string,
279 | comment?: string
280 | ): Promise<any> {
281 | try {
282 | const headers = createBasicHeaders(config.email, config.apiToken);
283 | const baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
284 | const url = `${baseUrl}/rest/api/3/issue/${issueIdOrKey}/transitions`;
285 | const data: any = {
286 | transition: {
287 | id: transitionId,
288 | },
289 | };
290 | if (comment) {
291 | data.update = {
292 | comment: [
293 | {
294 | add: {
295 | body: {
296 | type: "doc",
297 | version: 1,
298 | content: [
299 | {
300 | type: "paragraph",
301 | content: [
302 | {
303 | type: "text",
304 | text: comment,
305 | },
306 | ],
307 | },
308 | ],
309 | },
310 | },
311 | },
312 | ],
313 | };
314 | }
315 | logger.debug(`Transitioning issue ${issueIdOrKey} to status ID ${transitionId}`);
316 | const curlCmd = `curl -X POST -H "Content-Type: application/json" -H "Accept: application/json" -H "User-Agent: MCP-Atlassian-Server/1.0.0" -u "${config.email}:${config.apiToken.substring(0, 5)}..." "${url}" -d '${JSON.stringify(data)}'`;
317 | logger.info(`Debug with curl: ${curlCmd}`);
318 | const response = await fetch(url, {
319 | method: "POST",
320 | headers,
321 | body: JSON.stringify(data),
322 | credentials: "omit",
323 | });
324 | if (!response.ok) {
325 | const statusCode = response.status;
326 | const responseText = await response.text();
327 | logger.error(`Jira API error (${statusCode}):`, responseText);
328 | if (statusCode === 400) {
329 | throw new ApiError(
330 | ApiErrorType.VALIDATION_ERROR,
331 | "Invalid transition ID or not applicable",
332 | statusCode,
333 | new Error(responseText)
334 | );
335 | } else if (statusCode === 401) {
336 | throw new ApiError(
337 | ApiErrorType.AUTHENTICATION_ERROR,
338 | "Unauthorized. Check your credentials.",
339 | statusCode,
340 | new Error(responseText)
341 | );
342 | } else if (statusCode === 403) {
343 | throw new ApiError(
344 | ApiErrorType.AUTHORIZATION_ERROR,
345 | "No permission to transition issue",
346 | statusCode,
347 | new Error(responseText)
348 | );
349 | } else if (statusCode === 404) {
350 | throw new ApiError(
351 | ApiErrorType.NOT_FOUND_ERROR,
352 | `Issue ${issueIdOrKey} does not exist`,
353 | statusCode,
354 | new Error(responseText)
355 | );
356 | } else if (statusCode === 429) {
357 | throw new ApiError(
358 | ApiErrorType.RATE_LIMIT_ERROR,
359 | "API rate limit exceeded",
360 | statusCode,
361 | new Error(responseText)
362 | );
363 | } else {
364 | throw new ApiError(
365 | ApiErrorType.SERVER_ERROR,
366 | `Jira API error: ${responseText}`,
367 | statusCode,
368 | new Error(responseText)
369 | );
370 | }
371 | }
372 | return {
373 | success: true,
374 | message: `Issue ${issueIdOrKey} transitioned successfully`,
375 | transitionId,
376 | };
377 | } catch (error: any) {
378 | logger.error(`Error transitioning issue ${issueIdOrKey}:`, error);
379 | if (error instanceof ApiError) {
380 | throw error;
381 | }
382 | throw new ApiError(
383 | ApiErrorType.UNKNOWN_ERROR,
384 | `Error transitioning issue: ${error instanceof Error ? error.message : String(error)}`,
385 | 500,
386 | error instanceof Error ? error : new Error(String(error))
387 | );
388 | }
389 | }
390 |
391 | // Assign issue to a user
392 | export async function assignIssue(
393 | config: AtlassianConfig,
394 | issueIdOrKey: string,
395 | accountId: string | null
396 | ): Promise<any> {
397 | try {
398 | const headers = createBasicHeaders(config.email, config.apiToken);
399 | const baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
400 | const url = `${baseUrl}/rest/api/3/issue/${issueIdOrKey}/assignee`;
401 | const data = { accountId: accountId };
402 | logger.debug(`Assigning issue ${issueIdOrKey} to account ID ${accountId || "UNASSIGNED"}`);
403 | const curlCmd = `curl -X PUT -H "Content-Type: application/json" -H "Accept: application/json" -H "User-Agent: MCP-Atlassian-Server/1.0.0" -u "${config.email}:${config.apiToken.substring(0, 5)}..." "${url}" -d '${JSON.stringify(data)}'`;
404 | logger.info(`Debug with curl: ${curlCmd}`);
405 | const response = await fetch(url, {
406 | method: "PUT",
407 | headers,
408 | body: JSON.stringify(data),
409 | credentials: "omit",
410 | });
411 | if (!response.ok) {
412 | const statusCode = response.status;
413 | const responseText = await response.text();
414 | logger.error(`Jira API error (${statusCode}):`, responseText);
415 | if (statusCode === 400) {
416 | throw new ApiError(
417 | ApiErrorType.VALIDATION_ERROR,
418 | "Invalid data",
419 | statusCode,
420 | new Error(responseText)
421 | );
422 | } else if (statusCode === 401) {
423 | throw new ApiError(
424 | ApiErrorType.AUTHENTICATION_ERROR,
425 | "Unauthorized. Check your credentials.",
426 | statusCode,
427 | new Error(responseText)
428 | );
429 | } else if (statusCode === 403) {
430 | throw new ApiError(
431 | ApiErrorType.AUTHORIZATION_ERROR,
432 | "No permission to assign issue",
433 | statusCode,
434 | new Error(responseText)
435 | );
436 | } else if (statusCode === 404) {
437 | throw new ApiError(
438 | ApiErrorType.NOT_FOUND_ERROR,
439 | `Issue ${issueIdOrKey} does not exist`,
440 | statusCode,
441 | new Error(responseText)
442 | );
443 | } else if (statusCode === 429) {
444 | throw new ApiError(
445 | ApiErrorType.RATE_LIMIT_ERROR,
446 | "API rate limit exceeded",
447 | statusCode,
448 | new Error(responseText)
449 | );
450 | } else {
451 | throw new ApiError(
452 | ApiErrorType.SERVER_ERROR,
453 | `Jira API error: ${responseText}`,
454 | statusCode,
455 | new Error(responseText)
456 | );
457 | }
458 | }
459 | return {
460 | success: true,
461 | message: accountId
462 | ? `Issue ${issueIdOrKey} assigned successfully`
463 | : `Issue ${issueIdOrKey} unassigned successfully`,
464 | };
465 | } catch (error: any) {
466 | logger.error(`Error assigning issue ${issueIdOrKey}:`, error);
467 | if (error instanceof ApiError) {
468 | throw error;
469 | }
470 | throw new ApiError(
471 | ApiErrorType.UNKNOWN_ERROR,
472 | `Error assigning issue: ${error instanceof Error ? error.message : String(error)}`,
473 | 500,
474 | error instanceof Error ? error : new Error(String(error))
475 | );
476 | }
477 | }
478 |
479 | // Create a new dashboard
480 | export async function createDashboard(config: AtlassianConfig, data: { name: string, description?: string, sharePermissions?: any[] }): Promise<any> {
481 | try {
482 | const headers = createBasicHeaders(config.email, config.apiToken);
483 | const baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
484 | const url = `${baseUrl}/rest/api/3/dashboard`;
485 | logger.debug(`Creating dashboard: ${data.name}`);
486 | const response = await fetch(url, {
487 | method: 'POST',
488 | headers,
489 | body: JSON.stringify(data),
490 | credentials: 'omit',
491 | });
492 | if (!response.ok) {
493 | const responseText = await response.text();
494 | logger.error(`Jira API error (${response.status}):`, responseText);
495 | throw new Error(`Jira API error: ${response.status} ${responseText}`);
496 | }
497 | return await response.json();
498 | } catch (error) {
499 | logger.error(`Error creating dashboard:`, error);
500 | throw error;
501 | }
502 | }
503 |
504 | // Update a dashboard
505 | export async function updateDashboard(config: AtlassianConfig, dashboardId: string, data: { name?: string, description?: string, sharePermissions?: any[] }): Promise<any> {
506 | try {
507 | const headers = createBasicHeaders(config.email, config.apiToken);
508 | const baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
509 | const url = `${baseUrl}/rest/api/3/dashboard/${dashboardId}`;
510 | logger.debug(`Updating dashboard ${dashboardId}`);
511 | const response = await fetch(url, {
512 | method: 'PUT',
513 | headers,
514 | body: JSON.stringify(data),
515 | credentials: 'omit',
516 | });
517 | if (!response.ok) {
518 | const responseText = await response.text();
519 | logger.error(`Jira API error (${response.status}):`, responseText);
520 | throw new Error(`Jira API error: ${response.status} ${responseText}`);
521 | }
522 | return await response.json();
523 | } catch (error) {
524 | logger.error(`Error updating dashboard ${dashboardId}:`, error);
525 | throw error;
526 | }
527 | }
528 |
529 | // Add a gadget to a dashboard
530 | export async function addGadgetToDashboard(config: AtlassianConfig, dashboardId: string, data: { uri: string, color?: string, position?: any, title?: string, properties?: any }): Promise<any> {
531 | try {
532 | const headers = createBasicHeaders(config.email, config.apiToken);
533 | const baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
534 | const url = `${baseUrl}/rest/api/3/dashboard/${dashboardId}/gadget`;
535 | logger.debug(`Adding gadget to dashboard ${dashboardId}`);
536 | const response = await fetch(url, {
537 | method: 'POST',
538 | headers,
539 | body: JSON.stringify(data),
540 | credentials: 'omit',
541 | });
542 | if (!response.ok) {
543 | const responseText = await response.text();
544 | logger.error(`Jira API error (${response.status}):`, responseText);
545 | throw new Error(`Jira API error: ${response.status} ${responseText}`);
546 | }
547 | return await response.json();
548 | } catch (error) {
549 | logger.error(`Error adding gadget to dashboard ${dashboardId}:`, error);
550 | throw error;
551 | }
552 | }
553 |
554 | // Remove a gadget from a dashboard
555 | export async function removeGadgetFromDashboard(config: AtlassianConfig, dashboardId: string, gadgetId: string): Promise<any> {
556 | try {
557 | const headers = createBasicHeaders(config.email, config.apiToken);
558 | const baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
559 | const url = `${baseUrl}/rest/api/3/dashboard/${dashboardId}/gadget/${gadgetId}`;
560 | logger.debug(`Removing gadget ${gadgetId} from dashboard ${dashboardId}`);
561 | const response = await fetch(url, {
562 | method: 'DELETE',
563 | headers,
564 | credentials: 'omit',
565 | });
566 | if (!response.ok) {
567 | const responseText = await response.text();
568 | logger.error(`Jira API error (${response.status}):`, responseText);
569 | throw new Error(`Jira API error: ${response.status} ${responseText}`);
570 | }
571 | return { success: true };
572 | } catch (error) {
573 | logger.error(`Error removing gadget ${gadgetId} from dashboard ${dashboardId}:`, error);
574 | throw error;
575 | }
576 | }
577 |
578 | // Create a new filter
579 | export async function createFilter(
580 | config: AtlassianConfig,
581 | name: string,
582 | jql: string,
583 | description?: string,
584 | favourite?: boolean
585 | ): Promise<any> {
586 | try {
587 | const headers = createBasicHeaders(config.email, config.apiToken);
588 | const baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
589 | const url = `${baseUrl}/rest/api/3/filter`;
590 | const data: any = {
591 | name,
592 | jql,
593 | description: description || '',
594 | favourite: favourite !== undefined ? favourite : false
595 | };
596 | logger.debug(`Creating Jira filter: ${name}`);
597 | const response = await fetch(url, {
598 | method: 'POST',
599 | headers,
600 | body: JSON.stringify(data),
601 | credentials: 'omit',
602 | });
603 | if (!response.ok) {
604 | const responseText = await response.text();
605 | logger.error(`Jira API error (${response.status}):`, responseText);
606 | throw new Error(`Jira API error: ${response.status} ${responseText}`);
607 | }
608 | return await response.json();
609 | } catch (error) {
610 | logger.error(`Error creating Jira filter:`, error);
611 | throw error;
612 | }
613 | }
614 |
615 | // Update a filter
616 | export async function updateFilter(
617 | config: AtlassianConfig,
618 | filterId: string,
619 | updateData: { name?: string; jql?: string; description?: string; favourite?: boolean; sharePermissions?: any[] }
620 | ): Promise<any> {
621 | try {
622 | const headers = createBasicHeaders(config.email, config.apiToken);
623 | const baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
624 | const url = `${baseUrl}/rest/api/3/filter/${filterId}`;
625 | logger.debug(`Updating Jira filter ${filterId}`);
626 | // Chỉ build payload với các trường hợp lệ
627 | const allowedFields = ['name', 'jql', 'description', 'favourite', 'sharePermissions'] as const;
628 | type AllowedField = typeof allowedFields[number];
629 | const data: any = {};
630 | for (const key of allowedFields) {
631 | if (updateData[key as AllowedField] !== undefined) {
632 | data[key] = updateData[key as AllowedField];
633 | }
634 | }
635 | logger.debug('Payload for updateFilter:', JSON.stringify(data));
636 | const response = await fetch(url, {
637 | method: 'PUT',
638 | headers,
639 | body: JSON.stringify(data),
640 | credentials: 'omit',
641 | });
642 | if (!response.ok) {
643 | const responseText = await response.text();
644 | logger.error(`Jira API error (${response.status}):`, responseText);
645 | throw new Error(`Jira API error: ${response.status} ${responseText}`);
646 | }
647 | return await response.json();
648 | } catch (error) {
649 | logger.error(`Error updating Jira filter ${filterId}:`, error);
650 | throw error;
651 | }
652 | }
653 |
654 | // Delete a filter
655 | export async function deleteFilter(
656 | config: AtlassianConfig,
657 | filterId: string
658 | ): Promise<void> {
659 | try {
660 | const headers = createBasicHeaders(config.email, config.apiToken);
661 | const baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
662 | const url = `${baseUrl}/rest/api/3/filter/${filterId}`;
663 | logger.debug(`Deleting Jira filter ${filterId}`);
664 | const response = await fetch(url, {
665 | method: 'DELETE',
666 | headers,
667 | credentials: 'omit',
668 | });
669 | if (!response.ok) {
670 | const responseText = await response.text();
671 | logger.error(`Jira API error (${response.status}):`, responseText);
672 | throw new Error(`Jira API error: ${response.status} ${responseText}`);
673 | }
674 | } catch (error) {
675 | logger.error(`Error deleting Jira filter ${filterId}:`, error);
676 | throw error;
677 | }
678 | }
679 |
680 | // Lấy danh sách tất cả gadget có sẵn để thêm vào dashboard
681 | export async function getJiraAvailableGadgets(config: AtlassianConfig): Promise<any> {
682 | const headers = createBasicHeaders(config.email, config.apiToken);
683 | const baseUrl = normalizeAtlassianBaseUrl(config.baseUrl);
684 | const url = `${baseUrl}/rest/api/3/dashboard/gadgets`;
685 | const response = await fetch(url, { headers, credentials: 'omit' });
686 | if (!response.ok) {
687 | const responseText = await response.text();
688 | logger.error(`Jira API error (gadgets, ${response.status}):`, responseText);
689 | throw new Error(`Jira API error (gadgets): ${response.status} ${responseText}`);
690 | }
691 | return await response.json();
692 | }
```