Files
CHORUS/cmd/seqthink-wrapper/main.go
anthonyrawlins 3ce9811826 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>
2025-10-13 08:35:43 +11:00

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
}