Compare commits

..

3 Commits

Author SHA1 Message Date
yangzhaohan 795ad7660c feat(tool): 添加 Docker 日志实时跟踪功能
- 扩展 DockerTool 支持实时日志跟踪模式
- 新增 follow 参数用于启用持续日志输出
- 实现 SSE 流式推送日志到客户端
- 添加后台协程处理日志流和错误输出
- 集成 server 通知机制推送实时日志行
- 优化命令执行和上下文取消逻辑
2026-07-06 11:43:19 +08:00
yangzhaohan 88939869ff README: 更新端口为 9090,补充自定义端口说明 2026-07-06 10:48:28 +08:00
yangzhaohan 843024d7da 修复 Docker 构建 + 端口可配置
- Dockerfile 设置 GOPROXY=https://goproxy.cn,direct 解决国内网络问题
- 新增 addr 配置项,支持自定义端口,默认 :8080
- docker-compose 端口改为 ${OPS_MCP_PORT:-9090}
- 移除 EXPOSE 指令(端口通过 compose 管理)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-06 10:47:31 +08:00
8 changed files with 117 additions and 13 deletions
+1 -1
View File
@@ -1 +1 @@
OPS_MCP_TRANSPORT=sse OPS_MCP_PORT=9090
+2 -1
View File
@@ -1,5 +1,7 @@
FROM golang:1.24-alpine AS builder FROM golang:1.24-alpine AS builder
ENV GOPROXY=https://goproxy.cn,direct
WORKDIR /app WORKDIR /app
COPY go.mod go.sum ./ COPY go.mod go.sum ./
@@ -15,5 +17,4 @@ RUN apk add --no-cache docker-cli ca-certificates
COPY --from=builder /server /server COPY --from=builder /server /server
COPY config/ /config/ COPY config/ /config/
EXPOSE 8080
ENTRYPOINT ["/server"] ENTRYPOINT ["/server"]
+6 -2
View File
@@ -23,13 +23,17 @@ make build && ./ops-mcp
## Docker Compose 部署 ## Docker Compose 部署
```bash ```bash
# 默认端口 9090
docker compose up -d --build docker compose up -d --build
# 自定义端口
OPS_MCP_PORT=3000 docker compose up -d --build
``` ```
然后 Claude Desktop 通过 SSE 连接: Claude Desktop SSE 连接(端口改成你设置的)
```json ```json
{ "mcpServers": { "ops-mcp": { "url": "http://<server>:8080/sse" } } } { "mcpServers": { "ops-mcp": { "url": "http://<server>:9090/sse" } } }
``` ```
## MCP 工具 ## MCP 工具
+2 -2
View File
@@ -17,10 +17,10 @@ func main() {
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
slog.Warn("config load failed, using defaults", "err", err) slog.Warn("config load failed, using defaults", "err", err)
cfg = &config.Config{Transport: "stdio"} cfg = &config.Config{Transport: "stdio", Addr: ":8080"}
} }
if err := server.Run(cfg.Transport, tool.NewDockerTool()); err != nil { if err := server.Run(cfg.Transport, cfg.Addr, tool.NewDockerTool()); err != nil {
slog.Error("fatal", "err", err) slog.Error("fatal", "err", err)
os.Exit(1) os.Exit(1)
} }
+2 -1
View File
@@ -8,6 +8,7 @@ import (
type Config struct { type Config struct {
Transport string `mapstructure:"transport"` // stdio | sse Transport string `mapstructure:"transport"` // stdio | sse
Addr string `mapstructure:"addr"` // 监听地址,默认 :8080
} }
func Load() (*Config, error) { func Load() (*Config, error) {
@@ -20,8 +21,8 @@ func Load() (*Config, error) {
v.SetEnvPrefix("OPS_MCP") v.SetEnvPrefix("OPS_MCP")
v.AutomaticEnv() v.AutomaticEnv()
v.SetDefault("transport", "stdio") v.SetDefault("transport", "stdio")
v.SetDefault("addr", ":8080")
// 配置文件不存在不算错误,直接用默认值 + 环境变量
if err := v.ReadInConfig(); err != nil { if err := v.ReadInConfig(); err != nil {
slog.Warn("no config file found, using defaults and env vars", "err", err) slog.Warn("no config file found, using defaults and env vars", "err", err)
} }
+3
View File
@@ -6,7 +6,10 @@ services:
image: ops-mcp:latest image: ops-mcp:latest
container_name: ops-mcp container_name: ops-mcp
restart: unless-stopped restart: unless-stopped
ports:
- "${OPS_MCP_PORT:-9090}:${OPS_MCP_PORT:-9090}"
environment: environment:
- OPS_MCP_TRANSPORT=sse - OPS_MCP_TRANSPORT=sse
- OPS_MCP_ADDR=:${OPS_MCP_PORT:-9090}
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
+3 -3
View File
@@ -16,7 +16,7 @@ import (
const version = "0.1.0" const version = "0.1.0"
// Run 启动 MCP Server。 // Run 启动 MCP Server。
func Run(transport string, tools ...tool.Tool) error { func Run(transport, addr string, tools ...tool.Tool) error {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@@ -60,8 +60,8 @@ func Run(transport string, tools ...tool.Tool) error {
slog.Info("starting MCP server", "transport", "stdio") slog.Info("starting MCP server", "transport", "stdio")
return server.ServeStdio(mcpServer) return server.ServeStdio(mcpServer)
case "sse": case "sse":
slog.Info("starting MCP server", "transport", "sse", "addr", ":8080") slog.Info("starting MCP server", "transport", "sse", "addr", addr)
return server.NewSSEServer(mcpServer).Start(":8080") return server.NewSSEServer(mcpServer).Start(addr)
default: default:
return fmt.Errorf("unknown transport: %s", transport) return fmt.Errorf("unknown transport: %s", transport)
} }
+98 -3
View File
@@ -1,6 +1,7 @@
package tool package tool
import ( import (
"bufio"
"bytes" "bytes"
"context" "context"
"fmt" "fmt"
@@ -12,7 +13,7 @@ import (
"github.com/mark3labs/mcp-go/server" "github.com/mark3labs/mcp-go/server"
) )
// DockerTool 提供 docker logs 查询。 // DockerTool 提供 docker logs 查询与实时跟踪
type DockerTool struct{} type DockerTool struct{}
func NewDockerTool() *DockerTool { func NewDockerTool() *DockerTool {
@@ -20,7 +21,7 @@ func NewDockerTool() *DockerTool {
} }
func (d *DockerTool) Name() string { return "docker" } func (d *DockerTool) Name() string { return "docker" }
func (d *DockerTool) Description() string { return "Docker 容器日志查询" } func (d *DockerTool) Description() string { return "Docker 容器日志查询与实时跟踪" }
func (d *DockerTool) Initialize(_ context.Context) error { func (d *DockerTool) Initialize(_ context.Context) error {
if _, err := exec.LookPath("docker"); err != nil { if _, err := exec.LookPath("docker"); err != nil {
@@ -38,7 +39,7 @@ func (d *DockerTool) HealthCheck(ctx context.Context) error {
func (d *DockerTool) Register(mcpServer *server.MCPServer) error { func (d *DockerTool) Register(mcpServer *server.MCPServer) error {
mcpServer.AddTool(mcp.NewTool("docker_logs", mcpServer.AddTool(mcp.NewTool("docker_logs",
mcp.WithDescription("获取 Docker 容器日志,等价于 docker logs --tail N <container>"), mcp.WithDescription("获取 Docker 容器日志,等价于 docker logs --tail N <container>follow 模式通过 SSE 实时推送日志流"),
mcp.WithString("container", mcp.WithString("container",
mcp.Required(), mcp.Required(),
mcp.Description("容器名称或 ID"), mcp.Description("容器名称或 ID"),
@@ -46,6 +47,9 @@ func (d *DockerTool) Register(mcpServer *server.MCPServer) error {
mcp.WithNumber("tail", mcp.WithNumber("tail",
mcp.Description("返回最后 N 行日志,默认 100"), mcp.Description("返回最后 N 行日志,默认 100"),
), ),
mcp.WithBoolean("follow",
mcp.Description("是否持续跟踪日志输出(类似 docker logs -f),默认 false。SSE 模式下日志行通过 notifications/docker_logs/stream 推送"),
),
), d.handleLogs) ), d.handleLogs)
return nil return nil
@@ -63,6 +67,18 @@ func (d *DockerTool) handleLogs(ctx context.Context, req mcp.CallToolRequest) (*
tail = int(t) tail = int(t)
} }
follow := false
if f, ok := args["follow"].(bool); ok {
follow = f
}
if !follow {
return d.oneShotLogs(ctx, container, tail)
}
return d.followLogs(ctx, container, tail)
}
func (d *DockerTool) oneShotLogs(ctx context.Context, container string, tail int) (*mcp.CallToolResult, error) {
cmd := exec.CommandContext(ctx, "docker", "logs", cmd := exec.CommandContext(ctx, "docker", "logs",
"--tail", strconv.Itoa(tail), "--tail", strconv.Itoa(tail),
"--timestamps", "--timestamps",
@@ -86,3 +102,82 @@ func (d *DockerTool) handleLogs(ctx context.Context, req mcp.CallToolRequest) (*
return mcp.NewToolResultText(output), nil return mcp.NewToolResultText(output), nil
} }
func (d *DockerTool) followLogs(ctx context.Context, container string, tail int) (*mcp.CallToolResult, error) {
srv := server.ServerFromContext(ctx)
followCtx, cancelFollow := context.WithCancel(context.Background())
cmd := exec.CommandContext(followCtx, "docker", "logs",
"--tail", strconv.Itoa(tail),
"--timestamps",
"-f",
container,
)
stdout, err := cmd.StdoutPipe()
if err != nil {
cancelFollow()
return mcp.NewToolResultError(fmt.Sprintf("创建管道失败: %v", err)), nil
}
stderr, err := cmd.StderrPipe()
if err != nil {
cancelFollow()
return mcp.NewToolResultError(fmt.Sprintf("创建管道失败: %v", err)), nil
}
if err := cmd.Start(); err != nil {
cancelFollow()
return mcp.NewToolResultError(fmt.Sprintf("docker logs 启动失败: %v", err)), nil
}
// 后台读取 stderr
go func() {
scanner := bufio.NewScanner(stderr)
for scanner.Scan() {
slog.Warn("docker logs stderr", "container", container, "line", scanner.Text())
}
if err := scanner.Err(); err != nil {
slog.Warn("docker logs stderr scanner error", "container", container, "err", err)
}
}()
// 后台流式推送 stdout
go func() {
defer cancelFollow()
defer func() {
if err := cmd.Wait(); err != nil {
slog.Warn("docker logs -f exited with error", "container", container, "err", err)
}
}()
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := scanner.Text()
if srv == nil {
continue
}
if err := srv.SendNotificationToClient(ctx, "notifications/docker_logs/stream", map[string]any{
"container": container,
"line": line,
}); err != nil {
slog.Debug("docker logs notification failed, stopping stream", "err", err)
return
}
}
if err := scanner.Err(); err != nil {
slog.Warn("docker logs stdout scanner error", "container", container, "err", err)
}
if srv != nil {
_ = srv.SendNotificationToClient(ctx, "notifications/docker_logs/stream", map[string]any{
"container": container,
"line": "--- 日志流结束 ---",
})
}
}()
return mcp.NewToolResultText(
fmt.Sprintf("开始跟踪容器 %s 日志(tail=%d, follow=true)…", container, tail),
), nil
}