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 project_management__set_goal tool
64 setGoalTool := mcp.NewTool("project_management__set_goal",
65 mcp.WithDescription("Set the initial project goal. Returns error if already set and encourages calling project_management__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 project_management__change_goal tool
78 changeGoalTool := mcp.NewTool("project_management__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 project_management__add_tasks tool
96 addTasksTool := mcp.NewTool("project_management__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 project_management__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 project_management__get_tasks tool
120 getTasksTool := mcp.NewTool("project_management__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 project_management__update_task_statuses tool
129 updateTasksTool := mcp.NewTool("project_management__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 project_management__delete_tasks tool
153 deleteTasksTool := mcp.NewTool("project_management__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 project_management__set_goal tool call
167func (s *Server) handleSetGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
168 s.logger.Info("Received project_management__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 project_management__change_goal tool call
193func (s *Server) handleChangeGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
194 s.logger.Info("Received project_management__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 project_management__add_tasks tool call
219func (s *Server) handleAddTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
220 s.logger.Info("Received project_management__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 // No existing tasks - show verbose instructions + task list
255 goal := s.planner.GetGoal()
256 goalText := "your planning session"
257 if goal != nil {
258 goalText = fmt.Sprintf("\"%s\"", goal.Text)
259 }
260 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)
261 } else {
262 // Had existing tasks - just show the task list (like get_tasks)
263 response = taskList
264 }
265 return createSuccessResult(response), nil
266}
267
268// handleGetTasks handles the project_management__get_tasks tool call
269func (s *Server) handleGetTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
270 s.logger.Info("Received project_management__get_tasks tool call")
271
272 // Parse request
273 var req GetTasksRequest
274 if err := parseRequest(request.GetArguments(), &req); err != nil {
275 return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
276 }
277
278 // Validate request
279 if err := s.validator.ValidateGetTasksRequest(req); err != nil {
280 return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
281 }
282
283 // Default status to "all" if empty
284 statusFilter := req.Status
285 if statusFilter == "" {
286 statusFilter = "all"
287 }
288
289 var taskList string
290 if statusFilter == "all" {
291 taskList = s.planner.GetTasks()
292 } else {
293 taskList = s.planner.GetTasksByStatus(statusFilter)
294 }
295
296 return createSuccessResult(taskList), nil
297}
298
299// handleUpdateTaskStatuses handles the project_management__update_task_statuses tool call
300func (s *Server) handleUpdateTaskStatuses(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
301 s.logger.Info("Received project_management__update_task_statuses tool call")
302
303 // Parse request
304 var req UpdateTaskStatusesRequest
305 if err := parseRequest(request.GetArguments(), &req); err != nil {
306 return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
307 }
308
309 // Validate request
310 if err := s.validator.ValidateUpdateTaskStatusesRequest(req); err != nil {
311 return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
312 }
313
314 // Convert MCP task update inputs to planning task updates
315 updates := make([]planning.TaskUpdate, 0, len(req.Tasks))
316 for _, mcpUpdate := range req.Tasks {
317 updates = append(updates, planning.TaskUpdate{
318 TaskID: mcpUpdate.TaskID,
319 Status: planning.ParseStatus(mcpUpdate.Status),
320 })
321 }
322
323 // Update task statuses
324 if err := s.planner.UpdateTasks(updates); err != nil {
325 s.logger.Error("Failed to update task statuses", "error", err)
326 return createErrorResult(fmt.Sprintf("Error updating task statuses: %v", err)), nil
327 }
328
329 // Return full task list
330 taskList := s.planner.GetTasks()
331 return createSuccessResult(taskList), nil
332}
333
334// handleDeleteTasks handles the project_management__delete_tasks tool call
335func (s *Server) handleDeleteTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
336 s.logger.Info("Received project_management__delete_tasks tool call")
337
338 // Parse request
339 var req DeleteTasksRequest
340 if err := parseRequest(request.GetArguments(), &req); err != nil {
341 return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
342 }
343
344 // Validate request
345 if err := s.validator.ValidateDeleteTasksRequest(req); err != nil {
346 return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
347 }
348
349 // Delete tasks
350 if err := s.planner.DeleteTasks(req.TaskIDs); err != nil {
351 s.logger.Error("Failed to delete tasks", "error", err)
352 return createErrorResult(fmt.Sprintf("Error deleting tasks: %v", err)), nil
353 }
354
355 // Return full task list
356 taskList := s.planner.GetTasks()
357 return createSuccessResult(taskList), nil
358}
359
360// GetServer returns the underlying MCP server
361func (s *Server) GetServer() *server.MCPServer {
362 return s.server
363}