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 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 initial project goal. Returns error if already set and encourages calling change_goal"),
66 mcp.WithString("title",
67 mcp.Required(),
68 mcp.Description("The goal title"),
69 ),
70 mcp.WithString("description",
71 mcp.Required(),
72 mcp.Description("The goal description"),
73 ),
74 )
75 mcpServer.AddTool(setGoalTool, s.handleSetGoal)
76
77 // Register change_goal tool
78 changeGoalTool := mcp.NewTool("change_goal",
79 mcp.WithDescription("Change an existing project 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 mcp.Description("The new goal title"),
83 ),
84 mcp.WithString("description",
85 mcp.Required(),
86 mcp.Description("The new goal description"),
87 ),
88 mcp.WithString("reason",
89 mcp.Required(),
90 mcp.Description("The reason for changing the goal"),
91 ),
92 )
93 mcpServer.AddTool(changeGoalTool, s.handleChangeGoal)
94
95 // Register add_tasks tool
96 addTasksTool := mcp.NewTool("add_tasks",
97 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."),
98 mcp.WithArray("tasks",
99 mcp.Required(),
100 mcp.Description("Array of tasks to add"),
101 mcp.Items(map[string]any{
102 "type": "object",
103 "properties": map[string]any{
104 "title": map[string]any{
105 "type": "string",
106 "description": "Task title",
107 },
108 "description": map[string]any{
109 "type": "string",
110 "description": "Task description (optional)",
111 },
112 },
113 "required": []string{"title"},
114 }),
115 ),
116 )
117 mcpServer.AddTool(addTasksTool, s.handleAddTasks)
118
119 // Register get_tasks tool
120 getTasksTool := mcp.NewTool("get_tasks",
121 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."),
122 mcp.WithString("status",
123 mcp.Description("Filter tasks by status: all, pending, in_progress, completed, cancelled, or failed (default: all)"),
124 ),
125 )
126 mcpServer.AddTool(getTasksTool, s.handleGetTasks)
127
128 // Register update_task_statuses tool
129 updateTasksTool := mcp.NewTool("update_task_statuses",
130 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."),
131 mcp.WithArray("tasks",
132 mcp.Required(),
133 mcp.Description("Array of task updates"),
134 mcp.Items(map[string]any{
135 "type": "object",
136 "properties": map[string]any{
137 "task_id": map[string]any{
138 "type": "string",
139 "description": "The task ID to update",
140 },
141 "status": map[string]any{
142 "type": "string",
143 "description": "New status: pending, in_progress, completed, cancelled, or failed",
144 },
145 },
146 "required": []string{"task_id", "status"},
147 }),
148 ),
149 )
150 mcpServer.AddTool(updateTasksTool, s.handleUpdateTaskStatuses)
151
152 // Register delete_tasks tool
153 deleteTasksTool := mcp.NewTool("delete_tasks",
154 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."),
155 mcp.WithArray("task_ids",
156 mcp.Required(),
157 mcp.Description("Array of task IDs to delete"),
158 mcp.Items(map[string]any{
159 "type": "string",
160 }),
161 ),
162 )
163 mcpServer.AddTool(deleteTasksTool, s.handleDeleteTasks)
164}
165
166// handleSetGoal handles the set_goal tool call
167func (s *Server) handleSetGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
168 s.logger.Info("Received set_goal tool call")
169
170 // Parse request
171 var req SetGoalRequest
172 if err := parseRequest(request.GetArguments(), &req); err != nil {
173 return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
174 }
175
176 // Validate request
177 if err := s.validator.ValidateSetGoalRequest(req); err != nil {
178 return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
179 }
180
181 // Set goal
182 if err := s.planner.SetGoal(req.Title, req.Description); err != nil {
183 s.logger.Error("Failed to set goal", "error", err)
184 return createErrorResult(fmt.Sprintf("Error setting goal: %v", err)), nil
185 }
186
187 goalText := formatGoalText(req.Title, req.Description)
188 response := fmt.Sprintf("Goal \"%s\" saved! You probably want to add one or more tasks now.", goalText)
189 return createSuccessResult(response), nil
190}
191
192// handleChangeGoal handles the change_goal tool call
193func (s *Server) handleChangeGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
194 s.logger.Info("Received change_goal tool call")
195
196 // Parse request
197 var req ChangeGoalRequest
198 if err := parseRequest(request.GetArguments(), &req); err != nil {
199 return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
200 }
201
202 // Validate request
203 if err := s.validator.ValidateChangeGoalRequest(req); err != nil {
204 return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
205 }
206
207 // Change goal
208 if err := s.planner.ChangeGoal(req.Title, req.Description, req.Reason); err != nil {
209 s.logger.Error("Failed to change goal", "error", err)
210 return createErrorResult(fmt.Sprintf("Error changing goal: %v", err)), nil
211 }
212
213 goalText := formatGoalText(req.Title, req.Description)
214 response := fmt.Sprintf("Goal changed to \"%s\" (reason: %s). You probably want to add one or more tasks now.", goalText, req.Reason)
215 return createSuccessResult(response), nil
216}
217
218// handleAddTasks handles the add_tasks tool call
219func (s *Server) handleAddTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
220 s.logger.Info("Received add_tasks tool call")
221
222 // Parse request
223 var req AddTasksRequest
224 if err := parseRequest(request.GetArguments(), &req); err != nil {
225 return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
226 }
227
228 // Validate request
229 if err := s.validator.ValidateAddTasksRequest(req); err != nil {
230 return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
231 }
232
233 // Convert MCP task inputs to planning task inputs
234 tasks := make([]planning.TaskInput, 0, len(req.Tasks))
235 for _, mcpTask := range req.Tasks {
236 tasks = append(tasks, planning.TaskInput{
237 Title: mcpTask.Title,
238 Description: mcpTask.Description,
239 })
240 }
241
242 // Add tasks
243 result, err := s.planner.AddTasks(tasks)
244 if err != nil {
245 s.logger.Error("Failed to add tasks", "error", err)
246 return createErrorResult(fmt.Sprintf("Error adding tasks: %v", err)), nil
247 }
248
249 // Get the full task list with goal and legend
250 taskList := s.planner.GetTasks()
251
252 var response string
253 if !result.HadExistingTasks {
254 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.\n\n%s", taskList)
255 } else {
256 response = taskList
257 }
258 return createSuccessResult(response), nil
259}
260
261// handleGetTasks handles the get_tasks tool call
262func (s *Server) handleGetTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
263 s.logger.Info("Received get_tasks tool call")
264
265 // Parse request
266 var req GetTasksRequest
267 if err := parseRequest(request.GetArguments(), &req); err != nil {
268 return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
269 }
270
271 // Validate request
272 if err := s.validator.ValidateGetTasksRequest(req); err != nil {
273 return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
274 }
275
276 // Default status to "all" if empty
277 statusFilter := req.Status
278 if statusFilter == "" {
279 statusFilter = "all"
280 }
281
282 var taskList string
283 if statusFilter == "all" {
284 taskList = s.planner.GetTasks()
285 } else {
286 taskList = s.planner.GetTasksByStatus(statusFilter)
287 }
288
289 return createSuccessResult(taskList), nil
290}
291
292// handleUpdateTaskStatuses handles the update_task_statuses tool call
293func (s *Server) handleUpdateTaskStatuses(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
294 s.logger.Info("Received update_task_statuses tool call")
295
296 // Parse request
297 var req UpdateTaskStatusesRequest
298 if err := parseRequest(request.GetArguments(), &req); err != nil {
299 return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
300 }
301
302 // Validate request
303 if err := s.validator.ValidateUpdateTaskStatusesRequest(req); err != nil {
304 return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
305 }
306
307 // Convert MCP task update inputs to planning task updates
308 updates := make([]planning.TaskUpdate, 0, len(req.Tasks))
309 for _, mcpUpdate := range req.Tasks {
310 updates = append(updates, planning.TaskUpdate{
311 TaskID: mcpUpdate.TaskID,
312 Status: planning.ParseStatus(mcpUpdate.Status),
313 })
314 }
315
316 // Update task statuses
317 if err := s.planner.UpdateTasks(updates); err != nil {
318 s.logger.Error("Failed to update task statuses", "error", err)
319 return createErrorResult(fmt.Sprintf("Error updating task statuses: %v", err)), nil
320 }
321
322 // Return full task list
323 taskList := s.planner.GetTasks()
324 return createSuccessResult(taskList), nil
325}
326
327// handleDeleteTasks handles the delete_tasks tool call
328func (s *Server) handleDeleteTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
329 s.logger.Info("Received delete_tasks tool call")
330
331 // Parse request
332 var req DeleteTasksRequest
333 if err := parseRequest(request.GetArguments(), &req); err != nil {
334 return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
335 }
336
337 // Validate request
338 if err := s.validator.ValidateDeleteTasksRequest(req); err != nil {
339 return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
340 }
341
342 // Delete tasks
343 if err := s.planner.DeleteTasks(req.TaskIDs); err != nil {
344 s.logger.Error("Failed to delete tasks", "error", err)
345 return createErrorResult(fmt.Sprintf("Error deleting tasks: %v", err)), nil
346 }
347
348 // Return full task list
349 taskList := s.planner.GetTasks()
350 return createSuccessResult(taskList), nil
351}
352
353// GetServer returns the underlying MCP server
354func (s *Server) GetServer() *server.MCPServer {
355 return s.server
356}