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 createErrorResult("Error: title parameter is required and must be a string"), nil
173	}
174
175	description, ok := arguments["description"].(string)
176	if !ok {
177		return createErrorResult("Error: description parameter is required and must be a string"), nil
178	}
179
180	// Set goal
181	if err := s.planner.SetGoal(title, description); err != nil {
182		s.logger.Error("Failed to set goal", "error", err)
183		return createErrorResult(fmt.Sprintf("Error setting goal: %v", err)), nil
184	}
185
186	goalText := formatGoalText(title, description)
187	response := fmt.Sprintf("Goal \"%s\" saved! You probably want to add one or more tasks now.", goalText)
188	return createSuccessResult(response), nil
189}
190
191// handleChangeGoal handles the project_management__change_goal tool call
192func (s *Server) handleChangeGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
193	s.logger.Info("Received project_management__change_goal tool call")
194
195	// Extract parameters
196	arguments := request.GetArguments()
197	title, ok := arguments["title"].(string)
198	if !ok || title == "" {
199		return createErrorResult("Error: title parameter is required and must be a string"), nil
200	}
201
202	description, ok := arguments["description"].(string)
203	if !ok {
204		return createErrorResult("Error: description parameter is required and must be a string"), nil
205	}
206
207	reason, ok := arguments["reason"].(string)
208	if !ok || reason == "" {
209		return createErrorResult("Error: reason parameter is required and must be a string"), nil
210	}
211
212	// Change goal
213	if err := s.planner.ChangeGoal(title, description, reason); err != nil {
214		s.logger.Error("Failed to change goal", "error", err)
215		return createErrorResult(fmt.Sprintf("Error changing goal: %v", err)), nil
216	}
217
218	goalText := formatGoalText(title, description)
219	response := fmt.Sprintf("Goal changed to \"%s\" (reason: %s). You probably want to add one or more tasks now.", goalText, reason)
220	return createSuccessResult(response), nil
221}
222
223// handleAddTasks handles the project_management__add_tasks tool call
224func (s *Server) handleAddTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
225	s.logger.Info("Received project_management__add_tasks tool call")
226
227	// Extract parameters
228	arguments := request.GetArguments()
229	tasksRaw, ok := arguments["tasks"]
230	if !ok {
231		return createErrorResult("Error: tasks parameter is required"), nil
232	}
233
234	// Convert to slice of interfaces
235	tasksSlice, ok := tasksRaw.([]any)
236	if !ok {
237		return createErrorResult("Error: tasks parameter must be an array"), nil
238	}
239
240	// Parse tasks
241	tasks := make([]planning.TaskInput, 0, len(tasksSlice))
242	for _, taskRaw := range tasksSlice {
243		taskMap, ok := taskRaw.(map[string]any)
244		if !ok {
245			return createErrorResult("Error: each task must be an object"), nil
246		}
247
248		title, ok := taskMap["title"].(string)
249		if !ok || title == "" {
250			return createErrorResult("Error: each task must have a non-empty title"), nil
251		}
252
253		description, _ := taskMap["description"].(string)
254
255		tasks = append(tasks, planning.TaskInput{
256			Title:       title,
257			Description: description,
258		})
259	}
260
261	// Add tasks
262	result, err := s.planner.AddTasks(tasks)
263	if err != nil {
264		s.logger.Error("Failed to add tasks", "error", err)
265		return createErrorResult(fmt.Sprintf("Error adding tasks: %v", err)), nil
266	}
267
268	// Get the full task list with goal and legend
269	taskList := s.planner.GetTasks()
270
271	var response string
272	if !result.HadExistingTasks {
273		// No existing tasks - show verbose instructions + task list
274		goal := s.planner.GetGoal()
275		goalText := "your planning session"
276		if goal != nil {
277			goalText = fmt.Sprintf("\"%s\"", goal.Text)
278		}
279		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)
280	} else {
281		// Had existing tasks - just show the task list (like get_tasks)
282		response = taskList
283	}
284	return createSuccessResult(response), nil
285}
286
287// handleGetTasks handles the project_management__get_tasks tool call
288func (s *Server) handleGetTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
289	s.logger.Info("Received project_management__get_tasks tool call")
290
291	// Extract parameters
292	arguments := request.GetArguments()
293	statusFilter, _ := arguments["status"].(string)
294	if statusFilter == "" {
295		statusFilter = "all"
296	}
297
298	var taskList string
299	if statusFilter == "all" {
300		taskList = s.planner.GetTasks()
301	} else {
302		taskList = s.planner.GetTasksByStatus(statusFilter)
303	}
304
305	return createSuccessResult(taskList), nil
306}
307
308// handleUpdateTaskStatuses handles the project_management__update_task_statuses tool call
309func (s *Server) handleUpdateTaskStatuses(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
310	s.logger.Info("Received project_management__update_task_statuses tool call")
311
312	// Extract parameters
313	arguments := request.GetArguments()
314	tasksRaw, ok := arguments["tasks"]
315	if !ok {
316		return createErrorResult("Error: tasks parameter is required"), nil
317	}
318
319	// Convert to slice of interfaces
320	tasksSlice, ok := tasksRaw.([]any)
321	if !ok {
322		return createErrorResult("Error: tasks parameter must be an array"), nil
323	}
324
325	if len(tasksSlice) == 0 {
326		return createErrorResult("Error: at least one task update is required"), nil
327	}
328
329	// Parse task updates
330	updates := make([]planning.TaskUpdate, 0, len(tasksSlice))
331	for _, taskRaw := range tasksSlice {
332		taskMap, ok := taskRaw.(map[string]any)
333		if !ok {
334			return createErrorResult("Error: each task update must be an object"), nil
335		}
336
337		taskID, ok := taskMap["task_id"].(string)
338		if !ok || taskID == "" {
339			return createErrorResult("Error: each task update must have a non-empty task_id"), nil
340		}
341
342		statusStr, ok := taskMap["status"].(string)
343		if !ok || statusStr == "" {
344			return createErrorResult("Error: each task update must have a non-empty status"), nil
345		}
346
347		// Parse status
348		status := planning.ParseStatus(statusStr)
349
350		updates = append(updates, planning.TaskUpdate{
351			TaskID: taskID,
352			Status: status,
353		})
354	}
355
356	// Update task statuses
357	if err := s.planner.UpdateTasks(updates); err != nil {
358		s.logger.Error("Failed to update task statuses", "error", err)
359		return createErrorResult(fmt.Sprintf("Error updating task statuses: %v", err)), nil
360	}
361
362	// Return full task list
363	taskList := s.planner.GetTasks()
364	return createSuccessResult(taskList), nil
365}
366
367// handleDeleteTasks handles the project_management__delete_tasks tool call
368func (s *Server) handleDeleteTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
369	s.logger.Info("Received project_management__delete_tasks tool call")
370
371	// Extract parameters
372	arguments := request.GetArguments()
373	taskIDsRaw, ok := arguments["task_ids"]
374	if !ok {
375		return createErrorResult("Error: task_ids parameter is required"), nil
376	}
377
378	// Convert to slice of interfaces
379	taskIDsSlice, ok := taskIDsRaw.([]any)
380	if !ok {
381		return createErrorResult("Error: task_ids parameter must be an array"), nil
382	}
383
384	if len(taskIDsSlice) == 0 {
385		return createErrorResult("Error: at least one task ID is required"), nil
386	}
387
388	// Parse task IDs
389	taskIDs := make([]string, 0, len(taskIDsSlice))
390	for _, taskIDRaw := range taskIDsSlice {
391		taskID, ok := taskIDRaw.(string)
392		if !ok || taskID == "" {
393			return createErrorResult("Error: each task ID must be a non-empty string"), nil
394		}
395		taskIDs = append(taskIDs, taskID)
396	}
397
398	// Delete tasks
399	if err := s.planner.DeleteTasks(taskIDs); err != nil {
400		s.logger.Error("Failed to delete tasks", "error", err)
401		return createErrorResult(fmt.Sprintf("Error deleting tasks: %v", err)), nil
402	}
403
404	// Return full task list
405	taskList := s.planner.GetTasks()
406	return createSuccessResult(taskList), nil
407}
408
409// GetServer returns the underlying MCP server
410func (s *Server) GetServer() *server.MCPServer {
411	return s.server
412}