Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1279,6 +1279,14 @@ The following sets of tools are available:
- `query`: Search query using GitHub's powerful code search syntax. Examples: 'content:Skill language:Java org:github', 'NOT is:archived language:Python OR language:go', 'repo:github/github-mcp-server'. Supports exact matching, language filters, path filters, and more. (string, required)
- `sort`: Sort field ('indexed' only) (string, optional)

- **search_commits** - Search commits
- **Required OAuth Scopes**: `repo`
- `order`: Sort order (string, optional)
- `page`: Page number for pagination (min 1) (number, optional)
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- `query`: Commit search query. Examples: 'repo:owner/repo fix bug', 'author:defunkt', 'committer-date:>2024-01-01'. Supports advanced search syntax. (string, required)
- `sort`: Sort field ('author-date' or 'committer-date') (string, optional)

- **search_repositories** - Search repositories
- **Required OAuth Scopes**: `repo`
- `minimal_output`: Return minimal repository information (default: true). When false, returns full GitHub API repository objects. (boolean, optional)
Expand Down
47 changes: 47 additions & 0 deletions pkg/github/__toolsnaps__/search_commits.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"annotations": {
"readOnlyHint": true,
"title": "Search commits"
},
"description": "Search for commits across GitHub repositories using specialized commit search syntax. Great for finding specific changes, authors, or messages.",
"inputSchema": {
"properties": {
"order": {
"description": "Sort order",
"enum": [
"asc",
"desc"
],
"type": "string"
},
"page": {
"description": "Page number for pagination (min 1)",
"minimum": 1,
"type": "number"
},
"perPage": {
"description": "Results per page for pagination (min 1, max 100)",
"maximum": 100,
"minimum": 1,
"type": "number"
},
"query": {
"description": "Commit search query. Examples: 'repo:owner/repo fix bug', 'author:defunkt', 'committer-date:\u003e2024-01-01'. Supports advanced search syntax.",
"type": "string"
},
"sort": {
"description": "Sort field ('author-date' or 'committer-date')",
"enum": [
"author-date",
"committer-date"
],
"type": "string"
}
},
"required": [
"query"
],
"type": "object"
},
"name": "search_commits"
}
1 change: 1 addition & 0 deletions pkg/github/helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ const (
GetSearchIssues = "GET /search/issues"
GetSearchUsers = "GET /search/users"
GetSearchRepositories = "GET /search/repositories"
GetSearchCommits = "GET /search/commits"

// Raw content endpoints (used for GitHub raw content API, not standard API)
// These are used with the raw content client that interacts with raw.githubusercontent.com
Expand Down
7 changes: 7 additions & 0 deletions pkg/github/minimal_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,13 @@ type MinimalIssueComment struct {
UpdatedAt string `json:"updated_at,omitempty"`
}

// MinimalSearchCommitsResult is the trimmed output type for commit search results.
type MinimalSearchCommitsResult struct {
TotalCount int `json:"total_count"`
IncompleteResults bool `json:"incomplete_results"`
Items []MinimalCommit `json:"items"`
}

// MinimalFileContentResponse is the trimmed output type for create/update/delete file responses.
type MinimalFileContentResponse struct {
Content *MinimalFileContent `json:"content,omitempty"`
Expand Down
156 changes: 156 additions & 0 deletions pkg/github/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"time"

ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/inventory"
Expand Down Expand Up @@ -430,3 +431,158 @@ func SearchOrgs(t translations.TranslationHelperFunc) inventory.ServerTool {
},
)
}

// SearchCommits creates a tool to search for commits across GitHub repositories.
func SearchCommits(t translations.TranslationHelperFunc) inventory.ServerTool {
schema := &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"query": {
Type: "string",
Description: "Commit search query. Examples: 'repo:owner/repo fix bug', 'author:defunkt', 'committer-date:>2024-01-01'. Supports advanced search syntax.",
},
"sort": {
Type: "string",
Description: "Sort field ('author-date' or 'committer-date')",
Enum: []any{"author-date", "committer-date"},
},
"order": {
Type: "string",
Description: "Sort order",
Enum: []any{"asc", "desc"},
},
},
Required: []string{"query"},
}
WithPagination(schema)

return NewTool(
ToolsetMetadataRepos,
mcp.Tool{
Name: "search_commits",
Description: t("TOOL_SEARCH_COMMITS_DESCRIPTION", "Search for commits across GitHub repositories using specialized commit search syntax. Great for finding specific changes, authors, or messages."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_SEARCH_COMMITS_USER_TITLE", "Search commits"),
ReadOnlyHint: true,
},
InputSchema: schema,
},
[]scopes.Scope{scopes.Repo},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
query, err := RequiredParam[string](args, "query")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
sort, err := OptionalParam[string](args, "sort")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
order, err := OptionalParam[string](args, "order")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
pagination, err := OptionalPaginationParams(args)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}

opts := &github.SearchOptions{
Sort: sort,
Order: order,
ListOptions: github.ListOptions{
Page: pagination.Page,
PerPage: pagination.PerPage,
},
}

client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
result, resp, err := client.Search.Commits(ctx, query, opts)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
fmt.Sprintf("failed to search commits with query '%s'", query),
resp,
err,
), nil, nil
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil
}
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to search commits", resp, body), nil, nil
}

