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"),
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"),
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"),
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 if err := s.planner.AddTasks(tasks); err != nil {
232 s.logger.Error("Failed to add tasks", "error", err)
233 return &mcp.CallToolResult{
234 Content: []mcp.Content{
235 mcp.TextContent{
236 Type: "text",
237 Text: fmt.Sprintf("Error adding tasks: %v", err),
238 },
239 },
240 IsError: true,
241 }, nil
242 }
243
244 // Get current goal for reminder
245 goal := s.planner.GetGoal()
246 goalText := "your planning session"
247 if goal != nil {
248 goalText = fmt.Sprintf("\"%s\"", goal.Text)
249 }
250
251 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.", goalText)
252 return &mcp.CallToolResult{
253 Content: []mcp.Content{
254 mcp.TextContent{
255 Type: "text",
256 Text: response,
257 },
258 },
259 }, nil
260}
261
262// handleGetTasks handles the get_tasks tool call
263func (s *Server) handleGetTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
264 s.logger.Info("Received get_tasks tool call")
265
266 taskList := s.planner.GetTasks()
267
268 return &mcp.CallToolResult{
269 Content: []mcp.Content{
270 mcp.TextContent{
271 Type: "text",
272 Text: taskList,
273 },
274 },
275 }, nil
276}
277
278// handleUpdateTaskStatus handles the update_task_status tool call
279func (s *Server) handleUpdateTaskStatus(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
280 s.logger.Info("Received update_task_status tool call")
281
282 // Extract parameters
283 arguments := request.GetArguments()
284 taskID, ok := arguments["task_id"].(string)
285 if !ok || taskID == "" {
286 return &mcp.CallToolResult{
287 Content: []mcp.Content{
288 mcp.TextContent{
289 Type: "text",
290 Text: "Error: task_id parameter is required and must be a string",
291 },
292 },
293 IsError: true,
294 }, nil
295 }
296
297 statusStr, ok := arguments["status"].(string)
298 if !ok || statusStr == "" {
299 return &mcp.CallToolResult{
300 Content: []mcp.Content{
301 mcp.TextContent{
302 Type: "text",
303 Text: "Error: status parameter is required and must be a string",
304 },
305 },
306 IsError: true,
307 }, nil
308 }
309
310 // Parse status
311 status := planning.ParseStatus(statusStr)
312
313 // Update task status
314 if err := s.planner.UpdateTaskStatus(taskID, status); err != nil {
315 s.logger.Error("Failed to update task status", "error", err)
316 return &mcp.CallToolResult{
317 Content: []mcp.Content{
318 mcp.TextContent{
319 Type: "text",
320 Text: fmt.Sprintf("Error updating task status: %v", err),
321 },
322 },
323 IsError: true,
324 }, nil
325 }
326
327 // Return full task list
328 taskList := s.planner.GetTasks()
329 return &mcp.CallToolResult{
330 Content: []mcp.Content{
331 mcp.TextContent{
332 Type: "text",
333 Text: taskList,
334 },
335 },
336 }, nil
337}
338
339// GetServer returns the underlying MCP server
340func (s *Server) GetServer() *server.MCPServer {
341 return s.server
342}