29 Commits

Author SHA1 Message Date
silverwind
c57e4c2e57 fix: prevent silent write loss on 301 redirects (#154)
When a Gitea repo is renamed, the API returns a 301 redirect. Go's default `http.Client` follows 301/302/303 redirects by changing the HTTP method from PATCH/POST/PUT to GET and dropping the request body. This causes mutating API calls (edit PR, create issue, etc.) to silently appear to succeed while no write actually occurs — the client receives the current resource data via the redirected GET and returns it as if the edit worked.

## Fix

Add a `CheckRedirect` function to both HTTP clients (SDK client in `gitea.go` and REST client in `rest.go`) that returns `http.ErrUseLastResponse` for non-GET/HEAD methods. This surfaces the redirect as an error instead of silently downgrading the request. GET/HEAD reads continue to follow redirects normally.

## Tests

- `TestCheckRedirect`: table-driven unit tests for all HTTP methods + redirect limit
- `TestDoJSON_RepoRenameRedirect`: regression test with `httptest` server proving PATCH to a 301 endpoint returns an error instead of silently succeeding
- `TestDoJSON_GETRedirectFollowed`: verifies GET reads still follow 301 redirects

*This PR was authored by Claude.*

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/154
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-03-13 17:45:59 +00:00
silverwind
22fc663387 Partially revert #149: remove Content-Type middleware, keep ErrServerClosed fix (#150)
Reverts the Content-Type middleware and custom server/mux plumbing from #149, keeping only the `http.ErrServerClosed` fix which is a legitimate bug.

The middleware is unnecessary because rmcp's `post_message` already returns early for 202/204 before any Content-Type validation. Both Go MCP SDKs (`modelcontextprotocol/go-sdk` used by GitHub's MCP server, and `mark3labs/mcp-go` used here) intentionally omit Content-Type on 202 responses per the MCP spec. See #148 for the full analysis.

