Compare commits

..

20 Commits

Author SHA1 Message Date
Jason 4d7a33e57e Add a sample config for Mistral's Vibe agent config (#194)
Note: There seems to be some nascent work on
https://github.com/mistralai/mistral-vibe to make it easier to pass
environment variables into the config and eliminate hardcoding. But,
it hasn't landed yet, as far as I can tell.

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/194
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Jason <jro@codegrinder.com>
Co-committed-by: Jason <jro@codegrinder.com>
2026-05-19 17:33:19 +00:00
silverwind 371a06403a Simplify codebase (#195)
Net **-650 LOC** by removing duplication and dead noise. All tests pass.

### Duplication & helpers
- Extracted shared slim helpers (`UserLogin`, `UserLogins`, `LabelNames`, `BodyWithAttachments`, `UserDetail`, `Repo`/`Repos`, `Label`/`Labels`) into `pkg/slim`. Deleted the 4 copies that lived in `issue/`, `pull/`, `search/`, `repo/` (plus duplicate `slimUserDetail`/`slimRepo`/`slimLabels` across packages).
- Added `params.GetOptionalBoolPtr` and `params.GetOptionalStringPtr`. Replaced 18 awkward `new(localVar); if !ok { = nil }` patterns across `repo/`, `pull/`, `issue/`, `label/`, `milestone/`, `search/`.
- Extracted `pullRequestReviewerFn` for the 99%-identical `createPullRequestReviewerFn`/`deletePullRequestReviewerFn` pair.

### Dead code & noise
- Deleted **122** `log.Debugf("Called X")` narration lines (`zap.AddCaller` already records the caller) and pruned 19 unused `log` imports.
- Removed the unused `log.Logger` wrapper; the mcp-go server now uses `log.Default().Sugar()` directly (matches `util.Logger`).
- Deleted dead `s.DeleteTools("")` — confirmed no-op in mcp-go.
- Stripped WHAT-narration comments per project guidance.

### Correctness & consistency
- Fixed `log.Errorf(err.Error())` format-string bug in `pkg/to/to.go` — a `%` in the error would have been interpreted as a directive.
- Standardized `to.TextResult`/`to.ErrorResult` usage; `release.go`, `tag.go`, `branch.go` were bypassing the helpers in 9 sites (skipping the wrapper's debug/error logging).
- Made `params.GetString` reject empty strings; dropped 21 redundant `err != nil || x == ""` checks in `operation/actions/`.
- Replaced raw `args["org"].(string)` in `ListOrgReposFn` with `params.GetString` to match the rest of the codebase.

### Performance
- **Cached `*gitea.Client` by host+token via `sync.Map`** + shared `*http.Transport` via `sync.Once` for both SDK and raw REST paths. Eliminates the SDK's `/api/v1/version` preflight on every tool call and enables connection keep-alive across requests.
- Gated `to.TextResult` debug log behind `flag.Debug` to skip the `string(bytes)` allocation when debug is off.
- Hoisted `8192` and `60s` magic numbers in `pkg/gitea/rest.go` into named constants.

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

---------

Co-authored-by: silverwind <silv3rwind@gmail.com>
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/195
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-05-19 08:25:36 +00:00
silverwind e36137f5a1 Add org.opencontainers.image.source label (#193)
Two image-label fixes:

- Add `org.opencontainers.image.source` so tools like renovate can retrieve release notes from the source repo.
- Re-declare `ARG VERSION` in the final stage. `ARG` after `FROM` is stage-scoped, so the builder-stage `VERSION` never reached the final stage and `org.opencontainers.image.version` expanded to an empty string in published images.

Closes #192

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

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/193
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-05-17 18:10:54 +00:00
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
Dennis Gaida 4c45b42cb5 feat(config): support GITEA_ACCESS_TOKEN_FILE for Docker secrets (#186)
I don't like secrets just being added via environment variables. Add support for the `_FILE` environment variable convention used by Docker secrets.

When `GITEA_ACCESS_TOKEN_FILE` is set, the token is read from the file at that path (e.g. `/run/secrets/gitea_token`). Trailing newlines are stripped to handle the typical Docker secrets file format on both Linux and Windows.

Token resolution precedence (highest to lowest):

  1. `--token` / `-T` CLI flag
  2. `GITEA_ACCESS_TOKEN` env var
  3. `GITEA_ACCESS_TOKEN_FILE` env var

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/186
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Dennis Gaida <gitea@mail.gaida.biz>
Co-committed-by: Dennis Gaida <gitea@mail.gaida.biz>
2026-05-09 23:04:50 +00:00
silverwind 7759c7f327 Inline issue/comment attachments in body (#183)
The Gitea API returns an `assets` array on issue and comment responses, but the SDK structs drop it — so attachments are invisible to MCP agents.

Append each attachment as a `[name](url)` markdown link at the end of the body, mirroring how GitHub embeds attachments inline (which `github-mcp-server` preserves as-is).

**Coverage:**
- `issue_read get` — issue body attachments
- `issue_read get_comments` — issue and PR conversation comment attachments (same endpoint)
- `pull_request_read get` — PR description attachments (Gitea's `/pulls/` endpoint omits `assets`, so a follow-up best-effort call to `/issues/{n}/assets` surfaces them; PRs are issues internally)

PR review summaries and line-comment reviews don't support attachments per the Gitea API spec, so nothing to do there.

Closes https://gitea.com/gitea/gitea-mcp/issues/182

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

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/183
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-05-08 05:44:53 +00:00
silverwind 5867f2f472 Replace AGENTS.md with concise coding guidelines (#185)
Replace AGENTS.md with minimal list of instructions to save context, the rest can be discovered easily.

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

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/185
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-05-07 21:33:05 +00:00
Philipp Horstenkamp 26f826d25c Update dev container version to use the same version as the app to go 1.26 (#180)
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/180
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Philipp Horstenkamp <182049+philipp-horstenkamp@noreply.gitea.com>
Co-committed-by: Philipp Horstenkamp <182049+philipp-horstenkamp@noreply.gitea.com>
2026-04-24 22:38:55 +00:00
silverwind baf792b061 Use golangci-lint fmt to format code (#178)
Use `golangci-lint fmt` to format code, replacing the previous gofumpt-based formatter. https://github.com/daixiang0/gci is used to order the imports.

Mirrors https://github.com/go-gitea/gitea/pull/37194.

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

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/178
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-04-17 22:38:13 +00:00
silverwind 08128b9471 Add notification_read and notification_write tools (#172)
Add notification management via two new MCP tools using the method-dispatch pattern:

- `notification_read`: list notifications (global or repo-scoped, with status/subject_type/since/before filters) and get single notification thread by ID
- `notification_write`: mark single notification as read, mark all notifications as read (global or repo-scoped)

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

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/172
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-04-02 16:47:24 +00:00
pengu 133fe487cd Add missing ref and deadline fields to issue responses (#169)
## Summary

### Read (issue responses)
- Add `ref` (branch) field to issue responses when non-empty
- Add `deadline` (due_date) field to issue responses when non-nil
- Applied to `slimIssue()`, `slimIssues()` in `operation/issue/slim.go` and `slimIssues()` in `operation/search/slim.go`

### Write (issue_write)
- Add `ref` parameter to `issue_write` tool for both `create` and `update` methods
- Allows setting the branch reference on issues via the MCP, consistent with the SDK's `CreateIssueOption.Ref` and `EditIssueOption.Ref` fields

Fixes #168

## Test plan
- [ ] `go test ./...` passes
- [ ] Verify `issue_read` returns `ref` when a branch is assigned to an issue
- [ ] Verify `issue_read` returns `deadline` when a due date is set
- [ ] Verify `list_issues` and `search_issues` include these fields
- [ ] Verify `issue_write` with method `update` can set `ref` on an issue
- [ ] Verify `issue_write` with method `create` can set `ref` on a new issue

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/169
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: pengu <jeremy@wenjy.fr>
Co-committed-by: pengu <jeremy@wenjy.fr>
2026-03-26 18:37:57 +00:00
silverwind 05682e2afa Document server-side pagination limit in README (#166)
The maximum effective page size for paginated tools is determined by the Gitea server's [`[api].MAX_RESPONSE_ITEMS`](https://github.com/go-gitea/gitea/blob/ffa626b585225d62718f39e1b5fcc00416b0b7e4/custom/conf/app.example.ini#L2471-L2472) setting (default: 50). Requesting a `perPage` value higher than this limit will be silently capped by the server.

Fixes https://gitea.com/gitea/gitea-mcp/issues/165

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/166
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-03-26 17:15:33 +00:00
silverwind a5dd03c7f0 Add missing tool parameters from Gitea SDK (#164)
Expose additional parameters that the Gitea SDK supports but were not yet wired through the MCP tool definitions.

**Motivation:** Systematic comparison against github-mcp-server and the Gitea SDK revealed several supported parameters that were missing from tool schemas.

- `list_issues`: `labels`, `since`, `before` filters
- `issue_write`: `labels` and `deadline` on create, `deadline`/`remove_deadline` on update
- `pull_request_write`: `labels`/`deadline` on create/update, `remove_deadline` on update, `force_merge`/`merge_when_checks_succeed`/`head_commit_id` on merge
- `list_branches`: `page`/`perPage` pagination
- `create_repo`: `trust_model`, `object_format_name`
- `label_write`: `is_archived` on create/edit

**Testing:** Added tests for issue list filters, issue create labels/deadline, PR create labels/deadline, and PR merge new params.

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/164
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-03-26 07:00:14 +00:00
silverwind 9056a5ef27 Add get_commit, get_repository_tree, and search_issues tools (#162)
Add three new read-only tools inspired by the GitHub MCP server:

- `get_commit`: Get details of a specific commit by SHA, branch, or tag
- `get_repository_tree`: Get the file tree of a repository with optional recursive traversal, pagination, and ref support
- `search_issues`: Search issues and pull requests across all accessible repositories with filters for state, type, labels, and owner

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/162
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-03-24 17:12:58 +00:00
silverwind c8004e9198 Update mcp-go to v0.45.0 (#163)
Update github.com/mark3labs/mcp-go from v0.44.0 to v0.45.0.

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/163
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-03-24 17:07:40 +00:00
64 changed files with 3703 additions and 1446 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "Gitea MCP DevContainer", "name": "Gitea MCP DevContainer",
"image": "mcr.microsoft.com/devcontainers/go:1.24-bookworm", "image": "mcr.microsoft.com/devcontainers/go:1.26-bookworm",
"features": {}, "features": {},
"customizations": { "customizations": {
"vscode": { "vscode": {
+12 -8
View File
@@ -71,7 +71,10 @@ linters:
- name: unexported-return - name: unexported-return
- name: var-declaration - name: var-declaration
- name: var-naming - name: var-naming
disabled: true arguments:
- [] # AllowList - do not remove as args for the rule are positional and won't work without lists first
- [] # DenyList
- - skip-package-name-checks: true
staticcheck: staticcheck:
checks: checks:
- all - all
@@ -91,20 +94,21 @@ linters:
- common-false-positives - common-false-positives
- legacy - legacy
- std-error-handling - std-error-handling
rules:
- linters:
- errcheck
- staticcheck
- unparam
path: _test\.go
issues: issues:
max-issues-per-linter: 0 max-issues-per-linter: 0
max-same-issues: 0 max-same-issues: 0
formatters: formatters:
enable: enable:
- gofmt - gci
- gofumpt - gofumpt
settings: settings:
gci:
custom-order: true
sections:
- standard
- prefix(gitea.com/gitea/gitea-mcp)
- blank
- default
gofumpt: gofumpt:
extra-rules: true extra-rules: true
exclusions: exclusions:
+8 -71
View File
@@ -1,71 +1,8 @@
# AGENTS.md - Use `make help` to find available development targets
- Run `make fmt` to format `.go` files, and run `make lint-go` to lint them
This file provides guidance to AI coding agents when working with code in this repository. - Run `make tidy` after any `go.mod` changes
- Ensure no trailing whitespace in edited files
## Development Commands - Use Conventional Commits format for commit messages and PR titles (e.g. `type(scope): subject`)
- Never force-push, amend, or squash unless asked. Use new commits and normal push for pull request updates
**Build**: `make build` - Build the gitea-mcp binary - Include authorship attribution in issue and pull request comments
**Install**: `make install` - Build and install to GOPATH/bin - Add `Co-Authored-By` lines to all commits, indicating name and model used
**Clean**: `make clean` - Remove build artifacts
**Test**: `go test ./...` - Run all tests
**Hot reload**: `make dev` - Start development server with hot reload (requires air)
**Dependencies**: `make vendor` - Tidy and verify module dependencies
## Architecture Overview
This is a **Gitea MCP (Model Context Protocol) Server** written in Go that provides MCP tools for interacting with Gitea repositories, issues, pull requests, users, and more.
**Core Components**:
- `main.go` + `cmd/cmd.go`: CLI entry point and flag parsing
- `operation/operation.go`: Main server setup and tool registration
- `pkg/tool/tool.go`: Tool registry with read/write categorization
- `operation/*/`: Individual tool modules (user, repo, issue, pull, search, wiki, etc.)
**Transport Modes**:
- **stdio** (default): Standard input/output for MCP clients
- **http**: HTTP server mode on configurable port (default 8080)
**Authentication**:
- Global token via `--token` flag or `GITEA_ACCESS_TOKEN` env var
- HTTP mode supports per-request Bearer token override in Authorization header
- Token precedence: HTTP Authorization header > CLI flag > environment variable
**Tool Organization**:
- Tools are categorized as read-only or write operations
- `--read-only` flag exposes only read tools
- Tool modules register via `Tool.RegisterRead()` and `Tool.RegisterWrite()`
**Key Configuration**:
- Default Gitea host: `https://gitea.com` (override with `--host` or `GITEA_HOST`)
- Environment variables can override CLI flags: `MCP_MODE`, `GITEA_READONLY`, `GITEA_DEBUG`, `GITEA_INSECURE`
- Logs are written to `~/.gitea-mcp/gitea-mcp.log` with rotation
## Available Tools
The server provides 40+ MCP tools covering:
- **User**: get_my_user_info, get_user_orgs, search_users
- **Repository**: create_repo, fork_repo, list_my_repos, search_repos
- **Branches/Tags**: create_branch, delete_branch, list_branches, create_tag, list_tags
- **Files**: get_file_content, create_file, update_file, delete_file, get_dir_content
- **Issues**: create_issue, list_repo_issues, create_issue_comment, edit_issue
- **Pull Requests**: create_pull_request, list_repo_pull_requests, get_pull_request_by_index
- **Releases**: create_release, list_releases, get_latest_release
- **Wiki**: create_wiki_page, update_wiki_page, list_wiki_pages
- **Search**: search_repos, search_users, search_org_teams
- **Version**: get_gitea_mcp_server_version
## Common Development Patterns
**Testing**: Use `go test ./operation -run TestFunctionName` for specific tests
**Token Context**: HTTP requests use `pkg/context.TokenContextKey` for request-scoped token access
**Flag Access**: All packages access configuration via global variables in `pkg/flag/flag.go`
**Graceful Shutdown**: HTTP mode implements graceful shutdown with 10-second timeout on SIGTERM/SIGINT
+3
View File
@@ -22,11 +22,14 @@ RUN --mount=type=cache,target=/go/pkg/mod \
# Final stage # Final stage
FROM gcr.io/distroless/static-debian12:nonroot FROM gcr.io/distroless/static-debian12:nonroot
ARG VERSION=dev
WORKDIR /app WORKDIR /app
COPY --from=builder --chown=nonroot:nonroot /app/gitea-mcp . COPY --from=builder --chown=nonroot:nonroot /app/gitea-mcp .
USER nonroot:nonroot USER nonroot:nonroot
LABEL org.opencontainers.image.version="${VERSION}" LABEL org.opencontainers.image.version="${VERSION}"
LABEL org.opencontainers.image.source="https://gitea.com/gitea/gitea-mcp"
CMD ["/app/gitea-mcp"] CMD ["/app/gitea-mcp"]
+21 -9
View File
@@ -3,12 +3,11 @@ EXECUTABLE := gitea-mcp
VERSION ?= $(shell git describe --tags --always | sed 's/-/+/' | sed 's/^v//') VERSION ?= $(shell git describe --tags --always | sed 's/-/+/' | sed 's/^v//')
LDFLAGS := -X "main.Version=$(VERSION)" LDFLAGS := -X "main.Version=$(VERSION)"
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.10.1 GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1 GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.3.0
GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.2
.PHONY: help .PHONY: help
help: ## Print this help message. help: ## print this help message
@echo "Usage: make [target]" @echo "Usage: make [target]"
@echo "" @echo ""
@echo "Targets:" @echo "Targets:"
@@ -16,7 +15,7 @@ help: ## Print this help message.
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
.PHONY: install .PHONY: install
install: build ## Install the application. install: build ## install the application
@echo "Installing $(EXECUTABLE)..." @echo "Installing $(EXECUTABLE)..."
@mkdir -p $(GOPATH)/bin @mkdir -p $(GOPATH)/bin
@cp $(EXECUTABLE) $(GOPATH)/bin/$(EXECUTABLE) @cp $(EXECUTABLE) $(GOPATH)/bin/$(EXECUTABLE)
@@ -24,23 +23,23 @@ install: build ## Install the application.
@echo "Please add $(GOPATH)/bin to your PATH if it is not already there." @echo "Please add $(GOPATH)/bin to your PATH if it is not already there."
.PHONY: uninstall .PHONY: uninstall
uninstall: ## Uninstall the application. uninstall: ## uninstall the application
@echo "Uninstalling $(EXECUTABLE)..." @echo "Uninstalling $(EXECUTABLE)..."
@rm -f $(GOPATH)/bin/$(EXECUTABLE) @rm -f $(GOPATH)/bin/$(EXECUTABLE)
@echo "Uninstalled $(EXECUTABLE) from $(GOPATH)/bin/$(EXECUTABLE)" @echo "Uninstalled $(EXECUTABLE) from $(GOPATH)/bin/$(EXECUTABLE)"
.PHONY: clean .PHONY: clean
clean: ## Clean the build artifacts. clean: ## delete build artifacts
@echo "Cleaning up build artifacts..." @echo "Cleaning up build artifacts..."
@rm -f $(EXECUTABLE) @rm -f $(EXECUTABLE)
@echo "Cleaned up $(EXECUTABLE)" @echo "Cleaned up $(EXECUTABLE)"
.PHONY: build .PHONY: build
build: ## Build the application. build: ## build the application
$(GO) build -v -ldflags '-s -w $(LDFLAGS)' -o $(EXECUTABLE) $(GO) build -v -ldflags '-s -w $(LDFLAGS)' -o $(EXECUTABLE)
.PHONY: air .PHONY: air
air: ## Install air for hot reload. air: ## install air for hot reload
@hash air > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ @hash air > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
$(GO) install github.com/air-verse/air@latest; \ $(GO) install github.com/air-verse/air@latest; \
fi fi
@@ -49,6 +48,19 @@ air: ## Install air for hot reload.
dev: air ## run the application with hot reload dev: air ## run the application with hot reload
air --build.cmd "make build" --build.bin ./gitea-mcp air --build.cmd "make build" --build.bin ./gitea-mcp
.PHONY: fmt
fmt: ## format the Go code
$(GO) run $(GOLANGCI_LINT_PACKAGE) fmt
.PHONY: fmt-check
fmt-check: fmt ## check that Go code is formatted
@diff=$$(git diff --color=always); \
if [ -n "$$diff" ]; then \
echo "Please run 'make fmt' and commit the result:"; \
printf "%s" "$${diff}"; \
exit 1; \
fi
.PHONY: lint .PHONY: lint
lint: lint-go ## lint everything lint: lint-go ## lint everything
+29
View File
@@ -15,6 +15,7 @@
- [🚧 Installation](#-installation) - [🚧 Installation](#-installation)
- [Usage with Claude Code](#usage-with-claude-code) - [Usage with Claude Code](#usage-with-claude-code)
- [Usage with VS Code](#usage-with-vs-code) - [Usage with VS Code](#usage-with-vs-code)
- [Usage with Mistral Vibe](#usage-with-mistral-vibe)
- [📥 Download the official binary release](#-download-the-official-binary-release) - [📥 Download the official binary release](#-download-the-official-binary-release)
- [🔧 Build from Source](#-build-from-source) - [🔧 Build from Source](#-build-from-source)
- [📁 Add to PATH](#-add-to-path) - [📁 Add to PATH](#-add-to-path)
@@ -85,6 +86,31 @@ Optionally, you can add it to a file called `.vscode/mcp.json` in your workspace
} }
``` ```
### Usage with Mistral Vibe
Add the following configuration to your Mistral Vibe MCP configuration file (`~/.vibe/config.toml`):
```toml
[[mcp_servers]]
name = "gitea"
transport = "stdio"
command = "docker"
args = [
"run",
"--rm",
"-i",
"-e",
"GITEA_ACCESS_TOKEN",
"-e",
"GITEA_HOST",
"docker.gitea.com/gitea-mcp-server",
]
[mcp_servers.env]
GITEA_ACCESS_TOKEN = "TOKEN"
GITEA_HOST = "https://gitea.com"
```
### 📥 Download the official binary release ### 📥 Download the official binary release
You can download the official release from [official Gitea MCP binary releases](https://gitea.com/gitea/gitea-mcp/releases). You can download the official release from [official Gitea MCP binary releases](https://gitea.com/gitea/gitea-mcp/releases).
@@ -166,6 +192,9 @@ To configure the MCP server for Gitea, add the following to your MCP configurati
> You can provide your Gitea host and access token either as command-line arguments or environment variables. > You can provide your Gitea host and access token either as command-line arguments or environment variables.
> Command-line arguments have the highest priority > Command-line arguments have the highest priority
> [!NOTE]
> Many tools support `page` and `perPage` parameters for pagination. The maximum effective page size is determined by the Gitea server's `[api].MAX_RESPONSE_ITEMS` setting (default: **50**). Requesting a `perPage` value higher than this limit will be silently capped by the server.
Once everything is set up, try typing the following in your MCP-compatible chatbox: Once everything is set up, try typing the following in your MCP-compatible chatbox:
```text ```text
+3
View File
@@ -166,6 +166,9 @@ cp gitea-mcp /usr/local/bin/
> 可通过命令行参数或环境变量提供 Gitea 主机和访问令牌。 > 可通过命令行参数或环境变量提供 Gitea 主机和访问令牌。
> 命令行参数优先。 > 命令行参数优先。
> [!注意]
> 许多工具支持 `page` 和 `perPage` 分页参数。最大有效页面大小由 Gitea 服务器的 `[api].MAX_RESPONSE_ITEMS` 设置决定(默认值:**50**)。请求超过此限制的 `perPage` 值将被服务器静默截断。
一切设置完成后,可在 MCP 聊天框输入: 一切设置完成后,可在 MCP 聊天框输入:
```text ```text
+3
View File
@@ -166,6 +166,9 @@ cp gitea-mcp /usr/local/bin/
> 可用命令列參數或環境變數提供 Gitea 主機與存取令牌。 > 可用命令列參數或環境變數提供 Gitea 主機與存取令牌。
> 命令列參數優先。 > 命令列參數優先。
> [!注意]
> 許多工具支援 `page` 和 `perPage` 分頁參數。最大有效頁面大小由 Gitea 伺服器的 `[api].MAX_RESPONSE_ITEMS` 設定決定(預設值:**50**)。請求超過此限制的 `perPage` 值將被伺服器靜默截斷。
一切設定完成後,可在 MCP 聊天框輸入: 一切設定完成後,可在 MCP 聊天框輸入:
```text ```text
+28
View File
@@ -5,6 +5,7 @@ import (
"flag" "flag"
"fmt" "fmt"
"os" "os"
"strings"
"text/tabwriter" "text/tabwriter"
"gitea.com/gitea/gitea-mcp/operation" "gitea.com/gitea/gitea-mcp/operation"
@@ -16,6 +17,7 @@ var (
host string host string
port int port int
token string token string
tools string
version bool version bool
) )
@@ -30,6 +32,9 @@ func init() {
flag.StringVar(&token, "token", "", "") flag.StringVar(&token, "token", "", "")
flag.BoolVar(&flagPkg.ReadOnly, "r", false, "") flag.BoolVar(&flagPkg.ReadOnly, "r", false, "")
flag.BoolVar(&flagPkg.ReadOnly, "read-only", false, "") flag.BoolVar(&flagPkg.ReadOnly, "read-only", false, "")
defaultTools := os.Getenv("GITEA_TOOLS")
flag.StringVar(&tools, "O", defaultTools, "")
flag.StringVar(&tools, "tools", defaultTools, "")
flag.BoolVar(&flagPkg.Debug, "d", false, "") flag.BoolVar(&flagPkg.Debug, "d", false, "")
flag.BoolVar(&flagPkg.Debug, "debug", false, "") flag.BoolVar(&flagPkg.Debug, "debug", false, "")
flag.BoolVar(&flagPkg.Insecure, "k", false, "") flag.BoolVar(&flagPkg.Insecure, "k", false, "")
@@ -47,16 +52,19 @@ func init() {
fmt.Fprintf(w, " -p, -port <number>\tHTTP server port (default: 8080)\n") fmt.Fprintf(w, " -p, -port <number>\tHTTP server port (default: 8080)\n")
fmt.Fprintf(w, " -T, -token <token>\tPersonal access token\n") fmt.Fprintf(w, " -T, -token <token>\tPersonal access token\n")
fmt.Fprintf(w, " -r, -read-only\tExpose only read-only tools\n") fmt.Fprintf(w, " -r, -read-only\tExpose only read-only tools\n")
fmt.Fprintf(w, " -O, -tools <names>\tComma-separated list of tool names to expose\n")
fmt.Fprintf(w, " -d, -debug\tEnable debug mode\n") fmt.Fprintf(w, " -d, -debug\tEnable debug mode\n")
fmt.Fprintf(w, " -k, -insecure\tIgnore TLS certificate errors\n") fmt.Fprintf(w, " -k, -insecure\tIgnore TLS certificate errors\n")
fmt.Fprintf(w, " -v, -version\tPrint version and exit\n") fmt.Fprintf(w, " -v, -version\tPrint version and exit\n")
fmt.Fprintln(w) fmt.Fprintln(w)
fmt.Fprintln(w, "Environment variables:") fmt.Fprintln(w, "Environment variables:")
fmt.Fprintf(w, " GITEA_ACCESS_TOKEN\tProvide access token\n") fmt.Fprintf(w, " GITEA_ACCESS_TOKEN\tProvide access token\n")
fmt.Fprintf(w, " GITEA_ACCESS_TOKEN_FILE\tPath to a file containing the access token (e.g. a Docker secret)\n")
fmt.Fprintf(w, " GITEA_DEBUG\tSet to 'true' for debug mode\n") fmt.Fprintf(w, " GITEA_DEBUG\tSet to 'true' for debug mode\n")
fmt.Fprintf(w, " GITEA_HOST\tOverride Gitea host URL\n") fmt.Fprintf(w, " GITEA_HOST\tOverride Gitea host URL\n")
fmt.Fprintf(w, " GITEA_INSECURE\tSet to 'true' to ignore TLS errors\n") fmt.Fprintf(w, " GITEA_INSECURE\tSet to 'true' to ignore TLS errors\n")
fmt.Fprintf(w, " GITEA_READONLY\tSet to 'true' for read-only mode\n") fmt.Fprintf(w, " GITEA_READONLY\tSet to 'true' for read-only mode\n")
fmt.Fprintf(w, " GITEA_TOOLS\tComma-separated list of tool names to expose\n")
fmt.Fprintf(w, " MCP_MODE\tOverride transport mode\n") fmt.Fprintf(w, " MCP_MODE\tOverride transport mode\n")
w.Flush() w.Flush()
} }
@@ -74,6 +82,16 @@ func init() {
if flagPkg.Token == "" { if flagPkg.Token == "" {
flagPkg.Token = os.Getenv("GITEA_ACCESS_TOKEN") flagPkg.Token = os.Getenv("GITEA_ACCESS_TOKEN")
} }
if flagPkg.Token == "" {
if tokenFile := os.Getenv("GITEA_ACCESS_TOKEN_FILE"); tokenFile != "" {
data, err := os.ReadFile(tokenFile)
if err != nil {
fmt.Fprintf(os.Stderr, "error reading GITEA_ACCESS_TOKEN_FILE: %v\n", err)
os.Exit(1)
}
flagPkg.Token = strings.TrimRight(string(data), "\r\n")
}
}
if os.Getenv("MCP_MODE") != "" { if os.Getenv("MCP_MODE") != "" {
flagPkg.Mode = os.Getenv("MCP_MODE") flagPkg.Mode = os.Getenv("MCP_MODE")
@@ -83,6 +101,16 @@ func init() {
flagPkg.ReadOnly = true flagPkg.ReadOnly = true
} }
allowed := map[string]struct{}{}
for t := range strings.SplitSeq(tools, ",") {
if t = strings.TrimSpace(t); t != "" {
allowed[t] = struct{}{}
}
}
if len(allowed) > 0 {
flagPkg.AllowedTools = allowed
}
if os.Getenv("GITEA_DEBUG") == "true" { if os.Getenv("GITEA_DEBUG") == "true" {
flagPkg.Debug = true flagPkg.Debug = true
} }
+1 -1
View File
@@ -4,7 +4,7 @@ go 1.26.0
require ( require (
code.gitea.io/sdk/gitea v0.23.2 code.gitea.io/sdk/gitea v0.23.2
github.com/mark3labs/mcp-go v0.44.0 github.com/mark3labs/mcp-go v0.45.0
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
) )
+2 -2
View File
@@ -28,8 +28,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mark3labs/mcp-go v0.44.0 h1:OlYfcVviAnwNN40QZUrrzU0QZjq3En7rCU5X09a/B7I= github.com/mark3labs/mcp-go v0.45.0 h1:s0S8qR/9fWaQ3pHxz7pm1uQ0DrswoSnRIxKIjbiQtkc=
github.com/mark3labs/mcp-go v0.44.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= github.com/mark3labs/mcp-go v0.45.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
+104 -123
View File
@@ -2,14 +2,13 @@ package actions
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"net/url" "net/url"
"strconv" "strconv"
"time" "time"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params" "gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
@@ -47,27 +46,29 @@ func toSecretMetas(secrets []*gitea_sdk.Secret) []secretMeta {
var ( var (
ActionsConfigReadTool = mcp.NewTool( ActionsConfigReadTool = mcp.NewTool(
ActionsConfigReadToolName, ActionsConfigReadToolName,
mcp.WithDescription("Read Actions secrets and variables configuration."), mcp.WithDescription("Read Actions secrets and variables."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list_repo_secrets", "list_org_secrets", "list_repo_variables", "get_repo_variable", "list_org_variables", "get_org_variable")), mcp.WithToolAnnotation(annotation.ReadOnly("Read Actions secrets and variables")),
mcp.WithString("owner", mcp.Description("repository owner (required for repo methods)")), mcp.WithString("method", mcp.Required(), mcp.Enum("list_repo_secrets", "list_org_secrets", "list_repo_variables", "get_repo_variable", "list_org_variables", "get_org_variable")),
mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")), mcp.WithString("owner", mcp.Description("for repo methods")),
mcp.WithString("org", mcp.Description("organization name (required for org methods)")), mcp.WithString("repo", mcp.Description("for repo methods")),
mcp.WithString("name", mcp.Description("variable name (required for get methods)")), mcp.WithString("org", mcp.Description("for org methods")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)), mcp.WithString("name", mcp.Description("for get methods")),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)), mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30), mcp.Min(1)),
) )
ActionsConfigWriteTool = mcp.NewTool( ActionsConfigWriteTool = mcp.NewTool(
ActionsConfigWriteToolName, ActionsConfigWriteToolName,
mcp.WithDescription("Manage Actions secrets and variables: create, update, or delete."), mcp.WithDescription("Write Actions secrets and variables: upsert, create, update, delete."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("upsert_repo_secret", "delete_repo_secret", "upsert_org_secret", "delete_org_secret", "create_repo_variable", "update_repo_variable", "delete_repo_variable", "create_org_variable", "update_org_variable", "delete_org_variable")), mcp.WithToolAnnotation(annotation.Destructive("Manage Actions secrets and variables")),
mcp.WithString("owner", mcp.Description("repository owner (required for repo methods)")), mcp.WithString("method", mcp.Required(), mcp.Enum("upsert_repo_secret", "delete_repo_secret", "upsert_org_secret", "delete_org_secret", "create_repo_variable", "update_repo_variable", "delete_repo_variable", "create_org_variable", "update_org_variable", "delete_org_variable")),
mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")), mcp.WithString("owner", mcp.Description("for repo methods")),
mcp.WithString("org", mcp.Description("organization name (required for org methods)")), mcp.WithString("repo", mcp.Description("for repo methods")),
mcp.WithString("name", mcp.Description("secret or variable name (required for most methods)")), mcp.WithString("org", mcp.Description("for org methods")),
mcp.WithString("data", mcp.Description("secret value (required for upsert secret methods)")), mcp.WithString("name", mcp.Description("secret or variable name")),
mcp.WithString("value", mcp.Description("variable value (required for create/update variable methods)")), mcp.WithString("data", mcp.Description("secret value (upsert)")),
mcp.WithString("description", mcp.Description("description for secret or variable")), mcp.WithString("value", mcp.Description("variable value")),
mcp.WithString("description"),
) )
) )
@@ -130,17 +131,14 @@ func configWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR
} }
} }
// Secret functions
func listRepoActionSecretsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listRepoActionSecretsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionSecretsFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" { if err != nil {
return to.ErrorResult(errors.New("owner is required")) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" { if err != nil {
return to.ErrorResult(errors.New("repo is required")) return to.ErrorResult(err)
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, pageSize := params.GetPagination(req.GetArguments(), 30)
@@ -160,22 +158,21 @@ func listRepoActionSecretsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp
} }
func upsertRepoActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func upsertRepoActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called upsertRepoActionSecretFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" { if err != nil {
return to.ErrorResult(errors.New("owner is required")) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" { if err != nil {
return to.ErrorResult(errors.New("repo is required")) return to.ErrorResult(err)
} }
name, err := params.GetString(req.GetArguments(), "name") name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" { if err != nil {
return to.ErrorResult(errors.New("name is required")) return to.ErrorResult(err)
} }
data, err := params.GetString(req.GetArguments(), "data") data, err := params.GetString(req.GetArguments(), "data")
if err != nil || data == "" { if err != nil {
return to.ErrorResult(errors.New("data is required")) return to.ErrorResult(err)
} }
description, _ := req.GetArguments()["description"].(string) description, _ := req.GetArguments()["description"].(string)
@@ -195,18 +192,17 @@ func upsertRepoActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mc
} }
func deleteRepoActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func deleteRepoActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteRepoActionSecretFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" { if err != nil {
return to.ErrorResult(errors.New("owner is required")) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" { if err != nil {
return to.ErrorResult(errors.New("repo is required")) return to.ErrorResult(err)
} }
name, err := params.GetString(req.GetArguments(), "name") name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" { if err != nil {
return to.ErrorResult(errors.New("name is required")) return to.ErrorResult(err)
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -221,10 +217,9 @@ func deleteRepoActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mc
} }
func listOrgActionSecretsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listOrgActionSecretsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listOrgActionSecretsFn")
org, err := params.GetString(req.GetArguments(), "org") org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" { if err != nil {
return to.ErrorResult(errors.New("org is required")) return to.ErrorResult(err)
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, pageSize := params.GetPagination(req.GetArguments(), 30)
@@ -244,18 +239,17 @@ func listOrgActionSecretsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.
} }
func upsertOrgActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func upsertOrgActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called upsertOrgActionSecretFn")
org, err := params.GetString(req.GetArguments(), "org") org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" { if err != nil {
return to.ErrorResult(errors.New("org is required")) return to.ErrorResult(err)
} }
name, err := params.GetString(req.GetArguments(), "name") name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" { if err != nil {
return to.ErrorResult(errors.New("name is required")) return to.ErrorResult(err)
} }
data, err := params.GetString(req.GetArguments(), "data") data, err := params.GetString(req.GetArguments(), "data")
if err != nil || data == "" { if err != nil {
return to.ErrorResult(errors.New("data is required")) return to.ErrorResult(err)
} }
description, _ := req.GetArguments()["description"].(string) description, _ := req.GetArguments()["description"].(string)
@@ -275,14 +269,13 @@ func upsertOrgActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp
} }
func deleteOrgActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func deleteOrgActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteOrgActionSecretFn")
org, err := params.GetString(req.GetArguments(), "org") org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" { if err != nil {
return to.ErrorResult(errors.New("org is required")) return to.ErrorResult(err)
} }
name, err := params.GetString(req.GetArguments(), "name") name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" { if err != nil {
return to.ErrorResult(errors.New("name is required")) return to.ErrorResult(err)
} }
escapedOrg := url.PathEscape(org) escapedOrg := url.PathEscape(org)
@@ -294,17 +287,14 @@ func deleteOrgActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp
return to.TextResult(map[string]any{"message": "secret deleted"}) return to.TextResult(map[string]any{"message": "secret deleted"})
} }
// Variable functions
func listRepoActionVariablesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listRepoActionVariablesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionVariablesFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" { if err != nil {
return to.ErrorResult(errors.New("owner is required")) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" { if err != nil {
return to.ErrorResult(errors.New("repo is required")) return to.ErrorResult(err)
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, pageSize := params.GetPagination(req.GetArguments(), 30)
@@ -321,18 +311,17 @@ func listRepoActionVariablesFn(ctx context.Context, req mcp.CallToolRequest) (*m
} }
func getRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getRepoActionVariableFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" { if err != nil {
return to.ErrorResult(errors.New("owner is required")) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" { if err != nil {
return to.ErrorResult(errors.New("repo is required")) return to.ErrorResult(err)
} }
name, err := params.GetString(req.GetArguments(), "name") name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" { if err != nil {
return to.ErrorResult(errors.New("name is required")) return to.ErrorResult(err)
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -347,22 +336,21 @@ func getRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp
} }
func createRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func createRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createRepoActionVariableFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" { if err != nil {
return to.ErrorResult(errors.New("owner is required")) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" { if err != nil {
return to.ErrorResult(errors.New("repo is required")) return to.ErrorResult(err)
} }
name, err := params.GetString(req.GetArguments(), "name") name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" { if err != nil {
return to.ErrorResult(errors.New("name is required")) return to.ErrorResult(err)
} }
value, err := params.GetString(req.GetArguments(), "value") value, err := params.GetString(req.GetArguments(), "value")
if err != nil || value == "" { if err != nil {
return to.ErrorResult(errors.New("value is required")) return to.ErrorResult(err)
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -377,22 +365,21 @@ func createRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*
} }
func updateRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func updateRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called updateRepoActionVariableFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" { if err != nil {
return to.ErrorResult(errors.New("owner is required")) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" { if err != nil {
return to.ErrorResult(errors.New("repo is required")) return to.ErrorResult(err)
} }
name, err := params.GetString(req.GetArguments(), "name") name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" { if err != nil {
return to.ErrorResult(errors.New("name is required")) return to.ErrorResult(err)
} }
value, err := params.GetString(req.GetArguments(), "value") value, err := params.GetString(req.GetArguments(), "value")
if err != nil || value == "" { if err != nil {
return to.ErrorResult(errors.New("value is required")) return to.ErrorResult(err)
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -407,18 +394,17 @@ func updateRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*
} }
func deleteRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func deleteRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteRepoActionVariableFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" { if err != nil {
return to.ErrorResult(errors.New("owner is required")) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" { if err != nil {
return to.ErrorResult(errors.New("repo is required")) return to.ErrorResult(err)
} }
name, err := params.GetString(req.GetArguments(), "name") name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" { if err != nil {
return to.ErrorResult(errors.New("name is required")) return to.ErrorResult(err)
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -433,10 +419,9 @@ func deleteRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*
} }
func listOrgActionVariablesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listOrgActionVariablesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listOrgActionVariablesFn")
org, err := params.GetString(req.GetArguments(), "org") org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" { if err != nil {
return to.ErrorResult(errors.New("org is required")) return to.ErrorResult(err)
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, pageSize := params.GetPagination(req.GetArguments(), 30)
@@ -454,14 +439,13 @@ func listOrgActionVariablesFn(ctx context.Context, req mcp.CallToolRequest) (*mc
} }
func getOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getOrgActionVariableFn")
org, err := params.GetString(req.GetArguments(), "org") org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" { if err != nil {
return to.ErrorResult(errors.New("org is required")) return to.ErrorResult(err)
} }
name, err := params.GetString(req.GetArguments(), "name") name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" { if err != nil {
return to.ErrorResult(errors.New("name is required")) return to.ErrorResult(err)
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -476,18 +460,17 @@ func getOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.
} }
func createOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func createOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createOrgActionVariableFn")
org, err := params.GetString(req.GetArguments(), "org") org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" { if err != nil {
return to.ErrorResult(errors.New("org is required")) return to.ErrorResult(err)
} }
name, err := params.GetString(req.GetArguments(), "name") name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" { if err != nil {
return to.ErrorResult(errors.New("name is required")) return to.ErrorResult(err)
} }
value, err := params.GetString(req.GetArguments(), "value") value, err := params.GetString(req.GetArguments(), "value")
if err != nil || value == "" { if err != nil {
return to.ErrorResult(errors.New("value is required")) return to.ErrorResult(err)
} }
description, _ := req.GetArguments()["description"].(string) description, _ := req.GetArguments()["description"].(string)
@@ -507,18 +490,17 @@ func createOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*m
} }
func updateOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func updateOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called updateOrgActionVariableFn")
org, err := params.GetString(req.GetArguments(), "org") org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" { if err != nil {
return to.ErrorResult(errors.New("org is required")) return to.ErrorResult(err)
} }
name, err := params.GetString(req.GetArguments(), "name") name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" { if err != nil {
return to.ErrorResult(errors.New("name is required")) return to.ErrorResult(err)
} }
value, err := params.GetString(req.GetArguments(), "value") value, err := params.GetString(req.GetArguments(), "value")
if err != nil || value == "" { if err != nil {
return to.ErrorResult(errors.New("value is required")) return to.ErrorResult(err)
} }
description, _ := req.GetArguments()["description"].(string) description, _ := req.GetArguments()["description"].(string)
@@ -537,14 +519,13 @@ func updateOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*m
} }
func deleteOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func deleteOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteOrgActionVariableFn")
org, err := params.GetString(req.GetArguments(), "org") org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" { if err != nil {
return to.ErrorResult(errors.New("org is required")) return to.ErrorResult(err)
} }
name, err := params.GetString(req.GetArguments(), "name") name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" { if err != nil {
return to.ErrorResult(errors.New("name is required")) return to.ErrorResult(err)
} }
_, err = gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("orgs/%s/actions/variables/%s", url.PathEscape(org), url.PathEscape(name)), nil, nil, nil) _, err = gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("orgs/%s/actions/variables/%s", url.PathEscape(org), url.PathEscape(name)), nil, nil, nil)
+66 -77
View File
@@ -10,8 +10,8 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params" "gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
@@ -27,31 +27,33 @@ const (
var ( var (
ActionsRunReadTool = mcp.NewTool( ActionsRunReadTool = mcp.NewTool(
ActionsRunReadToolName, ActionsRunReadToolName,
mcp.WithDescription("Read Actions workflow, run, and job data. Use method 'list_workflows'/'get_workflow' for workflows, 'list_runs'/'get_run' for runs, 'list_jobs'/'list_run_jobs' for jobs, 'get_job_log_preview'/'download_job_log' for logs."), mcp.WithDescription("Read Actions workflows, runs, jobs, and logs."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list_workflows", "get_workflow", "list_runs", "get_run", "list_jobs", "list_run_jobs", "get_job_log_preview", "download_job_log")), mcp.WithToolAnnotation(annotation.ReadOnly("Read Actions workflow, run, and job data")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("method", mcp.Required(), mcp.Enum("list_workflows", "get_workflow", "list_runs", "get_run", "list_jobs", "list_run_jobs", "get_job_log_preview", "download_job_log")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("workflow_id", mcp.Description("workflow ID or filename (required for 'get_workflow')")), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("run_id", mcp.Description("run ID (required for 'get_run', 'list_run_jobs')")), mcp.WithString("workflow_id", mcp.Description("ID or filename (for 'get_workflow')")),
mcp.WithNumber("job_id", mcp.Description("job ID (required for 'get_job_log_preview', 'download_job_log')")), mcp.WithNumber("run_id", mcp.Description("for 'get_run'/'list_run_jobs'")),
mcp.WithString("status", mcp.Description("optional status filter (for 'list_runs', 'list_jobs')")), mcp.WithNumber("job_id", mcp.Description("for log methods")),
mcp.WithNumber("tail_lines", mcp.Description("number of lines from end of log (for 'get_job_log_preview')"), mcp.DefaultNumber(200), mcp.Min(1)), mcp.WithString("status", mcp.Description("filter for 'list_runs'/'list_jobs'")),
mcp.WithNumber("max_bytes", mcp.Description("max bytes to return (for 'get_job_log_preview')"), mcp.DefaultNumber(65536), mcp.Min(1024)), mcp.WithNumber("tail_lines", mcp.Description("log tail lines"), mcp.DefaultNumber(200), mcp.Min(1)),
mcp.WithString("output_path", mcp.Description("output file path (for 'download_job_log')")), mcp.WithNumber("max_bytes", mcp.Description("max log bytes"), mcp.DefaultNumber(65536), mcp.Min(1024)),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)), mcp.WithString("output_path", mcp.Description("for 'download_job_log'")),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)), mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30), mcp.Min(1)),
) )
ActionsRunWriteTool = mcp.NewTool( ActionsRunWriteTool = mcp.NewTool(
ActionsRunWriteToolName, ActionsRunWriteToolName,
mcp.WithDescription("Trigger, cancel, or rerun Actions workflows."), mcp.WithDescription("Write Actions runs: dispatch, cancel, rerun."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("dispatch_workflow", "cancel_run", "rerun_run")), mcp.WithToolAnnotation(annotation.Write("Trigger, cancel, or rerun Actions workflows")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("method", mcp.Required(), mcp.Enum("dispatch_workflow", "cancel_run", "rerun_run")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("workflow_id", mcp.Description("workflow ID or filename (required for 'dispatch_workflow')")), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("ref", mcp.Description("git ref branch or tag (required for 'dispatch_workflow')")), mcp.WithString("workflow_id", mcp.Description("ID or filename (for 'dispatch_workflow')")),
mcp.WithObject("inputs", mcp.Description("workflow inputs object (for 'dispatch_workflow')")), mcp.WithString("ref", mcp.Description("branch or tag (for 'dispatch_workflow')")),
mcp.WithNumber("run_id", mcp.Description("run ID (required for 'cancel_run', 'rerun_run')")), mcp.WithObject("inputs", mcp.Description("for 'dispatch_workflow'")),
mcp.WithNumber("run_id", mcp.Description("for 'cancel_run'/'rerun_run'")),
) )
) )
@@ -122,14 +124,13 @@ func doJSONWithFallback(ctx context.Context, method string, paths []string, quer
} }
func listRepoActionWorkflowsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listRepoActionWorkflowsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionWorkflowsFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" { if err != nil {
return to.ErrorResult(errors.New("owner is required")) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" { if err != nil {
return to.ErrorResult(errors.New("repo is required")) return to.ErrorResult(err)
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, pageSize := params.GetPagination(req.GetArguments(), 30)
query := url.Values{} query := url.Values{}
@@ -150,18 +151,17 @@ func listRepoActionWorkflowsFn(ctx context.Context, req mcp.CallToolRequest) (*m
} }
func getRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getRepoActionWorkflowFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" { if err != nil {
return to.ErrorResult(errors.New("owner is required")) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" { if err != nil {
return to.ErrorResult(errors.New("repo is required")) return to.ErrorResult(err)
} }
workflowID, err := params.GetString(req.GetArguments(), "workflow_id") workflowID, err := params.GetString(req.GetArguments(), "workflow_id")
if err != nil || workflowID == "" { if err != nil {
return to.ErrorResult(errors.New("workflow_id is required")) return to.ErrorResult(err)
} }
var result any var result any
@@ -178,22 +178,21 @@ func getRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest) (*mcp
} }
func dispatchRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func dispatchRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called dispatchRepoActionWorkflowFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" { if err != nil {
return to.ErrorResult(errors.New("owner is required")) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" { if err != nil {
return to.ErrorResult(errors.New("repo is required")) return to.ErrorResult(err)
} }
workflowID, err := params.GetString(req.GetArguments(), "workflow_id") workflowID, err := params.GetString(req.GetArguments(), "workflow_id")
if err != nil || workflowID == "" { if err != nil {
return to.ErrorResult(errors.New("workflow_id is required")) return to.ErrorResult(err)
} }
ref, err := params.GetString(req.GetArguments(), "ref") ref, err := params.GetString(req.GetArguments(), "ref")
if err != nil || ref == "" { if err != nil {
return to.ErrorResult(errors.New("ref is required")) return to.ErrorResult(err)
} }
var inputs map[string]any var inputs map[string]any
@@ -228,14 +227,13 @@ func dispatchRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest)
} }
func listRepoActionRunsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listRepoActionRunsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionRunsFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" { if err != nil {
return to.ErrorResult(errors.New("owner is required")) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" { if err != nil {
return to.ErrorResult(errors.New("repo is required")) return to.ErrorResult(err)
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, pageSize := params.GetPagination(req.GetArguments(), 30)
statusFilter, _ := req.GetArguments()["status"].(string) statusFilter, _ := req.GetArguments()["status"].(string)
@@ -261,14 +259,13 @@ func listRepoActionRunsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
} }
func getRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getRepoActionRunFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" { if err != nil {
return to.ErrorResult(errors.New("owner is required")) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" { if err != nil {
return to.ErrorResult(errors.New("repo is required")) return to.ErrorResult(err)
} }
runID, err := params.GetIndex(req.GetArguments(), "run_id") runID, err := params.GetIndex(req.GetArguments(), "run_id")
if err != nil || runID <= 0 { if err != nil || runID <= 0 {
@@ -289,14 +286,13 @@ func getRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
} }
func cancelRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func cancelRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called cancelRepoActionRunFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" { if err != nil {
return to.ErrorResult(errors.New("owner is required")) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" { if err != nil {
return to.ErrorResult(errors.New("repo is required")) return to.ErrorResult(err)
} }
runID, err := params.GetIndex(req.GetArguments(), "run_id") runID, err := params.GetIndex(req.GetArguments(), "run_id")
if err != nil || runID <= 0 { if err != nil || runID <= 0 {
@@ -316,14 +312,13 @@ func cancelRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.C
} }
func rerunRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func rerunRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called rerunRepoActionRunFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" { if err != nil {
return to.ErrorResult(errors.New("owner is required")) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" { if err != nil {
return to.ErrorResult(errors.New("repo is required")) return to.ErrorResult(err)
} }
runID, err := params.GetIndex(req.GetArguments(), "run_id") runID, err := params.GetIndex(req.GetArguments(), "run_id")
if err != nil || runID <= 0 { if err != nil || runID <= 0 {
@@ -348,14 +343,13 @@ func rerunRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
} }
func listRepoActionJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listRepoActionJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionJobsFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" { if err != nil {
return to.ErrorResult(errors.New("owner is required")) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" { if err != nil {
return to.ErrorResult(errors.New("repo is required")) return to.ErrorResult(err)
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, pageSize := params.GetPagination(req.GetArguments(), 30)
statusFilter, _ := req.GetArguments()["status"].(string) statusFilter, _ := req.GetArguments()["status"].(string)
@@ -381,14 +375,13 @@ func listRepoActionJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
} }
func listRepoActionRunJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listRepoActionRunJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionRunJobsFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" { if err != nil {
return to.ErrorResult(errors.New("owner is required")) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" { if err != nil {
return to.ErrorResult(errors.New("repo is required")) return to.ErrorResult(err)
} }
runID, err := params.GetIndex(req.GetArguments(), "run_id") runID, err := params.GetIndex(req.GetArguments(), "run_id")
if err != nil || runID <= 0 { if err != nil || runID <= 0 {
@@ -413,8 +406,6 @@ func listRepoActionRunJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp
return to.TextResult(slimActionJobs(result)) return to.TextResult(slimActionJobs(result))
} }
// Log functions (merged from logs.go)
func logPaths(owner, repo string, jobID int64) []string { func logPaths(owner, repo string, jobID int64) []string {
return []string{ return []string{
fmt.Sprintf("repos/%s/%s/actions/jobs/%d/logs", url.PathEscape(owner), url.PathEscape(repo), jobID), fmt.Sprintf("repos/%s/%s/actions/jobs/%d/logs", url.PathEscape(owner), url.PathEscape(repo), jobID),
@@ -470,7 +461,6 @@ func limitBytes(data []byte, maxBytes int) ([]byte, bool) {
} }
func getRepoActionJobLogPreviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getRepoActionJobLogPreviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getRepoActionJobLogPreviewFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -505,7 +495,6 @@ func getRepoActionJobLogPreviewFn(ctx context.Context, req mcp.CallToolRequest)
} }
func downloadRepoActionJobLogFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func downloadRepoActionJobLogFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called downloadRepoActionJobLogFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
+105 -83
View File
@@ -3,10 +3,12 @@ package issue
import ( import (
"context" "context"
"fmt" "fmt"
"net/url"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params" "gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/slim"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
@@ -15,6 +17,18 @@ import (
"github.com/mark3labs/mcp-go/server" "github.com/mark3labs/mcp-go/server"
) )
// issueWithAssets / commentWithAssets wrap the SDK types to capture the
// `assets` field that the SDK currently drops on these endpoints.
type issueWithAssets struct {
gitea_sdk.Issue
Assets []*gitea_sdk.Attachment `json:"assets"`
}
type commentWithAssets struct {
gitea_sdk.Comment
Assets []*gitea_sdk.Attachment `json:"assets"`
}
var Tool = tool.New() var Tool = tool.New()
const ( const (
@@ -26,38 +40,46 @@ const (
var ( var (
ListRepoIssuesTool = mcp.NewTool( ListRepoIssuesTool = mcp.NewTool(
ListRepoIssuesToolName, ListRepoIssuesToolName,
mcp.WithDescription("List repository issues"), mcp.WithToolAnnotation(annotation.ReadOnly("List repository issues")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("state", mcp.Description("issue state"), mcp.DefaultString("all")), mcp.WithString("state", mcp.DefaultString("all")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)), mcp.WithArray("labels", mcp.Description("label name filter"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)), 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( IssueReadTool = mcp.NewTool(
IssueReadToolName, IssueReadToolName,
mcp.WithDescription("Get information about a specific issue. Use method 'get' for issue details, 'get_comments' for issue comments, 'get_labels' for issue labels."), mcp.WithDescription("Read issue: details, comments, or labels."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "get_comments", "get_labels")), mcp.WithToolAnnotation(annotation.ReadOnly("Read issue details")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("method", mcp.Required(), mcp.Enum("get", "get_comments", "get_labels")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithNumber("index", mcp.Required(), mcp.Description("repository issue index")), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("issue_number", mcp.Required()),
) )
IssueWriteTool = mcp.NewTool( IssueWriteTool = mcp.NewTool(
IssueWriteToolName, IssueWriteToolName,
mcp.WithDescription("Create or update issues and comments, manage labels. Use method 'create' to create an issue, 'update' to edit, 'add_comment'/'edit_comment' for comments, 'add_labels'/'remove_label'/'replace_labels'/'clear_labels' for label management."), mcp.WithDescription("Write issues: create, update, manage comments and labels."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "update", "add_comment", "edit_comment", "add_labels", "remove_label", "replace_labels", "clear_labels")), mcp.WithToolAnnotation(annotation.Write("Create or update issues, comments, and labels")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("method", mcp.Required(), mcp.Enum("create", "update", "add_comment", "edit_comment", "add_labels", "remove_label", "replace_labels", "clear_labels")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithNumber("index", mcp.Description("issue index (required for all methods except 'create')")), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("title", mcp.Description("issue title (required for 'create')")), mcp.WithNumber("issue_number", mcp.Description("required except for 'create'")),
mcp.WithString("body", mcp.Description("issue/comment body (required for 'create', 'add_comment', 'edit_comment')")), mcp.WithString("title", mcp.Description("required for 'create'")),
mcp.WithArray("assignees", mcp.Description("usernames to assign (for 'create', 'update')"), mcp.Items(map[string]any{"type": "string"})), mcp.WithString("body", mcp.Description("required for 'create'/'add_comment'/'edit_comment'")),
mcp.WithNumber("milestone", mcp.Description("milestone number (for 'create', 'update')")), mcp.WithArray("assignees", mcp.Items(map[string]any{"type": "string"})),
mcp.WithString("state", mcp.Description("issue state, one of open, closed, all (for 'update')")), mcp.WithNumber("milestone"),
mcp.WithNumber("commentID", mcp.Description("id of issue comment (required for 'edit_comment')")), mcp.WithString("state", mcp.Enum("open", "closed", "all")),
mcp.WithArray("labels", mcp.Description("array of label IDs (for 'add_labels', 'replace_labels')"), mcp.Items(map[string]any{"type": "number"})), mcp.WithNumber("commentID", mcp.Description("for 'edit_comment'")),
mcp.WithNumber("label_id", mcp.Description("label ID to remove (required for 'remove_label')")), mcp.WithArray("labels", mcp.Description("label IDs"), mcp.Items(map[string]any{"type": "number"})),
mcp.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"),
) )
) )
@@ -123,7 +145,6 @@ func issueWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRe
} }
func getIssueByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getIssueByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getIssueByIndexFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -132,24 +153,21 @@ func getIssueByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "index") index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
client, err := gitea.ClientFromContext(ctx) var issue issueWithAssets
if err != nil { path := fmt.Sprintf("repos/%s/%s/issues/%d", url.PathEscape(owner), url.PathEscape(repo), index)
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) if _, err := gitea.DoJSON(ctx, "GET", path, nil, nil, &issue); err != nil {
}
issue, _, err := client.GetIssue(owner, repo, index)
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/issue/%v err: %v", owner, repo, index, err)) return to.ErrorResult(fmt.Errorf("get %v/%v/issue/%v err: %v", owner, repo, index, err))
} }
m := slimIssue(&issue.Issue)
return to.TextResult(slimIssue(issue)) m["body"] = slim.BodyWithAttachments(issue.Body, issue.Assets)
return to.TextResult(m)
} }
func listRepoIssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listRepoIssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListIssuesFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -162,14 +180,22 @@ func listRepoIssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if !ok { if !ok {
state = "all" state = "all"
} }
labels := params.GetStringSlice(req.GetArguments(), "labels")
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.ListIssueOption{ opt := gitea_sdk.ListIssueOption{
State: gitea_sdk.StateType(state), State: gitea_sdk.StateType(state),
Labels: labels,
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: page, Page: page,
PageSize: pageSize, PageSize: pageSize,
}, },
} }
if t := params.GetOptionalTime(req.GetArguments(), "since"); t != nil {
opt.Since = *t
}
if t := params.GetOptionalTime(req.GetArguments(), "before"); t != nil {
opt.Before = *t
}
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
@@ -182,7 +208,6 @@ func listRepoIssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
} }
func createIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func createIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createIssueFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -213,6 +238,13 @@ func createIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR
opt.Milestone = milestone opt.Milestone = milestone
} }
} }
if labelIDs, err := params.GetInt64Slice(req.GetArguments(), "labels"); err == nil {
opt.Labels = labelIDs
}
if ref, ok := req.GetArguments()["ref"].(string); ok {
opt.Ref = ref
}
opt.Deadline = params.GetOptionalTime(req.GetArguments(), "deadline")
issue, _, err := client.CreateIssue(owner, repo, opt) issue, _, err := client.CreateIssue(owner, repo, opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/%v/issue err: %v", owner, repo, err)) return to.ErrorResult(fmt.Errorf("create %v/%v/issue err: %v", owner, repo, err))
@@ -222,7 +254,6 @@ func createIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR
} }
func createIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func createIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createIssueCommentFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -231,7 +262,7 @@ func createIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "index") index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -255,7 +286,6 @@ func createIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
} }
func editIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func editIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editIssueFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -264,30 +294,30 @@ func editIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRes
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "index") index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
opt := gitea_sdk.EditIssueOption{} args := req.GetArguments()
opt := gitea_sdk.EditIssueOption{
title, ok := req.GetArguments()["title"].(string) Body: params.GetPresentStringPtr(args, "body"),
if ok { Ref: params.GetPresentStringPtr(args, "ref"),
Assignees: params.GetStringSlice(args, "assignees"),
Deadline: params.GetOptionalTime(args, "deadline"),
RemoveDeadline: params.GetOptionalBoolPtr(args, "remove_deadline"),
}
if title, ok := args["title"].(string); ok {
opt.Title = title opt.Title = title
} }
body, ok := req.GetArguments()["body"].(string) if val, exists := args["milestone"]; exists {
if ok {
opt.Body = new(body)
}
opt.Assignees = params.GetStringSlice(req.GetArguments(), "assignees")
if val, exists := req.GetArguments()["milestone"]; exists {
if milestone, ok := params.ToInt64(val); ok { if milestone, ok := params.ToInt64(val); ok {
opt.Milestone = new(milestone) opt.Milestone = &milestone
} }
} }
state, ok := req.GetArguments()["state"].(string) if state, ok := args["state"].(string); ok {
if ok { s := gitea_sdk.StateType(state)
opt.State = new(gitea_sdk.StateType(state)) opt.State = &s
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -303,7 +333,6 @@ func editIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRes
} }
func editIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func editIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editIssueCommentFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -336,7 +365,6 @@ func editIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
} }
func getIssueCommentsByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getIssueCommentsByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getIssueCommentsByIndexFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -345,25 +373,25 @@ func getIssueCommentsByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*m
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "index") index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
opt := gitea_sdk.ListIssueCommentOptions{} var comments []commentWithAssets
client, err := gitea.ClientFromContext(ctx) path := fmt.Sprintf("repos/%s/%s/issues/%d/comments", url.PathEscape(owner), url.PathEscape(repo), index)
if err != nil { if _, err := gitea.DoJSON(ctx, "GET", path, nil, nil, &comments); err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
issue, _, err := client.ListIssueComments(owner, repo, index, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/issues/%v/comments err: %v", owner, repo, index, err)) return to.ErrorResult(fmt.Errorf("get %v/%v/issues/%v/comments err: %v", owner, repo, index, err))
} }
out := make([]map[string]any, 0, len(comments))
return to.TextResult(slimComments(issue)) for i := range comments {
m := slimComment(&comments[i].Comment)
m["body"] = slim.BodyWithAttachments(comments[i].Body, comments[i].Assets)
out = append(out, m)
}
return to.TextResult(out)
} }
func getIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getIssueLabelsFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -372,7 +400,7 @@ func getIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "index") index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -385,13 +413,10 @@ func getIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/issues/%v/labels err: %v", owner, repo, index, err)) return to.ErrorResult(fmt.Errorf("get %v/%v/issues/%v/labels err: %v", owner, repo, index, err))
} }
return to.TextResult(slimLabels(labels)) return to.TextResult(slim.Labels(labels))
} }
// Issue label operations (moved from label package)
func addIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func addIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called addIssueLabelsFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -400,7 +425,7 @@ func addIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "index") index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -417,11 +442,10 @@ func addIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("add labels to %v/%v/issue/%v err: %v", owner, repo, index, err)) return to.ErrorResult(fmt.Errorf("add labels to %v/%v/issue/%v err: %v", owner, repo, index, err))
} }
return to.TextResult(slimLabels(issueLabels)) return to.TextResult(slim.Labels(issueLabels))
} }
func replaceIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func replaceIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called replaceIssueLabelsFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -430,7 +454,7 @@ func replaceIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "index") index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -447,11 +471,10 @@ func replaceIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("replace labels on %v/%v/issue/%v err: %v", owner, repo, index, err)) return to.ErrorResult(fmt.Errorf("replace labels on %v/%v/issue/%v err: %v", owner, repo, index, err))
} }
return to.TextResult(slimLabels(issueLabels)) return to.TextResult(slim.Labels(issueLabels))
} }
func clearIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func clearIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called clearIssueLabelsFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -460,7 +483,7 @@ func clearIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "index") index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -477,7 +500,6 @@ func clearIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
} }
func removeIssueLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func removeIssueLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called removeIssueLabelFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -486,7 +508,7 @@ func removeIssueLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "index") index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
+269
View File
@@ -0,0 +1,269 @@
package issue
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"github.com/mark3labs/mcp-go/mcp"
)
func Test_listRepoIssuesFn_filters(t *testing.T) {
const (
owner = "octo"
repo = "demo"
)
var (
mu sync.Mutex
gotQuery string
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case r.URL.Path == fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"private":false}`))
case r.URL.Path == fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo):
mu.Lock()
gotQuery = r.URL.RawQuery
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost := flag.Host
origToken := flag.Token
origVersion := flag.Version
flag.Host = server.URL
flag.Token = ""
flag.Version = "test"
defer func() {
flag.Host = origHost
flag.Token = origToken
flag.Version = origVersion
}()
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: map[string]any{
"owner": owner,
"repo": repo,
"labels": []any{"bug", "enhancement"},
"since": "2026-01-01T00:00:00Z",
},
},
}
_, err := listRepoIssuesFn(context.Background(), req)
if err != nil {
t.Fatalf("listRepoIssuesFn() error = %v", err)
}
mu.Lock()
defer mu.Unlock()
if !strings.Contains(gotQuery, "labels=bug%2Cenhancement") {
t.Fatalf("expected labels query param, got %s", gotQuery)
}
if !strings.Contains(gotQuery, "since=2026-01-01") {
t.Fatalf("expected since query param, got %s", gotQuery)
}
}
func Test_createIssueFn_labels(t *testing.T) {
const (
owner = "octo"
repo = "demo"
)
var (
mu sync.Mutex
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/issues", owner, repo):
mu.Lock()
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
gotBody = body
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":1,"title":"test","state":"open"}`))
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost := flag.Host
origToken := flag.Token
origVersion := flag.Version
flag.Host = server.URL
flag.Token = ""
flag.Version = "test"
defer func() {
flag.Host = origHost
flag.Token = origToken
flag.Version = origVersion
}()
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: map[string]any{
"owner": owner,
"repo": repo,
"title": "test issue",
"body": "body",
"labels": []any{float64(10), float64(20)},
"deadline": "2026-06-01T00:00:00Z",
},
},
}
_, err := createIssueFn(context.Background(), req)
if err != nil {
t.Fatalf("createIssueFn() error = %v", err)
}
mu.Lock()
defer mu.Unlock()
labels, ok := gotBody["labels"].([]any)
if !ok || len(labels) != 2 {
t.Fatalf("expected 2 labels, got %v", gotBody["labels"])
}
if labels[0] != float64(10) || labels[1] != float64(20) {
t.Fatalf("expected labels [10,20], got %v", labels)
}
if gotBody["due_date"] == nil {
t.Fatalf("expected due_date to be set")
}
}
func Test_getIssueByIndexFn_includesAttachments(t *testing.T) {
const (
owner = "octo"
repo = "demo"
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/issues/42", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"number": 42,
"title": "bug with screenshot",
"body": "see attached",
"state": "open",
"assets": [
{"id": 1, "name": "shot.png", "size": 1024, "browser_download_url": "https://example/shot.png"}
]
}`))
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version
flag.Host, flag.Token, flag.Version = server.URL, "", "test"
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{
"owner": owner, "repo": repo, "issue_number": float64(42),
}}}
res, err := getIssueByIndexFn(context.Background(), req)
if err != nil {
t.Fatalf("getIssueByIndexFn() error = %v", err)
}
if res.IsError {
t.Fatalf("unexpected error result: %v", res.Content)
}
body := res.Content[0].(mcp.TextContent).Text
if !strings.Contains(body, `[shot.png](https://example/shot.png)`) {
t.Fatalf("expected attachment markdown inlined in body, got: %s", body)
}
if strings.Contains(body, `"attachments"`) {
t.Fatalf("attachments should be inlined into body, not a separate field: %s", body)
}
}
func Test_getIssueCommentsByIndexFn_includesAttachments(t *testing.T) {
const (
owner = "octo"
repo = "demo"
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/issues/7/comments", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"id": 1, "body": "see this", "assets": [
{"id": 9, "name": "log.txt", "size": 200, "browser_download_url": "https://example/log.txt"}
]},
{"id": 2, "body": "no attachment", "assets": []}
]`))
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version
flag.Host, flag.Token, flag.Version = server.URL, "", "test"
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{
"owner": owner, "repo": repo, "issue_number": float64(7),
}}}
res, err := getIssueCommentsByIndexFn(context.Background(), req)
if err != nil {
t.Fatalf("getIssueCommentsByIndexFn() error = %v", err)
}
if res.IsError {
t.Fatalf("unexpected error result: %v", res.Content)
}
body := res.Content[0].(mcp.TextContent).Text
if !strings.Contains(body, `[log.txt](https://example/log.txt)`) {
t.Fatalf("expected attachment markdown inlined in body, got: %s", body)
}
if strings.Contains(body, `"attachments"`) {
t.Fatalf("attachments should be inlined into body, not a separate field: %s", body)
}
}
+20 -64
View File
@@ -1,42 +1,11 @@
package issue package issue
import ( import (
"gitea.com/gitea/gitea-mcp/pkg/slim"
gitea_sdk "code.gitea.io/sdk/gitea" gitea_sdk "code.gitea.io/sdk/gitea"
) )
func userLogin(u *gitea_sdk.User) string {
if u == nil {
return ""
}
return u.UserName
}
func userLogins(users []*gitea_sdk.User) []string {
if len(users) == 0 {
return nil
}
out := make([]string, 0, len(users))
for _, u := range users {
if u != nil {
out = append(out, u.UserName)
}
}
return out
}
func labelNames(labels []*gitea_sdk.Label) []string {
if len(labels) == 0 {
return nil
}
out := make([]string, 0, len(labels))
for _, l := range labels {
if l != nil {
out = append(out, l.Name)
}
}
return out
}
func slimIssue(i *gitea_sdk.Issue) map[string]any { func slimIssue(i *gitea_sdk.Issue) map[string]any {
if i == nil { if i == nil {
return nil return nil
@@ -47,15 +16,15 @@ func slimIssue(i *gitea_sdk.Issue) map[string]any {
"body": i.Body, "body": i.Body,
"state": i.State, "state": i.State,
"html_url": i.HTMLURL, "html_url": i.HTMLURL,
"user": userLogin(i.Poster), "user": slim.UserLogin(i.Poster),
"labels": labelNames(i.Labels), "labels": slim.LabelNames(i.Labels),
"comments": i.Comments, "comments": i.Comments,
"created_at": i.Created, "created_at": i.Created,
"updated_at": i.Updated, "updated_at": i.Updated,
"closed_at": i.Closed, "closed_at": i.Closed,
} }
if len(i.Assignees) > 0 { if len(i.Assignees) > 0 {
m["assignees"] = userLogins(i.Assignees) m["assignees"] = slim.UserLogins(i.Assignees)
} }
if i.Milestone != nil { if i.Milestone != nil {
m["milestone"] = map[string]any{ m["milestone"] = map[string]any{
@@ -63,6 +32,12 @@ func slimIssue(i *gitea_sdk.Issue) map[string]any {
"title": i.Milestone.Title, "title": i.Milestone.Title,
} }
} }
if i.Ref != "" {
m["ref"] = i.Ref
}
if i.Deadline != nil {
m["deadline"] = i.Deadline
}
if i.PullRequest != nil { if i.PullRequest != nil {
m["is_pull"] = true m["is_pull"] = true
} }
@@ -80,13 +55,19 @@ func slimIssues(issues []*gitea_sdk.Issue) []map[string]any {
"title": i.Title, "title": i.Title,
"state": i.State, "state": i.State,
"html_url": i.HTMLURL, "html_url": i.HTMLURL,
"user": userLogin(i.Poster), "user": slim.UserLogin(i.Poster),
"comments": i.Comments, "comments": i.Comments,
"created_at": i.Created, "created_at": i.Created,
"updated_at": i.Updated, "updated_at": i.Updated,
} }
if len(i.Labels) > 0 { if len(i.Labels) > 0 {
m["labels"] = labelNames(i.Labels) m["labels"] = slim.LabelNames(i.Labels)
}
if i.Ref != "" {
m["ref"] = i.Ref
}
if i.Deadline != nil {
m["deadline"] = i.Deadline
} }
out = append(out, m) out = append(out, m)
} }
@@ -100,34 +81,9 @@ func slimComment(c *gitea_sdk.Comment) map[string]any {
return map[string]any{ return map[string]any{
"id": c.ID, "id": c.ID,
"body": c.Body, "body": c.Body,
"user": userLogin(c.Poster), "user": slim.UserLogin(c.Poster),
"html_url": c.HTMLURL, "html_url": c.HTMLURL,
"created_at": c.Created, "created_at": c.Created,
"updated_at": c.Updated, "updated_at": c.Updated,
} }
} }
func slimComments(comments []*gitea_sdk.Comment) []map[string]any {
out := make([]map[string]any, 0, len(comments))
for _, c := range comments {
out = append(out, slimComment(c))
}
return out
}
func slimLabels(labels []*gitea_sdk.Label) []map[string]any {
out := make([]map[string]any, 0, len(labels))
for _, l := range labels {
if l == nil {
continue
}
out = append(out, map[string]any{
"id": l.ID,
"name": l.Name,
"color": l.Color,
"description": l.Description,
"exclusive": l.Exclusive,
})
}
return out
}
+45 -56
View File
@@ -4,9 +4,10 @@ import (
"context" "context"
"fmt" "fmt"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params" "gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/slim"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
@@ -25,28 +26,31 @@ const (
var ( var (
LabelReadTool = mcp.NewTool( LabelReadTool = mcp.NewTool(
LabelReadToolName, LabelReadToolName,
mcp.WithDescription("Read label information. Use method 'list_repo_labels' to list repository labels, 'get_repo_label' to get a specific repo label, 'list_org_labels' to list organization labels."), mcp.WithDescription("Read repo or org labels."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list_repo_labels", "get_repo_label", "list_org_labels")), mcp.WithToolAnnotation(annotation.ReadOnly("Read labels")),
mcp.WithString("owner", mcp.Description("repository owner (required for repo methods)")), mcp.WithString("method", mcp.Required(), mcp.Enum("list_repo_labels", "get_repo_label", "list_org_labels")),
mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")), mcp.WithString("owner", mcp.Description("for repo methods")),
mcp.WithString("org", mcp.Description("organization name (required for 'list_org')")), mcp.WithString("repo", mcp.Description("for repo methods")),
mcp.WithNumber("id", mcp.Description("label ID (required for 'get_repo')")), mcp.WithString("org", mcp.Description("for org methods")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)), mcp.WithNumber("id", mcp.Description("label ID (for 'get_repo_label')")),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)), mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
) )
LabelWriteTool = mcp.NewTool( LabelWriteTool = mcp.NewTool(
LabelWriteToolName, LabelWriteToolName,
mcp.WithDescription("Create, edit, or delete labels for repositories or organizations."), mcp.WithDescription("Write labels (repo or org): create, edit, delete."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create_repo_label", "edit_repo_label", "delete_repo_label", "create_org_label", "edit_org_label", "delete_org_label")), mcp.WithToolAnnotation(annotation.Destructive("Create, update, or delete labels")),
mcp.WithString("owner", mcp.Description("repository owner (required for repo methods)")), mcp.WithString("method", mcp.Required(), mcp.Enum("create_repo_label", "edit_repo_label", "delete_repo_label", "create_org_label", "edit_org_label", "delete_org_label")),
mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")), mcp.WithString("owner", mcp.Description("for repo methods")),
mcp.WithString("org", mcp.Description("organization name (required for org methods)")), mcp.WithString("repo", mcp.Description("for repo methods")),
mcp.WithNumber("id", mcp.Description("label ID (required for edit/delete methods)")), mcp.WithString("org", mcp.Description("for org methods")),
mcp.WithString("name", mcp.Description("label name (required for create, optional for edit)")), mcp.WithNumber("id", mcp.Description("for edit/delete")),
mcp.WithString("color", mcp.Description("label color hex code e.g. #RRGGBB (required for create, optional for edit)")), mcp.WithString("name", mcp.Description("required for create")),
mcp.WithString("description", mcp.Description("label description")), mcp.WithString("color", mcp.Description("hex (#RRGGBB); required for create")),
mcp.WithBoolean("exclusive", mcp.Description("whether the label is exclusive (org labels only)")), mcp.WithString("description"),
mcp.WithBoolean("exclusive", mcp.Description("exclusive (org only)")),
mcp.WithBoolean("is_archived", mcp.Description("archived (repo only)")),
) )
) )
@@ -104,7 +108,6 @@ func labelWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRe
} }
func listRepoLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listRepoLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoLabelsFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -129,11 +132,10 @@ func listRepoLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("list %v/%v/labels err: %v", owner, repo, err)) return to.ErrorResult(fmt.Errorf("list %v/%v/labels err: %v", owner, repo, err))
} }
return to.TextResult(slimLabels(labels)) return to.TextResult(slim.Labels(labels))
} }
func getRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getRepoLabelFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -155,11 +157,10 @@ func getRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/label/%v err: %v", owner, repo, id, err)) return to.ErrorResult(fmt.Errorf("get %v/%v/label/%v err: %v", owner, repo, id, err))
} }
return to.TextResult(slimLabel(label)) return to.TextResult(slim.Label(label))
} }
func createRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func createRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createRepoLabelFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -178,10 +179,13 @@ func createRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
} }
description, _ := req.GetArguments()["description"].(string) // Optional description, _ := req.GetArguments()["description"].(string) // Optional
isArchived, _ := req.GetArguments()["is_archived"].(bool)
opt := gitea_sdk.CreateLabelOption{ opt := gitea_sdk.CreateLabelOption{
Name: name, Name: name,
Color: color, Color: color,
Description: description, Description: description,
IsArchived: isArchived,
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -192,11 +196,10 @@ func createRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/%v/label err: %v", owner, repo, err)) return to.ErrorResult(fmt.Errorf("create %v/%v/label err: %v", owner, repo, err))
} }
return to.TextResult(slimLabel(label)) return to.TextResult(slim.Label(label))
} }
func editRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func editRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editRepoLabelFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -210,15 +213,12 @@ func editRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
return to.ErrorResult(err) return to.ErrorResult(err)
} }
opt := gitea_sdk.EditLabelOption{} args := req.GetArguments()
if name, ok := req.GetArguments()["name"].(string); ok { opt := gitea_sdk.EditLabelOption{
opt.Name = new(name) Name: params.GetOptionalStringPtr(args, "name"),
} Color: params.GetOptionalStringPtr(args, "color"),
if color, ok := req.GetArguments()["color"].(string); ok { Description: params.GetPresentStringPtr(args, "description"),
opt.Color = new(color) IsArchived: params.GetOptionalBoolPtr(args, "is_archived"),
}
if description, ok := req.GetArguments()["description"].(string); ok {
opt.Description = new(description)
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -229,11 +229,10 @@ func editRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("edit %v/%v/label/%v err: %v", owner, repo, id, err)) return to.ErrorResult(fmt.Errorf("edit %v/%v/label/%v err: %v", owner, repo, id, err))
} }
return to.TextResult(slimLabel(label)) return to.TextResult(slim.Label(label))
} }
func deleteRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func deleteRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteRepoLabelFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -259,7 +258,6 @@ func deleteRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
} }
func listOrgLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listOrgLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listOrgLabelsFn")
org, err := params.GetString(req.GetArguments(), "org") org, err := params.GetString(req.GetArguments(), "org")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -280,11 +278,10 @@ func listOrgLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("list %v/labels err: %v", org, err)) return to.ErrorResult(fmt.Errorf("list %v/labels err: %v", org, err))
} }
return to.TextResult(slimLabels(labels)) return to.TextResult(slim.Labels(labels))
} }
func createOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func createOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createOrgLabelFn")
org, err := params.GetString(req.GetArguments(), "org") org, err := params.GetString(req.GetArguments(), "org")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -315,11 +312,10 @@ func createOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/labels err: %v", org, err)) return to.ErrorResult(fmt.Errorf("create %v/labels err: %v", org, err))
} }
return to.TextResult(slimLabel(label)) return to.TextResult(slim.Label(label))
} }
func editOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func editOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editOrgLabelFn")
org, err := params.GetString(req.GetArguments(), "org") org, err := params.GetString(req.GetArguments(), "org")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -329,18 +325,12 @@ func editOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
return to.ErrorResult(err) return to.ErrorResult(err)
} }
opt := gitea_sdk.EditOrgLabelOption{} args := req.GetArguments()
if name, ok := req.GetArguments()["name"].(string); ok { opt := gitea_sdk.EditOrgLabelOption{
opt.Name = new(name) Name: params.GetOptionalStringPtr(args, "name"),
} Color: params.GetOptionalStringPtr(args, "color"),
if color, ok := req.GetArguments()["color"].(string); ok { Description: params.GetPresentStringPtr(args, "description"),
opt.Color = new(color) Exclusive: params.GetOptionalBoolPtr(args, "exclusive"),
}
if description, ok := req.GetArguments()["description"].(string); ok {
opt.Description = new(description)
}
if exclusive, ok := req.GetArguments()["exclusive"].(bool); ok {
opt.Exclusive = new(exclusive)
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -351,11 +341,10 @@ func editOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("edit %v/labels/%v err: %v", org, id, err)) return to.ErrorResult(fmt.Errorf("edit %v/labels/%v err: %v", org, id, err))
} }
return to.TextResult(slimLabel(label)) return to.TextResult(slim.Label(label))
} }
func deleteOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func deleteOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteOrgLabelFn")
org, err := params.GetString(req.GetArguments(), "org") org, err := params.GetString(req.GetArguments(), "org")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
-25
View File
@@ -1,26 +1 @@
package label package label
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func slimLabel(l *gitea_sdk.Label) map[string]any {
if l == nil {
return nil
}
return map[string]any{
"id": l.ID,
"name": l.Name,
"color": l.Color,
"description": l.Description,
"exclusive": l.Exclusive,
}
}
func slimLabels(labels []*gitea_sdk.Label) []map[string]any {
out := make([]map[string]any, 0, len(labels))
for _, l := range labels {
out = append(out, slimLabel(l))
}
return out
}
-25
View File
@@ -1,25 +0,0 @@
package label
import (
"testing"
gitea_sdk "code.gitea.io/sdk/gitea"
)
func TestSlimLabel(t *testing.T) {
l := &gitea_sdk.Label{
ID: 1,
Name: "bug",
Color: "#d73a4a",
Description: "Something isn't working",
Exclusive: false,
}
m := slimLabel(l)
if m["name"] != "bug" {
t.Errorf("expected name bug, got %v", m["name"])
}
if m["color"] != "#d73a4a" {
t.Errorf("expected color, got %v", m["color"])
}
}
+32 -34
View File
@@ -4,8 +4,8 @@ import (
"context" "context"
"fmt" "fmt"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params" "gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
@@ -25,28 +25,30 @@ const (
var ( var (
MilestoneReadTool = mcp.NewTool( MilestoneReadTool = mcp.NewTool(
MilestoneReadToolName, MilestoneReadToolName,
mcp.WithDescription("Read milestone information. Use method 'get' to get a specific milestone, 'list' to list milestones."), mcp.WithDescription("Read milestones: get one or list."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "list")), mcp.WithToolAnnotation(annotation.ReadOnly("Read milestones")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("method", mcp.Required(), mcp.Enum("get", "list")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithNumber("id", mcp.Description("milestone id (required for 'get')")), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("state", mcp.Description("milestone state (for 'list')"), mcp.DefaultString("all")), mcp.WithNumber("id", mcp.Description("for 'get'")),
mcp.WithString("name", mcp.Description("milestone name filter (for 'list')")), mcp.WithString("state", mcp.DefaultString("all")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)), mcp.WithString("name", mcp.Description("name filter (for 'list')")),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)), mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
) )
MilestoneWriteTool = mcp.NewTool( MilestoneWriteTool = mcp.NewTool(
MilestoneWriteToolName, MilestoneWriteToolName,
mcp.WithDescription("Create, edit, or delete milestones."), mcp.WithDescription("Write milestones: create, update, delete."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "edit", "delete")), mcp.WithToolAnnotation(annotation.Destructive("Create, update, or delete milestones")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("method", mcp.Required(), mcp.Enum("create", "update", "edit", "delete")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithNumber("id", mcp.Description("milestone id (required for 'edit', 'delete')")), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("title", mcp.Description("milestone title (required for 'create')")), mcp.WithNumber("id", mcp.Description("for 'update'/'delete'")),
mcp.WithString("description", mcp.Description("milestone description")), mcp.WithString("title", mcp.Description("for 'create'")),
mcp.WithString("description"),
mcp.WithString("due_on", mcp.Description("due date")), mcp.WithString("due_on", mcp.Description("due date")),
mcp.WithString("state", mcp.Description("milestone state, one of open, closed (for 'edit')")), mcp.WithString("state", mcp.Enum("open", "closed")),
) )
) )
@@ -84,6 +86,8 @@ func milestoneWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
switch method { switch method {
case "create": case "create":
return createMilestoneFn(ctx, req) return createMilestoneFn(ctx, req)
case "update":
return editMilestoneFn(ctx, req)
case "edit": case "edit":
return editMilestoneFn(ctx, req) return editMilestoneFn(ctx, req)
case "delete": case "delete":
@@ -94,7 +98,6 @@ func milestoneWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
} }
func getMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getMilestoneFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -120,7 +123,6 @@ func getMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
} }
func listMilestonesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listMilestonesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listMilestonesFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -152,7 +154,6 @@ func listMilestonesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
} }
func createMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func createMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createMilestoneFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -174,6 +175,7 @@ func createMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
if ok { if ok {
opt.Description = description opt.Description = description
} }
opt.Deadline = params.GetOptionalTime(req.GetArguments(), "due_on")
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
@@ -188,7 +190,6 @@ func createMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
} }
func editMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func editMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editMilestoneFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -202,19 +203,17 @@ func editMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
return to.ErrorResult(err) return to.ErrorResult(err)
} }
opt := gitea_sdk.EditMilestoneOption{} args := req.GetArguments()
opt := gitea_sdk.EditMilestoneOption{
title, ok := req.GetArguments()["title"].(string) Description: params.GetPresentStringPtr(args, "description"),
if ok { Deadline: params.GetOptionalTime(args, "due_on"),
}
if title, ok := args["title"].(string); ok {
opt.Title = title opt.Title = title
} }
description, ok := req.GetArguments()["description"].(string) if state, ok := args["state"].(string); ok {
if ok { s := gitea_sdk.StateType(state)
opt.Description = new(description) opt.State = &s
}
state, ok := req.GetArguments()["state"].(string)
if ok {
opt.State = new(gitea_sdk.StateType(state))
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -230,7 +229,6 @@ func editMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
} }
func deleteMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func deleteMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteMilestoneFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
+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)
}
})
}
}
+213
View File
@@ -0,0 +1,213 @@
package notification
import (
"context"
"fmt"
"time"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
NotificationReadToolName = "notification_read"
NotificationWriteToolName = "notification_write"
)
var (
NotificationReadTool = mcp.NewTool(
NotificationReadToolName,
mcp.WithDescription("Read notifications: list (optionally scoped to a repo) or get a thread by ID."),
mcp.WithToolAnnotation(annotation.ReadOnly("Read notifications")),
mcp.WithString("method", mcp.Required(), mcp.Enum("list", "get")),
mcp.WithString("owner", mcp.Description("scope 'list' to a repo")),
mcp.WithString("repo", mcp.Description("scope 'list' to a repo")),
mcp.WithNumber("id", mcp.Description("thread ID (for 'get')")),
mcp.WithString("status", mcp.Enum("unread", "read", "pinned")),
mcp.WithString("subject_type", mcp.Enum("Issue", "Pull", "Commit", "Repository")),
mcp.WithString("since", mcp.Description("updated after ISO 8601")),
mcp.WithString("before", mcp.Description("updated before ISO 8601")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
)
NotificationWriteTool = mcp.NewTool(
NotificationWriteToolName,
mcp.WithDescription("Mark a notification or all notifications as read."),
mcp.WithToolAnnotation(annotation.Write("Manage notifications")),
mcp.WithString("method", mcp.Required(), mcp.Enum("mark_read", "mark_all_read")),
mcp.WithNumber("id", mcp.Description("thread ID (for 'mark_read')")),
mcp.WithString("owner", mcp.Description("scope 'mark_all_read' to a repo")),
mcp.WithString("repo", mcp.Description("scope 'mark_all_read' to a repo")),
mcp.WithString("last_read_at", mcp.Description("ISO 8601; defaults to now")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: NotificationReadTool,
Handler: notificationReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: NotificationWriteTool,
Handler: notificationWriteFn,
})
}
func notificationReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
method, err := params.GetString(args, "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "list":
return listNotificationsFn(ctx, req)
case "get":
return getNotificationFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func notificationWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
method, err := params.GetString(args, "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "mark_read":
return markNotificationReadFn(ctx, req)
case "mark_all_read":
return markAllNotificationsReadFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func listNotificationsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
page, pageSize := params.GetPagination(args, 30)
opt := gitea_sdk.ListNotificationOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
if status, ok := args["status"].(string); ok {
opt.Status = []gitea_sdk.NotifyStatus{gitea_sdk.NotifyStatus(status)}
}
if subjectType, ok := args["subject_type"].(string); ok {
opt.SubjectTypes = []gitea_sdk.NotifySubjectType{gitea_sdk.NotifySubjectType(subjectType)}
}
if t := params.GetOptionalTime(args, "since"); t != nil {
opt.Since = *t
}
if t := params.GetOptionalTime(args, "before"); t != nil {
opt.Before = *t
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
owner := params.GetOptionalString(args, "owner", "")
repo := params.GetOptionalString(args, "repo", "")
if owner != "" && repo != "" {
threads, _, err := client.ListRepoNotifications(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list %v/%v/notifications err: %v", owner, repo, err))
}
return to.TextResult(slimThreads(threads))
}
threads, _, err := client.ListNotifications(opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list notifications err: %v", err))
}
return to.TextResult(slimThreads(threads))
}
func getNotificationFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
id, err := params.GetIndex(req.GetArguments(), "id")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
thread, _, err := client.GetNotification(id)
if err != nil {
return to.ErrorResult(fmt.Errorf("get notification/%v err: %v", id, err))
}
return to.TextResult(slimThread(thread))
}
func markNotificationReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
id, err := params.GetIndex(req.GetArguments(), "id")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
thread, _, err := client.ReadNotification(id)
if err != nil {
return to.ErrorResult(fmt.Errorf("mark notification/%v read err: %v", id, err))
}
if thread != nil {
return to.TextResult(slimThread(thread))
}
return to.TextResult("Notification marked as read")
}
func markAllNotificationsReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
lastReadAt := time.Now()
if t := params.GetOptionalTime(args, "last_read_at"); t != nil {
lastReadAt = *t
}
opt := gitea_sdk.MarkNotificationOptions{
LastReadAt: lastReadAt,
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
owner := params.GetOptionalString(args, "owner", "")
repo := params.GetOptionalString(args, "repo", "")
if owner != "" && repo != "" {
threads, _, err := client.ReadRepoNotifications(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("mark %v/%v/notifications read err: %v", owner, repo, err))
}
if threads != nil {
return to.TextResult(slimThreads(threads))
}
return to.TextResult("All repository notifications marked as read")
}
threads, _, err := client.ReadNotifications(opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("mark all notifications read err: %v", err))
}
if threads != nil {
return to.TextResult(slimThreads(threads))
}
return to.TextResult("All notifications marked as read")
}
+66
View File
@@ -0,0 +1,66 @@
package notification
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func slimThread(t *gitea_sdk.NotificationThread) map[string]any {
if t == nil {
return nil
}
m := map[string]any{
"id": t.ID,
"unread": t.Unread,
"updated_at": t.UpdatedAt,
}
if t.Pinned {
m["pinned"] = true
}
if t.Repository != nil {
m["repository"] = t.Repository.FullName
}
if t.Subject != nil {
subject := map[string]any{
"title": t.Subject.Title,
"type": t.Subject.Type,
"state": t.Subject.State,
}
if t.Subject.HTMLURL != "" {
subject["html_url"] = t.Subject.HTMLURL
}
if t.Subject.LatestCommentHTMLURL != "" {
subject["latest_comment_html_url"] = t.Subject.LatestCommentHTMLURL
}
m["subject"] = subject
}
return m
}
func slimThreads(threads []*gitea_sdk.NotificationThread) []map[string]any {
out := make([]map[string]any, 0, len(threads))
for _, t := range threads {
if t == nil {
continue
}
m := map[string]any{
"id": t.ID,
"unread": t.Unread,
"updated_at": t.UpdatedAt,
}
if t.Pinned {
m["pinned"] = true
}
if t.Repository != nil {
m["repository"] = t.Repository.FullName
}
if t.Subject != nil {
m["subject"] = map[string]any{
"title": t.Subject.Title,
"type": t.Subject.Type,
"state": t.Subject.State,
}
}
out = append(out, m)
}
return out
}
+17 -36
View File
@@ -15,6 +15,8 @@ import (
"gitea.com/gitea/gitea-mcp/operation/issue" "gitea.com/gitea/gitea-mcp/operation/issue"
"gitea.com/gitea/gitea-mcp/operation/label" "gitea.com/gitea/gitea-mcp/operation/label"
"gitea.com/gitea/gitea-mcp/operation/milestone" "gitea.com/gitea/gitea-mcp/operation/milestone"
"gitea.com/gitea/gitea-mcp/operation/notification"
"gitea.com/gitea/gitea-mcp/operation/packages"
"gitea.com/gitea/gitea-mcp/operation/pull" "gitea.com/gitea/gitea-mcp/operation/pull"
"gitea.com/gitea/gitea-mcp/operation/repo" "gitea.com/gitea/gitea-mcp/operation/repo"
"gitea.com/gitea/gitea-mcp/operation/search" "gitea.com/gitea/gitea-mcp/operation/search"
@@ -25,47 +27,26 @@ import (
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context" mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
"gitea.com/gitea/gitea-mcp/pkg/flag" "gitea.com/gitea/gitea-mcp/pkg/flag"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/tool"
"github.com/mark3labs/mcp-go/server" "github.com/mark3labs/mcp-go/server"
) )
var mcpServer *server.MCPServer var (
mcpServer *server.MCPServer
domainTools = []*tool.Tool{
user.Tool, actions.Tool, repo.Tool, notification.Tool, issue.Tool,
label.Tool, milestone.Tool, packages.Tool, pull.Tool, search.Tool,
version.Tool, wiki.Tool, timetracking.Tool,
}
)
func RegisterTool(s *server.MCPServer) { func RegisterTool(s *server.MCPServer) {
// User Tool for _, t := range domainTools {
s.AddTools(user.Tool.Tools()...) s.AddTools(t.Tools()...)
}
// Actions Tool tool.WarnUnmatchedAllowedTools(domainTools...)
s.AddTools(actions.Tool.Tools()...)
// Repo Tool
s.AddTools(repo.Tool.Tools()...)
// Issue Tool
s.AddTools(issue.Tool.Tools()...)
// Label Tool
s.AddTools(label.Tool.Tools()...)
// Milestone Tool
s.AddTools(milestone.Tool.Tools()...)
// Pull Tool
s.AddTools(pull.Tool.Tools()...)
// Search Tool
s.AddTools(search.Tool.Tools()...)
// Version Tool
s.AddTools(version.Tool.Tools()...)
// Wiki Tool
s.AddTools(wiki.Tool.Tools()...)
// Time Tracking Tool
s.AddTools(timetracking.Tool.Tools()...)
s.DeleteTools("")
} }
// parseAuthToken extracts the token from an Authorization header. // parseAuthToken extracts the token from an Authorization header.
@@ -115,7 +96,7 @@ func Run() error {
case "http": case "http":
httpServer := server.NewStreamableHTTPServer( httpServer := server.NewStreamableHTTPServer(
mcpServer, mcpServer,
server.WithLogger(log.New()), server.WithLogger(log.Default().Sugar()),
server.WithHeartbeatInterval(30*time.Second), server.WithHeartbeatInterval(30*time.Second),
server.WithHTTPContextFunc(getContextWithToken), server.WithHTTPContextFunc(getContextWithToken),
) )
+220
View File
@@ -0,0 +1,220 @@
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/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) {
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) {
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) {
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) {
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
}
}
+275 -136
View File
@@ -3,11 +3,14 @@ package pull
import ( import (
"context" "context"
"fmt" "fmt"
"net/url"
"strings" "strings"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params" "gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/slim"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
@@ -28,72 +31,81 @@ const (
var ( var (
ListRepoPullRequestsTool = mcp.NewTool( ListRepoPullRequestsTool = mcp.NewTool(
ListRepoPullRequestsToolName, ListRepoPullRequestsToolName,
mcp.WithDescription("List repository pull requests"), mcp.WithToolAnnotation(annotation.ReadOnly("List pull requests")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("state", mcp.Description("state"), mcp.Enum("open", "closed", "all"), mcp.DefaultString("all")), mcp.WithString("state", mcp.Enum("open", "closed", "all"), mcp.DefaultString("all")),
mcp.WithString("sort", mcp.Description("sort"), mcp.Enum("oldest", "recentupdate", "leastupdate", "mostcomment", "leastcomment", "priority"), mcp.DefaultString("recentupdate")), mcp.WithString("sort", mcp.Enum("oldest", "recentupdate", "leastupdate", "mostcomment", "leastcomment", "priority"), mcp.DefaultString("recentupdate")),
mcp.WithNumber("milestone", mcp.Description("milestone")), mcp.WithNumber("milestone"),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)), mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)), mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
) )
PullRequestReadTool = mcp.NewTool( PullRequestReadTool = mcp.NewTool(
PullRequestReadToolName, PullRequestReadToolName,
mcp.WithDescription("Get pull request information. Use method 'get' for PR details, 'get_diff' for diff, 'get_reviews'/'get_review'/'get_review_comments' for review data."), mcp.WithDescription("Read pull request: details, diff, changed files, head commit status, reviews."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "get_diff", "get_reviews", "get_review", "get_review_comments")), mcp.WithToolAnnotation(annotation.ReadOnly("Read pull request details")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("method", mcp.Required(), mcp.Enum("get", "get_diff", "get_files", "get_status", "get_reviews", "get_review", "get_review_comments")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithNumber("index", mcp.Required(), mcp.Description("pull request index")), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("review_id", mcp.Description("review ID (required for 'get_review', 'get_review_comments')")), mcp.WithNumber("pull_number", mcp.Required()),
mcp.WithBoolean("binary", mcp.Description("whether to include binary file changes (for 'get_diff')")), mcp.WithNumber("review_id", mcp.Description("for 'get_review'/'get_review_comments'")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)), mcp.WithBoolean("binary", mcp.Description("include binary diff")),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)), mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
) )
PullRequestWriteTool = mcp.NewTool( PullRequestWriteTool = mcp.NewTool(
PullRequestWriteToolName, PullRequestWriteToolName,
mcp.WithDescription("Create, update, or merge pull requests, manage reviewers."), mcp.WithDescription("Write pull requests: create, update, close, reopen, merge, update branch from base, manage reviewers."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "update", "merge", "add_reviewers", "remove_reviewers")), mcp.WithToolAnnotation(annotation.Write("Create, update, close, reopen, or merge pull requests")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("method", mcp.Required(), mcp.Enum("create", "update", "close", "reopen", "merge", "update_branch", "add_reviewers", "remove_reviewers")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithNumber("index", mcp.Description("pull request index (required for all methods except 'create')")), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("title", mcp.Description("PR title (required for 'create', optional for 'update', 'merge')")), mcp.WithNumber("pull_number", mcp.Description("required except for 'create'")),
mcp.WithString("body", mcp.Description("PR body (required for 'create', optional for 'update')")), mcp.WithString("title", mcp.Description("required for 'create'; optional for 'update'/'merge'")),
mcp.WithString("head", mcp.Description("PR head branch (required for 'create')")), mcp.WithString("body", mcp.Description("required for 'create'; optional for 'update'")),
mcp.WithString("base", mcp.Description("PR base branch (required for 'create', optional for 'update')")), mcp.WithString("head", mcp.Description("head branch (required for 'create')")),
mcp.WithString("assignee", mcp.Description("username to assign (for 'update')")), mcp.WithString("base", mcp.Description("base branch (required for 'create')")),
mcp.WithArray("assignees", mcp.Description("usernames to assign (for 'update')"), mcp.Items(map[string]any{"type": "string"})), mcp.WithString("assignee", mcp.Description("for 'update'")),
mcp.WithNumber("milestone", mcp.Description("milestone number (for 'update')")), mcp.WithArray("assignees", mcp.Description("for 'update'"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithString("state", mcp.Description("PR state (for 'update')"), mcp.Enum("open", "closed")), mcp.WithNumber("milestone", mcp.Description("for 'update'")),
mcp.WithBoolean("allow_maintainer_edit", mcp.Description("allow maintainer to edit (for 'update')")), mcp.WithString("state", mcp.Description("for 'update'"), mcp.Enum("open", "closed")),
mcp.WithString("merge_style", mcp.Description("merge style (for 'merge')"), mcp.Enum("merge", "rebase", "rebase-merge", "squash", "fast-forward-only"), mcp.DefaultString("merge")), mcp.WithBoolean("allow_maintainer_edit", mcp.Description("for 'update'")),
mcp.WithString("message", mcp.Description("merge commit message (for 'merge') or dismissal reason")), mcp.WithArray("labels", mcp.Description("label IDs"), mcp.Items(map[string]any{"type": "number"})),
mcp.WithBoolean("delete_branch", mcp.Description("delete branch after merge (for 'merge')")), mcp.WithString("deadline", mcp.Description("ISO 8601")),
mcp.WithArray("reviewers", mcp.Description("reviewer usernames (for 'add_reviewers', 'remove_reviewers')"), mcp.Items(map[string]any{"type": "string"})), mcp.WithBoolean("remove_deadline", mcp.Description("for 'update'")),
mcp.WithArray("team_reviewers", mcp.Description("team reviewer names (for 'add_reviewers', 'remove_reviewers')"), mcp.Items(map[string]any{"type": "string"})), mcp.WithString("merge_style", mcp.Description("for 'merge'"), mcp.Enum("merge", "rebase", "rebase-merge", "squash", "fast-forward-only"), mcp.DefaultString("merge")),
mcp.WithBoolean("draft", mcp.Description("mark PR as draft (for 'create', 'update'). Gitea uses a 'WIP: ' title prefix for drafts.")), 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( PullRequestReviewWriteTool = mcp.NewTool(
PullRequestReviewWriteToolName, PullRequestReviewWriteToolName,
mcp.WithDescription("Manage pull request reviews: create, submit, delete, or dismiss."), mcp.WithDescription("Write PR reviews: create, submit, delete, dismiss."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "submit", "delete", "dismiss")), mcp.WithToolAnnotation(annotation.Write("Submit a pull request review")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("method", mcp.Required(), mcp.Enum("create", "submit", "delete", "dismiss")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithNumber("index", mcp.Required(), mcp.Description("pull request index")), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("review_id", mcp.Description("review ID (required for 'submit', 'delete', 'dismiss')")), mcp.WithNumber("pull_number", mcp.Required()),
mcp.WithString("state", mcp.Description("review state"), mcp.Enum("APPROVED", "REQUEST_CHANGES", "COMMENT", "PENDING")), mcp.WithNumber("review_id", mcp.Description("required except for 'create'")),
mcp.WithString("body", mcp.Description("review body/comment")), mcp.WithString("state", mcp.Enum("APPROVED", "REQUEST_CHANGES", "COMMENT", "PENDING")),
mcp.WithString("commit_id", mcp.Description("commit SHA to review (for 'create')")), mcp.WithString("body"),
mcp.WithString("message", mcp.Description("dismissal reason (for 'dismiss')")), mcp.WithString("commit_id", mcp.Description("for 'create'")),
mcp.WithArray("comments", mcp.Description("inline review comments (for 'create')"), mcp.Items(map[string]any{ mcp.WithString("message", mcp.Description("dismissal reason")),
mcp.WithArray("comments", mcp.Description("inline comments (for 'create')"), mcp.Items(map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"path": map[string]any{"type": "string", "description": "file path to comment on"}, "path": map[string]any{"type": "string"},
"body": map[string]any{"type": "string", "description": "comment body"}, "body": map[string]any{"type": "string"},
"old_line_num": map[string]any{"type": "number", "description": "line number in the old file (for deletions/changes)"}, "old_line_num": map[string]any{"type": "number", "description": "old-file line (deletions)"},
"new_line_num": map[string]any{"type": "number", "description": "line number in the new file (for additions/changes)"}, "new_line_num": map[string]any{"type": "number", "description": "new-file line (additions)"},
}, },
})), })),
) )
@@ -128,6 +140,10 @@ func pullRequestReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
return getPullRequestByIndexFn(ctx, req) return getPullRequestByIndexFn(ctx, req)
case "get_diff": case "get_diff":
return getPullRequestDiffFn(ctx, req) return getPullRequestDiffFn(ctx, req)
case "get_files":
return getPullRequestFilesFn(ctx, req)
case "get_status":
return getPullRequestStatusFn(ctx, req)
case "get_reviews": case "get_reviews":
return listPullRequestReviewsFn(ctx, req) return listPullRequestReviewsFn(ctx, req)
case "get_review": case "get_review":
@@ -149,8 +165,14 @@ func pullRequestWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
return createPullRequestFn(ctx, req) return createPullRequestFn(ctx, req)
case "update": case "update":
return editPullRequestFn(ctx, req) return editPullRequestFn(ctx, req)
case "close":
return closePullRequestFn(ctx, req)
case "reopen":
return reopenPullRequestFn(ctx, req)
case "merge": case "merge":
return mergePullRequestFn(ctx, req) return mergePullRequestFn(ctx, req)
case "update_branch":
return updatePullRequestBranchFn(ctx, req)
case "add_reviewers": case "add_reviewers":
return createPullRequestReviewerFn(ctx, req) return createPullRequestReviewerFn(ctx, req)
case "remove_reviewers": case "remove_reviewers":
@@ -160,6 +182,66 @@ func pullRequestWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
} }
} }
func closePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "pull_number")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
state := gitea_sdk.StateClosed
pr, _, err := client.EditPullRequest(owner, repo, index, gitea_sdk.EditPullRequestOption{
State: &state,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("close %v/%v/pr/%v err: %v", owner, repo, index, err))
}
return to.TextResult(slimPullRequest(pr))
}
func reopenPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "pull_number")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
state := gitea_sdk.StateOpen
pr, _, err := client.EditPullRequest(owner, repo, index, gitea_sdk.EditPullRequestOption{
State: &state,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("reopen %v/%v/pr/%v err: %v", owner, repo, index, err))
}
return to.TextResult(slimPullRequest(pr))
}
func pullRequestReviewWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func pullRequestReviewWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method") method, err := params.GetString(req.GetArguments(), "method")
if err != nil { if err != nil {
@@ -180,7 +262,6 @@ func pullRequestReviewWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mc
} }
func getPullRequestByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getPullRequestByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getPullRequestByIndexFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -190,7 +271,7 @@ func getPullRequestByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(args, "index") index, err := params.GetIndex(args, "pull_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -203,11 +284,20 @@ func getPullRequestByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp
return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v err: %v", owner, repo, index, err)) return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v err: %v", owner, repo, index, err))
} }
return to.TextResult(slimPullRequest(pr)) // /pulls/{n} omits `assets`; PRs are issues internally, so the issue
// assets endpoint surfaces description attachments.
var assets []*gitea_sdk.Attachment
assetsPath := fmt.Sprintf("repos/%s/%s/issues/%d/assets", url.PathEscape(owner), url.PathEscape(repo), index)
if _, err := gitea.DoJSON(ctx, "GET", assetsPath, nil, nil, &assets); err != nil {
log.Debugf("fetch %v/%v/issues/%v/assets err: %v", owner, repo, index, err)
}
m := slimPullRequest(pr)
m["body"] = slim.BodyWithAttachments(pr.Body, assets)
return to.TextResult(m)
} }
func getPullRequestDiffFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getPullRequestDiffFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getPullRequestDiffFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -217,7 +307,7 @@ func getPullRequestDiffFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(args, "index") index, err := params.GetIndex(args, "pull_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -238,7 +328,6 @@ func getPullRequestDiffFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
} }
func listRepoPullRequestsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listRepoPullRequestsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListRepoPullRequests")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -296,7 +385,6 @@ func applyDraftPrefix(title string, isDraft bool) string {
} }
func createPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func createPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createPullRequestFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -331,12 +419,17 @@ func createPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Cal
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
pr, _, err := client.CreatePullRequest(owner, repo, gitea_sdk.CreatePullRequestOption{ opt := gitea_sdk.CreatePullRequestOption{
Title: title, Title: title,
Body: body, Body: body,
Head: head, Head: head,
Base: base, Base: base,
}) }
if labelIDs, err := params.GetInt64Slice(args, "labels"); err == nil {
opt.Labels = labelIDs
}
opt.Deadline = params.GetOptionalTime(args, "deadline")
pr, _, err := client.CreatePullRequest(owner, repo, opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/%v/pull_request err: %v", owner, repo, err)) return to.ErrorResult(fmt.Errorf("create %v/%v/pull_request err: %v", owner, repo, err))
} }
@@ -344,8 +437,9 @@ func createPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Cal
return to.TextResult(slimPullRequest(pr)) return to.TextResult(slimPullRequest(pr))
} }
func createPullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { type reviewerOp func(client *gitea_sdk.Client, owner, repo string, index int64, opt gitea_sdk.PullReviewRequestOptions) (*gitea_sdk.Response, error)
log.Debugf("Called createPullRequestReviewerFn")
func pullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest, verb string, op reviewerOp) (*mcp.CallToolResult, error) {
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -355,7 +449,7 @@ func createPullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest) (
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(args, "index") index, err := params.GetIndex(args, "pull_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -368,70 +462,31 @@ func createPullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest) (
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
_, err = client.CreateReviewRequests(owner, repo, index, gitea_sdk.PullReviewRequestOptions{ if _, err := op(client, owner, repo, index, gitea_sdk.PullReviewRequestOptions{
Reviewers: reviewers, Reviewers: reviewers,
TeamReviewers: teamReviewers, TeamReviewers: teamReviewers,
}) }); err != nil {
if err != nil { return to.ErrorResult(fmt.Errorf("%s review requests for %v/%v/pr/%v err: %v", verb, owner, repo, index, err))
return to.ErrorResult(fmt.Errorf("create review requests for %v/%v/pr/%v err: %v", owner, repo, index, err))
} }
successMsg := map[string]any{ return to.TextResult(map[string]any{
"message": "Successfully created review requests", "message": fmt.Sprintf("Successfully %sd review requests", verb),
"reviewers": reviewers, "reviewers": reviewers,
"team_reviewers": teamReviewers, "team_reviewers": teamReviewers,
"pr_index": index, "pr_index": index,
"repository": fmt.Sprintf("%s/%s", owner, repo), "repository": fmt.Sprintf("%s/%s", owner, repo),
} })
}
return to.TextResult(successMsg) func createPullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return pullRequestReviewerFn(ctx, req, "create", (*gitea_sdk.Client).CreateReviewRequests)
} }
func deletePullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func deletePullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deletePullRequestReviewerFn") return pullRequestReviewerFn(ctx, req, "delete", (*gitea_sdk.Client).DeleteReviewRequests)
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, "index")
if err != nil {
return to.ErrorResult(err)
}
reviewers := params.GetStringSlice(args, "reviewers")
teamReviewers := params.GetStringSlice(args, "team_reviewers")
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.DeleteReviewRequests(owner, repo, index, gitea_sdk.PullReviewRequestOptions{
Reviewers: reviewers,
TeamReviewers: teamReviewers,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("delete review requests for %v/%v/pr/%v err: %v", owner, repo, index, err))
}
successMsg := map[string]any{
"message": "Successfully deleted review requests",
"reviewers": reviewers,
"team_reviewers": teamReviewers,
"pr_index": index,
"repository": fmt.Sprintf("%s/%s", owner, repo),
}
return to.TextResult(successMsg)
} }
func listPullRequestReviewsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listPullRequestReviewsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listPullRequestReviewsFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -441,7 +496,7 @@ func listPullRequestReviewsFn(ctx context.Context, req mcp.CallToolRequest) (*mc
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(args, "index") index, err := params.GetIndex(args, "pull_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -466,7 +521,6 @@ func listPullRequestReviewsFn(ctx context.Context, req mcp.CallToolRequest) (*mc
} }
func getPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getPullRequestReviewFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -476,7 +530,7 @@ func getPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(args, "index") index, err := params.GetIndex(args, "pull_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -499,7 +553,6 @@ func getPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.
} }
func listPullRequestReviewCommentsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listPullRequestReviewCommentsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listPullRequestReviewCommentsFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -509,7 +562,7 @@ func listPullRequestReviewCommentsFn(ctx context.Context, req mcp.CallToolReques
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(args, "index") index, err := params.GetIndex(args, "pull_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -532,7 +585,6 @@ func listPullRequestReviewCommentsFn(ctx context.Context, req mcp.CallToolReques
} }
func createPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func createPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createPullRequestReviewFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -542,7 +594,7 @@ func createPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*m
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(args, "index") index, err := params.GetIndex(args, "pull_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -597,7 +649,6 @@ func createPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*m
} }
func submitPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func submitPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called submitPullRequestReviewFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -607,7 +658,7 @@ func submitPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*m
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(args, "index") index, err := params.GetIndex(args, "pull_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -641,7 +692,6 @@ func submitPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*m
} }
func deletePullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func deletePullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deletePullRequestReviewFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -651,7 +701,7 @@ func deletePullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*m
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(args, "index") index, err := params.GetIndex(args, "pull_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -681,7 +731,6 @@ func deletePullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*m
} }
func dismissPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func dismissPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called dismissPullRequestReviewFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -691,7 +740,7 @@ func dismissPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(args, "index") index, err := params.GetIndex(args, "pull_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -726,7 +775,6 @@ func dismissPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*
} }
func mergePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func mergePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called mergePullRequestFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -736,7 +784,7 @@ func mergePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(args, "index") index, err := params.GetIndex(args, "pull_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -751,11 +799,18 @@ func mergePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
forceMerge, _ := args["force_merge"].(bool)
mergeWhenChecksSucceed, _ := args["merge_when_checks_succeed"].(bool)
headCommitID, _ := args["head_commit_id"].(string)
opt := gitea_sdk.MergePullRequestOption{ opt := gitea_sdk.MergePullRequestOption{
Style: gitea_sdk.MergeStyle(mergeStyle), Style: gitea_sdk.MergeStyle(mergeStyle),
Title: title, Title: title,
Message: message, Message: message,
DeleteBranchAfterMerge: deleteBranch, DeleteBranchAfterMerge: deleteBranch,
ForceMerge: forceMerge,
MergeWhenChecksSucceed: mergeWhenChecksSucceed,
HeadCommitId: headCommitID,
} }
merged, resp, err := client.MergePullRequest(owner, repo, index, opt) merged, resp, err := client.MergePullRequest(owner, repo, index, opt)
@@ -783,7 +838,6 @@ func mergePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
} }
func editPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func editPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editPullRequestFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -793,7 +847,7 @@ func editPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(args, "index") index, err := params.GetIndex(args, "pull_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -819,9 +873,10 @@ func editPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
} }
opt.Title = applyDraftPrefix(opt.Title, draft) opt.Title = applyDraftPrefix(opt.Title, draft)
} }
if body, ok := args["body"].(string); ok { opt.Body = params.GetPresentStringPtr(args, "body")
opt.Body = new(body) opt.AllowMaintainerEdit = params.GetOptionalBoolPtr(args, "allow_maintainer_edit")
} opt.RemoveDeadline = params.GetOptionalBoolPtr(args, "remove_deadline")
opt.Deadline = params.GetOptionalTime(args, "deadline")
if base, ok := args["base"].(string); ok { if base, ok := args["base"].(string); ok {
opt.Base = base opt.Base = base
} }
@@ -837,10 +892,11 @@ func editPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
} }
} }
if state, ok := args["state"].(string); ok { if state, ok := args["state"].(string); ok {
opt.State = new(gitea_sdk.StateType(state)) s := gitea_sdk.StateType(state)
opt.State = &s
} }
if allowMaintainerEdit, ok := args["allow_maintainer_edit"].(bool); ok { if labelIDs, err := params.GetInt64Slice(args, "labels"); err == nil {
opt.AllowMaintainerEdit = new(allowMaintainerEdit) opt.Labels = labelIDs
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -855,3 +911,86 @@ func editPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
return to.TextResult(slimPullRequest(pr)) return to.TextResult(slimPullRequest(pr))
} }
func updatePullRequestBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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) {
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) {
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)
}
+445 -13
View File
@@ -6,10 +6,12 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"sync" "sync"
"testing" "testing"
"gitea.com/gitea/gitea-mcp/pkg/flag" "gitea.com/gitea/gitea-mcp/pkg/flag"
"github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/mcp"
) )
@@ -78,11 +80,11 @@ func Test_editPullRequestFn(t *testing.T) {
req := mcp.CallToolRequest{ req := mcp.CallToolRequest{
Params: mcp.CallToolParams{ Params: mcp.CallToolParams{
Arguments: map[string]any{ Arguments: map[string]any{
"owner": owner, "owner": owner,
"repo": repo, "repo": repo,
"index": ii.val, "pull_number": ii.val,
"title": "WIP: my feature", "title": "WIP: my feature",
"state": "open", "state": "open",
}, },
}, },
} }
@@ -193,7 +195,7 @@ func Test_mergePullRequestFn(t *testing.T) {
Arguments: map[string]any{ Arguments: map[string]any{
"owner": owner, "owner": owner,
"repo": repo, "repo": repo,
"index": ii.val, "pull_number": ii.val,
"merge_style": "squash", "merge_style": "squash",
"title": "feat: my squashed commit", "title": "feat: my squashed commit",
"message": "Squash merge of PR #5", "message": "Squash merge of PR #5",
@@ -254,6 +256,168 @@ func Test_mergePullRequestFn(t *testing.T) {
} }
} }
func Test_mergePullRequestFn_newParams(t *testing.T) {
const (
owner = "octo"
repo = "demo"
index = 8
)
var (
mu sync.Mutex
gotBody map[string]any
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"private":false}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner, repo, index):
mu.Lock()
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
gotBody = body
mu.Unlock()
w.WriteHeader(http.StatusOK)
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost := flag.Host
origToken := flag.Token
origVersion := flag.Version
flag.Host = server.URL
flag.Token = ""
flag.Version = "test"
defer func() {
flag.Host = origHost
flag.Token = origToken
flag.Version = origVersion
}()
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: map[string]any{
"owner": owner,
"repo": repo,
"pull_number": float64(index),
"merge_style": "merge",
"force_merge": true,
"merge_when_checks_succeed": true,
"head_commit_id": "abc123",
},
},
}
_, err := mergePullRequestFn(context.Background(), req)
if err != nil {
t.Fatalf("mergePullRequestFn() error = %v", err)
}
mu.Lock()
defer mu.Unlock()
if gotBody["force_merge"] != true {
t.Fatalf("expected force_merge true, got %v", gotBody["force_merge"])
}
if gotBody["merge_when_checks_succeed"] != true {
t.Fatalf("expected merge_when_checks_succeed true, got %v", gotBody["merge_when_checks_succeed"])
}
if gotBody["head_commit_id"] != "abc123" {
t.Fatalf("expected head_commit_id 'abc123', got %v", gotBody["head_commit_id"])
}
}
func Test_createPullRequestFn_labels(t *testing.T) {
const (
owner = "octo"
repo = "demo"
)
var (
mu sync.Mutex
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", owner, repo):
mu.Lock()
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
gotBody = body
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":1,"title":"test","state":"open"}`))
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost := flag.Host
origToken := flag.Token
origVersion := flag.Version
flag.Host = server.URL
flag.Token = ""
flag.Version = "test"
defer func() {
flag.Host = origHost
flag.Token = origToken
flag.Version = origVersion
}()
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: map[string]any{
"owner": owner,
"repo": repo,
"title": "test",
"body": "body",
"head": "feature",
"base": "main",
"labels": []any{float64(1), float64(2)},
"deadline": "2026-06-01T00:00:00Z",
},
},
}
_, err := createPullRequestFn(context.Background(), req)
if err != nil {
t.Fatalf("createPullRequestFn() error = %v", err)
}
mu.Lock()
defer mu.Unlock()
labels, ok := gotBody["labels"].([]any)
if !ok || len(labels) != 2 {
t.Fatalf("expected 2 labels, got %v", gotBody["labels"])
}
if labels[0] != float64(1) || labels[1] != float64(2) {
t.Fatalf("expected labels [1,2], got %v", labels)
}
if gotBody["due_date"] == nil {
t.Fatalf("expected due_date to be set")
}
}
func Test_applyDraftPrefix(t *testing.T) { func Test_applyDraftPrefix(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -452,9 +616,9 @@ func Test_editPullRequestFn_draft(t *testing.T) {
}() }()
args := map[string]any{ args := map[string]any{
"owner": owner, "owner": owner,
"repo": repo, "repo": repo,
"index": float64(index), "pull_number": float64(index),
} }
if tc.title != "" { if tc.title != "" {
args["title"] = tc.title args["title"] = tc.title
@@ -556,10 +720,10 @@ func Test_getPullRequestDiffFn(t *testing.T) {
req := mcp.CallToolRequest{ req := mcp.CallToolRequest{
Params: mcp.CallToolParams{ Params: mcp.CallToolParams{
Arguments: map[string]any{ Arguments: map[string]any{
"owner": owner, "owner": owner,
"repo": repo, "repo": repo,
"index": ii.val, "pull_number": ii.val,
"binary": true, "binary": true,
}, },
}, },
} }
@@ -607,3 +771,271 @@ func Test_getPullRequestDiffFn(t *testing.T) {
}) })
} }
} }
func Test_getPullRequestByIndexFn_includesAttachments(t *testing.T) {
const (
owner = "octo"
repo = "demo"
index = 9
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"private":false}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":9,"title":"feat","body":"see screenshot","state":"open"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets", owner, repo, index):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[{"id":1,"name":"shot.png","browser_download_url":"https://example/shot.png"}]`))
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version
flag.Host, flag.Token, flag.Version = server.URL, "", "test"
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{
"owner": owner, "repo": repo, "pull_number": float64(index),
}}}
res, err := getPullRequestByIndexFn(context.Background(), req)
if err != nil {
t.Fatalf("getPullRequestByIndexFn() error = %v", err)
}
if res.IsError {
t.Fatalf("unexpected error result: %v", res.Content)
}
body := res.Content[0].(mcp.TextContent).Text
if !strings.Contains(body, `[shot.png](https://example/shot.png)`) {
t.Fatalf("expected attachment markdown inlined in body, got: %s", body)
}
}
func Test_getPullRequestByIndexFn_emptyAssetsLeavesBody(t *testing.T) {
const (
owner = "octo"
repo = "demo"
index = 9
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"private":false}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":9,"title":"feat","body":"plain body","state":"open"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets", owner, repo, index):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version
flag.Host, flag.Token, flag.Version = server.URL, "", "test"
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{
"owner": owner, "repo": repo, "pull_number": float64(index),
}}}
res, err := getPullRequestByIndexFn(context.Background(), req)
if err != nil {
t.Fatalf("getPullRequestByIndexFn() error = %v", err)
}
body := res.Content[0].(mcp.TextContent).Text
if !strings.Contains(body, `"body":"plain body"`) {
t.Fatalf("expected body unchanged when assets are empty, got: %s", body)
}
}
func Test_getPullRequestByIndexFn_assetsFailureNonFatal(t *testing.T) {
const (
owner = "octo"
repo = "demo"
index = 9
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"private":false}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number":9,"title":"feat","body":"plain body","state":"open"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets", owner, repo, index):
http.Error(w, "boom", http.StatusInternalServerError)
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version
flag.Host, flag.Token, flag.Version = server.URL, "", "test"
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{
"owner": owner, "repo": repo, "pull_number": float64(index),
}}}
res, err := getPullRequestByIndexFn(context.Background(), req)
if err != nil {
t.Fatalf("getPullRequestByIndexFn() error = %v", err)
}
if res.IsError {
t.Fatalf("assets fetch failure should not fail the PR fetch: %v", res.Content)
}
body := res.Content[0].(mcp.TextContent).Text
if !strings.Contains(body, `"plain body"`) {
t.Fatalf("expected PR body preserved when assets fail, got: %s", body)
}
}
func Test_closePullRequestFn(t *testing.T) {
const (
owner = "octo"
repo = "demo"
index = 7
)
var gotBody map[string]any
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"private":false}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index):
if r.Method != http.MethodPatch {
t.Errorf("expected PATCH method, got %s", r.Method)
}
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(fmt.Appendf(nil, `{"index":%d,"title":"Fix bug","state":"closed","head":{"ref":"fix-branch"},"base":{"ref":"main"}}`, index))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
})
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
origHost := flag.Host
origToken := flag.Token
flag.Host = server.URL
flag.Token = "test-token"
t.Cleanup(func() { flag.Host = origHost; flag.Token = origToken })
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: map[string]any{
"method": "close",
"owner": owner,
"repo": repo,
"pull_number": float64(index),
},
},
}
result, err := closePullRequestFn(context.Background(), req)
if err != nil {
t.Fatalf("closePullRequestFn() error = %v", err)
}
if gotBody["state"] != "closed" {
t.Errorf("expected state=closed, got %v", gotBody["state"])
}
if len(result.Content) == 0 {
t.Fatalf("expected content in result")
}
}
func Test_reopenPullRequestFn(t *testing.T) {
const (
owner = "octo"
repo = "demo"
index = 7
)
var gotBody map[string]any
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"private":false}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index):
if r.Method != http.MethodPatch {
t.Errorf("expected PATCH method, got %s", r.Method)
}
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(fmt.Appendf(nil, `{"index":%d,"title":"Fix bug","state":"open","head":{"ref":"fix-branch"},"base":{"ref":"main"}}`, index))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
})
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
origHost := flag.Host
origToken := flag.Token
flag.Host = server.URL
flag.Token = "test-token"
t.Cleanup(func() { flag.Host = origHost; flag.Token = origToken })
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: map[string]any{
"method": "reopen",
"owner": owner,
"repo": repo,
"pull_number": float64(index),
},
},
}
result, err := reopenPullRequestFn(context.Background(), req)
if err != nil {
t.Fatalf("reopenPullRequestFn() error = %v", err)
}
if gotBody["state"] != "open" {
t.Errorf("expected state=open, got %v", gotBody["state"])
}
if len(result.Content) == 0 {
t.Fatalf("expected content in result")
}
}
+10 -41
View File
@@ -1,42 +1,11 @@
package pull package pull
import ( import (
"gitea.com/gitea/gitea-mcp/pkg/slim"
gitea_sdk "code.gitea.io/sdk/gitea" gitea_sdk "code.gitea.io/sdk/gitea"
) )
func userLogin(u *gitea_sdk.User) string {
if u == nil {
return ""
}
return u.UserName
}
func userLogins(users []*gitea_sdk.User) []string {
if len(users) == 0 {
return nil
}
out := make([]string, 0, len(users))
for _, u := range users {
if u != nil {
out = append(out, u.UserName)
}
}
return out
}
func labelNames(labels []*gitea_sdk.Label) []string {
if len(labels) == 0 {
return nil
}
out := make([]string, 0, len(labels))
for _, l := range labels {
if l != nil {
out = append(out, l.Name)
}
}
return out
}
func repoRef(r *gitea_sdk.Repository) map[string]any { func repoRef(r *gitea_sdk.Repository) map[string]any {
if r == nil { if r == nil {
return nil return nil
@@ -60,8 +29,8 @@ func slimPullRequest(pr *gitea_sdk.PullRequest) map[string]any {
"merged": pr.HasMerged, "merged": pr.HasMerged,
"mergeable": pr.Mergeable, "mergeable": pr.Mergeable,
"html_url": pr.HTMLURL, "html_url": pr.HTMLURL,
"user": userLogin(pr.Poster), "user": slim.UserLogin(pr.Poster),
"labels": labelNames(pr.Labels), "labels": slim.LabelNames(pr.Labels),
"comments": pr.Comments, "comments": pr.Comments,
"created_at": pr.Created, "created_at": pr.Created,
"updated_at": pr.Updated, "updated_at": pr.Updated,
@@ -70,7 +39,7 @@ func slimPullRequest(pr *gitea_sdk.PullRequest) map[string]any {
if pr.HasMerged { if pr.HasMerged {
m["merged_at"] = pr.Merged m["merged_at"] = pr.Merged
m["merge_commit_sha"] = pr.MergedCommitID m["merge_commit_sha"] = pr.MergedCommitID
m["merged_by"] = userLogin(pr.MergedBy) m["merged_by"] = slim.UserLogin(pr.MergedBy)
} }
if pr.Head != nil { if pr.Head != nil {
head := map[string]any{"ref": pr.Head.Ref, "sha": pr.Head.Sha} head := map[string]any{"ref": pr.Head.Ref, "sha": pr.Head.Sha}
@@ -96,7 +65,7 @@ func slimPullRequest(pr *gitea_sdk.PullRequest) map[string]any {
m["changed_files"] = *pr.ChangedFiles m["changed_files"] = *pr.ChangedFiles
} }
if len(pr.Assignees) > 0 { if len(pr.Assignees) > 0 {
m["assignees"] = userLogins(pr.Assignees) m["assignees"] = slim.UserLogins(pr.Assignees)
} }
if pr.Milestone != nil { if pr.Milestone != nil {
m["milestone"] = pr.Milestone.Title m["milestone"] = pr.Milestone.Title
@@ -120,7 +89,7 @@ func slimPullRequests(prs []*gitea_sdk.PullRequest) []map[string]any {
"draft": pr.Draft, "draft": pr.Draft,
"merged": pr.HasMerged, "merged": pr.HasMerged,
"html_url": pr.HTMLURL, "html_url": pr.HTMLURL,
"user": userLogin(pr.Poster), "user": slim.UserLogin(pr.Poster),
"created_at": pr.Created, "created_at": pr.Created,
"updated_at": pr.Updated, "updated_at": pr.Updated,
} }
@@ -131,7 +100,7 @@ func slimPullRequests(prs []*gitea_sdk.PullRequest) []map[string]any {
m["base"] = pr.Base.Ref m["base"] = pr.Base.Ref
} }
if len(pr.Labels) > 0 { if len(pr.Labels) > 0 {
m["labels"] = labelNames(pr.Labels) m["labels"] = slim.LabelNames(pr.Labels)
} }
out = append(out, m) out = append(out, m)
} }
@@ -146,7 +115,7 @@ func slimReview(r *gitea_sdk.PullReview) map[string]any {
"id": r.ID, "id": r.ID,
"state": r.State, "state": r.State,
"body": r.Body, "body": r.Body,
"user": userLogin(r.Reviewer), "user": slim.UserLogin(r.Reviewer),
"comments_count": r.CodeCommentsCount, "comments_count": r.CodeCommentsCount,
"submitted_at": r.Submitted, "submitted_at": r.Submitted,
"html_url": r.HTMLURL, "html_url": r.HTMLURL,
@@ -175,7 +144,7 @@ func slimReviewComment(c *gitea_sdk.PullReviewComment) map[string]any {
"position": c.LineNum, "position": c.LineNum,
"old_position": c.OldLineNum, "old_position": c.OldLineNum,
"diff_hunk": c.DiffHunk, "diff_hunk": c.DiffHunk,
"user": userLogin(c.Reviewer), "user": slim.UserLogin(c.Reviewer),
"html_url": c.HTMLURL, "html_url": c.HTMLURL,
"created_at": c.Created, "created_at": c.Created,
"updated_at": c.Updated, "updated_at": c.Updated,
+19 -19
View File
@@ -4,8 +4,8 @@ import (
"context" "context"
"fmt" "fmt"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params" "gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
@@ -23,26 +23,28 @@ const (
var ( var (
CreateBranchTool = mcp.NewTool( CreateBranchTool = mcp.NewTool(
CreateBranchToolName, CreateBranchToolName,
mcp.WithDescription("Create branch"), mcp.WithToolAnnotation(annotation.Write("Create a new branch")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("branch", mcp.Required(), mcp.Description("Name of the branch to create")), mcp.WithString("branch", mcp.Required()),
mcp.WithString("old_branch", mcp.Required(), mcp.Description("Name of the old branch to create from")), mcp.WithString("old_branch", mcp.Description("source branch (default: repo default)")),
) )
DeleteBranchTool = mcp.NewTool( DeleteBranchTool = mcp.NewTool(
DeleteBranchToolName, DeleteBranchToolName,
mcp.WithDescription("Delete branch"), mcp.WithToolAnnotation(annotation.Destructive("Delete a branch")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("branch", mcp.Required(), mcp.Description("Name of the branch to delete")), mcp.WithString("branch", mcp.Required()),
) )
ListBranchesTool = mcp.NewTool( ListBranchesTool = mcp.NewTool(
ListBranchesToolName, ListBranchesToolName,
mcp.WithDescription("List branches"), mcp.WithToolAnnotation(annotation.ReadOnly("List repository branches")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
) )
) )
@@ -62,7 +64,6 @@ func init() {
} }
func CreateBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func CreateBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateBranchFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -90,11 +91,10 @@ func CreateBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
return to.ErrorResult(fmt.Errorf("create branch error: %v", err)) return to.ErrorResult(fmt.Errorf("create branch error: %v", err))
} }
return mcp.NewToolResultText("Branch Created"), nil return to.TextResult("Branch Created")
} }
func DeleteBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func DeleteBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteBranchFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -121,7 +121,6 @@ func DeleteBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
} }
func ListBranchesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListBranchesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListBranchesFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -131,10 +130,11 @@ func ListBranchesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
page, pageSize := params.GetPagination(args, 30)
opt := gitea_sdk.ListRepoBranchesOptions{ opt := gitea_sdk.ListRepoBranchesOptions{
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: 1, Page: page,
PageSize: 30, PageSize: pageSize,
}, },
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
+53 -21
View File
@@ -4,8 +4,8 @@ import (
"context" "context"
"fmt" "fmt"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params" "gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
@@ -16,17 +16,28 @@ import (
const ( const (
ListRepoCommitsToolName = "list_commits" ListRepoCommitsToolName = "list_commits"
GetCommitToolName = "get_commit"
) )
var ListRepoCommitsTool = mcp.NewTool( var (
ListRepoCommitsToolName, ListRepoCommitsTool = mcp.NewTool(
mcp.WithDescription("List repository commits"), ListRepoCommitsToolName,
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithToolAnnotation(annotation.ReadOnly("List repository commits")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("sha", mcp.Description("SHA or branch to start listing commits from")), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("path", mcp.Description("path indicates that only commits that include the path's file/dir should be returned.")), mcp.WithString("sha", mcp.Description("starting SHA or branch")),
mcp.WithNumber("page", mcp.Required(), mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)), mcp.WithString("path", mcp.Description("only commits touching this path")),
mcp.WithNumber("perPage", mcp.Required(), mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)), mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30), mcp.Min(1)),
)
GetCommitTool = mcp.NewTool(
GetCommitToolName,
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()),
)
) )
func init() { func init() {
@@ -34,10 +45,13 @@ func init() {
Tool: ListRepoCommitsTool, Tool: ListRepoCommitsTool,
Handler: ListRepoCommitsFn, Handler: ListRepoCommitsFn,
}) })
Tool.RegisterRead(server.ServerTool{
Tool: GetCommitTool,
Handler: GetCommitFn,
})
} }
func ListRepoCommitsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListRepoCommitsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListRepoCommitsFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -47,20 +61,13 @@ func ListRepoCommitsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
page, err := params.GetIndex(args, "page") page, pageSize := params.GetPagination(args, 30)
if err != nil {
return to.ErrorResult(err)
}
pageSize, err := params.GetIndex(args, "perPage")
if err != nil {
return to.ErrorResult(err)
}
sha, _ := args["sha"].(string) sha, _ := args["sha"].(string)
path, _ := args["path"].(string) path, _ := args["path"].(string)
opt := gitea_sdk.ListCommitOptions{ opt := gitea_sdk.ListCommitOptions{
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: int(page), Page: page,
PageSize: int(pageSize), PageSize: pageSize,
}, },
SHA: sha, SHA: sha,
Path: path, Path: path,
@@ -75,3 +82,28 @@ func ListRepoCommitsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
} }
return to.TextResult(slimCommits(commits)) return to.TextResult(slimCommits(commits))
} }
func GetCommitFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
sha, err := params.GetString(args, "sha")
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))
}
commit, _, err := client.GetSingleCommit(owner, repo, sha)
if err != nil {
return to.ErrorResult(fmt.Errorf("get commit %v err: %v", sha, err))
}
return to.TextResult(slimCommit(commit))
}
+32 -34
View File
@@ -8,8 +8,8 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params" "gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
@@ -28,45 +28,47 @@ const (
var ( var (
GetFileContentTool = mcp.NewTool( GetFileContentTool = mcp.NewTool(
GetFileToolName, GetFileToolName,
mcp.WithDescription("Get file Content and Metadata"), mcp.WithDescription("Get file content and metadata"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithToolAnnotation(annotation.ReadOnly("Get file content")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("ref", mcp.Required(), mcp.Description("ref can be branch/tag/commit")), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("filePath", mcp.Required(), mcp.Description("file path")), mcp.WithString("ref", mcp.Required(), mcp.Description("branch, tag, or commit SHA")),
mcp.WithBoolean("withLines", mcp.Description("whether to return file content with lines")), mcp.WithString("path", mcp.Required()),
mcp.WithBoolean("withLines", mcp.Description("return numbered lines")),
) )
GetDirContentTool = mcp.NewTool( GetDirContentTool = mcp.NewTool(
GetDirToolName, GetDirToolName,
mcp.WithDescription("Get a list of entries in a directory"), mcp.WithToolAnnotation(annotation.ReadOnly("Get directory contents")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("ref", mcp.Required(), mcp.Description("ref can be branch/tag/commit")), mcp.WithString("ref", mcp.Required(), mcp.Description("branch, tag, or commit SHA")),
mcp.WithString("filePath", mcp.Required(), mcp.Description("directory path")), mcp.WithString("path", mcp.Required()),
) )
CreateOrUpdateFileTool = mcp.NewTool( CreateOrUpdateFileTool = mcp.NewTool(
CreateOrUpdateFileToolName, CreateOrUpdateFileToolName,
mcp.WithDescription("Create or update a file. If sha is provided, updates the existing file; otherwise creates a new file."), mcp.WithDescription("Create or update a file (provide sha to update an existing file)."),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithToolAnnotation(annotation.Write("Create or update a file")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("filePath", mcp.Required(), mcp.Description("file path")), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("content", mcp.Required(), mcp.Description("file content")), mcp.WithString("path", mcp.Required()),
mcp.WithString("content", mcp.Required()),
mcp.WithString("message", mcp.Required(), mcp.Description("commit message")), mcp.WithString("message", mcp.Required(), mcp.Description("commit message")),
mcp.WithString("branch_name", mcp.Required(), mcp.Description("branch name")), mcp.WithString("branch_name", mcp.Required()),
mcp.WithString("sha", mcp.Description("SHA of the existing file (required for update, omit for create)")), mcp.WithString("sha", mcp.Description("existing file SHA (omit to create)")),
mcp.WithString("new_branch_name", mcp.Description("new branch name (for create only)")), mcp.WithString("new_branch_name", mcp.Description("new branch (create only)")),
) )
DeleteFileTool = mcp.NewTool( DeleteFileTool = mcp.NewTool(
DeleteFileToolName, DeleteFileToolName,
mcp.WithDescription("Delete file"), mcp.WithToolAnnotation(annotation.Destructive("Delete a file")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("filePath", mcp.Required(), mcp.Description("file path")), mcp.WithString("path", mcp.Required()),
mcp.WithString("message", mcp.Required(), mcp.Description("commit message")), mcp.WithString("message", mcp.Required(), mcp.Description("commit message")),
mcp.WithString("branch_name", mcp.Required(), mcp.Description("branch name")), mcp.WithString("branch_name", mcp.Required()),
mcp.WithString("sha", mcp.Required(), mcp.Description("sha")), mcp.WithString("sha", mcp.Required()),
) )
) )
@@ -95,7 +97,6 @@ type ContentLine struct {
} }
func GetFileContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetFileContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetFileFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -106,7 +107,7 @@ func GetFileContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
return to.ErrorResult(err) return to.ErrorResult(err)
} }
ref, _ := args["ref"].(string) ref, _ := args["ref"].(string)
filePath, err := params.GetString(args, "filePath") filePath, err := params.GetString(args, "path")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -159,7 +160,6 @@ func GetFileContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
} }
func GetDirContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetDirContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetDirContentFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -170,7 +170,7 @@ func GetDirContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
return to.ErrorResult(err) return to.ErrorResult(err)
} }
ref, _ := args["ref"].(string) ref, _ := args["ref"].(string)
filePath, err := params.GetString(args, "filePath") filePath, err := params.GetString(args, "path")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -186,7 +186,6 @@ func GetDirContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
} }
func CreateOrUpdateFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func CreateOrUpdateFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateOrUpdateFileFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -196,7 +195,7 @@ func CreateOrUpdateFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
filePath, err := params.GetString(args, "filePath") filePath, err := params.GetString(args, "path")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -246,7 +245,6 @@ func CreateOrUpdateFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
} }
func DeleteFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func DeleteFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteFileFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -256,7 +254,7 @@ func DeleteFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRe
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
filePath, err := params.GetString(args, "filePath") filePath, err := params.GetString(args, "path")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
+40 -55
View File
@@ -4,8 +4,8 @@ import (
"context" "context"
"fmt" "fmt"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params" "gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
@@ -25,49 +25,50 @@ const (
var ( var (
CreateReleaseTool = mcp.NewTool( CreateReleaseTool = mcp.NewTool(
CreateReleaseToolName, CreateReleaseToolName,
mcp.WithDescription("Create release"), mcp.WithToolAnnotation(annotation.Write("Create a release")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("tag_name", mcp.Required(), mcp.Description("tag name")), mcp.WithString("tag_name", mcp.Required()),
mcp.WithString("target", mcp.Required(), mcp.Description("target commitish")), mcp.WithString("target", mcp.Required(), mcp.Description("commitish")),
mcp.WithString("title", mcp.Required(), mcp.Description("release title")), mcp.WithString("title", mcp.Required()),
mcp.WithBoolean("is_draft", mcp.Description("Whether the release is draft"), mcp.DefaultBool(false)), mcp.WithBoolean("is_draft"),
mcp.WithBoolean("is_pre_release", mcp.Description("Whether the release is pre-release"), mcp.DefaultBool(false)), mcp.WithBoolean("is_pre_release"),
mcp.WithString("body", mcp.Description("release body")), mcp.WithString("body"),
) )
DeleteReleaseTool = mcp.NewTool( DeleteReleaseTool = mcp.NewTool(
DeleteReleaseToolName, DeleteReleaseToolName,
mcp.WithDescription("Delete release"), mcp.WithToolAnnotation(annotation.Destructive("Delete a release")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("id", mcp.Required(), mcp.Description("release id")), mcp.WithNumber("id", mcp.Required()),
) )
GetReleaseTool = mcp.NewTool( GetReleaseTool = mcp.NewTool(
GetReleaseToolName, GetReleaseToolName,
mcp.WithDescription("Get release"), mcp.WithDescription("Get a release by ID"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithToolAnnotation(annotation.ReadOnly("Get release details")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithNumber("id", mcp.Required(), mcp.Description("release id")), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("id", mcp.Required()),
) )
GetLatestReleaseTool = mcp.NewTool( GetLatestReleaseTool = mcp.NewTool(
GetLatestReleaseToolName, GetLatestReleaseToolName,
mcp.WithDescription("Get latest release"), mcp.WithToolAnnotation(annotation.ReadOnly("Get latest release")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
) )
ListReleasesTool = mcp.NewTool( ListReleasesTool = mcp.NewTool(
ListReleasesToolName, ListReleasesToolName,
mcp.WithDescription("List releases"), mcp.WithToolAnnotation(annotation.ReadOnly("List releases")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithBoolean("is_draft", mcp.Description("Whether the release is draft"), mcp.DefaultBool(false)), mcp.WithBoolean("is_draft"),
mcp.WithBoolean("is_pre_release", mcp.Description("Whether the release is pre-release"), mcp.DefaultBool(false)), mcp.WithBoolean("is_pre_release"),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)), mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(20), mcp.Min(1)), mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(20), mcp.Min(1)),
) )
) )
@@ -95,7 +96,6 @@ func init() {
} }
func CreateReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func CreateReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateReleasesFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -134,14 +134,13 @@ func CreateReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
IsPrerelease: isPreRelease, IsPrerelease: isPreRelease,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("create release error: %v", err) return to.ErrorResult(fmt.Errorf("create release error: %v", err))
} }
return mcp.NewToolResultText("Release Created"), nil return to.TextResult("Release Created")
} }
func DeleteReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func DeleteReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteReleaseFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -162,14 +161,13 @@ func DeleteReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
} }
_, err = client.DeleteRelease(owner, repo, id) _, err = client.DeleteRelease(owner, repo, id)
if err != nil { if err != nil {
return nil, fmt.Errorf("delete release error: %v", err) return to.ErrorResult(fmt.Errorf("delete release error: %v", err))
} }
return to.TextResult("Release deleted successfully") return to.TextResult("Release deleted successfully")
} }
func GetReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetReleaseFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -190,14 +188,13 @@ func GetReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRe
} }
release, _, err := client.GetRelease(owner, repo, id) release, _, err := client.GetRelease(owner, repo, id)
if err != nil { if err != nil {
return nil, fmt.Errorf("get release error: %v", err) return to.ErrorResult(fmt.Errorf("get release error: %v", err))
} }
return to.TextResult(slimRelease(release)) return to.TextResult(slimRelease(release))
} }
func GetLatestReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetLatestReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetLatestReleaseFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -214,14 +211,13 @@ func GetLatestReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
} }
release, _, err := client.GetLatestRelease(owner, repo) release, _, err := client.GetLatestRelease(owner, repo)
if err != nil { if err != nil {
return nil, fmt.Errorf("get latest release error: %v", err) return to.ErrorResult(fmt.Errorf("get latest release error: %v", err))
} }
return to.TextResult(slimRelease(release)) return to.TextResult(slimRelease(release))
} }
func ListReleasesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListReleasesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListReleasesFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -231,18 +227,7 @@ func ListReleasesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
var pIsDraft *bool page, pageSize := params.GetPagination(args, 20)
isDraft, ok := args["is_draft"].(bool)
if ok {
pIsDraft = new(isDraft)
}
var pIsPreRelease *bool
isPreRelease, ok := args["is_pre_release"].(bool)
if ok {
pIsPreRelease = new(isPreRelease)
}
page := params.GetOptionalInt(args, "page", 1)
pageSize := params.GetOptionalInt(args, "perPage", 20)
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
@@ -250,14 +235,14 @@ func ListReleasesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
} }
releases, _, err := client.ListReleases(owner, repo, gitea_sdk.ListReleasesOptions{ releases, _, err := client.ListReleases(owner, repo, gitea_sdk.ListReleasesOptions{
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: int(page), Page: page,
PageSize: int(pageSize), PageSize: pageSize,
}, },
IsDraft: pIsDraft, IsDraft: params.GetOptionalBoolPtr(args, "is_draft"),
IsPreRelease: pIsPreRelease, IsPreRelease: params.GetOptionalBoolPtr(args, "is_pre_release"),
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("list releases error: %v", err) return to.ErrorResult(fmt.Errorf("list releases error: %v", err))
} }
return to.TextResult(slimReleases(releases)) return to.TextResult(slimReleases(releases))
+52 -67
View File
@@ -2,12 +2,12 @@ package repo
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params" "gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/slim"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
@@ -28,42 +28,44 @@ const (
var ( var (
CreateRepoTool = mcp.NewTool( CreateRepoTool = mcp.NewTool(
CreateRepoToolName, CreateRepoToolName,
mcp.WithDescription("Create repository in personal account or organization"), mcp.WithToolAnnotation(annotation.Write("Create a new repository")),
mcp.WithString("name", mcp.Required(), mcp.Description("Name of the repository to create")), mcp.WithString("name", mcp.Required()),
mcp.WithString("description", mcp.Description("Description of the repository to create")), mcp.WithString("description"),
mcp.WithBoolean("private", mcp.Description("Whether the repository is private")), mcp.WithBoolean("private"),
mcp.WithString("issue_labels", mcp.Description("Issue Label set to use")), mcp.WithString("issue_labels"),
mcp.WithBoolean("auto_init", mcp.Description("Whether the repository should be auto-intialized?")), mcp.WithBoolean("auto_init"),
mcp.WithBoolean("template", mcp.Description("Whether the repository is template")), mcp.WithBoolean("template"),
mcp.WithString("gitignores", mcp.Description("Gitignores to use")), mcp.WithString("gitignores"),
mcp.WithString("license", mcp.Description("License to use")), mcp.WithString("license"),
mcp.WithString("readme", mcp.Description("Readme of the repository to create")), mcp.WithString("readme"),
mcp.WithString("default_branch", mcp.Description("DefaultBranch of the repository (used when initializes and in template)")), mcp.WithString("default_branch"),
mcp.WithString("organization", mcp.Description("Organization name to create repository in (optional - defaults to personal account)")), 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( ForkRepoTool = mcp.NewTool(
ForkRepoToolName, ForkRepoToolName,
mcp.WithDescription("Fork repository"), mcp.WithToolAnnotation(annotation.Write("Fork a repository")),
mcp.WithString("user", mcp.Required(), mcp.Description("User name of the repository to fork")), mcp.WithString("user", mcp.Required(), mcp.Description("owner of source repo")),
mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name to fork")), mcp.WithString("repo", mcp.Required()),
mcp.WithString("organization", mcp.Description("Organization name to fork")), mcp.WithString("organization", mcp.Description("target org")),
mcp.WithString("name", mcp.Description("Name of the forked repository")), mcp.WithString("name", mcp.Description("fork name")),
) )
ListMyReposTool = mcp.NewTool( ListMyReposTool = mcp.NewTool(
ListMyReposToolName, ListMyReposToolName,
mcp.WithDescription("List my repositories"), mcp.WithToolAnnotation(annotation.ReadOnly("List my repositories")),
mcp.WithNumber("page", mcp.Required(), mcp.Description("Page number"), mcp.DefaultNumber(1), mcp.Min(1)), mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Required(), mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)), mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30), mcp.Min(1)),
) )
ListOrgReposTool = mcp.NewTool( ListOrgReposTool = mcp.NewTool(
ListOrgReposToolName, ListOrgReposToolName,
mcp.WithDescription("List repositories of an organization"), mcp.WithToolAnnotation(annotation.ReadOnly("List organization repositories")),
mcp.WithString("org", mcp.Required(), mcp.Description("Organization name")), mcp.WithString("org", mcp.Required()),
mcp.WithNumber("page", mcp.Required(), mcp.Description("Page number"), mcp.DefaultNumber(1), mcp.Min(1)), mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("pageSize", mcp.Required(), mcp.Description("Page size number"), mcp.DefaultNumber(100), mcp.Min(1)), mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(100), mcp.Min(1)),
) )
) )
@@ -87,7 +89,6 @@ func init() {
} }
func CreateRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func CreateRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateRepoFn")
args := req.GetArguments() args := req.GetArguments()
name, err := params.GetString(args, "name") name, err := params.GetString(args, "name")
if err != nil { if err != nil {
@@ -102,19 +103,23 @@ func CreateRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRe
license, _ := args["license"].(string) license, _ := args["license"].(string)
readme, _ := args["readme"].(string) readme, _ := args["readme"].(string)
defaultBranch, _ := args["default_branch"].(string) defaultBranch, _ := args["default_branch"].(string)
trustModel, _ := args["trust_model"].(string)
objectFormatName, _ := args["object_format_name"].(string)
organization, _ := args["organization"].(string) organization, _ := args["organization"].(string)
opt := gitea_sdk.CreateRepoOption{ opt := gitea_sdk.CreateRepoOption{
Name: name, Name: name,
Description: description, Description: description,
Private: private, Private: private,
IssueLabels: issueLabels, IssueLabels: issueLabels,
AutoInit: autoInit, AutoInit: autoInit,
Template: template, Template: template,
Gitignores: gitignores, Gitignores: gitignores,
License: license, License: license,
Readme: readme, Readme: readme,
DefaultBranch: defaultBranch, DefaultBranch: defaultBranch,
TrustModel: gitea_sdk.TrustModel(trustModel),
ObjectFormatName: objectFormatName,
} }
var repo *gitea_sdk.Repository var repo *gitea_sdk.Repository
@@ -133,11 +138,10 @@ func CreateRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRe
return to.ErrorResult(fmt.Errorf("create repository '%s' err: %v", name, err)) return to.ErrorResult(fmt.Errorf("create repository '%s' err: %v", name, err))
} }
} }
return to.TextResult(slimRepo(repo)) return to.TextResult(slim.Repo(repo))
} }
func ForkRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ForkRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ForkRepoFn")
args := req.GetArguments() args := req.GetArguments()
user, err := params.GetString(args, "user") user, err := params.GetString(args, "user")
if err != nil { if err != nil {
@@ -147,19 +151,9 @@ func ForkRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResu
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
organization, ok := args["organization"].(string)
organizationPtr := new(organization)
if !ok || organization == "" {
organizationPtr = nil
}
name, ok := args["name"].(string)
namePtr := new(name)
if !ok || name == "" {
namePtr = nil
}
opt := gitea_sdk.CreateForkOption{ opt := gitea_sdk.CreateForkOption{
Organization: organizationPtr, Organization: params.GetOptionalStringPtr(args, "organization"),
Name: namePtr, Name: params.GetOptionalStringPtr(args, "name"),
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
@@ -173,7 +167,6 @@ func ForkRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResu
} }
func ListMyReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListMyReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListMyReposFn")
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.ListReposOptions{ opt := gitea_sdk.ListReposOptions{
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
@@ -190,27 +183,19 @@ func ListMyReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR
return to.ErrorResult(fmt.Errorf("list my repositories error: %v", err)) return to.ErrorResult(fmt.Errorf("list my repositories error: %v", err))
} }
return to.TextResult(slimRepos(repos)) return to.TextResult(slim.Repos(repos))
} }
func ListOrgReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListOrgReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListOrgReposFn") org, err := params.GetString(req.GetArguments(), "org")
org, ok := req.GetArguments()["org"].(string) if err != nil {
if !ok { return to.ErrorResult(err)
return to.ErrorResult(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{ opt := gitea_sdk.ListOrgReposOptions{
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: int(page), Page: page,
PageSize: int(pageSize), PageSize: pageSize,
}, },
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
+25 -48
View File
@@ -1,56 +1,11 @@
package repo package repo
import ( import (
"gitea.com/gitea/gitea-mcp/pkg/slim"
gitea_sdk "code.gitea.io/sdk/gitea" gitea_sdk "code.gitea.io/sdk/gitea"
) )
func userLogin(u *gitea_sdk.User) string {
if u == nil {
return ""
}
return u.UserName
}
func slimRepo(r *gitea_sdk.Repository) map[string]any {
if r == nil {
return nil
}
m := map[string]any{
"id": r.ID,
"full_name": r.FullName,
"description": r.Description,
"html_url": r.HTMLURL,
"clone_url": r.CloneURL,
"ssh_url": r.SSHURL,
"default_branch": r.DefaultBranch,
"private": r.Private,
"fork": r.Fork,
"archived": r.Archived,
"language": r.Language,
"stars_count": r.Stars,
"forks_count": r.Forks,
"open_issues_count": r.OpenIssues,
"open_pr_counter": r.OpenPulls,
"created_at": r.Created,
"updated_at": r.Updated,
}
if r.Owner != nil {
m["owner"] = r.Owner.UserName
}
if len(r.Topics) > 0 {
m["topics"] = r.Topics
}
return m
}
func slimRepos(repos []*gitea_sdk.Repository) []map[string]any {
out := make([]map[string]any, 0, len(repos))
for _, r := range repos {
out = append(out, slimRepo(r))
}
return out
}
func slimBranch(b *gitea_sdk.Branch) map[string]any { func slimBranch(b *gitea_sdk.Branch) map[string]any {
if b == nil { if b == nil {
return nil return nil
@@ -144,7 +99,7 @@ func slimRelease(r *gitea_sdk.Release) map[string]any {
"draft": r.IsDraft, "draft": r.IsDraft,
"prerelease": r.IsPrerelease, "prerelease": r.IsPrerelease,
"html_url": r.HTMLURL, "html_url": r.HTMLURL,
"author": userLogin(r.Publisher), "author": slim.UserLogin(r.Publisher),
"created_at": r.CreatedAt, "created_at": r.CreatedAt,
"published_at": r.PublishedAt, "published_at": r.PublishedAt,
} }
@@ -184,6 +139,28 @@ func slimContents(c *gitea_sdk.ContentsResponse) map[string]any {
return m return m
} }
func slimTree(t *gitea_sdk.GitTreeResponse) map[string]any {
if t == nil {
return nil
}
entries := make([]map[string]any, 0, len(t.Entries))
for _, e := range t.Entries {
entries = append(entries, map[string]any{
"path": e.Path,
"mode": e.Mode,
"type": e.Type,
"size": e.Size,
"sha": e.SHA,
})
}
return map[string]any{
"sha": t.SHA,
"truncated": t.Truncated,
"total_count": t.TotalCount,
"tree": entries,
}
}
func slimDirEntries(entries []*gitea_sdk.ContentsResponse) []map[string]any { func slimDirEntries(entries []*gitea_sdk.ContentsResponse) []map[string]any {
out := make([]map[string]any, 0, len(entries)) out := make([]map[string]any, 0, len(entries))
for _, c := range entries { for _, c := range entries {
-33
View File
@@ -6,39 +6,6 @@ import (
gitea_sdk "code.gitea.io/sdk/gitea" gitea_sdk "code.gitea.io/sdk/gitea"
) )
func TestSlimRepo(t *testing.T) {
r := &gitea_sdk.Repository{
ID: 1,
FullName: "org/repo",
Description: "A test repo",
HTMLURL: "https://gitea.com/org/repo",
CloneURL: "https://gitea.com/org/repo.git",
SSHURL: "git@gitea.com:org/repo.git",
DefaultBranch: "main",
Private: false,
Fork: false,
Archived: false,
Language: "Go",
Stars: 10,
Forks: 2,
Owner: &gitea_sdk.User{UserName: "org"},
Topics: []string{"mcp", "gitea"},
}
m := slimRepo(r)
if m["full_name"] != "org/repo" {
t.Errorf("expected full_name org/repo, got %v", m["full_name"])
}
if m["owner"] != "org" {
t.Errorf("expected owner org, got %v", m["owner"])
}
topics := m["topics"].([]string)
if len(topics) != 2 {
t.Errorf("expected 2 topics, got %d", len(topics))
}
}
func TestSlimTag(t *testing.T) { func TestSlimTag(t *testing.T) {
tag := &gitea_sdk.Tag{ tag := &gitea_sdk.Tag{
Name: "v1.0.0", Name: "v1.0.0",
+26 -30
View File
@@ -4,8 +4,8 @@ import (
"context" "context"
"fmt" "fmt"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params" "gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
@@ -24,37 +24,37 @@ const (
var ( var (
CreateTagTool = mcp.NewTool( CreateTagTool = mcp.NewTool(
CreateTagToolName, CreateTagToolName,
mcp.WithDescription("Create tag"), mcp.WithToolAnnotation(annotation.Write("Create a tag")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("tag_name", mcp.Required(), mcp.Description("tag name")), mcp.WithString("tag_name", mcp.Required()),
mcp.WithString("target", mcp.Description("target commitish"), mcp.DefaultString("")), mcp.WithString("target", mcp.Description("commitish")),
mcp.WithString("message", mcp.Description("tag message"), mcp.DefaultString("")), mcp.WithString("message", mcp.Description("tag message")),
) )
DeleteTagTool = mcp.NewTool( DeleteTagTool = mcp.NewTool(
DeleteTagToolName, DeleteTagToolName,
mcp.WithDescription("Delete tag"), mcp.WithToolAnnotation(annotation.Destructive("Delete a tag")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("tag_name", mcp.Required(), mcp.Description("tag name")), mcp.WithString("tag_name", mcp.Required()),
) )
GetTagTool = mcp.NewTool( GetTagTool = mcp.NewTool(
GetTagToolName, GetTagToolName,
mcp.WithDescription("Get tag"), mcp.WithToolAnnotation(annotation.ReadOnly("Get tag details")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("tag_name", mcp.Required(), mcp.Description("tag name")), mcp.WithString("tag_name", mcp.Required()),
) )
ListTagsTool = mcp.NewTool( ListTagsTool = mcp.NewTool(
ListTagsToolName, ListTagsToolName,
mcp.WithDescription("List tags"), mcp.WithToolAnnotation(annotation.ReadOnly("List tags")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)), mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(20), mcp.Min(1)), mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(20), mcp.Min(1)),
) )
) )
@@ -78,7 +78,6 @@ func init() {
} }
func CreateTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func CreateTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateTagFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -105,14 +104,13 @@ func CreateTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRes
Message: message, Message: message,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("create tag error: %v", err) return to.ErrorResult(fmt.Errorf("create tag error: %v", err))
} }
return mcp.NewToolResultText("Tag Created"), nil return to.TextResult("Tag Created")
} }
func DeleteTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func DeleteTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteTagFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -133,14 +131,13 @@ func DeleteTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRes
} }
_, err = client.DeleteTag(owner, repo, tagName) _, err = client.DeleteTag(owner, repo, tagName)
if err != nil { if err != nil {
return nil, fmt.Errorf("delete tag error: %v", err) return to.ErrorResult(fmt.Errorf("delete tag error: %v", err))
} }
return to.TextResult("Tag deleted") return to.TextResult("Tag deleted")
} }
func GetTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetTagFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -161,14 +158,13 @@ func GetTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult
} }
tag, _, err := client.GetTag(owner, repo, tagName) tag, _, err := client.GetTag(owner, repo, tagName)
if err != nil { if err != nil {
return nil, fmt.Errorf("get tag error: %v", err) return to.ErrorResult(fmt.Errorf("get tag error: %v", err))
} }
return to.TextResult(slimTag(tag)) return to.TextResult(slimTag(tag))
} }
func ListTagsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListTagsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListTagsFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -179,7 +175,7 @@ func ListTagsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResu
return to.ErrorResult(err) return to.ErrorResult(err)
} }
page := params.GetOptionalInt(args, "page", 1) page := params.GetOptionalInt(args, "page", 1)
pageSize := params.GetOptionalInt(args, "perPage", 20) pageSize := params.GetOptionalInt(args, "per_page", 20)
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
@@ -192,7 +188,7 @@ func ListTagsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResu
}, },
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("list tags error: %v", err) return to.ErrorResult(fmt.Errorf("list tags error: %v", err))
} }
return to.TextResult(slimTags(tags)) return to.TextResult(slimTags(tags))
+73
View File
@@ -0,0 +1,73 @@
package repo
import (
"context"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
GetRepoTreeToolName = "get_repository_tree"
)
var GetRepoTreeTool = mcp.NewTool(
GetRepoTreeToolName,
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() {
Tool.RegisterRead(server.ServerTool{
Tool: GetRepoTreeTool,
Handler: GetRepoTreeFn,
})
}
func GetRepoTreeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
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)
}
treeSHA, err := params.GetString(args, "tree_sha")
if err != nil {
return to.ErrorResult(err)
}
recursive, _ := args["recursive"].(bool)
page, pageSize := params.GetPagination(args, 30)
opt := gitea_sdk.ListTreeOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
Ref: treeSHA,
Recursive: recursive,
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
tree, _, err := client.GetTrees(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("get repository tree err: %v", err))
}
return to.TextResult(slimTree(tree))
}
+52
View File
@@ -0,0 +1,52 @@
package repo
import (
"slices"
"testing"
gitea_sdk "code.gitea.io/sdk/gitea"
)
func TestSlimTree(t *testing.T) {
tree := &gitea_sdk.GitTreeResponse{
SHA: "abc123",
TotalCount: 2,
Truncated: false,
Entries: []gitea_sdk.GitEntry{
{Path: "src", Mode: "040000", Type: "tree", Size: 0, SHA: "def456"},
{Path: "main.go", Mode: "100644", Type: "blob", Size: 42, SHA: "789abc"},
},
}
m := slimTree(tree)
if m["sha"] != "abc123" {
t.Errorf("expected sha abc123, got %v", m["sha"])
}
if m["total_count"] != 2 {
t.Errorf("expected total_count 2, got %v", m["total_count"])
}
entries := m["tree"].([]map[string]any)
if len(entries) != 2 {
t.Fatalf("expected 2 entries, got %d", len(entries))
}
if entries[0]["path"] != "src" {
t.Errorf("expected first entry path src, got %v", entries[0]["path"])
}
if entries[1]["type"] != "blob" {
t.Errorf("expected second entry type blob, got %v", entries[1]["type"])
}
}
func TestSlimTreeNil(t *testing.T) {
if m := slimTree(nil); m != nil {
t.Errorf("expected nil, got %v", m)
}
}
func TestGetRepoTreeToolRequired(t *testing.T) {
for _, field := range []string{"owner", "repo", "tree_sha"} {
if !slices.Contains(GetRepoTreeTool.InputSchema.Required, field) {
t.Errorf("expected %q to be required", field)
}
}
}
+92 -47
View File
@@ -3,10 +3,12 @@ package search
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params" "gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/slim"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
@@ -21,40 +23,54 @@ const (
SearchUsersToolName = "search_users" SearchUsersToolName = "search_users"
SearchOrgTeamsToolName = "search_org_teams" SearchOrgTeamsToolName = "search_org_teams"
SearchReposToolName = "search_repos" SearchReposToolName = "search_repos"
SearchIssuesToolName = "search_issues"
) )
var ( var (
SearchUsersTool = mcp.NewTool( SearchUsersTool = mcp.NewTool(
SearchUsersToolName, SearchUsersToolName,
mcp.WithDescription("search users"), mcp.WithToolAnnotation(annotation.ReadOnly("Search users")),
mcp.WithString("keyword", mcp.Required(), mcp.Description("Keyword")), mcp.WithString("query", mcp.Required()),
mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)), mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)), mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
) )
SearOrgTeamsTool = mcp.NewTool( SearOrgTeamsTool = mcp.NewTool(
SearchOrgTeamsToolName, SearchOrgTeamsToolName,
mcp.WithDescription("search organization teams"), mcp.WithToolAnnotation(annotation.ReadOnly("Search organization teams")),
mcp.WithString("org", mcp.Required(), mcp.Description("organization name")), mcp.WithString("org", mcp.Required()),
mcp.WithString("query", mcp.Required(), mcp.Description("search organization teams")), mcp.WithString("query", mcp.Required()),
mcp.WithBoolean("includeDescription", mcp.Description("include description?")), mcp.WithBoolean("includeDescription"),
mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)), mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)), mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
) )
SearchReposTool = mcp.NewTool( SearchReposTool = mcp.NewTool(
SearchReposToolName, SearchReposToolName,
mcp.WithDescription("search repos"), mcp.WithToolAnnotation(annotation.ReadOnly("Search repositories")),
mcp.WithString("keyword", mcp.Required(), mcp.Description("Keyword")), mcp.WithString("query", mcp.Required()),
mcp.WithBoolean("keywordIsTopic", mcp.Description("KeywordIsTopic")), mcp.WithBoolean("keywordIsTopic"),
mcp.WithBoolean("keywordInDescription", mcp.Description("KeywordInDescription")), mcp.WithBoolean("keywordInDescription"),
mcp.WithNumber("ownerID", mcp.Description("OwnerID")), mcp.WithNumber("ownerID"),
mcp.WithBoolean("isPrivate", mcp.Description("IsPrivate")), mcp.WithBoolean("isPrivate"),
mcp.WithBoolean("isArchived", mcp.Description("IsArchived")), mcp.WithBoolean("isArchived"),
mcp.WithString("sort", mcp.Description("Sort")), mcp.WithString("sort"),
mcp.WithString("order", mcp.Description("Order")), mcp.WithString("order"),
mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)), mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)), mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
)
SearchIssuesTool = mcp.NewTool(
SearchIssuesToolName,
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)),
) )
) )
@@ -71,11 +87,14 @@ func init() {
Tool: SearchReposTool, Tool: SearchReposTool,
Handler: ReposFn, Handler: ReposFn,
}) })
Tool.RegisterRead(server.ServerTool{
Tool: SearchIssuesTool,
Handler: IssuesFn,
})
} }
func UsersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func UsersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called UsersFn") keyword, err := params.GetString(req.GetArguments(), "query")
keyword, err := params.GetString(req.GetArguments(), "keyword")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -99,7 +118,6 @@ func UsersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult,
} }
func OrgTeamsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func OrgTeamsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called OrgTeamsFn")
org, err := params.GetString(req.GetArguments(), "org") org, err := params.GetString(req.GetArguments(), "org")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -130,34 +148,23 @@ func OrgTeamsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResu
} }
func ReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ReposFn") keyword, err := params.GetString(req.GetArguments(), "query")
keyword, err := params.GetString(req.GetArguments(), "keyword")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
keywordIsTopic, _ := req.GetArguments()["keywordIsTopic"].(bool) args := req.GetArguments()
keywordInDescription, _ := req.GetArguments()["keywordInDescription"].(bool) keywordIsTopic, _ := args["keywordIsTopic"].(bool)
ownerID := params.GetOptionalInt(req.GetArguments(), "ownerID", 0) keywordInDescription, _ := args["keywordInDescription"].(bool)
var pIsPrivate *bool sort, _ := args["sort"].(string)
isPrivate, ok := req.GetArguments()["isPrivate"].(bool) order, _ := args["order"].(string)
if ok { page, pageSize := params.GetPagination(args, 30)
pIsPrivate = new(isPrivate)
}
var pIsArchived *bool
isArchived, ok := req.GetArguments()["isArchived"].(bool)
if ok {
pIsArchived = new(isArchived)
}
sort, _ := req.GetArguments()["sort"].(string)
order, _ := req.GetArguments()["order"].(string)
page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.SearchRepoOptions{ opt := gitea_sdk.SearchRepoOptions{
Keyword: keyword, Keyword: keyword,
KeywordIsTopic: keywordIsTopic, KeywordIsTopic: keywordIsTopic,
KeywordInDescription: keywordInDescription, KeywordInDescription: keywordInDescription,
OwnerID: ownerID, OwnerID: params.GetOptionalInt(args, "ownerID", 0),
IsPrivate: pIsPrivate, IsPrivate: params.GetOptionalBoolPtr(args, "isPrivate"),
IsArchived: pIsArchived, IsArchived: params.GetOptionalBoolPtr(args, "isArchived"),
Sort: sort, Sort: sort,
Order: order, Order: order,
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
@@ -173,5 +180,43 @@ func ReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult,
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("search repos error: %v", err)) return to.ErrorResult(fmt.Errorf("search repos error: %v", err))
} }
return to.TextResult(slimRepos(repos)) return to.TextResult(slim.Repos(repos))
}
func IssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
query, err := params.GetString(args, "query")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(args, 30)
opt := gitea_sdk.ListIssueOption{
KeyWord: query,
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
if state, ok := args["state"].(string); ok {
opt.State = gitea_sdk.StateType(state)
}
if issueType, ok := args["type"].(string); ok {
opt.Type = gitea_sdk.IssueType(issueType)
}
if labels, ok := args["labels"].(string); ok && labels != "" {
opt.Labels = strings.Split(labels, ",")
}
if owner, ok := args["owner"].(string); ok {
opt.Owner = owner
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
issues, _, err := client.ListIssues(opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("search issues err: %v", err))
}
return to.TextResult(slimIssues(issues))
} }
+2 -2
View File
@@ -16,7 +16,7 @@ func TestSearchToolsRequiredFields(t *testing.T) {
{ {
name: "search_users", name: "search_users",
tool: SearchUsersTool, tool: SearchUsersTool,
required: []string{"keyword"}, required: []string{"query"},
}, },
{ {
name: "search_org_teams", name: "search_org_teams",
@@ -26,7 +26,7 @@ func TestSearchToolsRequiredFields(t *testing.T) {
{ {
name: "search_repos", name: "search_repos",
tool: SearchReposTool, tool: SearchReposTool,
required: []string{"keyword"}, required: []string{"query"},
}, },
} }
+35 -52
View File
@@ -1,28 +1,15 @@
package search package search
import ( import (
"gitea.com/gitea/gitea-mcp/pkg/slim"
gitea_sdk "code.gitea.io/sdk/gitea" gitea_sdk "code.gitea.io/sdk/gitea"
) )
func slimUserDetail(u *gitea_sdk.User) map[string]any {
if u == nil {
return nil
}
return map[string]any{
"id": u.ID,
"login": u.UserName,
"full_name": u.FullName,
"email": u.Email,
"avatar_url": u.AvatarURL,
"html_url": u.HTMLURL,
"is_admin": u.IsAdmin,
}
}
func slimUserDetails(users []*gitea_sdk.User) []map[string]any { func slimUserDetails(users []*gitea_sdk.User) []map[string]any {
out := make([]map[string]any, 0, len(users)) out := make([]map[string]any, 0, len(users))
for _, u := range users { for _, u := range users {
out = append(out, slimUserDetail(u)) out = append(out, slim.UserDetail(u))
} }
return out return out
} }
@@ -47,42 +34,38 @@ func slimTeams(teams []*gitea_sdk.Team) []map[string]any {
return out return out
} }
func slimRepo(r *gitea_sdk.Repository) map[string]any { func slimIssues(issues []*gitea_sdk.Issue) []map[string]any {
if r == nil { out := make([]map[string]any, 0, len(issues))
return nil for _, i := range issues {
} if i == nil {
m := map[string]any{ continue
"id": r.ID, }
"full_name": r.FullName, m := map[string]any{
"description": r.Description, "number": i.Index,
"html_url": r.HTMLURL, "title": i.Title,
"clone_url": r.CloneURL, "state": i.State,
"ssh_url": r.SSHURL, "html_url": i.HTMLURL,
"default_branch": r.DefaultBranch, "user": slim.UserLogin(i.Poster),
"private": r.Private, "comments": i.Comments,
"fork": r.Fork, "created_at": i.Created,
"archived": r.Archived, "updated_at": i.Updated,
"language": r.Language, }
"stars_count": r.Stars, if len(i.Labels) > 0 {
"forks_count": r.Forks, m["labels"] = slim.LabelNames(i.Labels)
"open_issues_count": r.OpenIssues, }
"open_pr_counter": r.OpenPulls, if i.Repository != nil {
"created_at": r.Created, m["repository"] = i.Repository.FullName
"updated_at": r.Updated, }
} if i.Ref != "" {
if r.Owner != nil { m["ref"] = i.Ref
m["owner"] = r.Owner.UserName }
} if i.Deadline != nil {
if len(r.Topics) > 0 { m["deadline"] = i.Deadline
m["topics"] = r.Topics }
} if i.PullRequest != nil {
return m m["is_pull"] = true
} }
out = append(out, m)
func slimRepos(repos []*gitea_sdk.Repository) []map[string]any {
out := make([]map[string]any, 0, len(repos))
for _, r := range repos {
out = append(out, slimRepo(r))
} }
return out return out
} }
+54
View File
@@ -0,0 +1,54 @@
package search
import (
"slices"
"testing"
gitea_sdk "code.gitea.io/sdk/gitea"
)
func TestSlimIssues(t *testing.T) {
issues := []*gitea_sdk.Issue{
{
Index: 1,
Title: "Bug report",
State: gitea_sdk.StateOpen,
HTMLURL: "https://gitea.com/org/repo/issues/1",
Poster: &gitea_sdk.User{UserName: "alice"},
Labels: []*gitea_sdk.Label{{Name: "bug"}},
Repository: &gitea_sdk.RepositoryMeta{FullName: "org/repo"},
PullRequest: nil,
},
{
Index: 2,
Title: "Add feature",
State: gitea_sdk.StateOpen,
Poster: &gitea_sdk.User{UserName: "bob"},
Repository: &gitea_sdk.RepositoryMeta{FullName: "org/repo"},
PullRequest: &gitea_sdk.PullRequestMeta{},
},
}
result := slimIssues(issues)
if len(result) != 2 {
t.Fatalf("expected 2 issues, got %d", len(result))
}
if result[0]["repository"] != "org/repo" {
t.Errorf("expected repository org/repo, got %v", result[0]["repository"])
}
if result[0]["labels"].([]string)[0] != "bug" {
t.Errorf("expected label bug, got %v", result[0]["labels"])
}
if _, ok := result[0]["is_pull"]; ok {
t.Error("issue should not have is_pull")
}
if result[1]["is_pull"] != true {
t.Error("PR should have is_pull=true")
}
}
func TestSearchIssuesToolRequired(t *testing.T) {
if !slices.Contains(SearchIssuesTool.InputSchema.Required, "query") {
t.Error("search_issues should require query")
}
}
+24 -35
View File
@@ -5,13 +5,13 @@ import (
"context" "context"
"fmt" "fmt"
gitea_sdk "code.gitea.io/sdk/gitea" "gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params" "gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server" "github.com/mark3labs/mcp-go/server"
) )
@@ -26,24 +26,26 @@ const (
var ( var (
TimetrackingReadTool = mcp.NewTool( TimetrackingReadTool = mcp.NewTool(
TimetrackingReadToolName, TimetrackingReadToolName,
mcp.WithDescription("Read time tracking data. Use method 'list_issue_times' for issue times, 'list_repo_times' for repository times, 'get_my_stopwatches' for active stopwatches, 'get_my_times' for all your tracked times."), mcp.WithDescription("Read time tracking: issue times, repo times, active stopwatches, your tracked times."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list_issue_times", "list_repo_times", "get_my_stopwatches", "get_my_times")), mcp.WithToolAnnotation(annotation.ReadOnly("Read tracked time")),
mcp.WithString("owner", mcp.Description("repository owner (required for 'list_issue_times', 'list_repo_times')")), mcp.WithString("method", mcp.Required(), mcp.Enum("list_issue_times", "list_repo_times", "get_my_stopwatches", "get_my_times")),
mcp.WithString("repo", mcp.Description("repository name (required for 'list_issue_times', 'list_repo_times')")), mcp.WithString("owner", mcp.Description("for list_* methods")),
mcp.WithNumber("index", mcp.Description("issue index (required for 'list_issue_times')")), mcp.WithString("repo", mcp.Description("for list_* methods")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)), mcp.WithNumber("issue_number", mcp.Description("for 'list_issue_times'")),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)), mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
) )
TimetrackingWriteTool = mcp.NewTool( TimetrackingWriteTool = mcp.NewTool(
TimetrackingWriteToolName, TimetrackingWriteToolName,
mcp.WithDescription("Manage time tracking: stopwatches and tracked time entries."), mcp.WithDescription("Write time tracking: stopwatches and entries."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("start_stopwatch", "stop_stopwatch", "delete_stopwatch", "add_time", "delete_time")), mcp.WithToolAnnotation(annotation.Write("Add or manage tracked time")),
mcp.WithString("owner", mcp.Description("repository owner (required for all methods)")), mcp.WithString("method", mcp.Required(), mcp.Enum("start_stopwatch", "stop_stopwatch", "delete_stopwatch", "add_time", "delete_time")),
mcp.WithString("repo", mcp.Description("repository name (required for all methods)")), mcp.WithString("owner", mcp.Description(params.OwnerDesc)),
mcp.WithNumber("index", mcp.Description("issue index (required for all methods)")), mcp.WithString("repo", mcp.Description(params.RepoDesc)),
mcp.WithNumber("time", mcp.Description("time to add in seconds (required for 'add_time')")), mcp.WithNumber("issue_number"),
mcp.WithNumber("id", mcp.Description("tracked time entry ID (required for 'delete_time')")), mcp.WithNumber("time", mcp.Description("seconds (for 'add_time')")),
mcp.WithNumber("id", mcp.Description("entry ID (for 'delete_time')")),
) )
) )
@@ -92,10 +94,7 @@ func writeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult,
} }
} }
// Stopwatch handler functions
func startStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func startStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called startStopwatchFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -104,7 +103,7 @@ func startStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "index") index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -120,7 +119,6 @@ func startStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
} }
func stopStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func stopStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called stopStopwatchFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -129,7 +127,7 @@ func stopStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "index") index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -145,7 +143,6 @@ func stopStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
} }
func deleteStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func deleteStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteStopwatchFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -154,7 +151,7 @@ func deleteStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "index") index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -170,7 +167,6 @@ func deleteStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
} }
func getMyStopwatchesFn(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getMyStopwatchesFn(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getMyStopwatchesFn")
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
@@ -185,10 +181,7 @@ func getMyStopwatchesFn(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallTo
return to.TextResult(slimStopWatches(stopwatches)) return to.TextResult(slimStopWatches(stopwatches))
} }
// Tracked time handler functions
func listTrackedTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listTrackedTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listTrackedTimesFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -197,7 +190,7 @@ func listTrackedTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "index") index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -223,7 +216,6 @@ func listTrackedTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
} }
func addTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func addTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called addTrackedTimeFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -232,7 +224,7 @@ func addTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "index") index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -255,7 +247,6 @@ func addTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
} }
func deleteTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func deleteTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteTrackedTimeFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -265,7 +256,7 @@ func deleteTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Cal
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "index") index, err := params.GetIndex(req.GetArguments(), "issue_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -285,7 +276,6 @@ func deleteTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Cal
} }
func listRepoTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listRepoTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoTimesFn")
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -316,7 +306,6 @@ func listRepoTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
} }
func getMyTimesFn(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getMyTimesFn(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getMyTimesFn")
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
-15
View File
@@ -4,21 +4,6 @@ import (
gitea_sdk "code.gitea.io/sdk/gitea" gitea_sdk "code.gitea.io/sdk/gitea"
) )
func slimUserDetail(u *gitea_sdk.User) map[string]any {
if u == nil {
return nil
}
return map[string]any{
"id": u.ID,
"login": u.UserName,
"full_name": u.FullName,
"email": u.Email,
"avatar_url": u.AvatarURL,
"html_url": u.HTMLURL,
"is_admin": u.IsAdmin,
}
}
func slimOrg(o *gitea_sdk.Organization) map[string]any { func slimOrg(o *gitea_sdk.Organization) map[string]any {
if o == nil { if o == nil {
return nil return nil
-39
View File
@@ -1,39 +0,0 @@
package user
import (
"testing"
gitea_sdk "code.gitea.io/sdk/gitea"
)
func TestSlimUserDetail(t *testing.T) {
u := &gitea_sdk.User{
ID: 42,
UserName: "alice",
FullName: "Alice Smith",
Email: "alice@example.com",
AvatarURL: "https://gitea.com/avatars/42",
HTMLURL: "https://gitea.com/alice",
IsAdmin: true,
}
m := slimUserDetail(u)
if m["id"] != int64(42) {
t.Errorf("expected id 42, got %v", m["id"])
}
if m["login"] != "alice" {
t.Errorf("expected login alice, got %v", m["login"])
}
if m["full_name"] != "Alice Smith" {
t.Errorf("expected full_name Alice Smith, got %v", m["full_name"])
}
if m["is_admin"] != true {
t.Errorf("expected is_admin true, got %v", m["is_admin"])
}
}
func TestSlimUserDetail_Nil(t *testing.T) {
if m := slimUserDetail(nil); m != nil {
t.Errorf("expected nil for nil user, got %v", m)
}
}
+13 -42
View File
@@ -4,9 +4,10 @@ import (
"context" "context"
"fmt" "fmt"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params" "gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/slim"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
@@ -16,60 +17,34 @@ import (
) )
const ( const (
// GetMyUserInfoToolName is the unique tool name used for MCP registration and lookup of the get_me command.
GetMyUserInfoToolName = "get_me" GetMyUserInfoToolName = "get_me"
// GetUserOrgsToolName is the unique tool name used for MCP registration and lookup of the get_user_orgs command. GetUserOrgsToolName = "get_user_orgs"
GetUserOrgsToolName = "get_user_orgs"
// defaultPage is the default starting page number used for paginated organization listings.
defaultPage = 1
// defaultPageSize is the default number of organizations per page for paginated queries.
defaultPageSize = 30
) )
// Tool is the MCP tool manager instance for registering all MCP tools in this package.
var Tool = tool.New() var Tool = tool.New()
var ( var (
// GetMyUserInfoTool is the MCP tool for retrieving the current user's info.
// It is registered with a specific name and a description string.
GetMyUserInfoTool = mcp.NewTool( GetMyUserInfoTool = mcp.NewTool(
GetMyUserInfoToolName, GetMyUserInfoToolName,
mcp.WithDescription("Get my user info"), mcp.WithDescription("Get current user"),
mcp.WithToolAnnotation(annotation.ReadOnly("Get current user information")),
) )
// GetUserOrgsTool is the MCP tool for listing organizations for the authenticated user.
// It supports pagination via "page" and "perPage" arguments with default values specified above.
GetUserOrgsTool = mcp.NewTool( GetUserOrgsTool = mcp.NewTool(
GetUserOrgsToolName, GetUserOrgsToolName,
mcp.WithDescription("Get organizations associated with the authenticated user"), mcp.WithDescription("List current user's organizations"),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(defaultPage)), mcp.WithToolAnnotation(annotation.ReadOnly("Get user organizations")),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(defaultPageSize)), mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)),
) )
) )
// init registers all MCP tools in Tool at package initialization.
// This function ensures the handler functions are registered before server usage.
func init() { func init() {
registerTools() Tool.RegisterRead(server.ServerTool{Tool: GetMyUserInfoTool, Handler: GetUserInfoFn})
Tool.RegisterRead(server.ServerTool{Tool: GetUserOrgsTool, Handler: GetUserOrgsFn})
} }
// registerTools registers all local MCP tool definitions and their handler functions.
// To add new functionality, append your tool/handler pair to the tools slice below.
func registerTools() {
tools := []server.ServerTool{
{Tool: GetMyUserInfoTool, Handler: GetUserInfoFn},
{Tool: GetUserOrgsTool, Handler: GetUserOrgsFn},
}
for _, t := range tools {
Tool.RegisterRead(t)
}
}
// GetUserInfoFn is the handler for "get_me" MCP tool requests.
// Logs invocation, fetches current user info from gitea, wraps result for MCP.
func GetUserInfoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetUserInfoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[User] Called GetUserInfoFn")
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
@@ -78,15 +53,11 @@ func GetUserInfoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get user info err: %v", err)) return to.ErrorResult(fmt.Errorf("get user info err: %v", err))
} }
return to.TextResult(slimUserDetail(user)) return to.TextResult(slim.UserDetail(user))
} }
// GetUserOrgsFn is the handler for "get_user_orgs" MCP tool requests.
// Logs invocation, pulls validated pagination arguments from request,
// performs Gitea organization listing, and wraps the result for MCP.
func GetUserOrgsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetUserOrgsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[User] Called GetUserOrgsFn") page, pageSize := params.GetPagination(req.GetArguments(), 30)
page, pageSize := params.GetPagination(req.GetArguments(), defaultPageSize)
opt := gitea_sdk.ListOrgsOptions{ opt := gitea_sdk.ListOrgsOptions{
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
+2 -3
View File
@@ -4,8 +4,8 @@ import (
"context" "context"
"fmt" "fmt"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/flag" "gitea.com/gitea/gitea-mcp/pkg/flag"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
@@ -21,7 +21,7 @@ const (
var GetGiteaMCPServerVersionTool = mcp.NewTool( var GetGiteaMCPServerVersionTool = mcp.NewTool(
GetGiteaMCPServerVersion, GetGiteaMCPServerVersion,
mcp.WithDescription("Get Gitea MCP Server Version"), mcp.WithToolAnnotation(annotation.ReadOnly("Get server version")),
) )
func init() { func init() {
@@ -32,7 +32,6 @@ func init() {
} }
func GetGiteaMCPServerVersionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetGiteaMCPServerVersionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetGiteaMCPServerVersionFn")
version := flag.Version version := flag.Version
if version == "" { if version == "" {
version = "dev" version = "dev"
+15 -19
View File
@@ -6,8 +6,8 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"gitea.com/gitea/gitea-mcp/pkg/annotation"
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params" "gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
@@ -26,22 +26,24 @@ const (
var ( var (
WikiReadTool = mcp.NewTool( WikiReadTool = mcp.NewTool(
WikiReadToolName, WikiReadToolName,
mcp.WithDescription("Read wiki page information. Use method 'list' to list pages, 'get' to get page content, 'get_revisions' for revision history."), mcp.WithDescription("Read wiki: list pages, get content, revision history."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list", "get", "get_revisions")), mcp.WithToolAnnotation(annotation.ReadOnly("Read wiki pages")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("method", mcp.Required(), mcp.Enum("list", "get", "get_revisions")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("pageName", mcp.Description("wiki page name (required for 'get', 'get_revisions')")), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("pageName", mcp.Description("for 'get'/'get_revisions'")),
) )
WikiWriteTool = mcp.NewTool( WikiWriteTool = mcp.NewTool(
WikiWriteToolName, WikiWriteToolName,
mcp.WithDescription("Create, update, or delete wiki pages."), mcp.WithDescription("Write wiki pages: create, update, delete."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "update", "delete")), mcp.WithToolAnnotation(annotation.Destructive("Create, update, or delete wiki pages")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("method", mcp.Required(), mcp.Enum("create", "update", "delete")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("pageName", mcp.Description("wiki page name (required for 'update', 'delete')")), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("title", mcp.Description("wiki page title (required for 'create', optional for 'update')")), mcp.WithString("pageName", mcp.Description("for 'update'/'delete'")),
mcp.WithString("content", mcp.Description("page content (required for 'create', 'update')")), mcp.WithString("title", mcp.Description("for 'create'")),
mcp.WithString("content", mcp.Description("for 'create'/'update'")),
mcp.WithString("message", mcp.Description("commit message")), mcp.WithString("message", mcp.Description("commit message")),
) )
) )
@@ -92,7 +94,6 @@ func wikiWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRes
} }
func listWikiPagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listWikiPagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listWikiPagesFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -113,7 +114,6 @@ func listWikiPagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
} }
func getWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getWikiPageFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -138,7 +138,6 @@ func getWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR
} }
func getWikiRevisionsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getWikiRevisionsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getWikiRevisionsFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -163,7 +162,6 @@ func getWikiRevisionsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
} }
func createWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func createWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createWikiPageFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -203,7 +201,6 @@ func createWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
} }
func updateWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func updateWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called updateWikiPageFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
@@ -249,7 +246,6 @@ func updateWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
} }
func deleteWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func deleteWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteWikiPageFn")
args := req.GetArguments() args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
+2 -2
View File
@@ -32,10 +32,10 @@ func TestWikiWriteBase64Encoding(t *testing.T) {
var gotBody map[string]string var gotBody map[string]string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body) body, _ := io.ReadAll(r.Body)
json.Unmarshal(body, &gotBody) _ = json.Unmarshal(body, &gotBody)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"title":"test"}`)) _, _ = w.Write([]byte(`{"title":"test"}`))
})) }))
defer srv.Close() defer srv.Close()
+18
View File
@@ -0,0 +1,18 @@
package annotation
import "github.com/mark3labs/mcp-go/mcp"
func ReadOnly(title string) mcp.ToolAnnotation {
t := true
return mcp.ToolAnnotation{Title: title, ReadOnlyHint: &t}
}
func Write(title string) mcp.ToolAnnotation {
f := false
return mcp.ToolAnnotation{Title: title, ReadOnlyHint: &f}
}
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 Version string
Mode string Mode string
Insecure bool Insecure bool
ReadOnly bool ReadOnly bool
Debug bool Debug bool
AllowedTools map[string]struct{}
) )
+33 -13
View File
@@ -6,27 +6,47 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"sync"
"code.gitea.io/sdk/gitea"
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context" mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
"gitea.com/gitea/gitea-mcp/pkg/flag" "gitea.com/gitea/gitea-mcp/pkg/flag"
"code.gitea.io/sdk/gitea"
) )
var (
clientCache sync.Map // token -> *gitea.Client
sharedTransOnce sync.Once
sharedTrans *http.Transport
)
func sharedTransport() *http.Transport {
sharedTransOnce.Do(func() {
sharedTrans = http.DefaultTransport.(*http.Transport).Clone()
if flag.Insecure {
sharedTrans.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // user-requested insecure mode
}
})
return sharedTrans
}
// NewClient returns a cached *gitea.Client keyed by host+token. The SDK's per-client
// version cache and the shared transport let us reuse keep-alive connections
// and avoid the SDK's /api/v1/version preflight on every tool call.
func NewClient(token string) (*gitea.Client, error) { func NewClient(token string) (*gitea.Client, error) {
httpClient := &http.Client{ key := flag.Host + "\x00" + token
Transport: http.DefaultTransport, if v, ok := clientCache.Load(key); ok {
CheckRedirect: checkRedirect, return v.(*gitea.Client), nil
} }
httpClient := &http.Client{
Transport: sharedTransport(),
CheckRedirect: checkRedirect,
}
opts := []gitea.ClientOption{ opts := []gitea.ClientOption{
gitea.SetToken(token), gitea.SetToken(token),
gitea.SetHTTPClient(httpClient),
} }
if flag.Insecure {
httpClient.Transport.(*http.Transport).TLSClientConfig = &tls.Config{
InsecureSkipVerify: true,
}
}
opts = append(opts, gitea.SetHTTPClient(httpClient))
if flag.Debug { if flag.Debug {
opts = append(opts, gitea.SetDebugMode()) opts = append(opts, gitea.SetDebugMode())
} }
@@ -34,10 +54,10 @@ func NewClient(token string) (*gitea.Client, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("create gitea client err: %w", err) return nil, fmt.Errorf("create gitea client err: %w", err)
} }
// Set user agent for the client
client.SetUserAgent("gitea-mcp-server/" + flag.Version) client.SetUserAgent("gitea-mcp-server/" + flag.Version)
return client, nil
actual, _ := clientCache.LoadOrStore(key, client)
return actual.(*gitea.Client), nil
} }
// checkRedirect prevents Go from silently changing mutating requests (POST, PATCH, etc.) // checkRedirect prevents Go from silently changing mutating requests (POST, PATCH, etc.)
+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) { mux.HandleFunc("GET /api/v1/repos/owner/new-name/pulls/1", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{"id": 1, "title": "found"}) _ = json.NewEncoder(w).Encode(map[string]any{"id": 1, "title": "found"})
}) })
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
defer srv.Close() defer srv.Close()
+25 -16
View File
@@ -3,7 +3,6 @@ package gitea
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/tls"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -11,12 +10,18 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"sync"
"time" "time"
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context" mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
"gitea.com/gitea/gitea-mcp/pkg/flag" "gitea.com/gitea/gitea-mcp/pkg/flag"
) )
const (
httpClientTimeout = 60 * time.Second
errBodySnippetSize = 8192
)
type HTTPError struct { type HTTPError struct {
StatusCode int StatusCode int
Body string Body string
@@ -38,16 +43,20 @@ func tokenFromContext(ctx context.Context) string {
return flag.Token return flag.Token
} }
func newRESTHTTPClient() *http.Client { var (
transport := http.DefaultTransport.(*http.Transport).Clone() restClientOnce sync.Once
if flag.Insecure { restClient *http.Client
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // user-requested insecure mode )
}
return &http.Client{ func restHTTPClient() *http.Client {
Transport: transport, restClientOnce.Do(func() {
Timeout: 60 * time.Second, restClient = &http.Client{
CheckRedirect: checkRedirect, Transport: sharedTransport(),
} Timeout: httpClientTimeout,
CheckRedirect: checkRedirect,
}
})
return restClient
} }
func buildAPIURL(path string, query url.Values) (string, error) { func buildAPIURL(path string, query url.Values) (string, error) {
@@ -96,7 +105,7 @@ func DoJSON(ctx context.Context, method, path string, query url.Values, body, re
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
} }
client := newRESTHTTPClient() client := restHTTPClient()
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return 0, fmt.Errorf("do request: %w", err) return 0, fmt.Errorf("do request: %w", err)
@@ -104,7 +113,7 @@ func DoJSON(ctx context.Context, method, path string, query url.Values, body, re
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodySnippet, _ := io.ReadAll(io.LimitReader(resp.Body, 8192)) bodySnippet, _ := io.ReadAll(io.LimitReader(resp.Body, errBodySnippetSize))
return resp.StatusCode, &HTTPError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(bodySnippet))} return resp.StatusCode, &HTTPError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(bodySnippet))}
} }
@@ -151,7 +160,7 @@ func DoBytes(ctx context.Context, method, path string, query url.Values, body an
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
} }
client := newRESTHTTPClient() client := restHTTPClient()
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, 0, fmt.Errorf("do request: %w", err) return nil, 0, fmt.Errorf("do request: %w", err)
@@ -165,8 +174,8 @@ func DoBytes(ctx context.Context, method, path string, query url.Values, body an
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodySnippet := respBytes bodySnippet := respBytes
if len(bodySnippet) > 8192 { if len(bodySnippet) > errBodySnippetSize {
bodySnippet = bodySnippet[:8192] bodySnippet = bodySnippet[:errBodySnippetSize]
} }
return nil, resp.StatusCode, &HTTPError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(bodySnippet))} return nil, resp.StatusCode, &HTTPError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(bodySnippet))}
} }
+1 -18
View File
@@ -6,6 +6,7 @@ import (
"time" "time"
"gitea.com/gitea/gitea-mcp/pkg/flag" "gitea.com/gitea/gitea-mcp/pkg/flag"
"go.uber.org/zap" "go.uber.org/zap"
"go.uber.org/zap/zapcore" "go.uber.org/zap/zapcore"
"gopkg.in/natefinch/lumberjack.v2" "gopkg.in/natefinch/lumberjack.v2"
@@ -78,24 +79,6 @@ func SetDefault(logger *zap.Logger) {
} }
} }
func New() *Logger {
return &Logger{
defaultLogger: Default(),
}
}
type Logger struct {
defaultLogger *zap.Logger
}
func (l *Logger) Infof(msg string, args ...any) {
l.defaultLogger.Sugar().Infof(msg, args...)
}
func (l *Logger) Errorf(msg string, args ...any) {
l.defaultLogger.Sugar().Errorf(msg, args...)
}
func Debug(msg string, fields ...zap.Field) { func Debug(msg string, fields ...zap.Field) {
Default().Debug(msg, fields...) Default().Debug(msg, fields...)
} }
+56 -16
View File
@@ -3,18 +3,28 @@ package params
import ( import (
"fmt" "fmt"
"strconv" "strconv"
"time"
) )
// GetString extracts a required string parameter from MCP tool arguments. // 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. Empty strings are treated as missing.
func GetString(args map[string]any, key string) (string, error) { func GetString(args map[string]any, key string) (string, error) {
val, ok := args[key].(string) val, ok := args[key].(string)
if !ok { if !ok || val == "" {
return "", fmt.Errorf("%s is required", key) return "", fmt.Errorf("%s is required", key)
} }
return val, nil return val, nil
} }
// GetOptionalString extracts an optional string parameter with a default value.
func GetOptionalString(args map[string]any, key, defaultVal string) string { func GetOptionalString(args map[string]any, key, defaultVal string) string {
if val, ok := args[key].(string); ok { if val, ok := args[key].(string); ok {
return val return val
@@ -22,7 +32,6 @@ func GetOptionalString(args map[string]any, key, defaultVal string) string {
return defaultVal return defaultVal
} }
// GetStringSlice extracts an optional string slice parameter from MCP tool arguments.
func GetStringSlice(args map[string]any, key string) []string { func GetStringSlice(args map[string]any, key string) []string {
val, ok := args[key] val, ok := args[key]
if !ok { if !ok {
@@ -41,13 +50,11 @@ func GetStringSlice(args map[string]any, key string) []string {
return out return out
} }
// GetPagination extracts page and perPage parameters, returning them as ints.
func GetPagination(args map[string]any, defaultPageSize int64) (page, pageSize int) { func GetPagination(args map[string]any, defaultPageSize int64) (page, pageSize int) {
return int(GetOptionalInt(args, "page", 1)), int(GetOptionalInt(args, "perPage", defaultPageSize)) return int(GetOptionalInt(args, "page", 1)), int(GetOptionalInt(args, "per_page", defaultPageSize))
} }
// ToInt64 converts a value to int64, accepting both float64 (JSON number) and // ToInt64 accepts float64 (JSON number) and string representations.
// string representations. Returns false if the value cannot be converted.
func ToInt64(val any) (int64, bool) { func ToInt64(val any) (int64, bool) {
switch v := val.(type) { switch v := val.(type) {
case float64: case float64:
@@ -63,10 +70,8 @@ func ToInt64(val any) (int64, bool) {
} }
} }
// GetIndex extracts a required integer parameter from MCP tool arguments. // GetIndex extracts a required integer. Accepts numeric or string forms — LLM callers
// It accepts both numeric (float64 from JSON) and string representations. // often pass identifiers like issue/PR numbers as strings.
// This provides better UX for LLM callers that may naturally use strings
// for identifiers like issue/PR numbers.
func GetIndex(args map[string]any, key string) (int64, error) { func GetIndex(args map[string]any, key string) (int64, error) {
val, exists := args[key] val, exists := args[key]
if !exists { if !exists {
@@ -84,7 +89,6 @@ func GetIndex(args map[string]any, key string) (int64, error) {
return 0, fmt.Errorf("%s must be a number or numeric string", key) return 0, fmt.Errorf("%s must be a number or numeric string", key)
} }
// GetInt64Slice extracts a required int64 slice parameter from MCP tool arguments.
func GetInt64Slice(args map[string]any, key string) ([]int64, error) { func GetInt64Slice(args map[string]any, key string) ([]int64, error) {
raw, ok := args[key].([]any) raw, ok := args[key].([]any)
if !ok { if !ok {
@@ -101,9 +105,18 @@ func GetInt64Slice(args map[string]any, key string) ([]int64, error) {
return out, nil return out, nil
} }
// GetOptionalInt extracts an optional integer parameter from MCP tool arguments. // GetOptionalTime parses RFC3339, returning nil if missing or unparseable.
// Returns defaultVal if the key is missing or the value cannot be parsed. func GetOptionalTime(args map[string]any, key string) *time.Time {
// Accepts both float64 (JSON number) and string representations. val, ok := args[key].(string)
if !ok {
return nil
}
if t, err := time.Parse(time.RFC3339, val); err == nil {
return &t
}
return nil
}
func GetOptionalInt(args map[string]any, key string, defaultVal int64) int64 { func GetOptionalInt(args map[string]any, key string, defaultVal int64) int64 {
val, exists := args[key] val, exists := args[key]
if !exists { if !exists {
@@ -114,3 +127,30 @@ func GetOptionalInt(args map[string]any, key string, defaultVal int64) int64 {
} }
return defaultVal return defaultVal
} }
// GetOptionalBoolPtr is for SDK fields where nil/false/true are distinct (e.g. "no change" vs "set to false").
func GetOptionalBoolPtr(args map[string]any, key string) *bool {
if v, ok := args[key].(bool); ok {
return &v
}
return nil
}
// GetOptionalStringPtr returns nil when the key is missing OR the value is an empty string.
// Use this for create/fork-style fields where "" is meaningless (e.g. fork target name).
func GetOptionalStringPtr(args map[string]any, key string) *string {
if v, ok := args[key].(string); ok && v != "" {
return &v
}
return nil
}
// GetPresentStringPtr returns &v whenever the key is present as a string, including "".
// Use this for PATCH-style fields where the SDK distinguishes "no change" (nil) from
// "set to empty" (&""), e.g. clearing an issue body or label description.
func GetPresentStringPtr(args map[string]any, key string) *string {
if v, ok := args[key].(string); ok {
return &v
}
return nil
}
+47
View File
@@ -5,6 +5,17 @@ import (
"testing" "testing"
) )
func TestGetPagination(t *testing.T) {
page, perPage := GetPagination(map[string]any{"page": float64(2), "per_page": float64(40)}, 30)
if page != 2 || perPage != 40 {
t.Errorf("GetPagination = (%d, %d), want (2, 40)", page, perPage)
}
page, perPage = GetPagination(map[string]any{}, 30)
if page != 1 || perPage != 30 {
t.Errorf("GetPagination defaults = (%d, %d), want (1, 30)", page, perPage)
}
}
func TestToInt64(t *testing.T) { func TestToInt64(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -62,6 +73,42 @@ func TestGetOptionalInt(t *testing.T) {
} }
} }
func TestGetOptionalStringPtr(t *testing.T) {
if p := GetOptionalStringPtr(map[string]any{}, "k"); p != nil {
t.Errorf("missing key: got %v, want nil", p)
}
if p := GetOptionalStringPtr(map[string]any{"k": ""}, "k"); p != nil {
t.Errorf("empty string: got %v, want nil", p)
}
if p := GetOptionalStringPtr(map[string]any{"k": 42}, "k"); p != nil {
t.Errorf("non-string: got %v, want nil", p)
}
if p := GetOptionalStringPtr(map[string]any{"k": nil}, "k"); p != nil {
t.Errorf("nil value (JSON null): got %v, want nil", p)
}
if p := GetOptionalStringPtr(map[string]any{"k": "x"}, "k"); p == nil || *p != "x" {
t.Errorf("non-empty: got %v, want &\"x\"", p)
}
}
func TestGetPresentStringPtr(t *testing.T) {
if p := GetPresentStringPtr(map[string]any{}, "k"); p != nil {
t.Errorf("missing key: got %v, want nil", p)
}
if p := GetPresentStringPtr(map[string]any{"k": 42}, "k"); p != nil {
t.Errorf("non-string: got %v, want nil", p)
}
if p := GetPresentStringPtr(map[string]any{"k": nil}, "k"); p != nil {
t.Errorf("nil value (JSON null): got %v, want nil", p)
}
if p := GetPresentStringPtr(map[string]any{"k": ""}, "k"); p == nil || *p != "" {
t.Errorf("empty string: got %v, want &\"\"", p)
}
if p := GetPresentStringPtr(map[string]any{"k": "x"}, "k"); p == nil || *p != "x" {
t.Errorf("non-empty: got %v, want &\"x\"", p)
}
}
func TestGetIndex(t *testing.T) { func TestGetIndex(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
+135
View File
@@ -0,0 +1,135 @@
package slim
import (
"fmt"
"strings"
gitea_sdk "code.gitea.io/sdk/gitea"
)
func UserLogin(u *gitea_sdk.User) string {
if u == nil {
return ""
}
return u.UserName
}
func UserLogins(users []*gitea_sdk.User) []string {
if len(users) == 0 {
return nil
}
out := make([]string, 0, len(users))
for _, u := range users {
if u != nil {
out = append(out, u.UserName)
}
}
return out
}
func LabelNames(labels []*gitea_sdk.Label) []string {
if len(labels) == 0 {
return nil
}
out := make([]string, 0, len(labels))
for _, l := range labels {
if l != nil {
out = append(out, l.Name)
}
}
return out
}
func BodyWithAttachments(body string, atts []*gitea_sdk.Attachment) string {
links := make([]string, 0, len(atts))
for _, a := range atts {
if a == nil || a.DownloadURL == "" {
continue
}
links = append(links, fmt.Sprintf("[%s](%s)", a.Name, a.DownloadURL))
}
if len(links) == 0 {
return body
}
joined := strings.Join(links, "\n")
if body == "" {
return joined
}
return body + "\n\n" + joined
}
func UserDetail(u *gitea_sdk.User) map[string]any {
if u == nil {
return nil
}
return map[string]any{
"id": u.ID,
"login": u.UserName,
"full_name": u.FullName,
"email": u.Email,
"avatar_url": u.AvatarURL,
"html_url": u.HTMLURL,
"is_admin": u.IsAdmin,
}
}
func Repo(r *gitea_sdk.Repository) map[string]any {
if r == nil {
return nil
}
m := map[string]any{
"id": r.ID,
"full_name": r.FullName,
"description": r.Description,
"html_url": r.HTMLURL,
"clone_url": r.CloneURL,
"ssh_url": r.SSHURL,
"default_branch": r.DefaultBranch,
"private": r.Private,
"fork": r.Fork,
"archived": r.Archived,
"language": r.Language,
"stars_count": r.Stars,
"forks_count": r.Forks,
"open_issues_count": r.OpenIssues,
"open_pr_counter": r.OpenPulls,
"created_at": r.Created,
"updated_at": r.Updated,
}
if r.Owner != nil {
m["owner"] = r.Owner.UserName
}
if len(r.Topics) > 0 {
m["topics"] = r.Topics
}
return m
}
func Repos(repos []*gitea_sdk.Repository) []map[string]any {
out := make([]map[string]any, 0, len(repos))
for _, r := range repos {
out = append(out, Repo(r))
}
return out
}
func Label(l *gitea_sdk.Label) map[string]any {
if l == nil {
return nil
}
return map[string]any{
"id": l.ID,
"name": l.Name,
"color": l.Color,
"description": l.Description,
"exclusive": l.Exclusive,
}
}
func Labels(labels []*gitea_sdk.Label) []map[string]any {
out := make([]map[string]any, 0, len(labels))
for _, l := range labels {
out = append(out, Label(l))
}
return out
}
+110
View File
@@ -0,0 +1,110 @@
package slim
import (
"testing"
gitea_sdk "code.gitea.io/sdk/gitea"
)
func TestUserDetail(t *testing.T) {
u := &gitea_sdk.User{
ID: 42,
UserName: "alice",
FullName: "Alice Smith",
Email: "alice@example.com",
AvatarURL: "https://gitea.com/avatars/42",
HTMLURL: "https://gitea.com/alice",
IsAdmin: true,
}
m := UserDetail(u)
if m["id"] != int64(42) {
t.Errorf("expected id 42, got %v", m["id"])
}
if m["login"] != "alice" {
t.Errorf("expected login alice, got %v", m["login"])
}
if m["full_name"] != "Alice Smith" {
t.Errorf("expected full_name Alice Smith, got %v", m["full_name"])
}
if m["is_admin"] != true {
t.Errorf("expected is_admin true, got %v", m["is_admin"])
}
}
func TestUserDetail_Nil(t *testing.T) {
if m := UserDetail(nil); m != nil {
t.Errorf("expected nil for nil user, got %v", m)
}
}
func TestLabel(t *testing.T) {
l := &gitea_sdk.Label{
ID: 1,
Name: "bug",
Color: "#d73a4a",
Description: "Something isn't working",
Exclusive: false,
}
m := Label(l)
if m["name"] != "bug" {
t.Errorf("expected name bug, got %v", m["name"])
}
if m["color"] != "#d73a4a" {
t.Errorf("expected color, got %v", m["color"])
}
}
func TestRepo(t *testing.T) {
r := &gitea_sdk.Repository{
ID: 1,
FullName: "org/repo",
Description: "A test repo",
HTMLURL: "https://gitea.com/org/repo",
CloneURL: "https://gitea.com/org/repo.git",
SSHURL: "git@gitea.com:org/repo.git",
DefaultBranch: "main",
Language: "Go",
Stars: 10,
Forks: 2,
Owner: &gitea_sdk.User{UserName: "org"},
Topics: []string{"mcp", "gitea"},
}
m := Repo(r)
if m["full_name"] != "org/repo" {
t.Errorf("expected full_name org/repo, got %v", m["full_name"])
}
if m["owner"] != "org" {
t.Errorf("expected owner org, got %v", m["owner"])
}
topics := m["topics"].([]string)
if len(topics) != 2 {
t.Errorf("expected 2 topics, got %d", len(topics))
}
}
func TestBodyWithAttachments(t *testing.T) {
atts := []*gitea_sdk.Attachment{
{Name: "shot.png", DownloadURL: "https://example/shot.png"},
{Name: "log.txt", DownloadURL: "https://example/log.txt"},
}
got := BodyWithAttachments("see attached", atts)
want := "see attached\n\n[shot.png](https://example/shot.png)\n[log.txt](https://example/log.txt)"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
if got := BodyWithAttachments("only body", nil); got != "only body" {
t.Errorf("nil attachments should return body unchanged, got %q", got)
}
if got := BodyWithAttachments("", atts); got != "[shot.png](https://example/shot.png)\n[log.txt](https://example/log.txt)" {
t.Errorf("empty body should drop separator, got %q", got)
}
skipped := []*gitea_sdk.Attachment{nil, {Name: "noop", DownloadURL: ""}}
if got := BodyWithAttachments("body", skipped); got != "body" {
t.Errorf("nil/empty-URL attachments should be skipped, got %q", got)
}
}
+6 -2
View File
@@ -4,7 +4,9 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/mcp"
) )
@@ -13,11 +15,13 @@ func TextResult(v any) (*mcp.CallToolResult, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("marshal result err: %v", err) return nil, fmt.Errorf("marshal result err: %v", err)
} }
log.Debugf("Text Result: %s", string(resultBytes)) if flag.Debug {
log.Debugf("Text Result: %s", string(resultBytes))
}
return mcp.NewToolResultText(string(resultBytes)), nil return mcp.NewToolResultText(string(resultBytes)), nil
} }
func ErrorResult(err error) (*mcp.CallToolResult, error) { func ErrorResult(err error) (*mcp.CallToolResult, error) {
log.Errorf(err.Error()) log.Errorf("%s", err.Error())
return nil, err return nil, err
} }
+48 -7
View File
@@ -1,7 +1,12 @@
package tool package tool
import ( import (
"slices"
"strings"
"gitea.com/gitea/gitea-mcp/pkg/flag" "gitea.com/gitea/gitea-mcp/pkg/flag"
"gitea.com/gitea/gitea-mcp/pkg/log"
"github.com/mark3labs/mcp-go/server" "github.com/mark3labs/mcp-go/server"
) )
@@ -26,12 +31,48 @@ func (t *Tool) RegisterRead(s server.ServerTool) {
} }
func (t *Tool) Tools() []server.ServerTool { func (t *Tool) Tools() []server.ServerTool {
tools := make([]server.ServerTool, 0, len(t.write)+len(t.read)) all := make([]server.ServerTool, 0, len(t.write)+len(t.read))
if flag.ReadOnly { if !flag.ReadOnly {
tools = append(tools, t.read...) all = append(all, t.write...)
return tools
} }
tools = append(tools, t.write...) all = append(all, t.read...)
tools = append(tools, t.read...) if len(flag.AllowedTools) == 0 {
return tools return all
}
filtered := make([]server.ServerTool, 0, len(all))
for _, st := range all {
if _, ok := flag.AllowedTools[st.Tool.Name]; ok {
filtered = append(filtered, st)
}
}
return filtered
}
// WarnUnmatchedAllowedTools logs any names in flag.AllowedTools that don't
// match a tool registered on any of the given domains. No-op if the allowlist
// is empty.
func WarnUnmatchedAllowedTools(domains ...*Tool) {
if len(flag.AllowedTools) == 0 {
return
}
known := map[string]struct{}{}
for _, d := range domains {
for _, st := range d.read {
known[st.Tool.Name] = struct{}{}
}
for _, st := range d.write {
known[st.Tool.Name] = struct{}{}
}
}
var unmatched []string
for name := range flag.AllowedTools {
if _, ok := known[name]; !ok {
unmatched = append(unmatched, name)
}
}
if len(unmatched) == 0 {
return
}
slices.Sort(unmatched)
log.Warnf("Unknown tools in --tools allowlist (ignored): %s", strings.Join(unmatched, ", "))
} }
+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)
}
})
}
}