diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..8f302e7c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.github +.vscode +script +third-party +.dockerignore +.gitignore +**/*.yml +**/*.yaml +**/*.md +**/*_test.go +LICENSE diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index a37813e3..374715d6 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -24,7 +24,7 @@ jobs: go mod verify go mod download - LINT_VERSION=1.64.8 + LINT_VERSION=2.1.6 curl -fsSL https://github.com/golangci/golangci-lint/releases/download/v${LINT_VERSION}/golangci-lint-${LINT_VERSION}-linux-amd64.tar.gz | \ tar xz --strip-components 1 --wildcards \*/golangci-lint mkdir -p bin && mv golangci-lint bin/ @@ -45,6 +45,6 @@ jobs: assert-nothing-changed go fmt ./... assert-nothing-changed go mod tidy - bin/golangci-lint run --out-format=colored-line-number --timeout=3m || STATUS=$? + bin/golangci-lint run --timeout=3m || STATUS=$? exit $STATUS diff --git a/.gitignore b/.gitignore index 9371be3e..12649366 100644 --- a/.gitignore +++ b/.gitignore @@ -2,11 +2,12 @@ cmd/github-mcp-server/github-mcp-server # VSCode -.vscode/mcp.json +.vscode/* +!.vscode/launch.json # Added by goreleaser init: dist/ __debug_bin* -# Go +# Go vendor diff --git a/.golangci.yml b/.golangci.yml index 43e3d62d..61302f6f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,3 +1,6 @@ +# https://golangci-lint.run/usage/configuration +version: "2" + run: timeout: 5m tests: true @@ -8,21 +11,37 @@ linters: - govet - errcheck - staticcheck - - gofmt - - goimports - revive - ineffassign - - typecheck - unused - - gosimple - misspell - nakedret - bodyclose - gocritic - makezero - gosec + settings: + staticcheck: + checks: + - all + - '-QF1008' # Allow embedded structs to be referenced by field + - '-ST1000' # Do not require package comments + revive: + rules: + - name: exported + disabled: true + - name: exported + disabled: true + - name: package-comments + disabled: true + +formatters: + enable: + - gofmt + - goimports output: - formats: colored-line-number - print-issued-lines: true - print-linter-name: true + formats: + text: + print-linter-name: true + print-issued-lines: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fe307d1d..11d63a38 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ Please note that this project is released with a [Contributor Code of Conduct](C These are one time installations required to be able to test your changes locally as part of the pull request (PR) submission process. 1. install Go [through download](https://go.dev/doc/install) | [through Homebrew](https://formulae.brew.sh/formula/go) -1. [install golangci-lint](https://golangci-lint.run/welcome/install/#local-installation) +1. [install golangci-lint v2](https://golangci-lint.run/welcome/install/#local-installation) ## Submitting a pull request diff --git a/Dockerfile b/Dockerfile index 05fe1ddd..333ac010 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,27 +1,26 @@ +FROM golang:1.24.3-alpine AS build ARG VERSION="dev" -FROM golang:1.23.7 AS build -# allow this step access to build arg -ARG VERSION # Set the working directory WORKDIR /build -RUN go env -w GOMODCACHE=/root/.cache/go-build +# Install git +RUN --mount=type=cache,target=/var/cache/apk \ + apk add git -# Install dependencies -COPY go.mod go.sum ./ -RUN --mount=type=cache,target=/root/.cache/go-build go mod download - -COPY . ./ # Build the server -RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=$(git rev-parse HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - -o github-mcp-server cmd/github-mcp-server/main.go +# go build automatically download required module dependencies to /go/pkg/mod +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=bind,target=. \ + CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=$(git rev-parse HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + -o /bin/github-mcp-server cmd/github-mcp-server/main.go # Make a stage to run the app FROM gcr.io/distroless/base-debian12 # Set the working directory WORKDIR /server # Copy the binary from the build stage -COPY --from=build /build/github-mcp-server . +COPY --from=build /bin/github-mcp-server . # Command to run the server CMD ["./github-mcp-server", "stdio"] diff --git a/README.md b/README.md index 5977763b..352bb50e 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ automation and interaction capabilities for developers and tools. ## Prerequisites 1. To run the server in a container, you will need to have [Docker](https://www.docker.com/) installed. -2. Once Docker is installed, you will also need to ensure Docker is running. +2. Once Docker is installed, you will also need to ensure Docker is running. The image is public; if you get errors on pull, you may have an expired token and need to `docker logout ghcr.io`. 3. Lastly you will need to [Create a GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new). The MCP server can use many of the GitHub APIs, so enable the permissions that you feel comfortable granting your AI tools (to learn more about access tokens, please check out the [documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)). @@ -27,10 +27,6 @@ For quick installation, use one of the one-click install buttons at the top of t For manual installation, add the following JSON block to your User Settings (JSON) file in VS Code. You can do this by pressing `Ctrl + Shift + P` and typing `Preferences: Open User Settings (JSON)`. -Optionally, you can add it to a file called `.vscode/mcp.json` in your workspace. This will allow you to share the configuration with others. - -> Note that the `mcp` key is not needed in the `.vscode/mcp.json` file. - ```json { "mcp": { @@ -62,6 +58,39 @@ Optionally, you can add it to a file called `.vscode/mcp.json` in your workspace } ``` +Optionally, you can add a similar example (i.e. without the mcp key) to a file called `.vscode/mcp.json` in your workspace. This will allow you to share the configuration with others. + + +```json +{ + "inputs": [ + { + "type": "promptString", + "id": "github_token", + "description": "GitHub Personal Access Token", + "password": true + } + ], + "servers": { + "github": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}" + } + } + } +} + +``` + More about using MCP server tools in VS Code's [agent mode documentation](https://code.visualstudio.com/docs/copilot/chat/mcp-servers). ### Usage with Claude Desktop @@ -171,7 +200,7 @@ GITHUB_TOOLSETS="all" ./github-mcp-server **Note**: This feature is currently in beta and may not be available in all environments. Please test it out and let us know if you encounter any issues. -Instead of starting with all tools enabled, you can turn on dynamic toolset discovery. Dynamic toolsets allow the MCP host to list and enable toolsets in response to a user prompt. This should help to avoid situations where the model gets confused by the shear number of tools available. +Instead of starting with all tools enabled, you can turn on dynamic toolset discovery. Dynamic toolsets allow the MCP host to list and enable toolsets in response to a user prompt. This should help to avoid situations where the model gets confused by the sheer number of tools available. ### Using Dynamic Tool Discovery @@ -194,6 +223,27 @@ docker run -i --rm \ The flag `--gh-host` and the environment variable `GITHUB_HOST` can be used to set the GitHub Enterprise Server hostname. +Prefix the hostname with the `https://` URI scheme, as it otherwise defaults to `http://` which GitHub Enterprise Server does not support. + +``` json +"github": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "-e", + "GITHUB_HOST", + "ghcr.io/github/github-mcp-server" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}", + "GITHUB_HOST": "https://" + } +} +``` ## i18n / Overriding Descriptions @@ -408,6 +458,13 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `base`: New base branch name (string, optional) - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) +- **request_copilot_review** - Request a GitHub Copilot review for a pull request (experimental; subject to GitHub API support) + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `pullNumber`: Pull request number (number, required) + - _Note_: Currently, this tool will only work for github.com + ### Repositories - **create_or_update_file** - Create or update a single file in a repository @@ -477,7 +534,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `page`: Page number, for files in the commit (number, optional) - `perPage`: Results per page, for files in the commit (number, optional) - - **search_code** - Search for code across GitHub repositories +- **search_code** - Search for code across GitHub repositories - `query`: Search query (string, required) - `sort`: Sort field (string, optional) - `order`: Sort order (string, optional) @@ -487,7 +544,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description ### Users - **search_users** - Search for GitHub users - - `query`: Search query (string, required) + - `q`: Search query (string, required) - `sort`: Sort field (string, optional) - `order`: Sort order (string, optional) - `page`: Page number (number, optional) diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 5ca0e21c..fb716f78 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -1,25 +1,17 @@ package main import ( - "context" + "errors" "fmt" - "io" - stdlog "log" "os" - "os/signal" - "syscall" + "github.com/github/github-mcp-server/internal/ghmcp" "github.com/github/github-mcp-server/pkg/github" - iolog "github.com/github/github-mcp-server/pkg/log" - "github.com/github/github-mcp-server/pkg/translations" - gogithub "github.com/google/go-github/v69/github" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" - log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" ) +// These variables are set by the build process using ldflags. var version = "version" var commit = "commit" var date = "date" @@ -36,28 +28,34 @@ var ( Use: "stdio", Short: "Start stdio server", Long: `Start a server that communicates via standard input/output streams using JSON-RPC messages.`, - Run: func(_ *cobra.Command, _ []string) { - logFile := viper.GetString("log-file") - readOnly := viper.GetBool("read-only") - exportTranslations := viper.GetBool("export-translations") - logger, err := initLogger(logFile) - if err != nil { - stdlog.Fatal("Failed to initialize logger:", err) + RunE: func(_ *cobra.Command, _ []string) error { + token := viper.GetString("personal_access_token") + if token == "" { + return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set") } - enabledToolsets := viper.GetStringSlice("toolsets") - - logCommands := viper.GetBool("enable-command-logging") - cfg := runConfig{ - readOnly: readOnly, - logger: logger, - logCommands: logCommands, - exportTranslations: exportTranslations, - enabledToolsets: enabledToolsets, + // If you're wondering why we're not using viper.GetStringSlice("toolsets"), + // it's because viper doesn't handle comma-separated values correctly for env + // vars when using GetStringSlice. + // https://github.com/spf13/viper/issues/380 + var enabledToolsets []string + if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil { + return fmt.Errorf("failed to unmarshal toolsets: %w", err) } - if err := runStdioServer(cfg); err != nil { - stdlog.Fatal("failed to run stdio server:", err) + + stdioServerConfig := ghmcp.StdioServerConfig{ + Version: version, + Host: viper.GetString("host"), + Token: token, + EnabledToolsets: enabledToolsets, + DynamicToolsets: viper.GetBool("dynamic_toolsets"), + ReadOnly: viper.GetBool("read-only"), + ExportTranslations: viper.GetBool("export-translations"), + EnableCommandLogging: viper.GetBool("enable-command-logging"), + LogFilePath: viper.GetString("log-file"), } + + return ghmcp.RunStdioServer(stdioServerConfig) }, } ) @@ -95,143 +93,9 @@ func initConfig() { viper.AutomaticEnv() } -func initLogger(outPath string) (*log.Logger, error) { - if outPath == "" { - return log.New(), nil - } - - file, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) - if err != nil { - return nil, fmt.Errorf("failed to open log file: %w", err) - } - - logger := log.New() - logger.SetLevel(log.DebugLevel) - logger.SetOutput(file) - - return logger, nil -} - -type runConfig struct { - readOnly bool - logger *log.Logger - logCommands bool - exportTranslations bool - enabledToolsets []string -} - -func runStdioServer(cfg runConfig) error { - // Create app context - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - // Create GH client - token := viper.GetString("personal_access_token") - if token == "" { - cfg.logger.Fatal("GITHUB_PERSONAL_ACCESS_TOKEN not set") - } - ghClient := gogithub.NewClient(nil).WithAuthToken(token) - ghClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", version) - - host := viper.GetString("host") - - if host != "" { - var err error - ghClient, err = ghClient.WithEnterpriseURLs(host, host) - if err != nil { - return fmt.Errorf("failed to create GitHub client with host: %w", err) - } - } - - t, dumpTranslations := translations.TranslationHelper() - - beforeInit := func(_ context.Context, _ any, message *mcp.InitializeRequest) { - ghClient.UserAgent = fmt.Sprintf("github-mcp-server/%s (%s/%s)", version, message.Params.ClientInfo.Name, message.Params.ClientInfo.Version) - } - - getClient := func(_ context.Context) (*gogithub.Client, error) { - return ghClient, nil // closing over client - } - - hooks := &server.Hooks{ - OnBeforeInitialize: []server.OnBeforeInitializeFunc{beforeInit}, - } - // Create server - ghServer := github.NewServer(version, server.WithHooks(hooks)) - - enabled := cfg.enabledToolsets - dynamic := viper.GetBool("dynamic_toolsets") - if dynamic { - // filter "all" from the enabled toolsets - enabled = make([]string, 0, len(cfg.enabledToolsets)) - for _, toolset := range cfg.enabledToolsets { - if toolset != "all" { - enabled = append(enabled, toolset) - } - } - } - - // Create default toolsets - toolsets, err := github.InitToolsets(enabled, cfg.readOnly, getClient, t) - context := github.InitContextToolset(getClient, t) - - if err != nil { - stdlog.Fatal("Failed to initialize toolsets:", err) - } - - // Register resources with the server - github.RegisterResources(ghServer, getClient, t) - // Register the tools with the server - toolsets.RegisterTools(ghServer) - context.RegisterTools(ghServer) - - if dynamic { - dynamic := github.InitDynamicToolset(ghServer, toolsets, t) - dynamic.RegisterTools(ghServer) - } - - stdioServer := server.NewStdioServer(ghServer) - - stdLogger := stdlog.New(cfg.logger.Writer(), "stdioserver", 0) - stdioServer.SetErrorLogger(stdLogger) - - if cfg.exportTranslations { - // Once server is initialized, all translations are loaded - dumpTranslations() - } - - // Start listening for messages - errC := make(chan error, 1) - go func() { - in, out := io.Reader(os.Stdin), io.Writer(os.Stdout) - - if cfg.logCommands { - loggedIO := iolog.NewIOLogger(in, out, cfg.logger) - in, out = loggedIO, loggedIO - } - - errC <- stdioServer.Listen(ctx, in, out) - }() - - // Output github-mcp-server string - _, _ = fmt.Fprintf(os.Stderr, "GitHub MCP Server running on stdio\n") - - // Wait for shutdown signal - select { - case <-ctx.Done(): - cfg.logger.Infof("shutting down server...") - case err := <-errC: - if err != nil { - return fmt.Errorf("error running server: %w", err) - } - } - - return nil -} - func main() { if err := rootCmd.Execute(); err != nil { - fmt.Println(err) + fmt.Fprintf(os.Stderr, "%v\n", err) os.Exit(1) } } diff --git a/cmd/mcpcurl/README.md b/cmd/mcpcurl/README.md index 0104a1b3..493ce5b1 100644 --- a/cmd/mcpcurl/README.md +++ b/cmd/mcpcurl/README.md @@ -17,7 +17,7 @@ be executed against the configured MCP server. ## Usage -```bash +```console mcpcurl --stdio-server-cmd="" [flags] ``` @@ -33,7 +33,7 @@ The `--stdio-server-cmd` flag is required for all commands and specifies the com List available tools in Anthropic's MCP server: -```bash +```console % ./mcpcurl --stdio-server-cmd "docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN mcp/github" tools --help Contains all dynamically generated tool commands from the schema @@ -72,7 +72,7 @@ Use "mcpcurl tools [command] --help" for more information about a command. Get help for a specific tool: -```bash +```console % ./mcpcurl --stdio-server-cmd "docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN mcp/github" tools get_issue --help Get details of a specific issue in a GitHub repository @@ -93,7 +93,7 @@ Global Flags: Use one of the tools: -```bash +```console % ./mcpcurl --stdio-server-cmd "docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN mcp/github" tools get_issue --owner golang --repo go --issue_number 1 { "active_lock_reason": null, diff --git a/cmd/mcpcurl/main.go b/cmd/mcpcurl/main.go index dfc639b9..bc192587 100644 --- a/cmd/mcpcurl/main.go +++ b/cmd/mcpcurl/main.go @@ -77,7 +77,7 @@ type ( Arguments map[string]interface{} `json:"arguments"` } - // Define structure to match the response format + // Content matches the response format of a text content response Content struct { Type string `json:"type"` Text string `json:"text"` @@ -284,10 +284,10 @@ func addCommandFromTool(toolsCmd *cobra.Command, tool *Tool, prettyPrint bool) { cmd.Flags().Bool(name, false, description) case "array": if prop.Items != nil { - if prop.Items.Type == "string" { + switch prop.Items.Type { + case "string": cmd.Flags().StringSlice(name, []string{}, description) - } else if prop.Items.Type == "object" { - // For complex objects in arrays, we'll use a JSON string that users can provide + case "object": cmd.Flags().String(name+"-json", "", description+" (provide as JSON array)") } } @@ -327,11 +327,12 @@ func buildArgumentsMap(cmd *cobra.Command, tool *Tool) (map[string]interface{}, } case "array": if prop.Items != nil { - if prop.Items.Type == "string" { + switch prop.Items.Type { + case "string": if values, _ := cmd.Flags().GetStringSlice(name); len(values) > 0 { arguments[name] = values } - } else if prop.Items.Type == "object" { + case "object": if jsonStr, _ := cmd.Flags().GetString(name + "-json"); jsonStr != "" { var jsonArray []interface{} if err := json.Unmarshal([]byte(jsonStr), &jsonArray); err != nil { diff --git a/conformance/conformance_test.go b/conformance/conformance_test.go deleted file mode 100644 index cd69e013..00000000 --- a/conformance/conformance_test.go +++ /dev/null @@ -1,435 +0,0 @@ -//go:build conformance - -package conformance_test - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "reflect" - "strings" - "testing" - - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/network" - "github.com/docker/docker/client" - "github.com/docker/docker/pkg/stdcopy" - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "github.com/stretchr/testify/require" -) - -type maintainer string - -const ( - anthropic maintainer = "anthropic" - github maintainer = "github" -) - -type testLogWriter struct { - t *testing.T -} - -func (w testLogWriter) Write(p []byte) (n int, err error) { - w.t.Log(string(p)) - return len(p), nil -} - -func start(t *testing.T, m maintainer) server { - var image string - if m == github { - image = "github/github-mcp-server" - } else { - image = "mcp/github" - } - - ctx := context.Background() - dockerClient, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) - require.NoError(t, err) - - containerCfg := &container.Config{ - OpenStdin: true, - AttachStdin: true, - AttachStdout: true, - AttachStderr: true, - Env: []string{ - fmt.Sprintf("GITHUB_PERSONAL_ACCESS_TOKEN=%s", os.Getenv("GITHUB_PERSONAL_ACCESS_TOKEN")), - }, - Image: image, - } - - resp, err := dockerClient.ContainerCreate( - ctx, - containerCfg, - &container.HostConfig{}, - &network.NetworkingConfig{}, - nil, - "") - require.NoError(t, err) - - t.Cleanup(func() { - require.NoError(t, dockerClient.ContainerRemove(ctx, resp.ID, container.RemoveOptions{Force: true})) - }) - - hijackedResponse, err := dockerClient.ContainerAttach(ctx, resp.ID, container.AttachOptions{ - Stream: true, - Stdin: true, - Stdout: true, - Stderr: true, - }) - require.NoError(t, err) - t.Cleanup(func() { hijackedResponse.Close() }) - - require.NoError(t, dockerClient.ContainerStart(ctx, resp.ID, container.StartOptions{})) - - serverStart := make(chan serverStartResult) - go func() { - prOut, pwOut := io.Pipe() - prErr, pwErr := io.Pipe() - - go func() { - // Ignore error, we should be done? - // TODO: maybe check for use of closed network connection specifically - _, _ = stdcopy.StdCopy(pwOut, pwErr, hijackedResponse.Reader) - pwOut.Close() - pwErr.Close() - }() - - bufferedStderr := bufio.NewReader(prErr) - line, err := bufferedStderr.ReadString('\n') - if err != nil { - serverStart <- serverStartResult{err: err} - } - - if strings.TrimSpace(line) != "GitHub MCP Server running on stdio" { - serverStart <- serverStartResult{ - err: fmt.Errorf("unexpected server output: %s", line), - } - return - } - - serverStart <- serverStartResult{ - server: server{ - m: m, - log: testLogWriter{t}, - stdin: hijackedResponse.Conn, - stdout: bufio.NewReader(prOut), - }, - } - }() - - t.Logf("waiting for %s server to start...", m) - serveResult := <-serverStart - require.NoError(t, serveResult.err, "expected the server to start successfully") - - return serveResult.server -} - -func TestCapabilities(t *testing.T) { - anthropicServer := start(t, anthropic) - githubServer := start(t, github) - - req := initializeRequest{ - JSONRPC: "2.0", - ID: 1, - Method: "initialize", - Params: initializeParams{ - ProtocolVersion: "2025-03-26", - Capabilities: clientCapabilities{}, - ClientInfo: clientInfo{ - Name: "ConformanceTest", - Version: "0.0.1", - }, - }, - } - - require.NoError(t, anthropicServer.send(req)) - - var anthropicInitializeResponse initializeResponse - require.NoError(t, anthropicServer.receive(&anthropicInitializeResponse)) - - require.NoError(t, githubServer.send(req)) - - var ghInitializeResponse initializeResponse - require.NoError(t, githubServer.receive(&ghInitializeResponse)) - - // Any capabilities in the anthropic response should be present in the github response - // (though the github response may have additional capabilities) - if diff := diffNonNilFields(anthropicInitializeResponse.Result.Capabilities, ghInitializeResponse.Result.Capabilities, ""); diff != "" { - t.Errorf("capabilities mismatch:\n%s", diff) - } -} - -func diffNonNilFields(a, b interface{}, path string) string { - var sb strings.Builder - - va := reflect.ValueOf(a) - vb := reflect.ValueOf(b) - - if !va.IsValid() { - return "" - } - - if va.Kind() == reflect.Ptr { - if va.IsNil() { - return "" - } - if !vb.IsValid() || vb.IsNil() { - sb.WriteString(path + "\n") - return sb.String() - } - va = va.Elem() - vb = vb.Elem() - } - - if va.Kind() != reflect.Struct || vb.Kind() != reflect.Struct { - return "" - } - - t := va.Type() - for i := range va.NumField() { - field := t.Field(i) - if !field.IsExported() { - continue - } - - subPath := field.Name - if path != "" { - subPath = fmt.Sprintf("%s.%s", path, field.Name) - } - - fieldA := va.Field(i) - fieldB := vb.Field(i) - - switch fieldA.Kind() { - case reflect.Ptr: - if fieldA.IsNil() { - continue // not required - } - if fieldB.IsNil() { - sb.WriteString(subPath + "\n") - continue - } - sb.WriteString(diffNonNilFields(fieldA.Interface(), fieldB.Interface(), subPath)) - - case reflect.Struct: - sb.WriteString(diffNonNilFields(fieldA.Interface(), fieldB.Interface(), subPath)) - - default: - zero := reflect.Zero(fieldA.Type()) - if !reflect.DeepEqual(fieldA.Interface(), zero.Interface()) { - // fieldA is non-zero; now check that fieldB matches - if !reflect.DeepEqual(fieldA.Interface(), fieldB.Interface()) { - sb.WriteString(subPath + "\n") - } - } - } - } - - return sb.String() -} - -func TestListTools(t *testing.T) { - anthropicServer := start(t, anthropic) - githubServer := start(t, github) - - req := listToolsRequest{ - JSONRPC: "2.0", - ID: 1, - Method: "tools/list", - } - - require.NoError(t, anthropicServer.send(req)) - - var anthropicListToolsResponse listToolsResponse - require.NoError(t, anthropicServer.receive(&anthropicListToolsResponse)) - - require.NoError(t, githubServer.send(req)) - - var ghListToolsResponse listToolsResponse - require.NoError(t, githubServer.receive(&ghListToolsResponse)) - - require.NoError(t, isToolListSubset(anthropicListToolsResponse.Result, ghListToolsResponse.Result), "expected the github list tools response to be a subset of the anthropic list tools response") -} - -func isToolListSubset(subset, superset listToolsResult) error { - // Build a map from tool name to Tool from the superset - supersetMap := make(map[string]tool) - for _, tool := range superset.Tools { - supersetMap[tool.Name] = tool - } - - var err error - for _, tool := range subset.Tools { - sup, ok := supersetMap[tool.Name] - if !ok { - return fmt.Errorf("tool %q not found in superset", tool.Name) - } - - // Intentionally ignore the description fields because there are lots of slight differences. - // if tool.Description != sup.Description { - // return fmt.Errorf("description mismatch for tool %q, got %q expected %q", tool.Name, tool.Description, sup.Description) - // } - - // Ignore any description fields within the input schema properties for the same reason - ignoreDescOpt := cmp.FilterPath(func(p cmp.Path) bool { - // Look for a field named "Properties" somewhere in the path - for _, ps := range p { - if sf, ok := ps.(cmp.StructField); ok && sf.Name() == "Properties" { - return true - } - } - return false - }, cmpopts.IgnoreMapEntries(func(k string, _ any) bool { - return k == "description" - })) - - if diff := cmp.Diff(tool.InputSchema, sup.InputSchema, ignoreDescOpt); diff != "" { - err = errors.Join(err, fmt.Errorf("inputSchema mismatch for tool %q:\n%s", tool.Name, diff)) - } - } - - return err -} - -type serverStartResult struct { - server server - err error -} - -type server struct { - m maintainer - log io.Writer - - stdin io.Writer - stdout *bufio.Reader -} - -func (s server) send(req request) error { - b, err := req.marshal() - if err != nil { - return err - } - - fmt.Fprintf(s.log, "sending %s: %s\n", s.m, string(b)) - - n, err := s.stdin.Write(append(b, '\n')) - if err != nil { - return err - } - - if n != len(b)+1 { - return fmt.Errorf("wrote %d bytes, expected %d", n, len(b)+1) - } - - return nil -} - -func (s server) receive(res response) error { - line, err := s.stdout.ReadBytes('\n') - if err != nil { - if err == io.EOF { - return fmt.Errorf("EOF after reading %s", string(line)) - } - return err - } - - fmt.Fprintf(s.log, "received from %s: %s\n", s.m, string(line)) - - return res.unmarshal(line) -} - -type request interface { - marshal() ([]byte, error) -} - -type response interface { - unmarshal([]byte) error -} - -type jsonRPRCRequest[params any] struct { - JSONRPC string `json:"jsonrpc"` - ID int `json:"id"` - Method string `json:"method"` - Params params `json:"params"` -} - -func (r jsonRPRCRequest[any]) marshal() ([]byte, error) { - return json.Marshal(r) -} - -type jsonRPRCResponse[result any] struct { - JSONRPC string `json:"jsonrpc"` - ID int `json:"id"` - Method string `json:"method"` - Result result `json:"result"` -} - -func (r *jsonRPRCResponse[any]) unmarshal(b []byte) error { - return json.Unmarshal(b, r) -} - -type initializeRequest = jsonRPRCRequest[initializeParams] - -type initializeParams struct { - ProtocolVersion string `json:"protocolVersion"` - Capabilities clientCapabilities `json:"capabilities"` - ClientInfo clientInfo `json:"clientInfo"` -} - -type clientCapabilities struct{} // don't actually care about any of these right now - -type clientInfo struct { - Name string `json:"name"` - Version string `json:"version"` -} - -type initializeResponse = jsonRPRCResponse[initializeResult] - -type initializeResult struct { - ProtocolVersion string `json:"protocolVersion"` - Capabilities serverCapabilities `json:"capabilities"` - ServerInfo serverInfo `json:"serverInfo"` -} - -type serverCapabilities struct { - Logging *struct{} `json:"logging,omitempty"` - Prompts *struct { - ListChanged bool `json:"listChanged,omitempty"` - } `json:"prompts,omitempty"` - Resources *struct { - Subscribe bool `json:"subscribe,omitempty"` - ListChanged bool `json:"listChanged,omitempty"` - } `json:"resources,omitempty"` - Tools *struct { - ListChanged bool `json:"listChanged,omitempty"` - } `json:"tools,omitempty"` -} - -type serverInfo struct { - Name string `json:"name"` - Version string `json:"version"` -} - -type listToolsRequest = jsonRPRCRequest[struct{}] - -type listToolsResponse = jsonRPRCResponse[listToolsResult] - -type listToolsResult struct { - Tools []tool `json:"tools"` -} -type tool struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - InputSchema inputSchema `json:"inputSchema"` -} - -type inputSchema struct { - Type string `json:"type"` - Properties map[string]any `json:"properties,omitempty"` - Required []string `json:"required,omitempty"` -} diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 00000000..82de966b --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,92 @@ +# End To End (e2e) Tests + +The purpose of the E2E tests is to have a simple (currently) test that gives maintainers some confidence in the black box behavior of our artifacts. It does this by: + * Building the `github-mcp-server` docker image + * Running the image + * Interacting with the server via stdio + * Issuing requests that interact with the live GitHub API + +## Running the Tests + +A service must be running that supports image building and container creation via the `docker` CLI. + +Since these tests require a token to interact with real resources on the GitHub API, it is gated behind the `e2e` build flag. + +``` +GITHUB_MCP_SERVER_E2E_TOKEN= go test -v --tags e2e ./e2e +``` + +The `GITHUB_MCP_SERVER_E2E_TOKEN` environment variable is mapped to `GITHUB_PERSONAL_ACCESS_TOKEN` internally, but separated to avoid accidental reuse of credentials. + +## Example + +The following diff adjusts the `get_me` tool to return `foobar` as the user login. + +```diff +diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go +index 1c91d70..ac4ef2b 100644 +--- a/pkg/github/context_tools.go ++++ b/pkg/github/context_tools.go +@@ -39,6 +39,8 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mc + return mcp.NewToolResultError(fmt.Sprintf("failed to get user: %s", string(body))), nil + } + ++ user.Login = sPtr("foobar") ++ + r, err := json.Marshal(user) + if err != nil { + return nil, fmt.Errorf("failed to marshal user: %w", err) +@@ -47,3 +49,7 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mc + return mcp.NewToolResultText(string(r)), nil + } + } ++ ++func sPtr(s string) *string { ++ return &s ++} +``` + +Running the tests: + +``` +➜ GITHUB_MCP_SERVER_E2E_TOKEN=$(gh auth token) go test -v --tags e2e ./e2e +=== RUN TestE2E + e2e_test.go:92: Building Docker image for e2e tests... + e2e_test.go:36: Starting Stdio MCP client... +=== RUN TestE2E/Initialize +=== RUN TestE2E/CallTool_get_me + e2e_test.go:85: + Error Trace: /Users/williammartin/workspace/github-mcp-server/e2e/e2e_test.go:85 + Error: Not equal: + expected: "foobar" + actual : "williammartin" + + Diff: + --- Expected + +++ Actual + @@ -1 +1 @@ + -foobar + +williammartin + Test: TestE2E/CallTool_get_me + Messages: expected login to match +--- FAIL: TestE2E (1.05s) + --- PASS: TestE2E/Initialize (0.09s) + --- FAIL: TestE2E/CallTool_get_me (0.46s) +FAIL +FAIL github.com/github/github-mcp-server/e2e 1.433s +FAIL +``` + +## Debugging the Tests + +It is possible to provide `GITHUB_MCP_SERVER_E2E_DEBUG=true` to run the e2e tests with an in-process version of the MCP server. This has slightly reduced coverage as it doesn't integrate with Docker, or make use of the cobra/viper configuration parsing. However, it allows for placing breakpoints in the MCP Server internals, supporting much better debugging flows than the fully black-box tests. + +One might argue that the lack of visibility into failures for the black box tests also indicates a product need, but this solves for the immediate pain point felt as a maintainer. + +## Limitations + +The current test suite is intentionally very limited in scope. This is because the maintenance costs on e2e tests tend to increase significantly over time. To read about some challenges with GitHub integration tests, see [go-github integration tests README](https://github.com/google/go-github/blob/5b75aa86dba5cf4af2923afa0938774f37fa0a67/test/README.md). We will expand this suite circumspectly! + +The tests are quite repetitive and verbose. This is intentional as we want to see them develop more before committing to abstractions. + +Currently, visibility into failures is not particularly good. We're hoping that we can pull apart the mcp-go client and have it hook into streams representing stdio without requiring an exec. This way we can get breakpoints in the debugger easily. diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go new file mode 100644 index 00000000..e3696497 --- /dev/null +++ b/e2e/e2e_test.go @@ -0,0 +1,919 @@ +//go:build e2e + +package e2e_test + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "os/exec" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/github/github-mcp-server/internal/ghmcp" + "github.com/github/github-mcp-server/pkg/github" + "github.com/github/github-mcp-server/pkg/translations" + gogithub "github.com/google/go-github/v69/github" + mcpClient "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" +) + +var ( + // Shared variables and sync.Once instances to ensure one-time execution + getTokenOnce sync.Once + token string + + buildOnce sync.Once + buildError error +) + +// getE2EToken ensures the environment variable is checked only once and returns the token +func getE2EToken(t *testing.T) string { + getTokenOnce.Do(func() { + token = os.Getenv("GITHUB_MCP_SERVER_E2E_TOKEN") + if token == "" { + t.Fatalf("GITHUB_MCP_SERVER_E2E_TOKEN environment variable is not set") + } + }) + return token +} + +// ensureDockerImageBuilt makes sure the Docker image is built only once across all tests +func ensureDockerImageBuilt(t *testing.T) { + buildOnce.Do(func() { + t.Log("Building Docker image for e2e tests...") + cmd := exec.Command("docker", "build", "-t", "github/e2e-github-mcp-server", ".") + cmd.Dir = ".." // Run this in the context of the root, where the Dockerfile is located. + output, err := cmd.CombinedOutput() + buildError = err + if err != nil { + t.Logf("Docker build output: %s", string(output)) + } + }) + + // Check if the build was successful + require.NoError(t, buildError, "expected to build Docker image successfully") +} + +// clientOpts holds configuration options for the MCP client setup +type clientOpts struct { + // Toolsets to enable in the MCP server + enabledToolsets []string +} + +// clientOption defines a function type for configuring ClientOpts +type clientOption func(*clientOpts) + +// withToolsets returns an option that either sets an Env Var when executing in docker, +// or sets the toolsets in the MCP server when running in-process. +func withToolsets(toolsets []string) clientOption { + return func(opts *clientOpts) { + opts.enabledToolsets = toolsets + } +} + +func setupMCPClient(t *testing.T, options ...clientOption) *mcpClient.Client { + // Get token and ensure Docker image is built + token := getE2EToken(t) + + // Create and configure options + opts := &clientOpts{} + + // Apply all options to configure the opts struct + for _, option := range options { + option(opts) + } + + // By default, we run the tests including the Docker image, but with DEBUG + // enabled, we run the server in-process, allowing for easier debugging. + var client *mcpClient.Client + if os.Getenv("GITHUB_MCP_SERVER_E2E_DEBUG") == "" { + ensureDockerImageBuilt(t) + + // Prepare Docker arguments + args := []string{ + "docker", + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", // Personal access token is all required + } + + // Add toolsets environment variable to the Docker arguments + if len(opts.enabledToolsets) > 0 { + args = append(args, "-e", "GITHUB_TOOLSETS") + } + + // Add the image name + args = append(args, "github/e2e-github-mcp-server") + + // Construct the env vars for the MCP Client to execute docker with + dockerEnvVars := []string{ + fmt.Sprintf("GITHUB_PERSONAL_ACCESS_TOKEN=%s", token), + fmt.Sprintf("GITHUB_TOOLSETS=%s", strings.Join(opts.enabledToolsets, ",")), + } + + // Create the client + t.Log("Starting Stdio MCP client...") + var err error + client, err = mcpClient.NewStdioMCPClient(args[0], dockerEnvVars, args[1:]...) + require.NoError(t, err, "expected to create client successfully") + } else { + // We need this because the fully compiled server has a default for the viper config, which is + // not in scope for using the MCP server directly. This probably indicates that we should refactor + // so that there is a shared setup mechanism, but let's wait till we feel more friction. + enabledToolsets := opts.enabledToolsets + if enabledToolsets == nil { + enabledToolsets = github.DefaultTools + } + + ghServer, err := ghmcp.NewMCPServer(ghmcp.MCPServerConfig{ + Token: token, + EnabledToolsets: enabledToolsets, + Translator: translations.NullTranslationHelper, + }) + require.NoError(t, err, "expected to construct MCP server successfully") + + t.Log("Starting In Process MCP client...") + client, err = mcpClient.NewInProcessClient(ghServer) + require.NoError(t, err, "expected to create in-process client successfully") + } + + t.Cleanup(func() { + require.NoError(t, client.Close(), "expected to close client successfully") + }) + + // Initialize the client + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + request := mcp.InitializeRequest{} + request.Params.ProtocolVersion = "2025-03-26" + request.Params.ClientInfo = mcp.Implementation{ + Name: "e2e-test-client", + Version: "0.0.1", + } + + result, err := client.Initialize(ctx, request) + require.NoError(t, err, "failed to initialize client") + require.Equal(t, "github-mcp-server", result.ServerInfo.Name, "unexpected server name") + + return client +} + +func TestGetMe(t *testing.T) { + t.Parallel() + + mcpClient := setupMCPClient(t) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // When we call the "get_me" tool + request := mcp.CallToolRequest{} + request.Params.Name = "get_me" + + response, err := mcpClient.CallTool(ctx, request) + require.NoError(t, err, "expected to call 'get_me' tool successfully") + + require.False(t, response.IsError, "expected result not to be an error") + require.Len(t, response.Content, 1, "expected content to have one item") + + textContent, ok := response.Content[0].(mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + + var trimmedContent struct { + Login string `json:"login"` + } + err = json.Unmarshal([]byte(textContent.Text), &trimmedContent) + require.NoError(t, err, "expected to unmarshal text content successfully") + + // Then the login in the response should match the login obtained via the same + // token using the GitHub API. + ghClient := gogithub.NewClient(nil).WithAuthToken(getE2EToken(t)) + user, _, err := ghClient.Users.Get(context.Background(), "") + require.NoError(t, err, "expected to get user successfully") + require.Equal(t, trimmedContent.Login, *user.Login, "expected login to match") + +} + +func TestToolsets(t *testing.T) { + t.Parallel() + + mcpClient := setupMCPClient( + t, + withToolsets([]string{"repos", "issues"}), + ) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + request := mcp.ListToolsRequest{} + response, err := mcpClient.ListTools(ctx, request) + require.NoError(t, err, "expected to list tools successfully") + + // We could enumerate the tools here, but we'll need to expose that information + // declaratively in the MCP server, so for the moment let's just check the existence + // of an issue and repo tool, and the non-existence of a pull_request tool. + var toolsContains = func(expectedName string) bool { + return slices.ContainsFunc(response.Tools, func(tool mcp.Tool) bool { + return tool.Name == expectedName + }) + } + + require.True(t, toolsContains("get_issue"), "expected to find 'get_issue' tool") + require.True(t, toolsContains("list_branches"), "expected to find 'list_branches' tool") + require.False(t, toolsContains("get_pull_request"), "expected not to find 'get_pull_request' tool") +} + +func TestTags(t *testing.T) { + t.Parallel() + + mcpClient := setupMCPClient(t) + + ctx := context.Background() + + // First, who am I + getMeRequest := mcp.CallToolRequest{} + getMeRequest.Params.Name = "get_me" + + t.Log("Getting current user...") + resp, err := mcpClient.CallTool(ctx, getMeRequest) + require.NoError(t, err, "expected to call 'get_me' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + require.False(t, resp.IsError, "expected result not to be an error") + require.Len(t, resp.Content, 1, "expected content to have one item") + + textContent, ok := resp.Content[0].(mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + + var trimmedGetMeText struct { + Login string `json:"login"` + } + err = json.Unmarshal([]byte(textContent.Text), &trimmedGetMeText) + require.NoError(t, err, "expected to unmarshal text content successfully") + + currentOwner := trimmedGetMeText.Login + + // Then create a repository with a README (via autoInit) + repoName := fmt.Sprintf("github-mcp-server-e2e-%s-%d", t.Name(), time.Now().UnixMilli()) + createRepoRequest := mcp.CallToolRequest{} + createRepoRequest.Params.Name = "create_repository" + createRepoRequest.Params.Arguments = map[string]any{ + "name": repoName, + "private": true, + "autoInit": true, + } + + t.Logf("Creating repository %s/%s...", currentOwner, repoName) + _, err = mcpClient.CallTool(ctx, createRepoRequest) + require.NoError(t, err, "expected to call 'get_me' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + // Cleanup the repository after the test + t.Cleanup(func() { + // MCP Server doesn't support deletions, but we can use the GitHub Client + ghClient := gogithub.NewClient(nil).WithAuthToken(getE2EToken(t)) + t.Logf("Deleting repository %s/%s...", currentOwner, repoName) + _, err := ghClient.Repositories.Delete(context.Background(), currentOwner, repoName) + require.NoError(t, err, "expected to delete repository successfully") + }) + + // Then create a tag + // MCP Server doesn't support tag creation, but we can use the GitHub Client + ghClient := gogithub.NewClient(nil).WithAuthToken(getE2EToken(t)) + t.Logf("Creating tag %s/%s:%s...", currentOwner, repoName, "v0.0.1") + ref, _, err := ghClient.Git.GetRef(context.Background(), currentOwner, repoName, "refs/heads/main") + require.NoError(t, err, "expected to get ref successfully") + + tagObj, _, err := ghClient.Git.CreateTag(context.Background(), currentOwner, repoName, &gogithub.Tag{ + Tag: gogithub.Ptr("v0.0.1"), + Message: gogithub.Ptr("v0.0.1"), + Object: &gogithub.GitObject{ + SHA: ref.Object.SHA, + Type: gogithub.Ptr("commit"), + }, + }) + require.NoError(t, err, "expected to create tag object successfully") + + _, _, err = ghClient.Git.CreateRef(context.Background(), currentOwner, repoName, &gogithub.Reference{ + Ref: gogithub.Ptr("refs/tags/v0.0.1"), + Object: &gogithub.GitObject{ + SHA: tagObj.SHA, + }, + }) + require.NoError(t, err, "expected to create tag ref successfully") + + // List the tags + listTagsRequest := mcp.CallToolRequest{} + listTagsRequest.Params.Name = "list_tags" + listTagsRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + } + + t.Logf("Listing tags for %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, listTagsRequest) + require.NoError(t, err, "expected to call 'list_tags' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + require.False(t, resp.IsError, "expected result not to be an error") + require.Len(t, resp.Content, 1, "expected content to have one item") + + textContent, ok = resp.Content[0].(mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + + var trimmedTags []struct { + Name string `json:"name"` + Commit struct { + SHA string `json:"sha"` + } `json:"commit"` + } + err = json.Unmarshal([]byte(textContent.Text), &trimmedTags) + require.NoError(t, err, "expected to unmarshal text content successfully") + + require.Len(t, trimmedTags, 1, "expected to find one tag") + require.Equal(t, "v0.0.1", trimmedTags[0].Name, "expected tag name to match") + require.Equal(t, *ref.Object.SHA, trimmedTags[0].Commit.SHA, "expected tag SHA to match") + + // And fetch an individual tag + getTagRequest := mcp.CallToolRequest{} + getTagRequest.Params.Name = "get_tag" + getTagRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "tag": "v0.0.1", + } + + t.Logf("Getting tag %s/%s:%s...", currentOwner, repoName, "v0.0.1") + resp, err = mcpClient.CallTool(ctx, getTagRequest) + require.NoError(t, err, "expected to call 'get_tag' tool successfully") + require.False(t, resp.IsError, "expected result not to be an error") + + var trimmedTag []struct { // don't understand why this is an array + Name string `json:"name"` + Commit struct { + SHA string `json:"sha"` + } `json:"commit"` + } + err = json.Unmarshal([]byte(textContent.Text), &trimmedTag) + require.NoError(t, err, "expected to unmarshal text content successfully") + require.Len(t, trimmedTag, 1, "expected to find one tag") + require.Equal(t, "v0.0.1", trimmedTag[0].Name, "expected tag name to match") + require.Equal(t, *ref.Object.SHA, trimmedTag[0].Commit.SHA, "expected tag SHA to match") +} + +func TestFileDeletion(t *testing.T) { + t.Parallel() + + mcpClient := setupMCPClient(t) + + ctx := context.Background() + + // First, who am I + getMeRequest := mcp.CallToolRequest{} + getMeRequest.Params.Name = "get_me" + + t.Log("Getting current user...") + resp, err := mcpClient.CallTool(ctx, getMeRequest) + require.NoError(t, err, "expected to call 'get_me' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + require.False(t, resp.IsError, "expected result not to be an error") + require.Len(t, resp.Content, 1, "expected content to have one item") + + textContent, ok := resp.Content[0].(mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + + var trimmedGetMeText struct { + Login string `json:"login"` + } + err = json.Unmarshal([]byte(textContent.Text), &trimmedGetMeText) + require.NoError(t, err, "expected to unmarshal text content successfully") + + currentOwner := trimmedGetMeText.Login + + // Then create a repository with a README (via autoInit) + repoName := fmt.Sprintf("github-mcp-server-e2e-%s-%d", t.Name(), time.Now().UnixMilli()) + createRepoRequest := mcp.CallToolRequest{} + createRepoRequest.Params.Name = "create_repository" + createRepoRequest.Params.Arguments = map[string]any{ + "name": repoName, + "private": true, + "autoInit": true, + } + t.Logf("Creating repository %s/%s...", currentOwner, repoName) + _, err = mcpClient.CallTool(ctx, createRepoRequest) + require.NoError(t, err, "expected to call 'get_me' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + // Cleanup the repository after the test + t.Cleanup(func() { + // MCP Server doesn't support deletions, but we can use the GitHub Client + ghClient := gogithub.NewClient(nil).WithAuthToken(getE2EToken(t)) + t.Logf("Deleting repository %s/%s...", currentOwner, repoName) + _, err := ghClient.Repositories.Delete(context.Background(), currentOwner, repoName) + require.NoError(t, err, "expected to delete repository successfully") + }) + + // Create a branch on which to create a new commit + createBranchRequest := mcp.CallToolRequest{} + createBranchRequest.Params.Name = "create_branch" + createBranchRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "branch": "test-branch", + "from_branch": "main", + } + + t.Logf("Creating branch in %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, createBranchRequest) + require.NoError(t, err, "expected to call 'create_branch' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + // Create a commit with a new file + commitRequest := mcp.CallToolRequest{} + commitRequest.Params.Name = "create_or_update_file" + commitRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "path": "test-file.txt", + "content": fmt.Sprintf("Created by e2e test %s", t.Name()), + "message": "Add test file", + "branch": "test-branch", + } + + t.Logf("Creating commit with new file in %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, commitRequest) + require.NoError(t, err, "expected to call 'create_or_update_file' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + textContent, ok = resp.Content[0].(mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + + var trimmedCommitText struct { + SHA string `json:"sha"` + } + err = json.Unmarshal([]byte(textContent.Text), &trimmedCommitText) + require.NoError(t, err, "expected to unmarshal text content successfully") + + // Check the file exists + getFileContentsRequest := mcp.CallToolRequest{} + getFileContentsRequest.Params.Name = "get_file_contents" + getFileContentsRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "path": "test-file.txt", + "branch": "test-branch", + } + + t.Logf("Getting file contents in %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, getFileContentsRequest) + require.NoError(t, err, "expected to call 'get_file_contents' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + textContent, ok = resp.Content[0].(mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + + var trimmedGetFileText struct { + Content string `json:"content"` + } + err = json.Unmarshal([]byte(textContent.Text), &trimmedGetFileText) + require.NoError(t, err, "expected to unmarshal text content successfully") + b, err := base64.StdEncoding.DecodeString(trimmedGetFileText.Content) + require.NoError(t, err, "expected to decode base64 content successfully") + require.Equal(t, fmt.Sprintf("Created by e2e test %s", t.Name()), string(b), "expected file content to match") + + // Delete the file + deleteFileRequest := mcp.CallToolRequest{} + deleteFileRequest.Params.Name = "delete_file" + deleteFileRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "path": "test-file.txt", + "message": "Delete test file", + "branch": "test-branch", + } + + t.Logf("Deleting file in %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, deleteFileRequest) + require.NoError(t, err, "expected to call 'delete_file' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + // See that there is a commit that removes the file + listCommitsRequest := mcp.CallToolRequest{} + listCommitsRequest.Params.Name = "list_commits" + listCommitsRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "sha": "test-branch", // can be SHA or branch, which is an unfortunate API design + } + + t.Logf("Listing commits in %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, listCommitsRequest) + require.NoError(t, err, "expected to call 'list_commits' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + textContent, ok = resp.Content[0].(mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + + var trimmedListCommitsText []struct { + SHA string `json:"sha"` + Commit struct { + Message string `json:"message"` + } + Files []struct { + Filename string `json:"filename"` + Deletions int `json:"deletions"` + } + } + err = json.Unmarshal([]byte(textContent.Text), &trimmedListCommitsText) + require.NoError(t, err, "expected to unmarshal text content successfully") + require.GreaterOrEqual(t, len(trimmedListCommitsText), 1, "expected to find at least one commit") + + deletionCommit := trimmedListCommitsText[0] + require.Equal(t, "Delete test file", deletionCommit.Commit.Message, "expected commit message to match") + + // Now get the commit so we can look at the file changes because list_commits doesn't include them + getCommitRequest := mcp.CallToolRequest{} + getCommitRequest.Params.Name = "get_commit" + getCommitRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "sha": deletionCommit.SHA, + } + + t.Logf("Getting commit %s/%s:%s...", currentOwner, repoName, deletionCommit.SHA) + resp, err = mcpClient.CallTool(ctx, getCommitRequest) + require.NoError(t, err, "expected to call 'get_commit' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + textContent, ok = resp.Content[0].(mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + + var trimmedGetCommitText struct { + Files []struct { + Filename string `json:"filename"` + Deletions int `json:"deletions"` + } + } + err = json.Unmarshal([]byte(textContent.Text), &trimmedGetCommitText) + require.NoError(t, err, "expected to unmarshal text content successfully") + require.Len(t, trimmedGetCommitText.Files, 1, "expected to find one file change") + require.Equal(t, "test-file.txt", trimmedGetCommitText.Files[0].Filename, "expected filename to match") + require.Equal(t, 1, trimmedGetCommitText.Files[0].Deletions, "expected one deletion") +} + +func TestDirectoryDeletion(t *testing.T) { + t.Parallel() + + mcpClient := setupMCPClient(t) + + ctx := context.Background() + + // First, who am I + getMeRequest := mcp.CallToolRequest{} + getMeRequest.Params.Name = "get_me" + + t.Log("Getting current user...") + resp, err := mcpClient.CallTool(ctx, getMeRequest) + require.NoError(t, err, "expected to call 'get_me' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + require.False(t, resp.IsError, "expected result not to be an error") + require.Len(t, resp.Content, 1, "expected content to have one item") + + textContent, ok := resp.Content[0].(mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + + var trimmedGetMeText struct { + Login string `json:"login"` + } + err = json.Unmarshal([]byte(textContent.Text), &trimmedGetMeText) + require.NoError(t, err, "expected to unmarshal text content successfully") + + currentOwner := trimmedGetMeText.Login + + // Then create a repository with a README (via autoInit) + repoName := fmt.Sprintf("github-mcp-server-e2e-%s-%d", t.Name(), time.Now().UnixMilli()) + createRepoRequest := mcp.CallToolRequest{} + createRepoRequest.Params.Name = "create_repository" + createRepoRequest.Params.Arguments = map[string]any{ + "name": repoName, + "private": true, + "autoInit": true, + } + t.Logf("Creating repository %s/%s...", currentOwner, repoName) + _, err = mcpClient.CallTool(ctx, createRepoRequest) + require.NoError(t, err, "expected to call 'get_me' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + // Cleanup the repository after the test + t.Cleanup(func() { + // MCP Server doesn't support deletions, but we can use the GitHub Client + ghClient := gogithub.NewClient(nil).WithAuthToken(getE2EToken(t)) + t.Logf("Deleting repository %s/%s...", currentOwner, repoName) + _, err := ghClient.Repositories.Delete(context.Background(), currentOwner, repoName) + require.NoError(t, err, "expected to delete repository successfully") + }) + + // Create a branch on which to create a new commit + createBranchRequest := mcp.CallToolRequest{} + createBranchRequest.Params.Name = "create_branch" + createBranchRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "branch": "test-branch", + "from_branch": "main", + } + + t.Logf("Creating branch in %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, createBranchRequest) + require.NoError(t, err, "expected to call 'create_branch' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + // Create a commit with a new file + commitRequest := mcp.CallToolRequest{} + commitRequest.Params.Name = "create_or_update_file" + commitRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "path": "test-dir/test-file.txt", + "content": fmt.Sprintf("Created by e2e test %s", t.Name()), + "message": "Add test file", + "branch": "test-branch", + } + + t.Logf("Creating commit with new file in %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, commitRequest) + require.NoError(t, err, "expected to call 'create_or_update_file' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + textContent, ok = resp.Content[0].(mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + + var trimmedCommitText struct { + SHA string `json:"sha"` + } + err = json.Unmarshal([]byte(textContent.Text), &trimmedCommitText) + require.NoError(t, err, "expected to unmarshal text content successfully") + + // Check the file exists + getFileContentsRequest := mcp.CallToolRequest{} + getFileContentsRequest.Params.Name = "get_file_contents" + getFileContentsRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "path": "test-dir/test-file.txt", + "branch": "test-branch", + } + + t.Logf("Getting file contents in %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, getFileContentsRequest) + require.NoError(t, err, "expected to call 'get_file_contents' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + textContent, ok = resp.Content[0].(mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + + var trimmedGetFileText struct { + Content string `json:"content"` + } + err = json.Unmarshal([]byte(textContent.Text), &trimmedGetFileText) + require.NoError(t, err, "expected to unmarshal text content successfully") + b, err := base64.StdEncoding.DecodeString(trimmedGetFileText.Content) + require.NoError(t, err, "expected to decode base64 content successfully") + require.Equal(t, fmt.Sprintf("Created by e2e test %s", t.Name()), string(b), "expected file content to match") + + // Delete the directory containing the file + deleteFileRequest := mcp.CallToolRequest{} + deleteFileRequest.Params.Name = "delete_file" + deleteFileRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "path": "test-dir", + "message": "Delete test directory", + "branch": "test-branch", + } + + t.Logf("Deleting directory in %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, deleteFileRequest) + require.NoError(t, err, "expected to call 'delete_file' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + // See that there is a commit that removes the directory + listCommitsRequest := mcp.CallToolRequest{} + listCommitsRequest.Params.Name = "list_commits" + listCommitsRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "sha": "test-branch", // can be SHA or branch, which is an unfortunate API design + } + + t.Logf("Listing commits in %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, listCommitsRequest) + require.NoError(t, err, "expected to call 'list_commits' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + textContent, ok = resp.Content[0].(mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + + var trimmedListCommitsText []struct { + SHA string `json:"sha"` + Commit struct { + Message string `json:"message"` + } + Files []struct { + Filename string `json:"filename"` + Deletions int `json:"deletions"` + } `json:"files"` + } + err = json.Unmarshal([]byte(textContent.Text), &trimmedListCommitsText) + require.NoError(t, err, "expected to unmarshal text content successfully") + require.GreaterOrEqual(t, len(trimmedListCommitsText), 1, "expected to find at least one commit") + + deletionCommit := trimmedListCommitsText[0] + require.Equal(t, "Delete test directory", deletionCommit.Commit.Message, "expected commit message to match") + + // Now get the commit so we can look at the file changes because list_commits doesn't include them + getCommitRequest := mcp.CallToolRequest{} + getCommitRequest.Params.Name = "get_commit" + getCommitRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "sha": deletionCommit.SHA, + } + + t.Logf("Getting commit %s/%s:%s...", currentOwner, repoName, deletionCommit.SHA) + resp, err = mcpClient.CallTool(ctx, getCommitRequest) + require.NoError(t, err, "expected to call 'get_commit' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + textContent, ok = resp.Content[0].(mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + + var trimmedGetCommitText struct { + Files []struct { + Filename string `json:"filename"` + Deletions int `json:"deletions"` + } + } + err = json.Unmarshal([]byte(textContent.Text), &trimmedGetCommitText) + require.NoError(t, err, "expected to unmarshal text content successfully") + require.Len(t, trimmedGetCommitText.Files, 1, "expected to find one file change") + require.Equal(t, "test-dir/test-file.txt", trimmedGetCommitText.Files[0].Filename, "expected filename to match") + require.Equal(t, 1, trimmedGetCommitText.Files[0].Deletions, "expected one deletion") +} + +func TestRequestCopilotReview(t *testing.T) { + t.Parallel() + + mcpClient := setupMCPClient(t) + + ctx := context.Background() + + // First, who am I + getMeRequest := mcp.CallToolRequest{} + getMeRequest.Params.Name = "get_me" + + t.Log("Getting current user...") + resp, err := mcpClient.CallTool(ctx, getMeRequest) + require.NoError(t, err, "expected to call 'get_me' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + require.False(t, resp.IsError, "expected result not to be an error") + require.Len(t, resp.Content, 1, "expected content to have one item") + + textContent, ok := resp.Content[0].(mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + + var trimmedGetMeText struct { + Login string `json:"login"` + } + err = json.Unmarshal([]byte(textContent.Text), &trimmedGetMeText) + require.NoError(t, err, "expected to unmarshal text content successfully") + + currentOwner := trimmedGetMeText.Login + + // Then create a repository with a README (via autoInit) + repoName := fmt.Sprintf("github-mcp-server-e2e-%s-%d", t.Name(), time.Now().UnixMilli()) + createRepoRequest := mcp.CallToolRequest{} + createRepoRequest.Params.Name = "create_repository" + createRepoRequest.Params.Arguments = map[string]any{ + "name": repoName, + "private": true, + "autoInit": true, + } + + t.Logf("Creating repository %s/%s...", currentOwner, repoName) + _, err = mcpClient.CallTool(ctx, createRepoRequest) + require.NoError(t, err, "expected to call 'create_repository' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + // Cleanup the repository after the test + t.Cleanup(func() { + // MCP Server doesn't support deletions, but we can use the GitHub Client + ghClient := gogithub.NewClient(nil).WithAuthToken(getE2EToken(t)) + t.Logf("Deleting repository %s/%s...", currentOwner, repoName) + _, err := ghClient.Repositories.Delete(context.Background(), currentOwner, repoName) + require.NoError(t, err, "expected to delete repository successfully") + }) + + // Create a branch on which to create a new commit + createBranchRequest := mcp.CallToolRequest{} + createBranchRequest.Params.Name = "create_branch" + createBranchRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "branch": "test-branch", + "from_branch": "main", + } + + t.Logf("Creating branch in %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, createBranchRequest) + require.NoError(t, err, "expected to call 'create_branch' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + // Create a commit with a new file + commitRequest := mcp.CallToolRequest{} + commitRequest.Params.Name = "create_or_update_file" + commitRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "path": "test-file.txt", + "content": fmt.Sprintf("Created by e2e test %s", t.Name()), + "message": "Add test file", + "branch": "test-branch", + } + + t.Logf("Creating commit with new file in %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, commitRequest) + require.NoError(t, err, "expected to call 'create_or_update_file' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + textContent, ok = resp.Content[0].(mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + + var trimmedCommitText struct { + SHA string `json:"sha"` + } + err = json.Unmarshal([]byte(textContent.Text), &trimmedCommitText) + require.NoError(t, err, "expected to unmarshal text content successfully") + commitId := trimmedCommitText.SHA + + // Create a pull request + prRequest := mcp.CallToolRequest{} + prRequest.Params.Name = "create_pull_request" + prRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "title": "Test PR", + "body": "This is a test PR", + "head": "test-branch", + "base": "main", + "commitId": commitId, + } + + t.Logf("Creating pull request in %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, prRequest) + require.NoError(t, err, "expected to call 'create_pull_request' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + // Request a copilot review + requestCopilotReviewRequest := mcp.CallToolRequest{} + requestCopilotReviewRequest.Params.Name = "request_copilot_review" + requestCopilotReviewRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "pullNumber": 1, + } + + t.Logf("Requesting Copilot review for pull request in %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, requestCopilotReviewRequest) + require.NoError(t, err, "expected to call 'request_copilot_review' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + textContent, ok = resp.Content[0].(mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + require.Equal(t, "", textContent.Text, "expected content to be empty") + + // Finally, get requested reviews and see copilot is in there + // MCP Server doesn't support requesting reviews yet, but we can use the GitHub Client + ghClient := gogithub.NewClient(nil).WithAuthToken(getE2EToken(t)) + t.Logf("Getting reviews for pull request in %s/%s...", currentOwner, repoName) + reviewRequests, _, err := ghClient.PullRequests.ListReviewers(context.Background(), currentOwner, repoName, 1, nil) + require.NoError(t, err, "expected to get review requests successfully") + + // Check that there is one review request from copilot + require.Len(t, reviewRequests.Users, 1, "expected to find one review request") + require.Equal(t, "Copilot", *reviewRequests.Users[0].Login, "expected review request to be for Copilot") + require.Equal(t, "Bot", *reviewRequests.Users[0].Type, "expected review request to be for Bot") +} diff --git a/go.mod b/go.mod index 7c09fba9..7b850829 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,9 @@ module github.com/github/github-mcp-server go 1.23.7 require ( - github.com/docker/docker v28.0.4+incompatible - github.com/google/go-cmp v0.7.0 github.com/google/go-github/v69 v69.2.0 - github.com/mark3labs/mcp-go v0.20.1 - github.com/migueleliasweb/go-github-mock v1.1.0 + github.com/mark3labs/mcp-go v0.27.0 + github.com/migueleliasweb/go-github-mock v1.3.0 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 @@ -15,31 +13,17 @@ require ( ) require ( - github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/containerd/log v0.1.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/distribution/reference v0.6.0 // indirect - github.com/docker/go-connections v0.5.0 // indirect - github.com/docker/go-units v0.5.0 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect - github.com/go-logr/logr v1.4.2 // indirect - github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/google/go-github/v64 v64.0.0 // indirect + github.com/google/go-github/v71 v71.0.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/mux v1.8.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/moby/docker-image-spec v1.3.1 // indirect - github.com/moby/term v0.5.0 // indirect - github.com/morikuni/aec v1.0.0 // indirect - github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/sagikazarmark/locafero v0.9.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.14.0 // indirect @@ -47,20 +31,10 @@ require ( github.com/spf13/pflag v1.0.6 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect - go.opentelemetry.io/otel v1.35.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 // indirect - go.opentelemetry.io/otel/metric v1.35.0 // indirect - go.opentelemetry.io/otel/sdk v1.35.0 // indirect - go.opentelemetry.io/otel/trace v1.35.0 // indirect - go.opentelemetry.io/proto/otlp v1.5.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.23.0 // indirect golang.org/x/time v0.5.0 // indirect - google.golang.org/protobuf v1.36.5 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - gotest.tools/v3 v3.5.1 // indirect ) diff --git a/go.sum b/go.sum index 3378b4fd..8b960ad5 100644 --- a/go.sum +++ b/go.sum @@ -1,80 +1,42 @@ -github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= -github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= -github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= -github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= -github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v28.0.4+incompatible h1:JNNkBctYKurkw6FrHfKqY0nKIDf5nrbxjVBtS+cdcok= -github.com/docker/docker v28.0.4+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= -github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= -github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= -github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-github/v64 v64.0.0 h1:4G61sozmY3eiPAjjoOHponXDBONm+utovTKbyUb2Qdg= -github.com/google/go-github/v64 v64.0.0/go.mod h1:xB3vqMQNdHzilXBiO2I+M7iEFtHf+DP/omBOv6tQzVo= github.com/google/go-github/v69 v69.2.0 h1:wR+Wi/fN2zdUx9YxSmYE0ktiX9IAR/BeePzeaUUbEHE= github.com/google/go-github/v69 v69.2.0/go.mod h1:xne4jymxLR6Uj9b7J7PyTpkMYstEMMwGZa0Aehh1azM= +github.com/google/go-github/v71 v71.0.0 h1:Zi16OymGKZZMm8ZliffVVJ/Q9YZreDKONCr+WUd0Z30= +github.com/google/go-github/v71 v71.0.0/go.mod h1:URZXObp2BLlMjwu0O8g4y6VBneUj2bCHgnI8FfgZ51M= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 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/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mark3labs/mcp-go v0.20.1 h1:E1Bbx9K8d8kQmDZ1QHblM38c7UU2evQ2LlkANk1U/zw= -github.com/mark3labs/mcp-go v0.20.1/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE= -github.com/migueleliasweb/go-github-mock v1.1.0 h1:GKaOBPsrPGkAKgtfuWY8MclS1xR6MInkx1SexJucMwE= -github.com/migueleliasweb/go-github-mock v1.1.0/go.mod h1:pYe/XlGs4BGMfRY4vmeixVsODHnVDDhJ9zoi0qzSMHc= -github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= -github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= -github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= -github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= -github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= -github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= -github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= -github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/mark3labs/mcp-go v0.27.0 h1:iok9kU4DUIU2/XVLgFS2Q9biIDqstC0jY4EQTK2Erzc= +github.com/mark3labs/mcp-go v0.27.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= +github.com/migueleliasweb/go-github-mock v1.3.0 h1:2sVP9JEMB2ubQw1IKto3/fzF51oFC6eVWOOFDgQoq88= +github.com/migueleliasweb/go-github-mock v1.3.0/go.mod h1:ipQhV8fTcj/G6m7BKzin08GaJ/3B5/SonRAkgrk0zCY= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -105,75 +67,19 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= -go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= -go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= -go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= -go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= -go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= -go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= -go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= -go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= -go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= -go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -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-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -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.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/api v0.0.0-20250102185135-69823020774d h1:H8tOf8XM88HvKqLTxe755haY6r1fqqzLbEnfrmLXlSA= -google.golang.org/genproto/googleapis/api v0.0.0-20250102185135-69823020774d/go.mod h1:2v7Z7gP2ZUOGsaFyxATQSRoBnKygqVq2Cwnvom7QiqY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d h1:xJJRGY7TJcvIlpSrN3K6LAWgNFUILlO+OMAqtg9aqnw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d/go.mod h1:3ENsm/5D1mzDyhpzeRi1NR784I0BcofWBoSc5QqqMK4= -google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU= -google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= -google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= -google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= -gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go new file mode 100644 index 00000000..3434d9cd --- /dev/null +++ b/internal/ghmcp/server.go @@ -0,0 +1,215 @@ +package ghmcp + +import ( + "context" + "fmt" + "io" + "log" + "os" + "os/signal" + "syscall" + + "github.com/github/github-mcp-server/pkg/github" + mcplog "github.com/github/github-mcp-server/pkg/log" + "github.com/github/github-mcp-server/pkg/translations" + gogithub "github.com/google/go-github/v69/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/sirupsen/logrus" +) + +type MCPServerConfig struct { + // Version of the server + Version string + + // GitHub Host to target for API requests (e.g. github.com or github.enterprise.com) + Host string + + // GitHub Token to authenticate with the GitHub API + Token string + + // EnabledToolsets is a list of toolsets to enable + // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration + EnabledToolsets []string + + // Whether to enable dynamic toolsets + // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#dynamic-tool-discovery + DynamicToolsets bool + + // ReadOnly indicates if we should only offer read-only tools + ReadOnly bool + + // Translator provides translated text for the server tooling + Translator translations.TranslationHelperFunc +} + +func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) { + ghClient := gogithub.NewClient(nil).WithAuthToken(cfg.Token) + ghClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", cfg.Version) + + if cfg.Host != "" { + var err error + ghClient, err = ghClient.WithEnterpriseURLs(cfg.Host, cfg.Host) + if err != nil { + return nil, fmt.Errorf("failed to create GitHub client with host: %w", err) + } + } + + // When a client send an initialize request, update the user agent to include the client info. + beforeInit := func(_ context.Context, _ any, message *mcp.InitializeRequest) { + ghClient.UserAgent = fmt.Sprintf( + "github-mcp-server/%s (%s/%s)", + cfg.Version, + message.Params.ClientInfo.Name, + message.Params.ClientInfo.Version, + ) + } + + hooks := &server.Hooks{ + OnBeforeInitialize: []server.OnBeforeInitializeFunc{beforeInit}, + } + + ghServer := github.NewServer(cfg.Version, server.WithHooks(hooks)) + + enabledToolsets := cfg.EnabledToolsets + if cfg.DynamicToolsets { + // filter "all" from the enabled toolsets + enabledToolsets = make([]string, 0, len(cfg.EnabledToolsets)) + for _, toolset := range cfg.EnabledToolsets { + if toolset != "all" { + enabledToolsets = append(enabledToolsets, toolset) + } + } + } + + getClient := func(_ context.Context) (*gogithub.Client, error) { + return ghClient, nil // closing over client + } + + // Create default toolsets + toolsets, err := github.InitToolsets( + enabledToolsets, + cfg.ReadOnly, + getClient, + cfg.Translator, + ) + if err != nil { + return nil, fmt.Errorf("failed to initialize toolsets: %w", err) + } + + context := github.InitContextToolset(getClient, cfg.Translator) + github.RegisterResources(ghServer, getClient, cfg.Translator) + + // Register the tools with the server + toolsets.RegisterTools(ghServer) + context.RegisterTools(ghServer) + + if cfg.DynamicToolsets { + dynamic := github.InitDynamicToolset(ghServer, toolsets, cfg.Translator) + dynamic.RegisterTools(ghServer) + } + + return ghServer, nil +} + +type StdioServerConfig struct { + // Version of the server + Version string + + // GitHub Host to target for API requests (e.g. github.com or github.enterprise.com) + Host string + + // GitHub Token to authenticate with the GitHub API + Token string + + // EnabledToolsets is a list of toolsets to enable + // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration + EnabledToolsets []string + + // Whether to enable dynamic toolsets + // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#dynamic-tool-discovery + DynamicToolsets bool + + // ReadOnly indicates if we should only register read-only tools + ReadOnly bool + + // ExportTranslations indicates if we should export translations + // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#i18n--overriding-descriptions + ExportTranslations bool + + // EnableCommandLogging indicates if we should log commands + EnableCommandLogging bool + + // Path to the log file if not stderr + LogFilePath string +} + +// RunStdioServer is not concurrent safe. +func RunStdioServer(cfg StdioServerConfig) error { + // Create app context + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + t, dumpTranslations := translations.TranslationHelper() + + ghServer, err := NewMCPServer(MCPServerConfig{ + Version: cfg.Version, + Host: cfg.Host, + Token: cfg.Token, + EnabledToolsets: cfg.EnabledToolsets, + DynamicToolsets: cfg.DynamicToolsets, + ReadOnly: cfg.ReadOnly, + Translator: t, + }) + if err != nil { + return fmt.Errorf("failed to create MCP server: %w", err) + } + + stdioServer := server.NewStdioServer(ghServer) + + logrusLogger := logrus.New() + if cfg.LogFilePath != "" { + file, err := os.OpenFile(cfg.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + if err != nil { + return fmt.Errorf("failed to open log file: %w", err) + } + + logrusLogger.SetLevel(logrus.DebugLevel) + logrusLogger.SetOutput(file) + } + stdLogger := log.New(logrusLogger.Writer(), "stdioserver", 0) + stdioServer.SetErrorLogger(stdLogger) + + if cfg.ExportTranslations { + // Once server is initialized, all translations are loaded + dumpTranslations() + } + + // Start listening for messages + errC := make(chan error, 1) + go func() { + in, out := io.Reader(os.Stdin), io.Writer(os.Stdout) + + if cfg.EnableCommandLogging { + loggedIO := mcplog.NewIOLogger(in, out, logrusLogger) + in, out = loggedIO, loggedIO + } + + errC <- stdioServer.Listen(ctx, in, out) + }() + + // Output github-mcp-server string + _, _ = fmt.Fprintf(os.Stderr, "GitHub MCP Server running on stdio\n") + + // Wait for shutdown signal + select { + case <-ctx.Done(): + logrusLogger.Infof("shutting down server...") + case err := <-errC: + if err != nil { + return fmt.Errorf("error running server: %w", err) + } + } + + return nil +} diff --git a/pkg/github/code_scanning.go b/pkg/github/code_scanning.go index b33f32c1..34a1b9ed 100644 --- a/pkg/github/code_scanning.go +++ b/pkg/github/code_scanning.go @@ -16,6 +16,10 @@ import ( func GetCodeScanningAlert(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_code_scanning_alert", mcp.WithDescription(t("TOOL_GET_CODE_SCANNING_ALERT_DESCRIPTION", "Get details of a specific code scanning alert in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_CODE_SCANNING_ALERT_USER_TITLE", "Get code scanning alert"), + ReadOnlyHint: toBoolPtr(true), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("The owner of the repository."), @@ -74,6 +78,10 @@ func GetCodeScanningAlert(getClient GetClientFn, t translations.TranslationHelpe func ListCodeScanningAlerts(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("list_code_scanning_alerts", mcp.WithDescription(t("TOOL_LIST_CODE_SCANNING_ALERTS_DESCRIPTION", "List code scanning alerts in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_CODE_SCANNING_ALERTS_USER_TITLE", "List code scanning alerts"), + ReadOnlyHint: toBoolPtr(true), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("The owner of the repository."), diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 1c91d703..0e8bcacb 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -16,6 +16,10 @@ import ( func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_me", mcp.WithDescription(t("TOOL_GET_ME_DESCRIPTION", "Get details of the authenticated GitHub user. Use this when a request include \"me\", \"my\"...")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_ME_USER_TITLE", "Get my user profile"), + ReadOnlyHint: toBoolPtr(true), + }), mcp.WithString("reason", mcp.Description("Optional: reason the session was created"), ), diff --git a/pkg/github/dynamic_tools.go b/pkg/github/dynamic_tools.go index d4d5f27a..0b098fb3 100644 --- a/pkg/github/dynamic_tools.go +++ b/pkg/github/dynamic_tools.go @@ -22,6 +22,11 @@ func ToolsetEnum(toolsetGroup *toolsets.ToolsetGroup) mcp.PropertyOption { func EnableToolset(s *server.MCPServer, toolsetGroup *toolsets.ToolsetGroup, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("enable_toolset", mcp.WithDescription(t("TOOL_ENABLE_TOOLSET_DESCRIPTION", "Enable one of the sets of tools the GitHub MCP server provides, use get_toolset_tools and list_available_toolsets first to see what this will enable")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_ENABLE_TOOLSET_USER_TITLE", "Enable a toolset"), + // Not modifying GitHub data so no need to show a warning + ReadOnlyHint: toBoolPtr(true), + }), mcp.WithString("toolset", mcp.Required(), mcp.Description("The name of the toolset to enable"), @@ -57,6 +62,10 @@ func EnableToolset(s *server.MCPServer, toolsetGroup *toolsets.ToolsetGroup, t t func ListAvailableToolsets(toolsetGroup *toolsets.ToolsetGroup, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("list_available_toolsets", mcp.WithDescription(t("TOOL_LIST_AVAILABLE_TOOLSETS_DESCRIPTION", "List all available toolsets this GitHub MCP server can offer, providing the enabled status of each. Use this when a task could be achieved with a GitHub tool and the currently available tools aren't enough. Call get_toolset_tools with these toolset names to discover specific tools you can call")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_AVAILABLE_TOOLSETS_USER_TITLE", "List available toolsets"), + ReadOnlyHint: toBoolPtr(true), + }), ), func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { // We need to convert the toolsetGroup back to a map for JSON serialization @@ -87,6 +96,10 @@ func ListAvailableToolsets(toolsetGroup *toolsets.ToolsetGroup, t translations.T func GetToolsetsTools(toolsetGroup *toolsets.ToolsetGroup, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_toolset_tools", mcp.WithDescription(t("TOOL_GET_TOOLSET_TOOLS_DESCRIPTION", "Lists all the capabilities that are enabled with the specified toolset, use this to get clarity on whether enabling a toolset would help you to complete a task")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_TOOLSET_TOOLS_USER_TITLE", "List all tools in a toolset"), + ReadOnlyHint: toBoolPtr(true), + }), mcp.WithString("toolset", mcp.Required(), mcp.Description("The name of the toolset you want to get the tools for"), diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index 40fc0b94..3032c938 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -10,6 +10,32 @@ import ( "github.com/stretchr/testify/require" ) +type expectations struct { + path string + queryParams map[string]string + requestBody any +} + +// expect is a helper function to create a partial mock that expects various +// request behaviors, such as path, query parameters, and request body. +func expect(t *testing.T, e expectations) *partialMock { + return &partialMock{ + t: t, + expectedPath: e.path, + expectedQueryParams: e.queryParams, + expectedRequestBody: e.requestBody, + } +} + +// expectPath is a helper function to create a partial mock that expects a +// request with the given path, with the ability to chain a response handler. +func expectPath(t *testing.T, expectedPath string) *partialMock { + return &partialMock{ + t: t, + expectedPath: expectedPath, + } +} + // expectQueryParams is a helper function to create a partial mock that expects a // request with the given query parameters, with the ability to chain a response handler. func expectQueryParams(t *testing.T, expectedQueryParams map[string]string) *partialMock { @@ -29,7 +55,9 @@ func expectRequestBody(t *testing.T, expectedRequestBody any) *partialMock { } type partialMock struct { - t *testing.T + t *testing.T + + expectedPath string expectedQueryParams map[string]string expectedRequestBody any } @@ -37,12 +65,8 @@ type partialMock struct { func (p *partialMock) andThen(responseHandler http.HandlerFunc) http.HandlerFunc { p.t.Helper() return func(w http.ResponseWriter, r *http.Request) { - if p.expectedRequestBody != nil { - var unmarshaledRequestBody any - err := json.NewDecoder(r.Body).Decode(&unmarshaledRequestBody) - require.NoError(p.t, err) - - require.Equal(p.t, p.expectedRequestBody, unmarshaledRequestBody) + if p.expectedPath != "" { + require.Equal(p.t, p.expectedPath, r.URL.Path) } if p.expectedQueryParams != nil { @@ -52,6 +76,14 @@ func (p *partialMock) andThen(responseHandler http.HandlerFunc) http.HandlerFunc } } + if p.expectedRequestBody != nil { + var unmarshaledRequestBody any + err := json.NewDecoder(r.Body).Decode(&unmarshaledRequestBody) + require.NoError(p.t, err) + + require.Equal(p.t, p.expectedRequestBody, unmarshaledRequestBody) + } + responseHandler(w, r) } } diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 1324bd56..7c8451d3 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -17,7 +17,11 @@ import ( // GetIssue creates a tool to get details of a specific issue in a GitHub repository. func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_issue", - mcp.WithDescription(t("TOOL_GET_ISSUE_DESCRIPTION", "Get details of a specific issue in a GitHub repository")), + mcp.WithDescription(t("TOOL_GET_ISSUE_DESCRIPTION", "Get details of a specific issue in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_ISSUE_USER_TITLE", "Get issue details"), + ReadOnlyHint: toBoolPtr(true), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("The owner of the repository"), @@ -75,7 +79,11 @@ func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool // AddIssueComment creates a tool to add a comment to an issue. func AddIssueComment(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("add_issue_comment", - mcp.WithDescription(t("TOOL_ADD_ISSUE_COMMENT_DESCRIPTION", "Add a comment to an existing issue")), + mcp.WithDescription(t("TOOL_ADD_ISSUE_COMMENT_DESCRIPTION", "Add a comment to a specific issue in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_ADD_ISSUE_COMMENT_USER_TITLE", "Add comment to issue"), + ReadOnlyHint: toBoolPtr(false), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), @@ -145,7 +153,11 @@ func AddIssueComment(getClient GetClientFn, t translations.TranslationHelperFunc // SearchIssues creates a tool to search for issues and pull requests. func SearchIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("search_issues", - mcp.WithDescription(t("TOOL_SEARCH_ISSUES_DESCRIPTION", "Search for issues and pull requests across GitHub repositories")), + mcp.WithDescription(t("TOOL_SEARCH_ISSUES_DESCRIPTION", "Search for issues in GitHub repositories.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_SEARCH_ISSUES_USER_TITLE", "Search issues"), + ReadOnlyHint: toBoolPtr(true), + }), mcp.WithString("q", mcp.Required(), mcp.Description("Search query using GitHub issues search syntax"), @@ -229,7 +241,11 @@ func SearchIssues(getClient GetClientFn, t translations.TranslationHelperFunc) ( // CreateIssue creates a tool to create a new issue in a GitHub repository. func CreateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("create_issue", - mcp.WithDescription(t("TOOL_CREATE_ISSUE_DESCRIPTION", "Create a new issue in a GitHub repository")), + mcp.WithDescription(t("TOOL_CREATE_ISSUE_DESCRIPTION", "Create a new issue in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_CREATE_ISSUE_USER_TITLE", "Open new issue"), + ReadOnlyHint: toBoolPtr(false), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), @@ -347,7 +363,11 @@ func CreateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t // ListIssues creates a tool to list and filter repository issues func ListIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("list_issues", - mcp.WithDescription(t("TOOL_LIST_ISSUES_DESCRIPTION", "List issues in a GitHub repository with filtering options")), + mcp.WithDescription(t("TOOL_LIST_ISSUES_DESCRIPTION", "List issues in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_ISSUES_USER_TITLE", "List issues"), + ReadOnlyHint: toBoolPtr(true), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), @@ -465,7 +485,11 @@ func ListIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (to // UpdateIssue creates a tool to update an existing issue in a GitHub repository. func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("update_issue", - mcp.WithDescription(t("TOOL_UPDATE_ISSUE_DESCRIPTION", "Update an existing issue in a GitHub repository")), + mcp.WithDescription(t("TOOL_UPDATE_ISSUE_DESCRIPTION", "Update an existing issue in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_UPDATE_ISSUE_USER_TITLE", "Edit issue"), + ReadOnlyHint: toBoolPtr(false), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), @@ -607,7 +631,11 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t // GetIssueComments creates a tool to get comments for a GitHub issue. func GetIssueComments(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_issue_comments", - mcp.WithDescription(t("TOOL_GET_ISSUE_COMMENTS_DESCRIPTION", "Get comments for a GitHub issue")), + mcp.WithDescription(t("TOOL_GET_ISSUE_COMMENTS_DESCRIPTION", "Get comments for a specific issue in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_ISSUE_COMMENTS_USER_TITLE", "Get issue comments"), + ReadOnlyHint: toBoolPtr(true), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 1ecd209e..f9039d2f 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -16,7 +16,11 @@ import ( // GetPullRequest creates a tool to get details of a specific pull request. func GetPullRequest(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_pull_request", - mcp.WithDescription(t("TOOL_GET_PULL_REQUEST_DESCRIPTION", "Get details of a specific pull request")), + mcp.WithDescription(t("TOOL_GET_PULL_REQUEST_DESCRIPTION", "Get details of a specific pull request in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_PULL_REQUEST_USER_TITLE", "Get pull request details"), + ReadOnlyHint: toBoolPtr(true), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), @@ -74,7 +78,11 @@ func GetPullRequest(getClient GetClientFn, t translations.TranslationHelperFunc) // UpdatePullRequest creates a tool to update an existing pull request. func UpdatePullRequest(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("update_pull_request", - mcp.WithDescription(t("TOOL_UPDATE_PULL_REQUEST_DESCRIPTION", "Update an existing pull request in a GitHub repository")), + mcp.WithDescription(t("TOOL_UPDATE_PULL_REQUEST_DESCRIPTION", "Update an existing pull request in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_UPDATE_PULL_REQUEST_USER_TITLE", "Edit pull request"), + ReadOnlyHint: toBoolPtr(false), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), @@ -191,7 +199,11 @@ func UpdatePullRequest(getClient GetClientFn, t translations.TranslationHelperFu // ListPullRequests creates a tool to list and filter repository pull requests. func ListPullRequests(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("list_pull_requests", - mcp.WithDescription(t("TOOL_LIST_PULL_REQUESTS_DESCRIPTION", "List and filter repository pull requests")), + mcp.WithDescription(t("TOOL_LIST_PULL_REQUESTS_DESCRIPTION", "List pull requests in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_PULL_REQUESTS_USER_TITLE", "List pull requests"), + ReadOnlyHint: toBoolPtr(true), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), @@ -296,7 +308,11 @@ func ListPullRequests(getClient GetClientFn, t translations.TranslationHelperFun // MergePullRequest creates a tool to merge a pull request. func MergePullRequest(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("merge_pull_request", - mcp.WithDescription(t("TOOL_MERGE_PULL_REQUEST_DESCRIPTION", "Merge a pull request")), + mcp.WithDescription(t("TOOL_MERGE_PULL_REQUEST_DESCRIPTION", "Merge a pull request in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_MERGE_PULL_REQUEST_USER_TITLE", "Merge pull request"), + ReadOnlyHint: toBoolPtr(false), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), @@ -381,7 +397,11 @@ func MergePullRequest(getClient GetClientFn, t translations.TranslationHelperFun // GetPullRequestFiles creates a tool to get the list of files changed in a pull request. func GetPullRequestFiles(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_pull_request_files", - mcp.WithDescription(t("TOOL_GET_PULL_REQUEST_FILES_DESCRIPTION", "Get the list of files changed in a pull request")), + mcp.WithDescription(t("TOOL_GET_PULL_REQUEST_FILES_DESCRIPTION", "Get the files changed in a specific pull request.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_PULL_REQUEST_FILES_USER_TITLE", "Get pull request files"), + ReadOnlyHint: toBoolPtr(true), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), @@ -440,7 +460,11 @@ func GetPullRequestFiles(getClient GetClientFn, t translations.TranslationHelper // GetPullRequestStatus creates a tool to get the combined status of all status checks for a pull request. func GetPullRequestStatus(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_pull_request_status", - mcp.WithDescription(t("TOOL_GET_PULL_REQUEST_STATUS_DESCRIPTION", "Get the combined status of all status checks for a pull request")), + mcp.WithDescription(t("TOOL_GET_PULL_REQUEST_STATUS_DESCRIPTION", "Get the status of a specific pull request.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_PULL_REQUEST_STATUS_USER_TITLE", "Get pull request status checks"), + ReadOnlyHint: toBoolPtr(true), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), @@ -513,7 +537,11 @@ func GetPullRequestStatus(getClient GetClientFn, t translations.TranslationHelpe // UpdatePullRequestBranch creates a tool to update a pull request branch with the latest changes from the base branch. func UpdatePullRequestBranch(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("update_pull_request_branch", - mcp.WithDescription(t("TOOL_UPDATE_PULL_REQUEST_BRANCH_DESCRIPTION", "Update a pull request branch with the latest changes from the base branch")), + mcp.WithDescription(t("TOOL_UPDATE_PULL_REQUEST_BRANCH_DESCRIPTION", "Update the branch of a pull request with the latest changes from the base branch.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_UPDATE_PULL_REQUEST_BRANCH_USER_TITLE", "Update pull request branch"), + ReadOnlyHint: toBoolPtr(false), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), @@ -587,7 +615,11 @@ func UpdatePullRequestBranch(getClient GetClientFn, t translations.TranslationHe // GetPullRequestComments creates a tool to get the review comments on a pull request. func GetPullRequestComments(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_pull_request_comments", - mcp.WithDescription(t("TOOL_GET_PULL_REQUEST_COMMENTS_DESCRIPTION", "Get the review comments on a pull request")), + mcp.WithDescription(t("TOOL_GET_PULL_REQUEST_COMMENTS_DESCRIPTION", "Get comments for a specific pull request.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_PULL_REQUEST_COMMENTS_USER_TITLE", "Get pull request comments"), + ReadOnlyHint: toBoolPtr(true), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), @@ -651,7 +683,11 @@ func GetPullRequestComments(getClient GetClientFn, t translations.TranslationHel // AddPullRequestReviewComment creates a tool to add a review comment to a pull request. func AddPullRequestReviewComment(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("add_pull_request_review_comment", - mcp.WithDescription(t("TOOL_ADD_PULL_REQUEST_COMMENT_DESCRIPTION", "Add a review comment to a pull request")), + mcp.WithDescription(t("TOOL_ADD_PULL_REQUEST_REVIEW_COMMENT_DESCRIPTION", "Add a review comment to a pull request.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_ADD_PULL_REQUEST_REVIEW_COMMENT_USER_TITLE", "Add review comment to pull request"), + ReadOnlyHint: toBoolPtr(false), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), @@ -821,7 +857,11 @@ func AddPullRequestReviewComment(getClient GetClientFn, t translations.Translati // GetPullRequestReviews creates a tool to get the reviews on a pull request. func GetPullRequestReviews(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_pull_request_reviews", - mcp.WithDescription(t("TOOL_GET_PULL_REQUEST_REVIEWS_DESCRIPTION", "Get the reviews on a pull request")), + mcp.WithDescription(t("TOOL_GET_PULL_REQUEST_REVIEWS_DESCRIPTION", "Get reviews for a specific pull request.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_PULL_REQUEST_REVIEWS_USER_TITLE", "Get pull request reviews"), + ReadOnlyHint: toBoolPtr(true), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), @@ -879,7 +919,11 @@ func GetPullRequestReviews(getClient GetClientFn, t translations.TranslationHelp // CreatePullRequestReview creates a tool to submit a review on a pull request. func CreatePullRequestReview(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("create_pull_request_review", - mcp.WithDescription(t("TOOL_CREATE_PULL_REQUEST_REVIEW_DESCRIPTION", "Create a review on a pull request")), + mcp.WithDescription(t("TOOL_CREATE_PULL_REQUEST_REVIEW_DESCRIPTION", "Create a review for a pull request.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_CREATE_PULL_REQUEST_REVIEW_USER_TITLE", "Submit pull request review"), + ReadOnlyHint: toBoolPtr(false), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), @@ -1091,7 +1135,11 @@ func CreatePullRequestReview(getClient GetClientFn, t translations.TranslationHe // CreatePullRequest creates a tool to create a new pull request. func CreatePullRequest(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("create_pull_request", - mcp.WithDescription(t("TOOL_CREATE_PULL_REQUEST_DESCRIPTION", "Create a new pull request in a GitHub repository")), + mcp.WithDescription(t("TOOL_CREATE_PULL_REQUEST_DESCRIPTION", "Create a new pull request in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_CREATE_PULL_REQUEST_USER_TITLE", "Open new pull request"), + ReadOnlyHint: toBoolPtr(false), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), @@ -1198,3 +1246,75 @@ func CreatePullRequest(getClient GetClientFn, t translations.TranslationHelperFu return mcp.NewToolResultText(string(r)), nil } } + +// RequestCopilotReview creates a tool to request a Copilot review for a pull request. +// Note that this tool will not work on GHES where this feature is unsupported. In future, we should not expose this +// tool if the configured host does not support it. +func RequestCopilotReview(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + return mcp.NewTool("request_copilot_review", + mcp.WithDescription(t("TOOL_REQUEST_COPILOT_REVIEW_DESCRIPTION", "Request a GitHub Copilot code review for a pull request. Use this for automated feedback on pull requests, usually before requesting a human reviewer.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_REQUEST_COPILOT_REVIEW_USER_TITLE", "Request Copilot review"), + ReadOnlyHint: toBoolPtr(false), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("pullNumber", + mcp.Required(), + mcp.Description("Pull request number"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := requiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + repo, err := requiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + pullNumber, err := RequiredInt(request, "pullNumber") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + _, resp, err := client.PullRequests.RequestReviewers( + ctx, + owner, + repo, + pullNumber, + github.ReviewersRequest{ + // The login name of the copilot reviewer bot + Reviewers: []string{"copilot-pull-request-reviewer[bot]"}, + }, + ) + if err != nil { + return nil, fmt.Errorf("failed to request copilot review: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to request copilot review: %s", string(body))), nil + } + + // Return nothing on success, as there's not much value in returning the Pull Request itself + return mcp.NewToolResultText(""), nil + } +} diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index bb372624..fe60e598 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -1916,3 +1916,112 @@ func Test_AddPullRequestReviewComment(t *testing.T) { }) } } + +func Test_RequestCopilotReview(t *testing.T) { + t.Parallel() + + mockClient := github.NewClient(nil) + tool, _ := RequestCopilotReview(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "request_copilot_review", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "pullNumber") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber"}) + + // Setup mock PR for success case + mockPR := &github.PullRequest{ + Number: github.Ptr(42), + Title: github.Ptr("Test PR"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), + Head: &github.PullRequestBranch{ + SHA: github.Ptr("abcd1234"), + Ref: github.Ptr("feature-branch"), + }, + Base: &github.PullRequestBranch{ + Ref: github.Ptr("main"), + }, + Body: github.Ptr("This is a test PR"), + User: &github.User{ + Login: github.Ptr("testuser"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful request", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, + expect(t, expectations{ + path: "/repos/owner/repo/pulls/1/requested_reviewers", + requestBody: map[string]any{ + "reviewers": []any{"copilot-pull-request-reviewer[bot]"}, + }, + }).andThen( + mockResponse(t, http.StatusCreated, mockPR), + ), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(1), + }, + expectError: false, + }, + { + name: "request fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to request copilot review", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + client := github.NewClient(tc.mockedClient) + _, handler := RequestCopilotReview(stubGetClientFn(client), translations.NullTranslationHelper) + + request := createMCPRequest(tc.requestArgs) + + result, err := handler(context.Background(), request) + + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + require.NoError(t, err) + assert.NotNil(t, result) + assert.Len(t, result.Content, 1) + + textContent := getTextResult(t, result) + require.Equal(t, "", textContent.Text) + }) + } +} diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 51948730..4403e2a1 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -16,6 +16,10 @@ import ( func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_commit", mcp.WithDescription(t("TOOL_GET_COMMITS_DESCRIPTION", "Get details for a commit from a GitHub repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_COMMITS_USER_TITLE", "Get commit details"), + ReadOnlyHint: toBoolPtr(true), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), @@ -84,6 +88,10 @@ func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (too func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("list_commits", mcp.WithDescription(t("TOOL_LIST_COMMITS_DESCRIPTION", "Get list of commits of a branch in a GitHub repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_COMMITS_USER_TITLE", "List commits"), + ReadOnlyHint: toBoolPtr(true), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), @@ -154,6 +162,10 @@ func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (t func ListBranches(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("list_branches", mcp.WithDescription(t("TOOL_LIST_BRANCHES_DESCRIPTION", "List branches in a GitHub repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_BRANCHES_USER_TITLE", "List branches"), + ReadOnlyHint: toBoolPtr(true), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), @@ -216,7 +228,11 @@ func ListBranches(getClient GetClientFn, t translations.TranslationHelperFunc) ( // CreateOrUpdateFile creates a tool to create or update a file in a GitHub repository. func CreateOrUpdateFile(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("create_or_update_file", - mcp.WithDescription(t("TOOL_CREATE_OR_UPDATE_FILE_DESCRIPTION", "Create or update a single file in a GitHub repository")), + mcp.WithDescription(t("TOOL_CREATE_OR_UPDATE_FILE_DESCRIPTION", "Create or update a single file in a GitHub repository. If updating, you must provide the SHA of the file you want to update.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_CREATE_OR_UPDATE_FILE_USER_TITLE", "Create or update file"), + ReadOnlyHint: toBoolPtr(false), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner (username or organization)"), @@ -271,7 +287,7 @@ func CreateOrUpdateFile(getClient GetClientFn, t translations.TranslationHelperF return mcp.NewToolResultError(err.Error()), nil } - // Convert content to base64 + // json.Marshal encodes byte arrays with base64, which is required for the API. contentBytes := []byte(content) // Create the file options @@ -322,6 +338,10 @@ func CreateOrUpdateFile(getClient GetClientFn, t translations.TranslationHelperF func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("create_repository", mcp.WithDescription(t("TOOL_CREATE_REPOSITORY_DESCRIPTION", "Create a new GitHub repository in your account")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_CREATE_REPOSITORY_USER_TITLE", "Create repository"), + ReadOnlyHint: toBoolPtr(false), + }), mcp.WithString("name", mcp.Required(), mcp.Description("Repository name"), @@ -392,6 +412,10 @@ func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFun func GetFileContents(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_file_contents", mcp.WithDescription(t("TOOL_GET_FILE_CONTENTS_DESCRIPTION", "Get the contents of a file or directory from a GitHub repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_FILE_CONTENTS_USER_TITLE", "Get file or directory contents"), + ReadOnlyHint: toBoolPtr(true), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner (username or organization)"), @@ -465,6 +489,10 @@ func GetFileContents(getClient GetClientFn, t translations.TranslationHelperFunc func ForkRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("fork_repository", mcp.WithDescription(t("TOOL_FORK_REPOSITORY_DESCRIPTION", "Fork a GitHub repository to your account or specified organization")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_FORK_REPOSITORY_USER_TITLE", "Fork repository"), + ReadOnlyHint: toBoolPtr(false), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), @@ -528,10 +556,174 @@ func ForkRepository(getClient GetClientFn, t translations.TranslationHelperFunc) } } +// DeleteFile creates a tool to delete a file in a GitHub repository. +// This tool uses a more roundabout way of deleting a file than just using the client.Repositories.DeleteFile. +// This is because REST file deletion endpoint (and client.Repositories.DeleteFile) don't add commit signing to the deletion commit, +// unlike how the endpoint backing the create_or_update_files tool does. This appears to be a quirk of the API. +// The approach implemented here gets automatic commit signing when used with either the github-actions user or as an app, +// both of which suit an LLM well. +func DeleteFile(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("delete_file", + mcp.WithDescription(t("TOOL_DELETE_FILE_DESCRIPTION", "Delete a file from a GitHub repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_DELETE_FILE_USER_TITLE", "Delete file"), + ReadOnlyHint: toBoolPtr(false), + DestructiveHint: toBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner (username or organization)"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("path", + mcp.Required(), + mcp.Description("Path to the file to delete"), + ), + mcp.WithString("message", + mcp.Required(), + mcp.Description("Commit message"), + ), + mcp.WithString("branch", + mcp.Required(), + mcp.Description("Branch to delete the file from"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := requiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := requiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + path, err := requiredParam[string](request, "path") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + message, err := requiredParam[string](request, "message") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + branch, err := requiredParam[string](request, "branch") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // Get the reference for the branch + ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+branch) + if err != nil { + return nil, fmt.Errorf("failed to get branch reference: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + // Get the commit object that the branch points to + baseCommit, resp, err := client.Git.GetCommit(ctx, owner, repo, *ref.Object.SHA) + if err != nil { + return nil, fmt.Errorf("failed to get base commit: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to get commit: %s", string(body))), nil + } + + // Create a tree entry for the file deletion by setting SHA to nil + treeEntries := []*github.TreeEntry{ + { + Path: github.Ptr(path), + Mode: github.Ptr("100644"), // Regular file mode + Type: github.Ptr("blob"), + SHA: nil, // Setting SHA to nil deletes the file + }, + } + + // Create a new tree with the deletion + newTree, resp, err := client.Git.CreateTree(ctx, owner, repo, *baseCommit.Tree.SHA, treeEntries) + if err != nil { + return nil, fmt.Errorf("failed to create tree: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to create tree: %s", string(body))), nil + } + + // Create a new commit with the new tree + commit := &github.Commit{ + Message: github.Ptr(message), + Tree: newTree, + Parents: []*github.Commit{{SHA: baseCommit.SHA}}, + } + newCommit, resp, err := client.Git.CreateCommit(ctx, owner, repo, commit, nil) + if err != nil { + return nil, fmt.Errorf("failed to create commit: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to create commit: %s", string(body))), nil + } + + // Update the branch reference to point to the new commit + ref.Object.SHA = newCommit.SHA + _, resp, err = client.Git.UpdateRef(ctx, owner, repo, ref, false) + if err != nil { + return nil, fmt.Errorf("failed to update reference: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to update reference: %s", string(body))), nil + } + + // Create a response similar to what the DeleteFile API would return + response := map[string]interface{}{ + "commit": newCommit, + "content": nil, + } + + r, err := json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + // CreateBranch creates a tool to create a new branch. func CreateBranch(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("create_branch", mcp.WithDescription(t("TOOL_CREATE_BRANCH_DESCRIPTION", "Create a new branch in a GitHub repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_CREATE_BRANCH_USER_TITLE", "Create branch"), + ReadOnlyHint: toBoolPtr(false), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), @@ -617,6 +809,10 @@ func CreateBranch(getClient GetClientFn, t translations.TranslationHelperFunc) ( func PushFiles(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("push_files", mcp.WithDescription(t("TOOL_PUSH_FILES_DESCRIPTION", "Push multiple files to a GitHub repository in a single commit")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_PUSH_FILES_USER_TITLE", "Push files to repository"), + ReadOnlyHint: toBoolPtr(false), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), @@ -760,3 +956,147 @@ func PushFiles(getClient GetClientFn, t translations.TranslationHelperFunc) (too return mcp.NewToolResultText(string(r)), nil } } + +// ListTags creates a tool to list tags in a GitHub repository. +func ListTags(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_tags", + mcp.WithDescription(t("TOOL_LIST_TAGS_DESCRIPTION", "List git tags in a GitHub repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_TAGS_USER_TITLE", "List tags"), + ReadOnlyHint: toBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + WithPagination(), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := requiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := requiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + pagination, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &github.ListOptions{ + Page: pagination.page, + PerPage: pagination.perPage, + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + tags, resp, err := client.Repositories.ListTags(ctx, owner, repo, opts) + if err != nil { + return nil, fmt.Errorf("failed to list tags: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to list tags: %s", string(body))), nil + } + + r, err := json.Marshal(tags) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// GetTag creates a tool to get details about a specific tag in a GitHub repository. +func GetTag(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_tag", + mcp.WithDescription(t("TOOL_GET_TAG_DESCRIPTION", "Get details about a specific git tag in a GitHub repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_TAG_USER_TITLE", "Get tag details"), + ReadOnlyHint: toBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("tag", + mcp.Required(), + mcp.Description("Tag name"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := requiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := requiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + tag, err := requiredParam[string](request, "tag") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // First get the tag reference + ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/tags/"+tag) + if err != nil { + return nil, fmt.Errorf("failed to get tag reference: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to get tag reference: %s", string(body))), nil + } + + // Then get the tag object + tagObj, resp, err := client.Git.GetTag(ctx, owner, repo, *ref.Object.SHA) + if err != nil { + return nil, fmt.Errorf("failed to get tag object: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to get tag object: %s", string(body))), nil + } + + r, err := json.Marshal(tagObj) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 5b8129fe..6bb97da5 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -1528,3 +1528,449 @@ func Test_ListBranches(t *testing.T) { }) } } + +func Test_DeleteFile(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := DeleteFile(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "delete_file", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "path") + assert.Contains(t, tool.InputSchema.Properties, "message") + assert.Contains(t, tool.InputSchema.Properties, "branch") + // SHA is no longer required since we're using Git Data API + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "path", "message", "branch"}) + + // Setup mock objects for Git Data API + mockRef := &github.Reference{ + Ref: github.Ptr("refs/heads/main"), + Object: &github.GitObject{ + SHA: github.Ptr("abc123"), + }, + } + + mockCommit := &github.Commit{ + SHA: github.Ptr("abc123"), + Tree: &github.Tree{ + SHA: github.Ptr("def456"), + }, + } + + mockTree := &github.Tree{ + SHA: github.Ptr("ghi789"), + } + + mockNewCommit := &github.Commit{ + SHA: github.Ptr("jkl012"), + Message: github.Ptr("Delete example file"), + HTMLURL: github.Ptr("https://github.com/owner/repo/commit/jkl012"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedCommitSHA string + expectedErrMsg string + }{ + { + name: "successful file deletion using Git Data API", + mockedClient: mock.NewMockedHTTPClient( + // Get branch reference + mock.WithRequestMatch( + mock.GetReposGitRefByOwnerByRepoByRef, + mockRef, + ), + // Get commit + mock.WithRequestMatch( + mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + mockCommit, + ), + // Create tree + mock.WithRequestMatchHandler( + mock.PostReposGitTreesByOwnerByRepo, + expectRequestBody(t, map[string]interface{}{ + "base_tree": "def456", + "tree": []interface{}{ + map[string]interface{}{ + "path": "docs/example.md", + "mode": "100644", + "type": "blob", + "sha": nil, + }, + }, + }).andThen( + mockResponse(t, http.StatusCreated, mockTree), + ), + ), + // Create commit + mock.WithRequestMatchHandler( + mock.PostReposGitCommitsByOwnerByRepo, + expectRequestBody(t, map[string]interface{}{ + "message": "Delete example file", + "tree": "ghi789", + "parents": []interface{}{"abc123"}, + }).andThen( + mockResponse(t, http.StatusCreated, mockNewCommit), + ), + ), + // Update reference + mock.WithRequestMatchHandler( + mock.PatchReposGitRefsByOwnerByRepoByRef, + expectRequestBody(t, map[string]interface{}{ + "sha": "jkl012", + "force": false, + }).andThen( + mockResponse(t, http.StatusOK, &github.Reference{ + Ref: github.Ptr("refs/heads/main"), + Object: &github.GitObject{ + SHA: github.Ptr("jkl012"), + }, + }), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "docs/example.md", + "message": "Delete example file", + "branch": "main", + }, + expectError: false, + expectedCommitSHA: "jkl012", + }, + { + name: "file deletion fails - branch not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Reference not found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "docs/nonexistent.md", + "message": "Delete nonexistent file", + "branch": "nonexistent-branch", + }, + expectError: true, + expectedErrMsg: "failed to get branch reference", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := DeleteFile(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + require.NoError(t, err) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var response map[string]interface{} + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + + // Verify the response contains the expected commit + commit, ok := response["commit"].(map[string]interface{}) + require.True(t, ok) + commitSHA, ok := commit["sha"].(string) + require.True(t, ok) + assert.Equal(t, tc.expectedCommitSHA, commitSHA) + }) + } +} + +func Test_ListTags(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ListTags(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "list_tags", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + // Setup mock tags for success case + mockTags := []*github.RepositoryTag{ + { + Name: github.Ptr("v1.0.0"), + Commit: &github.Commit{ + SHA: github.Ptr("v1.0.0-tag-sha"), + URL: github.Ptr("https://api.github.com/repos/owner/repo/commits/abc123"), + }, + ZipballURL: github.Ptr("https://github.com/owner/repo/zipball/v1.0.0"), + TarballURL: github.Ptr("https://github.com/owner/repo/tarball/v1.0.0"), + }, + { + Name: github.Ptr("v0.9.0"), + Commit: &github.Commit{ + SHA: github.Ptr("v0.9.0-tag-sha"), + URL: github.Ptr("https://api.github.com/repos/owner/repo/commits/def456"), + }, + ZipballURL: github.Ptr("https://github.com/owner/repo/zipball/v0.9.0"), + TarballURL: github.Ptr("https://github.com/owner/repo/tarball/v0.9.0"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedTags []*github.RepositoryTag + expectedErrMsg string + }{ + { + name: "successful tags list", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposTagsByOwnerByRepo, + expectPath( + t, + "/repos/owner/repo/tags", + ).andThen( + mockResponse(t, http.StatusOK, mockTags), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + expectedTags: mockTags, + }, + { + name: "list tags fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposTagsByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message": "Internal Server Error"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "failed to list tags", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ListTags(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + require.NoError(t, err) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Parse and verify the result + var returnedTags []*github.RepositoryTag + err = json.Unmarshal([]byte(textContent.Text), &returnedTags) + require.NoError(t, err) + + // Verify each tag + require.Equal(t, len(tc.expectedTags), len(returnedTags)) + for i, expectedTag := range tc.expectedTags { + assert.Equal(t, *expectedTag.Name, *returnedTags[i].Name) + assert.Equal(t, *expectedTag.Commit.SHA, *returnedTags[i].Commit.SHA) + } + }) + } +} + +func Test_GetTag(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := GetTag(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "get_tag", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "tag") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "tag"}) + + mockTagRef := &github.Reference{ + Ref: github.Ptr("refs/tags/v1.0.0"), + Object: &github.GitObject{ + SHA: github.Ptr("v1.0.0-tag-sha"), + }, + } + + mockTagObj := &github.Tag{ + SHA: github.Ptr("v1.0.0-tag-sha"), + Tag: github.Ptr("v1.0.0"), + Message: github.Ptr("Release v1.0.0"), + Object: &github.GitObject{ + Type: github.Ptr("commit"), + SHA: github.Ptr("abc123"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedTag *github.Tag + expectedErrMsg string + }{ + { + name: "successful tag retrieval", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + expectPath( + t, + "/repos/owner/repo/git/ref/tags/v1.0.0", + ).andThen( + mockResponse(t, http.StatusOK, mockTagRef), + ), + ), + mock.WithRequestMatchHandler( + mock.GetReposGitTagsByOwnerByRepoByTagSha, + expectPath( + t, + "/repos/owner/repo/git/tags/v1.0.0-tag-sha", + ).andThen( + mockResponse(t, http.StatusOK, mockTagObj), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "tag": "v1.0.0", + }, + expectError: false, + expectedTag: mockTagObj, + }, + { + name: "tag reference not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Reference does not exist"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "tag": "v1.0.0", + }, + expectError: true, + expectedErrMsg: "failed to get tag reference", + }, + { + name: "tag object not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposGitRefByOwnerByRepoByRef, + mockTagRef, + ), + mock.WithRequestMatchHandler( + mock.GetReposGitTagsByOwnerByRepoByTagSha, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Tag object does not exist"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "tag": "v1.0.0", + }, + expectError: true, + expectedErrMsg: "failed to get tag object", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := GetTag(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + require.NoError(t, err) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Parse and verify the result + var returnedTag github.Tag + err = json.Unmarshal([]byte(textContent.Text), &returnedTag) + require.NoError(t, err) + + assert.Equal(t, *tc.expectedTag.SHA, *returnedTag.SHA) + assert.Equal(t, *tc.expectedTag.Tag, *returnedTag.Tag) + assert.Equal(t, *tc.expectedTag.Message, *returnedTag.Message) + assert.Equal(t, *tc.expectedTag.Object.Type, *returnedTag.Object.Type) + assert.Equal(t, *tc.expectedTag.Object.SHA, *returnedTag.Object.SHA) + }) + } +} diff --git a/pkg/github/search.go b/pkg/github/search.go index dc85c177..ac5e2994 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -16,6 +16,10 @@ import ( func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("search_repositories", mcp.WithDescription(t("TOOL_SEARCH_REPOSITORIES_DESCRIPTION", "Search for GitHub repositories")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_SEARCH_REPOSITORIES_USER_TITLE", "Search repositories"), + ReadOnlyHint: toBoolPtr(true), + }), mcp.WithString("query", mcp.Required(), mcp.Description("Search query"), @@ -70,6 +74,10 @@ func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperF func SearchCode(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("search_code", mcp.WithDescription(t("TOOL_SEARCH_CODE_DESCRIPTION", "Search for code across GitHub repositories")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_SEARCH_CODE_USER_TITLE", "Search code"), + ReadOnlyHint: toBoolPtr(true), + }), mcp.WithString("q", mcp.Required(), mcp.Description("Search query using GitHub code search syntax"), @@ -142,6 +150,10 @@ func SearchCode(getClient GetClientFn, t translations.TranslationHelperFunc) (to func SearchUsers(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("search_users", mcp.WithDescription(t("TOOL_SEARCH_USERS_DESCRIPTION", "Search for GitHub users")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_SEARCH_USERS_USER_TITLE", "Search users"), + ReadOnlyHint: toBoolPtr(true), + }), mcp.WithString("q", mcp.Required(), mcp.Description("Search query using GitHub users search syntax"), diff --git a/pkg/github/secret_scanning.go b/pkg/github/secret_scanning.go index ee344061..847fcfc6 100644 --- a/pkg/github/secret_scanning.go +++ b/pkg/github/secret_scanning.go @@ -17,6 +17,10 @@ func GetSecretScanningAlert(getClient GetClientFn, t translations.TranslationHel return mcp.NewTool( "get_secret_scanning_alert", mcp.WithDescription(t("TOOL_GET_SECRET_SCANNING_ALERT_DESCRIPTION", "Get details of a specific secret scanning alert in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_SECRET_SCANNING_ALERT_USER_TITLE", "Get secret scanning alert"), + ReadOnlyHint: toBoolPtr(true), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("The owner of the repository."), @@ -76,6 +80,10 @@ func ListSecretScanningAlerts(getClient GetClientFn, t translations.TranslationH return mcp.NewTool( "list_secret_scanning_alerts", mcp.WithDescription(t("TOOL_LIST_SECRET_SCANNING_ALERTS_DESCRIPTION", "List secret scanning alerts in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_SECRET_SCANNING_ALERTS_USER_TITLE", "List secret scanning alerts"), + ReadOnlyHint: toBoolPtr(true), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("The owner of the repository."), diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 35dabaef..b2464b75 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -27,6 +27,8 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn, toolsets.NewServerTool(SearchCode(getClient, t)), toolsets.NewServerTool(GetCommit(getClient, t)), toolsets.NewServerTool(ListBranches(getClient, t)), + toolsets.NewServerTool(ListTags(getClient, t)), + toolsets.NewServerTool(GetTag(getClient, t)), ). AddWriteTools( toolsets.NewServerTool(CreateOrUpdateFile(getClient, t)), @@ -34,6 +36,7 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn, toolsets.NewServerTool(ForkRepository(getClient, t)), toolsets.NewServerTool(CreateBranch(getClient, t)), toolsets.NewServerTool(PushFiles(getClient, t)), + toolsets.NewServerTool(DeleteFile(getClient, t)), ) issues := toolsets.NewToolset("issues", "GitHub Issues related tools"). AddReadTools( @@ -67,6 +70,7 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn, toolsets.NewServerTool(CreatePullRequest(getClient, t)), toolsets.NewServerTool(UpdatePullRequest(getClient, t)), toolsets.NewServerTool(AddPullRequestReviewComment(getClient, t)), + toolsets.NewServerTool(RequestCopilotReview(getClient, t)), ) codeSecurity := toolsets.NewToolset("code_security", "Code security related tools, such as GitHub Code Scanning"). AddReadTools( @@ -118,6 +122,11 @@ func InitDynamicToolset(s *server.MCPServer, tsg *toolsets.ToolsetGroup, t trans toolsets.NewServerTool(GetToolsetsTools(tsg, t)), toolsets.NewServerTool(EnableToolset(s, tsg, t)), ) + dynamicToolSelection.Enabled = true return dynamicToolSelection } + +func toBoolPtr(b bool) *bool { + return &b +} diff --git a/pkg/toolsets/toolsets.go b/pkg/toolsets/toolsets.go index d4397fc9..7400119c 100644 --- a/pkg/toolsets/toolsets.go +++ b/pkg/toolsets/toolsets.go @@ -58,6 +58,11 @@ func (t *Toolset) SetReadOnly() { func (t *Toolset) AddWriteTools(tools ...server.ServerTool) *Toolset { // Silently ignore if the toolset is read-only to avoid any breach of that contract + for _, tool := range tools { + if *tool.Tool.Annotations.ReadOnlyHint { + panic(fmt.Sprintf("tool (%s) is incorrectly annotated as read-only", tool.Tool.Name)) + } + } if !t.readOnly { t.writeTools = append(t.writeTools, tools...) } @@ -65,6 +70,11 @@ func (t *Toolset) AddWriteTools(tools ...server.ServerTool) *Toolset { } func (t *Toolset) AddReadTools(tools ...server.ServerTool) *Toolset { + for _, tool := range tools { + if !*tool.Tool.Annotations.ReadOnlyHint { + panic(fmt.Sprintf("tool (%s) must be annotated as read-only", tool.Tool.Name)) + } + } t.readTools = append(t.readTools, tools...) return t } diff --git a/pkg/translations/translations.go b/pkg/translations/translations.go index 741ee2b5..0cc1c187 100644 --- a/pkg/translations/translations.go +++ b/pkg/translations/translations.go @@ -56,7 +56,7 @@ func TranslationHelper() (TranslationHelperFunc, func()) { } } -// dump translationKeyMap to a json file called github-mcp-server-config.json +// DumpTranslationKeyMap writes the translation map to a json file called github-mcp-server-config.json func DumpTranslationKeyMap(translationKeyMap map[string]string) error { file, err := os.Create("github-mcp-server-config.json") if err != nil { diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index 389bb966..18c0379e 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -13,7 +13,7 @@ Some packages may only be included on certain architectures or operating systems - [github.com/google/go-github/v69/github](https://pkg.go.dev/github.com/google/go-github/v69/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v69.2.0/LICENSE)) - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.1.0/LICENSE)) - [github.com/google/uuid](https://pkg.go.dev/github.com/google/uuid) ([BSD-3-Clause](https://github.com/google/uuid/blob/v1.6.0/LICENSE)) - - [github.com/mark3labs/mcp-go](https://pkg.go.dev/github.com/mark3labs/mcp-go) ([MIT](https://github.com/mark3labs/mcp-go/blob/v0.20.1/LICENSE)) + - [github.com/mark3labs/mcp-go](https://pkg.go.dev/github.com/mark3labs/mcp-go) ([MIT](https://github.com/mark3labs/mcp-go/blob/v0.27.0/LICENSE)) - [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.3/LICENSE)) - [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.9.0/LICENSE)) - [github.com/sirupsen/logrus](https://pkg.go.dev/github.com/sirupsen/logrus) ([MIT](https://github.com/sirupsen/logrus/blob/v1.9.3/LICENSE)) diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md index 389bb966..18c0379e 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -13,7 +13,7 @@ Some packages may only be included on certain architectures or operating systems - [github.com/google/go-github/v69/github](https://pkg.go.dev/github.com/google/go-github/v69/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v69.2.0/LICENSE)) - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.1.0/LICENSE)) - [github.com/google/uuid](https://pkg.go.dev/github.com/google/uuid) ([BSD-3-Clause](https://github.com/google/uuid/blob/v1.6.0/LICENSE)) - - [github.com/mark3labs/mcp-go](https://pkg.go.dev/github.com/mark3labs/mcp-go) ([MIT](https://github.com/mark3labs/mcp-go/blob/v0.20.1/LICENSE)) + - [github.com/mark3labs/mcp-go](https://pkg.go.dev/github.com/mark3labs/mcp-go) ([MIT](https://github.com/mark3labs/mcp-go/blob/v0.27.0/LICENSE)) - [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.3/LICENSE)) - [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.9.0/LICENSE)) - [github.com/sirupsen/logrus](https://pkg.go.dev/github.com/sirupsen/logrus) ([MIT](https://github.com/sirupsen/logrus/blob/v1.9.3/LICENSE)) diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md index 96d037cc..72f669db 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -14,7 +14,7 @@ Some packages may only be included on certain architectures or operating systems - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.1.0/LICENSE)) - [github.com/google/uuid](https://pkg.go.dev/github.com/google/uuid) ([BSD-3-Clause](https://github.com/google/uuid/blob/v1.6.0/LICENSE)) - [github.com/inconshreveable/mousetrap](https://pkg.go.dev/github.com/inconshreveable/mousetrap) ([Apache-2.0](https://github.com/inconshreveable/mousetrap/blob/v1.1.0/LICENSE)) - - [github.com/mark3labs/mcp-go](https://pkg.go.dev/github.com/mark3labs/mcp-go) ([MIT](https://github.com/mark3labs/mcp-go/blob/v0.20.1/LICENSE)) + - [github.com/mark3labs/mcp-go](https://pkg.go.dev/github.com/mark3labs/mcp-go) ([MIT](https://github.com/mark3labs/mcp-go/blob/v0.27.0/LICENSE)) - [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.3/LICENSE)) - [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.9.0/LICENSE)) - [github.com/sirupsen/logrus](https://pkg.go.dev/github.com/sirupsen/logrus) ([MIT](https://github.com/sirupsen/logrus/blob/v1.9.3/LICENSE))