Compare commits

..

6 Commits

Author SHA1 Message Date
silverwind 2e67d5ebf3 Trim tool schemas, add param aliases, new PR methods (#191)
- Tool list size reduced by 26.6% (43,032 → 31,599 bytes on the `tools/list` JSON-RPC response).
- Trim redundant tool/param descriptions; shared description constants for `owner`/`repo`/`page`/`per_page`.
- Schemas now use github-mcp-server param names directly: `issue_number` (was `index` on issue tools), `pull_number` (was `index` on PR tools), `path` (was `filePath`), `query` (was `keyword` on user/repo search), `per_page` (was `perPage`).
- New PR read methods `get_files` and `get_status`; new PR write method `update_branch` (update PR branch from base).
- `list_org_repos` now uses `per_page` (was `pageSize`).
- `milestone_write` accepts `update` and `edit`.
- `create_branch` `old_branch` is optional; Gitea defaults to the repo default branch.
- Fix `list_commits` handler to honour optional `page`/`per_page` schema (was erroring out when callers omitted them).

---
This PR was written with the help of Claude Opus 4.7

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/191
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-05-14 06:24:51 +00:00
silverwind a77b54acdd fix(milestone): persist due_on on create and edit (#189)
Fixes https://gitea.com/gitea/gitea-mcp/issues/187

The `due_on` argument was declared in the `milestone_write` schema but never read by `createMilestoneFn` or `editMilestoneFn`, so `opt.Deadline` was always nil and the field was silently dropped. This reuses the existing `params.GetOptionalTime` helper that already handles the analogous `deadline` field on issues and pull requests.

---
This PR was written with the help of Claude Opus 4.7

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/189
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-05-12 00:21:18 +00:00
silverwind 9275c5a0e1 Update golangci-lint and tighten lint config (#190)
Bump `golangci-lint` to v2.12.2 and pin `govulncheck` to v1.3.0. Align `.golangci.yml` with the gitea repo's config: enable `revive` `var-naming` (with `skip-package-name-checks`) and drop the test-file exclusion for `errcheck`/`staticcheck`/`unparam`. Fix the now-surfaced `errcheck` violations in test handlers by discarding return values to match the existing codebase pattern.

---
This PR was written with the help of Claude Opus 4.7

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/190
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-05-12 00:04:36 +00:00
Martin Mikula bcefbaa9c1 feat: add --tools flag to filter exposed MCP tools (#167)
Adds `-O`/`-tools` CLI flag and `GITEA_TOOLS` environment variable
accepting a comma-separated list of tool names. When set, only the
listed tools are exposed to MCP clients, which lets AI agents trim
their tool context. Composes with `--read-only`. Unknown names are
logged at startup so typos surface instead of failing silently.

Co-Authored-By: silverwind <me@silverwind.io>
Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
2026-05-10 12:07:16 +02:00
Skyf0l cd82f6f207 Add package listing and management tools (#170)
Adds `package_read` and `package_write` MCP tools for the Gitea
Packages API.

- `package_read` (read): `list`, `list_versions`, `get`
- `package_write` (write): `delete`

Package names containing slashes (e.g. container image paths like
`my-repo/my-image`) are accepted raw or pre-encoded and URL-encoded
correctly without double-encoding.

Co-Authored-By: silverwind <me@silverwind.io>
Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
2026-05-10 11:42:01 +02:00
unpossible 329a97d5d2 Add tool annotations and PR close/reopen support (#174)
Add MCP `ToolAnnotation` metadata (Title, ReadOnlyHint, DestructiveHint)
to all registered tools so MCP hosts (VS Code, Claude, Cursor) get
accurate per-tool hints. A shared `pkg/annotation` package exposes
`ReadOnly`, `Write`, and `Destructive` helpers for consistency.

Add `close` and `reopen` methods to `pull_request_write` so PR state
can be toggled without going through the generic `update` path.

Co-Authored-By: silverwind <me@silverwind.io>
Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
2026-05-10 11:25:22 +02:00
38 changed files with 1733 additions and 507 deletions
+4 -7
View File
@@ -71,7 +71,10 @@ linters:
- name: unexported-return
- name: var-declaration
- 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:
checks:
- all
@@ -91,12 +94,6 @@ linters:
- common-false-positives
- legacy
- std-error-handling
rules:
- linters:
- errcheck
- staticcheck
- unparam
path: _test\.go
issues:
max-issues-per-linter: 0
max-same-issues: 0
+2 -2
View File
@@ -3,8 +3,8 @@ EXECUTABLE := gitea-mcp
VERSION ?= $(shell git describe --tags --always | sed 's/-/+/' | sed 's/^v//')
LDFLAGS := -X "main.Version=$(VERSION)"
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.3.0
.PHONY: help
help: ## print this help message
+16
View File
@@ -17,6 +17,7 @@ var (
host string
port int
token string
tools string
version bool
)
@@ -31,6 +32,9 @@ func init() {
flag.StringVar(&token, "token", "", "")
flag.BoolVar(&flagPkg.ReadOnly, "r", 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, "debug", false, "")
flag.BoolVar(&flagPkg.Insecure, "k", false, "")
@@ -48,6 +52,7 @@ func init() {
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, " -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, " -k, -insecure\tIgnore TLS certificate errors\n")
fmt.Fprintf(w, " -v, -version\tPrint version and exit\n")
@@ -59,6 +64,7 @@ func init() {
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_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")
w.Flush()
}
@@ -95,6 +101,16 @@ func init() {
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" {
flagPkg.Debug = true
}
+20 -17
View File
@@ -8,6 +8,7 @@ import (
"strconv"
"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"
@@ -47,27 +48,29 @@ func toSecretMetas(secrets []*gitea_sdk.Secret) []secretMeta {
var (
ActionsConfigReadTool = mcp.NewTool(
ActionsConfigReadToolName,
mcp.WithDescription("Read Actions secrets and variables configuration."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list_repo_secrets", "list_org_secrets", "list_repo_variables", "get_repo_variable", "list_org_variables", "get_org_variable")),
mcp.WithString("owner", mcp.Description("repository owner (required for repo methods)")),
mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")),
mcp.WithString("org", mcp.Description("organization name (required for org methods)")),
mcp.WithString("name", mcp.Description("variable name (required for get methods)")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30), mcp.Min(1)),
mcp.WithDescription("Read Actions secrets and variables."),
mcp.WithToolAnnotation(annotation.ReadOnly("Read Actions secrets and variables")),
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("owner", mcp.Description("for repo methods")),
mcp.WithString("repo", mcp.Description("for repo methods")),
mcp.WithString("org", mcp.Description("for org methods")),
mcp.WithString("name", mcp.Description("for get methods")),
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(
ActionsConfigWriteToolName,
mcp.WithDescription("Manage Actions secrets and variables: create, update, or delete."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("upsert_repo_secret", "delete_repo_secret", "upsert_org_secret", "delete_org_secret", "create_repo_variable", "update_repo_variable", "delete_repo_variable", "create_org_variable", "update_org_variable", "delete_org_variable")),
mcp.WithString("owner", mcp.Description("repository owner (required for repo methods)")),
mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")),
mcp.WithString("org", mcp.Description("organization name (required for org methods)")),
mcp.WithString("name", mcp.Description("secret or variable name (required for most methods)")),
mcp.WithString("data", mcp.Description("secret value (required for upsert secret methods)")),
mcp.WithString("value", mcp.Description("variable value (required for create/update variable methods)")),
mcp.WithString("description", mcp.Description("description for secret or variable")),
mcp.WithDescription("Write Actions secrets and variables: upsert, create, update, delete."),
mcp.WithToolAnnotation(annotation.Destructive("Manage Actions secrets and variables")),
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("owner", mcp.Description("for repo methods")),
mcp.WithString("repo", mcp.Description("for repo methods")),
mcp.WithString("org", mcp.Description("for org methods")),
mcp.WithString("name", mcp.Description("secret or variable name")),
mcp.WithString("data", mcp.Description("secret value (upsert)")),
mcp.WithString("value", mcp.Description("variable value")),
mcp.WithString("description"),
)
)
+24 -21
View File
@@ -10,6 +10,7 @@ import (
"path/filepath"
"strconv"
"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"
@@ -27,31 +28,33 @@ const (
var (
ActionsRunReadTool = mcp.NewTool(
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.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list_workflows", "get_workflow", "list_runs", "get_run", "list_jobs", "list_run_jobs", "get_job_log_preview", "download_job_log")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("workflow_id", mcp.Description("workflow ID or filename (required for 'get_workflow')")),
mcp.WithNumber("run_id", mcp.Description("run ID (required for 'get_run', 'list_run_jobs')")),
mcp.WithNumber("job_id", mcp.Description("job ID (required for 'get_job_log_preview', 'download_job_log')")),
mcp.WithString("status", mcp.Description("optional status filter (for 'list_runs', 'list_jobs')")),
mcp.WithNumber("tail_lines", mcp.Description("number of lines from end of log (for 'get_job_log_preview')"), mcp.DefaultNumber(200), mcp.Min(1)),
mcp.WithNumber("max_bytes", mcp.Description("max bytes to return (for 'get_job_log_preview')"), mcp.DefaultNumber(65536), mcp.Min(1024)),
mcp.WithString("output_path", mcp.Description("output file path (for 'download_job_log')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("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.WithDescription("Read Actions workflows, runs, jobs, and logs."),
mcp.WithToolAnnotation(annotation.ReadOnly("Read Actions workflow, run, and job data")),
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("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("workflow_id", mcp.Description("ID or filename (for 'get_workflow')")),
mcp.WithNumber("run_id", mcp.Description("for 'get_run'/'list_run_jobs'")),
mcp.WithNumber("job_id", mcp.Description("for log methods")),
mcp.WithString("status", mcp.Description("filter for 'list_runs'/'list_jobs'")),
mcp.WithNumber("tail_lines", mcp.Description("log tail lines"), mcp.DefaultNumber(200), mcp.Min(1)),
mcp.WithNumber("max_bytes", mcp.Description("max log bytes"), mcp.DefaultNumber(65536), mcp.Min(1024)),
mcp.WithString("output_path", mcp.Description("for 'download_job_log'")),
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(
ActionsRunWriteToolName,
mcp.WithDescription("Trigger, cancel, or rerun Actions workflows."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("dispatch_workflow", "cancel_run", "rerun_run")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("workflow_id", mcp.Description("workflow ID or filename (required for 'dispatch_workflow')")),
mcp.WithString("ref", mcp.Description("git ref branch or tag (required for 'dispatch_workflow')")),
mcp.WithObject("inputs", mcp.Description("workflow inputs object (for 'dispatch_workflow')")),
mcp.WithNumber("run_id", mcp.Description("run ID (required for 'cancel_run', 'rerun_run')")),
mcp.WithDescription("Write Actions runs: dispatch, cancel, rerun."),
mcp.WithToolAnnotation(annotation.Write("Trigger, cancel, or rerun Actions workflows")),
mcp.WithString("method", mcp.Required(), mcp.Enum("dispatch_workflow", "cancel_run", "rerun_run")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("workflow_id", mcp.Description("ID or filename (for 'dispatch_workflow')")),
mcp.WithString("ref", mcp.Description("branch or tag (for 'dispatch_workflow')")),
mcp.WithObject("inputs", mcp.Description("for 'dispatch_workflow'")),
mcp.WithNumber("run_id", mcp.Description("for 'cancel_run'/'rerun_run'")),
)
)
+42 -39
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"net/url"
"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"
@@ -39,44 +40,46 @@ const (
var (
ListRepoIssuesTool = mcp.NewTool(
ListRepoIssuesToolName,
mcp.WithDescription("List repository issues"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("state", mcp.Description("issue state"), mcp.DefaultString("all")),
mcp.WithArray("labels", mcp.Description("filter by label names"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithString("since", mcp.Description("filter issues updated after this ISO 8601 timestamp")),
mcp.WithString("before", mcp.Description("filter issues updated before this ISO 8601 timestamp")),
mcp.WithNumber("page", mcp.Description("page number"), 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.WithToolAnnotation(annotation.ReadOnly("List repository issues")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("state", mcp.DefaultString("all")),
mcp.WithArray("labels", mcp.Description("label name filter"), mcp.Items(map[string]any{"type": "string"})),
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)),
)
IssueReadTool = mcp.NewTool(
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.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "get_comments", "get_labels")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("repository issue index")),
mcp.WithDescription("Read issue: details, comments, or labels."),
mcp.WithToolAnnotation(annotation.ReadOnly("Read issue details")),
mcp.WithString("method", mcp.Required(), mcp.Enum("get", "get_comments", "get_labels")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("issue_number", mcp.Required()),
)
IssueWriteTool = mcp.NewTool(
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.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "update", "add_comment", "edit_comment", "add_labels", "remove_label", "replace_labels", "clear_labels")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Description("issue index (required for all methods except 'create')")),
mcp.WithString("title", mcp.Description("issue title (required for 'create')")),
mcp.WithString("body", mcp.Description("issue/comment body (required for 'create', 'add_comment', 'edit_comment')")),
mcp.WithArray("assignees", mcp.Description("usernames to assign (for 'create', 'update')"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithNumber("milestone", mcp.Description("milestone number (for 'create', 'update')")),
mcp.WithString("state", mcp.Description("issue state, one of open, closed, all (for 'update')")),
mcp.WithNumber("commentID", mcp.Description("id of issue comment (required for 'edit_comment')")),
mcp.WithArray("labels", mcp.Description("array of label IDs (for 'create', 'add_labels', 'replace_labels')"), mcp.Items(map[string]any{"type": "number"})),
mcp.WithNumber("label_id", mcp.Description("label ID to remove (required for 'remove_label')")),
mcp.WithString("ref", mcp.Description("branch name to associate with the issue (for 'create', 'update')")),
mcp.WithString("deadline", mcp.Description("due date in ISO 8601 format (for 'create', 'update')")),
mcp.WithBoolean("remove_deadline", mcp.Description("unset due date (for 'update')")),
mcp.WithDescription("Write issues: create, update, manage comments and labels."),
mcp.WithToolAnnotation(annotation.Write("Create or update issues, comments, and labels")),
mcp.WithString("method", mcp.Required(), mcp.Enum("create", "update", "add_comment", "edit_comment", "add_labels", "remove_label", "replace_labels", "clear_labels")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("issue_number", mcp.Description("required except for 'create'")),
mcp.WithString("title", mcp.Description("required for 'create'")),
mcp.WithString("body", mcp.Description("required for 'create'/'add_comment'/'edit_comment'")),
mcp.WithArray("assignees", mcp.Items(map[string]any{"type": "string"})),
mcp.WithNumber("milestone"),
mcp.WithString("state", mcp.Enum("open", "closed", "all")),
mcp.WithNumber("commentID", mcp.Description("for 'edit_comment'")),
mcp.WithArray("labels", mcp.Description("label IDs"), mcp.Items(map[string]any{"type": "number"})),
mcp.WithNumber("label_id", mcp.Description("for 'remove_label'")),
mcp.WithString("ref", mcp.Description("branch to associate")),
mcp.WithString("deadline", mcp.Description("ISO 8601")),
mcp.WithBoolean("remove_deadline"),
)
)
@@ -151,7 +154,7 @@ func getIssueByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil {
return to.ErrorResult(err)
}
@@ -263,7 +266,7 @@ func createIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil {
return to.ErrorResult(err)
}
@@ -296,7 +299,7 @@ func editIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRes
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil {
return to.ErrorResult(err)
}
@@ -384,7 +387,7 @@ func getIssueCommentsByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*m
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil {
return to.ErrorResult(err)
}
@@ -412,7 +415,7 @@ func getIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil {
return to.ErrorResult(err)
}
@@ -440,7 +443,7 @@ func addIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil {
return to.ErrorResult(err)
}
@@ -470,7 +473,7 @@ func replaceIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil {
return to.ErrorResult(err)
}
@@ -500,7 +503,7 @@ func clearIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil {
return to.ErrorResult(err)
}
@@ -526,7 +529,7 @@ func removeIssueLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil {
return to.ErrorResult(err)
}
+2 -2
View File
@@ -201,7 +201,7 @@ func Test_getIssueByIndexFn_includesAttachments(t *testing.T) {
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, "index": float64(42),
"owner": owner, "repo": repo, "issue_number": float64(42),
}}}
res, err := getIssueByIndexFn(context.Background(), req)
if err != nil {
@@ -250,7 +250,7 @@ func Test_getIssueCommentsByIndexFn_includesAttachments(t *testing.T) {
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, "index": float64(7),
"owner": owner, "repo": repo, "issue_number": float64(7),
}}}
res, err := getIssueCommentsByIndexFn(context.Background(), req)
if err != nil {
+22 -19
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"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"
@@ -25,29 +26,31 @@ const (
var (
LabelReadTool = mcp.NewTool(
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.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list_repo_labels", "get_repo_label", "list_org_labels")),
mcp.WithString("owner", mcp.Description("repository owner (required for repo methods)")),
mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")),
mcp.WithString("org", mcp.Description("organization name (required for 'list_org')")),
mcp.WithNumber("id", mcp.Description("label ID (required for 'get_repo')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
mcp.WithDescription("Read repo or org labels."),
mcp.WithToolAnnotation(annotation.ReadOnly("Read labels")),
mcp.WithString("method", mcp.Required(), mcp.Enum("list_repo_labels", "get_repo_label", "list_org_labels")),
mcp.WithString("owner", mcp.Description("for repo methods")),
mcp.WithString("repo", mcp.Description("for repo methods")),
mcp.WithString("org", mcp.Description("for org methods")),
mcp.WithNumber("id", mcp.Description("label ID (for 'get_repo_label')")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
)
LabelWriteTool = mcp.NewTool(
LabelWriteToolName,
mcp.WithDescription("Create, edit, or delete labels for repositories or organizations."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create_repo_label", "edit_repo_label", "delete_repo_label", "create_org_label", "edit_org_label", "delete_org_label")),
mcp.WithString("owner", mcp.Description("repository owner (required for repo methods)")),
mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")),
mcp.WithString("org", mcp.Description("organization name (required for org methods)")),
mcp.WithNumber("id", mcp.Description("label ID (required for edit/delete methods)")),
mcp.WithString("name", mcp.Description("label name (required for create, optional for edit)")),
mcp.WithString("color", mcp.Description("label color hex code e.g. #RRGGBB (required for create, optional for edit)")),
mcp.WithString("description", mcp.Description("label description")),
mcp.WithBoolean("exclusive", mcp.Description("whether the label is exclusive (org labels only)")),
mcp.WithBoolean("is_archived", mcp.Description("whether the label is archived (for create/edit repo label methods)")),
mcp.WithDescription("Write labels (repo or org): create, edit, delete."),
mcp.WithToolAnnotation(annotation.Destructive("Create, update, or delete labels")),
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("owner", mcp.Description("for repo methods")),
mcp.WithString("repo", mcp.Description("for repo methods")),
mcp.WithString("org", mcp.Description("for org methods")),
mcp.WithNumber("id", mcp.Description("for edit/delete")),
mcp.WithString("name", mcp.Description("required for create")),
mcp.WithString("color", mcp.Description("hex (#RRGGBB); required for create")),
mcp.WithString("description"),
mcp.WithBoolean("exclusive", mcp.Description("exclusive (org only)")),
mcp.WithBoolean("is_archived", mcp.Description("archived (repo only)")),
)
)
+24 -17
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"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"
@@ -25,28 +26,30 @@ const (
var (
MilestoneReadTool = mcp.NewTool(
MilestoneReadToolName,
mcp.WithDescription("Read milestone information. Use method 'get' to get a specific milestone, 'list' to list milestones."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "list")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Description("milestone id (required for 'get')")),
mcp.WithString("state", mcp.Description("milestone state (for 'list')"), mcp.DefaultString("all")),
mcp.WithString("name", mcp.Description("milestone name filter (for 'list')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
mcp.WithDescription("Read milestones: get one or list."),
mcp.WithToolAnnotation(annotation.ReadOnly("Read milestones")),
mcp.WithString("method", mcp.Required(), mcp.Enum("get", "list")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("id", mcp.Description("for 'get'")),
mcp.WithString("state", mcp.DefaultString("all")),
mcp.WithString("name", mcp.Description("name filter (for 'list')")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
)
MilestoneWriteTool = mcp.NewTool(
MilestoneWriteToolName,
mcp.WithDescription("Create, edit, or delete milestones."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "edit", "delete")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Description("milestone id (required for 'edit', 'delete')")),
mcp.WithString("title", mcp.Description("milestone title (required for 'create')")),
mcp.WithString("description", mcp.Description("milestone description")),
mcp.WithDescription("Write milestones: create, update, delete."),
mcp.WithToolAnnotation(annotation.Destructive("Create, update, or delete milestones")),
mcp.WithString("method", mcp.Required(), mcp.Enum("create", "update", "edit", "delete")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("id", mcp.Description("for 'update'/'delete'")),
mcp.WithString("title", mcp.Description("for 'create'")),
mcp.WithString("description"),
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 {
case "create":
return createMilestoneFn(ctx, req)
case "update":
return editMilestoneFn(ctx, req)
case "edit":
return editMilestoneFn(ctx, req)
case "delete":
@@ -174,6 +179,7 @@ func createMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
if ok {
opt.Description = description
}
opt.Deadline = params.GetOptionalTime(req.GetArguments(), "due_on")
client, err := gitea.ClientFromContext(ctx)
if err != nil {
@@ -216,6 +222,7 @@ func editMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
if ok {
opt.State = new(gitea_sdk.StateType(state))
}
opt.Deadline = params.GetOptionalTime(req.GetArguments(), "due_on")
client, err := gitea.ClientFromContext(ctx)
if err != nil {
+84
View File
@@ -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)
}
})
}
}
+20 -17
View File
@@ -5,6 +5,7 @@ import (
"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"
@@ -26,27 +27,29 @@ const (
var (
NotificationReadTool = mcp.NewTool(
NotificationReadToolName,
mcp.WithDescription("Get notifications. Use method 'list' to list notifications (optionally scoped to a repo), 'get' to get a single notification thread by ID."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list", "get")),
mcp.WithString("owner", mcp.Description("repository owner (for 'list' to scope to a repo)")),
mcp.WithString("repo", mcp.Description("repository name (for 'list' to scope to a repo)")),
mcp.WithNumber("id", mcp.Description("notification thread ID (required for 'get')")),
mcp.WithString("status", mcp.Description("filter by status (for 'list')"), mcp.Enum("unread", "read", "pinned")),
mcp.WithString("subject_type", mcp.Description("filter by subject type (for 'list')"), mcp.Enum("Issue", "Pull", "Commit", "Repository")),
mcp.WithString("since", mcp.Description("filter notifications updated after this ISO 8601 timestamp (for 'list')")),
mcp.WithString("before", mcp.Description("filter notifications updated before this ISO 8601 timestamp (for 'list')")),
mcp.WithNumber("page", mcp.Description("page number"), 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.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("Manage notifications. Use method 'mark_read' to mark a single notification as read, 'mark_all_read' to mark all notifications as read (optionally scoped to a repo)."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("mark_read", "mark_all_read")),
mcp.WithNumber("id", mcp.Description("notification thread ID (required for 'mark_read')")),
mcp.WithString("owner", mcp.Description("repository owner (for 'mark_all_read' to scope to a repo)")),
mcp.WithString("repo", mcp.Description("repository name (for 'mark_all_read' to scope to a repo)")),
mcp.WithString("last_read_at", mcp.Description("ISO 8601 timestamp, marks notifications before this time as read (for 'mark_all_read', defaults to now)")),
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")),
)
)
+15 -37
View File
@@ -16,6 +16,7 @@ import (
"gitea.com/gitea/gitea-mcp/operation/label"
"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/repo"
"gitea.com/gitea/gitea-mcp/operation/search"
@@ -26,50 +27,27 @@ import (
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/tool"
"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) {
// User Tool
s.AddTools(user.Tool.Tools()...)
// Actions Tool
s.AddTools(actions.Tool.Tools()...)
// Repo Tool
s.AddTools(repo.Tool.Tools()...)
// Notification Tool
s.AddTools(notification.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()...)
for _, t := range domainTools {
s.AddTools(t.Tools()...)
}
s.DeleteTools("")
tool.WarnUnmatchedAllowedTools(domainTools...)
}
// parseAuthToken extracts the token from an Authorization header.
+225
View File
@@ -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")
}
+381
View File
@@ -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")
}
}
+43
View File
@@ -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
}
}
+231 -71
View File
@@ -6,6 +6,7 @@ import (
"net/url"
"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"
@@ -29,78 +30,81 @@ const (
var (
ListRepoPullRequestsTool = mcp.NewTool(
ListRepoPullRequestsToolName,
mcp.WithDescription("List repository pull requests"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("state", mcp.Description("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.WithNumber("milestone", mcp.Description("milestone")),
mcp.WithNumber("page", mcp.Description("page number"), 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.WithToolAnnotation(annotation.ReadOnly("List pull requests")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("state", mcp.Enum("open", "closed", "all"), mcp.DefaultString("all")),
mcp.WithString("sort", mcp.Enum("oldest", "recentupdate", "leastupdate", "mostcomment", "leastcomment", "priority"), mcp.DefaultString("recentupdate")),
mcp.WithNumber("milestone"),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
)
PullRequestReadTool = mcp.NewTool(
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.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "get_diff", "get_reviews", "get_review", "get_review_comments")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("pull request index")),
mcp.WithNumber("review_id", mcp.Description("review ID (required for 'get_review', 'get_review_comments')")),
mcp.WithBoolean("binary", mcp.Description("whether to include binary file changes (for 'get_diff')")),
mcp.WithNumber("page", mcp.Description("page number"), 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.WithDescription("Read pull request: details, diff, changed files, head commit status, reviews."),
mcp.WithToolAnnotation(annotation.ReadOnly("Read pull request details")),
mcp.WithString("method", mcp.Required(), mcp.Enum("get", "get_diff", "get_files", "get_status", "get_reviews", "get_review", "get_review_comments")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("pull_number", mcp.Required()),
mcp.WithNumber("review_id", mcp.Description("for 'get_review'/'get_review_comments'")),
mcp.WithBoolean("binary", mcp.Description("include binary diff")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
)
PullRequestWriteTool = mcp.NewTool(
PullRequestWriteToolName,
mcp.WithDescription("Create, update, or merge pull requests, manage reviewers."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "update", "merge", "add_reviewers", "remove_reviewers")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Description("pull request index (required for all methods except 'create')")),
mcp.WithString("title", mcp.Description("PR title (required for 'create', optional for 'update', 'merge')")),
mcp.WithString("body", mcp.Description("PR body (required for 'create', optional for 'update')")),
mcp.WithString("head", mcp.Description("PR head branch (required for 'create')")),
mcp.WithString("base", mcp.Description("PR base branch (required for 'create', optional for 'update')")),
mcp.WithString("assignee", mcp.Description("username to assign (for 'update')")),
mcp.WithArray("assignees", mcp.Description("usernames to assign (for 'update')"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithNumber("milestone", mcp.Description("milestone number (for 'update')")),
mcp.WithString("state", mcp.Description("PR state (for 'update')"), mcp.Enum("open", "closed")),
mcp.WithBoolean("allow_maintainer_edit", mcp.Description("allow maintainer to edit (for 'update')")),
mcp.WithArray("labels", mcp.Description("array of label IDs (for 'create', 'update')"), mcp.Items(map[string]any{"type": "number"})),
mcp.WithString("deadline", mcp.Description("due date in ISO 8601 format (for 'create', 'update')")),
mcp.WithBoolean("remove_deadline", mcp.Description("unset due date (for 'update')")),
mcp.WithString("merge_style", mcp.Description("merge style (for 'merge')"), mcp.Enum("merge", "rebase", "rebase-merge", "squash", "fast-forward-only"), mcp.DefaultString("merge")),
mcp.WithString("message", mcp.Description("merge commit message (for 'merge') or dismissal reason")),
mcp.WithBoolean("delete_branch", mcp.Description("delete branch after merge (for 'merge')")),
mcp.WithBoolean("force_merge", mcp.Description("force merge even if checks are not passing (for 'merge')")),
mcp.WithBoolean("merge_when_checks_succeed", mcp.Description("auto-merge when checks succeed (for 'merge')")),
mcp.WithString("head_commit_id", mcp.Description("expected head commit SHA for merge conflict detection (for 'merge')")),
mcp.WithArray("reviewers", mcp.Description("reviewer usernames (for 'add_reviewers', 'remove_reviewers')"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithArray("team_reviewers", mcp.Description("team reviewer names (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.WithDescription("Write pull requests: create, update, close, reopen, merge, update branch from base, manage reviewers."),
mcp.WithToolAnnotation(annotation.Write("Create, update, close, reopen, or merge pull requests")),
mcp.WithString("method", mcp.Required(), mcp.Enum("create", "update", "close", "reopen", "merge", "update_branch", "add_reviewers", "remove_reviewers")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("pull_number", mcp.Description("required except for 'create'")),
mcp.WithString("title", mcp.Description("required for 'create'; optional for 'update'/'merge'")),
mcp.WithString("body", mcp.Description("required for 'create'; optional for 'update'")),
mcp.WithString("head", mcp.Description("head branch (required for 'create')")),
mcp.WithString("base", mcp.Description("base branch (required for 'create')")),
mcp.WithString("assignee", mcp.Description("for 'update'")),
mcp.WithArray("assignees", mcp.Description("for 'update'"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithNumber("milestone", mcp.Description("for 'update'")),
mcp.WithString("state", mcp.Description("for 'update'"), mcp.Enum("open", "closed")),
mcp.WithBoolean("allow_maintainer_edit", mcp.Description("for 'update'")),
mcp.WithArray("labels", mcp.Description("label IDs"), mcp.Items(map[string]any{"type": "number"})),
mcp.WithString("deadline", mcp.Description("ISO 8601")),
mcp.WithBoolean("remove_deadline", mcp.Description("for 'update'")),
mcp.WithString("merge_style", mcp.Description("for 'merge'"), mcp.Enum("merge", "rebase", "rebase-merge", "squash", "fast-forward-only"), mcp.DefaultString("merge")),
mcp.WithString("message", mcp.Description("merge commit message or dismissal reason")),
mcp.WithBoolean("delete_branch", mcp.Description("for 'merge'")),
mcp.WithBoolean("force_merge", mcp.Description("merge even if checks fail")),
mcp.WithBoolean("merge_when_checks_succeed", mcp.Description("for 'merge'")),
mcp.WithString("head_commit_id", mcp.Description("expected head SHA for conflict detection")),
mcp.WithArray("reviewers", mcp.Description("for 'add_reviewers'/'remove_reviewers'"), mcp.Items(map[string]any{"type": "string"})),
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(
PullRequestReviewWriteToolName,
mcp.WithDescription("Manage pull request reviews: create, submit, delete, or dismiss."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "submit", "delete", "dismiss")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("pull request index")),
mcp.WithNumber("review_id", mcp.Description("review ID (required for 'submit', 'delete', 'dismiss')")),
mcp.WithString("state", mcp.Description("review state"), mcp.Enum("APPROVED", "REQUEST_CHANGES", "COMMENT", "PENDING")),
mcp.WithString("body", mcp.Description("review body/comment")),
mcp.WithString("commit_id", mcp.Description("commit SHA to review (for 'create')")),
mcp.WithString("message", mcp.Description("dismissal reason (for 'dismiss')")),
mcp.WithArray("comments", mcp.Description("inline review comments (for 'create')"), mcp.Items(map[string]any{
mcp.WithDescription("Write PR reviews: create, submit, delete, dismiss."),
mcp.WithToolAnnotation(annotation.Write("Submit a pull request review")),
mcp.WithString("method", mcp.Required(), mcp.Enum("create", "submit", "delete", "dismiss")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("pull_number", mcp.Required()),
mcp.WithNumber("review_id", mcp.Description("required except for 'create'")),
mcp.WithString("state", mcp.Enum("APPROVED", "REQUEST_CHANGES", "COMMENT", "PENDING")),
mcp.WithString("body"),
mcp.WithString("commit_id", mcp.Description("for 'create'")),
mcp.WithString("message", mcp.Description("dismissal reason")),
mcp.WithArray("comments", mcp.Description("inline comments (for 'create')"), mcp.Items(map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string", "description": "file path to comment on"},
"body": map[string]any{"type": "string", "description": "comment body"},
"old_line_num": map[string]any{"type": "number", "description": "line number in the old file (for deletions/changes)"},
"new_line_num": map[string]any{"type": "number", "description": "line number in the new file (for additions/changes)"},
"path": map[string]any{"type": "string"},
"body": map[string]any{"type": "string"},
"old_line_num": map[string]any{"type": "number", "description": "old-file line (deletions)"},
"new_line_num": map[string]any{"type": "number", "description": "new-file line (additions)"},
},
})),
)
@@ -135,6 +139,10 @@ func pullRequestReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
return getPullRequestByIndexFn(ctx, req)
case "get_diff":
return getPullRequestDiffFn(ctx, req)
case "get_files":
return getPullRequestFilesFn(ctx, req)
case "get_status":
return getPullRequestStatusFn(ctx, req)
case "get_reviews":
return listPullRequestReviewsFn(ctx, req)
case "get_review":
@@ -156,8 +164,14 @@ func pullRequestWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
return createPullRequestFn(ctx, req)
case "update":
return editPullRequestFn(ctx, req)
case "close":
return closePullRequestFn(ctx, req)
case "reopen":
return reopenPullRequestFn(ctx, req)
case "merge":
return mergePullRequestFn(ctx, req)
case "update_branch":
return updatePullRequestBranchFn(ctx, req)
case "add_reviewers":
return createPullRequestReviewerFn(ctx, req)
case "remove_reviewers":
@@ -167,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) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
@@ -197,7 +271,7 @@ func getPullRequestByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(args, "index")
index, err := params.GetIndex(args, "pull_number")
if err != nil {
return to.ErrorResult(err)
}
@@ -234,7 +308,7 @@ func getPullRequestDiffFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(args, "index")
index, err := params.GetIndex(args, "pull_number")
if err != nil {
return to.ErrorResult(err)
}
@@ -377,7 +451,7 @@ func createPullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest) (
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(args, "index")
index, err := params.GetIndex(args, "pull_number")
if err != nil {
return to.ErrorResult(err)
}
@@ -420,7 +494,7 @@ func deletePullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest) (
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(args, "index")
index, err := params.GetIndex(args, "pull_number")
if err != nil {
return to.ErrorResult(err)
}
@@ -463,7 +537,7 @@ func listPullRequestReviewsFn(ctx context.Context, req mcp.CallToolRequest) (*mc
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(args, "index")
index, err := params.GetIndex(args, "pull_number")
if err != nil {
return to.ErrorResult(err)
}
@@ -498,7 +572,7 @@ func getPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(args, "index")
index, err := params.GetIndex(args, "pull_number")
if err != nil {
return to.ErrorResult(err)
}
@@ -531,7 +605,7 @@ func listPullRequestReviewCommentsFn(ctx context.Context, req mcp.CallToolReques
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(args, "index")
index, err := params.GetIndex(args, "pull_number")
if err != nil {
return to.ErrorResult(err)
}
@@ -564,7 +638,7 @@ func createPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*m
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(args, "index")
index, err := params.GetIndex(args, "pull_number")
if err != nil {
return to.ErrorResult(err)
}
@@ -629,7 +703,7 @@ func submitPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*m
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(args, "index")
index, err := params.GetIndex(args, "pull_number")
if err != nil {
return to.ErrorResult(err)
}
@@ -673,7 +747,7 @@ func deletePullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*m
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(args, "index")
index, err := params.GetIndex(args, "pull_number")
if err != nil {
return to.ErrorResult(err)
}
@@ -713,7 +787,7 @@ func dismissPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(args, "index")
index, err := params.GetIndex(args, "pull_number")
if err != nil {
return to.ErrorResult(err)
}
@@ -758,7 +832,7 @@ func mergePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(args, "index")
index, err := params.GetIndex(args, "pull_number")
if err != nil {
return to.ErrorResult(err)
}
@@ -822,7 +896,7 @@ func editPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(args, "index")
index, err := params.GetIndex(args, "pull_number")
if err != nil {
return to.ErrorResult(err)
}
@@ -891,3 +965,89 @@ func editPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
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)
}
+145 -17
View File
@@ -80,11 +80,11 @@ func Test_editPullRequestFn(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: map[string]any{
"owner": owner,
"repo": repo,
"index": ii.val,
"title": "WIP: my feature",
"state": "open",
"owner": owner,
"repo": repo,
"pull_number": ii.val,
"title": "WIP: my feature",
"state": "open",
},
},
}
@@ -195,7 +195,7 @@ func Test_mergePullRequestFn(t *testing.T) {
Arguments: map[string]any{
"owner": owner,
"repo": repo,
"index": ii.val,
"pull_number": ii.val,
"merge_style": "squash",
"title": "feat: my squashed commit",
"message": "Squash merge of PR #5",
@@ -308,7 +308,7 @@ func Test_mergePullRequestFn_newParams(t *testing.T) {
Arguments: map[string]any{
"owner": owner,
"repo": repo,
"index": float64(index),
"pull_number": float64(index),
"merge_style": "merge",
"force_merge": true,
"merge_when_checks_succeed": true,
@@ -616,9 +616,9 @@ func Test_editPullRequestFn_draft(t *testing.T) {
}()
args := map[string]any{
"owner": owner,
"repo": repo,
"index": float64(index),
"owner": owner,
"repo": repo,
"pull_number": float64(index),
}
if tc.title != "" {
args["title"] = tc.title
@@ -720,10 +720,10 @@ func Test_getPullRequestDiffFn(t *testing.T) {
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: map[string]any{
"owner": owner,
"repo": repo,
"index": ii.val,
"binary": true,
"owner": owner,
"repo": repo,
"pull_number": ii.val,
"binary": true,
},
},
}
@@ -805,7 +805,7 @@ func Test_getPullRequestByIndexFn_includesAttachments(t *testing.T) {
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, "index": float64(index),
"owner": owner, "repo": repo, "pull_number": float64(index),
}}}
res, err := getPullRequestByIndexFn(context.Background(), req)
if err != nil {
@@ -853,7 +853,7 @@ func Test_getPullRequestByIndexFn_emptyAssetsLeavesBody(t *testing.T) {
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, "index": float64(index),
"owner": owner, "repo": repo, "pull_number": float64(index),
}}}
res, err := getPullRequestByIndexFn(context.Background(), req)
if err != nil {
@@ -897,7 +897,7 @@ func Test_getPullRequestByIndexFn_assetsFailureNonFatal(t *testing.T) {
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, "index": float64(index),
"owner": owner, "repo": repo, "pull_number": float64(index),
}}}
res, err := getPullRequestByIndexFn(context.Background(), req)
if err != nil {
@@ -911,3 +911,131 @@ func Test_getPullRequestByIndexFn_assetsFailureNonFatal(t *testing.T) {
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")
}
}
+15 -14
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"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"
@@ -23,28 +24,28 @@ const (
var (
CreateBranchTool = mcp.NewTool(
CreateBranchToolName,
mcp.WithDescription("Create branch"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("branch", mcp.Required(), mcp.Description("Name of the branch to create")),
mcp.WithString("old_branch", mcp.Required(), mcp.Description("Name of the old branch to create from")),
mcp.WithToolAnnotation(annotation.Write("Create a new branch")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("branch", mcp.Required()),
mcp.WithString("old_branch", mcp.Description("source branch (default: repo default)")),
)
DeleteBranchTool = mcp.NewTool(
DeleteBranchToolName,
mcp.WithDescription("Delete branch"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("branch", mcp.Required(), mcp.Description("Name of the branch to delete")),
mcp.WithToolAnnotation(annotation.Destructive("Delete a branch")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("branch", mcp.Required()),
)
ListBranchesTool = mcp.NewTool(
ListBranchesToolName,
mcp.WithDescription("List branches"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30)),
mcp.WithToolAnnotation(annotation.ReadOnly("List repository branches")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
)
)
+15 -21
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"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"
@@ -22,21 +23,21 @@ const (
var (
ListRepoCommitsTool = mcp.NewTool(
ListRepoCommitsToolName,
mcp.WithDescription("List repository commits"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("sha", mcp.Description("SHA or branch to start listing commits from")),
mcp.WithString("path", mcp.Description("path indicates that only commits that include the path's file/dir should be returned.")),
mcp.WithNumber("page", mcp.Required(), mcp.Description("page number"), 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.WithToolAnnotation(annotation.ReadOnly("List repository commits")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("sha", mcp.Description("starting SHA or branch")),
mcp.WithString("path", mcp.Description("only commits touching this path")),
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)),
)
GetCommitTool = mcp.NewTool(
GetCommitToolName,
mcp.WithDescription("Get details of a specific commit"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("sha", mcp.Required(), mcp.Description("commit SHA")),
mcp.WithToolAnnotation(annotation.ReadOnly("Get commit details")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("sha", mcp.Required()),
)
)
@@ -62,20 +63,13 @@ func ListRepoCommitsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
if err != nil {
return to.ErrorResult(err)
}
page, err := params.GetIndex(args, "page")
if err != nil {
return to.ErrorResult(err)
}
pageSize, err := params.GetIndex(args, "perPage")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(args, 30)
sha, _ := args["sha"].(string)
path, _ := args["path"].(string)
opt := gitea_sdk.ListCommitOptions{
ListOptions: gitea_sdk.ListOptions{
Page: int(page),
PageSize: int(pageSize),
Page: page,
PageSize: pageSize,
},
SHA: sha,
Path: path,
+32 -29
View File
@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"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"
@@ -28,45 +29,47 @@ const (
var (
GetFileContentTool = mcp.NewTool(
GetFileToolName,
mcp.WithDescription("Get file Content and Metadata"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("ref", mcp.Required(), mcp.Description("ref can be branch/tag/commit")),
mcp.WithString("filePath", mcp.Required(), mcp.Description("file path")),
mcp.WithBoolean("withLines", mcp.Description("whether to return file content with lines")),
mcp.WithDescription("Get file content and metadata"),
mcp.WithToolAnnotation(annotation.ReadOnly("Get file content")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("ref", mcp.Required(), mcp.Description("branch, tag, or commit SHA")),
mcp.WithString("path", mcp.Required()),
mcp.WithBoolean("withLines", mcp.Description("return numbered lines")),
)
GetDirContentTool = mcp.NewTool(
GetDirToolName,
mcp.WithDescription("Get a list of entries in a directory"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("ref", mcp.Required(), mcp.Description("ref can be branch/tag/commit")),
mcp.WithString("filePath", mcp.Required(), mcp.Description("directory path")),
mcp.WithToolAnnotation(annotation.ReadOnly("Get directory contents")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("ref", mcp.Required(), mcp.Description("branch, tag, or commit SHA")),
mcp.WithString("path", mcp.Required()),
)
CreateOrUpdateFileTool = mcp.NewTool(
CreateOrUpdateFileToolName,
mcp.WithDescription("Create or update a file. If sha is provided, updates the existing file; otherwise creates a new file."),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("filePath", mcp.Required(), mcp.Description("file path")),
mcp.WithString("content", mcp.Required(), mcp.Description("file content")),
mcp.WithDescription("Create or update a file (provide sha to update an existing file)."),
mcp.WithToolAnnotation(annotation.Write("Create or update a file")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("path", mcp.Required()),
mcp.WithString("content", mcp.Required()),
mcp.WithString("message", mcp.Required(), mcp.Description("commit message")),
mcp.WithString("branch_name", mcp.Required(), mcp.Description("branch name")),
mcp.WithString("sha", mcp.Description("SHA of the existing file (required for update, omit for create)")),
mcp.WithString("new_branch_name", mcp.Description("new branch name (for create only)")),
mcp.WithString("branch_name", mcp.Required()),
mcp.WithString("sha", mcp.Description("existing file SHA (omit to create)")),
mcp.WithString("new_branch_name", mcp.Description("new branch (create only)")),
)
DeleteFileTool = mcp.NewTool(
DeleteFileToolName,
mcp.WithDescription("Delete file"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("filePath", mcp.Required(), mcp.Description("file path")),
mcp.WithToolAnnotation(annotation.Destructive("Delete a file")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("path", mcp.Required()),
mcp.WithString("message", mcp.Required(), mcp.Description("commit message")),
mcp.WithString("branch_name", mcp.Required(), mcp.Description("branch name")),
mcp.WithString("sha", mcp.Required(), mcp.Description("sha")),
mcp.WithString("branch_name", mcp.Required()),
mcp.WithString("sha", mcp.Required()),
)
)
@@ -106,7 +109,7 @@ func GetFileContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
return to.ErrorResult(err)
}
ref, _ := args["ref"].(string)
filePath, err := params.GetString(args, "filePath")
filePath, err := params.GetString(args, "path")
if err != nil {
return to.ErrorResult(err)
}
@@ -170,7 +173,7 @@ func GetDirContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
return to.ErrorResult(err)
}
ref, _ := args["ref"].(string)
filePath, err := params.GetString(args, "filePath")
filePath, err := params.GetString(args, "path")
if err != nil {
return to.ErrorResult(err)
}
@@ -196,7 +199,7 @@ func CreateOrUpdateFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
if err != nil {
return to.ErrorResult(err)
}
filePath, err := params.GetString(args, "filePath")
filePath, err := params.GetString(args, "path")
if err != nil {
return to.ErrorResult(err)
}
@@ -256,7 +259,7 @@ func DeleteFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRe
if err != nil {
return to.ErrorResult(err)
}
filePath, err := params.GetString(args, "filePath")
filePath, err := params.GetString(args, "path")
if err != nil {
return to.ErrorResult(err)
}
+30 -28
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"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"
@@ -25,49 +26,50 @@ const (
var (
CreateReleaseTool = mcp.NewTool(
CreateReleaseToolName,
mcp.WithDescription("Create release"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("tag_name", mcp.Required(), mcp.Description("tag name")),
mcp.WithString("target", mcp.Required(), mcp.Description("target commitish")),
mcp.WithString("title", mcp.Required(), mcp.Description("release title")),
mcp.WithBoolean("is_draft", mcp.Description("Whether the release is draft"), mcp.DefaultBool(false)),
mcp.WithBoolean("is_pre_release", mcp.Description("Whether the release is pre-release"), mcp.DefaultBool(false)),
mcp.WithString("body", mcp.Description("release body")),
mcp.WithToolAnnotation(annotation.Write("Create a release")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("tag_name", mcp.Required()),
mcp.WithString("target", mcp.Required(), mcp.Description("commitish")),
mcp.WithString("title", mcp.Required()),
mcp.WithBoolean("is_draft"),
mcp.WithBoolean("is_pre_release"),
mcp.WithString("body"),
)
DeleteReleaseTool = mcp.NewTool(
DeleteReleaseToolName,
mcp.WithDescription("Delete release"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Required(), mcp.Description("release id")),
mcp.WithToolAnnotation(annotation.Destructive("Delete a release")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("id", mcp.Required()),
)
GetReleaseTool = mcp.NewTool(
GetReleaseToolName,
mcp.WithDescription("Get release"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Required(), mcp.Description("release id")),
mcp.WithDescription("Get a release by ID"),
mcp.WithToolAnnotation(annotation.ReadOnly("Get release details")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("id", mcp.Required()),
)
GetLatestReleaseTool = mcp.NewTool(
GetLatestReleaseToolName,
mcp.WithDescription("Get latest release"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithToolAnnotation(annotation.ReadOnly("Get latest release")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
)
ListReleasesTool = mcp.NewTool(
ListReleasesToolName,
mcp.WithDescription("List releases"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithBoolean("is_draft", mcp.Description("Whether the release is draft"), mcp.DefaultBool(false)),
mcp.WithBoolean("is_pre_release", mcp.Description("Whether the release is pre-release"), mcp.DefaultBool(false)),
mcp.WithNumber("page", mcp.Description("page number"), 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.WithToolAnnotation(annotation.ReadOnly("List releases")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithBoolean("is_draft"),
mcp.WithBoolean("is_pre_release"),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), 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)
}
page := params.GetOptionalInt(args, "page", 1)
pageSize := params.GetOptionalInt(args, "perPage", 20)
pageSize := params.GetOptionalInt(args, "per_page", 20)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
+30 -36
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"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"
@@ -28,44 +29,44 @@ const (
var (
CreateRepoTool = mcp.NewTool(
CreateRepoToolName,
mcp.WithDescription("Create repository in personal account or organization"),
mcp.WithString("name", mcp.Required(), mcp.Description("Name of the repository to create")),
mcp.WithString("description", mcp.Description("Description of the repository to create")),
mcp.WithBoolean("private", mcp.Description("Whether the repository is private")),
mcp.WithString("issue_labels", mcp.Description("Issue Label set to use")),
mcp.WithBoolean("auto_init", mcp.Description("Whether the repository should be auto-intialized?")),
mcp.WithBoolean("template", mcp.Description("Whether the repository is template")),
mcp.WithString("gitignores", mcp.Description("Gitignores to use")),
mcp.WithString("license", mcp.Description("License to use")),
mcp.WithString("readme", mcp.Description("Readme of the repository to create")),
mcp.WithString("default_branch", mcp.Description("DefaultBranch of the repository (used when initializes and in template)")),
mcp.WithString("trust_model", mcp.Description("Trust model for verifying GPG signatures"), mcp.Enum("default", "collaborator", "committer", "collaboratorcommitter")),
mcp.WithString("object_format_name", mcp.Description("Object format: sha1 or sha256"), mcp.Enum("sha1", "sha256")),
mcp.WithString("organization", mcp.Description("Organization name to create repository in (optional - defaults to personal account)")),
mcp.WithToolAnnotation(annotation.Write("Create a new repository")),
mcp.WithString("name", mcp.Required()),
mcp.WithString("description"),
mcp.WithBoolean("private"),
mcp.WithString("issue_labels"),
mcp.WithBoolean("auto_init"),
mcp.WithBoolean("template"),
mcp.WithString("gitignores"),
mcp.WithString("license"),
mcp.WithString("readme"),
mcp.WithString("default_branch"),
mcp.WithString("trust_model", mcp.Enum("default", "collaborator", "committer", "collaboratorcommitter")),
mcp.WithString("object_format_name", mcp.Enum("sha1", "sha256")),
mcp.WithString("organization", mcp.Description("defaults to personal account")),
)
ForkRepoTool = mcp.NewTool(
ForkRepoToolName,
mcp.WithDescription("Fork repository"),
mcp.WithString("user", mcp.Required(), mcp.Description("User name of the repository to fork")),
mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name to fork")),
mcp.WithString("organization", mcp.Description("Organization name to fork")),
mcp.WithString("name", mcp.Description("Name of the forked repository")),
mcp.WithToolAnnotation(annotation.Write("Fork a repository")),
mcp.WithString("user", mcp.Required(), mcp.Description("owner of source repo")),
mcp.WithString("repo", mcp.Required()),
mcp.WithString("organization", mcp.Description("target org")),
mcp.WithString("name", mcp.Description("fork name")),
)
ListMyReposTool = mcp.NewTool(
ListMyReposToolName,
mcp.WithDescription("List my repositories"),
mcp.WithNumber("page", mcp.Required(), mcp.Description("Page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Required(), mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30), mcp.Min(1)),
mcp.WithToolAnnotation(annotation.ReadOnly("List my repositories")),
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)),
)
ListOrgReposTool = mcp.NewTool(
ListOrgReposToolName,
mcp.WithDescription("List repositories of an organization"),
mcp.WithString("org", mcp.Required(), mcp.Description("Organization name")),
mcp.WithNumber("page", mcp.Required(), mcp.Description("Page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("pageSize", mcp.Required(), mcp.Description("Page size number"), mcp.DefaultNumber(100), mcp.Min(1)),
mcp.WithToolAnnotation(annotation.ReadOnly("List organization repositories")),
mcp.WithString("org", mcp.Required()),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), 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 {
return to.ErrorResult(errors.New("organization name is required"))
}
page, ok := req.GetArguments()["page"].(float64)
if !ok {
page = 1
}
pageSize, ok := req.GetArguments()["pageSize"].(float64)
if !ok {
pageSize = 100
}
page, pageSize := params.GetPagination(req.GetArguments(), 100)
opt := gitea_sdk.ListOrgReposOptions{
ListOptions: gitea_sdk.ListOptions{
Page: int(page),
PageSize: int(pageSize),
Page: page,
PageSize: pageSize,
},
}
client, err := gitea.ClientFromContext(ctx)
+21 -20
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"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"
@@ -24,37 +25,37 @@ const (
var (
CreateTagTool = mcp.NewTool(
CreateTagToolName,
mcp.WithDescription("Create tag"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("tag_name", mcp.Required(), mcp.Description("tag name")),
mcp.WithString("target", mcp.Description("target commitish"), mcp.DefaultString("")),
mcp.WithString("message", mcp.Description("tag message"), mcp.DefaultString("")),
mcp.WithToolAnnotation(annotation.Write("Create a tag")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("tag_name", mcp.Required()),
mcp.WithString("target", mcp.Description("commitish")),
mcp.WithString("message", mcp.Description("tag message")),
)
DeleteTagTool = mcp.NewTool(
DeleteTagToolName,
mcp.WithDescription("Delete tag"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("tag_name", mcp.Required(), mcp.Description("tag name")),
mcp.WithToolAnnotation(annotation.Destructive("Delete a tag")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("tag_name", mcp.Required()),
)
GetTagTool = mcp.NewTool(
GetTagToolName,
mcp.WithDescription("Get tag"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("tag_name", mcp.Required(), mcp.Description("tag name")),
mcp.WithToolAnnotation(annotation.ReadOnly("Get tag details")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("tag_name", mcp.Required()),
)
ListTagsTool = mcp.NewTool(
ListTagsToolName,
mcp.WithDescription("List tags"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("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.WithToolAnnotation(annotation.ReadOnly("List tags")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), 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)
}
page := params.GetOptionalInt(args, "page", 1)
pageSize := params.GetOptionalInt(args, "perPage", 20)
pageSize := params.GetOptionalInt(args, "per_page", 20)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
+8 -7
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"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"
@@ -20,13 +21,13 @@ const (
var GetRepoTreeTool = mcp.NewTool(
GetRepoTreeToolName,
mcp.WithDescription("Get the file tree of a repository"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("tree_sha", mcp.Required(), mcp.Description("SHA, branch name, or tag name")),
mcp.WithBoolean("recursive", mcp.Description("whether to get the tree recursively")),
mcp.WithNumber("page", mcp.Description("page number"), 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.WithToolAnnotation(annotation.ReadOnly("Get repository file tree")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("tree_sha", mcp.Required(), mcp.Description("SHA, branch, or tag")),
mcp.WithBoolean("recursive"),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
)
func init() {
+33 -31
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"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"
@@ -28,47 +29,48 @@ const (
var (
SearchUsersTool = mcp.NewTool(
SearchUsersToolName,
mcp.WithDescription("search users"),
mcp.WithString("keyword", mcp.Required(), mcp.Description("Keyword")),
mcp.WithNumber("page", mcp.Description("Page"), 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.WithToolAnnotation(annotation.ReadOnly("Search users")),
mcp.WithString("query", mcp.Required()),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
)
SearOrgTeamsTool = mcp.NewTool(
SearchOrgTeamsToolName,
mcp.WithDescription("search organization teams"),
mcp.WithString("org", mcp.Required(), mcp.Description("organization name")),
mcp.WithString("query", mcp.Required(), mcp.Description("search organization teams")),
mcp.WithBoolean("includeDescription", mcp.Description("include description?")),
mcp.WithNumber("page", mcp.Description("Page"), 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.WithToolAnnotation(annotation.ReadOnly("Search organization teams")),
mcp.WithString("org", mcp.Required()),
mcp.WithString("query", mcp.Required()),
mcp.WithBoolean("includeDescription"),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
)
SearchReposTool = mcp.NewTool(
SearchReposToolName,
mcp.WithDescription("search repos"),
mcp.WithString("keyword", mcp.Required(), mcp.Description("Keyword")),
mcp.WithBoolean("keywordIsTopic", mcp.Description("KeywordIsTopic")),
mcp.WithBoolean("keywordInDescription", mcp.Description("KeywordInDescription")),
mcp.WithNumber("ownerID", mcp.Description("OwnerID")),
mcp.WithBoolean("isPrivate", mcp.Description("IsPrivate")),
mcp.WithBoolean("isArchived", mcp.Description("IsArchived")),
mcp.WithString("sort", mcp.Description("Sort")),
mcp.WithString("order", mcp.Description("Order")),
mcp.WithNumber("page", mcp.Description("Page"), 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.WithToolAnnotation(annotation.ReadOnly("Search repositories")),
mcp.WithString("query", mcp.Required()),
mcp.WithBoolean("keywordIsTopic"),
mcp.WithBoolean("keywordInDescription"),
mcp.WithNumber("ownerID"),
mcp.WithBoolean("isPrivate"),
mcp.WithBoolean("isArchived"),
mcp.WithString("sort"),
mcp.WithString("order"),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
)
SearchIssuesTool = mcp.NewTool(
SearchIssuesToolName,
mcp.WithDescription("Search for issues and pull requests across all accessible repositories"),
mcp.WithString("query", mcp.Required(), mcp.Description("search keyword")),
mcp.WithString("state", mcp.Description("filter by state: open, closed, all"), mcp.Enum("open", "closed", "all")),
mcp.WithString("type", mcp.Description("filter by type: issues, pulls"), mcp.Enum("issues", "pulls")),
mcp.WithString("labels", mcp.Description("comma-separated list of label names")),
mcp.WithString("owner", mcp.Description("filter by repository owner")),
mcp.WithNumber("page", mcp.Description("page number"), 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.WithDescription("Search issues and PRs across repositories"),
mcp.WithToolAnnotation(annotation.ReadOnly("Search issues")),
mcp.WithString("query", mcp.Required()),
mcp.WithString("state", mcp.Enum("open", "closed", "all")),
mcp.WithString("type", mcp.Enum("issues", "pulls")),
mcp.WithString("labels", mcp.Description("comma-separated")),
mcp.WithString("owner", mcp.Description("filter by owner")),
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) {
log.Debugf("Called UsersFn")
keyword, err := params.GetString(req.GetArguments(), "keyword")
keyword, err := params.GetString(req.GetArguments(), "query")
if err != nil {
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) {
log.Debugf("Called ReposFn")
keyword, err := params.GetString(req.GetArguments(), "keyword")
keyword, err := params.GetString(req.GetArguments(), "query")
if err != nil {
return to.ErrorResult(err)
}
+2 -2
View File
@@ -16,7 +16,7 @@ func TestSearchToolsRequiredFields(t *testing.T) {
{
name: "search_users",
tool: SearchUsersTool,
required: []string{"keyword"},
required: []string{"query"},
},
{
name: "search_org_teams",
@@ -26,7 +26,7 @@ func TestSearchToolsRequiredFields(t *testing.T) {
{
name: "search_repos",
tool: SearchReposTool,
required: []string{"keyword"},
required: []string{"query"},
},
}
+23 -20
View File
@@ -5,6 +5,7 @@ import (
"context"
"fmt"
"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"
@@ -26,24 +27,26 @@ const (
var (
TimetrackingReadTool = mcp.NewTool(
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.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list_issue_times", "list_repo_times", "get_my_stopwatches", "get_my_times")),
mcp.WithString("owner", mcp.Description("repository owner (required for 'list_issue_times', 'list_repo_times')")),
mcp.WithString("repo", mcp.Description("repository name (required for 'list_issue_times', 'list_repo_times')")),
mcp.WithNumber("index", mcp.Description("issue index (required for 'list_issue_times')")),
mcp.WithNumber("page", mcp.Description("page number"), 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.WithDescription("Read time tracking: issue times, repo times, active stopwatches, your tracked times."),
mcp.WithToolAnnotation(annotation.ReadOnly("Read tracked time")),
mcp.WithString("method", mcp.Required(), mcp.Enum("list_issue_times", "list_repo_times", "get_my_stopwatches", "get_my_times")),
mcp.WithString("owner", mcp.Description("for list_* methods")),
mcp.WithString("repo", mcp.Description("for list_* methods")),
mcp.WithNumber("issue_number", mcp.Description("for 'list_issue_times'")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
)
TimetrackingWriteTool = mcp.NewTool(
TimetrackingWriteToolName,
mcp.WithDescription("Manage time tracking: stopwatches and tracked time entries."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("start_stopwatch", "stop_stopwatch", "delete_stopwatch", "add_time", "delete_time")),
mcp.WithString("owner", mcp.Description("repository owner (required for all methods)")),
mcp.WithString("repo", mcp.Description("repository name (required for all methods)")),
mcp.WithNumber("index", mcp.Description("issue index (required for all methods)")),
mcp.WithNumber("time", mcp.Description("time to add in seconds (required for 'add_time')")),
mcp.WithNumber("id", mcp.Description("tracked time entry ID (required for 'delete_time')")),
mcp.WithDescription("Write time tracking: stopwatches and entries."),
mcp.WithToolAnnotation(annotation.Write("Add or manage tracked time")),
mcp.WithString("method", mcp.Required(), mcp.Enum("start_stopwatch", "stop_stopwatch", "delete_stopwatch", "add_time", "delete_time")),
mcp.WithString("owner", mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Description(params.RepoDesc)),
mcp.WithNumber("issue_number"),
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 {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil {
return to.ErrorResult(err)
}
@@ -129,7 +132,7 @@ func stopStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil {
return to.ErrorResult(err)
}
@@ -154,7 +157,7 @@ func deleteStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil {
return to.ErrorResult(err)
}
@@ -197,7 +200,7 @@ func listTrackedTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil {
return to.ErrorResult(err)
}
@@ -232,7 +235,7 @@ func addTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil {
return to.ErrorResult(err)
}
@@ -265,7 +268,7 @@ func deleteTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Cal
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil {
return to.ErrorResult(err)
}
+8 -5
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"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"
@@ -35,16 +36,18 @@ var (
// It is registered with a specific name and a description string.
GetMyUserInfoTool = mcp.NewTool(
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.
// 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(
GetUserOrgsToolName,
mcp.WithDescription("Get organizations associated with the authenticated user"),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(defaultPage)),
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.WithDescription("List current user's organizations"),
mcp.WithToolAnnotation(annotation.ReadOnly("Get user organizations")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(defaultPage)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(defaultPageSize)),
)
)
+2 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/to"
@@ -21,7 +22,7 @@ const (
var GetGiteaMCPServerVersionTool = mcp.NewTool(
GetGiteaMCPServerVersion,
mcp.WithDescription("Get Gitea MCP Server Version"),
mcp.WithToolAnnotation(annotation.ReadOnly("Get server version")),
)
func init() {
+15 -12
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net/url"
"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"
@@ -26,22 +27,24 @@ const (
var (
WikiReadTool = mcp.NewTool(
WikiReadToolName,
mcp.WithDescription("Read wiki page information. Use method 'list' to list pages, 'get' to get page content, 'get_revisions' for revision history."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list", "get", "get_revisions")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("pageName", mcp.Description("wiki page name (required for 'get', 'get_revisions')")),
mcp.WithDescription("Read wiki: list pages, get content, revision history."),
mcp.WithToolAnnotation(annotation.ReadOnly("Read wiki pages")),
mcp.WithString("method", mcp.Required(), mcp.Enum("list", "get", "get_revisions")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("pageName", mcp.Description("for 'get'/'get_revisions'")),
)
WikiWriteTool = mcp.NewTool(
WikiWriteToolName,
mcp.WithDescription("Create, update, or delete wiki pages."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "update", "delete")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("pageName", mcp.Description("wiki page name (required for 'update', 'delete')")),
mcp.WithString("title", mcp.Description("wiki page title (required for 'create', optional for 'update')")),
mcp.WithString("content", mcp.Description("page content (required for 'create', 'update')")),
mcp.WithDescription("Write wiki pages: create, update, delete."),
mcp.WithToolAnnotation(annotation.Destructive("Create, update, or delete wiki pages")),
mcp.WithString("method", mcp.Required(), mcp.Enum("create", "update", "delete")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("pageName", mcp.Description("for 'update'/'delete'")),
mcp.WithString("title", mcp.Description("for 'create'")),
mcp.WithString("content", mcp.Description("for 'create'/'update'")),
mcp.WithString("message", mcp.Description("commit message")),
)
)
+2 -2
View File
@@ -32,10 +32,10 @@ func TestWikiWriteBase64Encoding(t *testing.T) {
var gotBody map[string]string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
json.Unmarshal(body, &gotBody)
_ = json.Unmarshal(body, &gotBody)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"title":"test"}`))
_, _ = w.Write([]byte(`{"title":"test"}`))
}))
defer srv.Close()
+22
View File
@@ -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}
}
+4 -3
View File
@@ -7,7 +7,8 @@ var (
Version string
Mode string
Insecure bool
ReadOnly bool
Debug bool
Insecure bool
ReadOnly bool
Debug bool
AllowedTools map[string]struct{}
)
+1 -1
View File
@@ -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) {
w.Header().Set("Content-Type", "application/json")
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)
defer srv.Close()
+12 -2
View File
@@ -6,6 +6,16 @@ import (
"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.
func GetString(args map[string]any, key string) (string, error) {
val, ok := args[key].(string)
@@ -42,9 +52,9 @@ func GetStringSlice(args map[string]any, key string) []string {
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) {
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
+11
View File
@@ -5,6 +5,17 @@ import (
"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) {
tests := []struct {
name string
+47 -7
View File
@@ -1,7 +1,11 @@
package tool
import (
"slices"
"strings"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"gitea.com/gitea/gitea-mcp/pkg/log"
"github.com/mark3labs/mcp-go/server"
)
@@ -27,12 +31,48 @@ func (t *Tool) RegisterRead(s server.ServerTool) {
}
func (t *Tool) Tools() []server.ServerTool {
tools := make([]server.ServerTool, 0, len(t.write)+len(t.read))
if flag.ReadOnly {
tools = append(tools, t.read...)
return tools
all := make([]server.ServerTool, 0, len(t.write)+len(t.read))
if !flag.ReadOnly {
all = append(all, t.write...)
}
tools = append(tools, t.write...)
tools = append(tools, t.read...)
return tools
all = append(all, t.read...)
if len(flag.AllowedTools) == 0 {
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, ", "))
}
+100
View File
@@ -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)
}
})
}
}