@projora/mcp-server 1.0.0
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/README.md +131 -0
- package/build/index.d.ts +2 -0
- package/build/index.js +956 -0
- package/package.json +55 -0
package/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# @projora/mcp-server
|
|
2
|
+
|
|
3
|
+
A Model Context Protocol (MCP) server for [Projora](https://projora.app) task management. Integrates with Claude Code and OpenCode to manage tasks directly from your AI coding assistant.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **start_task** - Start working on a task (sets status to "In Progress", adds comment, returns full context)
|
|
8
|
+
- **complete_task** - Mark a task as done with an optional summary
|
|
9
|
+
- **get_task** - Get a specific task by its key (e.g., "PRJ-123")
|
|
10
|
+
- **list_projects** - List all projects
|
|
11
|
+
- **list_tasks** - List tasks with optional filters (project, status, priority, assignee)
|
|
12
|
+
- **my_tasks** - Get tasks assigned to you
|
|
13
|
+
- **overdue_tasks** - Get all overdue tasks
|
|
14
|
+
- **search_tasks** - Search for tasks and projects by keyword
|
|
15
|
+
- **update_task** - Update task fields (title, description, priority, assignee, due date)
|
|
16
|
+
- **update_task_status** - Update task status
|
|
17
|
+
- **add_comment** - Add a comment to a task
|
|
18
|
+
- **log_time** - Log time worked on a task
|
|
19
|
+
- **get_project** - Get project details including linked GitHub repository
|
|
20
|
+
- **get_config** - Get available statuses, priorities, and task types
|
|
21
|
+
- **dashboard_stats** - Get dashboard statistics
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx @projora/mcp-server
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or install globally:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install -g @projora/mcp-server
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Configuration
|
|
36
|
+
|
|
37
|
+
The server requires two environment variables:
|
|
38
|
+
|
|
39
|
+
- `PROJORA_GRAPHQL_URL` - Your Projora GraphQL endpoint (defaults to `https://api.projora.app/graphql`)
|
|
40
|
+
- `PROJORA_AUTH_TOKEN` - Your API token from Projora
|
|
41
|
+
|
|
42
|
+
Get your API token from **Settings > Integrations** in the Projora web app.
|
|
43
|
+
|
|
44
|
+
## Usage with Claude Code
|
|
45
|
+
|
|
46
|
+
Add to your Claude Code configuration (`~/.claude.json`):
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"projora": {
|
|
51
|
+
"command": "npx",
|
|
52
|
+
"args": ["-y", "@projora/mcp-server"],
|
|
53
|
+
"env": {
|
|
54
|
+
"PROJORA_AUTH_TOKEN": "your-api-token"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Usage with OpenCode
|
|
61
|
+
|
|
62
|
+
Add to your OpenCode configuration (`opencode.json`):
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"mcp": {
|
|
67
|
+
"projora": {
|
|
68
|
+
"type": "local",
|
|
69
|
+
"command": ["npx", "-y", "@projora/mcp-server"],
|
|
70
|
+
"enabled": true,
|
|
71
|
+
"environment": {
|
|
72
|
+
"PROJORA_AUTH_TOKEN": "your-api-token"
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
> Note: `PROJORA_GRAPHQL_URL` defaults to `https://api.projora.app/graphql`. Only set it if you're using a self-hosted instance.
|
|
80
|
+
|
|
81
|
+
## Example Prompts
|
|
82
|
+
|
|
83
|
+
Once configured, use prompts like:
|
|
84
|
+
|
|
85
|
+
- "Start working on task PRJ-123"
|
|
86
|
+
- "Show me my tasks"
|
|
87
|
+
- "What are the overdue tasks?"
|
|
88
|
+
- "Search for tasks related to authentication"
|
|
89
|
+
- "Mark task PRJ-123 as done with summary: Fixed the login bug"
|
|
90
|
+
- "Add a comment to PRJ-123: Need to review the test coverage"
|
|
91
|
+
- "Log 2 hours on task PRJ-123"
|
|
92
|
+
|
|
93
|
+
## Workflow Example
|
|
94
|
+
|
|
95
|
+
1. **Start a task**: "Use projora to start working on task PRJ-123"
|
|
96
|
+
- Status automatically changes to "In Progress"
|
|
97
|
+
- A comment is added noting work has started
|
|
98
|
+
- You get the full task context including description, GitHub repo, and suggested branch name
|
|
99
|
+
|
|
100
|
+
2. **Work on the task**: Make your changes, commit code, etc.
|
|
101
|
+
|
|
102
|
+
3. **Complete the task**: "Use projora to complete task PRJ-123 with summary: Implemented the new feature with tests"
|
|
103
|
+
- Status changes to "Done"
|
|
104
|
+
- A completion comment is added with your summary
|
|
105
|
+
|
|
106
|
+
## Development
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
# Clone the repo
|
|
110
|
+
git clone https://github.com/projora/projora.git
|
|
111
|
+
cd projora/mcp-server
|
|
112
|
+
|
|
113
|
+
# Install dependencies
|
|
114
|
+
npm install
|
|
115
|
+
|
|
116
|
+
# Build
|
|
117
|
+
npm run build
|
|
118
|
+
|
|
119
|
+
# Run locally
|
|
120
|
+
PROJORA_GRAPHQL_URL=http://localhost:8000/graphql PROJORA_AUTH_TOKEN=your-token npm start
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT
|
|
126
|
+
|
|
127
|
+
## Links
|
|
128
|
+
|
|
129
|
+
- [Projora](https://projora.app)
|
|
130
|
+
- [Documentation](https://projora.app/docs)
|
|
131
|
+
- [GitHub](https://github.com/projora/projora)
|
package/build/index.d.ts
ADDED
package/build/index.js
ADDED
|
@@ -0,0 +1,956 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
// Configuration - set via environment variables
|
|
6
|
+
const GRAPHQL_URL = process.env.PROJORA_GRAPHQL_URL || "https://api.projora.app/graphql";
|
|
7
|
+
const AUTH_TOKEN = process.env.PROJORA_AUTH_TOKEN || "";
|
|
8
|
+
// Create server instance
|
|
9
|
+
const server = new McpServer({
|
|
10
|
+
name: "projora",
|
|
11
|
+
version: "1.0.0",
|
|
12
|
+
});
|
|
13
|
+
// GraphQL query helper
|
|
14
|
+
async function graphqlQuery(query, variables = {}) {
|
|
15
|
+
const headers = {
|
|
16
|
+
"Content-Type": "application/json",
|
|
17
|
+
Accept: "application/json",
|
|
18
|
+
};
|
|
19
|
+
if (AUTH_TOKEN) {
|
|
20
|
+
headers["Authorization"] = `Bearer ${AUTH_TOKEN}`;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const response = await fetch(GRAPHQL_URL, {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers,
|
|
26
|
+
body: JSON.stringify({ query, variables }),
|
|
27
|
+
});
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
console.error(`HTTP error! status: ${response.status}`);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const result = (await response.json());
|
|
33
|
+
if (result.errors) {
|
|
34
|
+
console.error("GraphQL errors:", JSON.stringify(result.errors));
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return result.data ?? null;
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
console.error("Error making GraphQL request:", error);
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Format task for display
|
|
45
|
+
function formatTask(task) {
|
|
46
|
+
const lines = [
|
|
47
|
+
`[${task.taskKey}] ${task.title}`,
|
|
48
|
+
` Status: ${task.status?.name || "No status"}`,
|
|
49
|
+
` Priority: ${task.priority?.name || "No priority"}`,
|
|
50
|
+
` Project: ${task.project.name}`,
|
|
51
|
+
` List: ${task.taskList.name}`,
|
|
52
|
+
];
|
|
53
|
+
if (task.assignee) {
|
|
54
|
+
lines.push(` Assignee: ${task.assignee.name}`);
|
|
55
|
+
}
|
|
56
|
+
if (task.dueDate) {
|
|
57
|
+
lines.push(` Due: ${task.dueDate}${task.isOverdue ? " (OVERDUE)" : ""}`);
|
|
58
|
+
}
|
|
59
|
+
if (task.description) {
|
|
60
|
+
lines.push(` Description: ${task.description.substring(0, 200)}${task.description.length > 200 ? "..." : ""}`);
|
|
61
|
+
}
|
|
62
|
+
lines.push(` Comments: ${task.commentsCount}`);
|
|
63
|
+
return lines.join("\n");
|
|
64
|
+
}
|
|
65
|
+
// Register tools
|
|
66
|
+
// 1. List all projects
|
|
67
|
+
server.registerTool("list_projects", {
|
|
68
|
+
description: "List all projects in Projora",
|
|
69
|
+
inputSchema: {},
|
|
70
|
+
}, async () => {
|
|
71
|
+
const query = `
|
|
72
|
+
query ListProjects {
|
|
73
|
+
projects {
|
|
74
|
+
id
|
|
75
|
+
name
|
|
76
|
+
key
|
|
77
|
+
description
|
|
78
|
+
taskCount
|
|
79
|
+
openTaskCount
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
`;
|
|
83
|
+
const data = await graphqlQuery(query);
|
|
84
|
+
if (!data) {
|
|
85
|
+
return {
|
|
86
|
+
content: [{ type: "text", text: "Failed to fetch projects. Check your authentication token." }],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (data.projects.length === 0) {
|
|
90
|
+
return {
|
|
91
|
+
content: [{ type: "text", text: "No projects found." }],
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const projectList = data.projects
|
|
95
|
+
.map((p) => `[${p.key}] ${p.name} - ${p.openTaskCount}/${p.taskCount} open tasks\n ${p.description || "No description"}`)
|
|
96
|
+
.join("\n\n");
|
|
97
|
+
return {
|
|
98
|
+
content: [{ type: "text", text: `Projects:\n\n${projectList}` }],
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
// 2. List tasks with optional filters
|
|
102
|
+
server.registerTool("list_tasks", {
|
|
103
|
+
description: "List tasks in Projora with optional filters",
|
|
104
|
+
inputSchema: {
|
|
105
|
+
projectId: z.string().optional().describe("Filter by project ID"),
|
|
106
|
+
statusId: z.string().optional().describe("Filter by status ID"),
|
|
107
|
+
priorityId: z.string().optional().describe("Filter by priority ID"),
|
|
108
|
+
assigneeId: z.string().optional().describe("Filter by assignee ID"),
|
|
109
|
+
},
|
|
110
|
+
}, async ({ projectId, statusId, priorityId, assigneeId }) => {
|
|
111
|
+
const query = `
|
|
112
|
+
query ListTasks($projectId: ID, $statusId: ID, $priorityId: ID, $assigneeId: ID) {
|
|
113
|
+
tasks(projectId: $projectId, statusId: $statusId, priorityId: $priorityId, assigneeId: $assigneeId) {
|
|
114
|
+
id
|
|
115
|
+
taskKey
|
|
116
|
+
title
|
|
117
|
+
description
|
|
118
|
+
dueDate
|
|
119
|
+
isOverdue
|
|
120
|
+
commentsCount
|
|
121
|
+
createdAt
|
|
122
|
+
updatedAt
|
|
123
|
+
status { id name color }
|
|
124
|
+
priority { id name color }
|
|
125
|
+
taskType { id name }
|
|
126
|
+
assignee { id name email }
|
|
127
|
+
project { id name key }
|
|
128
|
+
taskList { id name }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
`;
|
|
132
|
+
const data = await graphqlQuery(query, {
|
|
133
|
+
projectId,
|
|
134
|
+
statusId,
|
|
135
|
+
priorityId,
|
|
136
|
+
assigneeId,
|
|
137
|
+
});
|
|
138
|
+
if (!data) {
|
|
139
|
+
return {
|
|
140
|
+
content: [{ type: "text", text: "Failed to fetch tasks. Check your authentication token." }],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (data.tasks.length === 0) {
|
|
144
|
+
return {
|
|
145
|
+
content: [{ type: "text", text: "No tasks found matching the criteria." }],
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const taskList = data.tasks.map(formatTask).join("\n\n---\n\n");
|
|
149
|
+
return {
|
|
150
|
+
content: [{ type: "text", text: `Found ${data.tasks.length} tasks:\n\n${taskList}` }],
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
// 3. Get a specific task by key (e.g., "PRJ-123")
|
|
154
|
+
server.registerTool("get_task", {
|
|
155
|
+
description: "Get a specific task by its key (e.g., 'PRJ-123')",
|
|
156
|
+
inputSchema: {
|
|
157
|
+
taskKey: z.string().describe("The task key (e.g., 'PRJ-123')"),
|
|
158
|
+
},
|
|
159
|
+
}, async ({ taskKey }) => {
|
|
160
|
+
const query = `
|
|
161
|
+
query GetTask($taskKey: String!) {
|
|
162
|
+
taskByKey(taskKey: $taskKey) {
|
|
163
|
+
id
|
|
164
|
+
taskKey
|
|
165
|
+
title
|
|
166
|
+
description
|
|
167
|
+
dueDate
|
|
168
|
+
isOverdue
|
|
169
|
+
commentsCount
|
|
170
|
+
createdAt
|
|
171
|
+
updatedAt
|
|
172
|
+
status { id name color }
|
|
173
|
+
priority { id name color }
|
|
174
|
+
taskType { id name }
|
|
175
|
+
assignee { id name email }
|
|
176
|
+
project { id name key }
|
|
177
|
+
taskList { id name }
|
|
178
|
+
comments {
|
|
179
|
+
id
|
|
180
|
+
body
|
|
181
|
+
user { name }
|
|
182
|
+
createdAt
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
`;
|
|
187
|
+
const data = await graphqlQuery(query, { taskKey });
|
|
188
|
+
if (!data || !data.taskByKey) {
|
|
189
|
+
return {
|
|
190
|
+
content: [{ type: "text", text: `Task '${taskKey}' not found.` }],
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
const task = data.taskByKey;
|
|
194
|
+
let output = formatTask(task);
|
|
195
|
+
if (task.comments && task.comments.length > 0) {
|
|
196
|
+
output += "\n\nComments:\n";
|
|
197
|
+
task.comments.forEach((comment) => {
|
|
198
|
+
output += `\n - ${comment.user.name} (${comment.createdAt}):\n ${comment.body}\n`;
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
content: [{ type: "text", text: output }],
|
|
203
|
+
};
|
|
204
|
+
});
|
|
205
|
+
// 4. Get my tasks (assigned to current user)
|
|
206
|
+
server.registerTool("my_tasks", {
|
|
207
|
+
description: "Get tasks assigned to the current user",
|
|
208
|
+
inputSchema: {
|
|
209
|
+
limit: z.number().optional().describe("Maximum number of tasks to return"),
|
|
210
|
+
},
|
|
211
|
+
}, async ({ limit }) => {
|
|
212
|
+
const query = `
|
|
213
|
+
query MyTasks($limit: Int) {
|
|
214
|
+
myTasks(limit: $limit) {
|
|
215
|
+
id
|
|
216
|
+
taskKey
|
|
217
|
+
title
|
|
218
|
+
description
|
|
219
|
+
dueDate
|
|
220
|
+
isOverdue
|
|
221
|
+
commentsCount
|
|
222
|
+
createdAt
|
|
223
|
+
updatedAt
|
|
224
|
+
status { id name color }
|
|
225
|
+
priority { id name color }
|
|
226
|
+
taskType { id name }
|
|
227
|
+
assignee { id name email }
|
|
228
|
+
project { id name key }
|
|
229
|
+
taskList { id name }
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
`;
|
|
233
|
+
const data = await graphqlQuery(query, { limit });
|
|
234
|
+
if (!data) {
|
|
235
|
+
return {
|
|
236
|
+
content: [{ type: "text", text: "Failed to fetch your tasks. Check your authentication token." }],
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
if (data.myTasks.length === 0) {
|
|
240
|
+
return {
|
|
241
|
+
content: [{ type: "text", text: "You have no tasks assigned to you." }],
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
const taskList = data.myTasks.map(formatTask).join("\n\n---\n\n");
|
|
245
|
+
return {
|
|
246
|
+
content: [{ type: "text", text: `Your ${data.myTasks.length} tasks:\n\n${taskList}` }],
|
|
247
|
+
};
|
|
248
|
+
});
|
|
249
|
+
// 5. Get overdue tasks
|
|
250
|
+
server.registerTool("overdue_tasks", {
|
|
251
|
+
description: "Get all overdue tasks",
|
|
252
|
+
inputSchema: {
|
|
253
|
+
limit: z.number().optional().describe("Maximum number of tasks to return"),
|
|
254
|
+
},
|
|
255
|
+
}, async ({ limit }) => {
|
|
256
|
+
const query = `
|
|
257
|
+
query OverdueTasks($limit: Int) {
|
|
258
|
+
overdueTasks(limit: $limit) {
|
|
259
|
+
id
|
|
260
|
+
taskKey
|
|
261
|
+
title
|
|
262
|
+
description
|
|
263
|
+
dueDate
|
|
264
|
+
isOverdue
|
|
265
|
+
commentsCount
|
|
266
|
+
createdAt
|
|
267
|
+
updatedAt
|
|
268
|
+
status { id name color }
|
|
269
|
+
priority { id name color }
|
|
270
|
+
taskType { id name }
|
|
271
|
+
assignee { id name email }
|
|
272
|
+
project { id name key }
|
|
273
|
+
taskList { id name }
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
`;
|
|
277
|
+
const data = await graphqlQuery(query, { limit });
|
|
278
|
+
if (!data) {
|
|
279
|
+
return {
|
|
280
|
+
content: [{ type: "text", text: "Failed to fetch overdue tasks. Check your authentication token." }],
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
if (data.overdueTasks.length === 0) {
|
|
284
|
+
return {
|
|
285
|
+
content: [{ type: "text", text: "No overdue tasks found." }],
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
const taskList = data.overdueTasks.map(formatTask).join("\n\n---\n\n");
|
|
289
|
+
return {
|
|
290
|
+
content: [{ type: "text", text: `${data.overdueTasks.length} overdue tasks:\n\n${taskList}` }],
|
|
291
|
+
};
|
|
292
|
+
});
|
|
293
|
+
// 6. Search tasks
|
|
294
|
+
server.registerTool("search_tasks", {
|
|
295
|
+
description: "Search for tasks and projects by keyword",
|
|
296
|
+
inputSchema: {
|
|
297
|
+
query: z.string().describe("Search query string"),
|
|
298
|
+
},
|
|
299
|
+
}, async ({ query: searchQuery }) => {
|
|
300
|
+
const query = `
|
|
301
|
+
query Search($query: String!) {
|
|
302
|
+
search(query: $query) {
|
|
303
|
+
tasks {
|
|
304
|
+
id
|
|
305
|
+
taskKey
|
|
306
|
+
title
|
|
307
|
+
description
|
|
308
|
+
dueDate
|
|
309
|
+
isOverdue
|
|
310
|
+
commentsCount
|
|
311
|
+
createdAt
|
|
312
|
+
updatedAt
|
|
313
|
+
status { id name color }
|
|
314
|
+
priority { id name color }
|
|
315
|
+
taskType { id name }
|
|
316
|
+
assignee { id name email }
|
|
317
|
+
project { id name key }
|
|
318
|
+
taskList { id name }
|
|
319
|
+
}
|
|
320
|
+
projects {
|
|
321
|
+
id
|
|
322
|
+
name
|
|
323
|
+
key
|
|
324
|
+
description
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
`;
|
|
329
|
+
const data = await graphqlQuery(query, { query: searchQuery });
|
|
330
|
+
if (!data) {
|
|
331
|
+
return {
|
|
332
|
+
content: [{ type: "text", text: "Failed to search. Check your authentication token." }],
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
let output = `Search results for "${searchQuery}":\n\n`;
|
|
336
|
+
if (data.search.projects.length > 0) {
|
|
337
|
+
output += `Projects (${data.search.projects.length}):\n`;
|
|
338
|
+
data.search.projects.forEach((p) => {
|
|
339
|
+
output += ` [${p.key}] ${p.name}\n`;
|
|
340
|
+
});
|
|
341
|
+
output += "\n";
|
|
342
|
+
}
|
|
343
|
+
if (data.search.tasks.length > 0) {
|
|
344
|
+
output += `Tasks (${data.search.tasks.length}):\n\n`;
|
|
345
|
+
output += data.search.tasks.map(formatTask).join("\n\n---\n\n");
|
|
346
|
+
}
|
|
347
|
+
if (data.search.projects.length === 0 && data.search.tasks.length === 0) {
|
|
348
|
+
output += "No results found.";
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
content: [{ type: "text", text: output }],
|
|
352
|
+
};
|
|
353
|
+
});
|
|
354
|
+
// 7. Get dashboard stats
|
|
355
|
+
server.registerTool("dashboard_stats", {
|
|
356
|
+
description: "Get dashboard statistics overview",
|
|
357
|
+
inputSchema: {},
|
|
358
|
+
}, async () => {
|
|
359
|
+
const query = `
|
|
360
|
+
query DashboardStats {
|
|
361
|
+
dashboardStats {
|
|
362
|
+
totalTasks
|
|
363
|
+
openTasks
|
|
364
|
+
inProgressTasks
|
|
365
|
+
completedTasks
|
|
366
|
+
overdueTasks
|
|
367
|
+
totalProjects
|
|
368
|
+
activeProjects
|
|
369
|
+
myTasksCount
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
`;
|
|
373
|
+
const data = await graphqlQuery(query);
|
|
374
|
+
if (!data) {
|
|
375
|
+
return {
|
|
376
|
+
content: [{ type: "text", text: "Failed to fetch dashboard stats. Check your authentication token." }],
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
const stats = data.dashboardStats;
|
|
380
|
+
const output = `Dashboard Statistics:
|
|
381
|
+
|
|
382
|
+
Tasks:
|
|
383
|
+
- Total: ${stats.totalTasks}
|
|
384
|
+
- Open: ${stats.openTasks}
|
|
385
|
+
- In Progress: ${stats.inProgressTasks}
|
|
386
|
+
- Completed: ${stats.completedTasks}
|
|
387
|
+
- Overdue: ${stats.overdueTasks}
|
|
388
|
+
- My Tasks: ${stats.myTasksCount}
|
|
389
|
+
|
|
390
|
+
Projects:
|
|
391
|
+
- Total: ${stats.totalProjects}
|
|
392
|
+
- Active: ${stats.activeProjects}`;
|
|
393
|
+
return {
|
|
394
|
+
content: [{ type: "text", text: output }],
|
|
395
|
+
};
|
|
396
|
+
});
|
|
397
|
+
// 8. Get statuses, priorities, and task types for reference
|
|
398
|
+
server.registerTool("get_config", {
|
|
399
|
+
description: "Get available statuses, priorities, and task types",
|
|
400
|
+
inputSchema: {},
|
|
401
|
+
}, async () => {
|
|
402
|
+
const query = `
|
|
403
|
+
query GetConfig {
|
|
404
|
+
statuses { id name color isClosed }
|
|
405
|
+
priorities { id name color level }
|
|
406
|
+
taskTypes { id name color icon }
|
|
407
|
+
}
|
|
408
|
+
`;
|
|
409
|
+
const data = await graphqlQuery(query);
|
|
410
|
+
if (!data) {
|
|
411
|
+
return {
|
|
412
|
+
content: [{ type: "text", text: "Failed to fetch configuration. Check your authentication token." }],
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
let output = "Available Configuration:\n\n";
|
|
416
|
+
output += "Statuses:\n";
|
|
417
|
+
data.statuses.forEach((s) => {
|
|
418
|
+
output += ` - ${s.name} (ID: ${s.id})${s.isClosed ? " [Closed]" : ""}\n`;
|
|
419
|
+
});
|
|
420
|
+
output += "\nPriorities:\n";
|
|
421
|
+
data.priorities.forEach((p) => {
|
|
422
|
+
output += ` - ${p.name} (ID: ${p.id}, Level: ${p.level})\n`;
|
|
423
|
+
});
|
|
424
|
+
output += "\nTask Types:\n";
|
|
425
|
+
data.taskTypes.forEach((t) => {
|
|
426
|
+
output += ` - ${t.name} (ID: ${t.id})\n`;
|
|
427
|
+
});
|
|
428
|
+
return {
|
|
429
|
+
content: [{ type: "text", text: output }],
|
|
430
|
+
};
|
|
431
|
+
});
|
|
432
|
+
// Helper function to find a status by name (case-insensitive partial match)
|
|
433
|
+
async function findStatusByName(statusName) {
|
|
434
|
+
const query = `
|
|
435
|
+
query GetStatuses {
|
|
436
|
+
statuses { id name }
|
|
437
|
+
}
|
|
438
|
+
`;
|
|
439
|
+
const data = await graphqlQuery(query);
|
|
440
|
+
if (!data)
|
|
441
|
+
return null;
|
|
442
|
+
const lowerName = statusName.toLowerCase();
|
|
443
|
+
return data.statuses.find(s => s.name.toLowerCase().includes(lowerName)) || null;
|
|
444
|
+
}
|
|
445
|
+
// Helper function to update task status
|
|
446
|
+
async function updateTaskStatusById(taskId, statusId) {
|
|
447
|
+
const mutation = `
|
|
448
|
+
mutation UpdateTaskStatus($id: ID!, $statusId: ID!) {
|
|
449
|
+
updateTask(id: $id, input: { statusId: $statusId }) {
|
|
450
|
+
id
|
|
451
|
+
taskKey
|
|
452
|
+
status { id name }
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
`;
|
|
456
|
+
const result = await graphqlQuery(mutation, {
|
|
457
|
+
id: taskId,
|
|
458
|
+
statusId,
|
|
459
|
+
});
|
|
460
|
+
return result?.updateTask || null;
|
|
461
|
+
}
|
|
462
|
+
// Helper function to add a comment to a task
|
|
463
|
+
async function addCommentToTask(taskId, body) {
|
|
464
|
+
const mutation = `
|
|
465
|
+
mutation CreateComment($taskId: ID!, $body: String!) {
|
|
466
|
+
createComment(taskId: $taskId, body: $body) {
|
|
467
|
+
id
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
`;
|
|
471
|
+
const result = await graphqlQuery(mutation, { taskId, body });
|
|
472
|
+
return !!result?.createComment;
|
|
473
|
+
}
|
|
474
|
+
// 9. Start working on a task - get full context and set status to In Progress
|
|
475
|
+
server.registerTool("start_task", {
|
|
476
|
+
description: "Start working on a task. This will: 1) Set the task status to 'In Progress', 2) Add a comment noting work has started, 3) Return comprehensive task details including description, comments, GitHub repo info, and suggested branch name.",
|
|
477
|
+
inputSchema: {
|
|
478
|
+
taskKey: z.string().describe("The task key (e.g., 'PRJ-123')"),
|
|
479
|
+
},
|
|
480
|
+
}, async ({ taskKey }) => {
|
|
481
|
+
const query = `
|
|
482
|
+
query StartTask($taskKey: String!) {
|
|
483
|
+
taskByKey(taskKey: $taskKey) {
|
|
484
|
+
id
|
|
485
|
+
taskKey
|
|
486
|
+
title
|
|
487
|
+
description
|
|
488
|
+
dueDate
|
|
489
|
+
isOverdue
|
|
490
|
+
estimatedHours
|
|
491
|
+
status { id name color }
|
|
492
|
+
priority { id name color }
|
|
493
|
+
taskType { id name }
|
|
494
|
+
assignee { id name email }
|
|
495
|
+
reporter { id name email }
|
|
496
|
+
project {
|
|
497
|
+
id
|
|
498
|
+
name
|
|
499
|
+
key
|
|
500
|
+
description
|
|
501
|
+
githubRepoOwner
|
|
502
|
+
githubRepoName
|
|
503
|
+
githubDefaultBranch
|
|
504
|
+
githubRepoFullName
|
|
505
|
+
githubRepoUrl
|
|
506
|
+
}
|
|
507
|
+
taskList { id name }
|
|
508
|
+
comments {
|
|
509
|
+
id
|
|
510
|
+
body
|
|
511
|
+
user { name }
|
|
512
|
+
createdAt
|
|
513
|
+
}
|
|
514
|
+
subTasks {
|
|
515
|
+
id
|
|
516
|
+
taskKey
|
|
517
|
+
title
|
|
518
|
+
status { name }
|
|
519
|
+
}
|
|
520
|
+
dependencies {
|
|
521
|
+
dependsOn { taskKey title status { name } }
|
|
522
|
+
type
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
`;
|
|
527
|
+
const data = await graphqlQuery(query, { taskKey });
|
|
528
|
+
if (!data || !data.taskByKey) {
|
|
529
|
+
return {
|
|
530
|
+
content: [{ type: "text", text: `Task '${taskKey}' not found.` }],
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
const task = data.taskByKey;
|
|
534
|
+
const project = task.project;
|
|
535
|
+
const previousStatus = task.status?.name || 'No status';
|
|
536
|
+
// Update status to "In Progress" if not already in progress or done
|
|
537
|
+
let statusUpdateMessage = "";
|
|
538
|
+
if (!task.status?.name?.toLowerCase().includes('progress') && !task.status?.name?.toLowerCase().includes('done')) {
|
|
539
|
+
const inProgressStatus = await findStatusByName('in progress');
|
|
540
|
+
if (inProgressStatus) {
|
|
541
|
+
const updated = await updateTaskStatusById(task.id, inProgressStatus.id);
|
|
542
|
+
if (updated) {
|
|
543
|
+
statusUpdateMessage = `\n**Status updated: ${previousStatus} → ${updated.status.name}**\n`;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
// Add a comment noting that work has started
|
|
548
|
+
const timestamp = new Date().toISOString().split('T')[0];
|
|
549
|
+
await addCommentToTask(task.id, `Started working on this task via Claude Code on ${timestamp}`);
|
|
550
|
+
// Generate suggested branch name
|
|
551
|
+
const slugifiedTitle = task.title
|
|
552
|
+
.toLowerCase()
|
|
553
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
554
|
+
.replace(/^-|-$/g, '')
|
|
555
|
+
.substring(0, 50);
|
|
556
|
+
const suggestedBranch = `feature/${task.taskKey.toLowerCase()}-${slugifiedTitle}`;
|
|
557
|
+
let output = `# Task: ${task.taskKey} - ${task.title}\n`;
|
|
558
|
+
if (statusUpdateMessage) {
|
|
559
|
+
output += statusUpdateMessage;
|
|
560
|
+
}
|
|
561
|
+
output += `\n`;
|
|
562
|
+
output += `## Overview\n`;
|
|
563
|
+
output += `- **Project**: ${project.name} (${project.key})\n`;
|
|
564
|
+
output += `- **Status**: ${task.status?.name || 'No status'}${statusUpdateMessage ? ' (just updated)' : ''}\n`;
|
|
565
|
+
output += `- **Priority**: ${task.priority?.name || 'No priority'}\n`;
|
|
566
|
+
output += `- **Type**: ${task.taskType?.name || 'No type'}\n`;
|
|
567
|
+
output += `- **Assignee**: ${task.assignee?.name || 'Unassigned'}\n`;
|
|
568
|
+
output += `- **Reporter**: ${task.reporter?.name || 'Unknown'}\n`;
|
|
569
|
+
if (task.dueDate) {
|
|
570
|
+
output += `- **Due Date**: ${task.dueDate}${task.isOverdue ? ' (OVERDUE)' : ''}\n`;
|
|
571
|
+
}
|
|
572
|
+
if (task.estimatedHours) {
|
|
573
|
+
output += `- **Estimate**: ${task.estimatedHours} hours\n`;
|
|
574
|
+
}
|
|
575
|
+
output += `- **List**: ${task.taskList.name}\n`;
|
|
576
|
+
if (task.description) {
|
|
577
|
+
output += `\n## Description\n${task.description}\n`;
|
|
578
|
+
}
|
|
579
|
+
// GitHub info
|
|
580
|
+
if (project.githubRepoFullName) {
|
|
581
|
+
output += `\n## GitHub Repository\n`;
|
|
582
|
+
output += `- **Repository**: ${project.githubRepoFullName}\n`;
|
|
583
|
+
output += `- **URL**: ${project.githubRepoUrl}\n`;
|
|
584
|
+
output += `- **Default Branch**: ${project.githubDefaultBranch}\n`;
|
|
585
|
+
output += `- **Suggested Branch**: \`${suggestedBranch}\`\n`;
|
|
586
|
+
output += `\nTo create a branch:\n\`\`\`bash\ngit checkout -b ${suggestedBranch}\n\`\`\`\n`;
|
|
587
|
+
}
|
|
588
|
+
// Sub-tasks
|
|
589
|
+
if (task.subTasks && task.subTasks.length > 0) {
|
|
590
|
+
output += `\n## Sub-tasks (${task.subTasks.length})\n`;
|
|
591
|
+
task.subTasks.forEach((sub) => {
|
|
592
|
+
const statusIcon = sub.status?.name === 'Done' ? '✓' : '○';
|
|
593
|
+
output += `- ${statusIcon} [${sub.taskKey}] ${sub.title}\n`;
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
// Dependencies
|
|
597
|
+
if (task.dependencies && task.dependencies.length > 0) {
|
|
598
|
+
output += `\n## Dependencies\n`;
|
|
599
|
+
task.dependencies.forEach((dep) => {
|
|
600
|
+
const status = dep.dependsOn.status?.name || 'No status';
|
|
601
|
+
output += `- ${dep.type === 'blocked_by' ? 'Blocked by' : 'Blocks'}: [${dep.dependsOn.taskKey}] ${dep.dependsOn.title} (${status})\n`;
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
// Recent comments
|
|
605
|
+
if (task.comments && task.comments.length > 0) {
|
|
606
|
+
output += `\n## Recent Comments (${task.comments.length})\n`;
|
|
607
|
+
const recentComments = task.comments.slice(0, 5);
|
|
608
|
+
recentComments.forEach((comment) => {
|
|
609
|
+
output += `\n**${comment.user.name}** (${comment.createdAt}):\n${comment.body}\n`;
|
|
610
|
+
});
|
|
611
|
+
if (task.comments.length > 5) {
|
|
612
|
+
output += `\n... and ${task.comments.length - 5} more comments\n`;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
output += `\n---\n`;
|
|
616
|
+
output += `**Tip:** When you're done, use \`complete_task\` to mark this task as Done.\n`;
|
|
617
|
+
return {
|
|
618
|
+
content: [{ type: "text", text: output }],
|
|
619
|
+
};
|
|
620
|
+
});
|
|
621
|
+
// 9b. Complete a task - mark as Done and add completion comment
|
|
622
|
+
server.registerTool("complete_task", {
|
|
623
|
+
description: "Mark a task as complete/done. This will: 1) Set the task status to 'Done', 2) Add a comment with an optional summary of what was accomplished.",
|
|
624
|
+
inputSchema: {
|
|
625
|
+
taskKey: z.string().describe("The task key (e.g., 'PRJ-123')"),
|
|
626
|
+
summary: z.string().optional().describe("Optional summary of what was accomplished"),
|
|
627
|
+
},
|
|
628
|
+
}, async ({ taskKey, summary }) => {
|
|
629
|
+
// Get the task
|
|
630
|
+
const getTaskQuery = `
|
|
631
|
+
query GetTaskId($taskKey: String!) {
|
|
632
|
+
taskByKey(taskKey: $taskKey) {
|
|
633
|
+
id
|
|
634
|
+
taskKey
|
|
635
|
+
title
|
|
636
|
+
status { id name }
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
`;
|
|
640
|
+
const taskData = await graphqlQuery(getTaskQuery, { taskKey });
|
|
641
|
+
if (!taskData || !taskData.taskByKey) {
|
|
642
|
+
return {
|
|
643
|
+
content: [{ type: "text", text: `Task '${taskKey}' not found.` }],
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
const task = taskData.taskByKey;
|
|
647
|
+
const previousStatus = task.status?.name || 'No status';
|
|
648
|
+
// Find the "Done" status
|
|
649
|
+
const doneStatus = await findStatusByName('done');
|
|
650
|
+
if (!doneStatus) {
|
|
651
|
+
return {
|
|
652
|
+
content: [{ type: "text", text: `Could not find a 'Done' status. Available statuses may have different names.` }],
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
// Update status to Done
|
|
656
|
+
const updated = await updateTaskStatusById(task.id, doneStatus.id);
|
|
657
|
+
if (!updated) {
|
|
658
|
+
return {
|
|
659
|
+
content: [{ type: "text", text: `Failed to update task status. Check your permissions.` }],
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
// Add completion comment
|
|
663
|
+
const timestamp = new Date().toISOString().split('T')[0];
|
|
664
|
+
let commentBody = `Task completed via Claude Code on ${timestamp}`;
|
|
665
|
+
if (summary) {
|
|
666
|
+
commentBody += `\n\n**Summary:**\n${summary}`;
|
|
667
|
+
}
|
|
668
|
+
await addCommentToTask(task.id, commentBody);
|
|
669
|
+
let output = `# Task Completed: ${task.taskKey}\n\n`;
|
|
670
|
+
output += `**${task.title}**\n\n`;
|
|
671
|
+
output += `Status updated: ${previousStatus} → ${updated.status.name}\n`;
|
|
672
|
+
if (summary) {
|
|
673
|
+
output += `\nSummary added to task comments.\n`;
|
|
674
|
+
}
|
|
675
|
+
return {
|
|
676
|
+
content: [{ type: "text", text: output }],
|
|
677
|
+
};
|
|
678
|
+
});
|
|
679
|
+
// 10. Update task status
|
|
680
|
+
server.registerTool("update_task_status", {
|
|
681
|
+
description: "Update the status of a task",
|
|
682
|
+
inputSchema: {
|
|
683
|
+
taskKey: z.string().describe("The task key (e.g., 'PRJ-123')"),
|
|
684
|
+
statusId: z.string().describe("The ID of the new status (use get_config to see available statuses)"),
|
|
685
|
+
},
|
|
686
|
+
}, async ({ taskKey, statusId }) => {
|
|
687
|
+
// First get the task ID
|
|
688
|
+
const getTaskQuery = `
|
|
689
|
+
query GetTaskId($taskKey: String!) {
|
|
690
|
+
taskByKey(taskKey: $taskKey) {
|
|
691
|
+
id
|
|
692
|
+
status { name }
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
`;
|
|
696
|
+
const taskData = await graphqlQuery(getTaskQuery, { taskKey });
|
|
697
|
+
if (!taskData || !taskData.taskByKey) {
|
|
698
|
+
return {
|
|
699
|
+
content: [{ type: "text", text: `Task '${taskKey}' not found.` }],
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
const mutation = `
|
|
703
|
+
mutation UpdateTaskStatus($id: ID!, $statusId: ID!) {
|
|
704
|
+
updateTask(id: $id, input: { statusId: $statusId }) {
|
|
705
|
+
id
|
|
706
|
+
taskKey
|
|
707
|
+
status { id name }
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
`;
|
|
711
|
+
const result = await graphqlQuery(mutation, {
|
|
712
|
+
id: taskData.taskByKey.id,
|
|
713
|
+
statusId,
|
|
714
|
+
});
|
|
715
|
+
if (!result) {
|
|
716
|
+
return {
|
|
717
|
+
content: [{ type: "text", text: `Failed to update task status. Check your permissions.` }],
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
const previousStatus = taskData.taskByKey.status?.name || 'None';
|
|
721
|
+
return {
|
|
722
|
+
content: [{ type: "text", text: `Updated ${result.updateTask.taskKey}: ${previousStatus} → ${result.updateTask.status.name}` }],
|
|
723
|
+
};
|
|
724
|
+
});
|
|
725
|
+
// 11. Add a comment to a task
|
|
726
|
+
server.registerTool("add_comment", {
|
|
727
|
+
description: "Add a comment to a task",
|
|
728
|
+
inputSchema: {
|
|
729
|
+
taskKey: z.string().describe("The task key (e.g., 'PRJ-123')"),
|
|
730
|
+
body: z.string().describe("The comment text"),
|
|
731
|
+
},
|
|
732
|
+
}, async ({ taskKey, body }) => {
|
|
733
|
+
// First get the task ID
|
|
734
|
+
const getTaskQuery = `
|
|
735
|
+
query GetTaskId($taskKey: String!) {
|
|
736
|
+
taskByKey(taskKey: $taskKey) {
|
|
737
|
+
id
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
`;
|
|
741
|
+
const taskData = await graphqlQuery(getTaskQuery, { taskKey });
|
|
742
|
+
if (!taskData || !taskData.taskByKey) {
|
|
743
|
+
return {
|
|
744
|
+
content: [{ type: "text", text: `Task '${taskKey}' not found.` }],
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
const mutation = `
|
|
748
|
+
mutation CreateComment($taskId: ID!, $body: String!) {
|
|
749
|
+
createComment(taskId: $taskId, body: $body) {
|
|
750
|
+
id
|
|
751
|
+
body
|
|
752
|
+
createdAt
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
`;
|
|
756
|
+
const result = await graphqlQuery(mutation, {
|
|
757
|
+
taskId: taskData.taskByKey.id,
|
|
758
|
+
body,
|
|
759
|
+
});
|
|
760
|
+
if (!result) {
|
|
761
|
+
return {
|
|
762
|
+
content: [{ type: "text", text: `Failed to add comment. Check your permissions.` }],
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
return {
|
|
766
|
+
content: [{ type: "text", text: `Comment added to ${taskKey} at ${result.createComment.createdAt}` }],
|
|
767
|
+
};
|
|
768
|
+
});
|
|
769
|
+
// 12. Log time on a task
|
|
770
|
+
server.registerTool("log_time", {
|
|
771
|
+
description: "Log time worked on a task",
|
|
772
|
+
inputSchema: {
|
|
773
|
+
taskKey: z.string().describe("The task key (e.g., 'PRJ-123')"),
|
|
774
|
+
minutes: z.number().describe("Duration in minutes"),
|
|
775
|
+
description: z.string().optional().describe("Optional description of work done"),
|
|
776
|
+
},
|
|
777
|
+
}, async ({ taskKey, minutes, description }) => {
|
|
778
|
+
// First get the task ID
|
|
779
|
+
const getTaskQuery = `
|
|
780
|
+
query GetTaskId($taskKey: String!) {
|
|
781
|
+
taskByKey(taskKey: $taskKey) {
|
|
782
|
+
id
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
`;
|
|
786
|
+
const taskData = await graphqlQuery(getTaskQuery, { taskKey });
|
|
787
|
+
if (!taskData || !taskData.taskByKey) {
|
|
788
|
+
return {
|
|
789
|
+
content: [{ type: "text", text: `Task '${taskKey}' not found.` }],
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
const now = new Date();
|
|
793
|
+
const startedAt = new Date(now.getTime() - minutes * 60 * 1000);
|
|
794
|
+
const mutation = `
|
|
795
|
+
mutation LogTime($taskId: ID!, $input: LogTimeInput!) {
|
|
796
|
+
logTime(taskId: $taskId, input: $input) {
|
|
797
|
+
id
|
|
798
|
+
durationMinutes
|
|
799
|
+
formattedDuration
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
`;
|
|
803
|
+
const result = await graphqlQuery(mutation, {
|
|
804
|
+
taskId: taskData.taskByKey.id,
|
|
805
|
+
input: {
|
|
806
|
+
description: description || null,
|
|
807
|
+
startedAt: startedAt.toISOString().replace('T', ' ').substring(0, 19),
|
|
808
|
+
endedAt: now.toISOString().replace('T', ' ').substring(0, 19),
|
|
809
|
+
},
|
|
810
|
+
});
|
|
811
|
+
if (!result) {
|
|
812
|
+
return {
|
|
813
|
+
content: [{ type: "text", text: `Failed to log time. Check your permissions.` }],
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
return {
|
|
817
|
+
content: [{ type: "text", text: `Logged ${result.logTime.formattedDuration} on ${taskKey}${description ? `: ${description}` : ''}` }],
|
|
818
|
+
};
|
|
819
|
+
});
|
|
820
|
+
// 13. Update task (general)
|
|
821
|
+
server.registerTool("update_task", {
|
|
822
|
+
description: "Update task fields (title, description, priority, assignee, due date, estimate)",
|
|
823
|
+
inputSchema: {
|
|
824
|
+
taskKey: z.string().describe("The task key (e.g., 'PRJ-123')"),
|
|
825
|
+
title: z.string().optional().describe("New title"),
|
|
826
|
+
description: z.string().optional().describe("New description"),
|
|
827
|
+
priorityId: z.string().optional().describe("New priority ID"),
|
|
828
|
+
assigneeId: z.string().optional().describe("New assignee user ID"),
|
|
829
|
+
dueDate: z.string().optional().describe("New due date (YYYY-MM-DD)"),
|
|
830
|
+
estimatedHours: z.number().optional().describe("Estimated hours"),
|
|
831
|
+
},
|
|
832
|
+
}, async ({ taskKey, title, description, priorityId, assigneeId, dueDate, estimatedHours }) => {
|
|
833
|
+
// First get the task ID
|
|
834
|
+
const getTaskQuery = `
|
|
835
|
+
query GetTaskId($taskKey: String!) {
|
|
836
|
+
taskByKey(taskKey: $taskKey) {
|
|
837
|
+
id
|
|
838
|
+
title
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
`;
|
|
842
|
+
const taskData = await graphqlQuery(getTaskQuery, { taskKey });
|
|
843
|
+
if (!taskData || !taskData.taskByKey) {
|
|
844
|
+
return {
|
|
845
|
+
content: [{ type: "text", text: `Task '${taskKey}' not found.` }],
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
const input = {};
|
|
849
|
+
if (title !== undefined)
|
|
850
|
+
input.title = title;
|
|
851
|
+
if (description !== undefined)
|
|
852
|
+
input.description = description;
|
|
853
|
+
if (priorityId !== undefined)
|
|
854
|
+
input.priorityId = priorityId;
|
|
855
|
+
if (assigneeId !== undefined)
|
|
856
|
+
input.assigneeId = assigneeId;
|
|
857
|
+
if (dueDate !== undefined)
|
|
858
|
+
input.dueDate = dueDate;
|
|
859
|
+
if (estimatedHours !== undefined)
|
|
860
|
+
input.estimatedHours = estimatedHours;
|
|
861
|
+
if (Object.keys(input).length === 0) {
|
|
862
|
+
return {
|
|
863
|
+
content: [{ type: "text", text: `No fields to update provided.` }],
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
const mutation = `
|
|
867
|
+
mutation UpdateTask($id: ID!, $input: UpdateTaskInput!) {
|
|
868
|
+
updateTask(id: $id, input: $input) {
|
|
869
|
+
id
|
|
870
|
+
taskKey
|
|
871
|
+
title
|
|
872
|
+
status { name }
|
|
873
|
+
priority { name }
|
|
874
|
+
assignee { name }
|
|
875
|
+
dueDate
|
|
876
|
+
estimatedHours
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
`;
|
|
880
|
+
const result = await graphqlQuery(mutation, {
|
|
881
|
+
id: taskData.taskByKey.id,
|
|
882
|
+
input,
|
|
883
|
+
});
|
|
884
|
+
if (!result) {
|
|
885
|
+
return {
|
|
886
|
+
content: [{ type: "text", text: `Failed to update task. Check your permissions.` }],
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
const updatedFields = Object.keys(input).join(', ');
|
|
890
|
+
return {
|
|
891
|
+
content: [{ type: "text", text: `Updated ${result.updateTask.taskKey}: ${updatedFields}` }],
|
|
892
|
+
};
|
|
893
|
+
});
|
|
894
|
+
// 14. Get project with GitHub info
|
|
895
|
+
server.registerTool("get_project", {
|
|
896
|
+
description: "Get project details including linked GitHub repository",
|
|
897
|
+
inputSchema: {
|
|
898
|
+
projectKey: z.string().describe("The project key (e.g., 'PRJ')"),
|
|
899
|
+
},
|
|
900
|
+
}, async ({ projectKey }) => {
|
|
901
|
+
const query = `
|
|
902
|
+
query GetProjects {
|
|
903
|
+
projects {
|
|
904
|
+
id
|
|
905
|
+
name
|
|
906
|
+
key
|
|
907
|
+
description
|
|
908
|
+
taskCount
|
|
909
|
+
openTaskCount
|
|
910
|
+
githubRepoOwner
|
|
911
|
+
githubRepoName
|
|
912
|
+
githubDefaultBranch
|
|
913
|
+
githubRepoFullName
|
|
914
|
+
githubRepoUrl
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
`;
|
|
918
|
+
const data = await graphqlQuery(query);
|
|
919
|
+
if (!data) {
|
|
920
|
+
return {
|
|
921
|
+
content: [{ type: "text", text: "Failed to fetch projects. Check your authentication token." }],
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
const project = data.projects.find(p => p.key.toLowerCase() === projectKey.toLowerCase());
|
|
925
|
+
if (!project) {
|
|
926
|
+
return {
|
|
927
|
+
content: [{ type: "text", text: `Project with key '${projectKey}' not found.` }],
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
let output = `# Project: ${project.name} (${project.key})\n\n`;
|
|
931
|
+
output += `**Tasks**: ${project.openTaskCount} open / ${project.taskCount} total\n`;
|
|
932
|
+
if (project.description) {
|
|
933
|
+
output += `\n## Description\n${project.description}\n`;
|
|
934
|
+
}
|
|
935
|
+
if (project.githubRepoFullName) {
|
|
936
|
+
output += `\n## GitHub Repository\n`;
|
|
937
|
+
output += `- **Repository**: [${project.githubRepoFullName}](${project.githubRepoUrl})\n`;
|
|
938
|
+
output += `- **Default Branch**: ${project.githubDefaultBranch}\n`;
|
|
939
|
+
}
|
|
940
|
+
else {
|
|
941
|
+
output += `\n*No GitHub repository linked*\n`;
|
|
942
|
+
}
|
|
943
|
+
return {
|
|
944
|
+
content: [{ type: "text", text: output }],
|
|
945
|
+
};
|
|
946
|
+
});
|
|
947
|
+
// Main function to run the server
|
|
948
|
+
async function main() {
|
|
949
|
+
const transport = new StdioServerTransport();
|
|
950
|
+
await server.connect(transport);
|
|
951
|
+
console.error("Projora MCP Server running on stdio");
|
|
952
|
+
}
|
|
953
|
+
main().catch((error) => {
|
|
954
|
+
console.error("Fatal error in main():", error);
|
|
955
|
+
process.exit(1);
|
|
956
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@projora/mcp-server",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for Projora task management - integrate with Claude Code and OpenCode to manage tasks, update statuses, and track work",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"projora-mcp": "./build/index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./build/index.js",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc && chmod 755 build/index.js",
|
|
12
|
+
"dev": "tsc --watch",
|
|
13
|
+
"start": "node build/index.js",
|
|
14
|
+
"prepublishOnly": "npm run build"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"build"
|
|
18
|
+
],
|
|
19
|
+
"keywords": [
|
|
20
|
+
"mcp",
|
|
21
|
+
"model-context-protocol",
|
|
22
|
+
"projora",
|
|
23
|
+
"task-management",
|
|
24
|
+
"claude",
|
|
25
|
+
"opencode",
|
|
26
|
+
"ai-coding",
|
|
27
|
+
"project-management"
|
|
28
|
+
],
|
|
29
|
+
"author": {
|
|
30
|
+
"name": "Projora",
|
|
31
|
+
"email": "support@projora.app",
|
|
32
|
+
"url": "https://projora.app"
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/projora/projora.git",
|
|
38
|
+
"directory": "mcp-server"
|
|
39
|
+
},
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/projora/projora/issues"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://projora.app",
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=18.0.0"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
49
|
+
"zod": "^3.23.0"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/node": "^20.0.0",
|
|
53
|
+
"typescript": "^5.0.0"
|
|
54
|
+
}
|
|
55
|
+
}
|