Compare commits
5 Commits
dc907fb7e0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bea0d2f27 | ||
|
|
24c353383f | ||
|
|
be85baf410 | ||
|
|
658f4ba84f | ||
|
|
60212fc5d2 |
@@ -16,7 +16,10 @@
|
||||
},
|
||||
"infra": {
|
||||
"type": "http",
|
||||
"url": "https://infra-mcp.d-ma.be/mcp"
|
||||
"url": "https://infra-mcp.d-ma.be/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${INFRA_MCP_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
11
Dockerfile
11
Dockerfile
@@ -1,5 +1,16 @@
|
||||
FROM golang:1.26-alpine AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Fetch internal gitea-hosted Go modules (e.g. mcp-chassis) without going
|
||||
# through proxy.golang.org and without HTTP→HTTPS surprises. Gitea returns
|
||||
# http:// in its go-import meta tag, so rewrite to https here and bypass
|
||||
# the module proxy + sumdb.
|
||||
RUN apk add --no-cache git && \
|
||||
git config --global url."https://gitea.d-ma.be/".insteadOf "http://gitea.d-ma.be/"
|
||||
ENV GOPRIVATE=gitea.d-ma.be
|
||||
ENV GOPROXY=direct
|
||||
ENV GOSUMDB=off
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
|
||||
@@ -2,10 +2,12 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
chassisauth "gitea.d-ma.be/mathias/mcp-chassis/auth"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||
@@ -27,7 +29,7 @@ func main() {
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
jwtValidator, err := auth.NewJWTValidator(ctx, cfg.DexIssuerURL, cfg.MCPAudience)
|
||||
jwtValidator, err := chassisauth.NewJWTValidator(ctx, cfg.DexIssuerURL, cfg.MCPAudience)
|
||||
if err != nil {
|
||||
logger.Warn("jwt validator init failed; JWT auth disabled", "err", err)
|
||||
}
|
||||
@@ -66,8 +68,10 @@ func main() {
|
||||
reg.Register(tools.NewRepoTree(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewRepoTopicsUpdate(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewIssueGet(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewIssueList(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewIssueClose(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewIssueReopen(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewWorkflowRunList(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewReleaseCreate(giteaClient, ownerAllow))
|
||||
reg.Register(tools.NewRepoDelete(giteaClient, ownerAllow))
|
||||
|
||||
@@ -76,9 +80,17 @@ func main() {
|
||||
Sessions: mcp.NewSessionStore(),
|
||||
})
|
||||
|
||||
// resourceMetadataURL is only emitted in the WWW-Authenticate challenge
|
||||
// when both MCPResourceURL and a Dex issuer are wired; empty disables
|
||||
// the challenge so static-only clients aren't pushed into OAuth discovery.
|
||||
var resourceMetadataURL string
|
||||
if cfg.MCPResourceURL != "" && cfg.DexIssuerURL != "" {
|
||||
resourceMetadataURL = strings.TrimRight(cfg.MCPResourceURL, "/") + "/.well-known/oauth-protected-resource"
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/mcp", mcp.OriginAllowlist(cfg.OriginAllowlist)(
|
||||
auth.BearerMiddleware(jwtValidator, cfg.StaticToken,
|
||||
chassisauth.BearerMiddleware(cfg.StaticToken, jwtValidator, "gitea", resourceMetadataURL,
|
||||
auth.CallerMiddleware(mcpSrv),
|
||||
),
|
||||
))
|
||||
@@ -86,21 +98,10 @@ func main() {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
mux.HandleFunc("/.well-known/oauth-protected-resource", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
payload := map[string]any{
|
||||
"resource": cfg.MCPResourceURL,
|
||||
"authorization_servers": []string{},
|
||||
}
|
||||
if cfg.DexIssuerURL != "" {
|
||||
payload["authorization_servers"] = []string{cfg.DexIssuerURL}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
})
|
||||
if cfg.DexIssuerURL != "" {
|
||||
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
|
||||
chassisauth.ProtectedResourceHandler(cfg.MCPResourceURL, cfg.DexIssuerURL))
|
||||
}
|
||||
|
||||
addr := ":" + cfg.Port
|
||||
logger.Info("gitea-mcp starting", "addr", addr, "version", "0.1.0")
|
||||
|
||||
1
go.mod
1
go.mod
@@ -9,6 +9,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
gitea.d-ma.be/mathias/mcp-chassis v0.1.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.3 // indirect
|
||||
|
||||
2
go.sum
2
go.sum
@@ -1,3 +1,5 @@
|
||||
gitea.d-ma.be/mathias/mcp-chassis v0.1.0 h1:8RXO34+n7Vu8HnUMagars6fc4oemqRpMu7MVtjaj4qY=
|
||||
gitea.d-ma.be/mathias/mcp-chassis v0.1.0/go.mod h1:ajbLlwr2L7FAN3TBU39KucZkKJM02wTbKbDKDEW2YvE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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=
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BearerMiddleware authenticates requests via the Authorization header.
|
||||
//
|
||||
// A request is allowed when:
|
||||
//
|
||||
// 1. The Bearer token is a valid JWT issued by the configured Dex OIDC server, or
|
||||
// 2. The Bearer token matches staticToken (constant-time compare).
|
||||
//
|
||||
// Any other case — including missing or empty Authorization header — returns 401.
|
||||
//
|
||||
// The Gitea service PAT is intentionally NOT used to authenticate the caller:
|
||||
// it is only used by the Gitea client for upstream API calls. Decoupling the
|
||||
// two prevents the MCP endpoint from being reachable anonymously when a service
|
||||
// PAT happens to be configured.
|
||||
func BearerMiddleware(jwtValidator *JWTValidator, staticToken string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
bearer, hasBearer := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if !hasBearer || bearer == "" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if jwtValidator.Validate(r.Context(), bearer) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if staticToken != "" && subtle.ConstantTimeCompare([]byte(bearer), []byte(staticToken)) == 1 {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
})
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func okHandler(called *bool) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
if called != nil {
|
||||
*called = true
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBearerMiddleware_NoAuthHeader(t *testing.T) {
|
||||
srv := httptest.NewServer(auth.BearerMiddleware(nil, "", okHandler(nil)))
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Post(srv.URL+"/mcp", "application/json", nil)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestBearerMiddleware_NoAuthHeader_RejectsEvenWhenStaticConfigured(t *testing.T) {
|
||||
// A configured staticToken must not allow unauthenticated callers through.
|
||||
srv := httptest.NewServer(auth.BearerMiddleware(nil, "any-static", okHandler(nil)))
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Post(srv.URL+"/mcp", "application/json", nil)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestBearerMiddleware_EmptyBearer(t *testing.T) {
|
||||
srv := httptest.NewServer(auth.BearerMiddleware(nil, "static", okHandler(nil)))
|
||||
defer srv.Close()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/mcp", nil)
|
||||
req.Header.Set("Authorization", "Bearer ")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestBearerMiddleware_StaticToken_Valid(t *testing.T) {
|
||||
const staticToken = "my-static-token"
|
||||
called := false
|
||||
srv := httptest.NewServer(auth.BearerMiddleware(nil, staticToken, okHandler(&called)))
|
||||
defer srv.Close()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/mcp", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+staticToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.True(t, called)
|
||||
}
|
||||
|
||||
func TestBearerMiddleware_StaticToken_Invalid(t *testing.T) {
|
||||
srv := httptest.NewServer(auth.BearerMiddleware(nil, "correct-token", okHandler(nil)))
|
||||
defer srv.Close()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/mcp", nil)
|
||||
req.Header.Set("Authorization", "Bearer wrong-token")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestBearerMiddleware_UnknownBearer_NoStatic_NoJWT(t *testing.T) {
|
||||
srv := httptest.NewServer(auth.BearerMiddleware(nil, "", okHandler(nil)))
|
||||
defer srv.Close()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/mcp", nil)
|
||||
req.Header.Set("Authorization", "Bearer random-unknown-token")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v2/jwk"
|
||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||
)
|
||||
|
||||
// JWTValidator validates bearer tokens as JWTs issued by a Dex OIDC server.
|
||||
// A nil JWTValidator always returns false — JWT validation is disabled.
|
||||
type JWTValidator struct {
|
||||
issuer string
|
||||
aud string
|
||||
cache *jwk.Cache
|
||||
jwksURI string
|
||||
}
|
||||
|
||||
// NewJWTValidator creates a validator by fetching the OIDC discovery document
|
||||
// from issuerURL. Returns nil, nil when issuerURL is empty (disabled).
|
||||
func NewJWTValidator(ctx context.Context, issuerURL, audience string) (*JWTValidator, error) {
|
||||
if issuerURL == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
resp, err := http.Get(issuerURL + "/.well-known/openid-configuration")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch oidc discovery: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var doc struct {
|
||||
JWKSURI string `json:"jwks_uri"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
|
||||
return nil, fmt.Errorf("decode oidc discovery: %w", err)
|
||||
}
|
||||
|
||||
cache := jwk.NewCache(ctx)
|
||||
if err := cache.Register(doc.JWKSURI, jwk.WithRefreshInterval(time.Hour)); err != nil {
|
||||
return nil, fmt.Errorf("register jwks uri: %w", err)
|
||||
}
|
||||
// warm the cache immediately so first request doesn't block
|
||||
if _, err := cache.Refresh(ctx, doc.JWKSURI); err != nil {
|
||||
return nil, fmt.Errorf("warm jwks cache: %w", err)
|
||||
}
|
||||
|
||||
return &JWTValidator{
|
||||
issuer: issuerURL,
|
||||
aud: audience,
|
||||
cache: cache,
|
||||
jwksURI: doc.JWKSURI,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Validate returns true if rawToken is a valid JWT signed by the OIDC server.
|
||||
func (v *JWTValidator) Validate(ctx context.Context, rawToken string) bool {
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
keySet, err := v.cache.Get(ctx, v.jwksURI)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
opts := []jwt.ParseOption{
|
||||
jwt.WithKeySet(keySet),
|
||||
jwt.WithIssuer(v.issuer),
|
||||
jwt.WithValidate(true),
|
||||
}
|
||||
if v.aud != "" {
|
||||
opts = append(opts, jwt.WithAudience(v.aud))
|
||||
}
|
||||
_, err = jwt.Parse([]byte(rawToken), opts...)
|
||||
return err == nil
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Issue struct {
|
||||
@@ -72,6 +74,50 @@ func (c *Client) CreateIssue(ctx context.Context, owner, repo string, args Creat
|
||||
return &iss, nil
|
||||
}
|
||||
|
||||
// ListIssuesArgs captures the optional query params for ListIssues.
|
||||
type ListIssuesArgs struct {
|
||||
State string // "open" | "closed" | "all"
|
||||
Labels string // comma-separated label names
|
||||
Since string // ISO 8601
|
||||
Page int
|
||||
Limit int
|
||||
}
|
||||
|
||||
// ListIssues fetches issues for a repo. Pulls are excluded server-side
|
||||
// (type=issues) so they don't leak through the same endpoint.
|
||||
func (c *Client) ListIssues(ctx context.Context, owner, repo string, args ListIssuesArgs) ([]Issue, error) {
|
||||
q := url.Values{}
|
||||
q.Set("type", "issues")
|
||||
if args.State != "" {
|
||||
q.Set("state", args.State)
|
||||
}
|
||||
if args.Labels != "" {
|
||||
q.Set("labels", args.Labels)
|
||||
}
|
||||
if args.Since != "" {
|
||||
q.Set("since", args.Since)
|
||||
}
|
||||
if args.Page > 0 {
|
||||
q.Set("page", strconv.Itoa(args.Page))
|
||||
}
|
||||
if args.Limit > 0 {
|
||||
q.Set("limit", strconv.Itoa(args.Limit))
|
||||
}
|
||||
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues?%s", owner, repo, q.Encode())
|
||||
body, status, err := c.GetJSON(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var issues []Issue
|
||||
if err := json.Unmarshal(body, &issues); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return issues, nil
|
||||
}
|
||||
|
||||
// SetIssueState flips an issue between "open" and "closed" via PATCH.
|
||||
// Gitea uses the same endpoint for both transitions.
|
||||
func (c *Client) SetIssueState(ctx context.Context, owner, repo string, number int, state string) (*Issue, error) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -54,11 +55,21 @@ func (c *Client) DispatchWorkflow(ctx context.Context, owner, repo, workflow str
|
||||
|
||||
// WorkflowRun represents a Gitea Actions run.
|
||||
type WorkflowRun struct {
|
||||
ID int64 `json:"id"`
|
||||
Status string `json:"status"` // queued | in_progress | completed
|
||||
Conclusion string `json:"conclusion"` // success | failure | cancelled | skipped (only when completed)
|
||||
StartedAt string `json:"started_at"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
ID int64 `json:"id"`
|
||||
DisplayTitle string `json:"display_title,omitempty"`
|
||||
Status string `json:"status"` // queued | in_progress | completed
|
||||
Conclusion string `json:"conclusion"` // success | failure | cancelled | skipped (only when completed)
|
||||
Event string `json:"event,omitempty"`
|
||||
HeadSHA string `json:"head_sha,omitempty"`
|
||||
HeadBranch string `json:"head_branch,omitempty"`
|
||||
WorkflowID string `json:"workflow_id,omitempty"`
|
||||
RunNumber int64 `json:"run_number,omitempty"`
|
||||
StartedAt string `json:"started_at"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Actor struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"actor,omitempty"`
|
||||
}
|
||||
|
||||
// GetWorkflowRun fetches the status of a specific Actions run.
|
||||
@@ -77,3 +88,59 @@ func (c *Client) GetWorkflowRun(ctx context.Context, owner, repo string, runID i
|
||||
}
|
||||
return &run, nil
|
||||
}
|
||||
|
||||
// ListWorkflowRunsArgs captures the optional query params for ListWorkflowRuns.
|
||||
type ListWorkflowRunsArgs struct {
|
||||
Branch string
|
||||
HeadSHA string
|
||||
Status string // queued | in_progress | completed | all
|
||||
Event string // push | pull_request | schedule | workflow_dispatch | all
|
||||
Workflow string
|
||||
Page int
|
||||
Limit int
|
||||
}
|
||||
|
||||
type workflowRunsResponse struct {
|
||||
TotalCount int64 `json:"total_count"`
|
||||
WorkflowRuns []WorkflowRun `json:"workflow_runs"`
|
||||
}
|
||||
|
||||
// ListWorkflowRuns fetches recent Actions runs for a repo with optional filters.
|
||||
// Status / Event of "all" or "" are treated as no-filter.
|
||||
func (c *Client) ListWorkflowRuns(ctx context.Context, owner, repo string, args ListWorkflowRunsArgs) (*workflowRunsResponse, error) {
|
||||
q := url.Values{}
|
||||
if args.Branch != "" {
|
||||
q.Set("branch", args.Branch)
|
||||
}
|
||||
if args.HeadSHA != "" {
|
||||
q.Set("head_sha", args.HeadSHA)
|
||||
}
|
||||
if args.Status != "" && args.Status != "all" {
|
||||
q.Set("status", args.Status)
|
||||
}
|
||||
if args.Event != "" && args.Event != "all" {
|
||||
q.Set("event", args.Event)
|
||||
}
|
||||
if args.Workflow != "" {
|
||||
q.Set("workflow", args.Workflow)
|
||||
}
|
||||
if args.Page > 0 {
|
||||
q.Set("page", strconv.Itoa(args.Page))
|
||||
}
|
||||
if args.Limit > 0 {
|
||||
q.Set("limit", strconv.Itoa(args.Limit))
|
||||
}
|
||||
p := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs?%s", owner, repo, q.Encode())
|
||||
body, status, err := c.GetJSON(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := MapStatus(status, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp workflowRunsResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
83
internal/tools/issue_list.go
Normal file
83
internal/tools/issue_list.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type IssueList struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewIssueList(c *gitea.Client, a *allowlist.Allowlist) *IssueList {
|
||||
return &IssueList{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *IssueList) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "issue_list",
|
||||
Description: "List issues in a repo with optional filters. PRs are excluded (use pr_list for those).",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"state":{"type":"string","enum":["open","closed","all"]},
|
||||
"labels":{"type":"string"},
|
||||
"since":{"type":"string"},
|
||||
"page":{"type":"integer","minimum":1},
|
||||
"limit":{"type":"integer","minimum":1,"maximum":50}
|
||||
},
|
||||
"required":["owner","name"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type issueListArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Labels string `json:"labels"`
|
||||
Since string `json:"since"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
func (t *IssueList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args issueListArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.State == "" {
|
||||
args.State = "open"
|
||||
}
|
||||
args.Limit = capLimit(args.Limit, 30)
|
||||
if args.Page < 1 {
|
||||
args.Page = 1
|
||||
}
|
||||
issues, err := t.c.ListIssues(ctx, args.Owner, args.Name, gitea.ListIssuesArgs{
|
||||
State: args.State,
|
||||
Labels: args.Labels,
|
||||
Since: args.Since,
|
||||
Page: args.Page,
|
||||
Limit: args.Limit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{
|
||||
"issues": issues,
|
||||
}
|
||||
if len(issues) == args.Limit {
|
||||
out["next_page"] = args.Page + 1
|
||||
}
|
||||
return textOK(out)
|
||||
}
|
||||
88
internal/tools/issue_list_test.go
Normal file
88
internal/tools/issue_list_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIssueListTool(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantQuery map[string]string
|
||||
respBody string
|
||||
assert func(t *testing.T, out string)
|
||||
}{
|
||||
{
|
||||
name: "happy path defaults",
|
||||
input: `{"owner":"mathias","name":"infra"}`,
|
||||
wantQuery: map[string]string{"type": "issues", "state": "open", "page": "1", "limit": "30"},
|
||||
respBody: `[{"number":42,"title":"fix auth","state":"open","html_url":"http://gitea.example/m/infra/issues/42"},{"number":41,"title":"add tests","state":"open"}]`,
|
||||
assert: func(t *testing.T, out string) {
|
||||
assert.Contains(t, out, `"number":42`)
|
||||
assert.Contains(t, out, `"number":41`)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "state filter",
|
||||
input: `{"owner":"mathias","name":"infra","state":"closed"}`,
|
||||
wantQuery: map[string]string{"type": "issues", "state": "closed"},
|
||||
respBody: `[]`,
|
||||
assert: func(t *testing.T, out string) {
|
||||
assert.Contains(t, out, `"issues":[]`)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "label + since filter",
|
||||
input: `{"owner":"mathias","name":"infra","labels":"bug,critical","since":"2026-05-01T00:00:00Z"}`,
|
||||
wantQuery: map[string]string{"labels": "bug,critical", "since": "2026-05-01T00:00:00Z"},
|
||||
respBody: `[]`,
|
||||
assert: func(t *testing.T, out string) {},
|
||||
},
|
||||
{
|
||||
name: "empty result",
|
||||
input: `{"owner":"mathias","name":"infra"}`,
|
||||
wantQuery: map[string]string{"state": "open"},
|
||||
respBody: `[]`,
|
||||
assert: func(t *testing.T, out string) {
|
||||
assert.Contains(t, out, `"issues":[]`)
|
||||
assert.NotContains(t, out, `next_page`)
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/infra/issues", r.URL.Path)
|
||||
q := r.URL.Query()
|
||||
for k, v := range tc.wantQuery {
|
||||
assert.Equal(t, v, q.Get(k), "query param %q", k)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(tc.respBody))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewIssueList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(tc.input))
|
||||
require.NoError(t, err)
|
||||
tc.assert(t, string(out))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueListAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewIssueList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x"}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
87
internal/tools/workflow_run_list.go
Normal file
87
internal/tools/workflow_run_list.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
|
||||
)
|
||||
|
||||
type WorkflowRunList struct {
|
||||
c *gitea.Client
|
||||
a *allowlist.Allowlist
|
||||
}
|
||||
|
||||
func NewWorkflowRunList(c *gitea.Client, a *allowlist.Allowlist) *WorkflowRunList {
|
||||
return &WorkflowRunList{c: c, a: a}
|
||||
}
|
||||
|
||||
func (t *WorkflowRunList) Descriptor() registry.ToolDescriptor {
|
||||
return registry.ToolDescriptor{
|
||||
Name: "workflow_run_list",
|
||||
Description: "List recent Gitea Actions workflow runs with optional filters (branch, head_sha, status, event, workflow).",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"owner":{"type":"string"},
|
||||
"name":{"type":"string"},
|
||||
"branch":{"type":"string"},
|
||||
"head_sha":{"type":"string"},
|
||||
"status":{"type":"string","enum":["queued","in_progress","completed","all"]},
|
||||
"event":{"type":"string","enum":["push","pull_request","schedule","workflow_dispatch","all"]},
|
||||
"workflow":{"type":"string"},
|
||||
"page":{"type":"integer","minimum":1},
|
||||
"limit":{"type":"integer","minimum":1,"maximum":50}
|
||||
},
|
||||
"required":["owner","name"]
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
type workflowRunListArgs struct {
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Branch string `json:"branch"`
|
||||
HeadSHA string `json:"head_sha"`
|
||||
Status string `json:"status"`
|
||||
Event string `json:"event"`
|
||||
Workflow string `json:"workflow"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
func (t *WorkflowRunList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var args workflowRunListArgs
|
||||
if err := parseArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := t.a.Check(args.Owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args.Limit = capLimit(args.Limit, 10)
|
||||
if args.Page < 1 {
|
||||
args.Page = 1
|
||||
}
|
||||
resp, err := t.c.ListWorkflowRuns(ctx, args.Owner, args.Name, gitea.ListWorkflowRunsArgs{
|
||||
Branch: args.Branch,
|
||||
HeadSHA: args.HeadSHA,
|
||||
Status: args.Status,
|
||||
Event: args.Event,
|
||||
Workflow: args.Workflow,
|
||||
Page: args.Page,
|
||||
Limit: args.Limit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{
|
||||
"runs": resp.WorkflowRuns,
|
||||
"total": resp.TotalCount,
|
||||
}
|
||||
if len(resp.WorkflowRuns) == args.Limit {
|
||||
out["next_page"] = args.Page + 1
|
||||
}
|
||||
return textOK(out)
|
||||
}
|
||||
98
internal/tools/workflow_run_list_test.go
Normal file
98
internal/tools/workflow_run_list_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
|
||||
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWorkflowRunListTool(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantQuery map[string]string
|
||||
notQuery []string
|
||||
respBody string
|
||||
assert func(t *testing.T, out string)
|
||||
}{
|
||||
{
|
||||
name: "happy path defaults",
|
||||
input: `{"owner":"mathias","name":"gitea-mcp"}`,
|
||||
wantQuery: map[string]string{"page": "1", "limit": "10"},
|
||||
respBody: `{"total_count":2,"workflow_runs":[{"id":823,"status":"completed","conclusion":"success","head_sha":"dc907fb"},{"id":822,"status":"completed","conclusion":"success","head_sha":"c4bd339"}]}`,
|
||||
assert: func(t *testing.T, out string) {
|
||||
assert.Contains(t, out, `"id":823`)
|
||||
assert.Contains(t, out, `"total":2`)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "head_sha short filter",
|
||||
input: `{"owner":"mathias","name":"gitea-mcp","head_sha":"dc907fb"}`,
|
||||
wantQuery: map[string]string{"head_sha": "dc907fb"},
|
||||
respBody: `{"total_count":1,"workflow_runs":[{"id":823,"status":"completed","conclusion":"success","head_sha":"dc907fb"}]}`,
|
||||
assert: func(t *testing.T, out string) {
|
||||
assert.Contains(t, out, `"id":823`)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "status filter",
|
||||
input: `{"owner":"mathias","name":"gitea-mcp","status":"in_progress"}`,
|
||||
wantQuery: map[string]string{"status": "in_progress"},
|
||||
respBody: `{"total_count":0,"workflow_runs":[]}`,
|
||||
assert: func(t *testing.T, out string) {
|
||||
assert.Contains(t, out, `"runs":[]`)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "status=all is no-op",
|
||||
input: `{"owner":"mathias","name":"gitea-mcp","status":"all"}`,
|
||||
notQuery: []string{"status"},
|
||||
respBody: `{"total_count":0,"workflow_runs":[]}`,
|
||||
assert: func(t *testing.T, out string) {},
|
||||
},
|
||||
{
|
||||
name: "branch filter",
|
||||
input: `{"owner":"mathias","name":"gitea-mcp","branch":"main"}`,
|
||||
wantQuery: map[string]string{"branch": "main"},
|
||||
respBody: `{"total_count":0,"workflow_runs":[]}`,
|
||||
assert: func(t *testing.T, out string) {},
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
assert.Equal(t, "/api/v1/repos/mathias/gitea-mcp/actions/runs", r.URL.Path)
|
||||
q := r.URL.Query()
|
||||
for k, v := range tc.wantQuery {
|
||||
assert.Equal(t, v, q.Get(k), "query param %q", k)
|
||||
}
|
||||
for _, k := range tc.notQuery {
|
||||
assert.Equal(t, "", q.Get(k), "query param %q should be absent", k)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(tc.respBody))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tool := tools.NewWorkflowRunList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
|
||||
out, err := tool.Call(context.Background(), json.RawMessage(tc.input))
|
||||
require.NoError(t, err)
|
||||
tc.assert(t, string(out))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowRunListAllowlistRejects(t *testing.T) {
|
||||
tool := tools.NewWorkflowRunList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
|
||||
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x"}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
Reference in New Issue
Block a user