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_task_status tool
102 updateTaskStatusTool := mcp.NewTool("update_task_status",
103 mcp.WithDescription("Update the status of a specific task. Maintain your planning workflow by regularly updating task statuses as you make progress."),
104 mcp.WithString("task_id",
105 mcp.Required(),
106 mcp.Description("The task ID to update"),
107 ),
108 mcp.WithString("status",
109 mcp.Required(),
110 mcp.Description("New status: pending, in_progress, completed, or failed"),
111 ),
112 )
113 mcpServer.AddTool(updateTaskStatusTool, s.handleUpdateTaskStatus)
114}
115
116// handleUpdateGoal handles the update_goal tool call
117func (s *Server) handleUpdateGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
118 s.logger.Info("Received update_goal tool call")
119
120 // Extract parameters
121 arguments := request.GetArguments()
122 goal, ok := arguments["goal"].(string)
123 if !ok || goal == "" {
124 return &mcp.CallToolResult{
125 Content: []mcp.Content{
126 mcp.TextContent{
127 Type: "text",
128 Text: "Error: goal parameter is required and must be a string",
129 },
130 },
131 IsError: true,
132 }, nil
133 }
134
135 // Update goal
136 if err := s.planner.UpdateGoal(goal); err != nil {
137 s.logger.Error("Failed to update goal", "error", err)
138 return &mcp.CallToolResult{
139 Content: []mcp.Content{
140 mcp.TextContent{
141 Type: "text",
142 Text: fmt.Sprintf("Error updating goal: %v", err),
143 },
144 },
145 IsError: true,
146 }, nil
147 }
148
149 response := fmt.Sprintf("Goal \"%s\" saved! You probably want to add one or more tasks now.", goal)
150 return &mcp.CallToolResult{
151 Content: []mcp.Content{
152 mcp.TextContent{
153 Type: "text",
154 Text: response,
155 },
156 },
157 }, nil
158}
159
160// handleAddTasks handles the add_tasks tool call
161func (s *Server) handleAddTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
162 s.logger.Info("Received add_tasks tool call")
163
164 // Extract parameters
165 arguments := request.GetArguments()
166 tasksRaw, ok := arguments["tasks"]
167 if !ok {
168 return &mcp.CallToolResult{
169 Content: []mcp.Content{
170 mcp.TextContent{
171 Type: "text",
172 Text: "Error: tasks parameter is required",
173 },
174 },
175 IsError: true,
176 }, nil
177 }
178
179 // Convert to slice of interfaces
180 tasksSlice, ok := tasksRaw.([]any)
181 if !ok {
182 return &mcp.CallToolResult{
183 Content: []mcp.Content{
184 mcp.TextContent{
185 Type: "text",
186 Text: "Error: tasks parameter must be an array",
187 },
188 },
189 IsError: true,
190 }, nil
191 }
192
193 // Parse tasks
194 tasks := make([]planning.TaskInput, 0, len(tasksSlice))
195 for _, taskRaw := range tasksSlice {
196 taskMap, ok := taskRaw.(map[string]any)
197 if !ok {
198 return &mcp.CallToolResult{
199 Content: []mcp.Content{
200 mcp.TextContent{
201 Type: "text",
202 Text: "Error: each task must be an object",
203 },
204 },
205 IsError: true,
206 }, nil
207 }
208
209 title, ok := taskMap["title"].(string)
210 if !ok || title == "" {
211 return &mcp.CallToolResult{
212 Content: []mcp.Content{
213 mcp.TextContent{
214 Type: "text",
215 Text: "Error: each task must have a non-empty title",
216 },
217 },
218 IsError: true,
219 }, nil
220 }
221
222 description, _ := taskMap["description"].(string)
223
224 tasks = append(tasks, planning.TaskInput{
225 Title: title,
226 Description: description,
227 })
228 }
229
230 // Add tasks
231 result, err := s.planner.AddTasks(tasks)
232 if err != nil {
233 s.logger.Error("Failed to add tasks", "error", err)
234 return &mcp.CallToolResult{
235 Content: []mcp.Content{
236 mcp.TextContent{
237 Type: "text",
238 Text: fmt.Sprintf("Error adding tasks: %v", err),
239 },
240 },
241 IsError: true,
242 }, 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 // No existing tasks - show verbose instructions + task list
251 goal := s.planner.GetGoal()
252 goalText := "your planning session"
253 if goal != nil {
254 goalText = fmt.Sprintf("\"%s\"", goal.Text)
255 }
256 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)
257 } else {
258 // Had existing tasks - just show the task list (like get_tasks)
259 response = taskList
260 }
261 return &mcp.CallToolResult{
262 Content: []mcp.Content{
263 mcp.TextContent{
264 Type: "text",
265 Text: response,
266 },
267 },
268 }, nil
269}
270
271// handleGetTasks handles the get_tasks tool call
272func (s *Server) handleGetTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
273 s.logger.Info("Received get_tasks tool call")
274
275 taskList := s.planner.GetTasks()
276
277 return &mcp.CallToolResult{
278 Content: []mcp.Content{
279 mcp.TextContent{
280 Type: "text",
281 Text: taskList,
282 },
283 },
284 }, nil
285}
286
287// handleUpdateTaskStatus handles the update_task_status tool call
288func (s *Server) handleUpdateTaskStatus(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
289 s.logger.Info("Received update_task_status tool call")
290
291 // Extract parameters
292 arguments := request.GetArguments()
293 taskID, ok := arguments["task_id"].(string)
294 if !ok || taskID == "" {
295 return &mcp.CallToolResult{
296 Content: []mcp.Content{
297 mcp.TextContent{
298 Type: "text",
299 Text: "Error: task_id parameter is required and must be a string",
300 },
301 },
302 IsError: true,
303 }, nil
304 }
305
306 statusStr, ok := arguments["status"].(string)
307 if !ok || statusStr == "" {
308 return &mcp.CallToolResult{
309 Content: []mcp.Content{
310 mcp.TextContent{
311 Type: "text",
312 Text: "Error: status parameter is required and must be a string",
313 },
314 },
315 IsError: true,
316 }, nil
317 }
318
319 // Parse status
320 status := planning.ParseStatus(statusStr)
321
322 // Update task status
323 if err := s.planner.UpdateTaskStatus(taskID, status); err != nil {
324 s.logger.Error("Failed to update task status", "error", err)
325 return &mcp.CallToolResult{
326 Content: []mcp.Content{
327 mcp.TextContent{
328 Type: "text",
329 Text: fmt.Sprintf("Error updating task status: %v", err),
330 },
331 },
332 IsError: true,
333 }, nil
334 }
335
336 // Return full task list
337 taskList := s.planner.GetTasks()
338 return &mcp.CallToolResult{
339 Content: []mcp.Content{
340 mcp.TextContent{
341 Type: "text",
342 Text: taskList,
343 },
344 },
345 }, nil
346}
347
348// GetServer returns the underlying MCP server
349func (s *Server) GetServer() *server.MCPServer {
350 return s.server
351}