3 Commits

Author SHA1 Message Date
silverwind
1620f1d6a9 refactor(test): use strings.Contains directly instead of wrapper
Remove the custom contains() wrapper function in params_test.go
and use strings.Contains directly as suggested in review.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 18:57:35 +01:00
James Pharaoh
b8630b5b9a refactor: simplify test helper using stdlib strings.Contains
Addresses code review feedback from silverwind:
- Replace complex custom contains() implementation with strings.Contains
- Remove unnecessary containsMiddle() function
- Improves readability and maintainability by using standard library

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 12:24:44 +00:00
James Pharaoh
71dbc9d6da feat: accept string or number for index parameters (#121)
This change makes index parameters more flexible by accepting both
numeric and string values. LLM agents often pass issue/PR indices
as strings (e.g., "123") since they appear as string identifiers in
URLs and CLI contexts. The implementation:

- Created pkg/params package with GetIndex() helper function
- Updated 25+ tool functions across issue, pull, label, and timetracking operations
- Improved error messages to say "must be a valid integer" instead of misleading "is required"
- Added comprehensive tests for both numeric and string inputs

This improves UX for MCP clients and LLMs while maintaining backward
compatibility with existing numeric callers.

Fixes: #121

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 09:23:45 +00:00
62 changed files with 3573 additions and 4924 deletions

View File

@@ -14,7 +14,7 @@ jobs:
DOCKER_LATEST: nightly DOCKER_LATEST: nightly
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v4
with: with:
fetch-depth: 0 # all history for all branches and tags fetch-depth: 0 # all history for all branches and tags
@@ -37,7 +37,7 @@ jobs:
echo REPO_VERSION=$(git describe --tags --always | sed 's/-/+/' | sed 's/^v//') >> $GITHUB_OUTPUT echo REPO_VERSION=$(git describe --tags --always | sed 's/-/+/' | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Build and push - name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile

View File

@@ -10,17 +10,20 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v5
with: with:
go-version: stable go-version: stable
- name: Install GoReleaser
run: go install github.com/goreleaser/goreleaser/v2@latest
- name: Run GoReleaser - name: Run GoReleaser
run: goreleaser release --clean uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
# 'latest', 'nightly', or a semver
version: "~> v2"
args: release --clean
env: env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GORELEASER_FORCE_TOKEN: "gitea" GORELEASER_FORCE_TOKEN: "gitea"
@@ -32,7 +35,7 @@ jobs:
DOCKER_LATEST: latest DOCKER_LATEST: latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v4
with: with:
fetch-depth: 0 # all history for all branches and tags fetch-depth: 0 # all history for all branches and tags
@@ -55,7 +58,7 @@ jobs:
echo REPO_VERSION=${GITHUB_REF_NAME#v} >> $GITHUB_OUTPUT echo REPO_VERSION=${GITHUB_REF_NAME#v} >> $GITHUB_OUTPUT
- name: Build and push - name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile

View File

@@ -7,13 +7,20 @@ jobs:
check-and-test: check-and-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- uses: actions/setup-go@v6 - uses: actions/setup-go@v5
with: with:
go-version-file: 'go.mod' go-version-file: 'go.mod'
- name: lint
run: make lint
- name: build - name: build
run: make build run: |
- name: security-check make build
run: make security-check
govulncheck_job:
runs-on: ubuntu-latest
name: Run govulncheck
steps:
- id: govulncheck
uses: golang/govulncheck-action@v1
with:
go-version-file: 'go.mod'
go-package: ./...

View File

@@ -1,113 +0,0 @@
version: "2"
output:
sort-order:
- file
linters:
default: none
enable:
- bidichk
- bodyclose
- depguard
- errcheck
- forbidigo
- gocheckcompilerdirectives
- gocritic
- govet
- ineffassign
- mirror
- modernize
- nakedret
- nilnil
- nolintlint
- perfsprint
- revive
- staticcheck
- testifylint
- unconvert
- unparam
- unused
- usestdlibvars
- usetesting
- wastedassign
settings:
depguard:
rules:
main:
deny:
- pkg: io/ioutil
desc: use os or io instead
- pkg: golang.org/x/exp
desc: it's experimental and unreliable
- pkg: github.com/pkg/errors
desc: use builtin errors package instead
nolintlint:
allow-unused: false
require-explanation: true
require-specific: true
gocritic:
enabled-checks:
- equalFold
disabled-checks: []
revive:
severity: error
rules:
- name: blank-imports
- name: constant-logical-expr
- name: context-as-argument
- name: context-keys-type
- name: dot-imports
- name: empty-lines
- name: error-return
- name: error-strings
- name: exported
- name: identical-branches
- name: if-return
- name: increment-decrement
- name: modifies-value-receiver
- name: package-comments
- name: redefines-builtin-id
- name: superfluous-else
- name: time-naming
- name: unexported-return
- name: var-declaration
- name: var-naming
disabled: true
staticcheck:
checks:
- all
testifylint: {}
usetesting:
os-temp-dir: true
perfsprint:
concat-loop: false
govet:
enable:
- nilness
- unusedwrite
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
rules:
- linters:
- errcheck
- staticcheck
- unparam
path: _test\.go
issues:
max-issues-per-linter: 0
max-same-issues: 0
formatters:
enable:
- gofmt
- gofumpt
settings:
gofumpt:
extra-rules: true
exclusions:
generated: lax
run:
timeout: 10m

View File

@@ -47,24 +47,17 @@ This is a **Gitea MCP (Model Context Protocol) Server** written in Go that provi
## Available Tools ## Available Tools
The server provides 45 MCP tools covering: The server provides 40+ MCP tools covering:
- **User**: get_me, get_user_orgs - **User**: get_my_user_info, get_user_orgs, search_users
- **Search**: search_users, search_repos, search_org_teams - **Repository**: create_repo, fork_repo, list_my_repos, search_repos
- **Repository**: create_repo, fork_repo, list_my_repos - **Branches/Tags**: create_branch, delete_branch, list_branches, create_tag, list_tags
- **Branches**: list_branches, create_branch, delete_branch - **Files**: get_file_content, create_file, update_file, delete_file, get_dir_content
- **Tags**: list_tags, get_tag, create_tag, delete_tag - **Issues**: create_issue, list_repo_issues, create_issue_comment, edit_issue
- **Files**: get_file_contents, get_dir_contents, create_or_update_file, delete_file - **Pull Requests**: create_pull_request, list_repo_pull_requests, get_pull_request_by_index
- **Commits**: list_commits - **Releases**: create_release, list_releases, get_latest_release
- **Issues**: list_issues, issue_read, issue_write - **Wiki**: create_wiki_page, update_wiki_page, list_wiki_pages
- **Pull Requests**: list_pull_requests, pull_request_read, pull_request_write, pull_request_review_write - **Search**: search_repos, search_users, search_org_teams
- **Labels**: label_read, label_write
- **Milestones**: milestone_read, milestone_write
- **Releases**: list_releases, get_release, get_latest_release, create_release, delete_release
- **Wiki**: wiki_read, wiki_write
- **Time Tracking**: timetracking_read, timetracking_write
- **Actions Runs**: actions_run_read, actions_run_write
- **Actions Config**: actions_config_read, actions_config_write
- **Version**: get_gitea_mcp_server_version - **Version**: get_gitea_mcp_server_version
## Common Development Patterns ## Common Development Patterns

View File

@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1.4 # syntax=docker/dockerfile:1.4
# Build stage # Build stage
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS builder
ARG VERSION=dev ARG VERSION=dev
ARG TARGETOS ARG TARGETOS

View File

@@ -3,10 +3,6 @@ EXECUTABLE := gitea-mcp
VERSION ?= $(shell git describe --tags --always | sed 's/-/+/' | sed 's/^v//') VERSION ?= $(shell git describe --tags --always | sed 's/-/+/' | sed 's/^v//')
LDFLAGS := -X "main.Version=$(VERSION)" LDFLAGS := -X "main.Version=$(VERSION)"
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.10.1
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1
GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.2
.PHONY: help .PHONY: help
help: ## Print this help message. help: ## Print this help message.
@echo "Usage: make [target]" @echo "Usage: make [target]"
@@ -49,29 +45,8 @@ air: ## Install air for hot reload.
dev: air ## run the application with hot reload dev: air ## run the application with hot reload
air --build.cmd "make build" --build.bin ./gitea-mcp air --build.cmd "make build" --build.bin ./gitea-mcp
.PHONY: lint
lint: lint-go ## lint everything
.PHONY: lint-fix
lint-fix: lint-go-fix ## lint everything and fix issues
.PHONY: lint-go
lint-go: ## lint go files
$(GO) run $(GOLANGCI_LINT_PACKAGE) run
.PHONY: lint-go-fix
lint-go-fix: ## lint go files and fix issues
$(GO) run $(GOLANGCI_LINT_PACKAGE) run --fix
.PHONY: security-check
security-check: ## run security check
$(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true
.PHONY: tidy
tidy: ## run go mod tidy
$(eval MIN_GO_VERSION := $(shell grep -Eo '^go\s+[0-9]+\.[0-9.]+' go.mod | cut -d' ' -f2))
$(GO) mod tidy -compat=$(MIN_GO_VERSION)
.PHONY: vendor .PHONY: vendor
vendor: tidy ## tidy and verify module dependencies vendor: ## tidy and verify module dependencies
$(GO) mod verify @echo 'Tidying and verifying module dependencies...'
go mod tidy
go mod verify

View File

@@ -221,7 +221,6 @@ The Gitea MCP Server supports the following tools:
| submit_pull_request_review | Pull Request | Submit a pending review | | submit_pull_request_review | Pull Request | Submit a pending review |
| delete_pull_request_review | Pull Request | Delete a review | | delete_pull_request_review | Pull Request | Delete a review |
| dismiss_pull_request_review | Pull Request | Dismiss a review with optional message | | dismiss_pull_request_review | Pull Request | Dismiss a review with optional message |
| merge_pull_request | Pull Request | Merge a pull request |
| search_users | User | Search for users | | search_users | User | Search for users |
| search_org_teams | Organization | Search for teams in an organization | | search_org_teams | Organization | Search for teams in an organization |
| list_org_labels | Organization | List labels defined at organization level | | list_org_labels | Organization | List labels defined at organization level |

View File

@@ -220,7 +220,6 @@ Gitea MCP 服务器支持以下工具:
| submit_pull_request_review | 拉取请求 | 提交待处理的审查 | | submit_pull_request_review | 拉取请求 | 提交待处理的审查 |
| delete_pull_request_review | 拉取请求 | 删除审查 | | delete_pull_request_review | 拉取请求 | 删除审查 |
| dismiss_pull_request_review | 拉取请求 | 驳回审查(可附消息) | | dismiss_pull_request_review | 拉取请求 | 驳回审查(可附消息) |
| merge_pull_request | 拉取请求 | 合并拉取请求 |
| search_users | 用户 | 搜索用户 | | search_users | 用户 | 搜索用户 |
| search_org_teams | 组织 | 搜索组织团队 | | search_org_teams | 组织 | 搜索组织团队 |
| list_org_labels | 组织 | 列出组织标签 | | list_org_labels | 组织 | 列出组织标签 |

View File

@@ -220,7 +220,6 @@ Gitea MCP 伺服器支援以下工具:
| submit_pull_request_review | 拉取請求 | 提交待處理的審查 | | submit_pull_request_review | 拉取請求 | 提交待處理的審查 |
| delete_pull_request_review | 拉取請求 | 刪除審查 | | delete_pull_request_review | 拉取請求 | 刪除審查 |
| dismiss_pull_request_review | 拉取請求 | 駁回審查(可附訊息) | | dismiss_pull_request_review | 拉取請求 | 駁回審查(可附訊息) |
| merge_pull_request | 拉取請求 | 合併拉取請求 |
| search_users | 用戶 | 搜尋用戶 | | search_users | 用戶 | 搜尋用戶 |
| search_org_teams | 組織 | 搜尋組織團隊 | | search_org_teams | 組織 | 搜尋組織團隊 |
| list_org_labels | 組織 | 列出組織標籤 | | list_org_labels | 組織 | 列出組織標籤 |

View File

@@ -3,9 +3,7 @@ package cmd
import ( import (
"context" "context"
"flag" "flag"
"fmt"
"os" "os"
"text/tabwriter"
"gitea.com/gitea/gitea-mcp/operation" "gitea.com/gitea/gitea-mcp/operation"
flagPkg "gitea.com/gitea/gitea-mcp/pkg/flag" flagPkg "gitea.com/gitea/gitea-mcp/pkg/flag"
@@ -16,50 +14,57 @@ var (
host string host string
port int port int
token string token string
version bool
) )
func init() { func init() {
flag.StringVar(&flagPkg.Mode, "t", "stdio", "") flag.StringVar(
flag.StringVar(&flagPkg.Mode, "transport", "stdio", "") &flagPkg.Mode,
flag.StringVar(&host, "H", os.Getenv("GITEA_HOST"), "") "t",
flag.StringVar(&host, "host", os.Getenv("GITEA_HOST"), "") "stdio",
flag.IntVar(&port, "p", 8080, "") "Transport type (stdio or http)",
flag.IntVar(&port, "port", 8080, "") )
flag.StringVar(&token, "T", "", "") flag.StringVar(
flag.StringVar(&token, "token", "", "") &flagPkg.Mode,
flag.BoolVar(&flagPkg.ReadOnly, "r", false, "") "transport",
flag.BoolVar(&flagPkg.ReadOnly, "read-only", false, "") "stdio",
flag.BoolVar(&flagPkg.Debug, "d", false, "") "Transport type (stdio or http)",
flag.BoolVar(&flagPkg.Debug, "debug", false, "") )
flag.BoolVar(&flagPkg.Insecure, "k", false, "") flag.StringVar(
flag.BoolVar(&flagPkg.Insecure, "insecure", false, "") &host,
flag.BoolVar(&version, "v", false, "") "host",
flag.BoolVar(&version, "version", false, "") os.Getenv("GITEA_HOST"),
"Gitea host",
flag.Usage = func() { )
w := tabwriter.NewWriter(os.Stderr, 0, 0, 3, ' ', 0) flag.IntVar(
fmt.Fprintln(os.Stderr, "Usage: gitea-mcp [options]") &port,
fmt.Fprintln(os.Stderr) "port",
fmt.Fprintln(os.Stderr, "Options:") 8080,
fmt.Fprintf(w, " -t, -transport <type>\tTransport type: stdio or http (default: stdio)\n") "http port",
fmt.Fprintf(w, " -H, -host <url>\tGitea host URL (default: https://gitea.com)\n") )
fmt.Fprintf(w, " -p, -port <number>\tHTTP server port (default: 8080)\n") flag.StringVar(
fmt.Fprintf(w, " -T, -token <token>\tPersonal access token\n") &token,
fmt.Fprintf(w, " -r, -read-only\tExpose only read-only tools\n") "token",
fmt.Fprintf(w, " -d, -debug\tEnable debug mode\n") "",
fmt.Fprintf(w, " -k, -insecure\tIgnore TLS certificate errors\n") "Your personal access token",
fmt.Fprintf(w, " -v, -version\tPrint version and exit\n") )
fmt.Fprintln(w) flag.BoolVar(
fmt.Fprintln(w, "Environment variables:") &flagPkg.ReadOnly,
fmt.Fprintf(w, " GITEA_ACCESS_TOKEN\tProvide access token\n") "read-only",
fmt.Fprintf(w, " GITEA_DEBUG\tSet to 'true' for debug mode\n") false,
fmt.Fprintf(w, " GITEA_HOST\tOverride Gitea host URL\n") "Read-only mode",
fmt.Fprintf(w, " GITEA_INSECURE\tSet to 'true' to ignore TLS errors\n") )
fmt.Fprintf(w, " GITEA_READONLY\tSet to 'true' for read-only mode\n") flag.BoolVar(
fmt.Fprintf(w, " MCP_MODE\tOverride transport mode\n") &flagPkg.Debug,
w.Flush() "d",
} false,
"debug mode (If -d flag is provided, debug mode will be enabled by default)",
)
flag.BoolVar(
&flagPkg.Insecure,
"insecure",
false,
"ignore TLS certificate errors",
)
flag.Parse() flag.Parse()
@@ -94,16 +99,12 @@ func init() {
} }
func Execute() { func Execute() {
if version { defer log.Default().Sync()
fmt.Fprintln(os.Stdout, flagPkg.Version)
return
}
defer log.Default().Sync() //nolint:errcheck // best-effort flush
if err := operation.Run(); err != nil { if err := operation.Run(); err != nil {
if err == context.Canceled { if err == context.Canceled {
log.Info("Server shutdown due to context cancellation") log.Info("Server shutdown due to context cancellation")
return return
} }
log.Fatalf("Run Gitea MCP Server Error: %v", err) //nolint:gocritic // intentional exit after defer log.Fatalf("Run Gitea MCP Server Error: %v", err)
} }
} }

14
go.mod
View File

@@ -1,11 +1,11 @@
module gitea.com/gitea/gitea-mcp module gitea.com/gitea/gitea-mcp
go 1.26.0 go 1.24.0
require ( require (
code.gitea.io/sdk/gitea v0.23.2 code.gitea.io/sdk/gitea v0.22.1
github.com/mark3labs/mcp-go v0.44.0 github.com/mark3labs/mcp-go v0.42.0
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
) )
@@ -16,14 +16,14 @@ require (
github.com/davidmz/go-pageant v1.0.2 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-fed/httpsig v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/go-version v1.8.0 // indirect github.com/hashicorp/go-version v1.7.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect
github.com/mailru/easyjson v0.9.1 // indirect github.com/mailru/easyjson v0.9.1 // indirect
github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cast v1.10.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.org/x/crypto v0.43.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.37.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

28
go.sum
View File

@@ -1,5 +1,5 @@
code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg= code.gitea.io/sdk/gitea v0.22.1 h1:7K05KjRORyTcTYULQ/AwvlVS6pawLcWyXZcTr7gHFyA=
code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= code.gitea.io/sdk/gitea v0.22.1/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
@@ -18,8 +18,8 @@ github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 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/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -28,8 +28,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mark3labs/mcp-go v0.44.0 h1:OlYfcVviAnwNN40QZUrrzU0QZjq3En7rCU5X09a/B7I= github.com/mark3labs/mcp-go v0.42.0 h1:gk/8nYJh8t3yroCAOBhNbYsM9TCKvkM13I5t5Hfu6Ls=
github.com/mark3labs/mcp-go v0.44.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= github.com/mark3labs/mcp-go v0.42.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
@@ -46,23 +46,23 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@@ -5,7 +5,9 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/flag" "gitea.com/gitea/gitea-mcp/pkg/flag"
) )
var Version = "dev" var (
Version = "dev"
)
func init() { func init() {
flag.Version = Version flag.Version = Version

View File

@@ -6,3 +6,5 @@ import (
// Tool is the registry for all Actions-related MCP tools. // Tool is the registry for all Actions-related MCP tools.
var Tool = tool.New() var Tool = tool.New()

View File

@@ -1,555 +0,0 @@
package actions
import (
"context"
"errors"
"fmt"
"net/url"
"strconv"
"time"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
ActionsConfigReadToolName = "actions_config_read"
ActionsConfigWriteToolName = "actions_config_write"
)
type secretMeta struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
CreatedAt time.Time `json:"created_at,omitzero"`
}
func toSecretMetas(secrets []*gitea_sdk.Secret) []secretMeta {
metas := make([]secretMeta, 0, len(secrets))
for _, s := range secrets {
if s == nil {
continue
}
metas = append(metas, secretMeta{
Name: s.Name,
Description: s.Description,
CreatedAt: s.Created,
})
}
return metas
}
var (
ActionsConfigReadTool = mcp.NewTool(
ActionsConfigReadToolName,
mcp.WithDescription("Read Actions secrets and variables configuration."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list_repo_secrets", "list_org_secrets", "list_repo_variables", "get_repo_variable", "list_org_variables", "get_org_variable")),
mcp.WithString("owner", mcp.Description("repository owner (required for repo methods)")),
mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")),
mcp.WithString("org", mcp.Description("organization name (required for org methods)")),
mcp.WithString("name", mcp.Description("variable name (required for get methods)")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)),
)
ActionsConfigWriteTool = mcp.NewTool(
ActionsConfigWriteToolName,
mcp.WithDescription("Manage Actions secrets and variables: create, update, or delete."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("upsert_repo_secret", "delete_repo_secret", "upsert_org_secret", "delete_org_secret", "create_repo_variable", "update_repo_variable", "delete_repo_variable", "create_org_variable", "update_org_variable", "delete_org_variable")),
mcp.WithString("owner", mcp.Description("repository owner (required for repo methods)")),
mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")),
mcp.WithString("org", mcp.Description("organization name (required for org methods)")),
mcp.WithString("name", mcp.Description("secret or variable name (required for most methods)")),
mcp.WithString("data", mcp.Description("secret value (required for upsert secret methods)")),
mcp.WithString("value", mcp.Description("variable value (required for create/update variable methods)")),
mcp.WithString("description", mcp.Description("description for secret or variable")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{Tool: ActionsConfigReadTool, Handler: configReadFn})
Tool.RegisterWrite(server.ServerTool{Tool: ActionsConfigWriteTool, Handler: configWriteFn})
}
func configReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "list_repo_secrets":
return listRepoActionSecretsFn(ctx, req)
case "list_org_secrets":
return listOrgActionSecretsFn(ctx, req)
case "list_repo_variables":
return listRepoActionVariablesFn(ctx, req)
case "get_repo_variable":
return getRepoActionVariableFn(ctx, req)
case "list_org_variables":
return listOrgActionVariablesFn(ctx, req)
case "get_org_variable":
return getOrgActionVariableFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func configWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "upsert_repo_secret":
return upsertRepoActionSecretFn(ctx, req)
case "delete_repo_secret":
return deleteRepoActionSecretFn(ctx, req)
case "upsert_org_secret":
return upsertOrgActionSecretFn(ctx, req)
case "delete_org_secret":
return deleteOrgActionSecretFn(ctx, req)
case "create_repo_variable":
return createRepoActionVariableFn(ctx, req)
case "update_repo_variable":
return updateRepoActionVariableFn(ctx, req)
case "delete_repo_variable":
return deleteRepoActionVariableFn(ctx, req)
case "create_org_variable":
return createOrgActionVariableFn(ctx, req)
case "update_org_variable":
return updateOrgActionVariableFn(ctx, req)
case "delete_org_variable":
return deleteOrgActionVariableFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
// Secret functions
func listRepoActionSecretsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionSecretsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
secrets, _, err := client.ListRepoActionSecret(owner, repo, gitea_sdk.ListRepoActionSecretOption{
ListOptions: gitea_sdk.ListOptions{Page: page, PageSize: pageSize},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list repo action secrets err: %v", err))
}
return to.TextResult(toSecretMetas(secrets))
}
func upsertRepoActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called upsertRepoActionSecretFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
data, err := params.GetString(req.GetArguments(), "data")
if err != nil || data == "" {
return to.ErrorResult(errors.New("data is required"))
}
description, _ := req.GetArguments()["description"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.CreateRepoActionSecret(owner, repo, gitea_sdk.CreateSecretOption{
Name: name,
Data: data,
Description: description,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("upsert repo action secret err: %v", err))
}
return to.TextResult(map[string]any{"message": "secret upserted", "status": resp.StatusCode})
}
func deleteRepoActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteRepoActionSecretFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.DeleteRepoActionSecret(owner, repo, name)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete repo action secret err: %v", err))
}
return to.TextResult(map[string]any{"message": "secret deleted", "status": resp.StatusCode})
}
func listOrgActionSecretsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listOrgActionSecretsFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
secrets, _, err := client.ListOrgActionSecret(org, gitea_sdk.ListOrgActionSecretOption{
ListOptions: gitea_sdk.ListOptions{Page: page, PageSize: pageSize},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list org action secrets err: %v", err))
}
return to.TextResult(toSecretMetas(secrets))
}
func upsertOrgActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called upsertOrgActionSecretFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
data, err := params.GetString(req.GetArguments(), "data")
if err != nil || data == "" {
return to.ErrorResult(errors.New("data is required"))
}
description, _ := req.GetArguments()["description"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.CreateOrgActionSecret(org, gitea_sdk.CreateSecretOption{
Name: name,
Data: data,
Description: description,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("upsert org action secret err: %v", err))
}
return to.TextResult(map[string]any{"message": "secret upserted", "status": resp.StatusCode})
}
func deleteOrgActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteOrgActionSecretFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
escapedOrg := url.PathEscape(org)
escapedSecret := url.PathEscape(name)
_, err = gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("orgs/%s/actions/secrets/%s", escapedOrg, escapedSecret), nil, nil, nil)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete org action secret err: %v", err))
}
return to.TextResult(map[string]any{"message": "secret deleted"})
}
// Variable functions
func listRepoActionVariablesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionVariablesFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
query := url.Values{}
query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize))
var result any
_, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/actions/variables", url.PathEscape(owner), url.PathEscape(repo)), query, nil, &result)
if err != nil {
return to.ErrorResult(fmt.Errorf("list repo action variables err: %v", err))
}
return to.TextResult(result)
}
func getRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getRepoActionVariableFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
variable, _, err := client.GetRepoActionVariable(owner, repo, name)
if err != nil {
return to.ErrorResult(fmt.Errorf("get repo action variable err: %v", err))
}
return to.TextResult(variable)
}
func createRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createRepoActionVariableFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
value, err := params.GetString(req.GetArguments(), "value")
if err != nil || value == "" {
return to.ErrorResult(errors.New("value is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.CreateRepoActionVariable(owner, repo, name, value)
if err != nil {
return to.ErrorResult(fmt.Errorf("create repo action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable created", "status": resp.StatusCode})
}
func updateRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called updateRepoActionVariableFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
value, err := params.GetString(req.GetArguments(), "value")
if err != nil || value == "" {
return to.ErrorResult(errors.New("value is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.UpdateRepoActionVariable(owner, repo, name, value)
if err != nil {
return to.ErrorResult(fmt.Errorf("update repo action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable updated", "status": resp.StatusCode})
}
func deleteRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteRepoActionVariableFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.DeleteRepoActionVariable(owner, repo, name)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete repo action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable deleted", "status": resp.StatusCode})
}
func listOrgActionVariablesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listOrgActionVariablesFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
variables, _, err := client.ListOrgActionVariable(org, gitea_sdk.ListOrgActionVariableOption{
ListOptions: gitea_sdk.ListOptions{Page: page, PageSize: pageSize},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list org action variables err: %v", err))
}
return to.TextResult(variables)
}
func getOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getOrgActionVariableFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
variable, _, err := client.GetOrgActionVariable(org, name)
if err != nil {
return to.ErrorResult(fmt.Errorf("get org action variable err: %v", err))
}
return to.TextResult(variable)
}
func createOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createOrgActionVariableFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
value, err := params.GetString(req.GetArguments(), "value")
if err != nil || value == "" {
return to.ErrorResult(errors.New("value is required"))
}
description, _ := req.GetArguments()["description"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.CreateOrgActionVariable(org, gitea_sdk.CreateOrgActionVariableOption{
Name: name,
Value: value,
Description: description,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("create org action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable created", "status": resp.StatusCode})
}
func updateOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called updateOrgActionVariableFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
value, err := params.GetString(req.GetArguments(), "value")
if err != nil || value == "" {
return to.ErrorResult(errors.New("value is required"))
}
description, _ := req.GetArguments()["description"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.UpdateOrgActionVariable(org, name, gitea_sdk.UpdateOrgActionVariableOption{
Value: value,
Description: description,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("update org action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable updated", "status": resp.StatusCode})
}
func deleteOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteOrgActionVariableFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
_, err = gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("orgs/%s/actions/variables/%s", url.PathEscape(org), url.PathEscape(name)), nil, nil, nil)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete org action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable deleted"})
}

198
operation/actions/logs.go Normal file
View File

@@ -0,0 +1,198 @@
package actions
import (
"context"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/to"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
GetRepoActionJobLogPreviewToolName = "get_repo_action_job_log_preview"
DownloadRepoActionJobLogToolName = "download_repo_action_job_log"
)
var (
GetRepoActionJobLogPreviewTool = mcp.NewTool(
GetRepoActionJobLogPreviewToolName,
mcp.WithDescription("Get a repository Actions job log preview (tail/limited for chat-friendly output)"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("job_id", mcp.Required(), mcp.Description("job ID")),
mcp.WithNumber("tail_lines", mcp.Description("number of lines from the end of the log"), mcp.DefaultNumber(200), mcp.Min(1)),
mcp.WithNumber("max_bytes", mcp.Description("max bytes to return"), mcp.DefaultNumber(65536), mcp.Min(1024)),
)
DownloadRepoActionJobLogTool = mcp.NewTool(
DownloadRepoActionJobLogToolName,
mcp.WithDescription("Download a repository Actions job log to a file on the MCP server filesystem"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("job_id", mcp.Required(), mcp.Description("job ID")),
mcp.WithString("output_path", mcp.Description("optional output file path; if omitted, uses ~/.gitea-mcp/artifacts/actions-logs/...")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{Tool: GetRepoActionJobLogPreviewTool, Handler: GetRepoActionJobLogPreviewFn})
Tool.RegisterRead(server.ServerTool{Tool: DownloadRepoActionJobLogTool, Handler: DownloadRepoActionJobLogFn})
}
func logPaths(owner, repo string, jobID int64) []string {
// Primary candidate endpoints, plus a few commonly-seen variants across versions.
// We try these in order; 404/405 falls through.
return []string{
fmt.Sprintf("repos/%s/%s/actions/jobs/%d/logs", url.PathEscape(owner), url.PathEscape(repo), jobID),
fmt.Sprintf("repos/%s/%s/actions/jobs/%d/log", url.PathEscape(owner), url.PathEscape(repo), jobID),
fmt.Sprintf("repos/%s/%s/actions/tasks/%d/log", url.PathEscape(owner), url.PathEscape(repo), jobID),
fmt.Sprintf("repos/%s/%s/actions/task/%d/log", url.PathEscape(owner), url.PathEscape(repo), jobID),
}
}
func fetchJobLogBytes(ctx context.Context, owner, repo string, jobID int64) ([]byte, string, error) {
var lastErr error
for _, p := range logPaths(owner, repo, jobID) {
b, _, err := gitea.DoBytes(ctx, "GET", p, nil, nil, "text/plain")
if err == nil {
return b, p, nil
}
lastErr = err
var httpErr *gitea.HTTPError
if errors.As(err, &httpErr) && (httpErr.StatusCode == 404 || httpErr.StatusCode == 405) {
continue
}
return nil, p, err
}
return nil, "", lastErr
}
func tailByLines(data []byte, tailLines int) []byte {
if tailLines <= 0 || len(data) == 0 {
return data
}
// Find the start index of the last N lines by scanning backwards.
lines := 0
i := len(data) - 1
for i >= 0 {
if data[i] == '\n' {
lines++
if lines > tailLines {
return data[i+1:]
}
}
i--
}
return data
}
func limitBytes(data []byte, maxBytes int) ([]byte, bool) {
if maxBytes <= 0 {
return data, false
}
if len(data) <= maxBytes {
return data, false
}
// Keep the tail so the most recent log content is preserved.
return data[len(data)-maxBytes:], true
}
func GetRepoActionJobLogPreviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetRepoActionJobLogPreviewFn")
owner, ok := req.GetArguments()["owner"].(string)
if !ok || owner == "" {
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok || repo == "" {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
jobIDFloat, ok := req.GetArguments()["job_id"].(float64)
if !ok || jobIDFloat <= 0 {
return to.ErrorResult(fmt.Errorf("job_id is required"))
}
tailLinesFloat, _ := req.GetArguments()["tail_lines"].(float64)
maxBytesFloat, _ := req.GetArguments()["max_bytes"].(float64)
tailLines := int(tailLinesFloat)
if tailLines <= 0 {
tailLines = 200
}
maxBytes := int(maxBytesFloat)
if maxBytes <= 0 {
maxBytes = 65536
}
jobID := int64(jobIDFloat)
raw, usedPath, err := fetchJobLogBytes(ctx, owner, repo, jobID)
if err != nil {
return to.ErrorResult(fmt.Errorf("get job log err: %v", err))
}
tailed := tailByLines(raw, tailLines)
limited, truncated := limitBytes(tailed, maxBytes)
return to.TextResult(map[string]any{
"endpoint": usedPath,
"job_id": jobID,
"bytes": len(raw),
"tail_lines": tailLines,
"max_bytes": maxBytes,
"truncated": truncated,
"log": string(limited),
})
}
func DownloadRepoActionJobLogFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DownloadRepoActionJobLogFn")
owner, ok := req.GetArguments()["owner"].(string)
if !ok || owner == "" {
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok || repo == "" {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
jobIDFloat, ok := req.GetArguments()["job_id"].(float64)
if !ok || jobIDFloat <= 0 {
return to.ErrorResult(fmt.Errorf("job_id is required"))
}
outputPath, _ := req.GetArguments()["output_path"].(string)
jobID := int64(jobIDFloat)
raw, usedPath, err := fetchJobLogBytes(ctx, owner, repo, jobID)
if err != nil {
return to.ErrorResult(fmt.Errorf("download job log err: %v", err))
}
if outputPath == "" {
home, _ := os.UserHomeDir()
if home == "" {
home = os.TempDir()
}
outputPath = filepath.Join(home, ".gitea-mcp", "artifacts", "actions-logs", owner, repo, fmt.Sprintf("%d.log", jobID))
}
if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil {
return to.ErrorResult(fmt.Errorf("create output dir err: %v", err))
}
if err := os.WriteFile(outputPath, raw, 0o600); err != nil {
return to.ErrorResult(fmt.Errorf("write log file err: %v", err))
}
return to.TextResult(map[string]any{
"endpoint": usedPath,
"job_id": jobID,
"path": outputPath,
"bytes": len(raw),
})
}

View File

@@ -20,3 +20,5 @@ func TestLimitBytesKeepsTail(t *testing.T) {
t.Fatalf("limitBytes tail = %q, want %q", string(out), "6789") t.Fatalf("limitBytes tail = %q, want %q", string(out), "6789")
} }
} }

View File

@@ -4,15 +4,10 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net/http"
"net/url" "net/url"
"os"
"path/filepath"
"strconv"
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
"github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/mcp"
@@ -20,124 +15,157 @@ import (
) )
const ( const (
ActionsRunReadToolName = "actions_run_read" ListRepoActionWorkflowsToolName = "list_repo_action_workflows"
ActionsRunWriteToolName = "actions_run_write" GetRepoActionWorkflowToolName = "get_repo_action_workflow"
DispatchRepoActionWorkflowToolName = "dispatch_repo_action_workflow"
ListRepoActionRunsToolName = "list_repo_action_runs"
GetRepoActionRunToolName = "get_repo_action_run"
CancelRepoActionRunToolName = "cancel_repo_action_run"
RerunRepoActionRunToolName = "rerun_repo_action_run"
ListRepoActionJobsToolName = "list_repo_action_jobs"
ListRepoActionRunJobsToolName = "list_repo_action_run_jobs"
) )
var ( var (
ActionsRunReadTool = mcp.NewTool( ListRepoActionWorkflowsTool = mcp.NewTool(
ActionsRunReadToolName, ListRepoActionWorkflowsToolName,
mcp.WithDescription("Read Actions workflow, run, and job data. Use method 'list_workflows'/'get_workflow' for workflows, 'list_runs'/'get_run' for runs, 'list_jobs'/'list_run_jobs' for jobs, 'get_job_log_preview'/'download_job_log' for logs."), mcp.WithDescription("List repository Actions workflows"),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list_workflows", "get_workflow", "list_runs", "get_run", "list_jobs", "list_run_jobs", "get_job_log_preview", "download_job_log")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("workflow_id", mcp.Description("workflow ID or filename (required for 'get_workflow')")),
mcp.WithNumber("run_id", mcp.Description("run ID (required for 'get_run', 'list_run_jobs')")),
mcp.WithNumber("job_id", mcp.Description("job ID (required for 'get_job_log_preview', 'download_job_log')")),
mcp.WithString("status", mcp.Description("optional status filter (for 'list_runs', 'list_jobs')")),
mcp.WithNumber("tail_lines", mcp.Description("number of lines from end of log (for 'get_job_log_preview')"), mcp.DefaultNumber(200), mcp.Min(1)),
mcp.WithNumber("max_bytes", mcp.Description("max bytes to return (for 'get_job_log_preview')"), mcp.DefaultNumber(65536), mcp.Min(1024)),
mcp.WithString("output_path", mcp.Description("output file path (for 'download_job_log')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)), mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)), mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(50), mcp.Min(1)),
) )
ActionsRunWriteTool = mcp.NewTool( GetRepoActionWorkflowTool = mcp.NewTool(
ActionsRunWriteToolName, GetRepoActionWorkflowToolName,
mcp.WithDescription("Trigger, cancel, or rerun Actions workflows."), mcp.WithDescription("Get a repository Actions workflow by ID"),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("dispatch_workflow", "cancel_run", "rerun_run")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("workflow_id", mcp.Description("workflow ID or filename (required for 'dispatch_workflow')")), mcp.WithString("workflow_id", mcp.Required(), mcp.Description("workflow ID or filename (e.g. 'my-workflow.yml')")),
mcp.WithString("ref", mcp.Description("git ref branch or tag (required for 'dispatch_workflow')")), )
mcp.WithObject("inputs", mcp.Description("workflow inputs object (for 'dispatch_workflow')")),
mcp.WithNumber("run_id", mcp.Description("run ID (required for 'cancel_run', 'rerun_run')")), DispatchRepoActionWorkflowTool = mcp.NewTool(
DispatchRepoActionWorkflowToolName,
mcp.WithDescription("Trigger (dispatch) a repository Actions workflow"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("workflow_id", mcp.Required(), mcp.Description("workflow ID or filename (e.g. 'my-workflow.yml')")),
mcp.WithString("ref", mcp.Required(), mcp.Description("git ref (branch or tag)")),
mcp.WithObject("inputs", mcp.Description("workflow inputs object")),
)
ListRepoActionRunsTool = mcp.NewTool(
ListRepoActionRunsToolName,
mcp.WithDescription("List repository Actions workflow runs"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(50), mcp.Min(1)),
mcp.WithString("status", mcp.Description("optional status filter")),
)
GetRepoActionRunTool = mcp.NewTool(
GetRepoActionRunToolName,
mcp.WithDescription("Get a repository Actions run by ID"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("run_id", mcp.Required(), mcp.Description("run ID")),
)
CancelRepoActionRunTool = mcp.NewTool(
CancelRepoActionRunToolName,
mcp.WithDescription("Cancel a repository Actions run"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("run_id", mcp.Required(), mcp.Description("run ID")),
)
RerunRepoActionRunTool = mcp.NewTool(
RerunRepoActionRunToolName,
mcp.WithDescription("Rerun a repository Actions run"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("run_id", mcp.Required(), mcp.Description("run ID")),
)
ListRepoActionJobsTool = mcp.NewTool(
ListRepoActionJobsToolName,
mcp.WithDescription("List repository Actions jobs"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(50), mcp.Min(1)),
mcp.WithString("status", mcp.Description("optional status filter")),
)
ListRepoActionRunJobsTool = mcp.NewTool(
ListRepoActionRunJobsToolName,
mcp.WithDescription("List Actions jobs for a specific run"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("run_id", mcp.Required(), mcp.Description("run ID")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(50), mcp.Min(1)),
) )
) )
func init() { func init() {
Tool.RegisterRead(server.ServerTool{Tool: ActionsRunReadTool, Handler: runReadFn}) Tool.RegisterRead(server.ServerTool{Tool: ListRepoActionWorkflowsTool, Handler: ListRepoActionWorkflowsFn})
Tool.RegisterWrite(server.ServerTool{Tool: ActionsRunWriteTool, Handler: runWriteFn}) Tool.RegisterRead(server.ServerTool{Tool: GetRepoActionWorkflowTool, Handler: GetRepoActionWorkflowFn})
Tool.RegisterWrite(server.ServerTool{Tool: DispatchRepoActionWorkflowTool, Handler: DispatchRepoActionWorkflowFn})
Tool.RegisterRead(server.ServerTool{Tool: ListRepoActionRunsTool, Handler: ListRepoActionRunsFn})
Tool.RegisterRead(server.ServerTool{Tool: GetRepoActionRunTool, Handler: GetRepoActionRunFn})
Tool.RegisterWrite(server.ServerTool{Tool: CancelRepoActionRunTool, Handler: CancelRepoActionRunFn})
Tool.RegisterWrite(server.ServerTool{Tool: RerunRepoActionRunTool, Handler: RerunRepoActionRunFn})
Tool.RegisterRead(server.ServerTool{Tool: ListRepoActionJobsTool, Handler: ListRepoActionJobsFn})
Tool.RegisterRead(server.ServerTool{Tool: ListRepoActionRunJobsTool, Handler: ListRepoActionRunJobsFn})
} }
func runReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func doJSONWithFallback(ctx context.Context, method string, paths []string, query url.Values, body any, respOut any) (string, int, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "list_workflows":
return listRepoActionWorkflowsFn(ctx, req)
case "get_workflow":
return getRepoActionWorkflowFn(ctx, req)
case "list_runs":
return listRepoActionRunsFn(ctx, req)
case "get_run":
return getRepoActionRunFn(ctx, req)
case "list_jobs":
return listRepoActionJobsFn(ctx, req)
case "list_run_jobs":
return listRepoActionRunJobsFn(ctx, req)
case "get_job_log_preview":
return getRepoActionJobLogPreviewFn(ctx, req)
case "download_job_log":
return downloadRepoActionJobLogFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func runWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "dispatch_workflow":
return dispatchRepoActionWorkflowFn(ctx, req)
case "cancel_run":
return cancelRepoActionRunFn(ctx, req)
case "rerun_run":
return rerunRepoActionRunFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func doJSONWithFallback(ctx context.Context, method string, paths []string, query url.Values, body, respOut any) error {
var lastErr error var lastErr error
for _, p := range paths { for _, p := range paths {
_, err := gitea.DoJSON(ctx, method, p, query, body, respOut) status, err := gitea.DoJSON(ctx, method, p, query, body, respOut)
if err == nil { if err == nil {
return nil return p, status, nil
} }
lastErr = err lastErr = err
var httpErr *gitea.HTTPError var httpErr *gitea.HTTPError
if errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) { if errors.As(err, &httpErr) && (httpErr.StatusCode == 404 || httpErr.StatusCode == 405) {
continue continue
} }
return err return p, status, err
} }
return lastErr return "", 0, lastErr
} }
func listRepoActionWorkflowsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListRepoActionWorkflowsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionWorkflowsFn") log.Debugf("Called ListRepoActionWorkflowsFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, ok := req.GetArguments()["owner"].(string)
if err != nil || owner == "" { if !ok || owner == "" {
return to.ErrorResult(errors.New("owner is required")) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil || repo == "" { if !ok || repo == "" {
return to.ErrorResult(errors.New("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
}
page, _ := req.GetArguments()["page"].(float64)
if page <= 0 {
page = 1
}
pageSize, _ := req.GetArguments()["pageSize"].(float64)
if pageSize <= 0 {
pageSize = 50
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30)
query := url.Values{} query := url.Values{}
query.Set("page", strconv.Itoa(page)) query.Set("page", fmt.Sprintf("%d", int(page)))
query.Set("limit", strconv.Itoa(pageSize)) query.Set("limit", fmt.Sprintf("%d", int(pageSize)))
var result any var result any
err = doJSONWithFallback(ctx, "GET", _, _, err := doJSONWithFallback(ctx, "GET",
[]string{ []string{
fmt.Sprintf("repos/%s/%s/actions/workflows", url.PathEscape(owner), url.PathEscape(repo)), fmt.Sprintf("repos/%s/%s/actions/workflows", url.PathEscape(owner), url.PathEscape(repo)),
}, },
@@ -146,26 +174,26 @@ func listRepoActionWorkflowsFn(ctx context.Context, req mcp.CallToolRequest) (*m
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("list action workflows err: %v", err)) return to.ErrorResult(fmt.Errorf("list action workflows err: %v", err))
} }
return to.TextResult(slimActionWorkflows(result)) return to.TextResult(result)
} }
func getRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getRepoActionWorkflowFn") log.Debugf("Called GetRepoActionWorkflowFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, ok := req.GetArguments()["owner"].(string)
if err != nil || owner == "" { if !ok || owner == "" {
return to.ErrorResult(errors.New("owner is required")) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil || repo == "" { if !ok || repo == "" {
return to.ErrorResult(errors.New("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
workflowID, err := params.GetString(req.GetArguments(), "workflow_id") workflowID, ok := req.GetArguments()["workflow_id"].(string)
if err != nil || workflowID == "" { if !ok || workflowID == "" {
return to.ErrorResult(errors.New("workflow_id is required")) return to.ErrorResult(fmt.Errorf("workflow_id is required"))
} }
var result any var result any
err = doJSONWithFallback(ctx, "GET", _, _, err := doJSONWithFallback(ctx, "GET",
[]string{ []string{
fmt.Sprintf("repos/%s/%s/actions/workflows/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflowID)), fmt.Sprintf("repos/%s/%s/actions/workflows/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflowID)),
}, },
@@ -174,32 +202,37 @@ func getRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest) (*mcp
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get action workflow err: %v", err)) return to.ErrorResult(fmt.Errorf("get action workflow err: %v", err))
} }
return to.TextResult(slimActionWorkflow(result)) return to.TextResult(result)
} }
func dispatchRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func DispatchRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called dispatchRepoActionWorkflowFn") log.Debugf("Called DispatchRepoActionWorkflowFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, ok := req.GetArguments()["owner"].(string)
if err != nil || owner == "" { if !ok || owner == "" {
return to.ErrorResult(errors.New("owner is required")) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil || repo == "" { if !ok || repo == "" {
return to.ErrorResult(errors.New("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
workflowID, err := params.GetString(req.GetArguments(), "workflow_id") workflowID, ok := req.GetArguments()["workflow_id"].(string)
if err != nil || workflowID == "" { if !ok || workflowID == "" {
return to.ErrorResult(errors.New("workflow_id is required")) return to.ErrorResult(fmt.Errorf("workflow_id is required"))
} }
ref, err := params.GetString(req.GetArguments(), "ref") ref, ok := req.GetArguments()["ref"].(string)
if err != nil || ref == "" { if !ok || ref == "" {
return to.ErrorResult(errors.New("ref is required")) return to.ErrorResult(fmt.Errorf("ref is required"))
} }
var inputs map[string]any var inputs map[string]any
if raw, exists := req.GetArguments()["inputs"]; exists { if raw, exists := req.GetArguments()["inputs"]; exists {
if m, ok := raw.(map[string]any); ok { if m, ok := raw.(map[string]any); ok {
inputs = m inputs = m
} else if m, ok := raw.(map[string]interface{}); ok {
inputs = make(map[string]any, len(m))
for k, v := range m {
inputs[k] = v
}
} }
} }
@@ -210,7 +243,7 @@ func dispatchRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest)
body["inputs"] = inputs body["inputs"] = inputs
} }
err = doJSONWithFallback(ctx, "POST", _, _, err := doJSONWithFallback(ctx, "POST",
[]string{ []string{
fmt.Sprintf("repos/%s/%s/actions/workflows/%s/dispatches", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflowID)), fmt.Sprintf("repos/%s/%s/actions/workflows/%s/dispatches", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflowID)),
fmt.Sprintf("repos/%s/%s/actions/workflows/%s/dispatch", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflowID)), fmt.Sprintf("repos/%s/%s/actions/workflows/%s/dispatch", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflowID)),
@@ -219,7 +252,7 @@ func dispatchRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest)
) )
if err != nil { if err != nil {
var httpErr *gitea.HTTPError var httpErr *gitea.HTTPError
if errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) { if errors.As(err, &httpErr) && (httpErr.StatusCode == 404 || httpErr.StatusCode == 405) {
return to.ErrorResult(fmt.Errorf("workflow dispatch not supported on this Gitea version (endpoint returned %d). Check https://docs.gitea.com/api/1.24/ for available Actions endpoints", httpErr.StatusCode)) return to.ErrorResult(fmt.Errorf("workflow dispatch not supported on this Gitea version (endpoint returned %d). Check https://docs.gitea.com/api/1.24/ for available Actions endpoints", httpErr.StatusCode))
} }
return to.ErrorResult(fmt.Errorf("dispatch action workflow err: %v", err)) return to.ErrorResult(fmt.Errorf("dispatch action workflow err: %v", err))
@@ -227,28 +260,35 @@ func dispatchRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest)
return to.TextResult(map[string]any{"message": "workflow dispatched"}) return to.TextResult(map[string]any{"message": "workflow dispatched"})
} }
func listRepoActionRunsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListRepoActionRunsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionRunsFn") log.Debugf("Called ListRepoActionRunsFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, ok := req.GetArguments()["owner"].(string)
if err != nil || owner == "" { if !ok || owner == "" {
return to.ErrorResult(errors.New("owner is required")) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil || repo == "" { if !ok || repo == "" {
return to.ErrorResult(errors.New("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
}
page, _ := req.GetArguments()["page"].(float64)
if page <= 0 {
page = 1
}
pageSize, _ := req.GetArguments()["pageSize"].(float64)
if pageSize <= 0 {
pageSize = 50
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30)
statusFilter, _ := req.GetArguments()["status"].(string) statusFilter, _ := req.GetArguments()["status"].(string)
query := url.Values{} query := url.Values{}
query.Set("page", strconv.Itoa(page)) query.Set("page", fmt.Sprintf("%d", int(page)))
query.Set("limit", strconv.Itoa(pageSize)) query.Set("limit", fmt.Sprintf("%d", int(pageSize)))
if statusFilter != "" { if statusFilter != "" {
query.Set("status", statusFilter) query.Set("status", statusFilter)
} }
var result any var result any
err = doJSONWithFallback(ctx, "GET", _, _, err := doJSONWithFallback(ctx, "GET",
[]string{ []string{
fmt.Sprintf("repos/%s/%s/actions/runs", url.PathEscape(owner), url.PathEscape(repo)), fmt.Sprintf("repos/%s/%s/actions/runs", url.PathEscape(owner), url.PathEscape(repo)),
}, },
@@ -257,55 +297,55 @@ func listRepoActionRunsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("list action runs err: %v", err)) return to.ErrorResult(fmt.Errorf("list action runs err: %v", err))
} }
return to.TextResult(slimActionRuns(result)) return to.TextResult(result)
} }
func getRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getRepoActionRunFn") log.Debugf("Called GetRepoActionRunFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, ok := req.GetArguments()["owner"].(string)
if err != nil || owner == "" { if !ok || owner == "" {
return to.ErrorResult(errors.New("owner is required")) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil || repo == "" { if !ok || repo == "" {
return to.ErrorResult(errors.New("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
runID, err := params.GetIndex(req.GetArguments(), "run_id") runID, ok := req.GetArguments()["run_id"].(float64)
if err != nil || runID <= 0 { if !ok || runID <= 0 {
return to.ErrorResult(errors.New("run_id is required")) return to.ErrorResult(fmt.Errorf("run_id is required"))
} }
var result any var result any
err = doJSONWithFallback(ctx, "GET", _, _, err := doJSONWithFallback(ctx, "GET",
[]string{ []string{
fmt.Sprintf("repos/%s/%s/actions/runs/%d", url.PathEscape(owner), url.PathEscape(repo), runID), fmt.Sprintf("repos/%s/%s/actions/runs/%d", url.PathEscape(owner), url.PathEscape(repo), int64(runID)),
}, },
nil, nil, &result, nil, nil, &result,
) )
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get action run err: %v", err)) return to.ErrorResult(fmt.Errorf("get action run err: %v", err))
} }
return to.TextResult(slimActionRun(result)) return to.TextResult(result)
} }
func cancelRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func CancelRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called cancelRepoActionRunFn") log.Debugf("Called CancelRepoActionRunFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, ok := req.GetArguments()["owner"].(string)
if err != nil || owner == "" { if !ok || owner == "" {
return to.ErrorResult(errors.New("owner is required")) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil || repo == "" { if !ok || repo == "" {
return to.ErrorResult(errors.New("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
runID, err := params.GetIndex(req.GetArguments(), "run_id") runID, ok := req.GetArguments()["run_id"].(float64)
if err != nil || runID <= 0 { if !ok || runID <= 0 {
return to.ErrorResult(errors.New("run_id is required")) return to.ErrorResult(fmt.Errorf("run_id is required"))
} }
err = doJSONWithFallback(ctx, "POST", _, _, err := doJSONWithFallback(ctx, "POST",
[]string{ []string{
fmt.Sprintf("repos/%s/%s/actions/runs/%d/cancel", url.PathEscape(owner), url.PathEscape(repo), runID), fmt.Sprintf("repos/%s/%s/actions/runs/%d/cancel", url.PathEscape(owner), url.PathEscape(repo), int64(runID)),
}, },
nil, nil, nil, nil, nil, nil,
) )
@@ -315,31 +355,31 @@ func cancelRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.C
return to.TextResult(map[string]any{"message": "run cancellation requested"}) return to.TextResult(map[string]any{"message": "run cancellation requested"})
} }
func rerunRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func RerunRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called rerunRepoActionRunFn") log.Debugf("Called RerunRepoActionRunFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, ok := req.GetArguments()["owner"].(string)
if err != nil || owner == "" { if !ok || owner == "" {
return to.ErrorResult(errors.New("owner is required")) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil || repo == "" { if !ok || repo == "" {
return to.ErrorResult(errors.New("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
runID, err := params.GetIndex(req.GetArguments(), "run_id") runID, ok := req.GetArguments()["run_id"].(float64)
if err != nil || runID <= 0 { if !ok || runID <= 0 {
return to.ErrorResult(errors.New("run_id is required")) return to.ErrorResult(fmt.Errorf("run_id is required"))
} }
err = doJSONWithFallback(ctx, "POST", _, _, err := doJSONWithFallback(ctx, "POST",
[]string{ []string{
fmt.Sprintf("repos/%s/%s/actions/runs/%d/rerun", url.PathEscape(owner), url.PathEscape(repo), runID), fmt.Sprintf("repos/%s/%s/actions/runs/%d/rerun", url.PathEscape(owner), url.PathEscape(repo), int64(runID)),
fmt.Sprintf("repos/%s/%s/actions/runs/%d/rerun-failed-jobs", url.PathEscape(owner), url.PathEscape(repo), runID), fmt.Sprintf("repos/%s/%s/actions/runs/%d/rerun-failed-jobs", url.PathEscape(owner), url.PathEscape(repo), int64(runID)),
}, },
nil, nil, nil, nil, nil, nil,
) )
if err != nil { if err != nil {
var httpErr *gitea.HTTPError var httpErr *gitea.HTTPError
if errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) { if errors.As(err, &httpErr) && (httpErr.StatusCode == 404 || httpErr.StatusCode == 405) {
return to.ErrorResult(fmt.Errorf("workflow rerun not supported on this Gitea version (endpoint returned %d). Check https://docs.gitea.com/api/1.24/ for available Actions endpoints", httpErr.StatusCode)) return to.ErrorResult(fmt.Errorf("workflow rerun not supported on this Gitea version (endpoint returned %d). Check https://docs.gitea.com/api/1.24/ for available Actions endpoints", httpErr.StatusCode))
} }
return to.ErrorResult(fmt.Errorf("rerun action run err: %v", err)) return to.ErrorResult(fmt.Errorf("rerun action run err: %v", err))
@@ -347,28 +387,35 @@ func rerunRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
return to.TextResult(map[string]any{"message": "run rerun requested"}) return to.TextResult(map[string]any{"message": "run rerun requested"})
} }
func listRepoActionJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListRepoActionJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionJobsFn") log.Debugf("Called ListRepoActionJobsFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, ok := req.GetArguments()["owner"].(string)
if err != nil || owner == "" { if !ok || owner == "" {
return to.ErrorResult(errors.New("owner is required")) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil || repo == "" { if !ok || repo == "" {
return to.ErrorResult(errors.New("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
}
page, _ := req.GetArguments()["page"].(float64)
if page <= 0 {
page = 1
}
pageSize, _ := req.GetArguments()["pageSize"].(float64)
if pageSize <= 0 {
pageSize = 50
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30)
statusFilter, _ := req.GetArguments()["status"].(string) statusFilter, _ := req.GetArguments()["status"].(string)
query := url.Values{} query := url.Values{}
query.Set("page", strconv.Itoa(page)) query.Set("page", fmt.Sprintf("%d", int(page)))
query.Set("limit", strconv.Itoa(pageSize)) query.Set("limit", fmt.Sprintf("%d", int(pageSize)))
if statusFilter != "" { if statusFilter != "" {
query.Set("status", statusFilter) query.Set("status", statusFilter)
} }
var result any var result any
err = doJSONWithFallback(ctx, "GET", _, _, err := doJSONWithFallback(ctx, "GET",
[]string{ []string{
fmt.Sprintf("repos/%s/%s/actions/jobs", url.PathEscape(owner), url.PathEscape(repo)), fmt.Sprintf("repos/%s/%s/actions/jobs", url.PathEscape(owner), url.PathEscape(repo)),
}, },
@@ -377,173 +424,45 @@ func listRepoActionJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("list action jobs err: %v", err)) return to.ErrorResult(fmt.Errorf("list action jobs err: %v", err))
} }
return to.TextResult(slimActionJobs(result)) return to.TextResult(result)
} }
func listRepoActionRunJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListRepoActionRunJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionRunJobsFn") log.Debugf("Called ListRepoActionRunJobsFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, ok := req.GetArguments()["owner"].(string)
if err != nil || owner == "" { if !ok || owner == "" {
return to.ErrorResult(errors.New("owner is required")) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil || repo == "" { if !ok || repo == "" {
return to.ErrorResult(errors.New("repo is required")) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
runID, err := params.GetIndex(req.GetArguments(), "run_id") runID, ok := req.GetArguments()["run_id"].(float64)
if err != nil || runID <= 0 { if !ok || runID <= 0 {
return to.ErrorResult(errors.New("run_id is required")) return to.ErrorResult(fmt.Errorf("run_id is required"))
}
page, _ := req.GetArguments()["page"].(float64)
if page <= 0 {
page = 1
}
pageSize, _ := req.GetArguments()["pageSize"].(float64)
if pageSize <= 0 {
pageSize = 50
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30)
query := url.Values{} query := url.Values{}
query.Set("page", strconv.Itoa(page)) query.Set("page", fmt.Sprintf("%d", int(page)))
query.Set("limit", strconv.Itoa(pageSize)) query.Set("limit", fmt.Sprintf("%d", int(pageSize)))
var result any var result any
err = doJSONWithFallback(ctx, "GET", _, _, err := doJSONWithFallback(ctx, "GET",
[]string{ []string{
fmt.Sprintf("repos/%s/%s/actions/runs/%d/jobs", url.PathEscape(owner), url.PathEscape(repo), runID), fmt.Sprintf("repos/%s/%s/actions/runs/%d/jobs", url.PathEscape(owner), url.PathEscape(repo), int64(runID)),
}, },
query, nil, &result, query, nil, &result,
) )
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("list action run jobs err: %v", err)) return to.ErrorResult(fmt.Errorf("list action run jobs err: %v", err))
} }
return to.TextResult(slimActionJobs(result)) return to.TextResult(result)
}
// Log functions (merged from logs.go)
func logPaths(owner, repo string, jobID int64) []string {
return []string{
fmt.Sprintf("repos/%s/%s/actions/jobs/%d/logs", url.PathEscape(owner), url.PathEscape(repo), jobID),
fmt.Sprintf("repos/%s/%s/actions/jobs/%d/log", url.PathEscape(owner), url.PathEscape(repo), jobID),
fmt.Sprintf("repos/%s/%s/actions/tasks/%d/log", url.PathEscape(owner), url.PathEscape(repo), jobID),
fmt.Sprintf("repos/%s/%s/actions/task/%d/log", url.PathEscape(owner), url.PathEscape(repo), jobID),
}
}
func fetchJobLogBytes(ctx context.Context, owner, repo string, jobID int64) ([]byte, string, error) {
var lastErr error
for _, p := range logPaths(owner, repo, jobID) {
b, _, err := gitea.DoBytes(ctx, "GET", p, nil, nil, "text/plain")
if err == nil {
return b, p, nil
}
lastErr = err
var httpErr *gitea.HTTPError
if errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) {
continue
}
return nil, p, err
}
return nil, "", lastErr
}
func tailByLines(data []byte, tailLines int) []byte {
if tailLines <= 0 || len(data) == 0 {
return data
}
lines := 0
i := len(data) - 1
for i >= 0 {
if data[i] == '\n' {
lines++
if lines > tailLines {
return data[i+1:]
}
}
i--
}
return data
}
func limitBytes(data []byte, maxBytes int) ([]byte, bool) {
if maxBytes <= 0 {
return data, false
}
if len(data) <= maxBytes {
return data, false
}
return data[len(data)-maxBytes:], true
}
func getRepoActionJobLogPreviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getRepoActionJobLogPreviewFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
jobID, err := params.GetIndex(req.GetArguments(), "job_id")
if err != nil {
return to.ErrorResult(err)
}
tailLines := int(params.GetOptionalInt(req.GetArguments(), "tail_lines", 200))
maxBytes := int(params.GetOptionalInt(req.GetArguments(), "max_bytes", 65536))
raw, usedPath, err := fetchJobLogBytes(ctx, owner, repo, jobID)
if err != nil {
return to.ErrorResult(fmt.Errorf("get job log err: %v", err))
}
tailed := tailByLines(raw, tailLines)
limited, truncated := limitBytes(tailed, maxBytes)
return to.TextResult(map[string]any{
"endpoint": usedPath,
"job_id": jobID,
"bytes": len(raw),
"tail_lines": tailLines,
"max_bytes": maxBytes,
"truncated": truncated,
"log": string(limited),
})
}
func downloadRepoActionJobLogFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called downloadRepoActionJobLogFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
jobID, err := params.GetIndex(req.GetArguments(), "job_id")
if err != nil {
return to.ErrorResult(err)
}
outputPath, _ := req.GetArguments()["output_path"].(string)
raw, usedPath, err := fetchJobLogBytes(ctx, owner, repo, jobID)
if err != nil {
return to.ErrorResult(fmt.Errorf("download job log err: %v", err))
}
if outputPath == "" {
home, _ := os.UserHomeDir()
if home == "" {
home = os.TempDir()
}
outputPath = filepath.Join(home, ".gitea-mcp", "artifacts", "actions-logs", owner, repo, fmt.Sprintf("%d.log", jobID))
}
if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil {
return to.ErrorResult(fmt.Errorf("create output dir err: %v", err))
}
if err := os.WriteFile(outputPath, raw, 0o600); err != nil {
return to.ErrorResult(fmt.Errorf("write log file err: %v", err))
}
return to.TextResult(map[string]any{
"endpoint": usedPath,
"job_id": jobID,
"path": outputPath,
"bytes": len(raw),
})
} }

View File

@@ -0,0 +1,292 @@
package actions
import (
"context"
"fmt"
"net/url"
"time"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
ListRepoActionSecretsToolName = "list_repo_action_secrets"
UpsertRepoActionSecretToolName = "upsert_repo_action_secret"
DeleteRepoActionSecretToolName = "delete_repo_action_secret"
ListOrgActionSecretsToolName = "list_org_action_secrets"
UpsertOrgActionSecretToolName = "upsert_org_action_secret"
DeleteOrgActionSecretToolName = "delete_org_action_secret"
)
type secretMeta struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
}
var (
ListRepoActionSecretsTool = mcp.NewTool(
ListRepoActionSecretsToolName,
mcp.WithDescription("List repository Actions secrets (metadata only; secret values are never returned)"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(100), mcp.Min(1)),
)
UpsertRepoActionSecretTool = mcp.NewTool(
UpsertRepoActionSecretToolName,
mcp.WithDescription("Create or update (upsert) a repository Actions secret"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("name", mcp.Required(), mcp.Description("secret name")),
mcp.WithString("data", mcp.Required(), mcp.Description("secret value")),
mcp.WithString("description", mcp.Description("secret description")),
)
DeleteRepoActionSecretTool = mcp.NewTool(
DeleteRepoActionSecretToolName,
mcp.WithDescription("Delete a repository Actions secret"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("secretName", mcp.Required(), mcp.Description("secret name")),
)
ListOrgActionSecretsTool = mcp.NewTool(
ListOrgActionSecretsToolName,
mcp.WithDescription("List organization Actions secrets (metadata only; secret values are never returned)"),
mcp.WithString("org", mcp.Required(), mcp.Description("organization name")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(100), mcp.Min(1)),
)
UpsertOrgActionSecretTool = mcp.NewTool(
UpsertOrgActionSecretToolName,
mcp.WithDescription("Create or update (upsert) an organization Actions secret"),
mcp.WithString("org", mcp.Required(), mcp.Description("organization name")),
mcp.WithString("name", mcp.Required(), mcp.Description("secret name")),
mcp.WithString("data", mcp.Required(), mcp.Description("secret value")),
mcp.WithString("description", mcp.Description("secret description")),
)
DeleteOrgActionSecretTool = mcp.NewTool(
DeleteOrgActionSecretToolName,
mcp.WithDescription("Delete an organization Actions secret"),
mcp.WithString("org", mcp.Required(), mcp.Description("organization name")),
mcp.WithString("secretName", mcp.Required(), mcp.Description("secret name")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{Tool: ListRepoActionSecretsTool, Handler: ListRepoActionSecretsFn})
Tool.RegisterWrite(server.ServerTool{Tool: UpsertRepoActionSecretTool, Handler: UpsertRepoActionSecretFn})
Tool.RegisterWrite(server.ServerTool{Tool: DeleteRepoActionSecretTool, Handler: DeleteRepoActionSecretFn})
Tool.RegisterRead(server.ServerTool{Tool: ListOrgActionSecretsTool, Handler: ListOrgActionSecretsFn})
Tool.RegisterWrite(server.ServerTool{Tool: UpsertOrgActionSecretTool, Handler: UpsertOrgActionSecretFn})
Tool.RegisterWrite(server.ServerTool{Tool: DeleteOrgActionSecretTool, Handler: DeleteOrgActionSecretFn})
}
func ListRepoActionSecretsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListRepoActionSecretsFn")
owner, ok := req.GetArguments()["owner"].(string)
if !ok || owner == "" {
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok || repo == "" {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
page, _ := req.GetArguments()["page"].(float64)
if page <= 0 {
page = 1
}
pageSize, _ := req.GetArguments()["pageSize"].(float64)
if pageSize <= 0 {
pageSize = 100
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
secrets, _, err := client.ListRepoActionSecret(owner, repo, gitea_sdk.ListRepoActionSecretOption{
ListOptions: gitea_sdk.ListOptions{Page: int(page), PageSize: int(pageSize)},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list repo action secrets err: %v", err))
}
metas := make([]secretMeta, 0, len(secrets))
for _, s := range secrets {
if s == nil {
continue
}
metas = append(metas, secretMeta{
Name: s.Name,
Description: s.Description,
CreatedAt: s.Created,
})
}
return to.TextResult(metas)
}
func UpsertRepoActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called UpsertRepoActionSecretFn")
owner, ok := req.GetArguments()["owner"].(string)
if !ok || owner == "" {
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok || repo == "" {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
name, ok := req.GetArguments()["name"].(string)
if !ok || name == "" {
return to.ErrorResult(fmt.Errorf("name is required"))
}
data, ok := req.GetArguments()["data"].(string)
if !ok || data == "" {
return to.ErrorResult(fmt.Errorf("data is required"))
}
description, _ := req.GetArguments()["description"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.CreateRepoActionSecret(owner, repo, gitea_sdk.CreateSecretOption{
Name: name,
Data: data,
Description: description,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("upsert repo action secret err: %v", err))
}
return to.TextResult(map[string]any{"message": "secret upserted", "status": resp.StatusCode})
}
func DeleteRepoActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteRepoActionSecretFn")
owner, ok := req.GetArguments()["owner"].(string)
if !ok || owner == "" {
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok || repo == "" {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
secretName, ok := req.GetArguments()["secretName"].(string)
if !ok || secretName == "" {
return to.ErrorResult(fmt.Errorf("secretName is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.DeleteRepoActionSecret(owner, repo, secretName)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete repo action secret err: %v", err))
}
return to.TextResult(map[string]any{"message": "secret deleted", "status": resp.StatusCode})
}
func ListOrgActionSecretsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListOrgActionSecretsFn")
org, ok := req.GetArguments()["org"].(string)
if !ok || org == "" {
return to.ErrorResult(fmt.Errorf("org is required"))
}
page, _ := req.GetArguments()["page"].(float64)
if page <= 0 {
page = 1
}
pageSize, _ := req.GetArguments()["pageSize"].(float64)
if pageSize <= 0 {
pageSize = 100
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
secrets, _, err := client.ListOrgActionSecret(org, gitea_sdk.ListOrgActionSecretOption{
ListOptions: gitea_sdk.ListOptions{Page: int(page), PageSize: int(pageSize)},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list org action secrets err: %v", err))
}
metas := make([]secretMeta, 0, len(secrets))
for _, s := range secrets {
if s == nil {
continue
}
metas = append(metas, secretMeta{
Name: s.Name,
Description: s.Description,
CreatedAt: s.Created,
})
}
return to.TextResult(metas)
}
func UpsertOrgActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called UpsertOrgActionSecretFn")
org, ok := req.GetArguments()["org"].(string)
if !ok || org == "" {
return to.ErrorResult(fmt.Errorf("org is required"))
}
name, ok := req.GetArguments()["name"].(string)
if !ok || name == "" {
return to.ErrorResult(fmt.Errorf("name is required"))
}
data, ok := req.GetArguments()["data"].(string)
if !ok || data == "" {
return to.ErrorResult(fmt.Errorf("data is required"))
}
description, _ := req.GetArguments()["description"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.CreateOrgActionSecret(org, gitea_sdk.CreateSecretOption{
Name: name,
Data: data,
Description: description,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("upsert org action secret err: %v", err))
}
return to.TextResult(map[string]any{"message": "secret upserted", "status": resp.StatusCode})
}
func DeleteOrgActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteOrgActionSecretFn")
org, ok := req.GetArguments()["org"].(string)
if !ok || org == "" {
return to.ErrorResult(fmt.Errorf("org is required"))
}
secretName, ok := req.GetArguments()["secretName"].(string)
if !ok || secretName == "" {
return to.ErrorResult(fmt.Errorf("secretName is required"))
}
escapedOrg := url.PathEscape(org)
escapedSecret := url.PathEscape(secretName)
_, err := gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("orgs/%s/actions/secrets/%s", escapedOrg, escapedSecret), nil, nil, nil)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete org action secret err: %v", err))
}
return to.TextResult(map[string]any{"message": "secret deleted"})
}

View File

@@ -1,92 +0,0 @@
package actions
func pick(m map[string]any, keys ...string) map[string]any {
out := make(map[string]any, len(keys))
for _, k := range keys {
if v, ok := m[k]; ok {
out[k] = v
}
}
return out
}
func slimPaginated(raw any, itemFn func(map[string]any) map[string]any) any {
m, ok := raw.(map[string]any)
if !ok {
return raw
}
result := make(map[string]any)
if tc, ok := m["total_count"]; ok {
result["total_count"] = tc
}
for key, val := range m {
if key == "total_count" {
continue
}
arr, ok := val.([]any)
if !ok {
continue
}
slimmed := make([]any, 0, len(arr))
for _, item := range arr {
if im, ok := item.(map[string]any); ok {
slimmed = append(slimmed, itemFn(im))
}
}
result[key] = slimmed
break
}
return result
}
func slimRun(m map[string]any) map[string]any {
return pick(m, "id", "name", "head_branch", "head_sha", "run_number",
"event", "status", "conclusion", "workflow_id",
"html_url", "created_at", "updated_at")
}
func slimJob(m map[string]any) map[string]any {
out := pick(m, "id", "run_id", "name", "workflow_name",
"status", "conclusion", "html_url",
"started_at", "completed_at")
if steps, ok := m["steps"].([]any); ok {
slim := make([]any, 0, len(steps))
for _, s := range steps {
if sm, ok := s.(map[string]any); ok {
slim = append(slim, pick(sm, "name", "number", "status", "conclusion"))
}
}
out["steps"] = slim
}
return out
}
func slimWorkflow(m map[string]any) map[string]any {
return pick(m, "id", "name", "path", "state", "html_url", "created_at", "updated_at")
}
func slimActionRun(raw any) any {
if m, ok := raw.(map[string]any); ok {
return slimRun(m)
}
return raw
}
func slimActionRuns(raw any) any {
return slimPaginated(raw, slimRun)
}
func slimActionJobs(raw any) any {
return slimPaginated(raw, slimJob)
}
func slimActionWorkflow(raw any) any {
if m, ok := raw.(map[string]any); ok {
return slimWorkflow(m)
}
return raw
}
func slimActionWorkflows(raw any) any {
return slimPaginated(raw, slimWorkflow)
}

View File

@@ -0,0 +1,402 @@
package actions
import (
"context"
"fmt"
"net/url"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
ListRepoActionVariablesToolName = "list_repo_action_variables"
GetRepoActionVariableToolName = "get_repo_action_variable"
CreateRepoActionVariableToolName = "create_repo_action_variable"
UpdateRepoActionVariableToolName = "update_repo_action_variable"
DeleteRepoActionVariableToolName = "delete_repo_action_variable"
ListOrgActionVariablesToolName = "list_org_action_variables"
GetOrgActionVariableToolName = "get_org_action_variable"
CreateOrgActionVariableToolName = "create_org_action_variable"
UpdateOrgActionVariableToolName = "update_org_action_variable"
DeleteOrgActionVariableToolName = "delete_org_action_variable"
)
var (
ListRepoActionVariablesTool = mcp.NewTool(
ListRepoActionVariablesToolName,
mcp.WithDescription("List repository Actions variables"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(100), mcp.Min(1)),
)
GetRepoActionVariableTool = mcp.NewTool(
GetRepoActionVariableToolName,
mcp.WithDescription("Get a repository Actions variable by name"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("name", mcp.Required(), mcp.Description("variable name")),
)
CreateRepoActionVariableTool = mcp.NewTool(
CreateRepoActionVariableToolName,
mcp.WithDescription("Create a repository Actions variable"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("name", mcp.Required(), mcp.Description("variable name")),
mcp.WithString("value", mcp.Required(), mcp.Description("variable value")),
)
UpdateRepoActionVariableTool = mcp.NewTool(
UpdateRepoActionVariableToolName,
mcp.WithDescription("Update a repository Actions variable"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("name", mcp.Required(), mcp.Description("variable name")),
mcp.WithString("value", mcp.Required(), mcp.Description("new variable value")),
)
DeleteRepoActionVariableTool = mcp.NewTool(
DeleteRepoActionVariableToolName,
mcp.WithDescription("Delete a repository Actions variable"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("name", mcp.Required(), mcp.Description("variable name")),
)
ListOrgActionVariablesTool = mcp.NewTool(
ListOrgActionVariablesToolName,
mcp.WithDescription("List organization Actions variables"),
mcp.WithString("org", mcp.Required(), mcp.Description("organization name")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(100), mcp.Min(1)),
)
GetOrgActionVariableTool = mcp.NewTool(
GetOrgActionVariableToolName,
mcp.WithDescription("Get an organization Actions variable by name"),
mcp.WithString("org", mcp.Required(), mcp.Description("organization name")),
mcp.WithString("name", mcp.Required(), mcp.Description("variable name")),
)
CreateOrgActionVariableTool = mcp.NewTool(
CreateOrgActionVariableToolName,
mcp.WithDescription("Create an organization Actions variable"),
mcp.WithString("org", mcp.Required(), mcp.Description("organization name")),
mcp.WithString("name", mcp.Required(), mcp.Description("variable name")),
mcp.WithString("value", mcp.Required(), mcp.Description("variable value")),
mcp.WithString("description", mcp.Description("variable description")),
)
UpdateOrgActionVariableTool = mcp.NewTool(
UpdateOrgActionVariableToolName,
mcp.WithDescription("Update an organization Actions variable"),
mcp.WithString("org", mcp.Required(), mcp.Description("organization name")),
mcp.WithString("name", mcp.Required(), mcp.Description("variable name")),
mcp.WithString("value", mcp.Required(), mcp.Description("new variable value")),
mcp.WithString("description", mcp.Description("new variable description")),
)
DeleteOrgActionVariableTool = mcp.NewTool(
DeleteOrgActionVariableToolName,
mcp.WithDescription("Delete an organization Actions variable"),
mcp.WithString("org", mcp.Required(), mcp.Description("organization name")),
mcp.WithString("name", mcp.Required(), mcp.Description("variable name")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{Tool: ListRepoActionVariablesTool, Handler: ListRepoActionVariablesFn})
Tool.RegisterRead(server.ServerTool{Tool: GetRepoActionVariableTool, Handler: GetRepoActionVariableFn})
Tool.RegisterWrite(server.ServerTool{Tool: CreateRepoActionVariableTool, Handler: CreateRepoActionVariableFn})
Tool.RegisterWrite(server.ServerTool{Tool: UpdateRepoActionVariableTool, Handler: UpdateRepoActionVariableFn})
Tool.RegisterWrite(server.ServerTool{Tool: DeleteRepoActionVariableTool, Handler: DeleteRepoActionVariableFn})
Tool.RegisterRead(server.ServerTool{Tool: ListOrgActionVariablesTool, Handler: ListOrgActionVariablesFn})
Tool.RegisterRead(server.ServerTool{Tool: GetOrgActionVariableTool, Handler: GetOrgActionVariableFn})
Tool.RegisterWrite(server.ServerTool{Tool: CreateOrgActionVariableTool, Handler: CreateOrgActionVariableFn})
Tool.RegisterWrite(server.ServerTool{Tool: UpdateOrgActionVariableTool, Handler: UpdateOrgActionVariableFn})
Tool.RegisterWrite(server.ServerTool{Tool: DeleteOrgActionVariableTool, Handler: DeleteOrgActionVariableFn})
}
func ListRepoActionVariablesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListRepoActionVariablesFn")
owner, ok := req.GetArguments()["owner"].(string)
if !ok || owner == "" {
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok || repo == "" {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
page, _ := req.GetArguments()["page"].(float64)
if page <= 0 {
page = 1
}
pageSize, _ := req.GetArguments()["pageSize"].(float64)
if pageSize <= 0 {
pageSize = 100
}
query := url.Values{}
query.Set("page", fmt.Sprintf("%d", int(page)))
query.Set("limit", fmt.Sprintf("%d", int(pageSize)))
var result any
_, err := gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/actions/variables", url.PathEscape(owner), url.PathEscape(repo)), query, nil, &result)
if err != nil {
return to.ErrorResult(fmt.Errorf("list repo action variables err: %v", err))
}
return to.TextResult(result)
}
func GetRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetRepoActionVariableFn")
owner, ok := req.GetArguments()["owner"].(string)
if !ok || owner == "" {
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok || repo == "" {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
name, ok := req.GetArguments()["name"].(string)
if !ok || name == "" {
return to.ErrorResult(fmt.Errorf("name is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
variable, _, err := client.GetRepoActionVariable(owner, repo, name)
if err != nil {
return to.ErrorResult(fmt.Errorf("get repo action variable err: %v", err))
}
return to.TextResult(variable)
}
func CreateRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateRepoActionVariableFn")
owner, ok := req.GetArguments()["owner"].(string)
if !ok || owner == "" {
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok || repo == "" {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
name, ok := req.GetArguments()["name"].(string)
if !ok || name == "" {
return to.ErrorResult(fmt.Errorf("name is required"))
}
value, ok := req.GetArguments()["value"].(string)
if !ok || value == "" {
return to.ErrorResult(fmt.Errorf("value is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.CreateRepoActionVariable(owner, repo, name, value)
if err != nil {
return to.ErrorResult(fmt.Errorf("create repo action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable created", "status": resp.StatusCode})
}
func UpdateRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called UpdateRepoActionVariableFn")
owner, ok := req.GetArguments()["owner"].(string)
if !ok || owner == "" {
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok || repo == "" {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
name, ok := req.GetArguments()["name"].(string)
if !ok || name == "" {
return to.ErrorResult(fmt.Errorf("name is required"))
}
value, ok := req.GetArguments()["value"].(string)
if !ok || value == "" {
return to.ErrorResult(fmt.Errorf("value is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.UpdateRepoActionVariable(owner, repo, name, value)
if err != nil {
return to.ErrorResult(fmt.Errorf("update repo action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable updated", "status": resp.StatusCode})
}
func DeleteRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteRepoActionVariableFn")
owner, ok := req.GetArguments()["owner"].(string)
if !ok || owner == "" {
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok || repo == "" {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
name, ok := req.GetArguments()["name"].(string)
if !ok || name == "" {
return to.ErrorResult(fmt.Errorf("name is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.DeleteRepoActionVariable(owner, repo, name)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete repo action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable deleted", "status": resp.StatusCode})
}
func ListOrgActionVariablesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListOrgActionVariablesFn")
org, ok := req.GetArguments()["org"].(string)
if !ok || org == "" {
return to.ErrorResult(fmt.Errorf("org is required"))
}
page, _ := req.GetArguments()["page"].(float64)
if page <= 0 {
page = 1
}
pageSize, _ := req.GetArguments()["pageSize"].(float64)
if pageSize <= 0 {
pageSize = 100
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
variables, _, err := client.ListOrgActionVariable(org, gitea_sdk.ListOrgActionVariableOption{
ListOptions: gitea_sdk.ListOptions{Page: int(page), PageSize: int(pageSize)},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list org action variables err: %v", err))
}
return to.TextResult(variables)
}
func GetOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetOrgActionVariableFn")
org, ok := req.GetArguments()["org"].(string)
if !ok || org == "" {
return to.ErrorResult(fmt.Errorf("org is required"))
}
name, ok := req.GetArguments()["name"].(string)
if !ok || name == "" {
return to.ErrorResult(fmt.Errorf("name is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
variable, _, err := client.GetOrgActionVariable(org, name)
if err != nil {
return to.ErrorResult(fmt.Errorf("get org action variable err: %v", err))
}
return to.TextResult(variable)
}
func CreateOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateOrgActionVariableFn")
org, ok := req.GetArguments()["org"].(string)
if !ok || org == "" {
return to.ErrorResult(fmt.Errorf("org is required"))
}
name, ok := req.GetArguments()["name"].(string)
if !ok || name == "" {
return to.ErrorResult(fmt.Errorf("name is required"))
}
value, ok := req.GetArguments()["value"].(string)
if !ok || value == "" {
return to.ErrorResult(fmt.Errorf("value is required"))
}
description, _ := req.GetArguments()["description"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.CreateOrgActionVariable(org, gitea_sdk.CreateOrgActionVariableOption{
Name: name,
Value: value,
Description: description,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("create org action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable created", "status": resp.StatusCode})
}
func UpdateOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called UpdateOrgActionVariableFn")
org, ok := req.GetArguments()["org"].(string)
if !ok || org == "" {
return to.ErrorResult(fmt.Errorf("org is required"))
}
name, ok := req.GetArguments()["name"].(string)
if !ok || name == "" {
return to.ErrorResult(fmt.Errorf("name is required"))
}
value, ok := req.GetArguments()["value"].(string)
if !ok || value == "" {
return to.ErrorResult(fmt.Errorf("value is required"))
}
description, _ := req.GetArguments()["description"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.UpdateOrgActionVariable(org, name, gitea_sdk.UpdateOrgActionVariableOption{
Value: value,
Description: description,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("update org action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable updated", "status": resp.StatusCode})
}
func DeleteOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteOrgActionVariableFn")
org, ok := req.GetArguments()["org"].(string)
if !ok || org == "" {
return to.ErrorResult(fmt.Errorf("org is required"))
}
name, ok := req.GetArguments()["name"].(string)
if !ok || name == "" {
return to.ErrorResult(fmt.Errorf("name is required"))
}
_, err := gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("orgs/%s/actions/variables/%s", url.PathEscape(org), url.PathEscape(name)), nil, nil, nil)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete org action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable deleted"})
}

View File

@@ -7,6 +7,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params" "gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/ptr"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
@@ -18,12 +19,24 @@ import (
var Tool = tool.New() var Tool = tool.New()
const ( const (
ListRepoIssuesToolName = "list_issues" GetIssueByIndexToolName = "get_issue_by_index"
IssueReadToolName = "issue_read" ListRepoIssuesToolName = "list_repo_issues"
IssueWriteToolName = "issue_write" CreateIssueToolName = "create_issue"
CreateIssueCommentToolName = "create_issue_comment"
EditIssueToolName = "edit_issue"
EditIssueCommentToolName = "edit_issue_comment"
GetIssueCommentsByIndexToolName = "get_issue_comments_by_index"
) )
var ( var (
GetIssueByIndexTool = mcp.NewTool(
GetIssueByIndexToolName,
mcp.WithDescription("get issue by index"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("repository issue index")),
)
ListRepoIssuesTool = mcp.NewTool( ListRepoIssuesTool = mcp.NewTool(
ListRepoIssuesToolName, ListRepoIssuesToolName,
mcp.WithDescription("List repository issues"), mcp.WithDescription("List repository issues"),
@@ -31,106 +44,98 @@ var (
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("state", mcp.Description("issue state"), mcp.DefaultString("all")), mcp.WithString("state", mcp.Description("issue state"), mcp.DefaultString("all")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)), mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)), mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(100)),
) )
IssueReadTool = mcp.NewTool( CreateIssueTool = mcp.NewTool(
IssueReadToolName, CreateIssueToolName,
mcp.WithDescription("Get information about a specific issue. Use method 'get' for issue details, 'get_comments' for issue comments, 'get_labels' for issue labels."), mcp.WithDescription("create issue"),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "get_comments", "get_labels")), mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("title", mcp.Required(), mcp.Description("issue title")),
mcp.WithString("body", mcp.Required(), mcp.Description("issue body")),
)
CreateIssueCommentTool = mcp.NewTool(
CreateIssueCommentToolName,
mcp.WithDescription("create issue comment"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("repository issue index")), mcp.WithNumber("index", mcp.Required(), mcp.Description("repository issue index")),
mcp.WithString("body", mcp.Required(), mcp.Description("issue comment body")),
) )
IssueWriteTool = mcp.NewTool( EditIssueTool = mcp.NewTool(
IssueWriteToolName, EditIssueToolName,
mcp.WithDescription("Create or update issues and comments, manage labels. Use method 'create' to create an issue, 'update' to edit, 'add_comment'/'edit_comment' for comments, 'add_labels'/'remove_label'/'replace_labels'/'clear_labels' for label management."), mcp.WithDescription("edit issue"),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "update", "add_comment", "edit_comment", "add_labels", "remove_label", "replace_labels", "clear_labels")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Description("issue index (required for all methods except 'create')")), mcp.WithNumber("index", mcp.Required(), mcp.Description("repository issue index")),
mcp.WithString("title", mcp.Description("issue title (required for 'create')")), mcp.WithString("title", mcp.Description("issue title"), mcp.DefaultString("")),
mcp.WithString("body", mcp.Description("issue/comment body (required for 'create', 'add_comment', 'edit_comment')")), mcp.WithString("body", mcp.Description("issue body content")),
mcp.WithArray("assignees", mcp.Description("usernames to assign (for 'create', 'update')"), mcp.Items(map[string]any{"type": "string"})), mcp.WithArray("assignees", mcp.Description("usernames to assign to this issue"), mcp.Items(map[string]interface{}{"type": "string"})),
mcp.WithNumber("milestone", mcp.Description("milestone number (for 'create', 'update')")), mcp.WithNumber("milestone", mcp.Description("milestone number")),
mcp.WithString("state", mcp.Description("issue state, one of open, closed, all (for 'update')")), mcp.WithString("state", mcp.Description("issue state, one of open, closed, all")),
mcp.WithNumber("commentID", mcp.Description("id of issue comment (required for 'edit_comment')")), )
mcp.WithArray("labels", mcp.Description("array of label IDs (for 'add_labels', 'replace_labels')"), mcp.Items(map[string]any{"type": "number"})),
mcp.WithNumber("label_id", mcp.Description("label ID to remove (required for 'remove_label')")), EditIssueCommentTool = mcp.NewTool(
EditIssueCommentToolName,
mcp.WithDescription("edit issue comment"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("commentID", mcp.Required(), mcp.Description("id of issue comment")),
mcp.WithString("body", mcp.Required(), mcp.Description("issue comment body")),
)
GetIssueCommentsByIndexTool = mcp.NewTool(
GetIssueCommentsByIndexToolName,
mcp.WithDescription("get issue comment by index"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("repository issue index")),
) )
) )
func init() { func init() {
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(server.ServerTool{
Tool: ListRepoIssuesTool, Tool: GetIssueByIndexTool,
Handler: listRepoIssuesFn, Handler: GetIssueByIndexFn,
}) })
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(server.ServerTool{
Tool: IssueReadTool, Tool: ListRepoIssuesTool,
Handler: issueReadFn, Handler: ListRepoIssuesFn,
}) })
Tool.RegisterWrite(server.ServerTool{ Tool.RegisterWrite(server.ServerTool{
Tool: IssueWriteTool, Tool: CreateIssueTool,
Handler: issueWriteFn, Handler: CreateIssueFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: CreateIssueCommentTool,
Handler: CreateIssueCommentFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: EditIssueTool,
Handler: EditIssueFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: EditIssueCommentTool,
Handler: EditIssueCommentFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetIssueCommentsByIndexTool,
Handler: GetIssueCommentsByIndexFn,
}) })
} }
func issueReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetIssueByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments() log.Debugf("Called GetIssueByIndexFn")
method, err := params.GetString(args, "method") owner, ok := req.GetArguments()["owner"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
switch method { repo, ok := req.GetArguments()["repo"].(string)
case "get": if !ok {
return getIssueByIndexFn(ctx, req) return to.ErrorResult(fmt.Errorf("repo is required"))
case "get_comments":
return getIssueCommentsByIndexFn(ctx, req)
case "get_labels":
return getIssueLabelsFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func issueWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
method, err := params.GetString(args, "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "create":
return createIssueFn(ctx, req)
case "update":
return editIssueFn(ctx, req)
case "add_comment":
return createIssueCommentFn(ctx, req)
case "edit_comment":
return editIssueCommentFn(ctx, req)
case "add_labels":
return addIssueLabelsFn(ctx, req)
case "remove_label":
return removeIssueLabelFn(ctx, req)
case "replace_labels":
return replaceIssueLabelsFn(ctx, req)
case "clear_labels":
return clearIssueLabelsFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func getIssueByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getIssueByIndexFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "index") index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil { if err != nil {
@@ -145,29 +150,36 @@ func getIssueByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
return to.ErrorResult(fmt.Errorf("get %v/%v/issue/%v err: %v", owner, repo, index, err)) return to.ErrorResult(fmt.Errorf("get %v/%v/issue/%v err: %v", owner, repo, index, err))
} }
return to.TextResult(slimIssue(issue)) return to.TextResult(issue)
} }
func listRepoIssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListRepoIssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListIssuesFn") log.Debugf("Called ListIssuesFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, ok := req.GetArguments()["owner"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
state, ok := req.GetArguments()["state"].(string) state, ok := req.GetArguments()["state"].(string)
if !ok { if !ok {
state = "all" state = "all"
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, ok := req.GetArguments()["page"].(float64)
if !ok {
page = 1
}
pageSize, ok := req.GetArguments()["pageSize"].(float64)
if !ok {
pageSize = 100
}
opt := gitea_sdk.ListIssueOption{ opt := gitea_sdk.ListIssueOption{
State: gitea_sdk.StateType(state), State: gitea_sdk.StateType(state),
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: page, Page: int(page),
PageSize: pageSize, PageSize: int(pageSize),
}, },
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -178,66 +190,59 @@ func listRepoIssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/issues err: %v", owner, repo, err)) return to.ErrorResult(fmt.Errorf("get %v/%v/issues err: %v", owner, repo, err))
} }
return to.TextResult(slimIssues(issues)) return to.TextResult(issues)
} }
func createIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func CreateIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createIssueFn") log.Debugf("Called CreateIssueFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, ok := req.GetArguments()["owner"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
title, err := params.GetString(req.GetArguments(), "title") title, ok := req.GetArguments()["title"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("title is required"))
} }
body, err := params.GetString(req.GetArguments(), "body") body, ok := req.GetArguments()["body"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("body is required"))
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
opt := gitea_sdk.CreateIssueOption{ issue, _, err := client.CreateIssue(owner, repo, gitea_sdk.CreateIssueOption{
Title: title, Title: title,
Body: body, Body: body,
} })
opt.Assignees = params.GetStringSlice(req.GetArguments(), "assignees")
if val, exists := req.GetArguments()["milestone"]; exists {
if milestone, ok := params.ToInt64(val); ok {
opt.Milestone = milestone
}
}
issue, _, err := client.CreateIssue(owner, repo, opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/%v/issue err: %v", owner, repo, err)) return to.ErrorResult(fmt.Errorf("create %v/%v/issue err: %v", owner, repo, err))
} }
return to.TextResult(slimIssue(issue)) return to.TextResult(issue)
} }
func createIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func CreateIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createIssueCommentFn") log.Debugf("Called CreateIssueCommentFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, ok := req.GetArguments()["owner"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, err := params.GetIndex(req.GetArguments(), "index") index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
body, err := params.GetString(req.GetArguments(), "body") body, ok := req.GetArguments()["body"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("body is required"))
} }
opt := gitea_sdk.CreateIssueCommentOption{ opt := gitea_sdk.CreateIssueCommentOption{
Body: body, Body: body,
@@ -251,18 +256,18 @@ func createIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
return to.ErrorResult(fmt.Errorf("create %v/%v/issue/%v/comment err: %v", owner, repo, index, err)) return to.ErrorResult(fmt.Errorf("create %v/%v/issue/%v/comment err: %v", owner, repo, index, err))
} }
return to.TextResult(slimComment(issueComment)) return to.TextResult(issueComment)
} }
func editIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func EditIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editIssueFn") log.Debugf("Called EditIssueFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, ok := req.GetArguments()["owner"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, err := params.GetIndex(req.GetArguments(), "index") index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil { if err != nil {
@@ -277,17 +282,26 @@ func editIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRes
} }
body, ok := req.GetArguments()["body"].(string) body, ok := req.GetArguments()["body"].(string)
if ok { if ok {
opt.Body = new(body) opt.Body = ptr.To(body)
} }
opt.Assignees = params.GetStringSlice(req.GetArguments(), "assignees") var assignees []string
if val, exists := req.GetArguments()["milestone"]; exists { if assigneesArg, exists := req.GetArguments()["assignees"]; exists {
if milestone, ok := params.ToInt64(val); ok { if assigneesSlice, ok := assigneesArg.([]interface{}); ok {
opt.Milestone = new(milestone) for _, assignee := range assigneesSlice {
if assigneeStr, ok := assignee.(string); ok {
assignees = append(assignees, assigneeStr)
} }
} }
}
}
opt.Assignees = assignees
milestone, ok := req.GetArguments()["milestone"].(float64)
if ok {
opt.Milestone = ptr.To(int64(milestone))
}
state, ok := req.GetArguments()["state"].(string) state, ok := req.GetArguments()["state"].(string)
if ok { if ok {
opt.State = new(gitea_sdk.StateType(state)) opt.State = ptr.To(gitea_sdk.StateType(state))
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -299,26 +313,26 @@ func editIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRes
return to.ErrorResult(fmt.Errorf("edit %v/%v/issue/%v err: %v", owner, repo, index, err)) return to.ErrorResult(fmt.Errorf("edit %v/%v/issue/%v err: %v", owner, repo, index, err))
} }
return to.TextResult(slimIssue(issue)) return to.TextResult(issue)
} }
func editIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func EditIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editIssueCommentFn") log.Debugf("Called EditIssueCommentFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, ok := req.GetArguments()["owner"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
commentID, err := params.GetIndex(req.GetArguments(), "commentID") commentID, ok := req.GetArguments()["commentID"].(float64)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("comment ID is required"))
} }
body, err := params.GetString(req.GetArguments(), "body") body, ok := req.GetArguments()["body"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("body is required"))
} }
opt := gitea_sdk.EditIssueCommentOption{ opt := gitea_sdk.EditIssueCommentOption{
Body: body, Body: body,
@@ -327,23 +341,23 @@ func editIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
issueComment, _, err := client.EditIssueComment(owner, repo, commentID, opt) issueComment, _, err := client.EditIssueComment(owner, repo, int64(commentID), opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("edit %v/%v/issues/comments/%v err: %v", owner, repo, commentID, err)) return to.ErrorResult(fmt.Errorf("edit %v/%v/issues/comments/%v err: %v", owner, repo, int64(commentID), err))
} }
return to.TextResult(slimComment(issueComment)) return to.TextResult(issueComment)
} }
func getIssueCommentsByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetIssueCommentsByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getIssueCommentsByIndexFn") log.Debugf("Called GetIssueCommentsByIndexFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, ok := req.GetArguments()["owner"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, err := params.GetIndex(req.GetArguments(), "index") index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil { if err != nil {
@@ -359,149 +373,5 @@ func getIssueCommentsByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*m
return to.ErrorResult(fmt.Errorf("get %v/%v/issues/%v/comments err: %v", owner, repo, index, err)) return to.ErrorResult(fmt.Errorf("get %v/%v/issues/%v/comments err: %v", owner, repo, index, err))
} }
return to.TextResult(slimComments(issue)) return to.TextResult(issue)
}
func getIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getIssueLabelsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
labels, _, err := client.GetIssueLabels(owner, repo, index, gitea_sdk.ListLabelsOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/issues/%v/labels err: %v", owner, repo, index, err))
}
return to.TextResult(slimLabels(labels))
}
// Issue label operations (moved from label package)
func addIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called addIssueLabelsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil {
return to.ErrorResult(err)
}
labels, err := params.GetInt64Slice(req.GetArguments(), "labels")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
issueLabels, _, err := client.AddIssueLabels(owner, repo, index, gitea_sdk.IssueLabelsOption{Labels: labels})
if err != nil {
return to.ErrorResult(fmt.Errorf("add labels to %v/%v/issue/%v err: %v", owner, repo, index, err))
}
return to.TextResult(slimLabels(issueLabels))
}
func replaceIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called replaceIssueLabelsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil {
return to.ErrorResult(err)
}
labels, err := params.GetInt64Slice(req.GetArguments(), "labels")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
issueLabels, _, err := client.ReplaceIssueLabels(owner, repo, index, gitea_sdk.IssueLabelsOption{Labels: labels})
if err != nil {
return to.ErrorResult(fmt.Errorf("replace labels on %v/%v/issue/%v err: %v", owner, repo, index, err))
}
return to.TextResult(slimLabels(issueLabels))
}
func clearIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called clearIssueLabelsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.ClearIssueLabels(owner, repo, index)
if err != nil {
return to.ErrorResult(fmt.Errorf("clear labels on %v/%v/issue/%v err: %v", owner, repo, index, err))
}
return to.TextResult("Labels cleared successfully")
}
func removeIssueLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called removeIssueLabelFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil {
return to.ErrorResult(err)
}
labelID, err := params.GetIndex(req.GetArguments(), "label_id")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.DeleteIssueLabel(owner, repo, index, labelID)
if err != nil {
return to.ErrorResult(fmt.Errorf("remove label %v from %v/%v/issue/%v err: %v", labelID, owner, repo, index, err))
}
return to.TextResult("Label removed successfully")
} }

View File

@@ -1,133 +0,0 @@
package issue
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func userLogin(u *gitea_sdk.User) string {
if u == nil {
return ""
}
return u.UserName
}
func userLogins(users []*gitea_sdk.User) []string {
if len(users) == 0 {
return nil
}
out := make([]string, 0, len(users))
for _, u := range users {
if u != nil {
out = append(out, u.UserName)
}
}
return out
}
func labelNames(labels []*gitea_sdk.Label) []string {
if len(labels) == 0 {
return nil
}
out := make([]string, 0, len(labels))
for _, l := range labels {
if l != nil {
out = append(out, l.Name)
}
}
return out
}
func slimIssue(i *gitea_sdk.Issue) map[string]any {
if i == nil {
return nil
}
m := map[string]any{
"number": i.Index,
"title": i.Title,
"body": i.Body,
"state": i.State,
"html_url": i.HTMLURL,
"user": userLogin(i.Poster),
"labels": labelNames(i.Labels),
"comments": i.Comments,
"created_at": i.Created,
"updated_at": i.Updated,
"closed_at": i.Closed,
}
if len(i.Assignees) > 0 {
m["assignees"] = userLogins(i.Assignees)
}
if i.Milestone != nil {
m["milestone"] = map[string]any{
"id": i.Milestone.ID,
"title": i.Milestone.Title,
}
}
if i.PullRequest != nil {
m["is_pull"] = true
}
return m
}
func slimIssues(issues []*gitea_sdk.Issue) []map[string]any {
out := make([]map[string]any, 0, len(issues))
for _, i := range issues {
if i == nil {
continue
}
m := map[string]any{
"number": i.Index,
"title": i.Title,
"state": i.State,
"html_url": i.HTMLURL,
"user": userLogin(i.Poster),
"comments": i.Comments,
"created_at": i.Created,
"updated_at": i.Updated,
}
if len(i.Labels) > 0 {
m["labels"] = labelNames(i.Labels)
}
out = append(out, m)
}
return out
}
func slimComment(c *gitea_sdk.Comment) map[string]any {
if c == nil {
return nil
}
return map[string]any{
"id": c.ID,
"body": c.Body,
"user": userLogin(c.Poster),
"html_url": c.HTMLURL,
"created_at": c.Created,
"updated_at": c.Updated,
}
}
func slimComments(comments []*gitea_sdk.Comment) []map[string]any {
out := make([]map[string]any, 0, len(comments))
for _, c := range comments {
out = append(out, slimComment(c))
}
return out
}
func slimLabels(labels []*gitea_sdk.Label) []map[string]any {
out := make([]map[string]any, 0, len(labels))
for _, l := range labels {
if l == nil {
continue
}
out = append(out, map[string]any{
"id": l.ID,
"name": l.Name,
"color": l.Color,
"description": l.Description,
"exclusive": l.Exclusive,
})
}
return out
}

View File

@@ -1,69 +0,0 @@
package issue
import (
"testing"
gitea_sdk "code.gitea.io/sdk/gitea"
)
func TestSlimIssue(t *testing.T) {
i := &gitea_sdk.Issue{
Index: 42,
Title: "Bug report",
Body: "Something is broken",
State: "open",
HTMLURL: "https://gitea.com/org/repo/issues/42",
Poster: &gitea_sdk.User{UserName: "alice"},
Labels: []*gitea_sdk.Label{{Name: "bug"}},
Milestone: &gitea_sdk.Milestone{
ID: 1,
Title: "v1.0",
},
PullRequest: &gitea_sdk.PullRequestMeta{HasMerged: false},
}
m := slimIssue(i)
if m["number"] != int64(42) {
t.Errorf("expected number 42, got %v", m["number"])
}
if m["body"] != "Something is broken" {
t.Errorf("expected body, got %v", m["body"])
}
if m["is_pull"] != true {
t.Error("expected is_pull true for issue with PullRequest")
}
ms := m["milestone"].(map[string]any)
if ms["title"] != "v1.0" {
t.Errorf("expected milestone title v1.0, got %v", ms["title"])
}
}
func TestSlimIssues_ListIsSlimmer(t *testing.T) {
i := &gitea_sdk.Issue{
Index: 1,
Title: "Issue",
State: "open",
Body: "Full body",
Poster: &gitea_sdk.User{UserName: "alice"},
Labels: []*gitea_sdk.Label{{Name: "enhancement"}},
}
single := slimIssue(i)
list := slimIssues([]*gitea_sdk.Issue{i})
// Single has body, list does not
if _, ok := single["body"]; !ok {
t.Error("single issue should have body")
}
if _, ok := list[0]["body"]; ok {
t.Error("list issue should not have body")
}
}
func TestSlimIssues_Nil(t *testing.T) {
if r := slimIssues(nil); len(r) != 0 {
t.Errorf("expected empty slice, got %v", r)
}
}

View File

@@ -7,6 +7,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params" "gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/ptr"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
@@ -18,107 +19,219 @@ import (
var Tool = tool.New() var Tool = tool.New()
const ( const (
LabelReadToolName = "label_read" ListRepoLabelsToolName = "list_repo_labels"
LabelWriteToolName = "label_write" GetRepoLabelToolName = "get_repo_label"
CreateRepoLabelToolName = "create_repo_label"
EditRepoLabelToolName = "edit_repo_label"
DeleteRepoLabelToolName = "delete_repo_label"
AddIssueLabelsToolName = "add_issue_labels"
ReplaceIssueLabelsToolName = "replace_issue_labels"
ClearIssueLabelsToolName = "clear_issue_labels"
RemoveIssueLabelToolName = "remove_issue_label"
ListOrgLabelsToolName = "list_org_labels"
CreateOrgLabelToolName = "create_org_label"
EditOrgLabelToolName = "edit_org_label"
DeleteOrgLabelToolName = "delete_org_label"
) )
var ( var (
LabelReadTool = mcp.NewTool( ListRepoLabelsTool = mcp.NewTool(
LabelReadToolName, ListRepoLabelsToolName,
mcp.WithDescription("Read label information. Use method 'list_repo_labels' to list repository labels, 'get_repo_label' to get a specific repo label, 'list_org_labels' to list organization labels."), mcp.WithDescription("Lists all labels for a given repository"),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list_repo_labels", "get_repo_label", "list_org_labels")), mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("owner", mcp.Description("repository owner (required for repo methods)")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")),
mcp.WithString("org", mcp.Description("organization name (required for 'list_org')")),
mcp.WithNumber("id", mcp.Description("label ID (required for 'get_repo')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)), mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)), mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(100)),
) )
LabelWriteTool = mcp.NewTool( GetRepoLabelTool = mcp.NewTool(
LabelWriteToolName, GetRepoLabelToolName,
mcp.WithDescription("Create, edit, or delete labels for repositories or organizations."), mcp.WithDescription("Gets a single label by its ID for a repository"),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create_repo_label", "edit_repo_label", "delete_repo_label", "create_org_label", "edit_org_label", "delete_org_label")), mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("owner", mcp.Description("repository owner (required for repo methods)")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")), mcp.WithNumber("id", mcp.Required(), mcp.Description("label ID")),
mcp.WithString("org", mcp.Description("organization name (required for org methods)")),
mcp.WithNumber("id", mcp.Description("label ID (required for edit/delete methods)")),
mcp.WithString("name", mcp.Description("label name (required for create, optional for edit)")),
mcp.WithString("color", mcp.Description("label color hex code e.g. #RRGGBB (required for create, optional for edit)")),
mcp.WithString("description", mcp.Description("label description")),
mcp.WithBoolean("exclusive", mcp.Description("whether the label is exclusive (org labels only)")),
) )
CreateRepoLabelTool = mcp.NewTool(
CreateRepoLabelToolName,
mcp.WithDescription("Creates a new label for a repository"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("name", mcp.Required(), mcp.Description("label name")),
mcp.WithString("color", mcp.Required(), mcp.Description("label color (hex code, e.g., #RRGGBB)")),
mcp.WithString("description", mcp.Description("label description")),
)
EditRepoLabelTool = mcp.NewTool(
EditRepoLabelToolName,
mcp.WithDescription("Edits an existing label in a repository"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Required(), mcp.Description("label ID")),
mcp.WithString("name", mcp.Description("new label name")),
mcp.WithString("color", mcp.Description("new label color (hex code, e.g., #RRGGBB)")),
mcp.WithString("description", mcp.Description("new label description")),
)
DeleteRepoLabelTool = mcp.NewTool(
DeleteRepoLabelToolName,
mcp.WithDescription("Deletes a label from a repository"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Required(), mcp.Description("label ID")),
)
AddIssueLabelsTool = mcp.NewTool(
AddIssueLabelsToolName,
mcp.WithDescription("Adds one or more labels to an issue"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("issue index")),
mcp.WithArray("labels", mcp.Required(), mcp.Description("array of label IDs to add"), mcp.Items(map[string]interface{}{"type": "number"})),
)
ReplaceIssueLabelsTool = mcp.NewTool(
ReplaceIssueLabelsToolName,
mcp.WithDescription("Replaces all labels on an issue"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("issue index")),
mcp.WithArray("labels", mcp.Required(), mcp.Description("array of label IDs to replace with"), mcp.Items(map[string]interface{}{"type": "number"})),
)
ClearIssueLabelsTool = mcp.NewTool(
ClearIssueLabelsToolName,
mcp.WithDescription("Removes all labels from an issue"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("issue index")),
)
RemoveIssueLabelTool = mcp.NewTool(
RemoveIssueLabelToolName,
mcp.WithDescription("Removes a single label from an issue"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("issue index")),
mcp.WithNumber("label_id", mcp.Required(), mcp.Description("label ID to remove")),
)
ListOrgLabelsTool = mcp.NewTool(
ListOrgLabelsToolName,
mcp.WithDescription("Lists labels defined at organization level"),
mcp.WithString("org", mcp.Required(), mcp.Description("organization name")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(100)),
)
CreateOrgLabelTool = mcp.NewTool(
CreateOrgLabelToolName,
mcp.WithDescription("Creates a new label for an organization"),
mcp.WithString("org", mcp.Required(), mcp.Description("organization name")),
mcp.WithString("name", mcp.Required(), mcp.Description("label name")),
mcp.WithString("color", mcp.Required(), mcp.Description("label color (hex code, e.g., #RRGGBB)")),
mcp.WithString("description", mcp.Description("label description")),
mcp.WithBoolean("exclusive", mcp.Description("whether the label is exclusive"), mcp.DefaultBool(false)),
)
EditOrgLabelTool = mcp.NewTool(
EditOrgLabelToolName,
mcp.WithDescription("Edits an existing organization label"),
mcp.WithString("org", mcp.Required(), mcp.Description("organization name")),
mcp.WithNumber("id", mcp.Required(), mcp.Description("label ID")),
mcp.WithString("name", mcp.Description("new label name")),
mcp.WithString("color", mcp.Description("new label color (hex code, e.g., #RRGGBB)")),
mcp.WithString("description", mcp.Description("new label description")),
mcp.WithBoolean("exclusive", mcp.Description("whether the label is exclusive")),
)
DeleteOrgLabelTool = mcp.NewTool(
DeleteOrgLabelToolName,
mcp.WithDescription("Deletes an organization label by ID"),
mcp.WithString("org", mcp.Required(), mcp.Description("organization name")),
mcp.WithNumber("id", mcp.Required(), mcp.Description("label ID")),
)
) )
func init() { func init() {
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(server.ServerTool{
Tool: LabelReadTool, Tool: ListRepoLabelsTool,
Handler: labelReadFn, Handler: ListRepoLabelsFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetRepoLabelTool,
Handler: GetRepoLabelFn,
}) })
Tool.RegisterWrite(server.ServerTool{ Tool.RegisterWrite(server.ServerTool{
Tool: LabelWriteTool, Tool: CreateRepoLabelTool,
Handler: labelWriteFn, Handler: CreateRepoLabelFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: EditRepoLabelTool,
Handler: EditRepoLabelFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: DeleteRepoLabelTool,
Handler: DeleteRepoLabelFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: AddIssueLabelsTool,
Handler: AddIssueLabelsFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: ReplaceIssueLabelsTool,
Handler: ReplaceIssueLabelsFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: ClearIssueLabelsTool,
Handler: ClearIssueLabelsFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: RemoveIssueLabelTool,
Handler: RemoveIssueLabelFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: ListOrgLabelsTool,
Handler: ListOrgLabelsFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: CreateOrgLabelTool,
Handler: CreateOrgLabelFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: EditOrgLabelTool,
Handler: EditOrgLabelFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: DeleteOrgLabelTool,
Handler: DeleteOrgLabelFn,
}) })
} }
func labelReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListRepoLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments() log.Debugf("Called ListRepoLabelsFn")
method, err := params.GetString(args, "method") owner, ok := req.GetArguments()["owner"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
switch method { repo, ok := req.GetArguments()["repo"].(string)
case "list_repo_labels": if !ok {
return listRepoLabelsFn(ctx, req) return to.ErrorResult(fmt.Errorf("repo is required"))
case "get_repo_label":
return getRepoLabelFn(ctx, req)
case "list_org_labels":
return listOrgLabelsFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
page, ok := req.GetArguments()["page"].(float64)
if !ok {
page = 1
} }
pageSize, ok := req.GetArguments()["pageSize"].(float64)
func labelWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { if !ok {
args := req.GetArguments() pageSize = 100
method, err := params.GetString(args, "method")
if err != nil {
return to.ErrorResult(err)
} }
switch method {
case "create_repo_label":
return createRepoLabelFn(ctx, req)
case "edit_repo_label":
return editRepoLabelFn(ctx, req)
case "delete_repo_label":
return deleteRepoLabelFn(ctx, req)
case "create_org_label":
return createOrgLabelFn(ctx, req)
case "edit_org_label":
return editOrgLabelFn(ctx, req)
case "delete_org_label":
return deleteOrgLabelFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func listRepoLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoLabelsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.ListLabelsOptions{ opt := gitea_sdk.ListLabelsOptions{
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: page, Page: int(page),
PageSize: pageSize, PageSize: int(pageSize),
}, },
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -129,52 +242,52 @@ func listRepoLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("list %v/%v/labels err: %v", owner, repo, err)) return to.ErrorResult(fmt.Errorf("list %v/%v/labels err: %v", owner, repo, err))
} }
return to.TextResult(slimLabels(labels)) return to.TextResult(labels)
} }
func getRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getRepoLabelFn") log.Debugf("Called GetRepoLabelFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, ok := req.GetArguments()["owner"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
id, err := params.GetIndex(req.GetArguments(), "id") id, ok := req.GetArguments()["id"].(float64)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("label ID is required"))
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
label, _, err := client.GetRepoLabel(owner, repo, id) label, _, err := client.GetRepoLabel(owner, repo, int64(id))
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/label/%v err: %v", owner, repo, id, err)) return to.ErrorResult(fmt.Errorf("get %v/%v/label/%v err: %v", owner, repo, int64(id), err))
} }
return to.TextResult(slimLabel(label)) return to.TextResult(label)
} }
func createRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func CreateRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createRepoLabelFn") log.Debugf("Called CreateRepoLabelFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, ok := req.GetArguments()["owner"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
name, err := params.GetString(req.GetArguments(), "name") name, ok := req.GetArguments()["name"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("name is required"))
} }
color, err := params.GetString(req.GetArguments(), "color") color, ok := req.GetArguments()["color"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("color is required"))
} }
description, _ := req.GetArguments()["description"].(string) // Optional description, _ := req.GetArguments()["description"].(string) // Optional
@@ -192,84 +305,231 @@ func createRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/%v/label err: %v", owner, repo, err)) return to.ErrorResult(fmt.Errorf("create %v/%v/label err: %v", owner, repo, err))
} }
return to.TextResult(slimLabel(label)) return to.TextResult(label)
} }
func editRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func EditRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editRepoLabelFn") log.Debugf("Called EditRepoLabelFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, ok := req.GetArguments()["owner"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
id, err := params.GetIndex(req.GetArguments(), "id") id, ok := req.GetArguments()["id"].(float64)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("label ID is required"))
} }
opt := gitea_sdk.EditLabelOption{} opt := gitea_sdk.EditLabelOption{}
if name, ok := req.GetArguments()["name"].(string); ok { if name, ok := req.GetArguments()["name"].(string); ok {
opt.Name = new(name) opt.Name = ptr.To(name)
} }
if color, ok := req.GetArguments()["color"].(string); ok { if color, ok := req.GetArguments()["color"].(string); ok {
opt.Color = new(color) opt.Color = ptr.To(color)
} }
if description, ok := req.GetArguments()["description"].(string); ok { if description, ok := req.GetArguments()["description"].(string); ok {
opt.Description = new(description) opt.Description = ptr.To(description)
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
label, _, err := client.EditLabel(owner, repo, id, opt) label, _, err := client.EditLabel(owner, repo, int64(id), opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("edit %v/%v/label/%v err: %v", owner, repo, id, err)) return to.ErrorResult(fmt.Errorf("edit %v/%v/label/%v err: %v", owner, repo, int64(id), err))
} }
return to.TextResult(slimLabel(label)) return to.TextResult(label)
} }
func deleteRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func DeleteRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteRepoLabelFn") log.Debugf("Called DeleteRepoLabelFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, ok := req.GetArguments()["owner"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
id, err := params.GetIndex(req.GetArguments(), "id") id, ok := req.GetArguments()["id"].(float64)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("label ID is required"))
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
_, err = client.DeleteLabel(owner, repo, id) _, err = client.DeleteLabel(owner, repo, int64(id))
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("delete %v/%v/label/%v err: %v", owner, repo, id, err)) return to.ErrorResult(fmt.Errorf("delete %v/%v/label/%v err: %v", owner, repo, int64(id), err))
} }
return to.TextResult("Label deleted successfully") return to.TextResult("Label deleted successfully")
} }
func listOrgLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func AddIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listOrgLabelsFn") log.Debugf("Called AddIssueLabelsFn")
org, err := params.GetString(req.GetArguments(), "org") owner, ok := req.GetArguments()["owner"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30) labelsRaw, ok := req.GetArguments()["labels"].([]interface{})
if !ok {
return to.ErrorResult(fmt.Errorf("labels (array of IDs) is required"))
}
var labels []int64
for _, l := range labelsRaw {
if labelID, ok := l.(float64); ok {
labels = append(labels, int64(labelID))
} else {
return to.ErrorResult(fmt.Errorf("invalid label ID in labels array"))
}
}
opt := gitea_sdk.IssueLabelsOption{
Labels: labels,
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
issueLabels, _, err := client.AddIssueLabels(owner, repo, index, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("add labels to %v/%v/issue/%v err: %v", owner, repo, index, err))
}
return to.TextResult(issueLabels)
}
func ReplaceIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ReplaceIssueLabelsFn")
owner, ok := req.GetArguments()["owner"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil {
return to.ErrorResult(err)
}
labelsRaw, ok := req.GetArguments()["labels"].([]interface{})
if !ok {
return to.ErrorResult(fmt.Errorf("labels (array of IDs) is required"))
}
var labels []int64
for _, l := range labelsRaw {
if labelID, ok := l.(float64); ok {
labels = append(labels, int64(labelID))
} else {
return to.ErrorResult(fmt.Errorf("invalid label ID in labels array"))
}
}
opt := gitea_sdk.IssueLabelsOption{
Labels: labels,
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
issueLabels, _, err := client.ReplaceIssueLabels(owner, repo, index, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("replace labels on %v/%v/issue/%v err: %v", owner, repo, index, err))
}
return to.TextResult(issueLabels)
}
func ClearIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ClearIssueLabelsFn")
owner, ok := req.GetArguments()["owner"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.ClearIssueLabels(owner, repo, index)
if err != nil {
return to.ErrorResult(fmt.Errorf("clear labels on %v/%v/issue/%v err: %v", owner, repo, index, err))
}
return to.TextResult("Labels cleared successfully")
}
func RemoveIssueLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called RemoveIssueLabelFn")
owner, ok := req.GetArguments()["owner"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil {
return to.ErrorResult(err)
}
labelID, ok := req.GetArguments()["label_id"].(float64)
if !ok {
return to.ErrorResult(fmt.Errorf("label ID is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.DeleteIssueLabel(owner, repo, index, int64(labelID))
if err != nil {
return to.ErrorResult(fmt.Errorf("remove label %v from %v/%v/issue/%v err: %v", int64(labelID), owner, repo, index, err))
}
return to.TextResult("Label removed successfully")
}
func ListOrgLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListOrgLabelsFn")
org, ok := req.GetArguments()["org"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("org is required"))
}
page, ok := req.GetArguments()["page"].(float64)
if !ok {
page = 1
}
pageSize, ok := req.GetArguments()["pageSize"].(float64)
if !ok {
pageSize = 100
}
opt := gitea_sdk.ListOrgLabelsOptions{ opt := gitea_sdk.ListOrgLabelsOptions{
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: page, Page: int(page),
PageSize: pageSize, PageSize: int(pageSize),
}, },
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -280,22 +540,22 @@ func listOrgLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("list %v/labels err: %v", org, err)) return to.ErrorResult(fmt.Errorf("list %v/labels err: %v", org, err))
} }
return to.TextResult(slimLabels(labels)) return to.TextResult(labels)
} }
func createOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func CreateOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createOrgLabelFn") log.Debugf("Called CreateOrgLabelFn")
org, err := params.GetString(req.GetArguments(), "org") org, ok := req.GetArguments()["org"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("org is required"))
} }
name, err := params.GetString(req.GetArguments(), "name") name, ok := req.GetArguments()["name"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("name is required"))
} }
color, err := params.GetString(req.GetArguments(), "color") color, ok := req.GetArguments()["color"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("color is required"))
} }
description, _ := req.GetArguments()["description"].(string) description, _ := req.GetArguments()["description"].(string)
exclusive, _ := req.GetArguments()["exclusive"].(bool) exclusive, _ := req.GetArguments()["exclusive"].(bool)
@@ -315,63 +575,63 @@ func createOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/labels err: %v", org, err)) return to.ErrorResult(fmt.Errorf("create %v/labels err: %v", org, err))
} }
return to.TextResult(slimLabel(label)) return to.TextResult(label)
} }
func editOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func EditOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editOrgLabelFn") log.Debugf("Called EditOrgLabelFn")
org, err := params.GetString(req.GetArguments(), "org") org, ok := req.GetArguments()["org"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("org is required"))
} }
id, err := params.GetIndex(req.GetArguments(), "id") id, ok := req.GetArguments()["id"].(float64)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("label ID is required"))
} }
opt := gitea_sdk.EditOrgLabelOption{} opt := gitea_sdk.EditOrgLabelOption{}
if name, ok := req.GetArguments()["name"].(string); ok { if name, ok := req.GetArguments()["name"].(string); ok {
opt.Name = new(name) opt.Name = ptr.To(name)
} }
if color, ok := req.GetArguments()["color"].(string); ok { if color, ok := req.GetArguments()["color"].(string); ok {
opt.Color = new(color) opt.Color = ptr.To(color)
} }
if description, ok := req.GetArguments()["description"].(string); ok { if description, ok := req.GetArguments()["description"].(string); ok {
opt.Description = new(description) opt.Description = ptr.To(description)
} }
if exclusive, ok := req.GetArguments()["exclusive"].(bool); ok { if exclusive, ok := req.GetArguments()["exclusive"].(bool); ok {
opt.Exclusive = new(exclusive) opt.Exclusive = ptr.To(exclusive)
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
label, _, err := client.EditOrgLabel(org, id, opt) label, _, err := client.EditOrgLabel(org, int64(id), opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("edit %v/labels/%v err: %v", org, id, err)) return to.ErrorResult(fmt.Errorf("edit %v/labels/%v err: %v", org, int64(id), err))
} }
return to.TextResult(slimLabel(label)) return to.TextResult(label)
} }
func deleteOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func DeleteOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteOrgLabelFn") log.Debugf("Called DeleteOrgLabelFn")
org, err := params.GetString(req.GetArguments(), "org") org, ok := req.GetArguments()["org"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("org is required"))
} }
id, err := params.GetIndex(req.GetArguments(), "id") id, ok := req.GetArguments()["id"].(float64)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("label ID is required"))
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
_, err = client.DeleteOrgLabel(org, id) _, err = client.DeleteOrgLabel(org, int64(id))
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("delete %v/labels/%v err: %v", org, id, err)) return to.ErrorResult(fmt.Errorf("delete %v/labels/%v err: %v", org, int64(id), err))
} }
return to.TextResult("Label deleted successfully") return to.TextResult("Label deleted successfully")
} }

