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:
100
pkg/seqthink/mcpclient/client.go
Normal file
100
pkg/seqthink/mcpclient/client.go
Normal 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
|
||||
}
|
||||
39
pkg/seqthink/observability/logger.go
Normal file
39
pkg/seqthink/observability/logger.go
Normal 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)
|
||||
}
|
||||
}
|
||||
85
pkg/seqthink/observability/metrics.go
Normal file
85
pkg/seqthink/observability/metrics.go
Normal 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()
|
||||
}
|
||||
150
pkg/seqthink/proxy/server.go
Normal file
150
pkg/seqthink/proxy/server.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user