1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5package mcp
6
7import (
8 "context"
9 "fmt"
10 "log/slog"
11
12 "github.com/mark3labs/mcp-go/mcp"
13 "github.com/mark3labs/mcp-go/server"
14
15 "git.sr.ht/~amolith/planning-mcp-server/internal/config"
16 "git.sr.ht/~amolith/planning-mcp-server/internal/planning"
17)
18
19// Server wraps the MCP server and implements planning tools
20type Server struct {
21 config *config.Config
22 logger *slog.Logger
23 planner *planning.Manager
24 server *server.MCPServer
25}
26
27// New creates a new MCP server
28func New(cfg *config.Config, logger *slog.Logger, planner *planning.Manager) (*Server, error) {
29 if cfg == nil {
30 return nil, fmt.Errorf("config cannot be nil")
31 }
32 if logger == nil {
33 return nil, fmt.Errorf("logger cannot be nil")
34 }
35 if planner == nil {
36 return nil, fmt.Errorf("planner cannot be nil")
37 }
38
39 s := &Server{
40 config: cfg,
41 logger: logger,
42 planner: planner,
43 }
44
45 // Create MCP server
46 mcpServer := server.NewMCPServer(
47 "planning-mcp-server",
48 "1.0.0",
49 server.WithToolCapabilities(true),
50 )
51
52 // Register tools
53 s.registerTools(mcpServer)
54
55 s.server = mcpServer
56 return s, nil
57}
58
59// registerTools registers all planning tools
60func (s *Server) registerTools(mcpServer *server.MCPServer) {
61 // Register project_management__set_goal tool
62 setGoalTool := mcp.NewTool("project_management__set_goal",
63 mcp.WithDescription("Set the initial project goal. Returns error if already set and encourages calling project_management__change_goal"),
64 mcp.WithString("title",
65 mcp.Required(),
66 mcp.Description("The goal title"),
67 ),
68 mcp.WithString("description",
69 mcp.Required(),
70 mcp.Description("The goal description"),
71 ),
72 )
73 mcpServer.AddTool(setGoalTool, s.handleSetGoal)
74
75 // Register project_management__change_goal tool
76 changeGoalTool := mcp.NewTool("project_management__change_goal",
77 mcp.WithDescription("Change an existing project goal. Only use if the operator explicitly requests clearing the board/list/goal and doing something else"),
78 mcp.WithString("title",
79 mcp.Required(),
80 mcp.Description("The new goal title"),
81 ),
82 mcp.WithString("description",
83 mcp.Required(),
84 mcp.Description("The new goal description"),
85 ),
86 mcp.WithString("reason",
87 mcp.Required(),
88 mcp.Description("The reason for changing the goal"),
89 ),
90 )
91 mcpServer.AddTool(changeGoalTool, s.handleChangeGoal)
92
93 // Register project_management__add_tasks tool
94 addTasksTool := mcp.NewTool("project_management__add_tasks",
95 mcp.WithDescription("Add one or more tasks to work on. Break tasks down into the smallest units of work possible. If there are more than one, use me to keep track of where you are in fulfilling the user's request. Call project_management__get_tasks often to stay on track."),
96 mcp.WithArray("tasks",
97 mcp.Required(),
98 mcp.Description("Array of tasks to add"),
99 mcp.Items(map[string]any{
100 "type": "object",
101 "properties": map[string]any{
102 "title": map[string]any{
103 "type": "string",
104 "description": "Task title",
105 },
106 "description": map[string]any{
107 "type": "string",
108 "description": "Task description (optional)",
109 },
110 },
111 "required": []string{"title"},
112 }),
113 ),
114 )
115 mcpServer.AddTool(addTasksTool, s.handleAddTasks)
116
117 // Register project_management__get_tasks tool
118 getTasksTool := mcp.NewTool("project_management__get_tasks",
119 mcp.WithDescription("Get task list with status indicators. Call this frequently to stay organized and track your progress. Prefer to call with 'all' or 'pending', only 'completed' if unsure, only 'cancelled' or 'failed' if the operator explicitly asks."),
120 mcp.WithString("status",
121 mcp.Description("Filter tasks by status: all, pending, in_progress, completed, cancelled, or failed (default: all)"),
122 ),
123 )
124 mcpServer.AddTool(getTasksTool, s.handleGetTasks)
125
126 // Register project_management__update_task_statuses tool
127 updateTasksTool := mcp.NewTool("project_management__update_task_statuses",
128 mcp.WithDescription("Update the status of one or more tasks. Never cancel tasks on your own. If something doesn't work or there's an error, mark it failed and decide whether to ask the operator for guidance or continue on your own. Usually prefer to ask the operator."),
129 mcp.WithArray("tasks",
130 mcp.Required(),
131 mcp.Description("Array of task updates"),
132 mcp.Items(map[string]any{
133 "type": "object",
134 "properties": map[string]any{
135 "task_id": map[string]any{
136 "type": "string",
137 "description": "The task ID to update",
138 },
139 "status": map[string]any{
140 "type": "string",
141 "description": "New status: pending, in_progress, completed, cancelled, or failed",
142 },
143 },
144 "required": []string{"task_id", "status"},
145 }),
146 ),
147 )
148 mcpServer.AddTool(updateTasksTool, s.handleUpdateTaskStatuses)
149
150 // Register project_management__delete_tasks tool
151 deleteTasksTool := mcp.NewTool("project_management__delete_tasks",
152 mcp.WithDescription("Delete one or more tasks by their IDs. Only use if the operator explicitly requests clearing the board/list/goal and doing something else. Otherwise, update statuses to 'cancelled', 'failed', etc. as appropriate. After deletion, respond with the resulting task list."),
153 mcp.WithArray("task_ids",
154 mcp.Required(),
155 mcp.Description("Array of task IDs to delete"),
156 mcp.Items(map[string]any{
157 "type": "string",
158 }),
159 ),
160 )
161 mcpServer.AddTool(deleteTasksTool, s.handleDeleteTasks)
162}
163
164// handleSetGoal handles the project_management__set_goal tool call
165func (s *Server) handleSetGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
166 s.logger.Info("Received project_management__set_goal tool call")
167
168 // Extract parameters
169 arguments := request.GetArguments()
170 title, ok := arguments["title"].(string)
171 if !ok || title == "" {
172 return &mcp.CallToolResult{
173 Content: []mcp.Content{
174 mcp.TextContent{
175 Type: "text",
176 Text: "Error: title parameter is required and must be a string",
177 },
178 },
179 IsError: true,
180 }, nil
181 }
182
183 description, ok := arguments["description"].(string)
184 if !ok {
185 return &mcp.CallToolResult{
186 Content: []mcp.Content{
187 mcp.TextContent{
188 Type: "text",
189 Text: "Error: description parameter is required and must be a string",
190 },
191 },
192 IsError: true,
193 }, nil
194 }
195
196 // Set goal
197 if err := s.planner.SetGoal(title, description); err != nil {
198 s.logger.Error("Failed to set goal", "error", err)
199 return &mcp.CallToolResult{
200 Content: []mcp.Content{
201 mcp.TextContent{
202 Type: "text",
203 Text: fmt.Sprintf("Error setting goal: %v", err),
204 },
205 },
206 IsError: true,
207 }, nil
208 }
209
210 goalText := title
211 if description != "" {
212 goalText = fmt.Sprintf("%s: %s", title, description)
213 }
214 response := fmt.Sprintf("Goal \"%s\" saved! You probably want to add one or more tasks now.", goalText)
215 return &mcp.CallToolResult{
216 Content: []mcp.Content{
217 mcp.TextContent{
218 Type: "text",
219 Text: response,
220 },
221 },
222 }, nil
223}
224
225// handleChangeGoal handles the project_management__change_goal tool call
226func (s *Server) handleChangeGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
227 s.logger.Info("Received project_management__change_goal tool call")
228
229 // Extract parameters
230 arguments := request.GetArguments()
231 title, ok := arguments["title"].(string)
232 if !ok || title == "" {
233 return &mcp.CallToolResult{
234 Content: []mcp.Content{
235 mcp.TextContent{
236 Type: "text",
237 Text: "Error: title parameter is required and must be a string",
238 },
239 },
240 IsError: true,
241 }, nil
242 }
243
244 description, ok := arguments["description"].(string)
245 if !ok {
246 return &mcp.CallToolResult{
247 Content: []mcp.Content{
248 mcp.TextContent{
249 Type: "text",
250 Text: "Error: description parameter is required and must be a string",
251 },
252 },
253 IsError: true,
254 }, nil
255 }
256
257 reason, ok := arguments["reason"].(string)
258 if !ok || reason == "" {
259 return &mcp.CallToolResult{
260 Content: []mcp.Content{
261 mcp.TextContent{
262 Type: "text",
263 Text: "Error: reason parameter is required and must be a string",
264 },
265 },
266 IsError: true,
267 }, nil
268 }
269
270 // Change goal
271 if err := s.planner.ChangeGoal(title, description, reason); err != nil {
272 s.logger.Error("Failed to change goal", "error", err)
273 return &mcp.CallToolResult{
274 Content: []mcp.Content{
275 mcp.TextContent{
276 Type: "text",
277 Text: fmt.Sprintf("Error changing goal: %v", err),
278 },
279 },
280 IsError: true,
281 }, nil
282 }
283
284 goalText := title
285 if description != "" {
286 goalText = fmt.Sprintf("%s: %s", title, description)
287 }
288 response := fmt.Sprintf("Goal changed to \"%s\" (reason: %s). You probably want to add one or more tasks now.", goalText, reason)
289 return &mcp.CallToolResult{
290 Content: []mcp.Content{
291 mcp.TextContent{
292 Type: "text",
293 Text: response,
294 },
295 },
296 }, nil
297}
298
299// handleAddTasks handles the project_management__add_tasks tool call
300func (s *Server) handleAddTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
301 s.logger.Info("Received project_management__add_tasks tool call")
302
303 // Extract parameters
304 arguments := request.GetArguments()
305 tasksRaw, ok := arguments["tasks"]
306 if !ok {
307 return &mcp.CallToolResult{
308 Content: []mcp.Content{
309 mcp.TextContent{
310 Type: "text",
311 Text: "Error: tasks parameter is required",
312 },
313 },
314 IsError: true,
315 }, nil
316 }
317
318 // Convert to slice of interfaces
319 tasksSlice, ok := tasksRaw.([]any)
320 if !ok {
321 return &mcp.CallToolResult{
322 Content: []mcp.Content{
323 mcp.TextContent{
324 Type: "text",
325 Text: "Error: tasks parameter must be an array",
326 },
327 },
328 IsError: true,
329 }, nil
330 }
331
332 // Parse tasks
333 tasks := make([]planning.TaskInput, 0, len(tasksSlice))
334 for _, taskRaw := range tasksSlice {
335 taskMap, ok := taskRaw.(map[string]any)
336 if !ok {
337 return &mcp.CallToolResult{
338 Content: []mcp.Content{
339 mcp.TextContent{
340 Type: "text",
341 Text: "Error: each task must be an object",
342 },
343 },
344 IsError: true,
345 }, nil
346 }
347
348 title, ok := taskMap["title"].(string)
349 if !ok || title == "" {
350 return &mcp.CallToolResult{
351 Content: []mcp.Content{
352 mcp.TextContent{
353 Type: "text",
354 Text: "Error: each task must have a non-empty title",
355 },
356 },
357 IsError: true,
358 }, nil
359 }
360
361 description, _ := taskMap["description"].(string)
362
363 tasks = append(tasks, planning.TaskInput{
364 Title: title,
365 Description: description,
366 })
367 }
368
369 // Add tasks
370 result, err := s.planner.AddTasks(tasks)
371 if err != nil {
372 s.logger.Error("Failed to add tasks", "error", err)
373 return &mcp.CallToolResult{
374 Content: []mcp.Content{
375 mcp.TextContent{
376 Type: "text",
377 Text: fmt.Sprintf("Error adding tasks: %v", err),
378 },
379 },
380 IsError: true,
381 }, nil
382 }
383
384 // Get the full task list with goal and legend
385 taskList := s.planner.GetTasks()
386
387 var response string
388 if !result.HadExistingTasks {
389 // No existing tasks - show verbose instructions + task list
390 goal := s.planner.GetGoal()
391 goalText := "your planning session"
392 if goal != nil {
393 goalText = fmt.Sprintf("\"%s\"", goal.Text)
394 }
395 response = fmt.Sprintf("Tasks added successfully! Get started on your first one once you're ready, and call `project_management__get_tasks` frequently to remind yourself where you are in the process. Reminder that your overarching goal is %s.\n\n%s", goalText, taskList)
396 } else {
397 // Had existing tasks - just show the task list (like get_tasks)
398 response = taskList
399 }
400 return &mcp.CallToolResult{
401 Content: []mcp.Content{
402 mcp.TextContent{
403 Type: "text",
404 Text: response,
405 },
406 },
407 }, nil
408}
409
410// handleGetTasks handles the project_management__get_tasks tool call
411func (s *Server) handleGetTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
412 s.logger.Info("Received project_management__get_tasks tool call")
413
414 // Extract parameters
415 arguments := request.GetArguments()
416 statusFilter, _ := arguments["status"].(string)
417 if statusFilter == "" {
418 statusFilter = "all"
419 }
420
421 var taskList string
422 if statusFilter == "all" {
423 taskList = s.planner.GetTasks()
424 } else {
425 taskList = s.planner.GetTasksByStatus(statusFilter)
426 }
427
428 return &mcp.CallToolResult{
429 Content: []mcp.Content{
430 mcp.TextContent{
431 Type: "text",
432 Text: taskList,
433 },
434 },
435 }, nil
436}
437
438// handleUpdateTaskStatuses handles the project_management__update_task_statuses tool call
439func (s *Server) handleUpdateTaskStatuses(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
440 s.logger.Info("Received project_management__update_task_statuses tool call")
441
442 // Extract parameters
443 arguments := request.GetArguments()
444 tasksRaw, ok := arguments["tasks"]
445 if !ok {
446 return &mcp.CallToolResult{
447 Content: []mcp.Content{
448 mcp.TextContent{
449 Type: "text",
450 Text: "Error: tasks parameter is required",
451 },
452 },
453 IsError: true,
454 }, nil
455 }
456
457 // Convert to slice of interfaces
458 tasksSlice, ok := tasksRaw.([]any)
459 if !ok {
460 return &mcp.CallToolResult{
461 Content: []mcp.Content{
462 mcp.TextContent{
463 Type: "text",
464 Text: "Error: tasks parameter must be an array",
465 },
466 },
467 IsError: true,
468 }, nil
469 }
470
471 if len(tasksSlice) == 0 {
472 return &mcp.CallToolResult{
473 Content: []mcp.Content{
474 mcp.TextContent{
475 Type: "text",
476 Text: "Error: at least one task update is required",
477 },
478 },
479 IsError: true,
480 }, nil
481 }
482
483 // Parse task updates
484 updates := make([]planning.TaskUpdate, 0, len(tasksSlice))
485 for _, taskRaw := range tasksSlice {
486 taskMap, ok := taskRaw.(map[string]any)
487 if !ok {
488 return &mcp.CallToolResult{
489 Content: []mcp.Content{
490 mcp.TextContent{
491 Type: "text",
492 Text: "Error: each task update must be an object",
493 },
494 },
495 IsError: true,
496 }, nil
497 }
498
499 taskID, ok := taskMap["task_id"].(string)
500 if !ok || taskID == "" {
501 return &mcp.CallToolResult{
502 Content: []mcp.Content{
503 mcp.TextContent{
504 Type: "text",
505 Text: "Error: each task update must have a non-empty task_id",
506 },
507 },
508 IsError: true,
509 }, nil
510 }
511
512 statusStr, ok := taskMap["status"].(string)
513 if !ok || statusStr == "" {
514 return &mcp.CallToolResult{
515 Content: []mcp.Content{
516 mcp.TextContent{
517 Type: "text",
518 Text: "Error: each task update must have a non-empty status",
519 },
520 },
521 IsError: true,
522 }, nil
523 }
524
525 // Parse status
526 status := planning.ParseStatus(statusStr)
527
528 updates = append(updates, planning.TaskUpdate{
529 TaskID: taskID,
530 Status: status,
531 })
532 }
533
534 // Update task statuses
535 if err := s.planner.UpdateTasks(updates); err != nil {
536 s.logger.Error("Failed to update task statuses", "error", err)
537 return &mcp.CallToolResult{
538 Content: []mcp.Content{
539 mcp.TextContent{
540 Type: "text",
541 Text: fmt.Sprintf("Error updating task statuses: %v", err),
542 },
543 },
544 IsError: true,
545 }, nil
546 }
547
548 // Return full task list
549 taskList := s.planner.GetTasks()
550 return &mcp.CallToolResult{
551 Content: []mcp.Content{
552 mcp.TextContent{
553 Type: "text",
554 Text: taskList,
555 },
556 },
557 }, nil
558}
559
560// handleDeleteTasks handles the project_management__delete_tasks tool call
561func (s *Server) handleDeleteTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
562 s.logger.Info("Received project_management__delete_tasks tool call")
563
564 // Extract parameters
565 arguments := request.GetArguments()
566 taskIDsRaw, ok := arguments["task_ids"]
567 if !ok {
568 return &mcp.CallToolResult{
569 Content: []mcp.Content{
570 mcp.TextContent{
571 Type: "text",
572 Text: "Error: task_ids parameter is required",
573 },
574 },
575 IsError: true,
576 }, nil
577 }
578
579 // Convert to slice of interfaces
580 taskIDsSlice, ok := taskIDsRaw.([]any)
581 if !ok {
582 return &mcp.CallToolResult{
583 Content: []mcp.Content{
584 mcp.TextContent{
585 Type: "text",
586 Text: "Error: task_ids parameter must be an array",
587 },
588 },
589 IsError: true,
590 }, nil
591 }
592
593 if len(taskIDsSlice) == 0 {
594 return &mcp.CallToolResult{
595 Content: []mcp.Content{
596 mcp.TextContent{
597 Type: "text",
598 Text: "Error: at least one task ID is required",
599 },
600 },
601 IsError: true,
602 }, nil
603 }
604
605 // Parse task IDs
606 taskIDs := make([]string, 0, len(taskIDsSlice))
607 for _, taskIDRaw := range taskIDsSlice {
608 taskID, ok := taskIDRaw.(string)
609 if !ok || taskID == "" {
610 return &mcp.CallToolResult{
611 Content: []mcp.Content{
612 mcp.TextContent{
613 Type: "text",
614 Text: "Error: each task ID must be a non-empty string",
615 },
616 },
617 IsError: true,
618 }, nil
619 }
620 taskIDs = append(taskIDs, taskID)
621 }
622
623 // Delete tasks
624 if err := s.planner.DeleteTasks(taskIDs); err != nil {
625 s.logger.Error("Failed to delete tasks", "error", err)
626 return &mcp.CallToolResult{
627 Content: []mcp.Content{
628 mcp.TextContent{
629 Type: "text",
630 Text: fmt.Sprintf("Error deleting tasks: %v", err),
631 },
632 },
633 IsError: true,
634 }, nil
635 }
636
637 // Return full task list
638 taskList := s.planner.GetTasks()
639 return &mcp.CallToolResult{
640 Content: []mcp.Content{
641 mcp.TextContent{
642 Type: "text",
643 Text: taskList,
644 },
645 },
646 }, nil
647}
648
649// GetServer returns the underlying MCP server
650func (s *Server) GetServer() *server.MCPServer {
651 return s.server
652}