View File

@@ -1,26 +0,0 @@
package label
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func slimLabel(l *gitea_sdk.Label) map[string]any {
if l == nil {
return nil
}
return map[string]any{
"id": l.ID,
"name": l.Name,
"color": l.Color,
"description": l.Description,
"exclusive": l.Exclusive,
}
}
func slimLabels(labels []*gitea_sdk.Label) []map[string]any {
out := make([]map[string]any, 0, len(labels))
for _, l := range labels {
out = append(out, slimLabel(l))
}
return out
}

View File

@@ -1,25 +0,0 @@
package label
import (
"testing"
gitea_sdk "code.gitea.io/sdk/gitea"
)
func TestSlimLabel(t *testing.T) {
l := &gitea_sdk.Label{
ID: 1,
Name: "bug",
Color: "#d73a4a",
Description: "Something isn't working",
Exclusive: false,
}
m := slimLabel(l)
if m["name"] != "bug" {
t.Errorf("expected name bug, got %v", m["name"])
}
if m["color"] != "#d73a4a" {
t.Errorf("expected color, got %v", m["color"])
}
}

View File

@@ -6,7 +6,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params" "gitea.com/gitea/gitea-mcp/pkg/ptr"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
@@ -18,126 +18,145 @@ import (
var Tool = tool.New() var Tool = tool.New()
const ( const (
MilestoneReadToolName = "milestone_read" GetMilestoneToolName = "get_milestone"
MilestoneWriteToolName = "milestone_write" ListMilestonesToolName = "list_milestones"
CreateMilestoneToolName = "create_milestone"
EditMilestoneToolName = "edit_milestone"
DeleteMilestoneToolName = "delete_milestone"
) )
var ( var (
MilestoneReadTool = mcp.NewTool( GetMilestoneTool = mcp.NewTool(
MilestoneReadToolName, GetMilestoneToolName,
mcp.WithDescription("Read milestone information. Use method 'get' to get a specific milestone, 'list' to list milestones."), mcp.WithDescription("get milestone by id"),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "list")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Description("milestone id (required for 'get')")), mcp.WithNumber("id", mcp.Required(), mcp.Description("milestone id")),
mcp.WithString("state", mcp.Description("milestone state (for 'list')"), mcp.DefaultString("all")),
mcp.WithString("name", mcp.Description("milestone name filter (for 'list')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
) )
MilestoneWriteTool = mcp.NewTool( ListMilestonesTool = mcp.NewTool(
MilestoneWriteToolName, ListMilestonesToolName,
mcp.WithDescription("Create, edit, or delete milestones."), mcp.WithDescription("List milestones"),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "edit", "delete")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Description("milestone id (required for 'edit', 'delete')")), mcp.WithString("state", mcp.Description("milestone state"), mcp.DefaultString("all")),
mcp.WithString("title", mcp.Description("milestone title (required for 'create')")), mcp.WithString("name", mcp.Description("milestone name")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(100)),
)
CreateMilestoneTool = mcp.NewTool(
CreateMilestoneToolName,
mcp.WithDescription("create milestone"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("title", mcp.Required(), mcp.Description("milestone title")),
mcp.WithString("description", mcp.Description("milestone description")), mcp.WithString("description", mcp.Description("milestone description")),
mcp.WithString("due_on", mcp.Description("due date")), mcp.WithString("due_on", mcp.Description("due date")),
mcp.WithString("state", mcp.Description("milestone state, one of open, closed (for 'edit')")), )
EditMilestoneTool = mcp.NewTool(
EditMilestoneToolName,
mcp.WithDescription("edit milestone"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Required(), mcp.Description("milestone id")),
mcp.WithString("title", mcp.Description("milestone title")),
mcp.WithString("description", mcp.Description("milestone description")),
mcp.WithString("due_on", mcp.Description("due date")),
mcp.WithString("state", mcp.Description("milestone state, one of open, closed")),
)
DeleteMilestoneTool = mcp.NewTool(
DeleteMilestoneToolName,
mcp.WithDescription("delete milestone"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Required(), mcp.Description("milestone id")),
) )
) )
func init() { func init() {
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(server.ServerTool{
Tool: MilestoneReadTool, Tool: GetMilestoneTool,
Handler: milestoneReadFn, Handler: GetMilestoneFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: ListMilestonesTool,
Handler: ListMilestonesFn,
}) })
Tool.RegisterWrite(server.ServerTool{ Tool.RegisterWrite(server.ServerTool{
Tool: MilestoneWriteTool, Tool: CreateMilestoneTool,
Handler: milestoneWriteFn, Handler: CreateMilestoneFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: EditMilestoneTool,
Handler: EditMilestoneFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: DeleteMilestoneTool,
Handler: DeleteMilestoneFn,
}) })
} }
func milestoneReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method") log.Debugf("Called GetMilestoneFn")
if err != nil { owner, ok := req.GetArguments()["owner"].(string)
return to.ErrorResult(err) if !ok {
return to.ErrorResult(fmt.Errorf("owner is required"))
} }
switch method { repo, ok := req.GetArguments()["repo"].(string)
case "get": if !ok {
return getMilestoneFn(ctx, req) return to.ErrorResult(fmt.Errorf("repo is required"))
case "list":
return listMilestonesFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
} id, ok := req.GetArguments()["id"].(float64)
if !ok {
func milestoneWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { return to.ErrorResult(fmt.Errorf("id is required"))
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "create":
return createMilestoneFn(ctx, req)
case "edit":
return editMilestoneFn(ctx, req)
case "delete":
return deleteMilestoneFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func getMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getMilestoneFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
id, err := params.GetIndex(req.GetArguments(), "id")
if err != nil {
return to.ErrorResult(err)
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
milestone, _, err := client.GetMilestone(owner, repo, id) milestone, _, err := client.GetMilestone(owner, repo, int64(id))
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/milestone/%v err: %v", owner, repo, id, err)) return to.ErrorResult(fmt.Errorf("get %v/%v/milestone/%v err: %v", owner, repo, int64(id), err))
} }
return to.TextResult(slimMilestone(milestone)) return to.TextResult(milestone)
} }
func listMilestonesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListMilestonesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listMilestonesFn") log.Debugf("Called ListMilestonesFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, ok := req.GetArguments()["owner"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
}
state, ok := req.GetArguments()["state"].(string)
if !ok {
state = "all"
}
name, ok := req.GetArguments()["name"].(string)
if !ok {
name = ""
}
page, ok := req.GetArguments()["page"].(float64)
if !ok {
page = 1
}
pageSize, ok := req.GetArguments()["pageSize"].(float64)
if !ok {
pageSize = 100
} }
state := params.GetOptionalString(req.GetArguments(), "state", "all")
name := params.GetOptionalString(req.GetArguments(), "name", "")
page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.ListMilestoneOption{ opt := gitea_sdk.ListMilestoneOption{
State: gitea_sdk.StateType(state), State: gitea_sdk.StateType(state),
Name: name, Name: name,
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: page, Page: int(page),
PageSize: pageSize, PageSize: int(pageSize),
}, },
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -148,22 +167,22 @@ func listMilestonesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/milestones err: %v", owner, repo, err)) return to.ErrorResult(fmt.Errorf("get %v/%v/milestones err: %v", owner, repo, err))
} }
return to.TextResult(slimMilestones(milestones)) return to.TextResult(milestones)
} }
func createMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func CreateMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createMilestoneFn") log.Debugf("Called CreateMilestoneFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, ok := req.GetArguments()["owner"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
title, err := params.GetString(req.GetArguments(), "title") title, ok := req.GetArguments()["title"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("title is required"))
} }
opt := gitea_sdk.CreateMilestoneOption{ opt := gitea_sdk.CreateMilestoneOption{
@@ -184,22 +203,22 @@ func createMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
return to.ErrorResult(fmt.Errorf("create %v/%v/milestone err: %v", owner, repo, err)) return to.ErrorResult(fmt.Errorf("create %v/%v/milestone err: %v", owner, repo, err))
} }
return to.TextResult(slimMilestone(milestone)) return to.TextResult(milestone)
} }
func editMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func EditMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editMilestoneFn") log.Debugf("Called EditMilestoneFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, ok := req.GetArguments()["owner"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
id, err := params.GetIndex(req.GetArguments(), "id") id, ok := req.GetArguments()["id"].(float64)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("id is required"))
} }
opt := gitea_sdk.EditMilestoneOption{} opt := gitea_sdk.EditMilestoneOption{}
@@ -210,46 +229,46 @@ func editMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
} }
description, ok := req.GetArguments()["description"].(string) description, ok := req.GetArguments()["description"].(string)
if ok { if ok {
opt.Description = new(description) opt.Description = ptr.To(description)
} }
state, ok := req.GetArguments()["state"].(string) state, ok := req.GetArguments()["state"].(string)
if ok { if ok {
opt.State = new(gitea_sdk.StateType(state)) opt.State = ptr.To(gitea_sdk.StateType(state))
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
milestone, _, err := client.EditMilestone(owner, repo, id, opt) milestone, _, err := client.EditMilestone(owner, repo, int64(id), opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("edit %v/%v/milestone/%v err: %v", owner, repo, id, err)) return to.ErrorResult(fmt.Errorf("edit %v/%v/milestone/%v err: %v", owner, repo, int64(id), err))
} }
return to.TextResult(slimMilestone(milestone)) return to.TextResult(milestone)
} }
func deleteMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func DeleteMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteMilestoneFn") log.Debugf("Called DeleteMilestoneFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, ok := req.GetArguments()["owner"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
id, err := params.GetIndex(req.GetArguments(), "id") id, ok := req.GetArguments()["id"].(float64)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("id is required"))
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
_, err = client.DeleteMilestone(owner, repo, id) _, err = client.DeleteMilestone(owner, repo, int64(id))
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("delete %v/%v/milestone/%v err: %v", owner, repo, id, err)) return to.ErrorResult(fmt.Errorf("delete %v/%v/milestone/%v err: %v", owner, repo, int64(id), err))
} }
return to.TextResult("Milestone deleted successfully") return to.TextResult("Milestone deleted successfully")

