From e8892ce1828a28826c73e91aa43057b5e127c599 Mon Sep 17 00:00:00 2001 From: Kaien Date: Sat, 8 Aug 2026 01:37:47 +0300 Subject: [PATCH] Initial commit: woodpecker-mcp Go port with stdio/HTTP transports and k8s deploy --- .dockerignore | 12 + .gitignore | 8 + .woodpecker.yml | 73 +++++ Dockerfile | 22 ++ README.md | 164 ++++++++++ go.mod | 14 + go.sum | 34 +++ internal/config/config.go | 56 ++++ internal/config/config_test.go | 60 ++++ internal/server/server.go | 23 ++ internal/server/server_test.go | 120 ++++++++ internal/tools/helpers.go | 139 +++++++++ internal/tools/logs.go | 102 +++++++ internal/tools/pipelines.go | 341 +++++++++++++++++++++ internal/tools/repositories.go | 197 ++++++++++++ internal/tools/tools.go | 15 + internal/tools/tools_test.go | 462 +++++++++++++++++++++++++++++ internal/version/version.go | 12 + internal/woodpecker/client.go | 118 ++++++++ internal/woodpecker/client_test.go | 149 ++++++++++ internal/woodpecker/errors.go | 103 +++++++ internal/woodpecker/errors_test.go | 93 ++++++ internal/woodpecker/types.go | 98 ++++++ k8s-deploy.yaml | 57 ++++ woodpecker-mcp-sealed.yaml | 15 + 25 files changed, 2487 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 .woodpecker.yml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/server/server.go create mode 100644 internal/server/server_test.go create mode 100644 internal/tools/helpers.go create mode 100644 internal/tools/logs.go create mode 100644 internal/tools/pipelines.go create mode 100644 internal/tools/repositories.go create mode 100644 internal/tools/tools.go create mode 100644 internal/tools/tools_test.go create mode 100644 internal/version/version.go create mode 100644 internal/woodpecker/client.go create mode 100644 internal/woodpecker/client_test.go create mode 100644 internal/woodpecker/errors.go create mode 100644 internal/woodpecker/errors_test.go create mode 100644 internal/woodpecker/types.go create mode 100644 k8s-deploy.yaml create mode 100644 woodpecker-mcp-sealed.yaml 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