diff --git a/README.md b/README.md index c7243033b..0d2a9ef5f 100644 --- a/README.md +++ b/README.md @@ -1090,6 +1090,12 @@ Possible options: - `repo`: Repository name (string, required) - `sha`: Commit SHA, branch name, or tag name (string, required) +- **get_file_blame** - Get file blame information + - `owner`: Repository owner (username or organization) (string, required) + - `path`: Path to the file in the repository (string, required) + - `ref`: Git reference (branch, tag, or commit SHA). Defaults to the repository's default branch (string, optional) + - `repo`: Repository name (string, required) + - **get_file_contents** - Get file or directory contents - `owner`: Repository owner (username or organization) (string, required) - `path`: Path to file/directory (directories must end with a slash '/') (string, optional) diff --git a/docs/remote-server.md b/docs/remote-server.md index 1030911ef..e06d41a75 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -19,7 +19,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) | |----------------|--------------------------------------------------|-------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Default | ["Default" toolset](../README.md#default-toolset) | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | +| all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | | Actions | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | | Code Security | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | | Dependabot | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | diff --git a/pkg/github/__toolsnaps__/get_file_blame.snap b/pkg/github/__toolsnaps__/get_file_blame.snap new file mode 100644 index 000000000..5d06ae9d7 --- /dev/null +++ b/pkg/github/__toolsnaps__/get_file_blame.snap @@ -0,0 +1,34 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get file blame information" + }, + "description": "Get git blame information for a file, showing who last modified each line", + "inputSchema": { + "type": "object", + "required": [ + "owner", + "repo", + "path" + ], + "properties": { + "owner": { + "type": "string", + "description": "Repository owner (username or organization)" + }, + "path": { + "type": "string", + "description": "Path to the file in the repository" + }, + "ref": { + "type": "string", + "description": "Git reference (branch, tag, or commit SHA). Defaults to the repository's default branch" + }, + "repo": { + "type": "string", + "description": "Repository name" + } + } + }, + "name": "get_file_blame" +} \ No newline at end of file diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index dbf24e8e3..28031e437 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -16,6 +16,7 @@ import ( "github.com/google/go-github/v79/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" ) func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { @@ -2113,3 +2114,198 @@ func UnstarRepository(getClient GetClientFn, t translations.TranslationHelperFun return tool, handler } + +func GetFileBlame(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { + tool := mcp.Tool{ + Name: "get_file_blame", + Description: t("TOOL_GET_FILE_BLAME_DESCRIPTION", "Get git blame information for a file, showing who last modified each line"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_GET_FILE_BLAME_USER_TITLE", "Get file blame information"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner (username or organization)", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "path": { + Type: "string", + Description: "Path to the file in the repository", + }, + "ref": { + Type: "string", + Description: "Git reference (branch, tag, or commit SHA). Defaults to the repository's default branch", + }, + }, + Required: []string{"owner", "repo", "path"}, + }, + } + + handler := mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + path, err := RequiredParam[string](args, "path") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + ref, err := OptionalParam[string](args, "ref") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := getGQLClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub GraphQL client: %w", err) + } + + // First, get the default branch if ref is not specified + if ref == "" { + var repoQuery struct { + Repository struct { + DefaultBranchRef struct { + Name githubv4.String + } + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + vars := map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + } + + if err := client.Query(ctx, &repoQuery, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, + "failed to get default branch", + err, + ), nil, nil + } + + // Validate that the repository has a default branch + if repoQuery.Repository.DefaultBranchRef.Name == "" { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, + "repository has no default branch", + fmt.Errorf("repository %s/%s has no default branch or is empty", owner, repo), + ), nil, nil + } + + ref = string(repoQuery.Repository.DefaultBranchRef.Name) + } + // Now query the blame information + var blameQuery struct { + Repository struct { + Object struct { + Commit struct { + Blame struct { + Ranges []struct { + StartingLine githubv4.Int + EndingLine githubv4.Int + Age githubv4.Int + Commit struct { + OID githubv4.String + Message githubv4.String + CommittedDate githubv4.DateTime + Author struct { + Name githubv4.String + Email githubv4.String + User *struct { + Login githubv4.String + URL githubv4.String + } + } + } + } + } `graphql:"blame(path: $path)"` + } `graphql:"... on Commit"` + } `graphql:"object(expression: $ref)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + vars := map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "ref": githubv4.String(ref), + "path": githubv4.String(path), + } + + if err := client.Query(ctx, &blameQuery, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, + fmt.Sprintf("failed to get blame for file: %s", path), + err, + ), nil, nil + } + + // Convert the blame ranges to a more readable format + type BlameRange struct { + StartingLine int `json:"starting_line"` + EndingLine int `json:"ending_line"` + Age int `json:"age"` + Commit struct { + SHA string `json:"sha"` + Message string `json:"message"` + CommittedDate string `json:"committed_date"` + Author struct { + Name string `json:"name"` + Email string `json:"email"` + Login *string `json:"login,omitempty"` + URL *string `json:"url,omitempty"` + } `json:"author"` + } `json:"commit"` + } + + type BlameResult struct { + Repository string `json:"repository"` + Path string `json:"path"` + Ref string `json:"ref"` + Ranges []BlameRange `json:"ranges"` + } + + result := BlameResult{ + Repository: fmt.Sprintf("%s/%s", owner, repo), + Path: path, + Ref: ref, + Ranges: make([]BlameRange, 0, len(blameQuery.Repository.Object.Commit.Blame.Ranges)), + } + + for _, r := range blameQuery.Repository.Object.Commit.Blame.Ranges { + br := BlameRange{ + StartingLine: int(r.StartingLine), + EndingLine: int(r.EndingLine), + Age: int(r.Age), + } + br.Commit.SHA = string(r.Commit.OID) + br.Commit.Message = string(r.Commit.Message) + br.Commit.CommittedDate = r.Commit.CommittedDate.Format("2006-01-02T15:04:05Z") + br.Commit.Author.Name = string(r.Commit.Author.Name) + br.Commit.Author.Email = string(r.Commit.Author.Email) + if r.Commit.Author.User != nil { + login := string(r.Commit.Author.User.Login) + url := string(r.Commit.Author.User.URL) + br.Commit.Author.Login = &login + br.Commit.Author.URL = &url + } + + result.Ranges = append(result.Ranges, br) + } + + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil + }) + + return tool, handler +} diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 7e76d4230..865e687d7 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/translations" @@ -17,6 +18,7 @@ import ( "github.com/google/jsonschema-go/jsonschema" "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -3470,3 +3472,338 @@ func Test_RepositoriesGetRepositoryTree(t *testing.T) { }) } } + +func Test_GetFileBlame(t *testing.T) { + // Verify tool definition once + mockClient := githubv4.NewClient(nil) + tool, _ := GetFileBlame(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + + assert.Equal(t, "get_file_blame", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "path") + assert.Contains(t, schema.Properties, "ref") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "path"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedErrMsg string + validateResponse func(t *testing.T, result string) + }{ + { + name: "successful blame with default branch", + mockedClient: githubv4mock.NewMockedHTTPClient( + // First query: get default branch + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + DefaultBranchRef struct { + Name githubv4.String + } + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]interface{}{ + "owner": githubv4.String("testowner"), + "repo": githubv4.String("testrepo"), + }, + githubv4mock.DataResponse(map[string]interface{}{ + "repository": map[string]interface{}{ + "defaultBranchRef": map[string]interface{}{ + "name": "main", + }, + }, + }), + ), + // Second query: get blame information + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Object struct { + Commit struct { + Blame struct { + Ranges []struct { + StartingLine githubv4.Int + EndingLine githubv4.Int + Age githubv4.Int + Commit struct { + OID githubv4.String + Message githubv4.String + CommittedDate githubv4.DateTime + Author struct { + Name githubv4.String + Email githubv4.String + User *struct { + Login githubv4.String + URL githubv4.String + } + } + } + } + } `graphql:"blame(path: $path)"` + } `graphql:"... on Commit"` + } `graphql:"object(expression: $ref)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]interface{}{ + "owner": githubv4.String("testowner"), + "repo": githubv4.String("testrepo"), + "ref": githubv4.String("main"), + "path": githubv4.String("README.md"), + }, + githubv4mock.DataResponse(map[string]interface{}{ + "repository": map[string]interface{}{ + "object": map[string]interface{}{ + "blame": map[string]interface{}{ + "ranges": []map[string]interface{}{ + { + "startingLine": 1, + "endingLine": 5, + "age": 2, + "commit": map[string]interface{}{ + "oid": "abc123def456", + "message": "Initial commit", + "committedDate": "2024-01-01T12:00:00Z", + "author": map[string]interface{}{ + "name": "John Doe", + "email": "john@example.com", + "user": map[string]interface{}{ + "login": "johndoe", + "url": "https://github.com/johndoe", + }, + }, + }, + }, + { + "startingLine": 6, + "endingLine": 10, + "age": 1, + "commit": map[string]interface{}{ + "oid": "def456ghi789", + "message": "Update README", + "committedDate": "2024-01-02T15:30:00Z", + "author": map[string]interface{}{ + "name": "Jane Smith", + "email": "jane@example.com", + "user": map[string]interface{}{ + "login": "janesmith", + "url": "https://github.com/janesmith", + }, + }, + }, + }, + }, + }, + }, + }, + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "testowner", + "repo": "testrepo", + "path": "README.md", + }, + expectError: false, + validateResponse: func(t *testing.T, result string) { + assert.Contains(t, result, "testowner/testrepo") + assert.Contains(t, result, "README.md") + assert.Contains(t, result, "John Doe") + assert.Contains(t, result, "Jane Smith") + assert.Contains(t, result, "abc123def456") + assert.Contains(t, result, "def456ghi789") + assert.Contains(t, result, "johndoe") + assert.Contains(t, result, "janesmith") + }, + }, + { + name: "successful blame with specific ref", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Object struct { + Commit struct { + Blame struct { + Ranges []struct { + StartingLine githubv4.Int + EndingLine githubv4.Int + Age githubv4.Int + Commit struct { + OID githubv4.String + Message githubv4.String + CommittedDate githubv4.DateTime + Author struct { + Name githubv4.String + Email githubv4.String + User *struct { + Login githubv4.String + URL githubv4.String + } + } + } + } + } `graphql:"blame(path: $path)"` + } `graphql:"... on Commit"` + } `graphql:"object(expression: $ref)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]interface{}{ + "owner": githubv4.String("testowner"), + "repo": githubv4.String("testrepo"), + "ref": githubv4.String("feature-branch"), + "path": githubv4.String("src/main.go"), + }, + githubv4mock.DataResponse(map[string]interface{}{ + "repository": map[string]interface{}{ + "object": map[string]interface{}{ + "blame": map[string]interface{}{ + "ranges": []map[string]interface{}{ + { + "startingLine": 1, + "endingLine": 3, + "age": 1, + "commit": map[string]interface{}{ + "oid": "xyz789abc123", + "message": "Add main function", + "committedDate": "2024-01-03T10:00:00Z", + "author": map[string]interface{}{ + "name": "Bob Developer", + "email": "bob@example.com", + "user": nil, + }, + }, + }, + }, + }, + }, + }, + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "testowner", + "repo": "testrepo", + "path": "src/main.go", + "ref": "feature-branch", + }, + expectError: false, + validateResponse: func(t *testing.T, result string) { + assert.Contains(t, result, "testowner/testrepo") + assert.Contains(t, result, "src/main.go") + assert.Contains(t, result, "feature-branch") + assert.Contains(t, result, "Bob Developer") + assert.Contains(t, result, "xyz789abc123") + }, + }, + { + name: "error fetching default branch", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + DefaultBranchRef struct { + Name githubv4.String + } + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]interface{}{ + "owner": githubv4.String("testowner"), + "repo": githubv4.String("testrepo"), + }, + githubv4mock.ErrorResponse("repository not found"), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "testowner", + "repo": "testrepo", + "path": "README.md", + }, + expectError: true, + expectedErrMsg: "repository not found", + }, + { + name: "error fetching blame", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Object struct { + Commit struct { + Blame struct { + Ranges []struct { + StartingLine githubv4.Int + EndingLine githubv4.Int + Age githubv4.Int + Commit struct { + OID githubv4.String + Message githubv4.String + CommittedDate githubv4.DateTime + Author struct { + Name githubv4.String + Email githubv4.String + User *struct { + Login githubv4.String + URL githubv4.String + } + } + } + } + } `graphql:"blame(path: $path)"` + } `graphql:"... on Commit"` + } `graphql:"object(expression: $ref)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]interface{}{ + "owner": githubv4.String("testowner"), + "repo": githubv4.String("testrepo"), + "ref": githubv4.String("main"), + "path": githubv4.String("nonexistent.txt"), + }, + githubv4mock.ErrorResponse("file not found"), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "testowner", + "repo": "testrepo", + "path": "nonexistent.txt", + "ref": "main", + }, + expectError: true, + expectedErrMsg: "file not found", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := githubv4.NewClient(tc.mockedClient) + _, handler := GetFileBlame(stubGetGQLClientFn(client), translations.NullTranslationHelper) + + request := createMCPRequest(tc.requestArgs) + + result, _, err := handler(context.Background(), &request, tc.requestArgs) + + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + } else { + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + if tc.validateResponse != nil { + tc.validateResponse(t, textContent.Text) + } + } + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index d37af98b8..c9a0064e3 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -172,6 +172,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(ListCommits(getClient, t)), toolsets.NewServerTool(SearchCode(getClient, t)), toolsets.NewServerTool(GetCommit(getClient, t)), + toolsets.NewServerTool(GetFileBlame(getGQLClient, t)), toolsets.NewServerTool(ListBranches(getClient, t)), toolsets.NewServerTool(ListTags(getClient, t)), toolsets.NewServerTool(GetTag(getClient, t)),