minimalCommits := make([]MinimalCommit, 0, len(result.Commits))
for _, commit := range result.Commits {
minimalCommit := MinimalCommit{
SHA: commit.GetSHA(),
HTMLURL: commit.GetHTMLURL(),
Comment thread
Not-Dhananjay-Mishra marked this conversation as resolved.
Outdated
}

if commit.Commit != nil {
minimalCommit.Commit = &MinimalCommitInfo{
Message: commit.Commit.GetMessage(),
}

if commit.Commit.Author != nil {
minimalCommit.Commit.Author = &MinimalCommitAuthor{
Name: commit.Commit.Author.GetName(),
Email: commit.Commit.Author.GetEmail(),
}
if commit.Commit.Author.Date != nil {
minimalCommit.Commit.Author.Date = commit.Commit.Author.Date.Format(time.RFC3339)
}
}

if commit.Commit.Committer != nil {
minimalCommit.Commit.Committer = &MinimalCommitAuthor{
Name: commit.Commit.Committer.GetName(),
Email: commit.Commit.Committer.GetEmail(),
}
if commit.Commit.Committer.Date != nil {
minimalCommit.Commit.Committer.Date = commit.Commit.Committer.Date.Format(time.RFC3339)
}
}
}

if commit.Author != nil {
minimalCommit.Author = &MinimalUser{
Login: commit.Author.GetLogin(),
ID: commit.Author.GetID(),
ProfileURL: commit.Author.GetHTMLURL(),
AvatarURL: commit.Author.GetAvatarURL(),
}
}

if commit.Committer != nil {
minimalCommit.Committer = &MinimalUser{
Login: commit.Committer.GetLogin(),
ID: commit.Committer.GetID(),
ProfileURL: commit.Committer.GetHTMLURL(),
AvatarURL: commit.Committer.GetAvatarURL(),
}
}

minimalCommits = append(minimalCommits, minimalCommit)
}

minimalResult := &MinimalSearchCommitsResult{
TotalCount: result.GetTotal(),
IncompleteResults: result.GetIncompleteResults(),
Items: minimalCommits,
}

r, err := json.Marshal(minimalResult)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
}

return utils.NewToolResultText(string(r)), nil, nil
},
)
}
126 changes: 126 additions & 0 deletions pkg/github/search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"testing"
"time"

"github.com/github/github-mcp-server/internal/toolsnaps"
"github.com/github/github-mcp-server/pkg/translations"
Expand Down Expand Up @@ -725,3 +726,128 @@ func Test_SearchOrgs(t *testing.T) {
})
}
}

func Test_SearchCommits(t *testing.T) {
serverTool := SearchCommits(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))

assert.Equal(t, "search_commits", tool.Name)
assert.NotEmpty(t, tool.Description)

schema, ok := tool.InputSchema.(*jsonschema.Schema)
require.True(t, ok, "InputSchema should be *jsonschema.Schema")
assert.Contains(t, schema.Properties, "query")
assert.Contains(t, schema.Properties, "sort")
assert.Contains(t, schema.Properties, "order")
assert.Contains(t, schema.Properties, "page")
assert.Contains(t, schema.Properties, "perPage")
assert.ElementsMatch(t, schema.Required, []string{"query"})

now := time.Now().Truncate(time.Second)
mockSearchResult := &github.CommitsSearchResult{
Total: github.Ptr(1),
IncompleteResults: github.Ptr(false),
Commits: []*github.CommitResult{
{
SHA: github.Ptr("abc123commit"),
HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123commit"),
Commit: &github.Commit{
Message: github.Ptr("Initial commit"),
Author: &github.CommitAuthor{
Name: github.Ptr("Author Name"),
Email: github.Ptr("author@example.com"),
Date: &github.Timestamp{Time: now},
},
},
Author: &github.User{
Login: github.Ptr("author"),
ID: github.Ptr(int64(1)),
HTMLURL: github.Ptr("https://github.com/author"),
},
},
},
}

tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectedResult *github.CommitsSearchResult
expectedErrMsg string
}{
{
name: "successful commit search",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchCommits: expectQueryParams(t, map[string]string{
"q": "fix bug in:message repo:owner/repo",
"sort": "author-date",
"order": "desc",
"page": "1",
"per_page": "30",
}).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "fix bug in:message repo:owner/repo",
"sort": "author-date",
"order": "desc",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "search fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchCommits: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"message": "Validation Failed"}`))
}),
}),
requestArgs: map[string]any{
"query": "invalid:syntax",
},
expectError: true,
expectedErrMsg: "failed to search commits",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client := github.NewClient(tc.mockedClient)
deps := BaseDeps{
Client: client,
}
handler := serverTool.Handler(deps)
request := createMCPRequest(tc.requestArgs)

result, err := handler(ContextWithDeps(context.Background(), deps), &request)

if tc.expectError {
require.NoError(t, err)
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}

require.NoError(t, err)
require.False(t, result.IsError)

textContent := getTextResult(t, result)
var returnedResult MinimalSearchCommitsResult
err = json.Unmarshal([]byte(textContent.Text), &returnedResult)
require.NoError(t, err)

assert.Equal(t, tc.expectedResult.GetTotal(), returnedResult.TotalCount)
assert.Len(t, returnedResult.Items, len(tc.expectedResult.Commits))
assert.Equal(t, *tc.expectedResult.Commits[0].SHA, returnedResult.Items[0].SHA)
assert.Equal(t, *tc.expectedResult.Commits[0].Commit.Message, returnedResult.Items[0].Commit.Message)
assert.Equal(t, *tc.expectedResult.Commits[0].Commit.Author.Name, returnedResult.Items[0].Commit.Author.Name)
assert.Equal(t, now.Format(time.RFC3339), returnedResult.Items[0].Commit.Author.Date)
assert.Equal(t, *tc.expectedResult.Commits[0].Author.Login, returnedResult.Items[0].Author.Login)
})
}
}
1 change: 1 addition & 0 deletions pkg/github/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool {
GetFileContents(t),
ListCommits(t),
SearchCode(t),
SearchCommits(t),
Comment thread
Not-Dhananjay-Mishra marked this conversation as resolved.
GetCommit(t),
ListBranches(t),
ListTags(t),
Expand Down
Loading