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.secluded.site/planning-mcp-server/internal/config"
 16	"git.secluded.site/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	validator Validator
 25	server    *server.MCPServer
 26}
 27
 28// New creates a new MCP server
 29func New(cfg *config.Config, logger *slog.Logger, planner *planning.Manager) (*Server, error) {
 30	if cfg == nil {
 31		return nil, fmt.Errorf("config cannot be nil")
 32	}
 33	if logger == nil {
 34		return nil, fmt.Errorf("logger cannot be nil")
 35	}
 36	if planner == nil {
 37		return nil, fmt.Errorf("planner cannot be nil")
 38	}
 39
 40	s := &Server{
 41		config:    cfg,
 42		logger:    logger,
 43		planner:   planner,
 44		validator: NewPlanningValidator(cfg),
 45	}
 46
 47	// Create MCP server
 48	mcpServer := server.NewMCPServer(
 49		"planning-mcp-server",
 50		"1.0.0",
 51		server.WithToolCapabilities(true),
 52	)
 53
 54	// Register tools
 55	s.registerTools(mcpServer)
 56
 57	s.server = mcpServer
 58	return s, nil
 59}
 60
 61// registerTools registers all planning tools
 62func (s *Server) registerTools(mcpServer *server.MCPServer) {
 63	// Register set_goal tool
 64	setGoalTool := mcp.NewTool("set_goal",
 65		mcp.WithDescription("Set the session goal. If this is a new conversation, use me first. Otherwise, use change_goal and include a reason."),
 66		mcp.WithString("title",
 67			mcp.Required(),
 68			mcp.Description("Short, imperative, sentence-case phrase concisely describing the session's overarching goal"),
 69		),
 70		mcp.WithString("description",
 71			mcp.Required(),
 72			mcp.Description("More comprehensive, paragraph-style description capturing additional nuance and detail"),
 73		),
 74	)
 75	mcpServer.AddTool(setGoalTool, s.handleSetGoal)
 76
 77	// Register change_goal tool
 78	changeGoalTool := mcp.NewTool("change_goal",
 79		mcp.WithDescription("Alter the existing session goal. Only use if the operator explicitly requests clearing the board/list/goal and doing something else"),
 80		mcp.WithString("title",
 81			mcp.Required(),
 82		),
 83		mcp.WithString("description",
 84			mcp.Required(),
 85		),
 86		mcp.WithString("reason",
 87			mcp.Required(),
 88			mcp.Description("_Must_ include adequate justification; doesn't have to be long, just complete. If you find the goal requires adjusting, do not just change it on your own. Suggest the change to the user and only call me with their consent. Examples: 'User requested doing x, y, and z' or 'We assumed X was true, but I discovered Y. User consented to the change.'"),
 89		),
 90	)
 91	mcpServer.AddTool(changeGoalTool, s.handleChangeGoal)
 92
 93	// Register add_tasks tool
 94	addTasksTool := mcp.NewTool("add_tasks",
 95		mcp.WithDescription("Add one or more tasks to work on. Break them down into the smallest, complete 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."),
 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": "Imperative, sentence-case phrase completely describing the task",
105					},
106					"description": map[string]any{
107						"type":        "string",
108						"description": "If the title isn't enough, use this field to capture additional nuance in a single paragraph. If title is enough, leave this field empty.",
109					},
110				},
111				"required": []string{"title"},
112			}),
113		),
114	)
115	mcpServer.AddTool(addTasksTool, s.handleAddTasks)
116
117	// Register get_tasks tool
118	getTasksTool := mcp.NewTool("get_tasks",
119		mcp.WithDescription("Get the goal and list of tasks. Prefer to call with 'all' or 'pending', only 'completed' if unsure, only 'cancelled' or 'failed' if the operator explicitly asks. The update tool prints the revised list, so calling me isn't always necessary."),
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 update_task_statuses tool
127	updateTasksTool := mcp.NewTool("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					},
138					"status": map[string]any{
139						"type": "string",
140					},
141				},
142				"required": []string{"task_id", "status"},
143			}),
144		),
145	)
146	mcpServer.AddTool(updateTasksTool, s.handleUpdateTaskStatuses)
147
148	// Register delete_tasks tool
149	deleteTasksTool := mcp.NewTool("delete_tasks",
150		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."),
151		mcp.WithArray("task_ids",
152			mcp.Required(),
153			mcp.Description("Array of task IDs to delete, can be one or many"),
154			mcp.Items(map[string]any{
155				"type": "string",
156			}),
157		),
158	)
159	mcpServer.AddTool(deleteTasksTool, s.handleDeleteTasks)
160
161	// Register modify_task tool
162	modifyTaskTool := mcp.NewTool("modify_task",
163		mcp.WithDescription("Modify the title and/or description of a task. ID and at least one of title/description are required. When one or the other is omitted, that field will not be modified."),
164		mcp.WithString("task_id",
165			mcp.Required(),
166			mcp.Description("ID of the task to modify"),
167		),
168		mcp.WithString("title",
169			mcp.Description("New title for the task (optional - if omitted, title remains unchanged)"),
170		),
171		mcp.WithString("description",
172			mcp.Description("New description for the task (optional - if omitted, description remains unchanged)"),
173		),
174	)
175	mcpServer.AddTool(modifyTaskTool, s.handleModifyTask)
176}
177
178// handleSetGoal handles the set_goal tool call
179func (s *Server) handleSetGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
180	s.logger.Info("Received set_goal tool call")
181
182	// Parse request
183	var req SetGoalRequest
184	if err := parseRequest(request.GetArguments(), &req); err != nil {
185		return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
186	}
187
188	// Validate request
189	if err := s.validator.ValidateSetGoalRequest(req); err != nil {
190		return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
191	}
192
193	// Set goal
194	if err := s.planner.SetGoal(req.Title, req.Description); err != nil {
195		s.logger.Error("Failed to set goal", "error", err)
196		return createErrorResult(fmt.Sprintf("Error setting goal: %v", err)), nil
197	}
198
199	goalText := formatGoalText(req.Title, req.Description)
200	response := fmt.Sprintf("Goal \"%s\" saved! You probably want to add one or more tasks now.", goalText)
201	return createSuccessResult(response), nil
202}
203
204// handleChangeGoal handles the change_goal tool call
205func (s *Server) handleChangeGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
206	s.logger.Info("Received change_goal tool call")
207
208	// Parse request
209	var req ChangeGoalRequest
210	if err := parseRequest(request.GetArguments(), &req); err != nil {
211		return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
212	}
213
214	// Validate request
215	if err := s.validator.ValidateChangeGoalRequest(req); err != nil {
216		return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
217	}
218
219	// Change goal
220	if err := s.planner.ChangeGoal(req.Title, req.Description, req.Reason); err != nil {
221		s.logger.Error("Failed to change goal", "error", err)
222		return createErrorResult(fmt.Sprintf("Error changing goal: %v", err)), nil
223	}
224
225	goalText := formatGoalText(req.Title, req.Description)
226	response := fmt.Sprintf("Goal changed to \"%s\" (reason: %s). You probably want to add one or more tasks now.", goalText, req.Reason)
227	return createSuccessResult(response), nil
228}
229
230// handleAddTasks handles the add_tasks tool call
231func (s *Server) handleAddTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
232	s.logger.Info("Received add_tasks tool call")
233
234	// Parse request
235	var req AddTasksRequest
236	if err := parseRequest(request.GetArguments(), &req); err != nil {
237		return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
238	}
239
240	// Validate request
241	if err := s.validator.ValidateAddTasksRequest(req); err != nil {
242		return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
243	}
244
245	// Convert MCP task inputs to planning task inputs
246	tasks := make([]planning.TaskInput, 0, len(req.Tasks))
247	for _, mcpTask := range req.Tasks {
248		tasks = append(tasks, planning.TaskInput{
249			Title:       mcpTask.Title,
250			Description: mcpTask.Description,
251		})
252	}
253
254	// Add tasks
255	result, err := s.planner.AddTasks(tasks)
256	if err != nil {
257		s.logger.Error("Failed to add tasks", "error", err)
258		return createErrorResult(fmt.Sprintf("Error adding tasks: %v", err)), nil
259	}
260
261	// Get the full task list with goal and legend
262	taskList := s.planner.GetTasks()
263
264	var response string
265	if !result.HadExistingTasks {
266		response = fmt.Sprintf("Tasks added successfully! Get started on your first one once you're ready. If you're updating statuses as you should, you may call `get_tasks` less frequently because `update_task_statuses` prints the revised list.\n\n%s", taskList)
267	} else {
268		response = taskList
269	}
270	return createSuccessResult(response), nil
271}
272
273// handleGetTasks handles the get_tasks tool call
274func (s *Server) handleGetTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
275	s.logger.Info("Received get_tasks tool call")
276
277	// Parse request
278	var req GetTasksRequest
279	if err := parseRequest(request.GetArguments(), &req); err != nil {
280		return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
281	}
282
283	// Validate request
284	if err := s.validator.ValidateGetTasksRequest(req); err != nil {
285		return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
286	}
287
288	// Default status to "all" if empty
289	statusFilter := req.Status
290	if statusFilter == "" {
291		statusFilter = "all"
292	}
293
294	var taskList string
295	if statusFilter == "all" {
296		taskList = s.planner.GetTasks()
297	} else {
298		taskList = s.planner.GetTasksByStatus(statusFilter)
299	}
300
301	return createSuccessResult(taskList), nil
302}
303
304// handleUpdateTaskStatuses handles the update_task_statuses tool call
305func (s *Server) handleUpdateTaskStatuses(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
306	s.logger.Info("Received update_task_statuses tool call")
307
308	// Parse request
309	var req UpdateTaskStatusesRequest
310	if err := parseRequest(request.GetArguments(), &req); err != nil {
311		return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
312	}
313
314	// Validate request
315	if err := s.validator.ValidateUpdateTaskStatusesRequest(req); err != nil {
316		return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
317	}
318
319	// Convert MCP task update inputs to planning task updates
320	updates := make([]planning.TaskUpdate, 0, len(req.Tasks))
321	for _, mcpUpdate := range req.Tasks {
322		updates = append(updates, planning.TaskUpdate{
323			TaskID: mcpUpdate.TaskID,
324			Status: planning.ParseStatus(mcpUpdate.Status),
325		})
326	}
327
328	// Update task statuses
329	if err := s.planner.UpdateTasks(updates); err != nil {
330		s.logger.Error("Failed to update task statuses", "error", err)
331		return createErrorResult(fmt.Sprintf("Error updating task statuses: %v", err)), nil
332	}
333
334	// Return full task list
335	taskList := s.planner.GetTasks()
336	return createSuccessResult(taskList), nil
337}
338
339// handleDeleteTasks handles the delete_tasks tool call
340func (s *Server) handleDeleteTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
341	s.logger.Info("Received delete_tasks tool call")
342
343	// Parse request
344	var req DeleteTasksRequest
345	if err := parseRequest(request.GetArguments(), &req); err != nil {
346		return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
347	}
348
349	// Validate request
350	if err := s.validator.ValidateDeleteTasksRequest(req); err != nil {
351		return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
352	}
353
354	// Delete tasks
355	if err := s.planner.DeleteTasks(req.TaskIDs); err != nil {
356		s.logger.Error("Failed to delete tasks", "error", err)
357		return createErrorResult(fmt.Sprintf("Error deleting tasks: %v", err)), nil
358	}
359
360	// Return full task list
361	taskList := s.planner.GetTasks()
362	return createSuccessResult(taskList), nil
363}
364
365// handleModifyTask handles the modify_task tool call
366func (s *Server) handleModifyTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
367	s.logger.Info("Received modify_task tool call")
368
369	// Parse request
370	var req ModifyTaskRequest
371	if err := parseRequest(request.GetArguments(), &req); err != nil {
372		return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
373	}
374
375	// Validate request
376	if err := s.validator.ValidateModifyTaskRequest(req); err != nil {
377		return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
378	}
379
380	// Modify task
381	if err := s.planner.ModifyTask(req.TaskID, req.Title, req.Description); err != nil {
382		s.logger.Error("Failed to modify task", "error", err)
383		return createErrorResult(fmt.Sprintf("Error modifying task: %v", err)), nil
384	}
385
386	// Return full task list
387	taskList := s.planner.GetTasks()
388	return createSuccessResult(taskList), nil
389}
390
391// GetServer returns the underlying MCP server
392func (s *Server) GetServer() *server.MCPServer {
393	return s.server
394}