This commit completes Beat 1 of the SequentialThinkingForCHORUS implementation, providing a functional plaintext skeleton for the age-encrypted wrapper. ## Deliverables ### 1. Main Wrapper Entry Point - `cmd/seqthink-wrapper/main.go`: HTTP server on :8443 - Configuration loading from environment variables - Graceful shutdown handling - MCP server readiness checking with timeout ### 2. MCP Client Package - `pkg/seqthink/mcpclient/client.go`: HTTP client for MCP server - Communicates with MCP server on localhost:8000 - Health check endpoint - Tool call endpoint with 120s timeout ### 3. Proxy Server Package - `pkg/seqthink/proxy/server.go`: HTTP handlers for wrapper - Health and readiness endpoints - Tool call proxy (plaintext for Beat 1) - SSE endpoint placeholder - Metrics endpoint integration ### 4. Observability Package - `pkg/seqthink/observability/logger.go`: Structured logging with zerolog - `pkg/seqthink/observability/metrics.go`: Prometheus metrics - Counters for requests, errors, decrypt/encrypt failures, policy denials - Request duration histogram ### 5. Docker Infrastructure - `deploy/seqthink/Dockerfile`: Multi-stage build - `deploy/seqthink/entrypoint.sh`: Startup orchestration - `deploy/seqthink/mcp_stub.py`: Minimal MCP server for testing ### 6. Build System Integration - Updated `Makefile` with `build-seqthink` target - Uses GOWORK=off and -mod=mod for clean builds - `docker-seqthink` target for container builds ## Testing Successfully builds with: ``` make build-seqthink ``` Binary successfully starts and waits for MCP server connection. ## Next Steps Beat 2 will add: - Age encryption/decryption (pkg/seqthink/ageio) - Content-Type: application/age enforcement - SSE streaming with encrypted frames - Golden tests for crypto round-trips 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
151 lines
3.9 KiB
Go
151 lines
3.9 KiB
Go
package proxy
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
|
|
"chorus/pkg/seqthink/mcpclient"
|
|
"chorus/pkg/seqthink/observability"
|
|
"github.com/gorilla/mux"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// ServerConfig holds the proxy server configuration
|
|
type ServerConfig struct {
|
|
MCPClient *mcpclient.Client
|
|
Metrics *observability.Metrics
|
|
MaxBodyMB int
|
|
AgeIdentPath string
|
|
AgeRecipsPath string
|
|
KachingJWKSURL string
|
|
RequiredScope string
|
|
}
|
|
|
|
// Server is the proxy server handling requests
|
|
type Server struct {
|
|
config ServerConfig
|
|
router *mux.Router
|
|
}
|
|
|
|
// NewServer creates a new proxy server
|
|
func NewServer(cfg ServerConfig) (*Server, error) {
|
|
s := &Server{
|
|
config: cfg,
|
|
router: mux.NewRouter(),
|
|
}
|
|
|
|
// Setup routes
|
|
s.setupRoutes()
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// Handler returns the HTTP handler
|
|
func (s *Server) Handler() http.Handler {
|
|
return s.router
|
|
}
|
|
|
|
// setupRoutes configures the HTTP routes
|
|
func (s *Server) setupRoutes() {
|
|
// Health checks
|
|
s.router.HandleFunc("/health", s.handleHealth).Methods("GET")
|
|
s.router.HandleFunc("/ready", s.handleReady).Methods("GET")
|
|
|
|
// MCP tool endpoint (plaintext for Beat 1)
|
|
s.router.HandleFunc("/mcp/tool", s.handleToolCall).Methods("POST")
|
|
|
|
// SSE endpoint (placeholder for Beat 1)
|
|
s.router.HandleFunc("/mcp/sse", s.handleSSE).Methods("GET")
|
|
|
|
// Metrics endpoint
|
|
s.router.Handle("/metrics", s.config.Metrics.Handler())
|
|
}
|
|
|
|
// handleHealth returns 200 OK if wrapper is running
|
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("OK"))
|
|
}
|
|
|
|
// handleReady checks if MCP server is ready
|
|
func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if err := s.config.MCPClient.Health(ctx); err != nil {
|
|
log.Error().Err(err).Msg("MCP server not ready")
|
|
http.Error(w, "MCP server not ready", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("READY"))
|
|
}
|
|
|
|
// handleToolCall proxies tool calls to MCP server (plaintext for Beat 1)
|
|
func (s *Server) handleToolCall(w http.ResponseWriter, r *http.Request) {
|
|
s.config.Metrics.IncrementRequests()
|
|
startTime := time.Now()
|
|
|
|
// Limit request body size
|
|
r.Body = http.MaxBytesReader(w, r.Body, int64(s.config.MaxBodyMB)*1024*1024)
|
|
|
|
// Read request body
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to read request body")
|
|
s.config.Metrics.IncrementErrors()
|
|
http.Error(w, "Failed to read request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Parse tool request
|
|
var toolReq mcpclient.ToolRequest
|
|
if err := json.Unmarshal(body, &toolReq); err != nil {
|
|
log.Error().Err(err).Msg("Failed to parse tool request")
|
|
s.config.Metrics.IncrementErrors()
|
|
http.Error(w, "Invalid request format", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
log.Info().
|
|
Str("tool", toolReq.Tool).
|
|
Msg("Proxying tool call to MCP server")
|
|
|
|
// Call MCP server
|
|
ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
|
|
defer cancel()
|
|
|
|
toolResp, err := s.config.MCPClient.CallTool(ctx, &toolReq)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("MCP tool call failed")
|
|
s.config.Metrics.IncrementErrors()
|
|
http.Error(w, fmt.Sprintf("Tool call failed: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Return response
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(toolResp); err != nil {
|
|
log.Error().Err(err).Msg("Failed to encode response")
|
|
s.config.Metrics.IncrementErrors()
|
|
return
|
|
}
|
|
|
|
duration := time.Since(startTime)
|
|
log.Info().
|
|
Str("tool", toolReq.Tool).
|
|
Dur("duration", duration).
|
|
Msg("Tool call completed")
|
|
}
|
|
|
|
// handleSSE is a placeholder for Server-Sent Events streaming (Beat 1)
|
|
func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
|
|
log.Warn().Msg("SSE endpoint not yet implemented")
|
|
http.Error(w, "SSE endpoint not implemented in Beat 1", http.StatusNotImplemented)
|
|
}
|