From 9056a5ef27a06d0a22fc888da81e1807b700b0e7 Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 24 Mar 2026 17:12:58 +0000 Subject: [PATCH] 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 Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/162 Reviewed-by: Lunny Xiao Co-authored-by: silverwind Co-committed-by: silverwind --- operation/repo/commit.go | 59 +++++++++++++++++++++++----- operation/repo/slim.go | 22 +++++++++++ operation/repo/tree.go | 74 +++++++++++++++++++++++++++++++++++ operation/repo/tree_test.go | 52 ++++++++++++++++++++++++ operation/search/search.go | 57 +++++++++++++++++++++++++++ operation/search/slim.go | 50 +++++++++++++++++++++++ operation/search/slim_test.go | 54 +++++++++++++++++++++++++ 7 files changed, 359 insertions(+), 9 deletions(-) create mode 100644 operation/repo/tree.go create mode 100644 operation/repo/tree_test.go create mode 100644 operation/search/slim_test.go diff --git a/operation/repo/commit.go b/operation/repo/commit.go index 1169307..2a8d8e4 100644 --- a/operation/repo/commit.go +++ b/operation/repo/commit.go @@ -16,17 +16,28 @@ import ( const ( ListRepoCommitsToolName = "list_commits" + GetCommitToolName = "get_commit" ) -var ListRepoCommitsTool = mcp.NewTool( - ListRepoCommitsToolName, - mcp.WithDescription("List repository commits"), - mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), - mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), - 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.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)), +var ( + ListRepoCommitsTool = mcp.NewTool( + ListRepoCommitsToolName, + mcp.WithDescription("List repository commits"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + 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.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)), + ) + + GetCommitTool = mcp.NewTool( + GetCommitToolName, + mcp.WithDescription("Get details of a specific commit"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithString("sha", mcp.Required(), mcp.Description("commit SHA")), + ) ) func init() { @@ -34,6 +45,10 @@ func init() { Tool: ListRepoCommitsTool, Handler: ListRepoCommitsFn, }) + Tool.RegisterRead(server.ServerTool{ + Tool: GetCommitTool, + Handler: GetCommitFn, + }) } func ListRepoCommitsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -75,3 +90,29 @@ func ListRepoCommitsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallT } return to.TextResult(slimCommits(commits)) } + +func GetCommitFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called GetCommitFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + sha, err := params.GetString(args, "sha") + if err != nil { + return to.ErrorResult(err) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + commit, _, err := client.GetSingleCommit(owner, repo, sha) + if err != nil { + return to.ErrorResult(fmt.Errorf("get commit %v err: %v", sha, err)) + } + return to.TextResult(slimCommit(commit)) +} diff --git a/operation/repo/slim.go b/operation/repo/slim.go index 5f3b418..10482ee 100644 --- a/operation/repo/slim.go +++ b/operation/repo/slim.go @@ -184,6 +184,28 @@ func slimContents(c *gitea_sdk.ContentsResponse) map[string]any { return m } +func slimTree(t *gitea_sdk.GitTreeResponse) map[string]any { + if t == nil { + return nil + } + entries := make([]map[string]any, 0, len(t.Entries)) + for _, e := range t.Entries { + entries = append(entries, map[string]any{ + "path": e.Path, + "mode": e.Mode, + "type": e.Type, + "size": e.Size, + "sha": e.SHA, + }) + } + return map[string]any{ + "sha": t.SHA, + "truncated": t.Truncated, + "total_count": t.TotalCount, + "tree": entries, + } +} + func slimDirEntries(entries []*gitea_sdk.ContentsResponse) []map[string]any { out := make([]map[string]any, 0, len(entries)) for _, c := range entries { diff --git a/operation/repo/tree.go b/operation/repo/tree.go new file mode 100644 index 0000000..cf7bbef --- /dev/null +++ b/operation/repo/tree.go @@ -0,0 +1,74 @@ +package repo + +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_sdk "code.gitea.io/sdk/gitea" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const ( + GetRepoTreeToolName = "get_repository_tree" +) + +var GetRepoTreeTool = mcp.NewTool( + GetRepoTreeToolName, + mcp.WithDescription("Get the file tree of a repository"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithString("tree_sha", mcp.Required(), mcp.Description("SHA, branch name, or tag name")), + mcp.WithBoolean("recursive", mcp.Description("whether to get the tree recursively")), + mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)), + mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30)), +) + +func init() { + Tool.RegisterRead(server.ServerTool{ + Tool: GetRepoTreeTool, + Handler: GetRepoTreeFn, + }) +} + +func GetRepoTreeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called GetRepoTreeFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + repo, err := params.GetString(args, "repo") + if err != nil { + return to.ErrorResult(err) + } + treeSHA, err := params.GetString(args, "tree_sha") + if err != nil { + return to.ErrorResult(err) + } + recursive, _ := args["recursive"].(bool) + page, pageSize := params.GetPagination(args, 30) + + opt := gitea_sdk.ListTreeOptions{ + ListOptions: gitea_sdk.ListOptions{ + Page: page, + PageSize: pageSize, + }, + Ref: treeSHA, + Recursive: recursive, + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + tree, _, err := client.GetTrees(owner, repo, opt) + if err != nil { + return to.ErrorResult(fmt.Errorf("get repository tree err: %v", err)) + } + return to.TextResult(slimTree(tree)) +} diff --git a/operation/repo/tree_test.go b/operation/repo/tree_test.go new file mode 100644 index 0000000..16c147d --- /dev/null +++ b/operation/repo/tree_test.go @@ -0,0 +1,52 @@ +package repo + +import ( + "slices" + "testing" + + gitea_sdk "code.gitea.io/sdk/gitea" +) + +func TestSlimTree(t *testing.T) { + tree := &gitea_sdk.GitTreeResponse{ + SHA: "abc123", + TotalCount: 2, + Truncated: false, + Entries: []gitea_sdk.GitEntry{ + {Path: "src", Mode: "040000", Type: "tree", Size: 0, SHA: "def456"}, + {Path: "main.go", Mode: "100644", Type: "blob", Size: 42, SHA: "789abc"}, + }, + } + + m := slimTree(tree) + if m["sha"] != "abc123" { + t.Errorf("expected sha abc123, got %v", m["sha"]) + } + if m["total_count"] != 2 { + t.Errorf("expected total_count 2, got %v", m["total_count"]) + } + entries := m["tree"].([]map[string]any) + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + if entries[0]["path"] != "src" { + t.Errorf("expected first entry path src, got %v", entries[0]["path"]) + } + if entries[1]["type"] != "blob" { + t.Errorf("expected second entry type blob, got %v", entries[1]["type"]) + } +} + +func TestSlimTreeNil(t *testing.T) { + if m := slimTree(nil); m != nil { + t.Errorf("expected nil, got %v", m) + } +} + +func TestGetRepoTreeToolRequired(t *testing.T) { + for _, field := range []string{"owner", "repo", "tree_sha"} { + if !slices.Contains(GetRepoTreeTool.InputSchema.Required, field) { + t.Errorf("expected %q to be required", field) + } + } +} diff --git a/operation/search/search.go b/operation/search/search.go index b9c1427..1272ea8 100644 --- a/operation/search/search.go +++ b/operation/search/search.go @@ -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)) +} diff --git a/operation/search/slim.go b/operation/search/slim.go index a37a6b5..734f08a 100644 --- a/operation/search/slim.go +++ b/operation/search/slim.go @@ -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 +} diff --git a/operation/search/slim_test.go b/operation/search/slim_test.go new file mode 100644 index 0000000..adac63e --- /dev/null +++ b/operation/search/slim_test.go @@ -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") + } +}