@fluentdata-ai/tempo-mcp-server 1.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +287 -0
- package/build/config.js +57 -0
- package/build/index.js +174 -0
- package/build/jira.js +105 -0
- package/build/tools.js +390 -0
- package/build/types.js +78 -0
- package/build/utils.js +75 -0
- package/package.json +79 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Ivelin Ivanov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
[](https://mseep.ai/app/ivelin-web-tempo-mcp-server)
|
|
2
|
+
|
|
3
|
+
# Tempo MCP Server
|
|
4
|
+
|
|
5
|
+
A Model Context Protocol (MCP) server for managing Tempo worklogs in Jira. This server provides tools for tracking time and managing worklogs through Tempo's API, making it accessible through Claude, Cursor and other MCP-compatible clients.
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/@ivelin-web/tempo-mcp-server)
|
|
8
|
+
[](https://opensource.org/licenses/MIT)
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
- **Retrieve Worklogs**: Get all worklogs for a specific date range
|
|
13
|
+
- **Create Worklog**: Log time against Jira issues
|
|
14
|
+
- **Bulk Create**: Create multiple worklogs in a single operation
|
|
15
|
+
- **Edit Worklog**: Modify time spent, dates, and descriptions
|
|
16
|
+
- **Delete Worklog**: Remove existing worklogs
|
|
17
|
+
|
|
18
|
+
## System Requirements
|
|
19
|
+
|
|
20
|
+
- Node.js 18+ (LTS recommended)
|
|
21
|
+
- Jira Cloud instance
|
|
22
|
+
- Tempo API token
|
|
23
|
+
- Jira API token
|
|
24
|
+
|
|
25
|
+
## Usage Options
|
|
26
|
+
|
|
27
|
+
There are two main ways to use this MCP server:
|
|
28
|
+
|
|
29
|
+
1. **NPX (Recommended for most users)**: Run directly without installation
|
|
30
|
+
2. **Local Clone**: Clone the repository for development or customization
|
|
31
|
+
|
|
32
|
+
## Option 1: NPX Usage
|
|
33
|
+
|
|
34
|
+
The easiest way to use this server is via npx without installation:
|
|
35
|
+
|
|
36
|
+
### Connecting to Claude Desktop (NPX Method)
|
|
37
|
+
|
|
38
|
+
1. Open your MCP client configuration file:
|
|
39
|
+
|
|
40
|
+
- Claude Desktop (macOS): `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
41
|
+
- Claude Desktop (Windows): `%APPDATA%\Claude\claude_desktop_config.json`
|
|
42
|
+
|
|
43
|
+
2. Add the following configuration:
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"mcpServers": {
|
|
48
|
+
"Jira_Tempo": {
|
|
49
|
+
"command": "npx",
|
|
50
|
+
"args": ["-y", "@esalgado/tempo-mcp-server"],
|
|
51
|
+
"env": {
|
|
52
|
+
"TEMPO_API_TOKEN": "your_tempo_api_token_here",
|
|
53
|
+
"JIRA_API_TOKEN": "your_jira_api_token_here",
|
|
54
|
+
"JIRA_EMAIL": "your_email@example.com",
|
|
55
|
+
"JIRA_BASE_URL": "https://your-org.atlassian.net"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
3. Restart your Claude Desktop client
|
|
63
|
+
|
|
64
|
+
### One-Click Install for Cursor
|
|
65
|
+
|
|
66
|
+
[](https://cursor.com/install-mcp?name=Jira%20Tempo&config=eyJjb21tYW5kIjoibnB4IC15IEBpdmVsaW4td2ViL3RlbXBvLW1jcC1zZXJ2ZXIiLCJlbnYiOnsiVEVNUE9fQVBJX1RPS0VOIjoieW91cl90ZW1wb19hcGlfdG9rZW5faGVyZSIsIkpJUkFfQVBJX1RPS0VOIjoieW91cl9qaXJhX2FwaV90b2tlbl9oZXJlIiwiSklSQV9FTUFJTCI6InlvdXJfZW1haWxAZXhhbXBsZS5jb20iLCJKSVJBX0JBU0VfVVJMIjoiaHR0cHM6Ly95b3VyLW9yZy5hdGxhc3NpYW4ubmV0In19)
|
|
67
|
+
|
|
68
|
+
## Option 2: Local Repository Clone
|
|
69
|
+
|
|
70
|
+
### Installation
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# Clone the repository
|
|
74
|
+
git clone https://github.com/ivelin-web/tempo-mcp-server.git
|
|
75
|
+
cd tempo-mcp-server
|
|
76
|
+
|
|
77
|
+
# Install dependencies
|
|
78
|
+
npm install
|
|
79
|
+
|
|
80
|
+
# Build TypeScript files
|
|
81
|
+
npm run build
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Running Locally
|
|
85
|
+
|
|
86
|
+
There are two ways to run the server locally:
|
|
87
|
+
|
|
88
|
+
#### 1. Using the MCP Inspector (for development and debugging)
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
npm run inspect
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
#### 2. Using Node directly
|
|
95
|
+
|
|
96
|
+
You can run the server directly with Node by pointing to the built JavaScript file:
|
|
97
|
+
|
|
98
|
+
### Connecting to Claude Desktop (Local Method)
|
|
99
|
+
|
|
100
|
+
1. Open your MCP client configuration file
|
|
101
|
+
2. Add the following configuration:
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{
|
|
105
|
+
"mcpServers": {
|
|
106
|
+
"Jira_Tempo": {
|
|
107
|
+
"command": "node",
|
|
108
|
+
"args": ["/ABSOLUTE/PATH/TO/tempo-mcp-server/build/index.js"],
|
|
109
|
+
"env": {
|
|
110
|
+
"TEMPO_API_TOKEN": "your_tempo_api_token_here",
|
|
111
|
+
"JIRA_API_TOKEN": "your_jira_api_token_here",
|
|
112
|
+
"JIRA_EMAIL": "your_email@example.com",
|
|
113
|
+
"JIRA_BASE_URL": "https://your-org.atlassian.net"
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
3. Restart your Claude Desktop client
|
|
121
|
+
|
|
122
|
+
## Getting API Tokens
|
|
123
|
+
|
|
124
|
+
1. **Tempo API Token**:
|
|
125
|
+
|
|
126
|
+
- Go to Tempo > Settings > API Integration
|
|
127
|
+
- Create a new API token with appropriate permissions
|
|
128
|
+
|
|
129
|
+
2. **Jira API Token**:
|
|
130
|
+
- Go to [Atlassian API tokens](https://id.atlassian.com/manage-profile/security/api-tokens)
|
|
131
|
+
- Create a new API token for your account
|
|
132
|
+
|
|
133
|
+
## Environment Variables
|
|
134
|
+
|
|
135
|
+
The server requires the following environment variables:
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
TEMPO_API_TOKEN # Your Tempo API token
|
|
139
|
+
JIRA_API_TOKEN # Your Jira API token
|
|
140
|
+
JIRA_EMAIL # Your Jira account email (required for basic auth)
|
|
141
|
+
JIRA_BASE_URL # Your Jira instance URL (e.g., https://your-org.atlassian.net)
|
|
142
|
+
JIRA_AUTH_TYPE # Optional: 'basic' (default) or 'bearer' for OAuth 2.0 tokens
|
|
143
|
+
JIRA_TEMPO_ACCOUNT_CUSTOM_FIELD_ID # Optional: Custom field ID for Tempo accounts
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
You can set these in your environment or provide them in the MCP client configuration.
|
|
147
|
+
|
|
148
|
+
### Authentication Types
|
|
149
|
+
|
|
150
|
+
The server supports two authentication methods for the Jira API:
|
|
151
|
+
|
|
152
|
+
#### Basic Authentication (default)
|
|
153
|
+
|
|
154
|
+
Uses email and API token. This is the traditional method:
|
|
155
|
+
|
|
156
|
+
```json
|
|
157
|
+
{
|
|
158
|
+
"env": {
|
|
159
|
+
"JIRA_API_TOKEN": "your_api_token",
|
|
160
|
+
"JIRA_EMAIL": "your_email@example.com",
|
|
161
|
+
"JIRA_AUTH_TYPE": "basic"
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
#### Bearer Token Authentication (OAuth 2.0)
|
|
167
|
+
|
|
168
|
+
For users who want to use OAuth 2.0 scoped tokens for enhanced security:
|
|
169
|
+
|
|
170
|
+
```json
|
|
171
|
+
{
|
|
172
|
+
"env": {
|
|
173
|
+
"JIRA_API_TOKEN": "your_oauth_access_token",
|
|
174
|
+
"JIRA_AUTH_TYPE": "bearer"
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Note: When using `bearer` auth, `JIRA_EMAIL` is not required as the user is identified from the token.
|
|
180
|
+
|
|
181
|
+
## Tempo Account Configuration
|
|
182
|
+
|
|
183
|
+
If your Tempo instance requires worklogs to be linked to accounts, set the custom field ID that contains the account information:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
JIRA_TEMPO_ACCOUNT_CUSTOM_FIELD_ID=10234
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
To find your custom field ID:
|
|
190
|
+
|
|
191
|
+
1. Go to Jira Settings → Issues → Custom Fields
|
|
192
|
+
2. Find your Tempo account field and note the ID from the URL or field configuration
|
|
193
|
+
|
|
194
|
+
## Available Tools
|
|
195
|
+
|
|
196
|
+
### retrieveWorklogs
|
|
197
|
+
|
|
198
|
+
Fetches worklogs for the configured user between start and end dates.
|
|
199
|
+
|
|
200
|
+
```
|
|
201
|
+
Parameters:
|
|
202
|
+
- startDate: String (YYYY-MM-DD)
|
|
203
|
+
- endDate: String (YYYY-MM-DD)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### createWorklog
|
|
207
|
+
|
|
208
|
+
Creates a new worklog for a specific Jira issue.
|
|
209
|
+
|
|
210
|
+
```
|
|
211
|
+
Parameters:
|
|
212
|
+
- issueKey: String (e.g., "PROJECT-123")
|
|
213
|
+
- timeSpentHours: Number (positive)
|
|
214
|
+
- date: String (YYYY-MM-DD)
|
|
215
|
+
- description: String (optional)
|
|
216
|
+
- startTime: String (HH:MM format, optional)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### bulkCreateWorklogs
|
|
220
|
+
|
|
221
|
+
Creates multiple worklogs in a single operation.
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
Parameters:
|
|
225
|
+
- worklogEntries: Array of {
|
|
226
|
+
issueKey: String
|
|
227
|
+
timeSpentHours: Number
|
|
228
|
+
date: String (YYYY-MM-DD)
|
|
229
|
+
description: String (optional)
|
|
230
|
+
startTime: String (HH:MM format, optional)
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### editWorklog
|
|
235
|
+
|
|
236
|
+
Modifies an existing worklog.
|
|
237
|
+
|
|
238
|
+
```
|
|
239
|
+
Parameters:
|
|
240
|
+
- worklogId: String
|
|
241
|
+
- timeSpentHours: Number (positive)
|
|
242
|
+
- description: String (optional)
|
|
243
|
+
- date: String (YYYY-MM-DD, optional)
|
|
244
|
+
- startTime: String (HH:MM format, optional)
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### deleteWorklog
|
|
248
|
+
|
|
249
|
+
Removes an existing worklog.
|
|
250
|
+
|
|
251
|
+
```
|
|
252
|
+
Parameters:
|
|
253
|
+
- worklogId: String
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## Project Structure
|
|
257
|
+
|
|
258
|
+
```
|
|
259
|
+
tempo-mcp-server/
|
|
260
|
+
├── src/ # Source code
|
|
261
|
+
│ ├── config.ts # Configuration management
|
|
262
|
+
│ ├── index.ts # MCP server implementation
|
|
263
|
+
│ ├── jira.ts # Jira API integration
|
|
264
|
+
│ ├── tools.ts # Tool implementations
|
|
265
|
+
│ ├── types.ts # TypeScript types and schemas
|
|
266
|
+
│ └── utils.ts # Utility functions
|
|
267
|
+
├── build/ # Compiled JavaScript (generated)
|
|
268
|
+
├── tsconfig.json # TypeScript configuration
|
|
269
|
+
└── package.json # Project metadata and scripts
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
## Troubleshooting
|
|
273
|
+
|
|
274
|
+
If you encounter issues:
|
|
275
|
+
|
|
276
|
+
1. Check that all environment variables are properly set
|
|
277
|
+
2. Verify your Jira and Tempo API tokens have the correct permissions
|
|
278
|
+
3. Check the console output for error messages
|
|
279
|
+
4. Try running with the inspector: `npm run inspect`
|
|
280
|
+
|
|
281
|
+
## License
|
|
282
|
+
|
|
283
|
+
[MIT](LICENSE)
|
|
284
|
+
|
|
285
|
+
## Credits
|
|
286
|
+
|
|
287
|
+
This server implements the [Model Context Protocol](https://modelcontextprotocol.io/) specification created by Anthropic.
|
package/build/config.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration manager for the MCP server
|
|
3
|
+
* Validates required environment variables and exports config settings
|
|
4
|
+
*/
|
|
5
|
+
import { envSchema } from './types.js';
|
|
6
|
+
import { ZodError } from 'zod';
|
|
7
|
+
import { config as loadEnv } from 'dotenv';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
10
|
+
// Load environment variables from .env file
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
const __dirname = path.dirname(__filename);
|
|
13
|
+
const envLoadResult = loadEnv({ path: path.resolve(__dirname, '../.env') });
|
|
14
|
+
if (envLoadResult.error) {
|
|
15
|
+
console.error('[WARN] Could not load .env file:', envLoadResult.error.message);
|
|
16
|
+
}
|
|
17
|
+
// Validate environment variables
|
|
18
|
+
function validateEnv() {
|
|
19
|
+
try {
|
|
20
|
+
// Parse and validate environment variables
|
|
21
|
+
return envSchema.parse(process.env);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
// Format and display validation errors
|
|
25
|
+
console.error('[ERROR] Environment validation failed:');
|
|
26
|
+
if (error instanceof ZodError) {
|
|
27
|
+
error.issues.forEach((err) => {
|
|
28
|
+
console.error(`- ${err.path.join('.')}: ${err.message}`);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
33
|
+
}
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// Get validated environment variables
|
|
38
|
+
const env = validateEnv();
|
|
39
|
+
// Application configuration with validated environment variables
|
|
40
|
+
const config = {
|
|
41
|
+
tempoApi: {
|
|
42
|
+
baseUrl: 'https://api.tempo.io/4',
|
|
43
|
+
token: env.TEMPO_API_TOKEN,
|
|
44
|
+
},
|
|
45
|
+
jiraApi: {
|
|
46
|
+
baseUrl: env.JIRA_BASE_URL,
|
|
47
|
+
token: env.JIRA_API_TOKEN,
|
|
48
|
+
email: env.JIRA_EMAIL,
|
|
49
|
+
authType: env.JIRA_AUTH_TYPE,
|
|
50
|
+
tempoAccountCustomFieldId: env.JIRA_TEMPO_ACCOUNT_CUSTOM_FIELD_ID || undefined,
|
|
51
|
+
},
|
|
52
|
+
server: {
|
|
53
|
+
name: 'tempo-mcp-server',
|
|
54
|
+
version: '1.0.0',
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
export default config;
|
package/build/index.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Tempo MCP Server
|
|
4
|
+
*
|
|
5
|
+
* A simple Model Context Protocol server for managing Tempo worklogs with TypeScript.
|
|
6
|
+
*/
|
|
7
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
8
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
9
|
+
import config from './config.js';
|
|
10
|
+
import * as tools from './tools.js';
|
|
11
|
+
import { retrieveWorklogsSchema, createWorklogSchema, } from './types.js';
|
|
12
|
+
// Create MCP server instance
|
|
13
|
+
const server = new McpServer({
|
|
14
|
+
name: config.server.name,
|
|
15
|
+
version: config.server.version,
|
|
16
|
+
}, {
|
|
17
|
+
capabilities: { logging: {} },
|
|
18
|
+
});
|
|
19
|
+
// Tool: retrieveWorklogs - fetch worklogs between two dates
|
|
20
|
+
server.registerTool('retrieveWorklogs', {
|
|
21
|
+
title: 'Retrieve Worklogs',
|
|
22
|
+
description: 'Retrieve worklogs for a specified date range.',
|
|
23
|
+
inputSchema: retrieveWorklogsSchema.shape,
|
|
24
|
+
}, async ({ startDate, endDate }) => {
|
|
25
|
+
try {
|
|
26
|
+
const result = await tools.retrieveWorklogs(startDate, endDate);
|
|
27
|
+
return {
|
|
28
|
+
content: result.content,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
console.error(`[ERROR] retrieveWorklogs failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
33
|
+
return {
|
|
34
|
+
content: [
|
|
35
|
+
{
|
|
36
|
+
type: 'text',
|
|
37
|
+
text: `Error retrieving worklogs: ${error instanceof Error ? error.message : String(error)}`,
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
isError: true,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
// Tool: createWorklog - create a single worklog entry
|
|
45
|
+
server.registerTool('createWorklog', {
|
|
46
|
+
title: 'Create Worklog',
|
|
47
|
+
description: 'Create a new worklog entry.',
|
|
48
|
+
inputSchema: createWorklogSchema.shape,
|
|
49
|
+
}, async ({ issueKey, timeSpentHours, date, description, startTime, remainingEstimateHours, }) => {
|
|
50
|
+
try {
|
|
51
|
+
const result = await tools.createWorklog(issueKey, timeSpentHours, date, description, startTime, remainingEstimateHours);
|
|
52
|
+
return {
|
|
53
|
+
content: result.content,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
console.error(`[ERROR] createWorklog failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
58
|
+
return {
|
|
59
|
+
content: [
|
|
60
|
+
{
|
|
61
|
+
type: 'text',
|
|
62
|
+
text: `Error creating worklog: ${error instanceof Error ? error.message : String(error)}`,
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
isError: true,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
// // Tool: bulkCreateWorklogs - create multiple worklog entries at once
|
|
70
|
+
// server.registerTool(
|
|
71
|
+
// 'bulkCreateWorklogs',
|
|
72
|
+
// bulkCreateWorklogsSchema.shape,
|
|
73
|
+
// async ({ worklogEntries }) => {
|
|
74
|
+
// try {
|
|
75
|
+
// const result = await tools.bulkCreateWorklogs(worklogEntries);
|
|
76
|
+
// return {
|
|
77
|
+
// content: result.content,
|
|
78
|
+
// };
|
|
79
|
+
// } catch (error) {
|
|
80
|
+
// console.error(
|
|
81
|
+
// `[ERROR] bulkCreateWorklogs failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
82
|
+
// );
|
|
83
|
+
// return {
|
|
84
|
+
// content: [
|
|
85
|
+
// {
|
|
86
|
+
// type: 'text',
|
|
87
|
+
// text: `Error creating multiple worklogs: ${error instanceof Error ? error.message : String(error)}`,
|
|
88
|
+
// },
|
|
89
|
+
// ],
|
|
90
|
+
// isError: true,
|
|
91
|
+
// };
|
|
92
|
+
// }
|
|
93
|
+
// },
|
|
94
|
+
// );
|
|
95
|
+
//
|
|
96
|
+
// // Tool: editWorklog - modify an existing worklog entry
|
|
97
|
+
// server.registerTool(
|
|
98
|
+
// 'editWorklog',
|
|
99
|
+
// editWorklogSchema.shape,
|
|
100
|
+
// async ({ worklogId, timeSpentHours, description, date, startTime }) => {
|
|
101
|
+
// try {
|
|
102
|
+
// const result = await tools.editWorklog(
|
|
103
|
+
// worklogId,
|
|
104
|
+
// timeSpentHours,
|
|
105
|
+
// description || null,
|
|
106
|
+
// date || null,
|
|
107
|
+
// startTime || undefined,
|
|
108
|
+
// );
|
|
109
|
+
// return {
|
|
110
|
+
// content: result.content,
|
|
111
|
+
// };
|
|
112
|
+
// } catch (error) {
|
|
113
|
+
// console.error(
|
|
114
|
+
// `[ERROR] editWorklog failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
115
|
+
// );
|
|
116
|
+
// return {
|
|
117
|
+
// content: [
|
|
118
|
+
// {
|
|
119
|
+
// type: 'text',
|
|
120
|
+
// text: `Error editing worklog: ${error instanceof Error ? error.message : String(error)}`,
|
|
121
|
+
// },
|
|
122
|
+
// ],
|
|
123
|
+
// isError: true,
|
|
124
|
+
// };
|
|
125
|
+
// }
|
|
126
|
+
// },
|
|
127
|
+
// );
|
|
128
|
+
//
|
|
129
|
+
// // Tool: deleteWorklog - remove an existing worklog entry
|
|
130
|
+
// server.registerTool(
|
|
131
|
+
// 'deleteWorklog',
|
|
132
|
+
// deleteWorklogSchema.shape,
|
|
133
|
+
// async ({ worklogId }) => {
|
|
134
|
+
// try {
|
|
135
|
+
// const result = await tools.deleteWorklog(worklogId);
|
|
136
|
+
// return {
|
|
137
|
+
// content: result.content,
|
|
138
|
+
// };
|
|
139
|
+
// } catch (error) {
|
|
140
|
+
// console.error(
|
|
141
|
+
// `[ERROR] deleteWorklog failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
142
|
+
// );
|
|
143
|
+
// return {
|
|
144
|
+
// content: [
|
|
145
|
+
// {
|
|
146
|
+
// type: 'text',
|
|
147
|
+
// text: `Error deleting worklog: ${error instanceof Error ? error.message : String(error)}`,
|
|
148
|
+
// },
|
|
149
|
+
// ],
|
|
150
|
+
// isError: true,
|
|
151
|
+
// };
|
|
152
|
+
// }
|
|
153
|
+
// },
|
|
154
|
+
// );
|
|
155
|
+
//
|
|
156
|
+
async function startServer() {
|
|
157
|
+
try {
|
|
158
|
+
const transport = new StdioServerTransport();
|
|
159
|
+
await server.connect(transport);
|
|
160
|
+
console.error('[INFO] MCP Server started successfully');
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
console.error(`[ERROR] Failed to start MCP Server: ${error instanceof Error ? error.message : String(error)}`);
|
|
164
|
+
if (error instanceof Error && error.stack) {
|
|
165
|
+
console.error(`[ERROR] Stack trace: ${error.stack}`);
|
|
166
|
+
}
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
startServer().catch((error) => {
|
|
171
|
+
console.error(`[ERROR] Unhandled exception: ${error instanceof Error ? error.message : String(error)}`);
|
|
172
|
+
process.exit(1);
|
|
173
|
+
});
|
|
174
|
+
export default server;
|
package/build/jira.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { issueIdSchema, idOrKeySchema, } from './types.js';
|
|
3
|
+
import config from './config.js';
|
|
4
|
+
// Build authorization header based on auth type
|
|
5
|
+
function getAuthHeader() {
|
|
6
|
+
if (config.jiraApi.authType === 'bearer') {
|
|
7
|
+
return `Bearer ${config.jiraApi.token}`;
|
|
8
|
+
}
|
|
9
|
+
// Basic auth (default)
|
|
10
|
+
return `Basic ${Buffer.from(`${config.jiraApi.email}:${config.jiraApi.token}`).toString('base64')}`;
|
|
11
|
+
}
|
|
12
|
+
// Jira API client with authentication
|
|
13
|
+
const jiraApi = axios.create({
|
|
14
|
+
baseURL: `${config.jiraApi.baseUrl}/rest/api/3`,
|
|
15
|
+
headers: {
|
|
16
|
+
Authorization: getAuthHeader(),
|
|
17
|
+
Accept: 'application/json',
|
|
18
|
+
'Content-Type': 'application/json',
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
// Standardized error handling for Jira API
|
|
22
|
+
function formatJiraError(error, context) {
|
|
23
|
+
if (axios.isAxiosError(error)) {
|
|
24
|
+
const statusCode = error.response?.status;
|
|
25
|
+
const message = error.response?.data?.message ||
|
|
26
|
+
error.response?.data?.errorMessages?.join(', ') ||
|
|
27
|
+
error.message;
|
|
28
|
+
return new Error(`${context}: ${statusCode} - ${message}`);
|
|
29
|
+
}
|
|
30
|
+
return new Error(`${context}: ${error.message}`);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Get user's account ID.
|
|
34
|
+
* - For Bearer auth: uses /myself endpoint
|
|
35
|
+
* - For Basic auth: searches by configured email
|
|
36
|
+
*/
|
|
37
|
+
export async function getCurrentUserAccountId() {
|
|
38
|
+
if (config.jiraApi.authType === 'bearer') {
|
|
39
|
+
// Bearer auth: get current user directly
|
|
40
|
+
const response = await jiraApi.get('/myself');
|
|
41
|
+
return response.data.accountId;
|
|
42
|
+
}
|
|
43
|
+
// Basic auth: search by email
|
|
44
|
+
const response = await jiraApi
|
|
45
|
+
.get('user/search', {
|
|
46
|
+
params: { query: config.jiraApi.email },
|
|
47
|
+
})
|
|
48
|
+
.catch((error) => {
|
|
49
|
+
throw new Error(`could not fetch user from jira: ${error}`);
|
|
50
|
+
});
|
|
51
|
+
const users = response.data;
|
|
52
|
+
if (!users || users.length === 0) {
|
|
53
|
+
throw new Error(`No user found with email: ${config.jiraApi.email}`);
|
|
54
|
+
}
|
|
55
|
+
// Find exact email match
|
|
56
|
+
const user = users.find((u) => u.emailAddress === config.jiraApi.email);
|
|
57
|
+
if (!user) {
|
|
58
|
+
throw new Error(`No exact match for email: ${config.jiraApi.email}`);
|
|
59
|
+
}
|
|
60
|
+
return user.accountId;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Get Jira issue key from issue ID
|
|
64
|
+
*/
|
|
65
|
+
export async function getIssueKeyById(issueId) {
|
|
66
|
+
try {
|
|
67
|
+
// Validate issue ID using the schema
|
|
68
|
+
const result = issueIdSchema().safeParse(issueId);
|
|
69
|
+
if (!result.success) {
|
|
70
|
+
throw new Error(result.error.issues[0].message || 'Issue ID validation failed');
|
|
71
|
+
}
|
|
72
|
+
const response = await jiraApi.get(`/issue/${issueId}`);
|
|
73
|
+
return response.data.key;
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
throw formatJiraError(error, `Failed to get issue key for ID ${issueId}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Get Jira issue from issue ID or key
|
|
81
|
+
*/
|
|
82
|
+
export async function getIssue(idOrKey) {
|
|
83
|
+
try {
|
|
84
|
+
// Validate issue ID using the schema
|
|
85
|
+
const result = idOrKeySchema().safeParse(idOrKey);
|
|
86
|
+
if (!result.success) {
|
|
87
|
+
throw new Error(result.error.issues[0].message || 'Issue identifier validation failed');
|
|
88
|
+
}
|
|
89
|
+
const response = await jiraApi.get(`/issue/${idOrKey}`);
|
|
90
|
+
// Find the Tempo account key
|
|
91
|
+
const tempoAccountId = config.jiraApi.tempoAccountCustomFieldId
|
|
92
|
+
? response.data.fields[`customfield_${config.jiraApi.tempoAccountCustomFieldId}`]?.id
|
|
93
|
+
: undefined;
|
|
94
|
+
const id = response.data.id;
|
|
95
|
+
const key = response.data.key;
|
|
96
|
+
return {
|
|
97
|
+
id,
|
|
98
|
+
key,
|
|
99
|
+
...(tempoAccountId ? { tempoAccountId } : {}),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
throw formatJiraError(error, `Failed to get issue for ${idOrKey}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
package/build/tools.js
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import config from './config.js';
|
|
3
|
+
import { getCurrentUserAccountId, getIssue } from './jira.js';
|
|
4
|
+
import { formatError, getIssueKeysMap, calculateEndTime } from './utils.js';
|
|
5
|
+
// API client for Tempo
|
|
6
|
+
const api = axios.create({
|
|
7
|
+
baseURL: config.tempoApi.baseUrl,
|
|
8
|
+
headers: {
|
|
9
|
+
Authorization: `Bearer ${config.tempoApi.token}`,
|
|
10
|
+
'Content-Type': 'application/json',
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
/**
|
|
14
|
+
* Retrieve worklogs for the configured user within a date range
|
|
15
|
+
*/
|
|
16
|
+
export async function retrieveWorklogs(startDate, endDate) {
|
|
17
|
+
try {
|
|
18
|
+
const accountId = await getCurrentUserAccountId();
|
|
19
|
+
// Fetch all pages of worklogs
|
|
20
|
+
let allWorklogs = [];
|
|
21
|
+
let nextUrl = null;
|
|
22
|
+
let isFirstRequest = true;
|
|
23
|
+
let pageCount = 0;
|
|
24
|
+
const MAX_PAGES = 500; // Safety limit to prevent infinite loops (25,000 worklogs max)
|
|
25
|
+
do {
|
|
26
|
+
if (pageCount >= MAX_PAGES) {
|
|
27
|
+
console.warn(`Reached maximum page limit (${MAX_PAGES}) while fetching worklogs`);
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
let response;
|
|
31
|
+
if (isFirstRequest) {
|
|
32
|
+
response = await api
|
|
33
|
+
.get(`/worklogs/user/${accountId}`, {
|
|
34
|
+
params: { from: startDate, to: endDate },
|
|
35
|
+
})
|
|
36
|
+
.catch((error) => {
|
|
37
|
+
if (error.response?.status === 401) {
|
|
38
|
+
throw new Error('Invalid API token. Please check your Tempo API token and try again.');
|
|
39
|
+
}
|
|
40
|
+
throw error;
|
|
41
|
+
});
|
|
42
|
+
isFirstRequest = false;
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
const expectedOrigin = new URL(config.tempoApi.baseUrl).origin;
|
|
46
|
+
const nextUrlOrigin = new URL(nextUrl).origin;
|
|
47
|
+
if (nextUrlOrigin !== expectedOrigin) {
|
|
48
|
+
throw new Error(`Invalid pagination URL: expected origin ${expectedOrigin}, got ${nextUrlOrigin}`);
|
|
49
|
+
}
|
|
50
|
+
response = await axios.get(nextUrl, {
|
|
51
|
+
headers: {
|
|
52
|
+
Authorization: `Bearer ${config.tempoApi.token}`,
|
|
53
|
+
'Content-Type': 'application/json',
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
const pageWorklogs = response.data.results || [];
|
|
58
|
+
allWorklogs = allWorklogs.concat(pageWorklogs);
|
|
59
|
+
// Check if there's a next page
|
|
60
|
+
nextUrl = response?.data?.metadata?.next || null;
|
|
61
|
+
pageCount++;
|
|
62
|
+
} while (nextUrl);
|
|
63
|
+
const worklogs = allWorklogs;
|
|
64
|
+
// If no worklogs found, return empty content
|
|
65
|
+
if (worklogs.length === 0) {
|
|
66
|
+
return {
|
|
67
|
+
content: [
|
|
68
|
+
{
|
|
69
|
+
type: 'text',
|
|
70
|
+
text: 'No worklogs found for the specified date range.',
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// Get issue keys for all worklogs
|
|
76
|
+
const issueIdToKeyMap = await getIssueKeysMap(worklogs);
|
|
77
|
+
// Format the response
|
|
78
|
+
const formattedContent = worklogs.map((worklog) => {
|
|
79
|
+
const tempoWorklogId = worklog.tempoWorklogId || 'Unknown';
|
|
80
|
+
const issueId = worklog.issue?.id || 'Unknown';
|
|
81
|
+
const issueKey = issueIdToKeyMap[issueId] || 'Unknown';
|
|
82
|
+
const description = worklog.description || 'No description';
|
|
83
|
+
const timeSpentHours = (worklog.timeSpentSeconds / 3600).toFixed(2);
|
|
84
|
+
const date = worklog.startDate || 'Unknown';
|
|
85
|
+
const startTime = worklog.startTime || '';
|
|
86
|
+
return {
|
|
87
|
+
type: 'text',
|
|
88
|
+
text: `TempoWorklogId: ${tempoWorklogId} | IssueKey: ${issueKey} | IssueId: ${issueId} | Date: ${date}${startTime ? ` | StartTime: ${startTime}` : ''} | Hours: ${timeSpentHours} | Description: ${description}`,
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
content: formattedContent,
|
|
93
|
+
metadata: {
|
|
94
|
+
totalCount: worklogs.length,
|
|
95
|
+
pagesProcessed: pageCount,
|
|
96
|
+
startDate,
|
|
97
|
+
endDate,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
return {
|
|
103
|
+
isError: true,
|
|
104
|
+
content: [
|
|
105
|
+
{
|
|
106
|
+
type: 'text',
|
|
107
|
+
text: `Error retrieving worklogs: ${formatError(error)}`,
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Create a new worklog
|
|
115
|
+
*/
|
|
116
|
+
export async function createWorklog(issueKey, timeSpentHours, date, description, startTime = undefined, remainingEstimateHours = undefined) {
|
|
117
|
+
try {
|
|
118
|
+
// Get issue ID and account ID
|
|
119
|
+
const issue = await getIssue(issueKey);
|
|
120
|
+
const accountId = await getCurrentUserAccountId();
|
|
121
|
+
const { id: issueId } = issue;
|
|
122
|
+
const account = await fetchTempoAccountFromIssue(issue);
|
|
123
|
+
const timeSpentSeconds = Math.round(timeSpentHours * 3600);
|
|
124
|
+
// Prepare payload
|
|
125
|
+
const payload = {
|
|
126
|
+
issueId: Number(issueId),
|
|
127
|
+
timeSpentSeconds,
|
|
128
|
+
billableSeconds: timeSpentSeconds,
|
|
129
|
+
startDate: date,
|
|
130
|
+
authorAccountId: accountId,
|
|
131
|
+
description: description || null,
|
|
132
|
+
...(startTime && { startTime: `${startTime}:00` }),
|
|
133
|
+
...(remainingEstimateHours !== undefined && {
|
|
134
|
+
remainingEstimateSeconds: Math.round(remainingEstimateHours * 3600),
|
|
135
|
+
}),
|
|
136
|
+
...(account && {
|
|
137
|
+
attributes: [{ key: '_Account_', value: account.key }],
|
|
138
|
+
}),
|
|
139
|
+
};
|
|
140
|
+
// Submit the worklog
|
|
141
|
+
const response = await api.post('/worklogs', payload);
|
|
142
|
+
// Calculate end time if start time is provided
|
|
143
|
+
let timeInfo = '';
|
|
144
|
+
if (startTime) {
|
|
145
|
+
const endTime = calculateEndTime(startTime, timeSpentHours);
|
|
146
|
+
timeInfo = ` starting at ${startTime} and ending at ${endTime}`;
|
|
147
|
+
}
|
|
148
|
+
const accountInfo = account ? ` with account '${account.name}'` : '';
|
|
149
|
+
return {
|
|
150
|
+
content: [
|
|
151
|
+
{
|
|
152
|
+
type: 'text',
|
|
153
|
+
text: `Worklog with ID ${response.data.tempoWorklogId} created successfully for ${issueKey}${accountInfo}. Time logged: ${timeSpentHours} hours on ${date}${timeInfo}`,
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
console.error(error);
|
|
160
|
+
return {
|
|
161
|
+
isError: true,
|
|
162
|
+
content: [
|
|
163
|
+
{
|
|
164
|
+
type: 'text',
|
|
165
|
+
text: `Failed to create worklog: ${formatError(error)}`,
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Create multiple worklogs
|
|
173
|
+
*/
|
|
174
|
+
export async function bulkCreateWorklogs(worklogEntries) {
|
|
175
|
+
try {
|
|
176
|
+
// Get user account ID
|
|
177
|
+
const authorAccountId = await getCurrentUserAccountId();
|
|
178
|
+
// Group entries by issue key
|
|
179
|
+
const entriesByIssueKey = {};
|
|
180
|
+
worklogEntries.forEach((entry) => {
|
|
181
|
+
if (!entriesByIssueKey[entry.issueKey]) {
|
|
182
|
+
entriesByIssueKey[entry.issueKey] = [];
|
|
183
|
+
}
|
|
184
|
+
entriesByIssueKey[entry.issueKey].push(entry);
|
|
185
|
+
});
|
|
186
|
+
const results = [];
|
|
187
|
+
const errors = [];
|
|
188
|
+
// Process each issue's entries
|
|
189
|
+
for (const [issueKey, entries] of Object.entries(entriesByIssueKey)) {
|
|
190
|
+
try {
|
|
191
|
+
const issue = await getIssue(issueKey);
|
|
192
|
+
const account = await fetchTempoAccountFromIssue(issue);
|
|
193
|
+
// Format entries for API
|
|
194
|
+
const formattedEntries = entries.map((entry) => ({
|
|
195
|
+
timeSpentSeconds: Math.round(entry.timeSpentHours * 3600),
|
|
196
|
+
startDate: entry.date,
|
|
197
|
+
authorAccountId,
|
|
198
|
+
description: entry.description || '',
|
|
199
|
+
...(entry.startTime && { startTime: `${entry.startTime}:00` }),
|
|
200
|
+
...(account && {
|
|
201
|
+
attributes: [{ key: '_Account_', value: account.key }],
|
|
202
|
+
}),
|
|
203
|
+
}));
|
|
204
|
+
const { id: issueId } = issue;
|
|
205
|
+
// Submit bulk request
|
|
206
|
+
const response = await api.post(`/worklogs/issue/${Number(issueId)}/bulk`, formattedEntries);
|
|
207
|
+
const createdWorklogs = response.data || [];
|
|
208
|
+
// Record results
|
|
209
|
+
entries.forEach((entry, i) => {
|
|
210
|
+
const created = createdWorklogs[i] || null;
|
|
211
|
+
// Calculate end time if startTime is provided
|
|
212
|
+
let endTime = undefined;
|
|
213
|
+
if (entry.startTime && created) {
|
|
214
|
+
endTime = calculateEndTime(entry.startTime, entry.timeSpentHours);
|
|
215
|
+
}
|
|
216
|
+
results.push({
|
|
217
|
+
issueKey,
|
|
218
|
+
timeSpentHours: entry.timeSpentHours,
|
|
219
|
+
date: entry.date,
|
|
220
|
+
worklogId: created?.tempoWorklogId || null,
|
|
221
|
+
success: !!created,
|
|
222
|
+
startTime: entry.startTime,
|
|
223
|
+
endTime,
|
|
224
|
+
account: account?.name,
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
const errorMessage = formatError(error);
|
|
230
|
+
// Record errors
|
|
231
|
+
entries.forEach((entry) => {
|
|
232
|
+
errors.push({
|
|
233
|
+
issueKey,
|
|
234
|
+
timeSpentHours: entry.timeSpentHours,
|
|
235
|
+
date: entry.date,
|
|
236
|
+
error: errorMessage,
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// Create content for response
|
|
242
|
+
const content = [];
|
|
243
|
+
const successCount = results.filter((r) => r.success).length;
|
|
244
|
+
// Add success messages
|
|
245
|
+
if (successCount > 0) {
|
|
246
|
+
content.push({
|
|
247
|
+
type: 'text',
|
|
248
|
+
text: `Successfully created ${successCount} worklogs:`,
|
|
249
|
+
});
|
|
250
|
+
results
|
|
251
|
+
.filter((r) => r.success)
|
|
252
|
+
.forEach((result) => {
|
|
253
|
+
let timeInfo = '';
|
|
254
|
+
if (result.startTime) {
|
|
255
|
+
timeInfo = ` starting at ${result.startTime}${result.endTime ? ` and ending at ${result.endTime}` : ''}`;
|
|
256
|
+
}
|
|
257
|
+
const accountInfo = result.account
|
|
258
|
+
? ` for account '${result.account}'`
|
|
259
|
+
: '';
|
|
260
|
+
content.push({
|
|
261
|
+
type: 'text',
|
|
262
|
+
text: `- Issue ${result.issueKey}: ${result.timeSpentHours} hours on ${result.date}${timeInfo}${accountInfo}`,
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
// Add error messages
|
|
267
|
+
if (errors.length > 0) {
|
|
268
|
+
content.push({
|
|
269
|
+
type: 'text',
|
|
270
|
+
text: `Failed to create ${errors.length} worklogs:`,
|
|
271
|
+
});
|
|
272
|
+
errors.forEach((error) => {
|
|
273
|
+
content.push({
|
|
274
|
+
type: 'text',
|
|
275
|
+
text: `- Issue ${error.issueKey}: ${error.timeSpentHours} hours on ${error.date}. Error: ${error.error}`,
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
content,
|
|
281
|
+
metadata: {
|
|
282
|
+
totalSuccess: successCount,
|
|
283
|
+
totalFailure: errors.length,
|
|
284
|
+
details: {
|
|
285
|
+
successes: results.filter((r) => r.success),
|
|
286
|
+
failures: errors,
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
isError: errors.length > 0 && successCount === 0,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
return {
|
|
294
|
+
isError: true,
|
|
295
|
+
content: [
|
|
296
|
+
{
|
|
297
|
+
type: 'text',
|
|
298
|
+
text: `Error processing bulk worklogs: ${formatError(error)}`,
|
|
299
|
+
},
|
|
300
|
+
],
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Edit an existing worklog
|
|
306
|
+
*/
|
|
307
|
+
export async function editWorklog(worklogId, timeSpentHours, description = null, date = null, startTime = undefined) {
|
|
308
|
+
try {
|
|
309
|
+
// Get current worklog
|
|
310
|
+
const response = await api.get(`/worklogs/${worklogId}`);
|
|
311
|
+
const worklog = response.data;
|
|
312
|
+
// Prepare update payload
|
|
313
|
+
const updatePayload = {
|
|
314
|
+
authorAccountId: worklog.author.accountId,
|
|
315
|
+
startDate: date || worklog.startDate,
|
|
316
|
+
timeSpentSeconds: Math.round(timeSpentHours * 3600),
|
|
317
|
+
billableSeconds: Math.round(timeSpentHours * 3600),
|
|
318
|
+
...(description !== null && { description }),
|
|
319
|
+
...(startTime && { startTime: `${startTime}:00` }),
|
|
320
|
+
};
|
|
321
|
+
// Update the worklog
|
|
322
|
+
await api.put(`/worklogs/${worklogId}`, updatePayload);
|
|
323
|
+
// Information about the update
|
|
324
|
+
let updateInfo = `Worklog updated successfully`;
|
|
325
|
+
// Calculate and show time info if we have a start time
|
|
326
|
+
if (startTime) {
|
|
327
|
+
const endTime = calculateEndTime(startTime, timeSpentHours);
|
|
328
|
+
updateInfo += `. Time logged: ${timeSpentHours} hours starting at ${startTime} and ending at ${endTime}`;
|
|
329
|
+
}
|
|
330
|
+
// Format response
|
|
331
|
+
return {
|
|
332
|
+
content: [
|
|
333
|
+
{
|
|
334
|
+
type: 'text',
|
|
335
|
+
text: updateInfo,
|
|
336
|
+
},
|
|
337
|
+
],
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
return {
|
|
342
|
+
isError: true,
|
|
343
|
+
content: [
|
|
344
|
+
{ type: 'text', text: `Failed to edit worklog: ${formatError(error)}` },
|
|
345
|
+
],
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Delete a worklog
|
|
351
|
+
*/
|
|
352
|
+
export async function deleteWorklog(worklogId) {
|
|
353
|
+
try {
|
|
354
|
+
// Get worklog details for the response
|
|
355
|
+
let worklogDetails = null;
|
|
356
|
+
try {
|
|
357
|
+
const response = await api.get(`/worklogs/${worklogId}`);
|
|
358
|
+
worklogDetails = response.data;
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
// Continue with deletion even if we can't get details
|
|
362
|
+
console.error(`Could not fetch worklog details: ${error.message}`);
|
|
363
|
+
}
|
|
364
|
+
// Delete the worklog
|
|
365
|
+
await api.delete(`/worklogs/${worklogId}`);
|
|
366
|
+
return {
|
|
367
|
+
content: [{ type: 'text', text: 'Worklog deleted successfully' }],
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
return {
|
|
372
|
+
isError: true,
|
|
373
|
+
content: [
|
|
374
|
+
{
|
|
375
|
+
type: 'text',
|
|
376
|
+
text: `Failed to delete worklog: ${formatError(error)}`,
|
|
377
|
+
},
|
|
378
|
+
],
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* @returns The tempo account associated with the issue via the Jira custom field, if any
|
|
384
|
+
*/
|
|
385
|
+
async function fetchTempoAccountFromIssue({ tempoAccountId, }) {
|
|
386
|
+
if (!tempoAccountId)
|
|
387
|
+
return undefined;
|
|
388
|
+
const response = await api.get(`/accounts/${tempoAccountId}`);
|
|
389
|
+
return response.data;
|
|
390
|
+
}
|
package/build/types.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
// Common validation schemas
|
|
3
|
+
export const dateSchema = () => z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be in YYYY-MM-DD format');
|
|
4
|
+
export const timeSchema = () => z
|
|
5
|
+
.string()
|
|
6
|
+
.regex(/^([01]\d|2[0-3]):([0-5]\d)$/, 'Time must be in HH:MM format');
|
|
7
|
+
export const issueKeySchema = () => z.string().min(1, 'Issue key cannot be empty');
|
|
8
|
+
export const issueIdSchema = () => z.union([
|
|
9
|
+
z.string().min(1, 'Issue ID cannot be empty'),
|
|
10
|
+
z.number().int().positive('Issue ID must be a positive integer'),
|
|
11
|
+
]);
|
|
12
|
+
export const idOrKeySchema = () => z.union([issueKeySchema(), issueIdSchema()]);
|
|
13
|
+
// Environment validation
|
|
14
|
+
export const envSchema = z
|
|
15
|
+
.object({
|
|
16
|
+
TEMPO_API_TOKEN: z.string().min(1, 'TEMPO_API_TOKEN is required'),
|
|
17
|
+
JIRA_BASE_URL: z.string().min(1, 'JIRA_BASE_URL is required'),
|
|
18
|
+
JIRA_API_TOKEN: z.string().min(1, 'JIRA_API_TOKEN is required'),
|
|
19
|
+
JIRA_EMAIL: z.string().optional(),
|
|
20
|
+
JIRA_AUTH_TYPE: z.enum(['basic', 'bearer']).optional().default('basic'),
|
|
21
|
+
JIRA_TEMPO_ACCOUNT_CUSTOM_FIELD_ID: z.string().optional(),
|
|
22
|
+
})
|
|
23
|
+
.refine((data) => data.JIRA_AUTH_TYPE === 'bearer' || data.JIRA_EMAIL, {
|
|
24
|
+
message: 'JIRA_EMAIL is required when using basic authentication',
|
|
25
|
+
});
|
|
26
|
+
// Worklog entry schema
|
|
27
|
+
export const worklogEntrySchema = z.object({
|
|
28
|
+
issueKey: issueKeySchema(),
|
|
29
|
+
timeSpentHours: z.number().positive('Time spent must be positive'),
|
|
30
|
+
date: dateSchema(),
|
|
31
|
+
description: z.string().optional(),
|
|
32
|
+
startTime: timeSchema().optional(),
|
|
33
|
+
});
|
|
34
|
+
function getToday() {
|
|
35
|
+
const d = new Date();
|
|
36
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
37
|
+
}
|
|
38
|
+
function getStartOfWeek() {
|
|
39
|
+
const d = new Date();
|
|
40
|
+
const day = d.getDay();
|
|
41
|
+
const diff = day === 0 ? -6 : 1 - day; // back to Monday
|
|
42
|
+
d.setDate(d.getDate() + diff);
|
|
43
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
44
|
+
}
|
|
45
|
+
// MCP tool schemas
|
|
46
|
+
export const retrieveWorklogsSchema = z.object({
|
|
47
|
+
startDate: dateSchema().default(getStartOfWeek),
|
|
48
|
+
endDate: dateSchema().default(getToday),
|
|
49
|
+
});
|
|
50
|
+
export const createWorklogSchema = z.object({
|
|
51
|
+
issueKey: issueKeySchema(),
|
|
52
|
+
timeSpentHours: z
|
|
53
|
+
.number()
|
|
54
|
+
.positive('Time spent must be positive')
|
|
55
|
+
.default(1.5),
|
|
56
|
+
date: dateSchema(),
|
|
57
|
+
description: z.string(),
|
|
58
|
+
startTime: timeSchema().optional().default('08:00'),
|
|
59
|
+
remainingEstimateHours: z
|
|
60
|
+
.number()
|
|
61
|
+
.nonnegative('Remaining estimate must be non-negative')
|
|
62
|
+
.optional(),
|
|
63
|
+
});
|
|
64
|
+
export const bulkCreateWorklogsSchema = z.object({
|
|
65
|
+
worklogEntries: z
|
|
66
|
+
.array(worklogEntrySchema)
|
|
67
|
+
.min(1, 'At least one worklog entry is required'),
|
|
68
|
+
});
|
|
69
|
+
export const editWorklogSchema = z.object({
|
|
70
|
+
worklogId: z.string().min(1, 'Worklog ID is required'),
|
|
71
|
+
timeSpentHours: z.number().positive('Time spent must be positive'),
|
|
72
|
+
description: z.string().optional().nullable(),
|
|
73
|
+
date: dateSchema().optional().nullable(),
|
|
74
|
+
startTime: timeSchema().optional(),
|
|
75
|
+
});
|
|
76
|
+
export const deleteWorklogSchema = z.object({
|
|
77
|
+
worklogId: z.string().min(1, 'Worklog ID is required'),
|
|
78
|
+
});
|
package/build/utils.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { getIssueKeyById } from './jira.js';
|
|
3
|
+
/**
|
|
4
|
+
* Standard error handling for API errors
|
|
5
|
+
* Extracts the most useful error message from Axios errors
|
|
6
|
+
*/
|
|
7
|
+
export function formatError(error) {
|
|
8
|
+
if (axios.isAxiosError(error)) {
|
|
9
|
+
const data = error.response?.data;
|
|
10
|
+
const status = error.response?.status;
|
|
11
|
+
const parts = [];
|
|
12
|
+
if (status)
|
|
13
|
+
parts.push(`[${status}]`);
|
|
14
|
+
if (data?.errors && Array.isArray(data.errors) && data.errors.length > 0) {
|
|
15
|
+
const errorMessages = data.errors.map((e) => e.message
|
|
16
|
+
? e.field
|
|
17
|
+
? `${e.field}: ${e.message}`
|
|
18
|
+
: e.message
|
|
19
|
+
: JSON.stringify(e));
|
|
20
|
+
parts.push(errorMessages.join('; '));
|
|
21
|
+
}
|
|
22
|
+
else if (data?.message) {
|
|
23
|
+
parts.push(data.message);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
parts.push(error.message);
|
|
27
|
+
}
|
|
28
|
+
return parts.join(' ');
|
|
29
|
+
}
|
|
30
|
+
return error.message;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Get issue keys for worklogs
|
|
34
|
+
* Maps Jira issue IDs to their corresponding issue keys
|
|
35
|
+
*/
|
|
36
|
+
export async function getIssueKeysMap(worklogs) {
|
|
37
|
+
// Extract unique issue IDs
|
|
38
|
+
const uniqueIssueIds = [
|
|
39
|
+
...new Set(worklogs.map((worklog) => worklog.issue?.id).filter((id) => id != null)),
|
|
40
|
+
];
|
|
41
|
+
if (uniqueIssueIds.length === 0)
|
|
42
|
+
return {};
|
|
43
|
+
// Create issue ID to key map
|
|
44
|
+
const issueIdToKeyMap = {};
|
|
45
|
+
// Fetch issue keys in parallel
|
|
46
|
+
await Promise.all(uniqueIssueIds.map(async (issueId) => {
|
|
47
|
+
try {
|
|
48
|
+
const issueKey = await getIssueKeyById(issueId);
|
|
49
|
+
issueIdToKeyMap[issueId] = issueKey;
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
console.error(`Could not get key for issue ID ${issueId}: ${error.message}`);
|
|
53
|
+
}
|
|
54
|
+
}));
|
|
55
|
+
return issueIdToKeyMap;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Calculate end time
|
|
59
|
+
* Calculates the end time based on the start time and hours spent
|
|
60
|
+
* @param startTime Time in format HH:MM
|
|
61
|
+
* @param hoursSpent Duration in hours (can be decimal)
|
|
62
|
+
* @returns End time in format HH:MM
|
|
63
|
+
*/
|
|
64
|
+
export function calculateEndTime(startTime, hoursSpent) {
|
|
65
|
+
// Parse the HH:MM format
|
|
66
|
+
const [hours, minutes] = startTime.split(':').map((num) => parseInt(num, 10));
|
|
67
|
+
// Create a Date object with today's date but with the given hours and minutes
|
|
68
|
+
const startTimeDate = new Date();
|
|
69
|
+
startTimeDate.setHours(hours, minutes, 0, 0);
|
|
70
|
+
// Add the duration in milliseconds
|
|
71
|
+
const endTimeDate = new Date(startTimeDate.getTime() + hoursSpent * 3600 * 1000);
|
|
72
|
+
// Format the end time as HH:MM
|
|
73
|
+
const endTime = `${endTimeDate.getHours().toString().padStart(2, '0')}:${endTimeDate.getMinutes().toString().padStart(2, '0')}`;
|
|
74
|
+
return endTime;
|
|
75
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fluentdata-ai/tempo-mcp-server",
|
|
3
|
+
"version": "1.3.3",
|
|
4
|
+
"description": "MCP server for managing Tempo worklogs in Jira",
|
|
5
|
+
"main": "build/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"tempo-mcp-server": "build/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"",
|
|
12
|
+
"start": "node build/index.js",
|
|
13
|
+
"dev": "tsx watch src/index.ts",
|
|
14
|
+
"inspect": "npx @modelcontextprotocol/inspector@latest tsx src/index.ts",
|
|
15
|
+
"prepare": "npm run build && husky",
|
|
16
|
+
"lint": "eslint",
|
|
17
|
+
"format": "prettier . --write",
|
|
18
|
+
"format:check": "prettier . --check"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"build",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"keywords": [
|
|
26
|
+
"mcp",
|
|
27
|
+
"tempo",
|
|
28
|
+
"jira",
|
|
29
|
+
"worklogs",
|
|
30
|
+
"time-tracking",
|
|
31
|
+
"claude",
|
|
32
|
+
"cursor",
|
|
33
|
+
"windsurf",
|
|
34
|
+
"cline",
|
|
35
|
+
"ai"
|
|
36
|
+
],
|
|
37
|
+
"author": "Ivelin Ivanov <ivelinivanov1999@gmail.com>",
|
|
38
|
+
"contributors": [
|
|
39
|
+
"Edgar Isai Salgado Cortez <edgarisaiwr@gmail.com>"
|
|
40
|
+
],
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/fluentdata-co/tempo-mcp-server.git"
|
|
45
|
+
},
|
|
46
|
+
"bugs": {
|
|
47
|
+
"url": "https://github.com/fluentdata-co/tempo-mcp-server/issues"
|
|
48
|
+
},
|
|
49
|
+
"homepage": "https://github.com/fluentdata-co/tempo-mcp-server#readme",
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
52
|
+
"axios": "^1.6.7",
|
|
53
|
+
"dotenv": "^17.3.1",
|
|
54
|
+
"zod": "^4.3.6"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@eslint/js": "^9.28.0",
|
|
58
|
+
"@types/node": "^20.11.0",
|
|
59
|
+
"eslint": "^9.28.0",
|
|
60
|
+
"eslint-config-prettier": "^10.1.5",
|
|
61
|
+
"globals": "^16.2.0",
|
|
62
|
+
"husky": "^9.1.7",
|
|
63
|
+
"lint-staged": "^16.1.0",
|
|
64
|
+
"prettier": "3.5.3",
|
|
65
|
+
"tsx": "^4.7.0",
|
|
66
|
+
"typescript": "^5.8.3",
|
|
67
|
+
"typescript-eslint": "^8.34.0"
|
|
68
|
+
},
|
|
69
|
+
"engines": {
|
|
70
|
+
"node": ">=18.0.0"
|
|
71
|
+
},
|
|
72
|
+
"publishConfig": {
|
|
73
|
+
"access": "public"
|
|
74
|
+
},
|
|
75
|
+
"lint-staged": {
|
|
76
|
+
"*.{js,ts}": "eslint --cache --fix --quiet",
|
|
77
|
+
"*": "prettier . --write"
|
|
78
|
+
}
|
|
79
|
+
}
|