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 update_goal tool
 62	updateGoalTool := mcp.NewTool("update_goal",
 63		mcp.WithDescription("Set or update the overarching goal for your planning session"),
 64		mcp.WithString("goal",
 65			mcp.Required(),
 66			mcp.Description("The goal text to set"),
 67		),
 68	)
 69	mcpServer.AddTool(updateGoalTool, s.handleUpdateGoal)
 70
 71	// Register add_tasks tool
 72	addTasksTool := mcp.NewTool("add_tasks",
 73		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 get_tasks often to stay on track."),
 74		mcp.WithArray("tasks",
 75			mcp.Required(),
 76			mcp.Description("Array of tasks to add"),
 77			mcp.Items(map[string]any{
 78				"type": "object",
 79				"properties": map[string]any{
 80					"title": map[string]any{
 81						"type":        "string",
 82						"description": "Task title",
 83					},
 84					"description": map[string]any{
 85						"type":        "string",
 86						"description": "Task description (optional)",
 87					},
 88				},
 89				"required": []string{"title"},
 90			}),
 91		),
 92	)
 93	mcpServer.AddTool(addTasksTool, s.handleAddTasks)
 94
 95	// Register get_tasks tool
 96	getTasksTool := mcp.NewTool("get_tasks",
 97		mcp.WithDescription("Get current task list with status indicators. Call this frequently to stay organized and track your progress."),
 98	)
 99	mcpServer.AddTool(getTasksTool, s.handleGetTasks)
