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	// Parse and validate request
169	var req SetGoalRequest
170	if err := parseAndValidate(request.GetArguments(), &req); err != nil {
171		return createErrorResult(fmt.Sprintf("Invalid request: %v", err)), nil
172	}
173
174	// Set goal
175	if err := s.planner.SetGoal(req.Title, req.Description); err != nil {
176		s.logger.Error("Failed to set goal", "error", err)
177		return createErrorResult(fmt.Sprintf("Error setting goal: %v", err)), nil
178	}
179
180	goalText := formatGoalText(req.Title, req.Description)
181	response := fmt.Sprintf("Goal \"%s\" saved! You probably want to add one or more tasks now.", goalText)
182	return createSuccessResult(response), nil
183}
184
185// handleChangeGoal handles the project_management__change_goal tool call
186func (s *Server) handleChangeGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
187	s.logger.Info("Received project_management__change_goal tool call")
188
189	// Parse and validate request
190	var req ChangeGoalRequest
191	if err := parseAndValidate(request.GetArguments(), &req); err != nil {
192		return createErrorResult(fmt.Sprintf("Invalid request: %v", err)), nil
193	}
194
195	// Change goal
196	if err := s.planner.ChangeGoal(req.Title, req.Description, req.Reason); err != nil {
197		s.logger.Error("Failed to change goal", "error", err)
198		return createErrorResult(fmt.Sprintf("Error changing goal: %v", err)), nil
199	}
200
201	goalText := formatGoalText(req.Title, req.Description)
202	response := fmt.Sprintf("Goal changed to \"%s\" (reason: %s). You probably want to add one or more tasks now.", goalText, req.Reason)
203	return createSuccessResult(response), nil
204}
205
206// handleAddTasks handles the project_management__add_tasks tool call
207func (s *Server) handleAddTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
208	s.logger.Info("Received project_management__add_tasks tool call")
209
210	// Parse and validate request
211	var req AddTasksRequest
212	if err := parseAndValidate(request.GetArguments(), &req); err != nil {
213		return createErrorResult(fmt.Sprintf("Invalid request: %v", err)), nil
214	}
215
216	// Convert MCP task inputs to planning task inputs
217	tasks := make([]planning.TaskInput, 0, len(req.Tasks))
218	for _, mcpTask := range req.Tasks {
219		tasks = append(tasks, planning.TaskInput{
220			Title:       mcpTask.Title,
221			Description: mcpTask.Description,
222		})
223	}
224
225	// Add tasks
226	result, err := s.planner.AddTasks(tasks)
227	if err != nil {
228		s.logger.Error("Failed to add tasks", "error", err)
229		return createErrorResult(fmt.Sprintf("Error adding tasks: %v", err)), nil
230	}
231
232	// Get the full task list with goal and legend
233	taskList := s.planner.GetTasks()
234
235	var response string
236	if !result.HadExistingTasks {
237		// No existing tasks - show verbose instructions + task list
238		goal := s.planner.GetGoal()
239		goalText := "your planning session"
240		if goal != nil {
241			goalText = fmt.Sprintf("\"%s\"", goal.Text)
242		}
243		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)
244	} else {
245		// Had existing tasks - just show the task list (like get_tasks)
246		response = taskList
247	}
248	return createSuccessResult(response), nil
249}
250
251// handleGetTasks handles the project_management__get_tasks tool call
252func (s *Server) handleGetTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
253	s.logger.Info("Received project_management__get_tasks tool call")
254
255	// Parse and validate request
256	var req GetTasksRequest
257	if err := parseAndValidate(request.GetArguments(), &req); err != nil {
258		return createErrorResult(fmt.Sprintf("Invalid request: %v", err)), nil
259	}
260
261	// Default status to "all" if empty
262	statusFilter := req.Status
263	if statusFilter == "" {
264		statusFilter = "all"
265	}
266
267	var taskList string
268	if statusFilter == "all" {
269		taskList = s.planner.GetTasks()
270	} else {
271		taskList = s.planner.GetTasksByStatus(statusFilter)
272	}
273
274	return createSuccessResult(taskList), nil
275}
276
277// handleUpdateTaskStatuses handles the project_management__update_task_statuses tool call
278func (s *Server) handleUpdateTaskStatuses(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
279	s.logger.Info("Received project_management__update_task_statuses tool call")
280
281	// Parse and validate request
282	var req UpdateTaskStatusesRequest
283	if err := parseAndValidate(request.GetArguments(), &req); err != nil {
284		return createErrorResult(fmt.Sprintf("Invalid request: %v", err)), nil
285	}
286
287	// Convert MCP task update inputs to planning task updates
288	updates := make([]planning.TaskUpdate, 0, len(req.Tasks))
289	for _, mcpUpdate := range req.Tasks {
290		updates = append(updates, planning.TaskUpdate{
291			TaskID: mcpUpdate.TaskID,
292			Status: planning.ParseStatus(mcpUpdate.Status),
293		})
294	}
295
296	// Update task statuses
297	if err := s.planner.UpdateTasks(updates); err != nil {
298		s.logger.Error("Failed to update task statuses", "error", err)
299		return createErrorResult(fmt.Sprintf("Error updating task statuses: %v", err)), nil
300	}
301
302	// Return full task list
303	taskList := s.planner.GetTasks()
304	return createSuccessResult(taskList), nil
305}
306
307// handleDeleteTasks handles the project_management__delete_tasks tool call
308func (s *Server) handleDeleteTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
309	s.logger.Info("Received project_management__delete_tasks tool call")
310
311	// Parse and validate request
312	var req DeleteTasksRequest
313	if err := parseAndValidate(request.GetArguments(), &req); err != nil {
314		return createErrorResult(fmt.Sprintf("Invalid request: %v", err)), nil
315	}
316
317	// Delete tasks
318	if err := s.planner.DeleteTasks(req.TaskIDs); err != nil {
319		s.logger.Error("Failed to delete tasks", "error", err)
320		return createErrorResult(fmt.Sprintf("Error deleting tasks: %v", err)), nil
321	}
322
323	// Return full task list
324	taskList := s.planner.GetTasks()
325	return createSuccessResult(taskList), nil
326}
327
328// GetServer returns the underlying MCP server
329func (s *Server) GetServer() *server.MCPServer {
330	return s.server
331}