From 66f3478c083e7730c9559357b07d60f24b036873 Mon Sep 17 00:00:00 2001 From: Kaien Date: Sat, 8 Aug 2026 01:49:52 +0300 Subject: [PATCH] Fix .gitignore/.dockerignore matching cmd/ source dir Pattern 'woodpecker-mcp' without a slash matched any file or directory with that name at any depth, silently excluding cmd/woodpecker-mcp/ from commits and Docker build contexts. Anchor to the repo root so only the compiled root binary is ignored. --- .dockerignore | 4 +- .gitignore | 4 +- cmd/woodpecker-mcp/main.go | 123 +++++++++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 cmd/woodpecker-mcp/main.go diff --git a/.dockerignore b/.dockerignore index 6d09aac..c70d29d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,6 @@ # Build artifacts -woodpecker-mcp -woodpecker-mcp.exe +/woodpecker-mcp +/woodpecker-mcp.exe dist/ *.test diff --git a/.gitignore b/.gitignore index 3d1f3d5..6d7a366 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # Build artifacts -woodpecker-mcp -woodpecker-mcp.exe +/woodpecker-mcp +/woodpecker-mcp.exe dist/ *.test diff --git a/cmd/woodpecker-mcp/main.go b/cmd/woodpecker-mcp/main.go new file mode 100644 index 0000000..b2e7116 --- /dev/null +++ b/cmd/woodpecker-mcp/main.go @@ -0,0 +1,123 @@ +// woodpecker-mcp is a Model Context Protocol (MCP) server that connects AI +// assistants to Woodpecker CI. It communicates over stdio by default; a +// remote Streamable HTTP transport is available with --transport=http. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + mcpgo "github.com/mark3labs/mcp-go/server" + + "gitea.fe.kaien.ru/kaien/woodpecker-mcp/internal/config" + "gitea.fe.kaien.ru/kaien/woodpecker-mcp/internal/server" + "gitea.fe.kaien.ru/kaien/woodpecker-mcp/internal/woodpecker" +) + +const ( + transportStdio transport = "stdio" + transportHTTP transport = "http" + + defaultListenAddr = ":8080" + shutdownTimeout = 5 * time.Second +) + +type transport string + +func parseTransport(name string) (transport, error) { + switch transport(name) { + case transportStdio, transportHTTP: + return transport(name), nil + default: + return "", fmt.Errorf("unsupported transport %q (supported: stdio, http)", name) + } +} + +func main() { + // All logs go to stderr; stdout is reserved for the MCP JSON-RPC protocol + // when running over stdio. + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + + var transportName, listenAddr string + flag.StringVar(&transportName, "transport", string(transportStdio), "MCP transport: stdio (default) or http") + flag.StringVar(&listenAddr, "listen", defaultListenAddr, "HTTP listen address (used with --transport=http)") + flag.Parse() + + tr, err := parseTransport(transportName) + if err != nil { + logger.Error("invalid configuration", "error", err) + os.Exit(1) + } + if tr == transportHTTP && strings.TrimSpace(listenAddr) == "" { + logger.Error("invalid configuration", "error", "--listen must not be empty with --transport=http") + os.Exit(1) + } + + cfg, err := config.Load() + if err != nil { + logger.Error("invalid configuration", "error", err) + os.Exit(1) + } + + client, err := woodpecker.NewClient(cfg.APIBaseURL(), cfg.WoodpeckerToken) + if err != nil { + logger.Error("failed to create Woodpecker API client", "error", err) + os.Exit(1) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + mcpServer := server.New(client) + + switch tr { + case transportStdio: + runStdio(ctx, logger, mcpServer) + case transportHTTP: + runHTTP(ctx, logger, mcpServer, listenAddr) + } +} + +// runStdio serves the MCP protocol over stdin/stdout until stdin closes or +// the context is cancelled. +func runStdio(ctx context.Context, logger *slog.Logger, mcpServer *mcpgo.MCPServer) { + transport := mcpgo.NewStdioServer(mcpServer) + if err := transport.Listen(ctx, os.Stdin, os.Stdout); err != nil { + logger.Error("MCP server stopped with error", "error", err) + os.Exit(1) + } +} + +// runHTTP serves the MCP protocol over Streamable HTTP at /mcp and shuts +// down gracefully when the context is cancelled. +func runHTTP(ctx context.Context, logger *slog.Logger, mcpServer *mcpgo.MCPServer, addr string) { + srv := mcpgo.NewStreamableHTTPServer(mcpServer) + + errCh := make(chan error, 1) + go func() { errCh <- srv.Start(addr) }() + logger.Info("MCP server listening", "address", addr, "endpoint", "/mcp") + + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + logger.Error("graceful shutdown failed", "error", err) + os.Exit(1) + } + case err := <-errCh: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("MCP HTTP server failed", "error", err) + os.Exit(1) + } + } +}