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
126// handleUpdateGoal handles the update_goal tool call
127func (s *Server) handleUpdateGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
128 s.logger.Info("Received update_goal tool call")
129
130 // Extract parameters
131 arguments := request.GetArguments()
132 goal, ok := arguments["goal"].(string)
133 if !ok || goal == "" {
134 return &mcp.CallToolResult{
135 Content: []mcp.Content{
136 mcp.TextContent{
137 Type: "text",
138 Text: "Error: goal parameter is required and must be a string",
139 },
140 },
141 IsError: true,
142 }, nil
143 }
144
145 // Update goal
146 if err := s.planner.UpdateGoal(goal); err != nil {
147 s.logger.Error("Failed to update goal", "error", err)
148 return &mcp.CallToolResult{
149 Content: []mcp.Content{
150 mcp.TextContent{
151 Type: "text",
152 Text: fmt.Sprintf("Error updating goal: %v", err),
153 },
154 },
155 IsError: true,
156 }, nil
157 }
158
159 response := fmt.Sprintf("Goal \"%s\" saved! You probably want to add one or more tasks now.", goal)
160 return &mcp.CallToolResult{
161 Content: []mcp.Content{
162 mcp.TextContent{
163 Type: "text",
164 Text: response,
165 },
166 },
167 }, nil
168}
169
170// handleAddTasks handles the add_tasks tool call
171func (s *Server) handleAddTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
172 s.logger.Info("Received add_tasks tool call")
173
174 // Extract parameters
175 arguments := request.GetArguments()
176 tasksRaw, ok := arguments["tasks"]
177 if !ok {
178 return &mcp.CallToolResult{
179 Content: []mcp.Content{
180 mcp.TextContent{
181 Type: "text",
182 Text: "Error: tasks parameter is required",
183 },
184 },
185 IsError: true,
186 }, nil
187 }
188
189 // Convert to slice of interfaces
190 tasksSlice, ok := tasksRaw.([]any)
191 if !ok {
192 return &mcp.CallToolResult{
193 Content: []mcp.Content{
194 mcp.TextContent{
195 Type: "text",
196 Text: "Error: tasks parameter must be an array",
197 },
198 },
199 IsError: true,
200 }, nil
201 }
202
203 // Parse tasks
204 tasks := make([]planning.TaskInput, 0, len(tasksSlice))
205 for _, taskRaw := range tasksSlice {
206 taskMap, ok := taskRaw.(map[string]any)
207 if !ok {
208 return &mcp.CallToolResult{
209 Content: []mcp.Content{
210 mcp.TextContent{
211 Type: "text",
212 Text: "Error: each task must be an object",
213 },
214 },
215 IsError: true,
216 }, nil
217 }
218
219 title, ok := taskMap["title"].(string)
220 if !ok || title == "" {
221 return &mcp.CallToolResult{
222 Content: []mcp.Content{
223 mcp.TextContent{
224 Type: "text",
225 Text: "Error: each task must have a non-empty title",
226 },
227 },
228 IsError: true,
229 }, nil
230 }
231
232 description, _ := taskMap["description"].(string)
233
234 tasks = append(tasks, planning.TaskInput{
235 Title: title,
236 Description: description,
237 })
238 }
239
240 // Add tasks
241 result, err := s.planner.AddTasks(tasks)
242 if err != nil {
243 s.logger.Error("Failed to add tasks", "error", err)
244 return &mcp.CallToolResult{
245 Content: []mcp.Content{
246 mcp.TextContent{
247 Type: "text",
248 Text: fmt.Sprintf("Error adding tasks: %v", err),
249 },
250 },
251 IsError: true,
252 }, nil
253 }
254
255 // Get the full task list with goal and legend
256 taskList := s.planner.GetTasks()
257
258 var response string
259 if !result.HadExistingTasks {
260 // No existing tasks - show verbose instructions + task list
261 goal := s.planner.GetGoal()
262 goalText := "your planning session"
263 if goal != nil {
264 goalText = fmt.Sprintf("\"%s\"", goal.Text)
265 }
266 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)
267 } else {
268 // Had existing tasks - just show the task list (like get_tasks)
269 response = taskList
270 }
271 return &mcp.CallToolResult{
272 Content: []mcp.Content{
273 mcp.TextContent{
274 Type: "text",
275 Text: response,
276 },
277 },
278 }, nil
279}
280
281// handleGetTasks handles the get_tasks tool call
282func (s *Server) handleGetTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
283 s.logger.Info("Received get_tasks tool call")
284
285 taskList := s.planner.GetTasks()
286
287 return &mcp.CallToolResult{
288 Content: []mcp.Content{
289 mcp.TextContent{
290 Type: "text",
291 Text: taskList,
292 },
293 },
294 }, nil
295}
296
297// handleUpdateTasks handles the update_tasks tool call
298func (s *Server) handleUpdateTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
299 s.logger.Info("Received update_tasks tool call")
300
301 // Extract parameters
302 arguments := request.GetArguments()
303 tasksRaw, ok := arguments["tasks"]
304 if !ok {
305 return &mcp.CallToolResult{
306 Content: []mcp.Content{
307 mcp.TextContent{
308 Type: "text",
309 Text: "Error: tasks parameter is required",
310 },
311 },
312 IsError: true,
313 }, nil
314 }
315
316 // Convert to slice of interfaces
317 tasksSlice, ok := tasksRaw.([]any)
318 if !ok {
319 return &mcp.CallToolResult{
320 Content: []mcp.Content{
321 mcp.TextContent{
322 Type: "text",
323 Text: "Error: tasks parameter must be an array",
324 },
325 },
326 IsError: true,
327 }, nil
328 }
329
330 if len(tasksSlice) == 0 {
331 return &mcp.CallToolResult{
332 Content: []mcp.Content{
333 mcp.TextContent{
334 Type: "text",
335 Text: "Error: at least one task update is required",
336 },
337 },
338 IsError: true,
339 }, nil
340 }
341
342 // Parse task updates
343 updates := make([]planning.TaskUpdate, 0, len(tasksSlice))
344 for _, taskRaw := range tasksSlice {
345 taskMap, ok := taskRaw.(map[string]any)
346 if !ok {
347 return &mcp.CallToolResult{
348 Content: []mcp.Content{
349 mcp.TextContent{
350 Type: "text",
351 Text: "Error: each task update must be an object",
352 },
353 },
354 IsError: true,
355 }, nil
356 }
357
358 taskID, ok := taskMap["task_id"].(string)
359 if !ok || taskID == "" {
360 return &mcp.CallToolResult{
361 Content: []mcp.Content{
362 mcp.TextContent{
363 Type: "text",
364 Text: "Error: each task update must have a non-empty task_id",
365 },
366 },
367 IsError: true,
368 }, nil
369 }
370
371 statusStr, ok := taskMap["status"].(string)
372 if !ok || statusStr == "" {
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 status",
378 },
379 },
380 IsError: true,
381 }, nil
382 }
383
384 // Parse status
385 status := planning.ParseStatus(statusStr)
386
387 updates = append(updates, planning.TaskUpdate{
388 TaskID: taskID,
389 Status: status,
390 })
391 }
392
393 // Update task statuses
394 if err := s.planner.UpdateTasks(updates); err != nil {
395 s.logger.Error("Failed to update task statuses", "error", err)
396 return &mcp.CallToolResult{
397 Content: []mcp.Content{
398 mcp.TextContent{
399 Type: "text",
400 Text: fmt.Sprintf("Error updating task statuses: %v", err),
401 },
402 },
403 IsError: true,
404 }, nil
405 }
406
407 // Return full task list
408 taskList := s.planner.GetTasks()
409 return &mcp.CallToolResult{
410 Content: []mcp.Content{
411 mcp.TextContent{
412 Type: "text",
413 Text: taskList,
414 },
415 },
416 }, nil
417}
418
419// GetServer returns the underlying MCP server
420func (s *Server) GetServer() *server.MCPServer {
421 return s.server
422}