100
101	// Register update_tasks tool
102	updateTasksTool := mcp.NewTool("update_tasks",
103		mcp.WithDescription("Update the status of one or more tasks. Maintain your planning workflow by regularly updating task statuses as you make progress."),
104		mcp.WithArray("tasks",
105			mcp.Required(),
106			mcp.Description("Array of task updates"),
107			mcp.Items(map[string]any{
108				"type": "object",
109				"properties": map[string]any{
110					"task_id": map[string]any{
111						"type":        "string",
112						"description": "The task ID to update",
113					},
114					"status": map[string]any{
115						"type":        "string",
116						"description": "New status: pending, in_progress, completed, or failed",
117					},
118				},
119				"required": []string{"task_id", "status"},
120			}),
121		),
122	)
123	mcpServer.AddTool(updateTasksTool, s.handleUpdateTasks)
124
125	// Register delete_tasks tool
126	deleteTasksTool := mcp.NewTool("delete_tasks",
127		mcp.WithDescription("Delete one or more tasks by their IDs. After deletion, respond with the resulting task list."),
128		mcp.WithArray("task_ids",
129			mcp.Required(),
130			mcp.Description("Array of task IDs to delete"),
131			mcp.Items(map[string]any{
132				"type": "string",
133			}),
134		),
135	)
136	mcpServer.AddTool(deleteTasksTool, s.handleDeleteTasks)
137}
138
139// handleUpdateGoal handles the update_goal tool call
140func (s *Server) handleUpdateGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
141	s.logger.Info("Received update_goal tool call")
142
143	// Extract parameters
144	arguments := request.GetArguments()
145	goal, ok := arguments["goal"].(string)
146	if !ok || goal == "" {
147		return &mcp.CallToolResult{
148			Content: []mcp.Content{
149				mcp.TextContent{
150					Type: "text",
151					Text: "Error: goal parameter is required and must be a string",
152				},
153			},
154			IsError: true,
155		}, nil
156	}
157
158	// Update goal
159	if err := s.planner.UpdateGoal(goal); err != nil {
160		s.logger.Error("Failed to update goal", "error", err)
161		return &mcp.CallToolResult{
162			Content: []mcp.Content{
163				mcp.TextContent{
164					Type: "text",
165					Text: fmt.Sprintf("Error updating goal: %v", err),
166				},
167			},
168			IsError: true,
169		}, nil
170	}
171
172	response := fmt.Sprintf("Goal \"%s\" saved! You probably want to add one or more tasks now.", goal)
173	return &mcp.CallToolResult{
174		Content: []mcp.Content{
175			mcp.TextContent{
176				Type: "text",
177				Text: response,
178			},
179		},
180	}, nil
181}
182
183// handleAddTasks handles the add_tasks tool call
184func (s *Server) handleAddTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
185	s.logger.Info("Received add_tasks tool call")
186
187	// Extract parameters
188	arguments := request.GetArguments()
189	tasksRaw, ok := arguments["tasks"]
190	if !ok {
191		return &mcp.CallToolResult{
192			Content: []mcp.Content{
193				mcp.TextContent{
194					Type: "text",
195					Text: "Error: tasks parameter is required",
196				},
197			},
198			IsError: true,
199		}, nil
200	}
201
202	// Convert to slice of interfaces
203	tasksSlice, ok := tasksRaw.([]any)
204	if !ok {
205		return &mcp.CallToolResult{
206			Content: []mcp.Content{
207				mcp.TextContent{
208					Type: "text",
209					Text: "Error: tasks parameter must be an array",
210				},
211			},
212			IsError: true,
213		}, nil
214	}
215
216	// Parse tasks
217	tasks := make([]planning.TaskInput, 0, len(tasksSlice))
218	for _, taskRaw := range tasksSlice {
219		taskMap, ok := taskRaw.(map[string]any)
220		if !ok {
221			return &mcp.CallToolResult{
222				Content: []mcp.Content{
223					mcp.TextContent{
224						Type: "text",
225						Text: "Error: each task must be an object",
226					},
227				},
228				IsError: true,
229			}, nil
230		}
231
232		title, ok := taskMap["title"].(string)
233		if !ok || title == "" {
234			return &mcp.CallToolResult{
235				Content: []mcp.Content{
236					mcp.TextContent{
237						Type: "text",
238						Text: "Error: each task must have a non-empty title",
239					},
240				},
241				IsError: true,
242			}, nil
243		}
244
245		description, _ := taskMap["description"].(string)
246
247		tasks = append(tasks, planning.TaskInput{
248			Title:       title,
249			Description: description,
250		})
251	}
252
253	// Add tasks
254	result, err := s.planner.AddTasks(tasks)
255	if err != nil {
256		s.logger.Error("Failed to add tasks", "error", err)
257		return &mcp.CallToolResult{
258			Content: []mcp.Content{
259				mcp.TextContent{
260					Type: "text",
261					Text: fmt.Sprintf("Error adding tasks: %v", err),
262				},
263			},
264			IsError: true,
265		}, 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 `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 &mcp.CallToolResult{
285		Content: []mcp.Content{
286			mcp.TextContent{
287				Type: "text",
288				Text: response,
289			},
290		},
291	}, nil
292}
293
294// handleGetTasks handles the get_tasks tool call
295func (s *Server) handleGetTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
296	s.logger.Info("Received get_tasks tool call")
297
298	taskList := s.planner.GetTasks()
299
300	return &mcp.CallToolResult{
301		Content: []mcp.Content{
302			mcp.TextContent{
303				Type: "text",
304				Text: taskList,
305			},
306		},
307	}, nil
308}
309
310// handleUpdateTasks handles the update_tasks tool call
311func (s *Server) handleUpdateTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
312	s.logger.Info("Received update_tasks tool call")
313
314	// Extract parameters
315	arguments := request.GetArguments()
316	tasksRaw, ok := arguments["tasks"]
317	if !ok {
318		return &mcp.CallToolResult{
319			Content: []mcp.Content{
320				mcp.TextContent{
321					Type: "text",
322					Text: "Error: tasks parameter is required",
323				},
324			},
325			IsError: true,
326		}, nil
327	}
328
329	// Convert to slice of interfaces
330	tasksSlice, ok := tasksRaw.([]any)
331	if !ok {
332		return &mcp.CallToolResult{
333			Content: []mcp.Content{
334				mcp.TextContent{
335					Type: "text",
336					Text: "Error: tasks parameter must be an array",
337				},
338			},
339			IsError: true,
340		}, nil
341	}
342
343	if len(tasksSlice) == 0 {
344		return &mcp.CallToolResult{
345			Content: []mcp.Content{
346				mcp.TextContent{
347					Type: "text",
348					Text: "Error: at least one task update is required",
349				},
350			},
351			IsError: true,
352		}, nil
353	}
354
355	// Parse task updates
356	updates := make([]planning.TaskUpdate, 0, len(tasksSlice))
357	for _, taskRaw := range tasksSlice {
358		taskMap, ok := taskRaw.(map[string]any)
359		if !ok {
360			return &mcp.CallToolResult{
361				Content: []mcp.Content{
362					mcp.TextContent{
363						Type: "text",
364						Text: "Error: each task update must be an object",
365					},
366				},
367				IsError: true,
368			}, nil
369		}
370
371		taskID, ok := taskMap["task_id"].(string)
372		if !ok || taskID == "" {
373			return &mcp.CallToolResult{
374				Content: []mcp.Content{
375					mcp.TextContent{
376						Type: "text",
377						Text: "Error: each task update must have a non-empty task_id",
378					},
379				},
380				IsError: true,
381			}, nil
382		}
383
384		statusStr, ok := taskMap["status"].(string)
385		if !ok || statusStr == "" {
386			return &mcp.CallToolResult{
387				Content: []mcp.Content{
388					mcp.TextContent{
389						Type: "text",
390						Text: "Error: each task update must have a non-empty status",
391					},
392				},
393				IsError: true,
394			}, nil
395		}
396
397		// Parse status
398		status := planning.ParseStatus(statusStr)
399
400		updates = append(updates, planning.TaskUpdate{
401			TaskID: taskID,
402			Status: status,
403		})
404	}
405
406	// Update task statuses
407	if err := s.planner.UpdateTasks(updates); err != nil {
408		s.logger.Error("Failed to update task statuses", "error", err)
409		return &mcp.CallToolResult{
410			Content: []mcp.Content{
411				mcp.TextContent{
412					Type: "text",
413					Text: fmt.Sprintf("Error updating task statuses: %v", err),
414				},
415			},
416			IsError: true,
417		}, nil
418	}
419
420	// Return full task list
421	taskList := s.planner.GetTasks()
422	return &mcp.CallToolResult{
423		Content: []mcp.Content{
424			mcp.TextContent{
425				Type: "text",
426				Text: taskList,
427			},
428		},
429	}, nil
430}
431
432// handleDeleteTasks handles the delete_tasks tool call
433func (s *Server) handleDeleteTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
434	s.logger.Info("Received delete_tasks tool call")
435
436	// Extract parameters
437	arguments := request.GetArguments()
438	taskIDsRaw, ok := arguments["task_ids"]
439	if !ok {
440		return &mcp.CallToolResult{
441			Content: []mcp.Content{
442				mcp.TextContent{
443					Type: "text",
444					Text: "Error: task_ids parameter is required",
445				},
446			},
447			IsError: true,
448		}, nil
449	}
450
451	// Convert to slice of interfaces
452	taskIDsSlice, ok := taskIDsRaw.([]any)
453	if !ok {
454		return &mcp.CallToolResult{
455			Content: []mcp.Content{
456				mcp.TextContent{
457					Type: "text",
458					Text: "Error: task_ids parameter must be an array",
459				},
460			},
461			IsError: true,
462		}, nil
463	}
464
465	if len(taskIDsSlice) == 0 {
466		return &mcp.CallToolResult{
467			Content: []mcp.Content{
468				mcp.TextContent{
469					Type: "text",
470					Text: "Error: at least one task ID is required",
471				},
472			},
473			IsError: true,
474		}, nil
475	}
476
477	// Parse task IDs
478	taskIDs := make([]string, 0, len(taskIDsSlice))
479	for _, taskIDRaw := range taskIDsSlice {
480		taskID, ok := taskIDRaw.(string)
481		if !ok || taskID == "" {
482			return &mcp.CallToolResult{
483				Content: []mcp.Content{
484					mcp.TextContent{
485						Type: "text",
486						Text: "Error: each task ID must be a non-empty string",
487					},
488				},
489				IsError: true,
490			}, nil
491		}
492		taskIDs = append(taskIDs, taskID)
493	}
494
495	// Delete tasks
496	if err := s.planner.DeleteTasks(taskIDs); err != nil {
497		s.logger.Error("Failed to delete tasks", "error", err)
498		return &mcp.CallToolResult{
499			Content: []mcp.Content{
500				mcp.TextContent{
501					Type: "text",
502					Text: fmt.Sprintf("Error deleting tasks: %v", err),
503				},
504			},
505			IsError: true,
506		}, nil
507	}
508
509	// Return full task list
510	taskList := s.planner.GetTasks()
511	return &mcp.CallToolResult{
512		Content: []mcp.Content{
513			mcp.TextContent{
514				Type: "text",
515				Text: taskList,
516			},
517		},
518	}, nil
519}
520
521// GetServer returns the underlying MCP server
522func (s *Server) GetServer() *server.MCPServer {
523	return s.server
524}