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}