Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
300 changes: 273 additions & 27 deletions internal/api/server.go

Large diffs are not rendered by default.

30 changes: 21 additions & 9 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,11 @@ Available tools:
func (s *Server) loggingMiddleware() mcp.Middleware {
return func(next mcp.MethodHandler) mcp.MethodHandler {
return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
logger := s.logger.With().
Str("method", method).
Str("session_id", req.GetSession().ID()).
Logger()
logCtx := s.logger.With().Str("method", method)
if session := req.GetSession(); session != nil {
logCtx = logCtx.Str("session_id", session.ID())
}
logger := logCtx.Logger()

logger.Debug().
Bool("has_params", req.GetParams() != nil).
Expand Down Expand Up @@ -131,8 +132,9 @@ func (s *Server) RunStdio(ctx context.Context) error {
return s.mcpServer.Run(ctx, &mcp.StdioTransport{})
}

// RunHTTP runs the MCP server over HTTP/SSE transport.
func (s *Server) RunHTTP(ctx context.Context, addr string, oauthCfg *oauth.Config) error {
// Handler returns an http.Handler for the MCP server that can be mounted
// on an existing HTTP server. The basePath is used for OAuth metadata URLs.
func (s *Server) Handler(basePath string, oauthCfg *oauth.Config) (http.Handler, error) {

Copilot AI Dec 18, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The basePath parameter is documented in the comment but never used in the function body. The OAuth metadata URL is constructed from oauthCfg.ResourceServerURL instead. Either remove this unused parameter or use it to construct the metadata URL if that was the intent.

Copilot uses AI. Check for mistakes.
mcpHandler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
return s.mcpServer
}, nil)
Expand All @@ -146,7 +148,7 @@ func (s *Server) RunHTTP(ctx context.Context, addr string, oauthCfg *oauth.Confi
// Check if OAuth is enabled and properly configured
if oauthCfg != nil && oauthCfg.Enabled {
if err := oauthCfg.Validate(); err != nil {
return fmt.Errorf("OAuth config validation failed: %w", err)
return nil, fmt.Errorf("OAuth config validation failed: %w", err)
}

// Register the Protected Resource Metadata endpoint (RFC 9728)
Expand All @@ -165,20 +167,30 @@ func (s *Server) RunHTTP(ctx context.Context, addr string, oauthCfg *oauth.Confi
Str("audience", oauthCfg.Audience).
Strs("scopes", oauthCfg.RequiredScopes).
Str("metadata_url", oauth.GetMetadataURL(oauthCfg.ResourceServerURL)).
Msg("OAuth2 authentication enabled")
Msg("OAuth2 authentication enabled for MCP")
}
}

// Register the MCP handler (with or without auth middleware)
mux.Handle("/", handler)

return mux, nil
}

// RunHTTP runs the MCP server over HTTP/SSE transport.
func (s *Server) RunHTTP(ctx context.Context, addr string, oauthCfg *oauth.Config) error {
handler, err := s.Handler("", oauthCfg)
if err != nil {
return err
}

if s.logger != nil {
s.logger.Info().Str("addr", addr).Msg("starting MCP server over HTTP")
}

server := &http.Server{
Addr: addr,
Handler: mux,
Handler: handler,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
Expand Down
49 changes: 49 additions & 0 deletions internal/storage/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -393,3 +393,52 @@ func (s *Storage) GetDKIMStats() ([]AuthResultStats, error) {
}
return stats, nil
}

// Setting represents a key-value setting
type Setting struct {
Key string `json:"key"`
Value string `json:"value"`
UpdatedAt int64 `json:"updated_at"`
}

// GetSetting retrieves a setting by key. Returns empty string if not found.
func (s *Storage) GetSetting(key string) (string, error) {
var value string
err := s.db.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value)
if err == sql.ErrNoRows {
return "", nil
}
if err != nil {
return "", err
}
return value, nil
}

// SetSetting stores or updates a setting.
func (s *Storage) SetSetting(key, value string) error {
_, err := s.db.Exec(`
INSERT INTO settings (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`, key, value, time.Now().Unix())
return err
}

// GetAllSettings retrieves all settings.
func (s *Storage) GetAllSettings() ([]Setting, error) {
rows, err := s.db.Query("SELECT key, value, updated_at FROM settings ORDER BY key")
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()

var settings []Setting
for rows.Next() {
var setting Setting
if err := rows.Scan(&setting.Key, &setting.Value, &setting.UpdatedAt); err != nil {
return nil, err
}
settings = append(settings, setting)
}
return settings, nil
}
6 changes: 6 additions & 0 deletions internal/storage/sqlite_cgo.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ func (s *Storage) init() error {
CREATE INDEX IF NOT EXISTS idx_reports_domain ON reports(domain);
CREATE INDEX IF NOT EXISTS idx_records_report_id ON records(report_id);
CREATE INDEX IF NOT EXISTS idx_records_source_ip ON records(source_ip);

CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`

_, err := s.db.Exec(schema)
Expand Down
6 changes: 6 additions & 0 deletions internal/storage/sqlite_no_cgo.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ func (s *Storage) init() error {
CREATE INDEX IF NOT EXISTS idx_reports_domain ON reports(domain);
CREATE INDEX IF NOT EXISTS idx_records_report_id ON records(report_id);
CREATE INDEX IF NOT EXISTS idx_records_source_ip ON records(source_ip);

CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`

_, err := s.db.Exec(schema)
Expand Down
82 changes: 62 additions & 20 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ func run(ctx context.Context, cmd *cli.Command) error {
if err := config.GenerateSample(configPath); err != nil {
return fmt.Errorf("failed to generate config: %w", err)
}
log.Printf("Sample configuration written to %s", configPath)
log.Info().Str("path", configPath).Msg("sample configuration written")
return nil
}

Expand Down Expand Up @@ -251,13 +251,54 @@ func run(ctx context.Context, cmd *cli.Command) error {
var m *metrics.Metrics
if metricsEnabled {
m = metrics.New(version, commit, date)
log.Println("Prometheus metrics enabled at /metrics")
log.Info().Msg("Prometheus metrics enabled at /metrics")
}

ctx, stop := signal.NotifyContext(ctx, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM)
defer stop()

server := api.NewServer(store, cfg.Server.Host, cfg.Server.Port, m)
server.SetLogger(log)

// Configure MCP integration (OAuth config parsed from flags)
var oauthCfg *oauth.Config
if mcpOAuthEnabled {
var scopes []string
if mcpOAuthScopes != "" {
for _, s := range strings.Split(mcpOAuthScopes, ",") {
scopes = append(scopes, strings.TrimSpace(s))
}
}

var resourceServerURL, audience string
if mcpOAuthAudience != "" {
resourceServerURL = mcpOAuthAudience
audience = mcpOAuthAudience
} else {
resourceServerURL = fmt.Sprintf("http://localhost:%d/mcp", cfg.Server.Port)
audience = resourceServerURL
}

oauthCfg = &oauth.Config{
Enabled: true,
Issuer: mcpOAuthIssuer,
Audience: audience,
ClientID: mcpOAuthClientID,
ClientSecret: mcpOAuthClientSecret,
RequiredScopes: scopes,
IntrospectionEndpoint: mcpOAuthIntrospection,
ResourceServerURL: resourceServerURL,
ResourceName: mcpOAuthResourceName,
InsecureSkipVerify: mcpOAuthInsecure,
}
Comment on lines +265 to +294

Copilot AI Dec 18, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The OAuth configuration code is duplicated between lines 213-246 (for standalone MCP mode) and lines 264-293 (for integrated mode). Consider extracting this into a helper function like buildOAuthConfig(mcpOAuthEnabled bool, ...) *oauth.Config to eliminate duplication and ensure consistency.

Copilot uses AI. Check for mistakes.
}

server.SetMCPConfig(&api.MCPConfig{
Version: version,
OAuth: oauthCfg,
Logger: log,
})

serverErrChan := make(chan error, 1)
go func() {
serverErrChan <- server.Start(ctx)
Expand All @@ -267,10 +308,10 @@ func run(ctx context.Context, cmd *cli.Command) error {
server.RefreshMetrics()

if serveOnly {
log.Println("Running in serve-only mode")
log.Info().Msg("running in serve-only mode")
select {
case <-ctx.Done():
log.Println("Shutting down...")
log.Info().Msg("shutting down")
case err := <-serverErrChan:
if err != nil {
return fmt.Errorf("server error: %w", err)
Expand All @@ -284,14 +325,14 @@ func run(ctx context.Context, cmd *cli.Command) error {
return fmt.Errorf("failed to fetch reports: %w", err)
}
server.RefreshMetrics()
log.Println("Fetch complete")
log.Info().Msg("fetch complete")
return nil
}

log.Printf("Starting continuous fetch mode (interval: %d seconds)", fetchInterval)
log.Info().Int64("interval_seconds", fetchInterval).Msg("starting continuous fetch mode")

if err := fetchReports(cfg, store, m); err != nil {
log.Printf("Initial fetch failed: %v", err)
log.Error().Err(err).Msg("initial fetch failed")
}
server.RefreshMetrics()

Expand All @@ -302,11 +343,11 @@ func run(ctx context.Context, cmd *cli.Command) error {
select {
case <-ticker.C:
if err := fetchReports(cfg, store, m); err != nil {
log.Printf("Fetch failed: %v", err)
log.Error().Err(err).Msg("fetch failed")
}
server.RefreshMetrics()
case <-ctx.Done():
log.Println("Shutting down...")
log.Info().Msg("shutting down")
return nil
case err := <-serverErrChan:
if err != nil {
Expand All @@ -317,7 +358,7 @@ func run(ctx context.Context, cmd *cli.Command) error {
}

func fetchReports(cfg *config.Config, store *storage.Storage, m *metrics.Metrics) error {
log.Println("Fetching DMARC reports...")
log.Info().Msg("fetching DMARC reports")

fetchStart := time.Now()
if m != nil {
Expand Down Expand Up @@ -353,15 +394,15 @@ func fetchReports(cfg *config.Config, store *storage.Storage, m *metrics.Metrics
}

if len(reports) == 0 {
log.Println("No new reports found")
log.Info().Msg("no new reports found")
if m != nil {
m.RecordFetchDuration(time.Since(fetchStart))
m.LastFetchTimestamp.SetToCurrentTime()
}
return nil
}

log.Printf("Processing %d reports...", len(reports))
log.Info().Int("count", len(reports)).Msg("processing reports")

// Process each report
processed := 0
Expand All @@ -373,7 +414,7 @@ func fetchReports(cfg *config.Config, store *storage.Storage, m *metrics.Metrics

feedback, err := parser.ParseReport(attachment.Data)
if err != nil {
log.Printf("Failed to parse %s: %v", attachment.Filename, err)
log.Error().Err(err).Str("filename", attachment.Filename).Msg("failed to parse report")
if m != nil {
m.ReportParseErrors.Inc()
}
Expand All @@ -384,7 +425,7 @@ func fetchReports(cfg *config.Config, store *storage.Storage, m *metrics.Metrics
}

if err := store.SaveReport(feedback); err != nil {
log.Printf("Failed to save report %s: %v", feedback.ReportMetadata.ReportID, err)
log.Error().Err(err).Str("report_id", feedback.ReportMetadata.ReportID).Msg("failed to save report")
if m != nil {
m.ReportStoreErrors.Inc()
}
Expand All @@ -394,11 +435,12 @@ func fetchReports(cfg *config.Config, store *storage.Storage, m *metrics.Metrics
m.ReportsStored.Inc()
}

log.Printf("Saved report: %s from %s (domain: %s, messages: %d)",
feedback.ReportMetadata.ReportID,
feedback.ReportMetadata.OrgName,
feedback.PolicyPublished.Domain,
feedback.GetTotalMessages())
log.Info().
Str("report_id", feedback.ReportMetadata.ReportID).
Str("org", feedback.ReportMetadata.OrgName).
Str("domain", feedback.PolicyPublished.Domain).
Int("messages", feedback.GetTotalMessages()).
Msg("saved report")
processed++
}
}
Expand All @@ -408,7 +450,7 @@ func fetchReports(cfg *config.Config, store *storage.Storage, m *metrics.Metrics
m.LastFetchTimestamp.SetToCurrentTime()
}

log.Printf("Successfully processed %d reports", processed)
log.Info().Int("count", processed).Msg("successfully processed reports")
return nil
}

Expand Down
Loading