Initial commit: woodpecker-mcp Go port with stdio/HTTP transports and k8s deploy
Some checks failed
ci/woodpecker/manual/woodpecker Pipeline failed

This commit is contained in:
Kaien 2026-08-08 01:37:47 +03:00
commit e8892ce182
25 changed files with 2487 additions and 0 deletions

12
.dockerignore Normal file
View File

@ -0,0 +1,12 @@
# Build artifacts
woodpecker-mcp
woodpecker-mcp.exe
dist/
*.test
# VCS
.git
.gitignore
# Go
vendor/

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
# Build artifacts
woodpecker-mcp
woodpecker-mcp.exe
dist/
*.test
# Go
vendor/

73
.woodpecker.yml Normal file
View File

@ -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]

22
Dockerfile Normal file
View File

@ -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"]

164
README.md Normal file
View File

@ -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://<host>: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

14
go.mod Normal file
View File

@ -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
)

34
go.sum Normal file
View File

@ -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=

56
internal/config/config.go Normal file
View File

@ -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"
}

View File

@ -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)
}
}

23
internal/server/server.go Normal file
View File

@ -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
}

View File

@ -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")
}
}

139
internal/tools/helpers.go Normal file
View File

@ -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)
}

102
internal/tools/logs.go Normal file
View File

@ -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
}

341
internal/tools/pipelines.go Normal file
View File

@ -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
}

View File

@ -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
}

15
internal/tools/tools.go Normal file
View File

@ -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)
}

View File

@ -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"])
}
}

View File

@ -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"

View File

@ -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)
}

View File

@ -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")
}
}

View File

@ -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)
}

View File

@ -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])
}
}
}
}

View File

@ -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"`
}

57
k8s-deploy.yaml Normal file
View File

@ -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

View File

@ -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