Compare commits

..
11 Commits
Author SHA1 Message Date
gronod 07949c1a63 fix(issue): satisfy embedlit lint in check_blocked
check-and-test / check-and-test (pull_request) Successful in 4m23s
release-nightly / release-image (push) Successful in 3m8s
Use the same ListOptions local as the other list helpers instead of
embedding gitea_sdk.ListOptions in the composite literal.
2026-09-16 08:20:35 +00:00
gronod 3e093c10b9 feat(issue): support cross-repo dependencies and check_blocked
check-and-test / check-and-test (pull_request) Failing after 2m25s
Send blocked_owner/blocked_repo and blocking_owner/blocking_repo in the
IssueMeta body so Gitea can resolve cross-repository links. Default those
fields to the path issue's owner/repo.

Add issue_read method check_blocked to summarize open blockers, and
document that dependency endpoints 404 when the repo has issue
dependencies disabled.
2026-09-16 08:03:23 +00:00
gronod 10f5a345d8 fix(issue): include owner and repo in dependency request bodies
check-and-test / check-and-test (pull_request) Successful in 2m23s
release-nightly / release-image (push) Successful in 2m58s
Gitea resolves the IssueMeta body against owner+repo, not only the
URL. The SDK type only has Index, so block/unblock/add/remove
dependency calls looked up a repo named "". Send the path owner/repo
in the JSON body via DoJSON.