View File

@@ -1,28 +0,0 @@
package milestone
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func slimMilestone(m *gitea_sdk.Milestone) map[string]any {
if m == nil {
return nil
}
return map[string]any{
"id": m.ID,
"title": m.Title,
"description": m.Description,
"state": m.State,
"open_issues": m.OpenIssues,
"closed_issues": m.ClosedIssues,
"due_on": m.Deadline,
}
}
func slimMilestones(milestones []*gitea_sdk.Milestone) []map[string]any {
out := make([]map[string]any, 0, len(milestones))
for _, m := range milestones {
out = append(out, slimMilestone(m))
}
return out
}

View File

@@ -2,7 +2,6 @@ package operation
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
@@ -11,14 +10,14 @@ import (
"syscall" "syscall"
"time" "time"
"gitea.com/gitea/gitea-mcp/operation/actions"
"gitea.com/gitea/gitea-mcp/operation/issue" "gitea.com/gitea/gitea-mcp/operation/issue"
"gitea.com/gitea/gitea-mcp/operation/label" "gitea.com/gitea/gitea-mcp/operation/label"
"gitea.com/gitea/gitea-mcp/operation/milestone" "gitea.com/gitea/gitea-mcp/operation/milestone"
"gitea.com/gitea/gitea-mcp/operation/timetracking"
"gitea.com/gitea/gitea-mcp/operation/actions"
"gitea.com/gitea/gitea-mcp/operation/pull" "gitea.com/gitea/gitea-mcp/operation/pull"
"gitea.com/gitea/gitea-mcp/operation/repo" "gitea.com/gitea/gitea-mcp/operation/repo"
"gitea.com/gitea/gitea-mcp/operation/search" "gitea.com/gitea/gitea-mcp/operation/search"
"gitea.com/gitea/gitea-mcp/operation/timetracking"
"gitea.com/gitea/gitea-mcp/operation/user" "gitea.com/gitea/gitea-mcp/operation/user"
"gitea.com/gitea/gitea-mcp/operation/version" "gitea.com/gitea/gitea-mcp/operation/version"
"gitea.com/gitea/gitea-mcp/operation/wiki" "gitea.com/gitea/gitea-mcp/operation/wiki"
@@ -68,33 +67,29 @@ func RegisterTool(s *server.MCPServer) {
s.DeleteTools("") s.DeleteTools("")
} }
// parseAuthToken extracts the token from an Authorization header. // parseBearerToken extracts the Bearer token from an Authorization header.
// Supports "Bearer <token>" (case-insensitive per RFC 7235) and
// Gitea-style "token <token>" formats.
// Returns the token and true if valid, empty string and false otherwise. // Returns the token and true if valid, empty string and false otherwise.
func parseAuthToken(authHeader string) (string, bool) { func parseBearerToken(authHeader string) (string, bool) {
if len(authHeader) > 7 && strings.EqualFold(authHeader[:7], "Bearer ") { const bearerPrefix = "Bearer "
token := strings.TrimSpace(authHeader[7:]) if len(authHeader) < len(bearerPrefix) || !strings.HasPrefix(authHeader, bearerPrefix) {
if token != "" {
return token, true
}
}
if len(authHeader) > 6 && strings.EqualFold(authHeader[:6], "token ") {
token := strings.TrimSpace(authHeader[6:])
if token != "" {
return token, true
}
}
return "", false return "", false
} }
token := strings.TrimSpace(authHeader[len(bearerPrefix):])
if token == "" {
return "", false
}
return token, true
}
func getContextWithToken(ctx context.Context, r *http.Request) context.Context { func getContextWithToken(ctx context.Context, r *http.Request) context.Context {
authHeader := r.Header.Get("Authorization") authHeader := r.Header.Get("Authorization")
if authHeader == "" { if authHeader == "" {
return ctx return ctx
} }
token, ok := parseAuthToken(authHeader) token, ok := parseBearerToken(authHeader)
if !ok { if !ok {
return ctx return ctx
} }
@@ -137,7 +132,7 @@ func Run() error {
close(shutdownDone) close(shutdownDone)
}() }()
if err := httpServer.Start(fmt.Sprintf(":%d", flag.Port)); err != nil && !errors.Is(err, http.ErrServerClosed) { if err := httpServer.Start(fmt.Sprintf(":%d", flag.Port)); err != nil {
return err return err
} }
<-shutdownDone // Wait for shutdown to finish <-shutdownDone // Wait for shutdown to finish

View File

@@ -4,7 +4,7 @@ import (
"testing" "testing"
) )
func TestParseAuthToken(t *testing.T) { func TestParseBearerToken(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
header string header string
@@ -12,29 +12,23 @@ func TestParseAuthToken(t *testing.T) {
wantOK bool wantOK bool
}{ }{
{ {
name: "valid Bearer token", name: "valid token",
header: "Bearer validtoken", header: "Bearer validtoken",
wantToken: "validtoken", wantToken: "validtoken",
wantOK: true, wantOK: true,
}, },
{
name: "lowercase bearer",
header: "bearer lowercase",
wantToken: "lowercase",
wantOK: true,
},
{
name: "uppercase BEARER",
header: "BEARER uppercase",
wantToken: "uppercase",
wantOK: true,
},
{ {
name: "token with spaces trimmed", name: "token with spaces trimmed",
header: "Bearer spacedToken ", header: "Bearer spacedToken ",
wantToken: "spacedToken", wantToken: "spacedToken",
wantOK: true, wantOK: true,
}, },
{
name: "lowercase bearer should fail",
header: "bearer lowercase",
wantToken: "",
wantOK: false,
},
{ {
name: "bearer with no token", name: "bearer with no token",
header: "Bearer ", header: "Bearer ",
@@ -53,24 +47,6 @@ func TestParseAuthToken(t *testing.T) {
wantToken: "", wantToken: "",
wantOK: false, wantOK: false,
}, },
{
name: "Gitea token format",
header: "token giteaapitoken",
wantToken: "giteaapitoken",
wantOK: true,
},
{
name: "Gitea Token format capitalized",
header: "Token giteaapitoken",
wantToken: "giteaapitoken",
wantOK: true,
},
{
name: "token with no value",
header: "token ",
wantToken: "",
wantOK: false,
},
{ {
name: "different auth type", name: "different auth type",
header: "Basic dXNlcjpwYXNz", header: "Basic dXNlcjpwYXNz",
@@ -84,7 +60,7 @@ func TestParseAuthToken(t *testing.T) {
wantOK: false, wantOK: false,
}, },
{ {
name: "bearer token with internal spaces", name: "token with internal spaces",
header: "Bearer token with spaces", header: "Bearer token with spaces",
wantToken: "token with spaces", wantToken: "token with spaces",
wantOK: true, wantOK: true,
@@ -93,12 +69,12 @@ func TestParseAuthToken(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
gotToken, gotOK := parseAuthToken(tt.header) gotToken, gotOK := parseBearerToken(tt.header)
if gotToken != tt.wantToken { if gotToken != tt.wantToken {
t.Errorf("parseAuthToken() token = %q, want %q", gotToken, tt.wantToken) t.Errorf("parseBearerToken() token = %q, want %q", gotToken, tt.wantToken)
} }
if gotOK != tt.wantOK { if gotOK != tt.wantOK {
t.Errorf("parseAuthToken() ok = %v, want %v", gotOK, tt.wantOK) t.Errorf("parseBearerToken() ok = %v, want %v", gotOK, tt.wantOK)
} }
}) })
} }

File diff suppressed because it is too large Load Diff

View File

@@ -13,248 +13,7 @@ import (
"github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/mcp"
) )
func Test_editPullRequestFn(t *testing.T) { func TestGetPullRequestDiffFn(t *testing.T) {
const (
owner = "octo"
repo = "demo"
index = 7
)
indexInputs := []struct {
name string
val any
}{
{"float64", float64(index)},
{"string", "7"},
}
for _, ii := range indexInputs {
t.Run(ii.name, func(t *testing.T) {
var (
mu sync.Mutex
gotMethod string
gotPath string
gotBody map[string]any
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"private":false}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index):
mu.Lock()
gotMethod = r.Method
gotPath = r.URL.Path
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
gotBody = body
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(fmt.Appendf(nil, `{"number":%d,"title":"%s","state":"open"}`, index, body["title"]))
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost := flag.Host
origToken := flag.Token
origVersion := flag.Version
flag.Host = server.URL
flag.Token = ""
flag.Version = "test"
defer func() {
flag.Host = origHost
flag.Token = origToken
flag.Version = origVersion
}()
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: map[string]any{
"owner": owner,
"repo": repo,
"index": ii.val,
"title": "WIP: my feature",
"state": "open",
},
},
}
result, err := editPullRequestFn(context.Background(), req)
if err != nil {
t.Fatalf("editPullRequestFn() error = %v", err)
}
mu.Lock()
defer mu.Unlock()
if gotMethod != http.MethodPatch {
t.Fatalf("expected PATCH request, got %s", gotMethod)
}
if gotPath != fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index) {
t.Fatalf("unexpected path: %s", gotPath)
}
if gotBody["title"] != "WIP: my feature" {
t.Fatalf("expected title 'WIP: my feature', got %v", gotBody["title"])
}
if gotBody["state"] != "open" {
t.Fatalf("expected state 'open', got %v", gotBody["state"])
}
if len(result.Content) == 0 {
t.Fatalf("expected content in result")
}
textContent, ok := mcp.AsTextContent(result.Content[0])
if !ok {
t.Fatalf("expected text content, got %T", result.Content[0])
}
var parsed map[string]any
if err := json.Unmarshal([]byte(textContent.Text), &parsed); err != nil {
t.Fatalf("unmarshal result text: %v", err)
}
if got := parsed["title"].(string); got != "WIP: my feature" {
t.Fatalf("result title = %q, want %q", got, "WIP: my feature")
}
})
}
}
func Test_mergePullRequestFn(t *testing.T) {
const (
owner = "octo"
repo = "demo"
index = 5
)
indexInputs := []struct {
name string
val any
}{
{"float64", float64(index)},
{"string", "5"},
}
for _, ii := range indexInputs {
t.Run(ii.name, func(t *testing.T) {
var (
mu sync.Mutex
gotMethod string
gotPath string
gotBody map[string]any
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"private":false}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner, repo, index):
mu.Lock()
gotMethod = r.Method
gotPath = r.URL.Path
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
gotBody = body
mu.Unlock()
w.WriteHeader(http.StatusOK)
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost := flag.Host
origToken := flag.Token
origVersion := flag.Version
flag.Host = server.URL
flag.Token = ""
flag.Version = "test"
defer func() {
flag.Host = origHost
flag.Token = origToken
flag.Version = origVersion
}()
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: map[string]any{
"owner": owner,
"repo": repo,
"index": ii.val,
"merge_style": "squash",
"title": "feat: my squashed commit",
"message": "Squash merge of PR #5",
"delete_branch": true,
},
},
}
result, err := mergePullRequestFn(context.Background(), req)
if err != nil {
t.Fatalf("mergePullRequestFn() error = %v", err)
}
mu.Lock()
defer mu.Unlock()
if gotMethod != http.MethodPost {
t.Fatalf("expected POST request, got %s", gotMethod)
}
if gotPath != fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner, repo, index) {
t.Fatalf("unexpected path: %s", gotPath)
}
if gotBody["Do"] != "squash" {
t.Fatalf("expected Do 'squash', got %v", gotBody["Do"])
}
if gotBody["MergeTitleField"] != "feat: my squashed commit" {
t.Fatalf("expected MergeTitleField 'feat: my squashed commit', got %v", gotBody["MergeTitleField"])
}
if gotBody["MergeMessageField"] != "Squash merge of PR #5" {
t.Fatalf("expected MergeMessageField 'Squash merge of PR #5', got %v", gotBody["MergeMessageField"])
}
if gotBody["delete_branch_after_merge"] != true {
t.Fatalf("expected delete_branch_after_merge true, got %v", gotBody["delete_branch_after_merge"])
}
if len(result.Content) == 0 {
t.Fatalf("expected content in result")
}
textContent, ok := mcp.AsTextContent(result.Content[0])
if !ok {
t.Fatalf("expected text content, got %T", result.Content[0])
}
var parsed map[string]any
if err := json.Unmarshal([]byte(textContent.Text), &parsed); err != nil {
t.Fatalf("unmarshal result text: %v", err)
}
if parsed["merged"] != true {
t.Fatalf("expected merged=true, got %v", parsed["merged"])
}
if parsed["merge_style"] != "squash" {
t.Fatalf("expected merge_style 'squash', got %v", parsed["merge_style"])
}
if parsed["branch_deleted"] != true {
t.Fatalf("expected branch_deleted=true, got %v", parsed["branch_deleted"])
}
})
}
}
func Test_getPullRequestDiffFn(t *testing.T) {
const ( const (
owner = "octo" owner = "octo"
repo = "demo" repo = "demo"
@@ -262,16 +21,6 @@ func Test_getPullRequestDiffFn(t *testing.T) {
diffRaw = "diff --git a/file.txt b/file.txt\n+line\n" diffRaw = "diff --git a/file.txt b/file.txt\n+line\n"
) )
indexInputs := []struct {
name string
val any
}{
{"float64", float64(index)},
{"string", "12"},
}
for _, ii := range indexInputs {
t.Run(ii.name, func(t *testing.T) {
var ( var (
mu sync.Mutex mu sync.Mutex
diffRequested bool diffRequested bool
@@ -328,15 +77,15 @@ func Test_getPullRequestDiffFn(t *testing.T) {
Arguments: map[string]any{ Arguments: map[string]any{
"owner": owner, "owner": owner,
"repo": repo, "repo": repo,
"index": ii.val, "index": float64(index),
"binary": true, "binary": true,
}, },
}, },
} }
result, err := getPullRequestDiffFn(context.Background(), req) result, err := GetPullRequestDiffFn(context.Background(), req)
if err != nil { if err != nil {
t.Fatalf("getPullRequestDiffFn() error = %v", err) t.Fatalf("GetPullRequestDiffFn() error = %v", err)
} }
select { select {
@@ -366,14 +115,26 @@ func Test_getPullRequestDiffFn(t *testing.T) {
t.Fatalf("expected text content, got %T", result.Content[0]) t.Fatalf("expected text content, got %T", result.Content[0])
} }
// The diff response is now a plain string var parsed struct {
var parsed string Result map[string]any `json:"Result"`
}
if err := json.Unmarshal([]byte(textContent.Text), &parsed); err != nil { if err := json.Unmarshal([]byte(textContent.Text), &parsed); err != nil {
t.Fatalf("unmarshal result text: %v", err) t.Fatalf("unmarshal result text: %v", err)
} }
if parsed != diffRaw {
t.Fatalf("diff = %q, want %q", parsed, diffRaw) if got, ok := parsed.Result["diff"].(string); !ok || got != diffRaw {
t.Fatalf("diff = %q, want %q", got, diffRaw)
} }
}) if got, ok := parsed.Result["binary"].(bool); !ok || got != true {
t.Fatalf("binary = %v, want true", got)
}
if got, ok := parsed.Result["index"].(float64); !ok || int64(got) != int64(index) {
t.Fatalf("index = %v, want %d", got, index)
}
if got, ok := parsed.Result["owner"].(string); !ok || got != owner {
t.Fatalf("owner = %q, want %q", got, owner)
}
if got, ok := parsed.Result["repo"].(string); !ok || got != repo {
t.Fatalf("repo = %q, want %q", got, repo)
} }
} }

