mirror of
https://github.com/amir20/dozzle.git
synced 2026-08-07 10:04:45 +00:00
feat: add search_container_logs MCP tool (#4832)
This commit is contained in:
@@ -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. |
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
+139
-1
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user