Compare commits

..

13 Commits

Author SHA1 Message Date
Lunny Xiao 3c6d2ecd6b feat(issue): add get_discussion_markdown to issue_read
Adds a new issue_read method that fetches an issue and its comments
and renders them as a single Markdown document, instead of a JSON
array. Formatting logic lives in a pure, unit-tested helper
(formatDiscussionMarkdown) covering comment rendering, empty comment
lists, and attachment-inlined bodies. Existing get/get_comments/
get_labels methods are unchanged.

Co-Authored-By: Codet <codet@commitgo.dev> (GPT-5-Codex)
2026-08-24 00:14:50 -07:00
Renovate Bot 815a0e26aa fix(deps): update go toolchain directive to v1.26.6 [security] (#242)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [go](https://go.dev/) ([source](https://github.com/golang/go)) | toolchain | patch | `1.26.5` → `1.26.6` |

---

### Invoking failure to reject ASCII-only Punycode-encoded labels in golang.org/x/net/idna
[CVE-2026-39821](https://nvd.nist.gov/vuln/detail/CVE-2026-39821) / [GO-2026-5026](https://pkg.go.dev/vuln/GO-2026-5026)

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

#### Details
The ToASCII and ToUnicode functions incorrectly accept Punycode-encoded labels that decode to an ASCII-only label. For example, ToUnicode("xn--example-.com") incorrectly returns the name "example.com" rather than an error.

This behavior can lead to privilege escalation in programs using the idna package. For example, a program which performs privilege checks on the ASCII hostname may reject "example.com" but permit "xn--example-.com". If that program subsequently converts the ASCII hostname to Unicode, it will inadvertently permits access to the Unicode name "example.com".

#### Severity
Unknown

#### References
- [https://go.dev/cl/767220](https://go.dev/cl/767220)
- [https://go.dev/issue/78760](https://go.dev/issue/78760)
- [https://groups.google.com/g/golang-announce/c/iI-mYSI0lu8](https://groups.google.com/g/golang-announce/c/iI-mYSI0lu8)
- [https://groups.google.com/g/golang-announce/c/94pEornpRlI](https://groups.google.com/g/golang-announce/c/94pEornpRlI)

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

---

### Parsing an invalid SVCB or HTTPS RR can panic in golang.org/x/net/dns/dnsmessage
BIT-golang-2026-46600 / [CVE-2026-46600](https://nvd.nist.gov/vuln/detail/CVE-2026-46600) / [GO-2026-5942](https://pkg.go.dev/vuln/GO-2026-5942)

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

#### Details
Parsing an invalid SVCB or HTTPS RR can panic when the size of a parameter value overflows the message buffer.

#### Severity
Unknown

#### References
- [https://go.dev/cl/786345](https://go.dev/cl/786345)
- [https://go.dev/issue/79795](https://go.dev/issue/79795)
- [https://groups.google.com/g/golang-announce/c/94pEornpRlI](https://groups.google.com/g/golang-announce/c/94pEornpRlI)

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

---

### Enforce maximum recursion depth in encoding/asn1
BIT-golang-2026-33818 / [CVE-2026-33818](https://nvd.nist.gov/vuln/detail/CVE-2026-33818) / [GO-2026-5972](https://pkg.go.dev/vuln/GO-2026-5972)

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

#### Details
Enforce a recursion limit in Unmarshal to prevent stack exhaustion when parsing deeply-nested, recursive structures.

#### Severity
Unknown

#### References
- [https://go.dev/issue/80405](https://go.dev/issue/80405)
- [https://groups.google.com/g/golang-announce/c/94pEornpRlI](https://groups.google.com/g/golang-announce/c/94pEornpRlI)
- [https://go.dev/cl/814980](https://go.dev/cl/814980)

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

---

### Add recursion depth guard during decode in encoding/xml
BIT-golang-2026-56859 / [CVE-2026-56859](https://nvd.nist.gov/vuln/detail/CVE-2026-56859) / [GO-2026-6088](https://pkg.go.dev/vuln/GO-2026-6088)

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

#### Details
Previously, DecodeElement would reset the depth counter causing it to never fire; this could lead to stack exhaustion.

#### Severity
Unknown

#### References
- [https://go.dev/issue/80481](https://go.dev/issue/80481)
- [https://go.dev/cl/803320](https://go.dev/cl/803320)
- [https://groups.google.com/g/golang-announce/c/94pEornpRlI](https://groups.google.com/g/golang-announce/c/94pEornpRlI)

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

---

### Apply ReadHeaderTimeout when doing unencrypted HTTP/2 check in net/http
BIT-golang-2026-56853 / [CVE-2026-56853](https://nvd.nist.gov/vuln/detail/CVE-2026-56853) / [GO-2026-6089](https://pkg.go.dev/vuln/GO-2026-6089)

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

#### Details
When a server is configured to support unencrypted HTTP/2, it reads a few bytes from each new connection to see if they contain the HTTP/2 client preface. ReadHeaderTimeout is unexpectedly not being applied when doing this.

#### Severity
Unknown

#### References
- [https://go.dev/issue/80205](https://go.dev/issue/80205)
- [https://go.dev/cl/795540](https://go.dev/cl/795540)
- [https://groups.google.com/g/golang-announce/c/94pEornpRlI](https://groups.google.com/g/golang-announce/c/94pEornpRlI)

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

---

### Limit handshake messages we are willing to accept post-handshake in crypto/tls
BIT-golang-2026-56862 / [CVE-2026-56862](https://nvd.nist.gov/vuln/detail/CVE-2026-56862) / [GO-2026-6090](https://pkg.go.dev/vuln/GO-2026-6090)

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

#### Details
Handshake messages, such as KeyUpdate, are always considered as state-advancing, regardless of whether a handshake has been completed or not. As a result, a malicious client can keep sending KeyUpdate messages to force the server to keep performing key derivation operations indefinitely.

#### Severity
Unknown

#### References
- [https://go.dev/issue/80528](https://go.dev/issue/80528)
- [https://go.dev/cl/804261](https://go.dev/cl/804261)
- [https://groups.google.com/g/golang-announce/c/94pEornpRlI](https://groups.google.com/g/golang-announce/c/94pEornpRlI)

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

---

### Fix Javascript regexp context tracking in html/template
BIT-golang-2026-56858 / [CVE-2026-56858](https://nvd.nist.gov/vuln/detail/CVE-2026-56858) / [GO-2026-6091](https://pkg.go.dev/vuln/GO-2026-6091)

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

#### Details
Previously, pathological inputs could close an unescaped '/' early, allowing for attack-controlled data to inject arbitrary content, potentially leading to XSS.

#### Severity
Unknown

#### References
- [https://go.dev/issue/80435](https://go.dev/issue/80435)
- [https://go.dev/cl/807100](https://go.dev/cl/807100)
- [https://groups.google.com/g/golang-announce/c/94pEornpRlI](https://groups.google.com/g/golang-announce/c/94pEornpRlI)

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

---

### Avoid quadratic complexity in resolvePath in net/url
BIT-golang-2026-56860 / [CVE-2026-56860](https://nvd.nist.gov/vuln/detail/CVE-2026-56860) / [GO-2026-6218](https://pkg.go.dev/vuln/GO-2026-6218)

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

#### Details
Previously, resolving relative paths containing parent directory ('..') segments performed string conversions and buffer rewrites on each step, resulting in quadratic time complexity and high memory allocation overhead.

Now, path resolution operates on a byte buffer using index-based backtracking for '..' segments, eliminating the quadratic time complexity and significantly reducing memory allocations.

#### Severity
Unknown

#### References
- [https://go.dev/cl/803681](https://go.dev/cl/803681)
- [https://go.dev/issue/80494](https://go.dev/issue/80494)
- [https://groups.google.com/g/golang-announce/c/94pEornpRlI](https://groups.google.com/g/golang-announce/c/94pEornpRlI)

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

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - ""
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

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/242
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-08-23 12:40:52 +00:00
Lunny Xiao 7fce9bc790 ci: upload release artifacts to Cloudflare R2 (#237)
Release binaries are currently only attached to the Gitea release, with no mirror on the download CDN. This adds a Cloudflare R2 upload to the tag release workflow, following the approach used by [gitea/tea](https://gitea.com/gitea/tea).

- Add `scripts/upload-r2.sh`, taken from `gitea/tea`: it uploads a single file to an R2 object key with curl's built-in AWS SigV4 signer (R2 is S3-API compatible), and supports a `--check-config` preflight mode. Credentials are passed through a curl config file on stdin so they never appear in `ps` output.
- Add a `Check R2 configuration` step at the beginning of the `goreleaser` job, so a missing `R2_*` secret fails the run before anything is built or published.
- Add an `Upload binaries to Cloudflare R2` step after GoReleaser, which mirrors every archive and `checksums.txt` from `dist/` to `gitea-mcp/<version>/<file>` in the bucket. `tea` does this with a goreleaser `publishers:` entry, but that is a GoReleaser Pro feature and this repository uses the OSS distribution, so the upload runs as a workflow step instead.

Required repository secrets: `R2_ENDPOINT`, `R2_BUCKET`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`. No AWS S3 upload is added; Cloudflare R2 is the only mirror.

Tested locally by pointing `R2_ENDPOINT` at a local HTTP server and verifying the script builds the expected path-style URL and signed request, plus `shellcheck` on the script.

_Authored by Codet (GPT-5-Codex) on behalf of @lunny._

---------

Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/237
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-08-17 17:15:32 +00:00
Renovate Bot 1f5fe9269b chore(deps): update dependencies (#235)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [docker/dockerfile](https://github.com/moby/buildkit) | syntax | minor | `1.25` → `1.26` |
| [docker/login-action](https://github.com/docker/login-action) ([changelog](https://github.com/docker/login-action/compare/371161bbe7024a29a25c5e19bfcbc0804fe9ad2c..dbcb813823bdd20940b903addbd779551569679f)) | action | digest | `371161b` → `dbcb813` |

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - Between 12:00 AM and 03:59 AM, only on Monday (`* 0-3 * * 1`)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://github.com/renovatebot/renovate/discussions) if that's undesired.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

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/235
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-08-10 20:47:34 +00:00
silverwind 7d22bc125b fix: honor new_branch_name on the file update path (#228)
The update path of `create_or_update_file` built `UpdateFileOptions` without `NewBranchName`, so a call asking for a new branch committed straight to the base branch and still reported success.

The success text now names the branch that got the commit.

Fixes: https://gitea.com/gitea/gitea-mcp/issues/217
Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/228
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
2026-08-09 10:10:58 +00:00
silverwind e81df2d9a0 fix: return handler errors and panics as tool results (#230)
Fixes https://gitea.com/gitea/gitea-mcp/issues/229

https://gitea.com/gitea/gitea-mcp/pulls/227 left an unexpected handler error and a recovered panic as JSON-RPC errors, which tell the client the request itself failed and kill the session, as in https://gitea.com/gitea/gitea-mcp/issues/229. Both now return tool results.
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/230
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-09 10:09:14 +00:00
Bo-Yi Wu 75f1adf979 feat!: support protocol 2026-07-28 over HTTP and add --bind (#227)
Adds MCP protocol `2026-07-28` over HTTP through the official Go SDK, and validates the `Origin` header on every request as the spec requires.

Tool and Gitea failures now come back as an ordinary `tools/call` result carrying `result.isError: true`, the way the SDK's own tool wrapper reports them. Malformed requests, unknown tools or methods, and server faults stay JSON-RPC errors.

Adds `-b, --bind` to narrow the listen address. The default still accepts every interface, so this is opt-in hardening. It matters because a request that omits `Authorization` falls back to the server's own token.

**Breaking: the HTTP endpoint no longer keeps a session per client.** What changes for a client:

1. `/mcp` accepts `POST` only, and answers `405` to `GET` or `DELETE`.
2. The server neither sends nor accepts `Mcp-Session-Id`, so there is no session handshake to perform.
3. There is no standalone SSE stream and no `Last-Event-ID` resumption. If a response stream breaks, send the whole request again under a new JSON-RPC id.

Clients that already speak current streamable HTTP need no changes. Anything relying on the session handshake or the standalone SSE stream should stay on the previous release.

---------

Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/227
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2026-08-07 15:14:26 +00:00
Bo-Yi Wu efcbdbb17f refactor: replace mcp-go with the official MCP Go SDK (#223)
## Summary

Replace `github.com/mark3labs/mcp-go` with the official
`github.com/modelcontextprotocol/go-sdk v1.7.0`.

All 54 existing tools, scopes, CLI, `stdio` and HTTP (`/mcp`) modes, and
per-request authentication remain supported. This PR is limited to the SDK
equivalence migration; the stateless 2026 HTTP transport is deferred to a
follow-up PR.

## Verification

- Tool definitions were compared with `main` and matched for all 54 tools.
- `make lint`, `make fmt`, `go test -count=1 -race ./...`, `make build`, and
  `make tidy` pass.

---------

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/223
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2026-08-06 05:36:21 +00:00
silverwind 8b236c2e26 chore: align go version handling with gitea (#226)
Aligns Go version handling with gitea, see https://github.com/go-gitea/gitea/pull/38559. `toolchain` names the build version, `go` stays the minimum.

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/226
Reviewed-by: techknowlogick <9+techknowlogick@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-05 22:50:18 +00:00
Renovate Bot 3fcd864582 chore(deps): update dependencies (#225)
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-08-03 13:18:41 +00:00
silverwind 4eaeb252a0 docs: sync AGENTS.md with gitea/gitea (#224)
Carries over the applicable rules from https://gitea.com/gitea/gitea `AGENTS.md`, covering test scope, linter escape hatches and PR conventions. Swaps `Co-Authored-By` for the `Assisted-by` trailer.

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/224
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-08-02 20:28:58 +00:00
silverwind 290d06b40b chore: onboard renovate, mark Makefile tool packages (#221)
chore: onboard renovate, mark `Makefile` tool packages (gitea/gitea-mcp#221)

Co-authored-by: silverwind <me@silverwind.io>
2026-07-30 16:42:43 +00:00
silverwind 80114e32e6 feat(pull): reply to and resolve review comments (#220)
Adds `reply_comment`, `resolve_thread` and `unresolve_thread` to `pull_request_review_write`, using the endpoints from https://github.com/go-gitea/gitea/pull/36683 and https://github.com/go-gitea/gitea/pull/36441 (SDK v1.2.0, no dependency change).

`review_id` is now optional for `pull_request_read` `get_review_comments`, so finding a comment to reply to takes one call instead of one per review. Review comments gained `review_id` and `resolved_by`.

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

Verified against gitea.com (1.27.0+dev). Written by Claude (Opus 5).

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/220
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2026-07-28 05:26:17 +00:00
64 changed files with 4077 additions and 1769 deletions
+5 -5
View File
@@ -14,18 +14,18 @@ jobs:
DOCKER_LATEST: nightly DOCKER_LATEST: nightly
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with: with:
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@v3 uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
- name: Set up Docker BuildX - name: Set up Docker BuildX
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v3 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with: with:
username: ${{ secrets.DOCKER_USER }} username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }} password: ${{ secrets.DOCKER_TOKEN }}
@@ -37,7 +37,7 @@ jobs:
echo REPO_VERSION=$(git describe --tags --always | sed 's/-/+/' | sed 's/^v//') >> $GITHUB_OUTPUT echo REPO_VERSION=$(git describe --tags --always | sed 's/-/+/' | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Build and push - name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
+34 -8
View File
@@ -10,13 +10,24 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 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@v6 uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with: with:
go-version: stable go-version-file: 'go.mod'
check-latest: true
- name: Install GoReleaser - name: Install GoReleaser
run: go install github.com/goreleaser/goreleaser/v2@latest run: go install github.com/goreleaser/goreleaser/v2@latest
- name: Run GoReleaser - name: Run GoReleaser
@@ -24,6 +35,21 @@ 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
@@ -32,18 +58,18 @@ jobs:
DOCKER_LATEST: latest DOCKER_LATEST: latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with: with:
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@v3 uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
- name: Set up Docker BuildX - name: Set up Docker BuildX
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v3 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with: with:
username: ${{ secrets.DOCKER_USER }} username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }} password: ${{ secrets.DOCKER_TOKEN }}
@@ -55,7 +81,7 @@ jobs:
echo REPO_VERSION=${GITHUB_REF_NAME#v} >> $GITHUB_OUTPUT echo REPO_VERSION=${GITHUB_REF_NAME#v} >> $GITHUB_OUTPUT
- name: Build and push - name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
+5 -2
View File
@@ -7,12 +7,15 @@ jobs:
check-and-test: check-and-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@v6 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with: with:
go-version-file: 'go.mod' go-version-file: 'go.mod'
check-latest: true
- name: lint - name: lint
run: make lint run: make lint
- name: test
run: make test
- name: build - name: build
run: make build run: make build
- name: security-check - name: security-check
+12 -8
View File
@@ -1,12 +1,16 @@
- Never assume, verify before claiming
- Use `make help` to find available development targets - Use `make help` to find available development targets
- Run `make fmt` to format `.go` files, and run `make lint-go` to lint them - PR descriptions: minimal, only what and why, no task lists or file listings
- Run `make tidy` after any `go.mod` changes - Reference issues and PRs by full URL, not by number
- Run single go tests with `go test -run '^TestName$' ./modulepath/`
- Ensure no trailing whitespace in edited files
- Use Conventional Commits for commit messages and PR titles, e.g. `type(scope): subject`; `!` before the colon if breaking. Use `test` type for test-only changes. - Use Conventional Commits for commit messages and PR titles, e.g. `type(scope): subject`; `!` before the colon if breaking. Use `test` type for test-only changes.
- Add an `Assisted-by: AGENT_NAME:MODEL_VERSION` trailer to commit messages, never `Co-Authored-By` or `Signed-off-by`
- Attribute agent authorship on one trailing line in issue and pull request comments, never as a PR description section
- Never force-push, amend, or squash unless asked. Use new commits and normal push for pull request updates - Never force-push, amend, or squash unless asked. Use new commits and normal push for pull request updates
- Preserve existing code comments, do not remove or rewrite comments that are still relevant - Keep comments short, prefer same-line, explain why, never narrate code. Preserve existing ones that still apply
- Keep comments short, prefer same-line, explain why, never narrate code - Ensure no trailing whitespace in edited files
- Run `make fmt` to format `.go` files, `make lint-go` to lint them, and `make tidy` after any `go.mod` changes
- Fix the cause rather than disabling a linter or weakening a test. Where unavoidable, use the narrowest scope with a trailing comment giving the reason
- Register new tools with `Tool.RegisterRead` or `Tool.RegisterWrite`, and add them to the tool tables in `README.md`, `README.zh-cn.md` and `README.zh-tw.md` - Register new tools with `Tool.RegisterRead` or `Tool.RegisterWrite`, and add them to the tool tables in `README.md`, `README.zh-cn.md` and `README.zh-tw.md`
- Include authorship attribution in issue and pull request comments - Run single go tests with `go test -run '^TestName$' ./modulepath/`
- Add `Co-Authored-By` lines to all commits, indicating name and model used - Write the fewest, fastest tests covering the behavior, extending an existing one where possible. Prefer unit tests where logic is testable in isolation
- Wait on a deterministic condition rather than `sleep`
+1 -1
View File
@@ -1,4 +1,4 @@
# syntax=docker/dockerfile:1.4 # syntax=docker/dockerfile:1.26
# Build stage # Build stage
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder
+13 -2
View File
@@ -3,8 +3,10 @@ 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.12.2 GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 # renovate: datasource=go
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.3.0 GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.6.0 # renovate: datasource=go
GOTEST_FLAGS ?= -race -timeout 20m
.PHONY: help .PHONY: help
help: ## print this help message help: ## print this help message
@@ -38,6 +40,10 @@ clean: ## delete build artifacts
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: test
test: ## run Go tests
$(GO) test $(GOTEST_FLAGS) ./...
.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 \
@@ -82,7 +88,12 @@ security-check: ## run security check
.PHONY: tidy .PHONY: tidy
tidy: ## run go mod tidy tidy: ## run go mod tidy
$(eval MIN_GO_VERSION := $(shell grep -Eo '^go\s+[0-9]+\.[0-9.]+' go.mod | cut -d' ' -f2)) $(eval MIN_GO_VERSION := $(shell grep -Eo '^go\s+[0-9]+\.[0-9.]+' go.mod | cut -d' ' -f2))
$(eval GO_TOOLCHAIN := $(shell grep -Eo '^toolchain\s+go[0-9.]+' go.mod | cut -d' ' -f2))
$(GO) mod tidy -compat=$(MIN_GO_VERSION) $(GO) mod tidy -compat=$(MIN_GO_VERSION)
@# workaround https://github.com/golang/go/issues/75331: restore toolchain if tidy dropped it
@if [ -n "$(GO_TOOLCHAIN)" ] && ! grep -qE '^toolchain\s' go.mod; then \
$(GO) mod edit -toolchain=$(GO_TOOLCHAIN); \
fi
.PHONY: vendor .PHONY: vendor
vendor: tidy ## tidy and verify module dependencies vendor: tidy ## tidy and verify module dependencies
+61 -55
View File
@@ -20,6 +20,12 @@ make install
Pass the Gitea host and access token as command-line flags or environment variables, flags take precedence. Run `gitea-mcp --help` for the full list of flags and environment variables. Logs are written to `$HOME/.gitea-mcp/gitea-mcp.log`, add `-d` for debug logging. Pass the Gitea host and access token as command-line flags or environment variables, flags take precedence. Run `gitea-mcp --help` for the full list of flags and environment variables. Logs are written to `$HOME/.gitea-mcp/gitea-mcp.log`, add `-d` for debug logging.
### MCP protocol and HTTP transport
The server supports MCP up to `2026-07-28` and negotiates down to the client's version, advertising only the `tools` capability. Tool and Gitea failures return a `tools/call` result with `result.isError: true`, while malformed requests and server faults stay JSON-RPC errors.
HTTP is always stateless: `/mcp` accepts POST only, without `Mcp-Session-Id`, standalone SSE or `Last-Event-ID` resumability. Origins are validated, and reverse proxies must forward `Mcp-Protocol-Version`, `Mcp-Method` and `Mcp-Name` unchanged. `Authorization: Bearer <token>` and `Authorization: token <token>` pass a Gitea credential per request, which is credential passthrough rather than MCP OAuth.
### Claude Code ### Claude Code
Runs the server through `go run` and requires [Go](https://go.dev): Runs the server through `go run` and requires [Go](https://go.dev):
@@ -129,62 +135,62 @@ Once configured, try `list all my repositories` in the chat box.
## Available Tools ## Available Tools
| Tool | Scope | Access | Description | | Tool | Scope | Access | Description |
| :--------------------------- | :----------- | :----- | :----------------------------------------------------------------------------------------- | | :--------------------------- | :----------- | :----- | :---------- |
| get_gitea_mcp_server_version | version | Read | Get the Gitea MCP server version | | get_gitea_mcp_server_version | version | Read | Get the Gitea MCP server version |
| get_me | user | Read | Get the current authenticated user | | get_me | user | Read | Get the current authenticated user |
| get_user_orgs | user | Read | List the current user's organizations | | get_user_orgs | user | Read | List the current user's organizations |
| search_users | search | Read | Search for users | | search_users | search | Read | Search for users |
| search_org_teams | search | Read | Search teams within an organization | | search_org_teams | search | Read | Search teams within an organization |
| search_repos | search | Read | Search for repositories | | search_repos | search | Read | Search for repositories |
| search_issues | search | Read | Search issues and pull requests across repositories | | search_issues | search | Read | Search issues and pull requests across repositories |
| notification_read | notification | Read | Read notifications: list (optionally scoped to a repo) or get a thread by ID | | notification_read | notification | Read | Read notifications: list (optionally scoped to a repo) or get a thread by ID |
| notification_write | notification | Write | Mark a notification or all notifications as read | | notification_write | notification | Write | Mark a notification or all notifications as read |
| label_read | label | Read | Read repository or organization labels | | label_read | label | Read | Read repository or organization labels |
| label_write | label | Write | Write labels (repo or org): create, edit, delete | | label_write | label | Write | Write labels (repo or org): create, edit, delete |
| milestone_read | milestone | Read | Read milestones: get one or list | | milestone_read | milestone | Read | Read milestones: get one or list |
| milestone_write | milestone | Write | Write milestones: create, update, delete | | milestone_write | milestone | Write | Write milestones: create, update, delete |
| wiki_read | wiki | Read | Read wiki: list pages, get content, revision history | | wiki_read | wiki | Read | Read wiki: list pages, get content, revision history |
| wiki_write | wiki | Write | Write wiki pages: create, update, delete | | wiki_write | wiki | Write | Write wiki pages: create, update, delete |
| timetracking_read | timetracking | Read | Read time tracking: issue/repo times, active stopwatches, your tracked times | | timetracking_read | timetracking | Read | Read time tracking: issue/repo times, active stopwatches, your tracked times |
| timetracking_write | timetracking | Write | Write time tracking: stopwatches and entries | | timetracking_write | timetracking | Write | Write time tracking: stopwatches and entries |
| package_read | packages | Read | Read package registry: list packages, list versions, or get a version | | package_read | packages | Read | Read package registry: list packages, list versions, or get a version |
| 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, or labels |
| issue_write | issue | Write | Write issues: create, update, manage comments and labels | | issue_write | issue | Write | Write issues: create, update, manage comments and labels |
| 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, changed files, head commit status, reviews | | 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 |
| pull_request_review_write | pull_request | Write | Write PR reviews: create, submit, delete, dismiss | | pull_request_review_write | pull_request | Write | Write PR reviews: create, submit, delete, dismiss, reply to and resolve review comments |
| actions_config_read | actions | Read | Read Actions secrets and variables | | actions_config_read | actions | Read | Read Actions secrets and variables |
| actions_config_write | actions | Write | Write Actions secrets and variables: upsert, create, update, delete | | actions_config_write | actions | Write | Write Actions secrets and variables: upsert, create, update, delete |
| actions_run_read | actions | Read | Read Actions workflows, runs, jobs, logs, and artifacts | | actions_run_read | actions | Read | Read Actions workflows, runs, jobs, logs, and artifacts |
| actions_run_write | actions | Write | Write Actions runs: dispatch, cancel, rerun | | actions_run_write | actions | Write | Write Actions runs: dispatch, cancel, rerun |
| create_repo | repository | Write | Create a new repository | | create_repo | repository | Write | Create a new repository |
| fork_repo | repository | Write | Fork a repository | | fork_repo | repository | Write | Fork a repository |
| list_my_repos | repository | Read | List repositories owned by the current user | | list_my_repos | repository | Read | List repositories owned by the current user |
| list_org_repos | repository | Read | List repositories in an organization | | list_org_repos | repository | Read | List repositories in an organization |
| get_repository_tree | repository | Read | Get the repository file tree | | get_repository_tree | repository | Read | Get the repository file tree |
| get_file_contents | file | Read | Get file content and metadata | | get_file_contents | file | Read | Get file content and metadata |
| get_dir_contents | file | Read | Get the entries in a directory | | get_dir_contents | file | Read | Get the entries in a directory |
| create_or_update_file | file | Write | Create or update a file (provide sha to update an existing file) | | create_or_update_file | file | Write | Create or update a file (provide sha to update an existing file) |
| delete_file | file | Write | Delete a file | | delete_file | file | Write | Delete a file |
| create_branch | branch | Write | Create a new branch | | create_branch | branch | Write | Create a new branch |
| delete_branch | branch | Write | Delete a branch | | delete_branch | branch | Write | Delete a branch |
| list_branches | branch | Read | List repository branches | | list_branches | branch | Read | List repository branches |
| create_tag | tag | Write | Create a tag | | create_tag | tag | Write | Create a tag |
| delete_tag | tag | Write | Delete a tag | | delete_tag | tag | Write | Delete a tag |
| get_tag | tag | Read | Get tag details | | get_tag | tag | Read | Get tag details |
| list_tags | tag | Read | List repository tags | | list_tags | tag | Read | List repository tags |
| list_commits | commit | Read | List repository commits | | list_commits | commit | Read | List repository commits |
| get_commit | commit | Read | Get commit details | | get_commit | commit | Read | Get commit details |
| create_release | release | Write | Create a release | | create_release | release | Write | Create a release |
| delete_release | release | Write | Delete a release | | delete_release | release | Write | Delete a release |
| get_release | release | Read | Get a release by ID | | get_release | release | Read | Get a release by ID |
| get_latest_release | release | Read | Get the latest release | | get_latest_release | release | Read | Get the latest release |
| list_releases | release | Read | List repository releases | | list_releases | release | Read | List repository releases |
> **Note:** Several tools are consolidated, action-based tools, a single tool exposes multiple operations through a `method` parameter. Tools with `Write` access are hidden when the server runs in read-only mode (`-r` / `GITEA_READONLY`), and the exposed tool set can be filtered by scope with `-S` / `--scope` (`GITEA_SCOPES`) and/or by individual tool name with `-O` / `--tools` (`GITEA_TOOLS`). > **Note:** Several tools are consolidated, action-based tools, a single tool exposes multiple operations through a `method` parameter. Tools with `Write` access are hidden when the server runs in read-only mode (`-r` / `GITEA_READONLY`), and the exposed tool set can be filtered by scope with `-S` / `--scope` (`GITEA_SCOPES`) and/or by individual tool name with `-O` / `--tools` (`GITEA_TOOLS`).
+61 -55
View File
@@ -20,6 +20,12 @@ make install
Gitea 主机和访问令牌可通过命令行参数或环境变量提供,命令行参数优先。运行 `gitea-mcp --help` 可查看完整的参数与环境变量列表。日志写入 `$HOME/.gitea-mcp/gitea-mcp.log`,加上 `-d` 可启用调试日志。 Gitea 主机和访问令牌可通过命令行参数或环境变量提供,命令行参数优先。运行 `gitea-mcp --help` 可查看完整的参数与环境变量列表。日志写入 `$HOME/.gitea-mcp/gitea-mcp.log`,加上 `-d` 可启用调试日志。
### MCP 协议与 HTTP 传输
服务器支持最高至 `2026-07-28` 的 MCP 协议,并向下协商到客户端的版本,仅声明 `tools` 能力。工具和 Gitea 执行失败会在 `tools/call` 结果中返回并设置 `result.isError: true`,格式错误的请求和服务器故障仍返回 JSON-RPC 错误。
HTTP 传输固定为无状态:`/mcp` 仅接受 POST,没有 `Mcp-Session-Id`、独立 SSE 和 `Last-Event-ID` 断点续传。服务器会验证来源,反向代理必须原样转发 `Mcp-Protocol-Version``Mcp-Method``Mcp-Name``Authorization: Bearer <令牌>``Authorization: token <令牌>` 会在每个请求中传递 Gitea 凭据,这是凭据透传,而不是 MCP OAuth。
### Claude Code ### Claude Code
通过 `go run` 运行服务器,需要安装 [Go](https://go.dev) 通过 `go run` 运行服务器,需要安装 [Go](https://go.dev)
@@ -129,62 +135,62 @@ Cursor 等客户端可使用 stdio 命令:
## 可用工具 ## 可用工具
| 工具 | 范围 | 访问 | 描述 | | 工具 | 范围 | 访问 | 描述 |
| :--------------------------- | :----------- | :- | :--------------------------------- | | :--------------------------- | :----------- | :--- | :--- |
| get_gitea_mcp_server_version | version | 读取 | 获取 Gitea MCP 服务器版本 | | get_gitea_mcp_server_version | version | 读取 | 获取 Gitea MCP 服务器版本 |
| get_me | user | 读取 | 获取当前已认证用户 | | get_me | user | 读取 | 获取当前已认证用户 |
| get_user_orgs | user | 读取 | 列出当前用户的组织 | | get_user_orgs | user | 读取 | 列出当前用户的组织 |
| search_users | search | 读取 | 搜索用户 | | search_users | search | 读取 | 搜索用户 |
| search_org_teams | search | 读取 | 搜索组织中的团队 | | search_org_teams | search | 读取 | 搜索组织中的团队 |
| search_repos | search | 读取 | 搜索仓库 | | search_repos | search | 读取 | 搜索仓库 |
| search_issues | search | 读取 | 跨仓库搜索问题和拉取请求 | | search_issues | search | 读取 | 跨仓库搜索问题和拉取请求 |
| notification_read | notification | 读取 | 读取通知:列出(可限定仓库)或按 ID 获取会话 | | notification_read | notification | 读取 | 读取通知:列出(可限定仓库)或按 ID 获取会话 |
| notification_write | notification | 写入 | 将某条或全部通知标记为已读 | | notification_write | notification | 写入 | 将某条或全部通知标记为已读 |
| label_read | label | 读取 | 读取仓库或组织标签 | | label_read | label | 读取 | 读取仓库或组织标签 |
| label_write | label | 写入 | 写入标签(仓库或组织):创建、编辑、删除 | | label_write | label | 写入 | 写入标签(仓库或组织):创建、编辑、删除 |
| milestone_read | milestone | 读取 | 读取里程碑:获取单个或列出 | | milestone_read | milestone | 读取 | 读取里程碑:获取单个或列出 |
| milestone_write | milestone | 写入 | 写入里程碑:创建、更新、删除 | | milestone_write | milestone | 写入 | 写入里程碑:创建、更新、删除 |
| wiki_read | wiki | 读取 | 读取 Wiki:列出页面、获取内容、修订历史 | | wiki_read | wiki | 读取 | 读取 Wiki:列出页面、获取内容、修订历史 |
| wiki_write | wiki | 写入 | 写入 Wiki 页面:创建、更新、删除 | | wiki_write | wiki | 写入 | 写入 Wiki 页面:创建、更新、删除 |
| timetracking_read | timetracking | 读取 | 读取时间跟踪:问题/仓库耗时、活动计时器、我的跟踪记录 | | timetracking_read | timetracking | 读取 | 读取时间跟踪:问题/仓库耗时、活动计时器、我的跟踪记录 |
| timetracking_write | timetracking | 写入 | 写入时间跟踪:计时器和记录条目 | | timetracking_write | timetracking | 写入 | 写入时间跟踪:计时器和记录条目 |
| package_read | packages | 读取 | 读取软件包注册表:列出软件包、列出版本或获取某个版本 | | package_read | packages | 读取 | 读取软件包注册表:列出软件包、列出版本或获取某个版本 |
| 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 | 写入 | 写入拉取请求:创建、更新、关闭、重新打开、合并、更新分支、管理审查者 |
| pull_request_review_write | pull_request | 写入 | 写入 PR 审查:创建、提交、删除、驳回 | | pull_request_review_write | pull_request | 写入 | 写入 PR 审查:创建、提交、删除、驳回、回复和解决审查评论 |
| actions_config_read | actions | 读取 | 读取 Actions 密钥和变量 | | actions_config_read | actions | 读取 | 读取 Actions 密钥和变量 |
| actions_config_write | actions | 写入 | 写入 Actions 密钥和变量:更新插入、创建、更新、删除 | | actions_config_write | actions | 写入 | 写入 Actions 密钥和变量:更新插入、创建、更新、删除 |
| actions_run_read | actions | 读取 | 读取 Actions 工作流、运行、作业、日志和构件 | | actions_run_read | actions | 读取 | 读取 Actions 工作流、运行、作业、日志和构件 |
| actions_run_write | actions | 写入 | 写入 Actions 运行:触发、取消、重新运行 | | actions_run_write | actions | 写入 | 写入 Actions 运行:触发、取消、重新运行 |
| create_repo | repository | 写入 | 创建新仓库 | | create_repo | repository | 写入 | 创建新仓库 |
| fork_repo | repository | 写入 | 复刻仓库 | | fork_repo | repository | 写入 | 复刻仓库 |
| list_my_repos | repository | 读取 | 列出当前用户拥有的仓库 | | list_my_repos | repository | 读取 | 列出当前用户拥有的仓库 |
| list_org_repos | repository | 读取 | 列出组织中的仓库 | | list_org_repos | repository | 读取 | 列出组织中的仓库 |
| get_repository_tree | repository | 读取 | 获取仓库文件树 | | get_repository_tree | repository | 读取 | 获取仓库文件树 |
| get_file_contents | file | 读取 | 获取文件内容和元数据 | | get_file_contents | file | 读取 | 获取文件内容和元数据 |
| get_dir_contents | file | 读取 | 获取目录中的条目 | | get_dir_contents | file | 读取 | 获取目录中的条目 |
| create_or_update_file | file | 写入 | 创建或更新文件(提供 sha 以更新现有文件) | | create_or_update_file | file | 写入 | 创建或更新文件(提供 sha 以更新现有文件) |
| delete_file | file | 写入 | 删除文件 | | delete_file | file | 写入 | 删除文件 |
| create_branch | branch | 写入 | 创建新分支 | | create_branch | branch | 写入 | 创建新分支 |
| delete_branch | branch | 写入 | 删除分支 | | delete_branch | branch | 写入 | 删除分支 |
| list_branches | branch | 读取 | 列出仓库分支 | | list_branches | branch | 读取 | 列出仓库分支 |
| create_tag | tag | 写入 | 创建标签 | | create_tag | tag | 写入 | 创建标签 |
| delete_tag | tag | 写入 | 删除标签 | | delete_tag | tag | 写入 | 删除标签 |
| get_tag | tag | 读取 | 获取标签详情 | | get_tag | tag | 读取 | 获取标签详情 |
| list_tags | tag | 读取 | 列出仓库标签 | | list_tags | tag | 读取 | 列出仓库标签 |
| list_commits | commit | 读取 | 列出仓库提交 | | list_commits | commit | 读取 | 列出仓库提交 |
| get_commit | commit | 读取 | 获取提交详情 | | get_commit | commit | 读取 | 获取提交详情 |
| create_release | release | 写入 | 创建版本发布 | | create_release | release | 写入 | 创建版本发布 |
| delete_release | release | 写入 | 删除版本发布 | | delete_release | release | 写入 | 删除版本发布 |
| get_release | release | 读取 | 按 ID 获取版本发布 | | get_release | release | 读取 | 按 ID 获取版本发布 |
| get_latest_release | release | 读取 | 获取最新版本发布 | | get_latest_release | release | 读取 | 获取最新版本发布 |
| list_releases | release | 读取 | 列出仓库版本发布 | | list_releases | release | 读取 | 列出仓库版本发布 |
> **说明:** 部分工具是聚合的、基于操作的工具,单个工具通过 `method` 参数暴露多个操作。当服务器以只读模式运行时(`-r` / `GITEA_READONLY`),访问为「写入」的工具会被隐藏;可通过 `-S` / `--scope``GITEA_SCOPES`)按范围过滤,或通过 `-O` / `--tools``GITEA_TOOLS`)按工具名称过滤对外暴露的工具集合。 > **说明:** 部分工具是聚合的、基于操作的工具,单个工具通过 `method` 参数暴露多个操作。当服务器以只读模式运行时(`-r` / `GITEA_READONLY`),访问为「写入」的工具会被隐藏;可通过 `-S` / `--scope``GITEA_SCOPES`)按范围过滤,或通过 `-O` / `--tools``GITEA_TOOLS`)按工具名称过滤对外暴露的工具集合。
+61 -55
View File
@@ -20,6 +20,12 @@ make install
Gitea 主機與存取令牌可透過命令列參數或環境變數提供,命令列參數優先。執行 `gitea-mcp --help` 可查看完整的參數與環境變數列表。日誌寫入 `$HOME/.gitea-mcp/gitea-mcp.log`,加上 `-d` 可啟用除錯日誌。 Gitea 主機與存取令牌可透過命令列參數或環境變數提供,命令列參數優先。執行 `gitea-mcp --help` 可查看完整的參數與環境變數列表。日誌寫入 `$HOME/.gitea-mcp/gitea-mcp.log`,加上 `-d` 可啟用除錯日誌。
### MCP 協定與 HTTP 傳輸
伺服器支援最高至 `2026-07-28` 的 MCP 協定,並向下協商到客戶端的版本,僅宣告 `tools` 能力。工具與 Gitea 執行失敗會在 `tools/call` 結果中回傳並設定 `result.isError: true`,格式錯誤的請求與伺服器故障仍回傳 JSON-RPC 錯誤。
HTTP 傳輸固定為無狀態:`/mcp` 只接受 POST,沒有 `Mcp-Session-Id`、獨立 SSE 與 `Last-Event-ID` 斷點續傳。伺服器會驗證來源,反向代理必須原樣轉發 `Mcp-Protocol-Version``Mcp-Method``Mcp-Name``Authorization: Bearer <令牌>``Authorization: token <令牌>` 會在每次請求中傳遞 Gitea 憑證,這是憑證透傳,而不是 MCP OAuth。
### Claude Code ### Claude Code
透過 `go run` 執行伺服器,需要安裝 [Go](https://go.dev) 透過 `go run` 執行伺服器,需要安裝 [Go](https://go.dev)
@@ -129,62 +135,62 @@ Cursor 等客戶端可使用 stdio 命令:
## 可用工具 ## 可用工具
| 工具 | 範圍 | 存取 | 描述 | | 工具 | 範圍 | 存取 | 描述 |
| :--------------------------- | :----------- | :- | :--------------------------------- | | :--------------------------- | :----------- | :--- | :--- |
| get_gitea_mcp_server_version | version | 讀取 | 取得 Gitea MCP 伺服器版本 | | get_gitea_mcp_server_version | version | 讀取 | 取得 Gitea MCP 伺服器版本 |
| get_me | user | 讀取 | 取得目前已認證用戶 | | get_me | user | 讀取 | 取得目前已認證用戶 |
| get_user_orgs | user | 讀取 | 列出目前用戶的組織 | | get_user_orgs | user | 讀取 | 列出目前用戶的組織 |
| search_users | search | 讀取 | 搜尋用戶 | | search_users | search | 讀取 | 搜尋用戶 |
| search_org_teams | search | 讀取 | 搜尋組織中的團隊 | | search_org_teams | search | 讀取 | 搜尋組織中的團隊 |
| search_repos | search | 讀取 | 搜尋倉庫 | | search_repos | search | 讀取 | 搜尋倉庫 |
| search_issues | search | 讀取 | 跨倉庫搜尋問題和拉取請求 | | search_issues | search | 讀取 | 跨倉庫搜尋問題和拉取請求 |
| notification_read | notification | 讀取 | 讀取通知:列出(可限定倉庫)或依 ID 取得會話 | | notification_read | notification | 讀取 | 讀取通知:列出(可限定倉庫)或依 ID 取得會話 |
| notification_write | notification | 寫入 | 將某條或全部通知標記為已讀 | | notification_write | notification | 寫入 | 將某條或全部通知標記為已讀 |
| label_read | label | 讀取 | 讀取倉庫或組織標籤 | | label_read | label | 讀取 | 讀取倉庫或組織標籤 |
| label_write | label | 寫入 | 寫入標籤(倉庫或組織):創建、編輯、刪除 | | label_write | label | 寫入 | 寫入標籤(倉庫或組織):創建、編輯、刪除 |
| milestone_read | milestone | 讀取 | 讀取里程碑:取得單個或列出 | | milestone_read | milestone | 讀取 | 讀取里程碑:取得單個或列出 |
| milestone_write | milestone | 寫入 | 寫入里程碑:創建、更新、刪除 | | milestone_write | milestone | 寫入 | 寫入里程碑:創建、更新、刪除 |
| wiki_read | wiki | 讀取 | 讀取 Wiki:列出頁面、取得內容、修訂歷史 | | wiki_read | wiki | 讀取 | 讀取 Wiki:列出頁面、取得內容、修訂歷史 |
| wiki_write | wiki | 寫入 | 寫入 Wiki 頁面:創建、更新、刪除 | | wiki_write | wiki | 寫入 | 寫入 Wiki 頁面:創建、更新、刪除 |
| timetracking_read | timetracking | 讀取 | 讀取時間追蹤:問題/倉庫耗時、活動計時器、我的追蹤記錄 | | timetracking_read | timetracking | 讀取 | 讀取時間追蹤:問題/倉庫耗時、活動計時器、我的追蹤記錄 |
| timetracking_write | timetracking | 寫入 | 寫入時間追蹤:計時器和記錄項目 | | timetracking_write | timetracking | 寫入 | 寫入時間追蹤:計時器和記錄項目 |
| package_read | packages | 讀取 | 讀取軟體套件註冊表:列出套件、列出版本或取得某個版本 | | package_read | packages | 讀取 | 讀取軟體套件註冊表:列出套件、列出版本或取得某個版本 |
| 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 | 寫入 | 寫入拉取請求:創建、更新、關閉、重新開啟、合併、更新分支、管理審查者 |
| pull_request_review_write | pull_request | 寫入 | 寫入 PR 審查:創建、提交、刪除、駁回 | | pull_request_review_write | pull_request | 寫入 | 寫入 PR 審查:創建、提交、刪除、駁回、回覆和解決審查評論 |
| actions_config_read | actions | 讀取 | 讀取 Actions 密鑰和變數 | | actions_config_read | actions | 讀取 | 讀取 Actions 密鑰和變數 |
| actions_config_write | actions | 寫入 | 寫入 Actions 密鑰和變數:更新插入、創建、更新、刪除 | | actions_config_write | actions | 寫入 | 寫入 Actions 密鑰和變數:更新插入、創建、更新、刪除 |
| actions_run_read | actions | 讀取 | 讀取 Actions 工作流程、執行、作業、日誌和產物 | | actions_run_read | actions | 讀取 | 讀取 Actions 工作流程、執行、作業、日誌和產物 |
| actions_run_write | actions | 寫入 | 寫入 Actions 執行:觸發、取消、重新執行 | | actions_run_write | actions | 寫入 | 寫入 Actions 執行:觸發、取消、重新執行 |
| create_repo | repository | 寫入 | 創建新倉庫 | | create_repo | repository | 寫入 | 創建新倉庫 |
| fork_repo | repository | 寫入 | 復刻倉庫 | | fork_repo | repository | 寫入 | 復刻倉庫 |
| list_my_repos | repository | 讀取 | 列出目前用戶擁有的倉庫 | | list_my_repos | repository | 讀取 | 列出目前用戶擁有的倉庫 |
| list_org_repos | repository | 讀取 | 列出組織中的倉庫 | | list_org_repos | repository | 讀取 | 列出組織中的倉庫 |
| get_repository_tree | repository | 讀取 | 取得倉庫檔案樹 | | get_repository_tree | repository | 讀取 | 取得倉庫檔案樹 |
| get_file_contents | file | 讀取 | 取得檔案內容與中繼資料 | | get_file_contents | file | 讀取 | 取得檔案內容與中繼資料 |
| get_dir_contents | file | 讀取 | 取得目錄中的項目 | | get_dir_contents | file | 讀取 | 取得目錄中的項目 |
| create_or_update_file | file | 寫入 | 創建或更新檔案(提供 sha 以更新現有檔案) | | create_or_update_file | file | 寫入 | 創建或更新檔案(提供 sha 以更新現有檔案) |
| delete_file | file | 寫入 | 刪除檔案 | | delete_file | file | 寫入 | 刪除檔案 |
| create_branch | branch | 寫入 | 創建新分支 | | create_branch | branch | 寫入 | 創建新分支 |
| delete_branch | branch | 寫入 | 刪除分支 | | delete_branch | branch | 寫入 | 刪除分支 |
| list_branches | branch | 讀取 | 列出倉庫分支 | | list_branches | branch | 讀取 | 列出倉庫分支 |
| create_tag | tag | 寫入 | 創建標籤 | | create_tag | tag | 寫入 | 創建標籤 |
| delete_tag | tag | 寫入 | 刪除標籤 | | delete_tag | tag | 寫入 | 刪除標籤 |
| get_tag | tag | 讀取 | 取得標籤詳情 | | get_tag | tag | 讀取 | 取得標籤詳情 |
| list_tags | tag | 讀取 | 列出倉庫標籤 | | list_tags | tag | 讀取 | 列出倉庫標籤 |
| list_commits | commit | 讀取 | 列出倉庫提交 | | list_commits | commit | 讀取 | 列出倉庫提交 |
| get_commit | commit | 讀取 | 取得提交詳情 | | get_commit | commit | 讀取 | 取得提交詳情 |
| create_release | release | 寫入 | 創建版本發布 | | create_release | release | 寫入 | 創建版本發布 |
| delete_release | release | 寫入 | 刪除版本發布 | | delete_release | release | 寫入 | 刪除版本發布 |
| get_release | release | 讀取 | 依 ID 取得版本發布 | | get_release | release | 讀取 | 依 ID 取得版本發布 |
| get_latest_release | release | 讀取 | 取得最新版本發布 | | get_latest_release | release | 讀取 | 取得最新版本發布 |
| list_releases | release | 讀取 | 列出倉庫版本發布 | | list_releases | release | 讀取 | 列出倉庫版本發布 |
> **說明:** 部分工具是聚合的、基於操作的工具,單個工具透過 `method` 參數暴露多個操作。當伺服器以唯讀模式執行時(`-r` / `GITEA_READONLY`),存取為「寫入」的工具會被隱藏;可透過 `-S` / `--scope``GITEA_SCOPES`)依範圍過濾,或透過 `-O` / `--tools``GITEA_TOOLS`)依工具名稱過濾對外暴露的工具集合。 > **說明:** 部分工具是聚合的、基於操作的工具,單個工具透過 `method` 參數暴露多個操作。當伺服器以唯讀模式執行時(`-r` / `GITEA_READONLY`),存取為「寫入」的工具會被隱藏;可透過 `-S` / `--scope``GITEA_SCOPES`)依範圍過濾,或透過 `-O` / `--tools``GITEA_TOOLS`)依工具名稱過濾對外暴露的工具集合。
+5
View File
@@ -17,6 +17,7 @@ import (
var ( var (
host string host string
bind string
port int port int
token string token string
tools string tools string
@@ -32,6 +33,8 @@ func initFlagSet(fs *flag.FlagSet, args []string, getenv func(string) string, re
fs.StringVar(&flagPkg.Mode, "transport", "stdio", "") fs.StringVar(&flagPkg.Mode, "transport", "stdio", "")
fs.StringVar(&host, "H", getenv("GITEA_HOST"), "") fs.StringVar(&host, "H", getenv("GITEA_HOST"), "")
fs.StringVar(&host, "host", getenv("GITEA_HOST"), "") fs.StringVar(&host, "host", getenv("GITEA_HOST"), "")
fs.StringVar(&bind, "b", "", "")
fs.StringVar(&bind, "bind", "", "")
fs.IntVar(&port, "p", 8080, "") fs.IntVar(&port, "p", 8080, "")
fs.IntVar(&port, "port", 8080, "") fs.IntVar(&port, "port", 8080, "")
fs.StringVar(&token, "T", "", "") fs.StringVar(&token, "T", "", "")
@@ -68,6 +71,7 @@ func initFlagSet(fs *flag.FlagSet, args []string, getenv func(string) string, re
fmt.Fprintln(stderr, "Options:") fmt.Fprintln(stderr, "Options:")
fmt.Fprintf(w, " -t, -transport <type>\tTransport type: stdio or http (default: stdio)\n") fmt.Fprintf(w, " -t, -transport <type>\tTransport type: stdio or http (default: stdio)\n")
fmt.Fprintf(w, " -H, -host <url>\tGitea host URL (default: https://gitea.com)\n") fmt.Fprintf(w, " -H, -host <url>\tGitea host URL (default: https://gitea.com)\n")
fmt.Fprintf(w, " -b, -bind <address>\tHTTP listen address, e.g. 127.0.0.1 (default: all interfaces)\n")
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")
@@ -99,6 +103,7 @@ func initFlagSet(fs *flag.FlagSet, args []string, getenv func(string) string, re
flagPkg.Host = "https://gitea.com" flagPkg.Host = "https://gitea.com"
} }
flagPkg.Bind = bind
flagPkg.Port = port flagPkg.Port = port
flagPkg.MaxInlineAttachmentBytes = maxInlineAttachmentBytes flagPkg.MaxInlineAttachmentBytes = maxInlineAttachmentBytes
+21
View File
@@ -10,6 +10,27 @@ import (
flagPkg "gitea.com/gitea/gitea-mcp/pkg/flag" flagPkg "gitea.com/gitea/gitea-mcp/pkg/flag"
) )
func TestInitFlagSetBind(t *testing.T) {
for _, test := range []struct {
name string
args []string
want string
}{
{name: "default is empty, meaning all interfaces", args: []string{}},
{name: "-b sets the address", args: []string{"-b", "127.0.0.1"}, want: "127.0.0.1"},
{name: "-bind sets an IPv6 literal", args: []string{"-bind", "::1"}, want: "::1"},
} {
t.Run(test.name, func(t *testing.T) {
t.Cleanup(func() { flagPkg.Bind = "" })
fs := flag.NewFlagSet("test", flag.ContinueOnError)
initFlagSet(fs, test.args, func(string) string { return "" }, func(string) ([]byte, error) { return nil, nil }, &bytes.Buffer{})
if flagPkg.Bind != test.want {
t.Errorf("Bind = %q, want %q", flagPkg.Bind, test.want)
}
})
}
}
func TestInitFlagSetScopes(t *testing.T) { func TestInitFlagSetScopes(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
+9 -5
View File
@@ -2,9 +2,11 @@ module gitea.com/gitea/gitea-mcp
go 1.26.0 go 1.26.0
toolchain go1.26.6
require ( require (
gitea.dev/sdk v1.2.0 gitea.dev/sdk v1.2.0
github.com/mark3labs/mcp-go v0.56.0 github.com/modelcontextprotocol/go-sdk v1.7.0
go.uber.org/zap v1.28.0 go.uber.org/zap v1.28.0
go.uber.org/zap/exp v0.3.0 go.uber.org/zap/exp v0.3.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
@@ -14,13 +16,15 @@ require (
github.com/42wim/httpsig v1.2.4 // indirect github.com/42wim/httpsig v1.2.4 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/jsonschema-go v0.4.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/go-version v1.9.0 // indirect github.com/hashicorp/go-version v1.9.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/segmentio/asm v1.1.3 // indirect
github.com/spf13/cast v1.10.0 // 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.54.0 // indirect golang.org/x/crypto v0.54.0 // indirect
golang.org/x/oauth2 v0.35.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
golang.org/x/text v0.40.0 // indirect golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.47.0 // indirect
) )
+16 -20
View File
@@ -6,32 +6,22 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mark3labs/mcp-go v0.56.0 h1:7aCj2wODCskMi08f923ADG+EfELZBdiKILny415cIS8=
github.com/mark3labs/mcp-go v0.56.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
@@ -50,6 +40,10 @@ golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
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.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
@@ -57,9 +51,11 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+21 -21
View File
@@ -15,7 +15,7 @@ import (
"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"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
// Artifact endpoints require Gitea 1.25+. Older servers answer 404/405, which is // Artifact endpoints require Gitea 1.25+. Older servers answer 404/405, which is
@@ -28,21 +28,21 @@ func artifactNotSupportedErr(err error) error {
return err return err
} }
func listRepoActionArtifactsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listRepoActionArtifactsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, pageSize := params.GetPagination(args, 30)
query := url.Values{} query := url.Values{}
query.Set("page", strconv.Itoa(page)) query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize)) query.Set("limit", strconv.Itoa(pageSize))
if name := params.GetOptionalString(req.GetArguments(), "artifact_name", ""); name != "" { if name := params.GetOptionalString(args, "artifact_name", ""); name != "" {
query.Set("name", name) query.Set("name", name)
} }
@@ -59,25 +59,25 @@ func listRepoActionArtifactsFn(ctx context.Context, req mcp.CallToolRequest) (*m
return to.TextResult(slimActionArtifacts(result)) return to.TextResult(slimActionArtifacts(result))
} }
func listRepoActionRunArtifactsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listRepoActionRunArtifactsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
runID, err := params.GetIndex(req.GetArguments(), "run_id") runID, err := params.GetIndex(args, "run_id")
if err != nil || runID <= 0 { if err != nil || runID <= 0 {
return to.ErrorResult(errors.New("run_id is required")) return to.ErrorResult(errors.New("run_id is required"))
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, pageSize := params.GetPagination(args, 30)
query := url.Values{} query := url.Values{}
query.Set("page", strconv.Itoa(page)) query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize)) query.Set("limit", strconv.Itoa(pageSize))
if name := params.GetOptionalString(req.GetArguments(), "artifact_name", ""); name != "" { if name := params.GetOptionalString(args, "artifact_name", ""); name != "" {
query.Set("name", name) query.Set("name", name)
} }
@@ -94,16 +94,16 @@ func listRepoActionRunArtifactsFn(ctx context.Context, req mcp.CallToolRequest)
return to.TextResult(slimActionArtifacts(result)) return to.TextResult(slimActionArtifacts(result))
} }
func getRepoActionArtifactFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getRepoActionArtifactFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
artifactID, err := params.GetIndex(req.GetArguments(), "artifact_id") artifactID, err := params.GetIndex(args, "artifact_id")
if err != nil || artifactID <= 0 { if err != nil || artifactID <= 0 {
return to.ErrorResult(errors.New("artifact_id is required")) return to.ErrorResult(errors.New("artifact_id is required"))
} }
@@ -121,20 +121,20 @@ func getRepoActionArtifactFn(ctx context.Context, req mcp.CallToolRequest) (*mcp
return to.TextResult(slimActionArtifact(result)) return to.TextResult(slimActionArtifact(result))
} }
func downloadRepoActionArtifactFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func downloadRepoActionArtifactFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
artifactID, err := params.GetIndex(req.GetArguments(), "artifact_id") artifactID, err := params.GetIndex(args, "artifact_id")
if err != nil || artifactID <= 0 { if err != nil || artifactID <= 0 {
return to.ErrorResult(errors.New("artifact_id is required")) return to.ErrorResult(errors.New("artifact_id is required"))
} }
outputPath, _ := req.GetArguments()["output_path"].(string) outputPath, _ := args["output_path"].(string)
// Best-effort metadata lookup: gives a friendly filename and lets us fail // Best-effort metadata lookup: gives a friendly filename and lets us fail
// early with a clear message when the artifact has expired. // early with a clear message when the artifact has expired.
+111 -111
View File
@@ -11,10 +11,10 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"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_sdk "gitea.dev/sdk" gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mark3labs/mcp-go/server"
) )
const ( const (
@@ -44,103 +44,103 @@ func toSecretMetas(secrets []*gitea_sdk.Secret) []secretMeta {
} }
var ( var (
ActionsConfigReadTool = mcp.NewTool( ActionsConfigReadTool = tool.NewDefinition(
ActionsConfigReadToolName, ActionsConfigReadToolName,
mcp.WithDescription("Read Actions secrets and variables."), "Read Actions secrets and variables.",
mcp.WithToolAnnotation(annotation.ReadOnly("Read Actions secrets and variables")), annotation.ReadOnly("Read Actions secrets and variables"),
mcp.WithString("method", mcp.Required(), mcp.Enum("list_repo_secrets", "list_org_secrets", "list_repo_variables", "get_repo_variable", "list_org_variables", "get_org_variable")), tool.String("method", tool.Required(), tool.Enum("list_repo_secrets", "list_org_secrets", "list_repo_variables", "get_repo_variable", "list_org_variables", "get_org_variable")),
mcp.WithString("owner", mcp.Description("for repo methods")), tool.String("owner", tool.Description("for repo methods")),
mcp.WithString("repo", mcp.Description("for repo methods")), tool.String("repo", tool.Description("for repo methods")),
mcp.WithString("org", mcp.Description("for org methods")), tool.String("org", tool.Description("for org methods")),
mcp.WithString("name", mcp.Description("for get methods")), tool.String("name", tool.Description("for get methods")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)), tool.Number("page", tool.Description(params.PageDesc), tool.Default(1), tool.Minimum(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30), mcp.Min(1)), tool.Number("per_page", tool.Description(params.PaginationDesc), tool.Default(30), tool.Minimum(1)),
) )
ActionsConfigWriteTool = mcp.NewTool( ActionsConfigWriteTool = tool.NewDefinition(
ActionsConfigWriteToolName, ActionsConfigWriteToolName,
mcp.WithDescription("Write Actions secrets and variables: upsert, create, update, delete."), "Write Actions secrets and variables: upsert, create, update, delete.",
mcp.WithToolAnnotation(annotation.Destructive("Manage Actions secrets and variables")), annotation.Destructive("Manage Actions secrets and variables"),
mcp.WithString("method", mcp.Required(), mcp.Enum("upsert_repo_secret", "delete_repo_secret", "upsert_org_secret", "delete_org_secret", "create_repo_variable", "update_repo_variable", "delete_repo_variable", "create_org_variable", "update_org_variable", "delete_org_variable")), tool.String("method", tool.Required(), tool.Enum("upsert_repo_secret", "delete_repo_secret", "upsert_org_secret", "delete_org_secret", "create_repo_variable", "update_repo_variable", "delete_repo_variable", "create_org_variable", "update_org_variable", "delete_org_variable")),
mcp.WithString("owner", mcp.Description("for repo methods")), tool.String("owner", tool.Description("for repo methods")),
mcp.WithString("repo", mcp.Description("for repo methods")), tool.String("repo", tool.Description("for repo methods")),
mcp.WithString("org", mcp.Description("for org methods")), tool.String("org", tool.Description("for org methods")),
mcp.WithString("name", mcp.Description("secret or variable name")), tool.String("name", tool.Description("secret or variable name")),
mcp.WithString("data", mcp.Description("secret value (upsert)")), tool.String("data", tool.Description("secret value (upsert)")),
mcp.WithString("value", mcp.Description("variable value")), tool.String("value", tool.Description("variable value")),
mcp.WithString("description"), tool.String("description"),
) )
) )
func init() { func init() {
Tool.RegisterRead(server.ServerTool{Tool: ActionsConfigReadTool, Handler: configReadFn}) Tool.RegisterRead(tool.ServerTool{Tool: ActionsConfigReadTool, Handler: configReadFn})
Tool.RegisterWrite(server.ServerTool{Tool: ActionsConfigWriteTool, Handler: configWriteFn}) Tool.RegisterWrite(tool.ServerTool{Tool: ActionsConfigWriteTool, Handler: configWriteFn})
} }
func configReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func configReadFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method") method, err := params.GetString(args, "method")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
switch method { switch method {
case "list_repo_secrets": case "list_repo_secrets":
return listRepoActionSecretsFn(ctx, req) return listRepoActionSecretsFn(ctx, args)
case "list_org_secrets": case "list_org_secrets":
return listOrgActionSecretsFn(ctx, req) return listOrgActionSecretsFn(ctx, args)
case "list_repo_variables": case "list_repo_variables":
return listRepoActionVariablesFn(ctx, req) return listRepoActionVariablesFn(ctx, args)
case "get_repo_variable": case "get_repo_variable":
return getRepoActionVariableFn(ctx, req) return getRepoActionVariableFn(ctx, args)
case "list_org_variables": case "list_org_variables":
return listOrgActionVariablesFn(ctx, req) return listOrgActionVariablesFn(ctx, args)
case "get_org_variable": case "get_org_variable":
return getOrgActionVariableFn(ctx, req) return getOrgActionVariableFn(ctx, args)
default: default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
} }
func configWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func configWriteFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method") method, err := params.GetString(args, "method")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
switch method { switch method {
case "upsert_repo_secret": case "upsert_repo_secret":
return upsertRepoActionSecretFn(ctx, req) return upsertRepoActionSecretFn(ctx, args)
case "delete_repo_secret": case "delete_repo_secret":
return deleteRepoActionSecretFn(ctx, req) return deleteRepoActionSecretFn(ctx, args)
case "upsert_org_secret": case "upsert_org_secret":
return upsertOrgActionSecretFn(ctx, req) return upsertOrgActionSecretFn(ctx, args)
case "delete_org_secret": case "delete_org_secret":
return deleteOrgActionSecretFn(ctx, req) return deleteOrgActionSecretFn(ctx, args)
case "create_repo_variable": case "create_repo_variable":
return createRepoActionVariableFn(ctx, req) return createRepoActionVariableFn(ctx, args)
case "update_repo_variable": case "update_repo_variable":
return updateRepoActionVariableFn(ctx, req) return updateRepoActionVariableFn(ctx, args)
case "delete_repo_variable": case "delete_repo_variable":
return deleteRepoActionVariableFn(ctx, req) return deleteRepoActionVariableFn(ctx, args)
case "create_org_variable": case "create_org_variable":
return createOrgActionVariableFn(ctx, req) return createOrgActionVariableFn(ctx, args)
case "update_org_variable": case "update_org_variable":
return updateOrgActionVariableFn(ctx, req) return updateOrgActionVariableFn(ctx, args)
case "delete_org_variable": case "delete_org_variable":
return deleteOrgActionVariableFn(ctx, req) return deleteOrgActionVariableFn(ctx, args)
default: default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
} }
func listRepoActionSecretsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listRepoActionSecretsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, pageSize := params.GetPagination(args, 30)
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
@@ -157,24 +157,24 @@ func listRepoActionSecretsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp
return to.TextResult(toSecretMetas(secrets)) return to.TextResult(toSecretMetas(secrets))
} }
func upsertRepoActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func upsertRepoActionSecretFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
name, err := params.GetString(req.GetArguments(), "name") name, err := params.GetString(args, "name")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
data, err := params.GetString(req.GetArguments(), "data") data, err := params.GetString(args, "data")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
description, _ := req.GetArguments()["description"].(string) description, _ := args["description"].(string)
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
@@ -190,16 +190,16 @@ func upsertRepoActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mc
return to.TextResult(map[string]any{"message": "secret upserted", "status": resp.StatusCode}) return to.TextResult(map[string]any{"message": "secret upserted", "status": resp.StatusCode})
} }
func deleteRepoActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func deleteRepoActionSecretFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
name, err := params.GetString(req.GetArguments(), "name") name, err := params.GetString(args, "name")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -215,12 +215,12 @@ func deleteRepoActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mc
return to.TextResult(map[string]any{"message": "secret deleted", "status": resp.StatusCode}) return to.TextResult(map[string]any{"message": "secret deleted", "status": resp.StatusCode})
} }
func listOrgActionSecretsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listOrgActionSecretsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
org, err := params.GetString(req.GetArguments(), "org") org, err := params.GetString(args, "org")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, pageSize := params.GetPagination(args, 30)
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
@@ -237,20 +237,20 @@ func listOrgActionSecretsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.
return to.TextResult(toSecretMetas(secrets)) return to.TextResult(toSecretMetas(secrets))
} }
func upsertOrgActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func upsertOrgActionSecretFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
org, err := params.GetString(req.GetArguments(), "org") org, err := params.GetString(args, "org")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
name, err := params.GetString(req.GetArguments(), "name") name, err := params.GetString(args, "name")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
data, err := params.GetString(req.GetArguments(), "data") data, err := params.GetString(args, "data")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
description, _ := req.GetArguments()["description"].(string) description, _ := args["description"].(string)
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
@@ -266,12 +266,12 @@ func upsertOrgActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp
return to.TextResult(map[string]any{"message": "secret upserted", "status": resp.StatusCode}) return to.TextResult(map[string]any{"message": "secret upserted", "status": resp.StatusCode})
} }
func deleteOrgActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func deleteOrgActionSecretFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
org, err := params.GetString(req.GetArguments(), "org") org, err := params.GetString(args, "org")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
name, err := params.GetString(req.GetArguments(), "name") name, err := params.GetString(args, "name")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -285,16 +285,16 @@ 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"})
} }
func listRepoActionVariablesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listRepoActionVariablesFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, pageSize := params.GetPagination(args, 30)
query := url.Values{} query := url.Values{}
query.Set("page", strconv.Itoa(page)) query.Set("page", strconv.Itoa(page))
@@ -308,16 +308,16 @@ func listRepoActionVariablesFn(ctx context.Context, req mcp.CallToolRequest) (*m
return to.TextResult(result) return to.TextResult(result)
} }
func getRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getRepoActionVariableFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
name, err := params.GetString(req.GetArguments(), "name") name, err := params.GetString(args, "name")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -333,20 +333,20 @@ func getRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp
return to.TextResult(variable) return to.TextResult(variable)
} }
func createRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func createRepoActionVariableFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
name, err := params.GetString(req.GetArguments(), "name") name, err := params.GetString(args, "name")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
value, err := params.GetString(req.GetArguments(), "value") value, err := params.GetString(args, "value")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -362,20 +362,20 @@ func createRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*
return to.TextResult(map[string]any{"message": "variable created", "status": resp.StatusCode}) return to.TextResult(map[string]any{"message": "variable created", "status": resp.StatusCode})
} }
func updateRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func updateRepoActionVariableFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
name, err := params.GetString(req.GetArguments(), "name") name, err := params.GetString(args, "name")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
value, err := params.GetString(req.GetArguments(), "value") value, err := params.GetString(args, "value")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -391,16 +391,16 @@ func updateRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*
return to.TextResult(map[string]any{"message": "variable updated", "status": resp.StatusCode}) return to.TextResult(map[string]any{"message": "variable updated", "status": resp.StatusCode})
} }
func deleteRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func deleteRepoActionVariableFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
name, err := params.GetString(req.GetArguments(), "name") name, err := params.GetString(args, "name")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -416,12 +416,12 @@ func deleteRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*
return to.TextResult(map[string]any{"message": "variable deleted", "status": resp.StatusCode}) return to.TextResult(map[string]any{"message": "variable deleted", "status": resp.StatusCode})
} }
func listOrgActionVariablesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listOrgActionVariablesFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
org, err := params.GetString(req.GetArguments(), "org") org, err := params.GetString(args, "org")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, pageSize := params.GetPagination(args, 30)
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
@@ -436,12 +436,12 @@ func listOrgActionVariablesFn(ctx context.Context, req mcp.CallToolRequest) (*mc
return to.TextResult(variables) return to.TextResult(variables)
} }
func getOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getOrgActionVariableFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
org, err := params.GetString(req.GetArguments(), "org") org, err := params.GetString(args, "org")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
name, err := params.GetString(req.GetArguments(), "name") name, err := params.GetString(args, "name")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -457,20 +457,20 @@ func getOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.
return to.TextResult(variable) return to.TextResult(variable)
} }
func createOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func createOrgActionVariableFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
org, err := params.GetString(req.GetArguments(), "org") org, err := params.GetString(args, "org")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
name, err := params.GetString(req.GetArguments(), "name") name, err := params.GetString(args, "name")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
value, err := params.GetString(req.GetArguments(), "value") value, err := params.GetString(args, "value")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
description, _ := req.GetArguments()["description"].(string) description, _ := args["description"].(string)
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
@@ -486,20 +486,20 @@ func createOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*m
return to.TextResult(map[string]any{"message": "variable created", "status": resp.StatusCode}) return to.TextResult(map[string]any{"message": "variable created", "status": resp.StatusCode})
} }
func updateOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func updateOrgActionVariableFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
org, err := params.GetString(req.GetArguments(), "org") org, err := params.GetString(args, "org")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
name, err := params.GetString(req.GetArguments(), "name") name, err := params.GetString(args, "name")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
value, err := params.GetString(req.GetArguments(), "value") value, err := params.GetString(args, "value")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
description, _ := req.GetArguments()["description"].(string) description, _ := args["description"].(string)
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
@@ -516,12 +516,12 @@ func updateOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*m
return to.TextResult(map[string]any{"message": "variable updated", "status": resp.StatusCode}) return to.TextResult(map[string]any{"message": "variable updated", "status": resp.StatusCode})
} }
func deleteOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func deleteOrgActionVariableFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
org, err := params.GetString(req.GetArguments(), "org") org, err := params.GetString(args, "org")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
name, err := params.GetString(req.GetArguments(), "name") name, err := params.GetString(args, "name")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
+107 -107
View File
@@ -14,9 +14,9 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"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"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mark3labs/mcp-go/server"
) )
const ( const (
@@ -25,94 +25,94 @@ const (
) )
var ( var (
ActionsRunReadTool = mcp.NewTool( ActionsRunReadTool = tool.NewDefinition(
ActionsRunReadToolName, ActionsRunReadToolName,
mcp.WithDescription("Read Actions workflows, runs, jobs, logs, and artifacts."), "Read Actions workflows, runs, jobs, logs, and artifacts.",
mcp.WithToolAnnotation(annotation.ReadOnly("Read Actions workflow, run, job, and artifact data")), annotation.ReadOnly("Read Actions workflow, run, job, and artifact data"),
mcp.WithString("method", mcp.Required(), mcp.Enum("list_workflows", "get_workflow", "list_runs", "get_run", "list_jobs", "list_run_jobs", "get_job", "get_job_log_preview", "download_job_log", "list_artifacts", "list_run_artifacts", "get_artifact", "download_artifact")), tool.String("method", tool.Required(), tool.Enum("list_workflows", "get_workflow", "list_runs", "get_run", "list_jobs", "list_run_jobs", "get_job", "get_job_log_preview", "download_job_log", "list_artifacts", "list_run_artifacts", "get_artifact", "download_artifact")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithString("workflow_id", mcp.Description("ID or filename (for 'get_workflow')")), tool.String("workflow_id", tool.Description("ID or filename (for 'get_workflow')")),
mcp.WithNumber("run_id", mcp.Description("for 'get_run'/'list_run_jobs'/'list_run_artifacts'")), tool.Number("run_id", tool.Description("for 'get_run'/'list_run_jobs'/'list_run_artifacts'")),
mcp.WithNumber("job_id", mcp.Description("for 'get_job'/log methods")), tool.Number("job_id", tool.Description("for 'get_job'/log methods")),
mcp.WithNumber("artifact_id", mcp.Description("for 'get_artifact'/'download_artifact'")), tool.Number("artifact_id", tool.Description("for 'get_artifact'/'download_artifact'")),
mcp.WithString("artifact_name", mcp.Description("name filter for 'list_artifacts'/'list_run_artifacts'")), tool.String("artifact_name", tool.Description("name filter for 'list_artifacts'/'list_run_artifacts'")),
mcp.WithString("status", mcp.Description("filter for 'list_runs'/'list_jobs'")), tool.String("status", tool.Description("filter for 'list_runs'/'list_jobs'")),
mcp.WithNumber("tail_lines", mcp.Description("log tail lines"), mcp.DefaultNumber(200), mcp.Min(1)), tool.Number("tail_lines", tool.Description("log tail lines"), tool.Default(200), tool.Minimum(1)),
mcp.WithNumber("max_bytes", mcp.Description("max log bytes"), mcp.DefaultNumber(65536), mcp.Min(1024)), tool.Number("max_bytes", tool.Description("max log bytes"), tool.Default(65536), tool.Minimum(1024)),
mcp.WithString("output_path", mcp.Description("for 'download_job_log'/'download_artifact'")), tool.String("output_path", tool.Description("for 'download_job_log'/'download_artifact'")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)), tool.Number("page", tool.Description(params.PageDesc), tool.Default(1), tool.Minimum(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30), mcp.Min(1)), tool.Number("per_page", tool.Description(params.PaginationDesc), tool.Default(30), tool.Minimum(1)),
) )
ActionsRunWriteTool = mcp.NewTool( ActionsRunWriteTool = tool.NewDefinition(
ActionsRunWriteToolName, ActionsRunWriteToolName,
mcp.WithDescription("Write Actions runs: dispatch, cancel, rerun."), "Write Actions runs: dispatch, cancel, rerun.",
mcp.WithToolAnnotation(annotation.Write("Trigger, cancel, or rerun Actions workflows")), annotation.Write("Trigger, cancel, or rerun Actions workflows"),
mcp.WithString("method", mcp.Required(), mcp.Enum("dispatch_workflow", "cancel_run", "rerun_run")), tool.String("method", tool.Required(), tool.Enum("dispatch_workflow", "cancel_run", "rerun_run")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithString("workflow_id", mcp.Description("ID or filename (for 'dispatch_workflow')")), tool.String("workflow_id", tool.Description("ID or filename (for 'dispatch_workflow')")),
mcp.WithString("ref", mcp.Description("branch or tag (for 'dispatch_workflow')")), tool.String("ref", tool.Description("branch or tag (for 'dispatch_workflow')")),
mcp.WithObject("inputs", mcp.Description("for 'dispatch_workflow'")), tool.Object("inputs", tool.Description("for 'dispatch_workflow'")),
mcp.WithNumber("run_id", mcp.Description("for 'cancel_run'/'rerun_run'")), tool.Number("run_id", tool.Description("for 'cancel_run'/'rerun_run'")),
) )
) )
func init() { func init() {
Tool.RegisterRead(server.ServerTool{Tool: ActionsRunReadTool, Handler: runReadFn}) Tool.RegisterRead(tool.ServerTool{Tool: ActionsRunReadTool, Handler: runReadFn})
Tool.RegisterWrite(server.ServerTool{Tool: ActionsRunWriteTool, Handler: runWriteFn}) Tool.RegisterWrite(tool.ServerTool{Tool: ActionsRunWriteTool, Handler: runWriteFn})
} }
func runReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func runReadFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method") method, err := params.GetString(args, "method")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
switch method { switch method {
case "list_workflows": case "list_workflows":
return listRepoActionWorkflowsFn(ctx, req) return listRepoActionWorkflowsFn(ctx, args)
case "get_workflow": case "get_workflow":
return getRepoActionWorkflowFn(ctx, req) return getRepoActionWorkflowFn(ctx, args)
case "list_runs": case "list_runs":
return listRepoActionRunsFn(ctx, req) return listRepoActionRunsFn(ctx, args)
case "get_run": case "get_run":
return getRepoActionRunFn(ctx, req) return getRepoActionRunFn(ctx, args)
case "list_jobs": case "list_jobs":
return listRepoActionJobsFn(ctx, req) return listRepoActionJobsFn(ctx, args)
case "list_run_jobs": case "list_run_jobs":
return listRepoActionRunJobsFn(ctx, req) return listRepoActionRunJobsFn(ctx, args)
case "get_job": case "get_job":
return getRepoActionJobFn(ctx, req) return getRepoActionJobFn(ctx, args)
case "get_job_log_preview": case "get_job_log_preview":
return getRepoActionJobLogPreviewFn(ctx, req) return getRepoActionJobLogPreviewFn(ctx, args)
case "download_job_log": case "download_job_log":
return downloadRepoActionJobLogFn(ctx, req) return downloadRepoActionJobLogFn(ctx, args)
case "list_artifacts": case "list_artifacts":
return listRepoActionArtifactsFn(ctx, req) return listRepoActionArtifactsFn(ctx, args)
case "list_run_artifacts": case "list_run_artifacts":
return listRepoActionRunArtifactsFn(ctx, req) return listRepoActionRunArtifactsFn(ctx, args)
case "get_artifact": case "get_artifact":
return getRepoActionArtifactFn(ctx, req) return getRepoActionArtifactFn(ctx, args)
case "download_artifact": case "download_artifact":
return downloadRepoActionArtifactFn(ctx, req) return downloadRepoActionArtifactFn(ctx, args)
default: default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
} }
func runWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func runWriteFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method") method, err := params.GetString(args, "method")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
switch method { switch method {
case "dispatch_workflow": case "dispatch_workflow":
return dispatchRepoActionWorkflowFn(ctx, req) return dispatchRepoActionWorkflowFn(ctx, args)
case "cancel_run": case "cancel_run":
return cancelRepoActionRunFn(ctx, req) return cancelRepoActionRunFn(ctx, args)
case "rerun_run": case "rerun_run":
return rerunRepoActionRunFn(ctx, req) return rerunRepoActionRunFn(ctx, args)
default: default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
@@ -135,16 +135,16 @@ func doJSONWithFallback(ctx context.Context, method string, paths []string, quer
return lastErr return lastErr
} }
func listRepoActionWorkflowsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listRepoActionWorkflowsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, pageSize := params.GetPagination(args, 30)
query := url.Values{} query := url.Values{}
query.Set("page", strconv.Itoa(page)) query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize)) query.Set("limit", strconv.Itoa(pageSize))
@@ -162,16 +162,16 @@ func listRepoActionWorkflowsFn(ctx context.Context, req mcp.CallToolRequest) (*m
return to.TextResult(slimActionWorkflows(result)) return to.TextResult(slimActionWorkflows(result))
} }
func getRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getRepoActionWorkflowFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
workflowID, err := params.GetString(req.GetArguments(), "workflow_id") workflowID, err := params.GetString(args, "workflow_id")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -189,26 +189,26 @@ func getRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest) (*mcp
return to.TextResult(slimActionWorkflow(result)) return to.TextResult(slimActionWorkflow(result))
} }
func dispatchRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func dispatchRepoActionWorkflowFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
workflowID, err := params.GetString(req.GetArguments(), "workflow_id") workflowID, err := params.GetString(args, "workflow_id")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
ref, err := params.GetString(req.GetArguments(), "ref") ref, err := params.GetString(args, "ref")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
var inputs map[string]any var inputs map[string]any
if raw, exists := req.GetArguments()["inputs"]; exists { if raw, exists := args["inputs"]; exists {
if m, ok := raw.(map[string]any); ok { if m, ok := raw.(map[string]any); ok {
inputs = m inputs = m
} }
@@ -238,17 +238,17 @@ func dispatchRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest)
return to.TextResult(map[string]any{"message": "workflow dispatched"}) return to.TextResult(map[string]any{"message": "workflow dispatched"})
} }
func listRepoActionRunsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listRepoActionRunsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, pageSize := params.GetPagination(args, 30)
statusFilter, _ := req.GetArguments()["status"].(string) statusFilter, _ := args["status"].(string)
query := url.Values{} query := url.Values{}
query.Set("page", strconv.Itoa(page)) query.Set("page", strconv.Itoa(page))
@@ -270,16 +270,16 @@ func listRepoActionRunsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
return to.TextResult(slimActionRuns(result)) return to.TextResult(slimActionRuns(result))
} }
func getRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getRepoActionRunFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
runID, err := params.GetIndex(req.GetArguments(), "run_id") runID, err := params.GetIndex(args, "run_id")
if err != nil || runID <= 0 { if err != nil || runID <= 0 {
return to.ErrorResult(errors.New("run_id is required")) return to.ErrorResult(errors.New("run_id is required"))
} }
@@ -297,16 +297,16 @@ func getRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
return to.TextResult(slimActionRun(result)) return to.TextResult(slimActionRun(result))
} }
func cancelRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func cancelRepoActionRunFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
runID, err := params.GetIndex(req.GetArguments(), "run_id") runID, err := params.GetIndex(args, "run_id")
if err != nil || runID <= 0 { if err != nil || runID <= 0 {
return to.ErrorResult(errors.New("run_id is required")) return to.ErrorResult(errors.New("run_id is required"))
} }
@@ -323,16 +323,16 @@ func cancelRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.C
return to.TextResult(map[string]any{"message": "run cancellation requested"}) return to.TextResult(map[string]any{"message": "run cancellation requested"})
} }
func rerunRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func rerunRepoActionRunFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
runID, err := params.GetIndex(req.GetArguments(), "run_id") runID, err := params.GetIndex(args, "run_id")
if err != nil || runID <= 0 { if err != nil || runID <= 0 {
return to.ErrorResult(errors.New("run_id is required")) return to.ErrorResult(errors.New("run_id is required"))
} }
@@ -354,17 +354,17 @@ func rerunRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
return to.TextResult(map[string]any{"message": "run rerun requested"}) return to.TextResult(map[string]any{"message": "run rerun requested"})
} }
func listRepoActionJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listRepoActionJobsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, pageSize := params.GetPagination(args, 30)
statusFilter, _ := req.GetArguments()["status"].(string) statusFilter, _ := args["status"].(string)
query := url.Values{} query := url.Values{}
query.Set("page", strconv.Itoa(page)) query.Set("page", strconv.Itoa(page))
@@ -386,20 +386,20 @@ func listRepoActionJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
return to.TextResult(slimActionJobs(result)) return to.TextResult(slimActionJobs(result))
} }
func listRepoActionRunJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listRepoActionRunJobsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
runID, err := params.GetIndex(req.GetArguments(), "run_id") runID, err := params.GetIndex(args, "run_id")
if err != nil || runID <= 0 { if err != nil || runID <= 0 {
return to.ErrorResult(errors.New("run_id is required")) return to.ErrorResult(errors.New("run_id is required"))
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, pageSize := params.GetPagination(args, 30)
query := url.Values{} query := url.Values{}
query.Set("page", strconv.Itoa(page)) query.Set("page", strconv.Itoa(page))
@@ -418,16 +418,16 @@ func listRepoActionRunJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp
return to.TextResult(slimActionJobs(result)) return to.TextResult(slimActionJobs(result))
} }
func getRepoActionJobFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getRepoActionJobFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
jobID, err := params.GetIndex(req.GetArguments(), "job_id") jobID, err := params.GetIndex(args, "job_id")
if err != nil || jobID <= 0 { if err != nil || jobID <= 0 {
return to.ErrorResult(errors.New("job_id is required")) return to.ErrorResult(errors.New("job_id is required"))
} }
@@ -503,21 +503,21 @@ func limitBytes(data []byte, maxBytes int) ([]byte, bool) {
return data[len(data)-maxBytes:], true return data[len(data)-maxBytes:], true
} }
func getRepoActionJobLogPreviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getRepoActionJobLogPreviewFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
jobID, err := params.GetIndex(req.GetArguments(), "job_id") jobID, err := params.GetIndex(args, "job_id")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
tailLines := int(params.GetOptionalInt(req.GetArguments(), "tail_lines", 200)) tailLines := int(params.GetOptionalInt(args, "tail_lines", 200))
maxBytes := int(params.GetOptionalInt(req.GetArguments(), "max_bytes", 65536)) maxBytes := int(params.GetOptionalInt(args, "max_bytes", 65536))
raw, usedPath, err := fetchJobLogBytes(ctx, owner, repo, jobID) raw, usedPath, err := fetchJobLogBytes(ctx, owner, repo, jobID)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get job log err: %v", err)) return to.ErrorResult(fmt.Errorf("get job log err: %v", err))
@@ -537,20 +537,20 @@ func getRepoActionJobLogPreviewFn(ctx context.Context, req mcp.CallToolRequest)
}) })
} }
func downloadRepoActionJobLogFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func downloadRepoActionJobLogFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
jobID, err := params.GetIndex(req.GetArguments(), "job_id") jobID, err := params.GetIndex(args, "job_id")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
outputPath, _ := req.GetArguments()["output_path"].(string) outputPath, _ := args["output_path"].(string)
raw, usedPath, err := fetchJobLogBytes(ctx, owner, repo, jobID) raw, usedPath, err := fetchJobLogBytes(ctx, owner, repo, jobID)
if err != nil { if err != nil {
+41 -39
View File
@@ -3,7 +3,6 @@ package issue
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -17,51 +16,51 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"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_sdk "gitea.dev/sdk" gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mark3labs/mcp-go/server"
) )
const AttachmentReadToolName = "attachment_read" const AttachmentReadToolName = "attachment_read"
var AttachmentReadTool = mcp.NewTool( var AttachmentReadTool = tool.NewDefinition(
AttachmentReadToolName, AttachmentReadToolName,
mcp.WithDescription("Read issue/comment attachments: list metadata, get metadata, or download content."), "Read issue/comment attachments: list metadata, get metadata, or download content.",
mcp.WithToolAnnotation(annotation.ReadOnly("Read issue or comment attachments")), annotation.ReadOnly("Read issue or comment attachments"),
mcp.WithString("method", mcp.Required(), mcp.Enum("list", "get", "download")), tool.String("method", tool.Required(), tool.Enum("list", "get", "download")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithNumber("issue_number", mcp.Description("required for issue attachment list/get or issue-scoped metadata lookup")), tool.Number("issue_number", tool.Description("required for issue attachment list/get or issue-scoped metadata lookup")),
mcp.WithNumber("comment_id", mcp.Description("required for comment attachment list/get or comment-scoped metadata lookup")), tool.Number("comment_id", tool.Description("required for comment attachment list/get or comment-scoped metadata lookup")),
mcp.WithNumber("attachment_id", mcp.Description("required for get and for download when attachment_uuid is not provided")), tool.Number("attachment_id", tool.Description("required for get and for download when attachment_uuid is not provided")),
mcp.WithString("attachment_uuid", mcp.Description("attachment UUID for direct download path lookup")), tool.String("attachment_uuid", tool.Description("attachment UUID for direct download path lookup")),
mcp.WithString("output_path", mcp.Description("write the attachment to this exact path")), tool.String("output_path", tool.Description("write the attachment to this exact path")),
) )
func init() { func init() {
Tool.RegisterRead(server.ServerTool{Tool: AttachmentReadTool, Handler: attachmentReadFn}) Tool.RegisterRead(tool.ServerTool{Tool: AttachmentReadTool, Handler: attachmentReadFn})
} }
func attachmentReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func attachmentReadFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method") method, err := params.GetString(args, "method")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
switch method { switch method {
case "list": case "list":
return listAttachmentsFn(ctx, req) return listAttachmentsFn(ctx, args)
case "get": case "get":
return getAttachmentFn(ctx, req) return getAttachmentFn(ctx, args)
case "download": case "download":
return downloadAttachmentFn(ctx, req) return downloadAttachmentFn(ctx, args)
default: default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
} }
func listAttachmentsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listAttachmentsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, repo, issueNumber, commentID, err := attachmentScopeArgs(req) owner, repo, issueNumber, commentID, err := attachmentScopeArgs(args)
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -81,29 +80,29 @@ func listAttachmentsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
return to.TextResult(slimAttachments(attachments)) return to.TextResult(slimAttachments(attachments))
} }
func getAttachmentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getAttachmentFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
att, err := lookupAttachment(ctx, req) att, err := lookupAttachment(ctx, args)
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
return to.TextResult(slimAttachment(att)) return to.TextResult(slimAttachment(att))
} }
func downloadAttachmentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func downloadAttachmentFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
explicitOutputPath := params.GetOptionalString(req.GetArguments(), "output_path", "") explicitOutputPath := params.GetOptionalString(args, "output_path", "")
attachmentUUID := strings.TrimSpace(params.GetOptionalString(req.GetArguments(), "attachment_uuid", "")) attachmentUUID := strings.TrimSpace(params.GetOptionalString(args, "attachment_uuid", ""))
var att *gitea_sdk.Attachment var att *gitea_sdk.Attachment
if attachmentUUID == "" { if attachmentUUID == "" {
att, err = lookupAttachment(ctx, req) att, err = lookupAttachment(ctx, args)
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -132,7 +131,10 @@ func downloadAttachmentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
} }
if len(limited) <= flag.MaxInlineAttachmentBytes { if len(limited) <= flag.MaxInlineAttachmentBytes {
text := fmt.Sprintf("attachment %s (%s, %d bytes, %s)", name, attachmentUUID, len(limited), mimeType) text := fmt.Sprintf("attachment %s (%s, %d bytes, %s)", name, attachmentUUID, len(limited), mimeType)
return mcp.NewToolResultImage(text, base64.StdEncoding.EncodeToString(limited), mimeType), nil return &mcp.CallToolResult{Content: []mcp.Content{
&mcp.TextContent{Text: text},
&mcp.ImageContent{Data: limited, MIMEType: mimeType},
}}, nil
} }
outputPath := defaultAttachmentPath(owner, repo, name, attachmentUUID) outputPath := defaultAttachmentPath(owner, repo, name, attachmentUUID)
if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil { if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil {
@@ -185,29 +187,29 @@ func attachmentFileResult(att *gitea_sdk.Attachment, outputPath string, written
return to.TextResult(res) return to.TextResult(res)
} }
func attachmentScopeArgs(req mcp.CallToolRequest) (owner, repo string, issueNumber, commentID int64, err error) { func attachmentScopeArgs(args map[string]any) (owner, repo string, issueNumber, commentID int64, err error) {
owner, err = params.GetString(req.GetArguments(), "owner") owner, err = params.GetString(args, "owner")
if err != nil { if err != nil {
return "", "", 0, 0, err return "", "", 0, 0, err
} }
repo, err = params.GetString(req.GetArguments(), "repo") repo, err = params.GetString(args, "repo")
if err != nil { if err != nil {
return "", "", 0, 0, err return "", "", 0, 0, err
} }
issueNumber = params.GetOptionalInt(req.GetArguments(), "issue_number", 0) issueNumber = params.GetOptionalInt(args, "issue_number", 0)
commentID = params.GetOptionalInt(req.GetArguments(), "comment_id", 0) commentID = params.GetOptionalInt(args, "comment_id", 0)
if (issueNumber > 0) == (commentID > 0) { if (issueNumber > 0) == (commentID > 0) {
return "", "", 0, 0, errors.New("exactly one of issue_number or comment_id is required") return "", "", 0, 0, errors.New("exactly one of issue_number or comment_id is required")
} }
return owner, repo, issueNumber, commentID, nil return owner, repo, issueNumber, commentID, nil
} }
func lookupAttachment(ctx context.Context, req mcp.CallToolRequest) (*gitea_sdk.Attachment, error) { func lookupAttachment(ctx context.Context, args map[string]any) (*gitea_sdk.Attachment, error) {
owner, repo, issueNumber, commentID, err := attachmentScopeArgs(req) owner, repo, issueNumber, commentID, err := attachmentScopeArgs(args)
if err != nil { if err != nil {
return nil, err return nil, err
} }
attachmentID := params.GetOptionalInt(req.GetArguments(), "attachment_id", 0) attachmentID := params.GetOptionalInt(args, "attachment_id", 0)
if attachmentID <= 0 { if attachmentID <= 0 {
return nil, errors.New("attachment_id is required") return nil, errors.New("attachment_id is required")
} }
+66 -7
View File
@@ -1,7 +1,9 @@
package issue package issue
import ( import (
"bytes"
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@@ -14,7 +16,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/flag" "gitea.com/gitea/gitea-mcp/pkg/flag"
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
func TestAttachmentFilename(t *testing.T) { func TestAttachmentFilename(t *testing.T) {
@@ -79,13 +81,13 @@ func TestAttachmentReadListIssueAttachments(t *testing.T) {
flag.Host, flag.Token, flag.Version = server.URL, "", "test" flag.Host, flag.Token, flag.Version = server.URL, "", "test"
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }() defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
res, err := attachmentReadFn(context.Background(), mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{ res, err := attachmentReadFn(context.Background(), map[string]any{
"method": "list", "owner": owner, "repo": repo, "issue_number": float64(42), "method": "list", "owner": owner, "repo": repo, "issue_number": float64(42),
}}}) })
if err != nil { if err != nil {
t.Fatalf("attachmentReadFn() error = %v", err) t.Fatalf("attachmentReadFn() error = %v", err)
} }
body := res.Content[0].(mcp.TextContent).Text body := res.Content[0].(*mcp.TextContent).Text
if !strings.Contains(body, `"mime_type":"image/png"`) || !strings.Contains(body, `"uuid":"uuid-1"`) { if !strings.Contains(body, `"mime_type":"image/png"`) || !strings.Contains(body, `"uuid":"uuid-1"`) {
t.Fatalf("unexpected body: %s", body) t.Fatalf("unexpected body: %s", body)
} }
@@ -158,13 +160,13 @@ func TestAttachmentReadDownloadSavesLargeAttachmentToDefaultFile(t *testing.T) {
flag.Host, flag.Token, flag.Version, flag.MaxInlineAttachmentBytes = origHost, origToken, origVersion, origInline flag.Host, flag.Token, flag.Version, flag.MaxInlineAttachmentBytes = origHost, origToken, origVersion, origInline
}() }()
res, err := attachmentReadFn(context.Background(), mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{ res, err := attachmentReadFn(context.Background(), map[string]any{
"method": "download", "owner": owner, "repo": repo, "issue_number": float64(42), "attachment_id": float64(1), "method": "download", "owner": owner, "repo": repo, "issue_number": float64(42), "attachment_id": float64(1),
}}}) })
if err != nil { if err != nil {
t.Fatalf("attachmentReadFn() error = %v", err) t.Fatalf("attachmentReadFn() error = %v", err)
} }
body := res.Content[0].(mcp.TextContent).Text body := res.Content[0].(*mcp.TextContent).Text
wantPath := filepath.Join(home, ".gitea-mcp", "attachments", owner, repo, "large-uuid-1.bin") wantPath := filepath.Join(home, ".gitea-mcp", "attachments", owner, repo, "large-uuid-1.bin")
if !strings.Contains(body, wantPath) { if !strings.Contains(body, wantPath) {
t.Fatalf("result missing path %q: %s", wantPath, body) t.Fatalf("result missing path %q: %s", wantPath, body)
@@ -180,3 +182,60 @@ func TestAttachmentReadDownloadSavesLargeAttachmentToDefaultFile(t *testing.T) {
t.Fatalf("result missing bytes: %s", body) t.Fatalf("result missing bytes: %s", body)
} }
} }
func TestAttachmentReadDownloadReturnsRawImageContent(t *testing.T) {
const uuid = "uuid-1"
payload := []byte{0, 1, 2, 250}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/attachments/"+uuid {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(payload)
}))
defer server.Close()
originalHost := flag.Host
originalLimit := flag.MaxInlineAttachmentBytes
flag.Host = server.URL
flag.MaxInlineAttachmentBytes = len(payload)
defer func() {
flag.Host = originalHost
flag.MaxInlineAttachmentBytes = originalLimit
}()
result, err := attachmentReadFn(context.Background(), map[string]any{
"method": "download",
"owner": "octo",
"repo": "demo",
"attachment_uuid": uuid,
})
if err != nil {
t.Fatalf("attachmentReadFn() error = %v", err)
}
if len(result.Content) != 2 {
t.Fatalf("content count = %d, want 2", len(result.Content))
}
if _, ok := result.Content[0].(*mcp.TextContent); !ok {
t.Fatalf("first content type = %T, want *mcp.TextContent", result.Content[0])
}
image, ok := result.Content[1].(*mcp.ImageContent)
if !ok {
t.Fatalf("second content type = %T, want *mcp.ImageContent", result.Content[1])
}
if image.MIMEType != "image/png" {
t.Errorf("image MIME type = %q, want image/png", image.MIMEType)
}
if !bytes.Equal(image.Data, payload) {
t.Errorf("image data = %v, want raw payload %v", image.Data, payload)
}
wire, err := json.Marshal(image)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
wantBase64 := base64.StdEncoding.EncodeToString(payload)
if !strings.Contains(string(wire), `"data":"`+wantBase64+`"`) {
t.Errorf("wire image = %s, want base64 data %q", wire, wantBase64)
}
}
+170 -124
View File
@@ -13,8 +13,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "gitea.dev/sdk" gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mark3labs/mcp-go/server"
) )
// issueWithAssets / commentWithAssets wrap the SDK types to capture the // issueWithAssets / commentWithAssets wrap the SDK types to capture the
@@ -38,125 +37,125 @@ const (
) )
var ( var (
ListRepoIssuesTool = mcp.NewTool( ListRepoIssuesTool = tool.NewDefinition(
ListRepoIssuesToolName, ListRepoIssuesToolName,
mcp.WithDescription("List issues in a repository (or pull requests, via the 'type' filter), filterable by state, labels, milestones, and update time range."), "List issues in a repository (or pull requests, via the 'type' filter), filterable by state, labels, milestones, and update time range.",
mcp.WithToolAnnotation(annotation.ReadOnly("List repository issues")), annotation.ReadOnly("List repository issues"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithString("state", mcp.DefaultString("all")), tool.String("state", tool.Default("all")),
mcp.WithString("type", mcp.Description("issues or pulls"), mcp.Enum("issues", "pulls")), tool.String("type", tool.Description("issues or pulls"), tool.Enum("issues", "pulls")),
mcp.WithArray("labels", mcp.Description("label name filter"), mcp.Items(map[string]any{"type": "string"})), tool.Array("labels", tool.Description("label name filter"), tool.Items(map[string]any{"type": "string"})),
mcp.WithArray("milestones", mcp.Description("milestone name or ID filter"), mcp.Items(map[string]any{"type": "string"})), tool.Array("milestones", tool.Description("milestone name or ID filter"), tool.Items(map[string]any{"type": "string"})),
mcp.WithString("since", mcp.Description("updated after ISO 8601")), tool.String("since", tool.Description("updated after ISO 8601")),
mcp.WithString("before", mcp.Description("updated before ISO 8601")), tool.String("before", tool.Description("updated before ISO 8601")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)), tool.Number("page", tool.Description(params.PageDesc), tool.Default(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)), tool.Number("per_page", tool.Description(params.PaginationDesc), tool.Default(30)),
) )
IssueReadTool = mcp.NewTool( IssueReadTool = tool.NewDefinition(
IssueReadToolName, IssueReadToolName,
mcp.WithDescription("Read issue: details, comments, or labels."), "Read issue: details, comments, labels, or the full discussion as Markdown.",
mcp.WithToolAnnotation(annotation.ReadOnly("Read issue details")), annotation.ReadOnly("Read issue details"),
mcp.WithString("method", mcp.Required(), mcp.Enum("get", "get_comments", "get_labels")), tool.String("method", tool.Required(), tool.Enum("get", "get_comments", "get_labels", "get_discussion_markdown")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithNumber("issue_number", mcp.Required()), tool.Number("issue_number", tool.Required()),
) )
IssueWriteTool = mcp.NewTool( IssueWriteTool = tool.NewDefinition(
IssueWriteToolName, IssueWriteToolName,
mcp.WithDescription("Write issues: create, update, manage comments and labels."), "Write issues: create, update, manage comments and labels.",
mcp.WithToolAnnotation(annotation.Write("Create or update issues, comments, and labels")), annotation.Write("Create or update issues, comments, and labels"),
mcp.WithString("method", mcp.Required(), mcp.Enum("create", "update", "add_comment", "edit_comment", "add_labels", "remove_label", "replace_labels", "clear_labels")), tool.String("method", tool.Required(), tool.Enum("create", "update", "add_comment", "edit_comment", "add_labels", "remove_label", "replace_labels", "clear_labels")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithNumber("issue_number", mcp.Description("required except for 'create'")), tool.Number("issue_number", tool.Description("required except for 'create'")),
mcp.WithString("title", mcp.Description("required for 'create'")), tool.String("title", tool.Description("required for 'create'")),
mcp.WithString("body", mcp.Description("required for 'create'/'add_comment'/'edit_comment'")), tool.String("body", tool.Description("required for 'create'/'add_comment'/'edit_comment'")),
mcp.WithArray("assignees", mcp.Items(map[string]any{"type": "string"})), tool.Array("assignees", tool.Items(map[string]any{"type": "string"})),
mcp.WithNumber("milestone"), tool.Number("milestone"),
mcp.WithString("state", mcp.Enum("open", "closed", "all")), tool.String("state", tool.Enum("open", "closed", "all")),
mcp.WithNumber("commentID", mcp.Description("for 'edit_comment'")), tool.Number("commentID", tool.Description("for 'edit_comment'")),
mcp.WithArray("labels", mcp.Description("label IDs"), mcp.Items(map[string]any{"type": "number"})), tool.Array("labels", tool.Description("label IDs"), tool.Items(map[string]any{"type": "number"})),
mcp.WithNumber("label_id", mcp.Description("for 'remove_label'")), tool.Number("label_id", tool.Description("for 'remove_label'")),
mcp.WithString("ref", mcp.Description("branch to associate")), tool.String("ref", tool.Description("branch to associate")),
mcp.WithString("deadline", mcp.Description("ISO 8601")), tool.String("deadline", tool.Description("ISO 8601")),
mcp.WithBoolean("remove_deadline"), tool.Boolean("remove_deadline"),
) )
) )
func init() { func init() {
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(tool.ServerTool{
Tool: ListRepoIssuesTool, Tool: ListRepoIssuesTool,
Handler: listRepoIssuesFn, Handler: listRepoIssuesFn,
}) })
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(tool.ServerTool{
Tool: IssueReadTool, Tool: IssueReadTool,
Handler: issueReadFn, Handler: issueReadFn,
}) })
Tool.RegisterWrite(server.ServerTool{ Tool.RegisterWrite(tool.ServerTool{
Tool: IssueWriteTool, Tool: IssueWriteTool,
Handler: issueWriteFn, Handler: issueWriteFn,
}) })
} }
func issueReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func issueReadFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
method, err := params.GetString(args, "method") method, err := params.GetString(args, "method")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
switch method { switch method {
case "get": case "get":
return getIssueByIndexFn(ctx, req) return getIssueByIndexFn(ctx, args)
case "get_comments": case "get_comments":
return getIssueCommentsByIndexFn(ctx, req) return getIssueCommentsByIndexFn(ctx, args)
case "get_labels": case "get_labels":
return getIssueLabelsFn(ctx, req) return getIssueLabelsFn(ctx, args)
case "get_discussion_markdown":
return getIssueDiscussionMarkdownFn(ctx, args)
default: default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
} }
func issueWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func issueWriteFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
method, err := params.GetString(args, "method") method, err := params.GetString(args, "method")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
switch method { switch method {
case "create": case "create":
return createIssueFn(ctx, req) return createIssueFn(ctx, args)
case "update": case "update":
return editIssueFn(ctx, req) return editIssueFn(ctx, args)
case "add_comment": case "add_comment":
return createIssueCommentFn(ctx, req) return createIssueCommentFn(ctx, args)
case "edit_comment": case "edit_comment":
return editIssueCommentFn(ctx, req) return editIssueCommentFn(ctx, args)
case "add_labels": case "add_labels":
return addIssueLabelsFn(ctx, req) return addIssueLabelsFn(ctx, args)
case "remove_label": case "remove_label":
return removeIssueLabelFn(ctx, req) return removeIssueLabelFn(ctx, args)
case "replace_labels": case "replace_labels":
return replaceIssueLabelsFn(ctx, req) return replaceIssueLabelsFn(ctx, args)
case "clear_labels": case "clear_labels":
return clearIssueLabelsFn(ctx, req) return clearIssueLabelsFn(ctx, args)
default: default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
} }
func getIssueByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getIssueByIndexFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "issue_number") index, err := params.GetIndex(args, "issue_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -170,22 +169,22 @@ func getIssueByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
return to.TextResult(m) return to.TextResult(m)
} }
func listRepoIssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listRepoIssuesFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
state, ok := req.GetArguments()["state"].(string) state, ok := args["state"].(string)
if !ok { if !ok {
state = "all" state = "all"
} }
labels := params.GetStringSlice(req.GetArguments(), "labels") labels := params.GetStringSlice(args, "labels")
milestones := params.GetStringSlice(req.GetArguments(), "milestones") milestones := params.GetStringSlice(args, "milestones")
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, pageSize := params.GetPagination(args, 30)
opt := gitea_sdk.ListIssueOption{ opt := gitea_sdk.ListIssueOption{
State: gitea_sdk.StateType(state), State: gitea_sdk.StateType(state),
Labels: labels, Labels: labels,
@@ -195,16 +194,16 @@ func listRepoIssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
PageSize: pageSize, PageSize: pageSize,
}, },
} }
switch req.GetArguments()["type"] { switch args["type"] {
case "issues": case "issues":
opt.Type = gitea_sdk.IssueTypeIssue opt.Type = gitea_sdk.IssueTypeIssue
case "pulls": case "pulls":
opt.Type = gitea_sdk.IssueTypePull opt.Type = gitea_sdk.IssueTypePull
} }
if t := params.GetOptionalTime(req.GetArguments(), "since"); t != nil { if t := params.GetOptionalTime(args, "since"); t != nil {
opt.Since = *t opt.Since = *t
} }
if t := params.GetOptionalTime(req.GetArguments(), "before"); t != nil { if t := params.GetOptionalTime(args, "before"); t != nil {
opt.Before = *t opt.Before = *t
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -218,20 +217,20 @@ func listRepoIssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
return to.TextResult(slimIssues(issues)) return to.TextResult(slimIssues(issues))
} }
func createIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func createIssueFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
title, err := params.GetString(req.GetArguments(), "title") title, err := params.GetString(args, "title")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
body, err := params.GetString(req.GetArguments(), "body") body, err := params.GetString(args, "body")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -243,19 +242,19 @@ func createIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR
Title: title, Title: title,
Body: body, Body: body,
} }
opt.Assignees = params.GetStringSlice(req.GetArguments(), "assignees") opt.Assignees = params.GetStringSlice(args, "assignees")
if val, exists := req.GetArguments()["milestone"]; exists { if val, exists := args["milestone"]; exists {
if milestone, ok := params.ToInt64(val); ok { if milestone, ok := params.ToInt64(val); ok {
opt.Milestone = milestone opt.Milestone = milestone
} }
} }
if labelIDs, err := params.GetInt64Slice(req.GetArguments(), "labels"); err == nil { if labelIDs, err := params.GetInt64Slice(args, "labels"); err == nil {
opt.Labels = labelIDs opt.Labels = labelIDs
} }
if ref, ok := req.GetArguments()["ref"].(string); ok { if ref, ok := args["ref"].(string); ok {
opt.Ref = ref opt.Ref = ref
} }
opt.Deadline = params.GetOptionalTime(req.GetArguments(), "deadline") opt.Deadline = params.GetOptionalTime(args, "deadline")
issue, _, err := client.Issues.CreateIssue(ctx, owner, repo, opt) issue, _, err := client.Issues.CreateIssue(ctx, 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))
@@ -264,20 +263,20 @@ func createIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR
return to.TextResult(slimIssue(issue)) return to.TextResult(slimIssue(issue))
} }
func createIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func createIssueCommentFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "issue_number") index, err := params.GetIndex(args, "issue_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
body, err := params.GetString(req.GetArguments(), "body") body, err := params.GetString(args, "body")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -296,21 +295,20 @@ func createIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
return to.TextResult(slimComment(issueComment)) return to.TextResult(slimComment(issueComment))
} }
func editIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func editIssueFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "issue_number") index, err := params.GetIndex(args, "issue_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
args := req.GetArguments()
opt := gitea_sdk.EditIssueOption{ opt := gitea_sdk.EditIssueOption{
Body: params.GetPresentStringPtr(args, "body"), Body: params.GetPresentStringPtr(args, "body"),
Ref: params.GetPresentStringPtr(args, "ref"), Ref: params.GetPresentStringPtr(args, "ref"),
@@ -343,20 +341,20 @@ func editIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRes
return to.TextResult(slimIssue(issue)) return to.TextResult(slimIssue(issue))
} }
func editIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func editIssueCommentFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
commentID, err := params.GetIndex(req.GetArguments(), "commentID") commentID, err := params.GetIndex(args, "commentID")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
body, err := params.GetString(req.GetArguments(), "body") body, err := params.GetString(args, "body")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -375,16 +373,16 @@ func editIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
return to.TextResult(slimComment(issueComment)) return to.TextResult(slimComment(issueComment))
} }
func getIssueCommentsByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getIssueCommentsByIndexFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "issue_number") index, err := params.GetIndex(args, "issue_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -402,16 +400,64 @@ func getIssueCommentsByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*m
return to.TextResult(out) return to.TextResult(out)
} }
func getIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getIssueDiscussionMarkdownFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "issue_number") index, err := params.GetIndex(args, "issue_number")
if err != nil {
return to.ErrorResult(err)
}
var issue issueWithAssets
issuePath := fmt.Sprintf("repos/%s/%s/issues/%d", url.PathEscape(owner), url.PathEscape(repo), index)
if _, err := gitea.DoJSON(ctx, "GET", issuePath, nil, nil, &issue); err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/issue/%v err: %v", owner, repo, index, err))
}
var comments []commentWithAssets
commentsPath := fmt.Sprintf("repos/%s/%s/issues/%d/comments", url.PathEscape(owner), url.PathEscape(repo), index)
if _, err := gitea.DoJSON(ctx, "GET", commentsPath, nil, nil, &comments); err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/issues/%v/comments err: %v", owner, repo, index, err))
}
discComments := make([]discussionComment, 0, len(comments))
for i := range comments {
discComments = append(discComments, discussionComment{
Author: slim.UserLogin(comments[i].Poster),
CreatedAt: comments[i].Created,
Body: slim.BodyWithAttachments(comments[i].Body, comments[i].Assets),
})
}
md := formatDiscussionMarkdown(discussionIssue{
Number: issue.Index,
Title: issue.Title,
Author: slim.UserLogin(issue.Poster),
State: string(issue.State),
Labels: slim.LabelNames(issue.Labels),
Body: slim.BodyWithAttachments(issue.Body, issue.Assets),
CreatedAt: issue.Created,
}, discComments)
return to.RawTextResult(md)
}
func getIssueLabelsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
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, "issue_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -427,20 +473,20 @@ func getIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
return to.TextResult(slim.Labels(labels)) return to.TextResult(slim.Labels(labels))
} }
func addIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func addIssueLabelsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "issue_number") index, err := params.GetIndex(args, "issue_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
labels, err := params.GetInt64Slice(req.GetArguments(), "labels") labels, err := params.GetInt64Slice(args, "labels")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -456,20 +502,20 @@ func addIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
return to.TextResult(slim.Labels(issueLabels)) return to.TextResult(slim.Labels(issueLabels))
} }
func replaceIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func replaceIssueLabelsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "issue_number") index, err := params.GetIndex(args, "issue_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
labels, err := params.GetInt64Slice(req.GetArguments(), "labels") labels, err := params.GetInt64Slice(args, "labels")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -485,16 +531,16 @@ func replaceIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
return to.TextResult(slim.Labels(issueLabels)) return to.TextResult(slim.Labels(issueLabels))
} }
func clearIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func clearIssueLabelsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "issue_number") index, err := params.GetIndex(args, "issue_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -510,20 +556,20 @@ func clearIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
return to.TextResult("Labels cleared successfully") return to.TextResult("Labels cleared successfully")
} }
func removeIssueLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func removeIssueLabelFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "issue_number") index, err := params.GetIndex(args, "issue_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
labelID, err := params.GetIndex(req.GetArguments(), "label_id") labelID, err := params.GetIndex(args, "label_id")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
+142 -37
View File
@@ -12,7 +12,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/flag" "gitea.com/gitea/gitea-mcp/pkg/flag"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
func Test_listRepoIssuesFn_filters(t *testing.T) { func Test_listRepoIssuesFn_filters(t *testing.T) {
@@ -60,20 +60,16 @@ func Test_listRepoIssuesFn_filters(t *testing.T) {
flag.Version = origVersion flag.Version = origVersion
}() }()
req := mcp.CallToolRequest{ args := map[string]any{
Params: mcp.CallToolParams{ "owner": owner,
Arguments: map[string]any{ "repo": repo,
"owner": owner, "type": "issues",
"repo": repo, "labels": []any{"bug", "enhancement"},
"type": "issues", "milestones": []any{"v1.0", "2"},
"labels": []any{"bug", "enhancement"}, "since": "2026-01-01T00:00:00Z",
"milestones": []any{"v1.0", "2"},
"since": "2026-01-01T00:00:00Z",
},
},
} }
_, err := listRepoIssuesFn(context.Background(), req) _, err := listRepoIssuesFn(context.Background(), args)
if err != nil { if err != nil {
t.Fatalf("listRepoIssuesFn() error = %v", err) t.Fatalf("listRepoIssuesFn() error = %v", err)
} }
@@ -126,17 +122,17 @@ func Test_listRepoIssuesFn_includesMilestone(t *testing.T) {
flag.Host, flag.Token, flag.Version = server.URL, "", "test" flag.Host, flag.Token, flag.Version = server.URL, "", "test"
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }() defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{ args := map[string]any{
"owner": owner, "repo": repo, "owner": owner, "repo": repo,
}}} }
res, err := listRepoIssuesFn(context.Background(), req) res, err := listRepoIssuesFn(context.Background(), args)
if err != nil { if err != nil {
t.Fatalf("listRepoIssuesFn() error = %v", err) t.Fatalf("listRepoIssuesFn() error = %v", err)
} }
if res.IsError { if res.IsError {
t.Fatalf("unexpected error result: %v", res.Content) t.Fatalf("unexpected error result: %v", res.Content)
} }
body := res.Content[0].(mcp.TextContent).Text body := res.Content[0].(*mcp.TextContent).Text
if !strings.Contains(body, `"milestone"`) || !strings.Contains(body, `"v1.0"`) { if !strings.Contains(body, `"milestone"`) || !strings.Contains(body, `"v1.0"`) {
t.Fatalf("expected milestone in list output, got: %s", body) t.Fatalf("expected milestone in list output, got: %s", body)
} }
@@ -189,20 +185,16 @@ func Test_createIssueFn_labels(t *testing.T) {
flag.Version = origVersion flag.Version = origVersion
}() }()
req := mcp.CallToolRequest{ args := map[string]any{
Params: mcp.CallToolParams{ "owner": owner,
Arguments: map[string]any{ "repo": repo,
"owner": owner, "title": "test issue",
"repo": repo, "body": "body",
"title": "test issue", "labels": []any{float64(10), float64(20)},
"body": "body", "deadline": "2026-06-01T00:00:00Z",
"labels": []any{float64(10), float64(20)},
"deadline": "2026-06-01T00:00:00Z",
},
},
} }
_, err := createIssueFn(context.Background(), req) _, err := createIssueFn(context.Background(), args)
if err != nil { if err != nil {
t.Fatalf("createIssueFn() error = %v", err) t.Fatalf("createIssueFn() error = %v", err)
} }
@@ -255,17 +247,17 @@ func Test_getIssueByIndexFn_includesAttachments(t *testing.T) {
flag.Host, flag.Token, flag.Version = server.URL, "", "test" flag.Host, flag.Token, flag.Version = server.URL, "", "test"
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }() defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{ args := map[string]any{
"owner": owner, "repo": repo, "issue_number": float64(42), "owner": owner, "repo": repo, "issue_number": float64(42),
}}} }
res, err := getIssueByIndexFn(context.Background(), req) res, err := getIssueByIndexFn(context.Background(), args)
if err != nil { if err != nil {
t.Fatalf("getIssueByIndexFn() error = %v", err) t.Fatalf("getIssueByIndexFn() error = %v", err)
} }
if res.IsError { if res.IsError {
t.Fatalf("unexpected error result: %v", res.Content) t.Fatalf("unexpected error result: %v", res.Content)
} }
body := res.Content[0].(mcp.TextContent).Text body := res.Content[0].(*mcp.TextContent).Text
if !strings.Contains(body, `[shot.png](https://example/shot.png)`) { if !strings.Contains(body, `[shot.png](https://example/shot.png)`) {
t.Fatalf("expected attachment markdown inlined in body, got: %s", body) t.Fatalf("expected attachment markdown inlined in body, got: %s", body)
} }
@@ -274,6 +266,119 @@ func Test_getIssueByIndexFn_includesAttachments(t *testing.T) {
} }
} }
func Test_getIssueDiscussionMarkdownFn(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",
"user": {"login": "octocat"},
"labels": [{"name": "bug"}],
"created_at": "2026-01-02T15:04:05Z",
"assets": [
{"id": 1, "name": "shot.png", "size": 1024, "browser_download_url": "https://example/shot.png"}
]
}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/issues/42/comments", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"id": 1, "body": "see this", "user": {"login": "reviewer"}, "created_at": "2026-01-03T09:00:00Z", "assets": [
{"id": 9, "name": "log.txt", "size": 200, "browser_download_url": "https://example/log.txt"}
]}
]`))
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),
}
res, err := getIssueDiscussionMarkdownFn(context.Background(), args)
if err != nil {
t.Fatalf("getIssueDiscussionMarkdownFn() error = %v", err)
}
if res.IsError {
t.Fatalf("unexpected error result: %v", res.Content)
}
body := res.Content[0].(*mcp.TextContent).Text
for _, want := range []string{
"# bug with screenshot (#42)",
"**Author:** octocat",
"**State:** open",
"**Labels:** bug",
"[shot.png](https://example/shot.png)",
"### reviewer on 2026-01-03T09:00:00Z",
"[log.txt](https://example/log.txt)",
} {
if !strings.Contains(body, want) {
t.Fatalf("expected markdown to contain %q, got:\n%s", want, body)
}
}
}
func Test_getIssueDiscussionMarkdownFn_noComments(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", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"number": 7, "title": "quiet issue", "state": "open", "user": {"login": "octocat"}}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/issues/7/comments", owner, repo):
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 }()
args := map[string]any{
"owner": owner, "repo": repo, "issue_number": float64(7),
}
res, err := getIssueDiscussionMarkdownFn(context.Background(), args)
if err != nil {
t.Fatalf("getIssueDiscussionMarkdownFn() 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, "_No comments yet._") {
t.Fatalf("expected placeholder for no comments, got:\n%s", body)
}
}
func Test_getIssueCommentsByIndexFn_includesAttachments(t *testing.T) { func Test_getIssueCommentsByIndexFn_includesAttachments(t *testing.T) {
const ( const (
owner = "octo" owner = "octo"
@@ -304,17 +409,17 @@ func Test_getIssueCommentsByIndexFn_includesAttachments(t *testing.T) {
flag.Host, flag.Token, flag.Version = server.URL, "", "test" flag.Host, flag.Token, flag.Version = server.URL, "", "test"
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }() defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{ args := map[string]any{
"owner": owner, "repo": repo, "issue_number": float64(7), "owner": owner, "repo": repo, "issue_number": float64(7),
}}} }
res, err := getIssueCommentsByIndexFn(context.Background(), req) res, err := getIssueCommentsByIndexFn(context.Background(), args)
if err != nil { if err != nil {
t.Fatalf("getIssueCommentsByIndexFn() error = %v", err) t.Fatalf("getIssueCommentsByIndexFn() error = %v", err)
} }
if res.IsError { if res.IsError {
t.Fatalf("unexpected error result: %v", res.Content) t.Fatalf("unexpected error result: %v", res.Content)
} }
body := res.Content[0].(mcp.TextContent).Text body := res.Content[0].(*mcp.TextContent).Text
if !strings.Contains(body, `[log.txt](https://example/log.txt)`) { if !strings.Contains(body, `[log.txt](https://example/log.txt)`) {
t.Fatalf("expected attachment markdown inlined in body, got: %s", body) t.Fatalf("expected attachment markdown inlined in body, got: %s", body)
} }
+63
View File
@@ -0,0 +1,63 @@
package issue
import (
"fmt"
"strings"
"time"
)
type discussionIssue struct {
Number int64
Title string
Author string
State string
Labels []string
Body string
CreatedAt time.Time
}
type discussionComment struct {
Author string
CreatedAt time.Time
Body string
}
// formatDiscussionMarkdown renders an issue and its comments as a single
// Markdown document suitable for display without further parsing.
func formatDiscussionMarkdown(issue discussionIssue, comments []discussionComment) string {
var b strings.Builder
fmt.Fprintf(&b, "# %s (#%d)\n\n", issue.Title, issue.Number)
fmt.Fprintf(&b, "**Author:** %s\n", issue.Author)
fmt.Fprintf(&b, "**State:** %s\n", issue.State)
if len(issue.Labels) > 0 {
fmt.Fprintf(&b, "**Labels:** %s\n", strings.Join(issue.Labels, ", "))
}
if !issue.CreatedAt.IsZero() {
fmt.Fprintf(&b, "**Created:** %s\n", issue.CreatedAt.UTC().Format(time.RFC3339))
}
b.WriteString("\n")
b.WriteString(strings.TrimSpace(issue.Body))
b.WriteString("\n\n---\n\n## Comments\n\n")
if len(comments) == 0 {
b.WriteString("_No comments yet._\n")
return b.String()
}
for i, c := range comments {
fmt.Fprintf(&b, "### %s", c.Author)
if !c.CreatedAt.IsZero() {
fmt.Fprintf(&b, " on %s", c.CreatedAt.UTC().Format(time.RFC3339))
}
b.WriteString("\n\n")
b.WriteString(strings.TrimSpace(c.Body))
if i < len(comments)-1 {
b.WriteString("\n\n---\n\n")
} else {
b.WriteString("\n")
}
}
return b.String()
}
+85
View File
@@ -0,0 +1,85 @@
package issue
import (
"strings"
"testing"
"time"
)
func Test_formatDiscussionMarkdown_includesIssueAndComments(t *testing.T) {
created := time.Date(2026, 1, 2, 15, 4, 5, 0, time.UTC)
commentTime := time.Date(2026, 1, 3, 9, 0, 0, 0, time.UTC)
md := formatDiscussionMarkdown(
discussionIssue{
Number: 42,
Title: "bug with screenshot",
Author: "octocat",
State: "open",
Labels: []string{"bug", "help wanted"},
Body: "see attached",
CreatedAt: created,
},
[]discussionComment{
{Author: "reviewer", CreatedAt: commentTime, Body: "thanks for reporting"},
},
)
wantSubstrings := []string{
"# bug with screenshot (#42)",
"**Author:** octocat",
"**State:** open",
"**Labels:** bug, help wanted",
"**Created:** 2026-01-02T15:04:05Z",
"see attached",
"## Comments",
"### reviewer",
"2026-01-03T09:00:00Z",
"thanks for reporting",
}
for _, want := range wantSubstrings {
if !strings.Contains(md, want) {
t.Fatalf("expected markdown to contain %q, got:\n%s", want, md)
}
}
}
func Test_formatDiscussionMarkdown_noComments(t *testing.T) {
md := formatDiscussionMarkdown(
discussionIssue{
Number: 1,
Title: "no comments yet",
Author: "octocat",
State: "open",
},
nil,
)
if !strings.Contains(md, "## Comments") {
t.Fatalf("expected a Comments section, got:\n%s", md)
}
if !strings.Contains(md, "_No comments yet._") {
t.Fatalf("expected placeholder for no comments, got:\n%s", md)
}
}
func Test_formatDiscussionMarkdown_attachmentBodyIsInlined(t *testing.T) {
md := formatDiscussionMarkdown(
discussionIssue{
Number: 7,
Title: "with attachment",
Author: "octocat",
State: "open",
Body: "see attached\n\n[shot.png](https://example/shot.png)",
},
[]discussionComment{
{Author: "reviewer", Body: "log attached\n\n[log.txt](https://example/log.txt)"},
},
)
if !strings.Contains(md, "[shot.png](https://example/shot.png)") {
t.Fatalf("expected issue attachment link in markdown, got:\n%s", md)
}
if !strings.Contains(md, "[log.txt](https://example/log.txt)") {
t.Fatalf("expected comment attachment link in markdown, got:\n%s", md)
}
}
+75 -80
View File
@@ -12,8 +12,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "gitea.dev/sdk" gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mark3labs/mcp-go/server"
) )
var Tool = tool.New("label") var Tool = tool.New("label")
@@ -24,99 +23,97 @@ const (
) )
var ( var (
LabelReadTool = mcp.NewTool( LabelReadTool = tool.NewDefinition(
LabelReadToolName, LabelReadToolName,
mcp.WithDescription("Read repo or org labels."), "Read repo or org labels.",
mcp.WithToolAnnotation(annotation.ReadOnly("Read labels")), annotation.ReadOnly("Read labels"),
mcp.WithString("method", mcp.Required(), mcp.Enum("list_repo_labels", "get_repo_label", "list_org_labels")), tool.String("method", tool.Required(), tool.Enum("list_repo_labels", "get_repo_label", "list_org_labels")),
mcp.WithString("owner", mcp.Description("for repo methods")), tool.String("owner", tool.Description("for repo methods")),
mcp.WithString("repo", mcp.Description("for repo methods")), tool.String("repo", tool.Description("for repo methods")),
mcp.WithString("org", mcp.Description("for org methods")), tool.String("org", tool.Description("for org methods")),
mcp.WithNumber("id", mcp.Description("label ID (for 'get_repo_label')")), tool.Number("id", tool.Description("label ID (for 'get_repo_label')")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)), tool.Number("page", tool.Description(params.PageDesc), tool.Default(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)), tool.Number("per_page", tool.Description(params.PaginationDesc), tool.Default(30)),
) )
LabelWriteTool = mcp.NewTool( LabelWriteTool = tool.NewDefinition(
LabelWriteToolName, LabelWriteToolName,
mcp.WithDescription("Write labels (repo or org): create, edit, delete."), "Write labels (repo or org): create, edit, delete.",
mcp.WithToolAnnotation(annotation.Destructive("Create, update, or delete labels")), annotation.Destructive("Create, update, or delete labels"),
mcp.WithString("method", mcp.Required(), mcp.Enum("create_repo_label", "edit_repo_label", "delete_repo_label", "create_org_label", "edit_org_label", "delete_org_label")), tool.String("method", tool.Required(), tool.Enum("create_repo_label", "edit_repo_label", "delete_repo_label", "create_org_label", "edit_org_label", "delete_org_label")),
mcp.WithString("owner", mcp.Description("for repo methods")), tool.String("owner", tool.Description("for repo methods")),
mcp.WithString("repo", mcp.Description("for repo methods")), tool.String("repo", tool.Description("for repo methods")),
mcp.WithString("org", mcp.Description("for org methods")), tool.String("org", tool.Description("for org methods")),
mcp.WithNumber("id", mcp.Description("for edit/delete")), tool.Number("id", tool.Description("for edit/delete")),
mcp.WithString("name", mcp.Description("required for create")), tool.String("name", tool.Description("required for create")),
mcp.WithString("color", mcp.Description("hex (#RRGGBB); required for create")), tool.String("color", tool.Description("hex (#RRGGBB); required for create")),
mcp.WithString("description"), tool.String("description"),
mcp.WithBoolean("exclusive", mcp.Description("exclusive (org only)")), tool.Boolean("exclusive", tool.Description("exclusive (org only)")),
mcp.WithBoolean("is_archived", mcp.Description("archived (repo only)")), tool.Boolean("is_archived", tool.Description("archived (repo only)")),
) )
) )
func init() { func init() {
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(tool.ServerTool{
Tool: LabelReadTool, Tool: LabelReadTool,
Handler: labelReadFn, Handler: labelReadFn,
}) })
Tool.RegisterWrite(server.ServerTool{ Tool.RegisterWrite(tool.ServerTool{
Tool: LabelWriteTool, Tool: LabelWriteTool,
Handler: labelWriteFn, Handler: labelWriteFn,
}) })
} }
func labelReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func labelReadFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
method, err := params.GetString(args, "method") method, err := params.GetString(args, "method")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
switch method { switch method {
case "list_repo_labels": case "list_repo_labels":
return listRepoLabelsFn(ctx, req) return listRepoLabelsFn(ctx, args)
case "get_repo_label": case "get_repo_label":
return getRepoLabelFn(ctx, req) return getRepoLabelFn(ctx, args)
case "list_org_labels": case "list_org_labels":
return listOrgLabelsFn(ctx, req) return listOrgLabelsFn(ctx, args)
default: default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
} }
func labelWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func labelWriteFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
method, err := params.GetString(args, "method") method, err := params.GetString(args, "method")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
switch method { switch method {
case "create_repo_label": case "create_repo_label":
return createRepoLabelFn(ctx, req) return createRepoLabelFn(ctx, args)
case "edit_repo_label": case "edit_repo_label":
return editRepoLabelFn(ctx, req) return editRepoLabelFn(ctx, args)
case "delete_repo_label": case "delete_repo_label":
return deleteRepoLabelFn(ctx, req) return deleteRepoLabelFn(ctx, args)
case "create_org_label": case "create_org_label":
return createOrgLabelFn(ctx, req) return createOrgLabelFn(ctx, args)
case "edit_org_label": case "edit_org_label":
return editOrgLabelFn(ctx, req) return editOrgLabelFn(ctx, args)
case "delete_org_label": case "delete_org_label":
return deleteOrgLabelFn(ctx, req) return deleteOrgLabelFn(ctx, args)
default: default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
} }
func listRepoLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listRepoLabelsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, pageSize := params.GetPagination(args, 30)
opt := gitea_sdk.ListLabelsOptions{ opt := gitea_sdk.ListLabelsOptions{
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
@@ -135,16 +132,16 @@ func listRepoLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
return to.TextResult(slim.Labels(labels)) return to.TextResult(slim.Labels(labels))
} }
func getRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getRepoLabelFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
id, err := params.GetIndex(req.GetArguments(), "id") id, err := params.GetIndex(args, "id")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -160,26 +157,26 @@ func getRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
return to.TextResult(slim.Label(label)) return to.TextResult(slim.Label(label))
} }
func createRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func createRepoLabelFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
name, err := params.GetString(req.GetArguments(), "name") name, err := params.GetString(args, "name")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
color, err := params.GetString(req.GetArguments(), "color") color, err := params.GetString(args, "color")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
description, _ := req.GetArguments()["description"].(string) // Optional description, _ := args["description"].(string) // Optional
isArchived, _ := req.GetArguments()["is_archived"].(bool) isArchived, _ := args["is_archived"].(bool)
opt := gitea_sdk.CreateLabelOption{ opt := gitea_sdk.CreateLabelOption{
Name: name, Name: name,
@@ -199,21 +196,20 @@ func createRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
return to.TextResult(slim.Label(label)) return to.TextResult(slim.Label(label))
} }
func editRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func editRepoLabelFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
id, err := params.GetIndex(req.GetArguments(), "id") id, err := params.GetIndex(args, "id")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
args := req.GetArguments()
opt := gitea_sdk.EditLabelOption{ opt := gitea_sdk.EditLabelOption{
Name: params.GetOptionalStringPtr(args, "name"), Name: params.GetOptionalStringPtr(args, "name"),
Color: params.GetOptionalStringPtr(args, "color"), Color: params.GetOptionalStringPtr(args, "color"),
@@ -232,16 +228,16 @@ func editRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
return to.TextResult(slim.Label(label)) return to.TextResult(slim.Label(label))
} }
func deleteRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func deleteRepoLabelFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
id, err := params.GetIndex(req.GetArguments(), "id") id, err := params.GetIndex(args, "id")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -257,12 +253,12 @@ func deleteRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
return to.TextResult("Label deleted successfully") return to.TextResult("Label deleted successfully")
} }
func listOrgLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listOrgLabelsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
org, err := params.GetString(req.GetArguments(), "org") org, err := params.GetString(args, "org")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, pageSize := params.GetPagination(args, 30)
opt := gitea_sdk.ListOrgLabelsOptions{ opt := gitea_sdk.ListOrgLabelsOptions{
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
@@ -281,21 +277,21 @@ func listOrgLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
return to.TextResult(slim.Labels(labels)) return to.TextResult(slim.Labels(labels))
} }
func createOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func createOrgLabelFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
org, err := params.GetString(req.GetArguments(), "org") org, err := params.GetString(args, "org")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
name, err := params.GetString(req.GetArguments(), "name") name, err := params.GetString(args, "name")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
color, err := params.GetString(req.GetArguments(), "color") color, err := params.GetString(args, "color")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
description, _ := req.GetArguments()["description"].(string) description, _ := args["description"].(string)
exclusive, _ := req.GetArguments()["exclusive"].(bool) exclusive, _ := args["exclusive"].(bool)
opt := gitea_sdk.CreateOrgLabelOption{ opt := gitea_sdk.CreateOrgLabelOption{
Name: name, Name: name,
@@ -315,17 +311,16 @@ func createOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
return to.TextResult(slim.Label(label)) return to.TextResult(slim.Label(label))
} }
func editOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func editOrgLabelFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
org, err := params.GetString(req.GetArguments(), "org") org, err := params.GetString(args, "org")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
id, err := params.GetIndex(req.GetArguments(), "id") id, err := params.GetIndex(args, "id")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
args := req.GetArguments()
opt := gitea_sdk.EditOrgLabelOption{ opt := gitea_sdk.EditOrgLabelOption{
Name: params.GetOptionalStringPtr(args, "name"), Name: params.GetOptionalStringPtr(args, "name"),
Color: params.GetOptionalStringPtr(args, "color"), Color: params.GetOptionalStringPtr(args, "color"),
@@ -344,12 +339,12 @@ func editOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
return to.TextResult(slim.Label(label)) return to.TextResult(slim.Label(label))
} }
func deleteOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func deleteOrgLabelFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
org, err := params.GetString(req.GetArguments(), "org") org, err := params.GetString(args, "org")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
id, err := params.GetIndex(req.GetArguments(), "id") id, err := params.GetIndex(args, "id")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
+59 -61
View File
@@ -11,8 +11,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "gitea.dev/sdk" gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mark3labs/mcp-go/server"
) )
var Tool = tool.New("milestone") var Tool = tool.New("milestone")
@@ -23,90 +22,90 @@ const (
) )
var ( var (
MilestoneReadTool = mcp.NewTool( MilestoneReadTool = tool.NewDefinition(
MilestoneReadToolName, MilestoneReadToolName,
mcp.WithDescription("Read milestones: get one or list."), "Read milestones: get one or list.",
mcp.WithToolAnnotation(annotation.ReadOnly("Read milestones")), annotation.ReadOnly("Read milestones"),
mcp.WithString("method", mcp.Required(), mcp.Enum("get", "list")), tool.String("method", tool.Required(), tool.Enum("get", "list")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithNumber("id", mcp.Description("for 'get'")), tool.Number("id", tool.Description("for 'get'")),
mcp.WithString("state", mcp.DefaultString("all")), tool.String("state", tool.Default("all")),
mcp.WithString("name", mcp.Description("name filter (for 'list')")), tool.String("name", tool.Description("name filter (for 'list')")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)), tool.Number("page", tool.Description(params.PageDesc), tool.Default(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)), tool.Number("per_page", tool.Description(params.PaginationDesc), tool.Default(30)),
) )
MilestoneWriteTool = mcp.NewTool( MilestoneWriteTool = tool.NewDefinition(
MilestoneWriteToolName, MilestoneWriteToolName,
mcp.WithDescription("Write milestones: create, update, delete."), "Write milestones: create, update, delete.",
mcp.WithToolAnnotation(annotation.Destructive("Create, update, or delete milestones")), annotation.Destructive("Create, update, or delete milestones"),
mcp.WithString("method", mcp.Required(), mcp.Enum("create", "update", "edit", "delete")), tool.String("method", tool.Required(), tool.Enum("create", "update", "edit", "delete")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithNumber("id", mcp.Description("for 'update'/'delete'")), tool.Number("id", tool.Description("for 'update'/'delete'")),
mcp.WithString("title", mcp.Description("for 'create'")), tool.String("title", tool.Description("for 'create'")),
mcp.WithString("description"), tool.String("description"),
mcp.WithString("due_on", mcp.Description("due date")), tool.String("due_on", tool.Description("due date")),
mcp.WithString("state", mcp.Enum("open", "closed")), tool.String("state", tool.Enum("open", "closed")),
) )
) )
func init() { func init() {
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(tool.ServerTool{
Tool: MilestoneReadTool, Tool: MilestoneReadTool,
Handler: milestoneReadFn, Handler: milestoneReadFn,
}) })
Tool.RegisterWrite(server.ServerTool{ Tool.RegisterWrite(tool.ServerTool{
Tool: MilestoneWriteTool, Tool: MilestoneWriteTool,
Handler: milestoneWriteFn, Handler: milestoneWriteFn,
}) })
} }
func milestoneReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func milestoneReadFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method") method, err := params.GetString(args, "method")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
switch method { switch method {
case "get": case "get":
return getMilestoneFn(ctx, req) return getMilestoneFn(ctx, args)
case "list": case "list":
return listMilestonesFn(ctx, req) return listMilestonesFn(ctx, args)
default: default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
} }
func milestoneWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func milestoneWriteFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method") method, err := params.GetString(args, "method")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
switch method { switch method {
case "create": case "create":
return createMilestoneFn(ctx, req) return createMilestoneFn(ctx, args)
case "update": case "update":
return editMilestoneFn(ctx, req) return editMilestoneFn(ctx, args)
case "edit": case "edit":
return editMilestoneFn(ctx, req) return editMilestoneFn(ctx, args)
case "delete": case "delete":
return deleteMilestoneFn(ctx, req) return deleteMilestoneFn(ctx, args)
default: default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
} }
func getMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getMilestoneFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
id, err := params.GetIndex(req.GetArguments(), "id") id, err := params.GetIndex(args, "id")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -122,18 +121,18 @@ func getMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
return to.TextResult(slimMilestone(milestone)) return to.TextResult(slimMilestone(milestone))
} }
func listMilestonesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listMilestonesFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
state := params.GetOptionalString(req.GetArguments(), "state", "all") state := params.GetOptionalString(args, "state", "all")
name := params.GetOptionalString(req.GetArguments(), "name", "") name := params.GetOptionalString(args, "name", "")
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, pageSize := params.GetPagination(args, 30)
opt := gitea_sdk.ListMilestoneOption{ opt := gitea_sdk.ListMilestoneOption{
State: gitea_sdk.StateType(state), State: gitea_sdk.StateType(state),
Name: name, Name: name,
@@ -153,16 +152,16 @@ func listMilestonesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
return to.TextResult(slimMilestones(milestones)) return to.TextResult(slimMilestones(milestones))
} }
func createMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func createMilestoneFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
title, err := params.GetString(req.GetArguments(), "title") title, err := params.GetString(args, "title")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -171,11 +170,11 @@ func createMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
Title: title, Title: title,
} }
description, ok := req.GetArguments()["description"].(string) description, ok := args["description"].(string)
if ok { if ok {
opt.Description = description opt.Description = description
} }
opt.Deadline = params.GetOptionalTime(req.GetArguments(), "due_on") opt.Deadline = params.GetOptionalTime(args, "due_on")
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
@@ -189,21 +188,20 @@ func createMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
return to.TextResult(slimMilestone(milestone)) return to.TextResult(slimMilestone(milestone))
} }
func editMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func editMilestoneFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
id, err := params.GetIndex(req.GetArguments(), "id") id, err := params.GetIndex(args, "id")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
args := req.GetArguments()
opt := gitea_sdk.EditMilestoneOption{ opt := gitea_sdk.EditMilestoneOption{
Description: params.GetPresentStringPtr(args, "description"), Description: params.GetPresentStringPtr(args, "description"),
Deadline: params.GetOptionalTime(args, "due_on"), Deadline: params.GetOptionalTime(args, "due_on"),
@@ -228,16 +226,16 @@ func editMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
return to.TextResult(slimMilestone(milestone)) return to.TextResult(slimMilestone(milestone))
} }
func deleteMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func deleteMilestoneFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
id, err := params.GetIndex(req.GetArguments(), "id") id, err := params.GetIndex(args, "id")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
+3 -3
View File
@@ -12,7 +12,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/flag" "gitea.com/gitea/gitea-mcp/pkg/flag"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
func Test_milestoneWriteFn_dueOn(t *testing.T) { func Test_milestoneWriteFn_dueOn(t *testing.T) {
@@ -56,7 +56,7 @@ func Test_milestoneWriteFn_dueOn(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
fn func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) fn func(context.Context, map[string]any) (*mcp.CallToolResult, error)
method string method string
extra map[string]any extra map[string]any
}{ }{
@@ -69,7 +69,7 @@ func Test_milestoneWriteFn_dueOn(t *testing.T) {
a := map[string]any{} a := map[string]any{}
maps.Copy(a, args) maps.Copy(a, args)
maps.Copy(a, tc.extra) maps.Copy(a, tc.extra)
res, err := tc.fn(context.Background(), mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: a}}) res, err := tc.fn(context.Background(), a)
if err != nil || res.IsError { if err != nil || res.IsError {
t.Fatalf("%s err=%v result=%v", tc.name, err, res) t.Fatalf("%s err=%v result=%v", tc.name, err, res)
} }
+36 -41
View File
@@ -12,8 +12,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "gitea.dev/sdk" gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mark3labs/mcp-go/server"
) )
var Tool = tool.New("notification") var Tool = tool.New("notification")
@@ -24,79 +23,76 @@ const (
) )
var ( var (
NotificationReadTool = mcp.NewTool( NotificationReadTool = tool.NewDefinition(
NotificationReadToolName, NotificationReadToolName,
mcp.WithDescription("Read notifications: list (optionally scoped to a repo) or get a thread by ID."), "Read notifications: list (optionally scoped to a repo) or get a thread by ID.",
mcp.WithToolAnnotation(annotation.ReadOnly("Read notifications")), annotation.ReadOnly("Read notifications"),
mcp.WithString("method", mcp.Required(), mcp.Enum("list", "get")), tool.String("method", tool.Required(), tool.Enum("list", "get")),
mcp.WithString("owner", mcp.Description("scope 'list' to a repo")), tool.String("owner", tool.Description("scope 'list' to a repo")),
mcp.WithString("repo", mcp.Description("scope 'list' to a repo")), tool.String("repo", tool.Description("scope 'list' to a repo")),
mcp.WithNumber("id", mcp.Description("thread ID (for 'get')")), tool.Number("id", tool.Description("thread ID (for 'get')")),
mcp.WithString("status", mcp.Enum("unread", "read", "pinned")), tool.String("status", tool.Enum("unread", "read", "pinned")),
mcp.WithString("subject_type", mcp.Enum("Issue", "Pull", "Commit", "Repository")), tool.String("subject_type", tool.Enum("Issue", "Pull", "Commit", "Repository")),
mcp.WithString("since", mcp.Description("updated after ISO 8601")), tool.String("since", tool.Description("updated after ISO 8601")),
mcp.WithString("before", mcp.Description("updated before ISO 8601")), tool.String("before", tool.Description("updated before ISO 8601")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)), tool.Number("page", tool.Description(params.PageDesc), tool.Default(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)), tool.Number("per_page", tool.Description(params.PaginationDesc), tool.Default(30)),
) )
NotificationWriteTool = mcp.NewTool( NotificationWriteTool = tool.NewDefinition(
NotificationWriteToolName, NotificationWriteToolName,
mcp.WithDescription("Mark a notification or all notifications as read."), "Mark a notification or all notifications as read.",
mcp.WithToolAnnotation(annotation.Write("Manage notifications")), annotation.Write("Manage notifications"),
mcp.WithString("method", mcp.Required(), mcp.Enum("mark_read", "mark_all_read")), tool.String("method", tool.Required(), tool.Enum("mark_read", "mark_all_read")),
mcp.WithNumber("id", mcp.Description("thread ID (for 'mark_read')")), tool.Number("id", tool.Description("thread ID (for 'mark_read')")),
mcp.WithString("owner", mcp.Description("scope 'mark_all_read' to a repo")), tool.String("owner", tool.Description("scope 'mark_all_read' to a repo")),
mcp.WithString("repo", mcp.Description("scope 'mark_all_read' to a repo")), tool.String("repo", tool.Description("scope 'mark_all_read' to a repo")),
mcp.WithString("last_read_at", mcp.Description("ISO 8601; defaults to now")), tool.String("last_read_at", tool.Description("ISO 8601; defaults to now")),
) )
) )
func init() { func init() {
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(tool.ServerTool{
Tool: NotificationReadTool, Tool: NotificationReadTool,
Handler: notificationReadFn, Handler: notificationReadFn,
}) })
Tool.RegisterWrite(server.ServerTool{ Tool.RegisterWrite(tool.ServerTool{
Tool: NotificationWriteTool, Tool: NotificationWriteTool,
Handler: notificationWriteFn, Handler: notificationWriteFn,
}) })
} }
func notificationReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func notificationReadFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
method, err := params.GetString(args, "method") method, err := params.GetString(args, "method")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
switch method { switch method {
case "list": case "list":
return listNotificationsFn(ctx, req) return listNotificationsFn(ctx, args)
case "get": case "get":
return getNotificationFn(ctx, req) return getNotificationFn(ctx, args)
default: default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
} }
func notificationWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func notificationWriteFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
method, err := params.GetString(args, "method") method, err := params.GetString(args, "method")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
switch method { switch method {
case "mark_read": case "mark_read":
return markNotificationReadFn(ctx, req) return markNotificationReadFn(ctx, args)
case "mark_all_read": case "mark_all_read":
return markAllNotificationsReadFn(ctx, req) return markAllNotificationsReadFn(ctx, args)
default: default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
} }
func listNotificationsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listNotificationsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
page, pageSize := params.GetPagination(args, 30) page, pageSize := params.GetPagination(args, 30)
opt := gitea_sdk.ListNotificationOptions{ opt := gitea_sdk.ListNotificationOptions{
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
@@ -139,8 +135,8 @@ func listNotificationsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Cal
return to.TextResult(slimThreads(threads)) return to.TextResult(slimThreads(threads))
} }
func getNotificationFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getNotificationFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
id, err := params.GetIndex(req.GetArguments(), "id") id, err := params.GetIndex(args, "id")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -155,8 +151,8 @@ func getNotificationFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
return to.TextResult(slimThread(thread)) return to.TextResult(slimThread(thread))
} }
func markNotificationReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func markNotificationReadFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
id, err := params.GetIndex(req.GetArguments(), "id") id, err := params.GetIndex(args, "id")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -174,8 +170,7 @@ func markNotificationReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.
return to.TextResult("Notification marked as read") return to.TextResult("Notification marked as read")
} }
func markAllNotificationsReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func markAllNotificationsReadFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
lastReadAt := time.Now() lastReadAt := time.Now()
if t := params.GetOptionalTime(args, "last_read_at"); t != nil { if t := params.GetOptionalTime(args, "last_read_at"); t != nil {
lastReadAt = *t lastReadAt = *t
+82 -24
View File
@@ -4,9 +4,11 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"strconv"
"strings" "strings"
"syscall" "syscall"
"time" "time"
@@ -29,11 +31,18 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
"github.com/mark3labs/mcp-go/server" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
// maxRequestBodyBytes raises the SDK's 4 MiB default, which is too tight for the
// base64 file content create_or_update_file accepts.
const maxRequestBodyBytes = 32 << 20
// httpReadHeaderTimeout bounds slow header reads without limiting SSE writes.
const httpReadHeaderTimeout = 10 * time.Second
var ( var (
mcpServer *server.MCPServer mcpServer *mcp.Server
domainTools = []*tool.Tool{ domainTools = []*tool.Tool{
user.Tool, actions.Tool, repo.Tool, notification.Tool, issue.Tool, user.Tool, actions.Tool, repo.Tool, notification.Tool, issue.Tool,
@@ -43,9 +52,11 @@ var (
} }
) )
func RegisterTool(s *server.MCPServer) { func RegisterTool(s *mcp.Server) {
for _, t := range domainTools { for _, t := range domainTools {
s.AddTools(t.Tools()...) for _, registeredTool := range t.Tools() {
s.AddTool(registeredTool.Tool, registeredTool.MCPHandler())
}
} }
tool.WarnUnmatchedAllowedTools(domainTools...) tool.WarnUnmatchedAllowedTools(domainTools...)
tool.WarnUnmatchedAllowedScopes(domainTools...) tool.WarnUnmatchedAllowedScopes(domainTools...)
@@ -71,8 +82,7 @@ func parseAuthToken(authHeader string) (string, bool) {
return "", false return "", false
} }
func getContextWithToken(ctx context.Context, r *http.Request) context.Context { func getContextWithToken(ctx context.Context, authHeader string) context.Context {
authHeader := r.Header.Get("Authorization")
if authHeader == "" { if authHeader == "" {
return ctx return ctx
} }
@@ -85,24 +95,63 @@ func getContextWithToken(ctx context.Context, r *http.Request) context.Context {
return context.WithValue(ctx, mcpContext.TokenContextKey, token) return context.WithValue(ctx, mcpContext.TokenContextKey, token)
} }
func authTokenMiddleware(next mcp.MethodHandler) mcp.MethodHandler {
return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
if extra := req.GetExtra(); extra != nil {
ctx = getContextWithToken(ctx, extra.Header.Get("Authorization"))
}
return next(ctx, method, req)
}
}
func protectMCPOrigin(next http.Handler) http.Handler {
protection := http.NewCrossOriginProtection()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check exempts safe methods, but MCP requires Origin validation on every request.
checkRequest := r
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
checkRequest = r.Clone(r.Context())
checkRequest.Method = http.MethodPost
}
if err := protection.Check(checkRequest); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func newHTTPServer(addr string, s *mcp.Server) *http.Server {
mux := http.NewServeMux()
mux.Handle("/mcp", protectMCPOrigin(mcp.NewStreamableHTTPHandler(
func(*http.Request) *mcp.Server { return s },
&mcp.StreamableHTTPOptions{
Logger: log.Slog(),
MaxRequestBodyBytes: maxRequestBodyBytes,
Stateless: true,
PropagateRequestCancellation: true,
},
)))
return &http.Server{
Addr: addr,
Handler: mux,
ReadHeaderTimeout: httpReadHeaderTimeout,
}
}
func Run() error { func Run() error {
mcpServer = newMCPServer(flag.Version) mcpServer = newMCPServer(flag.Version)
RegisterTool(mcpServer) RegisterTool(mcpServer)
switch flag.Mode { switch flag.Mode {
case "stdio": case "stdio":
if err := server.ServeStdio( if err := mcpServer.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
mcpServer,
); err != nil {
return err return err
} }
case "http": case "http":
httpServer := server.NewStreamableHTTPServer( addr := net.JoinHostPort(flag.Bind, strconv.Itoa(flag.Port))
mcpServer, httpServer := newHTTPServer(addr, mcpServer)
server.WithStreamableHTTPLogger(log.Slog()), log.Infof("Gitea MCP HTTP server listening on %s (stateless, protocol up to 2026-07-28)", addr)
server.WithHeartbeatInterval(30*time.Second),
server.WithHTTPContextFunc(getContextWithToken),
)
log.Infof("Gitea MCP HTTP server listening on :%d", flag.Port)
// Graceful shutdown setup // Graceful shutdown setup
sigCh := make(chan os.Signal, 1) sigCh := make(chan os.Signal, 1)
@@ -120,7 +169,7 @@ func Run() error {
close(shutdownDone) close(shutdownDone)
}() }()
if err := httpServer.Start(fmt.Sprintf(":%d", flag.Port)); err != nil && !errors.Is(err, http.ErrServerClosed) { if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err return err
} }
<-shutdownDone // Wait for shutdown to finish <-shutdownDone // Wait for shutdown to finish
@@ -130,12 +179,21 @@ func Run() error {
return nil return nil
} }
func newMCPServer(version string) *server.MCPServer { func newMCPServer(version string) *mcp.Server {
return server.NewMCPServer( // SDK keepalives send MCP ping requests and disconnect clients without a
"Gitea MCP Server", // server-to-client channel, so KeepAlive stays disabled.
version, s := mcp.NewServer(
server.WithToolCapabilities(true), &mcp.Implementation{
server.WithLogging(), Name: "Gitea MCP Server",
server.WithRecovery(), Version: version,
},
&mcp.ServerOptions{
Logger: log.Slog(),
Capabilities: &mcp.ServerCapabilities{
Tools: &mcp.ToolCapabilities{},
},
},
) )
s.AddReceivingMiddleware(authTokenMiddleware)
return s
} }
+12 -46
View File
@@ -1,54 +1,20 @@
package operation package operation
import ( import "testing"
"testing"
"gitea.com/gitea/gitea-mcp/pkg/flag" func TestNewHTTPServerConfig(t *testing.T) {
) server := newHTTPServer(":12345", newMCPServer("test"))
if server.Addr != ":12345" {
// TestAllToolsHaveDescriptions ensures every registered tool sets a non-empty t.Errorf("Addr = %q, want %q", server.Addr, ":12345")
// Tool.Description. mcp-go only serializes the "description" field of a tool
// when it is non-empty, so an omitted description makes strict MCP clients
// (e.g. mcp-probe) reject the tools/list response with "missing field
// `description`".
func TestAllToolsHaveDescriptions(t *testing.T) {
origRO, origAllow := flag.ReadOnly, flag.AllowedTools
t.Cleanup(func() {
flag.ReadOnly, flag.AllowedTools = origRO, origAllow
})
flag.ReadOnly = false
flag.AllowedTools = nil
var missing []string
for _, d := range domainTools {
for _, st := range d.Tools() {
if st.Tool.Description == "" {
missing = append(missing, st.Tool.Name)
}
}
} }
if len(missing) > 0 { if server.Handler == nil {
t.Errorf("tools missing a description: %v", missing) t.Error("Handler is nil")
} }
} if server.ReadHeaderTimeout != httpReadHeaderTimeout {
t.Errorf("ReadHeaderTimeout = %v, want %v", server.ReadHeaderTimeout, httpReadHeaderTimeout)
// TestDomainToolsScopesAreUniqueAndNonEmpty ensures every entry registered in }
// domainTools has a canonical, non-empty scope name and that no two domains if server.WriteTimeout != 0 {
// share the same scope (each domain.Tools() call is filtered by exactly one t.Errorf("WriteTimeout = %v, want zero for SSE", server.WriteTimeout)
// scope name via flag.AllowedScopes).
func TestDomainToolsScopesAreUniqueAndNonEmpty(t *testing.T) {
seen := map[string]struct{}{}
for _, d := range domainTools {
scope := d.Scope()
if scope == "" {
t.Errorf("domainTools contains a domain with an empty scope")
continue
}
if _, ok := seen[scope]; ok {
t.Errorf("domainTools contains a duplicate scope %q", scope)
continue
}
seen[scope] = struct{}{}
} }
} }
+32 -39
View File
@@ -13,8 +13,7 @@ import (
"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"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mark3labs/mcp-go/server"
) )
var Tool = tool.New("packages") var Tool = tool.New("packages")
@@ -25,70 +24,68 @@ const (
) )
var ( var (
PackageReadTool = mcp.NewTool( PackageReadTool = tool.NewDefinition(
PackageReadToolName, PackageReadToolName,
mcp.WithToolAnnotation(annotation.ReadOnly("Read package registry")), "Read package registry: list packages (one entry per version, filter via 'q'/'type'), list versions, or get a version.",
mcp.WithDescription("Read package registry: list packages (one entry per version, filter via 'q'/'type'), list versions, or get a version."), annotation.ReadOnly("Read package registry"),
mcp.WithString("method", mcp.Required(), mcp.Enum("list", "list_versions", "get")), tool.String("method", tool.Required(), tool.Enum("list", "list_versions", "get")),
mcp.WithString("owner", mcp.Required(), mcp.Description("user or org")), tool.String("owner", tool.Required(), tool.Description("user or org")),
mcp.WithString("type", mcp.Description("container/npm/maven/pypi/cargo/generic; required except 'list'")), tool.String("type", tool.Description("container/npm/maven/pypi/cargo/generic; required except 'list'")),
mcp.WithString("name", mcp.Description("slashes auto-encoded; required except 'list'")), tool.String("name", tool.Description("slashes auto-encoded; required except 'list'")),
mcp.WithString("version", mcp.Description("for 'get'")), tool.String("version", tool.Description("for 'get'")),
mcp.WithString("q", mcp.Description("search query")), tool.String("q", tool.Description("search query")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)), tool.Number("page", tool.Description(params.PageDesc), tool.Default(1), tool.Minimum(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30), mcp.Min(1)), tool.Number("per_page", tool.Description(params.PaginationDesc), tool.Default(30), tool.Minimum(1)),
) )
PackageWriteTool = mcp.NewTool( PackageWriteTool = tool.NewDefinition(
PackageWriteToolName, PackageWriteToolName,
mcp.WithToolAnnotation(annotation.Destructive("Delete a package version")), "Delete a package version (irreversible).",
mcp.WithDescription("Delete a package version (irreversible)."), annotation.Destructive("Delete a package version"),
mcp.WithString("method", mcp.Required(), mcp.Enum("delete")), tool.String("method", tool.Required(), tool.Enum("delete")),
mcp.WithString("owner", mcp.Required(), mcp.Description("user or org")), tool.String("owner", tool.Required(), tool.Description("user or org")),
mcp.WithString("type", mcp.Required(), mcp.Description("container/npm/maven/pypi/cargo/generic")), tool.String("type", tool.Required(), tool.Description("container/npm/maven/pypi/cargo/generic")),
mcp.WithString("name", mcp.Required(), mcp.Description("slashes auto-encoded")), tool.String("name", tool.Required(), tool.Description("slashes auto-encoded")),
mcp.WithString("version", mcp.Required()), tool.String("version", tool.Required()),
) )
) )
func init() { func init() {
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(tool.ServerTool{
Tool: PackageReadTool, Tool: PackageReadTool,
Handler: packageReadFn, Handler: packageReadFn,
}) })
Tool.RegisterWrite(server.ServerTool{ Tool.RegisterWrite(tool.ServerTool{
Tool: PackageWriteTool, Tool: PackageWriteTool,
Handler: packageWriteFn, Handler: packageWriteFn,
}) })
} }
func packageReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func packageReadFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
method, err := params.GetString(args, "method") method, err := params.GetString(args, "method")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
switch method { switch method {
case "list": case "list":
return listPackagesFn(ctx, req) return listPackagesFn(ctx, args)
case "list_versions": case "list_versions":
return listPackageVersionsFn(ctx, req) return listPackageVersionsFn(ctx, args)
case "get": case "get":
return getPackageFn(ctx, req) return getPackageFn(ctx, args)
default: default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
} }
func packageWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func packageWriteFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
method, err := params.GetString(args, "method") method, err := params.GetString(args, "method")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
switch method { switch method {
case "delete": case "delete":
return deletePackageVersionFn(ctx, req) return deletePackageVersionFn(ctx, args)
default: default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
@@ -108,8 +105,7 @@ func escapePackageName(name string) string {
return url.PathEscape(name) return url.PathEscape(name)
} }
func listPackagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listPackagesFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -135,8 +131,7 @@ func listPackagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
return to.TextResult(slimPackages(result)) return to.TextResult(slimPackages(result))
} }
func listPackageVersionsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listPackageVersionsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -164,8 +159,7 @@ func listPackageVersionsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.C
return to.TextResult(slimPackages(result)) return to.TextResult(slimPackages(result))
} }
func getPackageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getPackageFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -192,8 +186,7 @@ func getPackageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRe
return to.TextResult(slimPackage(result)) return to.TextResult(slimPackage(result))
} }
func deletePackageVersionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func deletePackageVersionFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
+30 -43
View File
@@ -11,7 +11,7 @@ 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"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
func TestPackageReadList(t *testing.T) { func TestPackageReadList(t *testing.T) {
@@ -37,13 +37,12 @@ func TestPackageReadList(t *testing.T) {
ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "test-token") ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "test-token")
t.Run("basic list", func(t *testing.T) { t.Run("basic list", func(t *testing.T) {
req := mcp.CallToolRequest{} args := map[string]any{
req.Params.Arguments = map[string]any{
"method": "list", "method": "list",
"owner": "test-org", "owner": "test-org",
} }
result, err := packageReadFn(ctx, req) result, err := packageReadFn(ctx, args)
if err != nil { if err != nil {
t.Fatalf("packageReadFn() error: %v", err) t.Fatalf("packageReadFn() error: %v", err)
} }
@@ -51,7 +50,7 @@ func TestPackageReadList(t *testing.T) {
t.Fatal("packageReadFn() returned error result") t.Fatal("packageReadFn() returned error result")
} }
text := result.Content[0].(mcp.TextContent).Text text := result.Content[0].(*mcp.TextContent).Text
var packages []map[string]any var packages []map[string]any
if err := json.Unmarshal([]byte(text), &packages); err != nil { if err := json.Unmarshal([]byte(text), &packages); err != nil {
t.Fatalf("failed to unmarshal result: %v", err) t.Fatalf("failed to unmarshal result: %v", err)
@@ -68,15 +67,14 @@ func TestPackageReadList(t *testing.T) {
}) })
t.Run("with type and query filters", func(t *testing.T) { t.Run("with type and query filters", func(t *testing.T) {
req := mcp.CallToolRequest{} args := map[string]any{
req.Params.Arguments = map[string]any{
"method": "list", "method": "list",
"owner": "test-org", "owner": "test-org",
"type": "container", "type": "container",
"q": "myimage", "q": "myimage",
} }
_, err := packageReadFn(ctx, req) _, err := packageReadFn(ctx, args)
if err != nil { if err != nil {
t.Fatalf("packageReadFn() error: %v", err) t.Fatalf("packageReadFn() error: %v", err)
} }
@@ -92,15 +90,14 @@ func TestPackageReadList(t *testing.T) {
}) })
t.Run("with pagination", func(t *testing.T) { t.Run("with pagination", func(t *testing.T) {
req := mcp.CallToolRequest{} args := map[string]any{
req.Params.Arguments = map[string]any{
"method": "list", "method": "list",
"owner": "test-org", "owner": "test-org",
"page": float64(2), "page": float64(2),
"per_page": float64(10), "per_page": float64(10),
} }
_, err := packageReadFn(ctx, req) _, err := packageReadFn(ctx, args)
if err != nil { if err != nil {
t.Fatalf("packageReadFn() error: %v", err) t.Fatalf("packageReadFn() error: %v", err)
} }
@@ -148,15 +145,14 @@ func TestPackageReadListVersions(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.testName, func(t *testing.T) { t.Run(tt.testName, func(t *testing.T) {
req := mcp.CallToolRequest{} args := map[string]any{
req.Params.Arguments = map[string]any{
"method": "list_versions", "method": "list_versions",
"owner": "test-org", "owner": "test-org",
"type": "container", "type": "container",
"name": tt.name, "name": tt.name,
} }
result, err := packageReadFn(ctx, req) result, err := packageReadFn(ctx, args)
if err != nil { if err != nil {
t.Fatalf("packageReadFn() error: %v", err) t.Fatalf("packageReadFn() error: %v", err)
} }
@@ -171,7 +167,7 @@ func TestPackageReadListVersions(t *testing.T) {
} }
mu.Unlock() mu.Unlock()
text := result.Content[0].(mcp.TextContent).Text text := result.Content[0].(*mcp.TextContent).Text
var versions []map[string]any var versions []map[string]any
if err := json.Unmarshal([]byte(text), &versions); err != nil { if err := json.Unmarshal([]byte(text), &versions); err != nil {
t.Fatalf("failed to unmarshal result: %v", err) t.Fatalf("failed to unmarshal result: %v", err)
@@ -215,8 +211,7 @@ func TestPackageReadGet(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.testName, func(t *testing.T) { t.Run(tt.testName, func(t *testing.T) {
req := mcp.CallToolRequest{} args := map[string]any{
req.Params.Arguments = map[string]any{
"method": "get", "method": "get",
"owner": "test-org", "owner": "test-org",
"type": "container", "type": "container",
@@ -224,7 +219,7 @@ func TestPackageReadGet(t *testing.T) {
"version": "v1.0.0", "version": "v1.0.0",
} }
result, err := packageReadFn(ctx, req) result, err := packageReadFn(ctx, args)
if err != nil { if err != nil {
t.Fatalf("packageReadFn() error: %v", err) t.Fatalf("packageReadFn() error: %v", err)
} }
@@ -239,7 +234,7 @@ func TestPackageReadGet(t *testing.T) {
} }
mu.Unlock() mu.Unlock()
text := result.Content[0].(mcp.TextContent).Text text := result.Content[0].(*mcp.TextContent).Text
var pkg map[string]any var pkg map[string]any
if err := json.Unmarshal([]byte(text), &pkg); err != nil { if err := json.Unmarshal([]byte(text), &pkg); err != nil {
t.Fatalf("failed to unmarshal result: %v", err) t.Fatalf("failed to unmarshal result: %v", err)
@@ -277,8 +272,7 @@ func TestPackageWriteDelete(t *testing.T) {
ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "test-token") ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "test-token")
req := mcp.CallToolRequest{} args := map[string]any{
req.Params.Arguments = map[string]any{
"method": "delete", "method": "delete",
"owner": "test-org", "owner": "test-org",
"type": "container", "type": "container",
@@ -286,7 +280,7 @@ func TestPackageWriteDelete(t *testing.T) {
"version": "v1.0.0", "version": "v1.0.0",
} }
result, err := packageWriteFn(ctx, req) result, err := packageWriteFn(ctx, args)
if err != nil { if err != nil {
t.Fatalf("packageWriteFn() error: %v", err) t.Fatalf("packageWriteFn() error: %v", err)
} }
@@ -305,27 +299,20 @@ func TestPackageWriteDelete(t *testing.T) {
} }
} }
func TestPackageReadUnknownMethod(t *testing.T) { func TestPackageUnknownMethod(t *testing.T) {
ctx := context.Background() for name, fn := range map[string]func(context.Context, map[string]any) (*mcp.CallToolResult, error){
req := mcp.CallToolRequest{} "packageReadFn": packageReadFn,
req.Params.Arguments = map[string]any{ "packageWriteFn": packageWriteFn,
"method": "bogus", } {
"owner": "test-org", t.Run(name, func(t *testing.T) {
} result, err := fn(context.Background(), map[string]any{"method": "bogus", "owner": "test-org"})
if _, err := packageReadFn(ctx, req); err == nil { if err != nil {
t.Fatal("expected error for unknown method") t.Fatalf("%s() error = %v", name, err)
} }
} if result == nil || !result.IsError {
t.Fatalf("%s() result = %#v, want an error result", name, result)
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")
} }
} }
+239 -147
View File
@@ -15,8 +15,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "gitea.dev/sdk" gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mark3labs/mcp-go/server"
) )
var Tool = tool.New("pull_request") var Tool = tool.New("pull_request")
@@ -29,78 +28,79 @@ const (
) )
var ( var (
ListRepoPullRequestsTool = mcp.NewTool( ListRepoPullRequestsTool = tool.NewDefinition(
ListRepoPullRequestsToolName, ListRepoPullRequestsToolName,
mcp.WithDescription("List pull requests in a repository, filterable by state and milestone, with configurable sort order (e.g. recently updated, most commented)."), "List pull requests in a repository, filterable by state and milestone, with configurable sort order (e.g. recently updated, most commented).",
mcp.WithToolAnnotation(annotation.ReadOnly("List pull requests")), annotation.ReadOnly("List pull requests"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithString("state", mcp.Enum("open", "closed", "all"), mcp.DefaultString("all")), tool.String("state", tool.Enum("open", "closed", "all"), tool.Default("all")),
mcp.WithString("sort", mcp.Enum("oldest", "recentupdate", "leastupdate", "mostcomment", "leastcomment", "priority"), mcp.DefaultString("recentupdate")), tool.String("sort", tool.Enum("oldest", "recentupdate", "leastupdate", "mostcomment", "leastcomment", "priority"), tool.Default("recentupdate")),
mcp.WithNumber("milestone"), tool.Number("milestone"),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)), tool.Number("page", tool.Description(params.PageDesc), tool.Default(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)), tool.Number("per_page", tool.Description(params.PaginationDesc), tool.Default(30)),
) )
PullRequestReadTool = mcp.NewTool( PullRequestReadTool = tool.NewDefinition(
PullRequestReadToolName, PullRequestReadToolName,
mcp.WithDescription("Read pull request: details, diff, changed files, head commit status, reviews."), "Read pull request: details, diff, changed files, head commit status, reviews, review comments.",
mcp.WithToolAnnotation(annotation.ReadOnly("Read pull request details")), annotation.ReadOnly("Read pull request details"),
mcp.WithString("method", mcp.Required(), mcp.Enum("get", "get_diff", "get_files", "get_status", "get_reviews", "get_review", "get_review_comments")), tool.String("method", tool.Required(), tool.Enum("get", "get_diff", "get_files", "get_status", "get_reviews", "get_review", "get_review_comments")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithNumber("pull_number", mcp.Required()), tool.Number("pull_number", tool.Required()),
mcp.WithNumber("review_id", mcp.Description("for 'get_review'/'get_review_comments'")), tool.Number("review_id", tool.Description("for 'get_review'; optional for 'get_review_comments', omit to list all")),
mcp.WithBoolean("binary", mcp.Description("include binary diff")), tool.Boolean("binary", tool.Description("include binary diff")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)), tool.Number("page", tool.Description(params.PageDesc), tool.Default(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)), tool.Number("per_page", tool.Description(params.PaginationDesc), tool.Default(30)),
) )
PullRequestWriteTool = mcp.NewTool( PullRequestWriteTool = tool.NewDefinition(
PullRequestWriteToolName, PullRequestWriteToolName,
mcp.WithDescription("Write pull requests: create, update, close, reopen, merge, update branch from base, manage reviewers."), "Write pull requests: create, update, close, reopen, merge, update branch from base, manage reviewers.",
mcp.WithToolAnnotation(annotation.Write("Create, update, close, reopen, or merge pull requests")), annotation.Write("Create, update, close, reopen, or merge pull requests"),
mcp.WithString("method", mcp.Required(), mcp.Enum("create", "update", "close", "reopen", "merge", "update_branch", "add_reviewers", "remove_reviewers")), tool.String("method", tool.Required(), tool.Enum("create", "update", "close", "reopen", "merge", "update_branch", "add_reviewers", "remove_reviewers")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithNumber("pull_number", mcp.Description("required except for 'create'")), tool.Number("pull_number", tool.Description("required except for 'create'")),
mcp.WithString("title", mcp.Description("required for 'create'; optional for 'update'/'merge'")), tool.String("title", tool.Description("required for 'create'; optional for 'update'/'merge'")),
mcp.WithString("body", mcp.Description("required for 'create'; optional for 'update'")), tool.String("body", tool.Description("required for 'create'; optional for 'update'")),
mcp.WithString("head", mcp.Description("head branch (required for 'create')")), tool.String("head", tool.Description("head branch (required for 'create')")),
mcp.WithString("base", mcp.Description("base branch (required for 'create')")), tool.String("base", tool.Description("base branch (required for 'create')")),
mcp.WithString("assignee", mcp.Description("for 'update'")), tool.String("assignee", tool.Description("for 'update'")),
mcp.WithArray("assignees", mcp.Description("for 'update'"), mcp.Items(map[string]any{"type": "string"})), tool.Array("assignees", tool.Description("for 'update'"), tool.Items(map[string]any{"type": "string"})),
mcp.WithNumber("milestone", mcp.Description("for 'update'")), tool.Number("milestone", tool.Description("for 'update'")),
mcp.WithString("state", mcp.Description("for 'update'"), mcp.Enum("open", "closed")), tool.String("state", tool.Description("for 'update'"), tool.Enum("open", "closed")),
mcp.WithBoolean("allow_maintainer_edit", mcp.Description("for 'update'")), tool.Boolean("allow_maintainer_edit", tool.Description("for 'update'")),
mcp.WithArray("labels", mcp.Description("label IDs"), mcp.Items(map[string]any{"type": "number"})), tool.Array("labels", tool.Description("label IDs"), tool.Items(map[string]any{"type": "number"})),
mcp.WithString("deadline", mcp.Description("ISO 8601")), tool.String("deadline", tool.Description("ISO 8601")),
mcp.WithBoolean("remove_deadline", mcp.Description("for 'update'")), tool.Boolean("remove_deadline", tool.Description("for 'update'")),
mcp.WithString("merge_style", mcp.Description("for 'merge'"), mcp.Enum("merge", "rebase", "rebase-merge", "squash", "fast-forward-only"), mcp.DefaultString("merge")), tool.String("merge_style", tool.Description("for 'merge'"), tool.Enum("merge", "rebase", "rebase-merge", "squash", "fast-forward-only"), tool.Default("merge")),
mcp.WithString("message", mcp.Description("merge commit message or dismissal reason")), tool.String("message", tool.Description("merge commit message or dismissal reason")),
mcp.WithBoolean("delete_branch", mcp.Description("for 'merge'")), tool.Boolean("delete_branch", tool.Description("for 'merge'")),
mcp.WithBoolean("force_merge", mcp.Description("merge even if checks fail")), tool.Boolean("force_merge", tool.Description("merge even if checks fail")),
mcp.WithBoolean("merge_when_checks_succeed", mcp.Description("for 'merge'")), tool.Boolean("merge_when_checks_succeed", tool.Description("for 'merge'")),
mcp.WithString("head_commit_id", mcp.Description("expected head SHA for conflict detection")), tool.String("head_commit_id", tool.Description("expected head SHA for conflict detection")),
mcp.WithArray("reviewers", mcp.Description("for 'add_reviewers'/'remove_reviewers'"), mcp.Items(map[string]any{"type": "string"})), tool.Array("reviewers", tool.Description("for 'add_reviewers'/'remove_reviewers'"), tool.Items(map[string]any{"type": "string"})),
mcp.WithArray("team_reviewers", mcp.Description("for 'add_reviewers'/'remove_reviewers'"), mcp.Items(map[string]any{"type": "string"})), tool.Array("team_reviewers", tool.Description("for 'add_reviewers'/'remove_reviewers'"), tool.Items(map[string]any{"type": "string"})),
mcp.WithBoolean("draft", mcp.Description("uses 'WIP: ' title prefix")), tool.Boolean("draft", tool.Description("uses 'WIP: ' title prefix")),
) )
PullRequestReviewWriteTool = mcp.NewTool( PullRequestReviewWriteTool = tool.NewDefinition(
PullRequestReviewWriteToolName, PullRequestReviewWriteToolName,
mcp.WithDescription("Write PR reviews: create, submit, delete, dismiss."), "Write PR reviews: create, submit, delete, dismiss, reply to and resolve review comments.",
mcp.WithToolAnnotation(annotation.Write("Submit a pull request review")), annotation.Write("Write pull request reviews"),
mcp.WithString("method", mcp.Required(), mcp.Enum("create", "submit", "delete", "dismiss")), tool.String("method", tool.Required(), tool.Enum("create", "submit", "delete", "dismiss", "reply_comment", "resolve_thread", "unresolve_thread")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithNumber("pull_number", mcp.Required()), tool.Number("pull_number", tool.Description("required except for 'resolve_thread'/'unresolve_thread'")),
mcp.WithNumber("review_id", mcp.Description("required except for 'create'")), tool.Number("review_id", tool.Description("for 'submit'/'delete'/'dismiss'")),
mcp.WithString("state", mcp.Enum("APPROVED", "REQUEST_CHANGES", "COMMENT", "PENDING")), tool.Number("comment_id", tool.Description("comment ID from 'get_review_comments'; resolve takes the thread's first")),
mcp.WithString("body"), tool.String("state", tool.Enum("APPROVED", "REQUEST_CHANGES", "COMMENT", "PENDING")),
mcp.WithString("commit_id", mcp.Description("for 'create'")), tool.String("body", tool.Description("review body, or reply text for 'reply_comment'")),
mcp.WithString("message", mcp.Description("dismissal reason")), tool.String("commit_id", tool.Description("for 'create'")),
mcp.WithArray("comments", mcp.Description("inline comments (for 'create')"), mcp.Items(map[string]any{ tool.String("message", tool.Description("dismissal reason")),
tool.Array("comments", tool.Description("inline comments (for 'create')"), tool.Items(map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"path": map[string]any{"type": "string"}, "path": map[string]any{"type": "string"},
@@ -113,86 +113,86 @@ var (
) )
func init() { func init() {
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(tool.ServerTool{
Tool: ListRepoPullRequestsTool, Tool: ListRepoPullRequestsTool,
Handler: listRepoPullRequestsFn, Handler: listRepoPullRequestsFn,
}) })
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(tool.ServerTool{
Tool: PullRequestReadTool, Tool: PullRequestReadTool,
Handler: pullRequestReadFn, Handler: pullRequestReadFn,
}) })
Tool.RegisterWrite(server.ServerTool{ Tool.RegisterWrite(tool.ServerTool{
Tool: PullRequestWriteTool, Tool: PullRequestWriteTool,
Handler: pullRequestWriteFn, Handler: pullRequestWriteFn,
}) })
Tool.RegisterWrite(server.ServerTool{ Tool.RegisterWrite(tool.ServerTool{
Tool: PullRequestReviewWriteTool, Tool: PullRequestReviewWriteTool,
Handler: pullRequestReviewWriteFn, Handler: pullRequestReviewWriteFn,
}) })
} }
func pullRequestReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func pullRequestReadFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method") method, err := params.GetString(args, "method")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
switch method { switch method {
case "get": case "get":
return getPullRequestByIndexFn(ctx, req) return getPullRequestByIndexFn(ctx, args)
case "get_diff": case "get_diff":
return getPullRequestDiffFn(ctx, req) return getPullRequestDiffFn(ctx, args)
case "get_files": case "get_files":
return getPullRequestFilesFn(ctx, req) return getPullRequestFilesFn(ctx, args)
case "get_status": case "get_status":
return getPullRequestStatusFn(ctx, req) return getPullRequestStatusFn(ctx, args)
case "get_reviews": case "get_reviews":
return listPullRequestReviewsFn(ctx, req) return listPullRequestReviewsFn(ctx, args)
case "get_review": case "get_review":
return getPullRequestReviewFn(ctx, req) return getPullRequestReviewFn(ctx, args)
case "get_review_comments": case "get_review_comments":
return listPullRequestReviewCommentsFn(ctx, req) return listPullRequestReviewCommentsFn(ctx, args)
default: default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
} }
func pullRequestWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func pullRequestWriteFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method") method, err := params.GetString(args, "method")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
switch method { switch method {
case "create": case "create":
return createPullRequestFn(ctx, req) return createPullRequestFn(ctx, args)
case "update": case "update":
return editPullRequestFn(ctx, req) return editPullRequestFn(ctx, args)
case "close": case "close":
return closePullRequestFn(ctx, req) return closePullRequestFn(ctx, args)
case "reopen": case "reopen":
return reopenPullRequestFn(ctx, req) return reopenPullRequestFn(ctx, args)
case "merge": case "merge":
return mergePullRequestFn(ctx, req) return mergePullRequestFn(ctx, args)
case "update_branch": case "update_branch":
return updatePullRequestBranchFn(ctx, req) return updatePullRequestBranchFn(ctx, args)
case "add_reviewers": case "add_reviewers":
return createPullRequestReviewerFn(ctx, req) return createPullRequestReviewerFn(ctx, args)
case "remove_reviewers": case "remove_reviewers":
return deletePullRequestReviewerFn(ctx, req) return deletePullRequestReviewerFn(ctx, args)
default: default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
} }
func closePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func closePullRequestFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "pull_number") index, err := params.GetIndex(args, "pull_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -213,16 +213,16 @@ func closePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
return to.TextResult(slimPullRequest(pr)) return to.TextResult(slimPullRequest(pr))
} }
func reopenPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func reopenPullRequestFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "pull_number") index, err := params.GetIndex(args, "pull_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -243,27 +243,32 @@ func reopenPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Cal
return to.TextResult(slimPullRequest(pr)) return to.TextResult(slimPullRequest(pr))
} }
func pullRequestReviewWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func pullRequestReviewWriteFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method") method, err := params.GetString(args, "method")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
switch method { switch method {
case "create": case "create":
return createPullRequestReviewFn(ctx, req) return createPullRequestReviewFn(ctx, args)
case "submit": case "submit":
return submitPullRequestReviewFn(ctx, req) return submitPullRequestReviewFn(ctx, args)
case "delete": case "delete":
return deletePullRequestReviewFn(ctx, req) return deletePullRequestReviewFn(ctx, args)
case "dismiss": case "dismiss":
return dismissPullRequestReviewFn(ctx, req) return dismissPullRequestReviewFn(ctx, args)
case "reply_comment":
return replyPullRequestReviewCommentFn(ctx, args)
case "resolve_thread":
return resolveReviewThreadFn(ctx, args)
case "unresolve_thread":
return unresolveReviewThreadFn(ctx, args)
default: default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
} }
func getPullRequestByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getPullRequestByIndexFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -298,8 +303,7 @@ func getPullRequestByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp
return to.TextResult(m) return to.TextResult(m)
} }
func getPullRequestDiffFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getPullRequestDiffFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -328,8 +332,7 @@ func getPullRequestDiffFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
return to.TextResult(string(diffBytes)) return to.TextResult(string(diffBytes))
} }
func listRepoPullRequestsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listRepoPullRequestsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -385,8 +388,7 @@ func applyDraftPrefix(title string, isDraft bool) string {
return title return title
} }
func createPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func createPullRequestFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -440,8 +442,7 @@ func createPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Cal
type reviewerOp func(client *gitea_sdk.PullRequestsService, ctx context.Context, owner, repo string, index int64, opt gitea_sdk.PullReviewRequestOptions) (*gitea_sdk.Response, error) type reviewerOp func(client *gitea_sdk.PullRequestsService, ctx context.Context, owner, repo string, index int64, opt gitea_sdk.PullReviewRequestOptions) (*gitea_sdk.Response, error)
func pullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest, verb string, op reviewerOp) (*mcp.CallToolResult, error) { func pullRequestReviewerFn(ctx context.Context, args map[string]any, verb string, op reviewerOp) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -479,16 +480,15 @@ func pullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest, verb st
}) })
} }
func createPullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func createPullRequestReviewerFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
return pullRequestReviewerFn(ctx, req, "create", (*gitea_sdk.PullRequestsService).CreateReviewRequests) return pullRequestReviewerFn(ctx, args, "create", (*gitea_sdk.PullRequestsService).CreateReviewRequests)
} }
func deletePullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func deletePullRequestReviewerFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
return pullRequestReviewerFn(ctx, req, "delete", (*gitea_sdk.PullRequestsService).DeleteReviewRequests) return pullRequestReviewerFn(ctx, args, "delete", (*gitea_sdk.PullRequestsService).DeleteReviewRequests)
} }
func listPullRequestReviewsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listPullRequestReviewsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -521,8 +521,7 @@ func listPullRequestReviewsFn(ctx context.Context, req mcp.CallToolRequest) (*mc
return to.TextResult(slimReviews(reviews)) return to.TextResult(slimReviews(reviews))
} }
func getPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getPullRequestReviewFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -553,8 +552,7 @@ func getPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.
return to.TextResult(slimReview(review)) return to.TextResult(slimReview(review))
} }
func listPullRequestReviewCommentsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listPullRequestReviewCommentsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -567,26 +565,45 @@ func listPullRequestReviewCommentsFn(ctx context.Context, req mcp.CallToolReques
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
reviewID, err := params.GetIndex(args, "review_id")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
comments, _, err := client.PullRequests.ListPullReviewComments(ctx, owner, repo, index, reviewID) // review comments hang off reviews, so without a review_id walk a page of
if err != nil { // reviews, keeping each thread and its replies together
return to.ErrorResult(fmt.Errorf("list review comments for review %v on %v/%v/pr/%v err: %v", reviewID, owner, repo, index, err)) var reviewIDs []int64
if reviewID := params.GetOptionalInt(args, "review_id", 0); reviewID != 0 {
reviewIDs = append(reviewIDs, reviewID)
} else {
page, pageSize := params.GetPagination(args, 30)
reviews, _, err := client.PullRequests.ListPullReviews(ctx, owner, repo, index, gitea_sdk.ListPullReviewsOptions{
ListOptions: gitea_sdk.ListOptions{Page: page, PageSize: pageSize},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list reviews for %v/%v/pr/%v err: %v", owner, repo, index, err))
}
reviewIDs = make([]int64, 0, len(reviews))
for _, review := range reviews {
if review.CodeCommentsCount > 0 {
reviewIDs = append(reviewIDs, review.ID)
}
}
}
var comments []*gitea_sdk.PullReviewComment
for _, reviewID := range reviewIDs {
reviewComments, _, err := client.PullRequests.ListPullReviewComments(ctx, owner, repo, index, reviewID)
if err != nil {
return to.ErrorResult(fmt.Errorf("list review comments for review %v on %v/%v/pr/%v err: %v", reviewID, owner, repo, index, err))
}
comments = append(comments, reviewComments...)
} }
return to.TextResult(slimReviewComments(comments)) return to.TextResult(slimReviewComments(comments))
} }
func createPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func createPullRequestReviewFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -649,8 +666,7 @@ func createPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*m
return to.TextResult(slimReview(review)) return to.TextResult(slimReview(review))
} }
func submitPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func submitPullRequestReviewFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -692,8 +708,7 @@ func submitPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*m
return to.TextResult(slimReview(review)) return to.TextResult(slimReview(review))
} }
func deletePullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func deletePullRequestReviewFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -731,8 +746,7 @@ func deletePullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*m
return to.TextResult(successMsg) return to.TextResult(successMsg)
} }
func dismissPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func dismissPullRequestReviewFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -775,8 +789,90 @@ func dismissPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*
return to.TextResult(successMsg) return to.TextResult(successMsg)
} }
func mergePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func replyPullRequestReviewCommentFn(ctx context.Context, args map[string]any) (*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)
}
commentID, err := params.GetIndex(args, "comment_id")
if err != nil {
return to.ErrorResult(err)
}
body, err := params.GetString(args, "body")
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))
}
comment, _, err := client.PullRequests.CreatePullReviewCommentReply(ctx, owner, repo, index, commentID, gitea_sdk.CreatePullReviewCommentReplyOptions{
Body: body,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("reply to review comment %v on %v/%v/pr/%v err: %v", commentID, owner, repo, index, err))
}
return to.TextResult(slimReviewComment(comment))
}
func resolveReviewThreadFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
return setReviewThreadResolvedFn(ctx, args, true)
}
func unresolveReviewThreadFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
return setReviewThreadResolvedFn(ctx, args, false)
}
func setReviewThreadResolvedFn(ctx context.Context, args map[string]any, resolved bool) (*mcp.CallToolResult, error) {
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)
}
commentID, err := params.GetIndex(args, "comment_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))
}
if resolved {
_, err = client.PullRequests.ResolvePullReviewComment(ctx, owner, repo, commentID)
} else {
_, err = client.PullRequests.UnresolvePullReviewComment(ctx, owner, repo, commentID)
}
if err != nil {
return to.ErrorResult(fmt.Errorf("set resolved=%v on review comment %v in %v/%v err: %v", resolved, commentID, owner, repo, err))
}
successMsg := map[string]any{
"message": "Successfully updated review thread",
"comment_id": commentID,
"resolved": resolved,
"repository": fmt.Sprintf("%s/%s", owner, repo),
}
return to.TextResult(successMsg)
}
func mergePullRequestFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -839,8 +935,7 @@ func mergePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
return to.TextResult(successMsg) return to.TextResult(successMsg)
} }
func editPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func editPullRequestFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -914,8 +1009,7 @@ 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) { func updatePullRequestBranchFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -936,8 +1030,7 @@ func updatePullRequestBranchFn(ctx context.Context, req mcp.CallToolRequest) (*m
return to.TextResult(map[string]any{"message": "branch updated from base"}) return to.TextResult(map[string]any{"message": "branch updated from base"})
} }
func getPullRequestFilesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getPullRequestFilesFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -964,8 +1057,7 @@ func getPullRequestFilesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.C
return to.TextResult(files) return to.TextResult(files)
} }
func getPullRequestStatusFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getPullRequestStatusFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
+207 -111
View File
@@ -12,7 +12,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/flag" "gitea.com/gitea/gitea-mcp/pkg/flag"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
func Test_editPullRequestFn(t *testing.T) { func Test_editPullRequestFn(t *testing.T) {
@@ -77,19 +77,15 @@ func Test_editPullRequestFn(t *testing.T) {
flag.Version = origVersion flag.Version = origVersion
}() }()
req := mcp.CallToolRequest{ args := map[string]any{
Params: mcp.CallToolParams{ "owner": owner,
Arguments: map[string]any{ "repo": repo,
"owner": owner, "pull_number": ii.val,
"repo": repo, "title": "WIP: my feature",
"pull_number": ii.val, "state": "open",
"title": "WIP: my feature",
"state": "open",
},
},
} }
result, err := editPullRequestFn(context.Background(), req) result, err := editPullRequestFn(context.Background(), args)
if err != nil { if err != nil {
t.Fatalf("editPullRequestFn() error = %v", err) t.Fatalf("editPullRequestFn() error = %v", err)
} }
@@ -113,7 +109,7 @@ func Test_editPullRequestFn(t *testing.T) {
if len(result.Content) == 0 { if len(result.Content) == 0 {
t.Fatalf("expected content in result") t.Fatalf("expected content in result")
} }
textContent, ok := mcp.AsTextContent(result.Content[0]) textContent, ok := result.Content[0].(*mcp.TextContent)
if !ok { if !ok {
t.Fatalf("expected text content, got %T", result.Content[0]) t.Fatalf("expected text content, got %T", result.Content[0])
} }
@@ -193,21 +189,17 @@ func Test_mergePullRequestFn(t *testing.T) {
flag.Version = origVersion flag.Version = origVersion
}() }()
req := mcp.CallToolRequest{ args := map[string]any{
Params: mcp.CallToolParams{ "owner": owner,
Arguments: map[string]any{ "repo": repo,
"owner": owner, "pull_number": ii.val,
"repo": repo, "merge_style": "squash",
"pull_number": ii.val, "title": "feat: my squashed commit",
"merge_style": "squash", "message": "Squash merge of PR #5",
"title": "feat: my squashed commit", "delete_branch": true,
"message": "Squash merge of PR #5",
"delete_branch": true,
},
},
} }
result, err := mergePullRequestFn(context.Background(), req) result, err := mergePullRequestFn(context.Background(), args)
if err != nil { if err != nil {
t.Fatalf("mergePullRequestFn() error = %v", err) t.Fatalf("mergePullRequestFn() error = %v", err)
} }
@@ -237,7 +229,7 @@ func Test_mergePullRequestFn(t *testing.T) {
if len(result.Content) == 0 { if len(result.Content) == 0 {
t.Fatalf("expected content in result") t.Fatalf("expected content in result")
} }
textContent, ok := mcp.AsTextContent(result.Content[0]) textContent, ok := result.Content[0].(*mcp.TextContent)
if !ok { if !ok {
t.Fatalf("expected text content, got %T", result.Content[0]) t.Fatalf("expected text content, got %T", result.Content[0])
} }
@@ -306,21 +298,17 @@ func Test_mergePullRequestFn_newParams(t *testing.T) {
flag.Version = origVersion flag.Version = origVersion
}() }()
req := mcp.CallToolRequest{ args := map[string]any{
Params: mcp.CallToolParams{ "owner": owner,
Arguments: map[string]any{ "repo": repo,
"owner": owner, "pull_number": float64(index),
"repo": repo, "merge_style": "merge",
"pull_number": float64(index), "force_merge": true,
"merge_style": "merge", "merge_when_checks_succeed": true,
"force_merge": true, "head_commit_id": "abc123",
"merge_when_checks_succeed": true,
"head_commit_id": "abc123",
},
},
} }
_, err := mergePullRequestFn(context.Background(), req) _, err := mergePullRequestFn(context.Background(), args)
if err != nil { if err != nil {
t.Fatalf("mergePullRequestFn() error = %v", err) t.Fatalf("mergePullRequestFn() error = %v", err)
} }
@@ -386,22 +374,18 @@ func Test_createPullRequestFn_labels(t *testing.T) {
flag.Version = origVersion flag.Version = origVersion
}() }()
req := mcp.CallToolRequest{ args := map[string]any{
Params: mcp.CallToolParams{ "owner": owner,
Arguments: map[string]any{ "repo": repo,
"owner": owner, "title": "test",
"repo": repo, "body": "body",
"title": "test", "head": "feature",
"body": "body", "base": "main",
"head": "feature", "labels": []any{float64(1), float64(2)},
"base": "main", "deadline": "2026-06-01T00:00:00Z",
"labels": []any{float64(1), float64(2)},
"deadline": "2026-06-01T00:00:00Z",
},
},
} }
_, err := createPullRequestFn(context.Background(), req) _, err := createPullRequestFn(context.Background(), args)
if err != nil { if err != nil {
t.Fatalf("createPullRequestFn() error = %v", err) t.Fatalf("createPullRequestFn() error = %v", err)
} }
@@ -525,13 +509,7 @@ func Test_createPullRequestFn_draft(t *testing.T) {
args["draft"] = tc.draft args["draft"] = tc.draft
} }
req := mcp.CallToolRequest{ _, err := createPullRequestFn(context.Background(), args)
Params: mcp.CallToolParams{
Arguments: args,
},
}
_, err := createPullRequestFn(context.Background(), req)
if err != nil { if err != nil {
t.Fatalf("createPullRequestFn() error = %v", err) t.Fatalf("createPullRequestFn() error = %v", err)
} }
@@ -630,13 +608,7 @@ func Test_editPullRequestFn_draft(t *testing.T) {
args["draft"] = tc.draft args["draft"] = tc.draft
} }
req := mcp.CallToolRequest{ _, err := editPullRequestFn(context.Background(), args)
Params: mcp.CallToolParams{
Arguments: args,
},
}
_, err := editPullRequestFn(context.Background(), req)
if err != nil { if err != nil {
t.Fatalf("editPullRequestFn() error = %v", err) t.Fatalf("editPullRequestFn() error = %v", err)
} }
@@ -720,18 +692,14 @@ func Test_getPullRequestDiffFn(t *testing.T) {
flag.Version = origVersion flag.Version = origVersion
}() }()
req := mcp.CallToolRequest{ args := map[string]any{
Params: mcp.CallToolParams{ "owner": owner,
Arguments: map[string]any{ "repo": repo,
"owner": owner, "pull_number": ii.val,
"repo": repo, "binary": true,
"pull_number": ii.val,
"binary": true,
},
},
} }
result, err := getPullRequestDiffFn(context.Background(), req) result, err := getPullRequestDiffFn(context.Background(), args)
if err != nil { if err != nil {
t.Fatalf("getPullRequestDiffFn() error = %v", err) t.Fatalf("getPullRequestDiffFn() error = %v", err)
} }
@@ -758,7 +726,7 @@ func Test_getPullRequestDiffFn(t *testing.T) {
t.Fatalf("expected content in result") t.Fatalf("expected content in result")
} }
textContent, ok := mcp.AsTextContent(result.Content[0]) textContent, ok := result.Content[0].(*mcp.TextContent)
if !ok { if !ok {
t.Fatalf("expected text content, got %T", result.Content[0]) t.Fatalf("expected text content, got %T", result.Content[0])
} }
@@ -807,17 +775,17 @@ func Test_getPullRequestByIndexFn_includesAttachments(t *testing.T) {
flag.Host, flag.Token, flag.Version = server.URL, "", "test" flag.Host, flag.Token, flag.Version = server.URL, "", "test"
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }() defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{ args := map[string]any{
"owner": owner, "repo": repo, "pull_number": float64(index), "owner": owner, "repo": repo, "pull_number": float64(index),
}}} }
res, err := getPullRequestByIndexFn(context.Background(), req) res, err := getPullRequestByIndexFn(context.Background(), args)
if err != nil { if err != nil {
t.Fatalf("getPullRequestByIndexFn() error = %v", err) t.Fatalf("getPullRequestByIndexFn() error = %v", err)
} }
if res.IsError { if res.IsError {
t.Fatalf("unexpected error result: %v", res.Content) t.Fatalf("unexpected error result: %v", res.Content)
} }
body := res.Content[0].(mcp.TextContent).Text body := res.Content[0].(*mcp.TextContent).Text
if !strings.Contains(body, `[shot.png](https://example/shot.png)`) { if !strings.Contains(body, `[shot.png](https://example/shot.png)`) {
t.Fatalf("expected attachment markdown inlined in body, got: %s", body) t.Fatalf("expected attachment markdown inlined in body, got: %s", body)
} }
@@ -855,14 +823,14 @@ func Test_getPullRequestByIndexFn_emptyAssetsLeavesBody(t *testing.T) {
flag.Host, flag.Token, flag.Version = server.URL, "", "test" flag.Host, flag.Token, flag.Version = server.URL, "", "test"
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }() defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{ args := map[string]any{
"owner": owner, "repo": repo, "pull_number": float64(index), "owner": owner, "repo": repo, "pull_number": float64(index),
}}} }
res, err := getPullRequestByIndexFn(context.Background(), req) res, err := getPullRequestByIndexFn(context.Background(), args)
if err != nil { if err != nil {
t.Fatalf("getPullRequestByIndexFn() error = %v", err) t.Fatalf("getPullRequestByIndexFn() error = %v", err)
} }
body := res.Content[0].(mcp.TextContent).Text body := res.Content[0].(*mcp.TextContent).Text
if !strings.Contains(body, `"body":"plain body"`) { if !strings.Contains(body, `"body":"plain body"`) {
t.Fatalf("expected body unchanged when assets are empty, got: %s", body) t.Fatalf("expected body unchanged when assets are empty, got: %s", body)
} }
@@ -899,17 +867,17 @@ func Test_getPullRequestByIndexFn_assetsFailureNonFatal(t *testing.T) {
flag.Host, flag.Token, flag.Version = server.URL, "", "test" flag.Host, flag.Token, flag.Version = server.URL, "", "test"
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }() defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{ args := map[string]any{
"owner": owner, "repo": repo, "pull_number": float64(index), "owner": owner, "repo": repo, "pull_number": float64(index),
}}} }
res, err := getPullRequestByIndexFn(context.Background(), req) res, err := getPullRequestByIndexFn(context.Background(), args)
if err != nil { if err != nil {
t.Fatalf("getPullRequestByIndexFn() error = %v", err) t.Fatalf("getPullRequestByIndexFn() error = %v", err)
} }
if res.IsError { if res.IsError {
t.Fatalf("assets fetch failure should not fail the PR fetch: %v", res.Content) t.Fatalf("assets fetch failure should not fail the PR fetch: %v", res.Content)
} }
body := res.Content[0].(mcp.TextContent).Text body := res.Content[0].(*mcp.TextContent).Text
if !strings.Contains(body, `"plain body"`) { if !strings.Contains(body, `"plain body"`) {
t.Fatalf("expected PR body preserved when assets fail, got: %s", body) t.Fatalf("expected PR body preserved when assets fail, got: %s", body)
} }
@@ -954,18 +922,14 @@ func Test_closePullRequestFn(t *testing.T) {
flag.Token = "test-token" flag.Token = "test-token"
t.Cleanup(func() { flag.Host = origHost; flag.Token = origToken }) t.Cleanup(func() { flag.Host = origHost; flag.Token = origToken })
req := mcp.CallToolRequest{ args := map[string]any{
Params: mcp.CallToolParams{ "method": "close",
Arguments: map[string]any{ "owner": owner,
"method": "close", "repo": repo,
"owner": owner, "pull_number": float64(index),
"repo": repo,
"pull_number": float64(index),
},
},
} }
result, err := closePullRequestFn(context.Background(), req) result, err := closePullRequestFn(context.Background(), args)
if err != nil { if err != nil {
t.Fatalf("closePullRequestFn() error = %v", err) t.Fatalf("closePullRequestFn() error = %v", err)
} }
@@ -1018,18 +982,14 @@ func Test_reopenPullRequestFn(t *testing.T) {
flag.Token = "test-token" flag.Token = "test-token"
t.Cleanup(func() { flag.Host = origHost; flag.Token = origToken }) t.Cleanup(func() { flag.Host = origHost; flag.Token = origToken })
req := mcp.CallToolRequest{ args := map[string]any{
Params: mcp.CallToolParams{ "method": "reopen",
Arguments: map[string]any{ "owner": owner,
"method": "reopen", "repo": repo,
"owner": owner, "pull_number": float64(index),
"repo": repo,
"pull_number": float64(index),
},
},
} }
result, err := reopenPullRequestFn(context.Background(), req) result, err := reopenPullRequestFn(context.Background(), args)
if err != nil { if err != nil {
t.Fatalf("reopenPullRequestFn() error = %v", err) t.Fatalf("reopenPullRequestFn() error = %v", err)
} }
@@ -1042,3 +1002,139 @@ func Test_reopenPullRequestFn(t *testing.T) {
t.Fatalf("expected content in result") t.Fatalf("expected content in result")
} }
} }
// serveStub points the client at a test server that answers the SDK version
// probe, leaving every other route to handler.
func serveStub(t *testing.T, handler http.HandlerFunc) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/version" {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.27.0"}`))
return
}
handler(w, r)
}))
t.Cleanup(server.Close)
origHost, origToken := flag.Host, flag.Token
flag.Host, flag.Token = server.URL, "test-token"
t.Cleanup(func() { flag.Host, flag.Token = origHost, origToken })
}
func Test_pullRequestReviewWriteFn_comments(t *testing.T) {
const (
owner = "octo"
repo = "demo"
index = 7
commentID = 42
)
for _, tc := range []struct {
method string
path string
wantBody string
}{
{"reply_comment", fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/comments/%d/replies", owner, repo, index, commentID), "sure"},
{"resolve_thread", fmt.Sprintf("/api/v1/repos/%s/%s/pulls/comments/%d/resolve", owner, repo, commentID), ""},
{"unresolve_thread", fmt.Sprintf("/api/v1/repos/%s/%s/pulls/comments/%d/unresolve", owner, repo, commentID), ""},
} {
t.Run(tc.method, func(t *testing.T) {
var gotPath, gotBody string
serveStub(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != tc.path {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
if r.Method != http.MethodPost {
t.Errorf("expected POST method, got %s", r.Method)
}
gotPath = r.URL.Path
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
gotBody, _ = body["body"].(string)
if tc.wantBody == "" {
w.WriteHeader(http.StatusNoContent)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":43,"body":"sure","path":"main.go","position":3}`))
})
args := map[string]any{
"method": tc.method,
"owner": owner,
"repo": repo,
"pull_number": float64(index),
"comment_id": float64(commentID),
"body": "sure",
}
result, err := pullRequestReviewWriteFn(context.Background(), args)
if err != nil {
t.Fatalf("pullRequestReviewWriteFn() error = %v", err)
}
if gotPath != tc.path {
t.Errorf("expected request to %s, got %q", tc.path, gotPath)
}
// resolve and unresolve send no body, reply sends the reply text
if gotBody != tc.wantBody {
t.Errorf("expected body %q, got %q", tc.wantBody, gotBody)
}
if len(result.Content) == 0 {
t.Fatalf("expected content in result")
}
})
}
}
func Test_listPullRequestReviewCommentsFn_allReviews(t *testing.T) {
const (
owner = "octo"
repo = "demo"
index = 7
)
var gotReviewPaths []string
serveStub(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews", owner, repo, index):
// the middle review has no review comments and must not be fetched
_, _ = w.Write([]byte(`[{"id":1,"comments_count":1},{"id":2,"comments_count":0},{"id":3,"comments_count":2}]`))
case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews/1/comments", owner, repo, index),
fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews/3/comments", owner, repo, index):
gotReviewPaths = append(gotReviewPaths, r.URL.Path)
_, _ = w.Write([]byte(`[{"id":11,"body":"nit","path":"main.go","position":3}]`))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
})
args := map[string]any{
"method": "get_review_comments",
"owner": owner,
"repo": repo,
"pull_number": float64(index),
}
result, err := pullRequestReadFn(context.Background(), args)
if err != nil {
t.Fatalf("pullRequestReadFn() error = %v", err)
}
if len(gotReviewPaths) != 2 {
t.Errorf("expected comments of 2 reviews to be fetched, got %v", gotReviewPaths)
}
if len(result.Content) == 0 {
t.Fatalf("expected content in result")
}
}
+7 -1
View File
@@ -137,8 +137,9 @@ func slimReviewComment(c *gitea_sdk.PullReviewComment) map[string]any {
if c == nil { if c == nil {
return nil return nil
} }
return map[string]any{ m := map[string]any{
"id": c.ID, "id": c.ID,
"review_id": c.ReviewID,
"body": c.Body, "body": c.Body,
"path": c.Path, "path": c.Path,
"position": c.LineNum, "position": c.LineNum,
@@ -149,6 +150,11 @@ func slimReviewComment(c *gitea_sdk.PullReviewComment) map[string]any {
"created_at": c.Created, "created_at": c.Created,
"updated_at": c.Updated, "updated_at": c.Updated,
} }
// the thread's first comment carries the resolver
if c.Resolver != nil {
m["resolved_by"] = slim.UserLogin(c.Resolver)
}
return m
} }
func slimReviewComments(comments []*gitea_sdk.PullReviewComment) []map[string]any { func slimReviewComments(comments []*gitea_sdk.PullReviewComment) []map[string]any {
+27 -31
View File
@@ -11,8 +11,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "gitea.dev/sdk" gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mark3labs/mcp-go/server"
) )
// BranchTool holds the branch-related tools (scope "branch"). // BranchTool holds the branch-related tools (scope "branch").
@@ -25,53 +24,52 @@ const (
) )
var ( var (
CreateBranchTool = mcp.NewTool( CreateBranchTool = tool.NewDefinition(
CreateBranchToolName, CreateBranchToolName,
mcp.WithDescription("Create a new branch in a repository, optionally from a specific source branch (defaults to the repository's default branch)."), "Create a new branch in a repository, optionally from a specific source branch (defaults to the repository's default branch).",
mcp.WithToolAnnotation(annotation.Write("Create a new branch")), annotation.Write("Create a new branch"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithString("branch", mcp.Required()), tool.String("branch", tool.Required()),
mcp.WithString("old_branch", mcp.Description("source branch (default: repo default)")), tool.String("old_branch", tool.Description("source branch (default: repo default)")),
) )
DeleteBranchTool = mcp.NewTool( DeleteBranchTool = tool.NewDefinition(
DeleteBranchToolName, DeleteBranchToolName,
mcp.WithDescription("Permanently delete a branch from a repository. This action is destructive and cannot be undone."), "Permanently delete a branch from a repository. This action is destructive and cannot be undone.",
mcp.WithToolAnnotation(annotation.Destructive("Delete a branch")), annotation.Destructive("Delete a branch"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithString("branch", mcp.Required()), tool.String("branch", tool.Required()),
) )
ListBranchesTool = mcp.NewTool( ListBranchesTool = tool.NewDefinition(
ListBranchesToolName, ListBranchesToolName,
mcp.WithDescription("List all branches in a repository, paginated."), "List all branches in a repository, paginated.",
mcp.WithToolAnnotation(annotation.ReadOnly("List repository branches")), annotation.ReadOnly("List repository branches"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)), tool.Number("page", tool.Description(params.PageDesc), tool.Default(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)), tool.Number("per_page", tool.Description(params.PaginationDesc), tool.Default(30)),
) )
) )
func init() { func init() {
BranchTool.RegisterWrite(server.ServerTool{ BranchTool.RegisterWrite(tool.ServerTool{
Tool: CreateBranchTool, Tool: CreateBranchTool,
Handler: CreateBranchFn, Handler: CreateBranchFn,
}) })
BranchTool.RegisterWrite(server.ServerTool{ BranchTool.RegisterWrite(tool.ServerTool{
Tool: DeleteBranchTool, Tool: DeleteBranchTool,
Handler: DeleteBranchFn, Handler: DeleteBranchFn,
}) })
BranchTool.RegisterRead(server.ServerTool{ BranchTool.RegisterRead(tool.ServerTool{
Tool: ListBranchesTool, Tool: ListBranchesTool,
Handler: ListBranchesFn, Handler: ListBranchesFn,
}) })
} }
func CreateBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func CreateBranchFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -101,8 +99,7 @@ func CreateBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
return to.TextResult("Branch Created") return to.TextResult("Branch Created")
} }
func DeleteBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func DeleteBranchFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -127,8 +124,7 @@ func DeleteBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
return to.TextResult("Branch Deleted") return to.TextResult("Branch Deleted")
} }
func ListBranchesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListBranchesFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
+20 -23
View File
@@ -11,8 +11,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "gitea.dev/sdk" gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mark3labs/mcp-go/server"
) )
// CommitTool holds the commit-related tools (scope "commit"). // CommitTool holds the commit-related tools (scope "commit").
@@ -24,41 +23,40 @@ const (
) )
var ( var (
ListRepoCommitsTool = mcp.NewTool( ListRepoCommitsTool = tool.NewDefinition(
ListRepoCommitsToolName, ListRepoCommitsToolName,
mcp.WithDescription("List commits in a repository, optionally starting from a specific branch or SHA and filtered to commits touching a given file path."), "List commits in a repository, optionally starting from a specific branch or SHA and filtered to commits touching a given file path.",
mcp.WithToolAnnotation(annotation.ReadOnly("List repository commits")), annotation.ReadOnly("List repository commits"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithString("sha", mcp.Description("starting SHA or branch")), tool.String("sha", tool.Description("starting SHA or branch")),
mcp.WithString("path", mcp.Description("only commits touching this path")), tool.String("path", tool.Description("only commits touching this path")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)), tool.Number("page", tool.Description(params.PageDesc), tool.Default(1), tool.Minimum(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30), mcp.Min(1)), tool.Number("per_page", tool.Description(params.PaginationDesc), tool.Default(30), tool.Minimum(1)),
) )
GetCommitTool = mcp.NewTool( GetCommitTool = tool.NewDefinition(
GetCommitToolName, GetCommitToolName,
mcp.WithDescription("Get details for a single commit in a repository by its SHA."), "Get details for a single commit in a repository by its SHA.",
mcp.WithToolAnnotation(annotation.ReadOnly("Get commit details")), annotation.ReadOnly("Get commit details"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithString("sha", mcp.Required()), tool.String("sha", tool.Required()),
) )
) )
func init() { func init() {
CommitTool.RegisterRead(server.ServerTool{ CommitTool.RegisterRead(tool.ServerTool{
Tool: ListRepoCommitsTool, Tool: ListRepoCommitsTool,
Handler: ListRepoCommitsFn, Handler: ListRepoCommitsFn,
}) })
CommitTool.RegisterRead(server.ServerTool{ CommitTool.RegisterRead(tool.ServerTool{
Tool: GetCommitTool, Tool: GetCommitTool,
Handler: GetCommitFn, Handler: GetCommitFn,
}) })
} }
func ListRepoCommitsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListRepoCommitsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -89,8 +87,7 @@ 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) { func GetCommitFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
+60 -65
View File
@@ -3,6 +3,7 @@ package repo
import ( import (
"bufio" "bufio"
"bytes" "bytes"
"cmp"
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
@@ -15,8 +16,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "gitea.dev/sdk" gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mark3labs/mcp-go/server"
) )
// FileTool holds the file-related tools (scope "file"). // FileTool holds the file-related tools (scope "file").
@@ -30,68 +30,68 @@ const (
) )
var ( var (
GetFileContentTool = mcp.NewTool( GetFileContentTool = tool.NewDefinition(
GetFileToolName, GetFileToolName,
mcp.WithDescription("Get file content and metadata"), "Get file content and metadata",
mcp.WithToolAnnotation(annotation.ReadOnly("Get file content")), annotation.ReadOnly("Get file content"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithString("ref", mcp.Required(), mcp.Description("branch, tag, or commit SHA")), tool.String("ref", tool.Required(), tool.Description("branch, tag, or commit SHA")),
mcp.WithString("path", mcp.Required()), tool.String("path", tool.Required()),
mcp.WithBoolean("withLines", mcp.Description("return numbered lines")), tool.Boolean("withLines", tool.Description("return numbered lines")),
) )
GetDirContentTool = mcp.NewTool( GetDirContentTool = tool.NewDefinition(
GetDirToolName, GetDirToolName,
mcp.WithDescription("List the entries (files and subdirectories) in a repository directory at a given ref (branch, tag, or commit SHA)."), "List the entries (files and subdirectories) in a repository directory at a given ref (branch, tag, or commit SHA).",
mcp.WithToolAnnotation(annotation.ReadOnly("Get directory contents")), annotation.ReadOnly("Get directory contents"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithString("ref", mcp.Required(), mcp.Description("branch, tag, or commit SHA")), tool.String("ref", tool.Required(), tool.Description("branch, tag, or commit SHA")),
mcp.WithString("path", mcp.Required()), tool.String("path", tool.Required()),
) )
CreateOrUpdateFileTool = mcp.NewTool( CreateOrUpdateFileTool = tool.NewDefinition(
CreateOrUpdateFileToolName, CreateOrUpdateFileToolName,
mcp.WithDescription("Create or update a file (provide sha to update an existing file)."), "Create or update a file (provide sha to update an existing file).",
mcp.WithToolAnnotation(annotation.Write("Create or update a file")), annotation.Write("Create or update a file"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithString("path", mcp.Required()), tool.String("path", tool.Required()),
mcp.WithString("content", mcp.Required()), tool.String("content", tool.Required()),
mcp.WithString("message", mcp.Required(), mcp.Description("commit message")), tool.String("message", tool.Required(), tool.Description("commit message")),
mcp.WithString("branch_name", mcp.Required()), tool.String("branch_name", tool.Required()),
mcp.WithString("sha", mcp.Description("existing file SHA (omit to create)")), tool.String("sha", tool.Description("existing file SHA (omit to create)")),
mcp.WithString("new_branch_name", mcp.Description("new branch (create only)")), tool.String("new_branch_name", tool.Description("branch to create from branch_name and commit to")),
) )
DeleteFileTool = mcp.NewTool( DeleteFileTool = tool.NewDefinition(
DeleteFileToolName, DeleteFileToolName,
mcp.WithDescription("Delete a file from a repository by committing the removal to a branch. Requires the file's current SHA and a commit message."), "Delete a file from a repository by committing the removal to a branch. Requires the file's current SHA and a commit message.",
mcp.WithToolAnnotation(annotation.Destructive("Delete a file")), annotation.Destructive("Delete a file"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithString("path", mcp.Required()), tool.String("path", tool.Required()),
mcp.WithString("message", mcp.Required(), mcp.Description("commit message")), tool.String("message", tool.Required(), tool.Description("commit message")),
mcp.WithString("branch_name", mcp.Required()), tool.String("branch_name", tool.Required()),
mcp.WithString("sha", mcp.Required()), tool.String("sha", tool.Required()),
) )
) )
func init() { func init() {
FileTool.RegisterRead(server.ServerTool{ FileTool.RegisterRead(tool.ServerTool{
Tool: GetFileContentTool, Tool: GetFileContentTool,
Handler: GetFileContentFn, Handler: GetFileContentFn,
}) })
FileTool.RegisterRead(server.ServerTool{ FileTool.RegisterRead(tool.ServerTool{
Tool: GetDirContentTool, Tool: GetDirContentTool,
Handler: GetDirContentFn, Handler: GetDirContentFn,
}) })
FileTool.RegisterWrite(server.ServerTool{ FileTool.RegisterWrite(tool.ServerTool{
Tool: CreateOrUpdateFileTool, Tool: CreateOrUpdateFileTool,
Handler: CreateOrUpdateFileFn, Handler: CreateOrUpdateFileFn,
}) })
FileTool.RegisterWrite(server.ServerTool{ FileTool.RegisterWrite(tool.ServerTool{
Tool: DeleteFileTool, Tool: DeleteFileTool,
Handler: DeleteFileFn, Handler: DeleteFileFn,
}) })
@@ -102,8 +102,7 @@ type ContentLine struct {
Content string `json:"content"` Content string `json:"content"`
} }
func GetFileContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetFileContentFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -165,8 +164,7 @@ func GetFileContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
return to.TextResult(slimContents(content)) return to.TextResult(slimContents(content))
} }
func GetDirContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetDirContentFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -191,8 +189,7 @@ func GetDirContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
return to.TextResult(slimDirEntries(content)) return to.TextResult(slimDirEntries(content))
} }
func CreateOrUpdateFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func CreateOrUpdateFileFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -208,6 +205,7 @@ func CreateOrUpdateFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
content, _ := args["content"].(string) content, _ := args["content"].(string)
message, _ := args["message"].(string) message, _ := args["message"].(string)
branchName, _ := args["branch_name"].(string) branchName, _ := args["branch_name"].(string)
newBranchName, _ := args["new_branch_name"].(string)
sha, _ := args["sha"].(string) sha, _ := args["sha"].(string)
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -215,43 +213,40 @@ func CreateOrUpdateFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
fileOpt := gitea_sdk.FileOptions{
Message: message,
BranchName: branchName,
NewBranchName: newBranchName,
}
targetBranch := cmp.Or(newBranchName, branchName)
if sha != "" { if sha != "" {
// Update existing file // Update existing file
opt := gitea_sdk.UpdateFileOptions{ opt := gitea_sdk.UpdateFileOptions{
SHA: sha, SHA: sha,
Content: base64.StdEncoding.EncodeToString([]byte(content)), Content: base64.StdEncoding.EncodeToString([]byte(content)),
FileOptions: gitea_sdk.FileOptions{ FileOptions: fileOpt,
Message: message,
BranchName: branchName,
},
} }
_, _, err = client.Repositories.UpdateFile(ctx, owner, repo, filePath, opt) _, _, err = client.Repositories.UpdateFile(ctx, owner, repo, filePath, opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("update file err: %v", err)) return to.ErrorResult(fmt.Errorf("update file err: %v", err))
} }
return to.TextResult("Update file success") return to.TextResult("Update file success on branch " + targetBranch)
} }
// Create new file // Create new file
opt := gitea_sdk.CreateFileOptions{ opt := gitea_sdk.CreateFileOptions{
Content: base64.StdEncoding.EncodeToString([]byte(content)), Content: base64.StdEncoding.EncodeToString([]byte(content)),
FileOptions: gitea_sdk.FileOptions{ FileOptions: fileOpt,
Message: message,
BranchName: branchName,
},
}
if newBranch, ok := args["new_branch_name"].(string); ok && newBranch != "" {
opt.NewBranchName = newBranch
} }
_, _, err = client.Repositories.CreateFile(ctx, owner, repo, filePath, opt) _, _, err = client.Repositories.CreateFile(ctx, owner, repo, filePath, opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("create file err: %v", err)) return to.ErrorResult(fmt.Errorf("create file err: %v", err))
} }
return to.TextResult("Create file success") return to.TextResult("Create file success on branch " + targetBranch)
} }
func DeleteFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func DeleteFileFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
+81
View File
@@ -0,0 +1,81 @@
package repo
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func TestCreateOrUpdateFileFnNewBranch(t *testing.T) {
const (
owner = "octo"
repo = "demo"
filePath = "README.md"
baseBranch = "main"
newBranch = "feature-x"
)
var (
mu sync.Mutex
gotBody map[string]any
)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
mu.Lock()
gotBody = body
mu.Unlock()
_, _ = w.Write([]byte(`{}`))
}))
defer server.Close()
origHost, origToken := flag.Host, flag.Token
flag.Host, flag.Token = server.URL, ""
defer func() { flag.Host, flag.Token = origHost, origToken }()
for _, tc := range []struct {
name string
sha string
wantResult string
}{
{"create", "", "Create file success on branch " + newBranch},
{"update", "blobsha", "Update file success on branch " + newBranch},
} {
t.Run(tc.name, func(t *testing.T) {
result, err := CreateOrUpdateFileFn(context.Background(), map[string]any{
"owner": owner,
"repo": repo,
"path": filePath,
"content": "hello",
"message": "update readme",
"branch_name": baseBranch,
"new_branch_name": newBranch,
"sha": tc.sha,
})
if err != nil {
t.Fatalf("CreateOrUpdateFileFn() error = %v", err)
}
mu.Lock()
defer mu.Unlock()
if gotBody["new_branch"] != newBranch {
t.Fatalf("new_branch = %v, want %s", gotBody["new_branch"], newBranch)
}
if gotBody["branch"] != baseBranch {
t.Fatalf("branch = %v, want %s", gotBody["branch"], baseBranch)
}
if text := result.Content[0].(*mcp.TextContent).Text; !strings.Contains(text, tc.wantResult) {
t.Fatalf("result = %s, want it to contain %q", text, tc.wantResult)
}
})
}
}
+48 -54
View File
@@ -11,8 +11,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "gitea.dev/sdk" gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mark3labs/mcp-go/server"
) )
// ReleaseTool holds the release-related tools (scope "release"). // ReleaseTool holds the release-related tools (scope "release").
@@ -27,84 +26,83 @@ const (
) )
var ( var (
CreateReleaseTool = mcp.NewTool( CreateReleaseTool = tool.NewDefinition(
CreateReleaseToolName, CreateReleaseToolName,
mcp.WithDescription("Create a new release in a repository from a tag, optionally marking it as a draft or pre-release."), "Create a new release in a repository from a tag, optionally marking it as a draft or pre-release.",
mcp.WithToolAnnotation(annotation.Write("Create a release")), annotation.Write("Create a release"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithString("tag_name", mcp.Required()), tool.String("tag_name", tool.Required()),
mcp.WithString("target", mcp.Required(), mcp.Description("commitish")), tool.String("target", tool.Required(), tool.Description("commitish")),
mcp.WithString("title", mcp.Required()), tool.String("title", tool.Required()),
mcp.WithBoolean("is_draft"), tool.Boolean("is_draft"),
mcp.WithBoolean("is_pre_release"), tool.Boolean("is_pre_release"),
mcp.WithString("body"), tool.String("body"),
) )
DeleteReleaseTool = mcp.NewTool( DeleteReleaseTool = tool.NewDefinition(
DeleteReleaseToolName, DeleteReleaseToolName,
mcp.WithDescription("Delete a release from a repository by its numeric ID. This action is destructive and cannot be undone."), "Delete a release from a repository by its numeric ID. This action is destructive and cannot be undone.",
mcp.WithToolAnnotation(annotation.Destructive("Delete a release")), annotation.Destructive("Delete a release"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithNumber("id", mcp.Required()), tool.Number("id", tool.Required()),
) )
GetReleaseTool = mcp.NewTool( GetReleaseTool = tool.NewDefinition(
GetReleaseToolName, GetReleaseToolName,
mcp.WithDescription("Get a release by ID"), "Get a release by ID",
mcp.WithToolAnnotation(annotation.ReadOnly("Get release details")), annotation.ReadOnly("Get release details"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithNumber("id", mcp.Required()), tool.Number("id", tool.Required()),
) )
GetLatestReleaseTool = mcp.NewTool( GetLatestReleaseTool = tool.NewDefinition(
GetLatestReleaseToolName, GetLatestReleaseToolName,
mcp.WithDescription("Get the most recent published (non-draft) release in a repository."), "Get the most recent published (non-draft) release in a repository.",
mcp.WithToolAnnotation(annotation.ReadOnly("Get latest release")), annotation.ReadOnly("Get latest release"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
) )
ListReleasesTool = mcp.NewTool( ListReleasesTool = tool.NewDefinition(
ListReleasesToolName, ListReleasesToolName,
mcp.WithDescription("List releases in a repository, optionally filtered to drafts or pre-releases."), "List releases in a repository, optionally filtered to drafts or pre-releases.",
mcp.WithToolAnnotation(annotation.ReadOnly("List releases")), annotation.ReadOnly("List releases"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithBoolean("is_draft"), tool.Boolean("is_draft"),
mcp.WithBoolean("is_pre_release"), tool.Boolean("is_pre_release"),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)), tool.Number("page", tool.Description(params.PageDesc), tool.Default(1), tool.Minimum(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(20), mcp.Min(1)), tool.Number("per_page", tool.Description(params.PaginationDesc), tool.Default(20), tool.Minimum(1)),
) )
) )
func init() { func init() {
ReleaseTool.RegisterWrite(server.ServerTool{ ReleaseTool.RegisterWrite(tool.ServerTool{
Tool: CreateReleaseTool, Tool: CreateReleaseTool,
Handler: CreateReleaseFn, Handler: CreateReleaseFn,
}) })
ReleaseTool.RegisterWrite(server.ServerTool{ ReleaseTool.RegisterWrite(tool.ServerTool{
Tool: DeleteReleaseTool, Tool: DeleteReleaseTool,
Handler: DeleteReleaseFn, Handler: DeleteReleaseFn,
}) })
ReleaseTool.RegisterRead(server.ServerTool{ ReleaseTool.RegisterRead(tool.ServerTool{
Tool: GetReleaseTool, Tool: GetReleaseTool,
Handler: GetReleaseFn, Handler: GetReleaseFn,
}) })
ReleaseTool.RegisterRead(server.ServerTool{ ReleaseTool.RegisterRead(tool.ServerTool{
Tool: GetLatestReleaseTool, Tool: GetLatestReleaseTool,
Handler: GetLatestReleaseFn, Handler: GetLatestReleaseFn,
}) })
ReleaseTool.RegisterRead(server.ServerTool{ ReleaseTool.RegisterRead(tool.ServerTool{
Tool: ListReleasesTool, Tool: ListReleasesTool,
Handler: ListReleasesFn, Handler: ListReleasesFn,
}) })
} }
func CreateReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func CreateReleaseFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -148,8 +146,7 @@ func CreateReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
return to.TextResult("Release Created") return to.TextResult("Release Created")
} }
func DeleteReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func DeleteReleaseFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -175,8 +172,7 @@ func DeleteReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
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, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -202,8 +198,7 @@ func GetReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRe
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, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -225,8 +220,7 @@ func GetLatestReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
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, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
+46 -49
View File
@@ -12,8 +12,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "gitea.dev/sdk" gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mark3labs/mcp-go/server"
) )
var Tool = tool.New("repository") var Tool = tool.New("repository")
@@ -26,74 +25,73 @@ const (
) )
var ( var (
CreateRepoTool = mcp.NewTool( CreateRepoTool = tool.NewDefinition(
CreateRepoToolName, CreateRepoToolName,
mcp.WithDescription("Create a new Git repository, optionally under an organization (defaults to the authenticated user's account), with options for visibility, template, license, .gitignore, and initial README."), "Create a new Git repository, optionally under an organization (defaults to the authenticated user's account), with options for visibility, template, license, .gitignore, and initial README.",
mcp.WithToolAnnotation(annotation.Write("Create a new repository")), annotation.Write("Create a new repository"),
mcp.WithString("name", mcp.Required()), tool.String("name", tool.Required()),
mcp.WithString("description"), tool.String("description"),
mcp.WithBoolean("private"), tool.Boolean("private"),
mcp.WithString("issue_labels"), tool.String("issue_labels"),
mcp.WithBoolean("auto_init"), tool.Boolean("auto_init"),
mcp.WithBoolean("template"), tool.Boolean("template"),
mcp.WithString("gitignores"), tool.String("gitignores"),
mcp.WithString("license"), tool.String("license"),
mcp.WithString("readme"), tool.String("readme"),
mcp.WithString("default_branch"), tool.String("default_branch"),
mcp.WithString("trust_model", mcp.Enum("default", "collaborator", "committer", "collaboratorcommitter")), tool.String("trust_model", tool.Enum("default", "collaborator", "committer", "collaboratorcommitter")),
mcp.WithString("object_format_name", mcp.Enum("sha1", "sha256")), tool.String("object_format_name", tool.Enum("sha1", "sha256")),
mcp.WithString("organization", mcp.Description("defaults to personal account")), tool.String("organization", tool.Description("defaults to personal account")),
) )
ForkRepoTool = mcp.NewTool( ForkRepoTool = tool.NewDefinition(
ForkRepoToolName, ForkRepoToolName,
mcp.WithDescription("Fork an existing repository into the authenticated user's account or a target organization, optionally under a new name."), "Fork an existing repository into the authenticated user's account or a target organization, optionally under a new name.",
mcp.WithToolAnnotation(annotation.Write("Fork a repository")), annotation.Write("Fork a repository"),
mcp.WithString("user", mcp.Required(), mcp.Description("owner of source repo")), tool.String("user", tool.Required(), tool.Description("owner of source repo")),
mcp.WithString("repo", mcp.Required()), tool.String("repo", tool.Required()),
mcp.WithString("organization", mcp.Description("target org")), tool.String("organization", tool.Description("target org")),
mcp.WithString("name", mcp.Description("fork name")), tool.String("name", tool.Description("fork name")),
) )
ListMyReposTool = mcp.NewTool( ListMyReposTool = tool.NewDefinition(
ListMyReposToolName, ListMyReposToolName,
mcp.WithDescription("List repositories owned by the authenticated user."), "List repositories owned by the authenticated user.",
mcp.WithToolAnnotation(annotation.ReadOnly("List my repositories")), annotation.ReadOnly("List my repositories"),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)), tool.Number("page", tool.Description(params.PageDesc), tool.Default(1), tool.Minimum(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30), mcp.Min(1)), tool.Number("per_page", tool.Description(params.PaginationDesc), tool.Default(30), tool.Minimum(1)),
) )
ListOrgReposTool = mcp.NewTool( ListOrgReposTool = tool.NewDefinition(
ListOrgReposToolName, ListOrgReposToolName,
mcp.WithDescription("List repositories belonging to an organization."), "List repositories belonging to an organization.",
mcp.WithToolAnnotation(annotation.ReadOnly("List organization repositories")), annotation.ReadOnly("List organization repositories"),
mcp.WithString("org", mcp.Required()), tool.String("org", tool.Required()),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)), tool.Number("page", tool.Description(params.PageDesc), tool.Default(1), tool.Minimum(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(100), mcp.Min(1)), tool.Number("per_page", tool.Description(params.PaginationDesc), tool.Default(100), tool.Minimum(1)),
) )
) )
func init() { func init() {
Tool.RegisterWrite(server.ServerTool{ Tool.RegisterWrite(tool.ServerTool{
Tool: CreateRepoTool, Tool: CreateRepoTool,
Handler: CreateRepoFn, Handler: CreateRepoFn,
}) })
Tool.RegisterWrite(server.ServerTool{ Tool.RegisterWrite(tool.ServerTool{
Tool: ForkRepoTool, Tool: ForkRepoTool,
Handler: ForkRepoFn, Handler: ForkRepoFn,
}) })
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(tool.ServerTool{
Tool: ListMyReposTool, Tool: ListMyReposTool,
Handler: ListMyReposFn, Handler: ListMyReposFn,
}) })
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(tool.ServerTool{
Tool: ListOrgReposTool, Tool: ListOrgReposTool,
Handler: ListOrgReposFn, Handler: ListOrgReposFn,
}) })
} }
func CreateRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func CreateRepoFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
name, err := params.GetString(args, "name") name, err := params.GetString(args, "name")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -145,8 +143,7 @@ func CreateRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRe
return to.TextResult(slim.Repo(repo)) return to.TextResult(slim.Repo(repo))
} }
func ForkRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ForkRepoFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
user, err := params.GetString(args, "user") user, err := params.GetString(args, "user")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -170,8 +167,8 @@ func ForkRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResu
return to.TextResult("Fork success") return to.TextResult("Fork success")
} }
func ListMyReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListMyReposFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, pageSize := params.GetPagination(args, 30)
opt := gitea_sdk.ListReposOptions{ opt := gitea_sdk.ListReposOptions{
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: page, Page: page,
@@ -190,12 +187,12 @@ func ListMyReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR
return to.TextResult(slim.Repos(repos)) return to.TextResult(slim.Repos(repos))
} }
func ListOrgReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListOrgReposFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
org, err := params.GetString(req.GetArguments(), "org") org, err := params.GetString(args, "org")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
page, pageSize := params.GetPagination(req.GetArguments(), 100) page, pageSize := params.GetPagination(args, 100)
opt := gitea_sdk.ListOrgReposOptions{ opt := gitea_sdk.ListOrgReposOptions{
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: page, Page: page,
+36 -41
View File
@@ -11,8 +11,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "gitea.dev/sdk" gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mark3labs/mcp-go/server"
) )
// TagTool holds the tag-related tools (scope "tag"). // TagTool holds the tag-related tools (scope "tag").
@@ -26,67 +25,66 @@ const (
) )
var ( var (
CreateTagTool = mcp.NewTool( CreateTagTool = tool.NewDefinition(
CreateTagToolName, CreateTagToolName,
mcp.WithDescription("Create a new Git tag in a repository at a target commit, branch, or existing tag, with an optional annotation message."), "Create a new Git tag in a repository at a target commit, branch, or existing tag, with an optional annotation message.",
mcp.WithToolAnnotation(annotation.Write("Create a tag")), annotation.Write("Create a tag"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithString("tag_name", mcp.Required()), tool.String("tag_name", tool.Required()),
mcp.WithString("target", mcp.Description("commitish")), tool.String("target", tool.Description("commitish")),
mcp.WithString("message", mcp.Description("tag message")), tool.String("message", tool.Description("tag message")),
) )
DeleteTagTool = mcp.NewTool( DeleteTagTool = tool.NewDefinition(
DeleteTagToolName, DeleteTagToolName,
mcp.WithDescription("Permanently delete a tag from a repository. This action is destructive and cannot be undone."), "Permanently delete a tag from a repository. This action is destructive and cannot be undone.",
mcp.WithToolAnnotation(annotation.Destructive("Delete a tag")), annotation.Destructive("Delete a tag"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithString("tag_name", mcp.Required()), tool.String("tag_name", tool.Required()),
) )
GetTagTool = mcp.NewTool( GetTagTool = tool.NewDefinition(
GetTagToolName, GetTagToolName,
mcp.WithDescription("Get details for a single tag in a repository by name."), "Get details for a single tag in a repository by name.",
mcp.WithToolAnnotation(annotation.ReadOnly("Get tag details")), annotation.ReadOnly("Get tag details"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithString("tag_name", mcp.Required()), tool.String("tag_name", tool.Required()),
) )
ListTagsTool = mcp.NewTool( ListTagsTool = tool.NewDefinition(
ListTagsToolName, ListTagsToolName,
mcp.WithDescription("List all tags in a repository, paginated."), "List all tags in a repository, paginated.",
mcp.WithToolAnnotation(annotation.ReadOnly("List tags")), annotation.ReadOnly("List tags"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)), tool.Number("page", tool.Description(params.PageDesc), tool.Default(1), tool.Minimum(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(20), mcp.Min(1)), tool.Number("per_page", tool.Description(params.PaginationDesc), tool.Default(20), tool.Minimum(1)),
) )
) )
func init() { func init() {
TagTool.RegisterWrite(server.ServerTool{ TagTool.RegisterWrite(tool.ServerTool{
Tool: CreateTagTool, Tool: CreateTagTool,
Handler: CreateTagFn, Handler: CreateTagFn,
}) })
TagTool.RegisterWrite(server.ServerTool{ TagTool.RegisterWrite(tool.ServerTool{
Tool: DeleteTagTool, Tool: DeleteTagTool,
Handler: DeleteTagFn, Handler: DeleteTagFn,
}) })
TagTool.RegisterRead(server.ServerTool{ TagTool.RegisterRead(tool.ServerTool{
Tool: GetTagTool, Tool: GetTagTool,
Handler: GetTagFn, Handler: GetTagFn,
}) })
TagTool.RegisterRead(server.ServerTool{ TagTool.RegisterRead(tool.ServerTool{
Tool: ListTagsTool, Tool: ListTagsTool,
Handler: ListTagsFn, Handler: ListTagsFn,
}) })
} }
func CreateTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func CreateTagFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -118,8 +116,7 @@ func CreateTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRes
return to.TextResult("Tag Created") return to.TextResult("Tag Created")
} }
func DeleteTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func DeleteTagFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -145,8 +142,7 @@ func DeleteTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRes
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, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -172,8 +168,7 @@ func GetTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult
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, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
+13 -14
View File
@@ -8,37 +8,36 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"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_sdk "gitea.dev/sdk" gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mark3labs/mcp-go/server"
) )
const ( const (
GetRepoTreeToolName = "get_repository_tree" GetRepoTreeToolName = "get_repository_tree"
) )
var GetRepoTreeTool = mcp.NewTool( var GetRepoTreeTool = tool.NewDefinition(
GetRepoTreeToolName, GetRepoTreeToolName,
mcp.WithDescription("Get the file tree of a repository at a given ref (SHA, branch, or tag), optionally recursively."), "Get the file tree of a repository at a given ref (SHA, branch, or tag), optionally recursively.",
mcp.WithToolAnnotation(annotation.ReadOnly("Get repository file tree")), annotation.ReadOnly("Get repository file tree"),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithString("tree_sha", mcp.Required(), mcp.Description("SHA, branch, or tag")), tool.String("tree_sha", tool.Required(), tool.Description("SHA, branch, or tag")),
mcp.WithBoolean("recursive"), tool.Boolean("recursive"),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)), tool.Number("page", tool.Description(params.PageDesc), tool.Default(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)), tool.Number("per_page", tool.Description(params.PaginationDesc), tool.Default(30)),
) )
func init() { func init() {
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(tool.ServerTool{
Tool: GetRepoTreeTool, Tool: GetRepoTreeTool,
Handler: GetRepoTreeFn, Handler: GetRepoTreeFn,
}) })
} }
func GetRepoTreeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetRepoTreeFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
+3 -1
View File
@@ -44,8 +44,10 @@ func TestSlimTreeNil(t *testing.T) {
} }
func TestGetRepoTreeToolRequired(t *testing.T) { func TestGetRepoTreeToolRequired(t *testing.T) {
inputSchema := GetRepoTreeTool.InputSchema.(map[string]any)
required, _ := inputSchema["required"].([]string)
for _, field := range []string{"owner", "repo", "tree_sha"} { for _, field := range []string{"owner", "repo", "tree_sha"} {
if !slices.Contains(GetRepoTreeTool.InputSchema.Required, field) { if !slices.Contains(required, field) {
t.Errorf("expected %q to be required", field) t.Errorf("expected %q to be required", field)
} }
} }
+926
View File
@@ -0,0 +1,926 @@
package operation
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"time"
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
"gitea.com/gitea/gitea-mcp/pkg/flag"
projectTo "gitea.com/gitea/gitea-mcp/pkg/to"
projectTool "gitea.com/gitea/gitea-mcp/pkg/tool"
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// Pin negotiated versions so SDK upgrades require compatibility review.
const (
testServerVersion = "test-version"
expectedProtocolVersion = "2026-07-28"
)
func exposeAllTools(t *testing.T) {
t.Helper()
originalReadOnly := flag.ReadOnly
originalAllowedTools := flag.AllowedTools
originalAllowedScopes := flag.AllowedScopes
originalVersion := flag.Version
t.Cleanup(func() {
flag.ReadOnly = originalReadOnly
flag.AllowedTools = originalAllowedTools
flag.AllowedScopes = originalAllowedScopes
flag.Version = originalVersion
})
flag.ReadOnly = false
flag.AllowedTools = nil
flag.AllowedScopes = nil
flag.Version = testServerVersion
}
// registeredToolCount is what the registry exposes under the current flags, so
// the transport assertions track tool additions without being edited.
func registeredToolCount() int {
count := 0
for _, domain := range domainTools {
count += len(domain.Tools())
}
return count
}
// stdioCommandEnvironment removes variables that override subprocess flags.
func stdioCommandEnvironment() []string {
environment := os.Environ()
filtered := make([]string, 0, len(environment))
for _, entry := range environment {
name, _, _ := strings.Cut(entry, "=")
switch name {
case "GITEA_READONLY", "GITEA_SCOPES", "GITEA_TOOLS", "MCP_MODE":
continue
}
filtered = append(filtered, entry)
}
return filtered
}
func textContent(t *testing.T, result *mcp.CallToolResult) string {
t.Helper()
if len(result.Content) != 1 {
t.Fatalf("content count = %d, want 1", len(result.Content))
}
content, ok := result.Content[0].(*mcp.TextContent)
if !ok {
t.Fatalf("content type = %T, want *mcp.TextContent", result.Content[0])
}
return content.Text
}
// listAndCallVersion is the round trip every transport must support. wantText
// differs per transport: the stdio subprocess resolves its version from the VCS
// build info (main.go:14), so only the in-process servers have a known one.
func listAndCallVersion(ctx context.Context, t *testing.T, session *mcp.ClientSession, wantText string) *mcp.ListToolsResult {
t.Helper()
result, err := session.ListTools(ctx, nil)
if err != nil {
t.Fatalf("ListTools() error = %v", err)
}
if want := registeredToolCount(); len(result.Tools) != want {
t.Fatalf("ListTools() count = %d, want %d", len(result.Tools), want)
}
callResult, err := session.CallTool(ctx, &mcp.CallToolParams{
Name: "get_gitea_mcp_server_version",
})
if err != nil {
t.Fatalf("CallTool() error = %v", err)
}
if got := textContent(t, callResult); !strings.Contains(got, wantText) {
t.Errorf("version tool result = %q, want it to contain %q", got, wantText)
}
return result
}
func assertToolsOnlyCapabilities(t *testing.T, capabilities *mcp.ServerCapabilities) {
t.Helper()
wireCapabilities, err := json.Marshal(capabilities)
if err != nil {
t.Fatalf("Marshal(server capabilities) error = %v", err)
}
// Any extra capability, or listChanged, changes these bytes.
if want := `{"tools":{}}`; string(wireCapabilities) != want {
t.Errorf("server capabilities = %s, want %s", wireCapabilities, want)
}
}
type rawRPCResponse struct {
status int
header http.Header
body []byte
}
type rpcRequest struct {
protocolVersion string
methodHeader string
nameHeader string
method string
params map[string]any
}
// postRPCRequest exists because the SDK client cannot be pinned to an old
// protocol version or made to send mismatched headers.
func postRPCRequest(t *testing.T, server *httptest.Server, call rpcRequest) rawRPCResponse {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
body, err := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": 1,
"method": call.method,
"params": call.params,
})
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL+"/mcp", bytes.NewReader(body))
if err != nil {
t.Fatalf("NewRequest() error = %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json, text/event-stream")
if call.protocolVersion != "" {
request.Header.Set("Mcp-Protocol-Version", call.protocolVersion)
}
if call.methodHeader != "" {
request.Header.Set("Mcp-Method", call.methodHeader)
}
if call.nameHeader != "" {
request.Header.Set("Mcp-Name", call.nameHeader)
}
response, err := server.Client().Do(request)
if err != nil {
t.Fatalf("POST %s error = %v", call.method, err)
}
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("ReadAll() error = %v", err)
}
return rawRPCResponse{status: response.StatusCode, header: response.Header.Clone(), body: responseBody}
}
func modernRequestMeta(protocolVersion string) map[string]any {
return map[string]any{
mcp.MetaKeyProtocolVersion: protocolVersion,
mcp.MetaKeyClientInfo: map[string]any{"name": "gitea-mcp-wire-test", "version": "1"},
mcp.MetaKeyClientCapabilities: map[string]any{},
}
}
func rpcPayload(response rawRPCResponse) []byte {
payload := bytes.TrimSpace(response.body)
for line := range bytes.SplitSeq(payload, []byte("\n")) {
if data, ok := bytes.CutPrefix(line, []byte("data: ")); ok {
return data
}
}
return payload
}
func rpcResult(t *testing.T, response rawRPCResponse) json.RawMessage {
t.Helper()
var wire struct {
Result json.RawMessage `json:"result"`
Error *json.RawMessage `json:"error"`
}
if err := json.Unmarshal(rpcPayload(response), &wire); err != nil {
t.Fatalf("Unmarshal(JSON-RPC response) error = %v; body = %s", err, response.body)
}
if wire.Error != nil {
t.Fatalf("JSON-RPC response has error %s", *wire.Error)
}
if len(wire.Result) == 0 {
t.Fatalf("JSON-RPC response has no result: %s", response.body)
}
return wire.Result
}
func rpcErrorCode(t *testing.T, response rawRPCResponse) int {
t.Helper()
payload := rpcPayload(response)
var wire struct {
Error *struct {
Code int `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(payload, &wire); err != nil {
t.Fatalf("Unmarshal(%q) error = %v", payload, err)
}
if wire.Error == nil {
t.Fatalf("response has no JSON-RPC error: %s", response.body)
}
return wire.Error.Code
}
// Regression test for https://gitea.com/gitea/gitea-mcp/issues/229
func callMissingRequiredArgument(ctx context.Context, t *testing.T, session *mcp.ClientSession) {
t.Helper()
result, err := session.CallTool(ctx, &mcp.CallToolParams{
Name: "search_issues",
Arguments: map[string]any{"state": "open"},
})
if err != nil {
t.Fatalf("CallTool() error = %v, want a tool result", err)
}
if !result.IsError {
t.Errorf("IsError = false, want true for a call without the required query")
}
if got := textContent(t, result); !strings.Contains(got, "query is required") {
t.Errorf("result = %q, want it to name the missing argument", got)
}
}
func TestOfficialSDKInMemory(t *testing.T) {
exposeAllTools(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
serverTransport, clientTransport := mcp.NewInMemoryTransports()
server := newMCPServer(testServerVersion)
RegisterTool(server)
serverDone := make(chan error, 1)
go func() {
serverDone <- server.Run(ctx, serverTransport)
}()
client := mcp.NewClient(&mcp.Implementation{Name: "gitea-mcp-test", Version: "1"}, nil)
session, err := client.Connect(ctx, clientTransport, nil)
if err != nil {
t.Fatalf("Connect() error = %v", err)
}
if got := session.InitializeResult().ProtocolVersion; got != expectedProtocolVersion {
t.Errorf("protocol version = %q, want %q", got, expectedProtocolVersion)
}
assertToolsOnlyCapabilities(t, session.InitializeResult().Capabilities)
listAndCallVersion(ctx, t, session, testServerVersion)
callMissingRequiredArgument(ctx, t, session)
if err := session.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
select {
case err := <-serverDone:
if err != nil && !errors.Is(err, context.Canceled) {
t.Fatalf("server Run() error = %v", err)
}
case <-ctx.Done():
t.Fatal("server did not stop after the client session closed")
}
}
func TestStreamableHTTP(t *testing.T) {
exposeAllTools(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
server := newMCPServer(testServerVersion)
RegisterTool(server)
httpTestServer := httptest.NewServer(newHTTPServer("", server).Handler)
defer httpTestServer.Close()
client := mcp.NewClient(&mcp.Implementation{Name: "gitea-mcp-http-test", Version: "1"}, nil)
session, err := client.Connect(ctx, &mcp.StreamableClientTransport{
Endpoint: httpTestServer.URL + "/mcp",
HTTPClient: httpTestServer.Client(),
DisableStandaloneSSE: true,
MaxRetries: -1,
}, nil)
if err != nil {
t.Fatalf("Connect() error = %v", err)
}
defer session.Close()
if got := session.InitializeResult().ProtocolVersion; got != expectedProtocolVersion {
t.Errorf("protocol version = %q, want %q", got, expectedProtocolVersion)
}
assertToolsOnlyCapabilities(t, session.InitializeResult().Capabilities)
firstList := listAndCallVersion(ctx, t, session, testServerVersion)
secondList, err := session.ListTools(ctx, nil)
if err != nil {
t.Fatalf("second ListTools() error = %v", err)
}
if !slices.EqualFunc(firstList.Tools, secondList.Tools, func(a, b *mcp.Tool) bool { return a.Name == b.Name }) {
t.Error("tools/list order changed between requests")
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, httpTestServer.URL+"/not-mcp", nil)
if err != nil {
t.Fatalf("NewRequest(GET outside /mcp) error = %v", err)
}
response, err := httpTestServer.Client().Do(request)
if err != nil {
t.Fatalf("GET outside /mcp error = %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusNotFound {
t.Errorf("GET outside /mcp status = %d, want %d", response.StatusCode, http.StatusNotFound)
}
for _, method := range []string{http.MethodGet, http.MethodDelete} {
request, err := http.NewRequestWithContext(ctx, method, httpTestServer.URL+"/mcp", nil)
if err != nil {
t.Fatalf("NewRequest(%s) error = %v", method, err)
}
response, err := httpTestServer.Client().Do(request)
if err != nil {
t.Fatalf("%s /mcp error = %v", method, err)
}
_ = response.Body.Close()
if response.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("%s /mcp status = %d, want %d", method, response.StatusCode, http.StatusMethodNotAllowed)
}
if allow := response.Header.Get("Allow"); allow != http.MethodPost {
t.Errorf("%s /mcp Allow = %q, want %q", method, allow, http.MethodPost)
}
}
}
func TestStreamableHTTP20260728Wire(t *testing.T) {
invoked := make(chan struct{}, 1)
server := newMCPServer(testServerVersion)
definition := &mcp.Tool{
Name: "test_execution_error",
Description: "Record valid calls and return a controlled tool execution error.",
InputSchema: map[string]any{"type": "object", "properties": map[string]any{}},
}
server.AddTool(definition, projectTool.ServerTool{
Tool: definition,
Handler: func(context.Context, map[string]any) (*mcp.CallToolResult, error) {
invoked <- struct{}{}
return projectTo.ErrorResult(errors.New("controlled execution failure"))
},
}.MCPHandler())
httpTestServer := httptest.NewServer(newHTTPServer("", server).Handler)
defer httpTestServer.Close()
callParams := map[string]any{
"_meta": modernRequestMeta(expectedProtocolVersion),
"name": definition.Name,
"arguments": map[string]any{},
}
t.Run("discover", func(t *testing.T) {
discover := postRPCRequest(t, httpTestServer, rpcRequest{
protocolVersion: expectedProtocolVersion,
methodHeader: "server/discover",
method: "server/discover",
params: map[string]any{"_meta": modernRequestMeta(expectedProtocolVersion)},
})
if discover.status != http.StatusOK {
t.Fatalf("status = %d, want %d; body = %s", discover.status, http.StatusOK, discover.body)
}
if sessionID := discover.header.Get("Mcp-Session-Id"); sessionID != "" {
t.Errorf("Mcp-Session-Id = %q, want empty", sessionID)
}
var result struct {
Meta map[string]json.RawMessage `json:"_meta"`
SupportedVersions []string `json:"supportedVersions"`
Capabilities *mcp.ServerCapabilities `json:"capabilities"`
}
if err := json.Unmarshal(rpcResult(t, discover), &result); err != nil {
t.Fatalf("Unmarshal(server/discover) error = %v", err)
}
if !slices.Contains(result.SupportedVersions, expectedProtocolVersion) {
t.Errorf("supportedVersions = %v, want %q", result.SupportedVersions, expectedProtocolVersion)
}
assertToolsOnlyCapabilities(t, result.Capabilities)
var serverInfo mcp.Implementation
if err := json.Unmarshal(result.Meta[mcp.MetaKeyServerInfo], &serverInfo); err != nil {
t.Fatalf("Unmarshal(%s) error = %v", mcp.MetaKeyServerInfo, err)
}
if serverInfo.Name != "Gitea MCP Server" || serverInfo.Version != testServerVersion {
t.Errorf("serverInfo = %+v, want Gitea MCP Server %s", serverInfo, testServerVersion)
}
})
t.Run("tool execution error", func(t *testing.T) {
call := postRPCRequest(t, httpTestServer, rpcRequest{
protocolVersion: expectedProtocolVersion,
methodHeader: "tools/call",
nameHeader: definition.Name,
method: "tools/call",
params: callParams,
})
if call.status != http.StatusOK {
t.Fatalf("status = %d, want %d; body = %s", call.status, http.StatusOK, call.body)
}
if sessionID := call.header.Get("Mcp-Session-Id"); sessionID != "" {
t.Errorf("Mcp-Session-Id = %q, want empty", sessionID)
}
var result struct {
IsError bool `json:"isError"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
}
if err := json.Unmarshal(rpcResult(t, call), &result); err != nil {
t.Fatalf("Unmarshal(tools/call) error = %v", err)
}
if !result.IsError {
t.Error("isError = false, want true")
}
if len(result.Content) != 1 || result.Content[0].Type != "text" || result.Content[0].Text != "controlled execution failure" {
t.Errorf("content = %+v, want controlled execution failure text", result.Content)
}
select {
case <-invoked:
default:
t.Error("valid tools/call did not invoke the tool handler")
}
})
for _, test := range []struct {
name string
protocolVersion string
methodHeader string
nameHeader string
}{
{name: "missing protocol", methodHeader: "tools/call", nameHeader: "test_execution_error"},
{name: "mismatched protocol", protocolVersion: "2025-11-25", methodHeader: "tools/call", nameHeader: "test_execution_error"},
{name: "missing method", protocolVersion: expectedProtocolVersion, nameHeader: "test_execution_error"},
{name: "mismatched method", protocolVersion: expectedProtocolVersion, methodHeader: "tools/list", nameHeader: "test_execution_error"},
{name: "missing name", protocolVersion: expectedProtocolVersion, methodHeader: "tools/call"},
{name: "mismatched name", protocolVersion: expectedProtocolVersion, methodHeader: "tools/call", nameHeader: "wrong_tool"},
} {
t.Run(test.name, func(t *testing.T) {
response := postRPCRequest(t, httpTestServer, rpcRequest{
protocolVersion: test.protocolVersion,
methodHeader: test.methodHeader,
nameHeader: test.nameHeader,
method: "tools/call",
params: callParams,
})
if response.status != http.StatusBadRequest {
t.Errorf("status = %d, want %d", response.status, http.StatusBadRequest)
}
if code := rpcErrorCode(t, response); code != mcp.CodeHeaderMismatch {
t.Errorf("error code = %d, want %d", code, mcp.CodeHeaderMismatch)
}
select {
case <-invoked:
t.Error("invalid headers invoked the tool handler")
default:
}
})
}
t.Run("unsupported protocol", func(t *testing.T) {
futureVersion := "2027-01-01"
response := postRPCRequest(t, httpTestServer, rpcRequest{
protocolVersion: futureVersion,
methodHeader: "server/discover",
method: "server/discover",
params: map[string]any{"_meta": modernRequestMeta(futureVersion)},
})
if response.status != http.StatusBadRequest {
t.Errorf("status = %d, want %d", response.status, http.StatusBadRequest)
}
if code := rpcErrorCode(t, response); code != mcp.CodeUnsupportedProtocolVersion {
t.Errorf("error code = %d, want %d", code, mcp.CodeUnsupportedProtocolVersion)
}
})
t.Run("unknown method", func(t *testing.T) {
response := postRPCRequest(t, httpTestServer, rpcRequest{
protocolVersion: expectedProtocolVersion,
methodHeader: "test/unknown",
method: "test/unknown",
params: map[string]any{"_meta": modernRequestMeta(expectedProtocolVersion)},
})
if response.status != http.StatusNotFound {
t.Errorf("status = %d, want %d", response.status, http.StatusNotFound)
}
if code := rpcErrorCode(t, response); code != jsonrpc.CodeMethodNotFound {
t.Errorf("error code = %d, want %d", code, jsonrpc.CodeMethodNotFound)
}
})
}
func TestStreamableHTTPLegacyCore(t *testing.T) {
exposeAllTools(t)
server := newMCPServer(testServerVersion)
RegisterTool(server)
httpTestServer := httptest.NewServer(newHTTPServer("", server).Handler)
defer httpTestServer.Close()
for _, protocolVersion := range []string{"2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"} {
t.Run(protocolVersion, func(t *testing.T) {
initialize := postRPCRequest(t, httpTestServer, rpcRequest{
method: "initialize",
params: map[string]any{
"protocolVersion": protocolVersion,
"clientInfo": map[string]any{"name": "gitea-mcp-legacy-test", "version": "1"},
"capabilities": map[string]any{},
},
})
if initialize.status != http.StatusOK {
t.Fatalf("initialize status = %d, want %d; body = %s", initialize.status, http.StatusOK, initialize.body)
}
var initializeResult struct {
ProtocolVersion string `json:"protocolVersion"`
}
if err := json.Unmarshal(rpcResult(t, initialize), &initializeResult); err != nil {
t.Fatalf("Unmarshal(initialize result) error = %v", err)
}
if initializeResult.ProtocolVersion != protocolVersion {
t.Errorf("initialize protocolVersion = %q, want %q", initializeResult.ProtocolVersion, protocolVersion)
}
if sessionID := initialize.header.Get("Mcp-Session-Id"); sessionID != "" {
t.Errorf("initialize Mcp-Session-Id = %q, want empty", sessionID)
}
list := postRPCRequest(t, httpTestServer, rpcRequest{
protocolVersion: protocolVersion,
method: "tools/list",
params: map[string]any{},
})
if list.status != http.StatusOK {
t.Fatalf("tools/list status = %d, want %d; body = %s", list.status, http.StatusOK, list.body)
}
var listResult struct {
Tools []json.RawMessage `json:"tools"`
}
if err := json.Unmarshal(rpcResult(t, list), &listResult); err != nil {
t.Fatalf("Unmarshal(tools/list result) error = %v", err)
}
if len(listResult.Tools) != registeredToolCount() {
t.Errorf("tools/list count = %d, want %d", len(listResult.Tools), registeredToolCount())
}
})
}
// The call path does not vary by version, unlike the two requests above.
call := postRPCRequest(t, httpTestServer, rpcRequest{
protocolVersion: "2025-11-25",
method: "tools/call",
params: map[string]any{
"name": "get_gitea_mcp_server_version",
"arguments": map[string]any{},
},
})
if call.status != http.StatusOK {
t.Fatalf("tools/call status = %d, want %d; body = %s", call.status, http.StatusOK, call.body)
}
var callResult struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
}
if err := json.Unmarshal(rpcResult(t, call), &callResult); err != nil {
t.Fatalf("Unmarshal(tools/call result) error = %v", err)
}
if len(callResult.Content) != 1 || !strings.Contains(callResult.Content[0].Text, testServerVersion) {
t.Errorf("tools/call content = %+v, want version %q", callResult.Content, testServerVersion)
}
}
// spaceReader yields an endless run of spaces, so oversized bodies can be sent
// without allocating them.
type spaceReader struct{}
func (spaceReader) Read(p []byte) (int, error) {
for index := range p {
p[index] = ' '
}
return len(p), nil
}
func TestStreamableHTTPRequestBodyLimit(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
server := newMCPServer(testServerVersion)
httpTestServer := httptest.NewServer(newHTTPServer("", server).Handler)
defer httpTestServer.Close()
for _, test := range []struct {
name string
size int64
tooLarge bool
}{
{name: "above the SDK default", size: mcp.DefaultMaxRequestBodyBytes + 1},
{name: "above our own limit", size: maxRequestBodyBytes + 1, tooLarge: true},
} {
t.Run(test.name, func(t *testing.T) {
request, err := http.NewRequestWithContext(ctx, http.MethodPost, httpTestServer.URL+"/mcp", io.LimitReader(spaceReader{}, test.size))
if err != nil {
t.Fatalf("NewRequest() error = %v", err)
}
request.ContentLength = test.size
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json, text/event-stream")
response, err := httpTestServer.Client().Do(request)
if err != nil {
t.Fatalf("POST %d bytes error = %v", test.size, err)
}
defer response.Body.Close()
if gotTooLarge := response.StatusCode == http.StatusRequestEntityTooLarge; gotTooLarge != test.tooLarge {
t.Errorf("POST %d bytes status = %d, want %d = %v", test.size, response.StatusCode, http.StatusRequestEntityTooLarge, test.tooLarge)
}
})
}
}
func TestStreamableHTTPOriginProtection(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
httpTestServer := httptest.NewServer(newHTTPServer("", newMCPServer(testServerVersion)).Handler)
defer httpTestServer.Close()
for _, test := range []struct {
name string
method string
origin string
wantForbidden bool
}{
{name: "native client without origin", method: http.MethodPost},
{name: "same origin", method: http.MethodPost, origin: httpTestServer.URL},
{name: "cross origin POST", method: http.MethodPost, origin: "https://attacker.example", wantForbidden: true},
{name: "cross origin GET", method: http.MethodGet, origin: "https://attacker.example", wantForbidden: true},
} {
t.Run(test.name, func(t *testing.T) {
request, err := http.NewRequestWithContext(ctx, test.method, httpTestServer.URL+"/mcp", strings.NewReader("{}"))
if err != nil {
t.Fatalf("NewRequest() error = %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json, text/event-stream")
if test.origin != "" {
request.Header.Set("Origin", test.origin)
}
response, err := httpTestServer.Client().Do(request)
if err != nil {
t.Fatalf("%s /mcp error = %v", test.method, err)
}
_, _ = io.Copy(io.Discard, response.Body)
_ = response.Body.Close()
if forbidden := response.StatusCode == http.StatusForbidden; forbidden != test.wantForbidden {
t.Errorf("status = %d, want forbidden = %v", response.StatusCode, test.wantForbidden)
}
})
}
}
func TestStreamableHTTPCancellation(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
started := make(chan struct{})
handlerCanceled := make(chan struct{})
server := newMCPServer(testServerVersion)
server.AddTool(
&mcp.Tool{
Name: "test_cancellation",
Description: "Wait for the request context to be canceled.",
InputSchema: map[string]any{"type": "object", "properties": map[string]any{}},
},
func(ctx context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
close(started)
<-ctx.Done()
close(handlerCanceled)
return nil, ctx.Err()
},
)
httpTestServer := httptest.NewServer(newHTTPServer("", server).Handler)
defer httpTestServer.Close()
client := mcp.NewClient(&mcp.Implementation{Name: "gitea-mcp-cancellation-test", Version: "1"}, nil)
session, err := client.Connect(ctx, &mcp.StreamableClientTransport{
Endpoint: httpTestServer.URL + "/mcp",
HTTPClient: httpTestServer.Client(),
DisableStandaloneSSE: true,
MaxRetries: -1,
}, nil)
if err != nil {
t.Fatalf("Connect() error = %v", err)
}
defer session.Close()
callCtx, cancelCall := context.WithCancel(ctx)
callDone := make(chan error, 1)
go func() {
_, err := session.CallTool(callCtx, &mcp.CallToolParams{Name: "test_cancellation"})
callDone <- err
}()
select {
case <-started:
case <-ctx.Done():
t.Fatal("tool handler did not start")
}
cancelCall()
select {
case <-handlerCanceled:
case <-ctx.Done():
t.Fatal("HTTP request cancellation did not reach the tool handler")
}
select {
case err := <-callDone:
if err == nil {
t.Error("CallTool() error = nil after cancellation")
}
case <-ctx.Done():
t.Fatal("CallTool() did not return after cancellation")
}
}
type authorizationTransport struct {
base http.RoundTripper
mu sync.RWMutex
value string
}
func (t *authorizationTransport) set(value string) {
t.mu.Lock()
defer t.mu.Unlock()
t.value = value
}
func (t *authorizationTransport) RoundTrip(request *http.Request) (*http.Response, error) {
clone := request.Clone(request.Context()) // Clone already copies the header
t.mu.RLock()
value := t.value
t.mu.RUnlock()
if value != "" {
clone.Header.Set("Authorization", value)
}
return t.base.RoundTrip(clone)
}
func authContextValue(ctx context.Context, session *mcp.ClientSession) (string, error) {
result, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "test_auth_context"})
if err != nil {
return "", err
}
if len(result.Content) != 1 {
return "", fmt.Errorf("content count = %d, want 1", len(result.Content))
}
content, ok := result.Content[0].(*mcp.TextContent)
if !ok {
return "", fmt.Errorf("content type = %T, want *mcp.TextContent", result.Content[0])
}
return content.Text, nil
}
func TestHTTPAuthPerRequest(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
server := newMCPServer(testServerVersion)
server.AddTool(
&mcp.Tool{
Name: "test_auth_context",
Description: "Return the request-scoped authentication token.",
InputSchema: map[string]any{"type": "object", "properties": map[string]any{}},
},
func(ctx context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
token, _ := ctx.Value(mcpContext.TokenContextKey).(string)
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: token}},
}, nil
},
)
httpTestServer := httptest.NewServer(newHTTPServer("", server).Handler)
defer httpTestServer.Close()
baseTransport := httpTestServer.Client().Transport
auth := &authorizationTransport{base: baseTransport}
auth.set("Bearer first-token")
baseClient := &http.Client{Transport: auth}
client := mcp.NewClient(&mcp.Implementation{Name: "gitea-mcp-auth-test", Version: "1"}, nil)
session, err := client.Connect(ctx, &mcp.StreamableClientTransport{
Endpoint: httpTestServer.URL + "/mcp",
HTTPClient: baseClient,
DisableStandaloneSSE: true,
MaxRetries: -1,
}, nil)
if err != nil {
t.Fatalf("Connect() error = %v", err)
}
defer session.Close()
for _, test := range []struct {
header string
want string
}{
{header: "Bearer first-token", want: "first-token"},
{header: "token second-token", want: "second-token"},
{header: "Basic ignored", want: ""},
} {
auth.set(test.header)
token, err := authContextValue(ctx, session)
if err != nil {
t.Fatalf("CallTool() with %q error = %v", test.header, err)
}
if token != test.want {
t.Errorf("CallTool() token = %q, want %q", token, test.want)
}
}
type authenticatedSession struct {
session *mcp.ClientSession
want string
}
concurrentSessions := make([]authenticatedSession, 0, 2)
for index, token := range []string{"parallel-one", "parallel-two"} {
transport := &authorizationTransport{base: baseTransport}
transport.set("Bearer " + token)
httpClient := &http.Client{Transport: transport}
parallelClient := mcp.NewClient(&mcp.Implementation{
Name: fmt.Sprintf("gitea-mcp-auth-parallel-%d", index),
Version: "1",
}, nil)
parallelSession, err := parallelClient.Connect(ctx, &mcp.StreamableClientTransport{
Endpoint: httpTestServer.URL + "/mcp",
HTTPClient: httpClient,
DisableStandaloneSSE: true,
MaxRetries: -1,
}, nil)
if err != nil {
t.Fatalf("parallel Connect() error = %v", err)
}
defer parallelSession.Close()
concurrentSessions = append(concurrentSessions, authenticatedSession{session: parallelSession, want: token})
}
var waitGroup sync.WaitGroup
errorsCh := make(chan error, 20)
for _, authenticated := range concurrentSessions {
for range 10 {
waitGroup.Go(func() {
got, err := authContextValue(ctx, authenticated.session)
if err != nil {
errorsCh <- err
return
}
if got != authenticated.want {
errorsCh <- fmt.Errorf("parallel token = %q, want %q", got, authenticated.want)
}
})
}
}
waitGroup.Wait()
close(errorsCh)
for err := range errorsCh {
t.Error(err)
}
}
func TestStdioCommandTransport(t *testing.T) {
if testing.Short() {
t.Skip("skipping subprocess build in short mode")
}
for name, value := range map[string]string{
"GITEA_READONLY": "true",
"GITEA_SCOPES": "user",
"GITEA_TOOLS": "get_me",
"MCP_MODE": "http",
} {
t.Setenv(name, value)
}
exposeAllTools(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
binary := filepath.Join(t.TempDir(), "gitea-mcp")
build := exec.CommandContext(ctx, "go", "build", "-o", binary, "..")
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("build stdio test binary: %v\n%s", err, output)
}
client := mcp.NewClient(&mcp.Implementation{Name: "gitea-mcp-stdio-test", Version: "1"}, nil)
command := exec.CommandContext(ctx, binary, "--transport", "stdio")
command.Env = stdioCommandEnvironment()
session, err := client.Connect(ctx, &mcp.CommandTransport{
Command: command,
TerminateDuration: 2 * time.Second,
}, nil)
if err != nil {
t.Fatalf("Connect() error = %v", err)
}
defer session.Close()
if got := session.InitializeResult().ProtocolVersion; got != expectedProtocolVersion {
t.Errorf("protocol version = %q, want %q", got, expectedProtocolVersion)
}
assertToolsOnlyCapabilities(t, session.InitializeResult().Capabilities)
listAndCallVersion(ctx, t, session, "Gitea MCP Server version:")
}
+53 -56
View File
@@ -13,8 +13,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "gitea.dev/sdk" gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mark3labs/mcp-go/server"
) )
var Tool = tool.New("search") var Tool = tool.New("search")
@@ -27,81 +26,81 @@ const (
) )
var ( var (
SearchUsersTool = mcp.NewTool( SearchUsersTool = tool.NewDefinition(
SearchUsersToolName, SearchUsersToolName,
mcp.WithDescription("Search for Gitea users by username or full name."), "Search for Gitea users by username or full name.",
mcp.WithToolAnnotation(annotation.ReadOnly("Search users")), annotation.ReadOnly("Search users"),
mcp.WithString("query", mcp.Required()), tool.String("query", tool.Required()),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)), tool.Number("page", tool.Description(params.PageDesc), tool.Default(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)), tool.Number("per_page", tool.Description(params.PaginationDesc), tool.Default(30)),
) )
SearOrgTeamsTool = mcp.NewTool( SearOrgTeamsTool = tool.NewDefinition(
SearchOrgTeamsToolName, SearchOrgTeamsToolName,
mcp.WithDescription("Search for teams within an organization by name, optionally including each team's description in the results."), "Search for teams within an organization by name, optionally including each team's description in the results.",
mcp.WithToolAnnotation(annotation.ReadOnly("Search organization teams")), annotation.ReadOnly("Search organization teams"),
mcp.WithString("org", mcp.Required()), tool.String("org", tool.Required()),
mcp.WithString("query", mcp.Required()), tool.String("query", tool.Required()),
mcp.WithBoolean("includeDescription"), tool.Boolean("includeDescription"),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)), tool.Number("page", tool.Description(params.PageDesc), tool.Default(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)), tool.Number("per_page", tool.Description(params.PaginationDesc), tool.Default(30)),
) )
SearchReposTool = mcp.NewTool( SearchReposTool = tool.NewDefinition(
SearchReposToolName, SearchReposToolName,
mcp.WithDescription("Search for repositories by keyword, with filters for topic/description matching, owner, visibility, archived status, and sort order."), "Search for repositories by keyword, with filters for topic/description matching, owner, visibility, archived status, and sort order.",
mcp.WithToolAnnotation(annotation.ReadOnly("Search repositories")), annotation.ReadOnly("Search repositories"),
mcp.WithString("query", mcp.Required()), tool.String("query", tool.Required()),
mcp.WithBoolean("keywordIsTopic"), tool.Boolean("keywordIsTopic"),
mcp.WithBoolean("keywordInDescription"), tool.Boolean("keywordInDescription"),
mcp.WithNumber("ownerID"), tool.Number("ownerID"),
mcp.WithBoolean("isPrivate"), tool.Boolean("isPrivate"),
mcp.WithBoolean("isArchived"), tool.Boolean("isArchived"),
mcp.WithString("sort"), tool.String("sort"),
mcp.WithString("order"), tool.String("order"),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)), tool.Number("page", tool.Description(params.PageDesc), tool.Default(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)), tool.Number("per_page", tool.Description(params.PaginationDesc), tool.Default(30)),
) )
SearchIssuesTool = mcp.NewTool( SearchIssuesTool = tool.NewDefinition(
SearchIssuesToolName, SearchIssuesToolName,
mcp.WithDescription("Search issues and PRs across repositories"), "Search issues and PRs across repositories",
mcp.WithToolAnnotation(annotation.ReadOnly("Search issues")), annotation.ReadOnly("Search issues"),
mcp.WithString("query", mcp.Required()), tool.String("query", tool.Required()),
mcp.WithString("state", mcp.Enum("open", "closed", "all")), tool.String("state", tool.Enum("open", "closed", "all")),
mcp.WithString("type", mcp.Enum("issues", "pulls")), tool.String("type", tool.Enum("issues", "pulls")),
mcp.WithString("labels", mcp.Description("comma-separated")), tool.String("labels", tool.Description("comma-separated")),
mcp.WithString("owner", mcp.Description("filter by owner")), tool.String("owner", tool.Description("filter by owner")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)), tool.Number("page", tool.Description(params.PageDesc), tool.Default(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)), tool.Number("per_page", tool.Description(params.PaginationDesc), tool.Default(30)),
) )
) )
func init() { func init() {
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(tool.ServerTool{
Tool: SearchUsersTool, Tool: SearchUsersTool,
Handler: UsersFn, Handler: UsersFn,
}) })
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(tool.ServerTool{
Tool: SearOrgTeamsTool, Tool: SearOrgTeamsTool,
Handler: OrgTeamsFn, Handler: OrgTeamsFn,
}) })
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(tool.ServerTool{
Tool: SearchReposTool, Tool: SearchReposTool,
Handler: ReposFn, Handler: ReposFn,
}) })
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(tool.ServerTool{
Tool: SearchIssuesTool, Tool: SearchIssuesTool,
Handler: IssuesFn, Handler: IssuesFn,
}) })
} }
func UsersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func UsersFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
keyword, err := params.GetString(req.GetArguments(), "query") keyword, err := params.GetString(args, "query")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, pageSize := params.GetPagination(args, 30)
opt := gitea_sdk.SearchUsersOption{ opt := gitea_sdk.SearchUsersOption{
KeyWord: keyword, KeyWord: keyword,
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
@@ -120,17 +119,17 @@ func UsersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult,
return to.TextResult(slimUserDetails(users)) return to.TextResult(slimUserDetails(users))
} }
func OrgTeamsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func OrgTeamsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
org, err := params.GetString(req.GetArguments(), "org") org, err := params.GetString(args, "org")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
query, err := params.GetString(req.GetArguments(), "query") query, err := params.GetString(args, "query")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
includeDescription, _ := req.GetArguments()["includeDescription"].(bool) includeDescription, _ := args["includeDescription"].(bool)
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, pageSize := params.GetPagination(args, 30)
opt := gitea_sdk.SearchTeamsOptions{ opt := gitea_sdk.SearchTeamsOptions{
Query: query, Query: query,
IncludeDescription: includeDescription, IncludeDescription: includeDescription,
@@ -150,12 +149,11 @@ func OrgTeamsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResu
return to.TextResult(slimTeams(teams)) return to.TextResult(slimTeams(teams))
} }
func ReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ReposFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
keyword, err := params.GetString(req.GetArguments(), "query") keyword, err := params.GetString(args, "query")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
args := req.GetArguments()
keywordIsTopic, _ := args["keywordIsTopic"].(bool) keywordIsTopic, _ := args["keywordIsTopic"].(bool)
keywordInDescription, _ := args["keywordInDescription"].(bool) keywordInDescription, _ := args["keywordInDescription"].(bool)
sort, _ := args["sort"].(string) sort, _ := args["sort"].(string)
@@ -186,8 +184,7 @@ func ReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult,
return to.TextResult(slim.Repos(repos)) return to.TextResult(slim.Repos(repos))
} }
func IssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func IssuesFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
query, err := params.GetString(args, "query") query, err := params.GetString(args, "query")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
+6 -4
View File
@@ -4,13 +4,13 @@ import (
"slices" "slices"
"testing" "testing"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
func TestSearchToolsRequiredFields(t *testing.T) { func TestSearchToolsRequiredFields(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
tool mcp.Tool tool *mcp.Tool
required []string required []string
}{ }{
{ {
@@ -32,9 +32,11 @@ func TestSearchToolsRequiredFields(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
inputSchema := tt.tool.InputSchema.(map[string]any)
required, _ := inputSchema["required"].([]string)
for _, field := range tt.required { for _, field := range tt.required {
if !slices.Contains(tt.tool.InputSchema.Required, field) { if !slices.Contains(required, field) {
t.Errorf("tool %s: expected %q to be required, got required=%v", tt.name, field, tt.tool.InputSchema.Required) t.Errorf("tool %s: expected %q to be required, got required=%v", tt.name, field, required)
} }
} }
}) })
+3 -1
View File
@@ -48,7 +48,9 @@ func TestSlimIssues(t *testing.T) {
} }
func TestSearchIssuesToolRequired(t *testing.T) { func TestSearchIssuesToolRequired(t *testing.T) {
if !slices.Contains(SearchIssuesTool.InputSchema.Required, "query") { inputSchema := SearchIssuesTool.InputSchema.(map[string]any)
required, _ := inputSchema["required"].([]string)
if !slices.Contains(required, "query") {
t.Error("search_issues should require query") t.Error("search_issues should require query")
} }
} }
+67 -68
View File
@@ -12,8 +12,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "gitea.dev/sdk" gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mark3labs/mcp-go/server"
) )
var Tool = tool.New("timetracking") var Tool = tool.New("timetracking")
@@ -24,86 +23,86 @@ const (
) )
var ( var (
TimetrackingReadTool = mcp.NewTool( TimetrackingReadTool = tool.NewDefinition(
TimetrackingReadToolName, TimetrackingReadToolName,
mcp.WithDescription("Read time tracking: issue times, repo times, active stopwatches, your tracked times."), "Read time tracking: issue times, repo times, active stopwatches, your tracked times.",
mcp.WithToolAnnotation(annotation.ReadOnly("Read tracked time")), annotation.ReadOnly("Read tracked time"),
mcp.WithString("method", mcp.Required(), mcp.Enum("list_issue_times", "list_repo_times", "get_my_stopwatches", "get_my_times")), tool.String("method", tool.Required(), tool.Enum("list_issue_times", "list_repo_times", "get_my_stopwatches", "get_my_times")),
mcp.WithString("owner", mcp.Description("for list_* methods")), tool.String("owner", tool.Description("for list_* methods")),
mcp.WithString("repo", mcp.Description("for list_* methods")), tool.String("repo", tool.Description("for list_* methods")),
mcp.WithNumber("issue_number", mcp.Description("for 'list_issue_times'")), tool.Number("issue_number", tool.Description("for 'list_issue_times'")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)), tool.Number("page", tool.Description(params.PageDesc), tool.Default(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)), tool.Number("per_page", tool.Description(params.PaginationDesc), tool.Default(30)),
) )
TimetrackingWriteTool = mcp.NewTool( TimetrackingWriteTool = tool.NewDefinition(
TimetrackingWriteToolName, TimetrackingWriteToolName,
mcp.WithDescription("Write time tracking: stopwatches and entries."), "Write time tracking: stopwatches and entries.",
mcp.WithToolAnnotation(annotation.Write("Add or manage tracked time")), annotation.Write("Add or manage tracked time"),
mcp.WithString("method", mcp.Required(), mcp.Enum("start_stopwatch", "stop_stopwatch", "delete_stopwatch", "add_time", "delete_time")), tool.String("method", tool.Required(), tool.Enum("start_stopwatch", "stop_stopwatch", "delete_stopwatch", "add_time", "delete_time")),
mcp.WithString("owner", mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Description(params.RepoDesc)), tool.String("repo", tool.Description(params.RepoDesc)),
mcp.WithNumber("issue_number"), tool.Number("issue_number"),
mcp.WithNumber("time", mcp.Description("seconds (for 'add_time')")), tool.Number("time", tool.Description("seconds (for 'add_time')")),
mcp.WithNumber("id", mcp.Description("entry ID (for 'delete_time')")), tool.Number("id", tool.Description("entry ID (for 'delete_time')")),
) )
) )
func init() { func init() {
Tool.RegisterRead(server.ServerTool{Tool: TimetrackingReadTool, Handler: readFn}) Tool.RegisterRead(tool.ServerTool{Tool: TimetrackingReadTool, Handler: readFn})
Tool.RegisterWrite(server.ServerTool{Tool: TimetrackingWriteTool, Handler: writeFn}) Tool.RegisterWrite(tool.ServerTool{Tool: TimetrackingWriteTool, Handler: writeFn})
} }
func readFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func readFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method") method, err := params.GetString(args, "method")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
switch method { switch method {
case "list_issue_times": case "list_issue_times":
return listTrackedTimesFn(ctx, req) return listTrackedTimesFn(ctx, args)
case "list_repo_times": case "list_repo_times":
return listRepoTimesFn(ctx, req) return listRepoTimesFn(ctx, args)
case "get_my_stopwatches": case "get_my_stopwatches":
return getMyStopwatchesFn(ctx, req) return getMyStopwatchesFn(ctx, args)
case "get_my_times": case "get_my_times":
return getMyTimesFn(ctx, req) return getMyTimesFn(ctx, args)
default: default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
} }
func writeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func writeFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method") method, err := params.GetString(args, "method")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
switch method { switch method {
case "start_stopwatch": case "start_stopwatch":
return startStopwatchFn(ctx, req) return startStopwatchFn(ctx, args)
case "stop_stopwatch": case "stop_stopwatch":
return stopStopwatchFn(ctx, req) return stopStopwatchFn(ctx, args)
case "delete_stopwatch": case "delete_stopwatch":
return deleteStopwatchFn(ctx, req) return deleteStopwatchFn(ctx, args)
case "add_time": case "add_time":
return addTrackedTimeFn(ctx, req) return addTrackedTimeFn(ctx, args)
case "delete_time": case "delete_time":
return deleteTrackedTimeFn(ctx, req) return deleteTrackedTimeFn(ctx, args)
default: default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
} }
func startStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func startStopwatchFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "issue_number") index, err := params.GetIndex(args, "issue_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -118,16 +117,16 @@ func startStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
return to.TextResult(fmt.Sprintf("Stopwatch started on issue %s/%s#%d", owner, repo, index)) return to.TextResult(fmt.Sprintf("Stopwatch started on issue %s/%s#%d", owner, repo, index))
} }
func stopStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func stopStopwatchFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "issue_number") index, err := params.GetIndex(args, "issue_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -142,16 +141,16 @@ func stopStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
return to.TextResult(fmt.Sprintf("Stopwatch stopped on issue %s/%s#%d - time recorded", owner, repo, index)) return to.TextResult(fmt.Sprintf("Stopwatch stopped on issue %s/%s#%d - time recorded", owner, repo, index))
} }
func deleteStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func deleteStopwatchFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "issue_number") index, err := params.GetIndex(args, "issue_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -166,7 +165,7 @@ func deleteStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
return to.TextResult(fmt.Sprintf("Stopwatch deleted/cancelled on issue %s/%s#%d", owner, repo, index)) return to.TextResult(fmt.Sprintf("Stopwatch deleted/cancelled on issue %s/%s#%d", owner, repo, index))
} }
func getMyStopwatchesFn(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getMyStopwatchesFn(ctx context.Context, _ map[string]any) (*mcp.CallToolResult, error) {
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))
@@ -181,20 +180,20 @@ func getMyStopwatchesFn(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallTo
return to.TextResult(slimStopWatches(stopwatches)) return to.TextResult(slimStopWatches(stopwatches))
} }
func listTrackedTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listTrackedTimesFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "issue_number") index, err := params.GetIndex(args, "issue_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, pageSize := params.GetPagination(args, 30)
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))
@@ -215,21 +214,21 @@ func listTrackedTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
return to.TextResult(slimTrackedTimes(times)) return to.TextResult(slimTrackedTimes(times))
} }
func addTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func addTrackedTimeFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "issue_number") index, err := params.GetIndex(args, "issue_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
timeSeconds, err := params.GetIndex(req.GetArguments(), "time") timeSeconds, err := params.GetIndex(args, "time")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -246,21 +245,21 @@ func addTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
return to.TextResult(slimTrackedTime(trackedTime)) return to.TextResult(slimTrackedTime(trackedTime))
} }
func deleteTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func deleteTrackedTimeFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
index, err := params.GetIndex(req.GetArguments(), "issue_number") index, err := params.GetIndex(args, "issue_number")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
id, err := params.GetIndex(req.GetArguments(), "id") id, err := params.GetIndex(args, "id")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
@@ -275,17 +274,17 @@ func deleteTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Cal
return to.TextResult(fmt.Sprintf("Tracked time entry %d deleted from issue %s/%s#%d", id, owner, repo, index)) return to.TextResult(fmt.Sprintf("Tracked time entry %d deleted from issue %s/%s#%d", id, owner, repo, index))
} }
func listRepoTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listRepoTimesFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
repo, err := params.GetString(req.GetArguments(), "repo") repo, err := params.GetString(args, "repo")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, pageSize := params.GetPagination(args, 30)
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))
@@ -305,7 +304,7 @@ func listRepoTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
return to.TextResult(slimTrackedTimes(times)) return to.TextResult(slimTrackedTimes(times))
} }
func getMyTimesFn(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getMyTimesFn(ctx context.Context, _ map[string]any) (*mcp.CallToolResult, error) {
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))
+142
View File
@@ -0,0 +1,142 @@
package operation
import (
"encoding/json"
"slices"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// TestToolContract checks the properties every exposed tool must hold, rather
// than a snapshot of the current surface, so adding a tool needs no fixture
// update and a malformed schema fails here instead of panicking in AddTool.
func TestToolContract(t *testing.T) {
scopeByName := map[string]string{}
seenScopes := map[string]struct{}{}
for _, domain := range domainTools {
scope := domain.Scope()
if scope == "" {
t.Error("domainTools contains a domain with an empty scope")
}
// Tools() filters one domain by exactly one scope name, so a shared
// scope would make --scope select more than the caller asked for.
if _, duplicate := seenScopes[scope]; duplicate {
t.Errorf("domainTools contains a duplicate scope %q", scope)
}
seenScopes[scope] = struct{}{}
for _, registered := range domain.ReadTools() {
assertToolContract(t, scope, registered.Tool, true, scopeByName)
}
for _, registered := range domain.WriteTools() {
assertToolContract(t, scope, registered.Tool, false, scopeByName)
}
}
if len(scopeByName) == 0 {
t.Fatal("no tools are registered")
}
}
func assertToolContract(t *testing.T, scope string, definition *mcp.Tool, readOnly bool, scopeByName map[string]string) {
t.Helper()
t.Run(definition.Name, func(t *testing.T) {
if previous, duplicate := scopeByName[definition.Name]; duplicate {
t.Errorf("tool name is already registered in scope %q; AddTool would silently replace it", previous)
}
scopeByName[definition.Name] = scope
// Strict MCP clients reject a tools/list entry without a description.
if definition.Description == "" {
t.Error("tool has no description")
}
// A write tool registered as read stays exposed under --read-only.
if definition.Annotations == nil || definition.Annotations.ReadOnlyHint != readOnly {
t.Errorf("annotations = %+v, want readOnlyHint %v", definition.Annotations, readOnly)
}
schema := decodeJSON(t, definition.InputSchema)
if schema["type"] != "object" {
t.Fatalf("input schema type = %v, want object", schema["type"])
}
properties, ok := schema["properties"].(map[string]any)
if !ok {
t.Fatalf("input schema properties = %T, want a JSON object", schema["properties"])
}
for name, raw := range properties {
property, ok := raw.(map[string]any)
if !ok {
t.Errorf("property %q = %T, want a JSON object", name, raw)
continue
}
assertPropertyContract(t, name, property)
}
})
}
func assertPropertyContract(t *testing.T, name string, property map[string]any) {
t.Helper()
propertyType, ok := property["type"].(string)
if !ok {
t.Errorf("property %q has no type", name)
return
}
enum, hasEnum := property["enum"].([]any)
if _, declared := property["enum"]; declared && len(enum) == 0 {
t.Errorf("property %q has an empty enum", name)
}
defaultValue, hasDefault := property["default"]
if !hasDefault {
return
}
if !matchesJSONType(defaultValue, propertyType) {
t.Errorf("property %q default %#v is not a %s", name, defaultValue, propertyType)
}
if hasEnum && !slices.Contains(enum, defaultValue) {
t.Errorf("property %q default %#v is not one of its enum values %#v", name, defaultValue, enum)
}
}
func matchesJSONType(value any, propertyType string) bool {
switch propertyType {
case "string":
_, ok := value.(string)
return ok
case "number":
_, ok := value.(float64)
return ok
case "boolean":
_, ok := value.(bool)
return ok
case "array":
_, ok := value.([]any)
return ok
case "object":
_, ok := value.(map[string]any)
return ok
default:
return false
}
}
// decodeJSON round-trips through JSON so the assertions see what an MCP client
// receives rather than the Go values behind it.
func decodeJSON(t *testing.T, value any) map[string]any {
t.Helper()
encoded, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(encoded, &decoded); err != nil {
t.Fatalf("decode: %v", err)
}
return decoded
}
+14 -15
View File
@@ -12,8 +12,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "gitea.dev/sdk" gitea_sdk "gitea.dev/sdk"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mark3labs/mcp-go/server"
) )
const ( const (
@@ -24,27 +23,27 @@ const (
var Tool = tool.New("user") var Tool = tool.New("user")
var ( var (
GetMyUserInfoTool = mcp.NewTool( GetMyUserInfoTool = tool.NewDefinition(
GetMyUserInfoToolName, GetMyUserInfoToolName,
mcp.WithDescription("Get current user"), "Get current user",
mcp.WithToolAnnotation(annotation.ReadOnly("Get current user information")), annotation.ReadOnly("Get current user information"),
) )
GetUserOrgsTool = mcp.NewTool( GetUserOrgsTool = tool.NewDefinition(
GetUserOrgsToolName, GetUserOrgsToolName,
mcp.WithDescription("List current user's organizations"), "List current user's organizations",
mcp.WithToolAnnotation(annotation.ReadOnly("Get user organizations")), annotation.ReadOnly("Get user organizations"),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1)), tool.Number("page", tool.Description(params.PageDesc), tool.Default(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30)), tool.Number("per_page", tool.Description(params.PaginationDesc), tool.Default(30)),
) )
) )
func init() { func init() {
Tool.RegisterRead(server.ServerTool{Tool: GetMyUserInfoTool, Handler: GetUserInfoFn}) Tool.RegisterRead(tool.ServerTool{Tool: GetMyUserInfoTool, Handler: GetUserInfoFn})
Tool.RegisterRead(server.ServerTool{Tool: GetUserOrgsTool, Handler: GetUserOrgsFn}) Tool.RegisterRead(tool.ServerTool{Tool: GetUserOrgsTool, Handler: GetUserOrgsFn})
} }
func GetUserInfoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetUserInfoFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
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))
@@ -56,8 +55,8 @@ func GetUserInfoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR
return to.TextResult(slim.UserDetail(user)) return to.TextResult(slim.UserDetail(user))
} }
func GetUserOrgsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetUserOrgsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
page, pageSize := params.GetPagination(req.GetArguments(), 30) page, pageSize := params.GetPagination(args, 30)
opt := gitea_sdk.ListOrgsOptions{ opt := gitea_sdk.ListOrgsOptions{
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
+6 -7
View File
@@ -9,8 +9,7 @@ import (
"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"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mark3labs/mcp-go/server"
) )
var Tool = tool.New("version") var Tool = tool.New("version")
@@ -19,20 +18,20 @@ const (
GetGiteaMCPServerVersion = "get_gitea_mcp_server_version" GetGiteaMCPServerVersion = "get_gitea_mcp_server_version"
) )
var GetGiteaMCPServerVersionTool = mcp.NewTool( var GetGiteaMCPServerVersionTool = tool.NewDefinition(
GetGiteaMCPServerVersion, GetGiteaMCPServerVersion,
mcp.WithDescription("Get the running version of the Gitea MCP Server itself (not the Gitea instance it connects to)."), "Get the running version of the Gitea MCP Server itself (not the Gitea instance it connects to).",
mcp.WithToolAnnotation(annotation.ReadOnly("Get server version")), annotation.ReadOnly("Get server version"),
) )
func init() { func init() {
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(tool.ServerTool{
Tool: GetGiteaMCPServerVersionTool, Tool: GetGiteaMCPServerVersionTool,
Handler: GetGiteaMCPServerVersionFn, Handler: GetGiteaMCPServerVersionFn,
}) })
} }
func GetGiteaMCPServerVersionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetGiteaMCPServerVersionFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
version := flag.Version version := flag.Version
if version == "" { if version == "" {
version = "dev" version = "dev"
+36 -43
View File
@@ -12,8 +12,7 @@ import (
"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"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mark3labs/mcp-go/server"
) )
var Tool = tool.New("wiki") var Tool = tool.New("wiki")
@@ -24,77 +23,76 @@ const (
) )
var ( var (
WikiReadTool = mcp.NewTool( WikiReadTool = tool.NewDefinition(
WikiReadToolName, WikiReadToolName,
mcp.WithDescription("Read wiki: list pages, get content, revision history."), "Read wiki: list pages, get content, revision history.",
mcp.WithToolAnnotation(annotation.ReadOnly("Read wiki pages")), annotation.ReadOnly("Read wiki pages"),
mcp.WithString("method", mcp.Required(), mcp.Enum("list", "get", "get_revisions")), tool.String("method", tool.Required(), tool.Enum("list", "get", "get_revisions")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithString("pageName", mcp.Description("for 'get'/'get_revisions'")), tool.String("pageName", tool.Description("for 'get'/'get_revisions'")),
) )
WikiWriteTool = mcp.NewTool( WikiWriteTool = tool.NewDefinition(
WikiWriteToolName, WikiWriteToolName,
mcp.WithDescription("Write wiki pages: create, update, delete."), "Write wiki pages: create, update, delete.",
mcp.WithToolAnnotation(annotation.Destructive("Create, update, or delete wiki pages")), annotation.Destructive("Create, update, or delete wiki pages"),
mcp.WithString("method", mcp.Required(), mcp.Enum("create", "update", "delete")), tool.String("method", tool.Required(), tool.Enum("create", "update", "delete")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), tool.String("owner", tool.Required(), tool.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), tool.String("repo", tool.Required(), tool.Description(params.RepoDesc)),
mcp.WithString("pageName", mcp.Description("for 'update'/'delete'")), tool.String("pageName", tool.Description("for 'update'/'delete'")),
mcp.WithString("title", mcp.Description("for 'create'")), tool.String("title", tool.Description("for 'create'")),
mcp.WithString("content", mcp.Description("for 'create'/'update'")), tool.String("content", tool.Description("for 'create'/'update'")),
mcp.WithString("message", mcp.Description("commit message")), tool.String("message", tool.Description("commit message")),
) )
) )
func init() { func init() {
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(tool.ServerTool{
Tool: WikiReadTool, Tool: WikiReadTool,
Handler: wikiReadFn, Handler: wikiReadFn,
}) })
Tool.RegisterWrite(server.ServerTool{ Tool.RegisterWrite(tool.ServerTool{
Tool: WikiWriteTool, Tool: WikiWriteTool,
Handler: wikiWriteFn, Handler: wikiWriteFn,
}) })
} }
func wikiReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func wikiReadFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method") method, err := params.GetString(args, "method")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
switch method { switch method {
case "list": case "list":
return listWikiPagesFn(ctx, req) return listWikiPagesFn(ctx, args)
case "get": case "get":
return getWikiPageFn(ctx, req) return getWikiPageFn(ctx, args)
case "get_revisions": case "get_revisions":
return getWikiRevisionsFn(ctx, req) return getWikiRevisionsFn(ctx, args)
default: default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
} }
func wikiWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func wikiWriteFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method") method, err := params.GetString(args, "method")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
} }
switch method { switch method {
case "create": case "create":
return createWikiPageFn(ctx, req) return createWikiPageFn(ctx, args)
case "update": case "update":
return updateWikiPageFn(ctx, req) return updateWikiPageFn(ctx, args)
case "delete": case "delete":
return deleteWikiPageFn(ctx, req) return deleteWikiPageFn(ctx, args)
default: default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
} }
func listWikiPagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listWikiPagesFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -113,8 +111,7 @@ func listWikiPagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
return to.TextResult(result) return to.TextResult(result)
} }
func getWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getWikiPageFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -137,8 +134,7 @@ func getWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR
return to.TextResult(result) return to.TextResult(result)
} }
func getWikiRevisionsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getWikiRevisionsFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -161,8 +157,7 @@ func getWikiRevisionsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
return to.TextResult(result) return to.TextResult(result)
} }
func createWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func createWikiPageFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -200,8 +195,7 @@ func createWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
return to.TextResult(result) return to.TextResult(result)
} }
func updateWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func updateWikiPageFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
@@ -245,8 +239,7 @@ func updateWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
return to.TextResult(result) return to.TextResult(result)
} }
func deleteWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func deleteWikiPageFn(ctx context.Context, args map[string]any) (*mcp.CallToolResult, error) {
args := req.GetArguments()
owner, err := params.GetString(args, "owner") owner, err := params.GetString(args, "owner")
if err != nil { if err != nil {
return to.ErrorResult(err) return to.ErrorResult(err)
+1 -6
View File
@@ -11,8 +11,6 @@ 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"
"github.com/mark3labs/mcp-go/mcp"
) )
func TestWikiWriteBase64Encoding(t *testing.T) { func TestWikiWriteBase64Encoding(t *testing.T) {
@@ -54,10 +52,7 @@ func TestWikiWriteBase64Encoding(t *testing.T) {
"title": "TestPage", "title": "TestPage",
} }
req := mcp.CallToolRequest{} result, err := wikiWriteFn(ctx, args)
req.Params.Arguments = args
result, err := wikiWriteFn(ctx, req)
if err != nil { if err != nil {
t.Fatalf("wikiWriteFn() error: %v", err) t.Fatalf("wikiWriteFn() error: %v", err)
} }
+11 -13
View File
@@ -1,18 +1,16 @@
package annotation package annotation
import "github.com/mark3labs/mcp-go/mcp" import "github.com/modelcontextprotocol/go-sdk/mcp"
func ReadOnly(title string) mcp.ToolAnnotation { func ReadOnly(title string) *mcp.ToolAnnotations {
return &mcp.ToolAnnotations{Title: title, ReadOnlyHint: true}
}
func Write(title string) *mcp.ToolAnnotations {
return &mcp.ToolAnnotations{Title: title}
}
func Destructive(title string) *mcp.ToolAnnotations {
t := true t := true
return mcp.ToolAnnotation{Title: title, ReadOnlyHint: &t} return &mcp.ToolAnnotations{Title: title, DestructiveHint: &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}
} }
+50
View File
@@ -0,0 +1,50 @@
package annotation
import (
"encoding/json"
"maps"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// The hints are what clients use to decide whether a tool needs confirmation, so
// assert the encoded form: an omitted readOnlyHint reads as false either way, but
// only the explicit form survives a client that checks for the key.
func TestAnnotations(t *testing.T) {
for _, test := range []struct {
name string
annotations *mcp.ToolAnnotations
want map[string]any
}{
{
name: "ReadOnly",
annotations: ReadOnly("Read"),
want: map[string]any{"title": "Read", "readOnlyHint": true, "idempotentHint": false},
},
{
name: "Write",
annotations: Write("Write"),
want: map[string]any{"title": "Write", "readOnlyHint": false, "idempotentHint": false},
},
{
name: "Destructive",
annotations: Destructive("Delete"),
want: map[string]any{"title": "Delete", "readOnlyHint": false, "idempotentHint": false, "destructiveHint": true},
},
} {
t.Run(test.name, func(t *testing.T) {
encoded, err := json.Marshal(test.annotations)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
var got map[string]any
if err := json.Unmarshal(encoded, &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if !maps.Equal(got, test.want) {
t.Errorf("annotations = %s, want %v", encoded, test.want)
}
})
}
}
+1
View File
@@ -2,6 +2,7 @@ package flag
var ( var (
Host string Host string
Bind string
Port int Port int
Token string Token string
Version string Version string
+18 -3
View File
@@ -7,7 +7,7 @@ import (
"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"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
func TextResult(v any) (*mcp.CallToolResult, error) { func TextResult(v any) (*mcp.CallToolResult, error) {
@@ -18,10 +18,25 @@ func TextResult(v any) (*mcp.CallToolResult, error) {
if flag.Debug { if flag.Debug {
log.Debugf("Text Result: %s", string(resultBytes)) log.Debugf("Text Result: %s", string(resultBytes))
} }
return mcp.NewToolResultText(string(resultBytes)), nil return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: string(resultBytes)}},
}, nil
}
// RawTextResult returns text as-is, without JSON-encoding it. Use it for
// content that is already meant to be read directly, such as Markdown.
func RawTextResult(text string) (*mcp.CallToolResult, error) {
if flag.Debug {
log.Debugf("Text Result: %s", text)
}
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: text}},
}, nil
} }
func ErrorResult(err error) (*mcp.CallToolResult, error) { func ErrorResult(err error) (*mcp.CallToolResult, error) {
log.Errorf("%s", err.Error()) log.Errorf("%s", err.Error())
return nil, err var result mcp.CallToolResult
result.SetError(err)
return &result, nil
} }
+42
View File
@@ -0,0 +1,42 @@
package to
import (
"errors"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func TestTextResult(t *testing.T) {
result, err := TextResult(map[string]any{"name": "gitea"})
if err != nil {
t.Fatalf("TextResult() error = %v", err)
}
if len(result.Content) != 1 {
t.Fatalf("len(Content) = %d, want 1", len(result.Content))
}
content, ok := result.Content[0].(*mcp.TextContent)
if !ok {
t.Fatalf("Content[0] type = %T, want *mcp.TextContent", result.Content[0])
}
if content.Text != `{"name":"gitea"}` {
t.Errorf("Text = %q, want JSON object", content.Text)
}
}
func TestErrorResult(t *testing.T) {
want := errors.New("failed")
result, err := ErrorResult(want)
if err != nil {
t.Fatalf("ErrorResult() error = %v", err)
}
if !result.IsError {
t.Error("IsError = false, want true")
}
if len(result.Content) != 1 {
t.Fatalf("len(Content) = %d, want 1", len(result.Content))
}
if content, ok := result.Content[0].(*mcp.TextContent); !ok || content.Text != want.Error() {
t.Errorf("Content[0] = %#v, want text %q", result.Content[0], want)
}
}
+106
View File
@@ -0,0 +1,106 @@
package tool
import "github.com/modelcontextprotocol/go-sdk/mcp"
// Property describes one property in a tool's input schema.
type Property struct {
name string
schema map[string]any
required bool
}
// PropertyOption configures one property in a tool's input schema.
type PropertyOption func(*Property)
// NewDefinition builds a tool definition without enabling SDK-side validation.
func NewDefinition(name, description string, annotations *mcp.ToolAnnotations, properties ...Property) *mcp.Tool {
inputProperties := make(map[string]any, len(properties))
required := make([]string, 0, len(properties))
for _, property := range properties {
inputProperties[property.name] = property.schema
if property.required {
required = append(required, property.name)
}
}
inputSchema := map[string]any{
"type": "object",
"properties": inputProperties,
}
if len(required) > 0 {
inputSchema["required"] = required
}
return &mcp.Tool{
Name: name,
Description: description,
Annotations: annotations,
InputSchema: inputSchema,
}
}
func String(name string, options ...PropertyOption) Property {
return newProperty(name, map[string]any{"type": "string"}, options...)
}
func Number(name string, options ...PropertyOption) Property {
return newProperty(name, map[string]any{"type": "number"}, options...)
}
func Boolean(name string, options ...PropertyOption) Property {
return newProperty(name, map[string]any{"type": "boolean"}, options...)
}
func Array(name string, options ...PropertyOption) Property {
return newProperty(name, map[string]any{"type": "array"}, options...)
}
func Object(name string, options ...PropertyOption) Property {
return newProperty(name, map[string]any{"type": "object", "properties": map[string]any{}}, options...)
}
func newProperty(name string, schema map[string]any, options ...PropertyOption) Property {
property := Property{name: name, schema: schema}
for _, option := range options {
option(&property)
}
return property
}
// Required marks the property as required on the parent schema. It is not a
// property-level keyword, so it never touches the emitted property schema.
func Required() PropertyOption {
return func(property *Property) {
property.required = true
}
}
func Description(description string) PropertyOption {
return func(property *Property) {
property.schema["description"] = description
}
}
func Enum(values ...string) PropertyOption {
return func(property *Property) {
property.schema["enum"] = values
}
}
func Default(value any) PropertyOption {
return func(property *Property) {
property.schema["default"] = value
}
}
func Minimum(value float64) PropertyOption {
return func(property *Property) {
property.schema["minimum"] = value
}
}
func Items(schema any) PropertyOption {
return func(property *Property) {
property.schema["items"] = schema
}
}
+71
View File
@@ -0,0 +1,71 @@
package tool
import (
"reflect"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func TestNewDefinition(t *testing.T) {
annotations := &mcp.ToolAnnotations{Title: "Example", ReadOnlyHint: true}
definition := NewDefinition(
"example",
"Example tool",
annotations,
String("owner", Required(), Description("repository owner"), Enum("one", "two"), Default("one")),
Number("page", Required(), Default(1), Minimum(1)),
Boolean("draft"),
Array("labels", Items(map[string]any{"type": "string"})),
Object("inputs", Description("workflow inputs")),
)
if definition.Name != "example" || definition.Description != "Example tool" {
t.Fatalf("definition = %#v", definition)
}
if definition.Annotations != annotations {
t.Fatal("NewDefinition did not preserve annotations")
}
want := map[string]any{
"type": "object",
"properties": map[string]any{
"owner": map[string]any{
"type": "string",
"description": "repository owner",
"enum": []string{"one", "two"},
"default": "one",
},
"page": map[string]any{
"type": "number",
"default": 1,
"minimum": float64(1),
},
"draft": map[string]any{"type": "boolean"},
"labels": map[string]any{
"type": "array",
"items": map[string]any{"type": "string"},
},
"inputs": map[string]any{
"type": "object",
"properties": map[string]any{},
"description": "workflow inputs",
},
},
"required": []string{"owner", "page"},
}
if !reflect.DeepEqual(definition.InputSchema, want) {
t.Errorf("InputSchema = %#v, want %#v", definition.InputSchema, want)
}
}
func TestNewDefinitionWithoutRequiredProperties(t *testing.T) {
definition := NewDefinition("empty", "", nil)
schema := definition.InputSchema.(map[string]any)
if _, ok := schema["required"]; ok {
t.Errorf("InputSchema unexpectedly contains required: %#v", schema)
}
if got := schema["properties"]; !reflect.DeepEqual(got, map[string]any{}) {
t.Errorf("properties = %#v, want empty map", got)
}
}
+125
View File
@@ -0,0 +1,125 @@
package tool
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func callTool(handler Handler, arguments json.RawMessage) (*mcp.CallToolResult, error) {
serverTool := ServerTool{Tool: &mcp.Tool{Name: "example"}, Handler: handler}
return serverTool.MCPHandler()(context.Background(), &mcp.CallToolRequest{
Params: &mcp.CallToolParamsRaw{Arguments: arguments},
})
}
func captureArguments(into *map[string]any) Handler {
return func(_ context.Context, arguments map[string]any) (*mcp.CallToolResult, error) {
*into = arguments
return &mcp.CallToolResult{}, nil
}
}
func TestMCPHandler(t *testing.T) {
var got map[string]any
result, err := callTool(captureArguments(&got), json.RawMessage(`{"count":2,"nested":{"enabled":true}}`))
if err != nil {
t.Fatalf("MCPHandler() error = %v", err)
}
if result == nil {
t.Fatal("MCPHandler() result is nil")
}
if got["count"] != float64(2) {
t.Errorf("count type/value = %T(%v), want float64(2)", got["count"], got["count"])
}
}
func TestMCPHandlerRejectsInvalidArguments(t *testing.T) {
called := false
handler := func(context.Context, map[string]any) (*mcp.CallToolResult, error) {
called = true
return &mcp.CallToolResult{}, nil
}
for _, arguments := range []json.RawMessage{json.RawMessage(`[]`), json.RawMessage(`"text"`), json.RawMessage(`{"broken"`)} {
_, err := callTool(handler, arguments)
assertProtocolErrorCode(t, err, jsonrpc.CodeInvalidParams)
}
if called {
t.Fatal("handler was called with invalid arguments")
}
}
// Tools without parameters are callable with an omitted or null "arguments",
// which is what clients send and what mcp-go accepted before the SDK migration.
func TestMCPHandlerAcceptsAbsentArguments(t *testing.T) {
for _, arguments := range []json.RawMessage{nil, json.RawMessage(`null`)} {
var got map[string]any
if _, err := callTool(captureArguments(&got), arguments); err != nil {
t.Fatalf("MCPHandler() with arguments %s error = %v", arguments, err)
}
if got == nil || len(got) != 0 {
t.Errorf("arguments = %#v, want an empty map", got)
}
}
}
func TestMCPHandlerErrorClassification(t *testing.T) {
for _, test := range []struct {
name string
handler Handler
wantCode int64 // zero when the failure belongs in a tool result rather than a protocol error
}{
{
name: "server error",
handler: func(context.Context, map[string]any) (*mcp.CallToolResult, error) { return nil, errors.New("failed") },
},
{
name: "protocol error",
handler: func(context.Context, map[string]any) (*mcp.CallToolResult, error) {
return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: "failed"}
},
wantCode: jsonrpc.CodeInvalidParams,
},
{
name: "panic",
handler: func(context.Context, map[string]any) (*mcp.CallToolResult, error) { panic("failed") },
},
} {
t.Run(test.name, func(t *testing.T) {
result, err := callTool(test.handler, nil)
if test.wantCode != 0 {
if result != nil {
t.Errorf("result = %#v, want nil", result)
}
assertProtocolErrorCode(t, err, test.wantCode)
return
}
if err != nil {
t.Fatalf("MCPHandler() error = %v, want nil", err)
}
if !result.IsError {
t.Error("IsError = false, want true")
}
if content, ok := result.Content[0].(*mcp.TextContent); !ok || !strings.Contains(content.Text, "failed") {
t.Errorf("Content[0] = %#v, want text naming the failure", result.Content[0])
}
})
}
}
func assertProtocolErrorCode(t *testing.T, err error, want int64) {
t.Helper()
var protocolErr *jsonrpc.Error
if !errors.As(err, &protocolErr) {
t.Fatalf("error = %v, want *jsonrpc.Error", err)
}
if protocolErr.Code != want {
t.Errorf("error code = %d, want %d", protocolErr.Code, want)
}
}
+67 -12
View File
@@ -1,26 +1,39 @@
package tool package tool
import ( import (
"context"
"encoding/json"
"errors"
"fmt"
"slices" "slices"
"strings" "strings"
"gitea.com/gitea/gitea-mcp/pkg/flag" "gitea.com/gitea/gitea-mcp/pkg/flag"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/to"
"github.com/mark3labs/mcp-go/server" "github.com/modelcontextprotocol/go-sdk/jsonrpc"
"github.com/modelcontextprotocol/go-sdk/mcp"
) )
type Handler func(context.Context, map[string]any) (*mcp.CallToolResult, error)
type ServerTool struct {
Tool *mcp.Tool
Handler Handler
}
type Tool struct { type Tool struct {
scope string scope string
write []server.ServerTool write []ServerTool
read []server.ServerTool read []ServerTool
} }
func New(scope string) *Tool { func New(scope string) *Tool {
return &Tool{ return &Tool{
scope: scope, scope: scope,
write: make([]server.ServerTool, 0, 100), write: make([]ServerTool, 0, 100),
read: make([]server.ServerTool, 0, 100), read: make([]ServerTool, 0, 100),
} }
} }
@@ -29,23 +42,23 @@ func (t *Tool) Scope() string {
return t.scope return t.scope
} }
func (t *Tool) RegisterWrite(s server.ServerTool) { func (t *Tool) RegisterWrite(s ServerTool) {
t.write = append(t.write, s) t.write = append(t.write, s)
} }
func (t *Tool) RegisterRead(s server.ServerTool) { func (t *Tool) RegisterRead(s ServerTool) {
t.read = append(t.read, s) t.read = append(t.read, s)
} }
// ReadTools returns the read-only tools registered on this domain, ignoring // ReadTools returns the read-only tools registered on this domain, ignoring
// the read-only and allowlist flags that Tools applies. // the read-only and allowlist flags that Tools applies.
func (t *Tool) ReadTools() []server.ServerTool { func (t *Tool) ReadTools() []ServerTool {
return t.read return t.read
} }
// WriteTools returns the write tools registered on this domain, ignoring the // WriteTools returns the write tools registered on this domain, ignoring the
// read-only and allowlist flags that Tools applies. // read-only and allowlist flags that Tools applies.
func (t *Tool) WriteTools() []server.ServerTool { func (t *Tool) WriteTools() []ServerTool {
return t.write return t.write
} }
@@ -53,8 +66,8 @@ func (t *Tool) WriteTools() []server.ServerTool {
// read-only filter and the scope/tool allowlists (union semantics: a tool is // read-only filter and the scope/tool allowlists (union semantics: a tool is
// kept if its domain's scope is in AllowedScopes OR its name is in // kept if its domain's scope is in AllowedScopes OR its name is in
// AllowedTools). With no allowlists set, all tools pass through unchanged. // AllowedTools). With no allowlists set, all tools pass through unchanged.
func (t *Tool) Tools() []server.ServerTool { func (t *Tool) Tools() []ServerTool {
all := make([]server.ServerTool, 0, len(t.write)+len(t.read)) all := make([]ServerTool, 0, len(t.write)+len(t.read))
if !flag.ReadOnly { if !flag.ReadOnly {
all = append(all, t.write...) all = append(all, t.write...)
} }
@@ -63,7 +76,7 @@ func (t *Tool) Tools() []server.ServerTool {
return all return all
} }
_, scopeAllowed := flag.AllowedScopes[t.scope] _, scopeAllowed := flag.AllowedScopes[t.scope]
filtered := make([]server.ServerTool, 0, len(all)) filtered := make([]ServerTool, 0, len(all))
for _, st := range all { for _, st := range all {
_, toolAllowed := flag.AllowedTools[st.Tool.Name] _, toolAllowed := flag.AllowedTools[st.Tool.Name]
if scopeAllowed || toolAllowed { if scopeAllowed || toolAllowed {
@@ -73,6 +86,48 @@ func (t *Tool) Tools() []server.ServerTool {
return filtered return filtered
} }
// MCPHandler adapts a project handler to the official SDK's low-level handler.
func (s ServerTool) MCPHandler() mcp.ToolHandler {
return func(ctx context.Context, req *mcp.CallToolRequest) (result *mcp.CallToolResult, err error) {
defer func() {
if recovered := recover(); recovered != nil {
result, err = to.ErrorResult(fmt.Errorf("panic recovered in %s tool handler: %v", s.Tool.Name, recovered))
}
}()
arguments, err := decodeArguments(req.Params.Arguments)
if err != nil {
return nil, err
}
result, err = s.Handler(ctx, arguments)
if err != nil {
var protocolErr *jsonrpc.Error
if errors.As(err, &protocolErr) {
return nil, err
}
return to.ErrorResult(err)
}
return result, nil
}
}
func decodeArguments(raw json.RawMessage) (map[string]any, error) {
// An omitted and a null "arguments" both mean the tool was called without any.
if len(raw) == 0 || string(raw) == "null" {
return map[string]any{}, nil
}
var arguments map[string]any
if err := json.Unmarshal(raw, &arguments); err != nil {
return nil, &jsonrpc.Error{
Code: jsonrpc.CodeInvalidParams,
Message: fmt.Sprintf("invalid tool arguments: %v", err),
}
}
return arguments, nil
}
// warnUnmatched logs the names present in allowlist but absent from known, // warnUnmatched logs the names present in allowlist but absent from known,
// via logUnmatched, so WarnUnmatchedAllowedTools and WarnUnmatchedAllowedScopes // via logUnmatched, so WarnUnmatchedAllowedTools and WarnUnmatchedAllowedScopes
// share the same "collect, sort, no-op when empty" logic and can't drift. // share the same "collect, sort, no-op when empty" logic and can't drift.
+4 -5
View File
@@ -6,15 +6,14 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/flag" "gitea.com/gitea/gitea-mcp/pkg/flag"
"github.com/mark3labs/mcp-go/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mark3labs/mcp-go/server"
) )
func makeTool(name string) server.ServerTool { func makeTool(name string) ServerTool {
return server.ServerTool{Tool: mcp.NewTool(name)} return ServerTool{Tool: &mcp.Tool{Name: name}}
} }
func names(sts []server.ServerTool) []string { func names(sts []ServerTool) []string {
out := make([]string, len(sts)) out := make([]string, len(sts))
for i, st := range sts { for i, st := range sts {
out[i] = st.Tool.Name out[i] = st.Tool.Name
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"local>gitea/renovate-config"
],
}
+96
View File
@@ -0,0 +1,96 @@
#!/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"