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>
174 lines
4.2 KiB
Go
174 lines
4.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"chorus/pkg/seqthink/mcpclient"
|
|
"chorus/pkg/seqthink/observability"
|
|
"chorus/pkg/seqthink/proxy"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// Config holds the wrapper configuration
|
|
type Config struct {
|
|
Port string
|
|
MCPLocalURL string
|
|
LogLevel string
|
|
MaxBodyMB int
|
|
HealthTimeout time.Duration
|
|
ShutdownTimeout time.Duration
|
|
AgeIdentPath string
|
|
AgeRecipsPath string
|
|
KachingJWKSURL string
|
|
RequiredScope string
|
|
}
|
|
|
|
func loadConfig() *Config {
|
|
return &Config{
|
|
Port: getEnv("PORT", "8443"),
|
|
MCPLocalURL: getEnv("MCP_LOCAL", "http://127.0.0.1:8000"),
|
|
LogLevel: getEnv("LOG_LEVEL", "info"),
|
|
MaxBodyMB: getEnvInt("MAX_BODY_MB", 4),
|
|
HealthTimeout: 5 * time.Second,
|
|
ShutdownTimeout: 30 * time.Second,
|
|
AgeIdentPath: getEnv("AGE_IDENT_PATH", ""),
|
|
AgeRecipsPath: getEnv("AGE_RECIPS_PATH", ""),
|
|
KachingJWKSURL: getEnv("KACHING_JWKS_URL", ""),
|
|
RequiredScope: getEnv("REQUIRED_SCOPE", "sequentialthinking.run"),
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
cfg := loadConfig()
|
|
|
|
// Initialize observability
|
|
observability.InitLogger(cfg.LogLevel)
|
|
metrics := observability.InitMetrics()
|
|
|
|
log.Info().
|
|
Str("port", cfg.Port).
|
|
Str("mcp_url", cfg.MCPLocalURL).
|
|
Str("version", "0.1.0-beta1").
|
|
Msg("🚀 Starting Sequential Thinking Age Wrapper")
|
|
|
|
// Create MCP client
|
|
mcpClient := mcpclient.New(cfg.MCPLocalURL)
|
|
|
|
// Wait for MCP server to be ready
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
log.Info().Msg("⏳ Waiting for MCP server...")
|
|
if err := waitForMCP(ctx, mcpClient); err != nil {
|
|
log.Fatal().Err(err).Msg("❌ MCP server not ready")
|
|
}
|
|
|
|
log.Info().Msg("✅ MCP server ready")
|
|
|
|
// Create proxy server
|
|
proxyServer, err := proxy.NewServer(proxy.ServerConfig{
|
|
MCPClient: mcpClient,
|
|
Metrics: metrics,
|
|
MaxBodyMB: cfg.MaxBodyMB,
|
|
AgeIdentPath: cfg.AgeIdentPath,
|
|
AgeRecipsPath: cfg.AgeRecipsPath,
|
|
KachingJWKSURL: cfg.KachingJWKSURL,
|
|
RequiredScope: cfg.RequiredScope,
|
|
})
|
|
|
|
if err != nil {
|
|
log.Fatal().Err(err).Msg("❌ Failed to create proxy server")
|
|
}
|
|
|
|
// Setup HTTP server
|
|
srv := &http.Server{
|
|
Addr: ":" + cfg.Port,
|
|
Handler: proxyServer.Handler(),
|
|
ReadTimeout: 30 * time.Second,
|
|
WriteTimeout: 90 * time.Second,
|
|
IdleTimeout: 120 * time.Second,
|
|
}
|
|
|
|
// Start server in goroutine
|
|
go func() {
|
|
log.Info().
|
|
Str("addr", srv.Addr).
|
|
Bool("encryption_enabled", cfg.AgeIdentPath != "").
|
|
Bool("policy_enabled", cfg.KachingJWKSURL != "").
|
|
Msg("🔐 Wrapper listening")
|
|
|
|
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
|
|
log.Fatal().Err(err).Msg("❌ HTTP server failed")
|
|
}
|
|
}()
|
|
|
|
// Wait for shutdown signal
|
|
sigChan := make(chan os.Signal, 1)
|
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
|
<-sigChan
|
|
|
|
log.Info().Msg("🛑 Shutting down gracefully...")
|
|
|
|
// Graceful shutdown
|
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
|
|
defer shutdownCancel()
|
|
|
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
log.Error().Err(err).Msg("⚠️ Shutdown error")
|
|
}
|
|
|
|
log.Info().Msg("✅ Shutdown complete")
|
|
}
|
|
|
|
// waitForMCP waits for MCP server to be ready
|
|
func waitForMCP(ctx context.Context, client *mcpclient.Client) error {
|
|
ticker := time.NewTicker(1 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("timeout waiting for MCP server")
|
|
case <-ticker.C:
|
|
if err := client.Health(ctx); err == nil {
|
|
return nil
|
|
}
|
|
log.Debug().Msg("Waiting for MCP server...")
|
|
}
|
|
}
|
|
}
|
|
|
|
// getEnv gets environment variable with default
|
|
func getEnv(key, defaultVal string) string {
|
|
if val := os.Getenv(key); val != "" {
|
|
return val
|
|
}
|
|
return defaultVal
|
|
}
|
|
|
|
// getEnvInt gets environment variable as int with default
|
|
func getEnvInt(key string, defaultVal int) int {
|
|
val := os.Getenv(key)
|
|
if val == "" {
|
|
return defaultVal
|
|
}
|
|
|
|
var result int
|
|
if _, err := fmt.Sscanf(val, "%d", &result); err != nil {
|
|
log.Warn().
|
|
Str("key", key).
|
|
Str("value", val).
|
|
Int("default", defaultVal).
|
|
Msg("Invalid integer env var, using default")
|
|
return defaultVal
|
|
}
|
|
|
|
return result
|
|
}
|