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 // Parse and validate request
169 var req SetGoalRequest
170 if err := parseAndValidate(request.GetArguments(), &req); err != nil {
171 return createErrorResult(fmt.Sprintf("Invalid request: %v", err)), nil
172 }
173
174 // Set goal
175 if err := s.planner.SetGoal(req.Title, req.Description); err != nil {
176 s.logger.Error("Failed to set goal", "error", err)
177 return createErrorResult(fmt.Sprintf("Error setting goal: %v", err)), nil
178 }
179
180 goalText := formatGoalText(req.Title, req.Description)
181 response := fmt.Sprintf("Goal \"%s\" saved! You probably want to add one or more tasks now.", goalText)
182 return createSuccessResult(response), nil
183}
184
185// handleChangeGoal handles the project_management__change_goal tool call
186func (s *Server) handleChangeGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
187 s.logger.Info("Received project_management__change_goal tool call")
188
189 // Parse and validate request
190 var req ChangeGoalRequest
191 if err := parseAndValidate(request.GetArguments(), &req); err != nil {
192 return createErrorResult(fmt.Sprintf("Invalid request: %v", err)), nil
193 }
194
195 // Change goal
196 if err := s.planner.ChangeGoal(req.Title, req.Description, req.Reason); err != nil {
197 s.logger.Error("Failed to change goal", "error", err)
198 return createErrorResult(fmt.Sprintf("Error changing goal: %v", err)), nil
199 }
200
201 goalText := formatGoalText(req.Title, req.Description)
202 response := fmt.Sprintf("Goal changed to \"%s\" (reason: %s). You probably want to add one or more tasks now.", goalText, req.Reason)
203 return createSuccessResult(response), nil
204}
205
206// handleAddTasks handles the project_management__add_tasks tool call
207func (s *Server) handleAddTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
208 s.logger.Info("Received project_management__add_tasks tool call")
209
210 // Parse and validate request
211 var req AddTasksRequest
212 if err := parseAndValidate(request.GetArguments(), &req); err != nil {
213 return createErrorResult(fmt.Sprintf("Invalid request: %v", err)), nil
214 }
215
216 // Convert MCP task inputs to planning task inputs
217 tasks := make([]planning.TaskInput, 0, len(req.Tasks))
218 for _, mcpTask := range req.Tasks {
219 tasks = append(tasks, planning.TaskInput{
220 Title: mcpTask.Title,
221 Description: mcpTask.Description,
222 })
223 }
224
225 // Add tasks
226 result, err := s.planner.AddTasks(tasks)
227 if err != nil {
228 s.logger.Error("Failed to add tasks", "error", err)
229 return createErrorResult(fmt.Sprintf("Error adding tasks: %v", err)), nil
230 }
231
232 // Get the full task list with goal and legend
233 taskList := s.planner.GetTasks()
234
235 var response string
236 if !result.HadExistingTasks {
237 // No existing tasks - show verbose instructions + task list
238 goal := s.planner.GetGoal()
239 goalText := "your planning session"
240 if goal != nil {
241 goalText = fmt.Sprintf("\"%s\"", goal.Text)
242 }
243 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)
244 } else {
245 // Had existing tasks - just show the task list (like get_tasks)
246 response = taskList
247 }
248 return createSuccessResult(response), nil
249}
250
251// handleGetTasks handles the project_management__get_tasks tool call
252func (s *Server) handleGetTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
253 s.logger.Info("Received project_management__get_tasks tool call")
254
255 // Parse and validate request
256 var req GetTasksRequest
257 if err := parseAndValidate(request.GetArguments(), &req); err != nil {
258 return createErrorResult(fmt.Sprintf("Invalid request: %v", err)), nil
259 }
260
261 // Default status to "all" if empty
262 statusFilter := req.Status
263 if statusFilter == "" {
264 statusFilter = "all"
265 }
266
267 var taskList string
268 if statusFilter == "all" {
269 taskList = s.planner.GetTasks()
270 } else {
271 taskList = s.planner.GetTasksByStatus(statusFilter)
272 }
273
274 return createSuccessResult(taskList), nil
275}
276
277// handleUpdateTaskStatuses handles the project_management__update_task_statuses tool call
278func (s *Server) handleUpdateTaskStatuses(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
279 s.logger.Info("Received project_management__update_task_statuses tool call")
280
281 // Parse and validate request
282 var req UpdateTaskStatusesRequest
283 if err := parseAndValidate(request.GetArguments(), &req); err != nil {
284 return createErrorResult(fmt.Sprintf("Invalid request: %v", err)), nil
285 }
286
287 // Convert MCP task update inputs to planning task updates
288 updates := make([]planning.TaskUpdate, 0, len(req.Tasks))
289 for _, mcpUpdate := range req.Tasks {
290 updates = append(updates, planning.TaskUpdate{
291 TaskID: mcpUpdate.TaskID,
292 Status: planning.ParseStatus(mcpUpdate.Status),
293 })
294 }
295
296 // Update task statuses
297 if err := s.planner.UpdateTasks(updates); err != nil {
298 s.logger.Error("Failed to update task statuses", "error", err)
299 return createErrorResult(fmt.Sprintf("Error updating task statuses: %v", err)), nil
300 }
301
302 // Return full task list
303 taskList := s.planner.GetTasks()
304 return createSuccessResult(taskList), nil
305}
306
307// handleDeleteTasks handles the project_management__delete_tasks tool call
308func (s *Server) handleDeleteTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
309 s.logger.Info("Received project_management__delete_tasks tool call")
310
311 // Parse and validate request
312 var req DeleteTasksRequest
313 if err := parseAndValidate(request.GetArguments(), &req); err != nil {
314 return createErrorResult(fmt.Sprintf("Invalid request: %v", err)), nil
315 }
316
317 // Delete tasks
318 if err := s.planner.DeleteTasks(req.TaskIDs); err != nil {
319 s.logger.Error("Failed to delete tasks", "error", err)
320 return createErrorResult(fmt.Sprintf("Error deleting tasks: %v", err)), nil
321 }
322
323 // Return full task list
324 taskList := s.planner.GetTasks()
325 return createSuccessResult(taskList), nil
326}
327
328// GetServer returns the underlying MCP server
329func (s *Server) GetServer() *server.MCPServer {
330 return s.server
331}