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 session goal. If this is a new conversation, use me first. Otherwise, use change_goal and include a reason."),
66 mcp.WithString("title",
67 mcp.Required(),
68 mcp.Description("Short, imperative, sentence-case phrase concisely describing the session's overarching goal"),
69 ),
70 mcp.WithString("description",
71 mcp.Required(),
72 mcp.Description("More comprehensive, paragraph-style description capturing additional nuance and detail"),
73 ),
74 )
75 mcpServer.AddTool(setGoalTool, s.handleSetGoal)
76
77 // Register change_goal tool
78 changeGoalTool := mcp.NewTool("change_goal",
79 mcp.WithDescription("Alter the existing session 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 ),
83 mcp.WithString("description",
84 mcp.Required(),
85 ),
86 mcp.WithString("reason",
87 mcp.Required(),
88 mcp.Description("_Must_ include adequate justification; doesn't have to be long, just complete. If you find the goal requires adjusting, do not just change it on your own. Suggest the change to the user and only call me with their consent. Examples: 'User requested doing x, y, and z' or 'We assumed X was true, but I discovered Y. User consented to the change.'"),
89 ),
90 )
91 mcpServer.AddTool(changeGoalTool, s.handleChangeGoal)
92
93 // Register add_tasks tool
94 addTasksTool := mcp.NewTool("add_tasks",
95 mcp.WithDescription("Add one or more tasks to work on. Break them down into the smallest, complete 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."),
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": "Imperative, sentence-case phrase completely describing the task",
105 },
106 "description": map[string]any{
107 "type": "string",
108 "description": "If the title isn't enough, use this field to capture additional nuance in a single paragraph. If title is enough, leave this field empty.",
109 },
110 },
111 "required": []string{"title"},
112 }),
113 ),
114 )
115 mcpServer.AddTool(addTasksTool, s.handleAddTasks)
116
117 // Register get_tasks tool
118 getTasksTool := mcp.NewTool("get_tasks",
119 mcp.WithDescription("Get the goal and list of tasks. Prefer to call with 'all' or 'pending', only 'completed' if unsure, only 'cancelled' or 'failed' if the operator explicitly asks. The update tool prints the revised list, so calling me isn't always necessary."),
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 update_task_statuses tool
127 updateTasksTool := mcp.NewTool("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 },
138 "status": map[string]any{
139 "type": "string",
140 },
141 },
142 "required": []string{"task_id", "status"},
143 }),
144 ),
145 )
146 mcpServer.AddTool(updateTasksTool, s.handleUpdateTaskStatuses)
147
148 // Register delete_tasks tool
149 deleteTasksTool := mcp.NewTool("delete_tasks",
150 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."),
151 mcp.WithArray("task_ids",
152 mcp.Required(),
153 mcp.Description("Array of task IDs to delete, can be one or many"),
154 mcp.Items(map[string]any{
155 "type": "string",
156 }),
157 ),
158 )
159 mcpServer.AddTool(deleteTasksTool, s.handleDeleteTasks)
160}
161
162// handleSetGoal handles the set_goal tool call
163func (s *Server) handleSetGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
164 s.logger.Info("Received set_goal tool call")
165
166 // Parse request
167 var req SetGoalRequest
168 if err := parseRequest(request.GetArguments(), &req); err != nil {
169 return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
170 }
171
172 // Validate request
173 if err := s.validator.ValidateSetGoalRequest(req); err != nil {
174 return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
175 }
176
177 // Set goal
178 if err := s.planner.SetGoal(req.Title, req.Description); err != nil {
179 s.logger.Error("Failed to set goal", "error", err)
180 return createErrorResult(fmt.Sprintf("Error setting goal: %v", err)), nil
181 }
182
183 goalText := formatGoalText(req.Title, req.Description)
184 response := fmt.Sprintf("Goal \"%s\" saved! You probably want to add one or more tasks now.", goalText)
185 return createSuccessResult(response), nil
186}
187
188// handleChangeGoal handles the change_goal tool call
189func (s *Server) handleChangeGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
190 s.logger.Info("Received change_goal tool call")
191
192 // Parse request
193 var req ChangeGoalRequest
194 if err := parseRequest(request.GetArguments(), &req); err != nil {
195 return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
196 }
197
198 // Validate request
199 if err := s.validator.ValidateChangeGoalRequest(req); err != nil {
200 return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
201 }
202
203 // Change goal
204 if err := s.planner.ChangeGoal(req.Title, req.Description, req.Reason); err != nil {
205 s.logger.Error("Failed to change goal", "error", err)
206 return createErrorResult(fmt.Sprintf("Error changing goal: %v", err)), nil
207 }
208
209 goalText := formatGoalText(req.Title, req.Description)
210 response := fmt.Sprintf("Goal changed to \"%s\" (reason: %s). You probably want to add one or more tasks now.", goalText, req.Reason)
211 return createSuccessResult(response), nil
212}
213
214// handleAddTasks handles the add_tasks tool call
215func (s *Server) handleAddTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
216 s.logger.Info("Received add_tasks tool call")
217
218 // Parse request
219 var req AddTasksRequest
220 if err := parseRequest(request.GetArguments(), &req); err != nil {
221 return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
222 }
223
224 // Validate request
225 if err := s.validator.ValidateAddTasksRequest(req); err != nil {
226 return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
227 }
228
229 // Convert MCP task inputs to planning task inputs
230 tasks := make([]planning.TaskInput, 0, len(req.Tasks))
231 for _, mcpTask := range req.Tasks {
232 tasks = append(tasks, planning.TaskInput{
233 Title: mcpTask.Title,
234 Description: mcpTask.Description,
235 })
236 }
237
238 // Add tasks
239 result, err := s.planner.AddTasks(tasks)
240 if err != nil {
241 s.logger.Error("Failed to add tasks", "error", err)
242 return createErrorResult(fmt.Sprintf("Error adding tasks: %v", err)), nil
243 }
244
245 // Get the full task list with goal and legend
246 taskList := s.planner.GetTasks()
247
248 var response string
249 if !result.HadExistingTasks {
250 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)
251 } else {
252 response = taskList
253 }
254 return createSuccessResult(response), nil
255}
256
257// handleGetTasks handles the get_tasks tool call
258func (s *Server) handleGetTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
259 s.logger.Info("Received get_tasks tool call")
260
261 // Parse request
262 var req GetTasksRequest
263 if err := parseRequest(request.GetArguments(), &req); err != nil {
264 return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
265 }
266
267 // Validate request
268 if err := s.validator.ValidateGetTasksRequest(req); err != nil {
269 return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
270 }
271
272 // Default status to "all" if empty
273 statusFilter := req.Status
274 if statusFilter == "" {
275 statusFilter = "all"
276 }
277
278 var taskList string
279 if statusFilter == "all" {
280 taskList = s.planner.GetTasks()
281 } else {
282 taskList = s.planner.GetTasksByStatus(statusFilter)
283 }
284
285 return createSuccessResult(taskList), nil
286}
287
288// handleUpdateTaskStatuses handles the update_task_statuses tool call
289func (s *Server) handleUpdateTaskStatuses(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
290 s.logger.Info("Received update_task_statuses tool call")
291
292 // Parse request
293 var req UpdateTaskStatusesRequest
294 if err := parseRequest(request.GetArguments(), &req); err != nil {
295 return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
296 }
297
298 // Validate request
299 if err := s.validator.ValidateUpdateTaskStatusesRequest(req); err != nil {
300 return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
301 }
302
303 // Convert MCP task update inputs to planning task updates
304 updates := make([]planning.TaskUpdate, 0, len(req.Tasks))
305 for _, mcpUpdate := range req.Tasks {
306 updates = append(updates, planning.TaskUpdate{
307 TaskID: mcpUpdate.TaskID,
308 Status: planning.ParseStatus(mcpUpdate.Status),
309 })
310 }
311
312 // Update task statuses
313 if err := s.planner.UpdateTasks(updates); err != nil {
314 s.logger.Error("Failed to update task statuses", "error", err)
315 return createErrorResult(fmt.Sprintf("Error updating task statuses: %v", err)), nil
316 }
317
318 // Return full task list
319 taskList := s.planner.GetTasks()
320 return createSuccessResult(taskList), nil
321}
322
323// handleDeleteTasks handles the delete_tasks tool call
324func (s *Server) handleDeleteTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
325 s.logger.Info("Received delete_tasks tool call")
326
327 // Parse request
328 var req DeleteTasksRequest
329 if err := parseRequest(request.GetArguments(), &req); err != nil {
330 return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
331 }
332
333 // Validate request
334 if err := s.validator.ValidateDeleteTasksRequest(req); err != nil {
335 return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
336 }
337
338 // Delete tasks
339 if err := s.planner.DeleteTasks(req.TaskIDs); err != nil {
340 s.logger.Error("Failed to delete tasks", "error", err)
341 return createErrorResult(fmt.Sprintf("Error deleting tasks: %v", err)), nil
342 }
343
344 // Return full task list
345 taskList := s.planner.GetTasks()
346 return createSuccessResult(taskList), nil
347}
348
349// GetServer returns the underlying MCP server
350func (s *Server) GetServer() *server.MCPServer {
351 return s.server
352}