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}