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