Assisted-by: Devin:SWE-2 Medium
2026-09-15 16:20:56 +01:00
gronod 554b7490e2 Merge pull request 'feat(issue): add issue blocking methods' (#2) from feat/issue-blocks into main
release-nightly / release-image (push) Successful in 2m28s
Reviewed-on: #2
2026-09-15 15:39:53 +01:00
gronod 9bfd55f97d fix(ci): login to registry with PAT secret
release-nightly / release-image (push) Successful in 2m32s
The actions task token is rejected by the container registry
(unauthorized at /v2/); use the REGISTRY_TOKEN repo secret instead.

Assisted-by: Devin:SWE-2-Medium
2026-09-15 15:19:21 +01:00
gronod 026f40bc15 feat(issue): add dependency methods to issue tools
check-and-test / check-and-test (pull_request) Successful in 2m21s
Expose Gitea /issues/{i}/dependencies endpoints: list_dependencies on
issue_read and add_dependency/remove_dependency on issue_write via the
blocking_issue param.

Assisted-by: Devin:SWE-2 Max
2026-09-15 14:48:10 +01:00
gronod 4f7a191b67 feat(issue): add block/unblock methods to issue tools
check-and-test / check-and-test (pull_request) Successful in 2m46s
Expose Gitea /issues/{i}/blocks endpoints: list_blocks on issue_read and
block_issue/unblock_issue on issue_write, with pagination params on
issue_read.

Assisted-by: Devin:SWE-2 Max
2026-09-15 14:46:47 +01:00
gronod 290ad8da5d chore(release): publish to self-hosted gitea instance
check-and-test / check-and-test (pull_request) Successful in 4m50s
release-nightly / release-image (push) Failing after 1m1s
Repoint image pushes to git.i3omb.com/gronod/gitea-mcp-server using
GITHUB_TOKEN auth, drop the Cloudflare R2 mirror, and update the docs
and OCI source label to match.

Assisted-by: Devin:SWE-2 High
2026-09-15 13:58:13 +01:00
Renovate Bot 87fd1a7fa2 chore(deps): update module golang.org/x/vuln to v1.8.0 (#267)
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-09-14 12:06:52 +00:00
Renovate Bot e31f3c3764 chore(deps): update dependencies (#265)
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-09-08 01:32:47 +00:00
Renovate Bot 0157291800 fix(deps): update module golang.org/x/crypto to v0.56.0 [security] (#262)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) | [`v0.55.0` → `v0.56.0`](https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.55.0...refs/tags/v0.56.0) | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2fcrypto/v0.56.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2fcrypto/v0.55.0/v0.56.0?slim=true) |

---

### Prevent DoS on deadlocked undecided channel in golang.org/x/crypto/ssh
[CVE-2026-78662](https://nvd.nist.gov/vuln/detail/CVE-2026-78662) / [GO-2026-6354](https://pkg.go.dev/vuln/GO-2026-6354)

<details>
<summary>More information</summary>

#### Details
Previously, a channel registered in the mux's chanList is not usable until it is established. A malicious peer was able flood the channel's incomingRequests, deadlocking the entire connection.

Now, we add an atomic established state, set when a channel becomes usable. Until such a time, handlePacket drops every packet other than the open confirmation/failure, without blocking and without tearing down the connection.

#### Severity
Unknown

#### References
- [https://go.dev/issue/81316](https://go.dev/issue/81316)
- [https://go.dev/cl/826504](https://go.dev/cl/826504)
- [https://groups.google.com/g/golang-announce/c/1y3fb2np35U](https://groups.google.com/g/golang-announce/c/1y3fb2np35U)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6354) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Prevent DoS on deadlocked established channel in golang.org/x/crypto/ssh
[CVE-2026-56855](https://nvd.nist.gov/vuln/detail/CVE-2026-56855) / [GO-2026-6355](https://pkg.go.dev/vuln/GO-2026-6355)

<details>
<summary>More information</summary>

#### Details
Previously, after a channel has been established, a malicious peer could send crafted messages that would deadlock the entire connection.

Now, we handle all RFC 4254 channel messages; global requests are handled explicitly. Then, treat all other messages as a protocol error and tear the connection down instead of buffering and blocking.

#### Severity
Unknown

#### References
- [https://go.dev/issue/81317](https://go.dev/issue/81317)
- [https://go.dev/cl/826524](https://go.dev/cl/826524)
- [https://groups.google.com/g/golang-announce/c/1y3fb2np35U](https://groups.google.com/g/golang-announce/c/1y3fb2np35U)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6355) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xOTEuMiIsInVwZGF0ZWRJblZlciI6IjQzLjE5MS4yIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/262
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-09-03 06:41:19 +00:00
14 changed files with 462 additions and 173 deletions
+8 -8
View File
@@ -10,8 +10,8 @@ jobs:
release-image: release-image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
DOCKER_ORG: gitea REGISTRY: git.i3omb.com
DOCKER_LATEST: nightly IMAGE_NAME: gronod/gitea-mcp-server
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
@@ -19,21 +19,21 @@ jobs:
fetch-depth: 0 # all history for all branches and tags fetch-depth: 0 # all history for all branches and tags
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4 uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
- name: Set up Docker BuildX - name: Set up Docker BuildX
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
- name: Login to DockerHub - name: Login to container registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with: with:
username: ${{ secrets.DOCKER_USER }} registry: ${{ env.REGISTRY }}
password: ${{ secrets.DOCKER_TOKEN }} username: gronod
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Get Meta - name: Get Meta
id: meta id: meta
run: | run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
echo REPO_VERSION=$(git describe --tags --always | sed 's/-/+/' | sed 's/^v//') >> $GITHUB_OUTPUT echo REPO_VERSION=$(git describe --tags --always | sed 's/-/+/' | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Build and push - name: Build and push
@@ -46,6 +46,6 @@ jobs:
linux/arm64 linux/arm64
push: true push: true
tags: | tags: |
${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}-server:${{ env.DOCKER_LATEST }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly
build-args: | build-args: |
VERSION=${{ steps.meta.outputs.REPO_VERSION }} VERSION=${{ steps.meta.outputs.REPO_VERSION }}
+9 -34
View File
@@ -13,16 +13,6 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with: with:
fetch-depth: 0 fetch-depth: 0
# The R2 upload only runs after goreleaser has already published
# the Gitea release, so fail early instead if the secrets are
# missing.
- name: Check R2 configuration
run: sh scripts/upload-r2.sh --check-config
env:
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
- name: Set up Go - name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with: with:
@@ -35,27 +25,12 @@ jobs:
env: env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GORELEASER_FORCE_TOKEN: "gitea" GORELEASER_FORCE_TOKEN: "gitea"
# goreleaser `publishers:` is a Pro-only feature, so the release
# artifacts are mirrored to Cloudflare R2 here instead.
- name: Upload binaries to Cloudflare R2
env:
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
VERSION: ${{ github.ref_name }}
run: |
for f in dist/*.tar.gz dist/*.zip dist/checksums.txt; do
[ -f "$f" ] || continue
echo "uploading $f"
sh scripts/upload-r2.sh "$f" "gitea-mcp/${VERSION#v}/$(basename "$f")"
done
release-image: release-image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
DOCKER_ORG: gitea REGISTRY: git.i3omb.com
DOCKER_LATEST: latest IMAGE_NAME: gronod/gitea-mcp-server
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
@@ -63,21 +38,21 @@ jobs:
fetch-depth: 0 # all history for all branches and tags fetch-depth: 0 # all history for all branches and tags
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4 uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
- name: Set up Docker BuildX - name: Set up Docker BuildX
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
- name: Login to DockerHub - name: Login to container registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with: with:
username: ${{ secrets.DOCKER_USER }} registry: ${{ env.REGISTRY }}
password: ${{ secrets.DOCKER_TOKEN }} username: gronod
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Get Meta - name: Get Meta
id: meta id: meta
run: | run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
echo REPO_VERSION=${GITHUB_REF_NAME#v} >> $GITHUB_OUTPUT echo REPO_VERSION=${GITHUB_REF_NAME#v} >> $GITHUB_OUTPUT
- name: Build and push - name: Build and push
@@ -92,5 +67,5 @@ jobs:
build-args: | build-args: |
VERSION=${{ steps.meta.outputs.REPO_VERSION }} VERSION=${{ steps.meta.outputs.REPO_VERSION }}
tags: | tags: |
${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}-server:${{ steps.meta.outputs.REPO_VERSION }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}-server:${{ env.DOCKER_LATEST }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
+2 -2
View File
@@ -71,6 +71,6 @@ release:
Released by [GoReleaser](https://github.com/goreleaser/goreleaser). Released by [GoReleaser](https://github.com/goreleaser/goreleaser).
gitea_urls: gitea_urls:
api: https://gitea.com/api/v1 api: https://git.i3omb.com/api/v1
download: https://gitea.com download: https://git.i3omb.com
force_token: gitea force_token: gitea
+2 -2
View File
@@ -1,4 +1,4 @@
# syntax=docker/dockerfile:1.26 # syntax=docker/dockerfile:1.27
# Build stage # Build stage
FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS builder FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS builder
@@ -30,7 +30,7 @@ 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" LABEL org.opencontainers.image.source="https://git.i3omb.com/gronod/gitea-mcp"
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["/app/gitea-mcp", "-healthcheck"] || exit 1 CMD ["/app/gitea-mcp", "-healthcheck"] || exit 1
+2 -2
View File
@@ -3,8 +3,8 @@ EXECUTABLE := gitea-mcp
VERSION ?= $(shell git describe --tags --always | sed 's/-/+/' | sed 's/^v//') VERSION ?= $(shell git describe --tags --always | sed 's/-/+/' | sed 's/^v//')
LDFLAGS := -X "main.Version=$(VERSION)" LDFLAGS := -X "main.Version=$(VERSION)"
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.1 # renovate: datasource=go GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2 # renovate: datasource=go
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.7.0 # renovate: datasource=go GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.8.0 # renovate: datasource=go
GOTEST_FLAGS ?= -race -timeout 20m GOTEST_FLAGS ?= -race -timeout 20m
+7 -7
View File
@@ -4,14 +4,14 @@
**Gitea MCP Server** connects a [Gitea](https://about.gitea.com) instance to [Model Context Protocol](https://modelcontextprotocol.io) clients, so repositories, issues, pull requests and more can be browsed and managed from an MCP-compatible chat interface. **Gitea MCP Server** connects a [Gitea](https://about.gitea.com) instance to [Model Context Protocol](https://modelcontextprotocol.io) clients, so repositories, issues, pull requests and more can be browsed and managed from an MCP-compatible chat interface.
[![Install with Docker in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=gitea&inputs=[{%22id%22:%22gitea_token%22,%22type%22:%22promptString%22,%22description%22:%22Gitea%20Personal%20Access%20Token%22,%22password%22:true}]&config={%22command%22:%22docker%22,%22args%22:[%22run%22,%22-i%22,%22--rm%22,%22-e%22,%22GITEA_ACCESS_TOKEN%22,%22docker.gitea.com/gitea-mcp-server%22],%22env%22:{%22GITEA_ACCESS_TOKEN%22:%22${input:gitea_token}%22}}) [![Install with Docker in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=gitea&inputs=[{%22id%22:%22gitea_token%22,%22type%22:%22promptString%22,%22description%22:%22Gitea%20Personal%20Access%20Token%22,%22password%22:true}]&config={%22command%22:%22docker%22,%22args%22:[%22run%22,%22-i%22,%22--rm%22,%22-e%22,%22GITEA_ACCESS_TOKEN%22,%22docker.gitea.com/gitea-mcp-server%22],%22env%22:{%22GITEA_ACCESS_TOKEN%22:%22${input:gitea_token}%22}}&quality=insiders) [![Install with Docker in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=gitea&inputs=[{%22id%22:%22gitea_token%22,%22type%22:%22promptString%22,%22description%22:%22Gitea%20Personal%20Access%20Token%22,%22password%22:true}]&config={%22command%22:%22docker%22,%22args%22:[%22run%22,%22-i%22,%22--rm%22,%22-e%22,%22GITEA_ACCESS_TOKEN%22,%22git.i3omb.com/gronod/gitea-mcp-server%22],%22env%22:{%22GITEA_ACCESS_TOKEN%22:%22${input:gitea_token}%22}}) [![Install with Docker in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=gitea&inputs=[{%22id%22:%22gitea_token%22,%22type%22:%22promptString%22,%22description%22:%22Gitea%20Personal%20Access%20Token%22,%22password%22:true}]&config={%22command%22:%22docker%22,%22args%22:[%22run%22,%22-i%22,%22--rm%22,%22-e%22,%22GITEA_ACCESS_TOKEN%22,%22git.i3omb.com/gronod/gitea-mcp-server%22],%22env%22:{%22GITEA_ACCESS_TOKEN%22:%22${input:gitea_token}%22}}&quality=insiders)
## Installation ## Installation
Download a binary from the [releases page](https://gitea.com/gitea/gitea-mcp/releases) and put it in your `PATH`, use the `docker.gitea.com/gitea-mcp-server` image, or build from source into `$GOPATH/bin` with `make` and Go 1.27 or later: Download a binary from the [releases page](https://git.i3omb.com/gronod/gitea-mcp/releases) and put it in your `PATH`, use the `git.i3omb.com/gronod/gitea-mcp-server` image, or build from source into `$GOPATH/bin` with `make` and Go 1.27 or later:
```bash ```bash
git clone https://gitea.com/gitea/gitea-mcp.git git clone https://git.i3omb.com/gronod/gitea-mcp.git
cd gitea-mcp cd gitea-mcp
make install make install
``` ```
@@ -57,7 +57,7 @@ Use the install buttons at the top of this README, or add the block below to you
"servers": { "servers": {
"gitea-mcp": { "gitea-mcp": {
"command": "docker", "command": "docker",
"args": ["run", "-i", "--rm", "-e", "GITEA_ACCESS_TOKEN", "docker.gitea.com/gitea-mcp-server"], "args": ["run", "-i", "--rm", "-e", "GITEA_ACCESS_TOKEN", "git.i3omb.com/gronod/gitea-mcp-server"],
"env": { "env": {
"GITEA_ACCESS_TOKEN": "${input:gitea_token}" "GITEA_ACCESS_TOKEN": "${input:gitea_token}"
} }
@@ -93,7 +93,7 @@ Add the following to `~/.vibe/config.toml`:
name = "gitea" name = "gitea"
transport = "stdio" transport = "stdio"
command = "docker" command = "docker"
args = ["run", "--rm", "-i", "-e", "GITEA_ACCESS_TOKEN", "-e", "GITEA_HOST", "docker.gitea.com/gitea-mcp-server"] args = ["run", "--rm", "-i", "-e", "GITEA_ACCESS_TOKEN", "-e", "GITEA_HOST", "git.i3omb.com/gronod/gitea-mcp-server"]
[mcp_servers.env] [mcp_servers.env]
GITEA_ACCESS_TOKEN = "TOKEN" GITEA_ACCESS_TOKEN = "TOKEN"
@@ -160,8 +160,8 @@ Once configured, try `list all my repositories` in the chat box.
| package_write | packages | Write | Delete a package version (irreversible) | | package_write | packages | Write | Delete a package version (irreversible) |
| list_issues | issue | Read | List repository issues | | list_issues | issue | Read | List repository issues |
| attachment_read | issue | Read | Read issue/comment attachments: list metadata, get metadata, or download content | | attachment_read | issue | Read | Read issue/comment attachments: list metadata, get metadata, or download content |
| issue_read | issue | Read | Read issue: details, comments, or labels | | issue_read | issue | Read | Read issue: details, comments, labels, or dependency/blocking links |
| issue_write | issue | Write | Write issues: create, update, manage comments and labels | | issue_write | issue | Write | Write issues: create, update, manage comments, labels, and dependency/blocking links |
| list_pull_requests | pull_request | Read | List repository pull requests | | list_pull_requests | pull_request | Read | List repository pull requests |
| pull_request_read | pull_request | Read | Read pull request: details, diff, files, status, reviews, review comments | | pull_request_read | pull_request | Read | Read pull request: details, diff, files, status, reviews, review comments |
| pull_request_write | pull_request | Write | Write pull requests: create, update, close, reopen, merge, update branch, manage reviewers | | pull_request_write | pull_request | Write | Write pull requests: create, update, close, reopen, merge, update branch, manage reviewers |
+7 -7
View File
@@ -4,14 +4,14 @@
**Gitea MCP 服务器** 将 [Gitea](https://about.gitea.com) 实例接入 [Model Context Protocol](https://modelcontextprotocol.io) 客户端,让仓库、问题、拉取请求等都能在兼容 MCP 的聊天界面中浏览和管理。 **Gitea MCP 服务器** 将 [Gitea](https://about.gitea.com) 实例接入 [Model Context Protocol](https://modelcontextprotocol.io) 客户端,让仓库、问题、拉取请求等都能在兼容 MCP 的聊天界面中浏览和管理。
[![在 VS Code 中使用 Docker 安装](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=gitea&inputs=[{%22id%22:%22gitea_token%22,%22type%22:%22promptString%22,%22description%22:%22Gitea%20Personal%20Access%20Token%22,%22password%22:true}]&config={%22command%22:%22docker%22,%22args%22:[%22run%22,%22-i%22,%22--rm%22,%22-e%22,%22GITEA_ACCESS_TOKEN%22,%22docker.gitea.com/gitea-mcp-server%22],%22env%22:{%22GITEA_ACCESS_TOKEN%22:%22${input:gitea_token}%22}}) [![在 VS Code Insiders 中使用 Docker 安装](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=gitea&inputs=[{%22id%22:%22gitea_token%22,%22type%22:%22promptString%22,%22description%22:%22Gitea%20Personal%20Access%20Token%22,%22password%22:true}]&config={%22command%22:%22docker%22,%22args%22:[%22run%22,%22-i%22,%22--rm%22,%22-e%22,%22GITEA_ACCESS_TOKEN%22,%22docker.gitea.com/gitea-mcp-server%22],%22env%22:{%22GITEA_ACCESS_TOKEN%22:%22${input:gitea_token}%22}}&quality=insiders) [![在 VS Code 中使用 Docker 安装](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=gitea&inputs=[{%22id%22:%22gitea_token%22,%22type%22:%22promptString%22,%22description%22:%22Gitea%20Personal%20Access%20Token%22,%22password%22:true}]&config={%22command%22:%22docker%22,%22args%22:[%22run%22,%22-i%22,%22--rm%22,%22-e%22,%22GITEA_ACCESS_TOKEN%22,%22git.i3omb.com/gronod/gitea-mcp-server%22],%22env%22:{%22GITEA_ACCESS_TOKEN%22:%22${input:gitea_token}%22}}) [![在 VS Code Insiders 中使用 Docker 安装](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=gitea&inputs=[{%22id%22:%22gitea_token%22,%22type%22:%22promptString%22,%22description%22:%22Gitea%20Personal%20Access%20Token%22,%22password%22:true}]&config={%22command%22:%22docker%22,%22args%22:[%22run%22,%22-i%22,%22--rm%22,%22-e%22,%22GITEA_ACCESS_TOKEN%22,%22git.i3omb.com/gronod/gitea-mcp-server%22],%22env%22:{%22GITEA_ACCESS_TOKEN%22:%22${input:gitea_token}%22}}&quality=insiders)
## 安装 ## 安装
可从 [发布页面](https://gitea.com/gitea/gitea-mcp/releases) 下载二进制文件并放入 `PATH`,或使用 `docker.gitea.com/gitea-mcp-server` 镜像,也可用 `make` 和 Go 1.27 及以上从源码构建到 `$GOPATH/bin`: 可从 [发布页面](https://git.i3omb.com/gronod/gitea-mcp/releases) 下载二进制文件并放入 `PATH`,或使用 `git.i3omb.com/gronod/gitea-mcp-server` 镜像,也可用 `make` 和 Go 1.27 及以上从源码构建到 `$GOPATH/bin`:
```bash ```bash
git clone https://gitea.com/gitea/gitea-mcp.git git clone https://git.i3omb.com/gronod/gitea-mcp.git
cd gitea-mcp cd gitea-mcp
make install make install
``` ```
@@ -57,7 +57,7 @@ claude mcp add --transport stdio --scope user gitea \
"servers": { "servers": {
"gitea-mcp": { "gitea-mcp": {
"command": "docker", "command": "docker",
"args": ["run", "-i", "--rm", "-e", "GITEA_ACCESS_TOKEN", "docker.gitea.com/gitea-mcp-server"], "args": ["run", "-i", "--rm", "-e", "GITEA_ACCESS_TOKEN", "git.i3omb.com/gronod/gitea-mcp-server"],
"env": { "env": {
"GITEA_ACCESS_TOKEN": "${input:gitea_token}" "GITEA_ACCESS_TOKEN": "${input:gitea_token}"
} }
@@ -93,7 +93,7 @@ claude mcp add --transport stdio --scope user gitea \
name = "gitea" name = "gitea"
transport = "stdio" transport = "stdio"
command = "docker" command = "docker"
args = ["run", "--rm", "-i", "-e", "GITEA_ACCESS_TOKEN", "-e", "GITEA_HOST", "docker.gitea.com/gitea-mcp-server"] args = ["run", "--rm", "-i", "-e", "GITEA_ACCESS_TOKEN", "-e", "GITEA_HOST", "git.i3omb.com/gronod/gitea-mcp-server"]
[mcp_servers.env] [mcp_servers.env]
GITEA_ACCESS_TOKEN = "TOKEN" GITEA_ACCESS_TOKEN = "TOKEN"
@@ -160,8 +160,8 @@ Cursor 等客户端可使用 stdio 命令:
| package_write | packages | 写入 | 删除软件包版本(不可恢复) | | package_write | packages | 写入 | 删除软件包版本(不可恢复) |
| list_issues | issue | 读取 | 列出仓库问题 | | list_issues | issue | 读取 | 列出仓库问题 |
| attachment_read | issue | 读取 | 读取问题/评论附件:列出元数据、获取元数据或下载内容 | | attachment_read | issue | 读取 | 读取问题/评论附件:列出元数据、获取元数据或下载内容 |
| issue_read | issue | 读取 | 读取问题:详情、评论或标签 | | issue_read | issue | 读取 | 读取问题:详情、评论、标签或阻塞关系 |
| issue_write | issue | 写入 | 写入问题:创建、更新、管理评论和标签 | | issue_write | issue | 写入 | 写入问题:创建、更新、管理评论、标签和阻塞关系 |
| list_pull_requests | pull_request | 读取 | 列出仓库拉取请求 | | list_pull_requests | pull_request | 读取 | 列出仓库拉取请求 |
| pull_request_read | pull_request | 读取 | 读取拉取请求:详情、差异、变更文件、头部提交状态、审查、审查评论 | | pull_request_read | pull_request | 读取 | 读取拉取请求:详情、差异、变更文件、头部提交状态、审查、审查评论 |
| pull_request_write | pull_request | 写入 | 写入拉取请求:创建、更新、关闭、重新打开、合并、更新分支、管理审查者 | | pull_request_write | pull_request | 写入 | 写入拉取请求:创建、更新、关闭、重新打开、合并、更新分支、管理审查者 |
+7 -7
View File
@@ -4,14 +4,14 @@
**Gitea MCP 伺服器** 將 [Gitea](https://about.gitea.com) 實例接入 [Model Context Protocol](https://modelcontextprotocol.io) 客戶端,讓倉庫、問題、拉取請求等都能在相容 MCP 的聊天介面中瀏覽與管理。 **Gitea MCP 伺服器** 將 [Gitea](https://about.gitea.com) 實例接入 [Model Context Protocol](https://modelcontextprotocol.io) 客戶端,讓倉庫、問題、拉取請求等都能在相容 MCP 的聊天介面中瀏覽與管理。
[![在 VS Code 中使用 Docker 安裝](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=gitea&inputs=[{%22id%22:%22gitea_token%22,%22type%22:%22promptString%22,%22description%22:%22Gitea%20Personal%20Access%20Token%22,%22password%22:true}]&config={%22command%22:%22docker%22,%22args%22:[%22run%22,%22-i%22,%22--rm%22,%22-e%22,%22GITEA_ACCESS_TOKEN%22,%22docker.gitea.com/gitea-mcp-server%22],%22env%22:{%22GITEA_ACCESS_TOKEN%22:%22${input:gitea_token}%22}}) [![在 VS Code Insiders 中使用 Docker 安裝](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=gitea&inputs=[{%22id%22:%22gitea_token%22,%22type%22:%22promptString%22,%22description%22:%22Gitea%20Personal%20Access%20Token%22,%22password%22:true}]&config={%22command%22:%22docker%22,%22args%22:[%22run%22,%22-i%22,%22--rm%22,%22-e%22,%22GITEA_ACCESS_TOKEN%22,%22docker.gitea.com/gitea-mcp-server%22],%22env%22:{%22GITEA_ACCESS_TOKEN%22:%22${input:gitea_token}%22}}&quality=insiders) [![在 VS Code 中使用 Docker 安裝](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=gitea&inputs=[{%22id%22:%22gitea_token%22,%22type%22:%22promptString%22,%22description%22:%22Gitea%20Personal%20Access%20Token%22,%22password%22:true}]&config={%22command%22:%22docker%22,%22args%22:[%22run%22,%22-i%22,%22--rm%22,%22-e%22,%22GITEA_ACCESS_TOKEN%22,%22git.i3omb.com/gronod/gitea-mcp-server%22],%22env%22:{%22GITEA_ACCESS_TOKEN%22:%22${input:gitea_token}%22}}) [![在 VS Code Insiders 中使用 Docker 安裝](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=gitea&inputs=[{%22id%22:%22gitea_token%22,%22type%22:%22promptString%22,%22description%22:%22Gitea%20Personal%20Access%20Token%22,%22password%22:true}]&config={%22command%22:%22docker%22,%22args%22:[%22run%22,%22-i%22,%22--rm%22,%22-e%22,%22GITEA_ACCESS_TOKEN%22,%22git.i3omb.com/gronod/gitea-mcp-server%22],%22env%22:{%22GITEA_ACCESS_TOKEN%22:%22${input:gitea_token}%22}}&quality=insiders)
## 安裝 ## 安裝
可從 [發布頁面](https://gitea.com/gitea/gitea-mcp/releases) 下載二進位檔並放入 `PATH`,或使用 `docker.gitea.com/gitea-mcp-server` 映像檔,也可用 `make` 與 Go 1.27 以上從原始碼建置到 `$GOPATH/bin`: 可從 [發布頁面](https://git.i3omb.com/gronod/gitea-mcp/releases) 下載二進位檔並放入 `PATH`,或使用 `git.i3omb.com/gronod/gitea-mcp-server` 映像檔,也可用 `make` 與 Go 1.27 以上從原始碼建置到 `$GOPATH/bin`:
```bash ```bash
git clone https://gitea.com/gitea/gitea-mcp.git git clone https://git.i3omb.com/gronod/gitea-mcp.git
cd gitea-mcp cd gitea-mcp
make install make install
``` ```
@@ -57,7 +57,7 @@ claude mcp add --transport stdio --scope user gitea \
"servers": { "servers": {
"gitea-mcp": { "gitea-mcp": {
"command": "docker", "command": "docker",
"args": ["run", "-i", "--rm", "-e", "GITEA_ACCESS_TOKEN", "docker.gitea.com/gitea-mcp-server"], "args": ["run", "-i", "--rm", "-e", "GITEA_ACCESS_TOKEN", "git.i3omb.com/gronod/gitea-mcp-server"],
"env": { "env": {
"GITEA_ACCESS_TOKEN": "${input:gitea_token}" "GITEA_ACCESS_TOKEN": "${input:gitea_token}"
} }
@@ -93,7 +93,7 @@ claude mcp add --transport stdio --scope user gitea \
name = "gitea" name = "gitea"
transport = "stdio" transport = "stdio"
command = "docker" command = "docker"
args = ["run", "--rm", "-i", "-e", "GITEA_ACCESS_TOKEN", "-e", "GITEA_HOST", "docker.gitea.com/gitea-mcp-server"] args = ["run", "--rm", "-i", "-e", "GITEA_ACCESS_TOKEN", "-e", "GITEA_HOST", "git.i3omb.com/gronod/gitea-mcp-server"]
[mcp_servers.env] [mcp_servers.env]
GITEA_ACCESS_TOKEN = "TOKEN" GITEA_ACCESS_TOKEN = "TOKEN"
@@ -160,8 +160,8 @@ Cursor 等客戶端可使用 stdio 命令:
| package_write | packages | 寫入 | 刪除軟體套件版本(不可復原) | | package_write | packages | 寫入 | 刪除軟體套件版本(不可復原) |
| list_issues | issue | 讀取 | 列出倉庫問題 | | list_issues | issue | 讀取 | 列出倉庫問題 |
| attachment_read | issue | 讀取 | 讀取問題/評論附件:列出中繼資料、取得中繼資料或下載內容 | | attachment_read | issue | 讀取 | 讀取問題/評論附件:列出中繼資料、取得中繼資料或下載內容 |
| issue_read | issue | 讀取 | 讀取問題:詳情、評論或標籤 | | issue_read | issue | 讀取 | 讀取問題:詳情、評論、標籤或阻塞關係 |
| issue_write | issue | 寫入 | 寫入問題:創建、更新、管理評論和標籤 | | issue_write | issue | 寫入 | 寫入問題:創建、更新、管理評論、標籤和阻塞關係 |
| list_pull_requests | pull_request | 讀取 | 列出倉庫拉取請求 | | list_pull_requests | pull_request | 讀取 | 列出倉庫拉取請求 |
| pull_request_read | pull_request | 讀取 | 讀取拉取請求:詳情、差異、變更檔案、頭部提交狀態、審查、審查評論 | | pull_request_read | pull_request | 讀取 | 讀取拉取請求:詳情、差異、變更檔案、頭部提交狀態、審查、審查評論 |
| pull_request_write | pull_request | 寫入 | 寫入拉取請求:創建、更新、關閉、重新開啟、合併、更新分支、管理審查者 | | pull_request_write | pull_request | 寫入 | 寫入拉取請求:創建、更新、關閉、重新開啟、合併、更新分支、管理審查者 |
+2 -2
View File
@@ -2,7 +2,7 @@ module gitea.com/gitea/gitea-mcp
go 1.27 go 1.27
toolchain go1.27.0 toolchain go1.27.1
require ( require (
gitea.dev/sdk v1.2.0 gitea.dev/sdk v1.2.0
@@ -21,7 +21,7 @@ require (
github.com/segmentio/encoding v0.5.4 // indirect github.com/segmentio/encoding v0.5.4 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.55.0 // indirect golang.org/x/crypto v0.56.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.47.0 // indirect
+2 -2
View File
@@ -37,8 +37,8 @@ go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
+181
View File
@@ -0,0 +1,181 @@
package issue
import (
"context"
"fmt"
"net/http"
"net/url"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "gitea.dev/sdk"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// issueMeta carries owner/repo in the body; the SDK's IssueMeta only has Index
// and Gitea resolves the body repo from those fields.
type issueMeta struct {
Index int64 `json:"index"`
Owner string `json:"owner"`
Repo string `json:"repo"`
}
func listIssueBlocksFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, repo, index, err := issueRef(args)
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))
}
listOpt := gitea_sdk.ListOptions{Page: page, PageSize: pageSize}
issues, _, err := client.Issues.ListIssueBlocks(ctx, owner, repo, index, gitea_sdk.ListIssueBlocksOptions{
ListOptions: listOpt,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list %v/%v/issue/%v/blocks err: %v", owner, repo, index, err))
}
return to.TextResult(slimIssues(issues))
}
func blockIssueFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
return setIssueBlockingFn(ctx, args, true)
}
func unblockIssueFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
return setIssueBlockingFn(ctx, args, false)
}
func setIssueBlockingFn(ctx context.Context, args map[string]any, block bool) (*mcp.CallToolResult, error) {
owner, repo, index, err := issueRef(args)
if err != nil {
return to.ErrorResult(err)
}
blocked, err := params.GetIndex(args, "blocked_issue")
if err != nil {
return to.ErrorResult(err)
}
blockedOwner, blockedRepo := relatedIssueRepo(args, "blocked_owner", "blocked_repo", owner, repo)
method := http.MethodPost
if !block {
method = http.MethodDelete
}
var issue gitea_sdk.Issue
path := fmt.Sprintf("repos/%s/%s/issues/%d/blocks", url.PathEscape(owner), url.PathEscape(repo), index)
if _, err := gitea.DoJSON(ctx, method, path, nil, issueMeta{Index: blocked, Owner: blockedOwner, Repo: blockedRepo}, &issue); err != nil {
return to.ErrorResult(fmt.Errorf("update %v/%v/issue/%v/blocks err: %v", owner, repo, index, err))
}
return to.TextResult(slimIssue(&issue))
}
func listIssueDependenciesFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, repo, index, err := issueRef(args)
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))
}
listOpt := gitea_sdk.ListOptions{Page: page, PageSize: pageSize}
issues, _, err := client.Issues.ListIssueDependencies(ctx, owner, repo, index, gitea_sdk.ListIssueDependenciesOptions{
ListOptions: listOpt,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list %v/%v/issue/%v/dependencies err: %v", owner, repo, index, err))
}
return to.TextResult(slimIssues(issues))
}
func addIssueDependencyFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
return setIssueDependencyFn(ctx, args, true)
}
func removeIssueDependencyFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
return setIssueDependencyFn(ctx, args, false)
}
func setIssueDependencyFn(ctx context.Context, args map[string]any, add bool) (*mcp.CallToolResult, error) {
owner, repo, index, err := issueRef(args)
if err != nil {
return to.ErrorResult(err)
}
blocking, err := params.GetIndex(args, "blocking_issue")
if err != nil {
return to.ErrorResult(err)
}
blockingOwner, blockingRepo := relatedIssueRepo(args, "blocking_owner", "blocking_repo", owner, repo)
method := http.MethodPost
if !add {
method = http.MethodDelete
}
var issue gitea_sdk.Issue
path := fmt.Sprintf("repos/%s/%s/issues/%d/dependencies", url.PathEscape(owner), url.PathEscape(repo), index)
if _, err := gitea.DoJSON(ctx, method, path, nil, issueMeta{Index: blocking, Owner: blockingOwner, Repo: blockingRepo}, &issue); err != nil {
return to.ErrorResult(fmt.Errorf("update %v/%v/issue/%v/dependencies err: %v", owner, repo, index, err))
}
return to.TextResult(slimIssue(&issue))
}
func checkIssueBlockedFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, repo, index, err := issueRef(args)
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))
}
listOpt := gitea_sdk.ListOptions{Page: page, PageSize: pageSize}
issues, _, err := client.Issues.ListIssueDependencies(ctx, owner, repo, index, gitea_sdk.ListIssueDependenciesOptions{
ListOptions: listOpt,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list %v/%v/issue/%v/dependencies err: %v", owner, repo, index, err))
}
openBlockers := make([]map[string]any, 0)
for _, issue := range issues {
if issue == nil {
continue
}
if issue.State == gitea_sdk.StateOpen || issue.State == "" {
openBlockers = append(openBlockers, slimIssue(issue))
}
}
return to.TextResult(map[string]any{
"blocked": len(openBlockers) > 0,
"total_dependencies": len(issues),
"open_blockers": len(openBlockers),
"blockers": slimIssues(issues),
"open": openBlockers,
})
}
func relatedIssueRepo(args map[string]any, ownerKey, repoKey, owner, repo string) (string, string) {
if o := params.GetOptionalString(args, ownerKey, ""); o != "" {
owner = o
}
if r := params.GetOptionalString(args, repoKey, ""); r != "" {
repo = r
}
return owner, repo
}
func issueRef(args map[string]any) (owner, repo string, index int64, err error) {
if owner, err = params.GetString(args, "owner"); err != nil {
return "", "", 0, err
}
if repo, err = params.GetString(args, "repo"); err != nil {
return "", "", 0, err
}
if index, err = params.GetIndex(args, "issue_number"); err != nil {
return "", "", 0, err
}
return owner, repo, index, nil
}
+207
View File
@@ -0,0 +1,207 @@
package issue
import (
"context"
"encoding/json"
"fmt"
"maps"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func Test_issueBlocking(t *testing.T) {
const (
owner = "octo"
repo = "demo"
)
blocksPath := fmt.Sprintf("/api/v1/repos/%s/%s/issues/42/blocks", owner, repo)
depsPath := fmt.Sprintf("/api/v1/repos/%s/%s/issues/42/dependencies", owner, repo)
tests := []struct {
name string
fn func(context.Context, map[string]any) (*mcp.CallToolResult, error)
args map[string]any
wantPath string
wantMethod string
wantQuery string
wantIndex float64
wantOwner string
wantRepo string
wantBody string
}{
{
name: "list blocks paginates",
fn: listIssueBlocksFn,
args: map[string]any{"page": float64(2), "per_page": float64(10)},
wantPath: blocksPath,
wantMethod: http.MethodGet,
wantQuery: "limit=10&page=2",
},
{
name: "block issue posts index",
fn: blockIssueFn,
args: map[string]any{"blocked_issue": float64(7)},
wantPath: blocksPath,
wantMethod: http.MethodPost,
wantIndex: 7,
wantOwner: owner,
wantRepo: repo,
},
{
name: "block issue posts cross-repo target",
fn: blockIssueFn,
args: map[string]any{
"blocked_issue": float64(7),
"blocked_owner": "other",
"blocked_repo": "elsewhere",
},
wantPath: blocksPath,
wantMethod: http.MethodPost,
wantIndex: 7,
wantOwner: "other",
wantRepo: "elsewhere",
},
{
name: "unblock issue deletes with index",
fn: unblockIssueFn,
args: map[string]any{"blocked_issue": float64(7)},
wantPath: blocksPath,
wantMethod: http.MethodDelete,
wantIndex: 7,
wantOwner: owner,
wantRepo: repo,
},
{
name: "list dependencies paginates",
fn: listIssueDependenciesFn,
args: map[string]any{"page": float64(3), "per_page": float64(5)},
wantPath: depsPath,
wantMethod: http.MethodGet,
wantQuery: "limit=5&page=3",
},
{
name: "add dependency posts index",
fn: addIssueDependencyFn,
args: map[string]any{"blocking_issue": float64(9)},
wantPath: depsPath,
wantMethod: http.MethodPost,
wantIndex: 9,
wantOwner: owner,
wantRepo: repo,
},
{
name: "add dependency posts cross-repo target",
fn: addIssueDependencyFn,
args: map[string]any{
"blocking_issue": float64(9),
"blocking_owner": "other",
"blocking_repo": "elsewhere",
},
wantPath: depsPath,
wantMethod: http.MethodPost,
wantIndex: 9,
wantOwner: "other",
wantRepo: "elsewhere",
},
{
name: "remove dependency deletes with index",
fn: removeIssueDependencyFn,
args: map[string]any{"blocking_issue": float64(9)},
wantPath: depsPath,
wantMethod: http.MethodDelete,
wantIndex: 9,
wantOwner: owner,
wantRepo: repo,
},
{
name: "check blocked summarizes open dependencies",
fn: checkIssueBlockedFn,
args: map[string]any{"page": float64(1), "per_page": float64(30)},
wantPath: depsPath,
wantMethod: http.MethodGet,
wantQuery: "limit=30&page=1",
wantBody: `"blocked":true`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var (
gotMethod string
gotPath string
gotQuery string
gotBody map[string]any
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.27.2"}`))
case blocksPath, depsPath:
gotMethod = r.Method
gotPath = r.URL.Path
gotQuery = r.URL.RawQuery
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodGet {
_, _ = w.Write([]byte(`[{"number":7,"title":"blocked","state":"open"}]`))
} else {
_, _ = w.Write([]byte(`{"number":7,"title":"blocked","state":"open"}`))
}
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, "issue_number": float64(42)}
maps.Copy(args, tt.args)
res, err := tt.fn(context.Background(), args)
if err != nil {
t.Fatalf("handler error = %v", err)
}
if res.IsError {
t.Fatalf("unexpected error result: %v", res.Content)
}
if gotMethod != tt.wantMethod {
t.Fatalf("method = %s, want %s", gotMethod, tt.wantMethod)
}
if gotPath != tt.wantPath {
t.Fatalf("path = %s, want %s", gotPath, tt.wantPath)
}
if tt.wantQuery != "" && gotQuery != tt.wantQuery {
t.Fatalf("query = %s, want %s", gotQuery, tt.wantQuery)
}
if tt.wantIndex != 0 {
if gotBody["index"] != tt.wantIndex {
t.Fatalf("body index = %v, want %v", gotBody["index"], tt.wantIndex)
}
if gotBody["owner"] != tt.wantOwner {
t.Fatalf("body owner = %v, want %s", gotBody["owner"], tt.wantOwner)
}
if gotBody["repo"] != tt.wantRepo {
t.Fatalf("body repo = %v, want %s", gotBody["repo"], tt.wantRepo)
}
}
body := res.Content[0].(*mcp.TextContent).Text
if tt.wantBody != "" {
if !strings.Contains(body, tt.wantBody) {
t.Fatalf("expected %s in output, got: %s", tt.wantBody, body)
}
} else if !strings.Contains(body, `"number":7`) && !strings.Contains(body, `"number": 7`) {
t.Fatalf("expected slim issue in output, got: %s", body)
}
})
}
}
+26 -4
View File
@@ -55,19 +55,21 @@ var (
IssueReadTool = tool.NewDefinition( IssueReadTool = tool.NewDefinition(
IssueReadToolName, IssueReadToolName,
"Read issue: details, comments, or labels.", "Read issue: details, comments, labels, or dependency/blocking links. list_dependencies is issues that block this one (blocked-by); list_blocks is issues this one blocks. check_blocked summarizes whether any of those dependencies are still open. These methods 404 if the repo has issue dependencies disabled.",
annotation.ReadOnly("Read issue details"), annotation.ReadOnly("Read issue details"),
tool.String("method", tool.Required(), tool.Enum("get", "get_comments", "get_labels")), tool.String("method", tool.Required(), tool.Enum("get", "get_comments", "get_labels", "list_blocks", "list_dependencies", "check_blocked")),
tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
tool.Number("issue_number", tool.Required()), tool.Number("issue_number", tool.Required()),
tool.Number("page", tool.Description(params.PageDesc), tool.Default(1)),
tool.Number("per_page", tool.Description(params.PaginationDesc), tool.Default(30)),
) )
IssueWriteTool = tool.NewDefinition( IssueWriteTool = tool.NewDefinition(
IssueWriteToolName, IssueWriteToolName,
"Write issues: create, update, manage comments and labels.", "Write issues: create, update, manage comments, labels, and dependency/blocking links. block_issue makes issue_number block blocked_issue; add_dependency makes issue_number depend on blocking_issue. Cross-repo links use blocked_owner/blocked_repo or blocking_owner/blocking_repo (instance must allow cross-repository dependencies). Repo must have issue dependencies enabled or Gitea returns 404.",
annotation.Write("Create or update issues, comments, and labels"), annotation.Write("Create or update issues, comments, and labels"),
tool.String("method", tool.Required(), tool.Enum("create", "update", "add_comment", "edit_comment", "add_labels", "remove_label", "replace_labels", "clear_labels")), tool.String("method", tool.Required(), tool.Enum("create", "update", "add_comment", "edit_comment", "add_labels", "remove_label", "replace_labels", "clear_labels", "block_issue", "unblock_issue", "add_dependency", "remove_dependency")),
tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
tool.Number("issue_number", tool.Description("required except for 'create'")), tool.Number("issue_number", tool.Description("required except for 'create'")),
@@ -82,6 +84,12 @@ var (
tool.String("ref", tool.Description("branch to associate")), tool.String("ref", tool.Description("branch to associate")),
tool.String("deadline", tool.Description("ISO 8601")), tool.String("deadline", tool.Description("ISO 8601")),
tool.Boolean("remove_deadline"), tool.Boolean("remove_deadline"),
tool.Number("blocked_issue", tool.Description("issue number that issue_number blocks (required for 'block_issue'/'unblock_issue')")),
tool.String("blocked_owner", tool.Description("owner of blocked_issue; defaults to owner")),
tool.String("blocked_repo", tool.Description("repo of blocked_issue; defaults to repo")),
tool.Number("blocking_issue", tool.Description("issue number that blocks issue_number (required for 'add_dependency'/'remove_dependency')")),
tool.String("blocking_owner", tool.Description("owner of blocking_issue; defaults to owner")),
tool.String("blocking_repo", tool.Description("repo of blocking_issue; defaults to repo")),
) )
) )
@@ -112,6 +120,12 @@ func issueReadFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult,
return getIssueCommentsByIndexFn(ctx, args) return getIssueCommentsByIndexFn(ctx, args)
case "get_labels": case "get_labels":
return getIssueLabelsFn(ctx, args) return getIssueLabelsFn(ctx, args)
case "list_blocks":
return listIssueBlocksFn(ctx, args)
case "list_dependencies":
return listIssueDependenciesFn(ctx, args)
case "check_blocked":
return checkIssueBlockedFn(ctx, args)
default: default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
@@ -139,6 +153,14 @@ func issueWriteFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult
return replaceIssueLabelsFn(ctx, args) return replaceIssueLabelsFn(ctx, args)
case "clear_labels": case "clear_labels":
return clearIssueLabelsFn(ctx, args) return clearIssueLabelsFn(ctx, args)
case "block_issue":
return blockIssueFn(ctx, args)
case "unblock_issue":
return unblockIssueFn(ctx, args)
case "add_dependency":
return addIssueDependencyFn(ctx, args)
case "remove_dependency":
return removeIssueDependencyFn(ctx, args)
default: default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
-96
View File
@@ -1,96 +0,0 @@
#!/bin/sh
# Copyright 2026 The Gitea Authors. All rights reserved.
# SPDX-License-Identifier: MIT
#
# upload-r2.sh uploads a single local file to a single object key in a
# Cloudflare R2 bucket, using curl's built-in AWS SigV4 signer (R2 is
# S3-API compatible).
#
# Usage:
# upload-r2.sh <local-file> <remote-key>
# upload-r2.sh --check-config
#
# The second form only validates that the required environment
# variables below are set (it does not touch the network), and is
# meant to be run as an early preflight step in CI so that a missing
# R2_* secret is reported before anything is built or published.
#
# Required environment variables:
# R2_ENDPOINT Base URL of the R2 endpoint, e.g.
# https://<account>.r2.cloudflarestorage.com
# R2_BUCKET Destination bucket name.
# R2_ACCESS_KEY_ID R2 access key id.
# R2_SECRET_ACCESS_KEY R2 secret access key.
set -eu
# check_env validates that all required R2_* environment variables are
# set and non-empty, so the validation logic only exists in one place
# for both the normal upload mode and --check-config.
check_env() {
missing=""
if [ -z "${R2_ENDPOINT:-}" ]; then
missing="$missing R2_ENDPOINT"
fi
if [ -z "${R2_BUCKET:-}" ]; then
missing="$missing R2_BUCKET"
fi
if [ -z "${R2_ACCESS_KEY_ID:-}" ]; then
missing="$missing R2_ACCESS_KEY_ID"
fi
if [ -z "${R2_SECRET_ACCESS_KEY:-}" ]; then
missing="$missing R2_SECRET_ACCESS_KEY"
fi
if [ -n "$missing" ]; then
echo "upload-r2.sh: missing required environment variable(s):$missing" >&2
exit 1
fi
}
if [ "$#" -eq 1 ] && [ "$1" = "--check-config" ]; then
check_env
echo "upload-r2.sh: R2 configuration OK"
exit 0
fi
if [ "$#" -ne 2 ]; then
echo "usage: upload-r2.sh <local-file> <remote-key>" >&2
echo " upload-r2.sh --check-config" >&2
exit 1
fi
local_file="$1"
remote_key="$2"
if [ ! -f "$local_file" ]; then
echo "upload-r2.sh: local file not found: $local_file" >&2
exit 1
fi
check_env
# Strip a single trailing slash from the endpoint, if present, so that
# building the path-style URL below never produces a double slash.
endpoint="${R2_ENDPOINT%/}"
url="$endpoint/$R2_BUCKET/$remote_key"
# Credentials are passed to curl through a config file read from
# stdin rather than as a command-line argument, so they never show up
# in `ps` output.
#
# --fail-with-body (instead of plain --fail) still exits non-zero on
# HTTP errors, but also prints R2's XML error body, which is where the
# actual error code lives (SignatureDoesNotMatch, NoSuchBucket,
# AccessDenied, ...). --retry 3 (without --retry-all-errors) only
# retries the transient cases (5xx, 408, 429, connection failures).
printf 'user = "%s:%s"\n' "$R2_ACCESS_KEY_ID" "$R2_SECRET_ACCESS_KEY" | curl \
--config - \
--fail-with-body \
--silent \
--show-error \
--retry 3 \
--aws-sigv4 "aws:amz:auto:s3" \
--upload-file "$local_file" \
"$url"