View File

@@ -1,191 +0,0 @@
package pull
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func userLogin(u *gitea_sdk.User) string {
if u == nil {
return ""
}
return u.UserName
}
func userLogins(users []*gitea_sdk.User) []string {
if len(users) == 0 {
return nil
}
out := make([]string, 0, len(users))
for _, u := range users {
if u != nil {
out = append(out, u.UserName)
}
}
return out
}
func labelNames(labels []*gitea_sdk.Label) []string {
if len(labels) == 0 {
return nil
}
out := make([]string, 0, len(labels))
for _, l := range labels {
if l != nil {
out = append(out, l.Name)
}
}
return out
}
func repoRef(r *gitea_sdk.Repository) map[string]any {
if r == nil {
return nil
}
return map[string]any{
"full_name": r.FullName,
"description": r.Description,
}
}
func slimPullRequest(pr *gitea_sdk.PullRequest) map[string]any {
if pr == nil {
return nil
}
m := map[string]any{
"number": pr.Index,
"title": pr.Title,
"body": pr.Body,
"state": pr.State,
"draft": pr.Draft,
"merged": pr.HasMerged,
"mergeable": pr.Mergeable,
"html_url": pr.HTMLURL,
"user": userLogin(pr.Poster),
"labels": labelNames(pr.Labels),
"comments": pr.Comments,
"created_at": pr.Created,
"updated_at": pr.Updated,
"closed_at": pr.Closed,
}
if pr.HasMerged {
m["merged_at"] = pr.Merged
m["merge_commit_sha"] = pr.MergedCommitID
m["merged_by"] = userLogin(pr.MergedBy)
}
if pr.Head != nil {
head := map[string]any{"ref": pr.Head.Ref, "sha": pr.Head.Sha}
if pr.Head.Repository != nil {
head["repo"] = repoRef(pr.Head.Repository)
}
m["head"] = head
}
if pr.Base != nil {
base := map[string]any{"ref": pr.Base.Ref, "sha": pr.Base.Sha}
if pr.Base.Repository != nil {
base["repo"] = repoRef(pr.Base.Repository)
}
m["base"] = base
}
if pr.Additions != nil {
m["additions"] = *pr.Additions
}
if pr.Deletions != nil {
m["deletions"] = *pr.Deletions
}
if pr.ChangedFiles != nil {
m["changed_files"] = *pr.ChangedFiles
}
if len(pr.Assignees) > 0 {
m["assignees"] = userLogins(pr.Assignees)
}
if pr.Milestone != nil {
m["milestone"] = pr.Milestone.Title
}
if pr.ReviewComments > 0 {
m["review_comments"] = pr.ReviewComments
}
return m
}
func slimPullRequests(prs []*gitea_sdk.PullRequest) []map[string]any {
out := make([]map[string]any, 0, len(prs))
for _, pr := range prs {
if pr == nil {
continue
}
m := map[string]any{
"number": pr.Index,
"title": pr.Title,
"state": pr.State,
"draft": pr.Draft,
"merged": pr.HasMerged,
"html_url": pr.HTMLURL,
"user": userLogin(pr.Poster),
"created_at": pr.Created,
"updated_at": pr.Updated,
}
if pr.Head != nil {
m["head"] = pr.Head.Ref
}
if pr.Base != nil {
m["base"] = pr.Base.Ref
}
if len(pr.Labels) > 0 {
m["labels"] = labelNames(pr.Labels)
}
out = append(out, m)
}
return out
}
func slimReview(r *gitea_sdk.PullReview) map[string]any {
if r == nil {
return nil
}
return map[string]any{
"id": r.ID,
"state": r.State,
"body": r.Body,
"user": userLogin(r.Reviewer),
"comments_count": r.CodeCommentsCount,
"submitted_at": r.Submitted,
"html_url": r.HTMLURL,
"stale": r.Stale,
"official": r.Official,
"dismissed": r.Dismissed,
}
}
func slimReviews(reviews []*gitea_sdk.PullReview) []map[string]any {
out := make([]map[string]any, 0, len(reviews))
for _, r := range reviews {
out = append(out, slimReview(r))
}
return out
}
func slimReviewComment(c *gitea_sdk.PullReviewComment) map[string]any {
if c == nil {
return nil
}
return map[string]any{
"id": c.ID,
"body": c.Body,
"path": c.Path,
"position": c.LineNum,
"old_position": c.OldLineNum,
"diff_hunk": c.DiffHunk,
"user": userLogin(c.Reviewer),
"html_url": c.HTMLURL,
"created_at": c.Created,
"updated_at": c.Updated,
}
}
func slimReviewComments(comments []*gitea_sdk.PullReviewComment) []map[string]any {
out := make([]map[string]any, 0, len(comments))
for _, c := range comments {
out = append(out, slimReviewComment(c))
}
return out
}

