Implement Beat 1: Sequential Thinking Age-Encrypted Wrapper (Skeleton)
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>
This commit is contained in:
173
cmd/seqthink-wrapper/main.go
Normal file
173
cmd/seqthink-wrapper/main.go
Normal file
@@ -0,0 +1,173 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user