diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md index d767c559..86c5f8ba 100644 --- a/docs/guide/mcp.md +++ b/docs/guide/mcp.md @@ -39,6 +39,7 @@ All tools are **read-only** and do not modify containers. | ---------------------- | ------------------------------------------------------------------------------------ | | `list_containers` | List all containers across all hosts. Supports optional `state` filter. | | `get_container_logs` | Fetch structured logs with detected levels, JSON parsing, and multi-line grouping. | +| `search_container_logs`| Search container logs for a keyword or phrase. Returns only matching entries. | | `list_hosts` | List all connected Docker hosts. | | `get_container_stats` | Get CPU and memory usage history for a container. | diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 186e2a68..4c96e6ab 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -68,6 +68,15 @@ type getContainerLogsParams struct { Stream *string `json:"stream,omitempty" jsonschema:"Which output stream to read: stdout, stderr, or all. Defaults to all."` } +type searchContainerLogsParams struct { + Host string `json:"host" jsonschema:"The host ID where the container is running. Use list_containers to find this."` + ContainerID string `json:"container_id" jsonschema:"The container ID (or short ID) to search logs from. Use list_containers to find this."` + Query string `json:"query" jsonschema:"The search string to look for in log messages. Case-insensitive by default."` + SinceMinutes *int `json:"since_minutes,omitempty" jsonschema:"Search logs from the last N minutes. Defaults to 5."` + Stream *string `json:"stream,omitempty" jsonschema:"Which output stream to search: stdout, stderr, or all. Defaults to all."` + CaseSensitive *bool `json:"case_sensitive,omitempty" jsonschema:"Whether to perform a case-sensitive search. Defaults to false."` +} + type getContainerStatsParams struct { Host string `json:"host" jsonschema:"The host ID where the container is running. Use list_containers to find this."` ContainerID string `json:"container_id" jsonschema:"The container ID to get stats for. Use list_containers to find this."` @@ -86,6 +95,12 @@ func (s *Server) registerTools() { Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true}, }, s.handleGetContainerLogs) + mcp.AddTool(s.mcpServer, &mcp.Tool{ + Name: "search_container_logs", + Description: "Search container logs for a keyword or phrase. Returns only matching log entries, making it efficient for finding specific errors or events without downloading large volumes of logs.", + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true}, + }, s.handleSearchContainerLogs) + mcp.AddTool(s.mcpServer, &mcp.Tool{ Name: "list_hosts", Description: "List all Docker hosts connected to Dozzle.", @@ -277,6 +292,169 @@ func (s *Server) handleGetContainerLogs(ctx context.Context, _ *mcp.CallToolRequ }, nil, nil } +func (s *Server) handleSearchContainerLogs(ctx context.Context, _ *mcp.CallToolRequest, params *searchContainerLogsParams) (*mcp.CallToolResult, any, error) { + if params.Host == "" || params.ContainerID == "" || params.Query == "" { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "host, container_id, and query are required"}}, + IsError: true, + }, nil, nil + } + + containerSvc, err := s.hostService.FindContainer(params.Host, params.ContainerID, s.labels) + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("container not found: %v", err)}}, + IsError: true, + }, nil, nil + } + + stream := "" + if params.Stream != nil { + stream = *params.Stream + } + var stdType container.StdType + switch stream { + case "", "all": + stdType = container.STDALL + case "stdout": + stdType = container.STDOUT + case "stderr": + stdType = container.STDERR + default: + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("invalid stream %q: must be stdout, stderr, or all", stream)}}, + IsError: true, + }, nil, nil + } + + sinceMinutes := 5 + if params.SinceMinutes != nil && *params.SinceMinutes > 0 { + sinceMinutes = *params.SinceMinutes + } + + caseSensitive := false + if params.CaseSensitive != nil { + caseSensitive = *params.CaseSensitive + } + + query := params.Query + if !caseSensitive { + query = strings.ToLower(query) + } + + logCtx, cancel := context.WithCancel(ctx) + defer cancel() + + since := time.Now().Add(-time.Duration(sinceMinutes) * time.Minute) + events, err := containerSvc.LogsBetweenDates(logCtx, since, time.Now(), stdType) + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("failed to read logs: %v", err)}}, + IsError: true, + }, nil, nil + } + + type logEntry struct { + Timestamp string `json:"timestamp"` + Level string `json:"level,omitempty"` + Stream string `json:"stream,omitempty"` + Type string `json:"type"` + Message any `json:"message"` + } + + var entries []logEntry + totalSize := 0 + const maxSize = 1024 * 1024 // 1MB limit + matchCount := 0 + scanned := 0 + + for event := range events { + scanned++ + + var msg any + switch event.Type { + case container.LogTypeGroup: + if fragments, ok := event.Message.([]container.LogFragment); ok { + lines := make([]string, len(fragments)) + for i, f := range fragments { + lines[i] = f.Message + } + msg = lines + } else { + msg = event.RawMessage + } + case container.LogTypeComplex: + msg = event.Message + default: + msg = event.RawMessage + } + + searchStr := messageToSearchString(msg) + if caseSensitive { + if !strings.Contains(searchStr, query) { + continue + } + } else { + if !strings.Contains(strings.ToLower(searchStr), query) { + continue + } + } + + matchCount++ + + entry := logEntry{ + Timestamp: time.UnixMilli(event.Timestamp).UTC().Format(time.RFC3339Nano), + Level: event.Level, + Stream: event.Stream, + Type: string(event.Type), + Message: msg, + } + + line, err := json.Marshal(entry) + if err != nil { + continue + } + + totalSize += len(line) + 1 + if totalSize > maxSize { + break + } + + entries = append(entries, entry) + } + + if len(entries) == 0 { + summary := fmt.Sprintf("(no matches for %q in %d log entries scanned)", params.Query, scanned) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: summary}}, + }, nil, nil + } + + var sb strings.Builder + fmt.Fprintf(&sb, "Found %d matches for %q (scanned %d entries):\n", matchCount, params.Query, scanned) + encoder := json.NewEncoder(&sb) + for _, entry := range entries { + if err := encoder.Encode(entry); err != nil { + return nil, nil, fmt.Errorf("failed to encode log entry: %w", err) + } + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: strings.TrimRight(sb.String(), "\n")}}, + }, nil, nil +} + +func messageToSearchString(msg any) string { + switch v := msg.(type) { + case string: + return v + case []string: + return strings.Join(v, "\n") + default: + return fmt.Sprintf("%v", v) + } +} + func (s *Server) handleListHosts(ctx context.Context, _ *mcp.CallToolRequest, _ *struct{}) (*mcp.CallToolResult, any, error) { hosts := s.hostService.Hosts() diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 959ba06b..98e051f3 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -292,9 +292,10 @@ func TestNewServerRegistersTools(t *testing.T) { assert.Contains(t, toolNames, "list_containers") assert.Contains(t, toolNames, "get_container_logs") + assert.Contains(t, toolNames, "search_container_logs") assert.Contains(t, toolNames, "list_hosts") assert.Contains(t, toolNames, "get_container_stats") - assert.Len(t, tools.Tools, 4) + assert.Len(t, tools.Tools, 5) } func TestGetContainerLogs(t *testing.T) { @@ -362,3 +363,140 @@ func TestGetContainerLogsInvalidStream(t *testing.T) { text := result.Content[0].(*mcp.TextContent).Text assert.Contains(t, text, "invalid stream") } + +func TestSearchContainerLogs(t *testing.T) { + now := time.Now() + svc := &mockHostService{ + containers: []container.Container{ + {ID: "abc123", Name: "web", Host: "local"}, + }, + logEvents: []*container.LogEvent{ + {Timestamp: now.UnixMilli(), Level: "info", Stream: "stdout", Type: container.LogTypeSingle, RawMessage: "Request received for /home"}, + {Timestamp: now.UnixMilli(), Level: "error", Stream: "stderr", Type: container.LogTypeSingle, RawMessage: "Failed to process payment"}, + {Timestamp: now.UnixMilli(), Level: "info", Stream: "stdout", Type: container.LogTypeSingle, RawMessage: "Request received for /about"}, + {Timestamp: now.UnixMilli(), Level: "error", Stream: "stderr", Type: container.LogTypeSingle, RawMessage: "payment gateway timeout"}, + }, + } + + s := NewServer(svc, nil, "test") + + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + + _, err := s.mcpServer.Connect(ctx, st, nil) + require.NoError(t, err) + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil) + session, err := client.Connect(ctx, ct, nil) + require.NoError(t, err) + defer session.Close() + + result, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "search_container_logs", + Arguments: map[string]any{"host": "local", "container_id": "abc123", "query": "payment"}, + }) + require.NoError(t, err) + assert.False(t, result.IsError) + text := result.Content[0].(*mcp.TextContent).Text + assert.Contains(t, text, "Failed to process payment") + assert.Contains(t, text, "payment gateway timeout") + assert.NotContains(t, text, "Request received for /home") + assert.Contains(t, text, "Found 2 matches") +} + +func TestSearchContainerLogsCaseSensitive(t *testing.T) { + now := time.Now() + svc := &mockHostService{ + containers: []container.Container{ + {ID: "abc123", Name: "web", Host: "local"}, + }, + logEvents: []*container.LogEvent{ + {Timestamp: now.UnixMilli(), Level: "info", Stream: "stdout", Type: container.LogTypeSingle, RawMessage: "Error: something went wrong"}, + {Timestamp: now.UnixMilli(), Level: "info", Stream: "stdout", Type: container.LogTypeSingle, RawMessage: "error: lowercase message"}, + }, + } + + s := NewServer(svc, nil, "test") + + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + + _, err := s.mcpServer.Connect(ctx, st, nil) + require.NoError(t, err) + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil) + session, err := client.Connect(ctx, ct, nil) + require.NoError(t, err) + defer session.Close() + + result, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "search_container_logs", + Arguments: map[string]any{"host": "local", "container_id": "abc123", "query": "Error", "case_sensitive": true}, + }) + require.NoError(t, err) + assert.False(t, result.IsError) + text := result.Content[0].(*mcp.TextContent).Text + assert.Contains(t, text, "Error: something went wrong") + assert.NotContains(t, text, "error: lowercase message") + assert.Contains(t, text, "Found 1 match") +} + +func TestSearchContainerLogsNoMatches(t *testing.T) { + now := time.Now() + svc := &mockHostService{ + containers: []container.Container{ + {ID: "abc123", Name: "web", Host: "local"}, + }, + logEvents: []*container.LogEvent{ + {Timestamp: now.UnixMilli(), Level: "info", Stream: "stdout", Type: container.LogTypeSingle, RawMessage: "everything is fine"}, + {Timestamp: now.UnixMilli(), Level: "info", Stream: "stdout", Type: container.LogTypeSingle, RawMessage: "all good here"}, + }, + } + + s := NewServer(svc, nil, "test") + + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + + _, err := s.mcpServer.Connect(ctx, st, nil) + require.NoError(t, err) + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil) + session, err := client.Connect(ctx, ct, nil) + require.NoError(t, err) + defer session.Close() + + result, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "search_container_logs", + Arguments: map[string]any{"host": "local", "container_id": "abc123", "query": "error"}, + }) + require.NoError(t, err) + assert.False(t, result.IsError) + text := result.Content[0].(*mcp.TextContent).Text + assert.Contains(t, text, "no matches") + assert.Contains(t, text, "2 log entries scanned") +} + +func TestSearchContainerLogsRequiredParams(t *testing.T) { + svc := &mockHostService{} + + s := NewServer(svc, nil, "test") + + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + + _, err := s.mcpServer.Connect(ctx, st, nil) + require.NoError(t, err) + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil) + session, err := client.Connect(ctx, ct, nil) + require.NoError(t, err) + defer session.Close() + + result, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "search_container_logs", + Arguments: map[string]any{}, + }) + require.NoError(t, err) + assert.True(t, result.IsError) +}