View File

@@ -1,124 +0,0 @@
package pull
import (
"testing"
"time"
gitea_sdk "code.gitea.io/sdk/gitea"
)
func TestSlimPullRequest(t *testing.T) {
now := time.Now()
additions := 10
deletions := 5
changedFiles := 3
pr := &gitea_sdk.PullRequest{
Index: 1,
Title: "Fix bug",
Body: "Fixes #123",
State: "open",
Draft: false,
HasMerged: false,
Mergeable: true,
HTMLURL: "https://gitea.com/org/repo/pulls/1",
Poster: &gitea_sdk.User{UserName: "bob"},
Labels: []*gitea_sdk.Label{
{Name: "bug"},
{Name: "priority"},
},
Comments: 2,
Created: &now,
Updated: &now,
Additions: &additions,
Deletions: &deletions,
ChangedFiles: &changedFiles,
Head: &gitea_sdk.PRBranchInfo{
Ref: "fix-branch",
Sha: "abc123",
},
Base: &gitea_sdk.PRBranchInfo{
Ref: "main",
Sha: "def456",
},
Assignees: []*gitea_sdk.User{
{UserName: "alice"},
},
Milestone: &gitea_sdk.Milestone{Title: "v1.0"},
}
m := slimPullRequest(pr)
if m["number"] != int64(1) {
t.Errorf("expected number 1, got %v", m["number"])
}
if m["title"] != "Fix bug" {
t.Errorf("expected title Fix bug, got %v", m["title"])
}
if m["user"] != "bob" {
t.Errorf("expected user bob, got %v", m["user"])
}
if m["additions"] != 10 {
t.Errorf("expected additions 10, got %v", m["additions"])
}
if m["milestone"] != "v1.0" {
t.Errorf("expected milestone v1.0, got %v", m["milestone"])
}
labels := m["labels"].([]string)
if len(labels) != 2 || labels[0] != "bug" {
t.Errorf("expected labels [bug priority], got %v", labels)
}
head := m["head"].(map[string]any)
if head["ref"] != "fix-branch" {
t.Errorf("expected head ref fix-branch, got %v", head["ref"])
}
assignees := m["assignees"].([]string)
if len(assignees) != 1 || assignees[0] != "alice" {
t.Errorf("expected assignees [alice], got %v", assignees)
}
// merged fields should not be present for unmerged PR
if _, ok := m["merged_at"]; ok {
t.Error("merged_at should not be present for unmerged PR")
}
}
func TestSlimPullRequests_ListIsSlimmer(t *testing.T) {
pr := &gitea_sdk.PullRequest{
Index: 1,
Title: "PR title",
State: "open",
HTMLURL: "https://gitea.com/org/repo/pulls/1",
Poster: &gitea_sdk.User{UserName: "bob"},
Body: "Full body text here",
Head: &gitea_sdk.PRBranchInfo{Ref: "feature"},
Base: &gitea_sdk.PRBranchInfo{Ref: "main"},
}
single := slimPullRequest(pr)
list := slimPullRequests([]*gitea_sdk.PullRequest{pr})
// Single has body, list does not
if _, ok := single["body"]; !ok {
t.Error("single PR should have body")
}
if _, ok := list[0]["body"]; ok {
t.Error("list PR should not have body")
}
// List has head as string ref, single has head as map
if _, ok := single["head"].(map[string]any); !ok {
t.Error("single PR head should be a map")
}
if list[0]["head"] != "feature" {
t.Errorf("list PR head should be string ref, got %v", list[0]["head"])
}
}
func TestSlimPullRequests_Nil(t *testing.T) {
if r := slimPullRequests(nil); len(r) != 0 {
t.Errorf("expected empty slice, got %v", r)
}
}

View File

@@ -6,7 +6,6 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "code.gitea.io/sdk/gitea" gitea_sdk "code.gitea.io/sdk/gitea"
@@ -63,20 +62,19 @@ func init() {
func CreateBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func CreateBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateBranchFn") log.Debugf("Called CreateBranchFn")
args := req.GetArguments() owner, ok := req.GetArguments()["owner"].(string)
owner, err := params.GetString(args, "owner") if !ok {
if err != nil { return to.ErrorResult(fmt.Errorf("owner is required"))
return to.ErrorResult(err)
} }
repo, err := params.GetString(args, "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
branch, err := params.GetString(args, "branch") branch, ok := req.GetArguments()["branch"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("branch is required"))
} }
oldBranch, _ := args["old_branch"].(string) oldBranch, _ := req.GetArguments()["old_branch"].(string)
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
@@ -95,18 +93,17 @@ func CreateBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
func DeleteBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func DeleteBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteBranchFn") log.Debugf("Called DeleteBranchFn")
args := req.GetArguments() owner, ok := req.GetArguments()["owner"].(string)
owner, err := params.GetString(args, "owner") if !ok {
if err != nil { return to.ErrorResult(fmt.Errorf("owner is required"))
return to.ErrorResult(err)
} }
repo, err := params.GetString(args, "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
branch, err := params.GetString(args, "branch") branch, ok := req.GetArguments()["branch"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("branch is required"))
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
@@ -122,19 +119,18 @@ func DeleteBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
func ListBranchesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListBranchesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListBranchesFn") log.Debugf("Called ListBranchesFn")
args := req.GetArguments() owner, ok := req.GetArguments()["owner"].(string)
owner, err := params.GetString(args, "owner") if !ok {
if err != nil { return to.ErrorResult(fmt.Errorf("owner is required"))
return to.ErrorResult(err)
} }
repo, err := params.GetString(args, "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
opt := gitea_sdk.ListRepoBranchesOptions{ opt := gitea_sdk.ListRepoBranchesOptions{
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: 1, Page: 1,
PageSize: 30, PageSize: 100,
}, },
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -146,5 +142,5 @@ func ListBranchesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
return to.ErrorResult(fmt.Errorf("list branches error: %v", err)) return to.ErrorResult(fmt.Errorf("list branches error: %v", err))
} }
return to.TextResult(slimBranches(branches)) return to.TextResult(branches)
} }

View File

@@ -6,7 +6,6 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "code.gitea.io/sdk/gitea" gitea_sdk "code.gitea.io/sdk/gitea"
@@ -15,7 +14,7 @@ import (
) )
const ( const (
ListRepoCommitsToolName = "list_commits" ListRepoCommitsToolName = "list_repo_commits"
) )
var ListRepoCommitsTool = mcp.NewTool( var ListRepoCommitsTool = mcp.NewTool(
@@ -26,7 +25,7 @@ var ListRepoCommitsTool = mcp.NewTool(
mcp.WithString("sha", mcp.Description("SHA or branch to start listing commits from")), mcp.WithString("sha", mcp.Description("SHA or branch to start listing commits from")),
mcp.WithString("path", mcp.Description("path indicates that only commits that include the path's file/dir should be returned.")), mcp.WithString("path", mcp.Description("path indicates that only commits that include the path's file/dir should be returned.")),
mcp.WithNumber("page", mcp.Required(), mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)), mcp.WithNumber("page", mcp.Required(), mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Required(), mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)), mcp.WithNumber("page_size", mcp.Required(), mcp.Description("page size"), mcp.DefaultNumber(50), mcp.Min(1)),
) )
func init() { func init() {
@@ -38,25 +37,24 @@ func init() {
func ListRepoCommitsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListRepoCommitsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListRepoCommitsFn") log.Debugf("Called ListRepoCommitsFn")
args := req.GetArguments() owner, ok := req.GetArguments()["owner"].(string)
owner, err := params.GetString(args, "owner") if !ok {
if err != nil { return to.ErrorResult(fmt.Errorf("owner is required"))
return to.ErrorResult(err)
} }
repo, err := params.GetString(args, "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
page, err := params.GetIndex(args, "page") page, ok := req.GetArguments()["page"].(float64)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("page is required"))
} }
pageSize, err := params.GetIndex(args, "perPage") pageSize, ok := req.GetArguments()["page_size"].(float64)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("page_size is required"))
} }
sha, _ := args["sha"].(string) sha, _ := req.GetArguments()["sha"].(string)
path, _ := args["path"].(string) path, _ := req.GetArguments()["path"].(string)
opt := gitea_sdk.ListCommitOptions{ opt := gitea_sdk.ListCommitOptions{
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: int(page), Page: int(page),
@@ -73,5 +71,5 @@ func ListRepoCommitsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("list repo commits err: %v", err)) return to.ErrorResult(fmt.Errorf("list repo commits err: %v", err))
} }
return to.TextResult(slimCommits(commits)) return to.TextResult(commits)
} }

View File

