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