233 lines
7.9 KiB
Go
233 lines
7.9 KiB
Go
// Package mcp provides MCP server functionality for agentctl.
|
|
// It exposes MCP tools that forward requests to the Kandev backend via the agent stream.
|
|
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/mark3labs/mcp-go/server"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// BackendClient is the interface for communicating with the Kandev backend.
|
|
// MCP tool handlers use this to forward requests to the backend.
|
|
type BackendClient interface {
|
|
// RequestPayload sends a request to the backend and unmarshals the response.
|
|
RequestPayload(ctx context.Context, action string, payload, result interface{}) error
|
|
}
|
|
|
|
// Server wraps the MCP server with backend client for communication.
|
|
type Server struct {
|
|
backend BackendClient
|
|
sessionID string
|
|
mcpServer *server.MCPServer
|
|
sseServer *server.SSEServer
|
|
httpServer *server.StreamableHTTPServer
|
|
logger *logger.Logger
|
|
mu sync.Mutex
|
|
running bool
|
|
}
|
|
|
|
// New creates a new MCP server for agentctl.
|
|
func New(backend BackendClient, sessionID string, log *logger.Logger) *Server {
|
|
s := &Server{
|
|
backend: backend,
|
|
sessionID: sessionID,
|
|
logger: log.WithFields(zap.String("component", "mcp-server")),
|
|
}
|
|
|
|
// Create MCP server
|
|
s.mcpServer = server.NewMCPServer(
|
|
"kandev-mcp",
|
|
"1.0.0",
|
|
server.WithToolCapabilities(true),
|
|
)
|
|
|
|
// Register tools
|
|
s.registerTools()
|
|
|
|
// Create SSE server for Claude Desktop, Cursor, etc.
|
|
s.sseServer = server.NewSSEServer(s.mcpServer)
|
|
|
|
// Create Streamable HTTP server for Codex
|
|
s.httpServer = server.NewStreamableHTTPServer(s.mcpServer,
|
|
server.WithEndpointPath("/mcp"),
|
|
)
|
|
|
|
return s
|
|
}
|
|
|
|
// RegisterRoutes adds MCP routes to the gin router.
|
|
func (s *Server) RegisterRoutes(router gin.IRouter) {
|
|
// SSE transport routes
|
|
router.GET("/sse", gin.WrapH(s.sseServer.SSEHandler()))
|
|
router.POST("/message", gin.WrapH(s.sseServer.MessageHandler()))
|
|
|
|
// Streamable HTTP transport route
|
|
router.Any("/mcp", gin.WrapH(s.httpServer))
|
|
|
|
s.logger.Info("registered MCP routes", zap.String("sse", "/sse"), zap.String("http", "/mcp"))
|
|
}
|
|
|
|
// Close shuts down the MCP server.
|
|
func (s *Server) Close(ctx context.Context) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if !s.running {
|
|
return nil
|
|
}
|
|
s.running = false
|
|
|
|
if s.sseServer != nil {
|
|
if err := s.sseServer.Shutdown(ctx); err != nil {
|
|
s.logger.Warn("failed to shutdown SSE server", zap.Error(err))
|
|
}
|
|
}
|
|
if s.httpServer != nil {
|
|
if err := s.httpServer.Shutdown(ctx); err != nil {
|
|
s.logger.Warn("failed to shutdown HTTP server", zap.Error(err))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// registerTools registers all MCP tools.
|
|
func (s *Server) registerTools() {
|
|
s.mcpServer.AddTool(
|
|
mcp.NewTool("list_workspaces",
|
|
mcp.WithDescription("List all workspaces. Use this first to get workspace IDs."),
|
|
),
|
|
s.listWorkspacesHandler(),
|
|
)
|
|
|
|
s.mcpServer.AddTool(
|
|
mcp.NewTool("list_boards",
|
|
mcp.WithDescription("List all boards in a workspace."),
|
|
mcp.WithString("workspace_id", mcp.Required(), mcp.Description("The workspace ID")),
|
|
),
|
|
s.listBoardsHandler(),
|
|
)
|
|
|
|
s.mcpServer.AddTool(
|
|
mcp.NewTool("list_workflow_steps",
|
|
mcp.WithDescription("List all workflow steps in a board."),
|
|
mcp.WithString("board_id", mcp.Required(), mcp.Description("The board ID")),
|
|
),
|
|
s.listWorkflowStepsHandler(),
|
|
)
|
|
|
|
s.mcpServer.AddTool(
|
|
mcp.NewTool("list_tasks",
|
|
mcp.WithDescription("List all tasks on a board."),
|
|
mcp.WithString("board_id", mcp.Required(), mcp.Description("The board ID")),
|
|
),
|
|
s.listTasksHandler(),
|
|
)
|
|
|
|
s.mcpServer.AddTool(
|
|
mcp.NewTool("create_task",
|
|
mcp.WithDescription("Create a new task on a board."),
|
|
mcp.WithString("workspace_id", mcp.Required(), mcp.Description("The workspace ID")),
|
|
mcp.WithString("board_id", mcp.Required(), mcp.Description("The board ID")),
|
|
mcp.WithString("workflow_step_id", mcp.Required(), mcp.Description("The workflow step ID")),
|
|
mcp.WithString("title", mcp.Required(), mcp.Description("The task title")),
|
|
mcp.WithString("description", mcp.Description("The task description")),
|
|
),
|
|
s.createTaskHandler(),
|
|
)
|
|
|
|
s.mcpServer.AddTool(
|
|
mcp.NewTool("update_task",
|
|
mcp.WithDescription("Update an existing task."),
|
|
mcp.WithString("task_id", mcp.Required(), mcp.Description("The task ID")),
|
|
mcp.WithString("title", mcp.Description("New title")),
|
|
mcp.WithString("description", mcp.Description("New description")),
|
|
mcp.WithString("state", mcp.Description("New state: not_started, in_progress, etc.")),
|
|
),
|
|
s.updateTaskHandler(),
|
|
)
|
|
|
|
s.mcpServer.AddTool(
|
|
mcp.NewTool("ask_user_question",
|
|
mcp.WithDescription(`Ask the user a clarifying question with multiple choice options.
|
|
|
|
Use this tool when you need user input to proceed. The user will see the prompt and can select one of the options or provide a custom text response.
|
|
|
|
IMPORTANT: Each option must be a concrete, actionable choice - NOT meta-text like "Answer questions below".
|
|
|
|
Options format - array of objects with:
|
|
- "label": Short text (1-5 words) shown as the clickable option
|
|
- "description": Brief explanation of what this option means
|
|
|
|
Example usage:
|
|
{
|
|
"prompt": "Which database should I use for this project?",
|
|
"options": [
|
|
{"label": "PostgreSQL", "description": "Relational database, good for complex queries"},
|
|
{"label": "MongoDB", "description": "Document database, flexible schema"},
|
|
{"label": "SQLite", "description": "Embedded database, simple setup"}
|
|
],
|
|
"context": "The project requires storing user profiles and relationships between entities."
|
|
}
|
|
|
|
Another example:
|
|
{
|
|
"prompt": "How should I handle the existing user data during migration?",
|
|
"options": [
|
|
{"label": "Migrate all", "description": "Keep all existing records"},
|
|
{"label": "Archive old", "description": "Archive records older than 1 year"},
|
|
{"label": "Fresh start", "description": "Delete existing data and start fresh"}
|
|
]
|
|
}`),
|
|
mcp.WithString("prompt", mcp.Required(), mcp.Description("The question to ask the user. Be specific and clear.")),
|
|
mcp.WithArray("options", mcp.Required(), mcp.Description(`Array of option objects. Each option must have: "label" (1-5 words, the clickable choice) and "description" (explanation of the option). Provide 2-4 concrete, actionable options.`)),
|
|
mcp.WithString("context", mcp.Description("Optional background information to help the user understand why you're asking this question.")),
|
|
),
|
|
s.askUserQuestionHandler(),
|
|
)
|
|
|
|
s.mcpServer.AddTool(
|
|
mcp.NewTool("create_task_plan",
|
|
mcp.WithDescription("Create or save a task plan. Use this to save your implementation plan for the current task."),
|
|
mcp.WithString("task_id", mcp.Required(), mcp.Description("The task ID to create a plan for")),
|
|
mcp.WithString("content", mcp.Required(), mcp.Description("The plan content in markdown format")),
|
|
mcp.WithString("title", mcp.Description("Optional title for the plan (default: 'Plan')")),
|
|
),
|
|
s.createTaskPlanHandler(),
|
|
)
|
|
|
|
s.mcpServer.AddTool(
|
|
mcp.NewTool("get_task_plan",
|
|
mcp.WithDescription("Get the current plan for a task. Use this to retrieve an existing plan, including any user edits."),
|
|
mcp.WithString("task_id", mcp.Required(), mcp.Description("The task ID to get the plan for")),
|
|
),
|
|
s.getTaskPlanHandler(),
|
|
)
|
|
|
|
s.mcpServer.AddTool(
|
|
mcp.NewTool("update_task_plan",
|
|
mcp.WithDescription("Update an existing task plan. Use this to modify the plan during implementation."),
|
|
mcp.WithString("task_id", mcp.Required(), mcp.Description("The task ID to update the plan for")),
|
|
mcp.WithString("content", mcp.Required(), mcp.Description("The updated plan content in markdown format")),
|
|
mcp.WithString("title", mcp.Description("Optional new title for the plan")),
|
|
),
|
|
s.updateTaskPlanHandler(),
|
|
)
|
|
|
|
s.mcpServer.AddTool(
|
|
mcp.NewTool("delete_task_plan",
|
|
mcp.WithDescription("Delete a task plan."),
|
|
mcp.WithString("task_id", mcp.Required(), mcp.Description("The task ID to delete the plan for")),
|
|
),
|
|
s.deleteTaskPlanHandler(),
|
|
)
|
|
|
|
s.logger.Info("registered MCP tools", zap.Int("count", 11))
|
|
}
|