diff --git a/src/cmd/cli/command/mcp.go b/src/cmd/cli/command/mcp.go index 4e2150501..1f5d80211 100644 --- a/src/cmd/cli/command/mcp.go +++ b/src/cmd/cli/command/mcp.go @@ -2,10 +2,7 @@ package command import ( "fmt" - "os" - "path/filepath" - cliClient "github.com/DefangLabs/defang/src/pkg/cli/client" "github.com/DefangLabs/defang/src/pkg/login" "github.com/DefangLabs/defang/src/pkg/mcp" "github.com/DefangLabs/defang/src/pkg/mcp/prompts" @@ -30,17 +27,9 @@ var mcpServerCmd = &cobra.Command{ Short: "Start defang MCP server", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - authPort, _ := cmd.Flags().GetInt("auth-server") term.SetDebug(true) - - term.Debug("Creating log file") - logFile, err := os.OpenFile(filepath.Join(cliClient.StateDir, "defang-mcp.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) - if err != nil { - term.Warnf("Failed to open log file: %v", err) - } else { - defer logFile.Close() - term.DefaultTerm = term.NewTerm(os.Stdin, logFile, logFile) - } + term.SetJSONMode(true) + authPort, _ := cmd.Flags().GetInt("auth-server") // Setup knowledge base term.Debug("Setting up knowledge base") @@ -56,6 +45,7 @@ var mcpServerCmd = &cobra.Command{ server.WithResourceCapabilities(true, true), // Enable resource management and notifications server.WithPromptCapabilities(true), // Enable interactive prompts server.WithToolCapabilities(true), // Enable dynamic tool list updates + server.WithLogging(), // Enable server logging server.WithInstructions(` Defang provides tools for deploying web applications to cloud providers (AWS, GCP, Digital Ocean) using a compose.yaml file. @@ -98,12 +88,12 @@ set_config - This tool sets or updates configuration variables for a deployed ap } // Start the server - term.Println("Starting Defang MCP server") + term.Info("Starting Defang MCP server") if err := server.ServeStdio(s); err != nil { return err } - term.Println("Server shutdown") + term.Info("Server shutdown") return nil }, diff --git a/src/pkg/term/colorizer.go b/src/pkg/term/colorizer.go index fab546074..7f3448f33 100644 --- a/src/pkg/term/colorizer.go +++ b/src/pkg/term/colorizer.go @@ -1,6 +1,7 @@ package term import ( + "encoding/json" "fmt" "io" "os" @@ -17,6 +18,7 @@ type Term struct { stdout, stderr io.Writer out, err *termenv.Output debug bool + jsonMode bool isTerminal bool hasDarkBg bool @@ -84,6 +86,10 @@ func (t *Term) SetDebug(debug bool) { t.debug = debug } +func (t *Term) SetJSONMode(jsonMode bool) { + t.jsonMode = jsonMode +} + func (t *Term) DoDebug() bool { return t.debug } @@ -198,6 +204,9 @@ func (t *Term) Debug(v ...any) (int, error) { if !t.debug { return 0, nil } + if t.jsonMode { + return t.outputJSON("debug", t.out, v...) + } return output(t.out, DebugColor, ensurePrefix(fmt.Sprintln(v...), " - ")) } @@ -205,34 +214,59 @@ func (t *Term) Debugf(format string, v ...any) (int, error) { if !t.debug { return 0, nil } + if t.jsonMode { + return t.outputJSON("debug", t.out, fmt.Sprintf(format, v...)) + } return output(t.out, DebugColor, ensureNewline(ensurePrefix(fmt.Sprintf(format, v...), " - "))) } func (t *Term) Info(v ...any) (int, error) { + if t.jsonMode { + return t.outputJSON("info", t.out, v...) + } return output(t.out, InfoColor, ensurePrefix(fmt.Sprintln(v...), " * ")) } func (t *Term) Infof(format string, v ...any) (int, error) { + if t.jsonMode { + return t.outputJSON("info", t.out, fmt.Sprintf(format, v...)) + } return output(t.out, InfoColor, ensureNewline(ensurePrefix(fmt.Sprintf(format, v...), " * "))) } func (t *Term) Warn(v ...any) (int, error) { + if t.jsonMode { + msg := strings.TrimSpace(fmt.Sprintln(v...)) + t.warnings = append(t.warnings, msg) + return t.outputJSON("warn", t.out, msg) + } msg := ensurePrefix(fmt.Sprintln(v...), " ! ") t.warnings = append(t.warnings, msg) return output(t.out, WarnColor, msg) } func (t *Term) Warnf(format string, v ...any) (int, error) { + if t.jsonMode { + msg := strings.TrimSpace(fmt.Sprintf(format, v...)) + t.warnings = append(t.warnings, msg) + return t.outputJSON("warn", t.out, msg) + } msg := ensureNewline(ensurePrefix(fmt.Sprintf(format, v...), " ! ")) t.warnings = append(t.warnings, msg) return output(t.out, WarnColor, msg) } func (t *Term) Error(v ...any) (int, error) { + if t.jsonMode { + return t.outputJSON("error", t.err, v...) + } return output(t.err, ErrorColor, fmt.Sprintln(v...)) } func (t *Term) Errorf(format string, v ...any) (int, error) { + if t.jsonMode { + return t.outputJSON("error", t.err, fmt.Sprintf(format, v...)) + } line := ensureNewline(fmt.Sprintf(format, v...)) return output(t.err, ErrorColor, line) } @@ -349,6 +383,10 @@ func SetDebug(debug bool) { DefaultTerm.SetDebug(debug) } +func SetJSONMode(jsonMode bool) { + DefaultTerm.SetJSONMode(jsonMode) +} + func DoDebug() bool { return DefaultTerm.DoDebug() } @@ -396,6 +434,23 @@ func Reset() { */ var ansiRegex = regexp.MustCompile("\x1b(?:[@-WYZ\\\\`-~]|\\[[0-?]*[ -/]*[@-~]|[X\\]^_].*?(?:\x1b\\\\|\x07|$))") +type LogEntry struct { + Level string `json:"level"` + Message string `json:"message"` +} + +func (t *Term) outputJSON(level string, w *termenv.Output, v ...any) (int, error) { + entry := LogEntry{ + Level: level, + Message: strings.TrimSpace(fmt.Sprintln(v...)), + } + data, err := json.Marshal(entry) + if err != nil { + return 0, err + } + return fmt.Fprint(w, string(data)+"\n") +} + func StripAnsi(s string) string { return ansiRegex.ReplaceAllLiteralString(s, "") } diff --git a/src/pkg/term/colorizer_test.go b/src/pkg/term/colorizer_test.go index 2ce889179..1b41000d4 100644 --- a/src/pkg/term/colorizer_test.go +++ b/src/pkg/term/colorizer_test.go @@ -2,6 +2,7 @@ package term import ( "bytes" + "encoding/json" "errors" "os" "strconv" @@ -224,22 +225,174 @@ func TestFlushWarnings(t *testing.T) { term.Warn(warning) } - bytesWritten, err := term.FlushWarnings() + _, err := term.FlushWarnings() if (err != nil) != test.expectErr { t.Errorf("FlushWarnings() error = %v, expectErr %v", err, test.expectErr) } - bytesInExpected := 0 - for _, msg := range test.expected { - bytesInExpected += len(msg) + }) + } +} + +func TestJSONOutput(t *testing.T) { + tests := []struct { + name string + level string + input []any + expected LogEntry + }{ + { + name: "simple string", + level: "info", + input: []any{"Hello, World!"}, + expected: LogEntry{Level: "info", Message: "Hello, World!"}, + }, + { + name: "multiple values", + level: "warn", + input: []any{"Hello", "World", 123}, + expected: LogEntry{Level: "warn", Message: "Hello World 123"}, + }, + { + name: "empty input", + level: "debug", + input: []any{""}, + expected: LogEntry{Level: "debug", Message: ""}, + }, + { + name: "error level", + level: "error", + input: []any{"Something went wrong"}, + expected: LogEntry{Level: "error", Message: "Something went wrong"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var buf bytes.Buffer + term := NewTerm(os.Stdin, &buf, &buf) + out := termenv.NewOutput(&buf) + + _, err := term.outputJSON(test.level, out, test.input...) + if err != nil { + t.Errorf("outputJSON() error = %v", err) } - if bytesInExpected != bytesWritten { - t.Errorf("FlushWarnings() expected %d byteWritten, got %d", bytesInExpected, bytesWritten) + // Parse the JSON output + var entry LogEntry + if err := json.Unmarshal(buf.Bytes()[:buf.Len()-1], &entry); err != nil { // -1 to remove trailing newline + t.Errorf("Failed to unmarshal JSON: %v", err) } - if term.getAllWarnings() != nil { - t.Errorf("after FlushWarnings() expected no warnings, got %v", term.getAllWarnings()) + if entry.Level != test.expected.Level { + t.Errorf("Expected level %q, got %q", test.expected.Level, entry.Level) + } + if entry.Message != test.expected.Message { + t.Errorf("Expected message %q, got %q", test.expected.Message, entry.Message) } }) } } + +func TestJSONMode(t *testing.T) { + defaultTerm := DefaultTerm + t.Cleanup(func() { + DefaultTerm = defaultTerm + }) + + var stdout, stderr bytes.Buffer + DefaultTerm = NewTerm(os.Stdin, &stdout, &stderr) + DefaultTerm.SetJSONMode(true) + DefaultTerm.SetDebug(true) + + // Test different log levels in JSON mode + Debug("Debug message") + Info("Info message") + Warn("Warning message") + Error("Error message") + + // Parse JSON outputs + stdoutLines := strings.Split(strings.TrimSpace(stdout.String()), "\n") + stderrLines := strings.Split(strings.TrimSpace(stderr.String()), "\n") + + // Check debug, info, warn go to stdout + if len(stdoutLines) != 3 { + t.Errorf("Expected 3 lines in stdout, got %d", len(stdoutLines)) + } + + // Check error goes to stderr + if len(stderrLines) != 1 { + t.Errorf("Expected 1 line in stderr, got %d", len(stderrLines)) + } + + // Verify JSON structure for each log level + expectedLevels := []string{"debug", "info", "warn"} + for i, line := range stdoutLines { + var entry LogEntry + if err := json.Unmarshal([]byte(line), &entry); err != nil { + t.Errorf("Failed to unmarshal stdout line %d: %v", i, err) + } + if entry.Level != expectedLevels[i] { + t.Errorf("Expected level %q, got %q", expectedLevels[i], entry.Level) + } + } + + // Verify error JSON + var errorEntry LogEntry + if err := json.Unmarshal([]byte(stderrLines[0]), &errorEntry); err != nil { + t.Errorf("Failed to unmarshal stderr: %v", err) + } + if errorEntry.Level != "error" { + t.Errorf("Expected error level, got %q", errorEntry.Level) + } +} + +func TestJSONModeDisabled(t *testing.T) { + defaultTerm := DefaultTerm + t.Cleanup(func() { + DefaultTerm = defaultTerm + }) + + var stdout, stderr bytes.Buffer + DefaultTerm = NewTerm(os.Stdin, &stdout, &stderr) + DefaultTerm.SetDebug(true) + + // First enable JSON mode and test it works + DefaultTerm.SetJSONMode(true) + Info("JSON test message") + + // Check that JSON mode is working + jsonOutput := stdout.String() + var entry LogEntry + if err := json.Unmarshal([]byte(strings.TrimSpace(jsonOutput)), &entry); err != nil { + t.Errorf("Expected valid JSON when JSON mode is enabled, but got error: %v", err) + } + if entry.Level != "info" || entry.Message != "JSON test message" { + t.Errorf("Expected JSON entry with level 'info' and message 'JSON test message', got: %+v", entry) + } + + // Clear the buffer and disable JSON mode + stdout.Reset() + stderr.Reset() + DefaultTerm.SetJSONMode(false) // Explicitly disable JSON mode + + Info("Test message") + Warn("Warning message") + + // Should output regular colored text with prefixes + output := stdout.String() + if !strings.Contains(output, " * Test message") { + t.Errorf("Expected info prefix ' * ', got: %q", output) + } + if !strings.Contains(output, " ! Warning message") { + t.Errorf("Expected warn prefix ' ! ', got: %q", output) + } + + // Should not be valid JSON + lines := strings.Split(strings.TrimSpace(output), "\n") + for _, line := range lines { + var entry LogEntry + if json.Unmarshal([]byte(line), &entry) == nil { + t.Errorf("Output should not be valid JSON when JSON mode is disabled, but got: %q", line) + } + } +}