server.go

  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	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}