Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e67d5ebf3 | |||
| a77b54acdd | |||
| 9275c5a0e1 | |||
| bcefbaa9c1 | |||
| cd82f6f207 | |||
| 329a97d5d2 | |||
| 4c45b42cb5 | |||
| 7759c7f327 | |||
| 5867f2f472 | |||
| 26f826d25c | |||
| baf792b061 | |||
| 08128b9471 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Gitea MCP DevContainer",
|
"name": "Gitea MCP DevContainer",
|
||||||
"image": "mcr.microsoft.com/devcontainers/go:1.24-bookworm",
|
"image": "mcr.microsoft.com/devcontainers/go:1.26-bookworm",
|
||||||
"features": {},
|
"features": {},
|
||||||
"customizations": {
|
"customizations": {
|
||||||
"vscode": {
|
"vscode": {
|
||||||
|
|||||||
+12
-8
@@ -71,7 +71,10 @@ linters:
|
|||||||
- name: unexported-return
|
- name: unexported-return
|
||||||
- name: var-declaration
|
- name: var-declaration
|
||||||
- name: var-naming
|
- name: var-naming
|
||||||
disabled: true
|
arguments:
|
||||||
|
- [] # AllowList - do not remove as args for the rule are positional and won't work without lists first
|
||||||
|
- [] # DenyList
|
||||||
|
- - skip-package-name-checks: true
|
||||||
staticcheck:
|
staticcheck:
|
||||||
checks:
|
checks:
|
||||||
- all
|
- all
|
||||||
@@ -91,20 +94,21 @@ linters:
|
|||||||
- common-false-positives
|
- common-false-positives
|
||||||
- legacy
|
- legacy
|
||||||
- std-error-handling
|
- std-error-handling
|
||||||
rules:
|
|
||||||
- linters:
|
|
||||||
- errcheck
|
|
||||||
- staticcheck
|
|
||||||
- unparam
|
|
||||||
path: _test\.go
|
|
||||||
issues:
|
issues:
|
||||||
max-issues-per-linter: 0
|
max-issues-per-linter: 0
|
||||||
max-same-issues: 0
|
max-same-issues: 0
|
||||||
formatters:
|
formatters:
|
||||||
enable:
|
enable:
|
||||||
- gofmt
|
- gci
|
||||||
- gofumpt
|
- gofumpt
|
||||||
settings:
|
settings:
|
||||||
|
gci:
|
||||||
|
custom-order: true
|
||||||
|
sections:
|
||||||
|
- standard
|
||||||
|
- prefix(gitea.com/gitea/gitea-mcp)
|
||||||
|
- blank
|
||||||
|
- default
|
||||||
gofumpt:
|
gofumpt:
|
||||||
extra-rules: true
|
extra-rules: true
|
||||||
exclusions:
|
exclusions:
|
||||||
|
|||||||
@@ -1,71 +1,8 @@
|
|||||||
# AGENTS.md
|
- Use `make help` to find available development targets
|
||||||
|
- Run `make fmt` to format `.go` files, and run `make lint-go` to lint them
|
||||||
This file provides guidance to AI coding agents when working with code in this repository.
|
- Run `make tidy` after any `go.mod` changes
|
||||||
|
- Ensure no trailing whitespace in edited files
|
||||||
## Development Commands
|
- Use Conventional Commits format for commit messages and PR titles (e.g. `type(scope): subject`)
|
||||||
|
- Never force-push, amend, or squash unless asked. Use new commits and normal push for pull request updates
|
||||||
**Build**: `make build` - Build the gitea-mcp binary
|
- Include authorship attribution in issue and pull request comments
|
||||||
**Install**: `make install` - Build and install to GOPATH/bin
|
- Add `Co-Authored-By` lines to all commits, indicating name and model used
|
||||||
**Clean**: `make clean` - Remove build artifacts
|
|
||||||
**Test**: `go test ./...` - Run all tests
|
|
||||||
**Hot reload**: `make dev` - Start development server with hot reload (requires air)
|
|
||||||
**Dependencies**: `make vendor` - Tidy and verify module dependencies
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
|
|
||||||
This is a **Gitea MCP (Model Context Protocol) Server** written in Go that provides MCP tools for interacting with Gitea repositories, issues, pull requests, users, and more.
|
|
||||||
|
|
||||||
**Core Components**:
|
|
||||||
|
|
||||||
- `main.go` + `cmd/cmd.go`: CLI entry point and flag parsing
|
|
||||||
- `operation/operation.go`: Main server setup and tool registration
|
|
||||||
- `pkg/tool/tool.go`: Tool registry with read/write categorization
|
|
||||||
- `operation/*/`: Individual tool modules (user, repo, issue, pull, search, wiki, etc.)
|
|
||||||
|
|
||||||
**Transport Modes**:
|
|
||||||
|
|
||||||
- **stdio** (default): Standard input/output for MCP clients
|
|
||||||
- **http**: HTTP server mode on configurable port (default 8080)
|
|
||||||
|
|
||||||
**Authentication**:
|
|
||||||
|
|
||||||
- Global token via `--token` flag or `GITEA_ACCESS_TOKEN` env var
|
|
||||||
- HTTP mode supports per-request Bearer token override in Authorization header
|
|
||||||
- Token precedence: HTTP Authorization header > CLI flag > environment variable
|
|
||||||
|
|
||||||
**Tool Organization**:
|
|
||||||
|
|
||||||
- Tools are categorized as read-only or write operations
|
|
||||||
- `--read-only` flag exposes only read tools
|
|
||||||
- Tool modules register via `Tool.RegisterRead()` and `Tool.RegisterWrite()`
|
|
||||||
|
|
||||||
**Key Configuration**:
|
|
||||||
|
|
||||||
- Default Gitea host: `https://gitea.com` (override with `--host` or `GITEA_HOST`)
|
|
||||||
- Environment variables can override CLI flags: `MCP_MODE`, `GITEA_READONLY`, `GITEA_DEBUG`, `GITEA_INSECURE`
|
|
||||||
- Logs are written to `~/.gitea-mcp/gitea-mcp.log` with rotation
|
|
||||||
|
|
||||||
## Available Tools
|
|
||||||
|
|
||||||
The server provides 40+ MCP tools covering:
|
|
||||||
|
|
||||||
- **User**: get_my_user_info, get_user_orgs, search_users
|
|
||||||
- **Repository**: create_repo, fork_repo, list_my_repos, search_repos
|
|
||||||
- **Branches/Tags**: create_branch, delete_branch, list_branches, create_tag, list_tags
|
|
||||||
- **Files**: get_file_content, create_file, update_file, delete_file, get_dir_content
|
|
||||||
- **Issues**: create_issue, list_repo_issues, create_issue_comment, edit_issue
|
|
||||||
- **Pull Requests**: create_pull_request, list_repo_pull_requests, get_pull_request_by_index
|
|
||||||
- **Releases**: create_release, list_releases, get_latest_release
|
|
||||||
- **Wiki**: create_wiki_page, update_wiki_page, list_wiki_pages
|
|
||||||
- **Search**: search_repos, search_users, search_org_teams
|
|
||||||
- **Version**: get_gitea_mcp_server_version
|
|
||||||
|
|
||||||
## Common Development Patterns
|
|
||||||
|
|
||||||
**Testing**: Use `go test ./operation -run TestFunctionName` for specific tests
|
|
||||||
|
|
||||||
**Token Context**: HTTP requests use `pkg/context.TokenContextKey` for request-scoped token access
|
|
||||||
|
|
||||||
**Flag Access**: All packages access configuration via global variables in `pkg/flag/flag.go`
|
|
||||||
|
|
||||||
**Graceful Shutdown**: HTTP mode implements graceful shutdown with 10-second timeout on SIGTERM/SIGINT
|
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ 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
|
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
|
||||||
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1
|
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.3.0
|
||||||
GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.2
|
|
||||||
|
|
||||||
.PHONY: help
|
.PHONY: help
|
||||||
help: ## print this help message
|
help: ## print this help message
|
||||||
@@ -51,7 +50,16 @@ dev: air ## run the application with hot reload
|
|||||||
|
|
||||||
.PHONY: fmt
|
.PHONY: fmt
|
||||||
fmt: ## format the Go code
|
fmt: ## format the Go code
|
||||||
$(GO) run $(GOFUMPT_PACKAGE) -w .
|
$(GO) run $(GOLANGCI_LINT_PACKAGE) fmt
|
||||||
|
|
||||||
|
.PHONY: fmt-check
|
||||||
|
fmt-check: fmt ## check that Go code is formatted
|
||||||
|
@diff=$$(git diff --color=always); \
|
||||||
|
if [ -n "$$diff" ]; then \
|
||||||
|
echo "Please run 'make fmt' and commit the result:"; \
|
||||||
|
printf "%s" "$${diff}"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
lint: lint-go ## lint everything
|
lint: lint-go ## lint everything
|
||||||
|
|||||||
+28
@@ -5,6 +5,7 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
|
|
||||||
"gitea.com/gitea/gitea-mcp/operation"
|
"gitea.com/gitea/gitea-mcp/operation"
|
||||||
@@ -16,6 +17,7 @@ var (
|
|||||||
host string
|
host string
|
||||||
port int
|
port int
|
||||||
token string
|
token string
|
||||||
|
tools string
|
||||||
version bool
|
version bool
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,6 +32,9 @@ func init() {
|
|||||||
flag.StringVar(&token, "token", "", "")
|
flag.StringVar(&token, "token", "", "")
|
||||||
flag.BoolVar(&flagPkg.ReadOnly, "r", false, "")
|
flag.BoolVar(&flagPkg.ReadOnly, "r", false, "")
|
||||||
flag.BoolVar(&flagPkg.ReadOnly, "read-only", false, "")
|
flag.BoolVar(&flagPkg.ReadOnly, "read-only", false, "")
|
||||||
|
defaultTools := os.Getenv("GITEA_TOOLS")
|
||||||
|
flag.StringVar(&tools, "O", defaultTools, "")
|
||||||
|
flag.StringVar(&tools, "tools", defaultTools, "")
|
||||||
flag.BoolVar(&flagPkg.Debug, "d", false, "")
|
flag.BoolVar(&flagPkg.Debug, "d", false, "")
|
||||||
flag.BoolVar(&flagPkg.Debug, "debug", false, "")
|
flag.BoolVar(&flagPkg.Debug, "debug", false, "")
|
||||||
flag.BoolVar(&flagPkg.Insecure, "k", false, "")
|
flag.BoolVar(&flagPkg.Insecure, "k", false, "")
|
||||||
@@ -47,16 +52,19 @@ func init() {
|
|||||||
fmt.Fprintf(w, " -p, -port <number>\tHTTP server port (default: 8080)\n")
|
fmt.Fprintf(w, " -p, -port <number>\tHTTP server port (default: 8080)\n")
|
||||||
fmt.Fprintf(w, " -T, -token <token>\tPersonal access token\n")
|
fmt.Fprintf(w, " -T, -token <token>\tPersonal access token\n")
|
||||||
fmt.Fprintf(w, " -r, -read-only\tExpose only read-only tools\n")
|
fmt.Fprintf(w, " -r, -read-only\tExpose only read-only tools\n")
|
||||||
|
fmt.Fprintf(w, " -O, -tools <names>\tComma-separated list of tool names to expose\n")
|
||||||
fmt.Fprintf(w, " -d, -debug\tEnable debug mode\n")
|
fmt.Fprintf(w, " -d, -debug\tEnable debug mode\n")
|
||||||
fmt.Fprintf(w, " -k, -insecure\tIgnore TLS certificate errors\n")
|
fmt.Fprintf(w, " -k, -insecure\tIgnore TLS certificate errors\n")
|
||||||
fmt.Fprintf(w, " -v, -version\tPrint version and exit\n")
|
fmt.Fprintf(w, " -v, -version\tPrint version and exit\n")
|
||||||
fmt.Fprintln(w)
|
fmt.Fprintln(w)
|
||||||
fmt.Fprintln(w, "Environment variables:")
|
fmt.Fprintln(w, "Environment variables:")
|
||||||
fmt.Fprintf(w, " GITEA_ACCESS_TOKEN\tProvide access token\n")
|
fmt.Fprintf(w, " GITEA_ACCESS_TOKEN\tProvide access token\n")
|
||||||
|
fmt.Fprintf(w, " GITEA_ACCESS_TOKEN_FILE\tPath to a file containing the access token (e.g. a Docker secret)\n")
|
||||||
fmt.Fprintf(w, " GITEA_DEBUG\tSet to 'true' for debug mode\n")
|
fmt.Fprintf(w, " GITEA_DEBUG\tSet to 'true' for debug mode\n")
|
||||||
fmt.Fprintf(w, " GITEA_HOST\tOverride Gitea host URL\n")
|
fmt.Fprintf(w, " GITEA_HOST\tOverride Gitea host URL\n")
|
||||||
fmt.Fprintf(w, " GITEA_INSECURE\tSet to 'true' to ignore TLS errors\n")
|
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")
|
fmt.Fprintf(w, " GITEA_READONLY\tSet to 'true' for read-only mode\n")
|
||||||
|
fmt.Fprintf(w, " GITEA_TOOLS\tComma-separated list of tool names to expose\n")
|
||||||
fmt.Fprintf(w, " MCP_MODE\tOverride transport mode\n")
|
fmt.Fprintf(w, " MCP_MODE\tOverride transport mode\n")
|
||||||
w.Flush()
|
w.Flush()
|
||||||
}
|
}
|
||||||
@@ -74,6 +82,16 @@ func init() {
|
|||||||
if flagPkg.Token == "" {
|
if flagPkg.Token == "" {
|
||||||
flagPkg.Token = os.Getenv("GITEA_ACCESS_TOKEN")
|
flagPkg.Token = os.Getenv("GITEA_ACCESS_TOKEN")
|
||||||
}
|
}
|
||||||
|
if flagPkg.Token == "" {
|
||||||
|
if tokenFile := os.Getenv("GITEA_ACCESS_TOKEN_FILE"); tokenFile != "" {
|
||||||
|
data, err := os.ReadFile(tokenFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error reading GITEA_ACCESS_TOKEN_FILE: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
flagPkg.Token = strings.TrimRight(string(data), "\r\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if os.Getenv("MCP_MODE") != "" {
|
if os.Getenv("MCP_MODE") != "" {
|
||||||
flagPkg.Mode = os.Getenv("MCP_MODE")
|
flagPkg.Mode = os.Getenv("MCP_MODE")
|
||||||
@@ -83,6 +101,16 @@ func init() {
|
|||||||
flagPkg.ReadOnly = true
|
flagPkg.ReadOnly = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allowed := map[string]struct{}{}
|
||||||
|
for t := range strings.SplitSeq(tools, ",") {
|
||||||
|
if t = strings.TrimSpace(t); t != "" {
|
||||||
|
allowed[t] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(allowed) > 0 {
|
||||||
|
flagPkg.AllowedTools = allowed
|
||||||
|
}
|
||||||
|
|
||||||
if os.Getenv("GITEA_DEBUG") == "true" {
|
if os.Getenv("GITEA_DEBUG") == "true" {
|
||||||
flagPkg.Debug = true
|
flagPkg.Debug = true
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-17
@@ -8,6 +8,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/annotation"
|
||||||
"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"
|
||||||
@@ -47,27 +48,29 @@ func toSecretMetas(secrets []*gitea_sdk.Secret) []secretMeta {
|
|||||||
var (
|
var (
|
||||||
ActionsConfigReadTool = mcp.NewTool(
|
ActionsConfigReadTool = mcp.NewTool(
|
||||||
ActionsConfigReadToolName,
|
ActionsConfigReadToolName,
|
||||||
mcp.WithDescription("Read Actions secrets and variables configuration."),
|
mcp.WithDescription("Read Actions secrets and variables."),
|
||||||
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.WithToolAnnotation(annotation.ReadOnly("Read Actions secrets and variables")),
|
||||||
mcp.WithString("owner", mcp.Description("repository owner (required for repo methods)")),
|
mcp.WithString("method", mcp.Required(), mcp.Enum("list_repo_secrets", "list_org_secrets", "list_repo_variables", "get_repo_variable", "list_org_variables", "get_org_variable")),
|
||||||
mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")),
|
mcp.WithString("owner", mcp.Description("for repo methods")),
|
||||||
mcp.WithString("org", mcp.Description("organization name (required for org methods)")),
|
mcp.WithString("repo", mcp.Description("for repo methods")),
|
||||||
mcp.WithString("name", mcp.Description("variable name (required for get methods)")),
|
mcp.WithString("org", mcp.Description("for org methods")),
|
||||||
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
|
mcp.WithString("name", mcp.Description("for get methods")),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30), mcp.Min(1)),
|
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)),
|
||||||
|
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30), mcp.Min(1)),
|
||||||
)
|
)
|
||||||
|
|
||||||
ActionsConfigWriteTool = mcp.NewTool(
|
ActionsConfigWriteTool = mcp.NewTool(
|
||||||
ActionsConfigWriteToolName,
|
ActionsConfigWriteToolName,
|
||||||
mcp.WithDescription("Manage Actions secrets and variables: create, update, or delete."),
|
mcp.WithDescription("Write Actions secrets and variables: upsert, create, update, 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.WithToolAnnotation(annotation.Destructive("Manage Actions secrets and variables")),
|
||||||
mcp.WithString("owner", mcp.Description("repository owner (required for repo methods)")),
|
mcp.WithString("method", mcp.Required(), 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("repo", mcp.Description("repository name (required for repo methods)")),
|
mcp.WithString("owner", mcp.Description("for repo methods")),
|
||||||
mcp.WithString("org", mcp.Description("organization name (required for org methods)")),
|
mcp.WithString("repo", mcp.Description("for repo methods")),
|
||||||
mcp.WithString("name", mcp.Description("secret or variable name (required for most methods)")),
|
mcp.WithString("org", mcp.Description("for org methods")),
|
||||||
mcp.WithString("data", mcp.Description("secret value (required for upsert secret methods)")),
|
mcp.WithString("name", mcp.Description("secret or variable name")),
|
||||||
mcp.WithString("value", mcp.Description("variable value (required for create/update variable methods)")),
|
mcp.WithString("data", mcp.Description("secret value (upsert)")),
|
||||||
mcp.WithString("description", mcp.Description("description for secret or variable")),
|
mcp.WithString("value", mcp.Description("variable value")),
|
||||||
|
mcp.WithString("description"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+24
-21
@@ -10,6 +10,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/annotation"
|
||||||
"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"
|
||||||
@@ -27,31 +28,33 @@ const (
|
|||||||
var (
|
var (
|
||||||
ActionsRunReadTool = mcp.NewTool(
|
ActionsRunReadTool = mcp.NewTool(
|
||||||
ActionsRunReadToolName,
|
ActionsRunReadToolName,
|
||||||
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("Read Actions workflows, runs, jobs, and logs."),
|
||||||
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.WithToolAnnotation(annotation.ReadOnly("Read Actions workflow, run, and job data")),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("method", mcp.Required(), mcp.Enum("list_workflows", "get_workflow", "list_runs", "get_run", "list_jobs", "list_run_jobs", "get_job_log_preview", "download_job_log")),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithString("workflow_id", mcp.Description("workflow ID or filename (required for 'get_workflow')")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
mcp.WithNumber("run_id", mcp.Description("run ID (required for 'get_run', 'list_run_jobs')")),
|
mcp.WithString("workflow_id", mcp.Description("ID or filename (for 'get_workflow')")),
|
||||||
mcp.WithNumber("job_id", mcp.Description("job ID (required for 'get_job_log_preview', 'download_job_log')")),
|
mcp.WithNumber("run_id", mcp.Description("for 'get_run'/'list_run_jobs'")),
|
||||||
mcp.WithString("status", mcp.Description("optional status filter (for 'list_runs', 'list_jobs')")),
|
mcp.WithNumber("job_id", mcp.Description("for log methods")),
|
||||||
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.WithString("status", mcp.Description("filter for 'list_runs'/'list_jobs'")),
|
||||||
mcp.WithNumber("max_bytes", mcp.Description("max bytes to return (for 'get_job_log_preview')"), mcp.DefaultNumber(65536), mcp.Min(1024)),
|
mcp.WithNumber("tail_lines", mcp.Description("log tail lines"), mcp.DefaultNumber(200), mcp.Min(1)),
|
||||||
mcp.WithString("output_path", mcp.Description("output file path (for 'download_job_log')")),
|
mcp.WithNumber("max_bytes", mcp.Description("max log bytes"), mcp.DefaultNumber(65536), mcp.Min(1024)),
|
||||||
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
|
mcp.WithString("output_path", mcp.Description("for 'download_job_log'")),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30), mcp.Min(1)),
|
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)),
|
||||||
|
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30), mcp.Min(1)),
|
||||||
)
|
)
|
||||||
|
|
||||||
ActionsRunWriteTool = mcp.NewTool(
|
ActionsRunWriteTool = mcp.NewTool(
|
||||||
ActionsRunWriteToolName,
|
ActionsRunWriteToolName,
|
||||||
mcp.WithDescription("Trigger, cancel, or rerun Actions workflows."),
|
mcp.WithDescription("Write Actions runs: dispatch, cancel, rerun."),
|
||||||
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("dispatch_workflow", "cancel_run", "rerun_run")),
|
mcp.WithToolAnnotation(annotation.Write("Trigger, cancel, or rerun Actions workflows")),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("method", mcp.Required(), mcp.Enum("dispatch_workflow", "cancel_run", "rerun_run")),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithString("workflow_id", mcp.Description("workflow ID or filename (required for 'dispatch_workflow')")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
mcp.WithString("ref", mcp.Description("git ref branch or tag (required for 'dispatch_workflow')")),
|
mcp.WithString("workflow_id", mcp.Description("ID or filename (for 'dispatch_workflow')")),
|
||||||
mcp.WithObject("inputs", mcp.Description("workflow inputs object (for 'dispatch_workflow')")),
|
mcp.WithString("ref", mcp.Description("branch or tag (for 'dispatch_workflow')")),
|
||||||
mcp.WithNumber("run_id", mcp.Description("run ID (required for 'cancel_run', 'rerun_run')")),
|
mcp.WithObject("inputs", mcp.Description("for 'dispatch_workflow'")),
|
||||||
|
mcp.WithNumber("run_id", mcp.Description("for 'cancel_run'/'rerun_run'")),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+71
-56
@@ -3,7 +3,9 @@ package issue
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/annotation"
|
||||||
"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"
|
||||||
@@ -15,6 +17,18 @@ import (
|
|||||||
"github.com/mark3labs/mcp-go/server"
|
"github.com/mark3labs/mcp-go/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// issueWithAssets / commentWithAssets wrap the SDK types to capture the
|
||||||
|
// `assets` field that the SDK currently drops on these endpoints.
|
||||||
|
type issueWithAssets struct {
|
||||||
|
gitea_sdk.Issue
|
||||||
|
Assets []*gitea_sdk.Attachment `json:"assets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type commentWithAssets struct {
|
||||||
|
gitea_sdk.Comment
|
||||||
|
Assets []*gitea_sdk.Attachment `json:"assets"`
|
||||||
|
}
|
||||||
|
|
||||||
var Tool = tool.New()
|
var Tool = tool.New()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -26,44 +40,46 @@ const (
|
|||||||
var (
|
var (
|
||||||
ListRepoIssuesTool = mcp.NewTool(
|
ListRepoIssuesTool = mcp.NewTool(
|
||||||
ListRepoIssuesToolName,
|
ListRepoIssuesToolName,
|
||||||
mcp.WithDescription("List repository issues"),
|
mcp.WithToolAnnotation(annotation.ReadOnly("List repository issues")),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
mcp.WithString("state", mcp.Description("issue state"), mcp.DefaultString("all")),
|
mcp.WithString("state", mcp.DefaultString("all")),
|
||||||
mcp.WithArray("labels", mcp.Description("filter by label names"), mcp.Items(map[string]any{"type": "string"})),
|
mcp.WithArray("labels", mcp.Description("label name filter"), mcp.Items(map[string]any{"type": "string"})),
|
||||||
mcp.WithString("since", mcp.Description("filter issues updated after this ISO 8601 timestamp")),
|
mcp.WithString("since", mcp.Description("updated after ISO 8601")),
|
||||||
mcp.WithString("before", mcp.Description("filter issues updated before this ISO 8601 timestamp")),
|
mcp.WithString("before", mcp.Description("updated before ISO 8601")),
|
||||||
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
|
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
|
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
|
||||||
)
|
)
|
||||||
|
|
||||||
IssueReadTool = mcp.NewTool(
|
IssueReadTool = mcp.NewTool(
|
||||||
IssueReadToolName,
|
IssueReadToolName,
|
||||||
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("Read issue: details, comments, or labels."),
|
||||||
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "get_comments", "get_labels")),
|
mcp.WithToolAnnotation(annotation.ReadOnly("Read issue details")),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("method", mcp.Required(), mcp.Enum("get", "get_comments", "get_labels")),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithNumber("index", mcp.Required(), mcp.Description("repository issue index")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
|
mcp.WithNumber("issue_number", mcp.Required()),
|
||||||
)
|
)
|
||||||
|
|
||||||
IssueWriteTool = mcp.NewTool(
|
IssueWriteTool = mcp.NewTool(
|
||||||
IssueWriteToolName,
|
IssueWriteToolName,
|
||||||
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("Write issues: create, update, manage comments and labels."),
|
||||||
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.WithToolAnnotation(annotation.Write("Create or update issues, comments, and labels")),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("method", mcp.Required(), mcp.Enum("create", "update", "add_comment", "edit_comment", "add_labels", "remove_label", "replace_labels", "clear_labels")),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithNumber("index", mcp.Description("issue index (required for all methods except 'create')")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
mcp.WithString("title", mcp.Description("issue title (required for 'create')")),
|
mcp.WithNumber("issue_number", mcp.Description("required except for 'create'")),
|
||||||
mcp.WithString("body", mcp.Description("issue/comment body (required for 'create', 'add_comment', 'edit_comment')")),
|
mcp.WithString("title", mcp.Description("required for 'create'")),
|
||||||
mcp.WithArray("assignees", mcp.Description("usernames to assign (for 'create', 'update')"), mcp.Items(map[string]any{"type": "string"})),
|
mcp.WithString("body", mcp.Description("required for 'create'/'add_comment'/'edit_comment'")),
|
||||||
mcp.WithNumber("milestone", mcp.Description("milestone number (for 'create', 'update')")),
|
mcp.WithArray("assignees", mcp.Items(map[string]any{"type": "string"})),
|
||||||
mcp.WithString("state", mcp.Description("issue state, one of open, closed, all (for 'update')")),
|
mcp.WithNumber("milestone"),
|
||||||
mcp.WithNumber("commentID", mcp.Description("id of issue comment (required for 'edit_comment')")),
|
mcp.WithString("state", mcp.Enum("open", "closed", "all")),
|
||||||
mcp.WithArray("labels", mcp.Description("array of label IDs (for 'create', 'add_labels', 'replace_labels')"), mcp.Items(map[string]any{"type": "number"})),
|
mcp.WithNumber("commentID", mcp.Description("for 'edit_comment'")),
|
||||||
mcp.WithNumber("label_id", mcp.Description("label ID to remove (required for 'remove_label')")),
|
mcp.WithArray("labels", mcp.Description("label IDs"), mcp.Items(map[string]any{"type": "number"})),
|
||||||
mcp.WithString("ref", mcp.Description("branch name to associate with the issue (for 'create', 'update')")),
|
mcp.WithNumber("label_id", mcp.Description("for 'remove_label'")),
|
||||||
mcp.WithString("deadline", mcp.Description("due date in ISO 8601 format (for 'create', 'update')")),
|
mcp.WithString("ref", mcp.Description("branch to associate")),
|
||||||
mcp.WithBoolean("remove_deadline", mcp.Description("unset due date (for 'update')")),
|
mcp.WithString("deadline", mcp.Description("ISO 8601")),
|
||||||
|
mcp.WithBoolean("remove_deadline"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -138,20 +154,18 @@ func getIssueByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
index, err := params.GetIndex(req.GetArguments(), "index")
|
index, err := params.GetIndex(req.GetArguments(), "issue_number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
client, err := gitea.ClientFromContext(ctx)
|
var issue issueWithAssets
|
||||||
if err != nil {
|
path := fmt.Sprintf("repos/%s/%s/issues/%d", url.PathEscape(owner), url.PathEscape(repo), index)
|
||||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
if _, err := gitea.DoJSON(ctx, "GET", path, nil, nil, &issue); err != nil {
|
||||||
}
|
|
||||||
issue, _, err := client.GetIssue(owner, repo, index)
|
|
||||||
if err != nil {
|
|
||||||
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))
|
||||||
}
|
}
|
||||||
|
m := slimIssue(&issue.Issue)
|
||||||
return to.TextResult(slimIssue(issue))
|
m["body"] = bodyWithAttachments(issue.Body, issue.Assets)
|
||||||
|
return to.TextResult(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
func listRepoIssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
func listRepoIssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
@@ -252,7 +266,7 @@ func createIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
index, err := params.GetIndex(req.GetArguments(), "index")
|
index, err := params.GetIndex(req.GetArguments(), "issue_number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
@@ -285,7 +299,7 @@ func editIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRes
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
index, err := params.GetIndex(req.GetArguments(), "index")
|
index, err := params.GetIndex(req.GetArguments(), "issue_number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
@@ -373,21 +387,22 @@ func getIssueCommentsByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*m
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
index, err := params.GetIndex(req.GetArguments(), "index")
|
index, err := params.GetIndex(req.GetArguments(), "issue_number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
opt := gitea_sdk.ListIssueCommentOptions{}
|
var comments []commentWithAssets
|
||||||
client, err := gitea.ClientFromContext(ctx)
|
path := fmt.Sprintf("repos/%s/%s/issues/%d/comments", url.PathEscape(owner), url.PathEscape(repo), index)
|
||||||
if err != nil {
|
if _, err := gitea.DoJSON(ctx, "GET", path, nil, nil, &comments); err != nil {
|
||||||
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
|
||||||
}
|
|
||||||
issue, _, err := client.ListIssueComments(owner, repo, index, opt)
|
|
||||||
if err != nil {
|
|
||||||
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))
|
||||||
}
|
}
|
||||||
|
out := make([]map[string]any, 0, len(comments))
|
||||||
return to.TextResult(slimComments(issue))
|
for i := range comments {
|
||||||
|
m := slimComment(&comments[i].Comment)
|
||||||
|
m["body"] = bodyWithAttachments(comments[i].Body, comments[i].Assets)
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return to.TextResult(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
func getIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
@@ -400,7 +415,7 @@ func getIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
index, err := params.GetIndex(req.GetArguments(), "index")
|
index, err := params.GetIndex(req.GetArguments(), "issue_number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
@@ -428,7 +443,7 @@ func addIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
index, err := params.GetIndex(req.GetArguments(), "index")
|
index, err := params.GetIndex(req.GetArguments(), "issue_number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
@@ -458,7 +473,7 @@ func replaceIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
index, err := params.GetIndex(req.GetArguments(), "index")
|
index, err := params.GetIndex(req.GetArguments(), "issue_number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
@@ -488,7 +503,7 @@ func clearIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
index, err := params.GetIndex(req.GetArguments(), "index")
|
index, err := params.GetIndex(req.GetArguments(), "issue_number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
@@ -514,7 +529,7 @@ func removeIssueLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
index, err := params.GetIndex(req.GetArguments(), "index")
|
index, err := params.GetIndex(req.GetArguments(), "issue_number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
||||||
|
|
||||||
"github.com/mark3labs/mcp-go/mcp"
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -165,3 +166,104 @@ func Test_createIssueFn_labels(t *testing.T) {
|
|||||||
t.Fatalf("expected due_date to be set")
|
t.Fatalf("expected due_date to be set")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_getIssueByIndexFn_includesAttachments(t *testing.T) {
|
||||||
|
const (
|
||||||
|
owner = "octo"
|
||||||
|
repo = "demo"
|
||||||
|
)
|
||||||
|
|
||||||
|
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/issues/42", owner, repo):
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{
|
||||||
|
"number": 42,
|
||||||
|
"title": "bug with screenshot",
|
||||||
|
"body": "see attached",
|
||||||
|
"state": "open",
|
||||||
|
"assets": [
|
||||||
|
{"id": 1, "name": "shot.png", "size": 1024, "browser_download_url": "https://example/shot.png"}
|
||||||
|
]
|
||||||
|
}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
server := httptest.NewServer(handler)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version
|
||||||
|
flag.Host, flag.Token, flag.Version = server.URL, "", "test"
|
||||||
|
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
|
||||||
|
|
||||||
|
req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{
|
||||||
|
"owner": owner, "repo": repo, "issue_number": float64(42),
|
||||||
|
}}}
|
||||||
|
res, err := getIssueByIndexFn(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getIssueByIndexFn() error = %v", err)
|
||||||
|
}
|
||||||
|
if res.IsError {
|
||||||
|
t.Fatalf("unexpected error result: %v", res.Content)
|
||||||
|
}
|
||||||
|
body := res.Content[0].(mcp.TextContent).Text
|
||||||
|
if !strings.Contains(body, `[shot.png](https://example/shot.png)`) {
|
||||||
|
t.Fatalf("expected attachment markdown inlined in body, got: %s", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, `"attachments"`) {
|
||||||
|
t.Fatalf("attachments should be inlined into body, not a separate field: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_getIssueCommentsByIndexFn_includesAttachments(t *testing.T) {
|
||||||
|
const (
|
||||||
|
owner = "octo"
|
||||||
|
repo = "demo"
|
||||||
|
)
|
||||||
|
|
||||||
|
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/issues/7/comments", owner, repo):
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[
|
||||||
|
{"id": 1, "body": "see this", "assets": [
|
||||||
|
{"id": 9, "name": "log.txt", "size": 200, "browser_download_url": "https://example/log.txt"}
|
||||||
|
]},
|
||||||
|
{"id": 2, "body": "no attachment", "assets": []}
|
||||||
|
]`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
server := httptest.NewServer(handler)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version
|
||||||
|
flag.Host, flag.Token, flag.Version = server.URL, "", "test"
|
||||||
|
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
|
||||||
|
|
||||||
|
req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{
|
||||||
|
"owner": owner, "repo": repo, "issue_number": float64(7),
|
||||||
|
}}}
|
||||||
|
res, err := getIssueCommentsByIndexFn(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getIssueCommentsByIndexFn() error = %v", err)
|
||||||
|
}
|
||||||
|
if res.IsError {
|
||||||
|
t.Fatalf("unexpected error result: %v", res.Content)
|
||||||
|
}
|
||||||
|
body := res.Content[0].(mcp.TextContent).Text
|
||||||
|
if !strings.Contains(body, `[log.txt](https://example/log.txt)`) {
|
||||||
|
t.Fatalf("expected attachment markdown inlined in body, got: %s", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, `"attachments"`) {
|
||||||
|
t.Fatalf("attachments should be inlined into body, not a separate field: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+21
-8
@@ -1,6 +1,9 @@
|
|||||||
package issue
|
package issue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
gitea_sdk "code.gitea.io/sdk/gitea"
|
gitea_sdk "code.gitea.io/sdk/gitea"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,6 +40,24 @@ func labelNames(labels []*gitea_sdk.Label) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func bodyWithAttachments(body string, atts []*gitea_sdk.Attachment) string {
|
||||||
|
links := make([]string, 0, len(atts))
|
||||||
|
for _, a := range atts {
|
||||||
|
if a == nil || a.DownloadURL == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
links = append(links, fmt.Sprintf("[%s](%s)", a.Name, a.DownloadURL))
|
||||||
|
}
|
||||||
|
if len(links) == 0 {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
joined := strings.Join(links, "\n")
|
||||||
|
if body == "" {
|
||||||
|
return joined
|
||||||
|
}
|
||||||
|
return body + "\n\n" + joined
|
||||||
|
}
|
||||||
|
|
||||||
func slimIssue(i *gitea_sdk.Issue) map[string]any {
|
func slimIssue(i *gitea_sdk.Issue) map[string]any {
|
||||||
if i == nil {
|
if i == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -119,14 +140,6 @@ func slimComment(c *gitea_sdk.Comment) map[string]any {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
func slimLabels(labels []*gitea_sdk.Label) []map[string]any {
|
||||||
out := make([]map[string]any, 0, len(labels))
|
out := make([]map[string]any, 0, len(labels))
|
||||||
for _, l := range labels {
|
for _, l := range labels {
|
||||||
|
|||||||
@@ -40,6 +40,29 @@ func TestSlimIssue(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBodyWithAttachments(t *testing.T) {
|
||||||
|
atts := []*gitea_sdk.Attachment{
|
||||||
|
{Name: "shot.png", DownloadURL: "https://example/shot.png"},
|
||||||
|
{Name: "log.txt", DownloadURL: "https://example/log.txt"},
|
||||||
|
}
|
||||||
|
got := bodyWithAttachments("see attached", atts)
|
||||||
|
want := "see attached\n\n[shot.png](https://example/shot.png)\n[log.txt](https://example/log.txt)"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := bodyWithAttachments("only body", nil); got != "only body" {
|
||||||
|
t.Errorf("nil attachments should return body unchanged, got %q", got)
|
||||||
|
}
|
||||||
|
if got := bodyWithAttachments("", atts); got != "[shot.png](https://example/shot.png)\n[log.txt](https://example/log.txt)" {
|
||||||
|
t.Errorf("empty body should drop separator, got %q", got)
|
||||||
|
}
|
||||||
|
skipped := []*gitea_sdk.Attachment{nil, {Name: "noop", DownloadURL: ""}}
|
||||||
|
if got := bodyWithAttachments("body", skipped); got != "body" {
|
||||||
|
t.Errorf("nil/empty-URL attachments should be skipped, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSlimIssues_ListIsSlimmer(t *testing.T) {
|
func TestSlimIssues_ListIsSlimmer(t *testing.T) {
|
||||||
i := &gitea_sdk.Issue{
|
i := &gitea_sdk.Issue{
|
||||||
Index: 1,
|
Index: 1,
|
||||||
|
|||||||
+22
-19
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/annotation"
|
||||||
"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"
|
||||||
@@ -25,29 +26,31 @@ const (
|
|||||||
var (
|
var (
|
||||||
LabelReadTool = mcp.NewTool(
|
LabelReadTool = mcp.NewTool(
|
||||||
LabelReadToolName,
|
LabelReadToolName,
|
||||||
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("Read repo or org labels."),
|
||||||
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list_repo_labels", "get_repo_label", "list_org_labels")),
|
mcp.WithToolAnnotation(annotation.ReadOnly("Read labels")),
|
||||||
mcp.WithString("owner", mcp.Description("repository owner (required for repo methods)")),
|
mcp.WithString("method", mcp.Required(), mcp.Enum("list_repo_labels", "get_repo_label", "list_org_labels")),
|
||||||
mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")),
|
mcp.WithString("owner", mcp.Description("for repo methods")),
|
||||||
mcp.WithString("org", mcp.Description("organization name (required for 'list_org')")),
|
mcp.WithString("repo", mcp.Description("for repo methods")),
|
||||||
mcp.WithNumber("id", mcp.Description("label ID (required for 'get_repo')")),
|
mcp.WithString("org", mcp.Description("for org methods")),
|
||||||
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
|
mcp.WithNumber("id", mcp.Description("label ID (for 'get_repo_label')")),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
|
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
|
||||||
|
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
|
||||||
)
|
)
|
||||||
|
|
||||||
LabelWriteTool = mcp.NewTool(
|
LabelWriteTool = mcp.NewTool(
|
||||||
LabelWriteToolName,
|
LabelWriteToolName,
|
||||||
mcp.WithDescription("Create, edit, or delete labels for repositories or organizations."),
|
mcp.WithDescription("Write labels (repo or org): create, edit, delete."),
|
||||||
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.WithToolAnnotation(annotation.Destructive("Create, update, or delete labels")),
|
||||||
mcp.WithString("owner", mcp.Description("repository owner (required for repo methods)")),
|
mcp.WithString("method", mcp.Required(), mcp.Enum("create_repo_label", "edit_repo_label", "delete_repo_label", "create_org_label", "edit_org_label", "delete_org_label")),
|
||||||
mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")),
|
mcp.WithString("owner", mcp.Description("for repo methods")),
|
||||||
mcp.WithString("org", mcp.Description("organization name (required for org methods)")),
|
mcp.WithString("repo", mcp.Description("for repo methods")),
|
||||||
mcp.WithNumber("id", mcp.Description("label ID (required for edit/delete methods)")),
|
mcp.WithString("org", mcp.Description("for org methods")),
|
||||||
mcp.WithString("name", mcp.Description("label name (required for create, optional for edit)")),
|
mcp.WithNumber("id", mcp.Description("for edit/delete")),
|
||||||
mcp.WithString("color", mcp.Description("label color hex code e.g. #RRGGBB (required for create, optional for edit)")),
|
mcp.WithString("name", mcp.Description("required for create")),
|
||||||
mcp.WithString("description", mcp.Description("label description")),
|
mcp.WithString("color", mcp.Description("hex (#RRGGBB); required for create")),
|
||||||
mcp.WithBoolean("exclusive", mcp.Description("whether the label is exclusive (org labels only)")),
|
mcp.WithString("description"),
|
||||||
mcp.WithBoolean("is_archived", mcp.Description("whether the label is archived (for create/edit repo label methods)")),
|
mcp.WithBoolean("exclusive", mcp.Description("exclusive (org only)")),
|
||||||
|
mcp.WithBoolean("is_archived", mcp.Description("archived (repo only)")),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/annotation"
|
||||||
"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"
|
||||||
@@ -25,28 +26,30 @@ const (
|
|||||||
var (
|
var (
|
||||||
MilestoneReadTool = mcp.NewTool(
|
MilestoneReadTool = mcp.NewTool(
|
||||||
MilestoneReadToolName,
|
MilestoneReadToolName,
|
||||||
mcp.WithDescription("Read milestone information. Use method 'get' to get a specific milestone, 'list' to list milestones."),
|
mcp.WithDescription("Read milestones: get one or list."),
|
||||||
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "list")),
|
mcp.WithToolAnnotation(annotation.ReadOnly("Read milestones")),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("method", mcp.Required(), mcp.Enum("get", "list")),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithNumber("id", mcp.Description("milestone id (required for 'get')")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
mcp.WithString("state", mcp.Description("milestone state (for 'list')"), mcp.DefaultString("all")),
|
mcp.WithNumber("id", mcp.Description("for 'get'")),
|
||||||
mcp.WithString("name", mcp.Description("milestone name filter (for 'list')")),
|
mcp.WithString("state", mcp.DefaultString("all")),
|
||||||
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
|
mcp.WithString("name", mcp.Description("name filter (for 'list')")),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
|
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
|
||||||
|
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
|
||||||
)
|
)
|
||||||
|
|
||||||
MilestoneWriteTool = mcp.NewTool(
|
MilestoneWriteTool = mcp.NewTool(
|
||||||
MilestoneWriteToolName,
|
MilestoneWriteToolName,
|
||||||
mcp.WithDescription("Create, edit, or delete milestones."),
|
mcp.WithDescription("Write milestones: create, update, delete."),
|
||||||
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "edit", "delete")),
|
mcp.WithToolAnnotation(annotation.Destructive("Create, update, or delete milestones")),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("method", mcp.Required(), mcp.Enum("create", "update", "edit", "delete")),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithNumber("id", mcp.Description("milestone id (required for 'edit', 'delete')")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
mcp.WithString("title", mcp.Description("milestone title (required for 'create')")),
|
mcp.WithNumber("id", mcp.Description("for 'update'/'delete'")),
|
||||||
mcp.WithString("description", mcp.Description("milestone description")),
|
mcp.WithString("title", mcp.Description("for 'create'")),
|
||||||
|
mcp.WithString("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')")),
|
mcp.WithString("state", mcp.Enum("open", "closed")),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -84,6 +87,8 @@ func milestoneWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
|
|||||||
switch method {
|
switch method {
|
||||||
case "create":
|
case "create":
|
||||||
return createMilestoneFn(ctx, req)
|
return createMilestoneFn(ctx, req)
|
||||||
|
case "update":
|
||||||
|
return editMilestoneFn(ctx, req)
|
||||||
case "edit":
|
case "edit":
|
||||||
return editMilestoneFn(ctx, req)
|
return editMilestoneFn(ctx, req)
|
||||||
case "delete":
|
case "delete":
|
||||||
@@ -174,6 +179,7 @@ func createMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
|
|||||||
if ok {
|
if ok {
|
||||||
opt.Description = description
|
opt.Description = description
|
||||||
}
|
}
|
||||||
|
opt.Deadline = params.GetOptionalTime(req.GetArguments(), "due_on")
|
||||||
|
|
||||||
client, err := gitea.ClientFromContext(ctx)
|
client, err := gitea.ClientFromContext(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -216,6 +222,7 @@ func editMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
|
|||||||
if ok {
|
if ok {
|
||||||
opt.State = new(gitea_sdk.StateType(state))
|
opt.State = new(gitea_sdk.StateType(state))
|
||||||
}
|
}
|
||||||
|
opt.Deadline = params.GetOptionalTime(req.GetArguments(), "due_on")
|
||||||
|
|
||||||
client, err := gitea.ClientFromContext(ctx)
|
client, err := gitea.ClientFromContext(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package milestone
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
||||||
|
|
||||||
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_milestoneWriteFn_dueOn(t *testing.T) {
|
||||||
|
const (
|
||||||
|
owner = "octo"
|
||||||
|
repo = "demo"
|
||||||
|
id = 42
|
||||||
|
due = "2026-05-18T23:59:59Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
bodies = map[string]map[string]any{}
|
||||||
|
)
|
||||||
|
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v1/version":
|
||||||
|
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
|
||||||
|
case fmt.Sprintf("/api/v1/repos/%s/%s/milestones", owner, repo),
|
||||||
|
fmt.Sprintf("/api/v1/repos/%s/%s/milestones/%d", owner, repo, id):
|
||||||
|
mu.Lock()
|
||||||
|
var body map[string]any
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
bodies[r.Method] = body
|
||||||
|
mu.Unlock()
|
||||||
|
_, _ = w.Write(fmt.Appendf(nil, `{"id":%d,"title":"v1","due_on":%q}`, id, due))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server := httptest.NewServer(handler)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version
|
||||||
|
flag.Host, flag.Token, flag.Version = server.URL, "", "test"
|
||||||
|
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
|
||||||
|
|
||||||
|
args := map[string]any{"owner": owner, "repo": repo, "due_on": due}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
fn func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error)
|
||||||
|
method string
|
||||||
|
extra map[string]any
|
||||||
|
}{
|
||||||
|
{"create", createMilestoneFn, http.MethodPost, map[string]any{"title": "v1"}},
|
||||||
|
{"edit", editMilestoneFn, http.MethodPatch, map[string]any{"id": float64(id)}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
a := map[string]any{}
|
||||||
|
maps.Copy(a, args)
|
||||||
|
maps.Copy(a, tc.extra)
|
||||||
|
res, err := tc.fn(context.Background(), mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: a}})
|
||||||
|
if err != nil || res.IsError {
|
||||||
|
t.Fatalf("%s err=%v result=%v", tc.name, err, res)
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
body := bodies[tc.method]
|
||||||
|
mu.Unlock()
|
||||||
|
if got, _ := body["due_on"].(string); got != due {
|
||||||
|
t.Fatalf("%s: expected due_on=%q, got %v (body: %v)", tc.name, due, got, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package notification
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/annotation"
|
||||||
|
"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.com/gitea/gitea-mcp/pkg/tool"
|
||||||
|
|
||||||
|
gitea_sdk "code.gitea.io/sdk/gitea"
|
||||||
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
|
"github.com/mark3labs/mcp-go/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Tool = tool.New()
|
||||||
|
|
||||||
|
const (
|
||||||
|
NotificationReadToolName = "notification_read"
|
||||||
|
NotificationWriteToolName = "notification_write"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
NotificationReadTool = mcp.NewTool(
|
||||||
|
NotificationReadToolName,
|
||||||
|
mcp.WithDescription("Read notifications: list (optionally scoped to a repo) or get a thread by ID."),
|
||||||
|
mcp.WithToolAnnotation(annotation.ReadOnly("Read notifications")),
|
||||||
|
mcp.WithString("method", mcp.Required(), mcp.Enum("list", "get")),
|
||||||
|
mcp.WithString("owner", mcp.Description("scope 'list' to a repo")),
|
||||||
|
mcp.WithString("repo", mcp.Description("scope 'list' to a repo")),
|
||||||
|
mcp.WithNumber("id", mcp.Description("thread ID (for 'get')")),
|
||||||
|
mcp.WithString("status", mcp.Enum("unread", "read", "pinned")),
|
||||||
|
mcp.WithString("subject_type", mcp.Enum("Issue", "Pull", "Commit", "Repository")),
|
||||||
|
mcp.WithString("since", mcp.Description("updated after ISO 8601")),
|
||||||
|
mcp.WithString("before", mcp.Description("updated before ISO 8601")),
|
||||||
|
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
|
||||||
|
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
|
||||||
|
)
|
||||||
|
|
||||||
|
NotificationWriteTool = mcp.NewTool(
|
||||||
|
NotificationWriteToolName,
|
||||||
|
mcp.WithDescription("Mark a notification or all notifications as read."),
|
||||||
|
mcp.WithToolAnnotation(annotation.Write("Manage notifications")),
|
||||||
|
mcp.WithString("method", mcp.Required(), mcp.Enum("mark_read", "mark_all_read")),
|
||||||
|
mcp.WithNumber("id", mcp.Description("thread ID (for 'mark_read')")),
|
||||||
|
mcp.WithString("owner", mcp.Description("scope 'mark_all_read' to a repo")),
|
||||||
|
mcp.WithString("repo", mcp.Description("scope 'mark_all_read' to a repo")),
|
||||||
|
mcp.WithString("last_read_at", mcp.Description("ISO 8601; defaults to now")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Tool.RegisterRead(server.ServerTool{
|
||||||
|
Tool: NotificationReadTool,
|
||||||
|
Handler: notificationReadFn,
|
||||||
|
})
|
||||||
|
Tool.RegisterWrite(server.ServerTool{
|
||||||
|
Tool: NotificationWriteTool,
|
||||||
|
Handler: notificationWriteFn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func notificationReadFn(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 "list":
|
||||||
|
return listNotificationsFn(ctx, req)
|
||||||
|
case "get":
|
||||||
|
return getNotificationFn(ctx, req)
|
||||||
|
default:
|
||||||
|
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func notificationWriteFn(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 "mark_read":
|
||||||
|
return markNotificationReadFn(ctx, req)
|
||||||
|
case "mark_all_read":
|
||||||
|
return markAllNotificationsReadFn(ctx, req)
|
||||||
|
default:
|
||||||
|
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listNotificationsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
log.Debugf("Called listNotificationsFn")
|
||||||
|
args := req.GetArguments()
|
||||||
|
page, pageSize := params.GetPagination(args, 30)
|
||||||
|
opt := gitea_sdk.ListNotificationOptions{
|
||||||
|
ListOptions: gitea_sdk.ListOptions{
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if status, ok := args["status"].(string); ok {
|
||||||
|
opt.Status = []gitea_sdk.NotifyStatus{gitea_sdk.NotifyStatus(status)}
|
||||||
|
}
|
||||||
|
if subjectType, ok := args["subject_type"].(string); ok {
|
||||||
|
opt.SubjectTypes = []gitea_sdk.NotifySubjectType{gitea_sdk.NotifySubjectType(subjectType)}
|
||||||
|
}
|
||||||
|
if t := params.GetOptionalTime(args, "since"); t != nil {
|
||||||
|
opt.Since = *t
|
||||||
|
}
|
||||||
|
if t := params.GetOptionalTime(args, "before"); t != nil {
|
||||||
|
opt.Before = *t
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := gitea.ClientFromContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
owner := params.GetOptionalString(args, "owner", "")
|
||||||
|
repo := params.GetOptionalString(args, "repo", "")
|
||||||
|
if owner != "" && repo != "" {
|
||||||
|
threads, _, err := client.ListRepoNotifications(owner, repo, opt)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("list %v/%v/notifications err: %v", owner, repo, err))
|
||||||
|
}
|
||||||
|
return to.TextResult(slimThreads(threads))
|
||||||
|
}
|
||||||
|
|
||||||
|
threads, _, err := client.ListNotifications(opt)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("list notifications err: %v", err))
|
||||||
|
}
|
||||||
|
return to.TextResult(slimThreads(threads))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getNotificationFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
log.Debugf("Called getNotificationFn")
|
||||||
|
id, err := params.GetIndex(req.GetArguments(), "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))
|
||||||
|
}
|
||||||
|
thread, _, err := client.GetNotification(id)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("get notification/%v err: %v", id, err))
|
||||||
|
}
|
||||||
|
return to.TextResult(slimThread(thread))
|
||||||
|
}
|
||||||
|
|
||||||
|
func markNotificationReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
log.Debugf("Called markNotificationReadFn")
|
||||||
|
id, err := params.GetIndex(req.GetArguments(), "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))
|
||||||
|
}
|
||||||
|
thread, _, err := client.ReadNotification(id)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("mark notification/%v read err: %v", id, err))
|
||||||
|
}
|
||||||
|
if thread != nil {
|
||||||
|
return to.TextResult(slimThread(thread))
|
||||||
|
}
|
||||||
|
return to.TextResult("Notification marked as read")
|
||||||
|
}
|
||||||
|
|
||||||
|
func markAllNotificationsReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
log.Debugf("Called markAllNotificationsReadFn")
|
||||||
|
args := req.GetArguments()
|
||||||
|
lastReadAt := time.Now()
|
||||||
|
if t := params.GetOptionalTime(args, "last_read_at"); t != nil {
|
||||||
|
lastReadAt = *t
|
||||||
|
}
|
||||||
|
opt := gitea_sdk.MarkNotificationOptions{
|
||||||
|
LastReadAt: lastReadAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := gitea.ClientFromContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
owner := params.GetOptionalString(args, "owner", "")
|
||||||
|
repo := params.GetOptionalString(args, "repo", "")
|
||||||
|
if owner != "" && repo != "" {
|
||||||
|
threads, _, err := client.ReadRepoNotifications(owner, repo, opt)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("mark %v/%v/notifications read err: %v", owner, repo, err))
|
||||||
|
}
|
||||||
|
if threads != nil {
|
||||||
|
return to.TextResult(slimThreads(threads))
|
||||||
|
}
|
||||||
|
return to.TextResult("All repository notifications marked as read")
|
||||||
|
}
|
||||||
|
|
||||||
|
threads, _, err := client.ReadNotifications(opt)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("mark all notifications read err: %v", err))
|
||||||
|
}
|
||||||
|
if threads != nil {
|
||||||
|
return to.TextResult(slimThreads(threads))
|
||||||
|
}
|
||||||
|
return to.TextResult("All notifications marked as read")
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package notification
|
||||||
|
|
||||||
|
import (
|
||||||
|
gitea_sdk "code.gitea.io/sdk/gitea"
|
||||||
|
)
|
||||||
|
|
||||||
|
func slimThread(t *gitea_sdk.NotificationThread) map[string]any {
|
||||||
|
if t == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m := map[string]any{
|
||||||
|
"id": t.ID,
|
||||||
|
"unread": t.Unread,
|
||||||
|
"updated_at": t.UpdatedAt,
|
||||||
|
}
|
||||||
|
if t.Pinned {
|
||||||
|
m["pinned"] = true
|
||||||
|
}
|
||||||
|
if t.Repository != nil {
|
||||||
|
m["repository"] = t.Repository.FullName
|
||||||
|
}
|
||||||
|
if t.Subject != nil {
|
||||||
|
subject := map[string]any{
|
||||||
|
"title": t.Subject.Title,
|
||||||
|
"type": t.Subject.Type,
|
||||||
|
"state": t.Subject.State,
|
||||||
|
}
|
||||||
|
if t.Subject.HTMLURL != "" {
|
||||||
|
subject["html_url"] = t.Subject.HTMLURL
|
||||||
|
}
|
||||||
|
if t.Subject.LatestCommentHTMLURL != "" {
|
||||||
|
subject["latest_comment_html_url"] = t.Subject.LatestCommentHTMLURL
|
||||||
|
}
|
||||||
|
m["subject"] = subject
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func slimThreads(threads []*gitea_sdk.NotificationThread) []map[string]any {
|
||||||
|
out := make([]map[string]any, 0, len(threads))
|
||||||
|
for _, t := range threads {
|
||||||
|
if t == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := map[string]any{
|
||||||
|
"id": t.ID,
|
||||||
|
"unread": t.Unread,
|
||||||
|
"updated_at": t.UpdatedAt,
|
||||||
|
}
|
||||||
|
if t.Pinned {
|
||||||
|
m["pinned"] = true
|
||||||
|
}
|
||||||
|
if t.Repository != nil {
|
||||||
|
m["repository"] = t.Repository.FullName
|
||||||
|
}
|
||||||
|
if t.Subject != nil {
|
||||||
|
m["subject"] = map[string]any{
|
||||||
|
"title": t.Subject.Title,
|
||||||
|
"type": t.Subject.Type,
|
||||||
|
"state": t.Subject.State,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+16
-34
@@ -15,6 +15,8 @@ import (
|
|||||||
"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/notification"
|
||||||
|
"gitea.com/gitea/gitea-mcp/operation/packages"
|
||||||
"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"
|
||||||
@@ -25,47 +27,27 @@ import (
|
|||||||
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
|
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
|
||||||
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
||||||
"gitea.com/gitea/gitea-mcp/pkg/log"
|
"gitea.com/gitea/gitea-mcp/pkg/log"
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/tool"
|
||||||
|
|
||||||
"github.com/mark3labs/mcp-go/server"
|
"github.com/mark3labs/mcp-go/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
var mcpServer *server.MCPServer
|
var (
|
||||||
|
mcpServer *server.MCPServer
|
||||||
|
|
||||||
|
domainTools = []*tool.Tool{
|
||||||
|
user.Tool, actions.Tool, repo.Tool, notification.Tool, issue.Tool,
|
||||||
|
label.Tool, milestone.Tool, packages.Tool, pull.Tool, search.Tool,
|
||||||
|
version.Tool, wiki.Tool, timetracking.Tool,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
func RegisterTool(s *server.MCPServer) {
|
func RegisterTool(s *server.MCPServer) {
|
||||||
// User Tool
|
for _, t := range domainTools {
|
||||||
s.AddTools(user.Tool.Tools()...)
|
s.AddTools(t.Tools()...)
|
||||||
|
}
|
||||||
// Actions Tool
|
|
||||||
s.AddTools(actions.Tool.Tools()...)
|
|
||||||
|
|
||||||
// Repo Tool
|
|
||||||
s.AddTools(repo.Tool.Tools()...)
|
|
||||||
|
|
||||||
// Issue Tool
|
|
||||||
s.AddTools(issue.Tool.Tools()...)
|
|
||||||
|
|
||||||
// Label Tool
|
|
||||||
s.AddTools(label.Tool.Tools()...)
|
|
||||||
|
|
||||||
// Milestone Tool
|
|
||||||
s.AddTools(milestone.Tool.Tools()...)
|
|
||||||
|
|
||||||
// Pull Tool
|
|
||||||
s.AddTools(pull.Tool.Tools()...)
|
|
||||||
|
|
||||||
// Search Tool
|
|
||||||
s.AddTools(search.Tool.Tools()...)
|
|
||||||
|
|
||||||
// Version Tool
|
|
||||||
s.AddTools(version.Tool.Tools()...)
|
|
||||||
|
|
||||||
// Wiki Tool
|
|
||||||
s.AddTools(wiki.Tool.Tools()...)
|
|
||||||
|
|
||||||
// Time Tracking Tool
|
|
||||||
s.AddTools(timetracking.Tool.Tools()...)
|
|
||||||
|
|
||||||
s.DeleteTools("")
|
s.DeleteTools("")
|
||||||
|
tool.WarnUnmatchedAllowedTools(domainTools...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseAuthToken extracts the token from an Authorization header.
|
// parseAuthToken extracts the token from an Authorization header.
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package packages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/annotation"
|
||||||
|
"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.com/gitea/gitea-mcp/pkg/tool"
|
||||||
|
|
||||||
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
|
"github.com/mark3labs/mcp-go/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Tool = tool.New()
|
||||||
|
|
||||||
|
const (
|
||||||
|
PackageReadToolName = "package_read"
|
||||||
|
PackageWriteToolName = "package_write"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
PackageReadTool = mcp.NewTool(
|
||||||
|
PackageReadToolName,
|
||||||
|
mcp.WithToolAnnotation(annotation.ReadOnly("Read package registry")),
|
||||||
|
mcp.WithDescription("Read package registry: list packages (one entry per version, filter via 'q'/'type'), list versions, or get a version."),
|
||||||
|
mcp.WithString("method", mcp.Required(), mcp.Enum("list", "list_versions", "get")),
|
||||||
|
mcp.WithString("owner", mcp.Required(), mcp.Description("user or org")),
|
||||||
|
mcp.WithString("type", mcp.Description("container/npm/maven/pypi/cargo/generic; required except 'list'")),
|
||||||
|
mcp.WithString("name", mcp.Description("slashes auto-encoded; required except 'list'")),
|
||||||
|
mcp.WithString("version", mcp.Description("for 'get'")),
|
||||||
|
mcp.WithString("q", mcp.Description("search query")),
|
||||||
|
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)),
|
||||||
|
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30), mcp.Min(1)),
|
||||||
|
)
|
||||||
|
|
||||||
|
PackageWriteTool = mcp.NewTool(
|
||||||
|
PackageWriteToolName,
|
||||||
|
mcp.WithToolAnnotation(annotation.Destructive("Delete a package version")),
|
||||||
|
mcp.WithDescription("Delete a package version (irreversible)."),
|
||||||
|
mcp.WithString("method", mcp.Required(), mcp.Enum("delete")),
|
||||||
|
mcp.WithString("owner", mcp.Required(), mcp.Description("user or org")),
|
||||||
|
mcp.WithString("type", mcp.Required(), mcp.Description("container/npm/maven/pypi/cargo/generic")),
|
||||||
|
mcp.WithString("name", mcp.Required(), mcp.Description("slashes auto-encoded")),
|
||||||
|
mcp.WithString("version", mcp.Required()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Tool.RegisterRead(server.ServerTool{
|
||||||
|
Tool: PackageReadTool,
|
||||||
|
Handler: packageReadFn,
|
||||||
|
})
|
||||||
|
Tool.RegisterWrite(server.ServerTool{
|
||||||
|
Tool: PackageWriteTool,
|
||||||
|
Handler: packageWriteFn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func packageReadFn(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 "list":
|
||||||
|
return listPackagesFn(ctx, req)
|
||||||
|
case "list_versions":
|
||||||
|
return listPackageVersionsFn(ctx, req)
|
||||||
|
case "get":
|
||||||
|
return getPackageFn(ctx, req)
|
||||||
|
default:
|
||||||
|
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func packageWriteFn(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 "delete":
|
||||||
|
return deletePackageVersionFn(ctx, req)
|
||||||
|
default:
|
||||||
|
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapePackageName normalises a package name for use in URL paths. It
|
||||||
|
// accepts both raw names (my-repo/my-image) and pre-encoded names
|
||||||
|
// (my-repo%2Fmy-image), decoding first to avoid double-encoding. A literal
|
||||||
|
// '%' followed by two hex digits in a raw name will be folded into its
|
||||||
|
// decoded form, but package names typically do not contain '%'.
|
||||||
|
func escapePackageName(name string) string {
|
||||||
|
if strings.Contains(name, "%") {
|
||||||
|
if decoded, err := url.PathUnescape(name); err == nil {
|
||||||
|
name = decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return url.PathEscape(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listPackagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
log.Debugf("Called listPackagesFn")
|
||||||
|
args := req.GetArguments()
|
||||||
|
owner, err := params.GetString(args, "owner")
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := url.Values{}
|
||||||
|
if typ, ok := args["type"].(string); ok && typ != "" {
|
||||||
|
query.Set("type", typ)
|
||||||
|
}
|
||||||
|
if q, ok := args["q"].(string); ok && q != "" {
|
||||||
|
query.Set("q", q)
|
||||||
|
}
|
||||||
|
page, pageSize := params.GetPagination(args, 30)
|
||||||
|
query.Set("page", strconv.Itoa(page))
|
||||||
|
query.Set("limit", strconv.Itoa(pageSize))
|
||||||
|
|
||||||
|
var result any
|
||||||
|
_, err = gitea.DoJSON(ctx, "GET", "packages/"+url.PathEscape(owner), query, nil, &result)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("list packages err: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return to.TextResult(slimPackages(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
func listPackageVersionsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
log.Debugf("Called listPackageVersionsFn")
|
||||||
|
args := req.GetArguments()
|
||||||
|
owner, err := params.GetString(args, "owner")
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(err)
|
||||||
|
}
|
||||||
|
typ, err := params.GetString(args, "type")
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(err)
|
||||||
|
}
|
||||||
|
name, err := params.GetString(args, "name")
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := url.Values{}
|
||||||
|
page, pageSize := params.GetPagination(args, 30)
|
||||||
|
query.Set("page", strconv.Itoa(page))
|
||||||
|
query.Set("limit", strconv.Itoa(pageSize))
|
||||||
|
|
||||||
|
var result any
|
||||||
|
_, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("packages/%s/%s/%s", url.PathEscape(owner), url.PathEscape(typ), escapePackageName(name)), query, nil, &result)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("list package versions err: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return to.TextResult(slimPackages(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPackageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
log.Debugf("Called getPackageFn")
|
||||||
|
args := req.GetArguments()
|
||||||
|
owner, err := params.GetString(args, "owner")
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(err)
|
||||||
|
}
|
||||||
|
typ, err := params.GetString(args, "type")
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(err)
|
||||||
|
}
|
||||||
|
name, err := params.GetString(args, "name")
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(err)
|
||||||
|
}
|
||||||
|
version, err := params.GetString(args, "version")
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result any
|
||||||
|
_, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("packages/%s/%s/%s/%s", url.PathEscape(owner), url.PathEscape(typ), escapePackageName(name), url.PathEscape(version)), nil, nil, &result)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("get package err: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return to.TextResult(slimPackage(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
func deletePackageVersionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
log.Debugf("Called deletePackageVersionFn")
|
||||||
|
args := req.GetArguments()
|
||||||
|
owner, err := params.GetString(args, "owner")
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(err)
|
||||||
|
}
|
||||||
|
typ, err := params.GetString(args, "type")
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(err)
|
||||||
|
}
|
||||||
|
name, err := params.GetString(args, "name")
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(err)
|
||||||
|
}
|
||||||
|
version, err := params.GetString(args, "version")
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("packages/%s/%s/%s/%s", url.PathEscape(owner), url.PathEscape(typ), escapePackageName(name), url.PathEscape(version)), nil, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("delete package version err: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return to.TextResult("Package version deleted successfully")
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
package packages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
||||||
|
|
||||||
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPackageReadList(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
var gotQuery map[string]string
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
gotQuery = map[string]string{}
|
||||||
|
for k, v := range r.URL.Query() {
|
||||||
|
gotQuery[k] = v[0]
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[{"id":1,"type":"container","name":"myrepo/myimage","version":"v1.0.0","html_url":"http://example.com","created_at":"2025-01-01T00:00:00Z","owner":{"login":"test-org"},"creator":{"login":"admin"}}]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
origHost := flag.Host
|
||||||
|
flag.Host = srv.URL
|
||||||
|
defer func() { flag.Host = origHost }()
|
||||||
|
|
||||||
|
ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "test-token")
|
||||||
|
|
||||||
|
t.Run("basic list", func(t *testing.T) {
|
||||||
|
req := mcp.CallToolRequest{}
|
||||||
|
req.Params.Arguments = map[string]any{
|
||||||
|
"method": "list",
|
||||||
|
"owner": "test-org",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := packageReadFn(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("packageReadFn() error: %v", err)
|
||||||
|
}
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatal("packageReadFn() returned error result")
|
||||||
|
}
|
||||||
|
|
||||||
|
text := result.Content[0].(mcp.TextContent).Text
|
||||||
|
var packages []map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(text), &packages); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal result: %v", err)
|
||||||
|
}
|
||||||
|
if len(packages) != 1 {
|
||||||
|
t.Fatalf("expected 1 package, got %d", len(packages))
|
||||||
|
}
|
||||||
|
if packages[0]["name"] != "myrepo/myimage" {
|
||||||
|
t.Errorf("expected name 'myrepo/myimage', got %v", packages[0]["name"])
|
||||||
|
}
|
||||||
|
if packages[0]["owner"] != "test-org" {
|
||||||
|
t.Errorf("expected owner 'test-org', got %v", packages[0]["owner"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("with type and query filters", func(t *testing.T) {
|
||||||
|
req := mcp.CallToolRequest{}
|
||||||
|
req.Params.Arguments = map[string]any{
|
||||||
|
"method": "list",
|
||||||
|
"owner": "test-org",
|
||||||
|
"type": "container",
|
||||||
|
"q": "myimage",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := packageReadFn(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("packageReadFn() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if gotQuery["type"] != "container" {
|
||||||
|
t.Errorf("expected type=container, got %q", gotQuery["type"])
|
||||||
|
}
|
||||||
|
if gotQuery["q"] != "myimage" {
|
||||||
|
t.Errorf("expected q=myimage, got %q", gotQuery["q"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("with pagination", func(t *testing.T) {
|
||||||
|
req := mcp.CallToolRequest{}
|
||||||
|
req.Params.Arguments = map[string]any{
|
||||||
|
"method": "list",
|
||||||
|
"owner": "test-org",
|
||||||
|
"page": float64(2),
|
||||||
|
"per_page": float64(10),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := packageReadFn(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("packageReadFn() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if gotQuery["page"] != "2" {
|
||||||
|
t.Errorf("expected page=2, got %q", gotQuery["page"])
|
||||||
|
}
|
||||||
|
if gotQuery["limit"] != "10" {
|
||||||
|
t.Errorf("expected limit=10, got %q", gotQuery["limit"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageReadListVersions(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
var gotPath string
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
gotPath = r.URL.RawPath
|
||||||
|
if gotPath == "" {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[{"id":1,"type":"container","name":"myrepo/myimage","version":"v1.0.0"},{"id":2,"type":"container","name":"myrepo/myimage","version":"v2.0.0"}]`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
origHost := flag.Host
|
||||||
|
flag.Host = srv.URL
|
||||||
|
defer func() { flag.Host = origHost }()
|
||||||
|
|
||||||
|
ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "test-token")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
testName string
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{"raw slash", "myrepo/myimage"},
|
||||||
|
{"pre-encoded slash", "myrepo%2Fmyimage"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.testName, func(t *testing.T) {
|
||||||
|
req := mcp.CallToolRequest{}
|
||||||
|
req.Params.Arguments = map[string]any{
|
||||||
|
"method": "list_versions",
|
||||||
|
"owner": "test-org",
|
||||||
|
"type": "container",
|
||||||
|
"name": tt.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := packageReadFn(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("packageReadFn() error: %v", err)
|
||||||
|
}
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatal("packageReadFn() returned error result")
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
wantPath := "/api/v1/packages/test-org/container/myrepo%2Fmyimage"
|
||||||
|
if gotPath != wantPath {
|
||||||
|
t.Errorf("request path = %q, want %q", gotPath, wantPath)
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
text := result.Content[0].(mcp.TextContent).Text
|
||||||
|
var versions []map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(text), &versions); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal result: %v", err)
|
||||||
|
}
|
||||||
|
if len(versions) != 2 {
|
||||||
|
t.Fatalf("expected 2 versions, got %d", len(versions))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageReadGet(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
var gotPath string
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
gotPath = r.URL.RawPath
|
||||||
|
if gotPath == "" {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"id":1,"type":"container","name":"myrepo/myimage","version":"v1.0.0","html_url":"http://example.com","created_at":"2025-01-01T00:00:00Z","owner":{"login":"test-org"}}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
origHost := flag.Host
|
||||||
|
flag.Host = srv.URL
|
||||||
|
defer func() { flag.Host = origHost }()
|
||||||
|
|
||||||
|
ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "test-token")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
testName string
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{"raw slash", "myrepo/myimage"},
|
||||||
|
{"pre-encoded slash", "myrepo%2Fmyimage"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.testName, func(t *testing.T) {
|
||||||
|
req := mcp.CallToolRequest{}
|
||||||
|
req.Params.Arguments = map[string]any{
|
||||||
|
"method": "get",
|
||||||
|
"owner": "test-org",
|
||||||
|
"type": "container",
|
||||||
|
"name": tt.name,
|
||||||
|
"version": "v1.0.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := packageReadFn(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("packageReadFn() error: %v", err)
|
||||||
|
}
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatal("packageReadFn() returned error result")
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
wantPath := "/api/v1/packages/test-org/container/myrepo%2Fmyimage/v1.0.0"
|
||||||
|
if gotPath != wantPath {
|
||||||
|
t.Errorf("request path = %q, want %q", gotPath, wantPath)
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
text := result.Content[0].(mcp.TextContent).Text
|
||||||
|
var pkg map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(text), &pkg); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal result: %v", err)
|
||||||
|
}
|
||||||
|
if pkg["name"] != "myrepo/myimage" {
|
||||||
|
t.Errorf("expected name 'myrepo/myimage', got %v", pkg["name"])
|
||||||
|
}
|
||||||
|
if pkg["version"] != "v1.0.0" {
|
||||||
|
t.Errorf("expected version 'v1.0.0', got %v", pkg["version"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageWriteDelete(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
var gotMethod string
|
||||||
|
var gotPath string
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
gotMethod = r.Method
|
||||||
|
gotPath = r.URL.RawPath
|
||||||
|
if gotPath == "" {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
origHost := flag.Host
|
||||||
|
flag.Host = srv.URL
|
||||||
|
defer func() { flag.Host = origHost }()
|
||||||
|
|
||||||
|
ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "test-token")
|
||||||
|
|
||||||
|
req := mcp.CallToolRequest{}
|
||||||
|
req.Params.Arguments = map[string]any{
|
||||||
|
"method": "delete",
|
||||||
|
"owner": "test-org",
|
||||||
|
"type": "container",
|
||||||
|
"name": "myrepo/myimage",
|
||||||
|
"version": "v1.0.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := packageWriteFn(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("packageWriteFn() error: %v", err)
|
||||||
|
}
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatal("packageWriteFn() returned error result")
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if gotMethod != "DELETE" {
|
||||||
|
t.Errorf("expected DELETE method, got %q", gotMethod)
|
||||||
|
}
|
||||||
|
wantPath := "/api/v1/packages/test-org/container/myrepo%2Fmyimage/v1.0.0"
|
||||||
|
if gotPath != wantPath {
|
||||||
|
t.Errorf("request path = %q, want %q", gotPath, wantPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageReadUnknownMethod(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
req := mcp.CallToolRequest{}
|
||||||
|
req.Params.Arguments = map[string]any{
|
||||||
|
"method": "bogus",
|
||||||
|
"owner": "test-org",
|
||||||
|
}
|
||||||
|
if _, err := packageReadFn(ctx, req); err == nil {
|
||||||
|
t.Fatal("expected error for unknown method")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageWriteUnknownMethod(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
req := mcp.CallToolRequest{}
|
||||||
|
req.Params.Arguments = map[string]any{
|
||||||
|
"method": "bogus",
|
||||||
|
"owner": "test-org",
|
||||||
|
}
|
||||||
|
if _, err := packageWriteFn(ctx, req); err == nil {
|
||||||
|
t.Fatal("expected error for unknown method")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEscapePackageName(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"simple name", "mypackage", "mypackage"},
|
||||||
|
{"raw slash", "myrepo/myimage", "myrepo%2Fmyimage"},
|
||||||
|
{"pre-encoded slash", "myrepo%2Fmyimage", "myrepo%2Fmyimage"},
|
||||||
|
{"pre-encoded uppercase", "myrepo%2Fmyimage", "myrepo%2Fmyimage"},
|
||||||
|
{"multiple slashes", "a/b/c", "a%2Fb%2Fc"},
|
||||||
|
{"pre-encoded multiple slashes", "a%2Fb%2Fc", "a%2Fb%2Fc"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := escapePackageName(tt.input)
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("escapePackageName(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSlimPackage(t *testing.T) {
|
||||||
|
raw := map[string]any{
|
||||||
|
"id": float64(1),
|
||||||
|
"type": "container",
|
||||||
|
"name": "test-repo/test-image",
|
||||||
|
"version": "v1.0.0",
|
||||||
|
"html_url": "http://example.com/pkg",
|
||||||
|
"created_at": "2025-01-01T00:00:00Z",
|
||||||
|
"owner": map[string]any{"login": "test-org", "id": float64(2), "email": ""},
|
||||||
|
"creator": map[string]any{"login": "admin", "id": float64(1)},
|
||||||
|
"repository": map[string]any{"full_name": "test-org/test-repo", "id": float64(3)},
|
||||||
|
}
|
||||||
|
|
||||||
|
slim := slimPackage(raw)
|
||||||
|
if slim["owner"] != "test-org" {
|
||||||
|
t.Errorf("expected owner 'test-org', got %v", slim["owner"])
|
||||||
|
}
|
||||||
|
if slim["creator"] != "admin" {
|
||||||
|
t.Errorf("expected creator 'admin', got %v", slim["creator"])
|
||||||
|
}
|
||||||
|
if slim["repository"] != "test-org/test-repo" {
|
||||||
|
t.Errorf("expected repository 'test-org/test-repo', got %v", slim["repository"])
|
||||||
|
}
|
||||||
|
if _, ok := slim["owner"].(map[string]any); ok {
|
||||||
|
t.Error("expected owner to be a string, not a map")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package packages
|
||||||
|
|
||||||
|
func slimPackage(v any) map[string]any {
|
||||||
|
m, ok := v.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := map[string]any{
|
||||||
|
"id": m["id"],
|
||||||
|
"type": m["type"],
|
||||||
|
"name": m["name"],
|
||||||
|
"version": m["version"],
|
||||||
|
"html_url": m["html_url"],
|
||||||
|
"created_at": m["created_at"],
|
||||||
|
}
|
||||||
|
if owner, ok := m["owner"].(map[string]any); ok {
|
||||||
|
out["owner"] = owner["login"]
|
||||||
|
}
|
||||||
|
if creator, ok := m["creator"].(map[string]any); ok {
|
||||||
|
out["creator"] = creator["login"]
|
||||||
|
}
|
||||||
|
if repo, ok := m["repository"].(map[string]any); ok {
|
||||||
|
out["repository"] = repo["full_name"]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func slimPackages(v any) any {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case []any:
|
||||||
|
out := make([]map[string]any, 0, len(val))
|
||||||
|
for _, item := range val {
|
||||||
|
if slim := slimPackage(item); slim != nil {
|
||||||
|
out = append(out, slim)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case map[string]any:
|
||||||
|
return slimPackage(val)
|
||||||
|
default:
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
+243
-72
@@ -3,8 +3,10 @@ package pull
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/annotation"
|
||||||
"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"
|
||||||
@@ -28,78 +30,81 @@ const (
|
|||||||
var (
|
var (
|
||||||
ListRepoPullRequestsTool = mcp.NewTool(
|
ListRepoPullRequestsTool = mcp.NewTool(
|
||||||
ListRepoPullRequestsToolName,
|
ListRepoPullRequestsToolName,
|
||||||
mcp.WithDescription("List repository pull requests"),
|
mcp.WithToolAnnotation(annotation.ReadOnly("List pull requests")),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
mcp.WithString("state", mcp.Description("state"), mcp.Enum("open", "closed", "all"), mcp.DefaultString("all")),
|
mcp.WithString("state", mcp.Enum("open", "closed", "all"), mcp.DefaultString("all")),
|
||||||
mcp.WithString("sort", mcp.Description("sort"), mcp.Enum("oldest", "recentupdate", "leastupdate", "mostcomment", "leastcomment", "priority"), mcp.DefaultString("recentupdate")),
|
mcp.WithString("sort", mcp.Enum("oldest", "recentupdate", "leastupdate", "mostcomment", "leastcomment", "priority"), mcp.DefaultString("recentupdate")),
|
||||||
mcp.WithNumber("milestone", mcp.Description("milestone")),
|
mcp.WithNumber("milestone"),
|
||||||
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
|
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
|
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
|
||||||
)
|
)
|
||||||
|
|
||||||
PullRequestReadTool = mcp.NewTool(
|
PullRequestReadTool = mcp.NewTool(
|
||||||
PullRequestReadToolName,
|
PullRequestReadToolName,
|
||||||
mcp.WithDescription("Get pull request information. Use method 'get' for PR details, 'get_diff' for diff, 'get_reviews'/'get_review'/'get_review_comments' for review data."),
|
mcp.WithDescription("Read pull request: details, diff, changed files, head commit status, reviews."),
|
||||||
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "get_diff", "get_reviews", "get_review", "get_review_comments")),
|
mcp.WithToolAnnotation(annotation.ReadOnly("Read pull request details")),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("method", mcp.Required(), mcp.Enum("get", "get_diff", "get_files", "get_status", "get_reviews", "get_review", "get_review_comments")),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithNumber("index", mcp.Required(), mcp.Description("pull request index")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
mcp.WithNumber("review_id", mcp.Description("review ID (required for 'get_review', 'get_review_comments')")),
|
mcp.WithNumber("pull_number", mcp.Required()),
|
||||||
mcp.WithBoolean("binary", mcp.Description("whether to include binary file changes (for 'get_diff')")),
|
mcp.WithNumber("review_id", mcp.Description("for 'get_review'/'get_review_comments'")),
|
||||||
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
|
mcp.WithBoolean("binary", mcp.Description("include binary diff")),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
|
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
|
||||||
|
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
|
||||||
)
|
)
|
||||||
|
|
||||||
PullRequestWriteTool = mcp.NewTool(
|
PullRequestWriteTool = mcp.NewTool(
|
||||||
PullRequestWriteToolName,
|
PullRequestWriteToolName,
|
||||||
mcp.WithDescription("Create, update, or merge pull requests, manage reviewers."),
|
mcp.WithDescription("Write pull requests: create, update, close, reopen, merge, update branch from base, manage reviewers."),
|
||||||
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "update", "merge", "add_reviewers", "remove_reviewers")),
|
mcp.WithToolAnnotation(annotation.Write("Create, update, close, reopen, or merge pull requests")),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("method", mcp.Required(), mcp.Enum("create", "update", "close", "reopen", "merge", "update_branch", "add_reviewers", "remove_reviewers")),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithNumber("index", mcp.Description("pull request index (required for all methods except 'create')")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
mcp.WithString("title", mcp.Description("PR title (required for 'create', optional for 'update', 'merge')")),
|
mcp.WithNumber("pull_number", mcp.Description("required except for 'create'")),
|
||||||
mcp.WithString("body", mcp.Description("PR body (required for 'create', optional for 'update')")),
|
mcp.WithString("title", mcp.Description("required for 'create'; optional for 'update'/'merge'")),
|
||||||
mcp.WithString("head", mcp.Description("PR head branch (required for 'create')")),
|
mcp.WithString("body", mcp.Description("required for 'create'; optional for 'update'")),
|
||||||
mcp.WithString("base", mcp.Description("PR base branch (required for 'create', optional for 'update')")),
|
mcp.WithString("head", mcp.Description("head branch (required for 'create')")),
|
||||||
mcp.WithString("assignee", mcp.Description("username to assign (for 'update')")),
|
mcp.WithString("base", mcp.Description("base branch (required for 'create')")),
|
||||||
mcp.WithArray("assignees", mcp.Description("usernames to assign (for 'update')"), mcp.Items(map[string]any{"type": "string"})),
|
mcp.WithString("assignee", mcp.Description("for 'update'")),
|
||||||
mcp.WithNumber("milestone", mcp.Description("milestone number (for 'update')")),
|
mcp.WithArray("assignees", mcp.Description("for 'update'"), mcp.Items(map[string]any{"type": "string"})),
|
||||||
mcp.WithString("state", mcp.Description("PR state (for 'update')"), mcp.Enum("open", "closed")),
|
mcp.WithNumber("milestone", mcp.Description("for 'update'")),
|
||||||
mcp.WithBoolean("allow_maintainer_edit", mcp.Description("allow maintainer to edit (for 'update')")),
|
mcp.WithString("state", mcp.Description("for 'update'"), mcp.Enum("open", "closed")),
|
||||||
mcp.WithArray("labels", mcp.Description("array of label IDs (for 'create', 'update')"), mcp.Items(map[string]any{"type": "number"})),
|
mcp.WithBoolean("allow_maintainer_edit", mcp.Description("for 'update'")),
|
||||||
mcp.WithString("deadline", mcp.Description("due date in ISO 8601 format (for 'create', 'update')")),
|
mcp.WithArray("labels", mcp.Description("label IDs"), mcp.Items(map[string]any{"type": "number"})),
|
||||||
mcp.WithBoolean("remove_deadline", mcp.Description("unset due date (for 'update')")),
|
mcp.WithString("deadline", mcp.Description("ISO 8601")),
|
||||||
mcp.WithString("merge_style", mcp.Description("merge style (for 'merge')"), mcp.Enum("merge", "rebase", "rebase-merge", "squash", "fast-forward-only"), mcp.DefaultString("merge")),
|
mcp.WithBoolean("remove_deadline", mcp.Description("for 'update'")),
|
||||||
mcp.WithString("message", mcp.Description("merge commit message (for 'merge') or dismissal reason")),
|
mcp.WithString("merge_style", mcp.Description("for 'merge'"), mcp.Enum("merge", "rebase", "rebase-merge", "squash", "fast-forward-only"), mcp.DefaultString("merge")),
|
||||||
mcp.WithBoolean("delete_branch", mcp.Description("delete branch after merge (for 'merge')")),
|
mcp.WithString("message", mcp.Description("merge commit message or dismissal reason")),
|
||||||
mcp.WithBoolean("force_merge", mcp.Description("force merge even if checks are not passing (for 'merge')")),
|
mcp.WithBoolean("delete_branch", mcp.Description("for 'merge'")),
|
||||||
mcp.WithBoolean("merge_when_checks_succeed", mcp.Description("auto-merge when checks succeed (for 'merge')")),
|
mcp.WithBoolean("force_merge", mcp.Description("merge even if checks fail")),
|
||||||
mcp.WithString("head_commit_id", mcp.Description("expected head commit SHA for merge conflict detection (for 'merge')")),
|
mcp.WithBoolean("merge_when_checks_succeed", mcp.Description("for 'merge'")),
|
||||||
mcp.WithArray("reviewers", mcp.Description("reviewer usernames (for 'add_reviewers', 'remove_reviewers')"), mcp.Items(map[string]any{"type": "string"})),
|
mcp.WithString("head_commit_id", mcp.Description("expected head SHA for conflict detection")),
|
||||||
mcp.WithArray("team_reviewers", mcp.Description("team reviewer names (for 'add_reviewers', 'remove_reviewers')"), mcp.Items(map[string]any{"type": "string"})),
|
mcp.WithArray("reviewers", mcp.Description("for 'add_reviewers'/'remove_reviewers'"), mcp.Items(map[string]any{"type": "string"})),
|
||||||
mcp.WithBoolean("draft", mcp.Description("mark PR as draft (for 'create', 'update'). Gitea uses a 'WIP: ' title prefix for drafts.")),
|
mcp.WithArray("team_reviewers", mcp.Description("for 'add_reviewers'/'remove_reviewers'"), mcp.Items(map[string]any{"type": "string"})),
|
||||||
|
mcp.WithBoolean("draft", mcp.Description("uses 'WIP: ' title prefix")),
|
||||||
)
|
)
|
||||||
|
|
||||||
PullRequestReviewWriteTool = mcp.NewTool(
|
PullRequestReviewWriteTool = mcp.NewTool(
|
||||||
PullRequestReviewWriteToolName,
|
PullRequestReviewWriteToolName,
|
||||||
mcp.WithDescription("Manage pull request reviews: create, submit, delete, or dismiss."),
|
mcp.WithDescription("Write PR reviews: create, submit, delete, dismiss."),
|
||||||
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "submit", "delete", "dismiss")),
|
mcp.WithToolAnnotation(annotation.Write("Submit a pull request review")),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("method", mcp.Required(), mcp.Enum("create", "submit", "delete", "dismiss")),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithNumber("index", mcp.Required(), mcp.Description("pull request index")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
mcp.WithNumber("review_id", mcp.Description("review ID (required for 'submit', 'delete', 'dismiss')")),
|
mcp.WithNumber("pull_number", mcp.Required()),
|
||||||
mcp.WithString("state", mcp.Description("review state"), mcp.Enum("APPROVED", "REQUEST_CHANGES", "COMMENT", "PENDING")),
|
mcp.WithNumber("review_id", mcp.Description("required except for 'create'")),
|
||||||
mcp.WithString("body", mcp.Description("review body/comment")),
|
mcp.WithString("state", mcp.Enum("APPROVED", "REQUEST_CHANGES", "COMMENT", "PENDING")),
|
||||||
mcp.WithString("commit_id", mcp.Description("commit SHA to review (for 'create')")),
|
mcp.WithString("body"),
|
||||||
mcp.WithString("message", mcp.Description("dismissal reason (for 'dismiss')")),
|
mcp.WithString("commit_id", mcp.Description("for 'create'")),
|
||||||
mcp.WithArray("comments", mcp.Description("inline review comments (for 'create')"), mcp.Items(map[string]any{
|
mcp.WithString("message", mcp.Description("dismissal reason")),
|
||||||
|
mcp.WithArray("comments", mcp.Description("inline comments (for 'create')"), mcp.Items(map[string]any{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]any{
|
"properties": map[string]any{
|
||||||
"path": map[string]any{"type": "string", "description": "file path to comment on"},
|
"path": map[string]any{"type": "string"},
|
||||||
"body": map[string]any{"type": "string", "description": "comment body"},
|
"body": map[string]any{"type": "string"},
|
||||||
"old_line_num": map[string]any{"type": "number", "description": "line number in the old file (for deletions/changes)"},
|
"old_line_num": map[string]any{"type": "number", "description": "old-file line (deletions)"},
|
||||||
"new_line_num": map[string]any{"type": "number", "description": "line number in the new file (for additions/changes)"},
|
"new_line_num": map[string]any{"type": "number", "description": "new-file line (additions)"},
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
@@ -134,6 +139,10 @@ func pullRequestReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
|
|||||||
return getPullRequestByIndexFn(ctx, req)
|
return getPullRequestByIndexFn(ctx, req)
|
||||||
case "get_diff":
|
case "get_diff":
|
||||||
return getPullRequestDiffFn(ctx, req)
|
return getPullRequestDiffFn(ctx, req)
|
||||||
|
case "get_files":
|
||||||
|
return getPullRequestFilesFn(ctx, req)
|
||||||
|
case "get_status":
|
||||||
|
return getPullRequestStatusFn(ctx, req)
|
||||||
case "get_reviews":
|
case "get_reviews":
|
||||||
return listPullRequestReviewsFn(ctx, req)
|
return listPullRequestReviewsFn(ctx, req)
|
||||||
case "get_review":
|
case "get_review":
|
||||||
@@ -155,8 +164,14 @@ func pullRequestWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
|
|||||||
return createPullRequestFn(ctx, req)
|
return createPullRequestFn(ctx, req)
|
||||||
case "update":
|
case "update":
|
||||||
return editPullRequestFn(ctx, req)
|
return editPullRequestFn(ctx, req)
|
||||||
|
case "close":
|
||||||
|
return closePullRequestFn(ctx, req)
|
||||||
|
case "reopen":
|
||||||
|
return reopenPullRequestFn(ctx, req)
|
||||||
case "merge":
|
case "merge":
|
||||||
return mergePullRequestFn(ctx, req)
|
return mergePullRequestFn(ctx, req)
|
||||||
|
case "update_branch":
|
||||||
|
return updatePullRequestBranchFn(ctx, req)
|
||||||
case "add_reviewers":
|
case "add_reviewers":
|
||||||
return createPullRequestReviewerFn(ctx, req)
|
return createPullRequestReviewerFn(ctx, req)
|
||||||
case "remove_reviewers":
|
case "remove_reviewers":
|
||||||
@@ -166,6 +181,66 @@ func pullRequestWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func closePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
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(), "pull_number")
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
state := gitea_sdk.StateClosed
|
||||||
|
pr, _, err := client.EditPullRequest(owner, repo, index, gitea_sdk.EditPullRequestOption{
|
||||||
|
State: &state,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("close %v/%v/pr/%v err: %v", owner, repo, index, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return to.TextResult(slimPullRequest(pr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func reopenPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
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(), "pull_number")
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
state := gitea_sdk.StateOpen
|
||||||
|
pr, _, err := client.EditPullRequest(owner, repo, index, gitea_sdk.EditPullRequestOption{
|
||||||
|
State: &state,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("reopen %v/%v/pr/%v err: %v", owner, repo, index, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return to.TextResult(slimPullRequest(pr))
|
||||||
|
}
|
||||||
|
|
||||||
func pullRequestReviewWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
func pullRequestReviewWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
method, err := params.GetString(req.GetArguments(), "method")
|
method, err := params.GetString(req.GetArguments(), "method")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -196,7 +271,7 @@ func getPullRequestByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
index, err := params.GetIndex(args, "index")
|
index, err := params.GetIndex(args, "pull_number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
@@ -209,7 +284,17 @@ func getPullRequestByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp
|
|||||||
return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v err: %v", owner, repo, index, err))
|
return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v err: %v", owner, repo, index, err))
|
||||||
}
|
}
|
||||||
|
|
||||||
return to.TextResult(slimPullRequest(pr))
|
// /pulls/{n} omits `assets`; PRs are issues internally, so the issue
|
||||||
|
// assets endpoint surfaces description attachments.
|
||||||
|
var assets []*gitea_sdk.Attachment
|
||||||
|
assetsPath := fmt.Sprintf("repos/%s/%s/issues/%d/assets", url.PathEscape(owner), url.PathEscape(repo), index)
|
||||||
|
if _, err := gitea.DoJSON(ctx, "GET", assetsPath, nil, nil, &assets); err != nil {
|
||||||
|
log.Debugf("fetch %v/%v/issues/%v/assets err: %v", owner, repo, index, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := slimPullRequest(pr)
|
||||||
|
m["body"] = bodyWithAttachments(pr.Body, assets)
|
||||||
|
return to.TextResult(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getPullRequestDiffFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
func getPullRequestDiffFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
@@ -223,7 +308,7 @@ func getPullRequestDiffFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
index, err := params.GetIndex(args, "index")
|
index, err := params.GetIndex(args, "pull_number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
@@ -366,7 +451,7 @@ func createPullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest) (
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
index, err := params.GetIndex(args, "index")
|
index, err := params.GetIndex(args, "pull_number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
@@ -409,7 +494,7 @@ func deletePullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest) (
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
index, err := params.GetIndex(args, "index")
|
index, err := params.GetIndex(args, "pull_number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
@@ -452,7 +537,7 @@ func listPullRequestReviewsFn(ctx context.Context, req mcp.CallToolRequest) (*mc
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
index, err := params.GetIndex(args, "index")
|
index, err := params.GetIndex(args, "pull_number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
@@ -487,7 +572,7 @@ func getPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
index, err := params.GetIndex(args, "index")
|
index, err := params.GetIndex(args, "pull_number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
@@ -520,7 +605,7 @@ func listPullRequestReviewCommentsFn(ctx context.Context, req mcp.CallToolReques
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
index, err := params.GetIndex(args, "index")
|
index, err := params.GetIndex(args, "pull_number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
@@ -553,7 +638,7 @@ func createPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*m
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
index, err := params.GetIndex(args, "index")
|
index, err := params.GetIndex(args, "pull_number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
@@ -618,7 +703,7 @@ func submitPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*m
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
index, err := params.GetIndex(args, "index")
|
index, err := params.GetIndex(args, "pull_number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
@@ -662,7 +747,7 @@ func deletePullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*m
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
index, err := params.GetIndex(args, "index")
|
index, err := params.GetIndex(args, "pull_number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
@@ -702,7 +787,7 @@ func dismissPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
index, err := params.GetIndex(args, "index")
|
index, err := params.GetIndex(args, "pull_number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
@@ -747,7 +832,7 @@ func mergePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
index, err := params.GetIndex(args, "index")
|
index, err := params.GetIndex(args, "pull_number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
@@ -811,7 +896,7 @@ func editPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
index, err := params.GetIndex(args, "index")
|
index, err := params.GetIndex(args, "pull_number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
@@ -880,3 +965,89 @@ func editPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
|
|||||||
|
|
||||||
return to.TextResult(slimPullRequest(pr))
|
return to.TextResult(slimPullRequest(pr))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updatePullRequestBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
log.Debugf("Called updatePullRequestBranchFn")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
index, err := params.GetIndex(args, "pull_number")
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := fmt.Sprintf("repos/%s/%s/pulls/%d/update", url.PathEscape(owner), url.PathEscape(repo), index)
|
||||||
|
if _, err := gitea.DoJSON(ctx, "POST", path, nil, nil, nil); err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("update %v/%v/pr/%v branch err: %v", owner, repo, index, err))
|
||||||
|
}
|
||||||
|
return to.TextResult(map[string]any{"message": "branch updated from base"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPullRequestFilesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
log.Debugf("Called getPullRequestFilesFn")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
index, err := params.GetIndex(args, "pull_number")
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(err)
|
||||||
|
}
|
||||||
|
page, pageSize := params.GetPagination(args, 30)
|
||||||
|
client, err := gitea.ClientFromContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
|
||||||
|
}
|
||||||
|
files, _, err := client.ListPullRequestFiles(owner, repo, index, gitea_sdk.ListPullRequestFilesOptions{
|
||||||
|
ListOptions: gitea_sdk.ListOptions{Page: page, PageSize: pageSize},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v files err: %v", owner, repo, index, err))
|
||||||
|
}
|
||||||
|
return to.TextResult(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPullRequestStatusFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
log.Debugf("Called getPullRequestStatusFn")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
index, err := params.GetIndex(args, "pull_number")
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
pr, _, err := client.GetPullRequest(owner, repo, index)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v err: %v", owner, repo, index, err))
|
||||||
|
}
|
||||||
|
if pr.Head == nil || pr.Head.Sha == "" {
|
||||||
|
return to.ErrorResult(fmt.Errorf("pr %v/%v/%v has no head SHA", owner, repo, index))
|
||||||
|
}
|
||||||
|
|
||||||
|
status, _, err := client.GetCombinedStatus(owner, repo, pr.Head.Sha)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v status err: %v", owner, repo, index, err))
|
||||||
|
}
|
||||||
|
return to.TextResult(status)
|
||||||
|
}
|
||||||
|
|||||||
+275
-5
@@ -6,10 +6,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
||||||
|
|
||||||
"github.com/mark3labs/mcp-go/mcp"
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -80,7 +82,7 @@ func Test_editPullRequestFn(t *testing.T) {
|
|||||||
Arguments: map[string]any{
|
Arguments: map[string]any{
|
||||||
"owner": owner,
|
"owner": owner,
|
||||||
"repo": repo,
|
"repo": repo,
|
||||||
"index": ii.val,
|
"pull_number": ii.val,
|
||||||
"title": "WIP: my feature",
|
"title": "WIP: my feature",
|
||||||
"state": "open",
|
"state": "open",
|
||||||
},
|
},
|
||||||
@@ -193,7 +195,7 @@ func Test_mergePullRequestFn(t *testing.T) {
|
|||||||
Arguments: map[string]any{
|
Arguments: map[string]any{
|
||||||
"owner": owner,
|
"owner": owner,
|
||||||
"repo": repo,
|
"repo": repo,
|
||||||
"index": ii.val,
|
"pull_number": ii.val,
|
||||||
"merge_style": "squash",
|
"merge_style": "squash",
|
||||||
"title": "feat: my squashed commit",
|
"title": "feat: my squashed commit",
|
||||||
"message": "Squash merge of PR #5",
|
"message": "Squash merge of PR #5",
|
||||||
@@ -306,7 +308,7 @@ func Test_mergePullRequestFn_newParams(t *testing.T) {
|
|||||||
Arguments: map[string]any{
|
Arguments: map[string]any{
|
||||||
"owner": owner,
|
"owner": owner,
|
||||||
"repo": repo,
|
"repo": repo,
|
||||||
"index": float64(index),
|
"pull_number": float64(index),
|
||||||
"merge_style": "merge",
|
"merge_style": "merge",
|
||||||
"force_merge": true,
|
"force_merge": true,
|
||||||
"merge_when_checks_succeed": true,
|
"merge_when_checks_succeed": true,
|
||||||
@@ -616,7 +618,7 @@ func Test_editPullRequestFn_draft(t *testing.T) {
|
|||||||
args := map[string]any{
|
args := map[string]any{
|
||||||
"owner": owner,
|
"owner": owner,
|
||||||
"repo": repo,
|
"repo": repo,
|
||||||
"index": float64(index),
|
"pull_number": float64(index),
|
||||||
}
|
}
|
||||||
if tc.title != "" {
|
if tc.title != "" {
|
||||||
args["title"] = tc.title
|
args["title"] = tc.title
|
||||||
@@ -720,7 +722,7 @@ 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,
|
"pull_number": ii.val,
|
||||||
"binary": true,
|
"binary": true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -769,3 +771,271 @@ func Test_getPullRequestDiffFn(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_getPullRequestByIndexFn_includesAttachments(t *testing.T) {
|
||||||
|
const (
|
||||||
|
owner = "octo"
|
||||||
|
repo = "demo"
|
||||||
|
index = 9
|
||||||
|
)
|
||||||
|
|
||||||
|
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):
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"number":9,"title":"feat","body":"see screenshot","state":"open"}`))
|
||||||
|
case fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets", owner, repo, index):
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[{"id":1,"name":"shot.png","browser_download_url":"https://example/shot.png"}]`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
server := httptest.NewServer(handler)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version
|
||||||
|
flag.Host, flag.Token, flag.Version = server.URL, "", "test"
|
||||||
|
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
|
||||||
|
|
||||||
|
req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{
|
||||||
|
"owner": owner, "repo": repo, "pull_number": float64(index),
|
||||||
|
}}}
|
||||||
|
res, err := getPullRequestByIndexFn(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getPullRequestByIndexFn() error = %v", err)
|
||||||
|
}
|
||||||
|
if res.IsError {
|
||||||
|
t.Fatalf("unexpected error result: %v", res.Content)
|
||||||
|
}
|
||||||
|
body := res.Content[0].(mcp.TextContent).Text
|
||||||
|
if !strings.Contains(body, `[shot.png](https://example/shot.png)`) {
|
||||||
|
t.Fatalf("expected attachment markdown inlined in body, got: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_getPullRequestByIndexFn_emptyAssetsLeavesBody(t *testing.T) {
|
||||||
|
const (
|
||||||
|
owner = "octo"
|
||||||
|
repo = "demo"
|
||||||
|
index = 9
|
||||||
|
)
|
||||||
|
|
||||||
|
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):
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"number":9,"title":"feat","body":"plain body","state":"open"}`))
|
||||||
|
case fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets", owner, repo, index):
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[]`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
server := httptest.NewServer(handler)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version
|
||||||
|
flag.Host, flag.Token, flag.Version = server.URL, "", "test"
|
||||||
|
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
|
||||||
|
|
||||||
|
req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{
|
||||||
|
"owner": owner, "repo": repo, "pull_number": float64(index),
|
||||||
|
}}}
|
||||||
|
res, err := getPullRequestByIndexFn(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getPullRequestByIndexFn() error = %v", err)
|
||||||
|
}
|
||||||
|
body := res.Content[0].(mcp.TextContent).Text
|
||||||
|
if !strings.Contains(body, `"body":"plain body"`) {
|
||||||
|
t.Fatalf("expected body unchanged when assets are empty, got: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_getPullRequestByIndexFn_assetsFailureNonFatal(t *testing.T) {
|
||||||
|
const (
|
||||||
|
owner = "octo"
|
||||||
|
repo = "demo"
|
||||||
|
index = 9
|
||||||
|
)
|
||||||
|
|
||||||
|
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):
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"number":9,"title":"feat","body":"plain body","state":"open"}`))
|
||||||
|
case fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets", owner, repo, index):
|
||||||
|
http.Error(w, "boom", http.StatusInternalServerError)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
server := httptest.NewServer(handler)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version
|
||||||
|
flag.Host, flag.Token, flag.Version = server.URL, "", "test"
|
||||||
|
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
|
||||||
|
|
||||||
|
req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{
|
||||||
|
"owner": owner, "repo": repo, "pull_number": float64(index),
|
||||||
|
}}}
|
||||||
|
res, err := getPullRequestByIndexFn(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getPullRequestByIndexFn() error = %v", err)
|
||||||
|
}
|
||||||
|
if res.IsError {
|
||||||
|
t.Fatalf("assets fetch failure should not fail the PR fetch: %v", res.Content)
|
||||||
|
}
|
||||||
|
body := res.Content[0].(mcp.TextContent).Text
|
||||||
|
if !strings.Contains(body, `"plain body"`) {
|
||||||
|
t.Fatalf("expected PR body preserved when assets fail, got: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_closePullRequestFn(t *testing.T) {
|
||||||
|
const (
|
||||||
|
owner = "octo"
|
||||||
|
repo = "demo"
|
||||||
|
index = 7
|
||||||
|
)
|
||||||
|
|
||||||
|
var 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):
|
||||||
|
if r.Method != http.MethodPatch {
|
||||||
|
t.Errorf("expected PATCH method, got %s", r.Method)
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write(fmt.Appendf(nil, `{"index":%d,"title":"Fix bug","state":"closed","head":{"ref":"fix-branch"},"base":{"ref":"main"}}`, index))
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server := httptest.NewServer(handler)
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
origHost := flag.Host
|
||||||
|
origToken := flag.Token
|
||||||
|
flag.Host = server.URL
|
||||||
|
flag.Token = "test-token"
|
||||||
|
t.Cleanup(func() { flag.Host = origHost; flag.Token = origToken })
|
||||||
|
|
||||||
|
req := mcp.CallToolRequest{
|
||||||
|
Params: mcp.CallToolParams{
|
||||||
|
Arguments: map[string]any{
|
||||||
|
"method": "close",
|
||||||
|
"owner": owner,
|
||||||
|
"repo": repo,
|
||||||
|
"pull_number": float64(index),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := closePullRequestFn(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("closePullRequestFn() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotBody["state"] != "closed" {
|
||||||
|
t.Errorf("expected state=closed, got %v", gotBody["state"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Content) == 0 {
|
||||||
|
t.Fatalf("expected content in result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_reopenPullRequestFn(t *testing.T) {
|
||||||
|
const (
|
||||||
|
owner = "octo"
|
||||||
|
repo = "demo"
|
||||||
|
index = 7
|
||||||
|
)
|
||||||
|
|
||||||
|
var 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):
|
||||||
|
if r.Method != http.MethodPatch {
|
||||||
|
t.Errorf("expected PATCH method, got %s", r.Method)
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write(fmt.Appendf(nil, `{"index":%d,"title":"Fix bug","state":"open","head":{"ref":"fix-branch"},"base":{"ref":"main"}}`, index))
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server := httptest.NewServer(handler)
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
origHost := flag.Host
|
||||||
|
origToken := flag.Token
|
||||||
|
flag.Host = server.URL
|
||||||
|
flag.Token = "test-token"
|
||||||
|
t.Cleanup(func() { flag.Host = origHost; flag.Token = origToken })
|
||||||
|
|
||||||
|
req := mcp.CallToolRequest{
|
||||||
|
Params: mcp.CallToolParams{
|
||||||
|
Arguments: map[string]any{
|
||||||
|
"method": "reopen",
|
||||||
|
"owner": owner,
|
||||||
|
"repo": repo,
|
||||||
|
"pull_number": float64(index),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := reopenPullRequestFn(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reopenPullRequestFn() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotBody["state"] != "open" {
|
||||||
|
t.Errorf("expected state=open, got %v", gotBody["state"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Content) == 0 {
|
||||||
|
t.Fatalf("expected content in result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,30 @@
|
|||||||
package pull
|
package pull
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
gitea_sdk "code.gitea.io/sdk/gitea"
|
gitea_sdk "code.gitea.io/sdk/gitea"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func bodyWithAttachments(body string, atts []*gitea_sdk.Attachment) string {
|
||||||
|
links := make([]string, 0, len(atts))
|
||||||
|
for _, a := range atts {
|
||||||
|
if a == nil || a.DownloadURL == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
links = append(links, fmt.Sprintf("[%s](%s)", a.Name, a.DownloadURL))
|
||||||
|
}
|
||||||
|
if len(links) == 0 {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
joined := strings.Join(links, "\n")
|
||||||
|
if body == "" {
|
||||||
|
return joined
|
||||||
|
}
|
||||||
|
return body + "\n\n" + joined
|
||||||
|
}
|
||||||
|
|
||||||
func userLogin(u *gitea_sdk.User) string {
|
func userLogin(u *gitea_sdk.User) string {
|
||||||
if u == nil {
|
if u == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
+15
-14
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/annotation"
|
||||||
"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"
|
||||||
@@ -23,28 +24,28 @@ const (
|
|||||||
var (
|
var (
|
||||||
CreateBranchTool = mcp.NewTool(
|
CreateBranchTool = mcp.NewTool(
|
||||||
CreateBranchToolName,
|
CreateBranchToolName,
|
||||||
mcp.WithDescription("Create branch"),
|
mcp.WithToolAnnotation(annotation.Write("Create a new branch")),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
mcp.WithString("branch", mcp.Required(), mcp.Description("Name of the branch to create")),
|
mcp.WithString("branch", mcp.Required()),
|
||||||
mcp.WithString("old_branch", mcp.Required(), mcp.Description("Name of the old branch to create from")),
|
mcp.WithString("old_branch", mcp.Description("source branch (default: repo default)")),
|
||||||
)
|
)
|
||||||
|
|
||||||
DeleteBranchTool = mcp.NewTool(
|
DeleteBranchTool = mcp.NewTool(
|
||||||
DeleteBranchToolName,
|
DeleteBranchToolName,
|
||||||
mcp.WithDescription("Delete branch"),
|
mcp.WithToolAnnotation(annotation.Destructive("Delete a branch")),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
mcp.WithString("branch", mcp.Required(), mcp.Description("Name of the branch to delete")),
|
mcp.WithString("branch", mcp.Required()),
|
||||||
)
|
)
|
||||||
|
|
||||||
ListBranchesTool = mcp.NewTool(
|
ListBranchesTool = mcp.NewTool(
|
||||||
ListBranchesToolName,
|
ListBranchesToolName,
|
||||||
mcp.WithDescription("List branches"),
|
mcp.WithToolAnnotation(annotation.ReadOnly("List repository branches")),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
|
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
|
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+15
-21
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/annotation"
|
||||||
"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"
|
||||||
@@ -22,21 +23,21 @@ const (
|
|||||||
var (
|
var (
|
||||||
ListRepoCommitsTool = mcp.NewTool(
|
ListRepoCommitsTool = mcp.NewTool(
|
||||||
ListRepoCommitsToolName,
|
ListRepoCommitsToolName,
|
||||||
mcp.WithDescription("List repository commits"),
|
mcp.WithToolAnnotation(annotation.ReadOnly("List repository commits")),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
mcp.WithString("sha", mcp.Description("SHA or branch to start listing commits from")),
|
mcp.WithString("sha", mcp.Description("starting SHA or branch")),
|
||||||
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("only commits touching this path")),
|
||||||
mcp.WithNumber("page", mcp.Required(), mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
|
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)),
|
||||||
mcp.WithNumber("perPage", mcp.Required(), mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30), mcp.Min(1)),
|
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30), mcp.Min(1)),
|
||||||
)
|
)
|
||||||
|
|
||||||
GetCommitTool = mcp.NewTool(
|
GetCommitTool = mcp.NewTool(
|
||||||
GetCommitToolName,
|
GetCommitToolName,
|
||||||
mcp.WithDescription("Get details of a specific commit"),
|
mcp.WithToolAnnotation(annotation.ReadOnly("Get commit details")),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
mcp.WithString("sha", mcp.Required(), mcp.Description("commit SHA")),
|
mcp.WithString("sha", mcp.Required()),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -62,20 +63,13 @@ func ListRepoCommitsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
page, err := params.GetIndex(args, "page")
|
page, pageSize := params.GetPagination(args, 30)
|
||||||
if err != nil {
|
|
||||||
return to.ErrorResult(err)
|
|
||||||
}
|
|
||||||
pageSize, err := params.GetIndex(args, "perPage")
|
|
||||||
if err != nil {
|
|
||||||
return to.ErrorResult(err)
|
|
||||||
}
|
|
||||||
sha, _ := args["sha"].(string)
|
sha, _ := args["sha"].(string)
|
||||||
path, _ := args["path"].(string)
|
path, _ := args["path"].(string)
|
||||||
opt := gitea_sdk.ListCommitOptions{
|
opt := gitea_sdk.ListCommitOptions{
|
||||||
ListOptions: gitea_sdk.ListOptions{
|
ListOptions: gitea_sdk.ListOptions{
|
||||||
Page: int(page),
|
Page: page,
|
||||||
PageSize: int(pageSize),
|
PageSize: pageSize,
|
||||||
},
|
},
|
||||||
SHA: sha,
|
SHA: sha,
|
||||||
Path: path,
|
Path: path,
|
||||||
|
|||||||
+32
-29
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/annotation"
|
||||||
"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"
|
||||||
@@ -28,45 +29,47 @@ const (
|
|||||||
var (
|
var (
|
||||||
GetFileContentTool = mcp.NewTool(
|
GetFileContentTool = mcp.NewTool(
|
||||||
GetFileToolName,
|
GetFileToolName,
|
||||||
mcp.WithDescription("Get file Content and Metadata"),
|
mcp.WithDescription("Get file content and metadata"),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithToolAnnotation(annotation.ReadOnly("Get file content")),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithString("ref", mcp.Required(), mcp.Description("ref can be branch/tag/commit")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
mcp.WithString("filePath", mcp.Required(), mcp.Description("file path")),
|
mcp.WithString("ref", mcp.Required(), mcp.Description("branch, tag, or commit SHA")),
|
||||||
mcp.WithBoolean("withLines", mcp.Description("whether to return file content with lines")),
|
mcp.WithString("path", mcp.Required()),
|
||||||
|
mcp.WithBoolean("withLines", mcp.Description("return numbered lines")),
|
||||||
)
|
)
|
||||||
|
|
||||||
GetDirContentTool = mcp.NewTool(
|
GetDirContentTool = mcp.NewTool(
|
||||||
GetDirToolName,
|
GetDirToolName,
|
||||||
mcp.WithDescription("Get a list of entries in a directory"),
|
mcp.WithToolAnnotation(annotation.ReadOnly("Get directory contents")),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
mcp.WithString("ref", mcp.Required(), mcp.Description("ref can be branch/tag/commit")),
|
mcp.WithString("ref", mcp.Required(), mcp.Description("branch, tag, or commit SHA")),
|
||||||
mcp.WithString("filePath", mcp.Required(), mcp.Description("directory path")),
|
mcp.WithString("path", mcp.Required()),
|
||||||
)
|
)
|
||||||
|
|
||||||
CreateOrUpdateFileTool = mcp.NewTool(
|
CreateOrUpdateFileTool = mcp.NewTool(
|
||||||
CreateOrUpdateFileToolName,
|
CreateOrUpdateFileToolName,
|
||||||
mcp.WithDescription("Create or update a file. If sha is provided, updates the existing file; otherwise creates a new file."),
|
mcp.WithDescription("Create or update a file (provide sha to update an existing file)."),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithToolAnnotation(annotation.Write("Create or update a file")),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithString("filePath", mcp.Required(), mcp.Description("file path")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
mcp.WithString("content", mcp.Required(), mcp.Description("file content")),
|
mcp.WithString("path", mcp.Required()),
|
||||||
|
mcp.WithString("content", mcp.Required()),
|
||||||
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.WithString("sha", mcp.Description("SHA of the existing file (required for update, omit for create)")),
|
mcp.WithString("sha", mcp.Description("existing file SHA (omit to create)")),
|
||||||
mcp.WithString("new_branch_name", mcp.Description("new branch name (for create only)")),
|
mcp.WithString("new_branch_name", mcp.Description("new branch (create only)")),
|
||||||
)
|
)
|
||||||
|
|
||||||
DeleteFileTool = mcp.NewTool(
|
DeleteFileTool = mcp.NewTool(
|
||||||
DeleteFileToolName,
|
DeleteFileToolName,
|
||||||
mcp.WithDescription("Delete file"),
|
mcp.WithToolAnnotation(annotation.Destructive("Delete a file")),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
mcp.WithString("filePath", mcp.Required(), mcp.Description("file path")),
|
mcp.WithString("path", mcp.Required()),
|
||||||
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.WithString("sha", mcp.Required(), mcp.Description("sha")),
|
mcp.WithString("sha", mcp.Required()),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -106,7 +109,7 @@ func GetFileContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
|
|||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
ref, _ := args["ref"].(string)
|
ref, _ := args["ref"].(string)
|
||||||
filePath, err := params.GetString(args, "filePath")
|
filePath, err := params.GetString(args, "path")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
@@ -170,7 +173,7 @@ func GetDirContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
|
|||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
ref, _ := args["ref"].(string)
|
ref, _ := args["ref"].(string)
|
||||||
filePath, err := params.GetString(args, "filePath")
|
filePath, err := params.GetString(args, "path")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
@@ -196,7 +199,7 @@ func CreateOrUpdateFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
filePath, err := params.GetString(args, "filePath")
|
filePath, err := params.GetString(args, "path")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
@@ -256,7 +259,7 @@ func DeleteFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRe
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
filePath, err := params.GetString(args, "filePath")
|
filePath, err := params.GetString(args, "path")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-28
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/annotation"
|
||||||
"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"
|
||||||
@@ -25,49 +26,50 @@ const (
|
|||||||
var (
|
var (
|
||||||
CreateReleaseTool = mcp.NewTool(
|
CreateReleaseTool = mcp.NewTool(
|
||||||
CreateReleaseToolName,
|
CreateReleaseToolName,
|
||||||
mcp.WithDescription("Create release"),
|
mcp.WithToolAnnotation(annotation.Write("Create a release")),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
mcp.WithString("tag_name", mcp.Required(), mcp.Description("tag name")),
|
mcp.WithString("tag_name", mcp.Required()),
|
||||||
mcp.WithString("target", mcp.Required(), mcp.Description("target commitish")),
|
mcp.WithString("target", mcp.Required(), mcp.Description("commitish")),
|
||||||
mcp.WithString("title", mcp.Required(), mcp.Description("release title")),
|
mcp.WithString("title", mcp.Required()),
|
||||||
mcp.WithBoolean("is_draft", mcp.Description("Whether the release is draft"), mcp.DefaultBool(false)),
|
mcp.WithBoolean("is_draft"),
|
||||||
mcp.WithBoolean("is_pre_release", mcp.Description("Whether the release is pre-release"), mcp.DefaultBool(false)),
|
mcp.WithBoolean("is_pre_release"),
|
||||||
mcp.WithString("body", mcp.Description("release body")),
|
mcp.WithString("body"),
|
||||||
)
|
)
|
||||||
|
|
||||||
DeleteReleaseTool = mcp.NewTool(
|
DeleteReleaseTool = mcp.NewTool(
|
||||||
DeleteReleaseToolName,
|
DeleteReleaseToolName,
|
||||||
mcp.WithDescription("Delete release"),
|
mcp.WithToolAnnotation(annotation.Destructive("Delete a release")),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
mcp.WithNumber("id", mcp.Required(), mcp.Description("release id")),
|
mcp.WithNumber("id", mcp.Required()),
|
||||||
)
|
)
|
||||||
|
|
||||||
GetReleaseTool = mcp.NewTool(
|
GetReleaseTool = mcp.NewTool(
|
||||||
GetReleaseToolName,
|
GetReleaseToolName,
|
||||||
mcp.WithDescription("Get release"),
|
mcp.WithDescription("Get a release by ID"),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithToolAnnotation(annotation.ReadOnly("Get release details")),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithNumber("id", mcp.Required(), mcp.Description("release id")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
|
mcp.WithNumber("id", mcp.Required()),
|
||||||
)
|
)
|
||||||
|
|
||||||
GetLatestReleaseTool = mcp.NewTool(
|
GetLatestReleaseTool = mcp.NewTool(
|
||||||
GetLatestReleaseToolName,
|
GetLatestReleaseToolName,
|
||||||
mcp.WithDescription("Get latest release"),
|
mcp.WithToolAnnotation(annotation.ReadOnly("Get latest release")),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
)
|
)
|
||||||
|
|
||||||
ListReleasesTool = mcp.NewTool(
|
ListReleasesTool = mcp.NewTool(
|
||||||
ListReleasesToolName,
|
ListReleasesToolName,
|
||||||
mcp.WithDescription("List releases"),
|
mcp.WithToolAnnotation(annotation.ReadOnly("List releases")),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
mcp.WithBoolean("is_draft", mcp.Description("Whether the release is draft"), mcp.DefaultBool(false)),
|
mcp.WithBoolean("is_draft"),
|
||||||
mcp.WithBoolean("is_pre_release", mcp.Description("Whether the release is pre-release"), mcp.DefaultBool(false)),
|
mcp.WithBoolean("is_pre_release"),
|
||||||
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
|
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(20), mcp.Min(1)),
|
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(20), mcp.Min(1)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -242,7 +244,7 @@ func ListReleasesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
|
|||||||
pIsPreRelease = new(isPreRelease)
|
pIsPreRelease = new(isPreRelease)
|
||||||
}
|
}
|
||||||
page := params.GetOptionalInt(args, "page", 1)
|
page := params.GetOptionalInt(args, "page", 1)
|
||||||
pageSize := params.GetOptionalInt(args, "perPage", 20)
|
pageSize := params.GetOptionalInt(args, "per_page", 20)
|
||||||
|
|
||||||
client, err := gitea.ClientFromContext(ctx)
|
client, err := gitea.ClientFromContext(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+30
-36
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/annotation"
|
||||||
"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"
|
||||||
@@ -28,44 +29,44 @@ const (
|
|||||||
var (
|
var (
|
||||||
CreateRepoTool = mcp.NewTool(
|
CreateRepoTool = mcp.NewTool(
|
||||||
CreateRepoToolName,
|
CreateRepoToolName,
|
||||||
mcp.WithDescription("Create repository in personal account or organization"),
|
mcp.WithToolAnnotation(annotation.Write("Create a new repository")),
|
||||||
mcp.WithString("name", mcp.Required(), mcp.Description("Name of the repository to create")),
|
mcp.WithString("name", mcp.Required()),
|
||||||
mcp.WithString("description", mcp.Description("Description of the repository to create")),
|
mcp.WithString("description"),
|
||||||
mcp.WithBoolean("private", mcp.Description("Whether the repository is private")),
|
mcp.WithBoolean("private"),
|
||||||
mcp.WithString("issue_labels", mcp.Description("Issue Label set to use")),
|
mcp.WithString("issue_labels"),
|
||||||
mcp.WithBoolean("auto_init", mcp.Description("Whether the repository should be auto-intialized?")),
|
mcp.WithBoolean("auto_init"),
|
||||||
mcp.WithBoolean("template", mcp.Description("Whether the repository is template")),
|
mcp.WithBoolean("template"),
|
||||||
mcp.WithString("gitignores", mcp.Description("Gitignores to use")),
|
mcp.WithString("gitignores"),
|
||||||
mcp.WithString("license", mcp.Description("License to use")),
|
mcp.WithString("license"),
|
||||||
mcp.WithString("readme", mcp.Description("Readme of the repository to create")),
|
mcp.WithString("readme"),
|
||||||
mcp.WithString("default_branch", mcp.Description("DefaultBranch of the repository (used when initializes and in template)")),
|
mcp.WithString("default_branch"),
|
||||||
mcp.WithString("trust_model", mcp.Description("Trust model for verifying GPG signatures"), mcp.Enum("default", "collaborator", "committer", "collaboratorcommitter")),
|
mcp.WithString("trust_model", mcp.Enum("default", "collaborator", "committer", "collaboratorcommitter")),
|
||||||
mcp.WithString("object_format_name", mcp.Description("Object format: sha1 or sha256"), mcp.Enum("sha1", "sha256")),
|
mcp.WithString("object_format_name", mcp.Enum("sha1", "sha256")),
|
||||||
mcp.WithString("organization", mcp.Description("Organization name to create repository in (optional - defaults to personal account)")),
|
mcp.WithString("organization", mcp.Description("defaults to personal account")),
|
||||||
)
|
)
|
||||||
|
|
||||||
ForkRepoTool = mcp.NewTool(
|
ForkRepoTool = mcp.NewTool(
|
||||||
ForkRepoToolName,
|
ForkRepoToolName,
|
||||||
mcp.WithDescription("Fork repository"),
|
mcp.WithToolAnnotation(annotation.Write("Fork a repository")),
|
||||||
mcp.WithString("user", mcp.Required(), mcp.Description("User name of the repository to fork")),
|
mcp.WithString("user", mcp.Required(), mcp.Description("owner of source repo")),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name to fork")),
|
mcp.WithString("repo", mcp.Required()),
|
||||||
mcp.WithString("organization", mcp.Description("Organization name to fork")),
|
mcp.WithString("organization", mcp.Description("target org")),
|
||||||
mcp.WithString("name", mcp.Description("Name of the forked repository")),
|
mcp.WithString("name", mcp.Description("fork name")),
|
||||||
)
|
)
|
||||||
|
|
||||||
ListMyReposTool = mcp.NewTool(
|
ListMyReposTool = mcp.NewTool(
|
||||||
ListMyReposToolName,
|
ListMyReposToolName,
|
||||||
mcp.WithDescription("List my repositories"),
|
mcp.WithToolAnnotation(annotation.ReadOnly("List my repositories")),
|
||||||
mcp.WithNumber("page", mcp.Required(), mcp.Description("Page number"), mcp.DefaultNumber(1), mcp.Min(1)),
|
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)),
|
||||||
mcp.WithNumber("perPage", mcp.Required(), mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30), mcp.Min(1)),
|
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30), mcp.Min(1)),
|
||||||
)
|
)
|
||||||
|
|
||||||
ListOrgReposTool = mcp.NewTool(
|
ListOrgReposTool = mcp.NewTool(
|
||||||
ListOrgReposToolName,
|
ListOrgReposToolName,
|
||||||
mcp.WithDescription("List repositories of an organization"),
|
mcp.WithToolAnnotation(annotation.ReadOnly("List organization repositories")),
|
||||||
mcp.WithString("org", mcp.Required(), mcp.Description("Organization name")),
|
mcp.WithString("org", mcp.Required()),
|
||||||
mcp.WithNumber("page", mcp.Required(), mcp.Description("Page number"), mcp.DefaultNumber(1), mcp.Min(1)),
|
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)),
|
||||||
mcp.WithNumber("pageSize", mcp.Required(), mcp.Description("Page size number"), mcp.DefaultNumber(100), mcp.Min(1)),
|
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(100), mcp.Min(1)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -205,18 +206,11 @@ func ListOrgReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
|
|||||||
if !ok {
|
if !ok {
|
||||||
return to.ErrorResult(errors.New("organization name is required"))
|
return to.ErrorResult(errors.New("organization name is required"))
|
||||||
}
|
}
|
||||||
page, ok := req.GetArguments()["page"].(float64)
|
page, pageSize := params.GetPagination(req.GetArguments(), 100)
|
||||||
if !ok {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
pageSize, ok := req.GetArguments()["pageSize"].(float64)
|
|
||||||
if !ok {
|
|
||||||
pageSize = 100
|
|
||||||
}
|
|
||||||
opt := gitea_sdk.ListOrgReposOptions{
|
opt := gitea_sdk.ListOrgReposOptions{
|
||||||
ListOptions: gitea_sdk.ListOptions{
|
ListOptions: gitea_sdk.ListOptions{
|
||||||
Page: int(page),
|
Page: page,
|
||||||
PageSize: int(pageSize),
|
PageSize: pageSize,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
client, err := gitea.ClientFromContext(ctx)
|
client, err := gitea.ClientFromContext(ctx)
|
||||||
|
|||||||
+21
-20
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/annotation"
|
||||||
"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"
|
||||||
@@ -24,37 +25,37 @@ const (
|
|||||||
var (
|
var (
|
||||||
CreateTagTool = mcp.NewTool(
|
CreateTagTool = mcp.NewTool(
|
||||||
CreateTagToolName,
|
CreateTagToolName,
|
||||||
mcp.WithDescription("Create tag"),
|
mcp.WithToolAnnotation(annotation.Write("Create a tag")),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
mcp.WithString("tag_name", mcp.Required(), mcp.Description("tag name")),
|
mcp.WithString("tag_name", mcp.Required()),
|
||||||
mcp.WithString("target", mcp.Description("target commitish"), mcp.DefaultString("")),
|
mcp.WithString("target", mcp.Description("commitish")),
|
||||||
mcp.WithString("message", mcp.Description("tag message"), mcp.DefaultString("")),
|
mcp.WithString("message", mcp.Description("tag message")),
|
||||||
)
|
)
|
||||||
|
|
||||||
DeleteTagTool = mcp.NewTool(
|
DeleteTagTool = mcp.NewTool(
|
||||||
DeleteTagToolName,
|
DeleteTagToolName,
|
||||||
mcp.WithDescription("Delete tag"),
|
mcp.WithToolAnnotation(annotation.Destructive("Delete a tag")),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
mcp.WithString("tag_name", mcp.Required(), mcp.Description("tag name")),
|
mcp.WithString("tag_name", mcp.Required()),
|
||||||
)
|
)
|
||||||
|
|
||||||
GetTagTool = mcp.NewTool(
|
GetTagTool = mcp.NewTool(
|
||||||
GetTagToolName,
|
GetTagToolName,
|
||||||
mcp.WithDescription("Get tag"),
|
mcp.WithToolAnnotation(annotation.ReadOnly("Get tag details")),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
mcp.WithString("tag_name", mcp.Required(), mcp.Description("tag name")),
|
mcp.WithString("tag_name", mcp.Required()),
|
||||||
)
|
)
|
||||||
|
|
||||||
ListTagsTool = mcp.NewTool(
|
ListTagsTool = mcp.NewTool(
|
||||||
ListTagsToolName,
|
ListTagsToolName,
|
||||||
mcp.WithDescription("List tags"),
|
mcp.WithToolAnnotation(annotation.ReadOnly("List tags")),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
|
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(20), mcp.Min(1)),
|
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(20), mcp.Min(1)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -179,7 +180,7 @@ func ListTagsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResu
|
|||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
page := params.GetOptionalInt(args, "page", 1)
|
page := params.GetOptionalInt(args, "page", 1)
|
||||||
pageSize := params.GetOptionalInt(args, "perPage", 20)
|
pageSize := params.GetOptionalInt(args, "per_page", 20)
|
||||||
|
|
||||||
client, err := gitea.ClientFromContext(ctx)
|
client, err := gitea.ClientFromContext(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/annotation"
|
||||||
"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"
|
||||||
@@ -20,13 +21,13 @@ const (
|
|||||||
|
|
||||||
var GetRepoTreeTool = mcp.NewTool(
|
var GetRepoTreeTool = mcp.NewTool(
|
||||||
GetRepoTreeToolName,
|
GetRepoTreeToolName,
|
||||||
mcp.WithDescription("Get the file tree of a repository"),
|
mcp.WithToolAnnotation(annotation.ReadOnly("Get repository file tree")),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
mcp.WithString("tree_sha", mcp.Required(), mcp.Description("SHA, branch name, or tag name")),
|
mcp.WithString("tree_sha", mcp.Required(), mcp.Description("SHA, branch, or tag")),
|
||||||
mcp.WithBoolean("recursive", mcp.Description("whether to get the tree recursively")),
|
mcp.WithBoolean("recursive"),
|
||||||
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
|
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
|
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|||||||
+33
-31
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/annotation"
|
||||||
"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"
|
||||||
@@ -28,47 +29,48 @@ const (
|
|||||||
var (
|
var (
|
||||||
SearchUsersTool = mcp.NewTool(
|
SearchUsersTool = mcp.NewTool(
|
||||||
SearchUsersToolName,
|
SearchUsersToolName,
|
||||||
mcp.WithDescription("search users"),
|
mcp.WithToolAnnotation(annotation.ReadOnly("Search users")),
|
||||||
mcp.WithString("keyword", mcp.Required(), mcp.Description("Keyword")),
|
mcp.WithString("query", mcp.Required()),
|
||||||
mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)),
|
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
|
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
|
||||||
)
|
)
|
||||||
|
|
||||||
SearOrgTeamsTool = mcp.NewTool(
|
SearOrgTeamsTool = mcp.NewTool(
|
||||||
SearchOrgTeamsToolName,
|
SearchOrgTeamsToolName,
|
||||||
mcp.WithDescription("search organization teams"),
|
mcp.WithToolAnnotation(annotation.ReadOnly("Search organization teams")),
|
||||||
mcp.WithString("org", mcp.Required(), mcp.Description("organization name")),
|
mcp.WithString("org", mcp.Required()),
|
||||||
mcp.WithString("query", mcp.Required(), mcp.Description("search organization teams")),
|
mcp.WithString("query", mcp.Required()),
|
||||||
mcp.WithBoolean("includeDescription", mcp.Description("include description?")),
|
mcp.WithBoolean("includeDescription"),
|
||||||
mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)),
|
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
|
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
|
||||||
)
|
)
|
||||||
|
|
||||||
SearchReposTool = mcp.NewTool(
|
SearchReposTool = mcp.NewTool(
|
||||||
SearchReposToolName,
|
SearchReposToolName,
|
||||||
mcp.WithDescription("search repos"),
|
mcp.WithToolAnnotation(annotation.ReadOnly("Search repositories")),
|
||||||
mcp.WithString("keyword", mcp.Required(), mcp.Description("Keyword")),
|
mcp.WithString("query", mcp.Required()),
|
||||||
mcp.WithBoolean("keywordIsTopic", mcp.Description("KeywordIsTopic")),
|
mcp.WithBoolean("keywordIsTopic"),
|
||||||
mcp.WithBoolean("keywordInDescription", mcp.Description("KeywordInDescription")),
|
mcp.WithBoolean("keywordInDescription"),
|
||||||
mcp.WithNumber("ownerID", mcp.Description("OwnerID")),
|
mcp.WithNumber("ownerID"),
|
||||||
mcp.WithBoolean("isPrivate", mcp.Description("IsPrivate")),
|
mcp.WithBoolean("isPrivate"),
|
||||||
mcp.WithBoolean("isArchived", mcp.Description("IsArchived")),
|
mcp.WithBoolean("isArchived"),
|
||||||
mcp.WithString("sort", mcp.Description("Sort")),
|
mcp.WithString("sort"),
|
||||||
mcp.WithString("order", mcp.Description("Order")),
|
mcp.WithString("order"),
|
||||||
mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)),
|
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
|
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
|
||||||
)
|
)
|
||||||
|
|
||||||
SearchIssuesTool = mcp.NewTool(
|
SearchIssuesTool = mcp.NewTool(
|
||||||
SearchIssuesToolName,
|
SearchIssuesToolName,
|
||||||
mcp.WithDescription("Search for issues and pull requests across all accessible repositories"),
|
mcp.WithDescription("Search issues and PRs across repositories"),
|
||||||
mcp.WithString("query", mcp.Required(), mcp.Description("search keyword")),
|
mcp.WithToolAnnotation(annotation.ReadOnly("Search issues")),
|
||||||
mcp.WithString("state", mcp.Description("filter by state: open, closed, all"), mcp.Enum("open", "closed", "all")),
|
mcp.WithString("query", mcp.Required()),
|
||||||
mcp.WithString("type", mcp.Description("filter by type: issues, pulls"), mcp.Enum("issues", "pulls")),
|
mcp.WithString("state", mcp.Enum("open", "closed", "all")),
|
||||||
mcp.WithString("labels", mcp.Description("comma-separated list of label names")),
|
mcp.WithString("type", mcp.Enum("issues", "pulls")),
|
||||||
mcp.WithString("owner", mcp.Description("filter by repository owner")),
|
mcp.WithString("labels", mcp.Description("comma-separated")),
|
||||||
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
|
mcp.WithString("owner", mcp.Description("filter by owner")),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
|
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
|
||||||
|
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -93,7 +95,7 @@ func init() {
|
|||||||
|
|
||||||
func UsersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
func UsersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
log.Debugf("Called UsersFn")
|
log.Debugf("Called UsersFn")
|
||||||
keyword, err := params.GetString(req.GetArguments(), "keyword")
|
keyword, err := params.GetString(req.GetArguments(), "query")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
@@ -149,7 +151,7 @@ func OrgTeamsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResu
|
|||||||
|
|
||||||
func ReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
func ReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
log.Debugf("Called ReposFn")
|
log.Debugf("Called ReposFn")
|
||||||
keyword, err := params.GetString(req.GetArguments(), "keyword")
|
keyword, err := params.GetString(req.GetArguments(), "query")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ func TestSearchToolsRequiredFields(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "search_users",
|
name: "search_users",
|
||||||
tool: SearchUsersTool,
|
tool: SearchUsersTool,
|
||||||
required: []string{"keyword"},
|
required: []string{"query"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "search_org_teams",
|
name: "search_org_teams",
|
||||||
@@ -26,7 +26,7 @@ func TestSearchToolsRequiredFields(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "search_repos",
|
name: "search_repos",
|
||||||
tool: SearchReposTool,
|
tool: SearchReposTool,
|
||||||
required: []string{"keyword"},
|
required: []string{"query"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
gitea_sdk "code.gitea.io/sdk/gitea"
|
"gitea.com/gitea/gitea-mcp/pkg/annotation"
|
||||||
"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/to"
|
"gitea.com/gitea/gitea-mcp/pkg/to"
|
||||||
"gitea.com/gitea/gitea-mcp/pkg/tool"
|
"gitea.com/gitea/gitea-mcp/pkg/tool"
|
||||||
|
|
||||||
|
gitea_sdk "code.gitea.io/sdk/gitea"
|
||||||
"github.com/mark3labs/mcp-go/mcp"
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
"github.com/mark3labs/mcp-go/server"
|
"github.com/mark3labs/mcp-go/server"
|
||||||
)
|
)
|
||||||
@@ -26,24 +27,26 @@ const (
|
|||||||
var (
|
var (
|
||||||
TimetrackingReadTool = mcp.NewTool(
|
TimetrackingReadTool = mcp.NewTool(
|
||||||
TimetrackingReadToolName,
|
TimetrackingReadToolName,
|
||||||
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."),
|
mcp.WithDescription("Read time tracking: issue times, repo times, active stopwatches, your tracked times."),
|
||||||
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.WithToolAnnotation(annotation.ReadOnly("Read tracked time")),
|
||||||
mcp.WithString("owner", mcp.Description("repository owner (required for 'list_issue_times', 'list_repo_times')")),
|
mcp.WithString("method", mcp.Required(), mcp.Enum("list_issue_times", "list_repo_times", "get_my_stopwatches", "get_my_times")),
|
||||||
mcp.WithString("repo", mcp.Description("repository name (required for 'list_issue_times', 'list_repo_times')")),
|
mcp.WithString("owner", mcp.Description("for list_* methods")),
|
||||||
mcp.WithNumber("index", mcp.Description("issue index (required for 'list_issue_times')")),
|
mcp.WithString("repo", mcp.Description("for list_* methods")),
|
||||||
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
|
mcp.WithNumber("issue_number", mcp.Description("for 'list_issue_times'")),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
|
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
|
||||||
|
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
|
||||||
)
|
)
|
||||||
|
|
||||||
TimetrackingWriteTool = mcp.NewTool(
|
TimetrackingWriteTool = mcp.NewTool(
|
||||||
TimetrackingWriteToolName,
|
TimetrackingWriteToolName,
|
||||||
mcp.WithDescription("Manage time tracking: stopwatches and tracked time entries."),
|
mcp.WithDescription("Write time tracking: stopwatches and entries."),
|
||||||
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("start_stopwatch", "stop_stopwatch", "delete_stopwatch", "add_time", "delete_time")),
|
mcp.WithToolAnnotation(annotation.Write("Add or manage tracked time")),
|
||||||
mcp.WithString("owner", mcp.Description("repository owner (required for all methods)")),
|
mcp.WithString("method", mcp.Required(), mcp.Enum("start_stopwatch", "stop_stopwatch", "delete_stopwatch", "add_time", "delete_time")),
|
||||||
mcp.WithString("repo", mcp.Description("repository name (required for all methods)")),
|
mcp.WithString("owner", mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithNumber("index", mcp.Description("issue index (required for all methods)")),
|
mcp.WithString("repo", mcp.Description(params.RepoDesc)),
|
||||||
mcp.WithNumber("time", mcp.Description("time to add in seconds (required for 'add_time')")),
|
mcp.WithNumber("issue_number"),
|
||||||
mcp.WithNumber("id", mcp.Description("tracked time entry ID (required for 'delete_time')")),
|
mcp.WithNumber("time", mcp.Description("seconds (for 'add_time')")),
|
||||||
|
mcp.WithNumber("id", mcp.Description("entry ID (for 'delete_time')")),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -104,7 +107,7 @@ func startStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
index, err := params.GetIndex(req.GetArguments(), "index")
|
index, err := params.GetIndex(req.GetArguments(), "issue_number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
@@ -129,7 +132,7 @@ func stopStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
index, err := params.GetIndex(req.GetArguments(), "index")
|
index, err := params.GetIndex(req.GetArguments(), "issue_number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
@@ -154,7 +157,7 @@ func deleteStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
index, err := params.GetIndex(req.GetArguments(), "index")
|
index, err := params.GetIndex(req.GetArguments(), "issue_number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
@@ -197,7 +200,7 @@ func listTrackedTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
index, err := params.GetIndex(req.GetArguments(), "index")
|
index, err := params.GetIndex(req.GetArguments(), "issue_number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
@@ -232,7 +235,7 @@ func addTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
index, err := params.GetIndex(req.GetArguments(), "index")
|
index, err := params.GetIndex(req.GetArguments(), "issue_number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
@@ -265,7 +268,7 @@ func deleteTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Cal
|
|||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
index, err := params.GetIndex(req.GetArguments(), "index")
|
index, err := params.GetIndex(req.GetArguments(), "issue_number")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return to.ErrorResult(err)
|
return to.ErrorResult(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/annotation"
|
||||||
"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"
|
||||||
@@ -35,16 +36,18 @@ var (
|
|||||||
// It is registered with a specific name and a description string.
|
// It is registered with a specific name and a description string.
|
||||||
GetMyUserInfoTool = mcp.NewTool(
|
GetMyUserInfoTool = mcp.NewTool(
|
||||||
GetMyUserInfoToolName,
|
GetMyUserInfoToolName,
|
||||||
mcp.WithDescription("Get my user info"),
|
mcp.WithDescription("Get current user"),
|
||||||
|
mcp.WithToolAnnotation(annotation.ReadOnly("Get current user information")),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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 "per_page" 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("List current user's organizations"),
|
||||||
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(defaultPage)),
|
mcp.WithToolAnnotation(annotation.ReadOnly("Get user organizations")),
|
||||||
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(defaultPageSize)),
|
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(defaultPage)),
|
||||||
|
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(defaultPageSize)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/annotation"
|
||||||
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
||||||
"gitea.com/gitea/gitea-mcp/pkg/log"
|
"gitea.com/gitea/gitea-mcp/pkg/log"
|
||||||
"gitea.com/gitea/gitea-mcp/pkg/to"
|
"gitea.com/gitea/gitea-mcp/pkg/to"
|
||||||
@@ -21,7 +22,7 @@ const (
|
|||||||
|
|
||||||
var GetGiteaMCPServerVersionTool = mcp.NewTool(
|
var GetGiteaMCPServerVersionTool = mcp.NewTool(
|
||||||
GetGiteaMCPServerVersion,
|
GetGiteaMCPServerVersion,
|
||||||
mcp.WithDescription("Get Gitea MCP Server Version"),
|
mcp.WithToolAnnotation(annotation.ReadOnly("Get server version")),
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|||||||
+15
-12
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/annotation"
|
||||||
"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"
|
||||||
@@ -26,22 +27,24 @@ const (
|
|||||||
var (
|
var (
|
||||||
WikiReadTool = mcp.NewTool(
|
WikiReadTool = mcp.NewTool(
|
||||||
WikiReadToolName,
|
WikiReadToolName,
|
||||||
mcp.WithDescription("Read wiki page information. Use method 'list' to list pages, 'get' to get page content, 'get_revisions' for revision history."),
|
mcp.WithDescription("Read wiki: list pages, get content, revision history."),
|
||||||
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list", "get", "get_revisions")),
|
mcp.WithToolAnnotation(annotation.ReadOnly("Read wiki pages")),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("method", mcp.Required(), mcp.Enum("list", "get", "get_revisions")),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithString("pageName", mcp.Description("wiki page name (required for 'get', 'get_revisions')")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
|
mcp.WithString("pageName", mcp.Description("for 'get'/'get_revisions'")),
|
||||||
)
|
)
|
||||||
|
|
||||||
WikiWriteTool = mcp.NewTool(
|
WikiWriteTool = mcp.NewTool(
|
||||||
WikiWriteToolName,
|
WikiWriteToolName,
|
||||||
mcp.WithDescription("Create, update, or delete wiki pages."),
|
mcp.WithDescription("Write wiki pages: create, update, delete."),
|
||||||
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "update", "delete")),
|
mcp.WithToolAnnotation(annotation.Destructive("Create, update, or delete wiki pages")),
|
||||||
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
|
mcp.WithString("method", mcp.Required(), mcp.Enum("create", "update", "delete")),
|
||||||
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
|
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
|
||||||
mcp.WithString("pageName", mcp.Description("wiki page name (required for 'update', 'delete')")),
|
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
|
||||||
mcp.WithString("title", mcp.Description("wiki page title (required for 'create', optional for 'update')")),
|
mcp.WithString("pageName", mcp.Description("for 'update'/'delete'")),
|
||||||
mcp.WithString("content", mcp.Description("page content (required for 'create', 'update')")),
|
mcp.WithString("title", mcp.Description("for 'create'")),
|
||||||
|
mcp.WithString("content", mcp.Description("for 'create'/'update'")),
|
||||||
mcp.WithString("message", mcp.Description("commit message")),
|
mcp.WithString("message", mcp.Description("commit message")),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ func TestWikiWriteBase64Encoding(t *testing.T) {
|
|||||||
var gotBody map[string]string
|
var gotBody map[string]string
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
body, _ := io.ReadAll(r.Body)
|
body, _ := io.ReadAll(r.Body)
|
||||||
json.Unmarshal(body, &gotBody)
|
_ = json.Unmarshal(body, &gotBody)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
w.Write([]byte(`{"title":"test"}`))
|
_, _ = w.Write([]byte(`{"title":"test"}`))
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Package annotation provides shared MCP tool annotation helpers.
|
||||||
|
package annotation
|
||||||
|
|
||||||
|
import "github.com/mark3labs/mcp-go/mcp"
|
||||||
|
|
||||||
|
// ReadOnly returns a ToolAnnotation for read-only tools.
|
||||||
|
func ReadOnly(title string) mcp.ToolAnnotation {
|
||||||
|
t := true
|
||||||
|
return mcp.ToolAnnotation{Title: title, ReadOnlyHint: &t}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write returns a ToolAnnotation for write tools.
|
||||||
|
func Write(title string) mcp.ToolAnnotation {
|
||||||
|
f := false
|
||||||
|
return mcp.ToolAnnotation{Title: title, ReadOnlyHint: &f}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destructive returns a ToolAnnotation for destructive write tools.
|
||||||
|
func Destructive(title string) mcp.ToolAnnotation {
|
||||||
|
f, t := false, true
|
||||||
|
return mcp.ToolAnnotation{Title: title, ReadOnlyHint: &f, DestructiveHint: &t}
|
||||||
|
}
|
||||||
@@ -10,4 +10,5 @@ var (
|
|||||||
Insecure bool
|
Insecure bool
|
||||||
ReadOnly bool
|
ReadOnly bool
|
||||||
Debug bool
|
Debug bool
|
||||||
|
AllowedTools map[string]struct{}
|
||||||
)
|
)
|
||||||
|
|||||||
+2
-1
@@ -7,9 +7,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
|
||||||
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
|
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
|
||||||
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
||||||
|
|
||||||
|
"code.gitea.io/sdk/gitea"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewClient(token string) (*gitea.Client, error) {
|
func NewClient(token string) (*gitea.Client, error) {
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ func TestDoJSON_GETRedirectFollowed(t *testing.T) {
|
|||||||
mux.HandleFunc("GET /api/v1/repos/owner/new-name/pulls/1", func(w http.ResponseWriter, r *http.Request) {
|
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.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
json.NewEncoder(w).Encode(map[string]any{"id": 1, "title": "found"})
|
_ = json.NewEncoder(w).Encode(map[string]any{"id": 1, "title": "found"})
|
||||||
})
|
})
|
||||||
srv := httptest.NewServer(mux)
|
srv := httptest.NewServer(mux)
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"go.uber.org/zap/zapcore"
|
"go.uber.org/zap/zapcore"
|
||||||
"gopkg.in/natefinch/lumberjack.v2"
|
"gopkg.in/natefinch/lumberjack.v2"
|
||||||
|
|||||||
+12
-2
@@ -6,6 +6,16 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Shared parameter description strings used across tools. Extracted to avoid
|
||||||
|
// repeating the same boilerplate in every tool schema (saves tokens in the
|
||||||
|
// tool list sent to MCP clients).
|
||||||
|
const (
|
||||||
|
OwnerDesc = "repo owner"
|
||||||
|
RepoDesc = "repo name"
|
||||||
|
PageDesc = "page"
|
||||||
|
PaginationDesc = "results per page"
|
||||||
|
)
|
||||||
|
|
||||||
// GetString extracts a required string parameter from MCP tool arguments.
|
// GetString extracts a required string parameter from MCP tool arguments.
|
||||||
func GetString(args map[string]any, key string) (string, error) {
|
func GetString(args map[string]any, key string) (string, error) {
|
||||||
val, ok := args[key].(string)
|
val, ok := args[key].(string)
|
||||||
@@ -42,9 +52,9 @@ func GetStringSlice(args map[string]any, key string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPagination extracts page and perPage parameters, returning them as ints.
|
// GetPagination extracts page and per_page parameters, returning them as ints.
|
||||||
func GetPagination(args map[string]any, defaultPageSize int64) (page, pageSize int) {
|
func GetPagination(args map[string]any, defaultPageSize int64) (page, pageSize int) {
|
||||||
return int(GetOptionalInt(args, "page", 1)), int(GetOptionalInt(args, "perPage", defaultPageSize))
|
return int(GetOptionalInt(args, "page", 1)), int(GetOptionalInt(args, "per_page", defaultPageSize))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToInt64 converts a value to int64, accepting both float64 (JSON number) and
|
// ToInt64 converts a value to int64, accepting both float64 (JSON number) and
|
||||||
|
|||||||
@@ -5,6 +5,17 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestGetPagination(t *testing.T) {
|
||||||
|
page, perPage := GetPagination(map[string]any{"page": float64(2), "per_page": float64(40)}, 30)
|
||||||
|
if page != 2 || perPage != 40 {
|
||||||
|
t.Errorf("GetPagination = (%d, %d), want (2, 40)", page, perPage)
|
||||||
|
}
|
||||||
|
page, perPage = GetPagination(map[string]any{}, 30)
|
||||||
|
if page != 1 || perPage != 30 {
|
||||||
|
t.Errorf("GetPagination defaults = (%d, %d), want (1, 30)", page, perPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestToInt64(t *testing.T) {
|
func TestToInt64(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.com/gitea/gitea-mcp/pkg/log"
|
"gitea.com/gitea/gitea-mcp/pkg/log"
|
||||||
|
|
||||||
"github.com/mark3labs/mcp-go/mcp"
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+48
-7
@@ -1,7 +1,12 @@
|
|||||||
package tool
|
package tool
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/log"
|
||||||
|
|
||||||
"github.com/mark3labs/mcp-go/server"
|
"github.com/mark3labs/mcp-go/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,12 +31,48 @@ func (t *Tool) RegisterRead(s server.ServerTool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *Tool) Tools() []server.ServerTool {
|
func (t *Tool) Tools() []server.ServerTool {
|
||||||
tools := make([]server.ServerTool, 0, len(t.write)+len(t.read))
|
all := make([]server.ServerTool, 0, len(t.write)+len(t.read))
|
||||||
if flag.ReadOnly {
|
if !flag.ReadOnly {
|
||||||
tools = append(tools, t.read...)
|
all = append(all, t.write...)
|
||||||
return tools
|
|
||||||
}
|
}
|
||||||
tools = append(tools, t.write...)
|
all = append(all, t.read...)
|
||||||
tools = append(tools, t.read...)
|
if len(flag.AllowedTools) == 0 {
|
||||||
return tools
|
return all
|
||||||
|
}
|
||||||
|
filtered := make([]server.ServerTool, 0, len(all))
|
||||||
|
for _, st := range all {
|
||||||
|
if _, ok := flag.AllowedTools[st.Tool.Name]; ok {
|
||||||
|
filtered = append(filtered, st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
// WarnUnmatchedAllowedTools logs any names in flag.AllowedTools that don't
|
||||||
|
// match a tool registered on any of the given domains. No-op if the allowlist
|
||||||
|
// is empty.
|
||||||
|
func WarnUnmatchedAllowedTools(domains ...*Tool) {
|
||||||
|
if len(flag.AllowedTools) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
known := map[string]struct{}{}
|
||||||
|
for _, d := range domains {
|
||||||
|
for _, st := range d.read {
|
||||||
|
known[st.Tool.Name] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, st := range d.write {
|
||||||
|
known[st.Tool.Name] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var unmatched []string
|
||||||
|
for name := range flag.AllowedTools {
|
||||||
|
if _, ok := known[name]; !ok {
|
||||||
|
unmatched = append(unmatched, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(unmatched) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slices.Sort(unmatched)
|
||||||
|
log.Warnf("Unknown tools in --tools allowlist (ignored): %s", strings.Join(unmatched, ", "))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package tool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.com/gitea/gitea-mcp/pkg/flag"
|
||||||
|
|
||||||
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
|
"github.com/mark3labs/mcp-go/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
func makeTool(name string) server.ServerTool {
|
||||||
|
return server.ServerTool{Tool: mcp.NewTool(name)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func names(sts []server.ServerTool) []string {
|
||||||
|
out := make([]string, len(sts))
|
||||||
|
for i, st := range sts {
|
||||||
|
out[i] = st.Tool.Name
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTools(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
readOnly bool
|
||||||
|
allowed map[string]struct{}
|
||||||
|
read []string
|
||||||
|
write []string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no filters returns write then read",
|
||||||
|
read: []string{"r1", "r2"},
|
||||||
|
write: []string{"w1", "w2"},
|
||||||
|
want: []string{"w1", "w2", "r1", "r2"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "read-only excludes write",
|
||||||
|
readOnly: true,
|
||||||
|
read: []string{"r1", "r2"},
|
||||||
|
write: []string{"w1"},
|
||||||
|
want: []string{"r1", "r2"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allowlist keeps only listed",
|
||||||
|
allowed: map[string]struct{}{"r1": {}, "w1": {}},
|
||||||
|
read: []string{"r1", "r2"},
|
||||||
|
write: []string{"w1", "w2"},
|
||||||
|
want: []string{"w1", "r1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allowlist intersected with read-only drops write entries",
|
||||||
|
readOnly: true,
|
||||||
|
allowed: map[string]struct{}{"r1": {}, "w1": {}},
|
||||||
|
read: []string{"r1", "r2"},
|
||||||
|
write: []string{"w1", "w2"},
|
||||||
|
want: []string{"r1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allowlist with only unknown names returns empty",
|
||||||
|
allowed: map[string]struct{}{"unknown": {}},
|
||||||
|
read: []string{"r1"},
|
||||||
|
write: []string{"w1"},
|
||||||
|
want: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty allowlist map passes through",
|
||||||
|
allowed: map[string]struct{}{},
|
||||||
|
read: []string{"r1"},
|
||||||
|
write: []string{"w1"},
|
||||||
|
want: []string{"w1", "r1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
origRO, origAllow := flag.ReadOnly, flag.AllowedTools
|
||||||
|
t.Cleanup(func() {
|
||||||
|
flag.ReadOnly, flag.AllowedTools = origRO, origAllow
|
||||||
|
})
|
||||||
|
flag.ReadOnly = tt.readOnly
|
||||||
|
flag.AllowedTools = tt.allowed
|
||||||
|
|
||||||
|
tr := New()
|
||||||
|
for _, n := range tt.read {
|
||||||
|
tr.RegisterRead(makeTool(n))
|
||||||
|
}
|
||||||
|
for _, n := range tt.write {
|
||||||
|
tr.RegisterWrite(makeTool(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
got := names(tr.Tools())
|
||||||
|
if !slices.Equal(got, tt.want) {
|
||||||
|
t.Errorf("Tools() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user