server.go

  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}