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:
anthonyrawlins
2025-10-13 08:35:43 +11:00
parent dd8be05e9c
commit 3ce9811826
11 changed files with 2424 additions and 9 deletions

View File

@@ -0,0 +1,100 @@
package mcpclient
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// Client is a client for the Sequential Thinking MCP server
type Client struct {
baseURL string
httpClient *http.Client
}
// ToolRequest represents a request to call an MCP tool
type ToolRequest struct {
Tool string `json:"tool"`
Payload map[string]interface{} `json:"payload"`
}
// ToolResponse represents the response from an MCP tool call
type ToolResponse struct {
Result interface{} `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
// New creates a new MCP client
func New(baseURL string) *Client {
return &Client{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 120 * time.Second, // Longer timeout for thinking operations
},
}
}
// Health checks if the MCP server is healthy
func (c *Client) Health(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/health", nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("http request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("health check failed: status %d", resp.StatusCode)
}
return nil
}
// CallTool calls an MCP tool
func (c *Client) CallTool(ctx context.Context, req *ToolRequest) (*ToolResponse, error) {
jsonData, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/mcp/tool", bytes.NewReader(jsonData))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("http request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("tool call failed: status %d, body: %s", resp.StatusCode, string(body))
}
var toolResp ToolResponse
if err := json.Unmarshal(body, &toolResp); err != nil {
return nil, fmt.Errorf("unmarshal response: %w", err)
}
if toolResp.Error != "" {
return nil, fmt.Errorf("tool error: %s", toolResp.Error)
}
return &toolResp, nil
}

View File

@@ -0,0 +1,39 @@
package observability
import (
"os"
"strings"
"time"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
// InitLogger initializes the global logger
func InitLogger(level string) {
// Set up zerolog with human-friendly console output
output := zerolog.ConsoleWriter{
Out: os.Stdout,
TimeFormat: time.RFC3339,
}
log.Logger = zerolog.New(output).
With().
Timestamp().
Caller().
Logger()
// Set log level
switch strings.ToLower(level) {
case "debug":
zerolog.SetGlobalLevel(zerolog.DebugLevel)
case "info":
zerolog.SetGlobalLevel(zerolog.InfoLevel)
case "warn":
zerolog.SetGlobalLevel(zerolog.WarnLevel)
case "error":
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
default:
zerolog.SetGlobalLevel(zerolog.InfoLevel)
}
}

View File

@@ -0,0 +1,85 @@
package observability
import (
"net/http"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// Metrics holds Prometheus metrics for the wrapper
type Metrics struct {
requestsTotal prometheus.Counter
errorsTotal prometheus.Counter
decryptFails prometheus.Counter
encryptFails prometheus.Counter
policyDenials prometheus.Counter
requestDuration prometheus.Histogram
}
// InitMetrics initializes Prometheus metrics
func InitMetrics() *Metrics {
return &Metrics{
requestsTotal: promauto.NewCounter(prometheus.CounterOpts{
Name: "seqthink_requests_total",
Help: "Total number of requests received",
}),
errorsTotal: promauto.NewCounter(prometheus.CounterOpts{
Name: "seqthink_errors_total",
Help: "Total number of errors",
}),
decryptFails: promauto.NewCounter(prometheus.CounterOpts{
Name: "seqthink_decrypt_failures_total",
Help: "Total number of decryption failures",
}),
encryptFails: promauto.NewCounter(prometheus.CounterOpts{
Name: "seqthink_encrypt_failures_total",
Help: "Total number of encryption failures",
}),
policyDenials: promauto.NewCounter(prometheus.CounterOpts{
Name: "seqthink_policy_denials_total",
Help: "Total number of policy denials",
}),
requestDuration: promauto.NewHistogram(prometheus.HistogramOpts{
Name: "seqthink_request_duration_seconds",
Help: "Request duration in seconds",
Buckets: prometheus.DefBuckets,
}),
}
}
// IncrementRequests increments the request counter
func (m *Metrics) IncrementRequests() {
m.requestsTotal.Inc()
}
// IncrementErrors increments the error counter
func (m *Metrics) IncrementErrors() {
m.errorsTotal.Inc()
}
// IncrementDecryptFails increments the decrypt failure counter
func (m *Metrics) IncrementDecryptFails() {
m.decryptFails.Inc()
}
// IncrementEncryptFails increments the encrypt failure counter
func (m *Metrics) IncrementEncryptFails() {
m.encryptFails.Inc()
}
// IncrementPolicyDenials increments the policy denial counter
func (m *Metrics) IncrementPolicyDenials() {
m.policyDenials.Inc()
}
// ObserveRequestDuration records request duration
func (m *Metrics) ObserveRequestDuration(seconds float64) {
m.requestDuration.Observe(seconds)
}
// Handler returns the Prometheus metrics HTTP handler
func (m *Metrics) Handler() http.Handler {
return promhttp.Handler()
}

View File

@@ -0,0 +1,150 @@
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)
}