Skip to content
Draft
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
20 changes: 5 additions & 15 deletions src/cmd/cli/command/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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")
Expand All @@ -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.

Expand Down Expand Up @@ -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
},
Expand Down
55 changes: 55 additions & 0 deletions src/pkg/term/colorizer.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package term

import (
"encoding/json"
"fmt"
"io"
"os"
Expand All @@ -17,6 +18,7 @@ type Term struct {
stdout, stderr io.Writer
out, err *termenv.Output
debug bool
jsonMode bool

isTerminal bool
hasDarkBg bool
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -198,41 +204,69 @@ 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...), " - "))
}

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)
}
Expand Down Expand Up @@ -349,6 +383,10 @@ func SetDebug(debug bool) {
DefaultTerm.SetDebug(debug)
}

func SetJSONMode(jsonMode bool) {
DefaultTerm.SetJSONMode(jsonMode)
}

func DoDebug() bool {
return DefaultTerm.DoDebug()
}
Expand Down Expand Up @@ -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, "")
}
Expand Down
169 changes: 161 additions & 8 deletions src/pkg/term/colorizer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package term

import (
"bytes"
"encoding/json"
"errors"
"os"
"strconv"
Expand Down Expand Up @@ -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)
}
}
}
Loading