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.secluded.site/planning-mcp-server/internal/config"
16 "git.secluded.site/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 // Register modify_task tool
162 modifyTaskTool := mcp.NewTool("modify_task",
163 mcp.WithDescription("Modify the title and/or description of a task. ID and at least one of title/description are required. When one or the other is omitted, that field will not be modified."),
164 mcp.WithString("task_id",
165 mcp.Required(),
166 mcp.Description("ID of the task to modify"),
167 ),
168 mcp.WithString("title",
169 mcp.Description("New title for the task (optional - if omitted, title remains unchanged)"),
170 ),
171 mcp.WithString("description",
172 mcp.Description("New description for the task (optional - if omitted, description remains unchanged)"),
173 ),
174 )
175 mcpServer.AddTool(modifyTaskTool, s.handleModifyTask)
176}
177
178// handleSetGoal handles the set_goal tool call
179func (s *Server) handleSetGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
180 s.logger.Info("Received set_goal tool call")
181
182 // Parse request
183 var req SetGoalRequest
184 if err := parseRequest(request.GetArguments(), &req); err != nil {
185 return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
186 }
187
188 // Validate request
189 if err := s.validator.ValidateSetGoalRequest(req); err != nil {
190 return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
191 }
192
193 // Set goal
194 if err := s.planner.SetGoal(req.Title, req.Description); err != nil {
195 s.logger.Error("Failed to set goal", "error", err)
196 return createErrorResult(fmt.Sprintf("Error setting goal: %v", err)), nil
197 }
198
199 goalText := formatGoalText(req.Title, req.Description)
200 response := fmt.Sprintf("Goal \"%s\" saved! You probably want to add one or more tasks now.", goalText)
201 return createSuccessResult(response), nil
202}
203
204// handleChangeGoal handles the change_goal tool call
205func (s *Server) handleChangeGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
206 s.logger.Info("Received change_goal tool call")
207
208 // Parse request
209 var req ChangeGoalRequest
210 if err := parseRequest(request.GetArguments(), &req); err != nil {
211 return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
212 }
213
214 // Validate request
215 if err := s.validator.ValidateChangeGoalRequest(req); err != nil {
216 return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
217 }
218
219 // Change goal
220 if err := s.planner.ChangeGoal(req.Title, req.Description, req.Reason); err != nil {
221 s.logger.Error("Failed to change goal", "error", err)
222 return createErrorResult(fmt.Sprintf("Error changing goal: %v", err)), nil
223 }
224
225 goalText := formatGoalText(req.Title, req.Description)
226 response := fmt.Sprintf("Goal changed to \"%s\" (reason: %s). You probably want to add one or more tasks now.", goalText, req.Reason)
227 return createSuccessResult(response), nil
228}
229
230// handleAddTasks handles the add_tasks tool call
231func (s *Server) handleAddTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
232 s.logger.Info("Received add_tasks tool call")
233
234 // Parse request
235 var req AddTasksRequest
236 if err := parseRequest(request.GetArguments(), &req); err != nil {
237 return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
238 }
239
240 // Validate request
241 if err := s.validator.ValidateAddTasksRequest(req); err != nil {
242 return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
243 }
244
245 // Convert MCP task inputs to planning task inputs
246 tasks := make([]planning.TaskInput, 0, len(req.Tasks))
247 for _, mcpTask := range req.Tasks {
248 tasks = append(tasks, planning.TaskInput{
249 Title: mcpTask.Title,
250 Description: mcpTask.Description,
251 })
252 }
253
254 // Add tasks
255 result, err := s.planner.AddTasks(tasks)
256 if err != nil {
257 s.logger.Error("Failed to add tasks", "error", err)
258 return createErrorResult(fmt.Sprintf("Error adding tasks: %v", err)), nil
259 }
260
261 // Get the full task list with goal and legend
262 taskList := s.planner.GetTasks()
263
264 var response string
265 if !result.HadExistingTasks {
266 response = fmt.Sprintf("Tasks added successfully! Get started on your first one once you're ready. If you're updating statuses as you should, you may call `get_tasks` less frequently because `update_task_statuses` prints the revised list.\n\n%s", taskList)
267 } else {
268 response = taskList
269 }
270 return createSuccessResult(response), nil
271}
272
273// handleGetTasks handles the get_tasks tool call
274func (s *Server) handleGetTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
275 s.logger.Info("Received get_tasks tool call")
276
277 // Parse request
278 var req GetTasksRequest
279 if err := parseRequest(request.GetArguments(), &req); err != nil {
280 return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
281 }
282
283 // Validate request
284 if err := s.validator.ValidateGetTasksRequest(req); err != nil {
285 return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
286 }
287
288 // Default status to "all" if empty
289 statusFilter := req.Status
290 if statusFilter == "" {
291 statusFilter = "all"
292 }
293
294 var taskList string
295 if statusFilter == "all" {
296 taskList = s.planner.GetTasks()
297 } else {
298 taskList = s.planner.GetTasksByStatus(statusFilter)
299 }
300
301 return createSuccessResult(taskList), nil
302}
303
304// handleUpdateTaskStatuses handles the update_task_statuses tool call
305func (s *Server) handleUpdateTaskStatuses(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
306 s.logger.Info("Received update_task_statuses tool call")
307
308 // Parse request
309 var req UpdateTaskStatusesRequest
310 if err := parseRequest(request.GetArguments(), &req); err != nil {
311 return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
312 }
313
314 // Validate request
315 if err := s.validator.ValidateUpdateTaskStatusesRequest(req); err != nil {
316 return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
317 }
318
319 // Convert MCP task update inputs to planning task updates
320 updates := make([]planning.TaskUpdate, 0, len(req.Tasks))
321 for _, mcpUpdate := range req.Tasks {
322 updates = append(updates, planning.TaskUpdate{
323 TaskID: mcpUpdate.TaskID,
324 Status: planning.ParseStatus(mcpUpdate.Status),
325 })
326 }
327
328 // Update task statuses
329 if err := s.planner.UpdateTasks(updates); err != nil {
330 s.logger.Error("Failed to update task statuses", "error", err)
331 return createErrorResult(fmt.Sprintf("Error updating task statuses: %v", err)), nil
332 }
333
334 // Return full task list
335 taskList := s.planner.GetTasks()
336 return createSuccessResult(taskList), nil
337}
338
339// handleDeleteTasks handles the delete_tasks tool call
340func (s *Server) handleDeleteTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
341 s.logger.Info("Received delete_tasks tool call")
342
343 // Parse request
344 var req DeleteTasksRequest
345 if err := parseRequest(request.GetArguments(), &req); err != nil {
346 return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
347 }
348
349 // Validate request
350 if err := s.validator.ValidateDeleteTasksRequest(req); err != nil {
351 return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
352 }
353
354 // Delete tasks
355 if err := s.planner.DeleteTasks(req.TaskIDs); err != nil {
356 s.logger.Error("Failed to delete tasks", "error", err)
357 return createErrorResult(fmt.Sprintf("Error deleting tasks: %v", err)), nil
358 }
359
360 // Return full task list
361 taskList := s.planner.GetTasks()
362 return createSuccessResult(taskList), nil
363}
364
365// handleModifyTask handles the modify_task tool call
366func (s *Server) handleModifyTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
367 s.logger.Info("Received modify_task tool call")
368
369 // Parse request
370 var req ModifyTaskRequest
371 if err := parseRequest(request.GetArguments(), &req); err != nil {
372 return createErrorResult(fmt.Sprintf("Invalid request format: %v", err)), nil
373 }
374
375 // Validate request
376 if err := s.validator.ValidateModifyTaskRequest(req); err != nil {
377 return createErrorResult(fmt.Sprintf("Validation error: %v", err)), nil
378 }
379
380 // Modify task
381 if err := s.planner.ModifyTask(req.TaskID, req.Title, req.Description); err != nil {
382 s.logger.Error("Failed to modify task", "error", err)
383 return createErrorResult(fmt.Sprintf("Error modifying task: %v", err)), nil
384 }
385
386 // Return full task list
387 taskList := s.planner.GetTasks()
388 return createSuccessResult(taskList), nil
389}
390
391// GetServer returns the underlying MCP server
392func (s *Server) GetServer() *server.MCPServer {
393 return s.server
394}