Add get_commit, get_repository_tree, and search_issues tools (#162)

Add three new read-only tools inspired by the GitHub MCP server:

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

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/162
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
This commit is contained in:
silverwind
2026-03-24 17:12:58 +00:00
committed by silverwind
parent c8004e9198
commit 9056a5ef27
7 changed files with 359 additions and 9 deletions

View File

@@ -3,6 +3,7 @@ package search
import (
"context"
"fmt"
"strings"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
@@ -21,6 +22,7 @@ const (
SearchUsersToolName = "search_users"
SearchOrgTeamsToolName = "search_org_teams"
SearchReposToolName = "search_repos"
SearchIssuesToolName = "search_issues"
)
var (
@@ -56,6 +58,18 @@ var (
mcp.WithNumber("page", mcp.Description("Page"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
SearchIssuesTool = mcp.NewTool(
SearchIssuesToolName,
mcp.WithDescription("Search for issues and pull requests across all accessible repositories"),
mcp.WithString("query", mcp.Required(), mcp.Description("search keyword")),
mcp.WithString("state", mcp.Description("filter by state: open, closed, all"), mcp.Enum("open", "closed", "all")),
mcp.WithString("type", mcp.Description("filter by type: issues, pulls"), mcp.Enum("issues", "pulls")),
mcp.WithString("labels", mcp.Description("comma-separated list of label names")),
mcp.WithString("owner", mcp.Description("filter by repository owner")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)),
)
)
func init() {
@@ -71,6 +85,10 @@ func init() {
Tool: SearchReposTool,
Handler: ReposFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: SearchIssuesTool,
Handler: IssuesFn,
})
}
func UsersFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -175,3 +193,42 @@ func ReposFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult,
}
return to.TextResult(slimRepos(repos))
}
func IssuesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called IssuesFn")
args := req.GetArguments()
query, err := params.GetString(args, "query")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(args, 30)
opt := gitea_sdk.ListIssueOption{
KeyWord: query,
ListOptions: gitea_sdk.ListOptions{
Page: page,
PageSize: pageSize,
},
}
if state, ok := args["state"].(string); ok {
opt.State = gitea_sdk.StateType(state)
}
if issueType, ok := args["type"].(string); ok {
opt.Type = gitea_sdk.IssueType(issueType)
}
if labels, ok := args["labels"].(string); ok && labels != "" {
opt.Labels = strings.Split(labels, ",")
}
if owner, ok := args["owner"].(string); ok {
opt.Owner = owner
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
issues, _, err := client.ListIssues(opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("search issues err: %v", err))
}
return to.TextResult(slimIssues(issues))
}

View File

@@ -86,3 +86,53 @@ func slimRepos(repos []*gitea_sdk.Repository) []map[string]any {
}
return out
}
func userLogin(u *gitea_sdk.User) string {
if u == nil {
return ""
}
return u.UserName
}
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 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)
}
if i.Repository != nil {
m["repository"] = i.Repository.FullName
}
if i.PullRequest != nil {
m["is_pull"] = true
}
out = append(out, m)
}
return out
}

View File

@@ -0,0 +1,54 @@
package search
import (
"slices"
"testing"
gitea_sdk "code.gitea.io/sdk/gitea"
)
func TestSlimIssues(t *testing.T) {
issues := []*gitea_sdk.Issue{
{
Index: 1,
Title: "Bug report",
State: gitea_sdk.StateOpen,
HTMLURL: "https://gitea.com/org/repo/issues/1",
Poster: &gitea_sdk.User{UserName: "alice"},
Labels: []*gitea_sdk.Label{{Name: "bug"}},
Repository: &gitea_sdk.RepositoryMeta{FullName: "org/repo"},
PullRequest: nil,
},
{
Index: 2,
Title: "Add feature",
State: gitea_sdk.StateOpen,
Poster: &gitea_sdk.User{UserName: "bob"},
Repository: &gitea_sdk.RepositoryMeta{FullName: "org/repo"},
PullRequest: &gitea_sdk.PullRequestMeta{},
},
}
result := slimIssues(issues)
if len(result) != 2 {
t.Fatalf("expected 2 issues, got %d", len(result))
}
if result[0]["repository"] != "org/repo" {
t.Errorf("expected repository org/repo, got %v", result[0]["repository"])
}
if result[0]["labels"].([]string)[0] != "bug" {
t.Errorf("expected label bug, got %v", result[0]["labels"])
}
if _, ok := result[0]["is_pull"]; ok {
t.Error("issue should not have is_pull")
}
if result[1]["is_pull"] != true {
t.Error("PR should have is_pull=true")
}
}
func TestSearchIssuesToolRequired(t *testing.T) {
if !slices.Contains(SearchIssuesTool.InputSchema.Required, "query") {
t.Error("search_issues should require query")
}
}