commit e8892ce1828a28826c73e91aa43057b5e127c599 Author: Kaien Date: Sat Aug 8 01:37:47 2026 +0300 Initial commit: woodpecker-mcp Go port with stdio/HTTP transports and k8s deploy diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6d09aac --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +# Build artifacts +woodpecker-mcp +woodpecker-mcp.exe +dist/ +*.test + +# VCS +.git +.gitignore + +# Go +vendor/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d1f3d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Build artifacts +woodpecker-mcp +woodpecker-mcp.exe +dist/ +*.test + +# Go +vendor/ diff --git a/.woodpecker.yml b/.woodpecker.yml new file mode 100644 index 0000000..c88ad79 --- /dev/null +++ b/.woodpecker.yml @@ -0,0 +1,73 @@ +clone: + git: + image: woodpeckerci/plugin-git + +steps: + - name: build-and-push + image: gcr.io/kaniko-project/executor:v1.23.2-debug + environment: + ZOT_USERNAME: + from_secret: ZOT_USERNAME + ZOT_PASSWORD: + from_secret: ZOT_PASSWORD + commands: + - mkdir -p /kaniko/.docker + # Authenticate kaniko against the zot registry. The username:password + # pair is base64-encoded into the Docker config.json auth field. + - >- + printf '{"auths":{"registry.be.kaien.ru":{"auth":"%s"}}}' + "$(printf '%s:%s' "$ZOT_USERNAME" "$ZOT_PASSWORD" | base64 | tr -d '\n')" + > /kaniko/.docker/config.json + # Publish an immutable per-commit tag (full CI_COMMIT_SHA) as the + # primary tag, plus :latest as a convenience alias. The version baked + # into the binary via ldflags matches the SHA. Kaniko pushes both in + # one build. + - >- + /kaniko/executor + --context "$CI_WORKSPACE" + --dockerfile "$CI_WORKSPACE/Dockerfile" + --build-arg VERSION=$CI_COMMIT_SHA + --destination "registry.be.kaien.ru/woodpecker_mcp/woodpecker_mcp:$CI_COMMIT_SHA" + --destination "registry.be.kaien.ru/woodpecker_mcp/woodpecker_mcp:latest" + when: + - branch: master + event: [push, manual] + + - name: deploy + image: alpine:latest + entrypoint: ["/bin/sh", "-c", 'printf "%s" "$CI_SCRIPT" | base64 -d > /tmp/woodpecker-script.sh && exec /bin/sh -e /tmp/woodpecker-script.sh'] + environment: + SSH_PRIVATE_KEY: + from_secret: SSH_PRIVATE_KEY + DEPLOY_HOST: + from_secret: DEPLOY_HOST + DEPLOY_USER: + from_secret: DEPLOY_USER + ZOT_USERNAME: + from_secret: ZOT_USERNAME + ZOT_PASSWORD: + from_secret: ZOT_PASSWORD + commands: + - apk add --no-cache openssh-client gettext + - eval $(ssh-agent -s) + - mkdir -p ~/.ssh && chmod 700 ~/.ssh + - echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa + - chmod 600 ~/.ssh/id_rsa + - ssh-add ~/.ssh/id_rsa + - ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts + - chmod 644 ~/.ssh/known_hosts + - echo "$ZOT_PASSWORD" | ssh $DEPLOY_USER@$DEPLOY_HOST "docker login registry.be.kaien.ru -u $ZOT_USERNAME --password-stdin" + # Pull the immutable SHA tag (also verifies it exists in the registry). + - ssh -n $DEPLOY_USER@$DEPLOY_HOST "docker pull registry.be.kaien.ru/woodpecker_mcp/woodpecker_mcp:$CI_COMMIT_SHA" + - ssh $DEPLOY_USER@$DEPLOY_HOST "kubectl --kubeconfig /etc/kubernetes/admin.conf apply -f -" < woodpecker-mcp-sealed.yaml + # Render placeholders (incl. CI_COMMIT_SHA) via the existing envsubst flow + # and apply. The image string now changes every commit, so kubectl apply + # alone triggers a rollout — no manual rollout restart needed. + - envsubst < k8s-deploy.yaml | ssh $DEPLOY_USER@$DEPLOY_HOST "kubectl --kubeconfig /etc/kubernetes/admin.conf apply -f -" + - ssh -n $DEPLOY_USER@$DEPLOY_HOST "kubectl --kubeconfig /etc/kubernetes/admin.conf -n mcp-servers rollout status deployment/woodpecker-mcp --timeout=120s" + - ssh -n $DEPLOY_USER@$DEPLOY_HOST "docker image prune -f" + depends_on: + - build-and-push + when: + - branch: master + event: [push, manual] diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e8e7aa6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26 AS builder + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +ARG VERSION=0.0.0 +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath \ + -ldflags "-s -w -X gitea.fe.kaien.ru/kaien/woodpecker-mcp/internal/version.Version=${VERSION}" \ + -o woodpecker-mcp ./cmd/woodpecker-mcp + +FROM gcr.io/distroless/static-debian12 + +COPY --from=builder /app/woodpecker-mcp /usr/local/bin/woodpecker-mcp + +# Only used with --transport=http; the default stdio mode needs no port. +EXPOSE 8080 + +ENTRYPOINT ["woodpecker-mcp"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e092462 --- /dev/null +++ b/README.md @@ -0,0 +1,164 @@ +# Woodpecker CI MCP Server + +A Model Context Protocol (MCP) server that connects AI assistants to Woodpecker CI. +Debug pipeline failures, analyze build logs, and troubleshoot CI/CD configurations with AI assistance. + +This is a Go implementation of [j04n-f/woodpecker-mcp](https://github.com/j04n-f/woodpecker-mcp) +built with [mark3labs/mcp-go](https://github.com/mark3labs/mcp-go). It speaks the MCP +protocol over stdio (JSON-RPC) and talks to the Woodpecker CI REST API. + +## Requirements + +- Go 1.26+ + +## Build + +```sh +go build -o woodpecker-mcp ./cmd/woodpecker-mcp +``` + +The version embedded in the `initialize` handshake can be set at build time: + +```sh +go build -ldflags "-X gitea.fe.kaien.ru/kaien/woodpecker-mcp/internal/version.Version=v1.2.3" -o woodpecker-mcp ./cmd/woodpecker-mcp +``` + +## Configuration + +| Environment variable | Required | Description | +| -------------------- | -------- | ---------------------------------------------------------------------------- | +| `WOODPECKER_URL` | yes | Base URL of your Woodpecker CI instance, e.g. `https://woodpecker.example.com` | +| `WOODPECKER_TOKEN` | yes | Personal access token (profile icon → "CLI & API") | + +The server refuses to start with a missing or invalid configuration. + +## Transports + +The server speaks MCP over **stdio by default** (the standard mode for local +MCP clients such as Claude Desktop, Cursor, etc.). A remote **Streamable HTTP** +transport is also available: + +| Flag | Default | Description | +| ----------- | -------- | ---------------------------------------------------- | +| `--transport` | `stdio` | `stdio` or `http` | +| `--listen` | `:8080` | HTTP listen address (used with `--transport=http`) | + +```sh +# stdio (default) +woodpecker-mcp + +# remote Streamable HTTP on :8080 (endpoint /mcp) +woodpecker-mcp --transport=http --listen=:8080 +``` + +The HTTP endpoint is `http://:8080/mcp` (Streamable HTTP, MCP spec +2025-06-18). No authentication is enforced — run it inside your trusted +network only. In Docker, expose the port when using HTTP mode: + +```sh +docker run -i --rm -p 8080:8080 \ + -e WOODPECKER_URL=https://your-woodpecker-instance.com \ + -e WOODPECKER_TOKEN=your-token-here \ + your-registry/woodpecker_mcp/woodpecker_mcp --transport=http +``` + +## Deployment (Kubernetes) + +The repository ships a `k8s-deploy.yaml` (Service + Deployment) that runs the +binary in **HTTP mode** (`--transport=http`) on port 8080. + +- Namespace: `mcp-servers` +- Service: `woodpecker-mcp` (ClusterIP, `8080 -> 8080`); in-cluster URL: + `http://woodpecker-mcp.mcp-servers:8080/mcp` +- Image: `registry.be.kaien.ru/woodpecker_mcp/woodpecker_mcp:$CI_COMMIT_SHA` + (rendered by `envsubst` in CI; every commit triggers a rollout via the + `deployment.l-ai/commit` annotation) +- Secrets: `WOODPECKER_TOKEN` comes from the SealedSecret + `woodpecker-mcp-sealed.yaml` (name `woodpecker-mcp`, ns `mcp-servers`). + `WOODPECKER_URL` is not secret and is set as a plain env var + (`https://ci.be.kaien.ru/` — a trailing slash is harmless, the config + trims it before appending `/api`). + +To reseal the token after rotation, run kubeseal and replace +`spec.encryptedData.WOODPECKER_TOKEN`: + +```sh +echo -n 'your-token' | kubeseal --namespace mcp-servers --name woodpecker-mcp --raw +``` + +Woodpecker CI secrets required by the pipeline: `SSH_PRIVATE_KEY`, +`DEPLOY_HOST`, `DEPLOY_USER` (deploy step) plus `ZOT_USERNAME`, +`ZOT_PASSWORD` (build step). + +## MCP Client Setup + +Add to your MCP client configuration: + +```json +{ + "woodpecker-ci": { + "command": "woodpecker-mcp", + "env": { + "WOODPECKER_TOKEN": "your-token-here", + "WOODPECKER_URL": "https://your-woodpecker-instance.com" + } + } +} +``` + +## API Reference + +| Tool | Description | Parameters | +| --------------------- | ---------------------------------------- | ---------------------------------------------------------------------------- | +| `search_repository` | Find repository by name | `name` (e.g. `"owner/repo"`) | +| `get_repository_by_id`| Get repository details by ID | `repoId` | +| `list_repositories` | List all repositories | Optional: `page`, `perPage`, `active`, `trusted` | +| `list_pipelines` | List repository pipelines | `repoId`, optional: `page`, `perPage`, `before`, `after` | +| `get_pipeline` | Get detailed pipeline info | `repoId`, `number` | +| `get_pipeline_config` | View pipeline configuration | `repoId`, `number` | +| `get_step_logs` | Get logs for debugging | `repoId`, `number`, `stepId` | + +All tools are read-only (GET requests only). Errors are returned as structured +JSON with a status-aware description and suggested next steps. + +## Development + +```sh +go vet ./... +go test -race ./... +``` + +## Examples + +### Debug a Failed Pipeline + +``` +AI: Can you check why pipeline #42 failed for repository owner/project? +``` + +### Analyze Build Performance + +``` +AI: Show me the recent pipeline performance for my main repository and identify any bottlenecks. +``` + +### Configuration Review + +``` +AI: Review the pipeline configuration for repository owner/project and suggest improvements. +``` + +## Project Layout + +``` +cmd/woodpecker-mcp/ entry point: config load, signal handling, stdio transport +internal/config/ environment configuration and validation +internal/server/ MCP server assembly +internal/tools/ MCP tool definitions and handlers (repositories, pipelines, logs) +internal/woodpecker/ Woodpecker CI REST API client (types, client, error contexts) +internal/version/ server name and version +``` + +## License + +MIT License diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2d23ffb --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module gitea.fe.kaien.ru/kaien/woodpecker-mcp + +go 1.26.2 + +require github.com/mark3labs/mcp-go v0.57.0 + +require ( + github.com/google/jsonschema-go v0.4.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..cff21b2 --- /dev/null +++ b/go.sum @@ -0,0 +1,34 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mark3labs/mcp-go v0.57.0 h1:jzWKyCzdWnwnZt05cvcQQ+ngiUl2RnixXJa7Kj4qP1E= +github.com/mark3labs/mcp-go v0.57.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..76f3954 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,56 @@ +// Package config loads and validates the server configuration from the +// environment. All settings are required and validated at startup so the +// process fails fast with a clear message instead of at first request. +package config + +import ( + "errors" + "fmt" + "net/url" + "os" + "strings" +) + +// Config holds the validated runtime configuration. +type Config struct { + // WoodpeckerURL is the base URL of the Woodpecker CI server + // (e.g. "https://woodpecker.example.com"). + WoodpeckerURL string + // WoodpeckerToken is the personal access token used for API authentication. + WoodpeckerToken string +} + +// Load reads configuration from the environment and validates it. +func Load() (*Config, error) { + cfg := &Config{ + WoodpeckerURL: os.Getenv("WOODPECKER_URL"), + WoodpeckerToken: os.Getenv("WOODPECKER_TOKEN"), + } + + if cfg.WoodpeckerURL == "" { + return nil, errors.New("WOODPECKER_URL is required (e.g. https://woodpecker.example.com)") + } + + u, err := url.Parse(cfg.WoodpeckerURL) + if err != nil { + return nil, fmt.Errorf("WOODPECKER_URL is not a valid URL: %w", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return nil, fmt.Errorf("WOODPECKER_URL must use http or https scheme, got %q", u.Scheme) + } + if u.Host == "" { + return nil, errors.New("WOODPECKER_URL must include a host (e.g. https://woodpecker.example.com)") + } + + if cfg.WoodpeckerToken == "" { + return nil, errors.New("WOODPECKER_TOKEN is required") + } + + return cfg, nil +} + +// APIBaseURL returns the Woodpecker REST API base URL: the server URL with a +// single trailing slash stripped and "/api" appended. +func (c *Config) APIBaseURL() string { + return strings.TrimSuffix(c.WoodpeckerURL, "/") + "/api" +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..2547fc1 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,60 @@ +package config + +import ( + "strings" + "testing" +) + +func TestLoadValid(t *testing.T) { + t.Setenv("WOODPECKER_URL", "https://woodpecker.example.com/") + t.Setenv("WOODPECKER_TOKEN", "secret") + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.WoodpeckerURL != "https://woodpecker.example.com/" { + t.Errorf("URL = %q", cfg.WoodpeckerURL) + } + if got := cfg.APIBaseURL(); got != "https://woodpecker.example.com/api" { + t.Errorf("APIBaseURL = %q", got) + } +} + +func TestLoadMissingEnv(t *testing.T) { + t.Setenv("WOODPECKER_URL", "") + t.Setenv("WOODPECKER_TOKEN", "") + + if _, err := Load(); err == nil { + t.Fatal("expected error when env is empty") + } +} + +func TestLoadInvalidURL(t *testing.T) { + t.Setenv("WOODPECKER_URL", "not a url ://") + t.Setenv("WOODPECKER_TOKEN", "secret") + + if _, err := Load(); err == nil { + t.Fatal("expected error for invalid URL") + } +} + +func TestLoadNonHTTPURL(t *testing.T) { + t.Setenv("WOODPECKER_URL", "ftp://woodpecker.example.com") + t.Setenv("WOODPECKER_TOKEN", "secret") + + _, err := Load() + if err == nil || !strings.Contains(err.Error(), "http or https") { + t.Fatalf("expected scheme error, got %v", err) + } +} + +func TestLoadMissingToken(t *testing.T) { + t.Setenv("WOODPECKER_URL", "https://woodpecker.example.com") + t.Setenv("WOODPECKER_TOKEN", "") + + _, err := Load() + if err == nil || !strings.Contains(err.Error(), "WOODPECKER_TOKEN") { + t.Fatalf("expected token error, got %v", err) + } +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..eef5f52 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,23 @@ +// Package server builds the MCP server and its tool set. +package server + +import ( + mcpgo "github.com/mark3labs/mcp-go/server" + + "gitea.fe.kaien.ru/kaien/woodpecker-mcp/internal/tools" + "gitea.fe.kaien.ru/kaien/woodpecker-mcp/internal/version" + "gitea.fe.kaien.ru/kaien/woodpecker-mcp/internal/woodpecker" +) + +// New creates the MCP server with all Woodpecker tools registered. +// The server advertises tool capabilities only; prompts are intentionally +// not implemented. +func New(c *woodpecker.Client) *mcpgo.MCPServer { + s := mcpgo.NewMCPServer( + version.Name, + version.Version, + mcpgo.WithToolCapabilities(true), + ) + tools.RegisterAll(s, c) + return s +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..512362d --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,120 @@ +package server + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "sort" + "strings" + "testing" + + mcpgo "github.com/mark3labs/mcp-go/server" + + "gitea.fe.kaien.ru/kaien/woodpecker-mcp/internal/woodpecker" +) + +func TestServerRegistersAllTools(t *testing.T) { + c, err := woodpecker.NewClient("http://localhost:8000/api", "token") + if err != nil { + t.Fatal(err) + } + + srv := New(c) + tools := srv.ListTools() + + got := make([]string, 0, len(tools)) + for name := range tools { + got = append(got, name) + } + sort.Strings(got) + + want := []string{ + "get_pipeline", + "get_pipeline_config", + "get_repository_by_id", + "get_step_logs", + "list_pipelines", + "list_repositories", + "search_repository", + } + if len(got) != len(want) { + t.Fatalf("registered %d tools, want %d: %v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("tool[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestServerHasNoPrompts(t *testing.T) { + c, err := woodpecker.NewClient("http://localhost:8000/api", "token") + if err != nil { + t.Fatal(err) + } + srv := New(c) + + if prompts := srv.ListPrompts(); len(prompts) != 0 { + t.Errorf("prompts must be disabled, got %d", len(prompts)) + } +} + +// TestStreamableHTTPHandshake exercises the remote transport: a POST +// initialize over Streamable HTTP must answer with the server info. +func TestStreamableHTTPHandshake(t *testing.T) { + c, err := woodpecker.NewClient("http://localhost:8000/api", "token") + if err != nil { + t.Fatal(err) + } + + httpServer := mcpgo.NewStreamableHTTPServer(New(c)) + ts := httptest.NewServer(httpServer) + defer ts.Close() + + req, err := http.NewRequest(http.MethodPost, ts.URL+"/mcp", + strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, body = %s", resp.StatusCode, body) + } + + var message struct { + Result struct { + ProtocolVersion string `json:"protocolVersion"` + ServerInfo struct { + Name string `json:"name"` + Version string `json:"version"` + } `json:"serverInfo"` + Capabilities struct { + Tools struct { + ListChanged bool `json:"listChanged"` + } `json:"tools"` + } `json:"capabilities"` + } `json:"result"` + } + if err := json.Unmarshal(body, &message); err != nil { + t.Fatalf("invalid JSON-RPC response: %v\n%s", err, body) + } + if message.Result.ServerInfo.Name != "woodpecker-mcp" { + t.Errorf("server name = %q", message.Result.ServerInfo.Name) + } + if !message.Result.Capabilities.Tools.ListChanged { + t.Error("tools capability missing listChanged") + } +} diff --git a/internal/tools/helpers.go b/internal/tools/helpers.go new file mode 100644 index 0000000..c9ed8ee --- /dev/null +++ b/internal/tools/helpers.go @@ -0,0 +1,139 @@ +package tools + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "math" + "net/url" + "strconv" + + "github.com/mark3labs/mcp-go/mcp" + + "gitea.fe.kaien.ru/kaien/woodpecker-mcp/internal/woodpecker" +) + +// decodedArguments returns the tool call arguments as a map. When the raw +// JSON payload is available it is decoded with json.Number so 64-bit integer +// arguments (repo IDs, pipeline numbers) never lose precision through float64. +func decodedArguments(req mcp.CallToolRequest) map[string]any { + if len(req.Params.RawArguments) > 0 { + var args map[string]any + dec := json.NewDecoder(bytes.NewReader(req.Params.RawArguments)) + dec.UseNumber() + if dec.Decode(&args) == nil { + return args + } + } + return req.GetArguments() +} + +// argString returns the string value for key, or "" when absent. +func argString(args map[string]any, key string) string { + v, _ := args[key].(string) + return v +} + +// argInt64 returns the integer value for key, honoring both the float64 +// values produced by standard JSON decoding and json.Number values produced +// by decodedArguments. ok is false when the key is absent or not an integer. +func argInt64(args map[string]any, key string) (int64, bool) { + v, ok := args[key] + if !ok { + return 0, false + } + switch n := v.(type) { + case json.Number: + i, err := n.Int64() + return i, err == nil + case float64: + if n != math.Trunc(n) { + return 0, false + } + return int64(n), true + default: + return 0, false + } +} + +// argBool returns the boolean value for key; ok is false when absent. +func argBool(args map[string]any, key string) (bool, bool) { + v, ok := args[key].(bool) + return v, ok +} + +// encodePath escapes a path segment the same way encodeURIComponent does for +// identifiers that must not contain slashes (e.g. "owner/repo"). +func encodePath(segment string) string { + return url.QueryEscape(segment) +} + +// render marshals v to a JSON string. Marshalling never fails for the plain +// output structs used by the tools; the defensive branch guards against +// misuse. +func render(v any) string { + b, err := json.Marshal(v) + if err != nil { + return `{"error":"failed to serialize response"}` + } + return string(b) +} + +// renderError produces the client-facing error payload for a failed tool +// call: the status-aware context JSON, wrapped for the MCP result. +func renderError(err error, context string) *mcp.CallToolResult { + var apiErr *woodpecker.APIError + if !errors.As(err, &apiErr) { + apiErr = &woodpecker.APIError{Status: 500, Message: err.Error()} + } + return mcp.NewToolResultError(apiErr.ToContext(context)) +} + +// decodeBase64 decodes s as base64 UTF-8. Invalid input is returned as-is, +// matching the lenient Buffer.from(data, "base64") behaviour of the original +// client. +func decodeBase64(s string) string { + b, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return s + } + return string(b) +} + +// readOnlyAnnotations marks a tool as read-only and idempotent. All +// woodpecker-mcp tools only perform GET requests, so clients may cache +// results and skip destructive-action confirmations. mcp-go defaults to +// destructive/open-world, so every field is set explicitly. +func readOnlyAnnotations() []mcp.ToolOption { + return []mcp.ToolOption{ + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithOpenWorldHintAnnotation(false), + } +} + +// newReadOnlyTool builds a tool whose options carry the read-only +// annotations in addition to opts. +func newReadOnlyTool(name string, opts ...mcp.ToolOption) mcp.Tool { + return mcp.NewTool(name, append(opts, readOnlyAnnotations()...)...) +} + +// parseIntParam validates and returns a positive integer parameter. +// When absent it returns fallback; when present but invalid it reports why. +func parseIntParam(args map[string]any, key string, fallback int64, min, max int64) (int64, error) { + v, ok := argInt64(args, key) + if !ok { + return fallback, nil + } + if v < min || v > max { + return 0, errors.New(key + " must be between " + strconv.FormatInt(min, 10) + " and " + strconv.FormatInt(max, 10)) + } + return v, nil +} + +// formatInt64 is a tiny helper to keep query building readable. +func formatInt64(v int64) string { + return strconv.FormatInt(v, 10) +} diff --git a/internal/tools/logs.go b/internal/tools/logs.go new file mode 100644 index 0000000..cc9b288 --- /dev/null +++ b/internal/tools/logs.go @@ -0,0 +1,102 @@ +package tools + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/mark3labs/mcp-go/mcp" + mcpgo "github.com/mark3labs/mcp-go/server" + + "gitea.fe.kaien.ru/kaien/woodpecker-mcp/internal/woodpecker" +) + +// noLogsMessage mirrors the exact message the original client returns when a +// step has no logs. +const noLogsMessage = "No logs available for this step -- use get_pipeline to verify step existence or status" + +// registerLogs registers the log tools: get_step_logs. +func registerLogs(s *mcpgo.MCPServer, c *woodpecker.Client) { + tool, handler := getStepLogsTool(c) + s.AddTool(tool, handler) +} + +// getStepLogsTool returns the decoded logs of a specific pipeline step. +func getStepLogsTool(c *woodpecker.Client) (mcp.Tool, mcpgo.ToolHandlerFunc) { + tool := newReadOnlyTool("get_step_logs", + mcp.WithToolTitle("Get Step Logs"), + mcp.WithDescription("Get logs for a specific pipeline step. Useful for debugging individual step failures or examining step output."), + mcp.WithInteger("repoId", + mcp.Required(), + mcp.Description("The repository ID"), + mcp.Min(1), + ), + mcp.WithInteger("number", + mcp.Required(), + mcp.Description("The pipeline number"), + mcp.Min(1), + ), + mcp.WithInteger("stepId", + mcp.Required(), + mcp.Description("The step ID (can be found in pipeline details)"), + mcp.Min(1), + ), + ) + + handler := func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := decodedArguments(req) + + repoID, bad := parseRepoID(args) + if bad != nil { + return bad, nil + } + number, bad := parsePipelineNumber(args) + if bad != nil { + return bad, nil + } + stepID, ok := argInt64(args, "stepId") + if !ok || stepID < 1 { + return mcp.NewToolResultError(render(map[string]string{ + "error": "stepId must be a positive integer", + })), nil + } + + endpoint := fmt.Sprintf("/repos/%d/logs/%d/%d", repoID, number, stepID) + + var raw json.RawMessage + if err := c.Get(ctx, endpoint, &raw); err != nil { + return renderError(err, fmt.Sprintf("logs for step %d in pipeline %d of repository %d", stepID, number, repoID)), nil + } + + // A literal null (step never ran) yields the no-logs message; an + // empty array yields an empty log string, mirroring the original. + if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return mcp.NewToolResultText(render(map[string]any{ + "stepId": stepID, + "logs": noLogsMessage, + })), nil + } + + var logs []woodpecker.LogEntry + if err := json.Unmarshal(raw, &logs); err != nil { + return renderError(errors.New("failed to decode logs: "+err.Error()), fmt.Sprintf("logs for step %d in pipeline %d of repository %d", stepID, number, repoID)), nil + } + + lines := make([]string, 0, len(logs)) + for _, entry := range logs { + if entry.Data == nil { + lines = append(lines, "") + continue + } + lines = append(lines, decodeBase64(*entry.Data)) + } + return mcp.NewToolResultText(render(map[string]any{ + "stepId": stepID, + "logs": strings.Join(lines, "\n"), + })), nil + } + return tool, handler +} diff --git a/internal/tools/pipelines.go b/internal/tools/pipelines.go new file mode 100644 index 0000000..45a90a1 --- /dev/null +++ b/internal/tools/pipelines.go @@ -0,0 +1,341 @@ +package tools + +import ( + "context" + "fmt" + "net/url" + + "github.com/mark3labs/mcp-go/mcp" + mcpgo "github.com/mark3labs/mcp-go/server" + + "gitea.fe.kaien.ru/kaien/woodpecker-mcp/internal/woodpecker" +) + +// pipelineSummaryOutput is the compact client-facing shape used by +// list_pipelines. +type pipelineSummaryOutput struct { + ID int64 `json:"id"` + Number int64 `json:"number"` + Status string `json:"status"` + Event string `json:"event"` + Branch string `json:"branch"` + Commit string `json:"commit"` + Message *string `json:"message,omitempty"` + Author *string `json:"author,omitempty"` + Started *int64 `json:"started,omitempty"` + Finished *int64 `json:"finished,omitempty"` + Created int64 `json:"created"` + Updated int64 `json:"updated"` +} + +func toPipelineSummaryOutput(p woodpecker.Pipeline) pipelineSummaryOutput { + return pipelineSummaryOutput{ + ID: p.ID, + Number: p.Number, + Status: p.Status, + Event: p.Event, + Branch: p.Branch, + Commit: p.Commit, + Message: p.Message, + Author: p.Author, + Started: p.StartedAt, + Finished: p.FinishedAt, + Created: p.CreatedAt, + Updated: p.UpdatedAt, + } +} + +// stepOutput is a step inside a workflow of the pipeline detail view. +type stepOutput struct { + ID int64 `json:"id"` + Name string `json:"name"` + State string `json:"state"` + Error *string `json:"error,omitempty"` + ExitCode *int64 `json:"exitCode,omitempty"` + Started *int64 `json:"started,omitempty"` + Finished *int64 `json:"finished,omitempty"` +} + +// workflowOutput is a workflow of the pipeline detail view. +type workflowOutput struct { + ID int64 `json:"id"` + Name string `json:"name"` + State string `json:"state"` + Error *string `json:"error,omitempty"` + Started *int64 `json:"started,omitempty"` + Finished *int64 `json:"finished,omitempty"` + Children []stepOutput `json:"children,omitempty"` +} + +// pipelineOutput is the full client-facing pipeline shape used by get_pipeline. +type pipelineOutput struct { + ID int64 `json:"id"` + Number int64 `json:"number"` + Status string `json:"status"` + Event string `json:"event"` + Branch string `json:"branch"` + Commit string `json:"commit"` + Message *string `json:"message,omitempty"` + Author *string `json:"author,omitempty"` + Email *string `json:"email,omitempty"` + Started *int64 `json:"started,omitempty"` + Finished *int64 `json:"finished,omitempty"` + Created int64 `json:"created"` + Updated int64 `json:"updated"` + Workflows []workflowOutput `json:"workflows,omitempty"` +} + +func toPipelineOutput(p woodpecker.Pipeline) pipelineOutput { + out := pipelineOutput{ + ID: p.ID, + Number: p.Number, + Status: p.Status, + Event: p.Event, + Branch: p.Branch, + Commit: p.Commit, + Message: p.Message, + Author: p.Author, + Email: p.AuthorEmail, + Started: p.StartedAt, + Finished: p.FinishedAt, + Created: p.CreatedAt, + Updated: p.UpdatedAt, + } + for _, w := range p.Workflows { + wo := workflowOutput{ + ID: w.ID, + Name: w.Name, + State: w.State, + Error: w.Error, + Started: w.StartTime, + Finished: w.EndTime, + } + for _, st := range w.Children { + wo.Children = append(wo.Children, stepOutput{ + ID: st.ID, + Name: st.Name, + State: st.State, + Error: st.Error, + ExitCode: st.ExitCode, + Started: st.StartTime, + Finished: st.EndTime, + }) + } + out.Workflows = append(out.Workflows, wo) + } + return out +} + +// registerPipelines registers the pipeline tools: list_pipelines, get_pipeline +// and get_pipeline_config. +func registerPipelines(s *mcpgo.MCPServer, c *woodpecker.Client) { + tool, handler := listPipelinesTool(c) + s.AddTool(tool, handler) + + tool, handler = getPipelineTool(c) + s.AddTool(tool, handler) + + tool, handler = getPipelineConfigTool(c) + s.AddTool(tool, handler) +} + +// parseRepoID reads and validates the repoId argument. +func parseRepoID(args map[string]any) (int64, *mcp.CallToolResult) { + repoID, ok := argInt64(args, "repoId") + if !ok || repoID < 1 { + return 0, mcp.NewToolResultError(render(map[string]string{ + "error": "repoId must be a positive integer", + })) + } + return repoID, nil +} + +// parsePipelineNumber reads and validates the number argument. +func parsePipelineNumber(args map[string]any) (int64, *mcp.CallToolResult) { + number, ok := argInt64(args, "number") + if !ok || number < 1 { + return 0, mcp.NewToolResultError(render(map[string]string{ + "error": "number must be a positive integer", + })) + } + return number, nil +} + +// listPipelinesTool lists pipelines for a repository with optional filtering. +func listPipelinesTool(c *woodpecker.Client) (mcp.Tool, mcpgo.ToolHandlerFunc) { + tool := newReadOnlyTool("list_pipelines", + mcp.WithToolTitle("List Pipelines"), + mcp.WithDescription(`List pipelines for a repository with optional filtering. Returns pipeline metadata including status, commit info, and timing. +If required information is not provided, use the git command or any available tool to get more context about the current commit, the repository, or the author of the commit.`), + mcp.WithInteger("repoId", + mcp.Required(), + mcp.Description("Numeric repository ID"), + mcp.Min(1), + ), + mcp.WithInteger("page", + mcp.Description("Page number for pagination"), + mcp.Min(1), + mcp.DefaultNumber(defaultPage), + ), + mcp.WithInteger("perPage", + mcp.Description("Number of pipelines per page"), + mcp.Min(1), + mcp.Max(maxPerPage), + mcp.DefaultNumber(defaultPerPage), + ), + mcp.WithString("before", + mcp.Description("Only return pipelines before this RFC3339 date (e.g., '2023-12-01T10:00:00Z')"), + ), + mcp.WithString("after", + mcp.Description("Only return pipelines after this RFC3339 date (e.g., '2023-12-01T10:00:00Z')"), + ), + ) + + handler := func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := decodedArguments(req) + + repoID, bad := parseRepoID(args) + if bad != nil { + return bad, nil + } + page, err := parseIntParam(args, "page", defaultPage, 1, 1<<31-1) + if err != nil { + return mcp.NewToolResultError(render(map[string]string{"error": err.Error()})), nil + } + perPage, err := parseIntParam(args, "perPage", defaultPerPage, 1, maxPerPage) + if err != nil { + return mcp.NewToolResultError(render(map[string]string{"error": err.Error()})), nil + } + + q := url.Values{} + q.Set("page", formatInt64(page)) + q.Set("perPage", formatInt64(perPage)) + if before := argString(args, "before"); before != "" { + q.Set("before", before) + } + if after := argString(args, "after"); after != "" { + q.Set("after", after) + } + + var pipelines []woodpecker.Pipeline + endpoint := fmt.Sprintf("/repos/%d/pipelines?%s", repoID, q.Encode()) + if err := c.Get(ctx, endpoint, &pipelines); err != nil { + return renderError(err, fmt.Sprintf("pipelines for repository %d", repoID)), nil + } + + output := make([]pipelineSummaryOutput, 0, len(pipelines)) + for _, p := range pipelines { + output = append(output, toPipelineSummaryOutput(p)) + } + return mcp.NewToolResultText(render(map[string]any{ + "repoId": repoID, + "pipelines": output, + "count": len(output), + })), nil + } + return tool, handler +} + +// getPipelineTool returns detailed information about a specific pipeline. +func getPipelineTool(c *woodpecker.Client) (mcp.Tool, mcpgo.ToolHandlerFunc) { + tool := newReadOnlyTool("get_pipeline", + mcp.WithToolTitle("Get Pipeline"), + mcp.WithDescription(`Get detailed information about a specific pipeline, including workflows, steps, and error details. +If required information is not provided, use the git command or any available tool to get more context about the current commit, the repository, or the author of the commit.`), + mcp.WithInteger("repoId", + mcp.Required(), + mcp.Description("The repository ID"), + mcp.Min(1), + ), + mcp.WithInteger("number", + mcp.Required(), + mcp.Description("The pipeline number"), + mcp.Min(1), + ), + ) + + handler := func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := decodedArguments(req) + + repoID, bad := parseRepoID(args) + if bad != nil { + return bad, nil + } + number, bad := parsePipelineNumber(args) + if bad != nil { + return bad, nil + } + + var pipeline woodpecker.Pipeline + endpoint := fmt.Sprintf("/repos/%d/pipelines/%d", repoID, number) + if err := c.Get(ctx, endpoint, &pipeline); err != nil { + return renderError(err, fmt.Sprintf("pipeline %d in repository %d", number, repoID)), nil + } + + return mcp.NewToolResultText(render(map[string]any{ + "pipelineNumber": number, + "pipeline": toPipelineOutput(pipeline), + })), nil + } + return tool, handler +} + +// configOutput is a decoded pipeline configuration file. +type configOutput struct { + Name string `json:"name"` + Hash string `json:"hash"` + Content string `json:"content"` +} + +// getPipelineConfigTool returns the configuration files used by a pipeline. +func getPipelineConfigTool(c *woodpecker.Client) (mcp.Tool, mcpgo.ToolHandlerFunc) { + tool := newReadOnlyTool("get_pipeline_config", + mcp.WithToolTitle("Get Pipeline Config"), + mcp.WithDescription(`Get the configuration files (.woodpecker.yml) used by a specific pipeline. Essential understanding pipeline setup. +If required information is not provided, use the git command or any available tool to get more context about the current commit, the repository, or the author of the commit.`), + mcp.WithInteger("repoId", + mcp.Required(), + mcp.Description("The repository ID"), + mcp.Min(1), + ), + mcp.WithInteger("number", + mcp.Required(), + mcp.Description("The pipeline number"), + mcp.Min(1), + ), + ) + + handler := func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := decodedArguments(req) + + repoID, bad := parseRepoID(args) + if bad != nil { + return bad, nil + } + number, bad := parsePipelineNumber(args) + if bad != nil { + return bad, nil + } + + var configs []woodpecker.Config + endpoint := fmt.Sprintf("/repos/%d/pipelines/%d/config", repoID, number) + if err := c.Get(ctx, endpoint, &configs); err != nil { + return renderError(err, fmt.Sprintf("configuration for pipeline %d in repository %d", number, repoID)), nil + } + + output := make([]configOutput, 0, len(configs)) + for _, cfg := range configs { + output = append(output, configOutput{ + Name: cfg.Name, + Hash: cfg.Hash, + Content: decodeBase64(cfg.Data), + }) + } + return mcp.NewToolResultText(render(map[string]any{ + "pipelineNumber": number, + "configs": output, + "count": len(output), + })), nil + } + return tool, handler +} diff --git a/internal/tools/repositories.go b/internal/tools/repositories.go new file mode 100644 index 0000000..de89668 --- /dev/null +++ b/internal/tools/repositories.go @@ -0,0 +1,197 @@ +package tools + +import ( + "context" + "fmt" + "net/url" + "regexp" + + "github.com/mark3labs/mcp-go/mcp" + mcpgo "github.com/mark3labs/mcp-go/server" + + "gitea.fe.kaien.ru/kaien/woodpecker-mcp/internal/woodpecker" +) + +const ( + defaultPage int64 = 1 + defaultPerPage int64 = 50 + maxPerPage int64 = 100 +) + +var repoNamePattern = regexp.MustCompile(`^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$`) + +// repositoryOutput is the client-facing shape of a repository. Field names +// and presence semantics match the original implementation. +type repositoryOutput struct { + ID int64 `json:"id"` + ForgeRemoteID string `json:"forge_remote_id"` + Name string `json:"name"` + Owner string `json:"owner"` + FullName string `json:"full_name"` + DefaultBranch *string `json:"default_branch,omitempty"` + Private bool `json:"private"` + Trusted bool `json:"trusted"` + Active bool `json:"active"` + ConfigFile *string `json:"config_file,omitempty"` + Timeout *int64 `json:"timeout,omitempty"` + ForgeID int64 `json:"forge_id"` +} + +func toRepositoryOutput(r woodpecker.Repo) repositoryOutput { + return repositoryOutput{ + ID: r.ID, + ForgeRemoteID: r.ForgeRemoteID, + Name: r.Name, + Owner: r.Owner, + FullName: r.FullName, + DefaultBranch: r.DefaultBranch, + Private: r.Private, + Trusted: r.Trusted, + Active: r.Active, + ConfigFile: r.ConfigFile, + Timeout: r.Timeout, + ForgeID: r.ForgeID, + } +} + +// registerRepositories registers the repository tools: search_repository, +// get_repository_by_id and list_repositories. +func registerRepositories(s *mcpgo.MCPServer, c *woodpecker.Client) { + tool, handler := searchRepositoryTool(c) + s.AddTool(tool, handler) + + tool, handler = getRepositoryByIDTool(c) + s.AddTool(tool, handler) + + tool, handler = listRepositoriesTool(c) + s.AddTool(tool, handler) +} + +// searchRepositoryTool finds a repository by its full name (owner/repo). +func searchRepositoryTool(c *woodpecker.Client) (mcp.Tool, mcpgo.ToolHandlerFunc) { + tool := newReadOnlyTool("search_repository", + mcp.WithToolTitle("Search Repository"), + mcp.WithDescription(`Find a repository by its full name (owner/repo format). Returns repository details including ID, URLs, and configuration. +If required information is not provided, use the git command or any available tool to get more context about the current commit, the repository, or the author of the commit.`), + mcp.WithString("name", + mcp.Required(), + mcp.Description("Repository full name (e.g., 'microsoft/vscode')"), + mcp.Pattern(repoNamePattern.String()), + ), + ) + + handler := func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := decodedArguments(req) + name := argString(args, "name") + if !repoNamePattern.MatchString(name) { + return mcp.NewToolResultError(render(map[string]string{ + "error": fmt.Sprintf("Invalid repository name format: %s. Expected format: owner/repo", name), + })), nil + } + + var repo woodpecker.Repo + if err := c.Get(ctx, "/repos/lookup/"+encodePath(name), &repo); err != nil { + return renderError(err, "repository lookup for "+name), nil + } + + return mcp.NewToolResultText(render(map[string]any{ + "name": name, + "repository": toRepositoryOutput(repo), + })), nil + } + return tool, handler +} + +// getRepositoryByIDTool returns repository details by numeric ID. +func getRepositoryByIDTool(c *woodpecker.Client) (mcp.Tool, mcpgo.ToolHandlerFunc) { + tool := newReadOnlyTool("get_repository_by_id", + mcp.WithToolTitle("Get Repository by ID"), + mcp.WithDescription("Get repository details by ID. Useful when you already have the repo ID from other operations and need repository context."), + mcp.WithInteger("repoId", + mcp.Required(), + mcp.Description("The repository ID"), + mcp.Min(1), + ), + ) + + handler := func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := decodedArguments(req) + repoID, ok := argInt64(args, "repoId") + if !ok || repoID < 1 { + return mcp.NewToolResultError(render(map[string]string{ + "error": "repoId must be a positive integer", + })), nil + } + + var repo woodpecker.Repo + if err := c.Get(ctx, "/repos/"+formatInt64(repoID), &repo); err != nil { + return renderError(err, fmt.Sprintf("repository with ID %d", repoID)), nil + } + + return mcp.NewToolResultText(render(map[string]any{ + "repoId": repoID, + "repository": toRepositoryOutput(repo), + })), nil + } + return tool, handler +} + +// listRepositoriesTool lists all repositories with optional filtering. +func listRepositoriesTool(c *woodpecker.Client) (mcp.Tool, mcpgo.ToolHandlerFunc) { + tool := newReadOnlyTool("list_repositories", + mcp.WithToolTitle("List Repositories"), + mcp.WithDescription(`List all repositories with optional filtering. Returns repository metadata including configuration, status, and access details. +If required information is not provided, use the git command or any available tool to get more context about the current commit, the repository, or the author of the commit.`), + mcp.WithInteger("page", + mcp.Description("Page number for pagination"), + mcp.Min(1), + mcp.DefaultNumber(defaultPage), + ), + mcp.WithInteger("perPage", + mcp.Description("Number of repositories per page"), + mcp.Min(1), + mcp.Max(maxPerPage), + mcp.DefaultNumber(defaultPerPage), + ), + mcp.WithBoolean("active", mcp.Description("Filter by active status")), + mcp.WithBoolean("trusted", mcp.Description("Filter by trusted status")), + ) + + handler := func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := decodedArguments(req) + + page, err := parseIntParam(args, "page", defaultPage, 1, 1<<31-1) + if err != nil { + return mcp.NewToolResultError(render(map[string]string{"error": err.Error()})), nil + } + perPage, err := parseIntParam(args, "perPage", defaultPerPage, 1, maxPerPage) + if err != nil { + return mcp.NewToolResultError(render(map[string]string{"error": err.Error()})), nil + } + + q := url.Values{} + q.Set("page", formatInt64(page)) + q.Set("perPage", formatInt64(perPage)) + if active, ok := argBool(args, "active"); ok { + q.Set("active", fmt.Sprintf("%t", active)) + } + if trusted, ok := argBool(args, "trusted"); ok { + q.Set("trusted", fmt.Sprintf("%t", trusted)) + } + + var repos []woodpecker.Repo + if err := c.Get(ctx, "/repos?"+q.Encode(), &repos); err != nil { + return renderError(err, "repositories list"), nil + } + + output := make([]repositoryOutput, 0, len(repos)) + for _, repo := range repos { + output = append(output, toRepositoryOutput(repo)) + } + return mcp.NewToolResultText(render(map[string]any{ + "repositories": output, + "count": len(output), + })), nil + } + return tool, handler +} diff --git a/internal/tools/tools.go b/internal/tools/tools.go new file mode 100644 index 0000000..738d5dd --- /dev/null +++ b/internal/tools/tools.go @@ -0,0 +1,15 @@ +// Package tools wires the Woodpecker MCP tools onto the MCP server. +package tools + +import ( + mcpgo "github.com/mark3labs/mcp-go/server" + + "gitea.fe.kaien.ru/kaien/woodpecker-mcp/internal/woodpecker" +) + +// RegisterAll registers every MCP tool on the server. +func RegisterAll(s *mcpgo.MCPServer, c *woodpecker.Client) { + registerRepositories(s, c) + registerPipelines(s, c) + registerLogs(s, c) +} diff --git a/internal/tools/tools_test.go b/internal/tools/tools_test.go new file mode 100644 index 0000000..5583ede --- /dev/null +++ b/internal/tools/tools_test.go @@ -0,0 +1,462 @@ +package tools + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/mark3labs/mcp-go/mcp" + mcpgo "github.com/mark3labs/mcp-go/server" + + "gitea.fe.kaien.ru/kaien/woodpecker-mcp/internal/woodpecker" +) + +const repoJSON = `{"id":7,"forge_remote_id":"101","name":"demo","owner":"me","full_name":"me/demo","default_branch":"main","private":false,"trusted":true,"active":true,"config_file":".woodpecker.yml","timeout":60,"forge_id":1}` + +const pipelineJSON = `{ + "id":1,"number":42,"status":"failure","event":"push", + "commit":"abc123","branch":"main","ref":"refs/heads/main", + "message":"fix: nope","author":"me","author_email":"me@example.com", + "created_at":10,"updated_at":11,"started_at":12,"finished_at":13, + "workflows":[ + {"id":1,"name":"build","state":"failure","start_time":12,"end_time":13,"pipeline_id":1, + "children":[ + {"id":11,"name":"build","uuid":"u1","type":"commands","state":"failure","error":"exit code 1","exit_code":1,"start_time":12,"end_time":13,"pipeline_id":1}, + {"id":12,"name":"publish","uuid":"u2","type":"plugin","state":"skipped","pipeline_id":1} + ]} + ] +}` + +// newFakeClient spins up a fake Woodpecker API backed by mux and returns a +// client pointed at it. +func newFakeClient(t *testing.T, mux *http.ServeMux) *woodpecker.Client { + t.Helper() + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + c, err := woodpecker.NewClient(srv.URL+"/api", "test-token", woodpecker.WithHTTPClient(srv.Client())) + if err != nil { + t.Fatal(err) + } + return c +} + +// call invokes a tool handler with the given arguments. +func call(t *testing.T, h mcpgo.ToolHandlerFunc, args map[string]any) *mcp.CallToolResult { + t.Helper() + res, err := h(context.Background(), mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: args}}) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + return res +} + +func resultText(t *testing.T, res *mcp.CallToolResult) string { + t.Helper() + if res == nil || len(res.Content) == 0 { + t.Fatal("result has no content") + } + tc, ok := res.Content[0].(mcp.TextContent) + if !ok { + t.Fatalf("unexpected content type %T", res.Content[0]) + } + return tc.Text +} + +func decodeResult[T any](t *testing.T, res *mcp.CallToolResult) T { + t.Helper() + var out T + if err := json.Unmarshal([]byte(resultText(t, res)), &out); err != nil { + t.Fatalf("result is not valid JSON: %v\n%s", err, resultText(t, res)) + } + return out +} + +func assertQuery(t *testing.T, r *http.Request, key, want string) { + t.Helper() + if got := r.URL.Query().Get(key); got != want { + t.Errorf("query %q = %q, want %q", key, got, want) + } +} + +// --- search_repository --- + +func TestSearchRepositorySuccess(t *testing.T) { + var gotPath string + mux := http.NewServeMux() + mux.HandleFunc("/api/repos/lookup/", func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.EscapedPath() + _, _ = w.Write([]byte(repoJSON)) + }) + c := newFakeClient(t, mux) + + _, h := searchRepositoryTool(c) + res := call(t, h, map[string]any{"name": "me/demo"}) + + if res.IsError { + t.Fatalf("unexpected error: %s", resultText(t, res)) + } + if gotPath != "/api/repos/lookup/me%2Fdemo" { + t.Errorf("path = %q", gotPath) + } + + out := decodeResult[map[string]any](t, res) + if out["name"] != "me/demo" { + t.Errorf("name = %v", out["name"]) + } + repo, _ := out["repository"].(map[string]any) + if repo == nil { + t.Fatal("missing repository field") + } + if repo["full_name"] != "me/demo" || repo["id"] != float64(7) { + t.Errorf("repository = %v", repo) + } + if repo["default_branch"] != "main" { + t.Errorf("default_branch = %v", repo["default_branch"]) + } +} + +func TestSearchRepositoryInvalidName(t *testing.T) { + c := newFakeClient(t, http.NewServeMux()) + _, h := searchRepositoryTool(c) + + for _, name := range []string{"no-slash", "owner/repo/extra", "space name/also", ""} { + res := call(t, h, map[string]any{"name": name}) + if !res.IsError { + t.Errorf("name %q: expected error result", name) + } + text := resultText(t, res) + if !strings.Contains(text, "Invalid repository name format") { + t.Errorf("name %q: error text %q", name, text) + } + } +} + +func TestSearchRepositoryNotFound(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/repos/lookup/", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"repository not found"}`)) + }) + c := newFakeClient(t, mux) + + _, h := searchRepositoryTool(c) + res := call(t, h, map[string]any{"name": "me/missing"}) + + if !res.IsError { + t.Fatal("expected error result") + } + text := resultText(t, res) + if !strings.Contains(text, `"error":"NOT_FOUND"`) { + t.Errorf("expected NOT_FOUND context, got %s", text) + } + if !strings.Contains(text, "repository lookup for me/missing") { + t.Errorf("expected operation context, got %s", text) + } +} + +// --- get_repository_by_id --- + +func TestGetRepositoryByID(t *testing.T) { + var gotPath string + mux := http.NewServeMux() + mux.HandleFunc("/api/repos/", func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + _, _ = w.Write([]byte(repoJSON)) + }) + c := newFakeClient(t, mux) + + _, h := getRepositoryByIDTool(c) + res := call(t, h, map[string]any{"repoId": float64(7)}) + + if res.IsError { + t.Fatalf("unexpected error: %s", resultText(t, res)) + } + if gotPath != "/api/repos/7" { + t.Errorf("path = %q", gotPath) + } + out := decodeResult[map[string]any](t, res) + if out["repoId"] != float64(7) { + t.Errorf("repoId = %v", out["repoId"]) + } +} + +func TestGetRepositoryByIDInvalid(t *testing.T) { + c := newFakeClient(t, http.NewServeMux()) + _, h := getRepositoryByIDTool(c) + res := call(t, h, map[string]any{"repoId": float64(0)}) + if !res.IsError { + t.Fatal("expected error result for repoId 0") + } +} + +// --- list_repositories --- + +func TestListRepositoriesParamsAndCount(t *testing.T) { + var req *http.Request + mux := http.NewServeMux() + mux.HandleFunc("/api/repos", func(w http.ResponseWriter, r *http.Request) { + req = r.Clone(r.Context()) + _, _ = w.Write([]byte("[" + repoJSON + "," + repoJSON + "]")) + }) + c := newFakeClient(t, mux) + + _, h := listRepositoriesTool(c) + res := call(t, h, map[string]any{ + "page": float64(2), + "perPage": float64(10), + "active": true, + }) + + if res.IsError { + t.Fatalf("unexpected error: %s", resultText(t, res)) + } + assertQuery(t, req, "page", "2") + assertQuery(t, req, "perPage", "10") + assertQuery(t, req, "active", "true") + + out := decodeResult[map[string]any](t, res) + if out["count"] != float64(2) { + t.Errorf("count = %v", out["count"]) + } +} + +func TestListRepositoriesDefaults(t *testing.T) { + var req *http.Request + mux := http.NewServeMux() + mux.HandleFunc("/api/repos", func(w http.ResponseWriter, r *http.Request) { + req = r.Clone(r.Context()) + _, _ = w.Write([]byte("[]")) + }) + c := newFakeClient(t, mux) + + _, h := listRepositoriesTool(c) + res := call(t, h, map[string]any{}) + + if res.IsError { + t.Fatalf("unexpected error: %s", resultText(t, res)) + } + assertQuery(t, req, "page", "1") + assertQuery(t, req, "perPage", "50") + if _, ok := req.URL.Query()["active"]; ok { + t.Error("active should be omitted when not provided") + } + out := decodeResult[map[string]any](t, res) + if out["count"] != float64(0) { + t.Errorf("count = %v", out["count"]) + } +} + +func TestListRepositoriesRejectsBadPerPage(t *testing.T) { + c := newFakeClient(t, http.NewServeMux()) + _, h := listRepositoriesTool(c) + res := call(t, h, map[string]any{"perPage": float64(200)}) + if !res.IsError { + t.Fatal("expected error result for perPage 200") + } + if !strings.Contains(resultText(t, res), "between") { + t.Errorf("error text %q", resultText(t, res)) + } +} + +// --- list_pipelines --- + +func TestListPipelines(t *testing.T) { + var req *http.Request + mux := http.NewServeMux() + mux.HandleFunc("/api/repos/1/pipelines", func(w http.ResponseWriter, r *http.Request) { + req = r.Clone(r.Context()) + _, _ = w.Write([]byte("[" + pipelineJSON + "]")) + }) + c := newFakeClient(t, mux) + + _, h := listPipelinesTool(c) + res := call(t, h, map[string]any{ + "repoId": float64(1), + "before": "2023-12-01T10:00:00Z", + "after": "2023-11-01T10:00:00Z", + "perPage": float64(5), + }) + + if res.IsError { + t.Fatalf("unexpected error: %s", resultText(t, res)) + } + assertQuery(t, req, "page", "1") + assertQuery(t, req, "perPage", "5") + assertQuery(t, req, "before", "2023-12-01T10:00:00Z") + assertQuery(t, req, "after", "2023-11-01T10:00:00Z") + + out := decodeResult[map[string]any](t, res) + if out["repoId"] != float64(1) || out["count"] != float64(1) { + t.Errorf("out = %v", out) + } + pipelines, _ := out["pipelines"].([]any) + first, _ := pipelines[0].(map[string]any) + if first["number"] != float64(42) || first["status"] != "failure" { + t.Errorf("first pipeline = %v", first) + } +} + +// --- get_pipeline --- + +func TestGetPipelineWorkflowsAndSteps(t *testing.T) { + var gotPath string + mux := http.NewServeMux() + mux.HandleFunc("/api/repos/1/pipelines/42", func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + _, _ = w.Write([]byte(pipelineJSON)) + }) + c := newFakeClient(t, mux) + + _, h := getPipelineTool(c) + res := call(t, h, map[string]any{"repoId": float64(1), "number": float64(42)}) + + if res.IsError { + t.Fatalf("unexpected error: %s", resultText(t, res)) + } + if gotPath != "/api/repos/1/pipelines/42" { + t.Errorf("path = %q", gotPath) + } + + out := decodeResult[map[string]any](t, res) + if out["pipelineNumber"] != float64(42) { + t.Errorf("pipelineNumber = %v", out["pipelineNumber"]) + } + p, _ := out["pipeline"].(map[string]any) + if p["email"] != "me@example.com" { + t.Errorf("email = %v", p["email"]) + } + workflows, _ := p["workflows"].([]any) + wf, _ := workflows[0].(map[string]any) + if wf["name"] != "build" { + t.Errorf("workflow = %v", wf) + } + children, _ := wf["children"].([]any) + step, _ := children[0].(map[string]any) + if step["exitCode"] != float64(1) || step["error"] != "exit code 1" { + t.Errorf("step = %v", step) + } + if _, hasError := wf["error"]; hasError { + t.Error("error should be omitted when null in the API response") + } +} + +func TestGetPipelineMissingNumber(t *testing.T) { + c := newFakeClient(t, http.NewServeMux()) + _, h := getPipelineTool(c) + res := call(t, h, map[string]any{"repoId": float64(1)}) + if !res.IsError { + t.Fatal("expected error result") + } +} + +// --- get_pipeline_config --- + +func TestGetPipelineConfigDecodesBase64(t *testing.T) { + configData := base64.StdEncoding.EncodeToString([]byte("pipeline:\n build:\n image: alpine")) + payload := fmt.Sprintf(`[{"name":"ci/.woodpecker.yml","data":%q,"hash":"abc123"}]`, configData) + + mux := http.NewServeMux() + mux.HandleFunc("/api/repos/1/pipelines/42/config", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(payload)) + }) + c := newFakeClient(t, mux) + + _, h := getPipelineConfigTool(c) + res := call(t, h, map[string]any{"repoId": float64(1), "number": float64(42)}) + + if res.IsError { + t.Fatalf("unexpected error: %s", resultText(t, res)) + } + out := decodeResult[map[string]any](t, res) + if out["count"] != float64(1) { + t.Errorf("count = %v", out["count"]) + } + configs, _ := out["configs"].([]any) + cfg, _ := configs[0].(map[string]any) + if cfg["content"] != "pipeline:\n build:\n image: alpine" { + t.Errorf("content = %q", cfg["content"]) + } + if cfg["hash"] != "abc123" || cfg["name"] != "ci/.woodpecker.yml" { + t.Errorf("cfg = %v", cfg) + } +} + +func TestGetPipelineConfigInvalidBase64FallsBack(t *testing.T) { + payload := `[{"name":"bad.woodpecker.yml","data":"not!base64!","hash":"h"}]` + mux := http.NewServeMux() + mux.HandleFunc("/api/repos/1/pipelines/1/config", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(payload)) + }) + c := newFakeClient(t, mux) + + _, h := getPipelineConfigTool(c) + res := call(t, h, map[string]any{"repoId": float64(1), "number": float64(1)}) + + if res.IsError { + t.Fatalf("unexpected error: %s", resultText(t, res)) + } + out := decodeResult[map[string]any](t, res) + configs, _ := out["configs"].([]any) + cfg, _ := configs[0].(map[string]any) + if cfg["content"] != "not!base64!" { + t.Errorf("content = %q, want raw fallback", cfg["content"]) + } +} + +// --- get_step_logs --- + +func TestGetStepLogsDecodesAndJoins(t *testing.T) { + lineOne := base64.StdEncoding.EncodeToString([]byte("line one")) + lineThree := base64.StdEncoding.EncodeToString([]byte("line three")) + payload := fmt.Sprintf( + `[{"id":1,"step_id":11,"time":1,"line":1,"data":%q,"type":0},`+ + `{"id":2,"step_id":11,"time":2,"line":2,"data":null,"type":0},`+ + `{"id":3,"step_id":11,"time":3,"line":3,"data":%q,"type":1}]`, + lineOne, lineThree, + ) + + mux := http.NewServeMux() + mux.HandleFunc("/api/repos/1/logs/42/11", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(payload)) + }) + c := newFakeClient(t, mux) + + _, h := getStepLogsTool(c) + res := call(t, h, map[string]any{"repoId": float64(1), "number": float64(42), "stepId": float64(11)}) + + if res.IsError { + t.Fatalf("unexpected error: %s", resultText(t, res)) + } + out := decodeResult[map[string]any](t, res) + if out["stepId"] != float64(11) { + t.Errorf("stepId = %v", out["stepId"]) + } + if out["logs"] != "line one\n\nline three" { + t.Errorf("logs = %q", out["logs"]) + } +} + +func TestGetStepLogsNullBody(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/repos/1/logs/42/11", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("null")) + }) + c := newFakeClient(t, mux) + + _, h := getStepLogsTool(c) + res := call(t, h, map[string]any{"repoId": float64(1), "number": float64(42), "stepId": float64(11)}) + + if res.IsError { + t.Fatalf("unexpected error: %s", resultText(t, res)) + } + out := decodeResult[map[string]any](t, res) + if out["logs"] != noLogsMessage { + t.Errorf("logs = %q, want no-logs message", out["logs"]) + } +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..928dfdc --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,12 @@ +// Package version holds build-time metadata for the server. +package version + +const ( + // Name is the MCP server name advertised to clients. + Name = "woodpecker-mcp" +) + +// Version is overridden at build time with: +// +// go build -ldflags "-X gitea.fe.kaien.ru/kaien/woodpecker-mcp/internal/version.Version=v1.2.3" ./cmd/woodpecker-mcp +var Version = "0.0.0" diff --git a/internal/woodpecker/client.go b/internal/woodpecker/client.go new file mode 100644 index 0000000..58cdbfd --- /dev/null +++ b/internal/woodpecker/client.go @@ -0,0 +1,118 @@ +package woodpecker + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "time" +) + +const ( + // defaultTimeout bounds a single API request. + defaultTimeout = 30 * time.Second + // maxResponseBytes guards against unbounded response bodies. + maxResponseBytes = 10 << 20 // 10 MiB +) + +// Client is an authenticated Woodpecker CI REST API client. +type Client struct { + baseURL string // e.g. "https://woodpecker.example.com/api" + token string + http *http.Client +} + +// Option configures a Client. +type Option func(*Client) + +// WithHTTPClient replaces the default HTTP client (useful for tests). +func WithHTTPClient(hc *http.Client) Option { + return func(c *Client) { c.http = hc } +} + +// NewClient creates a client for the given API base URL (already including +// the "/api" prefix, see config.Config.APIBaseURL). +func NewClient(apiBaseURL, token string, opts ...Option) (*Client, error) { + if strings.TrimSpace(apiBaseURL) == "" { + return nil, errors.New("woodpecker: empty API base URL") + } + if token == "" { + return nil, errors.New("woodpecker: empty token") + } + + c := &Client{ + baseURL: strings.TrimSuffix(apiBaseURL, "/"), + token: token, + http: &http.Client{Timeout: defaultTimeout}, + } + for _, opt := range opts { + opt(c) + } + return c, nil +} + +// Get performs an authenticated GET against endpoint (e.g. +// "/repos/lookup/owner%2Frepo") and decodes the JSON response into out. +// A nil out (or an empty response body) skips decoding. +// +// Every failure is returned as an *APIError so callers can render a +// client-facing error context. +func (c *Client) Get(ctx context.Context, endpoint string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+endpoint, nil) + if err != nil { + return &APIError{Status: 500, Message: err.Error()} + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.token) + + resp, err := c.http.Do(req) + if err != nil { + // Transport-level failures (DNS, connection refused, timeout, ...) + // surface as 500, matching the original client's behaviour. + return &APIError{Status: 500, Message: err.Error()} + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return &APIError{Status: 500, Message: "failed to read response body: " + err.Error()} + } + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return &APIError{Status: resp.StatusCode, Message: errorMessage(resp.StatusCode, body)} + } + + if out == nil || len(bytes.TrimSpace(body)) == 0 { + return nil + } + if err := json.Unmarshal(body, out); err != nil { + return &APIError{Status: 500, Message: "failed to decode response: " + err.Error()} + } + return nil +} + +// errorMessage extracts a human-readable message from a non-2xx response. +// It prefers the "error"/"message" fields Woodpecker returns, falling back +// to the raw body and finally to the HTTP status text. +func errorMessage(status int, body []byte) string { + var payload struct { + Error string `json:"error"` + Message string `json:"message"` + } + if err := json.Unmarshal(body, &payload); err == nil { + if payload.Error != "" { + return payload.Error + } + if payload.Message != "" { + return payload.Message + } + } + if text := strings.TrimSpace(string(body)); text != "" { + return text + } + return http.StatusText(status) +} diff --git a/internal/woodpecker/client_test.go b/internal/woodpecker/client_test.go new file mode 100644 index 0000000..7bb0406 --- /dev/null +++ b/internal/woodpecker/client_test.go @@ -0,0 +1,149 @@ +package woodpecker + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestNewClientValidation(t *testing.T) { + if _, err := NewClient("", "tok"); err == nil { + t.Error("expected error for empty base URL") + } + if _, err := NewClient("https://example.com/api", ""); err == nil { + t.Error("expected error for empty token") + } +} + +func TestNewClientBaseURL(t *testing.T) { + c, err := NewClient("https://woodpecker.example.com/api/", "tok") + if err != nil { + t.Fatal(err) + } + if c.baseURL != "https://woodpecker.example.com/api" { + t.Errorf("baseURL = %q, want trailing slash trimmed", c.baseURL) + } +} + +func TestGetSuccess(t *testing.T) { + var gotPath, gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":42,"name":"demo","owner":"me","full_name":"me/demo","private":true,"trusted":false,"active":true,"forge_id":1}`)) + })) + defer srv.Close() + + c, err := NewClient(srv.URL+"/api", "secret", WithHTTPClient(srv.Client())) + if err != nil { + t.Fatal(err) + } + + var repo Repo + if err := c.Get(context.Background(), "/repos/42", &repo); err != nil { + t.Fatal(err) + } + if gotPath != "/api/repos/42" { + t.Errorf("path = %q", gotPath) + } + if gotAuth != "Bearer secret" { + t.Errorf("auth header = %q", gotAuth) + } + if repo.ID != 42 || repo.FullName != "me/demo" || !repo.Private { + t.Errorf("unexpected repo: %+v", repo) + } +} + +func TestGetEmptyBodySkipsDecode(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c, _ := NewClient(srv.URL+"/api", "t", WithHTTPClient(srv.Client())) + var out Repo + if err := c.Get(context.Background(), "/repos/1", &out); err != nil { + t.Errorf("empty body should not fail: %v", err) + } +} + +func TestGetErrorStatusAndMessage(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"repository not found"}`)) + })) + defer srv.Close() + + c, _ := NewClient(srv.URL+"/api", "t", WithHTTPClient(srv.Client())) + err := c.Get(context.Background(), "/repos/999", &struct{}{}) + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("expected *APIError, got %T: %v", err, err) + } + if apiErr.Status != 404 { + t.Errorf("status = %d, want 404", apiErr.Status) + } + if apiErr.Message != "repository not found" { + t.Errorf("message = %q", apiErr.Message) + } +} + +func TestGetNetworkErrorBecomes500(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + url := srv.URL + srv.Close() // connections will fail now + + c, _ := NewClient(url+"/api", "t", WithHTTPClient(srv.Client())) + err := c.Get(context.Background(), "/repos/1", &struct{}{}) + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("expected *APIError, got %T: %v", err, err) + } + if apiErr.Status != 500 { + t.Errorf("status = %d, want 500 for transport error", apiErr.Status) + } + if apiErr.Message == "" { + t.Error("message must carry the transport error detail") + } +} + +func TestGetInvalidJSONBecomes500(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{not json`)) + })) + defer srv.Close() + + c, _ := NewClient(srv.URL+"/api", "t", WithHTTPClient(srv.Client())) + err := c.Get(context.Background(), "/repos/1", &Repo{}) + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("expected *APIError, got %T: %v", err, err) + } + if apiErr.Status != 500 { + t.Errorf("status = %d, want 500", apiErr.Status) + } +} + +func TestErrorTypeUnmarshalsWithPointers(t *testing.T) { + var p Pipeline + payload := `{"id":1,"number":5,"status":"running","event":"push","commit":"abc","branch":"main","ref":"refs/heads/main","created_at":10,"updated_at":11,"started_at":null,"message":null}` + if err := json.Unmarshal([]byte(payload), &p); err != nil { + t.Fatal(err) + } + if p.Message != nil { + t.Error("message should stay nil for explicit null") + } + if p.StartedAt != nil { + t.Error("started_at should stay nil for explicit null") + } + if p.Branch != "main" { + t.Errorf("branch = %q", p.Branch) + } + if !strings.HasPrefix(payload, "{") { + t.Fatal("sanity") + } +} diff --git a/internal/woodpecker/errors.go b/internal/woodpecker/errors.go new file mode 100644 index 0000000..7db1c0c --- /dev/null +++ b/internal/woodpecker/errors.go @@ -0,0 +1,103 @@ +package woodpecker + +import ( + "encoding/json" + "fmt" +) + +// APIError represents a failure returned by the Woodpecker API or an +// unrecoverable error while communicating with it. The Status field is an +// HTTP status code when the failure came from the API, or 500 for transport +// and decoding errors, mirroring the behaviour of the original client. +type APIError struct { + Status int + Message string +} + +func (e *APIError) Error() string { + if e.Message == "" { + return fmt.Sprintf("woodpecker api error (status %d)", e.Status) + } + return fmt.Sprintf("woodpecker api error (status %d): %s", e.Status, e.Message) +} + +// errorContext is the structured payload returned to the MCP client when a +// tool call fails. The shape is kept identical to the original TypeScript +// implementation so existing MCP clients see the same output. +type errorContext struct { + Description string `json:"description"` + Suggestions []string `json:"suggestions"` + Error string `json:"error"` +} + +// ToContext renders a client-facing error context for the given operation. +// The status code drives both the description and the suggested next steps. +func (e *APIError) ToContext(context string) string { + description := "Request failed for " + context + if e.Message != "" { + description += ": " + e.Message + } + ctx := errorContext{ + Description: description, + Suggestions: []string{ + "Check your network connection", + "Verify the Woodpecker CI server URL is correct", + "Try again in a few moments", + }, + Error: "UNKNOWN_ERROR", + } + switch e.Status { + case 401: + ctx = errorContext{ + Description: "Authentication failed for " + context, + Suggestions: []string{ + "Check that your WOODPECKER_TOKEN environment variable is set correctly", + "Verify the token has not expired", + "Ensure the token has sufficient permissions", + }, + Error: "AUTH_ERROR", + } + case 403: + ctx = errorContext{ + Description: "Access denied for " + context, + Suggestions: []string{ + "Check that you have permission to access this resource", + "Verify the repository/pipeline exists and is accessible", + "Contact your Woodpecker CI administrator if needed", + }, + Error: "PERMISSION_ERROR", + } + case 404: + ctx = errorContext{ + Description: "Resource not found for " + context, + Suggestions: []string{ + "Double-check the repository name, pipeline number, or step ID", + "Verify the resource exists and hasn't been deleted", + }, + Error: "NOT_FOUND", + } + case 429: + ctx = errorContext{ + Description: "Rate limit exceeded for " + context, + Suggestions: []string{ + "Wait a moment before retrying", + "Consider reducing the frequency of requests", + }, + Error: "RATE_LIMIT", + } + case 500, 502, 503, 504: + ctx = errorContext{ + Description: "Woodpecker CI server error for " + context, + Suggestions: []string{ + "Try again in a few moments", + "Check Woodpecker CI server status", + "Contact your administrator if the issue persists", + }, + Error: "SERVER_ERROR", + } + } + + // Marshalling a plain struct never fails; ignore the error defensively. + b, _ := json.Marshal(ctx) + return string(b) +} diff --git a/internal/woodpecker/errors_test.go b/internal/woodpecker/errors_test.go new file mode 100644 index 0000000..de7ada1 --- /dev/null +++ b/internal/woodpecker/errors_test.go @@ -0,0 +1,93 @@ +package woodpecker + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestAPIErrorToContextStatusMapping(t *testing.T) { + cases := []struct { + status int + wantCode string + wantInDesc string + }{ + {401, "AUTH_ERROR", "Authentication failed"}, + {403, "PERMISSION_ERROR", "Access denied"}, + {404, "NOT_FOUND", "Resource not found"}, + {429, "RATE_LIMIT", "Rate limit exceeded"}, + {500, "SERVER_ERROR", "server error"}, + {502, "SERVER_ERROR", "server error"}, + {503, "SERVER_ERROR", "server error"}, + {504, "SERVER_ERROR", "server error"}, + {418, "UNKNOWN_ERROR", "Request failed"}, + } + + for _, tc := range cases { + t.Run(strings.ToLower(tc.wantCode), func(t *testing.T) { + e := &APIError{Status: tc.status, Message: "boom"} + got := e.ToContext("sample operation") + + var ctx errorContext + if err := json.Unmarshal([]byte(got), &ctx); err != nil { + t.Fatalf("ToContext returned invalid JSON: %v\n%s", err, got) + } + if ctx.Error != tc.wantCode { + t.Errorf("error code = %q, want %q", ctx.Error, tc.wantCode) + } + if !strings.Contains(ctx.Description, tc.wantInDesc) { + t.Errorf("description %q does not contain %q", ctx.Description, tc.wantInDesc) + } + if !strings.Contains(ctx.Description, "sample operation") { + t.Errorf("description %q does not mention the context", ctx.Description) + } + if len(ctx.Suggestions) == 0 { + t.Error("suggestions must not be empty") + } + }) + } +} + +func TestAPIErrorToContextUnknownIncludesMessage(t *testing.T) { + e := &APIError{Status: 418, Message: "some detail"} + var ctx errorContext + if err := json.Unmarshal([]byte(e.ToContext("op")), &ctx); err != nil { + t.Fatal(err) + } + if !strings.Contains(ctx.Description, "some detail") { + t.Errorf("description %q should include the underlying message", ctx.Description) + } +} + +func TestAPIErrorSuggestionsMatchReference(t *testing.T) { + cases := []struct { + status int + want []string + }{ + {401, []string{ + "Check that your WOODPECKER_TOKEN environment variable is set correctly", + "Verify the token has not expired", + "Ensure the token has sufficient permissions", + }}, + {404, []string{ + "Double-check the repository name, pipeline number, or step ID", + "Verify the resource exists and hasn't been deleted", + }}, + } + + for _, tc := range cases { + e := &APIError{Status: tc.status} + var ctx errorContext + if err := json.Unmarshal([]byte(e.ToContext("op")), &ctx); err != nil { + t.Fatal(err) + } + if len(ctx.Suggestions) != len(tc.want) { + t.Fatalf("status %d: got %d suggestions, want %d", tc.status, len(ctx.Suggestions), len(tc.want)) + } + for i := range tc.want { + if ctx.Suggestions[i] != tc.want[i] { + t.Errorf("status %d suggestion %d = %q, want %q", tc.status, i, ctx.Suggestions[i], tc.want[i]) + } + } + } +} diff --git a/internal/woodpecker/types.go b/internal/woodpecker/types.go new file mode 100644 index 0000000..cf7be19 --- /dev/null +++ b/internal/woodpecker/types.go @@ -0,0 +1,98 @@ +// Package woodpecker is a minimal, focused client for the Woodpecker CI REST +// API. It only implements what the MCP tools need: authenticated GET requests +// against the API endpoints that are relevant for debugging pipelines. +package woodpecker + +// Domain types mirroring the Woodpecker CI API payloads. +// Fields that the API may omit or return as null are pointers so the output +// JSON can distinguish "absent" from an empty value. + +// Repo is a Woodpecker repository (project). +type Repo struct { + ID int64 `json:"id"` + ForgeRemoteID string `json:"forge_remote_id"` + Name string `json:"name"` + Owner string `json:"owner"` + FullName string `json:"full_name"` + DefaultBranch *string `json:"default_branch,omitempty"` + Private bool `json:"private"` + Trusted bool `json:"trusted"` + Active bool `json:"active"` + ConfigFile *string `json:"config_file,omitempty"` + Timeout *int64 `json:"timeout,omitempty"` + ForgeID int64 `json:"forge_id"` +} + +// PipelineError is a linter/compiler issue reported for a pipeline. +type PipelineError struct { + Type string `json:"type"` + Message string `json:"message"` + IsWarning bool `json:"is_warning"` + Data any `json:"data,omitempty"` +} + +// Step is a single step within a workflow. +type Step struct { + ID int64 `json:"id"` + Name string `json:"name"` + UUID string `json:"uuid"` + Type string `json:"type"` + State string `json:"state"` + Error *string `json:"error,omitempty"` + ExitCode *int64 `json:"exit_code,omitempty"` + StartTime *int64 `json:"start_time,omitempty"` + EndTime *int64 `json:"end_time,omitempty"` + PipelineID int64 `json:"pipeline_id"` +} + +// Workflow is a workflow within a pipeline; Children holds its steps. +type Workflow struct { + ID int64 `json:"id"` + Name string `json:"name"` + State string `json:"state"` + Error *string `json:"error,omitempty"` + StartTime *int64 `json:"start_time,omitempty"` + EndTime *int64 `json:"end_time,omitempty"` + PipelineID int64 `json:"pipeline_id"` + Children []Step `json:"children,omitempty"` +} + +// Pipeline is a Woodpecker pipeline (run of a commit). +type Pipeline struct { + ID int64 `json:"id"` + Number int64 `json:"number"` + Parent *int64 `json:"parent,omitempty"` + Event string `json:"event"` + Status string `json:"status"` + Errors []PipelineError `json:"errors,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + StartedAt *int64 `json:"started_at,omitempty"` + FinishedAt *int64 `json:"finished_at,omitempty"` + Commit string `json:"commit"` + Branch string `json:"branch"` + Ref string `json:"ref"` + Title *string `json:"title,omitempty"` + Message *string `json:"message,omitempty"` + Author *string `json:"author,omitempty"` + AuthorEmail *string `json:"author_email,omitempty"` + Workflows []Workflow `json:"workflows,omitempty"` +} + +// LogEntry is one line of step output. Data is base64-encoded UTF-8. +type LogEntry struct { + ID int64 `json:"id"` + StepID int64 `json:"step_id"` + Time int64 `json:"time"` + Line int64 `json:"line"` + Data *string `json:"data"` + Type int `json:"type"` +} + +// Config is a pipeline configuration file (.woodpecker.yml). Data is +// base64-encoded UTF-8. +type Config struct { + Name string `json:"name"` + Data string `json:"data"` + Hash string `json:"hash"` +} diff --git a/k8s-deploy.yaml b/k8s-deploy.yaml new file mode 100644 index 0000000..2976773 --- /dev/null +++ b/k8s-deploy.yaml @@ -0,0 +1,57 @@ +apiVersion: v1 +kind: Service +metadata: + name: woodpecker-mcp + namespace: mcp-servers +spec: + type: ClusterIP + ports: + - port: 8080 + targetPort: 8080 + name: mcp + selector: + app: woodpecker-mcp +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: woodpecker-mcp + namespace: mcp-servers + labels: + app: woodpecker-mcp +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: woodpecker-mcp + template: + metadata: + labels: + app: woodpecker-mcp + annotations: + # Immutable-deploy provenance: this changes on every commit, so the pod + # template changes and Kubernetes rolls out automatically (no manual restart). + deployment.l-ai/commit: "${CI_COMMIT_SHA}" + deployment.l-ai/repository: "woodpecker-mcp" + spec: + imagePullSecrets: + - name: registry-secret + containers: + - name: woodpecker-mcp + # Immutable per-commit tag (rendered by envsubst in CI from CI_COMMIT_SHA). + # Changing this string is what triggers a rollout on every deploy. + image: registry.be.kaien.ru/woodpecker_mcp/woodpecker_mcp:${CI_COMMIT_SHA} + imagePullPolicy: IfNotPresent + args: + - --transport=http + envFrom: + - secretRef: + name: woodpecker-mcp + env: + - name: WOODPECKER_URL + value: "https://ci.be.kaien.ru/" + ports: + - containerPort: 8080 + name: mcp diff --git a/woodpecker-mcp-sealed.yaml b/woodpecker-mcp-sealed.yaml new file mode 100644 index 0000000..5048351 --- /dev/null +++ b/woodpecker-mcp-sealed.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + creationTimestamp: null + name: woodpecker-mcp + namespace: mcp-servers +spec: + encryptedData: + WOODPECKER_TOKEN: AgDqyCMU2Tu8scbo7gRGiuHhVYMP7ucGIMhZnynQjurSsGqQaHe/+OYOYkBBETbOzK7qwpyA6HA2rix3/LMViJV3Gt5mM0SNG3/OUOfdq942KvAGlHNHQWbaw2vz8qH76D4/ksEYZ9aMo1cFd3KyTB7RkcdFkuWMuc86WX1Z1BIausBmbQZN5cn8gr/u5DKozR/m3bRfm99rdLeiL8OgPXd4IYtiz7r2s8Dwp6qxqTrbJS72JbCj4VxpmXu8qKDB/5sBpUr+dxzA79atviQ39vJoBvxr9Zi5WwzOkGt0w1F9bmgxNiRlXmhP/VOHlVcFPPWO2jDDZR8Ba1Ur5ewG6E6GnGQbxF1IDY/YoaFSJpWXuXQV4+4oZoUxl1DIWUcIz3Q8simUouAWs0H89zbnxOIBJpsduiwUaq/Y4z+lUJmAezHxjNAk30zLM0UAcd8KSxRLCW+FWS2ds7N5G71RHPIpFJVftWgcYmOENdHLjeL26vaN+XGH7IzR4q2ptxjVVXf8/qbLgKbzAe+NwX7FGd+buwC2L7e7Pj29ldc4LbNinmsL13fMD+H+Vs9HmTnBZGz6HY2M1En6E0nUwKJrTxi1mcdFuBVenxcAdwiX9VP4nOqYUiZUV9URF5hlNI9KMt2w0FCBY5bXdb6YicW8ozHCSZDd69jY6VVnBXGzlXZsxKTs+ylcf832gzVj9PqHzm+eEmmIXjc6Pyy/jAaZERMNqEVz02an5Y8zyPhAilHI3tYo3DrmHbBKkEEc+gRk4uATg1d3mWB/uiYXuZx9MQv2+ql0YVQUOkHTkcBFSxSlUihnyTR1Eeoq2AMnm1o361rosuqGV/VwfWGCpdhKPWx1l6A40dRRbL8= + template: + metadata: + creationTimestamp: null + name: woodpecker-mcp + namespace: mcp-servers \ No newline at end of file