@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 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)
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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
+ }