@@ -10,7 +10,6 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "code.gitea.io/sdk/gitea" gitea_sdk "code.gitea.io/sdk/gitea"
@@ -19,9 +18,10 @@ import (
) )
const ( const (
GetFileToolName = "get_file_contents" GetFileToolName = "get_file_content"
GetDirToolName = "get_dir_contents" GetDirToolName = "get_dir_content"
CreateOrUpdateFileToolName = "create_or_update_file" CreateFileToolName = "create_file"
UpdateFileToolName = "update_file"
DeleteFileToolName = "delete_file" DeleteFileToolName = "delete_file"
) )
@@ -45,17 +45,28 @@ var (
mcp.WithString("filePath", mcp.Required(), mcp.Description("directory path")), mcp.WithString("filePath", mcp.Required(), mcp.Description("directory path")),
) )
CreateOrUpdateFileTool = mcp.NewTool( CreateFileTool = mcp.NewTool(
CreateOrUpdateFileToolName, CreateFileToolName,
mcp.WithDescription("Create or update a file. If sha is provided, updates the existing file; otherwise creates a new file."), mcp.WithDescription("Create file"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("filePath", mcp.Required(), mcp.Description("file path")), mcp.WithString("filePath", mcp.Required(), mcp.Description("file path")),
mcp.WithString("content", mcp.Required(), mcp.Description("file content")), mcp.WithString("content", mcp.Required(), mcp.Description("file content")),
mcp.WithString("message", mcp.Required(), mcp.Description("commit message")), mcp.WithString("message", mcp.Required(), mcp.Description("commit message")),
mcp.WithString("branch_name", mcp.Required(), mcp.Description("branch name")), mcp.WithString("branch_name", mcp.Required(), mcp.Description("branch name")),
mcp.WithString("sha", mcp.Description("SHA of the existing file (required for update, omit for create)")), mcp.WithString("new_branch_name", mcp.Description("new branch name")),
mcp.WithString("new_branch_name", mcp.Description("new branch name (for create only)")), )
UpdateFileTool = mcp.NewTool(
UpdateFileToolName,
mcp.WithDescription("Update file"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("filePath", mcp.Required(), mcp.Description("file path")),
mcp.WithString("sha", mcp.Required(), mcp.Description("sha is the SHA for the file that already exists")),
mcp.WithString("content", mcp.Required(), mcp.Description("file content")),
mcp.WithString("message", mcp.Required(), mcp.Description("commit message")),
mcp.WithString("branch_name", mcp.Required(), mcp.Description("branch name")),
) )
DeleteFileTool = mcp.NewTool( DeleteFileTool = mcp.NewTool(
@@ -66,7 +77,7 @@ var (
mcp.WithString("filePath", mcp.Required(), mcp.Description("file path")), mcp.WithString("filePath", mcp.Required(), mcp.Description("file path")),
mcp.WithString("message", mcp.Required(), mcp.Description("commit message")), mcp.WithString("message", mcp.Required(), mcp.Description("commit message")),
mcp.WithString("branch_name", mcp.Required(), mcp.Description("branch name")), mcp.WithString("branch_name", mcp.Required(), mcp.Description("branch name")),
mcp.WithString("sha", mcp.Required(), mcp.Description("sha")), mcp.WithString("sha", mcp.Description("sha")),
) )
) )
@@ -80,8 +91,12 @@ func init() {
Handler: GetDirContentFn, Handler: GetDirContentFn,
}) })
Tool.RegisterWrite(server.ServerTool{ Tool.RegisterWrite(server.ServerTool{
Tool: CreateOrUpdateFileTool, Tool: CreateFileTool,
Handler: CreateOrUpdateFileFn, Handler: CreateFileFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: UpdateFileTool,
Handler: UpdateFileFn,
}) })
Tool.RegisterWrite(server.ServerTool{ Tool.RegisterWrite(server.ServerTool{
Tool: DeleteFileTool, Tool: DeleteFileTool,
@@ -96,19 +111,18 @@ type ContentLine struct {
func GetFileContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetFileContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetFileFn") log.Debugf("Called GetFileFn")
args := req.GetArguments() owner, ok := req.GetArguments()["owner"].(string)
owner, err := params.GetString(args, "owner") if !ok {
if err != nil { return to.ErrorResult(fmt.Errorf("owner is required"))
return to.ErrorResult(err)
} }
repo, err := params.GetString(args, "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
ref, _ := args["ref"].(string) ref, _ := req.GetArguments()["ref"].(string)
filePath, err := params.GetString(args, "filePath") filePath, ok := req.GetArguments()["filePath"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("filePath is required"))
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
@@ -118,7 +132,7 @@ func GetFileContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get file err: %v", err)) return to.ErrorResult(fmt.Errorf("get file err: %v", err))
} }
withLines, _ := args["withLines"].(bool) withLines, _ := req.GetArguments()["withLines"].(bool)
if withLines { if withLines {
rawContent, err := base64.StdEncoding.DecodeString(*content.Content) rawContent, err := base64.StdEncoding.DecodeString(*content.Content)
if err != nil { if err != nil {
@@ -137,6 +151,7 @@ func GetFileContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
LineNumber: line, LineNumber: line,
Content: scanner.Text(), Content: scanner.Text(),
}) })
} }
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
return to.ErrorResult(fmt.Errorf("scan content err: %v", err)) return to.ErrorResult(fmt.Errorf("scan content err: %v", err))
@@ -144,7 +159,7 @@ func GetFileContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
// remove the last blank line if exists // remove the last blank line if exists
// git does not consider the last line as a new line // git does not consider the last line as a new line
if len(contentLines) > 0 && contentLines[len(contentLines)-1].Content == "" { if contentLines[len(contentLines)-1].Content == "" {
contentLines = contentLines[:len(contentLines)-1] contentLines = contentLines[:len(contentLines)-1]
} }
@@ -155,24 +170,23 @@ func GetFileContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
contentStr := string(contentBytes) contentStr := string(contentBytes)
content.Content = &contentStr content.Content = &contentStr
} }
return to.TextResult(slimContents(content)) return to.TextResult(content)
} }
func GetDirContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetDirContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetDirContentFn") log.Debugf("Called GetDirContentFn")
args := req.GetArguments() owner, ok := req.GetArguments()["owner"].(string)
owner, err := params.GetString(args, "owner") if !ok {
if err != nil { return to.ErrorResult(fmt.Errorf("owner is required"))
return to.ErrorResult(err)
} }
repo, err := params.GetString(args, "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
ref, _ := args["ref"].(string) ref, _ := req.GetArguments()["ref"].(string)
filePath, err := params.GetString(args, "filePath") filePath, ok := req.GetArguments()["filePath"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("filePath is required"))
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
@@ -182,52 +196,26 @@ func GetDirContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get dir content err: %v", err)) return to.ErrorResult(fmt.Errorf("get dir content err: %v", err))
} }
return to.TextResult(slimDirEntries(content)) return to.TextResult(content)
} }
func CreateOrUpdateFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func CreateFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateOrUpdateFileFn") log.Debugf("Called CreateFileFn")
args := req.GetArguments() owner, ok := req.GetArguments()["owner"].(string)
owner, err := params.GetString(args, "owner") if !ok {
if err != nil { return to.ErrorResult(fmt.Errorf("owner is required"))
return to.ErrorResult(err)
} }
repo, err := params.GetString(args, "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
filePath, err := params.GetString(args, "filePath") filePath, ok := req.GetArguments()["filePath"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("filePath is required"))
} }
content, _ := args["content"].(string) content, _ := req.GetArguments()["content"].(string)
message, _ := args["message"].(string) message, _ := req.GetArguments()["message"].(string)
branchName, _ := args["branch_name"].(string) branchName, _ := req.GetArguments()["branch_name"].(string)
sha, _ := args["sha"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
if sha != "" {
// Update existing file
opt := gitea_sdk.UpdateFileOptions{
SHA: sha,
Content: base64.StdEncoding.EncodeToString([]byte(content)),
FileOptions: gitea_sdk.FileOptions{
Message: message,
BranchName: branchName,
},
}
_, _, err = client.UpdateFile(owner, repo, filePath, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("update file err: %v", err))
}
return to.TextResult("Update file success")
}
// Create new file
opt := gitea_sdk.CreateFileOptions{ opt := gitea_sdk.CreateFileOptions{
Content: base64.StdEncoding.EncodeToString([]byte(content)), Content: base64.StdEncoding.EncodeToString([]byte(content)),
FileOptions: gitea_sdk.FileOptions{ FileOptions: gitea_sdk.FileOptions{
@@ -235,8 +223,10 @@ func CreateOrUpdateFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
BranchName: branchName, BranchName: branchName,
}, },
} }
if newBranch, ok := args["new_branch_name"].(string); ok && newBranch != "" {
opt.NewBranchName = newBranch client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
_, _, err = client.CreateFile(owner, repo, filePath, opt) _, _, err = client.CreateFile(owner, repo, filePath, opt)
if err != nil { if err != nil {
@@ -245,26 +235,66 @@ func CreateOrUpdateFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
return to.TextResult("Create file success") return to.TextResult("Create file success")
} }
func UpdateFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called UpdateFileFn")
owner, ok := req.GetArguments()["owner"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
filePath, ok := req.GetArguments()["filePath"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("filePath is required"))
}
sha, ok := req.GetArguments()["sha"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("sha is required"))
}
content, _ := req.GetArguments()["content"].(string)
message, _ := req.GetArguments()["message"].(string)
branchName, _ := req.GetArguments()["branch_name"].(string)
opt := gitea_sdk.UpdateFileOptions{
SHA: sha,
Content: base64.StdEncoding.EncodeToString([]byte(content)),
FileOptions: gitea_sdk.FileOptions{
Message: message,
BranchName: branchName,
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, _, err = client.UpdateFile(owner, repo, filePath, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("update file err: %v", err))
}
return to.TextResult("Update file success")
}
func DeleteFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func DeleteFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteFileFn") log.Debugf("Called DeleteFileFn")
args := req.GetArguments() owner, ok := req.GetArguments()["owner"].(string)
owner, err := params.GetString(args, "owner") if !ok {
if err != nil { return to.ErrorResult(fmt.Errorf("owner is required"))
return to.ErrorResult(err)
} }
repo, err := params.GetString(args, "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
filePath, err := params.GetString(args, "filePath") filePath, ok := req.GetArguments()["filePath"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("filePath is required"))
} }
message, _ := args["message"].(string) message, _ := req.GetArguments()["message"].(string)
branchName, _ := args["branch_name"].(string) branchName, _ := req.GetArguments()["branch_name"].(string)
sha, err := params.GetString(args, "sha") sha, ok := req.GetArguments()["sha"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("sha is required"))
} }
opt := gitea_sdk.DeleteFileOptions{ opt := gitea_sdk.DeleteFileOptions{
FileOptions: gitea_sdk.FileOptions{ FileOptions: gitea_sdk.FileOptions{

View File

@@ -3,10 +3,11 @@ package repo
import ( import (
"context" "context"
"fmt" "fmt"
"time"
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params" "gitea.com/gitea/gitea-mcp/pkg/ptr"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "code.gitea.io/sdk/gitea" gitea_sdk "code.gitea.io/sdk/gitea"
@@ -67,7 +68,7 @@ var (
mcp.WithBoolean("is_draft", mcp.Description("Whether the release is draft"), mcp.DefaultBool(false)), mcp.WithBoolean("is_draft", mcp.Description("Whether the release is draft"), mcp.DefaultBool(false)),
mcp.WithBoolean("is_pre_release", mcp.Description("Whether the release is pre-release"), mcp.DefaultBool(false)), mcp.WithBoolean("is_pre_release", mcp.Description("Whether the release is pre-release"), mcp.DefaultBool(false)),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)), mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(20), mcp.Min(1)), mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(20), mcp.Min(1)),
) )
) )
@@ -94,32 +95,44 @@ func init() {
}) })
} }
// To avoid return too many tokens, we need to provide at least information as possible
// llm can call get release to get more information
type ListReleaseResult struct {
ID int64 `json:"id"`
TagName string `json:"tag_name"`
Target string `json:"target_commitish"`
Title string `json:"title"`
IsDraft bool `json:"draft"`
IsPrerelease bool `json:"prerelease"`
CreatedAt time.Time `json:"created_at"`
PublishedAt time.Time `json:"published_at"`
}
func CreateReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func CreateReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateReleasesFn") log.Debugf("Called CreateReleasesFn")
args := req.GetArguments() owner, ok := req.GetArguments()["owner"].(string)
owner, err := params.GetString(args, "owner") if !ok {
if err != nil { return nil, fmt.Errorf("owner is required")
return to.ErrorResult(err)
} }
repo, err := params.GetString(args, "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return nil, fmt.Errorf("repo is required")
} }
tagName, err := params.GetString(args, "tag_name") tagName, ok := req.GetArguments()["tag_name"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return nil, fmt.Errorf("tag_name is required")
} }
target, err := params.GetString(args, "target") target, ok := req.GetArguments()["target"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return nil, fmt.Errorf("target is required")
} }
title, err := params.GetString(args, "title") title, ok := req.GetArguments()["title"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return nil, fmt.Errorf("title is required")
} }
isDraft, _ := args["is_draft"].(bool) isDraft, _ := req.GetArguments()["is_draft"].(bool)
isPreRelease, _ := args["is_pre_release"].(bool) isPreRelease, _ := req.GetArguments()["is_pre_release"].(bool)
body, _ := args["body"].(string) body, _ := req.GetArguments()["body"].(string)
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
@@ -142,25 +155,24 @@ func CreateReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
func DeleteReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func DeleteReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteReleaseFn") log.Debugf("Called DeleteReleaseFn")
args := req.GetArguments() owner, ok := req.GetArguments()["owner"].(string)
owner, err := params.GetString(args, "owner") if !ok {
if err != nil { return nil, fmt.Errorf("owner is required")
return to.ErrorResult(err)
} }
repo, err := params.GetString(args, "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return nil, fmt.Errorf("repo is required")
} }
id, err := params.GetIndex(args, "id") id, ok := req.GetArguments()["id"].(float64)
if err != nil { if !ok {
return to.ErrorResult(err) return nil, fmt.Errorf("id is required")
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
_, err = client.DeleteRelease(owner, repo, id) _, err = client.DeleteRelease(owner, repo, int64(id))
if err != nil { if err != nil {
return nil, fmt.Errorf("delete release error: %v", err) return nil, fmt.Errorf("delete release error: %v", err)
} }
@@ -170,42 +182,40 @@ func DeleteReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
func GetReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetReleaseFn") log.Debugf("Called GetReleaseFn")
args := req.GetArguments() owner, ok := req.GetArguments()["owner"].(string)
owner, err := params.GetString(args, "owner") if !ok {
if err != nil { return nil, fmt.Errorf("owner is required")
return to.ErrorResult(err)
} }
repo, err := params.GetString(args, "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return nil, fmt.Errorf("repo is required")
} }
id, err := params.GetIndex(args, "id") id, ok := req.GetArguments()["id"].(float64)
if err != nil { if !ok {
return to.ErrorResult(err) return nil, fmt.Errorf("id is required")
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
release, _, err := client.GetRelease(owner, repo, id) release, _, err := client.GetRelease(owner, repo, int64(id))
if err != nil { if err != nil {
return nil, fmt.Errorf("get release error: %v", err) return nil, fmt.Errorf("get release error: %v", err)
} }
return to.TextResult(slimRelease(release)) return to.TextResult(release)
} }
func GetLatestReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetLatestReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetLatestReleaseFn") log.Debugf("Called GetLatestReleaseFn")
args := req.GetArguments() owner, ok := req.GetArguments()["owner"].(string)
owner, err := params.GetString(args, "owner") if !ok {
if err != nil { return nil, fmt.Errorf("owner is required")
return to.ErrorResult(err)
} }
repo, err := params.GetString(args, "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return nil, fmt.Errorf("repo is required")
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -217,32 +227,31 @@ func GetLatestReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
return nil, fmt.Errorf("get latest release error: %v", err) return nil, fmt.Errorf("get latest release error: %v", err)
} }
return to.TextResult(slimRelease(release)) return to.TextResult(release)
} }
func ListReleasesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListReleasesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListReleasesFn") log.Debugf("Called ListReleasesFn")
args := req.GetArguments() owner, ok := req.GetArguments()["owner"].(string)
owner, err := params.GetString(args, "owner") if !ok {
if err != nil { return nil, fmt.Errorf("owner is required")
return to.ErrorResult(err)
} }
repo, err := params.GetString(args, "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return nil, fmt.Errorf("repo is required")
} }
var pIsDraft *bool var pIsDraft *bool
isDraft, ok := args["is_draft"].(bool) isDraft, ok := req.GetArguments()["is_draft"].(bool)
if ok { if ok {
pIsDraft = new(isDraft) pIsDraft = ptr.To(isDraft)
} }
var pIsPreRelease *bool var pIsPreRelease *bool
isPreRelease, ok := args["is_pre_release"].(bool) isPreRelease, ok := req.GetArguments()["is_pre_release"].(bool)
if ok { if ok {
pIsPreRelease = new(isPreRelease) pIsPreRelease = ptr.To(isPreRelease)
} }
page := params.GetOptionalInt(args, "page", 1) page, _ := req.GetArguments()["page"].(float64)
pageSize := params.GetOptionalInt(args, "perPage", 20) pageSize, _ := req.GetArguments()["pageSize"].(float64)
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
@@ -260,5 +269,18 @@ func ListReleasesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
return nil, fmt.Errorf("list releases error: %v", err) return nil, fmt.Errorf("list releases error: %v", err)
} }
return to.TextResult(slimReleases(releases)) results := make([]ListReleaseResult, len(releases))
for _, release := range releases {
results = append(results, ListReleaseResult{
ID: release.ID,
TagName: release.TagName,
Target: release.Target,
Title: release.Title,
IsDraft: release.IsDraft,
IsPrerelease: release.IsPrerelease,
CreatedAt: release.CreatedAt,
PublishedAt: release.PublishedAt,
})
}
return to.TextResult(results)
} }

View File

@@ -7,7 +7,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params" "gitea.com/gitea/gitea-mcp/pkg/ptr"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
@@ -22,7 +22,6 @@ const (
CreateRepoToolName = "create_repo" CreateRepoToolName = "create_repo"
ForkRepoToolName = "fork_repo" ForkRepoToolName = "fork_repo"
ListMyReposToolName = "list_my_repos" ListMyReposToolName = "list_my_repos"
ListOrgReposToolName = "list_org_repos"
) )
var ( var (
@@ -55,14 +54,6 @@ var (
ListMyReposToolName, ListMyReposToolName,
mcp.WithDescription("List my repositories"), mcp.WithDescription("List my repositories"),
mcp.WithNumber("page", mcp.Required(), mcp.Description("Page number"), mcp.DefaultNumber(1), mcp.Min(1)), mcp.WithNumber("page", mcp.Required(), mcp.Description("Page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Required(), mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)),
)
ListOrgReposTool = mcp.NewTool(
ListOrgReposToolName,
mcp.WithDescription("List repositories of an organization"),
mcp.WithString("org", mcp.Required(), mcp.Description("Organization name")),
mcp.WithNumber("page", mcp.Required(), mcp.Description("Page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("pageSize", mcp.Required(), mcp.Description("Page size number"), mcp.DefaultNumber(100), mcp.Min(1)), mcp.WithNumber("pageSize", mcp.Required(), mcp.Description("Page size number"), mcp.DefaultNumber(100), mcp.Min(1)),
) )
) )
@@ -80,29 +71,57 @@ func init() {
Tool: ListMyReposTool, Tool: ListMyReposTool,
Handler: ListMyReposFn, Handler: ListMyReposFn,
}) })
Tool.RegisterRead(server.ServerTool{ }
Tool: ListOrgReposTool,
Handler: ListOrgReposFn, func RegisterTool(s *server.MCPServer) {
}) s.AddTool(CreateRepoTool, CreateRepoFn)
s.AddTool(ForkRepoTool, ForkRepoFn)
s.AddTool(ListMyReposTool, ListMyReposFn)
// File
s.AddTool(GetFileContentTool, GetFileContentFn)
s.AddTool(CreateFileTool, CreateFileFn)
s.AddTool(UpdateFileTool, UpdateFileFn)
s.AddTool(DeleteFileTool, DeleteFileFn)
// Branch
s.AddTool(CreateBranchTool, CreateBranchFn)
s.AddTool(DeleteBranchTool, DeleteBranchFn)
s.AddTool(ListBranchesTool, ListBranchesFn)
// Release
s.AddTool(CreateReleaseTool, CreateReleaseFn)
s.AddTool(DeleteReleaseTool, DeleteReleaseFn)
s.AddTool(GetReleaseTool, GetReleaseFn)
s.AddTool(GetLatestReleaseTool, GetLatestReleaseFn)
s.AddTool(ListReleasesTool, ListReleasesFn)
// Tag
s.AddTool(CreateTagTool, CreateTagFn)
s.AddTool(DeleteTagTool, DeleteTagFn)
s.AddTool(GetTagTool, GetTagFn)
s.AddTool(ListTagsTool, ListTagsFn)
// Commit
s.AddTool(ListRepoCommitsTool, ListRepoCommitsFn)
} }
func CreateRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func CreateRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateRepoFn") log.Debugf("Called CreateRepoFn")
args := req.GetArguments() name, ok := req.GetArguments()["name"].(string)
name, err := params.GetString(args, "name") if !ok {
if err != nil { return to.ErrorResult(errors.New("repository name is required"))
return to.ErrorResult(err)
} }
description, _ := args["description"].(string) description, _ := req.GetArguments()["description"].(string)
private, _ := args["private"].(bool) private, _ := req.GetArguments()["private"].(bool)
issueLabels, _ := args["issue_labels"].(string) issueLabels, _ := req.GetArguments()["issue_labels"].(string)
autoInit, _ := args["auto_init"].(bool) autoInit, _ := req.GetArguments()["auto_init"].(bool)
template, _ := args["template"].(bool) template, _ := req.GetArguments()["template"].(bool)
gitignores, _ := args["gitignores"].(string) gitignores, _ := req.GetArguments()["gitignores"].(string)
license, _ := args["license"].(string) license, _ := req.GetArguments()["license"].(string)
readme, _ := args["readme"].(string) readme, _ := req.GetArguments()["readme"].(string)
defaultBranch, _ := args["default_branch"].(string) defaultBranch, _ := req.GetArguments()["default_branch"].(string)
organization, _ := args["organization"].(string) organization, _ := req.GetArguments()["organization"].(string)
opt := gitea_sdk.CreateRepoOption{ opt := gitea_sdk.CreateRepoOption{
Name: name, Name: name,
@@ -133,27 +152,26 @@ func CreateRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRe
return to.ErrorResult(fmt.Errorf("create repository '%s' err: %v", name, err)) return to.ErrorResult(fmt.Errorf("create repository '%s' err: %v", name, err))
} }
} }
return to.TextResult(slimRepo(repo)) return to.TextResult(repo)
} }
func ForkRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ForkRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ForkRepoFn") log.Debugf("Called ForkRepoFn")
args := req.GetArguments() user, ok := req.GetArguments()["user"].(string)
user, err := params.GetString(args, "user") if !ok {
if err != nil { return to.ErrorResult(errors.New("user name is required"))
return to.ErrorResult(err)
} }
repo, err := params.GetString(args, "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(errors.New("repository name is required"))
} }
organization, ok := args["organization"].(string) organization, ok := req.GetArguments()["organization"].(string)
organizationPtr := new(organization) organizationPtr := ptr.To(organization)
if !ok || organization == "" { if !ok || organization == "" {
organizationPtr = nil organizationPtr = nil
} }
name, ok := args["name"].(string) name, ok := req.GetArguments()["name"].(string)
namePtr := new(name) namePtr := ptr.To(name)
if !ok || name == "" { if !ok || name == "" {
namePtr = nil namePtr = nil
} }
@@ -174,11 +192,18 @@ func ForkRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResu
func ListMyReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListMyReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListMyReposFn") log.Debugf("Called ListMyReposFn")
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, ok := req.GetArguments()["page"].(float64)
if !ok {
page = 1
}
pageSize, ok := req.GetArguments()["pageSize"].(float64)
if !ok {
pageSize = 100
}
opt := gitea_sdk.ListReposOptions{ opt := gitea_sdk.ListReposOptions{
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: page, Page: int(page),
PageSize: pageSize, PageSize: int(pageSize),
}, },
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -190,36 +215,5 @@ func ListMyReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR
return to.ErrorResult(fmt.Errorf("list my repositories error: %v", err)) return to.ErrorResult(fmt.Errorf("list my repositories error: %v", err))
} }
return to.TextResult(slimRepos(repos))
}
func ListOrgReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListOrgReposFn")
org, ok := req.GetArguments()["org"].(string)
if !ok {
return to.ErrorResult(errors.New("organization name is required"))
}
page, ok := req.GetArguments()["page"].(float64)
if !ok {
page = 1
}
pageSize, ok := req.GetArguments()["pageSize"].(float64)
if !ok {
pageSize = 100
}
opt := gitea_sdk.ListOrgReposOptions{
ListOptions: gitea_sdk.ListOptions{
Page: int(page),
PageSize: int(pageSize),
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
repos, _, err := client.ListOrgRepos(org, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list organization '%s' repositories error: %v", org, err))
}
return to.TextResult(repos) return to.TextResult(repos)
} }

View File

@@ -1,201 +0,0 @@
package repo
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func userLogin(u *gitea_sdk.User) string {
if u == nil {
return ""
}
return u.UserName
}
func slimRepo(r *gitea_sdk.Repository) map[string]any {
if r == nil {
return nil
}
m := map[string]any{
"id": r.ID,
"full_name": r.FullName,
"description": r.Description,
"html_url": r.HTMLURL,
"clone_url": r.CloneURL,
"ssh_url": r.SSHURL,
"default_branch": r.DefaultBranch,
"private": r.Private,
"fork": r.Fork,
"archived": r.Archived,
"language": r.Language,
"stars_count": r.Stars,
"forks_count": r.Forks,
"open_issues_count": r.OpenIssues,
"open_pr_counter": r.OpenPulls,
"created_at": r.Created,
"updated_at": r.Updated,
}
if r.Owner != nil {
m["owner"] = r.Owner.UserName
}
if len(r.Topics) > 0 {
m["topics"] = r.Topics
}
return m
}
func slimRepos(repos []*gitea_sdk.Repository) []map[string]any {
out := make([]map[string]any, 0, len(repos))
for _, r := range repos {
out = append(out, slimRepo(r))
}
return out
}
func slimBranch(b *gitea_sdk.Branch) map[string]any {
if b == nil {
return nil
}
m := map[string]any{
"name": b.Name,
"protected": b.Protected,
}
if b.Commit != nil {
m["commit_sha"] = b.Commit.ID
}
return m
}
func slimBranches(branches []*gitea_sdk.Branch) []map[string]any {
out := make([]map[string]any, 0, len(branches))
for _, b := range branches {
out = append(out, slimBranch(b))
}
return out
}
func slimCommit(c *gitea_sdk.Commit) map[string]any {
if c == nil {
return nil
}
m := map[string]any{
"sha": c.SHA,
"html_url": c.HTMLURL,
"created": c.Created,
}
if c.RepoCommit != nil {
m["message"] = c.RepoCommit.Message
if c.RepoCommit.Author != nil {
m["author"] = map[string]any{
"name": c.RepoCommit.Author.Name,
"email": c.RepoCommit.Author.Email,
"date": c.RepoCommit.Author.Date,
}
}
}
return m
}
func slimCommits(commits []*gitea_sdk.Commit) []map[string]any {
out := make([]map[string]any, 0, len(commits))
for _, c := range commits {
out = append(out, slimCommit(c))
}
return out
}
func slimTag(t *gitea_sdk.Tag) map[string]any {
if t == nil {
return nil
}
m := map[string]any{
"name": t.Name,
"message": t.Message,
}
if t.Commit != nil {
m["commit_sha"] = t.Commit.SHA
}
return m
}
func slimTags(tags []*gitea_sdk.Tag) []map[string]any {
out := make([]map[string]any, 0, len(tags))
for _, t := range tags {
m := map[string]any{
"name": t.Name,
}
if t.Commit != nil {
m["commit_sha"] = t.Commit.SHA
}
out = append(out, m)
}
return out
}
func slimRelease(r *gitea_sdk.Release) map[string]any {
if r == nil {
return nil
}
return map[string]any{
"id": r.ID,
"tag_name": r.TagName,
"target": r.Target,
"title": r.Title,
"body": r.Note,
"draft": r.IsDraft,
"prerelease": r.IsPrerelease,
"html_url": r.HTMLURL,
"author": userLogin(r.Publisher),
"created_at": r.CreatedAt,
"published_at": r.PublishedAt,
}
}
func slimReleases(releases []*gitea_sdk.Release) []map[string]any {
out := make([]map[string]any, 0, len(releases))
for _, r := range releases {
out = append(out, slimRelease(r))
}
return out
}
func slimContents(c *gitea_sdk.ContentsResponse) map[string]any {
if c == nil {
return nil
}
m := map[string]any{
"name": c.Name,
"path": c.Path,
"sha": c.SHA,
"type": c.Type,
"size": c.Size,
}
if c.Content != nil {
m["content"] = *c.Content
}
if c.Encoding != nil {
m["encoding"] = *c.Encoding
}
if c.HTMLURL != nil {
m["html_url"] = *c.HTMLURL
}
if c.DownloadURL != nil {
m["download_url"] = *c.DownloadURL
}
return m
}
func slimDirEntries(entries []*gitea_sdk.ContentsResponse) []map[string]any {
out := make([]map[string]any, 0, len(entries))
for _, c := range entries {
if c == nil {
continue
}
out = append(out, map[string]any{
"name": c.Name,
"path": c.Path,
"type": c.Type,
"size": c.Size,
})
}
return out
}

View File

@@ -1,142 +0,0 @@
package repo
import (
"testing"
gitea_sdk "code.gitea.io/sdk/gitea"
)
func TestSlimRepo(t *testing.T) {
r := &gitea_sdk.Repository{
ID: 1,
FullName: "org/repo",
Description: "A test repo",
HTMLURL: "https://gitea.com/org/repo",
CloneURL: "https://gitea.com/org/repo.git",
SSHURL: "git@gitea.com:org/repo.git",
DefaultBranch: "main",
Private: false,
Fork: false,
Archived: false,
Language: "Go",
Stars: 10,
Forks: 2,
Owner: &gitea_sdk.User{UserName: "org"},
Topics: []string{"mcp", "gitea"},
}
m := slimRepo(r)
if m["full_name"] != "org/repo" {
t.Errorf("expected full_name org/repo, got %v", m["full_name"])
}
if m["owner"] != "org" {
t.Errorf("expected owner org, got %v", m["owner"])
}
topics := m["topics"].([]string)
if len(topics) != 2 {
t.Errorf("expected 2 topics, got %d", len(topics))
}
}
func TestSlimTag(t *testing.T) {
tag := &gitea_sdk.Tag{
Name: "v1.0.0",
Message: "Release v1.0.0",
Commit: &gitea_sdk.CommitMeta{SHA: "abc123"},
}
m := slimTag(tag)
if m["name"] != "v1.0.0" {
t.Errorf("expected name v1.0.0, got %v", m["name"])
}
if m["message"] != "Release v1.0.0" {
t.Errorf("expected message, got %v", m["message"])
}
// List variant omits message
list := slimTags([]*gitea_sdk.Tag{tag})
if _, ok := list[0]["message"]; ok {
t.Error("Tags list should omit message")
}
if list[0]["name"] != "v1.0.0" {
t.Errorf("expected name in list, got %v", list[0]["name"])
}
}
func TestSlimRelease(t *testing.T) {
r := &gitea_sdk.Release{
ID: 1,
TagName: "v1.0.0",
Title: "First Release",
Note: "Release notes",
IsDraft: false,
Publisher: &gitea_sdk.User{UserName: "alice"},
}
m := slimRelease(r)
if m["tag_name"] != "v1.0.0" {
t.Errorf("expected tag_name v1.0.0, got %v", m["tag_name"])
}
if m["body"] != "Release notes" {
t.Errorf("expected body from Note field, got %v", m["body"])
}
if m["author"] != "alice" {
t.Errorf("expected author alice, got %v", m["author"])
}
}
func TestSlimContents(t *testing.T) {
content := "package main"
encoding := "base64"
htmlURL := "https://gitea.com/org/repo/src/branch/main/main.go"
c := &gitea_sdk.ContentsResponse{
Name: "main.go",
Path: "main.go",
SHA: "abc123",
Type: "file",
Size: 12,
Content: &content,
Encoding: &encoding,
HTMLURL: &htmlURL,
}
m := slimContents(c)
if m["name"] != "main.go" {
t.Errorf("expected name main.go, got %v", m["name"])
}
if m["content"] != "package main" {
t.Errorf("expected content, got %v", m["content"])
}
}
func TestSlimDirEntries(t *testing.T) {
entries := []*gitea_sdk.ContentsResponse{
{Name: "src", Path: "src", Type: "dir", Size: 0},
{Name: "main.go", Path: "main.go", Type: "file", Size: 100},
}
result := slimDirEntries(entries)
if len(result) != 2 {
t.Fatalf("expected 2 entries, got %d", len(result))
}
if result[0]["name"] != "src" {
t.Errorf("expected first entry name src, got %v", result[0]["name"])
}
// Dir entries should not have content
if _, ok := result[0]["content"]; ok {
t.Error("dir entries should not have content field")
}
}
func TestSlimTags_Nil(t *testing.T) {
if r := slimTags(nil); len(r) != 0 {
t.Errorf("expected empty slice, got %v", r)
}
}
func TestSlimReleases_Nil(t *testing.T) {
if r := slimReleases(nil); len(r) != 0 {
t.Errorf("expected empty slice, got %v", r)
}
}

View File