*PR written by Claude on behalf of @silverwind*

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/150
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-03-13 09:31:26 +00:00
Bo-Yi Wu
e0abd256a3 feat(mcp): add MCP tool to list organization repositories (#152)
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/152
Co-authored-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Co-committed-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2026-03-12 05:46:07 +00:00
Mutex
73263e74d0 http: set Content-Type for 202/204 streamable responses (#149)
Fixes: #148
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/149
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Mutex <gitea314@libertyprime.org>
Co-committed-by: Mutex <gitea314@libertyprime.org>
2026-03-09 13:07:27 +00:00
silverwind
bba612d238 Consolidate tools from 110 to 45 using method dispatch (#143)
Consolidate 110 individual MCP tools down to 45 using a method dispatch pattern, aligning tool names with the GitHub MCP server conventions.

**Motivation:** LLMs work better with fewer, well-organized tools. The method dispatch pattern (used by GitHub's MCP server) groups related operations under read/write tools with a `method` parameter.

**Changes:**
- Group related tools into `_read`/`_write` pairs with method dispatch (e.g. `issue_read`, `issue_write`, `pull_request_read`, `pull_request_write`)
- Rename tools to match GitHub MCP naming (`get_file_contents`, `create_or_update_file`, `list_issues`, `list_pull_requests`, etc.)
- Rename `pageSize` to `perPage` for GitHub MCP compat
- Move issue label ops (`add_labels`, `remove_label`, etc.) into `issue_write`
- Merge `create_file`/`update_file` into `create_or_update_file` with optional `sha`
- Make `delete_file` require `sha`
- Add `get_labels` method to `issue_read`
- Add shared helpers: `GetInt64Slice`, `GetStringSlice`, `GetPagination` in params package
- Unexport all dispatch handler functions
- Fix: pass assignees/milestone in `CreateIssue`, bounds check in `GetFileContent`

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/143
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-03-06 19:12:15 +00:00
silverwind
c3db4fb65f feat: slim tool responses (#141)
Reduce token usage by slimming tool responses. Instead of returning full Gitea SDK objects (with nested user/repo objects, avatars, permissions, etc.), each operation now has a colocated `slim.go` that extracts only the fields an LLM needs. List endpoints return even fewer fields than single-item endpoints.

Other changes:
- Add `params` helpers to DRY parameter extraction across 40+ handlers
- Remove `{"Result": ...}` wrapper for flatter responses
- Reduce default pageSize from 100 to 30

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

*Created by Claude on behalf of @silverwind*

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/141
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-03-05 05:56:23 +00:00
silverwind
9ce5604e4c Improve CLI help text and flags (#139)
## Summary
- Replace default `flag.Usage` with custom 2-column layout using `text/tabwriter`
- Add short and long aliases for all CLI flags
- Add environment variables section with sorted entries
- Handle `-version` flag in `Execute()`

## Sample output
```
Usage: gitea-mcp [options]

Options:
  -t, -transport <type>   Transport type: stdio or http (default: stdio)
  -H, -host <url>         Gitea host URL (default: https://gitea.com)
  -p, -port <number>      HTTP server port (default: 8080)
  -T, -token <token>      Personal access token
  -r, -read-only          Expose only read-only tools
  -d, -debug              Enable debug mode
  -k, -insecure           Ignore TLS certificate errors
  -v, -version            Print version and exit

Environment variables:
  GITEA_ACCESS_TOKEN   Provide access token
  GITEA_DEBUG          Set to 'true' for debug mode
  GITEA_HOST           Override Gitea host URL
  GITEA_INSECURE       Set to 'true' to ignore TLS errors
  GITEA_READONLY       Set to 'true' for read-only mode
  MCP_MODE             Override transport mode
```

*Created by Claude on behalf of @silverwind*

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/139
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-02-26 18:49:37 +00:00
silverwind
653781a199 fix: replace goreleaser-action with direct go install
The goreleaser/goreleaser-action@v7 JS wrapper was crashing in CI
before goreleaser could run. Install goreleaser via `go install`
instead to avoid the action's compatibility issues with the runner.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 02:09:18 +01:00
silverwind
67a1e1e7fe feat: accept string values for all numeric input parameters (#138)
## Summary

- MCP clients may send numbers as strings. This adds `ToInt64` and `GetOptionalInt` helpers to `pkg/params` and replaces all raw `.(float64)` type assertions across operation handlers to accept both `float64` and string inputs.

## Test plan

- [x] Verify `go test ./...` passes
- [x] Test with an MCP client that sends numeric parameters as strings

*Created by Claude on behalf of @silverwind*

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/138
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-02-25 23:28:14 +00:00
silverwind
4a2935d898 feat: add title parameter to merge_pull_request tool (#134)
## Summary
- Add missing `title` parameter to `merge_pull_request` tool for custom merge commit titles
- Use `params.GetIndex()` for consistent index parameter handling (supports both string and number inputs)
- Add test for `MergePullRequestFn`

Closes #120

## Test plan
- [x] `go test ./operation/pull/ -run TestMergePullRequestFn` passes
- [x] All existing tests pass (`go test ./...`)
- [x] Build succeeds (`make build`)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/134
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <silverwind@noreply.gitea.com>
Co-committed-by: silverwind <silverwind@noreply.gitea.com>
2026-02-25 19:05:09 +00:00
silverwind
6540693771 fix: parse Authorization header case-insensitively and support token format (#137)
## Summary
- Make auth header parsing RFC 7235 compliant by comparing the scheme case-insensitively (`bearer`, `BEARER`, etc. all work now)
- Add support for Gitea-style `token <value>` format in addition to `Bearer <value>`

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

---
*This PR was authored by Claude.*

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/137
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-02-25 19:04:14 +00:00
silverwind
3b9236695c fix: add missing required attributes to search tool schemas (#135)
## Summary
- Add `mcp.Required()` to `keyword` in `search_repos` and `search_users` tool schemas
- Add `mcp.Required()` to `org` and `query` in `search_org_teams` tool schema
- Add test verifying required fields are set on all search tool schemas
- Fixes MCP clients failing with `keyword is required` because the schema didn't declare the field as required

Closes #115

---
*This PR was authored by Claude.*

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/135
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <silverwind@noreply.gitea.com>
Co-committed-by: silverwind <silverwind@noreply.gitea.com>
2026-02-25 19:00:17 +00:00
silverwind
723a30ae23 fix: replace deprecated SDK calls in timetracking (#136)
## Summary
- Replace `client.GetMyStopwatches()` with `client.ListMyStopwatches(ListStopwatchesOptions{})`
- Replace `client.GetMyTrackedTimes()` with `client.ListMyTrackedTimes(ListTrackedTimesOptions{})`
- Fixes staticcheck SA1019 lint errors for deprecated API usage

## Test plan
- [x] `make lint` passes with 0 issues
- [x] `go test ./...` passes

*Created by Claude on behalf of @silverwind*

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/136
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-02-25 03:28:59 +00:00
techknowlogick
7bbe015ea7 Bump to go 1.26 2026-02-22 17:13:24 +00:00
silverwind
bb9470a259 chore: update Go and Actions dependencies (#132)
## Summary
- Update Go dependencies: gitea SDK v0.22.1→v0.23.2, mcp-go v0.42.0→v0.44.0, zap v1.27.0→v1.27.1, go-version v1.7.0→v1.8.0, x/crypto v0.43.0→v0.48.0, x/sys v0.37.0→v0.41.0
- Update Actions: checkout v4→v6, setup-go v5→v6, build-push-action v5→v6, goreleaser-action v6→v7

## Test plan
- [x] `go test ./...` passes
- [x] `make build` succeeds

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/132
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-02-22 17:10:25 +00:00
silverwind
8728c04748 chore: add golangci-lint, bump Go to 1.26, fix all lint issues (#133)
## Summary
- Add `.golangci.yml` with linter configuration matching the main gitea repo
- Add `lint`, `lint-fix`, `lint-go`, `lint-go-fix`, and `security-check` Makefile targets
- Add `tidy` Makefile target (extracts min Go version from `go.mod` for `-compat` flag)
- Bump minimum Go version to 1.26
- Update golangci-lint to v2.10.1
- Replace `golang/govulncheck-action` with `make security-check` in CI
- Add `make lint` step to CI
- Fix all lint issues across the codebase (formatting, `errors.New` vs `fmt.Errorf`, `any` vs `interface{}`, unused returns, stuttering names, Go 1.26 `new(expr)`, etc.)
- Remove unused `pkg/ptr` package (inlined by Go 1.26 `new(expr)`)
- Remove dead linter exclusions (staticcheck, gocritic, testifylint, dupl)

## Test plan
- [x] `make lint` passes
- [x] `go test ./...` passes
- [x] `make build` succeeds

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/133
Reviewed-by: techknowlogick <techknowlogick@noreply.gitea.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-02-22 17:10:04 +00:00
silverwind
4d5fa3ab2c feat: accept string or number for index parameters (#131)
This change makes index parameters more flexible by accepting both numeric and string values. LLM agents often pass issue/PR indices as strings (e.g., "123") since they appear as string identifiers in URLs and CLI contexts. The implementation:

- Created `pkg/params` package with `GetIndex()` helper function
- Updated 25+ tool functions across issue, pull, label, and timetracking operations
- Improved error messages to say "must be a valid integer" instead of misleading "is required"
- Added comprehensive tests for both numeric and string inputs

Based on #122 by @jamespharaoh with review feedback applied (replaced custom `contains()` test helper with `strings.Contains`). Verified working in Claude Code.

Fixes: https://gitea.com/gitea/gitea-mcp/issues/121
Fixes: https://gitea.com/gitea/gitea-mcp/issues/122
---------

Co-authored-by: James Pharaoh <james@pharaoh.uk>
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/131
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-02-20 23:47:22 +00:00
silverwind
21e4e1b42b feat: add edit_pull_request tool (#125)
## Summary
- Add `edit_pull_request` MCP tool to modify pull request properties
- Supports editing title, body, base branch, assignees, milestone, state, and maintainer edit permission
- Enables toggling WIP/draft status by modifying the title prefix

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

## Test plan
- [x] `go test ./...` passes
- [x] Verified against gitea.com: toggled WIP on/off via title edit, changed PR state

🤖 Generated with [Claude Code](https://claude.ai/claude-code)

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/125
Reviewed-by: Bo-Yi Wu (吳柏毅) <appleboy.tw@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-02-13 13:26:21 +00:00
tylermitchell
4aacfe348a feat(pull): add merge_pull_request tool (#123)
Add MCP tool to merge pull requests with support for:
- Multiple merge styles (merge, rebase, rebase-merge, squash, fast-forward-only)
- Custom merge commit messages
- Optional branch deletion after merge
- Detailed error handling for merge conflicts and edge cases

Updated all README files (English, Simplified Chinese, Traditional Chinese)
with the new tool entry.

---------

Co-authored-by: Tyler Potts <tyler@adhdafterdiagnosis.com>
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/123
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: silverwind <silverwind@noreply.gitea.com>
Co-authored-by: tylermitchell <tylermitchell@noreply.gitea.com>
Co-committed-by: tylermitchell <tylermitchell@noreply.gitea.com>
2026-02-11 17:53:09 +00:00
silverwind
1f7392305f docs: add --scope user to Claude Code examples in READMEs (#118)
Small followup to https://gitea.com/gitea/gitea-mcp/pulls/117. By using the user scope, the MCP server connection will be saved into the user's home directory, making it available for all repos, which is more useful than having to do this per-repo.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------

Co-authored-by: hiifong <f@f.style>
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/118
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: hiifong <f@f.style>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-02-06 03:16:37 +00:00
Gustav
c3b24d65fe added support for get_pull_request_diff (#119)
This function call is needed to be able to do AI code review to actually get the diff from the PR.

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/119
Reviewed-by: silverwind <silverwind@noreply.gitea.com>
Reviewed-by: hiifong <f@f.style>
Co-authored-by: Gustav <tvarsis@hotmail.com>
Co-committed-by: Gustav <tvarsis@hotmail.com>
2026-02-06 03:14:47 +00:00
silverwind
dcd01441c5 docs: add Claude Code usage example to README files (#117)
I verified this is working:

```bash
$ claude mcp list | grep gitea
gitea: go run gitea.com/gitea/gitea-mcp@latest -t stdio - ✓ Connected
```

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/117
Reviewed-by: hiifong <f@f.style>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
2026-02-04 02:45:37 +00:00
Thomas Foubert
2dbfc62042 feat(pull): add PR review tools (#111)
Add 8 new MCP tools for managing pull request reviews:

Read operations:
- list_pull_request_reviews: list all reviews for a PR
- get_pull_request_review: get a specific review by ID
- list_pull_request_review_comments: list inline comments for a review

Write operations:
- create_pull_request_review: create a review with optional inline comments
- submit_pull_request_review: submit a pending review
- delete_pull_request_review: delete a review
- dismiss_pull_request_review: dismiss a review with optional message
- delete_pull_request_reviewer: remove reviewer requests from a PR

Fixes #107

Co-authored-by: hiifong <i@hiif.ong>
Co-authored-by: hiifong <f@f.style>
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/111
Co-authored-by: Thomas Foubert <thomas.foubert@mistral.ai>
Co-committed-by: Thomas Foubert <thomas.foubert@mistral.ai>
2026-01-07 01:41:41 +00:00
runixer
e851f542f5 fix(actions): change workflow_id parameter type from number to string (#114)
The Gitea API expects workflow_id as a string (filename like 'my-workflow.yml'
or numeric ID as string), not as a number. This was causing 404 errors when
trying to get or dispatch workflows.

Affected tools:
- get_repo_action_workflow
- dispatch_repo_action_workflow

Co-authored-by: runixer <runixer@yandex.ru>
Co-authored-by: hiifong <f@f.style>
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/114
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: runixer <runixer@noreply.gitea.com>
Co-committed-by: runixer <runixer@noreply.gitea.com>
2026-01-01 14:29:06 +00:00
tylermitchell
b8f2377f47 feat: add time tracking tools (stopwatch and tracked times) (#113)
## Summary
Implements 9 new MCP tools for Gitea time tracking functionality, enabling AI assistants to help users track time spent on issues.

## New Tools

### Stopwatch Tools
| Tool | Type | Description |
|------|------|-------------|
| `start_stopwatch` | Write | Start timing work on an issue |
| `stop_stopwatch` | Write | Stop stopwatch and record tracked time |
| `delete_stopwatch` | Write | Cancel stopwatch without recording |
| `get_my_stopwatches` | Read | List all active stopwatches for current user |

### Tracked Time Tools
| Tool | Type | Description |
|------|------|-------------|
| `list_tracked_times` | Read | Get tracked times for a specific issue |
| `add_tracked_time` | Write | Manually add time entry to an issue |
| `delete_tracked_time` | Write | Remove a tracked time entry |
| `list_repo_times` | Read | Get all tracked times for a repository |
| `get_my_times` | Read | Get all tracked times for current user |

## Implementation
- Added new `operation/timetracking/timetracking.go` module
- Follows existing patterns from milestone.go
- Uses Gitea SDK v0.22.1 time tracking methods
- Registered in `operation/operation.go`

Fixes #112

Co-authored-by: Tyler Potts <tyler@adhdafterdiagnosis.com>
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/113
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: hiifong <f@f.style>
Co-authored-by: tylermitchell <tylermitchell@noreply.gitea.com>
Co-committed-by: tylermitchell <tylermitchell@noreply.gitea.com>
2026-01-01 14:28:50 +00:00
hiifong
017ca94a86 format 2025-12-18 23:14:11 +08:00
Shawn Anderson
17119bcab6 feat: add Gitea Actions support (secrets, variables, workflows, runs, jobs, logs) (#110)
# Add Gitea Actions support (secrets, variables, workflows, runs, jobs, logs)

## Summary

This PR adds comprehensive support for Gitea Actions API to the MCP server, enabling users to manage Actions secrets, variables, workflows, runs, jobs, and logs through the Model Context Protocol interface.

## New Features

### Actions Secrets (Repository & Organization Level)
- `list_repo_action_secrets` - List repository secrets (metadata only, values never exposed)
- `upsert_repo_action_secret` - Create or update a repository secret
- `delete_repo_action_secret` - Delete a repository secret
- `list_org_action_secrets` - List organization secrets
- `upsert_org_action_secret` - Create or update an organization secret
- `delete_org_action_secret` - Delete an organization secret

### Actions Variables (Repository & Organization Level)
- `list_repo_action_variables` - List repository variables
- `get_repo_action_variable` - Get a specific repository variable
- `create_repo_action_variable` - Create a repository variable
- `update_repo_action_variable` - Update a repository variable
- `delete_repo_action_variable` - Delete a repository variable
- `list_org_action_variables` - List organization variables
- `get_org_action_variable` - Get a specific organization variable
- `create_org_action_variable` - Create an organization variable
- `update_org_action_variable` - Update an organization variable
- `delete_org_action_variable` - Delete an organization variable

### Actions Workflows
- `list_repo_action_workflows` - List repository workflows
- `get_repo_action_workflow` - Get a specific workflow by ID
- `dispatch_repo_action_workflow` - Trigger (dispatch) a workflow run with optional inputs

### Actions Runs
- `list_repo_action_runs` - List workflow runs with optional status filtering
- `get_repo_action_run` - Get a specific run by ID
- `cancel_repo_action_run` - Cancel a running workflow
- `rerun_repo_action_run` - Rerun a workflow (with fallback routes for version compatibility)

### Actions Jobs
- `list_repo_action_jobs` - List all jobs in a repository
- `list_repo_action_run_jobs` - List jobs for a specific workflow run

### Actions Job Logs
- `get_repo_action_job_log_preview` - Get log preview with tail/limit support (chat-friendly)
- `download_repo_action_job_log` - Download full job logs to file (default: `~/.gitea-mcp/artifacts/actions-logs/`)

## Implementation Details

### Architecture
- Follows existing codebase patterns: new `operation/actions/` package with tools registered via `Tool.RegisterRead/Write()`
- Uses Gitea SDK (`code.gitea.io/sdk/gitea v0.22.1`) where endpoints are available
- Shared REST helper (`pkg/gitea/rest.go`) for endpoints not yet in SDK (workflows, runs, jobs, logs)

### Security
- **Secrets never expose values**: List/get operations return only safe metadata (name, description, created_at)
- Request-scoped token support: HTTP Bearer tokens properly respected (fixes issue where wiki REST calls were hardcoding `flag.Token`)

### Compatibility
- Fallback route logic for dispatch/rerun endpoints (handles Gitea version differences)
- Clear error messages when endpoints aren't available, referencing Gitea 1.24 API docs
- Graceful handling of 404/405 responses for unsupported endpoints

### Testing
- Unit tests for REST helper token precedence
- Unit tests for log truncation/formatting helpers
- All existing tests pass

## Files Changed

- **New**: `operation/actions/*` - Complete Actions module (secrets, variables, runs, logs)
- **New**: `pkg/gitea/rest.go` - Shared REST helper with token context support
- **New**: `pkg/gitea/rest_test.go` - Tests for REST helper
- **Modified**: `operation/operation.go` - Register Actions tools
- **Modified**: `operation/wiki/wiki.go` - Refactored to use shared REST helper (removed hardcoded token)
- **Modified**: `README.md` - Added all new tools to documentation

## Testing

```bash
# All tests pass
go test ./...

# Build succeeds
make build
```

## Example Usage

```python
# List repository secrets
mcp.call_tool("list_repo_action_secrets", {"owner": "user", "repo": "myrepo"})

# Trigger a workflow
mcp.call_tool("dispatch_repo_action_workflow", {
    "owner": "user",
    "repo": "myrepo",
    "workflow_id": 123,
    "ref": "main",
    "inputs": {"deploy_env": "production"}
})

# Get job log preview (last 100 lines)
mcp.call_tool("get_repo_action_job_log_preview", {
    "owner": "user",
    "repo": "myrepo",
    "job_id": 456,
    "tail_lines": 100
})
```

## Breaking Changes

None - this is a purely additive change.

## Related Issues

Fixes #[issue-number] (if applicable)

## Checklist

- [x] Code follows existing patterns and conventions
- [x] All tests pass
- [x] Documentation updated (README.md)
- [x] No breaking changes
- [x] Security considerations addressed (secrets never expose values)
- [x] Error handling implemented with clear messages
- [x] Version compatibility considered (fallback routes)

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/110
Reviewed-by: hiifong <f@f.style>
Co-authored-by: Shawn Anderson <sanderson@eye-catcher.com>
Co-committed-by: Shawn Anderson <sanderson@eye-catcher.com>
2025-12-18 15:00:52 +00:00
Stanislav Krasnyi
8b06d7154e Fix assignees parsing in Edit Issue (#109)
# Fix assignees parsing in EditIssueFn

## Problem
The `EditIssueFn` function in `operation/issue/issue.go` had a bug where assignees were not being properly parsed from the request arguments. The code was attempting to directly cast the assignees array to `[]string`, but the MCP framework passes arrays as `[]interface{}`. This caused the assignees to appear empty when editing issues through the Gitea-MCP endpoint.

## Solution
The assignees parsing logic in the `EditIssueFn` function has been fixed to properly handle the `[]interface{}` type that comes from the MCP framework:

1. Check if the assignees argument exists in the request
2. Type-assert it to `[]interface{}`
3. Iterate through each element and convert it to string
4. Assign the properly parsed string slice to `opt.Assignees`

## Changes
- Modified `operation/issue/issue.go` in the `EditIssueFn` function
- The fix follows the same pattern used successfully in other parts of the codebase (pull/pull.go and label/label.go)

## Testing
- The fix has been implemented and tested to ensure assignees are properly parsed and applied to issues
- No existing functionality was broken
- The solution maintains backward compatibility

## Impact
This fix resolves the issue where assignees were not being set when using the Gitea-MCP endpoint `/repos/{owner}/{repo}/issues/{index} (PATCH)` to edit issues with assignees.

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/109
Co-authored-by: Stanislav Krasnyi <stan.krasnyi@gmail.com>
Co-committed-by: Stanislav Krasnyi <stan.krasnyi@gmail.com>
2025-12-13 08:37:15 +00:00
Nassim Amar
bdd9fb1816 Milestone addition and Windows build support (#104)
## Milestone Implementation

The `milestone.go` file adds comprehensive milestone functionality to the Gitea MCP server with the following MCP tools:

### Tools Added:

1. __`get_milestone`__ - Retrieves a specific milestone by ID
2. __`list_milestones`__ - Lists repository milestones with filtering options
3. __`create_milestone`__ - Creates new milestones with title, description, and due dates
4. __`edit_milestone`__ - Modifies existing milestones including state changes
5. __`delete_milestone`__ - Removes milestones from repositories

### Integration with Other Components:

__Issue Management__:

- Issues can be associated with milestones through the `edit_issue` tool
- The `milestone` parameter (number) links issues to specific milestones
- This creates traceability between development tasks and project milestones

__Pull Request Filtering__:

- Pull requests can be filtered by milestone using the `milestone` parameter
- This enables viewing all PRs related to a specific milestone

### Key Features:

- __State Management__: Milestones support "open" and "closed" states
- __Due Dates__: Optional due dates for milestone tracking
- __Pagination__: List operations support pagination for large datasets
- __Full CRUD Operations__: Complete create, read, update, delete capabilities

### Workflow Integration:

While there's no direct commit message integration shown in the current implementation, milestones provide project planning capabilities that integrate with:

- Issue tracking (linking issues to milestones)
- Development workflow (filtering PRs by milestone)
- Project management (due dates, state tracking)

This addition enables project management capabilities within the Gitea MCP server, allowing users to organize work into milestones and track progress across issues and pull requests.

----------------------
feat: add Windows build support with PowerShell and batch scripts

Add comprehensive Windows build support including PowerShell script (build.ps1) and batch wrapper (build.bat) that replicate Makefile functionality. The scripts provide targets for building, installing, cleaning, and development with hot reload support. Also includes detailed BUILDING.md documentation for Windows users.

Co-authored-by: hiifong <i@hiif.ong>
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/104
Reviewed-by: hiifong <i@hiif.ong>
Co-authored-by: Nassim Amar <namar0x0309@pm.me>
Co-committed-by: Nassim Amar <namar0x0309@pm.me>
2025-11-02 03:18:57 +00:00
63 changed files with 6702 additions and 1899 deletions

View File

@@ -14,7 +14,7 @@ jobs:
DOCKER_LATEST: nightly DOCKER_LATEST: nightly
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v6
with: with:
fetch-depth: 0 # all history for all branches and tags fetch-depth: 0 # all history for all branches and tags
@@ -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@v5 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile

View File

@@ -10,20 +10,17 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v6
with: with:
go-version: stable go-version: stable
- name: Install GoReleaser
run: go install github.com/goreleaser/goreleaser/v2@latest
- name: Run GoReleaser - name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6 run: goreleaser release --clean
with:
distribution: goreleaser
# 'latest', 'nightly', or a semver
version: "~> v2"
args: release --clean
env: env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GORELEASER_FORCE_TOKEN: "gitea" GORELEASER_FORCE_TOKEN: "gitea"
@@ -35,7 +32,7 @@ jobs:
DOCKER_LATEST: latest DOCKER_LATEST: latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v6
with: with:
fetch-depth: 0 # all history for all branches and tags fetch-depth: 0 # all history for all branches and tags
@@ -58,7 +55,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@v5 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile

View File

@@ -7,20 +7,13 @@ jobs:
check-and-test: check-and-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- uses: actions/setup-go@v5 - uses: actions/setup-go@v6
with: with:
go-version-file: 'go.mod' go-version-file: 'go.mod'
- name: lint
run: make lint
- name: build - name: build
run: | run: make build
make build - name: security-check
run: make security-check
govulncheck_job:
runs-on: ubuntu-latest
name: Run govulncheck
steps:
- id: govulncheck
uses: golang/govulncheck-action@v1
with:
go-version-file: 'go.mod'
go-package: ./...

113
.golangci.yml Normal file
View File

@@ -0,0 +1,113 @@
version: "2"
output:
sort-order:
- file
linters:
default: none
enable:
- bidichk
- bodyclose
- depguard
- errcheck
- forbidigo
- gocheckcompilerdirectives
- gocritic
- govet
- ineffassign
- mirror
- modernize
- nakedret
- nilnil
- nolintlint
- perfsprint
- revive
- staticcheck
- testifylint
- unconvert
- unparam
- unused
- usestdlibvars
- usetesting
- wastedassign
settings:
depguard:
rules:
main:
deny:
- pkg: io/ioutil
desc: use os or io instead
- pkg: golang.org/x/exp
desc: it's experimental and unreliable
- pkg: github.com/pkg/errors
desc: use builtin errors package instead
nolintlint:
allow-unused: false
require-explanation: true
require-specific: true
gocritic:
enabled-checks:
- equalFold
disabled-checks: []
revive:
severity: error
rules:
- name: blank-imports
- name: constant-logical-expr
- name: context-as-argument
- name: context-keys-type
- name: dot-imports
- name: empty-lines
- name: error-return
- name: error-strings
- name: exported
- name: identical-branches
- name: if-return
- name: increment-decrement
- name: modifies-value-receiver
- name: package-comments
- name: redefines-builtin-id
- name: superfluous-else
- name: time-naming
- name: unexported-return
- name: var-declaration
- name: var-naming
disabled: true
staticcheck:
checks:
- all
testifylint: {}
usetesting:
os-temp-dir: true
perfsprint:
concat-loop: false
govet:
enable:
- nilness
- unusedwrite
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
rules:
- linters:
- errcheck
- staticcheck
- unparam
path: _test\.go
issues:
max-issues-per-linter: 0
max-same-issues: 0
formatters:
enable:
- gofmt
- gofumpt
settings:
gofumpt:
extra-rules: true
exclusions:
generated: lax
run:
timeout: 10m

71
AGENTS.md Normal file
View File

@@ -0,0 +1,71 @@
# AGENTS.md
This file provides guidance to AI coding agents when working with code in this repository.
## Development Commands
**Build**: `make build` - Build the gitea-mcp binary
**Install**: `make install` - Build and install to GOPATH/bin
**Clean**: `make clean` - Remove build artifacts
**Test**: `go test ./...` - Run all tests
**Hot reload**: `make dev` - Start development server with hot reload (requires air)
**Dependencies**: `make vendor` - Tidy and verify module dependencies
## Architecture Overview
This is a **Gitea MCP (Model Context Protocol) Server** written in Go that provides MCP tools for interacting with Gitea repositories, issues, pull requests, users, and more.
**Core Components**:
- `main.go` + `cmd/cmd.go`: CLI entry point and flag parsing
- `operation/operation.go`: Main server setup and tool registration
- `pkg/tool/tool.go`: Tool registry with read/write categorization
- `operation/*/`: Individual tool modules (user, repo, issue, pull, search, wiki, etc.)
**Transport Modes**:
- **stdio** (default): Standard input/output for MCP clients
- **http**: HTTP server mode on configurable port (default 8080)
**Authentication**:
- Global token via `--token` flag or `GITEA_ACCESS_TOKEN` env var
- HTTP mode supports per-request Bearer token override in Authorization header
- Token precedence: HTTP Authorization header > CLI flag > environment variable
**Tool Organization**:
- Tools are categorized as read-only or write operations
- `--read-only` flag exposes only read tools
- Tool modules register via `Tool.RegisterRead()` and `Tool.RegisterWrite()`
**Key Configuration**:
- Default Gitea host: `https://gitea.com` (override with `--host` or `GITEA_HOST`)
- Environment variables can override CLI flags: `MCP_MODE`, `GITEA_READONLY`, `GITEA_DEBUG`, `GITEA_INSECURE`
- Logs are written to `~/.gitea-mcp/gitea-mcp.log` with rotation
## Available Tools
The server provides 40+ MCP tools covering:
- **User**: get_my_user_info, get_user_orgs, search_users
- **Repository**: create_repo, fork_repo, list_my_repos, search_repos
- **Branches/Tags**: create_branch, delete_branch, list_branches, create_tag, list_tags
- **Files**: get_file_content, create_file, update_file, delete_file, get_dir_content
- **Issues**: create_issue, list_repo_issues, create_issue_comment, edit_issue
- **Pull Requests**: create_pull_request, list_repo_pull_requests, get_pull_request_by_index
- **Releases**: create_release, list_releases, get_latest_release
- **Wiki**: create_wiki_page, update_wiki_page, list_wiki_pages
- **Search**: search_repos, search_users, search_org_teams
- **Version**: get_gitea_mcp_server_version
## Common Development Patterns
**Testing**: Use `go test ./operation -run TestFunctionName` for specific tests
**Token Context**: HTTP requests use `pkg/context.TokenContextKey` for request-scoped token access
**Flag Access**: All packages access configuration via global variables in `pkg/flag/flag.go`
**Graceful Shutdown**: HTTP mode implements graceful shutdown with 10-second timeout on SIGTERM/SIGINT

63
BUILDING.md Normal file
View File

@@ -0,0 +1,63 @@
# Building gitea-mcp on Windows
This project includes PowerShell and batch scripts to build the gitea-mcp application on Windows systems.
## Prerequisites
- Go 1.24 or later
- Git (for version information)
- PowerShell 5.1 or later (included with Windows 10/11)
## Build Scripts
### PowerShell Script (`build.ps1`)
The main build script that replicates all Makefile functionality:
```powershell
# Show help
.\build.ps1 help
# Build the application
.\build.ps1 build
# Install the application
.\build.ps1 install
# Clean build artifacts
.\build.ps1 clean
# Run in development mode (hot reload)
.\build.ps1 dev
# Update vendor dependencies
.\build.ps1 vendor
```
### Batch File Wrapper (`build.bat`)
A simple wrapper to run the PowerShell script:
```cmd
# Run with default help target
build.bat
# Run specific target
build.bat build
build.bat install
```
## Available Targets
- **help** - Print help message
- **build** - Build the application executable
- **install** - Build and install to GOPATH/bin
- **uninstall** - Remove executable from GOPATH/bin
- **clean** - Remove build artifacts
- **air** - Install air for hot reload development
- **dev** - Run with hot reload development
- **vendor** - Tidy and verify Go module dependencies
## Output
The build process creates `gitea-mcp.exe` in the project directory.

View File

@@ -47,17 +47,24 @@ This is a **Gitea MCP (Model Context Protocol) Server** written in Go that provi
## Available Tools ## Available Tools
The server provides 40+ MCP tools covering: The server provides 45 MCP tools covering:
- **User**: get_my_user_info, get_user_orgs, search_users - **User**: get_me, get_user_orgs
- **Repository**: create_repo, fork_repo, list_my_repos, search_repos - **Search**: search_users, search_repos, search_org_teams
- **Branches/Tags**: create_branch, delete_branch, list_branches, create_tag, list_tags - **Repository**: create_repo, fork_repo, list_my_repos
- **Files**: get_file_content, create_file, update_file, delete_file, get_dir_content - **Branches**: list_branches, create_branch, delete_branch
- **Issues**: create_issue, list_repo_issues, create_issue_comment, edit_issue - **Tags**: list_tags, get_tag, create_tag, delete_tag
- **Pull Requests**: create_pull_request, list_repo_pull_requests, get_pull_request_by_index - **Files**: get_file_contents, get_dir_contents, create_or_update_file, delete_file
- **Releases**: create_release, list_releases, get_latest_release - **Commits**: list_commits
- **Wiki**: create_wiki_page, update_wiki_page, list_wiki_pages - **Issues**: list_issues, issue_read, issue_write
- **Search**: search_repos, search_users, search_org_teams - **Pull Requests**: list_pull_requests, pull_request_read, pull_request_write, pull_request_review_write
- **Labels**: label_read, label_write
- **Milestones**: milestone_read, milestone_write
- **Releases**: list_releases, get_release, get_latest_release, create_release, delete_release
- **Wiki**: wiki_read, wiki_write
- **Time Tracking**: timetracking_read, timetracking_write
- **Actions Runs**: actions_run_read, actions_run_write
- **Actions Config**: actions_config_read, actions_config_write
- **Version**: get_gitea_mcp_server_version - **Version**: get_gitea_mcp_server_version
## Common Development Patterns ## Common Development Patterns

View File

@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1.4 # syntax=docker/dockerfile:1.4
# Build stage # Build stage
FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS builder FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder
ARG VERSION=dev ARG VERSION=dev
ARG TARGETOS ARG TARGETOS

View File

@@ -3,6 +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.10.1
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1
GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.2
.PHONY: help .PHONY: help
help: ## Print this help message. help: ## Print this help message.
@echo "Usage: make [target]" @echo "Usage: make [target]"
@@ -45,8 +49,29 @@ air: ## Install air for hot reload.
dev: air ## run the application with hot reload dev: air ## run the application with hot reload
air --build.cmd "make build" --build.bin ./gitea-mcp air --build.cmd "make build" --build.bin ./gitea-mcp
.PHONY: lint
lint: lint-go ## lint everything
.PHONY: lint-fix
lint-fix: lint-go-fix ## lint everything and fix issues
.PHONY: lint-go
lint-go: ## lint go files
$(GO) run $(GOLANGCI_LINT_PACKAGE) run
.PHONY: lint-go-fix
lint-go-fix: ## lint go files and fix issues
$(GO) run $(GOLANGCI_LINT_PACKAGE) run --fix
.PHONY: security-check
security-check: ## run security check
$(GO) run $(GOVULNCHECK_PACKAGE) -show color ./... || true
.PHONY: tidy
tidy: ## run go mod tidy
$(eval MIN_GO_VERSION := $(shell grep -Eo '^go\s+[0-9]+\.[0-9.]+' go.mod | cut -d' ' -f2))
$(GO) mod tidy -compat=$(MIN_GO_VERSION)
.PHONY: vendor .PHONY: vendor
vendor: ## tidy and verify module dependencies vendor: tidy ## tidy and verify module dependencies
@echo 'Tidying and verifying module dependencies...' $(GO) mod verify
go mod tidy
go mod verify

149
README.md
View File

@@ -13,6 +13,7 @@
- [What is Gitea?](#what-is-gitea) - [What is Gitea?](#what-is-gitea)
- [What is MCP?](#what-is-mcp) - [What is MCP?](#what-is-mcp)
- [🚧 Installation](#-installation) - [🚧 Installation](#-installation)
- [Usage with Claude Code](#usage-with-claude-code)
- [Usage with VS Code](#usage-with-vs-code) - [Usage with VS Code](#usage-with-vs-code)
- [📥 Download the official binary release](#-download-the-official-binary-release) - [📥 Download the official binary release](#-download-the-official-binary-release)
- [🔧 Build from Source](#-build-from-source) - [🔧 Build from Source](#-build-from-source)
@@ -32,6 +33,17 @@ Model Context Protocol (MCP) is a protocol that allows for the integration of va
## 🚧 Installation ## 🚧 Installation
### Usage with Claude Code
This method uses `go run` and requires [Go](https://go.dev) to be installed.
```bash
claude mcp add --transport stdio --scope user gitea \
--env GITEA_ACCESS_TOKEN=token \
--env GITEA_HOST=https://gitea.com \
-- go run gitea.com/gitea/gitea-mcp@latest -t stdio
```
### Usage with VS Code ### Usage with VS Code
For quick installation, use one of the one-click install buttons at the top of this README. For quick installation, use one of the one-click install buttons at the top of this README.
@@ -164,56 +176,93 @@ list all my repositories
The Gitea MCP Server supports the following tools: The Gitea MCP Server supports the following tools:
| Tool | Scope | Description | | Tool | Scope | Description |
| :--------------------------: | :----------: | :------------------------------------------------------: | | :-------------------------------: | :----------: | :------------------------------------------------------: |
| get_my_user_info | User | Get the information of the authenticated user | | get_my_user_info | User | Get the information of the authenticated user |
| get_user_orgs | User | Get organizations associated with the authenticated user | | get_user_orgs | User | Get organizations associated with the authenticated user |
| create_repo | Repository | Create a new repository | | create_repo | Repository | Create a new repository |
| fork_repo | Repository | Fork a repository | | fork_repo | Repository | Fork a repository |
| list_my_repos | Repository | List all repositories owned by the authenticated user | | list_my_repos | Repository | List all repositories owned by the authenticated user |
| create_branch | Branch | Create a new branch | | create_branch | Branch | Create a new branch |
| delete_branch | Branch | Delete a branch | | delete_branch | Branch | Delete a branch |
| list_branches | Branch | List all branches in a repository | | list_branches | Branch | List all branches in a repository |
| create_release | Release | Create a new release in a repository | | create_release | Release | Create a new release in a repository |
| delete_release | Release | Delete a release from a repository | | delete_release | Release | Delete a release from a repository |
| get_release | Release | Get a release | | get_release | Release | Get a release |
| get_latest_release | Release | Get the latest release in a repository | | get_latest_release | Release | Get the latest release in a repository |
| list_releases | Release | List all releases in a repository | | list_releases | Release | List all releases in a repository |
| create_tag | Tag | Create a new tag | | create_tag | Tag | Create a new tag |
| delete_tag | Tag | Delete a tag | | delete_tag | Tag | Delete a tag |
| get_tag | Tag | Get a tag | | get_tag | Tag | Get a tag |
| list_tags | Tag | List all tags in a repository | | list_tags | Tag | List all tags in a repository |
| list_repo_commits | Commit | List all commits in a repository | | list_repo_commits | Commit | List all commits in a repository |
| get_file_content | File | Get the content and metadata of a file | | get_file_content | File | Get the content and metadata of a file |
| get_dir_content | File | Get a list of entries in a directory | | get_dir_content | File | Get a list of entries in a directory |
| create_file | File | Create a new file | | create_file | File | Create a new file |
| update_file | File | Update an existing file | | update_file | File | Update an existing file |
| delete_file | File | Delete a file | | delete_file | File | Delete a file |
| get_issue_by_index | Issue | Get an issue by its index | | get_issue_by_index | Issue | Get an issue by its index |
| list_repo_issues | Issue | List all issues in a repository | | list_repo_issues | Issue | List all issues in a repository |
| create_issue | Issue | Create a new issue | | create_issue | Issue | Create a new issue |
| create_issue_comment | Issue | Create a comment on an issue | | create_issue_comment | Issue | Create a comment on an issue |
| edit_issue | Issue | Edit a issue | | edit_issue | Issue | Edit a issue |
| edit_issue_comment | Issue | Edit a comment on an issue | | edit_issue_comment | Issue | Edit a comment on an issue |
| get_issue_comments_by_index | Issue | Get comments of an issue by its index | | get_issue_comments_by_index | Issue | Get comments of an issue by its index |
| get_pull_request_by_index | Pull Request | Get a pull request by its index | | get_pull_request_by_index | Pull Request | Get a pull request by its index |
| list_repo_pull_requests | Pull Request | List all pull requests in a repository | | get_pull_request_diff | Pull Request | Get a pull request diff |
| create_pull_request | Pull Request | Create a new pull request | | list_repo_pull_requests | Pull Request | List all pull requests in a repository |
| create_pull_request_reviewer | Pull Request | Add reviewers to a pull request | | create_pull_request | Pull Request | Create a new pull request |
| search_users | User | Search for users | | create_pull_request_reviewer | Pull Request | Add reviewers to a pull request |
| search_org_teams | Organization | Search for teams in an organization | | delete_pull_request_reviewer | Pull Request | Remove reviewers from a pull request |
| list_org_labels | Organization | List labels defined at organization level | | list_pull_request_reviews | Pull Request | List all reviews for a pull request |
| create_org_label | Organization | Create a label in an organization | | get_pull_request_review | Pull Request | Get a specific review by ID |
| edit_org_label | Organization | Edit a label in an organization | | list_pull_request_review_comments | Pull Request | List inline comments for a review |
| delete_org_label | Organization | Delete a label in an organization | | create_pull_request_review | Pull Request | Create a review with optional inline comments |
| search_repos | Repository | Search for repositories | | submit_pull_request_review | Pull Request | Submit a pending review |
| get_gitea_mcp_server_version | Server | Get the version of the Gitea MCP Server | | delete_pull_request_review | Pull Request | Delete a review |
| list_wiki_pages | Wiki | List all wiki pages in a repository | | dismiss_pull_request_review | Pull Request | Dismiss a review with optional message |
| get_wiki_page | Wiki | Get a wiki page content and metadata | | merge_pull_request | Pull Request | Merge a pull request |
| get_wiki_revisions | Wiki | Get revisions history of a wiki page | | search_users | User | Search for users |
| create_wiki_page | Wiki | Create a new wiki page | | search_org_teams | Organization | Search for teams in an organization |
| update_wiki_page | Wiki | Update an existing wiki page | | list_org_labels | Organization | List labels defined at organization level |
| delete_wiki_page | Wiki | Delete a wiki page | | create_org_label | Organization | Create a label in an organization |
| edit_org_label | Organization | Edit a label in an organization |
| delete_org_label | Organization | Delete a label in an organization |
| search_repos | Repository | Search for repositories |
| list_repo_action_secrets | Actions | List repository Actions secrets (metadata only) |
| upsert_repo_action_secret | Actions | Create/update (upsert) a repository Actions secret |
| delete_repo_action_secret | Actions | Delete a repository Actions secret |
| list_org_action_secrets | Actions | List organization Actions secrets (metadata only) |
| upsert_org_action_secret | Actions | Create/update (upsert) an organization Actions secret |
| delete_org_action_secret | Actions | Delete an organization Actions secret |
| list_repo_action_variables | Actions | List repository Actions variables |
| get_repo_action_variable | Actions | Get a repository Actions variable |
| create_repo_action_variable | Actions | Create a repository Actions variable |
| update_repo_action_variable | Actions | Update a repository Actions variable |
| delete_repo_action_variable | Actions | Delete a repository Actions variable |
| list_org_action_variables | Actions | List organization Actions variables |
| get_org_action_variable | Actions | Get an organization Actions variable |
| create_org_action_variable | Actions | Create an organization Actions variable |
| update_org_action_variable | Actions | Update an organization Actions variable |
| delete_org_action_variable | Actions | Delete an organization Actions variable |
| list_repo_action_workflows | Actions | List repository Actions workflows |
| get_repo_action_workflow | Actions | Get a repository Actions workflow |
| dispatch_repo_action_workflow | Actions | Trigger (dispatch) a repository Actions workflow |
| list_repo_action_runs | Actions | List repository Actions runs |
| get_repo_action_run | Actions | Get a repository Actions run |
| cancel_repo_action_run | Actions | Cancel a repository Actions run |
| rerun_repo_action_run | Actions | Rerun a repository Actions run |
| list_repo_action_jobs | Actions | List repository Actions jobs |
| list_repo_action_run_jobs | Actions | List Actions jobs for a run |
| get_repo_action_job_log_preview | Actions | Get a job log preview (tail/limited) |
| download_repo_action_job_log | Actions | Download a job log to a file |
| get_gitea_mcp_server_version | Server | Get the version of the Gitea MCP Server |
| list_wiki_pages | Wiki | List all wiki pages in a repository |
| get_wiki_page | Wiki | Get a wiki page content and metadata |
| get_wiki_revisions | Wiki | Get revisions history of a wiki page |
| create_wiki_page | Wiki | Create a new wiki page |
| update_wiki_page | Wiki | Update an existing wiki page |
| delete_wiki_page | Wiki | Delete a wiki page |
## 🐛 Debugging ## 🐛 Debugging

View File

@@ -13,6 +13,7 @@
- [什么是 Gitea](#什么是-gitea) - [什么是 Gitea](#什么是-gitea)
- [什么是 MCP](#什么是-mcp) - [什么是 MCP](#什么是-mcp)
- [🚧 安装](#-安装) - [🚧 安装](#-安装)
- [在 Claude Code 中使用](#在-claude-code-中使用)
- [在 VS Code 中使用](#在-vs-code-中使用) - [在 VS Code 中使用](#在-vs-code-中使用)
- [📥 下载官方二进制版本](#-下载官方二进制版本) - [📥 下载官方二进制版本](#-下载官方二进制版本)
- [🔧 从源码构建](#-从源码构建) - [🔧 从源码构建](#-从源码构建)
@@ -32,6 +33,17 @@ Model Context Protocol (MCP) 是一种协议,允许通过聊天界面整合各
## 🚧 安装 ## 🚧 安装
### 在 Claude Code 中使用
此方式使用 `go run`,需要安装 [Go](https://go.dev)。
```bash
claude mcp add --transport stdio --scope user gitea \
--env GITEA_ACCESS_TOKEN=token \
--env GITEA_HOST=https://gitea.com \
-- go run gitea.com/gitea/gitea-mcp@latest -t stdio
```
### 在 VS Code 中使用 ### 在 VS Code 中使用
要快速安装,请使用本 README 顶部的安装按钮。 要快速安装,请使用本 README 顶部的安装按钮。
@@ -164,56 +176,65 @@ cp gitea-mcp /usr/local/bin/
Gitea MCP 服务器支持以下工具: Gitea MCP 服务器支持以下工具:
| 工具 | 范围 | 描述 | | 工具 | 范围 | 描述 |
| :--------------------------: | :------: | :------------------------: | | :-------------------------------: | :------: | :------------------------: |
| get_my_user_info | 用户 | 获取已认证用户信息 | | get_my_user_info | 用户 | 获取已认证用户信息 |
| get_user_orgs | 用户 | 获取已认证用户关联组织 | | get_user_orgs | 用户 | 获取已认证用户关联组织 |
| create_repo | 仓库 | 创建新仓库 | | create_repo | 仓库 | 创建新仓库 |
| fork_repo | 仓库 | 复刻仓库 | | fork_repo | 仓库 | 复刻仓库 |
| list_my_repos | 仓库 | 列出用户所有仓库 | | list_my_repos | 仓库 | 列出用户所有仓库 |
| create_branch | 分支 | 创建新分支 | | create_branch | 分支 | 创建新分支 |
| delete_branch | 分支 | 删除分支 | | delete_branch | 分支 | 删除分支 |
| list_branches | 分支 | 列出所有分支 | | list_branches | 分支 | 列出所有分支 |
| create_release | 版本发布 | 创建新版本发布 | | create_release | 版本发布 | 创建新版本发布 |
| delete_release | 版本发布 | 删除版本发布 | | delete_release | 版本发布 | 删除版本发布 |
| get_release | 版本发布 | 获取版本发布 | | get_release | 版本发布 | 获取版本发布 |
| get_latest_release | 版本发布 | 获取最新版本发布 | | get_latest_release | 版本发布 | 获取最新版本发布 |
| list_releases | 版本发布 | 列出所有版本发布 | | list_releases | 版本发布 | 列出所有版本发布 |
| create_tag | 标签 | 创建新标签 | | create_tag | 标签 | 创建新标签 |
| delete_tag | 标签 | 删除标签 | | delete_tag | 标签 | 删除标签 |
| get_tag | 标签 | 获取标签 | | get_tag | 标签 | 获取标签 |
| list_tags | 标签 | 列出所有标签 | | list_tags | 标签 | 列出所有标签 |
| list_repo_commits | 提交 | 列出所有提交 | | list_repo_commits | 提交 | 列出所有提交 |
| get_file_content | 文件 | 获取文件内容和元数据 | | get_file_content | 文件 | 获取文件内容和元数据 |
| get_dir_content | 文件 | 获取目录内容列表 | | get_dir_content | 文件 | 获取目录内容列表 |
| create_file | 文件 | 创建新文件 | | create_file | 文件 | 创建新文件 |
| update_file | 文件 | 更新现有文件 | | update_file | 文件 | 更新现有文件 |
| delete_file | 文件 | 删除文件 | | delete_file | 文件 | 删除文件 |
| get_issue_by_index | 问题 | 按索引获取问题 | | get_issue_by_index | 问题 | 按索引获取问题 |
| list_repo_issues | 问题 | 列出所有问题 | | list_repo_issues | 问题 | 列出所有问题 |
| create_issue | 问题 | 创建新问题 | | create_issue | 问题 | 创建新问题 |
| create_issue_comment | 问题 | 在问题上创建评论 | | create_issue_comment | 问题 | 在问题上创建评论 |
| edit_issue | 问题 | 编辑问题 | | edit_issue | 问题 | 编辑问题 |
| edit_issue_comment | 问题 | 编辑问题评论 | | edit_issue_comment | 问题 | 编辑问题评论 |
| get_issue_comments_by_index | 问题 | 按索引获取问题评论 | | get_issue_comments_by_index | 问题 | 按索引获取问题评论 |
| get_pull_request_by_index | 拉取请求 | 按索引获取拉取请求 | | get_pull_request_by_index | 拉取请求 | 按索引获取拉取请求 |
| list_repo_pull_requests | 拉取请求 | 列出所有拉取请求 | | list_repo_pull_requests | 拉取请求 | 列出所有拉取请求 |
| create_pull_request | 拉取请求 | 创建新拉取请求 | | create_pull_request | 拉取请求 | 创建新拉取请求 |
| create_pull_request_reviewer | 拉取请求 | 为拉取请求添加审查者 | | create_pull_request_reviewer | 拉取请求 | 为拉取请求添加审查者 |
| search_users | 用户 | 搜索用户 | | delete_pull_request_reviewer | 拉取请求 | 移除拉取请求的审查者 |
| search_org_teams | 组织 | 搜索组织团队 | | list_pull_request_reviews | 拉取请求 | 列出拉取请求的所有审查 |
| list_org_labels | 组织 | 列出组织标签 | | get_pull_request_review | 拉取请求 | 按 ID 获取特定审查 |
| create_org_label | 组织 | 创建组织标签 | | list_pull_request_review_comments | 拉取请求 | 列出审查的行内评论 |
| edit_org_label | 组织 | 编辑组织标签 | | create_pull_request_review | 拉取请求 | 创建审查(可含行内评论) |
| delete_org_label | 组织 | 删除组织标签 | | submit_pull_request_review | 拉取请求 | 提交待处理的审查 |
| search_repos | 仓库 | 搜索仓库 | | delete_pull_request_review | 拉取请求 | 删除审查 |
| get_gitea_mcp_server_version | 服务器 | 获取 Gitea MCP 服务器版本 | | dismiss_pull_request_review | 拉取请求 | 驳回审查(可附消息) |
| list_wiki_pages | Wiki | 列出所有 Wiki 页面 | | merge_pull_request | 拉取请求 | 合并拉取请求 |
| get_wiki_page | Wiki | 获取 Wiki 页面内容和元数据 | | search_users | 用户 | 搜索用户 |
| get_wiki_revisions | Wiki | 获取 Wiki 修订历史 | | search_org_teams | 组织 | 搜索组织团队 |
| create_wiki_page | Wiki | 创建新 Wiki 页面 | | list_org_labels | 组织 | 列出组织标签 |
| update_wiki_page | Wiki | 更新现有 Wiki 页面 | | create_org_label | 组织 | 创建组织标签 |
| delete_wiki_page | Wiki | 删除 Wiki 页面 | | edit_org_label | 组织 | 编辑组织标签 |
| delete_org_label | 组织 | 删除组织标签 |
| search_repos | 仓库 | 搜索仓库 |
| get_gitea_mcp_server_version | 服务器 | 获取 Gitea MCP 服务器版本 |
| list_wiki_pages | Wiki | 列出所有 Wiki 页面 |
| get_wiki_page | Wiki | 获取 Wiki 页面内容和元数据 |
| get_wiki_revisions | Wiki | 获取 Wiki 修订历史 |
| create_wiki_page | Wiki | 创建新 Wiki 页面 |
| update_wiki_page | Wiki | 更新现有 Wiki 页面 |
| delete_wiki_page | Wiki | 删除 Wiki 页面 |
## 🐛 调试 ## 🐛 调试

View File

@@ -13,6 +13,7 @@
- [什麼是 Gitea](#什麼是-gitea) - [什麼是 Gitea](#什麼是-gitea)
- [什麼是 MCP](#什麼是-mcp) - [什麼是 MCP](#什麼是-mcp)
- [🚧 安裝](#-安裝) - [🚧 安裝](#-安裝)
- [在 Claude Code 中使用](#在-claude-code-中使用)
- [在 VS Code 中使用](#在-vs-code-中使用) - [在 VS Code 中使用](#在-vs-code-中使用)
- [📥 下載官方二進位版本](#-下載官方二進位版本) - [📥 下載官方二進位版本](#-下載官方二進位版本)
- [🔧 從原始碼建置](#-從原始碼建置) - [🔧 從原始碼建置](#-從原始碼建置)
@@ -32,6 +33,17 @@ Model Context Protocol (MCP) 是一種協議,允許透過聊天介面整合各
## 🚧 安裝 ## 🚧 安裝
### 在 Claude Code 中使用
此方式使用 `go run`,需要安裝 [Go](https://go.dev)。
```bash
claude mcp add --transport stdio --scope user gitea \
--env GITEA_ACCESS_TOKEN=token \
--env GITEA_HOST=https://gitea.com \
-- go run gitea.com/gitea/gitea-mcp@latest -t stdio
```
### 在 VS Code 中使用 ### 在 VS Code 中使用
欲快速安裝,請使用本 README 頂部的安裝按鈕。 欲快速安裝,請使用本 README 頂部的安裝按鈕。
@@ -164,56 +176,65 @@ cp gitea-mcp /usr/local/bin/
Gitea MCP 伺服器支援以下工具: Gitea MCP 伺服器支援以下工具:
| 工具 | 範圍 | 描述 | | 工具 | 範圍 | 描述 |
| :--------------------------: | :------: | :--------------------------: | | :-------------------------------: | :------: | :--------------------------: |
| get_my_user_info | 用戶 | 取得已認證用戶資訊 | | get_my_user_info | 用戶 | 取得已認證用戶資訊 |
| get_user_orgs | 用戶 | 取得已認證用戶所屬組織 | | get_user_orgs | 用戶 | 取得已認證用戶所屬組織 |
| create_repo | 倉庫 | 創建新倉庫 | | create_repo | 倉庫 | 創建新倉庫 |
| fork_repo | 倉庫 | 復刻倉庫 | | fork_repo | 倉庫 | 復刻倉庫 |
| list_my_repos | 倉庫 | 列出用戶所有倉庫 | | list_my_repos | 倉庫 | 列出用戶所有倉庫 |
| create_branch | 分支 | 創建新分支 | | create_branch | 分支 | 創建新分支 |
| delete_branch | 分支 | 刪除分支 | | delete_branch | 分支 | 刪除分支 |
| list_branches | 分支 | 列出所有分支 | | list_branches | 分支 | 列出所有分支 |
| create_release | 版本發布 | 創建新版本發布 | | create_release | 版本發布 | 創建新版本發布 |
| delete_release | 版本發布 | 刪除版本發布 | | delete_release | 版本發布 | 刪除版本發布 |
| get_release | 版本發布 | 取得版本發布 | | get_release | 版本發布 | 取得版本發布 |
| get_latest_release | 版本發布 | 取得最新版本發布 | | get_latest_release | 版本發布 | 取得最新版本發布 |
| list_releases | 版本發布 | 列出所有版本發布 | | list_releases | 版本發布 | 列出所有版本發布 |
| create_tag | 標籤 | 創建新標籤 | | create_tag | 標籤 | 創建新標籤 |
| delete_tag | 標籤 | 刪除標籤 | | delete_tag | 標籤 | 刪除標籤 |
| get_tag | 標籤 | 取得標籤 | | get_tag | 標籤 | 取得標籤 |
| list_tags | 標籤 | 列出所有標籤 | | list_tags | 標籤 | 列出所有標籤 |
| list_repo_commits | 提交 | 列出所有提交 | | list_repo_commits | 提交 | 列出所有提交 |
| get_file_content | 文件 | 取得文件內容與中繼資料 | | get_file_content | 文件 | 取得文件內容與中繼資料 |
| get_dir_content | 文件 | 取得目錄內容列表 | | get_dir_content | 文件 | 取得目錄內容列表 |
| create_file | 文件 | 創建新文件 | | create_file | 文件 | 創建新文件 |
| update_file | 文件 | 更新現有文件 | | update_file | 文件 | 更新現有文件 |
| delete_file | 文件 | 刪除文件 | | delete_file | 文件 | 刪除文件 |
| get_issue_by_index | 問題 | 依索引取得問題 | | get_issue_by_index | 問題 | 依索引取得問題 |
| list_repo_issues | 問題 | 列出所有問題 | | list_repo_issues | 問題 | 列出所有問題 |
| create_issue | 問題 | 創建新問題 | | create_issue | 問題 | 創建新問題 |
| create_issue_comment | 問題 | 在問題上創建評論 | | create_issue_comment | 問題 | 在問題上創建評論 |
| edit_issue | 問題 | 編輯問題 | | edit_issue | 問題 | 編輯問題 |
| edit_issue_comment | 問題 | 編輯問題評論 | | edit_issue_comment | 問題 | 編輯問題評論 |
| get_issue_comments_by_index | 問題 | 依索引取得問題評論 | | get_issue_comments_by_index | 問題 | 依索引取得問題評論 |
| get_pull_request_by_index | 拉取請求 | 依索引取得拉取請求 | | get_pull_request_by_index | 拉取請求 | 依索引取得拉取請求 |
| list_repo_pull_requests | 拉取請求 | 列出所有拉取請求 | | list_repo_pull_requests | 拉取請求 | 列出所有拉取請求 |
| create_pull_request | 拉取請求 | 創建新拉取請求 | | create_pull_request | 拉取請求 | 創建新拉取請求 |
| create_pull_request_reviewer | 拉取請求 | 為拉取請求添加審查者 | | create_pull_request_reviewer | 拉取請求 | 為拉取請求添加審查者 |
| search_users | 用戶 | 搜尋用戶 | | delete_pull_request_reviewer | 拉取請求 | 移除拉取請求的審查者 |
| search_org_teams | 組織 | 搜尋組織團隊 | | list_pull_request_reviews | 拉取請求 | 列出拉取請求的所有審查 |
| list_org_labels | 組織 | 列出組織標籤 | | get_pull_request_review | 拉取請求 | 依 ID 取得特定審查 |
| create_org_label | 組織 | 創建組織標籤 | | list_pull_request_review_comments | 拉取請求 | 列出審查的行內評論 |
| edit_org_label | 組織 | 編輯組織標籤 | | create_pull_request_review | 拉取請求 | 創建審查(可含行內評論) |
| delete_org_label | 組織 | 刪除組織標籤 | | submit_pull_request_review | 拉取請求 | 提交待處理的審查 |
| search_repos | 倉庫 | 搜尋倉庫 | | delete_pull_request_review | 拉取請求 | 刪除審查 |
| get_gitea_mcp_server_version | 伺服器 | 取得 Gitea MCP 伺服器版本 | | dismiss_pull_request_review | 拉取請求 | 駁回審查(可附訊息) |
| list_wiki_pages | Wiki | 列出所有 Wiki 頁面 | | merge_pull_request | 拉取請求 | 合併拉取請求 |
| get_wiki_page | Wiki | 取得 Wiki 頁面內容與中繼資料 | | search_users | 用戶 | 搜尋用戶 |
| get_wiki_revisions | Wiki | 取得 Wiki 修訂歷史 | | search_org_teams | 組織 | 搜尋組織團隊 |
| create_wiki_page | Wiki | 創建新 Wiki 頁面 | | list_org_labels | 組織 | 列出組織標籤 |
| update_wiki_page | Wiki | 更新現有 Wiki 頁面 | | create_org_label | 組織 | 創建組織標籤 |
| delete_wiki_page | Wiki | 刪除 Wiki 頁面 | | edit_org_label | 組織 | 編輯組織標籤 |
| delete_org_label | 組織 | 刪除組織標籤 |
| search_repos | 倉庫 | 搜尋倉庫 |
| get_gitea_mcp_server_version | 伺服器 | 取得 Gitea MCP 伺服器版本 |
| list_wiki_pages | Wiki | 列出所有 Wiki 頁面 |
| get_wiki_page | Wiki | 取得 Wiki 頁面內容與中繼資料 |
| get_wiki_revisions | Wiki | 取得 Wiki 修訂歷史 |
| create_wiki_page | Wiki | 創建新 Wiki 頁面 |
| update_wiki_page | Wiki | 更新現有 Wiki 頁面 |
| delete_wiki_page | Wiki | 刪除 Wiki 頁面 |
## 🐛 調試 ## 🐛 調試

2
build.bat Normal file
View File

@@ -0,0 +1,2 @@
@echo off
powershell -ExecutionPolicy Bypass -File "%~dp0build.ps1" %*

220
build.ps1 Normal file
View File

@@ -0,0 +1,220 @@
#!/usr/bin/env pwsh
# PowerShell build script for gitea-mcp
# Replicates the functionality of the Makefile
param(
[string]$Target = "help"
)
# Configuration
$EXECUTABLE = "gitea-mcp.exe"
$VERSION = & git describe --tags --always 2>$null | ForEach-Object { $_ -replace '-', '+' -replace '^v', '' }
if (-not $VERSION) { $VERSION = "dev" }
$LDFLAGS = "-X `"main.Version=$VERSION`""
# Colors for output (Windows PowerShell compatible)
$CYAN = "Cyan"
$RESET = "White"
function Write-Header {
param([string]$Message)
Write-Host "=== $Message ===" -ForegroundColor Green
}
function Write-Info {
param([string]$Message)
Write-Host $Message -ForegroundColor Yellow
}
function Write-Success {
param([string]$Message)
Write-Host $Message -ForegroundColor Green
}
function Write-Error {
param([string]$Message)
Write-Host $Message -ForegroundColor Red
}
function Get-Help {
Write-Host "Usage: .\build.ps1 [target]" -ForegroundColor Green
Write-Host ""
Write-Host "Targets:" -ForegroundColor Green
Write-Host ""
Write-Host ("{0,-30}" -f "help") -ForegroundColor Cyan -NoNewline
Write-Host " Print this help message."
Write-Host ("{0,-30}" -f "build") -ForegroundColor Cyan -NoNewline
Write-Host " Build the application."
Write-Host ("{0,-30}" -f "install") -ForegroundColor Cyan -NoNewline
Write-Host " Install the application."
Write-Host ("{0,-30}" -f "uninstall") -ForegroundColor Cyan -NoNewline
Write-Host " Uninstall the application."
Write-Host ("{0,-30}" -f "clean") -ForegroundColor Cyan -NoNewline
Write-Host " Clean the build artifacts."
Write-Host ("{0,-30}" -f "air") -ForegroundColor Cyan -NoNewline
Write-Host " Install air for hot reload."
Write-Host ("{0,-30}" -f "dev") -ForegroundColor Cyan -NoNewline
Write-Host " Run the application with hot reload."
Write-Host ("{0,-30}" -f "vendor") -ForegroundColor Cyan -NoNewline
Write-Host " Tidy and verify module dependencies."
}
function Build-App {
Write-Header "Building application"
$ldflags = "-s -w $LDFLAGS"
Write-Info "go build -v -ldflags '$ldflags' -o $EXECUTABLE"
try {
& go build -v -ldflags $ldflags -o $EXECUTABLE
if ($LASTEXITCODE -eq 0) {
Write-Success "Build successful: $EXECUTABLE"
} else {
Write-Error "Build failed with exit code: $LASTEXITCODE"
exit $LASTEXITCODE
}
} catch {
Write-Error "Build failed: $_"
exit 1
}
}
function Install-App {
Write-Header "Installing application"
# First build the application
Build-App
$GOPATH = $env:GOPATH
if (-not $GOPATH) {
$GOPATH = Join-Path $env:USERPROFILE "go"
}
$installDir = Join-Path $GOPATH "bin"
$installPath = Join-Path $installDir $EXECUTABLE
Write-Info "Installing $EXECUTABLE to $installPath"
# Create directory if it doesn't exist
if (-not (Test-Path $installDir)) {
New-Item -ItemType Directory -Path $installDir -Force | Out-Null
}
# Copy the executable
if (Test-Path $EXECUTABLE) {
Copy-Item $EXECUTABLE $installPath -Force
Write-Success "Installed $EXECUTABLE to $installPath"
Write-Info "Please add $installDir to your PATH if it is not already there."
} else {
Write-Error "Executable not found. Please build first."
exit 1
}
}
function Uninstall-App {
Write-Header "Uninstalling application"
$GOPATH = $env:GOPATH
if (-not $GOPATH) {
$GOPATH = Join-Path $env:USERPROFILE "go"
}
$installPath = Join-Path $GOPATH "bin" $EXECUTABLE
Write-Info "Uninstalling $EXECUTABLE from $installPath"
if (Test-Path $installPath) {
Remove-Item $installPath -Force
Write-Success "Uninstalled $EXECUTABLE from $installPath"
} else {
Write-Warning "$EXECUTABLE not found at $installPath"
}
}
function Clean-Build {
Write-Header "Cleaning build artifacts"
Write-Info "Cleaning up $EXECUTABLE"
if (Test-Path $EXECUTABLE) {
Remove-Item $EXECUTABLE -Force
Write-Success "Cleaned up $EXECUTABLE"
} else {
Write-Warning "$EXECUTABLE not found"
}
}
function Install-Air {
Write-Header "Installing air for hot reload"
# Check if air is already installed
$airPath = Get-Command air -ErrorAction SilentlyContinue
if ($airPath) {
Write-Success "air is already installed"
return
}
Write-Info "Installing github.com/air-verse/air@latest"
try {
& go install github.com/air-verse/air@latest
if ($LASTEXITCODE -eq 0) {
Write-Success "air installed successfully"
} else {
Write-Error "Failed to install air"
exit $LASTEXITCODE
}
} catch {
Write-Error "Failed to install air: $_"
exit 1
}
}
function Start-Dev {
Write-Header "Starting development mode with hot reload"
# Install air first
Install-Air
Write-Info "Starting air with build configuration"
& air --build.cmd "go build -o $EXECUTABLE" --build.bin "./$EXECUTABLE"
}
function Update-Vendor {
Write-Header "Tidying and verifying module dependencies"
Write-Info "Running go mod tidy"
& go mod tidy
if ($LASTEXITCODE -ne 0) {
Write-Error "go mod tidy failed"
exit $LASTEXITCODE
}
Write-Info "Running go mod verify"
& go mod verify
if ($LASTEXITCODE -ne 0) {
Write-Error "go mod verify failed"
exit $LASTEXITCODE
}
Write-Success "Dependencies updated successfully"
}
# Main execution logic
switch ($Target.ToLower()) {
"help" { Get-Help }
"build" { Build-App }
"install" { Install-App }
"uninstall" { Uninstall-App }
"clean" { Clean-Build }
"air" { Install-Air }
"dev" { Start-Dev }
"vendor" { Update-Vendor }
default {
Write-Error "Unknown target: $Target"
Write-Host ""
Get-Help
exit 1
}
}

View File

@@ -3,7 +3,9 @@ package cmd
import ( import (
"context" "context"
"flag" "flag"
"fmt"
"os" "os"
"text/tabwriter"
"gitea.com/gitea/gitea-mcp/operation" "gitea.com/gitea/gitea-mcp/operation"
flagPkg "gitea.com/gitea/gitea-mcp/pkg/flag" flagPkg "gitea.com/gitea/gitea-mcp/pkg/flag"
@@ -11,60 +13,53 @@ import (
) )
var ( var (
host string host string
port int port int
token string token string
version bool
) )
func init() { func init() {
flag.StringVar( flag.StringVar(&flagPkg.Mode, "t", "stdio", "")
&flagPkg.Mode, flag.StringVar(&flagPkg.Mode, "transport", "stdio", "")
"t", flag.StringVar(&host, "H", os.Getenv("GITEA_HOST"), "")
"stdio", flag.StringVar(&host, "host", os.Getenv("GITEA_HOST"), "")
"Transport type (stdio or http)", flag.IntVar(&port, "p", 8080, "")
) flag.IntVar(&port, "port", 8080, "")
flag.StringVar( flag.StringVar(&token, "T", "", "")
&flagPkg.Mode, flag.StringVar(&token, "token", "", "")
"transport", flag.BoolVar(&flagPkg.ReadOnly, "r", false, "")
"stdio", flag.BoolVar(&flagPkg.ReadOnly, "read-only", false, "")
"Transport type (stdio or http)", flag.BoolVar(&flagPkg.Debug, "d", false, "")
) flag.BoolVar(&flagPkg.Debug, "debug", false, "")
flag.StringVar( flag.BoolVar(&flagPkg.Insecure, "k", false, "")
&host, flag.BoolVar(&flagPkg.Insecure, "insecure", false, "")
"host", flag.BoolVar(&version, "v", false, "")
os.Getenv("GITEA_HOST"), flag.BoolVar(&version, "version", false, "")
"Gitea host",
) flag.Usage = func() {
flag.IntVar( w := tabwriter.NewWriter(os.Stderr, 0, 0, 3, ' ', 0)
&port, fmt.Fprintln(os.Stderr, "Usage: gitea-mcp [options]")
"port", fmt.Fprintln(os.Stderr)
8080, fmt.Fprintln(os.Stderr, "Options:")
"http port", 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")
flag.StringVar( fmt.Fprintf(w, " -p, -port <number>\tHTTP server port (default: 8080)\n")
&token, fmt.Fprintf(w, " -T, -token <token>\tPersonal access token\n")
"token", fmt.Fprintf(w, " -r, -read-only\tExpose only read-only tools\n")
"", fmt.Fprintf(w, " -d, -debug\tEnable debug mode\n")
"Your personal access token", fmt.Fprintf(w, " -k, -insecure\tIgnore TLS certificate errors\n")
) fmt.Fprintf(w, " -v, -version\tPrint version and exit\n")
flag.BoolVar( fmt.Fprintln(w)
&flagPkg.ReadOnly, fmt.Fprintln(w, "Environment variables:")
"read-only", fmt.Fprintf(w, " GITEA_ACCESS_TOKEN\tProvide access token\n")
false, fmt.Fprintf(w, " GITEA_DEBUG\tSet to 'true' for debug mode\n")
"Read-only mode", fmt.Fprintf(w, " GITEA_HOST\tOverride Gitea host URL\n")
) fmt.Fprintf(w, " GITEA_INSECURE\tSet to 'true' to ignore TLS errors\n")
flag.BoolVar( fmt.Fprintf(w, " GITEA_READONLY\tSet to 'true' for read-only mode\n")
&flagPkg.Debug, fmt.Fprintf(w, " MCP_MODE\tOverride transport mode\n")
"d", w.Flush()
false, }
"debug mode (If -d flag is provided, debug mode will be enabled by default)",
)
flag.BoolVar(
&flagPkg.Insecure,
"insecure",
false,
"ignore TLS certificate errors",
)
flag.Parse() flag.Parse()
@@ -99,12 +94,16 @@ func init() {
} }
func Execute() { func Execute() {
defer log.Default().Sync() if version {
fmt.Fprintln(os.Stdout, flagPkg.Version)
return
}
defer log.Default().Sync() //nolint:errcheck // best-effort flush
if err := operation.Run(); err != nil { if err := operation.Run(); err != nil {
if err == context.Canceled { if err == context.Canceled {
log.Info("Server shutdown due to context cancellation") log.Info("Server shutdown due to context cancellation")
return return
} }
log.Fatalf("Run Gitea MCP Server Error: %v", err) log.Fatalf("Run Gitea MCP Server Error: %v", err) //nolint:gocritic // intentional exit after defer
} }
} }

14
go.mod
View File

@@ -1,11 +1,11 @@
module gitea.com/gitea/gitea-mcp module gitea.com/gitea/gitea-mcp
go 1.24.0 go 1.26.0
require ( require (
code.gitea.io/sdk/gitea v0.22.1 code.gitea.io/sdk/gitea v0.23.2
github.com/mark3labs/mcp-go v0.42.0 github.com/mark3labs/mcp-go v0.44.0
go.uber.org/zap v1.27.0 go.uber.org/zap v1.27.1
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
) )
@@ -16,14 +16,14 @@ require (
github.com/davidmz/go-pageant v1.0.2 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-fed/httpsig v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect github.com/hashicorp/go-version v1.8.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect
github.com/mailru/easyjson v0.9.1 // indirect github.com/mailru/easyjson v0.9.1 // indirect
github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cast v1.10.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // 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.43.0 // indirect golang.org/x/crypto v0.48.0 // indirect
golang.org/x/sys v0.37.0 // indirect golang.org/x/sys v0.41.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

28
go.sum
View File

@@ -1,5 +1,5 @@
code.gitea.io/sdk/gitea v0.22.1 h1:7K05KjRORyTcTYULQ/AwvlVS6pawLcWyXZcTr7gHFyA= code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg=
code.gitea.io/sdk/gitea v0.22.1/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
@@ -18,8 +18,8 @@ github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 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/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -28,8 +28,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mark3labs/mcp-go v0.42.0 h1:gk/8nYJh8t3yroCAOBhNbYsM9TCKvkM13I5t5Hfu6Ls= github.com/mark3labs/mcp-go v0.44.0 h1:OlYfcVviAnwNN40QZUrrzU0QZjq3En7rCU5X09a/B7I=
github.com/mark3labs/mcp-go v0.42.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= github.com/mark3labs/mcp-go v0.44.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
@@ -46,23 +46,23 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
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/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
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/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=

View File

@@ -5,9 +5,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/flag" "gitea.com/gitea/gitea-mcp/pkg/flag"
) )
var ( var Version = "dev"
Version = "dev"
)
func init() { func init() {
flag.Version = Version flag.Version = Version

View File

@@ -0,0 +1,8 @@
package actions
import (
"gitea.com/gitea/gitea-mcp/pkg/tool"
)
// Tool is the registry for all Actions-related MCP tools.
var Tool = tool.New()

555
operation/actions/config.go Normal file
View File

@@ -0,0 +1,555 @@
package actions
import (
"context"
"errors"
"fmt"
"net/url"
"strconv"
"time"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
ActionsConfigReadToolName = "actions_config_read"
ActionsConfigWriteToolName = "actions_config_write"
)
type secretMeta struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
CreatedAt time.Time `json:"created_at,omitzero"`
}
func toSecretMetas(secrets []*gitea_sdk.Secret) []secretMeta {
metas := make([]secretMeta, 0, len(secrets))
for _, s := range secrets {
if s == nil {
continue
}
metas = append(metas, secretMeta{
Name: s.Name,
Description: s.Description,
CreatedAt: s.Created,
})
}
return metas
}
var (
ActionsConfigReadTool = mcp.NewTool(
ActionsConfigReadToolName,
mcp.WithDescription("Read Actions secrets and variables configuration."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list_repo_secrets", "list_org_secrets", "list_repo_variables", "get_repo_variable", "list_org_variables", "get_org_variable")),
mcp.WithString("owner", mcp.Description("repository owner (required for repo methods)")),
mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")),
mcp.WithString("org", mcp.Description("organization name (required for org methods)")),
mcp.WithString("name", mcp.Description("variable name (required for get methods)")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)),
)
ActionsConfigWriteTool = mcp.NewTool(
ActionsConfigWriteToolName,
mcp.WithDescription("Manage Actions secrets and variables: create, update, or delete."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("upsert_repo_secret", "delete_repo_secret", "upsert_org_secret", "delete_org_secret", "create_repo_variable", "update_repo_variable", "delete_repo_variable", "create_org_variable", "update_org_variable", "delete_org_variable")),
mcp.WithString("owner", mcp.Description("repository owner (required for repo methods)")),
mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")),
mcp.WithString("org", mcp.Description("organization name (required for org methods)")),
mcp.WithString("name", mcp.Description("secret or variable name (required for most methods)")),
mcp.WithString("data", mcp.Description("secret value (required for upsert secret methods)")),
mcp.WithString("value", mcp.Description("variable value (required for create/update variable methods)")),
mcp.WithString("description", mcp.Description("description for secret or variable")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{Tool: ActionsConfigReadTool, Handler: configReadFn})
Tool.RegisterWrite(server.ServerTool{Tool: ActionsConfigWriteTool, Handler: configWriteFn})
}
func configReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "list_repo_secrets":
return listRepoActionSecretsFn(ctx, req)
case "list_org_secrets":
return listOrgActionSecretsFn(ctx, req)
case "list_repo_variables":
return listRepoActionVariablesFn(ctx, req)
case "get_repo_variable":
return getRepoActionVariableFn(ctx, req)
case "list_org_variables":
return listOrgActionVariablesFn(ctx, req)
case "get_org_variable":
return getOrgActionVariableFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func configWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "upsert_repo_secret":
return upsertRepoActionSecretFn(ctx, req)
case "delete_repo_secret":
return deleteRepoActionSecretFn(ctx, req)
case "upsert_org_secret":
return upsertOrgActionSecretFn(ctx, req)
case "delete_org_secret":
return deleteOrgActionSecretFn(ctx, req)
case "create_repo_variable":
return createRepoActionVariableFn(ctx, req)
case "update_repo_variable":
return updateRepoActionVariableFn(ctx, req)
case "delete_repo_variable":
return deleteRepoActionVariableFn(ctx, req)
case "create_org_variable":
return createOrgActionVariableFn(ctx, req)
case "update_org_variable":
return updateOrgActionVariableFn(ctx, req)
case "delete_org_variable":
return deleteOrgActionVariableFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
// Secret functions
func listRepoActionSecretsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionSecretsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
secrets, _, err := client.ListRepoActionSecret(owner, repo, gitea_sdk.ListRepoActionSecretOption{
ListOptions: gitea_sdk.ListOptions{Page: page, PageSize: pageSize},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list repo action secrets err: %v", err))
}
return to.TextResult(toSecretMetas(secrets))
}
func upsertRepoActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called upsertRepoActionSecretFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
data, err := params.GetString(req.GetArguments(), "data")
if err != nil || data == "" {
return to.ErrorResult(errors.New("data is required"))
}
description, _ := req.GetArguments()["description"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.CreateRepoActionSecret(owner, repo, gitea_sdk.CreateSecretOption{
Name: name,
Data: data,
Description: description,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("upsert repo action secret err: %v", err))
}
return to.TextResult(map[string]any{"message": "secret upserted", "status": resp.StatusCode})
}
func deleteRepoActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteRepoActionSecretFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.DeleteRepoActionSecret(owner, repo, name)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete repo action secret err: %v", err))
}
return to.TextResult(map[string]any{"message": "secret deleted", "status": resp.StatusCode})
}
func listOrgActionSecretsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listOrgActionSecretsFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
secrets, _, err := client.ListOrgActionSecret(org, gitea_sdk.ListOrgActionSecretOption{
ListOptions: gitea_sdk.ListOptions{Page: page, PageSize: pageSize},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list org action secrets err: %v", err))
}
return to.TextResult(toSecretMetas(secrets))
}
func upsertOrgActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called upsertOrgActionSecretFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
data, err := params.GetString(req.GetArguments(), "data")
if err != nil || data == "" {
return to.ErrorResult(errors.New("data is required"))
}
description, _ := req.GetArguments()["description"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.CreateOrgActionSecret(org, gitea_sdk.CreateSecretOption{
Name: name,
Data: data,
Description: description,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("upsert org action secret err: %v", err))
}
return to.TextResult(map[string]any{"message": "secret upserted", "status": resp.StatusCode})
}
func deleteOrgActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteOrgActionSecretFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
escapedOrg := url.PathEscape(org)
escapedSecret := url.PathEscape(name)
_, err = gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("orgs/%s/actions/secrets/%s", escapedOrg, escapedSecret), nil, nil, nil)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete org action secret err: %v", err))
}
return to.TextResult(map[string]any{"message": "secret deleted"})
}
// Variable functions
func listRepoActionVariablesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionVariablesFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
query := url.Values{}
query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize))
var result any
_, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/actions/variables", url.PathEscape(owner), url.PathEscape(repo)), query, nil, &result)
if err != nil {
return to.ErrorResult(fmt.Errorf("list repo action variables err: %v", err))
}
return to.TextResult(result)
}
func getRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getRepoActionVariableFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
variable, _, err := client.GetRepoActionVariable(owner, repo, name)
if err != nil {
return to.ErrorResult(fmt.Errorf("get repo action variable err: %v", err))
}
return to.TextResult(variable)
}
func createRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createRepoActionVariableFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
value, err := params.GetString(req.GetArguments(), "value")
if err != nil || value == "" {
return to.ErrorResult(errors.New("value is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.CreateRepoActionVariable(owner, repo, name, value)
if err != nil {
return to.ErrorResult(fmt.Errorf("create repo action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable created", "status": resp.StatusCode})
}
func updateRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called updateRepoActionVariableFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
value, err := params.GetString(req.GetArguments(), "value")
if err != nil || value == "" {
return to.ErrorResult(errors.New("value is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.UpdateRepoActionVariable(owner, repo, name, value)
if err != nil {
return to.ErrorResult(fmt.Errorf("update repo action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable updated", "status": resp.StatusCode})
}
func deleteRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteRepoActionVariableFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.DeleteRepoActionVariable(owner, repo, name)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete repo action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable deleted", "status": resp.StatusCode})
}
func listOrgActionVariablesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listOrgActionVariablesFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
variables, _, err := client.ListOrgActionVariable(org, gitea_sdk.ListOrgActionVariableOption{
ListOptions: gitea_sdk.ListOptions{Page: page, PageSize: pageSize},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list org action variables err: %v", err))
}
return to.TextResult(variables)
}
func getOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getOrgActionVariableFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
variable, _, err := client.GetOrgActionVariable(org, name)
if err != nil {
return to.ErrorResult(fmt.Errorf("get org action variable err: %v", err))
}
return to.TextResult(variable)
}
func createOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createOrgActionVariableFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
value, err := params.GetString(req.GetArguments(), "value")
if err != nil || value == "" {
return to.ErrorResult(errors.New("value is required"))
}
description, _ := req.GetArguments()["description"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.CreateOrgActionVariable(org, gitea_sdk.CreateOrgActionVariableOption{
Name: name,
Value: value,
Description: description,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("create org action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable created", "status": resp.StatusCode})
}
func updateOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called updateOrgActionVariableFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
value, err := params.GetString(req.GetArguments(), "value")
if err != nil || value == "" {
return to.ErrorResult(errors.New("value is required"))
}
description, _ := req.GetArguments()["description"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
resp, err := client.UpdateOrgActionVariable(org, name, gitea_sdk.UpdateOrgActionVariableOption{
Value: value,
Description: description,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("update org action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable updated", "status": resp.StatusCode})
}
func deleteOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteOrgActionVariableFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil || org == "" {
return to.ErrorResult(errors.New("org is required"))
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil || name == "" {
return to.ErrorResult(errors.New("name is required"))
}
_, err = gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("orgs/%s/actions/variables/%s", url.PathEscape(org), url.PathEscape(name)), nil, nil, nil)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete org action variable err: %v", err))
}
return to.TextResult(map[string]any{"message": "variable deleted"})
}

View File

@@ -0,0 +1,22 @@
package actions
import "testing"
func TestTailByLines(t *testing.T) {
in := []byte("a\nb\nc\nd\n")
got := string(tailByLines(in, 2))
if got != "c\nd\n" {
t.Fatalf("tailByLines(...,2) = %q", got)
}
}
func TestLimitBytesKeepsTail(t *testing.T) {
in := []byte("0123456789")
out, truncated := limitBytes(in, 4)
if !truncated {
t.Fatalf("expected truncated=true")
}
if string(out) != "6789" {
t.Fatalf("limitBytes tail = %q, want %q", string(out), "6789")
}
}

549
operation/actions/runs.go Normal file
View File

@@ -0,0 +1,549 @@
package actions
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
ActionsRunReadToolName = "actions_run_read"
ActionsRunWriteToolName = "actions_run_write"
)
var (
ActionsRunReadTool = mcp.NewTool(
ActionsRunReadToolName,
mcp.WithDescription("Read Actions workflow, run, and job data. Use method 'list_workflows'/'get_workflow' for workflows, 'list_runs'/'get_run' for runs, 'list_jobs'/'list_run_jobs' for jobs, 'get_job_log_preview'/'download_job_log' for logs."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list_workflows", "get_workflow", "list_runs", "get_run", "list_jobs", "list_run_jobs", "get_job_log_preview", "download_job_log")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("workflow_id", mcp.Description("workflow ID or filename (required for 'get_workflow')")),
mcp.WithNumber("run_id", mcp.Description("run ID (required for 'get_run', 'list_run_jobs')")),
mcp.WithNumber("job_id", mcp.Description("job ID (required for 'get_job_log_preview', 'download_job_log')")),
mcp.WithString("status", mcp.Description("optional status filter (for 'list_runs', 'list_jobs')")),
mcp.WithNumber("tail_lines", mcp.Description("number of lines from end of log (for 'get_job_log_preview')"), mcp.DefaultNumber(200), mcp.Min(1)),
mcp.WithNumber("max_bytes", mcp.Description("max bytes to return (for 'get_job_log_preview')"), mcp.DefaultNumber(65536), mcp.Min(1024)),
mcp.WithString("output_path", mcp.Description("output file path (for 'download_job_log')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)),
)
ActionsRunWriteTool = mcp.NewTool(
ActionsRunWriteToolName,
mcp.WithDescription("Trigger, cancel, or rerun Actions workflows."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("dispatch_workflow", "cancel_run", "rerun_run")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("workflow_id", mcp.Description("workflow ID or filename (required for 'dispatch_workflow')")),
mcp.WithString("ref", mcp.Description("git ref branch or tag (required for 'dispatch_workflow')")),
mcp.WithObject("inputs", mcp.Description("workflow inputs object (for 'dispatch_workflow')")),
mcp.WithNumber("run_id", mcp.Description("run ID (required for 'cancel_run', 'rerun_run')")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{Tool: ActionsRunReadTool, Handler: runReadFn})
Tool.RegisterWrite(server.ServerTool{Tool: ActionsRunWriteTool, Handler: runWriteFn})
}
func runReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "list_workflows":
return listRepoActionWorkflowsFn(ctx, req)
case "get_workflow":
return getRepoActionWorkflowFn(ctx, req)
case "list_runs":
return listRepoActionRunsFn(ctx, req)
case "get_run":
return getRepoActionRunFn(ctx, req)
case "list_jobs":
return listRepoActionJobsFn(ctx, req)
case "list_run_jobs":
return listRepoActionRunJobsFn(ctx, req)
case "get_job_log_preview":
return getRepoActionJobLogPreviewFn(ctx, req)
case "download_job_log":
return downloadRepoActionJobLogFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func runWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "dispatch_workflow":
return dispatchRepoActionWorkflowFn(ctx, req)
case "cancel_run":
return cancelRepoActionRunFn(ctx, req)
case "rerun_run":
return rerunRepoActionRunFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func doJSONWithFallback(ctx context.Context, method string, paths []string, query url.Values, body, respOut any) error {
var lastErr error
for _, p := range paths {
_, err := gitea.DoJSON(ctx, method, p, query, body, respOut)
if err == nil {
return nil
}
lastErr = err
var httpErr *gitea.HTTPError
if errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) {
continue
}
return err
}
return lastErr
}
func listRepoActionWorkflowsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionWorkflowsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
query := url.Values{}
query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize))
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/workflows", url.PathEscape(owner), url.PathEscape(repo)),
},
query, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("list action workflows err: %v", err))
}
return to.TextResult(slimActionWorkflows(result))
}
func getRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getRepoActionWorkflowFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
workflowID, err := params.GetString(req.GetArguments(), "workflow_id")
if err != nil || workflowID == "" {
return to.ErrorResult(errors.New("workflow_id is required"))
}
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/workflows/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflowID)),
},
nil, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("get action workflow err: %v", err))
}
return to.TextResult(slimActionWorkflow(result))
}
func dispatchRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called dispatchRepoActionWorkflowFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
workflowID, err := params.GetString(req.GetArguments(), "workflow_id")
if err != nil || workflowID == "" {
return to.ErrorResult(errors.New("workflow_id is required"))
}
ref, err := params.GetString(req.GetArguments(), "ref")
if err != nil || ref == "" {
return to.ErrorResult(errors.New("ref is required"))
}
var inputs map[string]any
if raw, exists := req.GetArguments()["inputs"]; exists {
if m, ok := raw.(map[string]any); ok {
inputs = m
}
}
body := map[string]any{
"ref": ref,
}
if inputs != nil {
body["inputs"] = inputs
}
err = doJSONWithFallback(ctx, "POST",
[]string{
fmt.Sprintf("repos/%s/%s/actions/workflows/%s/dispatches", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflowID)),
fmt.Sprintf("repos/%s/%s/actions/workflows/%s/dispatch", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(workflowID)),
},
nil, body, nil,
)
if err != nil {
var httpErr *gitea.HTTPError
if errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) {
return to.ErrorResult(fmt.Errorf("workflow dispatch not supported on this Gitea version (endpoint returned %d). Check https://docs.gitea.com/api/1.24/ for available Actions endpoints", httpErr.StatusCode))
}
return to.ErrorResult(fmt.Errorf("dispatch action workflow err: %v", err))
}
return to.TextResult(map[string]any{"message": "workflow dispatched"})
}
func listRepoActionRunsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionRunsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
statusFilter, _ := req.GetArguments()["status"].(string)
query := url.Values{}
query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize))
if statusFilter != "" {
query.Set("status", statusFilter)
}
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs", url.PathEscape(owner), url.PathEscape(repo)),
},
query, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("list action runs err: %v", err))
}
return to.TextResult(slimActionRuns(result))
}
func getRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getRepoActionRunFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
runID, err := params.GetIndex(req.GetArguments(), "run_id")
if err != nil || runID <= 0 {
return to.ErrorResult(errors.New("run_id is required"))
}
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs/%d", url.PathEscape(owner), url.PathEscape(repo), runID),
},
nil, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("get action run err: %v", err))
}
return to.TextResult(slimActionRun(result))
}
func cancelRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called cancelRepoActionRunFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
runID, err := params.GetIndex(req.GetArguments(), "run_id")
if err != nil || runID <= 0 {
return to.ErrorResult(errors.New("run_id is required"))
}
err = doJSONWithFallback(ctx, "POST",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs/%d/cancel", url.PathEscape(owner), url.PathEscape(repo), runID),
},
nil, nil, nil,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("cancel action run err: %v", err))
}
return to.TextResult(map[string]any{"message": "run cancellation requested"})
}
func rerunRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called rerunRepoActionRunFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
runID, err := params.GetIndex(req.GetArguments(), "run_id")
if err != nil || runID <= 0 {
return to.ErrorResult(errors.New("run_id is required"))
}
err = doJSONWithFallback(ctx, "POST",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs/%d/rerun", url.PathEscape(owner), url.PathEscape(repo), runID),
fmt.Sprintf("repos/%s/%s/actions/runs/%d/rerun-failed-jobs", url.PathEscape(owner), url.PathEscape(repo), runID),
},
nil, nil, nil,
)
if err != nil {
var httpErr *gitea.HTTPError
if errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) {
return to.ErrorResult(fmt.Errorf("workflow rerun not supported on this Gitea version (endpoint returned %d). Check https://docs.gitea.com/api/1.24/ for available Actions endpoints", httpErr.StatusCode))
}
return to.ErrorResult(fmt.Errorf("rerun action run err: %v", err))
}
return to.TextResult(map[string]any{"message": "run rerun requested"})
}
func listRepoActionJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionJobsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
statusFilter, _ := req.GetArguments()["status"].(string)
query := url.Values{}
query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize))
if statusFilter != "" {
query.Set("status", statusFilter)
}
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/jobs", url.PathEscape(owner), url.PathEscape(repo)),
},
query, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("list action jobs err: %v", err))
}
return to.TextResult(slimActionJobs(result))
}
func listRepoActionRunJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoActionRunJobsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil || owner == "" {
return to.ErrorResult(errors.New("owner is required"))
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil || repo == "" {
return to.ErrorResult(errors.New("repo is required"))
}
runID, err := params.GetIndex(req.GetArguments(), "run_id")
if err != nil || runID <= 0 {
return to.ErrorResult(errors.New("run_id is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
query := url.Values{}
query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize))
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs/%d/jobs", url.PathEscape(owner), url.PathEscape(repo), runID),
},
query, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("list action run jobs err: %v", err))
}
return to.TextResult(slimActionJobs(result))
}
// Log functions (merged from logs.go)
func logPaths(owner, repo string, jobID int64) []string {
return []string{
fmt.Sprintf("repos/%s/%s/actions/jobs/%d/logs", url.PathEscape(owner), url.PathEscape(repo), jobID),
fmt.Sprintf("repos/%s/%s/actions/jobs/%d/log", url.PathEscape(owner), url.PathEscape(repo), jobID),
fmt.Sprintf("repos/%s/%s/actions/tasks/%d/log", url.PathEscape(owner), url.PathEscape(repo), jobID),
fmt.Sprintf("repos/%s/%s/actions/task/%d/log", url.PathEscape(owner), url.PathEscape(repo), jobID),
}
}
func fetchJobLogBytes(ctx context.Context, owner, repo string, jobID int64) ([]byte, string, error) {
var lastErr error
for _, p := range logPaths(owner, repo, jobID) {
b, _, err := gitea.DoBytes(ctx, "GET", p, nil, nil, "text/plain")
if err == nil {
return b, p, nil
}
lastErr = err
var httpErr *gitea.HTTPError
if errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) {
continue
}
return nil, p, err
}
return nil, "", lastErr
}
func tailByLines(data []byte, tailLines int) []byte {
if tailLines <= 0 || len(data) == 0 {
return data
}
lines := 0
i := len(data) - 1
for i >= 0 {
if data[i] == '\n' {
lines++
if lines > tailLines {
return data[i+1:]
}
}
i--
}
return data
}
func limitBytes(data []byte, maxBytes int) ([]byte, bool) {
if maxBytes <= 0 {
return data, false
}
if len(data) <= maxBytes {
return data, false
}
return data[len(data)-maxBytes:], true
}
func getRepoActionJobLogPreviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getRepoActionJobLogPreviewFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
jobID, err := params.GetIndex(req.GetArguments(), "job_id")
if err != nil {
return to.ErrorResult(err)
}
tailLines := int(params.GetOptionalInt(req.GetArguments(), "tail_lines", 200))
maxBytes := int(params.GetOptionalInt(req.GetArguments(), "max_bytes", 65536))
raw, usedPath, err := fetchJobLogBytes(ctx, owner, repo, jobID)
if err != nil {
return to.ErrorResult(fmt.Errorf("get job log err: %v", err))
}
tailed := tailByLines(raw, tailLines)
limited, truncated := limitBytes(tailed, maxBytes)
return to.TextResult(map[string]any{
"endpoint": usedPath,
"job_id": jobID,
"bytes": len(raw),
"tail_lines": tailLines,
"max_bytes": maxBytes,
"truncated": truncated,
"log": string(limited),
})
}
func downloadRepoActionJobLogFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called downloadRepoActionJobLogFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
jobID, err := params.GetIndex(req.GetArguments(), "job_id")
if err != nil {
return to.ErrorResult(err)
}
outputPath, _ := req.GetArguments()["output_path"].(string)
raw, usedPath, err := fetchJobLogBytes(ctx, owner, repo, jobID)
if err != nil {
return to.ErrorResult(fmt.Errorf("download job log err: %v", err))
}
if outputPath == "" {
home, _ := os.UserHomeDir()
if home == "" {
home = os.TempDir()
}
outputPath = filepath.Join(home, ".gitea-mcp", "artifacts", "actions-logs", owner, repo, fmt.Sprintf("%d.log", jobID))
}
if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil {
return to.ErrorResult(fmt.Errorf("create output dir err: %v", err))
}
if err := os.WriteFile(outputPath, raw, 0o600); err != nil {
return to.ErrorResult(fmt.Errorf("write log file err: %v", err))
}
return to.TextResult(map[string]any{
"endpoint": usedPath,
"job_id": jobID,
"path": outputPath,
"bytes": len(raw),
})
}

92
operation/actions/slim.go Normal file
View File

@@ -0,0 +1,92 @@
package actions
func pick(m map[string]any, keys ...string) map[string]any {
out := make(map[string]any, len(keys))
for _, k := range keys {
if v, ok := m[k]; ok {
out[k] = v
}
}
return out
}
func slimPaginated(raw any, itemFn func(map[string]any) map[string]any) any {
m, ok := raw.(map[string]any)
if !ok {
return raw
}
result := make(map[string]any)
if tc, ok := m["total_count"]; ok {
result["total_count"] = tc
}
for key, val := range m {
if key == "total_count" {
continue
}
arr, ok := val.([]any)
if !ok {
continue
}
slimmed := make([]any, 0, len(arr))
for _, item := range arr {
if im, ok := item.(map[string]any); ok {
slimmed = append(slimmed, itemFn(im))
}
}
result[key] = slimmed
break
}
return result
}
func slimRun(m map[string]any) map[string]any {
return pick(m, "id", "name", "head_branch", "head_sha", "run_number",
"event", "status", "conclusion", "workflow_id",
"html_url", "created_at", "updated_at")
}
func slimJob(m map[string]any) map[string]any {
out := pick(m, "id", "run_id", "name", "workflow_name",
"status", "conclusion", "html_url",
"started_at", "completed_at")
if steps, ok := m["steps"].([]any); ok {
slim := make([]any, 0, len(steps))
for _, s := range steps {
if sm, ok := s.(map[string]any); ok {
slim = append(slim, pick(sm, "name", "number", "status", "conclusion"))
}
}
out["steps"] = slim
}
return out
}
func slimWorkflow(m map[string]any) map[string]any {
return pick(m, "id", "name", "path", "state", "html_url", "created_at", "updated_at")
}
func slimActionRun(raw any) any {
if m, ok := raw.(map[string]any); ok {
return slimRun(m)
}
return raw
}
func slimActionRuns(raw any) any {
return slimPaginated(raw, slimRun)
}
func slimActionJobs(raw any) any {
return slimPaginated(raw, slimJob)
}
func slimActionWorkflow(raw any) any {
if m, ok := raw.(map[string]any); ok {
return slimWorkflow(m)
}
return raw
}
func slimActionWorkflows(raw any) any {
return slimPaginated(raw, slimWorkflow)
}

View File

@@ -6,7 +6,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/ptr" "gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
@@ -18,24 +18,12 @@ import (
var Tool = tool.New() var Tool = tool.New()
const ( const (
GetIssueByIndexToolName = "get_issue_by_index" ListRepoIssuesToolName = "list_issues"
ListRepoIssuesToolName = "list_repo_issues" IssueReadToolName = "issue_read"
CreateIssueToolName = "create_issue" IssueWriteToolName = "issue_write"
CreateIssueCommentToolName = "create_issue_comment"
EditIssueToolName = "edit_issue"
EditIssueCommentToolName = "edit_issue_comment"
GetIssueCommentsByIndexToolName = "get_issue_comments_by_index"
) )
var ( var (
GetIssueByIndexTool = mcp.NewTool(
GetIssueByIndexToolName,
mcp.WithDescription("get issue by index"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("repository issue index")),
)
ListRepoIssuesTool = mcp.NewTool( ListRepoIssuesTool = mcp.NewTool(
ListRepoIssuesToolName, ListRepoIssuesToolName,
mcp.WithDescription("List repository issues"), mcp.WithDescription("List repository issues"),
@@ -43,142 +31,143 @@ var (
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("state", mcp.Description("issue state"), mcp.DefaultString("all")), mcp.WithString("state", mcp.Description("issue state"), mcp.DefaultString("all")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)), mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(100)), mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
) )
CreateIssueTool = mcp.NewTool( IssueReadTool = mcp.NewTool(
CreateIssueToolName, IssueReadToolName,
mcp.WithDescription("create issue"), mcp.WithDescription("Get information about a specific issue. Use method 'get' for issue details, 'get_comments' for issue comments, 'get_labels' for issue labels."),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "get_comments", "get_labels")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("title", mcp.Required(), mcp.Description("issue title")),
mcp.WithString("body", mcp.Required(), mcp.Description("issue body")),
)
CreateIssueCommentTool = mcp.NewTool(
CreateIssueCommentToolName,
mcp.WithDescription("create issue comment"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("repository issue index")), mcp.WithNumber("index", mcp.Required(), mcp.Description("repository issue index")),
mcp.WithString("body", mcp.Required(), mcp.Description("issue comment body")),
) )
EditIssueTool = mcp.NewTool( IssueWriteTool = mcp.NewTool(
EditIssueToolName, IssueWriteToolName,
mcp.WithDescription("edit issue"), mcp.WithDescription("Create or update issues and comments, manage labels. Use method 'create' to create an issue, 'update' to edit, 'add_comment'/'edit_comment' for comments, 'add_labels'/'remove_label'/'replace_labels'/'clear_labels' for label management."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "update", "add_comment", "edit_comment", "add_labels", "remove_label", "replace_labels", "clear_labels")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("repository issue index")), mcp.WithNumber("index", mcp.Description("issue index (required for all methods except 'create')")),
mcp.WithString("title", mcp.Description("issue title"), mcp.DefaultString("")), mcp.WithString("title", mcp.Description("issue title (required for 'create')")),
mcp.WithString("body", mcp.Description("issue body content")), mcp.WithString("body", mcp.Description("issue/comment body (required for 'create', 'add_comment', 'edit_comment')")),
mcp.WithArray("assignees", mcp.Description("usernames to assign to this issue"), mcp.Items(map[string]interface{}{"type": "string"})), mcp.WithArray("assignees", mcp.Description("usernames to assign (for 'create', 'update')"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithNumber("milestone", mcp.Description("milestone number")), mcp.WithNumber("milestone", mcp.Description("milestone number (for 'create', 'update')")),
mcp.WithString("state", mcp.Description("issue state, one of open, closed, all")), mcp.WithString("state", mcp.Description("issue state, one of open, closed, all (for 'update')")),
) mcp.WithNumber("commentID", mcp.Description("id of issue comment (required for 'edit_comment')")),
mcp.WithArray("labels", mcp.Description("array of label IDs (for 'add_labels', 'replace_labels')"), mcp.Items(map[string]any{"type": "number"})),
EditIssueCommentTool = mcp.NewTool( mcp.WithNumber("label_id", mcp.Description("label ID to remove (required for 'remove_label')")),
EditIssueCommentToolName,
mcp.WithDescription("edit issue comment"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("commentID", mcp.Required(), mcp.Description("id of issue comment")),
mcp.WithString("body", mcp.Required(), mcp.Description("issue comment body")),
)
GetIssueCommentsByIndexTool = mcp.NewTool(
GetIssueCommentsByIndexToolName,
mcp.WithDescription("get issue comment by index"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("repository issue index")),
) )
) )
func init() { func init() {
Tool.RegisterRead(server.ServerTool{
Tool: GetIssueByIndexTool,
Handler: GetIssueByIndexFn,
})
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(server.ServerTool{
Tool: ListRepoIssuesTool, Tool: ListRepoIssuesTool,
Handler: ListRepoIssuesFn, Handler: listRepoIssuesFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: CreateIssueTool,
Handler: CreateIssueFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: CreateIssueCommentTool,
Handler: CreateIssueCommentFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: EditIssueTool,
Handler: EditIssueFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: EditIssueCommentTool,
Handler: EditIssueCommentFn,
}) })
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(server.ServerTool{
Tool: GetIssueCommentsByIndexTool, Tool: IssueReadTool,
Handler: GetIssueCommentsByIndexFn, Handler: issueReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: IssueWriteTool,
Handler: issueWriteFn,
}) })
} }
func GetIssueByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func issueReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetIssueByIndexFn") args := req.GetArguments()
owner, ok := req.GetArguments()["owner"].(string) method, err := params.GetString(args, "method")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("owner is required")) return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) switch method {
if !ok { case "get":
return to.ErrorResult(fmt.Errorf("repo is required")) return getIssueByIndexFn(ctx, req)
case "get_comments":
return getIssueCommentsByIndexFn(ctx, req)
case "get_labels":
return getIssueLabelsFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
index, ok := req.GetArguments()["index"].(float64) }
if !ok {
return to.ErrorResult(fmt.Errorf("index is required")) func issueWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
method, err := params.GetString(args, "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "create":
return createIssueFn(ctx, req)
case "update":
return editIssueFn(ctx, req)
case "add_comment":
return createIssueCommentFn(ctx, req)
case "edit_comment":
return editIssueCommentFn(ctx, req)
case "add_labels":
return addIssueLabelsFn(ctx, req)
case "remove_label":
return removeIssueLabelFn(ctx, req)
case "replace_labels":
return replaceIssueLabelsFn(ctx, req)
case "clear_labels":
return clearIssueLabelsFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func getIssueByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getIssueByIndexFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
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))
} }
issue, _, err := client.GetIssue(owner, repo, int64(index)) issue, _, err := client.GetIssue(owner, repo, index)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/issue/%v err: %v", owner, repo, int64(index), err)) return to.ErrorResult(fmt.Errorf("get %v/%v/issue/%v err: %v", owner, repo, index, err))
} }
return to.TextResult(issue) return to.TextResult(slimIssue(issue))
} }
func ListRepoIssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listRepoIssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListIssuesFn") log.Debugf("Called ListIssuesFn")
owner, ok := req.GetArguments()["owner"].(string) owner, err := params.GetString(req.GetArguments(), "owner")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("owner is required")) return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(req.GetArguments(), "repo")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(err)
} }
state, ok := req.GetArguments()["state"].(string) state, ok := req.GetArguments()["state"].(string)
if !ok { if !ok {
state = "all" state = "all"
} }
page, ok := req.GetArguments()["page"].(float64) page, pageSize := params.GetPagination(req.GetArguments(), 30)
if !ok {
page = 1
}
pageSize, ok := req.GetArguments()["pageSize"].(float64)
if !ok {
pageSize = 100
}
opt := gitea_sdk.ListIssueOption{ opt := gitea_sdk.ListIssueOption{
State: gitea_sdk.StateType(state), State: gitea_sdk.StateType(state),
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: int(page), Page: page,
PageSize: int(pageSize), PageSize: pageSize,
}, },
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -189,59 +178,66 @@ func ListRepoIssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/issues err: %v", owner, repo, err)) return to.ErrorResult(fmt.Errorf("get %v/%v/issues err: %v", owner, repo, err))
} }
return to.TextResult(issues) return to.TextResult(slimIssues(issues))
} }
func CreateIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func createIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateIssueFn") log.Debugf("Called createIssueFn")
owner, ok := req.GetArguments()["owner"].(string) owner, err := params.GetString(req.GetArguments(), "owner")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("owner is required")) return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(req.GetArguments(), "repo")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(err)
} }
title, ok := req.GetArguments()["title"].(string) title, err := params.GetString(req.GetArguments(), "title")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("title is required")) return to.ErrorResult(err)
} }
body, ok := req.GetArguments()["body"].(string) body, err := params.GetString(req.GetArguments(), "body")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("body is required")) 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))
} }
issue, _, err := client.CreateIssue(owner, repo, gitea_sdk.CreateIssueOption{ opt := gitea_sdk.CreateIssueOption{
Title: title, Title: title,
Body: body, Body: body,
}) }
opt.Assignees = params.GetStringSlice(req.GetArguments(), "assignees")
if val, exists := req.GetArguments()["milestone"]; exists {
if milestone, ok := params.ToInt64(val); ok {
opt.Milestone = milestone
}
}
issue, _, err := client.CreateIssue(owner, repo, opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/%v/issue err: %v", owner, repo, err)) return to.ErrorResult(fmt.Errorf("create %v/%v/issue err: %v", owner, repo, err))
} }
return to.TextResult(issue) return to.TextResult(slimIssue(issue))
} }
func CreateIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func createIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateIssueCommentFn") log.Debugf("Called createIssueCommentFn")
owner, ok := req.GetArguments()["owner"].(string) owner, err := params.GetString(req.GetArguments(), "owner")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("owner is required")) return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(req.GetArguments(), "repo")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(err)
} }
index, ok := req.GetArguments()["index"].(float64) index, err := params.GetIndex(req.GetArguments(), "index")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("index is required")) return to.ErrorResult(err)
} }
body, ok := req.GetArguments()["body"].(string) body, err := params.GetString(req.GetArguments(), "body")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("body is required")) return to.ErrorResult(err)
} }
opt := gitea_sdk.CreateIssueCommentOption{ opt := gitea_sdk.CreateIssueCommentOption{
Body: body, Body: body,
@@ -250,27 +246,27 @@ func CreateIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Ca
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))
} }
issueComment, _, err := client.CreateIssueComment(owner, repo, int64(index), opt) issueComment, _, err := client.CreateIssueComment(owner, repo, index, opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/%v/issue/%v/comment err: %v", owner, repo, int64(index), err)) return to.ErrorResult(fmt.Errorf("create %v/%v/issue/%v/comment err: %v", owner, repo, index, err))
} }
return to.TextResult(issueComment) return to.TextResult(slimComment(issueComment))
} }
func EditIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func editIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called EditIssueFn") log.Debugf("Called editIssueFn")
owner, ok := req.GetArguments()["owner"].(string) owner, err := params.GetString(req.GetArguments(), "owner")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("owner is required")) return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(req.GetArguments(), "repo")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(err)
} }
index, ok := req.GetArguments()["index"].(float64) index, err := params.GetIndex(req.GetArguments(), "index")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("index is required")) return to.ErrorResult(err)
} }
opt := gitea_sdk.EditIssueOption{} opt := gitea_sdk.EditIssueOption{}
@@ -281,50 +277,48 @@ func EditIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRes
} }
body, ok := req.GetArguments()["body"].(string) body, ok := req.GetArguments()["body"].(string)
if ok { if ok {
opt.Body = ptr.To(body) opt.Body = new(body)
} }
assignees, ok := req.GetArguments()["assignees"].([]string) opt.Assignees = params.GetStringSlice(req.GetArguments(), "assignees")
if ok { if val, exists := req.GetArguments()["milestone"]; exists {
opt.Assignees = assignees if milestone, ok := params.ToInt64(val); ok {
} opt.Milestone = new(milestone)
milestone, ok := req.GetArguments()["milestone"].(float64) }
if ok {
opt.Milestone = ptr.To(int64(milestone))
} }
state, ok := req.GetArguments()["state"].(string) state, ok := req.GetArguments()["state"].(string)
if ok { if ok {
opt.State = ptr.To(gitea_sdk.StateType(state)) opt.State = new(gitea_sdk.StateType(state))
} }
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))
} }
issue, _, err := client.EditIssue(owner, repo, int64(index), opt) issue, _, err := client.EditIssue(owner, repo, index, opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("edit %v/%v/issue/%v err: %v", owner, repo, int64(index), err)) return to.ErrorResult(fmt.Errorf("edit %v/%v/issue/%v err: %v", owner, repo, index, err))
} }
return to.TextResult(issue) return to.TextResult(slimIssue(issue))
} }
func EditIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func editIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called EditIssueCommentFn") log.Debugf("Called editIssueCommentFn")
owner, ok := req.GetArguments()["owner"].(string) owner, err := params.GetString(req.GetArguments(), "owner")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("owner is required")) return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(req.GetArguments(), "repo")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(err)
} }
commentID, ok := req.GetArguments()["commentID"].(float64) commentID, err := params.GetIndex(req.GetArguments(), "commentID")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("comment ID is required")) return to.ErrorResult(err)
} }
body, ok := req.GetArguments()["body"].(string) body, err := params.GetString(req.GetArguments(), "body")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("body is required")) return to.ErrorResult(err)
} }
opt := gitea_sdk.EditIssueCommentOption{ opt := gitea_sdk.EditIssueCommentOption{
Body: body, Body: body,
@@ -333,37 +327,181 @@ func EditIssueCommentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
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))
} }
issueComment, _, err := client.EditIssueComment(owner, repo, int64(commentID), opt) issueComment, _, err := client.EditIssueComment(owner, repo, commentID, opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("edit %v/%v/issues/comments/%v err: %v", owner, repo, int64(commentID), err)) return to.ErrorResult(fmt.Errorf("edit %v/%v/issues/comments/%v err: %v", owner, repo, commentID, err))
} }
return to.TextResult(issueComment) return to.TextResult(slimComment(issueComment))
} }
func GetIssueCommentsByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getIssueCommentsByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetIssueCommentsByIndexFn") log.Debugf("Called getIssueCommentsByIndexFn")
owner, ok := req.GetArguments()["owner"].(string) owner, err := params.GetString(req.GetArguments(), "owner")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("owner is required")) return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(req.GetArguments(), "repo")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(err)
} }
index, ok := req.GetArguments()["index"].(float64) index, err := params.GetIndex(req.GetArguments(), "index")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("index is required")) return to.ErrorResult(err)
} }
opt := gitea_sdk.ListIssueCommentOptions{} opt := gitea_sdk.ListIssueCommentOptions{}
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))
} }
issue, _, err := client.ListIssueComments(owner, repo, int64(index), opt) issue, _, err := client.ListIssueComments(owner, repo, index, opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/issues/%v/comments err: %v", owner, repo, int64(index), err)) return to.ErrorResult(fmt.Errorf("get %v/%v/issues/%v/comments err: %v", owner, repo, index, err))
} }
return to.TextResult(issue) return to.TextResult(slimComments(issue))
}
func getIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getIssueLabelsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
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))
}
labels, _, err := client.GetIssueLabels(owner, repo, index, gitea_sdk.ListLabelsOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/issues/%v/labels err: %v", owner, repo, index, err))
}
return to.TextResult(slimLabels(labels))
}
// Issue label operations (moved from label package)
func addIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called addIssueLabelsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil {
return to.ErrorResult(err)
}
labels, err := params.GetInt64Slice(req.GetArguments(), "labels")
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))
}
issueLabels, _, err := client.AddIssueLabels(owner, repo, index, gitea_sdk.IssueLabelsOption{Labels: labels})
if err != nil {
return to.ErrorResult(fmt.Errorf("add labels to %v/%v/issue/%v err: %v", owner, repo, index, err))
}
return to.TextResult(slimLabels(issueLabels))
}
func replaceIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called replaceIssueLabelsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil {
return to.ErrorResult(err)
}
labels, err := params.GetInt64Slice(req.GetArguments(), "labels")
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))
}
issueLabels, _, err := client.ReplaceIssueLabels(owner, repo, index, gitea_sdk.IssueLabelsOption{Labels: labels})
if err != nil {
return to.ErrorResult(fmt.Errorf("replace labels on %v/%v/issue/%v err: %v", owner, repo, index, err))
}
return to.TextResult(slimLabels(issueLabels))
}
func clearIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called clearIssueLabelsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
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))
}
_, err = client.ClearIssueLabels(owner, repo, index)
if err != nil {
return to.ErrorResult(fmt.Errorf("clear labels on %v/%v/issue/%v err: %v", owner, repo, index, err))
}
return to.TextResult("Labels cleared successfully")
}
func removeIssueLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called removeIssueLabelFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil {
return to.ErrorResult(err)
}
labelID, err := params.GetIndex(req.GetArguments(), "label_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))
}
_, err = client.DeleteIssueLabel(owner, repo, index, labelID)
if err != nil {
return to.ErrorResult(fmt.Errorf("remove label %v from %v/%v/issue/%v err: %v", labelID, owner, repo, index, err))
}
return to.TextResult("Label removed successfully")
} }

133
operation/issue/slim.go Normal file
View File

@@ -0,0 +1,133 @@
package issue
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func userLogin(u *gitea_sdk.User) string {
if u == nil {
return ""
}
return u.UserName
}
func userLogins(users []*gitea_sdk.User) []string {
if len(users) == 0 {
return nil
}
out := make([]string, 0, len(users))
for _, u := range users {
if u != nil {
out = append(out, u.UserName)
}
}
return out
}
func labelNames(labels []*gitea_sdk.Label) []string {
if len(labels) == 0 {
return nil
}
out := make([]string, 0, len(labels))
for _, l := range labels {
if l != nil {
out = append(out, l.Name)
}
}
return out
}
func slimIssue(i *gitea_sdk.Issue) map[string]any {
if i == nil {
return nil
}
m := map[string]any{
"number": i.Index,
"title": i.Title,
"body": i.Body,
"state": i.State,
"html_url": i.HTMLURL,
"user": userLogin(i.Poster),
"labels": labelNames(i.Labels),
"comments": i.Comments,
"created_at": i.Created,
"updated_at": i.Updated,
"closed_at": i.Closed,
}
if len(i.Assignees) > 0 {
m["assignees"] = userLogins(i.Assignees)
}
if i.Milestone != nil {
m["milestone"] = map[string]any{
"id": i.Milestone.ID,
"title": i.Milestone.Title,
}
}
if i.PullRequest != nil {
m["is_pull"] = true
}
return m
}
func slimIssues(issues []*gitea_sdk.Issue) []map[string]any {
out := make([]map[string]any, 0, len(issues))
for _, i := range issues {
if i == nil {
continue
}
m := map[string]any{
"number": i.Index,
"title": i.Title,
"state": i.State,
"html_url": i.HTMLURL,
"user": userLogin(i.Poster),
"comments": i.Comments,
"created_at": i.Created,
"updated_at": i.Updated,
}
if len(i.Labels) > 0 {
m["labels"] = labelNames(i.Labels)
}
out = append(out, m)
}
return out
}
func slimComment(c *gitea_sdk.Comment) map[string]any {
if c == nil {
return nil
}
return map[string]any{
"id": c.ID,
"body": c.Body,
"user": userLogin(c.Poster),
"html_url": c.HTMLURL,
"created_at": c.Created,
"updated_at": c.Updated,
}
}
func slimComments(comments []*gitea_sdk.Comment) []map[string]any {
out := make([]map[string]any, 0, len(comments))
for _, c := range comments {
out = append(out, slimComment(c))
}
return out
}
func slimLabels(labels []*gitea_sdk.Label) []map[string]any {
out := make([]map[string]any, 0, len(labels))
for _, l := range labels {
if l == nil {
continue
}
out = append(out, map[string]any{
"id": l.ID,
"name": l.Name,
"color": l.Color,
"description": l.Description,
"exclusive": l.Exclusive,
})
}
return out
}

View File

@@ -0,0 +1,69 @@
package issue
import (
"testing"
gitea_sdk "code.gitea.io/sdk/gitea"
)
func TestSlimIssue(t *testing.T) {
i := &gitea_sdk.Issue{
Index: 42,
Title: "Bug report",
Body: "Something is broken",
State: "open",
HTMLURL: "https://gitea.com/org/repo/issues/42",
Poster: &gitea_sdk.User{UserName: "alice"},
Labels: []*gitea_sdk.Label{{Name: "bug"}},
Milestone: &gitea_sdk.Milestone{
ID: 1,
Title: "v1.0",
},
PullRequest: &gitea_sdk.PullRequestMeta{HasMerged: false},
}
m := slimIssue(i)
if m["number"] != int64(42) {
t.Errorf("expected number 42, got %v", m["number"])
}
if m["body"] != "Something is broken" {
t.Errorf("expected body, got %v", m["body"])
}
if m["is_pull"] != true {
t.Error("expected is_pull true for issue with PullRequest")
}
ms := m["milestone"].(map[string]any)
if ms["title"] != "v1.0" {
t.Errorf("expected milestone title v1.0, got %v", ms["title"])
}
}
func TestSlimIssues_ListIsSlimmer(t *testing.T) {
i := &gitea_sdk.Issue{
Index: 1,
Title: "Issue",
State: "open",
Body: "Full body",
Poster: &gitea_sdk.User{UserName: "alice"},
Labels: []*gitea_sdk.Label{{Name: "enhancement"}},
}
single := slimIssue(i)
list := slimIssues([]*gitea_sdk.Issue{i})
// Single has body, list does not
if _, ok := single["body"]; !ok {
t.Error("single issue should have body")
}
if _, ok := list[0]["body"]; ok {
t.Error("list issue should not have body")
}
}
func TestSlimIssues_Nil(t *testing.T) {
if r := slimIssues(nil); len(r) != 0 {
t.Errorf("expected empty slice, got %v", r)
}
}

View File

@@ -6,7 +6,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/ptr" "gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
@@ -18,219 +18,107 @@ import (
var Tool = tool.New() var Tool = tool.New()
const ( const (
ListRepoLabelsToolName = "list_repo_labels" LabelReadToolName = "label_read"
GetRepoLabelToolName = "get_repo_label" LabelWriteToolName = "label_write"
CreateRepoLabelToolName = "create_repo_label"
EditRepoLabelToolName = "edit_repo_label"
DeleteRepoLabelToolName = "delete_repo_label"
AddIssueLabelsToolName = "add_issue_labels"
ReplaceIssueLabelsToolName = "replace_issue_labels"
ClearIssueLabelsToolName = "clear_issue_labels"
RemoveIssueLabelToolName = "remove_issue_label"
ListOrgLabelsToolName = "list_org_labels"
CreateOrgLabelToolName = "create_org_label"
EditOrgLabelToolName = "edit_org_label"
DeleteOrgLabelToolName = "delete_org_label"
) )
var ( var (
ListRepoLabelsTool = mcp.NewTool( LabelReadTool = mcp.NewTool(
ListRepoLabelsToolName, LabelReadToolName,
mcp.WithDescription("Lists all labels for a given repository"), mcp.WithDescription("Read label information. Use method 'list_repo_labels' to list repository labels, 'get_repo_label' to get a specific repo label, 'list_org_labels' to list organization labels."),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list_repo_labels", "get_repo_label", "list_org_labels")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("owner", mcp.Description("repository owner (required for repo methods)")),
mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")),
mcp.WithString("org", mcp.Description("organization name (required for 'list_org')")),
mcp.WithNumber("id", mcp.Description("label ID (required for 'get_repo')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)), mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(100)), mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
) )
GetRepoLabelTool = mcp.NewTool( LabelWriteTool = mcp.NewTool(
GetRepoLabelToolName, LabelWriteToolName,
mcp.WithDescription("Gets a single label by its ID for a repository"), mcp.WithDescription("Create, edit, or delete labels for repositories or organizations."),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create_repo_label", "edit_repo_label", "delete_repo_label", "create_org_label", "edit_org_label", "delete_org_label")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("owner", mcp.Description("repository owner (required for repo methods)")),
mcp.WithNumber("id", mcp.Required(), mcp.Description("label ID")), mcp.WithString("repo", mcp.Description("repository name (required for repo methods)")),
) mcp.WithString("org", mcp.Description("organization name (required for org methods)")),
mcp.WithNumber("id", mcp.Description("label ID (required for edit/delete methods)")),
CreateRepoLabelTool = mcp.NewTool( mcp.WithString("name", mcp.Description("label name (required for create, optional for edit)")),
CreateRepoLabelToolName, mcp.WithString("color", mcp.Description("label color hex code e.g. #RRGGBB (required for create, optional for edit)")),
mcp.WithDescription("Creates a new label for a repository"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("name", mcp.Required(), mcp.Description("label name")),
mcp.WithString("color", mcp.Required(), mcp.Description("label color (hex code, e.g., #RRGGBB)")),
mcp.WithString("description", mcp.Description("label description")), mcp.WithString("description", mcp.Description("label description")),
mcp.WithBoolean("exclusive", mcp.Description("whether the label is exclusive (org labels only)")),
) )
EditRepoLabelTool = mcp.NewTool(
EditRepoLabelToolName,
mcp.WithDescription("Edits an existing label in a repository"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Required(), mcp.Description("label ID")),
mcp.WithString("name", mcp.Description("new label name")),
mcp.WithString("color", mcp.Description("new label color (hex code, e.g., #RRGGBB)")),
mcp.WithString("description", mcp.Description("new label description")),
)
DeleteRepoLabelTool = mcp.NewTool(
DeleteRepoLabelToolName,
mcp.WithDescription("Deletes a label from a repository"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Required(), mcp.Description("label ID")),
)
AddIssueLabelsTool = mcp.NewTool(
AddIssueLabelsToolName,
mcp.WithDescription("Adds one or more labels to an issue"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("issue index")),
mcp.WithArray("labels", mcp.Required(), mcp.Description("array of label IDs to add"), mcp.Items(map[string]interface{}{"type": "number"})),
)
ReplaceIssueLabelsTool = mcp.NewTool(
ReplaceIssueLabelsToolName,
mcp.WithDescription("Replaces all labels on an issue"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("issue index")),
mcp.WithArray("labels", mcp.Required(), mcp.Description("array of label IDs to replace with"), mcp.Items(map[string]interface{}{"type": "number"})),
)
ClearIssueLabelsTool = mcp.NewTool(
ClearIssueLabelsToolName,
mcp.WithDescription("Removes all labels from an issue"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("issue index")),
)
RemoveIssueLabelTool = mcp.NewTool(
RemoveIssueLabelToolName,
mcp.WithDescription("Removes a single label from an issue"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("issue index")),
mcp.WithNumber("label_id", mcp.Required(), mcp.Description("label ID to remove")),
)
ListOrgLabelsTool = mcp.NewTool(
ListOrgLabelsToolName,
mcp.WithDescription("Lists labels defined at organization level"),
mcp.WithString("org", mcp.Required(), mcp.Description("organization name")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(100)),
)
CreateOrgLabelTool = mcp.NewTool(
CreateOrgLabelToolName,
mcp.WithDescription("Creates a new label for an organization"),
mcp.WithString("org", mcp.Required(), mcp.Description("organization name")),
mcp.WithString("name", mcp.Required(), mcp.Description("label name")),
mcp.WithString("color", mcp.Required(), mcp.Description("label color (hex code, e.g., #RRGGBB)")),
mcp.WithString("description", mcp.Description("label description")),
mcp.WithBoolean("exclusive", mcp.Description("whether the label is exclusive"), mcp.DefaultBool(false)),
)
EditOrgLabelTool = mcp.NewTool(
EditOrgLabelToolName,
mcp.WithDescription("Edits an existing organization label"),
mcp.WithString("org", mcp.Required(), mcp.Description("organization name")),
mcp.WithNumber("id", mcp.Required(), mcp.Description("label ID")),
mcp.WithString("name", mcp.Description("new label name")),
mcp.WithString("color", mcp.Description("new label color (hex code, e.g., #RRGGBB)")),
mcp.WithString("description", mcp.Description("new label description")),
mcp.WithBoolean("exclusive", mcp.Description("whether the label is exclusive")),
)
DeleteOrgLabelTool = mcp.NewTool(
DeleteOrgLabelToolName,
mcp.WithDescription("Deletes an organization label by ID"),
mcp.WithString("org", mcp.Required(), mcp.Description("organization name")),
mcp.WithNumber("id", mcp.Required(), mcp.Description("label ID")),
)
) )
func init() { func init() {
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(server.ServerTool{
Tool: ListRepoLabelsTool, Tool: LabelReadTool,
Handler: ListRepoLabelsFn, Handler: labelReadFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetRepoLabelTool,
Handler: GetRepoLabelFn,
}) })
Tool.RegisterWrite(server.ServerTool{ Tool.RegisterWrite(server.ServerTool{
Tool: CreateRepoLabelTool, Tool: LabelWriteTool,
Handler: CreateRepoLabelFn, Handler: labelWriteFn,
}) })
Tool.RegisterWrite(server.ServerTool{
Tool: EditRepoLabelTool,
Handler: EditRepoLabelFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: DeleteRepoLabelTool,
Handler: DeleteRepoLabelFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: AddIssueLabelsTool,
Handler: AddIssueLabelsFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: ReplaceIssueLabelsTool,
Handler: ReplaceIssueLabelsFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: ClearIssueLabelsTool,
Handler: ClearIssueLabelsFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: RemoveIssueLabelTool,
Handler: RemoveIssueLabelFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: ListOrgLabelsTool,
Handler: ListOrgLabelsFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: CreateOrgLabelTool,
Handler: CreateOrgLabelFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: EditOrgLabelTool,
Handler: EditOrgLabelFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: DeleteOrgLabelTool,
Handler: DeleteOrgLabelFn,
})
} }
func ListRepoLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func labelReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListRepoLabelsFn") args := req.GetArguments()
owner, ok := req.GetArguments()["owner"].(string) method, err := params.GetString(args, "method")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("owner is required")) return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) switch method {
if !ok { case "list_repo_labels":
return to.ErrorResult(fmt.Errorf("repo is required")) return listRepoLabelsFn(ctx, req)
case "get_repo_label":
return getRepoLabelFn(ctx, req)
case "list_org_labels":
return listOrgLabelsFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
page, ok := req.GetArguments()["page"].(float64) }
if !ok {
page = 1 func labelWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
method, err := params.GetString(args, "method")
if err != nil {
return to.ErrorResult(err)
} }
pageSize, ok := req.GetArguments()["pageSize"].(float64) switch method {
if !ok { case "create_repo_label":
pageSize = 100 return createRepoLabelFn(ctx, req)
case "edit_repo_label":
return editRepoLabelFn(ctx, req)
case "delete_repo_label":
return deleteRepoLabelFn(ctx, req)
case "create_org_label":
return createOrgLabelFn(ctx, req)
case "edit_org_label":
return editOrgLabelFn(ctx, req)
case "delete_org_label":
return deleteOrgLabelFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
}
func listRepoLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listRepoLabelsFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.ListLabelsOptions{ opt := gitea_sdk.ListLabelsOptions{
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: int(page), Page: page,
PageSize: int(pageSize), PageSize: pageSize,
}, },
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -241,52 +129,52 @@ func ListRepoLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("list %v/%v/labels err: %v", owner, repo, err)) return to.ErrorResult(fmt.Errorf("list %v/%v/labels err: %v", owner, repo, err))
} }
return to.TextResult(labels) return to.TextResult(slimLabels(labels))
} }
func GetRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetRepoLabelFn") log.Debugf("Called getRepoLabelFn")
owner, ok := req.GetArguments()["owner"].(string) owner, err := params.GetString(req.GetArguments(), "owner")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("owner is required")) return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(req.GetArguments(), "repo")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(err)
} }
id, ok := req.GetArguments()["id"].(float64) id, err := params.GetIndex(req.GetArguments(), "id")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("label ID is required")) 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))
} }
label, _, err := client.GetRepoLabel(owner, repo, int64(id)) label, _, err := client.GetRepoLabel(owner, repo, id)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/label/%v err: %v", owner, repo, int64(id), err)) return to.ErrorResult(fmt.Errorf("get %v/%v/label/%v err: %v", owner, repo, id, err))
} }
return to.TextResult(label) return to.TextResult(slimLabel(label))
} }
func CreateRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func createRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateRepoLabelFn") log.Debugf("Called createRepoLabelFn")
owner, ok := req.GetArguments()["owner"].(string) owner, err := params.GetString(req.GetArguments(), "owner")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("owner is required")) return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(req.GetArguments(), "repo")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(err)
} }
name, ok := req.GetArguments()["name"].(string) name, err := params.GetString(req.GetArguments(), "name")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("name is required")) return to.ErrorResult(err)
} }
color, ok := req.GetArguments()["color"].(string) color, err := params.GetString(req.GetArguments(), "color")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("color is required")) return to.ErrorResult(err)
} }
description, _ := req.GetArguments()["description"].(string) // Optional description, _ := req.GetArguments()["description"].(string) // Optional
@@ -304,333 +192,186 @@ func CreateRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/%v/label err: %v", owner, repo, err)) return to.ErrorResult(fmt.Errorf("create %v/%v/label err: %v", owner, repo, err))
} }
return to.TextResult(label) return to.TextResult(slimLabel(label))
} }
func EditRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func editRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called EditRepoLabelFn") log.Debugf("Called editRepoLabelFn")
owner, ok := req.GetArguments()["owner"].(string) owner, err := params.GetString(req.GetArguments(), "owner")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("owner is required")) return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(req.GetArguments(), "repo")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(err)
} }
id, ok := req.GetArguments()["id"].(float64) id, err := params.GetIndex(req.GetArguments(), "id")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("label ID is required")) return to.ErrorResult(err)
} }
opt := gitea_sdk.EditLabelOption{} opt := gitea_sdk.EditLabelOption{}
if name, ok := req.GetArguments()["name"].(string); ok { if name, ok := req.GetArguments()["name"].(string); ok {
opt.Name = ptr.To(name) opt.Name = new(name)
} }
if color, ok := req.GetArguments()["color"].(string); ok { if color, ok := req.GetArguments()["color"].(string); ok {
opt.Color = ptr.To(color) opt.Color = new(color)
} }
if description, ok := req.GetArguments()["description"].(string); ok { if description, ok := req.GetArguments()["description"].(string); ok {
opt.Description = ptr.To(description) opt.Description = new(description)
} }
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))
} }
label, _, err := client.EditLabel(owner, repo, int64(id), opt) label, _, err := client.EditLabel(owner, repo, id, opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("edit %v/%v/label/%v err: %v", owner, repo, int64(id), err)) return to.ErrorResult(fmt.Errorf("edit %v/%v/label/%v err: %v", owner, repo, id, err))
} }
return to.TextResult(label) return to.TextResult(slimLabel(label))
} }
func DeleteRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func deleteRepoLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteRepoLabelFn") log.Debugf("Called deleteRepoLabelFn")
owner, ok := req.GetArguments()["owner"].(string) owner, err := params.GetString(req.GetArguments(), "owner")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("owner is required")) return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(req.GetArguments(), "repo")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(err)
} }
id, ok := req.GetArguments()["id"].(float64) id, err := params.GetIndex(req.GetArguments(), "id")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("label ID is required")) 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))
} }
_, err = client.DeleteLabel(owner, repo, int64(id)) _, err = client.DeleteLabel(owner, repo, id)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("delete %v/%v/label/%v err: %v", owner, repo, int64(id), err)) return to.ErrorResult(fmt.Errorf("delete %v/%v/label/%v err: %v", owner, repo, id, err))
} }
return to.TextResult("Label deleted successfully") return to.TextResult("Label deleted successfully")
} }
func AddIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func listOrgLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called AddIssueLabelsFn") log.Debugf("Called listOrgLabelsFn")
owner, ok := req.GetArguments()["owner"].(string) org, err := params.GetString(req.GetArguments(), "org")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("owner is required")) return to.ErrorResult(err)
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
index, ok := req.GetArguments()["index"].(float64)
if !ok {
return to.ErrorResult(fmt.Errorf("issue index is required"))
}
labelsRaw, ok := req.GetArguments()["labels"].([]interface{})
if !ok {
return to.ErrorResult(fmt.Errorf("labels (array of IDs) is required"))
}
var labels []int64
for _, l := range labelsRaw {
if labelID, ok := l.(float64); ok {
labels = append(labels, int64(labelID))
} else {
return to.ErrorResult(fmt.Errorf("invalid label ID in labels array"))
}
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.IssueLabelsOption{ opt := gitea_sdk.ListOrgLabelsOptions{
Labels: labels, ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
labels, _, err := client.ListOrgLabels(org, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list %v/labels err: %v", org, err))
}
return to.TextResult(slimLabels(labels))
}
func createOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createOrgLabelFn")
org, err := params.GetString(req.GetArguments(), "org")
if err != nil {
return to.ErrorResult(err)
}
name, err := params.GetString(req.GetArguments(), "name")
if err != nil {
return to.ErrorResult(err)
}
color, err := params.GetString(req.GetArguments(), "color")
if err != nil {
return to.ErrorResult(err)
}
description, _ := req.GetArguments()["description"].(string)
exclusive, _ := req.GetArguments()["exclusive"].(bool)
opt := gitea_sdk.CreateOrgLabelOption{
Name: name,
Color: color,
Description: description,
Exclusive: exclusive,
} }
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))
} }
issueLabels, _, err := client.AddIssueLabels(owner, repo, int64(index), opt) label, _, err := client.CreateOrgLabel(org, opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("add labels to %v/%v/issue/%v err: %v", owner, repo, int64(index), err)) return to.ErrorResult(fmt.Errorf("create %v/labels err: %v", org, err))
} }
return to.TextResult(issueLabels) return to.TextResult(slimLabel(label))
} }
func ReplaceIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func editOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ReplaceIssueLabelsFn") log.Debugf("Called editOrgLabelFn")
owner, ok := req.GetArguments()["owner"].(string) org, err := params.GetString(req.GetArguments(), "org")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("owner is required")) return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) id, err := params.GetIndex(req.GetArguments(), "id")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(err)
}
index, ok := req.GetArguments()["index"].(float64)
if !ok {
return to.ErrorResult(fmt.Errorf("issue index is required"))
}
labelsRaw, ok := req.GetArguments()["labels"].([]interface{})
if !ok {
return to.ErrorResult(fmt.Errorf("labels (array of IDs) is required"))
}
var labels []int64
for _, l := range labelsRaw {
if labelID, ok := l.(float64); ok {
labels = append(labels, int64(labelID))
} else {
return to.ErrorResult(fmt.Errorf("invalid label ID in labels array"))
}
} }
opt := gitea_sdk.IssueLabelsOption{ opt := gitea_sdk.EditOrgLabelOption{}
Labels: labels, if name, ok := req.GetArguments()["name"].(string); ok {
opt.Name = new(name)
}
if color, ok := req.GetArguments()["color"].(string); ok {
opt.Color = new(color)
}
if description, ok := req.GetArguments()["description"].(string); ok {
opt.Description = new(description)
}
if exclusive, ok := req.GetArguments()["exclusive"].(bool); ok {
opt.Exclusive = new(exclusive)
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
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))
} }
issueLabels, _, err := client.ReplaceIssueLabels(owner, repo, int64(index), opt) label, _, err := client.EditOrgLabel(org, id, opt)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("replace labels on %v/%v/issue/%v err: %v", owner, repo, int64(index), err)) return to.ErrorResult(fmt.Errorf("edit %v/labels/%v err: %v", org, id, err))
} }
return to.TextResult(issueLabels) return to.TextResult(slimLabel(label))
} }
func ClearIssueLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func deleteOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ClearIssueLabelsFn") log.Debugf("Called deleteOrgLabelFn")
owner, ok := req.GetArguments()["owner"].(string) org, err := params.GetString(req.GetArguments(), "org")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("owner is required")) return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) id, err := params.GetIndex(req.GetArguments(), "id")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(err)
}
index, ok := req.GetArguments()["index"].(float64)
if !ok {
return to.ErrorResult(fmt.Errorf("issue index is required"))
} }
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))
} }
_, err = client.ClearIssueLabels(owner, repo, int64(index)) _, err = client.DeleteOrgLabel(org, id)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("clear labels on %v/%v/issue/%v err: %v", owner, repo, int64(index), err)) return to.ErrorResult(fmt.Errorf("delete %v/labels/%v err: %v", org, id, err))
} }
return to.TextResult("Labels cleared successfully") return to.TextResult("Label deleted successfully")
} }
func RemoveIssueLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called RemoveIssueLabelFn")
owner, ok := req.GetArguments()["owner"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
index, ok := req.GetArguments()["index"].(float64)
if !ok {
return to.ErrorResult(fmt.Errorf("issue index is required"))
}
labelID, ok := req.GetArguments()["label_id"].(float64)
if !ok {
return to.ErrorResult(fmt.Errorf("label ID is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.DeleteIssueLabel(owner, repo, int64(index), int64(labelID))
if err != nil {
return to.ErrorResult(fmt.Errorf("remove label %v from %v/%v/issue/%v err: %v", int64(labelID), owner, repo, int64(index), err))
}
return to.TextResult("Label removed successfully")
}
func ListOrgLabelsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListOrgLabelsFn")
org, ok := req.GetArguments()["org"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("org is required"))
}
page, ok := req.GetArguments()["page"].(float64)
if !ok {
page = 1
}
pageSize, ok := req.GetArguments()["pageSize"].(float64)
if !ok {
pageSize = 100
}
opt := gitea_sdk.ListOrgLabelsOptions{
ListOptions: gitea_sdk.ListOptions{
Page: int(page),
PageSize: int(pageSize),
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
labels, _, err := client.ListOrgLabels(org, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list %v/labels err: %v", org, err))
}
return to.TextResult(labels)
}
func CreateOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateOrgLabelFn")
org, ok := req.GetArguments()["org"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("org is required"))
}
name, ok := req.GetArguments()["name"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("name is required"))
}
color, ok := req.GetArguments()["color"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("color is required"))
}
description, _ := req.GetArguments()["description"].(string)
exclusive, _ := req.GetArguments()["exclusive"].(bool)
opt := gitea_sdk.CreateOrgLabelOption{
Name: name,
Color: color,
Description: description,
Exclusive: exclusive,
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
label, _, err := client.CreateOrgLabel(org, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/labels err: %v", org, err))
}
return to.TextResult(label)
}
func EditOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called EditOrgLabelFn")
org, ok := req.GetArguments()["org"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("org is required"))
}
id, ok := req.GetArguments()["id"].(float64)
if !ok {
return to.ErrorResult(fmt.Errorf("label ID is required"))
}
opt := gitea_sdk.EditOrgLabelOption{}
if name, ok := req.GetArguments()["name"].(string); ok {
opt.Name = ptr.To(name)
}
if color, ok := req.GetArguments()["color"].(string); ok {
opt.Color = ptr.To(color)
}
if description, ok := req.GetArguments()["description"].(string); ok {
opt.Description = ptr.To(description)
}
if exclusive, ok := req.GetArguments()["exclusive"].(bool); ok {
opt.Exclusive = ptr.To(exclusive)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
label, _, err := client.EditOrgLabel(org, int64(id), opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("edit %v/labels/%v err: %v", org, int64(id), err))
}
return to.TextResult(label)
}
func DeleteOrgLabelFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteOrgLabelFn")
org, ok := req.GetArguments()["org"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("org is required"))
}
id, ok := req.GetArguments()["id"].(float64)
if !ok {
return to.ErrorResult(fmt.Errorf("label ID is required"))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.DeleteOrgLabel(org, int64(id))
if err != nil {
return to.ErrorResult(fmt.Errorf("delete %v/labels/%v err: %v", org, int64(id), err))
}
return to.TextResult("Label deleted successfully")
}

26
operation/label/slim.go Normal file
View File

@@ -0,0 +1,26 @@
package label
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func slimLabel(l *gitea_sdk.Label) map[string]any {
if l == nil {
return nil
}
return map[string]any{
"id": l.ID,
"name": l.Name,
"color": l.Color,
"description": l.Description,
"exclusive": l.Exclusive,
}
}
func slimLabels(labels []*gitea_sdk.Label) []map[string]any {
out := make([]map[string]any, 0, len(labels))
for _, l := range labels {
out = append(out, slimLabel(l))
}
return out
}

View File

@@ -0,0 +1,25 @@
package label
import (
"testing"
gitea_sdk "code.gitea.io/sdk/gitea"
)
func TestSlimLabel(t *testing.T) {
l := &gitea_sdk.Label{
ID: 1,
Name: "bug",
Color: "#d73a4a",
Description: "Something isn't working",
Exclusive: false,
}
m := slimLabel(l)
if m["name"] != "bug" {
t.Errorf("expected name bug, got %v", m["name"])
}
if m["color"] != "#d73a4a" {
t.Errorf("expected color, got %v", m["color"])
}
}

View File

@@ -0,0 +1,256 @@
package milestone
import (
"context"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
MilestoneReadToolName = "milestone_read"
MilestoneWriteToolName = "milestone_write"
)
var (
MilestoneReadTool = mcp.NewTool(
MilestoneReadToolName,
mcp.WithDescription("Read milestone information. Use method 'get' to get a specific milestone, 'list' to list milestones."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "list")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Description("milestone id (required for 'get')")),
mcp.WithString("state", mcp.Description("milestone state (for 'list')"), mcp.DefaultString("all")),
mcp.WithString("name", mcp.Description("milestone name filter (for 'list')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
MilestoneWriteTool = mcp.NewTool(
MilestoneWriteToolName,
mcp.WithDescription("Create, edit, or delete milestones."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "edit", "delete")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Description("milestone id (required for 'edit', 'delete')")),
mcp.WithString("title", mcp.Description("milestone title (required for 'create')")),
mcp.WithString("description", mcp.Description("milestone description")),
mcp.WithString("due_on", mcp.Description("due date")),
mcp.WithString("state", mcp.Description("milestone state, one of open, closed (for 'edit')")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: MilestoneReadTool,
Handler: milestoneReadFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: MilestoneWriteTool,
Handler: milestoneWriteFn,
})
}
func milestoneReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "get":
return getMilestoneFn(ctx, req)
case "list":
return listMilestonesFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func milestoneWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "create":
return createMilestoneFn(ctx, req)
case "edit":
return editMilestoneFn(ctx, req)
case "delete":
return deleteMilestoneFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func getMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getMilestoneFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
id, err := params.GetIndex(req.GetArguments(), "id")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
milestone, _, err := client.GetMilestone(owner, repo, id)
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/milestone/%v err: %v", owner, repo, id, err))
}
return to.TextResult(slimMilestone(milestone))
}
func listMilestonesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listMilestonesFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
state := params.GetOptionalString(req.GetArguments(), "state", "all")
name := params.GetOptionalString(req.GetArguments(), "name", "")
page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.ListMilestoneOption{
State: gitea_sdk.StateType(state),
Name: name,
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
milestones, _, err := client.ListRepoMilestones(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/milestones err: %v", owner, repo, err))
}
return to.TextResult(slimMilestones(milestones))
}
func createMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createMilestoneFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
title, err := params.GetString(req.GetArguments(), "title")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.CreateMilestoneOption{
Title: title,
}
description, ok := req.GetArguments()["description"].(string)
if ok {
opt.Description = description
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
milestone, _, err := client.CreateMilestone(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/%v/milestone err: %v", owner, repo, err))
}
return to.TextResult(slimMilestone(milestone))
}
func editMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editMilestoneFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
id, err := params.GetIndex(req.GetArguments(), "id")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.EditMilestoneOption{}
title, ok := req.GetArguments()["title"].(string)
if ok {
opt.Title = title
}
description, ok := req.GetArguments()["description"].(string)
if ok {
opt.Description = new(description)
}
state, ok := req.GetArguments()["state"].(string)
if ok {
opt.State = new(gitea_sdk.StateType(state))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
milestone, _, err := client.EditMilestone(owner, repo, id, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("edit %v/%v/milestone/%v err: %v", owner, repo, id, err))
}
return to.TextResult(slimMilestone(milestone))
}
func deleteMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteMilestoneFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
id, err := params.GetIndex(req.GetArguments(), "id")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.DeleteMilestone(owner, repo, id)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete %v/%v/milestone/%v err: %v", owner, repo, id, err))
}
return to.TextResult("Milestone deleted successfully")
}

View File

@@ -0,0 +1,28 @@
package milestone
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func slimMilestone(m *gitea_sdk.Milestone) map[string]any {
if m == nil {
return nil
}
return map[string]any{
"id": m.ID,
"title": m.Title,
"description": m.Description,
"state": m.State,
"open_issues": m.OpenIssues,
"closed_issues": m.ClosedIssues,
"due_on": m.Deadline,
}
}
func slimMilestones(milestones []*gitea_sdk.Milestone) []map[string]any {
out := make([]map[string]any, 0, len(milestones))
for _, m := range milestones {
out = append(out, slimMilestone(m))
}
return out
}

View File

@@ -2,6 +2,7 @@ package operation
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
@@ -10,11 +11,14 @@ import (
"syscall" "syscall"
"time" "time"
"gitea.com/gitea/gitea-mcp/operation/actions"
"gitea.com/gitea/gitea-mcp/operation/issue" "gitea.com/gitea/gitea-mcp/operation/issue"
"gitea.com/gitea/gitea-mcp/operation/label" "gitea.com/gitea/gitea-mcp/operation/label"
"gitea.com/gitea/gitea-mcp/operation/milestone"
"gitea.com/gitea/gitea-mcp/operation/pull" "gitea.com/gitea/gitea-mcp/operation/pull"
"gitea.com/gitea/gitea-mcp/operation/repo" "gitea.com/gitea/gitea-mcp/operation/repo"
"gitea.com/gitea/gitea-mcp/operation/search" "gitea.com/gitea/gitea-mcp/operation/search"
"gitea.com/gitea/gitea-mcp/operation/timetracking"
"gitea.com/gitea/gitea-mcp/operation/user" "gitea.com/gitea/gitea-mcp/operation/user"
"gitea.com/gitea/gitea-mcp/operation/version" "gitea.com/gitea/gitea-mcp/operation/version"
"gitea.com/gitea/gitea-mcp/operation/wiki" "gitea.com/gitea/gitea-mcp/operation/wiki"
@@ -31,6 +35,9 @@ func RegisterTool(s *server.MCPServer) {
// User Tool // User Tool
s.AddTools(user.Tool.Tools()...) s.AddTools(user.Tool.Tools()...)
// Actions Tool
s.AddTools(actions.Tool.Tools()...)
// Repo Tool // Repo Tool
s.AddTools(repo.Tool.Tools()...) s.AddTools(repo.Tool.Tools()...)
@@ -40,6 +47,9 @@ func RegisterTool(s *server.MCPServer) {
// Label Tool // Label Tool
s.AddTools(label.Tool.Tools()...) s.AddTools(label.Tool.Tools()...)
// Milestone Tool
s.AddTools(milestone.Tool.Tools()...)
// Pull Tool // Pull Tool
s.AddTools(pull.Tool.Tools()...) s.AddTools(pull.Tool.Tools()...)
@@ -52,23 +62,30 @@ func RegisterTool(s *server.MCPServer) {
// Wiki Tool // Wiki Tool
s.AddTools(wiki.Tool.Tools()...) s.AddTools(wiki.Tool.Tools()...)
// Time Tracking Tool
s.AddTools(timetracking.Tool.Tools()...)
s.DeleteTools("") s.DeleteTools("")
} }
// parseBearerToken extracts the Bearer token from an Authorization header. // parseAuthToken extracts the token from an Authorization header.
// Supports "Bearer <token>" (case-insensitive per RFC 7235) and
// Gitea-style "token <token>" formats.
// Returns the token and true if valid, empty string and false otherwise. // Returns the token and true if valid, empty string and false otherwise.
func parseBearerToken(authHeader string) (string, bool) { func parseAuthToken(authHeader string) (string, bool) {
const bearerPrefix = "Bearer " if len(authHeader) > 7 && strings.EqualFold(authHeader[:7], "Bearer ") {
if len(authHeader) < len(bearerPrefix) || !strings.HasPrefix(authHeader, bearerPrefix) { token := strings.TrimSpace(authHeader[7:])
return "", false if token != "" {
return token, true
}
} }
if len(authHeader) > 6 && strings.EqualFold(authHeader[:6], "token ") {
token := strings.TrimSpace(authHeader[len(bearerPrefix):]) token := strings.TrimSpace(authHeader[6:])
if token == "" { if token != "" {
return "", false return token, true
}
} }
return "", false
return token, true
} }
func getContextWithToken(ctx context.Context, r *http.Request) context.Context { func getContextWithToken(ctx context.Context, r *http.Request) context.Context {
@@ -77,7 +94,7 @@ func getContextWithToken(ctx context.Context, r *http.Request) context.Context {
return ctx return ctx
} }
token, ok := parseBearerToken(authHeader) token, ok := parseAuthToken(authHeader)
if !ok { if !ok {
return ctx return ctx
} }
@@ -120,7 +137,7 @@ func Run() error {
close(shutdownDone) close(shutdownDone)
}() }()
if err := httpServer.Start(fmt.Sprintf(":%d", flag.Port)); err != nil { if err := httpServer.Start(fmt.Sprintf(":%d", flag.Port)); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err return err
} }
<-shutdownDone // Wait for shutdown to finish <-shutdownDone // Wait for shutdown to finish

View File

@@ -4,7 +4,7 @@ import (
"testing" "testing"
) )
func TestParseBearerToken(t *testing.T) { func TestParseAuthToken(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
header string header string
@@ -12,23 +12,29 @@ func TestParseBearerToken(t *testing.T) {
wantOK bool wantOK bool
}{ }{
{ {
name: "valid token", name: "valid Bearer token",
header: "Bearer validtoken", header: "Bearer validtoken",
wantToken: "validtoken", wantToken: "validtoken",
wantOK: true, wantOK: true,
}, },
{
name: "lowercase bearer",
header: "bearer lowercase",
wantToken: "lowercase",
wantOK: true,
},
{
name: "uppercase BEARER",
header: "BEARER uppercase",
wantToken: "uppercase",
wantOK: true,
},
{ {
name: "token with spaces trimmed", name: "token with spaces trimmed",
header: "Bearer spacedToken ", header: "Bearer spacedToken ",
wantToken: "spacedToken", wantToken: "spacedToken",
wantOK: true, wantOK: true,
}, },
{
name: "lowercase bearer should fail",
header: "bearer lowercase",
wantToken: "",
wantOK: false,
},
{ {
name: "bearer with no token", name: "bearer with no token",
header: "Bearer ", header: "Bearer ",
@@ -47,6 +53,24 @@ func TestParseBearerToken(t *testing.T) {
wantToken: "", wantToken: "",
wantOK: false, wantOK: false,
}, },
{
name: "Gitea token format",
header: "token giteaapitoken",
wantToken: "giteaapitoken",
wantOK: true,
},
{
name: "Gitea Token format capitalized",
header: "Token giteaapitoken",
wantToken: "giteaapitoken",
wantOK: true,
},
{
name: "token with no value",
header: "token ",
wantToken: "",
wantOK: false,
},
{ {
name: "different auth type", name: "different auth type",
header: "Basic dXNlcjpwYXNz", header: "Basic dXNlcjpwYXNz",
@@ -60,7 +84,7 @@ func TestParseBearerToken(t *testing.T) {
wantOK: false, wantOK: false,
}, },
{ {
name: "token with internal spaces", name: "bearer token with internal spaces",
header: "Bearer token with spaces", header: "Bearer token with spaces",
wantToken: "token with spaces", wantToken: "token with spaces",
wantOK: true, wantOK: true,
@@ -69,12 +93,12 @@ func TestParseBearerToken(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) {
gotToken, gotOK := parseBearerToken(tt.header) gotToken, gotOK := parseAuthToken(tt.header)
if gotToken != tt.wantToken { if gotToken != tt.wantToken {
t.Errorf("parseBearerToken() token = %q, want %q", gotToken, tt.wantToken) t.Errorf("parseAuthToken() token = %q, want %q", gotToken, tt.wantToken)
} }
if gotOK != tt.wantOK { if gotOK != tt.wantOK {
t.Errorf("parseBearerToken() ok = %v, want %v", gotOK, tt.wantOK) t.Errorf("parseAuthToken() ok = %v, want %v", gotOK, tt.wantOK)
} }
}) })
} }

View File

@@ -6,6 +6,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
@@ -17,21 +18,13 @@ import (
var Tool = tool.New() var Tool = tool.New()
const ( const (
GetPullRequestByIndexToolName = "get_pull_request_by_index" ListRepoPullRequestsToolName = "list_pull_requests"
ListRepoPullRequestsToolName = "list_repo_pull_requests" PullRequestReadToolName = "pull_request_read"
CreatePullRequestToolName = "create_pull_request" PullRequestWriteToolName = "pull_request_write"
CreatePullRequestReviewerToolName = "create_pull_request_reviewer" PullRequestReviewWriteToolName = "pull_request_review_write"
) )
var ( var (
GetPullRequestByIndexTool = mcp.NewTool(
GetPullRequestByIndexToolName,
mcp.WithDescription("get pull request by index"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("repository pull request index")),
)
ListRepoPullRequestsTool = mcp.NewTool( ListRepoPullRequestsTool = mcp.NewTool(
ListRepoPullRequestsToolName, ListRepoPullRequestsToolName,
mcp.WithDescription("List repository pull requests"), mcp.WithDescription("List repository pull requests"),
@@ -41,107 +34,229 @@ var (
mcp.WithString("sort", mcp.Description("sort"), mcp.Enum("oldest", "recentupdate", "leastupdate", "mostcomment", "leastcomment", "priority"), mcp.DefaultString("recentupdate")), mcp.WithString("sort", mcp.Description("sort"), mcp.Enum("oldest", "recentupdate", "leastupdate", "mostcomment", "leastcomment", "priority"), mcp.DefaultString("recentupdate")),
mcp.WithNumber("milestone", mcp.Description("milestone")), mcp.WithNumber("milestone", mcp.Description("milestone")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)), mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(100)), mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
) )
CreatePullRequestTool = mcp.NewTool( PullRequestReadTool = mcp.NewTool(
CreatePullRequestToolName, PullRequestReadToolName,
mcp.WithDescription("create pull request"), mcp.WithDescription("Get pull request information. Use method 'get' for PR details, 'get_diff' for diff, 'get_reviews'/'get_review'/'get_review_comments' for review data."),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("get", "get_diff", "get_reviews", "get_review", "get_review_comments")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("title", mcp.Required(), mcp.Description("pull request title")),
mcp.WithString("body", mcp.Required(), mcp.Description("pull request body")),
mcp.WithString("head", mcp.Required(), mcp.Description("pull request head")),
mcp.WithString("base", mcp.Required(), mcp.Description("pull request base")),
)
CreatePullRequestReviewerTool = mcp.NewTool(
CreatePullRequestReviewerToolName,
mcp.WithDescription("create pull request reviewer"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("pull request index")), mcp.WithNumber("index", mcp.Required(), mcp.Description("pull request index")),
mcp.WithArray("reviewers", mcp.Description("list of reviewer usernames"), mcp.Items(map[string]interface{}{"type": "string"})), mcp.WithNumber("review_id", mcp.Description("review ID (required for 'get_review', 'get_review_comments')")),
mcp.WithArray("team_reviewers", mcp.Description("list of team reviewer names"), mcp.Items(map[string]interface{}{"type": "string"})), mcp.WithBoolean("binary", mcp.Description("whether to include binary file changes (for 'get_diff')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
PullRequestWriteTool = mcp.NewTool(
PullRequestWriteToolName,
mcp.WithDescription("Create, update, or merge pull requests, manage reviewers."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "update", "merge", "add_reviewers", "remove_reviewers")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Description("pull request index (required for all methods except 'create')")),
mcp.WithString("title", mcp.Description("PR title (required for 'create', optional for 'update', 'merge')")),
mcp.WithString("body", mcp.Description("PR body (required for 'create', optional for 'update')")),
mcp.WithString("head", mcp.Description("PR head branch (required for 'create')")),
mcp.WithString("base", mcp.Description("PR base branch (required for 'create', optional for 'update')")),
mcp.WithString("assignee", mcp.Description("username to assign (for 'update')")),
mcp.WithArray("assignees", mcp.Description("usernames to assign (for 'update')"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithNumber("milestone", mcp.Description("milestone number (for 'update')")),
mcp.WithString("state", mcp.Description("PR state (for 'update')"), mcp.Enum("open", "closed")),
mcp.WithBoolean("allow_maintainer_edit", mcp.Description("allow maintainer to edit (for 'update')")),
mcp.WithString("merge_style", mcp.Description("merge style (for 'merge')"), mcp.Enum("merge", "rebase", "rebase-merge", "squash", "fast-forward-only"), mcp.DefaultString("merge")),
mcp.WithString("message", mcp.Description("merge commit message (for 'merge') or dismissal reason")),
mcp.WithBoolean("delete_branch", mcp.Description("delete branch after merge (for 'merge')")),
mcp.WithArray("reviewers", mcp.Description("reviewer usernames (for 'add_reviewers', 'remove_reviewers')"), mcp.Items(map[string]any{"type": "string"})),
mcp.WithArray("team_reviewers", mcp.Description("team reviewer names (for 'add_reviewers', 'remove_reviewers')"), mcp.Items(map[string]any{"type": "string"})),
)
PullRequestReviewWriteTool = mcp.NewTool(
PullRequestReviewWriteToolName,
mcp.WithDescription("Manage pull request reviews: create, submit, delete, or dismiss."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "submit", "delete", "dismiss")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("index", mcp.Required(), mcp.Description("pull request index")),
mcp.WithNumber("review_id", mcp.Description("review ID (required for 'submit', 'delete', 'dismiss')")),
mcp.WithString("state", mcp.Description("review state"), mcp.Enum("APPROVED", "REQUEST_CHANGES", "COMMENT", "PENDING")),
mcp.WithString("body", mcp.Description("review body/comment")),
mcp.WithString("commit_id", mcp.Description("commit SHA to review (for 'create')")),
mcp.WithString("message", mcp.Description("dismissal reason (for 'dismiss')")),
mcp.WithArray("comments", mcp.Description("inline review comments (for 'create')"), mcp.Items(map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string", "description": "file path to comment on"},
"body": map[string]any{"type": "string", "description": "comment body"},
"old_line_num": map[string]any{"type": "number", "description": "line number in the old file (for deletions/changes)"},
"new_line_num": map[string]any{"type": "number", "description": "line number in the new file (for additions/changes)"},
},
})),
) )
) )
func init() { func init() {
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(server.ServerTool{
Tool: GetPullRequestByIndexTool, Tool: ListRepoPullRequestsTool,
Handler: GetPullRequestByIndexFn, Handler: listRepoPullRequestsFn,
}) })
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(server.ServerTool{
Tool: ListRepoPullRequestsTool, Tool: PullRequestReadTool,
Handler: ListRepoPullRequestsFn, Handler: pullRequestReadFn,
}) })
Tool.RegisterWrite(server.ServerTool{ Tool.RegisterWrite(server.ServerTool{
Tool: CreatePullRequestTool, Tool: PullRequestWriteTool,
Handler: CreatePullRequestFn, Handler: pullRequestWriteFn,
}) })
Tool.RegisterWrite(server.ServerTool{ Tool.RegisterWrite(server.ServerTool{
Tool: CreatePullRequestReviewerTool, Tool: PullRequestReviewWriteTool,
Handler: CreatePullRequestReviewerFn, Handler: pullRequestReviewWriteFn,
}) })
} }
func GetPullRequestByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func pullRequestReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetPullRequestByIndexFn") method, err := params.GetString(req.GetArguments(), "method")
owner, ok := req.GetArguments()["owner"].(string) if err != nil {
if !ok { return to.ErrorResult(err)
return to.ErrorResult(fmt.Errorf("owner is required"))
} }
repo, ok := req.GetArguments()["repo"].(string) switch method {
if !ok { case "get":
return to.ErrorResult(fmt.Errorf("repo is required")) return getPullRequestByIndexFn(ctx, req)
case "get_diff":
return getPullRequestDiffFn(ctx, req)
case "get_reviews":
return listPullRequestReviewsFn(ctx, req)
case "get_review":
return getPullRequestReviewFn(ctx, req)
case "get_review_comments":
return listPullRequestReviewCommentsFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
index, ok := req.GetArguments()["index"].(float64) }
if !ok {
return to.ErrorResult(fmt.Errorf("index is required")) func pullRequestWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "create":
return createPullRequestFn(ctx, req)
case "update":
return editPullRequestFn(ctx, req)
case "merge":
return mergePullRequestFn(ctx, req)
case "add_reviewers":
return createPullRequestReviewerFn(ctx, req)
case "remove_reviewers":
return deletePullRequestReviewerFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func pullRequestReviewWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "create":
return createPullRequestReviewFn(ctx, req)
case "submit":
return submitPullRequestReviewFn(ctx, req)
case "delete":
return deletePullRequestReviewFn(ctx, req)
case "dismiss":
return dismissPullRequestReviewFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func getPullRequestByIndexFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getPullRequestByIndexFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(args, "index")
if err != nil {
return to.ErrorResult(err)
} }
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))
} }
pr, _, err := client.GetPullRequest(owner, repo, int64(index)) pr, _, err := client.GetPullRequest(owner, repo, index)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v err: %v", owner, repo, int64(index), err)) return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v err: %v", owner, repo, index, err))
} }
return to.TextResult(pr) return to.TextResult(slimPullRequest(pr))
} }
func ListRepoPullRequestsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func getPullRequestDiffFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getPullRequestDiffFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(args, "index")
if err != nil {
return to.ErrorResult(err)
}
binary, _ := args["binary"].(bool)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
diffBytes, _, err := client.GetPullRequestDiff(owner, repo, index, gitea_sdk.PullRequestDiffOptions{
Binary: binary,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/pr/%v diff err: %v", owner, repo, index, err))
}
return to.TextResult(string(diffBytes))
}
func listRepoPullRequestsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListRepoPullRequests") log.Debugf("Called ListRepoPullRequests")
owner, ok := req.GetArguments()["owner"].(string) args := req.GetArguments()
if !ok { owner, err := params.GetString(args, "owner")
return to.ErrorResult(fmt.Errorf("owner is required")) if err != nil {
return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(args, "repo")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(err)
}
state, _ := req.GetArguments()["state"].(string)
sort, ok := req.GetArguments()["sort"].(string)
if !ok {
sort = "recentupdate"
}
milestone, _ := req.GetArguments()["milestone"].(float64)
page, ok := req.GetArguments()["page"].(float64)
if !ok {
page = 1
}
pageSize, ok := req.GetArguments()["pageSize"].(float64)
if !ok {
pageSize = 100
} }
state, _ := args["state"].(string)
sort := params.GetOptionalString(args, "sort", "recentupdate")
milestone := params.GetOptionalInt(args, "milestone", 0)
page, pageSize := params.GetPagination(args, 30)
opt := gitea_sdk.ListPullRequestsOptions{ opt := gitea_sdk.ListPullRequestsOptions{
State: gitea_sdk.StateType(state), State: gitea_sdk.StateType(state),
Sort: sort, Sort: sort,
Milestone: int64(milestone), Milestone: milestone,
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: int(page), Page: page,
PageSize: int(pageSize), PageSize: pageSize,
}, },
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -153,34 +268,35 @@ func ListRepoPullRequestsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.
return to.ErrorResult(fmt.Errorf("list %v/%v/pull_requests err: %v", owner, repo, err)) return to.ErrorResult(fmt.Errorf("list %v/%v/pull_requests err: %v", owner, repo, err))
} }
return to.TextResult(pullRequests) return to.TextResult(slimPullRequests(pullRequests))
} }
func CreatePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func createPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreatePullRequestFn") log.Debugf("Called createPullRequestFn")
owner, ok := req.GetArguments()["owner"].(string) args := req.GetArguments()
if !ok { owner, err := params.GetString(args, "owner")
return to.ErrorResult(fmt.Errorf("owner is required")) if err != nil {
return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(args, "repo")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(err)
} }
title, ok := req.GetArguments()["title"].(string) title, err := params.GetString(args, "title")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("title is required")) return to.ErrorResult(err)
} }
body, ok := req.GetArguments()["body"].(string) body, err := params.GetString(args, "body")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("body is required")) return to.ErrorResult(err)
} }
head, ok := req.GetArguments()["head"].(string) head, err := params.GetString(args, "head")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("head is required")) return to.ErrorResult(err)
} }
base, ok := req.GetArguments()["base"].(string) base, err := params.GetString(args, "base")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("base is required")) return to.ErrorResult(err)
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
@@ -196,41 +312,243 @@ func CreatePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Cal
return to.ErrorResult(fmt.Errorf("create %v/%v/pull_request err: %v", owner, repo, err)) return to.ErrorResult(fmt.Errorf("create %v/%v/pull_request err: %v", owner, repo, err))
} }
return to.TextResult(pr) return to.TextResult(slimPullRequest(pr))
} }
func CreatePullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func createPullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreatePullRequestReviewerFn") log.Debugf("Called createPullRequestReviewerFn")
owner, ok := req.GetArguments()["owner"].(string) args := req.GetArguments()
if !ok { owner, err := params.GetString(args, "owner")
return to.ErrorResult(fmt.Errorf("owner is required")) if err != nil {
return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(args, "repo")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(err)
} }
index, ok := req.GetArguments()["index"].(float64) index, err := params.GetIndex(args, "index")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("index is required")) return to.ErrorResult(err)
} }
var reviewers []string reviewers := params.GetStringSlice(args, "reviewers")
if reviewersArg, exists := req.GetArguments()["reviewers"]; exists { teamReviewers := params.GetStringSlice(args, "team_reviewers")
if reviewersSlice, ok := reviewersArg.([]interface{}); ok {
for _, reviewer := range reviewersSlice { client, err := gitea.ClientFromContext(ctx)
if reviewerStr, ok := reviewer.(string); ok { if err != nil {
reviewers = append(reviewers, reviewerStr) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
}
}
} }
var teamReviewers []string _, err = client.CreateReviewRequests(owner, repo, index, gitea_sdk.PullReviewRequestOptions{
if teamReviewersArg, exists := req.GetArguments()["team_reviewers"]; exists { Reviewers: reviewers,
if teamReviewersSlice, ok := teamReviewersArg.([]interface{}); ok { TeamReviewers: teamReviewers,
for _, teamReviewer := range teamReviewersSlice { })
if teamReviewerStr, ok := teamReviewer.(string); ok { if err != nil {
teamReviewers = append(teamReviewers, teamReviewerStr) return to.ErrorResult(fmt.Errorf("create review requests for %v/%v/pr/%v err: %v", owner, repo, index, err))
}
successMsg := map[string]any{
"message": "Successfully created review requests",
"reviewers": reviewers,
"team_reviewers": teamReviewers,
"pr_index": index,
"repository": fmt.Sprintf("%s/%s", owner, repo),
}
return to.TextResult(successMsg)
}
func deletePullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deletePullRequestReviewerFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(args, "index")
if err != nil {
return to.ErrorResult(err)
}
reviewers := params.GetStringSlice(args, "reviewers")
teamReviewers := params.GetStringSlice(args, "team_reviewers")
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.DeleteReviewRequests(owner, repo, index, gitea_sdk.PullReviewRequestOptions{
Reviewers: reviewers,
TeamReviewers: teamReviewers,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("delete review requests for %v/%v/pr/%v err: %v", owner, repo, index, err))
}
successMsg := map[string]any{
"message": "Successfully deleted review requests",
"reviewers": reviewers,
"team_reviewers": teamReviewers,
"pr_index": index,
"repository": fmt.Sprintf("%s/%s", owner, repo),
}
return to.TextResult(successMsg)
}
func listPullRequestReviewsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listPullRequestReviewsFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(args, "index")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(args, 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
reviews, _, err := client.ListPullReviews(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))
}
return to.TextResult(slimReviews(reviews))
}
func getPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getPullRequestReviewFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(args, "index")
if err != nil {
return to.ErrorResult(err)
}
reviewID, err := params.GetIndex(args, "review_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))
}
review, _, err := client.GetPullReview(owner, repo, index, reviewID)
if err != nil {
return to.ErrorResult(fmt.Errorf("get review %v for %v/%v/pr/%v err: %v", reviewID, owner, repo, index, err))
}
return to.TextResult(slimReview(review))
}
func listPullRequestReviewCommentsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listPullRequestReviewCommentsFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(args, "index")
if err != nil {
return to.ErrorResult(err)
}
reviewID, err := params.GetIndex(args, "review_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))
}
comments, _, err := client.ListPullReviewComments(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))
}
return to.TextResult(slimReviewComments(comments))
}
func createPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called createPullRequestReviewFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(args, "index")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.CreatePullReviewOptions{}
if state, ok := args["state"].(string); ok {
opt.State = gitea_sdk.ReviewStateType(state)
}
if body, ok := args["body"].(string); ok {
opt.Body = body
}
if commitID, ok := args["commit_id"].(string); ok {
opt.CommitID = commitID
}
// Parse inline comments
if commentsArg, exists := args["comments"]; exists {
if commentsSlice, ok := commentsArg.([]any); ok {
for _, comment := range commentsSlice {
if commentMap, ok := comment.(map[string]any); ok {
reviewComment := gitea_sdk.CreatePullReviewComment{}
if path, ok := commentMap["path"].(string); ok {
reviewComment.Path = path
}
if body, ok := commentMap["body"].(string); ok {
reviewComment.Body = body
}
if oldLineNum, ok := params.ToInt64(commentMap["old_line_num"]); ok {
reviewComment.OldLineNum = oldLineNum
}
if newLineNum, ok := params.ToInt64(commentMap["new_line_num"]); ok {
reviewComment.NewLineNum = newLineNum
}
opt.Comments = append(opt.Comments, reviewComment)
} }
} }
} }
@@ -241,22 +559,254 @@ func CreatePullRequestReviewerFn(ctx context.Context, req mcp.CallToolRequest) (
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
_, err = client.CreateReviewRequests(owner, repo, int64(index), gitea_sdk.PullReviewRequestOptions{ review, _, err := client.CreatePullReview(owner, repo, index, opt)
Reviewers: reviewers,
TeamReviewers: teamReviewers,
})
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("create review requests for %v/%v/pr/%v err: %v", owner, repo, int64(index), err)) return to.ErrorResult(fmt.Errorf("create review for %v/%v/pr/%v err: %v", owner, repo, index, err))
} }
// Return a success message instead of the Response object which contains non-serializable functions return to.TextResult(slimReview(review))
successMsg := map[string]interface{}{ }
"message": "Successfully created review requests",
"reviewers": reviewers, func submitPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
"team_reviewers": teamReviewers, log.Debugf("Called submitPullRequestReviewFn")
"pr_index": int64(index), args := req.GetArguments()
"repository": fmt.Sprintf("%s/%s", owner, repo), owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(args, "index")
if err != nil {
return to.ErrorResult(err)
}
reviewID, err := params.GetIndex(args, "review_id")
if err != nil {
return to.ErrorResult(err)
}
state, err := params.GetString(args, "state")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.SubmitPullReviewOptions{
State: gitea_sdk.ReviewStateType(state),
}
if body, ok := args["body"].(string); ok {
opt.Body = body
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
review, _, err := client.SubmitPullReview(owner, repo, index, reviewID, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("submit review %v for %v/%v/pr/%v err: %v", reviewID, owner, repo, index, err))
}
return to.TextResult(slimReview(review))
}
func deletePullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deletePullRequestReviewFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(args, "index")
if err != nil {
return to.ErrorResult(err)
}
reviewID, err := params.GetIndex(args, "review_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))
}
_, err = client.DeletePullReview(owner, repo, index, reviewID)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete review %v for %v/%v/pr/%v err: %v", reviewID, owner, repo, index, err))
}
successMsg := map[string]any{
"message": "Successfully deleted review",
"review_id": reviewID,
"pr_index": index,
"repository": fmt.Sprintf("%s/%s", owner, repo),
} }
return to.TextResult(successMsg) return to.TextResult(successMsg)
} }
func dismissPullRequestReviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called dismissPullRequestReviewFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(args, "index")
if err != nil {
return to.ErrorResult(err)
}
reviewID, err := params.GetIndex(args, "review_id")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.DismissPullReviewOptions{}
if message, ok := args["message"].(string); ok {
opt.Message = message
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.DismissPullReview(owner, repo, index, reviewID, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("dismiss review %v for %v/%v/pr/%v err: %v", reviewID, owner, repo, index, err))
}
successMsg := map[string]any{
"message": "Successfully dismissed review",
"review_id": reviewID,
"pr_index": index,
"repository": fmt.Sprintf("%s/%s", owner, repo),
}
return to.TextResult(successMsg)
}
func mergePullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called mergePullRequestFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(args, "index")
if err != nil {
return to.ErrorResult(err)
}
mergeStyle := params.GetOptionalString(args, "merge_style", "merge")
title, _ := args["title"].(string)
message, _ := args["message"].(string)
deleteBranch, _ := args["delete_branch"].(bool)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
opt := gitea_sdk.MergePullRequestOption{
Style: gitea_sdk.MergeStyle(mergeStyle),
Title: title,
Message: message,
DeleteBranchAfterMerge: deleteBranch,
}
merged, resp, err := client.MergePullRequest(owner, repo, index, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("merge %v/%v/pr/%v err: %v", owner, repo, index, err))
}
if !merged && resp != nil && resp.StatusCode >= 400 {
return to.ErrorResult(fmt.Errorf("merge %v/%v/pr/%v failed: HTTP %d %s", owner, repo, index, resp.StatusCode, resp.Status))
}
if !merged {
return to.ErrorResult(fmt.Errorf("merge %v/%v/pr/%v returned merged=false", owner, repo, index))
}
successMsg := map[string]any{
"merged": merged,
"pr_index": index,
"repository": fmt.Sprintf("%s/%s", owner, repo),
"merge_style": mergeStyle,
"branch_deleted": deleteBranch,
}
return to.TextResult(successMsg)
}
func editPullRequestFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called editPullRequestFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(args, "index")
if err != nil {
return to.ErrorResult(err)
}
opt := gitea_sdk.EditPullRequestOption{}
if title, ok := args["title"].(string); ok {
opt.Title = title
}
if body, ok := args["body"].(string); ok {
opt.Body = new(body)
}
if base, ok := args["base"].(string); ok {
opt.Base = base
}
if assignee, ok := args["assignee"].(string); ok {
opt.Assignee = assignee
}
if assignees := params.GetStringSlice(args, "assignees"); assignees != nil {
opt.Assignees = assignees
}
if val, exists := args["milestone"]; exists {
if milestone, ok := params.ToInt64(val); ok {
opt.Milestone = milestone
}
}
if state, ok := args["state"].(string); ok {
opt.State = new(gitea_sdk.StateType(state))
}
if allowMaintainerEdit, ok := args["allow_maintainer_edit"].(bool); ok {
opt.AllowMaintainerEdit = new(allowMaintainerEdit)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
pr, _, err := client.EditPullRequest(owner, repo, index, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("edit %v/%v/pr/%v err: %v", owner, repo, index, err))
}
return to.TextResult(slimPullRequest(pr))
}

379
operation/pull/pull_test.go Normal file
View File

@@ -0,0 +1,379 @@
package pull
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"github.com/mark3labs/mcp-go/mcp"
)
func Test_editPullRequestFn(t *testing.T) {
const (
owner = "octo"
repo = "demo"
index = 7
)
indexInputs := []struct {
name string
val any
}{
{"float64", float64(index)},
{"string", "7"},
}
for _, ii := range indexInputs {
t.Run(ii.name, func(t *testing.T) {
var (
mu sync.Mutex
gotMethod string
gotPath string
gotBody map[string]any
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"private":false}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index):
mu.Lock()
gotMethod = r.Method
gotPath = r.URL.Path
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
gotBody = body
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(fmt.Appendf(nil, `{"number":%d,"title":"%s","state":"open"}`, index, body["title"]))
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost := flag.Host
origToken := flag.Token
origVersion := flag.Version
flag.Host = server.URL
flag.Token = ""
flag.Version = "test"
defer func() {
flag.Host = origHost
flag.Token = origToken
flag.Version = origVersion
}()
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: map[string]any{
"owner": owner,
"repo": repo,
"index": ii.val,
"title": "WIP: my feature",
"state": "open",
},
},
}
result, err := editPullRequestFn(context.Background(), req)
if err != nil {
t.Fatalf("editPullRequestFn() error = %v", err)
}
mu.Lock()
defer mu.Unlock()
if gotMethod != http.MethodPatch {
t.Fatalf("expected PATCH request, got %s", gotMethod)
}
if gotPath != fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index) {
t.Fatalf("unexpected path: %s", gotPath)
}
if gotBody["title"] != "WIP: my feature" {
t.Fatalf("expected title 'WIP: my feature', got %v", gotBody["title"])
}
if gotBody["state"] != "open" {
t.Fatalf("expected state 'open', got %v", gotBody["state"])
}
if len(result.Content) == 0 {
t.Fatalf("expected content in result")
}
textContent, ok := mcp.AsTextContent(result.Content[0])
if !ok {
t.Fatalf("expected text content, got %T", result.Content[0])
}
var parsed map[string]any
if err := json.Unmarshal([]byte(textContent.Text), &parsed); err != nil {
t.Fatalf("unmarshal result text: %v", err)
}
if got := parsed["title"].(string); got != "WIP: my feature" {
t.Fatalf("result title = %q, want %q", got, "WIP: my feature")
}
})
}
}
func Test_mergePullRequestFn(t *testing.T) {
const (
owner = "octo"
repo = "demo"
index = 5
)
indexInputs := []struct {
name string
val any
}{
{"float64", float64(index)},
{"string", "5"},
}
for _, ii := range indexInputs {
t.Run(ii.name, func(t *testing.T) {
var (
mu sync.Mutex
gotMethod string
gotPath string
gotBody map[string]any
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"private":false}`))
case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner, repo, index):
mu.Lock()
gotMethod = r.Method
gotPath = r.URL.Path
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
gotBody = body
mu.Unlock()
w.WriteHeader(http.StatusOK)
default:
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost := flag.Host
origToken := flag.Token
origVersion := flag.Version
flag.Host = server.URL
flag.Token = ""
flag.Version = "test"
defer func() {
flag.Host = origHost
flag.Token = origToken
flag.Version = origVersion
}()
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: map[string]any{
"owner": owner,
"repo": repo,
"index": ii.val,
"merge_style": "squash",
"title": "feat: my squashed commit",
"message": "Squash merge of PR #5",
"delete_branch": true,
},
},
}
result, err := mergePullRequestFn(context.Background(), req)
if err != nil {
t.Fatalf("mergePullRequestFn() error = %v", err)
}
mu.Lock()
defer mu.Unlock()
if gotMethod != http.MethodPost {
t.Fatalf("expected POST request, got %s", gotMethod)
}
if gotPath != fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner, repo, index) {
t.Fatalf("unexpected path: %s", gotPath)
}
if gotBody["Do"] != "squash" {
t.Fatalf("expected Do 'squash', got %v", gotBody["Do"])
}
if gotBody["MergeTitleField"] != "feat: my squashed commit" {
t.Fatalf("expected MergeTitleField 'feat: my squashed commit', got %v", gotBody["MergeTitleField"])
}
if gotBody["MergeMessageField"] != "Squash merge of PR #5" {
t.Fatalf("expected MergeMessageField 'Squash merge of PR #5', got %v", gotBody["MergeMessageField"])
}
if gotBody["delete_branch_after_merge"] != true {
t.Fatalf("expected delete_branch_after_merge true, got %v", gotBody["delete_branch_after_merge"])
}
if len(result.Content) == 0 {
t.Fatalf("expected content in result")
}
textContent, ok := mcp.AsTextContent(result.Content[0])
if !ok {
t.Fatalf("expected text content, got %T", result.Content[0])
}
var parsed map[string]any
if err := json.Unmarshal([]byte(textContent.Text), &parsed); err != nil {
t.Fatalf("unmarshal result text: %v", err)
}
if parsed["merged"] != true {
t.Fatalf("expected merged=true, got %v", parsed["merged"])
}
if parsed["merge_style"] != "squash" {
t.Fatalf("expected merge_style 'squash', got %v", parsed["merge_style"])
}
if parsed["branch_deleted"] != true {
t.Fatalf("expected branch_deleted=true, got %v", parsed["branch_deleted"])
}
})
}
}
func Test_getPullRequestDiffFn(t *testing.T) {
const (
owner = "octo"
repo = "demo"
index = 12
diffRaw = "diff --git a/file.txt b/file.txt\n+line\n"
)
indexInputs := []struct {
name string
val any
}{
{"float64", float64(index)},
{"string", "12"},
}
for _, ii := range indexInputs {
t.Run(ii.name, func(t *testing.T) {
var (
mu sync.Mutex
diffRequested bool
binaryValue string
)
errCh := make(chan error, 1)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"private":false}`))
case fmt.Sprintf("/%s/%s/pulls/%d.diff", owner, repo, index):
if r.Method != http.MethodGet {
select {
case errCh <- fmt.Errorf("unexpected method: %s", r.Method):
default:
}
}
mu.Lock()
diffRequested = true
binaryValue = r.URL.Query().Get("binary")
mu.Unlock()
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte(diffRaw))
default:
select {
case errCh <- fmt.Errorf("unexpected request path: %s", r.URL.Path):
default:
}
}
})
server := httptest.NewServer(handler)
defer server.Close()
origHost := flag.Host
origToken := flag.Token
origVersion := flag.Version
flag.Host = server.URL
flag.Token = ""
flag.Version = "test"
defer func() {
flag.Host = origHost
flag.Token = origToken
flag.Version = origVersion
}()
req := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: map[string]any{
"owner": owner,
"repo": repo,
"index": ii.val,
"binary": true,
},
},
}
result, err := getPullRequestDiffFn(context.Background(), req)
if err != nil {
t.Fatalf("getPullRequestDiffFn() error = %v", err)
}
select {
case reqErr := <-errCh:
t.Fatalf("handler error: %v", reqErr)
default:
}
mu.Lock()
requested := diffRequested
gotBinary := binaryValue
mu.Unlock()
if !requested {
t.Fatalf("expected diff request to be made")
}
if gotBinary != "true" {
t.Fatalf("expected binary=true query param, got %q", gotBinary)
}
if len(result.Content) == 0 {
t.Fatalf("expected content in result")
}
textContent, ok := mcp.AsTextContent(result.Content[0])
if !ok {
t.Fatalf("expected text content, got %T", result.Content[0])
}
// The diff response is now a plain string
var parsed string
if err := json.Unmarshal([]byte(textContent.Text), &parsed); err != nil {
t.Fatalf("unmarshal result text: %v", err)
}
if parsed != diffRaw {
t.Fatalf("diff = %q, want %q", parsed, diffRaw)
}
})
}
}

191
operation/pull/slim.go Normal file
View File

@@ -0,0 +1,191 @@
package pull
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func userLogin(u *gitea_sdk.User) string {
if u == nil {
return ""
}
return u.UserName
}
func userLogins(users []*gitea_sdk.User) []string {
if len(users) == 0 {
return nil
}
out := make([]string, 0, len(users))
for _, u := range users {
if u != nil {
out = append(out, u.UserName)
}
}
return out
}
func labelNames(labels []*gitea_sdk.Label) []string {
if len(labels) == 0 {
return nil
}
out := make([]string, 0, len(labels))
for _, l := range labels {
if l != nil {
out = append(out, l.Name)
}
}
return out
}
func repoRef(r *gitea_sdk.Repository) map[string]any {
if r == nil {
return nil
}
return map[string]any{
"full_name": r.FullName,
"description": r.Description,
}
}
func slimPullRequest(pr *gitea_sdk.PullRequest) map[string]any {
if pr == nil {
return nil
}
m := map[string]any{
"number": pr.Index,
"title": pr.Title,
"body": pr.Body,
"state": pr.State,
"draft": pr.Draft,
"merged": pr.HasMerged,
"mergeable": pr.Mergeable,
"html_url": pr.HTMLURL,
"user": userLogin(pr.Poster),
"labels": labelNames(pr.Labels),
"comments": pr.Comments,
"created_at": pr.Created,
"updated_at": pr.Updated,
"closed_at": pr.Closed,
}
if pr.HasMerged {
m["merged_at"] = pr.Merged
m["merge_commit_sha"] = pr.MergedCommitID
m["merged_by"] = userLogin(pr.MergedBy)
}
if pr.Head != nil {
head := map[string]any{"ref": pr.Head.Ref, "sha": pr.Head.Sha}
if pr.Head.Repository != nil {
head["repo"] = repoRef(pr.Head.Repository)
}
m["head"] = head
}
if pr.Base != nil {
base := map[string]any{"ref": pr.Base.Ref, "sha": pr.Base.Sha}
if pr.Base.Repository != nil {
base["repo"] = repoRef(pr.Base.Repository)
}
m["base"] = base
}
if pr.Additions != nil {
m["additions"] = *pr.Additions
}
if pr.Deletions != nil {
m["deletions"] = *pr.Deletions
}
if pr.ChangedFiles != nil {
m["changed_files"] = *pr.ChangedFiles
}
if len(pr.Assignees) > 0 {
m["assignees"] = userLogins(pr.Assignees)
}
if pr.Milestone != nil {
m["milestone"] = pr.Milestone.Title
}
if pr.ReviewComments > 0 {
m["review_comments"] = pr.ReviewComments
}
return m
}
func slimPullRequests(prs []*gitea_sdk.PullRequest) []map[string]any {
out := make([]map[string]any, 0, len(prs))
for _, pr := range prs {
if pr == nil {
continue
}
m := map[string]any{
"number": pr.Index,
"title": pr.Title,
"state": pr.State,
"draft": pr.Draft,
"merged": pr.HasMerged,
"html_url": pr.HTMLURL,
"user": userLogin(pr.Poster),
"created_at": pr.Created,
"updated_at": pr.Updated,
}
if pr.Head != nil {
m["head"] = pr.Head.Ref
}
if pr.Base != nil {
m["base"] = pr.Base.Ref
}
if len(pr.Labels) > 0 {
m["labels"] = labelNames(pr.Labels)
}
out = append(out, m)
}
return out
}
func slimReview(r *gitea_sdk.PullReview) map[string]any {
if r == nil {
return nil
}
return map[string]any{
"id": r.ID,
"state": r.State,
"body": r.Body,
"user": userLogin(r.Reviewer),
"comments_count": r.CodeCommentsCount,
"submitted_at": r.Submitted,
"html_url": r.HTMLURL,
"stale": r.Stale,
"official": r.Official,
"dismissed": r.Dismissed,
}
}
func slimReviews(reviews []*gitea_sdk.PullReview) []map[string]any {
out := make([]map[string]any, 0, len(reviews))
for _, r := range reviews {
out = append(out, slimReview(r))
}
return out
}
func slimReviewComment(c *gitea_sdk.PullReviewComment) map[string]any {
if c == nil {
return nil
}
return map[string]any{
"id": c.ID,
"body": c.Body,
"path": c.Path,
"position": c.LineNum,
"old_position": c.OldLineNum,
"diff_hunk": c.DiffHunk,
"user": userLogin(c.Reviewer),
"html_url": c.HTMLURL,
"created_at": c.Created,
"updated_at": c.Updated,
}
}
func slimReviewComments(comments []*gitea_sdk.PullReviewComment) []map[string]any {
out := make([]map[string]any, 0, len(comments))
for _, c := range comments {
out = append(out, slimReviewComment(c))
}
return out
}

124
operation/pull/slim_test.go Normal file
View File

@@ -0,0 +1,124 @@
package pull
import (
"testing"
"time"
gitea_sdk "code.gitea.io/sdk/gitea"
)
func TestSlimPullRequest(t *testing.T) {
now := time.Now()
additions := 10
deletions := 5
changedFiles := 3
pr := &gitea_sdk.PullRequest{
Index: 1,
Title: "Fix bug",
Body: "Fixes #123",
State: "open",
Draft: false,
HasMerged: false,
Mergeable: true,
HTMLURL: "https://gitea.com/org/repo/pulls/1",
Poster: &gitea_sdk.User{UserName: "bob"},
Labels: []*gitea_sdk.Label{
{Name: "bug"},
{Name: "priority"},
},
Comments: 2,
Created: &now,
Updated: &now,
Additions: &additions,
Deletions: &deletions,
ChangedFiles: &changedFiles,
Head: &gitea_sdk.PRBranchInfo{
Ref: "fix-branch",
Sha: "abc123",
},
Base: &gitea_sdk.PRBranchInfo{
Ref: "main",
Sha: "def456",
},
Assignees: []*gitea_sdk.User{
{UserName: "alice"},
},
Milestone: &gitea_sdk.Milestone{Title: "v1.0"},
}
m := slimPullRequest(pr)
if m["number"] != int64(1) {
t.Errorf("expected number 1, got %v", m["number"])
}
if m["title"] != "Fix bug" {
t.Errorf("expected title Fix bug, got %v", m["title"])
}
if m["user"] != "bob" {
t.Errorf("expected user bob, got %v", m["user"])
}
if m["additions"] != 10 {
t.Errorf("expected additions 10, got %v", m["additions"])
}
if m["milestone"] != "v1.0" {
t.Errorf("expected milestone v1.0, got %v", m["milestone"])
}
labels := m["labels"].([]string)
if len(labels) != 2 || labels[0] != "bug" {
t.Errorf("expected labels [bug priority], got %v", labels)
}
head := m["head"].(map[string]any)
if head["ref"] != "fix-branch" {
t.Errorf("expected head ref fix-branch, got %v", head["ref"])
}
assignees := m["assignees"].([]string)
if len(assignees) != 1 || assignees[0] != "alice" {
t.Errorf("expected assignees [alice], got %v", assignees)
}
// merged fields should not be present for unmerged PR
if _, ok := m["merged_at"]; ok {
t.Error("merged_at should not be present for unmerged PR")
}
}
func TestSlimPullRequests_ListIsSlimmer(t *testing.T) {
pr := &gitea_sdk.PullRequest{
Index: 1,
Title: "PR title",
State: "open",
HTMLURL: "https://gitea.com/org/repo/pulls/1",
Poster: &gitea_sdk.User{UserName: "bob"},
Body: "Full body text here",
Head: &gitea_sdk.PRBranchInfo{Ref: "feature"},
Base: &gitea_sdk.PRBranchInfo{Ref: "main"},
}
single := slimPullRequest(pr)
list := slimPullRequests([]*gitea_sdk.PullRequest{pr})
// Single has body, list does not
if _, ok := single["body"]; !ok {
t.Error("single PR should have body")
}
if _, ok := list[0]["body"]; ok {
t.Error("list PR should not have body")
}
// List has head as string ref, single has head as map
if _, ok := single["head"].(map[string]any); !ok {
t.Error("single PR head should be a map")
}
if list[0]["head"] != "feature" {
t.Errorf("list PR head should be string ref, got %v", list[0]["head"])
}
}
func TestSlimPullRequests_Nil(t *testing.T) {
if r := slimPullRequests(nil); len(r) != 0 {
t.Errorf("expected empty slice, got %v", r)
}
}

View File

@@ -6,6 +6,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "code.gitea.io/sdk/gitea" gitea_sdk "code.gitea.io/sdk/gitea"
@@ -62,19 +63,20 @@ func init() {
func CreateBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func CreateBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateBranchFn") log.Debugf("Called CreateBranchFn")
owner, ok := req.GetArguments()["owner"].(string) args := req.GetArguments()
if !ok { owner, err := params.GetString(args, "owner")
return to.ErrorResult(fmt.Errorf("owner is required")) if err != nil {
return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(args, "repo")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(err)
} }
branch, ok := req.GetArguments()["branch"].(string) branch, err := params.GetString(args, "branch")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("branch is required")) return to.ErrorResult(err)
} }
oldBranch, _ := req.GetArguments()["old_branch"].(string) oldBranch, _ := args["old_branch"].(string)
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
@@ -93,17 +95,18 @@ func CreateBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
func DeleteBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func DeleteBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteBranchFn") log.Debugf("Called DeleteBranchFn")
owner, ok := req.GetArguments()["owner"].(string) args := req.GetArguments()
if !ok { owner, err := params.GetString(args, "owner")
return to.ErrorResult(fmt.Errorf("owner is required")) if err != nil {
return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(args, "repo")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(err)
} }
branch, ok := req.GetArguments()["branch"].(string) branch, err := params.GetString(args, "branch")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("branch is required")) return to.ErrorResult(err)
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
@@ -119,18 +122,19 @@ func DeleteBranchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
func ListBranchesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListBranchesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListBranchesFn") log.Debugf("Called ListBranchesFn")
owner, ok := req.GetArguments()["owner"].(string) args := req.GetArguments()
if !ok { owner, err := params.GetString(args, "owner")
return to.ErrorResult(fmt.Errorf("owner is required")) if err != nil {
return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(args, "repo")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(err)
} }
opt := gitea_sdk.ListRepoBranchesOptions{ opt := gitea_sdk.ListRepoBranchesOptions{
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: 1, Page: 1,
PageSize: 100, PageSize: 30,
}, },
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -142,5 +146,5 @@ func ListBranchesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
return to.ErrorResult(fmt.Errorf("list branches error: %v", err)) return to.ErrorResult(fmt.Errorf("list branches error: %v", err))
} }
return to.TextResult(branches) return to.TextResult(slimBranches(branches))
} }

View File

@@ -6,6 +6,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "code.gitea.io/sdk/gitea" gitea_sdk "code.gitea.io/sdk/gitea"
@@ -14,7 +15,7 @@ import (
) )
const ( const (
ListRepoCommitsToolName = "list_repo_commits" ListRepoCommitsToolName = "list_commits"
) )
var ListRepoCommitsTool = mcp.NewTool( var ListRepoCommitsTool = mcp.NewTool(
@@ -25,7 +26,7 @@ var ListRepoCommitsTool = mcp.NewTool(
mcp.WithString("sha", mcp.Description("SHA or branch to start listing commits from")), mcp.WithString("sha", mcp.Description("SHA or branch to start listing commits from")),
mcp.WithString("path", mcp.Description("path indicates that only commits that include the path's file/dir should be returned.")), mcp.WithString("path", mcp.Description("path indicates that only commits that include the path's file/dir should be returned.")),
mcp.WithNumber("page", mcp.Required(), mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)), mcp.WithNumber("page", mcp.Required(), mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("page_size", mcp.Required(), mcp.Description("page size"), mcp.DefaultNumber(50), mcp.Min(1)), mcp.WithNumber("perPage", mcp.Required(), mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)),
) )
func init() { func init() {
@@ -37,24 +38,25 @@ func init() {
func ListRepoCommitsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListRepoCommitsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListRepoCommitsFn") log.Debugf("Called ListRepoCommitsFn")
owner, ok := req.GetArguments()["owner"].(string) args := req.GetArguments()
if !ok { owner, err := params.GetString(args, "owner")
return to.ErrorResult(fmt.Errorf("owner is required")) if err != nil {
return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(args, "repo")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(err)
} }
page, ok := req.GetArguments()["page"].(float64) page, err := params.GetIndex(args, "page")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("page is required")) return to.ErrorResult(err)
} }
pageSize, ok := req.GetArguments()["page_size"].(float64) pageSize, err := params.GetIndex(args, "perPage")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("page_size is required")) return to.ErrorResult(err)
} }
sha, _ := req.GetArguments()["sha"].(string) sha, _ := args["sha"].(string)
path, _ := req.GetArguments()["path"].(string) path, _ := args["path"].(string)
opt := gitea_sdk.ListCommitOptions{ opt := gitea_sdk.ListCommitOptions{
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: int(page), Page: int(page),
@@ -71,5 +73,5 @@ func ListRepoCommitsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("list repo commits err: %v", err)) return to.ErrorResult(fmt.Errorf("list repo commits err: %v", err))
} }
return to.TextResult(commits) return to.TextResult(slimCommits(commits))
} }

View File

@@ -10,6 +10,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "code.gitea.io/sdk/gitea" gitea_sdk "code.gitea.io/sdk/gitea"
@@ -18,11 +19,10 @@ import (
) )
const ( const (
GetFileToolName = "get_file_content" GetFileToolName = "get_file_contents"
GetDirToolName = "get_dir_content" GetDirToolName = "get_dir_contents"
CreateFileToolName = "create_file" CreateOrUpdateFileToolName = "create_or_update_file"
UpdateFileToolName = "update_file" DeleteFileToolName = "delete_file"
DeleteFileToolName = "delete_file"
) )
var ( var (
@@ -45,28 +45,17 @@ var (
mcp.WithString("filePath", mcp.Required(), mcp.Description("directory path")), mcp.WithString("filePath", mcp.Required(), mcp.Description("directory path")),
) )
CreateFileTool = mcp.NewTool( CreateOrUpdateFileTool = mcp.NewTool(
CreateFileToolName, CreateOrUpdateFileToolName,
mcp.WithDescription("Create file"), mcp.WithDescription("Create or update a file. If sha is provided, updates the existing file; otherwise creates a new file."),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("filePath", mcp.Required(), mcp.Description("file path")), mcp.WithString("filePath", mcp.Required(), mcp.Description("file path")),
mcp.WithString("content", mcp.Required(), mcp.Description("file content")), mcp.WithString("content", mcp.Required(), mcp.Description("file content")),
mcp.WithString("message", mcp.Required(), mcp.Description("commit message")), mcp.WithString("message", mcp.Required(), mcp.Description("commit message")),
mcp.WithString("branch_name", mcp.Required(), mcp.Description("branch name")), mcp.WithString("branch_name", mcp.Required(), mcp.Description("branch name")),
mcp.WithString("new_branch_name", mcp.Description("new branch name")), mcp.WithString("sha", mcp.Description("SHA of the existing file (required for update, omit for create)")),
) mcp.WithString("new_branch_name", mcp.Description("new branch name (for create only)")),
UpdateFileTool = mcp.NewTool(
UpdateFileToolName,
mcp.WithDescription("Update file"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("filePath", mcp.Required(), mcp.Description("file path")),
mcp.WithString("sha", mcp.Required(), mcp.Description("sha is the SHA for the file that already exists")),
mcp.WithString("content", mcp.Required(), mcp.Description("file content")),
mcp.WithString("message", mcp.Required(), mcp.Description("commit message")),
mcp.WithString("branch_name", mcp.Required(), mcp.Description("branch name")),
) )
DeleteFileTool = mcp.NewTool( DeleteFileTool = mcp.NewTool(
@@ -77,7 +66,7 @@ var (
mcp.WithString("filePath", mcp.Required(), mcp.Description("file path")), mcp.WithString("filePath", mcp.Required(), mcp.Description("file path")),
mcp.WithString("message", mcp.Required(), mcp.Description("commit message")), mcp.WithString("message", mcp.Required(), mcp.Description("commit message")),
mcp.WithString("branch_name", mcp.Required(), mcp.Description("branch name")), mcp.WithString("branch_name", mcp.Required(), mcp.Description("branch name")),
mcp.WithString("sha", mcp.Description("sha")), mcp.WithString("sha", mcp.Required(), mcp.Description("sha")),
) )
) )
@@ -91,12 +80,8 @@ func init() {
Handler: GetDirContentFn, Handler: GetDirContentFn,
}) })
Tool.RegisterWrite(server.ServerTool{ Tool.RegisterWrite(server.ServerTool{
Tool: CreateFileTool, Tool: CreateOrUpdateFileTool,
Handler: CreateFileFn, Handler: CreateOrUpdateFileFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: UpdateFileTool,
Handler: UpdateFileFn,
}) })
Tool.RegisterWrite(server.ServerTool{ Tool.RegisterWrite(server.ServerTool{
Tool: DeleteFileTool, Tool: DeleteFileTool,
@@ -111,18 +96,19 @@ type ContentLine struct {
func GetFileContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetFileContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetFileFn") log.Debugf("Called GetFileFn")
owner, ok := req.GetArguments()["owner"].(string) args := req.GetArguments()
if !ok { owner, err := params.GetString(args, "owner")
return to.ErrorResult(fmt.Errorf("owner is required")) if err != nil {
return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(args, "repo")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(err)
} }
ref, _ := req.GetArguments()["ref"].(string) ref, _ := args["ref"].(string)
filePath, ok := req.GetArguments()["filePath"].(string) filePath, err := params.GetString(args, "filePath")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("filePath is required")) return to.ErrorResult(err)
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
@@ -132,7 +118,7 @@ func GetFileContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get file err: %v", err)) return to.ErrorResult(fmt.Errorf("get file err: %v", err))
} }
withLines, _ := req.GetArguments()["withLines"].(bool) withLines, _ := args["withLines"].(bool)
if withLines { if withLines {
rawContent, err := base64.StdEncoding.DecodeString(*content.Content) rawContent, err := base64.StdEncoding.DecodeString(*content.Content)
if err != nil { if err != nil {
@@ -151,7 +137,6 @@ func GetFileContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
LineNumber: line, LineNumber: line,
Content: scanner.Text(), Content: scanner.Text(),
}) })
} }
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
return to.ErrorResult(fmt.Errorf("scan content err: %v", err)) return to.ErrorResult(fmt.Errorf("scan content err: %v", err))
@@ -159,7 +144,7 @@ func GetFileContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
// remove the last blank line if exists // remove the last blank line if exists
// git does not consider the last line as a new line // git does not consider the last line as a new line
if contentLines[len(contentLines)-1].Content == "" { if len(contentLines) > 0 && contentLines[len(contentLines)-1].Content == "" {
contentLines = contentLines[:len(contentLines)-1] contentLines = contentLines[:len(contentLines)-1]
} }
@@ -170,23 +155,24 @@ func GetFileContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
contentStr := string(contentBytes) contentStr := string(contentBytes)
content.Content = &contentStr content.Content = &contentStr
} }
return to.TextResult(content) return to.TextResult(slimContents(content))
} }
func GetDirContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetDirContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetDirContentFn") log.Debugf("Called GetDirContentFn")
owner, ok := req.GetArguments()["owner"].(string) args := req.GetArguments()
if !ok { owner, err := params.GetString(args, "owner")
return to.ErrorResult(fmt.Errorf("owner is required")) if err != nil {
return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(args, "repo")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(err)
} }
ref, _ := req.GetArguments()["ref"].(string) ref, _ := args["ref"].(string)
filePath, ok := req.GetArguments()["filePath"].(string) filePath, err := params.GetString(args, "filePath")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("filePath is required")) return to.ErrorResult(err)
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
@@ -196,26 +182,52 @@ func GetDirContentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get dir content err: %v", err)) return to.ErrorResult(fmt.Errorf("get dir content err: %v", err))
} }
return to.TextResult(content) return to.TextResult(slimDirEntries(content))
} }
func CreateFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func CreateOrUpdateFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateFileFn") log.Debugf("Called CreateOrUpdateFileFn")
owner, ok := req.GetArguments()["owner"].(string) args := req.GetArguments()
if !ok { owner, err := params.GetString(args, "owner")
return to.ErrorResult(fmt.Errorf("owner is required")) if err != nil {
return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(args, "repo")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(err)
} }
filePath, ok := req.GetArguments()["filePath"].(string) filePath, err := params.GetString(args, "filePath")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("filePath is required")) return to.ErrorResult(err)
} }
content, _ := req.GetArguments()["content"].(string) content, _ := args["content"].(string)
message, _ := req.GetArguments()["message"].(string) message, _ := args["message"].(string)
branchName, _ := req.GetArguments()["branch_name"].(string) branchName, _ := args["branch_name"].(string)
sha, _ := args["sha"].(string)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
if sha != "" {
// Update existing file
opt := gitea_sdk.UpdateFileOptions{
SHA: sha,
Content: base64.StdEncoding.EncodeToString([]byte(content)),
FileOptions: gitea_sdk.FileOptions{
Message: message,
BranchName: branchName,
},
}
_, _, err = client.UpdateFile(owner, repo, filePath, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("update file err: %v", err))
}
return to.TextResult("Update file success")
}
// 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: gitea_sdk.FileOptions{
@@ -223,10 +235,8 @@ func CreateFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRe
BranchName: branchName, BranchName: branchName,
}, },
} }
if newBranch, ok := args["new_branch_name"].(string); ok && newBranch != "" {
client, err := gitea.ClientFromContext(ctx) opt.NewBranchName = newBranch
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
} }
_, _, err = client.CreateFile(owner, repo, filePath, opt) _, _, err = client.CreateFile(owner, repo, filePath, opt)
if err != nil { if err != nil {
@@ -235,66 +245,26 @@ func CreateFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRe
return to.TextResult("Create file success") return to.TextResult("Create file success")
} }
func UpdateFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called UpdateFileFn")
owner, ok := req.GetArguments()["owner"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
filePath, ok := req.GetArguments()["filePath"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("filePath is required"))
}
sha, ok := req.GetArguments()["sha"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("sha is required"))
}
content, _ := req.GetArguments()["content"].(string)
message, _ := req.GetArguments()["message"].(string)
branchName, _ := req.GetArguments()["branch_name"].(string)
opt := gitea_sdk.UpdateFileOptions{
SHA: sha,
Content: base64.StdEncoding.EncodeToString([]byte(content)),
FileOptions: gitea_sdk.FileOptions{
Message: message,
BranchName: branchName,
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, _, err = client.UpdateFile(owner, repo, filePath, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("update file err: %v", err))
}
return to.TextResult("Update file success")
}
func DeleteFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func DeleteFileFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteFileFn") log.Debugf("Called DeleteFileFn")
owner, ok := req.GetArguments()["owner"].(string) args := req.GetArguments()
if !ok { owner, err := params.GetString(args, "owner")
return to.ErrorResult(fmt.Errorf("owner is required")) if err != nil {
return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(args, "repo")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(err)
} }
filePath, ok := req.GetArguments()["filePath"].(string) filePath, err := params.GetString(args, "filePath")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("filePath is required")) return to.ErrorResult(err)
} }
message, _ := req.GetArguments()["message"].(string) message, _ := args["message"].(string)
branchName, _ := req.GetArguments()["branch_name"].(string) branchName, _ := args["branch_name"].(string)
sha, ok := req.GetArguments()["sha"].(string) sha, err := params.GetString(args, "sha")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("sha is required")) return to.ErrorResult(err)
} }
opt := gitea_sdk.DeleteFileOptions{ opt := gitea_sdk.DeleteFileOptions{
FileOptions: gitea_sdk.FileOptions{ FileOptions: gitea_sdk.FileOptions{

View File

@@ -3,11 +3,10 @@ package repo
import ( import (
"context" "context"
"fmt" "fmt"
"time"
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/ptr" "gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "code.gitea.io/sdk/gitea" gitea_sdk "code.gitea.io/sdk/gitea"
@@ -68,7 +67,7 @@ var (
mcp.WithBoolean("is_draft", mcp.Description("Whether the release is draft"), mcp.DefaultBool(false)), mcp.WithBoolean("is_draft", mcp.Description("Whether the release is draft"), mcp.DefaultBool(false)),
mcp.WithBoolean("is_pre_release", mcp.Description("Whether the release is pre-release"), mcp.DefaultBool(false)), mcp.WithBoolean("is_pre_release", mcp.Description("Whether the release is pre-release"), mcp.DefaultBool(false)),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)), mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(20), mcp.Min(1)), mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(20), mcp.Min(1)),
) )
) )
@@ -95,44 +94,32 @@ func init() {
}) })
} }
// To avoid return too many tokens, we need to provide at least information as possible
// llm can call get release to get more information
type ListReleaseResult struct {
ID int64 `json:"id"`
TagName string `json:"tag_name"`
Target string `json:"target_commitish"`
Title string `json:"title"`
IsDraft bool `json:"draft"`
IsPrerelease bool `json:"prerelease"`
CreatedAt time.Time `json:"created_at"`
PublishedAt time.Time `json:"published_at"`
}
func CreateReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func CreateReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateReleasesFn") log.Debugf("Called CreateReleasesFn")
owner, ok := req.GetArguments()["owner"].(string) args := req.GetArguments()
if !ok { owner, err := params.GetString(args, "owner")
return nil, fmt.Errorf("owner is required") if err != nil {
return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(args, "repo")
if !ok { if err != nil {
return nil, fmt.Errorf("repo is required") return to.ErrorResult(err)
} }
tagName, ok := req.GetArguments()["tag_name"].(string) tagName, err := params.GetString(args, "tag_name")
if !ok { if err != nil {
return nil, fmt.Errorf("tag_name is required") return to.ErrorResult(err)
} }
target, ok := req.GetArguments()["target"].(string) target, err := params.GetString(args, "target")
if !ok { if err != nil {
return nil, fmt.Errorf("target is required") return to.ErrorResult(err)
} }
title, ok := req.GetArguments()["title"].(string) title, err := params.GetString(args, "title")
if !ok { if err != nil {
return nil, fmt.Errorf("title is required") return to.ErrorResult(err)
} }
isDraft, _ := req.GetArguments()["is_draft"].(bool) isDraft, _ := args["is_draft"].(bool)
isPreRelease, _ := req.GetArguments()["is_pre_release"].(bool) isPreRelease, _ := args["is_pre_release"].(bool)
body, _ := req.GetArguments()["body"].(string) body, _ := args["body"].(string)
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
@@ -155,24 +142,25 @@ func CreateReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
func DeleteReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func DeleteReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteReleaseFn") log.Debugf("Called DeleteReleaseFn")
owner, ok := req.GetArguments()["owner"].(string) args := req.GetArguments()
if !ok { owner, err := params.GetString(args, "owner")
return nil, fmt.Errorf("owner is required") if err != nil {
return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(args, "repo")
if !ok { if err != nil {
return nil, fmt.Errorf("repo is required") return to.ErrorResult(err)
} }
id, ok := req.GetArguments()["id"].(float64) id, err := params.GetIndex(args, "id")
if !ok { if err != nil {
return nil, fmt.Errorf("id is required") 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))
} }
_, err = client.DeleteRelease(owner, repo, int64(id)) _, err = client.DeleteRelease(owner, repo, id)
if err != nil { if err != nil {
return nil, fmt.Errorf("delete release error: %v", err) return nil, fmt.Errorf("delete release error: %v", err)
} }
@@ -182,40 +170,42 @@ func DeleteReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
func GetReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetReleaseFn") log.Debugf("Called GetReleaseFn")
owner, ok := req.GetArguments()["owner"].(string) args := req.GetArguments()
if !ok { owner, err := params.GetString(args, "owner")
return nil, fmt.Errorf("owner is required") if err != nil {
return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(args, "repo")
if !ok { if err != nil {
return nil, fmt.Errorf("repo is required") return to.ErrorResult(err)
} }
id, ok := req.GetArguments()["id"].(float64) id, err := params.GetIndex(args, "id")
if !ok { if err != nil {
return nil, fmt.Errorf("id is required") 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))
} }
release, _, err := client.GetRelease(owner, repo, int64(id)) release, _, err := client.GetRelease(owner, repo, id)
if err != nil { if err != nil {
return nil, fmt.Errorf("get release error: %v", err) return nil, fmt.Errorf("get release error: %v", err)
} }
return to.TextResult(release) return to.TextResult(slimRelease(release))
} }
func GetLatestReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetLatestReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetLatestReleaseFn") log.Debugf("Called GetLatestReleaseFn")
owner, ok := req.GetArguments()["owner"].(string) args := req.GetArguments()
if !ok { owner, err := params.GetString(args, "owner")
return nil, fmt.Errorf("owner is required") if err != nil {
return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(args, "repo")
if !ok { if err != nil {
return nil, fmt.Errorf("repo is required") return to.ErrorResult(err)
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -227,31 +217,32 @@ func GetLatestReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call
return nil, fmt.Errorf("get latest release error: %v", err) return nil, fmt.Errorf("get latest release error: %v", err)
} }
return to.TextResult(release) return to.TextResult(slimRelease(release))
} }
func ListReleasesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListReleasesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListReleasesFn") log.Debugf("Called ListReleasesFn")
owner, ok := req.GetArguments()["owner"].(string) args := req.GetArguments()
if !ok { owner, err := params.GetString(args, "owner")
return nil, fmt.Errorf("owner is required") if err != nil {
return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(args, "repo")
if !ok { if err != nil {
return nil, fmt.Errorf("repo is required") return to.ErrorResult(err)
} }
var pIsDraft *bool var pIsDraft *bool
isDraft, ok := req.GetArguments()["is_draft"].(bool) isDraft, ok := args["is_draft"].(bool)
if ok { if ok {
pIsDraft = ptr.To(isDraft) pIsDraft = new(isDraft)
} }
var pIsPreRelease *bool var pIsPreRelease *bool
isPreRelease, ok := req.GetArguments()["is_pre_release"].(bool) isPreRelease, ok := args["is_pre_release"].(bool)
if ok { if ok {
pIsPreRelease = ptr.To(isPreRelease) pIsPreRelease = new(isPreRelease)
} }
page, _ := req.GetArguments()["page"].(float64) page := params.GetOptionalInt(args, "page", 1)
pageSize, _ := req.GetArguments()["pageSize"].(float64) pageSize := params.GetOptionalInt(args, "perPage", 20)
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
@@ -269,18 +260,5 @@ func ListReleasesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
return nil, fmt.Errorf("list releases error: %v", err) return nil, fmt.Errorf("list releases error: %v", err)
} }
results := make([]ListReleaseResult, len(releases)) return to.TextResult(slimReleases(releases))
for _, release := range releases {
results = append(results, ListReleaseResult{
ID: release.ID,
TagName: release.TagName,
Target: release.Target,
Title: release.Title,
IsDraft: release.IsDraft,
IsPrerelease: release.IsPrerelease,
CreatedAt: release.CreatedAt,
PublishedAt: release.PublishedAt,
})
}
return to.TextResult(results)
} }

View File

@@ -7,7 +7,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/ptr" "gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
@@ -19,9 +19,10 @@ import (
var Tool = tool.New() var Tool = tool.New()
const ( const (
CreateRepoToolName = "create_repo" CreateRepoToolName = "create_repo"
ForkRepoToolName = "fork_repo" ForkRepoToolName = "fork_repo"
ListMyReposToolName = "list_my_repos" ListMyReposToolName = "list_my_repos"
ListOrgReposToolName = "list_org_repos"
) )
var ( var (
@@ -54,6 +55,14 @@ var (
ListMyReposToolName, ListMyReposToolName,
mcp.WithDescription("List my repositories"), mcp.WithDescription("List my repositories"),
mcp.WithNumber("page", mcp.Required(), mcp.Description("Page number"), mcp.DefaultNumber(1), mcp.Min(1)), mcp.WithNumber("page", mcp.Required(), mcp.Description("Page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("perPage", mcp.Required(), mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)),
)
ListOrgReposTool = mcp.NewTool(
ListOrgReposToolName,
mcp.WithDescription("List repositories of an organization"),
mcp.WithString("org", mcp.Required(), mcp.Description("Organization name")),
mcp.WithNumber("page", mcp.Required(), mcp.Description("Page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("pageSize", mcp.Required(), mcp.Description("Page size number"), mcp.DefaultNumber(100), mcp.Min(1)), mcp.WithNumber("pageSize", mcp.Required(), mcp.Description("Page size number"), mcp.DefaultNumber(100), mcp.Min(1)),
) )
) )
@@ -71,57 +80,29 @@ func init() {
Tool: ListMyReposTool, Tool: ListMyReposTool,
Handler: ListMyReposFn, Handler: ListMyReposFn,
}) })
} Tool.RegisterRead(server.ServerTool{
Tool: ListOrgReposTool,
func RegisterTool(s *server.MCPServer) { Handler: ListOrgReposFn,
s.AddTool(CreateRepoTool, CreateRepoFn) })
s.AddTool(ForkRepoTool, ForkRepoFn)
s.AddTool(ListMyReposTool, ListMyReposFn)
// File
s.AddTool(GetFileContentTool, GetFileContentFn)
s.AddTool(CreateFileTool, CreateFileFn)
s.AddTool(UpdateFileTool, UpdateFileFn)
s.AddTool(DeleteFileTool, DeleteFileFn)
// Branch
s.AddTool(CreateBranchTool, CreateBranchFn)
s.AddTool(DeleteBranchTool, DeleteBranchFn)
s.AddTool(ListBranchesTool, ListBranchesFn)
// Release
s.AddTool(CreateReleaseTool, CreateReleaseFn)
s.AddTool(DeleteReleaseTool, DeleteReleaseFn)
s.AddTool(GetReleaseTool, GetReleaseFn)
s.AddTool(GetLatestReleaseTool, GetLatestReleaseFn)
s.AddTool(ListReleasesTool, ListReleasesFn)
// Tag
s.AddTool(CreateTagTool, CreateTagFn)
s.AddTool(DeleteTagTool, DeleteTagFn)
s.AddTool(GetTagTool, GetTagFn)
s.AddTool(ListTagsTool, ListTagsFn)
// Commit
s.AddTool(ListRepoCommitsTool, ListRepoCommitsFn)
} }
func CreateRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func CreateRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateRepoFn") log.Debugf("Called CreateRepoFn")
name, ok := req.GetArguments()["name"].(string) args := req.GetArguments()
if !ok { name, err := params.GetString(args, "name")
return to.ErrorResult(errors.New("repository name is required")) if err != nil {
return to.ErrorResult(err)
} }
description, _ := req.GetArguments()["description"].(string) description, _ := args["description"].(string)
private, _ := req.GetArguments()["private"].(bool) private, _ := args["private"].(bool)
issueLabels, _ := req.GetArguments()["issue_labels"].(string) issueLabels, _ := args["issue_labels"].(string)
autoInit, _ := req.GetArguments()["auto_init"].(bool) autoInit, _ := args["auto_init"].(bool)
template, _ := req.GetArguments()["template"].(bool) template, _ := args["template"].(bool)
gitignores, _ := req.GetArguments()["gitignores"].(string) gitignores, _ := args["gitignores"].(string)
license, _ := req.GetArguments()["license"].(string) license, _ := args["license"].(string)
readme, _ := req.GetArguments()["readme"].(string) readme, _ := args["readme"].(string)
defaultBranch, _ := req.GetArguments()["default_branch"].(string) defaultBranch, _ := args["default_branch"].(string)
organization, _ := req.GetArguments()["organization"].(string) organization, _ := args["organization"].(string)
opt := gitea_sdk.CreateRepoOption{ opt := gitea_sdk.CreateRepoOption{
Name: name, Name: name,
@@ -152,26 +133,27 @@ func CreateRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRe
return to.ErrorResult(fmt.Errorf("create repository '%s' err: %v", name, err)) return to.ErrorResult(fmt.Errorf("create repository '%s' err: %v", name, err))
} }
} }
return to.TextResult(repo) return to.TextResult(slimRepo(repo))
} }
func ForkRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ForkRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ForkRepoFn") log.Debugf("Called ForkRepoFn")
user, ok := req.GetArguments()["user"].(string) args := req.GetArguments()
if !ok { user, err := params.GetString(args, "user")
return to.ErrorResult(errors.New("user name is required")) if err != nil {
return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(args, "repo")
if !ok { if err != nil {
return to.ErrorResult(errors.New("repository name is required")) return to.ErrorResult(err)
} }
organization, ok := req.GetArguments()["organization"].(string) organization, ok := args["organization"].(string)
organizationPtr := ptr.To(organization) organizationPtr := new(organization)
if !ok || organization == "" { if !ok || organization == "" {
organizationPtr = nil organizationPtr = nil
} }
name, ok := req.GetArguments()["name"].(string) name, ok := args["name"].(string)
namePtr := ptr.To(name) namePtr := new(name)
if !ok || name == "" { if !ok || name == "" {
namePtr = nil namePtr = nil
} }
@@ -192,18 +174,11 @@ func ForkRepoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResu
func ListMyReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListMyReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListMyReposFn") log.Debugf("Called ListMyReposFn")
page, ok := req.GetArguments()["page"].(float64) page, pageSize := params.GetPagination(req.GetArguments(), 30)
if !ok {
page = 1
}
pageSize, ok := req.GetArguments()["pageSize"].(float64)
if !ok {
pageSize = 100
}
opt := gitea_sdk.ListReposOptions{ opt := gitea_sdk.ListReposOptions{
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: int(page), Page: page,
PageSize: int(pageSize), PageSize: pageSize,
}, },
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -215,5 +190,36 @@ func ListMyReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR
return to.ErrorResult(fmt.Errorf("list my repositories error: %v", err)) return to.ErrorResult(fmt.Errorf("list my repositories error: %v", err))
} }
return to.TextResult(slimRepos(repos))
}
func ListOrgReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListOrgReposFn")
org, ok := req.GetArguments()["org"].(string)
if !ok {
return to.ErrorResult(errors.New("organization name is required"))
}
page, ok := req.GetArguments()["page"].(float64)
if !ok {
page = 1
}
pageSize, ok := req.GetArguments()["pageSize"].(float64)
if !ok {
pageSize = 100
}
opt := gitea_sdk.ListOrgReposOptions{
ListOptions: gitea_sdk.ListOptions{
Page: int(page),
PageSize: int(pageSize),
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
repos, _, err := client.ListOrgRepos(org, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list organization '%s' repositories error: %v", org, err))
}
return to.TextResult(repos) return to.TextResult(repos)
} }

201
operation/repo/slim.go Normal file
View File

@@ -0,0 +1,201 @@
package repo
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func userLogin(u *gitea_sdk.User) string {
if u == nil {
return ""
}
return u.UserName
}
func slimRepo(r *gitea_sdk.Repository) map[string]any {
if r == nil {
return nil
}
m := map[string]any{
"id": r.ID,
"full_name": r.FullName,
"description": r.Description,
"html_url": r.HTMLURL,
"clone_url": r.CloneURL,
"ssh_url": r.SSHURL,
"default_branch": r.DefaultBranch,
"private": r.Private,
"fork": r.Fork,
"archived": r.Archived,
"language": r.Language,
"stars_count": r.Stars,
"forks_count": r.Forks,
"open_issues_count": r.OpenIssues,
"open_pr_counter": r.OpenPulls,
"created_at": r.Created,
"updated_at": r.Updated,
}
if r.Owner != nil {
m["owner"] = r.Owner.UserName
}
if len(r.Topics) > 0 {
m["topics"] = r.Topics
}
return m
}
func slimRepos(repos []*gitea_sdk.Repository) []map[string]any {
out := make([]map[string]any, 0, len(repos))
for _, r := range repos {
out = append(out, slimRepo(r))
}
return out
}
func slimBranch(b *gitea_sdk.Branch) map[string]any {
if b == nil {
return nil
}
m := map[string]any{
"name": b.Name,
"protected": b.Protected,
}
if b.Commit != nil {
m["commit_sha"] = b.Commit.ID
}
return m
}
func slimBranches(branches []*gitea_sdk.Branch) []map[string]any {
out := make([]map[string]any, 0, len(branches))
for _, b := range branches {
out = append(out, slimBranch(b))
}
return out
}
func slimCommit(c *gitea_sdk.Commit) map[string]any {
if c == nil {
return nil
}
m := map[string]any{
"sha": c.SHA,
"html_url": c.HTMLURL,
"created": c.Created,
}
if c.RepoCommit != nil {
m["message"] = c.RepoCommit.Message
if c.RepoCommit.Author != nil {
m["author"] = map[string]any{
"name": c.RepoCommit.Author.Name,
"email": c.RepoCommit.Author.Email,
"date": c.RepoCommit.Author.Date,
}
}
}
return m
}
func slimCommits(commits []*gitea_sdk.Commit) []map[string]any {
out := make([]map[string]any, 0, len(commits))
for _, c := range commits {
out = append(out, slimCommit(c))
}
return out
}
func slimTag(t *gitea_sdk.Tag) map[string]any {
if t == nil {
return nil
}
m := map[string]any{
"name": t.Name,
"message": t.Message,
}
if t.Commit != nil {
m["commit_sha"] = t.Commit.SHA
}
return m
}
func slimTags(tags []*gitea_sdk.Tag) []map[string]any {
out := make([]map[string]any, 0, len(tags))
for _, t := range tags {
m := map[string]any{
"name": t.Name,
}
if t.Commit != nil {
m["commit_sha"] = t.Commit.SHA
}
out = append(out, m)
}
return out
}
func slimRelease(r *gitea_sdk.Release) map[string]any {
if r == nil {
return nil
}
return map[string]any{
"id": r.ID,
"tag_name": r.TagName,
"target": r.Target,
"title": r.Title,
"body": r.Note,
"draft": r.IsDraft,
"prerelease": r.IsPrerelease,
"html_url": r.HTMLURL,
"author": userLogin(r.Publisher),
"created_at": r.CreatedAt,
"published_at": r.PublishedAt,
}
}
func slimReleases(releases []*gitea_sdk.Release) []map[string]any {
out := make([]map[string]any, 0, len(releases))
for _, r := range releases {
out = append(out, slimRelease(r))
}
return out
}
func slimContents(c *gitea_sdk.ContentsResponse) map[string]any {
if c == nil {
return nil
}
m := map[string]any{
"name": c.Name,
"path": c.Path,
"sha": c.SHA,
"type": c.Type,
"size": c.Size,
}
if c.Content != nil {
m["content"] = *c.Content
}
if c.Encoding != nil {
m["encoding"] = *c.Encoding
}
if c.HTMLURL != nil {
m["html_url"] = *c.HTMLURL
}
if c.DownloadURL != nil {
m["download_url"] = *c.DownloadURL
}
return m
}
func slimDirEntries(entries []*gitea_sdk.ContentsResponse) []map[string]any {
out := make([]map[string]any, 0, len(entries))
for _, c := range entries {
if c == nil {
continue
}
out = append(out, map[string]any{
"name": c.Name,
"path": c.Path,
"type": c.Type,
"size": c.Size,
})
}
return out
}

142
operation/repo/slim_test.go Normal file
View File

@@ -0,0 +1,142 @@
package repo
import (
"testing"
gitea_sdk "code.gitea.io/sdk/gitea"
)
func TestSlimRepo(t *testing.T) {
r := &gitea_sdk.Repository{
ID: 1,
FullName: "org/repo",
Description: "A test repo",
HTMLURL: "https://gitea.com/org/repo",
CloneURL: "https://gitea.com/org/repo.git",
SSHURL: "git@gitea.com:org/repo.git",
DefaultBranch: "main",
Private: false,
Fork: false,
Archived: false,
Language: "Go",
Stars: 10,
Forks: 2,
Owner: &gitea_sdk.User{UserName: "org"},
Topics: []string{"mcp", "gitea"},
}
m := slimRepo(r)
if m["full_name"] != "org/repo" {
t.Errorf("expected full_name org/repo, got %v", m["full_name"])
}
if m["owner"] != "org" {
t.Errorf("expected owner org, got %v", m["owner"])
}
topics := m["topics"].([]string)
if len(topics) != 2 {
t.Errorf("expected 2 topics, got %d", len(topics))
}
}
func TestSlimTag(t *testing.T) {
tag := &gitea_sdk.Tag{
Name: "v1.0.0",
Message: "Release v1.0.0",
Commit: &gitea_sdk.CommitMeta{SHA: "abc123"},
}
m := slimTag(tag)
if m["name"] != "v1.0.0" {
t.Errorf("expected name v1.0.0, got %v", m["name"])
}
if m["message"] != "Release v1.0.0" {
t.Errorf("expected message, got %v", m["message"])
}
// List variant omits message
list := slimTags([]*gitea_sdk.Tag{tag})
if _, ok := list[0]["message"]; ok {
t.Error("Tags list should omit message")
}
if list[0]["name"] != "v1.0.0" {
t.Errorf("expected name in list, got %v", list[0]["name"])
}
}
func TestSlimRelease(t *testing.T) {
r := &gitea_sdk.Release{
ID: 1,
TagName: "v1.0.0",
Title: "First Release",
Note: "Release notes",
IsDraft: false,
Publisher: &gitea_sdk.User{UserName: "alice"},
}
m := slimRelease(r)
if m["tag_name"] != "v1.0.0" {
t.Errorf("expected tag_name v1.0.0, got %v", m["tag_name"])
}
if m["body"] != "Release notes" {
t.Errorf("expected body from Note field, got %v", m["body"])
}
if m["author"] != "alice" {
t.Errorf("expected author alice, got %v", m["author"])
}
}
func TestSlimContents(t *testing.T) {
content := "package main"
encoding := "base64"
htmlURL := "https://gitea.com/org/repo/src/branch/main/main.go"
c := &gitea_sdk.ContentsResponse{
Name: "main.go",
Path: "main.go",
SHA: "abc123",
Type: "file",
Size: 12,
Content: &content,
Encoding: &encoding,
HTMLURL: &htmlURL,
}
m := slimContents(c)
if m["name"] != "main.go" {
t.Errorf("expected name main.go, got %v", m["name"])
}
if m["content"] != "package main" {
t.Errorf("expected content, got %v", m["content"])
}
}
func TestSlimDirEntries(t *testing.T) {
entries := []*gitea_sdk.ContentsResponse{
{Name: "src", Path: "src", Type: "dir", Size: 0},
{Name: "main.go", Path: "main.go", Type: "file", Size: 100},
}
result := slimDirEntries(entries)
if len(result) != 2 {
t.Fatalf("expected 2 entries, got %d", len(result))
}
if result[0]["name"] != "src" {
t.Errorf("expected first entry name src, got %v", result[0]["name"])
}
// Dir entries should not have content
if _, ok := result[0]["content"]; ok {
t.Error("dir entries should not have content field")
}
}
func TestSlimTags_Nil(t *testing.T) {
if r := slimTags(nil); len(r) != 0 {
t.Errorf("expected empty slice, got %v", r)
}
}
func TestSlimReleases_Nil(t *testing.T) {
if r := slimReleases(nil); len(r) != 0 {
t.Errorf("expected empty slice, got %v", r)
}
}

View File

@@ -6,6 +6,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
gitea_sdk "code.gitea.io/sdk/gitea" gitea_sdk "code.gitea.io/sdk/gitea"
@@ -53,7 +54,7 @@ var (
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)), mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(20), mcp.Min(1)), mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(20), mcp.Min(1)),
) )
) )
@@ -76,31 +77,23 @@ func init() {
}) })
} }
// To avoid return too many tokens, we need to provide at least information as possible
// llm can call get tag to get more information
type ListTagResult struct {
ID string `json:"id"`
Name string `json:"name"`
Commit *gitea_sdk.CommitMeta `json:"commit"`
// message may be a long text, so we should not provide it here
}
func CreateTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func CreateTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateTagFn") log.Debugf("Called CreateTagFn")
owner, ok := req.GetArguments()["owner"].(string) args := req.GetArguments()
if !ok { owner, err := params.GetString(args, "owner")
return nil, fmt.Errorf("owner is required") if err != nil {
return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(args, "repo")
if !ok { if err != nil {
return nil, fmt.Errorf("repo is required") return to.ErrorResult(err)
} }
tagName, ok := req.GetArguments()["tag_name"].(string) tagName, err := params.GetString(args, "tag_name")
if !ok { if err != nil {
return nil, fmt.Errorf("tag_name is required") return to.ErrorResult(err)
} }
target, _ := req.GetArguments()["target"].(string) target, _ := args["target"].(string)
message, _ := req.GetArguments()["message"].(string) message, _ := args["message"].(string)
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
@@ -120,17 +113,18 @@ func CreateTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRes
func DeleteTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func DeleteTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteTagFn") log.Debugf("Called DeleteTagFn")
owner, ok := req.GetArguments()["owner"].(string) args := req.GetArguments()
if !ok { owner, err := params.GetString(args, "owner")
return nil, fmt.Errorf("owner is required") if err != nil {
return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(args, "repo")
if !ok { if err != nil {
return nil, fmt.Errorf("repo is required") return to.ErrorResult(err)
} }
tagName, ok := req.GetArguments()["tag_name"].(string) tagName, err := params.GetString(args, "tag_name")
if !ok { if err != nil {
return nil, fmt.Errorf("tag_name is required") return to.ErrorResult(err)
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -147,17 +141,18 @@ func DeleteTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRes
func GetTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetTagFn") log.Debugf("Called GetTagFn")
owner, ok := req.GetArguments()["owner"].(string) args := req.GetArguments()
if !ok { owner, err := params.GetString(args, "owner")
return nil, fmt.Errorf("owner is required") if err != nil {
return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(args, "repo")
if !ok { if err != nil {
return nil, fmt.Errorf("repo is required") return to.ErrorResult(err)
} }
tagName, ok := req.GetArguments()["tag_name"].(string) tagName, err := params.GetString(args, "tag_name")
if !ok { if err != nil {
return nil, fmt.Errorf("tag_name is required") return to.ErrorResult(err)
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -169,21 +164,22 @@ func GetTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult
return nil, fmt.Errorf("get tag error: %v", err) return nil, fmt.Errorf("get tag error: %v", err)
} }
return to.TextResult(tag) return to.TextResult(slimTag(tag))
} }
func ListTagsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ListTagsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListTagsFn") log.Debugf("Called ListTagsFn")
owner, ok := req.GetArguments()["owner"].(string) args := req.GetArguments()
if !ok { owner, err := params.GetString(args, "owner")
return nil, fmt.Errorf("owner is required") if err != nil {
return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(args, "repo")
if !ok { if err != nil {
return nil, fmt.Errorf("repo is required") return to.ErrorResult(err)
} }
page, _ := req.GetArguments()["page"].(float64) page := params.GetOptionalInt(args, "page", 1)
pageSize, _ := req.GetArguments()["pageSize"].(float64) pageSize := params.GetOptionalInt(args, "perPage", 20)
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
if err != nil { if err != nil {
@@ -199,13 +195,5 @@ func ListTagsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResu
return nil, fmt.Errorf("list tags error: %v", err) return nil, fmt.Errorf("list tags error: %v", err)
} }
results := make([]ListTagResult, 0, len(tags)) return to.TextResult(slimTags(tags))
for _, tag := range tags {
results = append(results, ListTagResult{
ID: tag.ID,
Name: tag.Name,
Commit: tag.Commit,
})
}
return to.TextResult(results)
} }

View File

@@ -6,7 +6,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/ptr" "gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
@@ -27,25 +27,25 @@ var (
SearchUsersTool = mcp.NewTool( SearchUsersTool = mcp.NewTool(
SearchUsersToolName, SearchUsersToolName,
mcp.WithDescription("search users"), mcp.WithDescription("search users"),
mcp.WithString("keyword", mcp.Description("Keyword")), mcp.WithString("keyword", mcp.Required(), mcp.Description("Keyword")),
mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)), mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)),
mcp.WithNumber("pageSize", mcp.Description("PageSize"), mcp.DefaultNumber(100)), mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
) )
SearOrgTeamsTool = mcp.NewTool( SearOrgTeamsTool = mcp.NewTool(
SearchOrgTeamsToolName, SearchOrgTeamsToolName,
mcp.WithDescription("search organization teams"), mcp.WithDescription("search organization teams"),
mcp.WithString("org", mcp.Description("organization name")), mcp.WithString("org", mcp.Required(), mcp.Description("organization name")),
mcp.WithString("query", mcp.Description("search organization teams")), mcp.WithString("query", mcp.Required(), mcp.Description("search organization teams")),
mcp.WithBoolean("includeDescription", mcp.Description("include description?")), mcp.WithBoolean("includeDescription", mcp.Description("include description?")),
mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)), mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)),
mcp.WithNumber("pageSize", mcp.Description("PageSize"), mcp.DefaultNumber(100)), mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
) )
SearchReposTool = mcp.NewTool( SearchReposTool = mcp.NewTool(
SearchReposToolName, SearchReposToolName,
mcp.WithDescription("search repos"), mcp.WithDescription("search repos"),
mcp.WithString("keyword", mcp.Description("Keyword")), mcp.WithString("keyword", mcp.Required(), mcp.Description("Keyword")),
mcp.WithBoolean("keywordIsTopic", mcp.Description("KeywordIsTopic")), mcp.WithBoolean("keywordIsTopic", mcp.Description("KeywordIsTopic")),
mcp.WithBoolean("keywordInDescription", mcp.Description("KeywordInDescription")), mcp.WithBoolean("keywordInDescription", mcp.Description("KeywordInDescription")),
mcp.WithNumber("ownerID", mcp.Description("OwnerID")), mcp.WithNumber("ownerID", mcp.Description("OwnerID")),
@@ -54,44 +54,37 @@ var (
mcp.WithString("sort", mcp.Description("Sort")), mcp.WithString("sort", mcp.Description("Sort")),
mcp.WithString("order", mcp.Description("Order")), mcp.WithString("order", mcp.Description("Order")),
mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)), mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)),
mcp.WithNumber("pageSize", mcp.Description("PageSize"), mcp.DefaultNumber(100)), mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
) )
) )
func init() { func init() {
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(server.ServerTool{
Tool: SearchUsersTool, Tool: SearchUsersTool,
Handler: SearchUsersFn, Handler: UsersFn,
}) })
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(server.ServerTool{
Tool: SearOrgTeamsTool, Tool: SearOrgTeamsTool,
Handler: SearchOrgTeamsFn, Handler: OrgTeamsFn,
}) })
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(server.ServerTool{
Tool: SearchReposTool, Tool: SearchReposTool,
Handler: SearchReposFn, Handler: ReposFn,
}) })
} }
func SearchUsersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func UsersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called SearchUsersFn") log.Debugf("Called UsersFn")
keyword, ok := req.GetArguments()["keyword"].(string) keyword, err := params.GetString(req.GetArguments(), "keyword")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("keyword is required")) return to.ErrorResult(err)
}
page, ok := req.GetArguments()["page"].(float64)
if !ok {
page = 1
}
pageSize, ok := req.GetArguments()["pageSize"].(float64)
if !ok {
pageSize = 100
} }
page, pageSize := params.GetPagination(req.GetArguments(), 30)
opt := gitea_sdk.SearchUsersOption{ opt := gitea_sdk.SearchUsersOption{
KeyWord: keyword, KeyWord: keyword,
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: int(page), Page: page,
PageSize: int(pageSize), PageSize: pageSize,
}, },
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -102,34 +95,27 @@ func SearchUsersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("search users err: %v", err)) return to.ErrorResult(fmt.Errorf("search users err: %v", err))
} }
return to.TextResult(users) return to.TextResult(slimUserDetails(users))
} }
func SearchOrgTeamsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func OrgTeamsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called SearchOrgTeamsFn") log.Debugf("Called OrgTeamsFn")
org, ok := req.GetArguments()["org"].(string) org, err := params.GetString(req.GetArguments(), "org")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("organization is required")) return to.ErrorResult(err)
} }
query, ok := req.GetArguments()["query"].(string) query, err := params.GetString(req.GetArguments(), "query")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("query is required")) return to.ErrorResult(err)
} }
includeDescription, _ := req.GetArguments()["includeDescription"].(bool) includeDescription, _ := req.GetArguments()["includeDescription"].(bool)
page, ok := req.GetArguments()["page"].(float64) page, pageSize := params.GetPagination(req.GetArguments(), 30)
if !ok {
page = 1
}
pageSize, ok := req.GetArguments()["pageSize"].(float64)
if !ok {
pageSize = 100
}
opt := gitea_sdk.SearchTeamsOptions{ opt := gitea_sdk.SearchTeamsOptions{
Query: query, Query: query,
IncludeDescription: includeDescription, IncludeDescription: includeDescription,
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: int(page), Page: page,
PageSize: int(pageSize), PageSize: pageSize,
}, },
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -140,50 +126,43 @@ func SearchOrgTeamsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("search organization teams error: %v", err)) return to.ErrorResult(fmt.Errorf("search organization teams error: %v", err))
} }
return to.TextResult(teams) return to.TextResult(slimTeams(teams))
} }
func SearchReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func ReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called SearchReposFn") log.Debugf("Called ReposFn")
keyword, ok := req.GetArguments()["keyword"].(string) keyword, err := params.GetString(req.GetArguments(), "keyword")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("keyword is required")) return to.ErrorResult(err)
} }
keywordIsTopic, _ := req.GetArguments()["keywordIsTopic"].(bool) keywordIsTopic, _ := req.GetArguments()["keywordIsTopic"].(bool)
keywordInDescription, _ := req.GetArguments()["keywordInDescription"].(bool) keywordInDescription, _ := req.GetArguments()["keywordInDescription"].(bool)
ownerID, _ := req.GetArguments()["ownerID"].(float64) ownerID := params.GetOptionalInt(req.GetArguments(), "ownerID", 0)
var pIsPrivate *bool var pIsPrivate *bool
isPrivate, ok := req.GetArguments()["isPrivate"].(bool) isPrivate, ok := req.GetArguments()["isPrivate"].(bool)
if ok { if ok {
pIsPrivate = ptr.To(isPrivate) pIsPrivate = new(isPrivate)
} }
var pIsArchived *bool var pIsArchived *bool
isArchived, ok := req.GetArguments()["isArchived"].(bool) isArchived, ok := req.GetArguments()["isArchived"].(bool)
if ok { if ok {
pIsArchived = ptr.To(isArchived) pIsArchived = new(isArchived)
} }
sort, _ := req.GetArguments()["sort"].(string) sort, _ := req.GetArguments()["sort"].(string)
order, _ := req.GetArguments()["order"].(string) order, _ := req.GetArguments()["order"].(string)
page, ok := req.GetArguments()["page"].(float64) page, pageSize := params.GetPagination(req.GetArguments(), 30)
if !ok {
page = 1
}
pageSize, ok := req.GetArguments()["pageSize"].(float64)
if !ok {
pageSize = 100
}
opt := gitea_sdk.SearchRepoOptions{ opt := gitea_sdk.SearchRepoOptions{
Keyword: keyword, Keyword: keyword,
KeywordIsTopic: keywordIsTopic, KeywordIsTopic: keywordIsTopic,
KeywordInDescription: keywordInDescription, KeywordInDescription: keywordInDescription,
OwnerID: int64(ownerID), OwnerID: ownerID,
IsPrivate: pIsPrivate, IsPrivate: pIsPrivate,
IsArchived: pIsArchived, IsArchived: pIsArchived,
Sort: sort, Sort: sort,
Order: order, Order: order,
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
Page: int(page), Page: page,
PageSize: int(pageSize), PageSize: pageSize,
}, },
} }
client, err := gitea.ClientFromContext(ctx) client, err := gitea.ClientFromContext(ctx)
@@ -194,5 +173,5 @@ func SearchReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("search repos error: %v", err)) return to.ErrorResult(fmt.Errorf("search repos error: %v", err))
} }
return to.TextResult(repos) return to.TextResult(slimRepos(repos))
} }

View File

@@ -0,0 +1,42 @@
package search
import (
"slices"
"testing"
"github.com/mark3labs/mcp-go/mcp"
)
func TestSearchToolsRequiredFields(t *testing.T) {
tests := []struct {
name string
tool mcp.Tool
required []string
}{
{
name: "search_users",
tool: SearchUsersTool,
required: []string{"keyword"},
},
{
name: "search_org_teams",
tool: SearOrgTeamsTool,
required: []string{"org", "query"},
},
{
name: "search_repos",
tool: SearchReposTool,
required: []string{"keyword"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for _, field := range tt.required {
if !slices.Contains(tt.tool.InputSchema.Required, field) {
t.Errorf("tool %s: expected %q to be required, got required=%v", tt.name, field, tt.tool.InputSchema.Required)
}
}
})
}
}

88
operation/search/slim.go Normal file
View File

@@ -0,0 +1,88 @@
package search
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func slimUserDetail(u *gitea_sdk.User) map[string]any {
if u == nil {
return nil
}
return map[string]any{
"id": u.ID,
"login": u.UserName,
"full_name": u.FullName,
"email": u.Email,
"avatar_url": u.AvatarURL,
"html_url": u.HTMLURL,
"is_admin": u.IsAdmin,
}
}
func slimUserDetails(users []*gitea_sdk.User) []map[string]any {
out := make([]map[string]any, 0, len(users))
for _, u := range users {
out = append(out, slimUserDetail(u))
}
return out
}
func slimTeam(t *gitea_sdk.Team) map[string]any {
if t == nil {
return nil
}
return map[string]any{
"id": t.ID,
"name": t.Name,
"description": t.Description,
"permission": t.Permission,
}
}
func slimTeams(teams []*gitea_sdk.Team) []map[string]any {
out := make([]map[string]any, 0, len(teams))
for _, t := range teams {
out = append(out, slimTeam(t))
}
return out
}
func slimRepo(r *gitea_sdk.Repository) map[string]any {
if r == nil {
return nil
}
m := map[string]any{
"id": r.ID,
"full_name": r.FullName,
"description": r.Description,
"html_url": r.HTMLURL,
"clone_url": r.CloneURL,
"ssh_url": r.SSHURL,
"default_branch": r.DefaultBranch,
"private": r.Private,
"fork": r.Fork,
"archived": r.Archived,
"language": r.Language,
"stars_count": r.Stars,
"forks_count": r.Forks,
"open_issues_count": r.OpenIssues,
"open_pr_counter": r.OpenPulls,
"created_at": r.Created,
"updated_at": r.Updated,
}
if r.Owner != nil {
m["owner"] = r.Owner.UserName
}
if len(r.Topics) > 0 {
m["topics"] = r.Topics
}
return m
}
func slimRepos(repos []*gitea_sdk.Repository) []map[string]any {
out := make([]map[string]any, 0, len(repos))
for _, r := range repos {
out = append(out, slimRepo(r))
}
return out
}

View File

@@ -0,0 +1,47 @@
package timetracking
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func slimStopWatch(s *gitea_sdk.StopWatch) map[string]any {
if s == nil {
return nil
}
return map[string]any{
"issue_index": s.IssueIndex,
"issue_title": s.IssueTitle,
"repo_name": s.RepoName,
"repo_owner": s.RepoOwnerName,
"created": s.Created,
"seconds": s.Seconds,
}
}
func slimStopWatches(watches []*gitea_sdk.StopWatch) []map[string]any {
out := make([]map[string]any, 0, len(watches))
for _, s := range watches {
out = append(out, slimStopWatch(s))
}
return out
}
func slimTrackedTime(t *gitea_sdk.TrackedTime) map[string]any {
if t == nil {
return nil
}
return map[string]any{
"id": t.ID,
"time": t.Time,
"user_name": t.UserName,
"created": t.Created,
}
}
func slimTrackedTimes(times []*gitea_sdk.TrackedTime) []map[string]any {
out := make([]map[string]any, 0, len(times))
for _, t := range times {
out = append(out, slimTrackedTime(t))
}
return out
}

View File

@@ -0,0 +1,332 @@
// Package timetracking provides MCP tools for Gitea time tracking operations
package timetracking
import (
"context"
"fmt"
gitea_sdk "code.gitea.io/sdk/gitea"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
TimetrackingReadToolName = "timetracking_read"
TimetrackingWriteToolName = "timetracking_write"
)
var (
TimetrackingReadTool = mcp.NewTool(
TimetrackingReadToolName,
mcp.WithDescription("Read time tracking data. Use method 'list_issue_times' for issue times, 'list_repo_times' for repository times, 'get_my_stopwatches' for active stopwatches, 'get_my_times' for all your tracked times."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list_issue_times", "list_repo_times", "get_my_stopwatches", "get_my_times")),
mcp.WithString("owner", mcp.Description("repository owner (required for 'list_issue_times', 'list_repo_times')")),
mcp.WithString("repo", mcp.Description("repository name (required for 'list_issue_times', 'list_repo_times')")),
mcp.WithNumber("index", mcp.Description("issue index (required for 'list_issue_times')")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
TimetrackingWriteTool = mcp.NewTool(
TimetrackingWriteToolName,
mcp.WithDescription("Manage time tracking: stopwatches and tracked time entries."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("start_stopwatch", "stop_stopwatch", "delete_stopwatch", "add_time", "delete_time")),
mcp.WithString("owner", mcp.Description("repository owner (required for all methods)")),
mcp.WithString("repo", mcp.Description("repository name (required for all methods)")),
mcp.WithNumber("index", mcp.Description("issue index (required for all methods)")),
mcp.WithNumber("time", mcp.Description("time to add in seconds (required for 'add_time')")),
mcp.WithNumber("id", mcp.Description("tracked time entry ID (required for 'delete_time')")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{Tool: TimetrackingReadTool, Handler: readFn})
Tool.RegisterWrite(server.ServerTool{Tool: TimetrackingWriteTool, Handler: writeFn})
}
func readFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "list_issue_times":
return listTrackedTimesFn(ctx, req)
case "list_repo_times":
return listRepoTimesFn(ctx, req)
case "get_my_stopwatches":
return getMyStopwatchesFn(ctx, req)
case "get_my_times":
return getMyTimesFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func writeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "start_stopwatch":
return startStopwatchFn(ctx, req)
case "stop_stopwatch":
return stopStopwatchFn(ctx, req)
case "delete_stopwatch":
return deleteStopwatchFn(ctx, req)
case "add_time":
return addTrackedTimeFn(ctx, req)
case "delete_time":
return deleteTrackedTimeFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
// Stopwatch handler functions
func startStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called startStopwatchFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
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))
}
_, err = client.StartIssueStopWatch(owner, repo, index)
if err != nil {
return to.ErrorResult(fmt.Errorf("start stopwatch on %s/%s#%d err: %v", owner, repo, index, err))
}
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) {
log.Debugf("Called stopStopwatchFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
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))
}
_, err = client.StopIssueStopWatch(owner, repo, index)
if err != nil {
return to.ErrorResult(fmt.Errorf("stop stopwatch on %s/%s#%d err: %v", owner, repo, index, err))
}
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) {
log.Debugf("Called deleteStopwatchFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
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))
}
_, err = client.DeleteIssueStopwatch(owner, repo, index)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete stopwatch on %s/%s#%d err: %v", owner, repo, index, err))
}
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) {
log.Debugf("Called getMyStopwatchesFn")
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
stopwatches, _, err := client.ListMyStopwatches(gitea_sdk.ListStopwatchesOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("get stopwatches err: %v", err))
}
if len(stopwatches) == 0 {
return to.TextResult("No active stopwatches")
}
return to.TextResult(slimStopWatches(stopwatches))
}
// Tracked time handler functions
func listTrackedTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listTrackedTimesFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
times, _, err := client.ListIssueTrackedTimes(owner, repo, index, gitea_sdk.ListTrackedTimesOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list tracked times for %s/%s#%d err: %v", owner, repo, index, err))
}
if len(times) == 0 {
return to.TextResult(fmt.Sprintf("No tracked times for issue %s/%s#%d", owner, repo, index))
}
return to.TextResult(slimTrackedTimes(times))
}
func addTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called addTrackedTimeFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil {
return to.ErrorResult(err)
}
timeSeconds, err := params.GetIndex(req.GetArguments(), "time")
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))
}
trackedTime, _, err := client.AddTime(owner, repo, index, gitea_sdk.AddTimeOption{
Time: timeSeconds,
})
if err != nil {
return to.ErrorResult(fmt.Errorf("add tracked time to %s/%s#%d err: %v", owner, repo, index, err))
}
return to.TextResult(slimTrackedTime(trackedTime))
}
func deleteTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called deleteTrackedTimeFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
index, err := params.GetIndex(req.GetArguments(), "index")
if err != nil {
return to.ErrorResult(err)
}
id, err := params.GetIndex(req.GetArguments(), "id")
if err != nil {
return to.ErrorResult(err)
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
_, err = client.DeleteTime(owner, repo, index, id)
if err != nil {
return to.ErrorResult(fmt.Errorf("delete tracked time %d from %s/%s#%d err: %v", id, owner, repo, index, err))
}
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) {
log.Debugf("Called listRepoTimesFn")
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
times, _, err := client.ListRepoTrackedTimes(owner, repo, gitea_sdk.ListTrackedTimesOptions{
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
})
if err != nil {
return to.ErrorResult(fmt.Errorf("list repo tracked times for %s/%s err: %v", owner, repo, err))
}
if len(times) == 0 {
return to.TextResult(fmt.Sprintf("No tracked times for repository %s/%s", owner, repo))
}
return to.TextResult(slimTrackedTimes(times))
}
func getMyTimesFn(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called getMyTimesFn")
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
times, _, err := client.ListMyTrackedTimes(gitea_sdk.ListTrackedTimesOptions{})
if err != nil {
return to.ErrorResult(fmt.Errorf("get tracked times err: %v", err))
}
if len(times) == 0 {
return to.TextResult("No tracked times found")
}
return to.TextResult(slimTrackedTimes(times))
}

42
operation/user/slim.go Normal file
View File

@@ -0,0 +1,42 @@
package user
import (
gitea_sdk "code.gitea.io/sdk/gitea"
)
func slimUserDetail(u *gitea_sdk.User) map[string]any {
if u == nil {
return nil
}
return map[string]any{
"id": u.ID,
"login": u.UserName,
"full_name": u.FullName,
"email": u.Email,
"avatar_url": u.AvatarURL,
"html_url": u.HTMLURL,
"is_admin": u.IsAdmin,
}
}
func slimOrg(o *gitea_sdk.Organization) map[string]any {
if o == nil {
return nil
}
return map[string]any{
"id": o.ID,
"name": o.Name,
"full_name": o.FullName,
"description": o.Description,
"avatar_url": o.AvatarURL,
"website": o.Website,
}
}
func slimOrgs(orgs []*gitea_sdk.Organization) []map[string]any {
out := make([]map[string]any, 0, len(orgs))
for _, o := range orgs {
out = append(out, slimOrg(o))
}
return out
}

View File

@@ -0,0 +1,39 @@
package user
import (
"testing"
gitea_sdk "code.gitea.io/sdk/gitea"
)
func TestSlimUserDetail(t *testing.T) {
u := &gitea_sdk.User{
ID: 42,
UserName: "alice",
FullName: "Alice Smith",
Email: "alice@example.com",
AvatarURL: "https://gitea.com/avatars/42",
HTMLURL: "https://gitea.com/alice",
IsAdmin: true,
}
m := slimUserDetail(u)
if m["id"] != int64(42) {
t.Errorf("expected id 42, got %v", m["id"])
}
if m["login"] != "alice" {
t.Errorf("expected login alice, got %v", m["login"])
}
if m["full_name"] != "Alice Smith" {
t.Errorf("expected full_name Alice Smith, got %v", m["full_name"])
}
if m["is_admin"] != true {
t.Errorf("expected is_admin true, got %v", m["is_admin"])
}
}
func TestSlimUserDetail_Nil(t *testing.T) {
if m := slimUserDetail(nil); m != nil {
t.Errorf("expected nil for nil user, got %v", m)
}
}

View File

@@ -6,6 +6,7 @@ import (
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
@@ -15,15 +16,15 @@ import (
) )
const ( const (
// GetMyUserInfoToolName is the unique tool name used for MCP registration and lookup of the get_my_user_info command. // GetMyUserInfoToolName is the unique tool name used for MCP registration and lookup of the get_me command.
GetMyUserInfoToolName = "get_my_user_info" GetMyUserInfoToolName = "get_me"
// GetUserOrgsToolName is the unique tool name used for MCP registration and lookup of the get_user_orgs command. // GetUserOrgsToolName is the unique tool name used for MCP registration and lookup of the get_user_orgs command.
GetUserOrgsToolName = "get_user_orgs" GetUserOrgsToolName = "get_user_orgs"
// defaultPage is the default starting page number used for paginated organization listings. // defaultPage is the default starting page number used for paginated organization listings.
defaultPage = 1 defaultPage = 1
// defaultPageSize is the default number of organizations per page for paginated queries. // defaultPageSize is the default number of organizations per page for paginated queries.
defaultPageSize = 100 defaultPageSize = 30
) )
// Tool is the MCP tool manager instance for registering all MCP tools in this package. // Tool is the MCP tool manager instance for registering all MCP tools in this package.
@@ -38,12 +39,12 @@ var (
) )
// GetUserOrgsTool is the MCP tool for listing organizations for the authenticated user. // GetUserOrgsTool is the MCP tool for listing organizations for the authenticated user.
// It supports pagination via "page" and "pageSize" arguments with default values specified above. // It supports pagination via "page" and "perPage" arguments with default values specified above.
GetUserOrgsTool = mcp.NewTool( GetUserOrgsTool = mcp.NewTool(
GetUserOrgsToolName, GetUserOrgsToolName,
mcp.WithDescription("Get organizations associated with the authenticated user"), mcp.WithDescription("Get organizations associated with the authenticated user"),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(defaultPage)), mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(defaultPage)),
mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(defaultPageSize)), mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(defaultPageSize)),
) )
) )
@@ -65,17 +66,7 @@ func registerTools() {
} }
} }
// getIntArg parses an integer argument from the MCP request arguments map. // GetUserInfoFn is the handler for "get_me" MCP tool requests.
// Returns def if missing, not a number, or less than 1. Used for pagination arguments.
func getIntArg(req mcp.CallToolRequest, name string, def int) int {
val, ok := req.GetArguments()[name].(float64)
if !ok || val < 1 {
return def
}
return int(val)
}
// GetUserInfoFn is the handler for "get_my_user_info" MCP tool requests.
// Logs invocation, fetches current user info from gitea, wraps result for MCP. // Logs invocation, fetches current user info from gitea, wraps result for MCP.
func GetUserInfoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetUserInfoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[User] Called GetUserInfoFn") log.Debugf("[User] Called GetUserInfoFn")
@@ -87,7 +78,7 @@ func GetUserInfoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get user info err: %v", err)) return to.ErrorResult(fmt.Errorf("get user info err: %v", err))
} }
return to.TextResult(user) return to.TextResult(slimUserDetail(user))
} }
// GetUserOrgsFn is the handler for "get_user_orgs" MCP tool requests. // GetUserOrgsFn is the handler for "get_user_orgs" MCP tool requests.
@@ -95,8 +86,7 @@ func GetUserInfoFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR
// performs Gitea organization listing, and wraps the result for MCP. // performs Gitea organization listing, and wraps the result for MCP.
func GetUserOrgsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func GetUserOrgsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("[User] Called GetUserOrgsFn") log.Debugf("[User] Called GetUserOrgsFn")
page := getIntArg(req, "page", defaultPage) page, pageSize := params.GetPagination(req.GetArguments(), defaultPageSize)
pageSize := getIntArg(req, "pageSize", defaultPageSize)
opt := gitea_sdk.ListOrgsOptions{ opt := gitea_sdk.ListOrgsOptions{
ListOptions: gitea_sdk.ListOptions{ ListOptions: gitea_sdk.ListOptions{
@@ -112,5 +102,5 @@ func GetUserOrgsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get user orgs err: %v", err)) return to.ErrorResult(fmt.Errorf("get user orgs err: %v", err))
} }
return to.TextResult(orgs) return to.TextResult(slimOrgs(orgs))
} }

View File

@@ -2,16 +2,12 @@ package wiki
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"io"
"net/http"
"net/url" "net/url"
"strings"
"gitea.com/gitea/gitea-mcp/pkg/flag"
"gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to" "gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool" "gitea.com/gitea/gitea-mcp/pkg/tool"
@@ -22,113 +18,92 @@ import (
var Tool = tool.New() var Tool = tool.New()
const ( const (
ListWikiPagesToolName = "list_wiki_pages" WikiReadToolName = "wiki_read"
GetWikiPageToolName = "get_wiki_page" WikiWriteToolName = "wiki_write"
GetWikiRevisionsToolName = "get_wiki_revisions"
CreateWikiPageToolName = "create_wiki_page"
UpdateWikiPageToolName = "update_wiki_page"
DeleteWikiPageToolName = "delete_wiki_page"
) )
var ( var (
ListWikiPagesTool = mcp.NewTool( WikiReadTool = mcp.NewTool(
ListWikiPagesToolName, WikiReadToolName,
mcp.WithDescription("List all wiki pages in a repository"), mcp.WithDescription("Read wiki page information. Use method 'list' to list pages, 'get' to get page content, 'get_revisions' for revision history."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list", "get", "get_revisions")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("pageName", mcp.Description("wiki page name (required for 'get', 'get_revisions')")),
) )
GetWikiPageTool = mcp.NewTool( WikiWriteTool = mcp.NewTool(
GetWikiPageToolName, WikiWriteToolName,
mcp.WithDescription("Get a wiki page content and metadata"), mcp.WithDescription("Create, update, or delete wiki pages."),
mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("create", "update", "delete")),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("pageName", mcp.Required(), mcp.Description("wiki page name")), mcp.WithString("pageName", mcp.Description("wiki page name (required for 'update', 'delete')")),
) mcp.WithString("title", mcp.Description("wiki page title (required for 'create', optional for 'update')")),
mcp.WithString("content_base64", mcp.Description("page content, base64 encoded (required for 'create', 'update')")),
GetWikiRevisionsTool = mcp.NewTool( mcp.WithString("message", mcp.Description("commit message")),
GetWikiRevisionsToolName,
mcp.WithDescription("Get revisions history of a wiki page"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("pageName", mcp.Required(), mcp.Description("wiki page name")),
)
CreateWikiPageTool = mcp.NewTool(
CreateWikiPageToolName,
mcp.WithDescription("Create a new wiki page"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("title", mcp.Required(), mcp.Description("wiki page title")),
mcp.WithString("content_base64", mcp.Required(), mcp.Description("page content, base64 encoded")),
mcp.WithString("message", mcp.Description("commit message (optional)")),
)
UpdateWikiPageTool = mcp.NewTool(
UpdateWikiPageToolName,
mcp.WithDescription("Update an existing wiki page"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("pageName", mcp.Required(), mcp.Description("current wiki page name")),
mcp.WithString("title", mcp.Description("new page title (optional)")),
mcp.WithString("content_base64", mcp.Required(), mcp.Description("page content, base64 encoded")),
mcp.WithString("message", mcp.Description("commit message (optional)")),
)
DeleteWikiPageTool = mcp.NewTool(
DeleteWikiPageToolName,
mcp.WithDescription("Delete a wiki page"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("pageName", mcp.Required(), mcp.Description("wiki page name to delete")),
) )
) )
func init() { func init() {
Tool.RegisterRead(server.ServerTool{ Tool.RegisterRead(server.ServerTool{
Tool: ListWikiPagesTool, Tool: WikiReadTool,
Handler: ListWikiPagesFn, Handler: wikiReadFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetWikiPageTool,
Handler: GetWikiPageFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetWikiRevisionsTool,
Handler: GetWikiRevisionsFn,
}) })
Tool.RegisterWrite(server.ServerTool{ Tool.RegisterWrite(server.ServerTool{
Tool: CreateWikiPageTool, Tool: WikiWriteTool,
Handler: CreateWikiPageFn, Handler: wikiWriteFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: UpdateWikiPageTool,
Handler: UpdateWikiPageFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: DeleteWikiPageTool,
Handler: DeleteWikiPageFn,
}) })
} }
func ListWikiPagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { func wikiReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListWikiPagesFn") method, err := params.GetString(req.GetArguments(), "method")
owner, ok := req.GetArguments()["owner"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
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(err)
}
switch method {
case "list":
return listWikiPagesFn(ctx, req)
case "get":
return getWikiPageFn(ctx, req)
case "get_revisions":
return getWikiRevisionsFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func wikiWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
method, err := params.GetString(req.GetArguments(), "method")
if err != nil {
return to.ErrorResult(err)
}
switch method {
case "create":
return createWikiPageFn(ctx, req)
case "update":
return updateWikiPageFn(ctx, req)
case "delete":
return deleteWikiPageFn(ctx, req)
default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
}
}
func listWikiPagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called listWikiPagesFn")
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)
} }
// Use direct HTTP request because SDK does not support yet wikis var result any
result, err := makeWikiAPIRequest(ctx, client, "GET", fmt.Sprintf("repos/%s/%s/wiki/pages", url.QueryEscape(owner), url.QueryEscape(repo)), nil) _, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/wiki/pages", url.PathEscape(owner), url.PathEscape(repo)), nil, nil, &result)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("list wiki pages err: %v", err)) return to.ErrorResult(fmt.Errorf("list wiki pages err: %v", err))
} }
@@ -136,27 +111,24 @@ 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, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetWikiPageFn") log.Debugf("Called getWikiPageFn")
owner, ok := req.GetArguments()["owner"].(string) args := req.GetArguments()
if !ok { owner, err := params.GetString(args, "owner")
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
pageName, ok := req.GetArguments()["pageName"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("pageName is required"))
}
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(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
pageName, err := params.GetString(args, "pageName")
if err != nil {
return to.ErrorResult(err)
} }
result, err := makeWikiAPIRequest(ctx, client, "GET", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.QueryEscape(owner), url.QueryEscape(repo), url.QueryEscape(pageName)), nil) var result any
_, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(pageName)), nil, nil, &result)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get wiki page err: %v", err)) return to.ErrorResult(fmt.Errorf("get wiki page err: %v", err))
} }
@@ -164,27 +136,24 @@ 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, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetWikiRevisionsFn") log.Debugf("Called getWikiRevisionsFn")
owner, ok := req.GetArguments()["owner"].(string) args := req.GetArguments()
if !ok { owner, err := params.GetString(args, "owner")
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
pageName, ok := req.GetArguments()["pageName"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("pageName is required"))
}
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(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
pageName, err := params.GetString(args, "pageName")
if err != nil {
return to.ErrorResult(err)
} }
result, err := makeWikiAPIRequest(ctx, client, "GET", fmt.Sprintf("repos/%s/%s/wiki/revisions/%s", url.QueryEscape(owner), url.QueryEscape(repo), url.QueryEscape(pageName)), nil) var result any
_, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/wiki/revisions/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(pageName)), nil, nil, &result)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("get wiki revisions err: %v", err)) return to.ErrorResult(fmt.Errorf("get wiki revisions err: %v", err))
} }
@@ -192,26 +161,27 @@ 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, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateWikiPageFn") log.Debugf("Called createWikiPageFn")
owner, ok := req.GetArguments()["owner"].(string) args := req.GetArguments()
if !ok { owner, err := params.GetString(args, "owner")
return to.ErrorResult(fmt.Errorf("owner is required")) if err != nil {
return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(args, "repo")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(err)
} }
title, ok := req.GetArguments()["title"].(string) title, err := params.GetString(args, "title")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("title is required")) return to.ErrorResult(err)
} }
contentBase64, ok := req.GetArguments()["content_base64"].(string) contentBase64, err := params.GetString(args, "content_base64")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("content_base64 is required")) return to.ErrorResult(err)
} }
message, _ := req.GetArguments()["message"].(string) message, _ := args["message"].(string)
if message == "" { if message == "" {
message = fmt.Sprintf("Create wiki page '%s'", title) message = fmt.Sprintf("Create wiki page '%s'", title)
} }
@@ -222,12 +192,8 @@ func CreateWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
"message": message, "message": message,
} }
client, err := gitea.ClientFromContext(ctx) var result any
if err != nil { _, err = gitea.DoJSON(ctx, "POST", fmt.Sprintf("repos/%s/%s/wiki/new", url.PathEscape(owner), url.PathEscape(repo)), nil, requestBody, &result)
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
result, err := makeWikiAPIRequest(ctx, client, "POST", fmt.Sprintf("repos/%s/%s/wiki/new", url.QueryEscape(owner), url.QueryEscape(repo)), requestBody)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("create wiki page err: %v", err)) return to.ErrorResult(fmt.Errorf("create wiki page err: %v", err))
} }
@@ -235,23 +201,24 @@ 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, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called UpdateWikiPageFn") log.Debugf("Called updateWikiPageFn")
owner, ok := req.GetArguments()["owner"].(string) args := req.GetArguments()
if !ok { owner, err := params.GetString(args, "owner")
return to.ErrorResult(fmt.Errorf("owner is required")) if err != nil {
return to.ErrorResult(err)
} }
repo, ok := req.GetArguments()["repo"].(string) repo, err := params.GetString(args, "repo")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("repo is required")) return to.ErrorResult(err)
} }
pageName, ok := req.GetArguments()["pageName"].(string) pageName, err := params.GetString(args, "pageName")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("pageName is required")) return to.ErrorResult(err)
} }
contentBase64, ok := req.GetArguments()["content_base64"].(string) contentBase64, err := params.GetString(args, "content_base64")
if !ok { if err != nil {
return to.ErrorResult(fmt.Errorf("content_base64 is required")) return to.ErrorResult(err)
} }
requestBody := map[string]string{ requestBody := map[string]string{
@@ -259,25 +226,20 @@ func UpdateWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
} }
// If title is given, use it. Otherwise, keep current page name // If title is given, use it. Otherwise, keep current page name
if title, ok := req.GetArguments()["title"].(string); ok && title != "" { if title, ok := args["title"].(string); ok && title != "" {
requestBody["title"] = title requestBody["title"] = title
} else { } else {
// Utiliser pageName comme fallback pour éviter "unnamed"
requestBody["title"] = pageName requestBody["title"] = pageName
} }
if message, ok := req.GetArguments()["message"].(string); ok && message != "" { if message, ok := args["message"].(string); ok && message != "" {
requestBody["message"] = message requestBody["message"] = message
} else { } else {
requestBody["message"] = fmt.Sprintf("Update wiki page '%s'", pageName) requestBody["message"] = fmt.Sprintf("Update wiki page '%s'", pageName)
} }
client, err := gitea.ClientFromContext(ctx) var result any
if err != nil { _, err = gitea.DoJSON(ctx, "PATCH", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(pageName)), nil, requestBody, &result)
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
result, err := makeWikiAPIRequest(ctx, client, "PATCH", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.QueryEscape(owner), url.QueryEscape(repo), url.QueryEscape(pageName)), requestBody)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("update wiki page err: %v", err)) return to.ErrorResult(fmt.Errorf("update wiki page err: %v", err))
} }
@@ -285,79 +247,26 @@ 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, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteWikiPageFn") log.Debugf("Called deleteWikiPageFn")
owner, ok := req.GetArguments()["owner"].(string) args := req.GetArguments()
if !ok { owner, err := params.GetString(args, "owner")
return to.ErrorResult(fmt.Errorf("owner is required"))
}
repo, ok := req.GetArguments()["repo"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("repo is required"))
}
pageName, ok := req.GetArguments()["pageName"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("pageName is required"))
}
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(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
pageName, err := params.GetString(args, "pageName")
if err != nil {
return to.ErrorResult(err)
} }
_, err = makeWikiAPIRequest(ctx, client, "DELETE", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.QueryEscape(owner), url.QueryEscape(repo), url.QueryEscape(pageName)), nil) _, err = gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(pageName)), nil, nil, nil)
if err != nil { if err != nil {
return to.ErrorResult(fmt.Errorf("delete wiki page err: %v", err)) return to.ErrorResult(fmt.Errorf("delete wiki page err: %v", err))
} }
return to.TextResult(map[string]string{"message": "Wiki page deleted successfully"}) return to.TextResult(map[string]string{"message": "Wiki page deleted successfully"})
} }
// Helper function to make HTTP requests to Gitea Wiki API
func makeWikiAPIRequest(ctx context.Context, client interface{}, method, path string, body interface{}) (interface{}, error) {
// Use flags to get base URL and token
apiURL := fmt.Sprintf("%s/api/v1/%s", flag.Host, path)
httpClient := &http.Client{}
var reqBody io.Reader
if body != nil {
bodyBytes, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
reqBody = strings.NewReader(string(bodyBytes))
}
req, err := http.NewRequestWithContext(ctx, method, apiURL, reqBody)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", fmt.Sprintf("token %s", flag.Token))
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to make request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("API request failed with status %d", resp.StatusCode)
}
if method == "DELETE" {
return map[string]string{"message": "success"}, nil
}
var result interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return result, nil
}

View File

@@ -3,6 +3,7 @@ package gitea
import ( import (
"context" "context"
"crypto/tls" "crypto/tls"
"errors"
"fmt" "fmt"
"net/http" "net/http"
@@ -13,7 +14,8 @@ import (
func NewClient(token string) (*gitea.Client, error) { func NewClient(token string) (*gitea.Client, error) {
httpClient := &http.Client{ httpClient := &http.Client{
Transport: http.DefaultTransport, Transport: http.DefaultTransport,
CheckRedirect: checkRedirect,
} }
opts := []gitea.ClientOption{ opts := []gitea.ClientOption{
@@ -34,10 +36,23 @@ func NewClient(token string) (*gitea.Client, error) {
} }
// Set user agent for the client // Set user agent for the client
client.SetUserAgent(fmt.Sprintf("gitea-mcp-server/%s", flag.Version)) client.SetUserAgent("gitea-mcp-server/" + flag.Version)
return client, nil return client, nil
} }
// checkRedirect prevents Go from silently changing mutating requests (POST, PATCH, etc.)
// to GET when following 301/302/303 redirects, which would drop the request body and
// make writes appear to succeed when they didn't.
func checkRedirect(_ *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
}
if via[0].Method != http.MethodGet && via[0].Method != http.MethodHead {
return http.ErrUseLastResponse
}
return nil
}
func ClientFromContext(ctx context.Context) (*gitea.Client, error) { func ClientFromContext(ctx context.Context) (*gitea.Client, error) {
token, ok := ctx.Value(mcpContext.TokenContextKey).(string) token, ok := ctx.Value(mcpContext.TokenContextKey).(string)
if !ok { if !ok {

120
pkg/gitea/redirect_test.go Normal file
View File

@@ -0,0 +1,120 @@
package gitea
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"gitea.com/gitea/gitea-mcp/pkg/flag"
)
func TestCheckRedirect(t *testing.T) {
for _, tc := range []struct {
name string
method string
wantErr error
}{
{"allows GET", http.MethodGet, nil},
{"allows HEAD", http.MethodHead, nil},
{"blocks PATCH", http.MethodPatch, http.ErrUseLastResponse},
{"blocks POST", http.MethodPost, http.ErrUseLastResponse},
{"blocks PUT", http.MethodPut, http.ErrUseLastResponse},
{"blocks DELETE", http.MethodDelete, http.ErrUseLastResponse},
} {
t.Run(tc.name, func(t *testing.T) {
via := []*http.Request{{Method: tc.method}}
err := checkRedirect(nil, via)
if err != tc.wantErr {
t.Fatalf("expected %v, got %v", tc.wantErr, err)
}
})
}
t.Run("stops after 10 redirects", func(t *testing.T) {
via := make([]*http.Request, 10)
for i := range via {
via[i] = &http.Request{Method: http.MethodGet}
}
err := checkRedirect(nil, via)
if err == nil || err == http.ErrUseLastResponse {
t.Fatalf("expected redirect limit error, got %v", err)
}
})
}
// TestDoJSON_RepoRenameRedirect is a regression test for the bug where a PATCH
// request to a renamed repo got a 301 redirect, Go's http.Client silently
// changed the method to GET, and the write appeared to succeed without error.
func TestDoJSON_RepoRenameRedirect(t *testing.T) {
// Simulate a Gitea API that returns 301 for the old repo name (like a renamed repo).
mux := http.NewServeMux()
mux.HandleFunc("PATCH /api/v1/repos/owner/old-name/pulls/1", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/api/v1/repos/owner/new-name/pulls/1", http.StatusMovedPermanently)
})
mux.HandleFunc("PATCH /api/v1/repos/owner/new-name/pulls/1", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"id":1,"title":"updated"}`)
})
mux.HandleFunc("GET /api/v1/repos/owner/new-name/pulls/1", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"id":1,"title":"not-updated"}`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
origHost := flag.Host
defer func() { flag.Host = origHost }()
flag.Host = srv.URL
var result map[string]any
status, err := DoJSON(context.Background(), http.MethodPatch, "repos/owner/old-name/pulls/1", nil, map[string]string{"title": "updated"}, &result)
if err != nil {
// The redirect should be blocked, returning the 301 response directly.
// DoJSON treats non-2xx as an error, which is the correct behavior.
if status != http.StatusMovedPermanently {
t.Fatalf("expected status 301, got %d (err: %v)", status, err)
}
return
}
// If we reach here without error, the redirect was followed. Verify the
// method was preserved (title should be "updated", not "not-updated").
title, _ := result["title"].(string)
if title == "not-updated" {
t.Fatal("PATCH was silently converted to GET on 301 redirect — write was lost")
}
}
// TestDoJSON_GETRedirectFollowed verifies that GET requests still follow redirects normally.
func TestDoJSON_GETRedirectFollowed(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/repos/owner/old-name/pulls/1", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/api/v1/repos/owner/new-name/pulls/1", http.StatusMovedPermanently)
})
mux.HandleFunc("GET /api/v1/repos/owner/new-name/pulls/1", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{"id": 1, "title": "found"})
})
srv := httptest.NewServer(mux)
defer srv.Close()
origHost := flag.Host
defer func() { flag.Host = origHost }()
flag.Host = srv.URL
var result map[string]any
status, err := DoJSON(context.Background(), http.MethodGet, "repos/owner/old-name/pulls/1", nil, nil, &result)
if err != nil {
t.Fatalf("GET redirect should be followed, got error: %v (status %d)", err, status)
}
title, _ := result["title"].(string)
if title != "found" {
t.Fatalf("expected title 'found', got %q", title)
}
}

175
pkg/gitea/rest.go Normal file
View File

@@ -0,0 +1,175 @@
package gitea
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
"gitea.com/gitea/gitea-mcp/pkg/flag"
)
type HTTPError struct {
StatusCode int
Body string
}
func (e *HTTPError) Error() string {
if e.Body == "" {
return fmt.Sprintf("request failed with status %d", e.StatusCode)
}
return fmt.Sprintf("request failed with status %d: %s", e.StatusCode, e.Body)
}
func tokenFromContext(ctx context.Context) string {
if ctx != nil {
if token, ok := ctx.Value(mcpContext.TokenContextKey).(string); ok && token != "" {
return token
}
}
return flag.Token
}
func newRESTHTTPClient() *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
if flag.Insecure {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // user-requested insecure mode
}
return &http.Client{
Transport: transport,
Timeout: 60 * time.Second,
CheckRedirect: checkRedirect,
}
}
func buildAPIURL(path string, query url.Values) (string, error) {
host := strings.TrimRight(flag.Host, "/")
if host == "" {
return "", errors.New("gitea host is empty")
}
p := strings.TrimLeft(path, "/")
u, err := url.Parse(fmt.Sprintf("%s/api/v1/%s", host, p))
if err != nil {
return "", err
}
if query != nil {
u.RawQuery = query.Encode()
}
return u.String(), nil
}
// DoJSON performs an API request and decodes a JSON response into respOut (if non-nil).
// It returns the HTTP status code.
func DoJSON(ctx context.Context, method, path string, query url.Values, body, respOut any) (int, error) {
var bodyReader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return 0, fmt.Errorf("marshal request body: %w", err)
}
bodyReader = bytes.NewReader(b)
}
u, err := buildAPIURL(path, query)
if err != nil {
return 0, err
}
req, err := http.NewRequestWithContext(ctx, method, u, bodyReader)
if err != nil {
return 0, fmt.Errorf("create request: %w", err)
}
token := tokenFromContext(ctx)
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
client := newRESTHTTPClient()
resp, err := client.Do(req)
if err != nil {
return 0, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodySnippet, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
return resp.StatusCode, &HTTPError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(bodySnippet))}
}
if respOut == nil {
_, _ = io.Copy(io.Discard, resp.Body) // best-effort
return resp.StatusCode, nil
}
if err := json.NewDecoder(resp.Body).Decode(respOut); err != nil {
return resp.StatusCode, fmt.Errorf("decode response: %w", err)
}
return resp.StatusCode, nil
}
// DoBytes performs an API request and returns the raw response bytes.
// It returns the HTTP status code.
func DoBytes(ctx context.Context, method, path string, query url.Values, body any, accept string) ([]byte, int, error) {
var bodyReader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, 0, fmt.Errorf("marshal request body: %w", err)
}
bodyReader = bytes.NewReader(b)
}
u, err := buildAPIURL(path, query)
if err != nil {
return nil, 0, err
}
req, err := http.NewRequestWithContext(ctx, method, u, bodyReader)
if err != nil {
return nil, 0, fmt.Errorf("create request: %w", err)
}
token := tokenFromContext(ctx)
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
if accept != "" {
req.Header.Set("Accept", accept)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
client := newRESTHTTPClient()
resp, err := client.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodySnippet := respBytes
if len(bodySnippet) > 8192 {
bodySnippet = bodySnippet[:8192]
}
return nil, resp.StatusCode, &HTTPError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(bodySnippet))}
}
return respBytes, resp.StatusCode, nil
}

30
pkg/gitea/rest_test.go Normal file
View File

@@ -0,0 +1,30 @@
package gitea
import (
"context"
"testing"
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
"gitea.com/gitea/gitea-mcp/pkg/flag"
)
func TestTokenFromContext(t *testing.T) {
orig := flag.Token
defer func() { flag.Token = orig }()
flag.Token = "flag-token"
t.Run("context token wins", func(t *testing.T) {
ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "ctx-token")
if got := tokenFromContext(ctx); got != "ctx-token" {
t.Fatalf("tokenFromContext() = %q, want %q", got, "ctx-token")
}
})
t.Run("fallback to flag token", func(t *testing.T) {
ctx := context.Background()
if got := tokenFromContext(ctx); got != "flag-token" {
t.Fatalf("tokenFromContext() = %q, want %q", got, "flag-token")
}
})
}

View File

@@ -1,7 +1,6 @@
package log package log
import ( import (
"fmt"
"os" "os"
"sync" "sync"
"time" "time"
@@ -35,14 +34,14 @@ func Default() *zap.Logger {
home = os.TempDir() home = os.TempDir()
} }
logDir := fmt.Sprintf("%s/.gitea-mcp", home) logDir := home + "/.gitea-mcp"
if err := os.MkdirAll(logDir, 0o700); err != nil { if err := os.MkdirAll(logDir, 0o700); err != nil {
// Fallback to temp directory if creation fails // Fallback to temp directory if creation fails
logDir = os.TempDir() logDir = os.TempDir()
} }
wss = append(wss, zapcore.AddSync(&lumberjack.Logger{ wss = append(wss, zapcore.AddSync(&lumberjack.Logger{
Filename: fmt.Sprintf("%s/gitea-mcp.log", logDir), Filename: logDir + "/gitea-mcp.log",
MaxSize: 100, MaxSize: 100,
MaxBackups: 10, MaxBackups: 10,
MaxAge: 30, MaxAge: 30,

116
pkg/params/params.go Normal file
View File

@@ -0,0 +1,116 @@
package params
import (
"fmt"
"strconv"
)
// GetString extracts a required string parameter from MCP tool arguments.
func GetString(args map[string]any, key string) (string, error) {
val, ok := args[key].(string)
if !ok {
return "", fmt.Errorf("%s is required", key)
}
return val, nil
}
// GetOptionalString extracts an optional string parameter with a default value.
func GetOptionalString(args map[string]any, key, defaultVal string) string {
if val, ok := args[key].(string); ok {
return val
}
return defaultVal
}
// GetStringSlice extracts an optional string slice parameter from MCP tool arguments.
func GetStringSlice(args map[string]any, key string) []string {
val, ok := args[key]
if !ok {
return nil
}
sliceVal, ok := val.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(sliceVal))
for _, item := range sliceVal {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
}
// GetPagination extracts page and perPage parameters, returning them as ints.
func GetPagination(args map[string]any, defaultPageSize int64) (page, pageSize int) {
return int(GetOptionalInt(args, "page", 1)), int(GetOptionalInt(args, "perPage", defaultPageSize))
}
// ToInt64 converts a value to int64, accepting both float64 (JSON number) and
// string representations. Returns false if the value cannot be converted.
func ToInt64(val any) (int64, bool) {
switch v := val.(type) {
case float64:
return int64(v), true
case string:
i, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return 0, false
}
return i, true
default:
return 0, false
}
}
// GetIndex extracts a required integer parameter from MCP tool arguments.
// It accepts both numeric (float64 from JSON) and string representations.
// This provides better UX for LLM callers that may naturally use strings
// for identifiers like issue/PR numbers.
func GetIndex(args map[string]any, key string) (int64, error) {
val, exists := args[key]
if !exists {
return 0, fmt.Errorf("%s is required", key)
}
if i, ok := ToInt64(val); ok {
return i, nil
}
if s, ok := val.(string); ok {
return 0, fmt.Errorf("%s must be a valid integer (got %q)", key, s)
}
return 0, fmt.Errorf("%s must be a number or numeric string", key)
}
// GetInt64Slice extracts a required int64 slice parameter from MCP tool arguments.
func GetInt64Slice(args map[string]any, key string) ([]int64, error) {
raw, ok := args[key].([]any)
if !ok {
return nil, fmt.Errorf("%s (array of IDs) is required", key)
}
out := make([]int64, 0, len(raw))
for _, v := range raw {
id, ok := ToInt64(v)
if !ok {
return nil, fmt.Errorf("invalid ID in %s array", key)
}
out = append(out, id)
}
return out, nil
}
// GetOptionalInt extracts an optional integer parameter from MCP tool arguments.
// Returns defaultVal if the key is missing or the value cannot be parsed.
// Accepts both float64 (JSON number) and string representations.
func GetOptionalInt(args map[string]any, key string, defaultVal int64) int64 {
val, exists := args[key]
if !exists {
return defaultVal
}
if i, ok := ToInt64(val); ok {
return i
}
return defaultVal
}

161
pkg/params/params_test.go Normal file
View File

@@ -0,0 +1,161 @@
package params
import (
"strings"
"testing"
)
func TestToInt64(t *testing.T) {
tests := []struct {
name string
val any
want int64
ok bool
}{
{"float64", float64(42), 42, true},
{"float64 zero", float64(0), 0, true},
{"float64 negative", float64(-5), -5, true},
{"string", "123", 123, true},
{"string zero", "0", 0, true},
{"string negative", "-10", -10, true},
{"invalid string", "abc", 0, false},
{"decimal string", "1.5", 0, false},
{"bool", true, 0, false},
{"nil", nil, 0, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := ToInt64(tt.val)
if ok != tt.ok {
t.Errorf("ToInt64() ok = %v, want %v", ok, tt.ok)
}
if got != tt.want {
t.Errorf("ToInt64() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetOptionalInt(t *testing.T) {
tests := []struct {
name string
args map[string]any
key string
defaultVal int64
want int64
}{
{"present float64", map[string]any{"page": float64(3)}, "page", 1, 3},
{"present string", map[string]any{"page": "5"}, "page", 1, 5},
{"missing key", map[string]any{}, "page", 1, 1},
{"invalid string", map[string]any{"page": "abc"}, "page", 1, 1},
{"invalid type", map[string]any{"page": true}, "page", 1, 1},
{"zero value", map[string]any{"id": float64(0)}, "id", 99, 0},
{"string zero", map[string]any{"id": "0"}, "id", 99, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := GetOptionalInt(tt.args, tt.key, tt.defaultVal)
if got != tt.want {
t.Errorf("GetOptionalInt() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetIndex(t *testing.T) {
tests := []struct {
name string
args map[string]any
key string
wantIndex int64
wantErr bool
errMsg string
}{
{
name: "valid float64",
args: map[string]any{"index": float64(123)},
key: "index",
wantIndex: 123,
wantErr: false,
},
{
name: "valid string",
args: map[string]any{"index": "456"},
key: "index",
wantIndex: 456,
wantErr: false,
},
{
name: "valid string with large number",
args: map[string]any{"index": "999999"},
key: "index",
wantIndex: 999999,
wantErr: false,
},
{
name: "missing parameter",
args: map[string]any{},
key: "index",
wantErr: true,
errMsg: "index is required",
},
{
name: "invalid string (not a number)",
args: map[string]any{"index": "abc"},
key: "index",
wantErr: true,
errMsg: "must be a valid integer",
},
{
name: "invalid string (decimal)",
args: map[string]any{"index": "12.34"},
key: "index",
wantErr: true,
errMsg: "must be a valid integer",
},
{
name: "invalid type (bool)",
args: map[string]any{"index": true},
key: "index",
wantErr: true,
errMsg: "must be a number or numeric string",
},
{
name: "invalid type (map)",
args: map[string]any{"index": map[string]string{"foo": "bar"}},
key: "index",
wantErr: true,
errMsg: "must be a number or numeric string",
},
{
name: "custom key name",
args: map[string]any{"pr_index": "789"},
key: "pr_index",
wantIndex: 789,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotIndex, err := GetIndex(tt.args, tt.key)
if tt.wantErr {
if err == nil {
t.Errorf("GetIndex() expected error but got nil")
return
}
if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("GetIndex() error = %v, want error containing %q", err, tt.errMsg)
}
return
}
if err != nil {
t.Errorf("GetIndex() unexpected error = %v", err)
return
}
if gotIndex != tt.wantIndex {
t.Errorf("GetIndex() = %v, want %v", gotIndex, tt.wantIndex)
}
})
}
}

View File

@@ -1,73 +0,0 @@
/*
Copyright 2023 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package ptr
import (
"fmt"
"reflect"
)
// AllPtrFieldsNil tests whether all pointer fields in a struct are nil. This is useful when,
// for example, an API struct is handled by plugins which need to distinguish
// "no plugin accepted this spec" from "this spec is empty".
//
// This function is only valid for structs and pointers to structs. Any other
// type will cause a panic. Passing a typed nil pointer will return true.
func AllPtrFieldsNil(obj interface{}) bool {
v := reflect.ValueOf(obj)
if !v.IsValid() {
panic(fmt.Sprintf("reflect.ValueOf() produced a non-valid Value for %#v", obj))
}
if v.Kind() == reflect.Ptr {
if v.IsNil() {
return true
}
v = v.Elem()
}
for i := 0; i < v.NumField(); i++ {
if v.Field(i).Kind() == reflect.Ptr && !v.Field(i).IsNil() {
return false
}
}
return true
}
// To returns a pointer to the given value.
func To[T any](v T) *T {
return &v
}
// Deref dereferences ptr and returns the value it points to if no nil, or else
// returns def.
func Deref[T any](ptr *T, def T) T {
if ptr != nil {
return *ptr
}
return def
}
// Equal returns true if both arguments are nil or both arguments
// dereference to the same value.
func Equal[T comparable](a, b *T) bool {
if (a == nil) != (b == nil) {
return false
}
if a == nil {
return true
}
return *a == *b
}

View File

@@ -8,13 +8,8 @@ import (
"github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/mcp"
) )
type textResult struct {
Result any
}
func TextResult(v any) (*mcp.CallToolResult, error) { func TextResult(v any) (*mcp.CallToolResult, error) {
result := textResult{v} resultBytes, err := json.Marshal(v)
resultBytes, err := json.Marshal(result)
if err != nil { if err != nil {
return nil, fmt.Errorf("marshal result err: %v", err) return nil, fmt.Errorf("marshal result err: %v", err)
} }