180 lines
4.9 KiB
Go
180 lines
4.9 KiB
Go
// Package mcpserver provides a reusable MCP server for Kandev task management.
|
|
// It exposes tools for listing, creating, and updating tasks via SSE and Streamable HTTP transports.
|
|
package mcpserver
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"sync"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/mark3labs/mcp-go/server"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// Config holds the MCP server configuration.
|
|
type Config struct {
|
|
Port int // Port to listen on
|
|
KandevURL string // Kandev API URL (e.g., http://localhost:8080)
|
|
}
|
|
|
|
// Server wraps the SSE and Streamable HTTP servers with lifecycle management.
|
|
// It supports both transports for compatibility with different MCP clients:
|
|
// - SSE transport (/sse) for Claude Desktop, Cursor, etc.
|
|
// - Streamable HTTP transport (/mcp) for Codex
|
|
type Server struct {
|
|
cfg Config
|
|
sseServer *server.SSEServer
|
|
streamableHTTPServer *server.StreamableHTTPServer
|
|
httpServer *http.Server
|
|
mu sync.Mutex
|
|
running bool
|
|
logger *logger.Logger
|
|
}
|
|
|
|
// New creates a new MCP server with the given configuration.
|
|
func New(cfg Config) *Server {
|
|
return &Server{
|
|
cfg: cfg,
|
|
logger: logger.Default().WithFields(),
|
|
}
|
|
}
|
|
|
|
// Start starts the MCP server in a goroutine and returns when it's listening.
|
|
// It starts both SSE and Streamable HTTP transports on the same port.
|
|
// It returns an error if the server fails to start.
|
|
func (s *Server) Start(ctx context.Context) error {
|
|
s.mu.Lock()
|
|
if s.running {
|
|
s.mu.Unlock()
|
|
return fmt.Errorf("server already running")
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
// Create MCP server (shared between both transports)
|
|
mcpServer := server.NewMCPServer(
|
|
"kandev-mcp",
|
|
"1.0.0",
|
|
server.WithToolCapabilities(true),
|
|
)
|
|
|
|
// Register tools (using internal logger)
|
|
registerTools(mcpServer, s.cfg, s.logger)
|
|
|
|
// Create SSE server (for Claude Desktop, Cursor, etc.)
|
|
s.sseServer = server.NewSSEServer(mcpServer)
|
|
|
|
// Create Streamable HTTP server (for Codex)
|
|
// Use WithEndpointPath to ensure it handles /mcp path
|
|
s.streamableHTTPServer = server.NewStreamableHTTPServer(mcpServer,
|
|
server.WithEndpointPath("/mcp"),
|
|
)
|
|
|
|
// Create HTTP mux to route requests to appropriate transport
|
|
mux := http.NewServeMux()
|
|
|
|
// SSE transport routes - handle /sse and /message paths
|
|
mux.Handle("/sse", s.sseServer.SSEHandler())
|
|
mux.Handle("/message", s.sseServer.MessageHandler())
|
|
|
|
// Streamable HTTP transport route - handle /mcp path
|
|
mux.Handle("/mcp", s.streamableHTTPServer)
|
|
|
|
// Verify the port is available by creating a test listener
|
|
addr := fmt.Sprintf(":%d", s.cfg.Port)
|
|
listener, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to listen on %s: %w", addr, err)
|
|
}
|
|
if tcpAddr, ok := listener.Addr().(*net.TCPAddr); ok {
|
|
s.cfg.Port = tcpAddr.Port
|
|
}
|
|
|
|
// Create HTTP server with the mux
|
|
s.httpServer = &http.Server{
|
|
Handler: mux,
|
|
}
|
|
|
|
// Ready channel to signal when server goroutine has started
|
|
ready := make(chan struct{})
|
|
|
|
// Start server in a goroutine
|
|
go func() {
|
|
s.mu.Lock()
|
|
s.running = true
|
|
s.mu.Unlock()
|
|
|
|
// Signal that we're starting
|
|
close(ready)
|
|
|
|
s.logger.Info("MCP server listening",
|
|
zap.Int("port", s.cfg.Port),
|
|
zap.String("sse_endpoint", "/sse"),
|
|
zap.String("streamable_http_endpoint", "/mcp"))
|
|
|
|
if err := s.httpServer.Serve(listener); err != nil && err != http.ErrServerClosed {
|
|
s.logger.Error("MCP server error", zap.Error(err))
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.running = false
|
|
s.mu.Unlock()
|
|
}()
|
|
|
|
// Wait for the goroutine to start or context cancellation
|
|
select {
|
|
case <-ready:
|
|
return nil
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
// Stop gracefully shuts down the server.
|
|
func (s *Server) Stop(ctx context.Context) error {
|
|
s.mu.Lock()
|
|
running := s.running
|
|
s.mu.Unlock()
|
|
|
|
if !running {
|
|
return nil
|
|
}
|
|
|
|
// Shutdown the HTTP server (this stops both transports)
|
|
if s.httpServer != nil {
|
|
if err := s.httpServer.Shutdown(ctx); err != nil {
|
|
return fmt.Errorf("failed to shutdown HTTP server: %w", err)
|
|
}
|
|
}
|
|
|
|
// Also shutdown the SSE server to clean up any active sessions
|
|
if s.sseServer != nil {
|
|
if err := s.sseServer.Shutdown(ctx); err != nil {
|
|
s.logger.Warn("failed to shutdown SSE server", zap.Error(err))
|
|
}
|
|
}
|
|
|
|
// Shutdown the Streamable HTTP server to clean up any active sessions
|
|
if s.streamableHTTPServer != nil {
|
|
if err := s.streamableHTTPServer.Shutdown(ctx); err != nil {
|
|
s.logger.Warn("failed to shutdown Streamable HTTP server", zap.Error(err))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SSEEndpoint returns the full SSE URL for clients that use SSE transport
|
|
// (e.g., Claude Desktop, Cursor).
|
|
func (s *Server) SSEEndpoint() string {
|
|
return fmt.Sprintf("http://localhost:%d/sse", s.cfg.Port)
|
|
}
|
|
|
|
// StreamableHTTPEndpoint returns the full Streamable HTTP URL for clients that use
|
|
// streamable HTTP transport (e.g., Codex).
|
|
func (s *Server) StreamableHTTPEndpoint() string {
|
|
return fmt.Sprintf("http://localhost:%d/mcp", s.cfg.Port)
|
|
}
|