Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,8 @@ See `docs/prerelease-builds.md` for download instructions.
- Go 1.24 (toolchain go1.24.10) + Cobra (CLI), Chi router (HTTP), Zap (logging), mark3labs/mcp-go (MCP protocol) (020-oauth-login-feedback)
- Go 1.24 (toolchain go1.24.10) + Cobra (CLI), Chi router (HTTP), Zap (logging), google/uuid (ID generation) (021-request-id-logging)
- BBolt database (`~/.mcpproxy/config.db`) - activity log extended with request_id field (021-request-id-logging)
- Go 1.24 (toolchain go1.24.10) + TypeScript 5.x / Vue 3.5 + Cobra CLI, Chi router, BBolt storage, Zap logging, mark3labs/mcp-go, Vue 3, Tailwind CSS, DaisyUI (024-expand-activity-log)
- BBolt database (`~/.mcpproxy/config.db`) - ActivityRecord model (024-expand-activity-log)

## Recent Changes
- 001-update-version-display: Added Go 1.24 (toolchain go1.24.10)
212 changes: 179 additions & 33 deletions cmd/mcpproxy/activity_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,18 +71,26 @@ type ActivityFilter struct {

// Validate validates the filter options
func (f *ActivityFilter) Validate() error {
// Validate type
// Validate type(s) - supports comma-separated values (Spec 024)
if f.Type != "" {
validTypes := []string{"tool_call", "policy_decision", "quarantine_change", "server_change"}
valid := false
for _, t := range validTypes {
if f.Type == t {
valid = true
break
}
validTypes := []string{
"tool_call", "policy_decision", "quarantine_change", "server_change",
"system_start", "system_stop", "internal_tool_call", "config_change", // Spec 024: new types
}
if !valid {
return fmt.Errorf("invalid type '%s': must be one of %v", f.Type, validTypes)
// Split by comma for multi-type support
types := strings.Split(f.Type, ",")
for _, t := range types {
t = strings.TrimSpace(t)
valid := false
for _, vt := range validTypes {
if t == vt {
valid = true
break
}
}
if !valid {
return fmt.Errorf("invalid type '%s': must be one of %v", t, validTypes)
}
}
}

Expand Down Expand Up @@ -517,7 +525,7 @@ func init() {
activityCmd.AddCommand(activityExportCmd)

// List command flags
activityListCmd.Flags().StringVarP(&activityType, "type", "t", "", "Filter by type: tool_call, policy_decision, quarantine_change, server_change")
activityListCmd.Flags().StringVarP(&activityType, "type", "t", "", "Filter by type (comma-separated for multiple): tool_call, system_start, system_stop, internal_tool_call, config_change, policy_decision, quarantine_change, server_change")
activityListCmd.Flags().StringVarP(&activityServer, "server", "s", "", "Filter by server name")
activityListCmd.Flags().StringVar(&activityTool, "tool", "", "Filter by tool name")
activityListCmd.Flags().StringVar(&activityStatus, "status", "", "Filter by status: success, error, blocked")
Expand All @@ -531,7 +539,7 @@ func init() {
activityListCmd.Flags().BoolVar(&activityNoIcons, "no-icons", false, "Disable emoji icons in output (use text instead)")

// Watch command flags
activityWatchCmd.Flags().StringVarP(&activityType, "type", "t", "", "Filter by type: tool_call, policy_decision")
activityWatchCmd.Flags().StringVarP(&activityType, "type", "t", "", "Filter by type (comma-separated): tool_call, system_start, system_stop, internal_tool_call, config_change, policy_decision, quarantine_change, server_change")
activityWatchCmd.Flags().StringVarP(&activityServer, "server", "s", "", "Filter by server name")

// Show command flags
Expand All @@ -547,7 +555,7 @@ func init() {
activityExportCmd.Flags().StringVarP(&activityExportFormat, "format", "f", "json", "Export format: json, csv")
activityExportCmd.Flags().BoolVar(&activityIncludeBodies, "include-bodies", false, "Include full request/response bodies")
// Reuse list filter flags for export
activityExportCmd.Flags().StringVarP(&activityType, "type", "t", "", "Filter by type")
activityExportCmd.Flags().StringVarP(&activityType, "type", "t", "", "Filter by type (comma-separated): tool_call, system_start, system_stop, internal_tool_call, config_change, policy_decision, quarantine_change, server_change")
activityExportCmd.Flags().StringVarP(&activityServer, "server", "s", "", "Filter by server name")
activityExportCmd.Flags().StringVar(&activityTool, "tool", "", "Filter by tool name")
activityExportCmd.Flags().StringVar(&activityStatus, "status", "", "Filter by status")
Expand Down Expand Up @@ -863,8 +871,9 @@ func watchActivityStream(ctx context.Context, sseURL string, outputFormat string
eventData = strings.TrimPrefix(line, "data: ")
case line == "":
// Empty line = event complete
// Only display completed events (started events have no status/duration)
if strings.HasPrefix(eventType, "activity.") && strings.HasSuffix(eventType, ".completed") {
// Display all activity events except .started (which have no meaningful status/duration)
// Includes: .completed, policy_decision, system_start, system_stop, config_change
if strings.HasPrefix(eventType, "activity.") && !strings.HasSuffix(eventType, ".started") {
displayActivityEvent(eventType, eventData, outputFormat)
}
eventType, eventData = "", ""
Expand Down Expand Up @@ -895,42 +904,81 @@ func displayActivityEvent(eventType, eventData, outputFormat string) {
event = wrapper
}

// Determine event category from eventType (e.g., "activity.tool_call.completed" -> "tool_call")
parts := strings.Split(eventType, ".")
eventCategory := ""
if len(parts) >= 2 {
eventCategory = parts[1]
}

// Apply client-side filters
if activityServer != "" {
if server := getStringField(event, "server_name"); server != activityServer {
// For tool_call events, check server_name
// For internal_tool_call events, check target_server
server := getStringField(event, "server_name")
if server == "" {
server = getStringField(event, "target_server")
}
if server == "" {
server = getStringField(event, "affected_entity") // for config_change
}
if server != activityServer {
return
}
}
if activityType != "" {
// Event type is like "activity.tool_call.completed", extract the middle part
parts := strings.Split(eventType, ".")
if len(parts) >= 2 && parts[1] != activityType {
if eventCategory != activityType {
return
}
}

// Skip successful call_tool_* internal tool calls to avoid duplicates
// These have a corresponding tool_call entry that shows the actual upstream call.
// Failed call_tool_* calls are shown since they have no corresponding tool_call.
if eventCategory == "internal_tool_call" {
internalToolName := getStringField(event, "internal_tool_name")
status := getStringField(event, "status")
if status == "success" && strings.HasPrefix(internalToolName, "call_tool_") {
return
}
}

// Format for table output: [HH:MM:SS] [SRC] server:tool status duration
// Format output based on event type
timestamp := time.Now().Format("15:04:05")

var line string
switch eventCategory {
case "tool_call":
line = formatToolCallEvent(event, timestamp)
case "internal_tool_call":
line = formatInternalToolCallEvent(event, timestamp)
case "policy_decision":
line = formatPolicyDecisionEvent(event, timestamp)
case "system_start":
line = formatSystemStartEvent(event, timestamp)
case "system_stop":
line = formatSystemStopEvent(event, timestamp)
case "config_change":
line = formatConfigChangeEvent(event, timestamp)
default:
// Fallback for unknown event types
line = fmt.Sprintf("[%s] [?] %s", timestamp, eventType)
}

fmt.Println(line)
}

// formatToolCallEvent formats a tool_call event for display
func formatToolCallEvent(event map[string]interface{}, timestamp string) string {
source := getStringField(event, "source")
server := getStringField(event, "server_name")
tool := getStringField(event, "tool_name")
status := getStringField(event, "status")
durationMs := getIntField(event, "duration_ms")
errMsg := getStringField(event, "error_message")

// Source indicator
sourceIcon := formatSourceIndicator(source)

// Status indicator
statusIcon := "?"
switch status {
case "success":
statusIcon = "\u2713" // checkmark
case "error":
statusIcon = "\u2717" // X
case "blocked":
statusIcon = "\u2298" // circle with slash
}
statusIcon := formatStatusIcon(status)

line := fmt.Sprintf("[%s] [%s] %s:%s %s %s", timestamp, sourceIcon, server, tool, statusIcon, formatActivityDuration(int64(durationMs)))
if errMsg != "" {
Expand All @@ -939,8 +987,106 @@ func displayActivityEvent(eventType, eventData, outputFormat string) {
if status == "blocked" {
line += " BLOCKED"
}
return line
}

fmt.Println(line)
// formatInternalToolCallEvent formats an internal_tool_call event for display
func formatInternalToolCallEvent(event map[string]interface{}, timestamp string) string {
internalTool := getStringField(event, "internal_tool_name")
targetServer := getStringField(event, "target_server")
targetTool := getStringField(event, "target_tool")
status := getStringField(event, "status")
durationMs := getIntField(event, "duration_ms")
errMsg := getStringField(event, "error_message")

statusIcon := formatStatusIcon(status)

// Format: [HH:MM:SS] [INT] internal_tool -> target_server:target_tool status duration
target := ""
if targetServer != "" && targetTool != "" {
target = fmt.Sprintf(" -> %s:%s", targetServer, targetTool)
} else if targetServer != "" {
target = fmt.Sprintf(" -> %s", targetServer)
}

line := fmt.Sprintf("[%s] [INT] %s%s %s %s", timestamp, internalTool, target, statusIcon, formatActivityDuration(int64(durationMs)))
if errMsg != "" {
line += " " + errMsg
}
return line
}

// formatPolicyDecisionEvent formats a policy_decision event for display
func formatPolicyDecisionEvent(event map[string]interface{}, timestamp string) string {
server := getStringField(event, "server_name")
tool := getStringField(event, "tool_name")
decision := getStringField(event, "decision")
reason := getStringField(event, "reason")

statusIcon := "\u2298" // circle with slash for blocked
if decision == "allowed" {
statusIcon = "\u2713"
}

line := fmt.Sprintf("[%s] [POL] %s:%s %s", timestamp, server, tool, statusIcon)
if reason != "" {
line += " " + reason
}
return line
}

// formatSystemStartEvent formats a system_start event for display
func formatSystemStartEvent(event map[string]interface{}, timestamp string) string {
version := getStringField(event, "version")
listenAddr := getStringField(event, "listen_address")
startupMs := getIntField(event, "startup_duration_ms")

return fmt.Sprintf("[%s] [SYS] \u25B6 Started v%s on %s (%s)", timestamp, version, listenAddr, formatActivityDuration(int64(startupMs)))
}

// formatSystemStopEvent formats a system_stop event for display
func formatSystemStopEvent(event map[string]interface{}, timestamp string) string {
reason := getStringField(event, "reason")
signal := getStringField(event, "signal")
uptimeSec := getIntField(event, "uptime_seconds")
errMsg := getStringField(event, "error_message")

line := fmt.Sprintf("[%s] [SYS] \u25A0 Stopped: %s", timestamp, reason)
if signal != "" {
line += fmt.Sprintf(" (signal: %s)", signal)
}
if uptimeSec > 0 {
line += fmt.Sprintf(" uptime: %ds", uptimeSec)
}
if errMsg != "" {
line += " error: " + errMsg
}
return line
}

// formatConfigChangeEvent formats a config_change event for display
func formatConfigChangeEvent(event map[string]interface{}, timestamp string) string {
action := getStringField(event, "action")
entity := getStringField(event, "affected_entity")
source := getStringField(event, "source")

sourceIcon := formatSourceIndicator(source)

return fmt.Sprintf("[%s] [%s] \u2699 Config: %s %s", timestamp, sourceIcon, action, entity)
}

// formatStatusIcon returns a status icon for the given status
func formatStatusIcon(status string) string {
switch status {
case "success":
return "\u2713" // checkmark
case "error":
return "\u2717" // X
case "blocked":
return "\u2298" // circle with slash
default:
return "?"
}
}

// runActivityShow implements the activity show command
Expand Down
8 changes: 8 additions & 0 deletions cmd/mcpproxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"os"
"os/signal"
"strings"
"sync/atomic"
"syscall"
"time"

Expand Down Expand Up @@ -554,13 +555,18 @@ func runServer(cmd *cobra.Command, _ []string) error {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)

// Spec 024: Track received signal for activity logging
var receivedSignal atomic.Value
receivedSignal.Store("")

// Setup signal handling for graceful shutdown with force quit on second signal
logger.Info("Signal handler goroutine starting - waiting for SIGINT or SIGTERM")
_ = logger.Sync()
go func() {
logger.Info("Signal handler goroutine is running, waiting for signal on channel")
_ = logger.Sync()
sig := <-sigChan
receivedSignal.Store(sig.String()) // Spec 024: Store signal for activity logging
logger.Info("Received signal, shutting down", zap.String("signal", sig.String()))
_ = logger.Sync() // Flush logs immediately so we can see shutdown messages
logger.Info("Press Ctrl+C again within 10 seconds to force quit")
Expand Down Expand Up @@ -592,6 +598,8 @@ func runServer(cmd *cobra.Command, _ []string) error {
// Wait for context to be cancelled
<-ctx.Done()
logger.Info("Shutting down server")
// Spec 024: Set shutdown info for activity logging
srv.SetShutdownInfo("signal", receivedSignal.Load().(string))
// Use Shutdown() instead of StopServer() to ensure proper container cleanup
// Shutdown() calls runtime.Close() which triggers ShutdownAll() for Docker cleanup
if err := srv.Shutdown(); err != nil {
Expand Down
Loading
Loading