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