feat: add search_container_logs MCP tool (#4832)

This commit is contained in:
morcth
2026-07-15 10:41:23 -04:00
committed by GitHub
parent 63fa7c9e04
commit a0704d5c7b
3 changed files with 318 additions and 1 deletions
+1
View File
@@ -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. |
+178
View File
@@ -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
View File
@@ -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)
}