Initial commit: woodpecker-mcp Go port with stdio/HTTP transports and k8s deploy
Some checks failed
ci/woodpecker/manual/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/manual/woodpecker Pipeline failed
This commit is contained in:
commit
e8892ce182
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@ -0,0 +1,12 @@
|
||||
# Build artifacts
|
||||
woodpecker-mcp
|
||||
woodpecker-mcp.exe
|
||||
dist/
|
||||
*.test
|
||||
|
||||
# VCS
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Go
|
||||
vendor/
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
# Build artifacts
|
||||
woodpecker-mcp
|
||||
woodpecker-mcp.exe
|
||||
dist/
|
||||
*.test
|
||||
|
||||
# Go
|
||||
vendor/
|
||||
73
.woodpecker.yml
Normal file
73
.woodpecker.yml
Normal 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
22
Dockerfile
Normal 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
164
README.md
Normal 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
14
go.mod
Normal 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
34
go.sum
Normal 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
56
internal/config/config.go
Normal 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"
|
||||
}
|
||||
60
internal/config/config_test.go
Normal file
60
internal/config/config_test.go
Normal 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
23
internal/server/server.go
Normal 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
|
||||
}
|
||||
120
internal/server/server_test.go
Normal file
120
internal/server/server_test.go
Normal 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
139
internal/tools/helpers.go
Normal 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
102
internal/tools/logs.go
Normal 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
341
internal/tools/pipelines.go
Normal 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
|
||||
}
|
||||
197
internal/tools/repositories.go
Normal file
197
internal/tools/repositories.go
Normal 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
15
internal/tools/tools.go
Normal 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)
|
||||
}
|
||||
462
internal/tools/tools_test.go
Normal file
462
internal/tools/tools_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
12
internal/version/version.go
Normal file
12
internal/version/version.go
Normal 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"
|
||||
118
internal/woodpecker/client.go
Normal file
118
internal/woodpecker/client.go
Normal 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)
|
||||
}
|
||||
149
internal/woodpecker/client_test.go
Normal file
149
internal/woodpecker/client_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
103
internal/woodpecker/errors.go
Normal file
103
internal/woodpecker/errors.go
Normal 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)
|
||||
}
|
||||
93
internal/woodpecker/errors_test.go
Normal file
93
internal/woodpecker/errors_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
98
internal/woodpecker/types.go
Normal file
98
internal/woodpecker/types.go
Normal 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
57
k8s-deploy.yaml
Normal 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
|
||||
15
woodpecker-mcp-sealed.yaml
Normal file
15
woodpecker-mcp-sealed.yaml
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user