@@ -6,7 +6,6 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "code.gitea.io/sdk/gitea" gitea_sdk "code.gitea.io/sdk/gitea"
@@ -54,7 +53,7 @@ var (
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)), mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(20), mcp.Min(1)), mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(20), mcp.Min(1)),
) )
) )
@@ -77,23 +76,31 @@ func init() {
}) })
} }
// To avoid return too many tokens, we need to provide at least information as possible
// llm can call get tag to get more information
type ListTagResult struct {
ID string `json:"id"`
Name string `json:"name"`
Commit *gitea_sdk.CommitMeta `json:"commit"`
// message may be a long text, so we should not provide it here
}
func CreateTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func CreateTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateTagFn") log.Debugf("Called CreateTagFn")
args := req.GetArguments() owner, ok := req.GetArguments()["owner"].(string)
owner, err := params.GetString(args, "owner") if !ok {
if err != nil { return nil, fmt.Errorf("owner is required")
return to.ErrorResult(err)
} }
repo, err := params.GetString(args, "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return nil, fmt.Errorf("repo is required")
} }
tagName, err := params.GetString(args, "tag_name") tagName, ok := req.GetArguments()["tag_name"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return nil, fmt.Errorf("tag_name is required")
} }
target, _ := args["target"].(string) target, _ := req.GetArguments()["target"].(string)
message, _ := args["message"].(string) message, _ := req.GetArguments()["message"].(string)
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
@@ -113,18 +120,17 @@ func CreateTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRes
func DeleteTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func DeleteTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteTagFn") log.Debugf("Called DeleteTagFn")
args := req.GetArguments() owner, ok := req.GetArguments()["owner"].(string)
owner, err := params.GetString(args, "owner") if !ok {
if err != nil { return nil, fmt.Errorf("owner is required")
return to.ErrorResult(err)
} }
repo, err := params.GetString(args, "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return nil, fmt.Errorf("repo is required")
} }
tagName, err := params.GetString(args, "tag_name") tagName, ok := req.GetArguments()["tag_name"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return nil, fmt.Errorf("tag_name is required")
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -141,18 +147,17 @@ func DeleteTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRes
func GetTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetTagFn") log.Debugf("Called GetTagFn")
args := req.GetArguments() owner, ok := req.GetArguments()["owner"].(string)
owner, err := params.GetString(args, "owner") if !ok {
if err != nil { return nil, fmt.Errorf("owner is required")
return to.ErrorResult(err)
} }
repo, err := params.GetString(args, "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return nil, fmt.Errorf("repo is required")
} }
tagName, err := params.GetString(args, "tag_name") tagName, ok := req.GetArguments()["tag_name"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return nil, fmt.Errorf("tag_name is required")
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -164,22 +169,21 @@ func GetTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult
return nil, fmt.Errorf("get tag error: %v", err) return nil, fmt.Errorf("get tag error: %v", err)
} }
return to.TextResult(slimTag(tag)) return to.TextResult(tag)
} }
func ListTagsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListTagsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListTagsFn") log.Debugf("Called ListTagsFn")
args := req.GetArguments() owner, ok := req.GetArguments()["owner"].(string)
owner, err := params.GetString(args, "owner") if !ok {
if err != nil { return nil, fmt.Errorf("owner is required")
return to.ErrorResult(err)
} }
repo, err := params.GetString(args, "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return nil, fmt.Errorf("repo is required")
} }
page := params.GetOptionalInt(args, "page", 1) page, _ := req.GetArguments()["page"].(float64)
pageSize := params.GetOptionalInt(args, "perPage", 20) pageSize, _ := req.GetArguments()["pageSize"].(float64)
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
@@ -195,5 +199,13 @@ func ListTagsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResu
return nil, fmt.Errorf("list tags error: %v", err) return nil, fmt.Errorf("list tags error: %v", err)
} }
return to.TextResult(slimTags(tags)) results := make([]ListTagResult, 0, len(tags))
for _, tag := range tags {
results = append(results, ListTagResult{
ID: tag.ID,
Name: tag.Name,
Commit: tag.Commit,
})
}
return to.TextResult(results)
} }

View File

@@ -6,7 +6,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params" "gitea.com/gitea/gitea-mcp/pkg/ptr"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
@@ -27,25 +27,25 @@ var (
SearchUsersTool = mcp.NewTool( SearchUsersTool = mcp.NewTool(
SearchUsersToolName, SearchUsersToolName,
mcp.WithDescription("search users"), mcp.WithDescription("search users"),
mcp.WithString("keyword", mcp.Required(), mcp.Description("Keyword")), mcp.WithString("keyword", mcp.Description("Keyword")),
mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)), mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)), mcp.WithNumber("pageSize", mcp.Description("PageSize"), mcp.DefaultNumber(100)),
) )
SearOrgTeamsTool = mcp.NewTool( SearOrgTeamsTool = mcp.NewTool(
SearchOrgTeamsToolName, SearchOrgTeamsToolName,
mcp.WithDescription("search organization teams"), mcp.WithDescription("search organization teams"),
mcp.WithString("org", mcp.Required(), mcp.Description("organization name")), mcp.WithString("org", mcp.Description("organization name")),
mcp.WithString("query", mcp.Required(), mcp.Description("search organization teams")), mcp.WithString("query", mcp.Description("search organization teams")),
mcp.WithBoolean("includeDescription", mcp.Description("include description?")), mcp.WithBoolean("includeDescription", mcp.Description("include description?")),
mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)), mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)), mcp.WithNumber("pageSize", mcp.Description("PageSize"), mcp.DefaultNumber(100)),
) )
SearchReposTool = mcp.NewTool( SearchReposTool = mcp.NewTool(
SearchReposToolName, SearchReposToolName,
mcp.WithDescription("search repos"), mcp.WithDescription("search repos"),
mcp.WithString("keyword", mcp.Required(), mcp.Description("Keyword")), mcp.WithString("keyword", mcp.Description("Keyword")),
mcp.WithBoolean("keywordIsTopic", mcp.Description("KeywordIsTopic")), mcp.WithBoolean("keywordIsTopic", mcp.Description("KeywordIsTopic")),
mcp.WithBoolean("keywordInDescription", mcp.Description("KeywordInDescription")), mcp.WithBoolean("keywordInDescription", mcp.Description("KeywordInDescription")),
mcp.WithNumber("ownerID", mcp.Description("OwnerID")), mcp.WithNumber("ownerID", mcp.Description("OwnerID")),
@@ -54,37 +54,44 @@ var (
mcp.WithString("sort", mcp.Description("Sort")), mcp.WithString("sort", mcp.Description("Sort")),
mcp.WithString("order", mcp.Description("Order")), mcp.WithString("order", mcp.Description("Order")),
mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)), mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)), mcp.WithNumber("pageSize", mcp.Description("PageSize"), mcp.DefaultNumber(100)),
) )
) )
func init() { func init() {
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(server.ServerTool{
Tool: SearchUsersTool, Tool: SearchUsersTool,
Handler: UsersFn, Handler: SearchUsersFn,
}) })
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(server.ServerTool{
Tool: SearOrgTeamsTool, Tool: SearOrgTeamsTool,
Handler: OrgTeamsFn, Handler: SearchOrgTeamsFn,
}) })
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(server.ServerTool{
Tool: SearchReposTool, Tool: SearchReposTool,
Handler: ReposFn, Handler: SearchReposFn,
}) })
} }
func UsersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func SearchUsersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called UsersFn") log.Debugf("Called SearchUsersFn")
keyword, err := params.GetString(req.GetArguments(), "keyword") keyword, ok := req.GetArguments()["keyword"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("keyword is required"))
}
page, ok := req.GetArguments()["page"].(float64)
if !ok {
page = 1
}
pageSize, ok := req.GetArguments()["pageSize"].(float64)
if !ok {
pageSize = 100
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.SearchUsersOption{ opt := gitea_sdk.SearchUsersOption{
KeyWord: keyword, KeyWord: keyword,
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: page, Page: int(page),
PageSize: pageSize, PageSize: int(pageSize),
}, },
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -95,27 +102,34 @@ func UsersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult,
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("search users err: %v", err)) return to.ErrorResult(fmt.Errorf("search users err: %v", err))
} }
return to.TextResult(slimUserDetails(users)) return to.TextResult(users)
} }
func OrgTeamsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func SearchOrgTeamsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called OrgTeamsFn") log.Debugf("Called SearchOrgTeamsFn")
org, err := params.GetString(req.GetArguments(), "org") org, ok := req.GetArguments()["org"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("organization is required"))
} }
query, err := params.GetString(req.GetArguments(), "query") query, ok := req.GetArguments()["query"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("query is required"))
} }
includeDescription, _ := req.GetArguments()["includeDescription"].(bool) includeDescription, _ := req.GetArguments()["includeDescription"].(bool)
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, ok := req.GetArguments()["page"].(float64)
if !ok {
page = 1
}
pageSize, ok := req.GetArguments()["pageSize"].(float64)
if !ok {
pageSize = 100
}
opt := gitea_sdk.SearchTeamsOptions{ opt := gitea_sdk.SearchTeamsOptions{
Query: query, Query: query,
IncludeDescription: includeDescription, IncludeDescription: includeDescription,
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: page, Page: int(page),
PageSize: pageSize, PageSize: int(pageSize),
}, },
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -126,43 +140,50 @@ func OrgTeamsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResu
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("search organization teams error: %v", err)) return to.ErrorResult(fmt.Errorf("search organization teams error: %v", err))
} }
return to.TextResult(slimTeams(teams)) return to.TextResult(teams)
} }
func ReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func SearchReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ReposFn") log.Debugf("Called SearchReposFn")
keyword, err := params.GetString(req.GetArguments(), "keyword") keyword, ok := req.GetArguments()["keyword"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("keyword is required"))
} }
keywordIsTopic, _ := req.GetArguments()["keywordIsTopic"].(bool) keywordIsTopic, _ := req.GetArguments()["keywordIsTopic"].(bool)
keywordInDescription, _ := req.GetArguments()["keywordInDescription"].(bool) keywordInDescription, _ := req.GetArguments()["keywordInDescription"].(bool)
ownerID := params.GetOptionalInt(req.GetArguments(), "ownerID", 0) ownerID, _ := req.GetArguments()["ownerID"].(float64)
var pIsPrivate *bool var pIsPrivate *bool
isPrivate, ok := req.GetArguments()["isPrivate"].(bool) isPrivate, ok := req.GetArguments()["isPrivate"].(bool)
if ok { if ok {
pIsPrivate = new(isPrivate) pIsPrivate = ptr.To(isPrivate)
} }
var pIsArchived *bool var pIsArchived *bool
isArchived, ok := req.GetArguments()["isArchived"].(bool) isArchived, ok := req.GetArguments()["isArchived"].(bool)
if ok { if ok {
pIsArchived = new(isArchived) pIsArchived = ptr.To(isArchived)
} }
sort, _ := req.GetArguments()["sort"].(string) sort, _ := req.GetArguments()["sort"].(string)
order, _ := req.GetArguments()["order"].(string) order, _ := req.GetArguments()["order"].(string)
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, ok := req.GetArguments()["page"].(float64)
if !ok {
page = 1
}
pageSize, ok := req.GetArguments()["pageSize"].(float64)
if !ok {
pageSize = 100
}
opt := gitea_sdk.SearchRepoOptions{ opt := gitea_sdk.SearchRepoOptions{
Keyword: keyword, Keyword: keyword,
KeywordIsTopic: keywordIsTopic, KeywordIsTopic: keywordIsTopic,
KeywordInDescription: keywordInDescription, KeywordInDescription: keywordInDescription,
OwnerID: ownerID, OwnerID: int64(ownerID),
IsPrivate: pIsPrivate, IsPrivate: pIsPrivate,
IsArchived: pIsArchived, IsArchived: pIsArchived,
Sort: sort, Sort: sort,
Order: order, Order: order,
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: page, Page: int(page),
PageSize: pageSize, PageSize: int(pageSize),
}, },
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -173,5 +194,5 @@ func ReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult,
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("search repos error: %v", err)) return to.ErrorResult(fmt.Errorf("search repos error: %v", err))
} }
return to.TextResult(slimRepos(repos)) return to.TextResult(repos)
} }

View File

@@ -1,42 +0,0 @@
package search
import (
"slices"
"testing"
"github.com/mark3labs/mcp-go/mcp"
)
func TestSearchToolsRequiredFields(t *testing.T) {
tests := []struct {
name string
tool mcp.Tool
required []string
}{
{
name: "search_users",
tool: SearchUsersTool,
required: []string{"keyword"},
},
{
name: "search_org_teams",
tool: SearOrgTeamsTool,
required: []string{"org", "query"},
},
{
name: "search_repos",
tool: SearchReposTool,
required: []string{"keyword"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for _, field := range tt.required {
if !slices.Contains(tt.tool.InputSchema.Required, field) {
t.Errorf("tool %s: expected %q to be required, got required=%v", tt.name, field, tt.tool.InputSchema.Required)
}
}
})
}
}

View File

@@ -1,88 +0,0 @@
package search
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func slimUserDetail(u *gitea_sdk.User) map[string]any {
if u == nil {
return nil
}
return map[string]any{
"id": u.ID,
"login": u.UserName,
"full_name": u.FullName,
"email": u.Email,
"avatar_url": u.AvatarURL,
"html_url": u.HTMLURL,
"is_admin": u.IsAdmin,
}
}
func slimUserDetails(users []*gitea_sdk.User) []map[string]any {
out := make([]map[string]any, 0, len(users))
for _, u := range users {
out = append(out, slimUserDetail(u))
}
return out
}
func slimTeam(t *gitea_sdk.Team) map[string]any {
if t == nil {
return nil
}
return map[string]any{
"id": t.ID,
"name": t.Name,
"description": t.Description,
"permission": t.Permission,
}
}
func slimTeams(teams []*gitea_sdk.Team) []map[string]any {
out := make([]map[string]any, 0, len(teams))
for _, t := range teams {
out = append(out, slimTeam(t))
}
return out
}
func slimRepo(r *gitea_sdk.Repository) map[string]any {
if r == nil {
return nil
}
m := map[string]any{
"id": r.ID,
"full_name": r.FullName,
"description": r.Description,
"html_url": r.HTMLURL,
"clone_url": r.CloneURL,
"ssh_url": r.SSHURL,
"default_branch": r.DefaultBranch,
"private": r.Private,
"fork": r.Fork,
"archived": r.Archived,
"language": r.Language,
"stars_count": r.Stars,
"forks_count": r.Forks,
"open_issues_count": r.OpenIssues,
"open_pr_counter": r.OpenPulls,
"created_at": r.Created,
"updated_at": r.Updated,
}
if r.Owner != nil {
m["owner"] = r.Owner.UserName
}
if len(r.Topics) > 0 {
m["topics"] = r.Topics
}
return m
}
func slimRepos(repos []*gitea_sdk.Repository) []map[string]any {
out := make([]map[string]any, 0, len(repos))
for _, r := range repos {
out = append(out, slimRepo(r))
}
return out
}

View File

@@ -1,47 +0,0 @@
package timetracking
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func slimStopWatch(s *gitea_sdk.StopWatch) map[string]any {
if s == nil {
return nil
}
return map[string]any{
"issue_index": s.IssueIndex,
"issue_title": s.IssueTitle,
"repo_name": s.RepoName,
"repo_owner": s.RepoOwnerName,
"created": s.Created,
"seconds": s.Seconds,
}
}
func slimStopWatches(watches []*gitea_sdk.StopWatch) []map[string]any {
out := make([]map[string]any, 0, len(watches))
for _, s := range watches {
out = append(out, slimStopWatch(s))
}
return out
}
func slimTrackedTime(t *gitea_sdk.TrackedTime) map[string]any {
if t == nil {
return nil
}
return map[string]any{
"id": t.ID,
"time": t.Time,
"user_name": t.UserName,
"created": t.Created,
}
}
func slimTrackedTimes(times []*gitea_sdk.TrackedTime) []map[string]any {
out := make([]map[string]any, 0, len(times))
for _, t := range times {
out = append(out, slimTrackedTime(t))
}
return out
}

View File

@@ -19,90 +19,121 @@ import (
var Tool = tool.New() var Tool = tool.New()
const ( const (
TimetrackingReadToolName = "timetracking_read" // Stopwatch tools
TimetrackingWriteToolName = "timetracking_write" StartStopwatchToolName = "start_stopwatch"
StopStopwatchToolName = "stop_stopwatch"
DeleteStopwatchToolName = "delete_stopwatch"
GetMyStopwatchesToolName = "get_my_stopwatches"
// Tracked time tools
ListTrackedTimesToolName = "list_tracked_times"
AddTrackedTimeToolName = "add_tracked_time"
DeleteTrackedTimeToolName = "delete_tracked_time"
ListRepoTimesToolName = "list_repo_times"
GetMyTimesToolName = "get_my_times"
) )
var ( var (
TimetrackingReadTool = mcp.NewTool( // Stopwatch tools
TimetrackingReadToolName, StartStopwatchTool = mcp.NewTool(
mcp.WithDescription("Read time tracking data. Use method 'list_issue_times' for issue times, 'list_repo_times' for repository times, 'get_my_stopwatches' for active stopwatches, 'get_my_times' for all your tracked times."), StartStopwatchToolName,
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list_issue_times", "list_repo_times", "get_my_stopwatches", "get_my_times")), mcp.WithDescription("Start a stopwatch on an issue to track time spent"),
mcp.WithString("owner", mcp.Description("repository owner (required for 'list_issue_times', 'list_repo_times')")), mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Description("repository name (required for 'list_issue_times', 'list_repo_times')")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Description("issue index (required for 'list_issue_times')")), mcp.WithNumber("index", mcp.Required(), mcp.Description("issue index")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
) )
TimetrackingWriteTool = mcp.NewTool( StopStopwatchTool = mcp.NewTool(
TimetrackingWriteToolName, StopStopwatchToolName,
mcp.WithDescription("Manage time tracking: stopwatches and tracked time entries."), mcp.WithDescription("Stop a running stopwatch on an issue and record the tracked time"),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("start_stopwatch", "stop_stopwatch", "delete_stopwatch", "add_time", "delete_time")), mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("owner", mcp.Description("repository owner (required for all methods)")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("repo", mcp.Description("repository name (required for all methods)")), mcp.WithNumber("index", mcp.Required(), mcp.Description("issue index")),
mcp.WithNumber("index", mcp.Description("issue index (required for all methods)")), )
mcp.WithNumber("time", mcp.Description("time to add in seconds (required for 'add_time')")),
mcp.WithNumber("id", mcp.Description("tracked time entry ID (required for 'delete_time')")), DeleteStopwatchTool = mcp.NewTool(
DeleteStopwatchToolName,
mcp.WithDescription("Delete/cancel a running stopwatch without recording time"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("issue index")),
)
GetMyStopwatchesTool = mcp.NewTool(
GetMyStopwatchesToolName,
mcp.WithDescription("Get all currently running stopwatches for the authenticated user"),
)
// Tracked time tools
ListTrackedTimesTool = mcp.NewTool(
ListTrackedTimesToolName,
mcp.WithDescription("List tracked times for a specific issue"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("issue index")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(100)),
)
AddTrackedTimeTool = mcp.NewTool(
AddTrackedTimeToolName,
mcp.WithDescription("Manually add tracked time to an issue"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("issue index")),
mcp.WithNumber("time", mcp.Required(), mcp.Description("time to add in seconds")),
)
DeleteTrackedTimeTool = mcp.NewTool(
DeleteTrackedTimeToolName,
mcp.WithDescription("Delete a tracked time entry from an issue"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("issue index")),
mcp.WithNumber("id", mcp.Required(), mcp.Description("tracked time entry ID")),
)
ListRepoTimesTool = mcp.NewTool(
ListRepoTimesToolName,
mcp.WithDescription("List all tracked times for a repository"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(100)),
)
GetMyTimesTool = mcp.NewTool(
GetMyTimesToolName,
mcp.WithDescription("Get all tracked times for the authenticated user"),
) )
) )
func init() { func init() {
Tool.RegisterRead(server.ServerTool{Tool: TimetrackingReadTool, Handler: readFn}) // Stopwatch tools
Tool.RegisterWrite(server.ServerTool{Tool: TimetrackingWriteTool, Handler: writeFn}) Tool.RegisterWrite(server.ServerTool{Tool: StartStopwatchTool, Handler: StartStopwatchFn})
} Tool.RegisterWrite(server.ServerTool{Tool: StopStopwatchTool, Handler: StopStopwatchFn})
Tool.RegisterWrite(server.ServerTool{Tool: DeleteStopwatchTool, Handler: DeleteStopwatchFn})
Tool.RegisterRead(server.ServerTool{Tool: GetMyStopwatchesTool, Handler: GetMyStopwatchesFn})
func readFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Tracked time tools
method, err := params.GetString(req.GetArguments(), "method") Tool.RegisterRead(server.ServerTool{Tool: ListTrackedTimesTool, Handler: ListTrackedTimesFn})
if err != nil { Tool.RegisterWrite(server.ServerTool{Tool: AddTrackedTimeTool, Handler: AddTrackedTimeFn})
return to.ErrorResult(err) Tool.RegisterWrite(server.ServerTool{Tool: DeleteTrackedTimeTool, Handler: DeleteTrackedTimeFn})
} Tool.RegisterRead(server.ServerTool{Tool: ListRepoTimesTool, Handler: ListRepoTimesFn})
switch method { Tool.RegisterRead(server.ServerTool{Tool: GetMyTimesTool, Handler: GetMyTimesFn})
case "list_issue_times":
return listTrackedTimesFn(ctx, req)
case "list_repo_times":
return listRepoTimesFn(ctx, req)
case "get_my_stopwatches":
return getMyStopwatchesFn(ctx, req)
case "get_my_times":
return getMyTimesFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func writeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "start_stopwatch":
return startStopwatchFn(ctx, req)
case "stop_stopwatch":
return stopStopwatchFn(ctx, req)
case "delete_stopwatch":
return deleteStopwatchFn(ctx, req)
case "add_time":
return addTrackedTimeFn(ctx, req)
case "delete_time":
return deleteTrackedTimeFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
} }
// Stopwatch handler functions // Stopwatch handler functions
func startStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func StartStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called startStopwatchFn") log.Debugf("Called StartStopwatchFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, ok := req.GetArguments()["owner"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, err := params.GetIndex(req.GetArguments(), "index") index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil { if err != nil {
@@ -119,15 +150,15 @@ func startStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
return to.TextResult(fmt.Sprintf("Stopwatch started on issue %s/%s#%d", owner, repo, index)) return to.TextResult(fmt.Sprintf("Stopwatch started on issue %s/%s#%d", owner, repo, index))
} }
func stopStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func StopStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called stopStopwatchFn") log.Debugf("Called StopStopwatchFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, ok := req.GetArguments()["owner"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, err := params.GetIndex(req.GetArguments(), "index") index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil { if err != nil {
@@ -144,15 +175,15 @@ func stopStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
return to.TextResult(fmt.Sprintf("Stopwatch stopped on issue %s/%s#%d - time recorded", owner, repo, index)) return to.TextResult(fmt.Sprintf("Stopwatch stopped on issue %s/%s#%d - time recorded", owner, repo, index))
} }
func deleteStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func DeleteStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteStopwatchFn") log.Debugf("Called DeleteStopwatchFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, ok := req.GetArguments()["owner"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, err := params.GetIndex(req.GetArguments(), "index") index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil { if err != nil {
@@ -169,39 +200,46 @@ func deleteStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
return to.TextResult(fmt.Sprintf("Stopwatch deleted/cancelled on issue %s/%s#%d", owner, repo, index)) return to.TextResult(fmt.Sprintf("Stopwatch deleted/cancelled on issue %s/%s#%d", owner, repo, index))
} }
func getMyStopwatchesFn(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetMyStopwatchesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getMyStopwatchesFn") log.Debugf("Called GetMyStopwatchesFn")
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
stopwatches, _, err := client.ListMyStopwatches(gitea_sdk.ListStopwatchesOptions{}) stopwatches, _, err := client.GetMyStopwatches()
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get stopwatches err: %v", err)) return to.ErrorResult(fmt.Errorf("get stopwatches err: %v", err))
} }
if len(stopwatches) == 0 { if len(stopwatches) == 0 {
return to.TextResult("No active stopwatches") return to.TextResult("No active stopwatches")
} }
return to.TextResult(slimStopWatches(stopwatches)) return to.TextResult(stopwatches)
} }
// Tracked time handler functions // Tracked time handler functions
func listTrackedTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListTrackedTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listTrackedTimesFn") log.Debugf("Called ListTrackedTimesFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, ok := req.GetArguments()["owner"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, err := params.GetIndex(req.GetArguments(), "index") index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, ok := req.GetArguments()["page"].(float64)
if !ok {
page = 1
}
pageSize, ok := req.GetArguments()["pageSize"].(float64)
if !ok {
pageSize = 100
}
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
@@ -209,8 +247,8 @@ func listTrackedTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
times, _, err := client.ListIssueTrackedTimes(owner, repo, index, gitea_sdk.ListTrackedTimesOptions{ times, _, err := client.ListIssueTrackedTimes(owner, repo, index, gitea_sdk.ListTrackedTimesOptions{
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: page, Page: int(page),
PageSize: pageSize, PageSize: int(pageSize),
}, },
}) })
if err != nil { if err != nil {
@@ -219,91 +257,98 @@ func listTrackedTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
if len(times) == 0 { if len(times) == 0 {
return to.TextResult(fmt.Sprintf("No tracked times for issue %s/%s#%d", owner, repo, index)) return to.TextResult(fmt.Sprintf("No tracked times for issue %s/%s#%d", owner, repo, index))
} }
return to.TextResult(slimTrackedTimes(times)) return to.TextResult(times)
} }
func addTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func AddTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called addTrackedTimeFn") log.Debugf("Called AddTrackedTimeFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, ok := req.GetArguments()["owner"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, err := params.GetIndex(req.GetArguments(), "index") index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
timeSeconds, err := params.GetIndex(req.GetArguments(), "time") timeSeconds, ok := req.GetArguments()["time"].(float64)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("time is required"))
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
trackedTime, _, err := client.AddTime(owner, repo, index, gitea_sdk.AddTimeOption{ trackedTime, _, err := client.AddTime(owner, repo, index, gitea_sdk.AddTimeOption{
Time: timeSeconds, Time: int64(timeSeconds),
}) })
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("add tracked time to %s/%s#%d err: %v", owner, repo, index, err)) return to.ErrorResult(fmt.Errorf("add tracked time to %s/%s#%d err: %v", owner, repo, index, err))
} }
return to.TextResult(slimTrackedTime(trackedTime)) return to.TextResult(trackedTime)
} }
func deleteTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func DeleteTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteTrackedTimeFn") log.Debugf("Called DeleteTrackedTimeFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, ok := req.GetArguments()["owner"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
index, err := params.GetIndex(req.GetArguments(), "index") index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
id, err := params.GetIndex(req.GetArguments(), "id") id, ok := req.GetArguments()["id"].(float64)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("id is required"))
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
_, err = client.DeleteTime(owner, repo, index, id) _, err = client.DeleteTime(owner, repo, index, int64(id))
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("delete tracked time %d from %s/%s#%d err: %v", id, owner, repo, index, err)) return to.ErrorResult(fmt.Errorf("delete tracked time %d from %s/%s#%d err: %v", int64(id), owner, repo, index, err))
} }
return to.TextResult(fmt.Sprintf("Tracked time entry %d deleted from issue %s/%s#%d", id, owner, repo, index)) return to.TextResult(fmt.Sprintf("Tracked time entry %d deleted from issue %s/%s#%d", int64(id), owner, repo, index))
} }
func listRepoTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListRepoTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoTimesFn") log.Debugf("Called ListRepoTimesFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, ok := req.GetArguments()["owner"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, ok := req.GetArguments()["page"].(float64)
if !ok {
page = 1
}
pageSize, ok := req.GetArguments()["pageSize"].(float64)
if !ok {
pageSize = 100
}
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
times, _, err := client.ListRepoTrackedTimes(owner, repo, gitea_sdk.ListTrackedTimesOptions{ times, _, err := client.ListRepoTrackedTimes(owner, repo, gitea_sdk.ListTrackedTimesOptions{
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: page, Page: int(page),
PageSize: pageSize, PageSize: int(pageSize),
}, },
}) })
if err != nil { if err != nil {
@@ -312,21 +357,21 @@ func listRepoTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
if len(times) == 0 { if len(times) == 0 {
return to.TextResult(fmt.Sprintf("No tracked times for repository %s/%s", owner, repo)) return to.TextResult(fmt.Sprintf("No tracked times for repository %s/%s", owner, repo))
} }
return to.TextResult(slimTrackedTimes(times)) return to.TextResult(times)
} }
func getMyTimesFn(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetMyTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getMyTimesFn") log.Debugf("Called GetMyTimesFn")
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
times, _, err := client.ListMyTrackedTimes(gitea_sdk.ListTrackedTimesOptions{}) times, _, err := client.GetMyTrackedTimes()
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get tracked times err: %v", err)) return to.ErrorResult(fmt.Errorf("get tracked times err: %v", err))
} }
if len(times) == 0 { if len(times) == 0 {
return to.TextResult("No tracked times found") return to.TextResult("No tracked times found")
} }
return to.TextResult(slimTrackedTimes(times)) return to.TextResult(times)
} }

View File

@@ -1,42 +0,0 @@
package user
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func slimUserDetail(u *gitea_sdk.User) map[string]any {
if u == nil {
return nil
}
return map[string]any{
"id": u.ID,
"login": u.UserName,
"full_name": u.FullName,
"email": u.Email,
"avatar_url": u.AvatarURL,
"html_url": u.HTMLURL,
"is_admin": u.IsAdmin,
}
}
func slimOrg(o *gitea_sdk.Organization) map[string]any {
if o == nil {
return nil
}
return map[string]any{
"id": o.ID,
"name": o.Name,
"full_name": o.FullName,
"description": o.Description,
"avatar_url": o.AvatarURL,
"website": o.Website,
}
}
func slimOrgs(orgs []*gitea_sdk.Organization) []map[string]any {
out := make([]map[string]any, 0, len(orgs))
for _, o := range orgs {
out = append(out, slimOrg(o))
}
return out
}

View File

@@ -1,39 +0,0 @@
package user
import (
"testing"
gitea_sdk "code.gitea.io/sdk/gitea"
)
func TestSlimUserDetail(t *testing.T) {
u := &gitea_sdk.User{
ID: 42,
UserName: "alice",
FullName: "Alice Smith",
Email: "alice@example.com",
AvatarURL: "https://gitea.com/avatars/42",
HTMLURL: "https://gitea.com/alice",
IsAdmin: true,
}
m := slimUserDetail(u)
if m["id"] != int64(42) {
t.Errorf("expected id 42, got %v", m["id"])
}
if m["login"] != "alice" {
t.Errorf("expected login alice, got %v", m["login"])
}
if m["full_name"] != "Alice Smith" {
t.Errorf("expected full_name Alice Smith, got %v", m["full_name"])
}
if m["is_admin"] != true {
t.Errorf("expected is_admin true, got %v", m["is_admin"])
}
}
func TestSlimUserDetail_Nil(t *testing.T) {
if m := slimUserDetail(nil); m != nil {
t.Errorf("expected nil for nil user, got %v", m)
}
}

View File

@@ -6,7 +6,6 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
@@ -16,15 +15,15 @@ import (
) )
const ( const (
// GetMyUserInfoToolName is the unique tool name used for MCP registration and lookup of the get_me command. // GetMyUserInfoToolName is the unique tool name used for MCP registration and lookup of the get_my_user_info command.
GetMyUserInfoToolName = "get_me" GetMyUserInfoToolName = "get_my_user_info"
// GetUserOrgsToolName is the unique tool name used for MCP registration and lookup of the get_user_orgs command. // GetUserOrgsToolName is the unique tool name used for MCP registration and lookup of the get_user_orgs command.
GetUserOrgsToolName = "get_user_orgs" GetUserOrgsToolName = "get_user_orgs"
// defaultPage is the default starting page number used for paginated organization listings. // defaultPage is the default starting page number used for paginated organization listings.
defaultPage = 1 defaultPage = 1
// defaultPageSize is the default number of organizations per page for paginated queries. // defaultPageSize is the default number of organizations per page for paginated queries.
defaultPageSize = 30 defaultPageSize = 100
) )
// Tool is the MCP tool manager instance for registering all MCP tools in this package. // Tool is the MCP tool manager instance for registering all MCP tools in this package.
@@ -39,12 +38,12 @@ var (
) )
// GetUserOrgsTool is the MCP tool for listing organizations for the authenticated user. // GetUserOrgsTool is the MCP tool for listing organizations for the authenticated user.
// It supports pagination via "page" and "perPage" arguments with default values specified above. // It supports pagination via "page" and "pageSize" arguments with default values specified above.
GetUserOrgsTool = mcp.NewTool( GetUserOrgsTool = mcp.NewTool(
GetUserOrgsToolName, GetUserOrgsToolName,
mcp.WithDescription("Get organizations associated with the authenticated user"), mcp.WithDescription("Get organizations associated with the authenticated user"),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(defaultPage)), mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(defaultPage)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(defaultPageSize)), mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(defaultPageSize)),
) )
) )
@@ -66,7 +65,17 @@ func registerTools() {
} }
} }
// GetUserInfoFn is the handler for "get_me" MCP tool requests. // getIntArg parses an integer argument from the MCP request arguments map.
// Returns def if missing, not a number, or less than 1. Used for pagination arguments.
func getIntArg(req mcp.CallToolRequest, name string, def int) int {
val, ok := req.GetArguments()[name].(float64)
if !ok || val < 1 {
return def
}
return int(val)
}
// GetUserInfoFn is the handler for "get_my_user_info" MCP tool requests.
// Logs invocation, fetches current user info from gitea, wraps result for MCP. // Logs invocation, fetches current user info from gitea, wraps result for MCP.
func GetUserInfoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetUserInfoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[User] Called GetUserInfoFn") log.Debugf("[User] Called GetUserInfoFn")
@@ -78,7 +87,7 @@ func GetUserInfoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get user info err: %v", err)) return to.ErrorResult(fmt.Errorf("get user info err: %v", err))
} }
return to.TextResult(slimUserDetail(user)) return to.TextResult(user)
} }
// GetUserOrgsFn is the handler for "get_user_orgs" MCP tool requests. // GetUserOrgsFn is the handler for "get_user_orgs" MCP tool requests.
@@ -86,7 +95,8 @@ func GetUserInfoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR
// performs Gitea organization listing, and wraps the result for MCP. // performs Gitea organization listing, and wraps the result for MCP.
func GetUserOrgsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetUserOrgsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[User] Called GetUserOrgsFn") log.Debugf("[User] Called GetUserOrgsFn")
page, pageSize := params.GetPagination(req.GetArguments(), defaultPageSize) page := getIntArg(req, "page", defaultPage)
pageSize := getIntArg(req, "pageSize", defaultPageSize)
opt := gitea_sdk.ListOrgsOptions{ opt := gitea_sdk.ListOrgsOptions{
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
@@ -102,5 +112,5 @@ func GetUserOrgsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get user orgs err: %v", err)) return to.ErrorResult(fmt.Errorf("get user orgs err: %v", err))
} }
return to.TextResult(slimOrgs(orgs)) return to.TextResult(orgs)
} }

View File

@@ -7,7 +7,6 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
@@ -18,92 +17,109 @@ import (
var Tool = tool.New() var Tool = tool.New()
const ( const (
WikiReadToolName = "wiki_read" ListWikiPagesToolName = "list_wiki_pages"
WikiWriteToolName = "wiki_write" GetWikiPageToolName = "get_wiki_page"
GetWikiRevisionsToolName = "get_wiki_revisions"
CreateWikiPageToolName = "create_wiki_page"
UpdateWikiPageToolName = "update_wiki_page"
DeleteWikiPageToolName = "delete_wiki_page"
) )
var ( var (
WikiReadTool = mcp.NewTool( ListWikiPagesTool = mcp.NewTool(
WikiReadToolName, ListWikiPagesToolName,
mcp.WithDescription("Read wiki page information. Use method 'list' to list pages, 'get' to get page content, 'get_revisions' for revision history."), mcp.WithDescription("List all wiki pages in a repository"),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list", "get", "get_revisions")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("pageName", mcp.Description("wiki page name (required for 'get', 'get_revisions')")),
) )
WikiWriteTool = mcp.NewTool( GetWikiPageTool = mcp.NewTool(
WikiWriteToolName, GetWikiPageToolName,
mcp.WithDescription("Create, update, or delete wiki pages."), mcp.WithDescription("Get a wiki page content and metadata"),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "update", "delete")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("pageName", mcp.Description("wiki page name (required for 'update', 'delete')")), mcp.WithString("pageName", mcp.Required(), mcp.Description("wiki page name")),
mcp.WithString("title", mcp.Description("wiki page title (required for 'create', optional for 'update')")), )
mcp.WithString("content_base64", mcp.Description("page content, base64 encoded (required for 'create', 'update')")),
mcp.WithString("message", mcp.Description("commit message")), GetWikiRevisionsTool = mcp.NewTool(
GetWikiRevisionsToolName,
mcp.WithDescription("Get revisions history of a wiki page"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("pageName", mcp.Required(), mcp.Description("wiki page name")),
)
CreateWikiPageTool = mcp.NewTool(
CreateWikiPageToolName,
mcp.WithDescription("Create a new wiki page"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("title", mcp.Required(), mcp.Description("wiki page title")),
mcp.WithString("content_base64", mcp.Required(), mcp.Description("page content, base64 encoded")),
mcp.WithString("message", mcp.Description("commit message (optional)")),
)
UpdateWikiPageTool = mcp.NewTool(
UpdateWikiPageToolName,
mcp.WithDescription("Update an existing wiki page"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("pageName", mcp.Required(), mcp.Description("current wiki page name")),
mcp.WithString("title", mcp.Description("new page title (optional)")),
mcp.WithString("content_base64", mcp.Required(), mcp.Description("page content, base64 encoded")),
mcp.WithString("message", mcp.Description("commit message (optional)")),
)
DeleteWikiPageTool = mcp.NewTool(
DeleteWikiPageToolName,
mcp.WithDescription("Delete a wiki page"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("pageName", mcp.Required(), mcp.Description("wiki page name to delete")),
) )
) )
func init() { func init() {
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(server.ServerTool{
Tool: WikiReadTool, Tool: ListWikiPagesTool,
Handler: wikiReadFn, Handler: ListWikiPagesFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetWikiPageTool,
Handler: GetWikiPageFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetWikiRevisionsTool,
Handler: GetWikiRevisionsFn,
}) })
Tool.RegisterWrite(server.ServerTool{ Tool.RegisterWrite(server.ServerTool{
Tool: WikiWriteTool, Tool: CreateWikiPageTool,
Handler: wikiWriteFn, Handler: CreateWikiPageFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: UpdateWikiPageTool,
Handler: UpdateWikiPageFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: DeleteWikiPageTool,
Handler: DeleteWikiPageFn,
}) })
} }
func wikiReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListWikiPagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method") log.Debugf("Called ListWikiPagesFn")
if err != nil { owner, ok := req.GetArguments()["owner"].(string)
return to.ErrorResult(err) if !ok {
} return to.ErrorResult(fmt.Errorf("owner is required"))
switch method {
case "list":
return listWikiPagesFn(ctx, req)
case "get":
return getWikiPageFn(ctx, req)
case "get_revisions":
return getWikiRevisionsFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
repo, ok := req.GetArguments()["repo"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("repo is required"))
} }
func wikiWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Use direct HTTP request because SDK does not support yet wikis
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "create":
return createWikiPageFn(ctx, req)
case "update":
return updateWikiPageFn(ctx, req)
case "delete":
return deleteWikiPageFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func listWikiPagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listWikiPagesFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
var result any var result any
_, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/wiki/pages", url.PathEscape(owner), url.PathEscape(repo)), nil, nil, &result) _, err := gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/wiki/pages", url.QueryEscape(owner), url.QueryEscape(repo)), nil, nil, &result)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("list wiki pages err: %v", err)) return to.ErrorResult(fmt.Errorf("list wiki pages err: %v", err))
} }
@@ -111,24 +127,23 @@ func listWikiPagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
return to.TextResult(result) return to.TextResult(result)
} }
func getWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getWikiPageFn") log.Debugf("Called GetWikiPageFn")
args := req.GetArguments() owner, ok := req.GetArguments()["owner"].(string)
owner, err := params.GetString(args, "owner") if !ok {
if err != nil { return to.ErrorResult(fmt.Errorf("owner is required"))
return to.ErrorResult(err)
} }
repo, err := params.GetString(args, "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
pageName, err := params.GetString(args, "pageName") pageName, ok := req.GetArguments()["pageName"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("pageName is required"))
} }
var result any var result any
_, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(pageName)), nil, nil, &result) _, err := gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.QueryEscape(owner), url.QueryEscape(repo), url.QueryEscape(pageName)), nil, nil, &result)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get wiki page err: %v", err)) return to.ErrorResult(fmt.Errorf("get wiki page err: %v", err))
} }
@@ -136,24 +151,23 @@ func getWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR
return to.TextResult(result) return to.TextResult(result)
} }
func getWikiRevisionsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetWikiRevisionsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getWikiRevisionsFn") log.Debugf("Called GetWikiRevisionsFn")
args := req.GetArguments() owner, ok := req.GetArguments()["owner"].(string)
owner, err := params.GetString(args, "owner") if !ok {
if err != nil { return to.ErrorResult(fmt.Errorf("owner is required"))
return to.ErrorResult(err)
} }
repo, err := params.GetString(args, "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
pageName, err := params.GetString(args, "pageName") pageName, ok := req.GetArguments()["pageName"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("pageName is required"))
} }
var result any var result any
_, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/wiki/revisions/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(pageName)), nil, nil, &result) _, err := gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/wiki/revisions/%s", url.QueryEscape(owner), url.QueryEscape(repo), url.QueryEscape(pageName)), nil, nil, &result)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get wiki revisions err: %v", err)) return to.ErrorResult(fmt.Errorf("get wiki revisions err: %v", err))
} }
@@ -161,27 +175,26 @@ func getWikiRevisionsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
return to.TextResult(result) return to.TextResult(result)
} }
func createWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func CreateWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createWikiPageFn") log.Debugf("Called CreateWikiPageFn")
args := req.GetArguments() owner, ok := req.GetArguments()["owner"].(string)
owner, err := params.GetString(args, "owner") if !ok {
if err != nil { return to.ErrorResult(fmt.Errorf("owner is required"))
return to.ErrorResult(err)
} }
repo, err := params.GetString(args, "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
title, err := params.GetString(args, "title") title, ok := req.GetArguments()["title"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("title is required"))
} }
contentBase64, err := params.GetString(args, "content_base64") contentBase64, ok := req.GetArguments()["content_base64"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("content_base64 is required"))
} }
message, _ := args["message"].(string) message, _ := req.GetArguments()["message"].(string)
if message == "" { if message == "" {
message = fmt.Sprintf("Create wiki page '%s'", title) message = fmt.Sprintf("Create wiki page '%s'", title)
} }
@@ -193,7 +206,7 @@ func createWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
} }
var result any var result any
_, err = gitea.DoJSON(ctx, "POST", fmt.Sprintf("repos/%s/%s/wiki/new", url.PathEscape(owner), url.PathEscape(repo)), nil, requestBody, &result) _, err := gitea.DoJSON(ctx, "POST", fmt.Sprintf("repos/%s/%s/wiki/new", url.QueryEscape(owner), url.QueryEscape(repo)), nil, requestBody, &result)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("create wiki page err: %v", err)) return to.ErrorResult(fmt.Errorf("create wiki page err: %v", err))
} }
@@ -201,24 +214,23 @@ func createWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
return to.TextResult(result) return to.TextResult(result)
} }
func updateWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func UpdateWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called updateWikiPageFn") log.Debugf("Called UpdateWikiPageFn")
args := req.GetArguments() owner, ok := req.GetArguments()["owner"].(string)
owner, err := params.GetString(args, "owner") if !ok {
if err != nil { return to.ErrorResult(fmt.Errorf("owner is required"))
return to.ErrorResult(err)
} }
repo, err := params.GetString(args, "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
pageName, err := params.GetString(args, "pageName") pageName, ok := req.GetArguments()["pageName"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("pageName is required"))
} }
contentBase64, err := params.GetString(args, "content_base64") contentBase64, ok := req.GetArguments()["content_base64"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("content_base64 is required"))
} }
requestBody := map[string]string{ requestBody := map[string]string{
@@ -226,20 +238,21 @@ func updateWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
} }
// If title is given, use it. Otherwise, keep current page name // If title is given, use it. Otherwise, keep current page name
if title, ok := args["title"].(string); ok && title != "" { if title, ok := req.GetArguments()["title"].(string); ok && title != "" {
requestBody["title"] = title requestBody["title"] = title
} else { } else {
// Utiliser pageName comme fallback pour éviter "unnamed"
requestBody["title"] = pageName requestBody["title"] = pageName
} }
if message, ok := args["message"].(string); ok && message != "" { if message, ok := req.GetArguments()["message"].(string); ok && message != "" {
requestBody["message"] = message requestBody["message"] = message
} else { } else {
requestBody["message"] = fmt.Sprintf("Update wiki page '%s'", pageName) requestBody["message"] = fmt.Sprintf("Update wiki page '%s'", pageName)
} }
var result any var result any
_, err = gitea.DoJSON(ctx, "PATCH", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(pageName)), nil, requestBody, &result) _, err := gitea.DoJSON(ctx, "PATCH", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.QueryEscape(owner), url.QueryEscape(repo), url.QueryEscape(pageName)), nil, requestBody, &result)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("update wiki page err: %v", err)) return to.ErrorResult(fmt.Errorf("update wiki page err: %v", err))
} }
@@ -247,23 +260,22 @@ func updateWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
return to.TextResult(result) return to.TextResult(result)
} }
func deleteWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func DeleteWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteWikiPageFn") log.Debugf("Called DeleteWikiPageFn")
args := req.GetArguments() owner, ok := req.GetArguments()["owner"].(string)
owner, err := params.GetString(args, "owner") if !ok {
if err != nil { return to.ErrorResult(fmt.Errorf("owner is required"))
return to.ErrorResult(err)
} }
repo, err := params.GetString(args, "repo") repo, ok := req.GetArguments()["repo"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("repo is required"))
} }
pageName, err := params.GetString(args, "pageName") pageName, ok := req.GetArguments()["pageName"].(string)
if err != nil { if !ok {
return to.ErrorResult(err) return to.ErrorResult(fmt.Errorf("pageName is required"))
} }
_, err = gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(pageName)), nil, nil, nil) _, err := gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.QueryEscape(owner), url.QueryEscape(repo), url.QueryEscape(pageName)), nil, nil, nil)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("delete wiki page err: %v", err)) return to.ErrorResult(fmt.Errorf("delete wiki page err: %v", err))
} }

View File

@@ -3,7 +3,6 @@ package gitea
import ( import (
"context" "context"
"crypto/tls" "crypto/tls"
"errors"
"fmt" "fmt"
"net/http" "net/http"
@@ -15,7 +14,6 @@ import (
func NewClient(token string) (*gitea.Client, error) { func NewClient(token string) (*gitea.Client, error) {
httpClient := &http.Client{ httpClient := &http.Client{
Transport: http.DefaultTransport, Transport: http.DefaultTransport,
CheckRedirect: checkRedirect,
} }
opts := []gitea.ClientOption{ opts := []gitea.ClientOption{
@@ -36,23 +34,10 @@ func NewClient(token string) (*gitea.Client, error) {
} }
// Set user agent for the client // Set user agent for the client
client.SetUserAgent("gitea-mcp-server/" + flag.Version) client.SetUserAgent(fmt.Sprintf("gitea-mcp-server/%s", flag.Version))
return client, nil return client, nil
} }
// checkRedirect prevents Go from silently changing mutating requests (POST, PATCH, etc.)
// to GET when following 301/302/303 redirects, which would drop the request body and
// make writes appear to succeed when they didn't.
func checkRedirect(_ *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
}
if via[0].Method != http.MethodGet && via[0].Method != http.MethodHead {
return http.ErrUseLastResponse
}
return nil
}
func ClientFromContext(ctx context.Context) (*gitea.Client, error) { func ClientFromContext(ctx context.Context) (*gitea.Client, error) {
token, ok := ctx.Value(mcpContext.TokenContextKey).(string) token, ok := ctx.Value(mcpContext.TokenContextKey).(string)
if !ok { if !ok {

View File

@@ -1,120 +0,0 @@
package gitea
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"gitea.com/gitea/gitea-mcp/pkg/flag"
)
func TestCheckRedirect(t *testing.T) {
for _, tc := range []struct {
name string
method string
wantErr error
}{
{"allows GET", http.MethodGet, nil},
{"allows HEAD", http.MethodHead, nil},
{"blocks PATCH", http.MethodPatch, http.ErrUseLastResponse},
{"blocks POST", http.MethodPost, http.ErrUseLastResponse},
{"blocks PUT", http.MethodPut, http.ErrUseLastResponse},
{"blocks DELETE", http.MethodDelete, http.ErrUseLastResponse},
} {
t.Run(tc.name, func(t *testing.T) {
via := []*http.Request{{Method: tc.method}}
err := checkRedirect(nil, via)
if err != tc.wantErr {
t.Fatalf("expected %v, got %v", tc.wantErr, err)
}
})
}
t.Run("stops after 10 redirects", func(t *testing.T) {
via := make([]*http.Request, 10)
for i := range via {
via[i] = &http.Request{Method: http.MethodGet}
}
err := checkRedirect(nil, via)
if err == nil || err == http.ErrUseLastResponse {
t.Fatalf("expected redirect limit error, got %v", err)
}
})
}
// TestDoJSON_RepoRenameRedirect is a regression test for the bug where a PATCH
// request to a renamed repo got a 301 redirect, Go's http.Client silently
// changed the method to GET, and the write appeared to succeed without error.
func TestDoJSON_RepoRenameRedirect(t *testing.T) {
// Simulate a Gitea API that returns 301 for the old repo name (like a renamed repo).
mux := http.NewServeMux()
mux.HandleFunc("PATCH /api/v1/repos/owner/old-name/pulls/1", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/api/v1/repos/owner/new-name/pulls/1", http.StatusMovedPermanently)
})
mux.HandleFunc("PATCH /api/v1/repos/owner/new-name/pulls/1", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"id":1,"title":"updated"}`)
})
mux.HandleFunc("GET /api/v1/repos/owner/new-name/pulls/1", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"id":1,"title":"not-updated"}`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
origHost := flag.Host
defer func() { flag.Host = origHost }()
flag.Host = srv.URL
var result map[string]any
status, err := DoJSON(context.Background(), http.MethodPatch, "repos/owner/old-name/pulls/1", nil, map[string]string{"title": "updated"}, &result)
if err != nil {
// The redirect should be blocked, returning the 301 response directly.
// DoJSON treats non-2xx as an error, which is the correct behavior.
if status != http.StatusMovedPermanently {
t.Fatalf("expected status 301, got %d (err: %v)", status, err)
}
return
}
// If we reach here without error, the redirect was followed. Verify the
// method was preserved (title should be "updated", not "not-updated").
title, _ := result["title"].(string)
if title == "not-updated" {
t.Fatal("PATCH was silently converted to GET on 301 redirect — write was lost")
}
}
// TestDoJSON_GETRedirectFollowed verifies that GET requests still follow redirects normally.
func TestDoJSON_GETRedirectFollowed(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/repos/owner/old-name/pulls/1", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/api/v1/repos/owner/new-name/pulls/1", http.StatusMovedPermanently)
})
mux.HandleFunc("GET /api/v1/repos/owner/new-name/pulls/1", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{"id": 1, "title": "found"})
})
srv := httptest.NewServer(mux)
defer srv.Close()
origHost := flag.Host
defer func() { flag.Host = origHost }()
flag.Host = srv.URL
var result map[string]any
status, err := DoJSON(context.Background(), http.MethodGet, "repos/owner/old-name/pulls/1", nil, nil, &result)
if err != nil {
t.Fatalf("GET redirect should be followed, got error: %v (status %d)", err, status)
}
title, _ := result["title"].(string)
if title != "found" {
t.Fatalf("expected title 'found', got %q", title)
}
}

View File

@@ -5,7 +5,6 @@ import (
"context" "context"
"crypto/tls" "crypto/tls"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -41,19 +40,18 @@ func tokenFromContext(ctx context.Context) string {
func newRESTHTTPClient() *http.Client { func newRESTHTTPClient() *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone() transport := http.DefaultTransport.(*http.Transport).Clone()
if flag.Insecure { if flag.Insecure {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // user-requested insecure mode transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec
} }
return &http.Client{ return &http.Client{
Transport: transport, Transport: transport,
Timeout: 60 * time.Second, Timeout: 60 * time.Second,
CheckRedirect: checkRedirect,
} }
} }
func buildAPIURL(path string, query url.Values) (string, error) { func buildAPIURL(path string, query url.Values) (string, error) {
host := strings.TrimRight(flag.Host, "/") host := strings.TrimRight(flag.Host, "/")
if host == "" { if host == "" {
return "", errors.New("gitea host is empty") return "", fmt.Errorf("gitea host is empty")
} }
p := strings.TrimLeft(path, "/") p := strings.TrimLeft(path, "/")
u, err := url.Parse(fmt.Sprintf("%s/api/v1/%s", host, p)) u, err := url.Parse(fmt.Sprintf("%s/api/v1/%s", host, p))
@@ -68,7 +66,7 @@ func buildAPIURL(path string, query url.Values) (string, error) {
// DoJSON performs an API request and decodes a JSON response into respOut (if non-nil). // DoJSON performs an API request and decodes a JSON response into respOut (if non-nil).
// It returns the HTTP status code. // It returns the HTTP status code.
func DoJSON(ctx context.Context, method, path string, query url.Values, body, respOut any) (int, error) { func DoJSON(ctx context.Context, method, path string, query url.Values, body any, respOut any) (int, error) {
var bodyReader io.Reader var bodyReader io.Reader
if body != nil { if body != nil {
b, err := json.Marshal(body) b, err := json.Marshal(body)
@@ -89,7 +87,7 @@ func DoJSON(ctx context.Context, method, path string, query url.Values, body, re
token := tokenFromContext(ctx) token := tokenFromContext(ctx)
if token != "" { if token != "" {
req.Header.Set("Authorization", "token "+token) req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
} }
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
if body != nil { if body != nil {
@@ -109,7 +107,7 @@ func DoJSON(ctx context.Context, method, path string, query url.Values, body, re
} }
if respOut == nil { if respOut == nil {
_, _ = io.Copy(io.Discard, resp.Body) // best-effort io.Copy(io.Discard, resp.Body) // best-effort
return resp.StatusCode, nil return resp.StatusCode, nil
} }
@@ -142,7 +140,7 @@ func DoBytes(ctx context.Context, method, path string, query url.Values, body an
token := tokenFromContext(ctx) token := tokenFromContext(ctx)
if token != "" { if token != "" {
req.Header.Set("Authorization", "token "+token) req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
} }
if accept != "" { if accept != "" {
req.Header.Set("Accept", accept) req.Header.Set("Accept", accept)
@@ -173,3 +171,5 @@ func DoBytes(ctx context.Context, method, path string, query url.Values, body an
return respBytes, resp.StatusCode, nil return respBytes, resp.StatusCode, nil
} }

View File

@@ -28,3 +28,5 @@ func TestTokenFromContext(t *testing.T) {
} }
}) })
} }

View File

@@ -1,6 +1,7 @@
package log package log
import ( import (
"fmt"
"os" "os"
"sync" "sync"
"time" "time"
@@ -34,14 +35,14 @@ func Default() *zap.Logger {
home = os.TempDir() home = os.TempDir()
} }
logDir := home + "/.gitea-mcp" logDir := fmt.Sprintf("%s/.gitea-mcp", home)
if err := os.MkdirAll(logDir, 0o700); err != nil { if err := os.MkdirAll(logDir, 0o700); err != nil {
// Fallback to temp directory if creation fails // Fallback to temp directory if creation fails
logDir = os.TempDir() logDir = os.TempDir()
} }
wss = append(wss, zapcore.AddSync(&lumberjack.Logger{ wss = append(wss, zapcore.AddSync(&lumberjack.Logger{
Filename: logDir + "/gitea-mcp.log", Filename: fmt.Sprintf("%s/gitea-mcp.log", logDir),
MaxSize: 100, MaxSize: 100,
MaxBackups: 10, MaxBackups: 10,
MaxAge: 30, MaxAge: 30,

View File

@@ -5,112 +5,29 @@ import (
"strconv" "strconv"
) )
// GetString extracts a required string parameter from MCP tool arguments. // GetIndex extracts an index parameter from MCP tool arguments.
func GetString(args map[string]any, key string) (string, error) {
val, ok := args[key].(string)
if !ok {
return "", fmt.Errorf("%s is required", key)
}
return val, nil
}
// GetOptionalString extracts an optional string parameter with a default value.
func GetOptionalString(args map[string]any, key, defaultVal string) string {
if val, ok := args[key].(string); ok {
return val
}
return defaultVal
}
// GetStringSlice extracts an optional string slice parameter from MCP tool arguments.
func GetStringSlice(args map[string]any, key string) []string {
val, ok := args[key]
if !ok {
return nil
}
sliceVal, ok := val.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(sliceVal))
for _, item := range sliceVal {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
}
// GetPagination extracts page and perPage parameters, returning them as ints.
func GetPagination(args map[string]any, defaultPageSize int64) (page, pageSize int) {
return int(GetOptionalInt(args, "page", 1)), int(GetOptionalInt(args, "perPage", defaultPageSize))
}
// ToInt64 converts a value to int64, accepting both float64 (JSON number) and
// string representations. Returns false if the value cannot be converted.
func ToInt64(val any) (int64, bool) {
switch v := val.(type) {
case float64:
return int64(v), true
case string:
i, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return 0, false
}
return i, true
default:
return 0, false
}
}
// GetIndex extracts a required integer parameter from MCP tool arguments.
// It accepts both numeric (float64 from JSON) and string representations. // It accepts both numeric (float64 from JSON) and string representations.
// This provides better UX for LLM callers that may naturally use strings // This provides better UX for LLM callers that may naturally use strings
// for identifiers like issue/PR numbers. // for identifiers like issue/PR numbers.
func GetIndex(args map[string]any, key string) (int64, error) { func GetIndex(args map[string]interface{}, key string) (int64, error) {
val, exists := args[key] val, exists := args[key]
if !exists { if !exists {
return 0, fmt.Errorf("%s is required", key) return 0, fmt.Errorf("%s is required", key)
} }
if i, ok := ToInt64(val); ok { // Try float64 (JSON number type)
return i, nil if f, ok := val.(float64); ok {
return int64(f), nil
} }
// Try string and parse to integer
if s, ok := val.(string); ok { if s, ok := val.(string); ok {
i, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return 0, fmt.Errorf("%s must be a valid integer (got %q)", key, s) return 0, fmt.Errorf("%s must be a valid integer (got %q)", key, s)
} }
return i, nil
}
return 0, fmt.Errorf("%s must be a number or numeric string", key) return 0, fmt.Errorf("%s must be a number or numeric string", key)
} }
// GetInt64Slice extracts a required int64 slice parameter from MCP tool arguments.
func GetInt64Slice(args map[string]any, key string) ([]int64, error) {
raw, ok := args[key].([]any)
if !ok {
return nil, fmt.Errorf("%s (array of IDs) is required", key)
}
out := make([]int64, 0, len(raw))
for _, v := range raw {
id, ok := ToInt64(v)
if !ok {
return nil, fmt.Errorf("invalid ID in %s array", key)
}
out = append(out, id)
}
return out, nil
}
// GetOptionalInt extracts an optional integer parameter from MCP tool arguments.
// Returns defaultVal if the key is missing or the value cannot be parsed.
// Accepts both float64 (JSON number) and string representations.
func GetOptionalInt(args map[string]any, key string, defaultVal int64) int64 {
val, exists := args[key]
if !exists {
return defaultVal
}
if i, ok := ToInt64(val); ok {
return i
}
return defaultVal
}

View File

@@ -5,67 +5,10 @@ import (
"testing" "testing"
) )
func TestToInt64(t *testing.T) {
tests := []struct {
name string
val any
want int64
ok bool
}{
{"float64", float64(42), 42, true},
{"float64 zero", float64(0), 0, true},
{"float64 negative", float64(-5), -5, true},
{"string", "123", 123, true},
{"string zero", "0", 0, true},
{"string negative", "-10", -10, true},
{"invalid string", "abc", 0, false},
{"decimal string", "1.5", 0, false},
{"bool", true, 0, false},
{"nil", nil, 0, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := ToInt64(tt.val)
if ok != tt.ok {
t.Errorf("ToInt64() ok = %v, want %v", ok, tt.ok)
}
if got != tt.want {
t.Errorf("ToInt64() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetOptionalInt(t *testing.T) {
tests := []struct {
name string
args map[string]any
key string
defaultVal int64
want int64
}{
{"present float64", map[string]any{"page": float64(3)}, "page", 1, 3},
{"present string", map[string]any{"page": "5"}, "page", 1, 5},
{"missing key", map[string]any{}, "page", 1, 1},
{"invalid string", map[string]any{"page": "abc"}, "page", 1, 1},
{"invalid type", map[string]any{"page": true}, "page", 1, 1},
{"zero value", map[string]any{"id": float64(0)}, "id", 99, 0},
{"string zero", map[string]any{"id": "0"}, "id", 99, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := GetOptionalInt(tt.args, tt.key, tt.defaultVal)
if got != tt.want {
t.Errorf("GetOptionalInt() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetIndex(t *testing.T) { func TestGetIndex(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
args map[string]any args map[string]interface{}
key string key string
wantIndex int64 wantIndex int64
wantErr bool wantErr bool
@@ -73,63 +16,63 @@ func TestGetIndex(t *testing.T) {
}{ }{
{ {
name: "valid float64", name: "valid float64",
args: map[string]any{"index": float64(123)}, args: map[string]interface{}{"index": float64(123)},
key: "index", key: "index",
wantIndex: 123, wantIndex: 123,
wantErr: false, wantErr: false,
}, },
{ {
name: "valid string", name: "valid string",
args: map[string]any{"index": "456"}, args: map[string]interface{}{"index": "456"},
key: "index", key: "index",
wantIndex: 456, wantIndex: 456,
wantErr: false, wantErr: false,
}, },
{ {
name: "valid string with large number", name: "valid string with large number",
args: map[string]any{"index": "999999"}, args: map[string]interface{}{"index": "999999"},
key: "index", key: "index",
wantIndex: 999999, wantIndex: 999999,
wantErr: false, wantErr: false,
}, },
{ {
name: "missing parameter", name: "missing parameter",
args: map[string]any{}, args: map[string]interface{}{},
key: "index", key: "index",
wantErr: true, wantErr: true,
errMsg: "index is required", errMsg: "index is required",
}, },
{ {
name: "invalid string (not a number)", name: "invalid string (not a number)",
args: map[string]any{"index": "abc"}, args: map[string]interface{}{"index": "abc"},
key: "index", key: "index",
wantErr: true, wantErr: true,
errMsg: "must be a valid integer", errMsg: "must be a valid integer",
}, },
{ {
name: "invalid string (decimal)", name: "invalid string (decimal)",
args: map[string]any{"index": "12.34"}, args: map[string]interface{}{"index": "12.34"},
key: "index", key: "index",
wantErr: true, wantErr: true,
errMsg: "must be a valid integer", errMsg: "must be a valid integer",
}, },
{ {
name: "invalid type (bool)", name: "invalid type (bool)",
args: map[string]any{"index": true}, args: map[string]interface{}{"index": true},
key: "index", key: "index",
wantErr: true, wantErr: true,
errMsg: "must be a number or numeric string", errMsg: "must be a number or numeric string",
}, },
{ {
name: "invalid type (map)", name: "invalid type (map)",
args: map[string]any{"index": map[string]string{"foo": "bar"}}, args: map[string]interface{}{"index": map[string]string{"foo": "bar"}},
key: "index", key: "index",
wantErr: true, wantErr: true,
errMsg: "must be a number or numeric string", errMsg: "must be a number or numeric string",
}, },
{ {
name: "custom key name", name: "custom key name",
args: map[string]any{"pr_index": "789"}, args: map[string]interface{}{"pr_index": "789"},
key: "pr_index", key: "pr_index",
wantIndex: 789, wantIndex: 789,
wantErr: false, wantErr: false,

73
pkg/ptr/ptr.go Normal file
View File

@@ -0,0 +1,73 @@
/*
Copyright 2023 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package ptr
import (
"fmt"
"reflect"
)
// AllPtrFieldsNil tests whether all pointer fields in a struct are nil. This is useful when,
// for example, an API struct is handled by plugins which need to distinguish
// "no plugin accepted this spec" from "this spec is empty".
//
// This function is only valid for structs and pointers to structs. Any other
// type will cause a panic. Passing a typed nil pointer will return true.
func AllPtrFieldsNil(obj interface{}) bool {
v := reflect.ValueOf(obj)
if !v.IsValid() {
panic(fmt.Sprintf("reflect.ValueOf() produced a non-valid Value for %#v", obj))
}
if v.Kind() == reflect.Ptr {
if v.IsNil() {
return true
}
v = v.Elem()
}
for i := 0; i < v.NumField(); i++ {
if v.Field(i).Kind() == reflect.Ptr && !v.Field(i).IsNil() {
return false
}
}
return true
}
// To returns a pointer to the given value.
func To[T any](v T) *T {
return &v
}
// Deref dereferences ptr and returns the value it points to if no nil, or else
// returns def.
func Deref[T any](ptr *T, def T) T {
if ptr != nil {
return *ptr
}
return def
}
// Equal returns true if both arguments are nil or both arguments
// dereference to the same value.
func Equal[T comparable](a, b *T) bool {
if (a == nil) != (b == nil) {
return false
}
if a == nil {
return true
}
return *a == *b
}

View File

@@ -8,8 +8,13 @@ import (
"github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/mcp"
) )
type textResult struct {
Result any
}
func TextResult(v any) (*mcp.CallToolResult, error) { func TextResult(v any) (*mcp.CallToolResult, error) {
resultBytes, err := json.Marshal(v) result := textResult{v}
resultBytes, err := json.Marshal(result)
if err != nil { if err != nil {
return nil, fmt.Errorf("marshal result err: %v", err) return nil, fmt.Errorf("marshal result err: %v", err)
} }