From 8bfe0c3d2e4d9401080e9ac9bb1754931e6eae45 Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Mon, 18 Aug 2025 17:03:42 +0200 Subject: [PATCH 1/9] Update "Close inactive issues" workflow to close issues after 180 days of inactivity (#909) * update PR_DAYS_BEFORE_STALE * update to mark as stale after 60 days --- .github/workflows/close-inactive-issues.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/close-inactive-issues.yml b/.github/workflows/close-inactive-issues.yml index c9ece2b6f..829233029 100644 --- a/.github/workflows/close-inactive-issues.yml +++ b/.github/workflows/close-inactive-issues.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest env: PR_DAYS_BEFORE_STALE: 60 - PR_DAYS_BEFORE_CLOSE: 10 + PR_DAYS_BEFORE_CLOSE: 120 PR_STALE_LABEL: stale permissions: issues: write From 96a705c6335b50539bb128c6c8da993324991c8c Mon Sep 17 00:00:00 2001 From: Dimitrios Philliou Date: Mon, 18 Aug 2025 08:10:13 -0700 Subject: [PATCH 2/9] Update Claude MCP install guide after testing (#706) * Revise Claude installation guide - Verified Claude Code installation steps - Identified and documented issues with Claude Desktop setup - Updated installation documentation based on testing * Revise instructions for opening Claude Code Updated recommendations for opening Claude Code. * Update docs/installation-guides/install-claude.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update docs/installation-guides/install-claude.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update installation guide for Claude setup Added installation option for using Claude Code using a release binary. * Change section title for Go Binary installation Updated section title for clarity regarding installation without Docker. * Close double quote in bash command --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: LuluBeatson Co-authored-by: Matt Holloway Co-authored-by: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> --- docs/installation-guides/install-claude.md | 227 +++++++++------------ 1 file changed, 95 insertions(+), 132 deletions(-) diff --git a/docs/installation-guides/install-claude.md b/docs/installation-guides/install-claude.md index 2c50be2f9..1a5b789f4 100644 --- a/docs/installation-guides/install-claude.md +++ b/docs/installation-guides/install-claude.md @@ -1,124 +1,98 @@ # Install GitHub MCP Server in Claude Applications -This guide covers installation of the GitHub MCP server for Claude Code CLI, Claude Desktop, and Claude Web applications. - -## Claude Web (claude.ai) - -Claude Web supports remote MCP servers through the Integrations built-in feature. +## Claude Code CLI ### Prerequisites +- Claude Code CLI installed +- [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new) +- For local setup: [Docker](https://www.docker.com/) installed and running +- Open Claude Code inside the directory for your project (recommended for best experience and clear scope of configuration) -1. Claude Pro, Team, or Enterprise account (Integrations not available on free plan) -2. [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new) - -### Installation - -**Note**: As of July 2025, the remote GitHub MCP Server has known compatibility issues with Claude Web. While Claude Web supports remote MCP servers from other providers (like Atlassian, Zapier, Notion), the GitHub MCP Server integration may not work reliably. - -For other remote MCP servers that do work with Claude Web: - -1. Go to [claude.ai](https://claude.ai) and log in -2. Click your profile icon → **Settings** -3. Navigate to **Integrations** section -4. Click **+ Add integration** or **Add More** -5. Enter the remote server URL -6. Follow the OAuth authentication flow when prompted +
+Storing Your PAT Securely +
-**Alternative**: Use Claude Desktop or Claude Code CLI for reliable GitHub MCP Server integration. +For security, avoid hardcoding your token. One common approach: ---- - -## Claude Code CLI - -Claude Code CLI provides command-line access to Claude with MCP server integration. - -### Prerequisites +1. Store your token in `.env` file +``` +GITHUB_PAT=your_token_here +``` -1. Claude Code CLI installed -2. [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new) -3. [Docker](https://www.docker.com/) installed and running +2. Add to .gitignore +```bash +echo -e ".env\n.mcp.json" >> .gitignore +``` -### Installation +
-Run the following command to add the GitHub MCP server using Docker: +### Remote Server Setup (Streamable HTTP) +1. Run the following command in the Claude Code CLI ```bash -claude mcp add github -- docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN ghcr.io/github/github-mcp-server +claude mcp add --transport http github https://api.githubcopilot.com/mcp -H "Authorization: Bearer YOUR_GITHUB_PAT" ``` -Then set the environment variable: +With an environment variable: ```bash -claude mcp update github -e GITHUB_PERSONAL_ACCESS_TOKEN=your_github_pat +claude mcp add --transport http github https://api.githubcopilot.com/mcp -H "Authorization: Bearer $(grep GITHUB_PAT .env | cut -d '=' -f2)" ``` +2. Restart Claude Code +3. Run `claude mcp list` to see if the GitHub server is configured + +### Local Server Setup (Docker required) -Or as a single command with the token inline: +### With Docker +1. Run the following command in the Claude Code CLI: ```bash -claude mcp add-json github '{"command": "docker", "args": ["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server"], "env": {"GITHUB_PERSONAL_ACCESS_TOKEN": "your_github_pat"}}' +claude mcp add github -e GITHUB_PERSONAL_ACCESS_TOKEN=YOUR_GITHUB_PAT -- docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN ghcr.io/github/github-mcp-server ``` -**Important**: The npm package `@modelcontextprotocol/server-github` is no longer supported as of April 2025. Use the official Docker image `ghcr.io/github/github-mcp-server` instead. +With an environment variable: +```bash +claude mcp add github -e GITHUB_PERSONAL_ACCESS_TOKEN=$(grep GITHUB_PAT .env | cut -d '=' -f2) -- docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN ghcr.io/github/github-mcp-server +``` +2. Restart Claude Code +3. Run `claude mcp list` to see if the GitHub server is configured -### Configuration Options +### With a Binary (no Docker) -- Use `-s user` to add the server to your user configuration (available across all projects) -- Use `-s project` to add the server to project-specific configuration (shared via `.mcp.json`) -- Default scope is `local` (available only to you in the current project) +1. Download [release binary](https://github.com/github/github-mcp-server/releases) +2. Add to your `PATH` +3. Run: +```bash +claude mcp add-json github '{"command": "github-mcp-server", "args": ["stdio"], "env": {"GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_PAT"}}' +``` +2. Restart Claude Code +3. Run `claude mcp list` to see if the GitHub server is configured ### Verification - -Run the following command to verify the installation: ```bash claude mcp list +claude mcp get github ``` --- ## Claude Desktop -Claude Desktop provides a graphical interface for interacting with the GitHub MCP Server. +> ⚠️ **Note**: Some users have reported compatibility issues with Claude Desktop and Docker-based MCP servers. We're investigating. If you experience issues, try using another MCP host, while we look into it! ### Prerequisites +- Claude Desktop installed (latest version) +- [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new) +- [Docker](https://www.docker.com/) installed and running -1. Claude Desktop installed -2. [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new) -3. [Docker](https://www.docker.com/) installed and running +> **Note**: Claude Desktop supports MCP servers that are both local (stdio) and remote ("connectors"). Remote servers can generally be added via Settings → Connectors → "Add custom connector". However, the GitHub remote MCP server requires OAuth authentication through a registered GitHub App (or OAuth App), which is not currently supported. Use the local Docker setup instead. ### Configuration File Location - - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` -- **Linux**: `~/.config/Claude/claude_desktop_config.json` (unofficial support) - -### Installation - -Add the following to your `claude_desktop_config.json`: - -```json -{ - "mcpServers": { - "github": { - "command": "docker", - "args": [ - "run", - "-i", - "--rm", - "-e", - "GITHUB_PERSONAL_ACCESS_TOKEN", - "ghcr.io/github/github-mcp-server" - ], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "your_github_pat" - } - } - } -} -``` +- **Linux**: `~/.config/Claude/claude_desktop_config.json` -**Important**: The npm package `@modelcontextprotocol/server-github` is no longer supported as of April 2025. Use the official Docker image `ghcr.io/github/github-mcp-server` instead. +### Local Server Setup (Docker) -### Using Environment Variables - -Claude Desktop supports environment variable references. You can use: +Add this codeblock to your `claude_desktop_config.json`: ```json { @@ -134,71 +108,60 @@ Claude Desktop supports environment variable references. You can use: "ghcr.io/github/github-mcp-server" ], "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_PAT" + "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_PAT" } } } } ``` -Then set the environment variable in your system before starting Claude Desktop. - -### Installation Steps - +### Manual Setup Steps 1. Open Claude Desktop -2. Go to Settings (from the Claude menu) → Developer → Edit Config -3. Add your chosen configuration -4. Save the file -5. Restart Claude Desktop - -### Verification - -After restarting, you should see: -- An MCP icon in the Claude Desktop interface -- The GitHub server listed as "running" in Developer settings +2. Go to Settings → Developer → Edit Config +3. Paste the code block above in your configuration file +4. If you're navigating to the configuration file outside of the app: + - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` + - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` +5. Open the file in a text editor +6. Paste one of the code blocks above, based on your chosen configuration (remote or local) +7. Replace `YOUR_GITHUB_PAT` with your actual token or $GITHUB_PAT environment variable +8. Save the file +9. Restart Claude Desktop --- ## Troubleshooting -### Claude Web -- Currently experiencing compatibility issues with the GitHub MCP Server -- Try other remote MCP servers (Atlassian, Zapier, Notion) which work reliably -- Use Claude Desktop or Claude Code CLI as alternatives for GitHub integration - -### Claude Code CLI -- Verify the command syntax is correct (note the single quotes around the JSON) -- Ensure Docker is running: `docker --version` -- Use `/mcp` command within Claude Code to check server status - -### Claude Desktop -- Check logs at: - - **macOS**: `~/Library/Logs/Claude/` - - **Windows**: `%APPDATA%\Claude\logs\` -- Look for `mcp-server-github.log` for server-specific errors -- Ensure configuration file is valid JSON -- Try running the Docker command manually in terminal to diagnose issues - -### Common Issues -- **Invalid JSON**: Validate your configuration at [jsonlint.com](https://jsonlint.com) -- **PAT issues**: Ensure your GitHub PAT has required scopes -- **Docker not found**: Install Docker Desktop and ensure it's running -- **Docker image pull fails**: Try `docker logout ghcr.io` then retry - ---- - -## Security Best Practices - -- **Protect configuration files**: Set appropriate file permissions -- **Use environment variables** when possible instead of hardcoding tokens -- **Limit PAT scope** to only necessary permissions -- **Regularly rotate** your GitHub Personal Access Tokens -- **Never commit** configuration files containing tokens to version control +**Authentication Failed:** +- Verify PAT has `repo` scope +- Check token hasn't expired + +**Remote Server:** +- Verify URL: `https://api.githubcopilot.com/mcp` + +**Docker Issues (Local Only):** +- Ensure Docker Desktop is running +- Try: `docker pull ghcr.io/github/github-mcp-server` +- If pull fails: `docker logout ghcr.io` then retry + +**Server Not Starting / Tools Not Showing:** +- Run `claude mcp list` to view currently configured MCP servers +- Validate JSON syntax +- If using an environment variable to store your PAT, make sure you're properly sourcing your PAT using the environment variable +- Restart Claude Code and check `/mcp` command +- Delete the GitHub server by running `claude mcp remove github` and repeating the setup process with a different method +- Make sure you're running Claude Code within the project you're currently working on to ensure the MCP configuration is properly scoped to your project +- Check logs: + - Claude Code: Use `/mcp` command + - Claude Desktop: `ls ~/Library/Logs/Claude/` and `cat ~/Library/Logs/Claude/mcp-server-*.log` (macOS) or `%APPDATA%\Claude\logs\` (Windows) --- -## Additional Resources +## Important Notes -- [Model Context Protocol Documentation](https://modelcontextprotocol.io) -- [Claude Code MCP Documentation](https://docs.anthropic.com/en/docs/claude-code/mcp) -- [Claude Web Integrations Support](https://support.anthropic.com/en/articles/11175166-about-custom-integrations-using-remote-mcp) +- The npm package `@modelcontextprotocol/server-github` is deprecated as of April 2025 +- Remote server requires Streamable HTTP support (check your Claude version) +- Configuration scopes for Claude Code: + - `-s user`: Available across all projects + - `-s project`: Shared via `.mcp.json` file + - Default: `local` (current project only) From 2621dbefd9c16da9e3f3e7e97922f941acabcc6c Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Tue, 19 Aug 2025 14:02:16 +0100 Subject: [PATCH 3/9] Add actions job log buffer and profiler (#866) * add sliding window for actions logs * refactor: fix sliding * remove trim content * only use up to 1mb of memory for logs * update to tail lines in second pass * add better memory usage calculation * increase window size to 5MB * update test * update vers * undo vers change * add incremental memory tracking * use ring buffer * remove unused ctx param * remove manual GC clear * fix cca feedback * extract ring buffer logic to new package * handle log content processing errors and use correct param for maxjobloglines * fix tailing * account for if tailLines exceeds window size * add profiling thats reusable * remove profiler testing * refactor profiler: introduce safeMemoryDelta for accurate memory delta calculations * linter fixes * Update pkg/buffer/buffer.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * use flag for maxJobLogLines * add param passing for context window size * refactor: rename contextWindowSize to contentWindowSize for consistency * fix: use tailLines if bigger but only if <= 5000 * fix: limit tailLines to a maximum of 500 for log content download * Update cmd/github-mcp-server/main.go Co-authored-by: Adam Holt * Update cmd/github-mcp-server/main.go Co-authored-by: Adam Holt * move profiler to internal/ * update actions test with new profiler location * fix: adjust buffer size limits * make line buffer 1028kb * fix mod path * change test to use same buffer size as normal use * improve test for non-sliding window implementation to not count empty lines * make test memory measurement more accurate * remove impossible conditional --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Adam Holt --- cmd/github-mcp-server/generate_docs.go | 4 +- cmd/github-mcp-server/main.go | 3 + internal/ghmcp/server.go | 23 ++- internal/profiler/profiler.go | 215 +++++++++++++++++++++++++ pkg/buffer/buffer.go | 69 ++++++++ pkg/github/actions.go | 68 ++++---- pkg/github/actions_test.go | 162 ++++++++++++++++++- pkg/github/tools.go | 4 +- 8 files changed, 493 insertions(+), 55 deletions(-) create mode 100644 internal/profiler/profiler.go create mode 100644 pkg/buffer/buffer.go diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index 7fc62b1ae..89cc37c22 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -64,7 +64,7 @@ func generateReadmeDocs(readmePath string) error { t, _ := translations.TranslationHelper() // Create toolset group with mock clients - tsg := github.DefaultToolsetGroup(false, mockGetClient, mockGetGQLClient, mockGetRawClient, t) + tsg := github.DefaultToolsetGroup(false, mockGetClient, mockGetGQLClient, mockGetRawClient, t, 5000) // Generate toolsets documentation toolsetsDoc := generateToolsetsDoc(tsg) @@ -302,7 +302,7 @@ func generateRemoteToolsetsDoc() string { t, _ := translations.TranslationHelper() // Create toolset group with mock clients - tsg := github.DefaultToolsetGroup(false, mockGetClient, mockGetGQLClient, mockGetRawClient, t) + tsg := github.DefaultToolsetGroup(false, mockGetClient, mockGetGQLClient, mockGetRawClient, t, 5000) // Generate table header buf.WriteString("| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) |\n") diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index cad002666..0a4545835 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -55,6 +55,7 @@ var ( ExportTranslations: viper.GetBool("export-translations"), EnableCommandLogging: viper.GetBool("enable-command-logging"), LogFilePath: viper.GetString("log-file"), + ContentWindowSize: viper.GetInt("content-window-size"), } return ghmcp.RunStdioServer(stdioServerConfig) }, @@ -75,6 +76,7 @@ func init() { rootCmd.PersistentFlags().Bool("enable-command-logging", false, "When enabled, the server will log all command requests and responses to the log file") rootCmd.PersistentFlags().Bool("export-translations", false, "Save translations to a JSON file") rootCmd.PersistentFlags().String("gh-host", "", "Specify the GitHub hostname (for GitHub Enterprise etc.)") + rootCmd.PersistentFlags().Int("content-window-size", 5000, "Specify the content window size") // Bind flag to viper _ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets")) @@ -84,6 +86,7 @@ func init() { _ = viper.BindPFlag("enable-command-logging", rootCmd.PersistentFlags().Lookup("enable-command-logging")) _ = viper.BindPFlag("export-translations", rootCmd.PersistentFlags().Lookup("export-translations")) _ = viper.BindPFlag("host", rootCmd.PersistentFlags().Lookup("gh-host")) + _ = viper.BindPFlag("content-window-size", rootCmd.PersistentFlags().Lookup("content-window-size")) // Add subcommands rootCmd.AddCommand(stdioCmd) diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 5fb9582b9..7ad71532f 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -47,6 +47,9 @@ type MCPServerConfig struct { // Translator provides translated text for the server tooling Translator translations.TranslationHelperFunc + + // Content window size + ContentWindowSize int } const stdioServerLogPrefix = "stdioserver" @@ -132,7 +135,7 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) { } // Create default toolsets - tsg := github.DefaultToolsetGroup(cfg.ReadOnly, getClient, getGQLClient, getRawClient, cfg.Translator) + tsg := github.DefaultToolsetGroup(cfg.ReadOnly, getClient, getGQLClient, getRawClient, cfg.Translator, cfg.ContentWindowSize) err = tsg.EnableToolsets(enabledToolsets) if err != nil { @@ -180,6 +183,9 @@ type StdioServerConfig struct { // Path to the log file if not stderr LogFilePath string + + // Content window size + ContentWindowSize int } // RunStdioServer is not concurrent safe. @@ -191,13 +197,14 @@ func RunStdioServer(cfg StdioServerConfig) error { 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, + Version: cfg.Version, + Host: cfg.Host, + Token: cfg.Token, + EnabledToolsets: cfg.EnabledToolsets, + DynamicToolsets: cfg.DynamicToolsets, + ReadOnly: cfg.ReadOnly, + Translator: t, + ContentWindowSize: cfg.ContentWindowSize, }) if err != nil { return fmt.Errorf("failed to create MCP server: %w", err) diff --git a/internal/profiler/profiler.go b/internal/profiler/profiler.go new file mode 100644 index 000000000..1cfb7ffae --- /dev/null +++ b/internal/profiler/profiler.go @@ -0,0 +1,215 @@ +package profiler + +import ( + "context" + "fmt" + "os" + "runtime" + "strconv" + "time" + + "log/slog" + "math" +) + +// Profile represents performance metrics for an operation +type Profile struct { + Operation string `json:"operation"` + Duration time.Duration `json:"duration_ns"` + MemoryBefore uint64 `json:"memory_before_bytes"` + MemoryAfter uint64 `json:"memory_after_bytes"` + MemoryDelta int64 `json:"memory_delta_bytes"` + LinesCount int `json:"lines_count,omitempty"` + BytesCount int64 `json:"bytes_count,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +// String returns a human-readable representation of the profile +func (p *Profile) String() string { + return fmt.Sprintf("[%s] %s: duration=%v, memory_delta=%+dB, lines=%d, bytes=%d", + p.Timestamp.Format("15:04:05.000"), + p.Operation, + p.Duration, + p.MemoryDelta, + p.LinesCount, + p.BytesCount, + ) +} + +func safeMemoryDelta(after, before uint64) int64 { + if after > math.MaxInt64 || before > math.MaxInt64 { + if after >= before { + diff := after - before + if diff > math.MaxInt64 { + return math.MaxInt64 + } + return int64(diff) + } + diff := before - after + if diff > math.MaxInt64 { + return -math.MaxInt64 + } + return -int64(diff) + } + + return int64(after) - int64(before) +} + +// Profiler provides minimal performance profiling capabilities +type Profiler struct { + logger *slog.Logger + enabled bool +} + +// New creates a new Profiler instance +func New(logger *slog.Logger, enabled bool) *Profiler { + return &Profiler{ + logger: logger, + enabled: enabled, + } +} + +// ProfileFunc profiles a function execution +func (p *Profiler) ProfileFunc(ctx context.Context, operation string, fn func() error) (*Profile, error) { + if !p.enabled { + return nil, fn() + } + + profile := &Profile{ + Operation: operation, + Timestamp: time.Now(), + } + + var memBefore runtime.MemStats + runtime.ReadMemStats(&memBefore) + profile.MemoryBefore = memBefore.Alloc + + start := time.Now() + err := fn() + profile.Duration = time.Since(start) + + var memAfter runtime.MemStats + runtime.ReadMemStats(&memAfter) + profile.MemoryAfter = memAfter.Alloc + profile.MemoryDelta = safeMemoryDelta(memAfter.Alloc, memBefore.Alloc) + + if p.logger != nil { + p.logger.InfoContext(ctx, "Performance profile", "profile", profile.String()) + } + + return profile, err +} + +// ProfileFuncWithMetrics profiles a function execution and captures additional metrics +func (p *Profiler) ProfileFuncWithMetrics(ctx context.Context, operation string, fn func() (int, int64, error)) (*Profile, error) { + if !p.enabled { + _, _, err := fn() + return nil, err + } + + profile := &Profile{ + Operation: operation, + Timestamp: time.Now(), + } + + var memBefore runtime.MemStats + runtime.ReadMemStats(&memBefore) + profile.MemoryBefore = memBefore.Alloc + + start := time.Now() + lines, bytes, err := fn() + profile.Duration = time.Since(start) + profile.LinesCount = lines + profile.BytesCount = bytes + + var memAfter runtime.MemStats + runtime.ReadMemStats(&memAfter) + profile.MemoryAfter = memAfter.Alloc + profile.MemoryDelta = safeMemoryDelta(memAfter.Alloc, memBefore.Alloc) + + if p.logger != nil { + p.logger.InfoContext(ctx, "Performance profile", "profile", profile.String()) + } + + return profile, err +} + +// Start begins timing an operation and returns a function to complete the profiling +func (p *Profiler) Start(ctx context.Context, operation string) func(lines int, bytes int64) *Profile { + if !p.enabled { + return func(int, int64) *Profile { return nil } + } + + profile := &Profile{ + Operation: operation, + Timestamp: time.Now(), + } + + var memBefore runtime.MemStats + runtime.ReadMemStats(&memBefore) + profile.MemoryBefore = memBefore.Alloc + + start := time.Now() + + return func(lines int, bytes int64) *Profile { + profile.Duration = time.Since(start) + profile.LinesCount = lines + profile.BytesCount = bytes + + var memAfter runtime.MemStats + runtime.ReadMemStats(&memAfter) + profile.MemoryAfter = memAfter.Alloc + profile.MemoryDelta = safeMemoryDelta(memAfter.Alloc, memBefore.Alloc) + + if p.logger != nil { + p.logger.InfoContext(ctx, "Performance profile", "profile", profile.String()) + } + + return profile + } +} + +var globalProfiler *Profiler + +// IsProfilingEnabled checks if profiling is enabled via environment variables +func IsProfilingEnabled() bool { + if enabled, err := strconv.ParseBool(os.Getenv("GITHUB_MCP_PROFILING_ENABLED")); err == nil { + return enabled + } + return false +} + +// Init initializes the global profiler +func Init(logger *slog.Logger, enabled bool) { + globalProfiler = New(logger, enabled) +} + +// InitFromEnv initializes the global profiler using environment variables +func InitFromEnv(logger *slog.Logger) { + globalProfiler = New(logger, IsProfilingEnabled()) +} + +// ProfileFunc profiles a function using the global profiler +func ProfileFunc(ctx context.Context, operation string, fn func() error) (*Profile, error) { + if globalProfiler == nil { + return nil, fn() + } + return globalProfiler.ProfileFunc(ctx, operation, fn) +} + +// ProfileFuncWithMetrics profiles a function with metrics using the global profiler +func ProfileFuncWithMetrics(ctx context.Context, operation string, fn func() (int, int64, error)) (*Profile, error) { + if globalProfiler == nil { + _, _, err := fn() + return nil, err + } + return globalProfiler.ProfileFuncWithMetrics(ctx, operation, fn) +} + +// Start begins timing using the global profiler +func Start(ctx context.Context, operation string) func(int, int64) *Profile { + if globalProfiler == nil { + return func(int, int64) *Profile { return nil } + } + return globalProfiler.Start(ctx, operation) +} diff --git a/pkg/buffer/buffer.go b/pkg/buffer/buffer.go new file mode 100644 index 000000000..546b5324c --- /dev/null +++ b/pkg/buffer/buffer.go @@ -0,0 +1,69 @@ +package buffer + +import ( + "bufio" + "fmt" + "net/http" + "strings" +) + +// ProcessResponseAsRingBufferToEnd reads the body of an HTTP response line by line, +// storing only the last maxJobLogLines lines using a ring buffer (sliding window). +// This efficiently retains the most recent lines, overwriting older ones as needed. +// +// Parameters: +// +// httpResp: The HTTP response whose body will be read. +// maxJobLogLines: The maximum number of log lines to retain. +// +// Returns: +// +// string: The concatenated log lines (up to maxJobLogLines), separated by newlines. +// int: The total number of lines read from the response. +// *http.Response: The original HTTP response. +// error: Any error encountered during reading. +// +// The function uses a ring buffer to efficiently store only the last maxJobLogLines lines. +// If the response contains more lines than maxJobLogLines, only the most recent lines are kept. +func ProcessResponseAsRingBufferToEnd(httpResp *http.Response, maxJobLogLines int) (string, int, *http.Response, error) { + lines := make([]string, maxJobLogLines) + validLines := make([]bool, maxJobLogLines) + totalLines := 0 + writeIndex := 0 + + scanner := bufio.NewScanner(httpResp.Body) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + for scanner.Scan() { + line := scanner.Text() + totalLines++ + + lines[writeIndex] = line + validLines[writeIndex] = true + writeIndex = (writeIndex + 1) % maxJobLogLines + } + + if err := scanner.Err(); err != nil { + return "", 0, httpResp, fmt.Errorf("failed to read log content: %w", err) + } + + var result []string + linesInBuffer := totalLines + if linesInBuffer > maxJobLogLines { + linesInBuffer = maxJobLogLines + } + + startIndex := 0 + if totalLines > maxJobLogLines { + startIndex = writeIndex + } + + for i := 0; i < linesInBuffer; i++ { + idx := (startIndex + i) % maxJobLogLines + if validLines[idx] { + result = append(result, lines[idx]) + } + } + + return strings.Join(result, "\n"), totalLines, httpResp, nil +} diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 38719f155..ace9d7288 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -4,11 +4,12 @@ import ( "context" "encoding/json" "fmt" - "io" "net/http" "strconv" "strings" + "github.com/github/github-mcp-server/internal/profiler" + buffer "github.com/github/github-mcp-server/pkg/buffer" ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v74/github" @@ -530,7 +531,7 @@ func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFun } // GetJobLogs creates a tool to download logs for a specific workflow job or efficiently get all failed job logs for a workflow run -func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc, contentWindowSize int) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_job_logs", mcp.WithDescription(t("TOOL_GET_JOB_LOGS_DESCRIPTION", "Download logs for a specific workflow job or efficiently get all failed job logs for a workflow run")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ @@ -613,10 +614,10 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (to if failedOnly && runID > 0 { // Handle failed-only mode: get logs for all failed jobs in the workflow run - return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent, tailLines) + return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent, tailLines, contentWindowSize) } else if jobID > 0 { // Handle single job mode - return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent, tailLines) + return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent, tailLines, contentWindowSize) } return mcp.NewToolResultError("Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"), nil @@ -624,7 +625,7 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (to } // handleFailedJobLogs gets logs for all failed jobs in a workflow run -func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool, tailLines int) (*mcp.CallToolResult, error) { +func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, error) { // First, get all jobs for the workflow run jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, &github.ListWorkflowJobsOptions{ Filter: "latest", @@ -656,7 +657,7 @@ func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo // Collect logs for all failed jobs var logResults []map[string]any for _, job := range failedJobs { - jobResult, resp, err := getJobLogData(ctx, client, owner, repo, job.GetID(), job.GetName(), returnContent, tailLines) + jobResult, resp, err := getJobLogData(ctx, client, owner, repo, job.GetID(), job.GetName(), returnContent, tailLines, contentWindowSize) if err != nil { // Continue with other jobs even if one fails jobResult = map[string]any{ @@ -689,8 +690,8 @@ func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo } // handleSingleJobLogs gets logs for a single job -func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool, tailLines int) (*mcp.CallToolResult, error) { - jobResult, resp, err := getJobLogData(ctx, client, owner, repo, jobID, "", returnContent, tailLines) +func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, error) { + jobResult, resp, err := getJobLogData(ctx, client, owner, repo, jobID, "", returnContent, tailLines, contentWindowSize) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get job logs", resp, err), nil } @@ -704,7 +705,7 @@ func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo } // getJobLogData retrieves log data for a single job, either as URL or content -func getJobLogData(ctx context.Context, client *github.Client, owner, repo string, jobID int64, jobName string, returnContent bool, tailLines int) (map[string]any, *github.Response, error) { +func getJobLogData(ctx context.Context, client *github.Client, owner, repo string, jobID int64, jobName string, returnContent bool, tailLines int, contentWindowSize int) (map[string]any, *github.Response, error) { // Get the download URL for the job logs url, resp, err := client.Actions.GetWorkflowJobLogs(ctx, owner, repo, jobID, 1) if err != nil { @@ -721,7 +722,7 @@ func getJobLogData(ctx context.Context, client *github.Client, owner, repo strin if returnContent { // Download and return the actual log content - content, originalLength, httpResp, err := downloadLogContent(url.String(), tailLines) //nolint:bodyclose // Response body is closed in downloadLogContent, but we need to return httpResp + content, originalLength, httpResp, err := downloadLogContent(ctx, url.String(), tailLines, contentWindowSize) //nolint:bodyclose // Response body is closed in downloadLogContent, but we need to return httpResp if err != nil { // To keep the return value consistent wrap the response as a GitHub Response ghRes := &github.Response{ @@ -742,9 +743,11 @@ func getJobLogData(ctx context.Context, client *github.Client, owner, repo strin return result, resp, nil } -// downloadLogContent downloads the actual log content from a GitHub logs URL -func downloadLogContent(logURL string, tailLines int) (string, int, *http.Response, error) { - httpResp, err := http.Get(logURL) //nolint:gosec // URLs are provided by GitHub API and are safe +func downloadLogContent(ctx context.Context, logURL string, tailLines int, maxLines int) (string, int, *http.Response, error) { + prof := profiler.New(nil, profiler.IsProfilingEnabled()) + finish := prof.Start(ctx, "log_buffer_processing") + + httpResp, err := http.Get(logURL) //nolint:gosec if err != nil { return "", 0, httpResp, fmt.Errorf("failed to download logs: %w", err) } @@ -754,36 +757,25 @@ func downloadLogContent(logURL string, tailLines int) (string, int, *http.Respon return "", 0, httpResp, fmt.Errorf("failed to download logs: HTTP %d", httpResp.StatusCode) } - content, err := io.ReadAll(httpResp.Body) + bufferSize := tailLines + if bufferSize > maxLines { + bufferSize = maxLines + } + + processedInput, totalLines, httpResp, err := buffer.ProcessResponseAsRingBufferToEnd(httpResp, bufferSize) if err != nil { - return "", 0, httpResp, fmt.Errorf("failed to read log content: %w", err) + return "", 0, httpResp, fmt.Errorf("failed to process log content: %w", err) } - // Clean up and format the log content for better readability - logContent := strings.TrimSpace(string(content)) + lines := strings.Split(processedInput, "\n") + if len(lines) > tailLines { + lines = lines[len(lines)-tailLines:] + } + finalResult := strings.Join(lines, "\n") - trimmedContent, lineCount := trimContent(logContent, tailLines) - return trimmedContent, lineCount, httpResp, nil -} + _ = finish(len(lines), int64(len(finalResult))) -// trimContent trims the content to a maximum length and returns the trimmed content and an original length -func trimContent(content string, tailLines int) (string, int) { - // Truncate to tail_lines if specified - lineCount := 0 - if tailLines > 0 { - - // Count backwards to find the nth newline from the end and a total number of lines - for i := len(content) - 1; i >= 0 && lineCount < tailLines; i-- { - if content[i] == '\n' { - lineCount++ - // If we have reached the tailLines, trim the content - if lineCount == tailLines { - content = content[i+1:] - } - } - } - } - return content, lineCount + return finalResult, totalLines, httpResp, nil } // RerunWorkflowRun creates a tool to re-run an entire workflow run diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 3d7521125..555ec04cb 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -3,10 +3,17 @@ package github import ( "context" "encoding/json" + "io" "net/http" "net/http/httptest" + "os" + "runtime" + "runtime/debug" + "strings" "testing" + "github.com/github/github-mcp-server/internal/profiler" + buffer "github.com/github/github-mcp-server/pkg/buffer" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v74/github" "github.com/migueleliasweb/go-github-mock/src/mock" @@ -807,7 +814,7 @@ func Test_GetWorkflowRunUsage(t *testing.T) { func Test_GetJobLogs(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) - tool, _ := GetJobLogs(stubGetClientFn(mockClient), translations.NullTranslationHelper) + tool, _ := GetJobLogs(stubGetClientFn(mockClient), translations.NullTranslationHelper, 5000) assert.Equal(t, "get_job_logs", tool.Name) assert.NotEmpty(t, tool.Description) @@ -1036,7 +1043,7 @@ func Test_GetJobLogs(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000) // Create call request request := createMCPRequest(tc.requestArgs) @@ -1095,7 +1102,7 @@ func Test_GetJobLogs_WithContentReturn(t *testing.T) { ) client := github.NewClient(mockedClient) - _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000) request := createMCPRequest(map[string]any{ "owner": "owner", @@ -1142,7 +1149,7 @@ func Test_GetJobLogs_WithContentReturnAndTailLines(t *testing.T) { ) client := github.NewClient(mockedClient) - _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000) request := createMCPRequest(map[string]any{ "owner": "owner", @@ -1162,8 +1169,153 @@ func Test_GetJobLogs_WithContentReturnAndTailLines(t *testing.T) { require.NoError(t, err) assert.Equal(t, float64(123), response["job_id"]) - assert.Equal(t, float64(1), response["original_length"]) + assert.Equal(t, float64(3), response["original_length"]) assert.Equal(t, expectedLogContent, response["logs_content"]) assert.Equal(t, "Job logs content retrieved successfully", response["message"]) assert.NotContains(t, response, "logs_url") // Should not have URL when returning content } + +func Test_GetJobLogs_WithContentReturnAndLargeTailLines(t *testing.T) { + logContent := "Line 1\nLine 2\nLine 3" + expectedLogContent := "Line 1\nLine 2\nLine 3" + + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(logContent)) + })) + defer testServer.Close() + + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", testServer.URL) + w.WriteHeader(http.StatusFound) + }), + ), + ) + + client := github.NewClient(mockedClient) + _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "job_id": float64(123), + "return_content": true, + "tail_lines": float64(100), + }) + + result, err := handler(context.Background(), request) + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + + assert.Equal(t, float64(123), response["job_id"]) + assert.Equal(t, float64(3), response["original_length"]) + assert.Equal(t, expectedLogContent, response["logs_content"]) + assert.Equal(t, "Job logs content retrieved successfully", response["message"]) + assert.NotContains(t, response, "logs_url") +} + +func Test_MemoryUsage_SlidingWindow_vs_NoWindow(t *testing.T) { + if testing.Short() { + t.Skip("Skipping memory profiling test in short mode") + } + + const logLines = 100000 + const bufferSize = 5000 + largeLogContent := strings.Repeat("log line with some content\n", logLines-1) + "final log line" + + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(largeLogContent)) + })) + defer testServer.Close() + + os.Setenv("GITHUB_MCP_PROFILING_ENABLED", "true") + defer os.Unsetenv("GITHUB_MCP_PROFILING_ENABLED") + + profiler.InitFromEnv(nil) + ctx := context.Background() + + debug.SetGCPercent(-1) + defer debug.SetGCPercent(100) + + for i := 0; i < 3; i++ { + runtime.GC() + } + + var baselineStats runtime.MemStats + runtime.ReadMemStats(&baselineStats) + + profile1, err1 := profiler.ProfileFuncWithMetrics(ctx, "sliding_window", func() (int, int64, error) { + resp1, err := http.Get(testServer.URL) + if err != nil { + return 0, 0, err + } + defer resp1.Body.Close() //nolint:bodyclose + content, totalLines, _, err := buffer.ProcessResponseAsRingBufferToEnd(resp1, bufferSize) //nolint:bodyclose + return totalLines, int64(len(content)), err + }) + require.NoError(t, err1) + + for i := 0; i < 3; i++ { + runtime.GC() + } + + profile2, err2 := profiler.ProfileFuncWithMetrics(ctx, "no_window", func() (int, int64, error) { + resp2, err := http.Get(testServer.URL) + if err != nil { + return 0, 0, err + } + defer resp2.Body.Close() //nolint:bodyclose + + allContent, err := io.ReadAll(resp2.Body) + if err != nil { + return 0, 0, err + } + + allLines := strings.Split(string(allContent), "\n") + var nonEmptyLines []string + for _, line := range allLines { + if line != "" { + nonEmptyLines = append(nonEmptyLines, line) + } + } + totalLines := len(nonEmptyLines) + + var resultLines []string + if totalLines > bufferSize { + resultLines = nonEmptyLines[totalLines-bufferSize:] + } else { + resultLines = nonEmptyLines + } + + result := strings.Join(resultLines, "\n") + return totalLines, int64(len(result)), nil + }) + require.NoError(t, err2) + + assert.Greater(t, profile2.MemoryDelta, profile1.MemoryDelta, + "Sliding window should use less memory than reading all into memory") + + assert.Equal(t, profile1.LinesCount, profile2.LinesCount, + "Both approaches should count the same number of input lines") + assert.InDelta(t, profile1.BytesCount, profile2.BytesCount, 100, + "Both approaches should produce similar output sizes (within 100 bytes)") + + memoryReduction := float64(profile2.MemoryDelta-profile1.MemoryDelta) / float64(profile2.MemoryDelta) * 100 + t.Logf("Memory reduction: %.1f%% (%.2f MB vs %.2f MB)", + memoryReduction, + float64(profile2.MemoryDelta)/1024/1024, + float64(profile1.MemoryDelta)/1024/1024) + + t.Logf("Baseline: %d bytes", baselineStats.Alloc) + t.Logf("Sliding window: %s", profile1.String()) + t.Logf("No window: %s", profile2.String()) +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 3fb39ada7..b50499650 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -16,7 +16,7 @@ type GetGQLClientFn func(context.Context) (*githubv4.Client, error) var DefaultTools = []string{"all"} -func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetGQLClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) *toolsets.ToolsetGroup { +func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetGQLClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc, contentWindowSize int) *toolsets.ToolsetGroup { tsg := toolsets.NewToolsetGroup(readOnly) // Define all available features with their default state (disabled) @@ -146,7 +146,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(GetWorkflowRun(getClient, t)), toolsets.NewServerTool(GetWorkflowRunLogs(getClient, t)), toolsets.NewServerTool(ListWorkflowJobs(getClient, t)), - toolsets.NewServerTool(GetJobLogs(getClient, t)), + toolsets.NewServerTool(GetJobLogs(getClient, t, contentWindowSize)), toolsets.NewServerTool(ListWorkflowRunArtifacts(getClient, t)), toolsets.NewServerTool(DownloadWorkflowRunArtifact(getClient, t)), toolsets.NewServerTool(GetWorkflowRunUsage(getClient, t)), From 73dcb46dd8066e443fff5ce8f75c904e207c2ebe Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Thu, 21 Aug 2025 10:57:46 +0200 Subject: [PATCH 4/9] Add get_release_by_tag tool (#938) * add get_release_by_tag tool * add tool * add tests * autogen * remove comment --- README.md | 5 + .../__toolsnaps__/get_release_by_tag.snap | 30 ++++ pkg/github/repositories.go | 66 +++++++ pkg/github/repositories_test.go | 165 ++++++++++++++++++ pkg/github/tools.go | 1 + 5 files changed, 267 insertions(+) create mode 100644 pkg/github/__toolsnaps__/get_release_by_tag.snap diff --git a/README.md b/README.md index e4543ecf5..b4168a136 100644 --- a/README.md +++ b/README.md @@ -846,6 +846,11 @@ The following sets of tools are available (all are on by default): - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) +- **get_release_by_tag** - Get a release by tag name + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `tag`: Tag name (e.g., 'v1.0.0') (string, required) + - **get_tag** - Get tag details - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) diff --git a/pkg/github/__toolsnaps__/get_release_by_tag.snap b/pkg/github/__toolsnaps__/get_release_by_tag.snap new file mode 100644 index 000000000..c96d3c30a --- /dev/null +++ b/pkg/github/__toolsnaps__/get_release_by_tag.snap @@ -0,0 +1,30 @@ +{ + "annotations": { + "title": "Get a release by tag name", + "readOnlyHint": true + }, + "description": "Get a specific release by its tag name in a GitHub repository", + "inputSchema": { + "properties": { + "owner": { + "description": "Repository owner", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "tag": { + "description": "Tag name (e.g., 'v1.0.0')", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "tag" + ], + "type": "object" + }, + "name": "get_release_by_tag" +} \ No newline at end of file diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 0925829a1..de2c6d01f 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -1441,6 +1441,72 @@ func GetLatestRelease(getClient GetClientFn, t translations.TranslationHelperFun } } +func GetReleaseByTag(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_release_by_tag", + mcp.WithDescription(t("TOOL_GET_RELEASE_BY_TAG_DESCRIPTION", "Get a specific release by its tag name in a GitHub repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_RELEASE_BY_TAG_USER_TITLE", "Get a release by tag name"), + 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 (e.g., 'v1.0.0')"), + ), + ), + 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) + } + + release, resp, err := client.Repositories.GetReleaseByTag(ctx, owner, repo, tag) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to get release by tag: %s", tag), + resp, + err, + ), nil + } + 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 release by tag: %s", string(body))), nil + } + + r, err := json.Marshal(release) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + // filterPaths filters the entries in a GitHub tree to find paths that // match the given suffix. // maxResults limits the number of results returned to first maxResults entries, diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 63e577600..f5ebfd32b 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -2287,6 +2287,171 @@ func Test_GetLatestRelease(t *testing.T) { } } +func Test_GetReleaseByTag(t *testing.T) { + mockClient := github.NewClient(nil) + tool, _ := GetReleaseByTag(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "get_release_by_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"}) + + mockRelease := &github.RepositoryRelease{ + ID: github.Ptr(int64(1)), + TagName: github.Ptr("v1.0.0"), + Name: github.Ptr("Release v1.0.0"), + Body: github.Ptr("This is the first stable release."), + Assets: []*github.ReleaseAsset{ + { + ID: github.Ptr(int64(1)), + Name: github.Ptr("release-v1.0.0.tar.gz"), + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedResult *github.RepositoryRelease + expectedErrMsg string + }{ + { + name: "successful release by tag fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposReleasesTagsByOwnerByRepoByTag, + mockRelease, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "tag": "v1.0.0", + }, + expectError: false, + expectedResult: mockRelease, + }, + { + name: "missing owner parameter", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "repo": "repo", + "tag": "v1.0.0", + }, + expectError: false, // Returns tool error, not Go error + expectedErrMsg: "missing required parameter: owner", + }, + { + name: "missing repo parameter", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner": "owner", + "tag": "v1.0.0", + }, + expectError: false, // Returns tool error, not Go error + expectedErrMsg: "missing required parameter: repo", + }, + { + name: "missing tag parameter", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, // Returns tool error, not Go error + expectedErrMsg: "missing required parameter: tag", + }, + { + name: "release by tag not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposReleasesTagsByOwnerByRepoByTag, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "tag": "v999.0.0", + }, + expectError: false, // API errors return tool errors, not Go errors + expectedErrMsg: "failed to get release by tag: v999.0.0", + }, + { + name: "server error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposReleasesTagsByOwnerByRepoByTag, + 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", + "tag": "v1.0.0", + }, + expectError: false, // API errors return tool errors, not Go errors + expectedErrMsg: "failed to get release by tag: v1.0.0", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + _, handler := GetReleaseByTag(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) + + if tc.expectedErrMsg != "" { + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + + var returnedRelease github.RepositoryRelease + err = json.Unmarshal([]byte(textContent.Text), &returnedRelease) + require.NoError(t, err) + + assert.Equal(t, *tc.expectedResult.ID, *returnedRelease.ID) + assert.Equal(t, *tc.expectedResult.TagName, *returnedRelease.TagName) + assert.Equal(t, *tc.expectedResult.Name, *returnedRelease.Name) + if tc.expectedResult.Body != nil { + assert.Equal(t, *tc.expectedResult.Body, *returnedRelease.Body) + } + if len(tc.expectedResult.Assets) > 0 { + require.Len(t, returnedRelease.Assets, len(tc.expectedResult.Assets)) + assert.Equal(t, *tc.expectedResult.Assets[0].Name, *returnedRelease.Assets[0].Name) + } + }) + } +} + func Test_filterPaths(t *testing.T) { tests := []struct { name string diff --git a/pkg/github/tools.go b/pkg/github/tools.go index b50499650..513b93e42 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -33,6 +33,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(GetTag(getClient, t)), toolsets.NewServerTool(ListReleases(getClient, t)), toolsets.NewServerTool(GetLatestRelease(getClient, t)), + toolsets.NewServerTool(GetReleaseByTag(getClient, t)), ). AddWriteTools( toolsets.NewServerTool(CreateOrUpdateFile(getClient, t)), From b189531635de259de730006a58a9c2295a8644d3 Mon Sep 17 00:00:00 2001 From: Rebecca Biju <113070179+beccccaboo@users.noreply.github.com> Date: Thu, 21 Aug 2025 03:24:00 -0700 Subject: [PATCH 5/9] docs(readme): Update readme to point to correct installation guides index (#892) * docs(readme): Update readme to point to correct installation guides index * feat(contributors): add list_repository_contributors tool * Revert "feat(contributors): add list_repository_contributors tool" This reverts commit ece480ea6f99f7131a6faa2d12fb2f62d3e53332. --------- Co-authored-by: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b4168a136..58166345a 100644 --- a/README.md +++ b/README.md @@ -244,7 +244,7 @@ For other MCP host applications, please refer to our installation guides: - **[Cursor](docs/installation-guides/install-cursor.md)** - Installation guide for Cursor IDE - **[Windsurf](docs/installation-guides/install-windsurf.md)** - Installation guide for Windsurf IDE -For a complete overview of all installation options, see our **[Installation Guides Index](docs/installation-guides/installation-guides.md)**. +For a complete overview of all installation options, see our **[Installation Guides Index](docs/installation-guides)**. > **Note:** Any host application that supports local MCP servers should be able to access the local GitHub MCP server. However, the specific configuration process, syntax and stability of the integration will vary by host application. While many may follow a similar format to the examples above, this is not guaranteed. Please refer to your host application's documentation for the correct MCP configuration syntax and setup process. From 47040f43ef43c34fb7783fff78f9758048da96b4 Mon Sep 17 00:00:00 2001 From: Jurre Date: Thu, 21 Aug 2025 12:48:46 +0200 Subject: [PATCH 6/9] Add Global Security Advisories Toolset (#919) --- README.md | 29 ++- docs/remote-server.md | 1 + pkg/github/security_advisories.go | 228 +++++++++++++++++++++++ pkg/github/security_advisories_test.go | 243 +++++++++++++++++++++++++ pkg/github/tools.go | 7 + 5 files changed, 505 insertions(+), 3 deletions(-) create mode 100644 pkg/github/security_advisories.go create mode 100644 pkg/github/security_advisories_test.go diff --git a/README.md b/README.md index 58166345a..06d8d3f44 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Alternatively, to manually configure VS Code, choose the appropriate JSON block VS Code (version 1.101 or greater) - + ```json { "servers": { @@ -130,7 +130,7 @@ To keep your GitHub PAT secure and reusable across different MCP hosts: ```bash # CLI usage claude mcp update github -e GITHUB_PERSONAL_ACCESS_TOKEN=$GITHUB_PAT - + # In config files (where supported) "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_PAT" @@ -241,7 +241,7 @@ For other MCP host applications, please refer to our installation guides: - **[GitHub Copilot in other IDEs](/docs/installation-guides/install-other-copilot-ides.md)** - Installation for JetBrains, Visual Studio, Eclipse, and Xcode with GitHub Copilot - **[Claude Code & Claude Desktop](docs/installation-guides/install-claude.md)** - Installation guide for Claude Code and Claude Desktop -- **[Cursor](docs/installation-guides/install-cursor.md)** - Installation guide for Cursor IDE +- **[Cursor](docs/installation-guides/install-cursor.md)** - Installation guide for Cursor IDE - **[Windsurf](docs/installation-guides/install-windsurf.md)** - Installation guide for Windsurf IDE For a complete overview of all installation options, see our **[Installation Guides Index](docs/installation-guides)**. @@ -295,6 +295,7 @@ The following sets of tools are available (all are on by default): | `pull_requests` | GitHub Pull Request related tools | | `repos` | GitHub Repository related tools | | `secret_protection` | Secret protection related tools, such as GitHub Secret Scanning | +| `security_advisories` | Security advisories related tools | | `users` | GitHub User related tools | @@ -923,6 +924,28 @@ The following sets of tools are available (all are on by default):
+Security Advisories + +- **get_global_security_advisory** - Get a global security advisory + - `ghsaId`: GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx). (string, required) + +- **list_global_security_advisories** - List global security advisories + - `affects`: Filter advisories by affected package or version (e.g. "package1,package2@1.0.0"). (string, optional) + - `cveId`: Filter by CVE ID. (string, optional) + - `cwes`: Filter by Common Weakness Enumeration IDs (e.g. ["79", "284", "22"]). (string[], optional) + - `ecosystem`: Filter by package ecosystem. (string, optional) + - `ghsaId`: Filter by GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx). (string, optional) + - `isWithdrawn`: Whether to only return withdrawn advisories. (boolean, optional) + - `modified`: Filter by publish or update date or date range (ISO 8601 date or range). (string, optional) + - `published`: Filter by publish date or date range (ISO 8601 date or range). (string, optional) + - `severity`: Filter by severity. (string, optional) + - `type`: Advisory type. (string, optional) + - `updated`: Filter by update date or date range (ISO 8601 date or range). (string, optional) + +
+ +
+ Users - **search_users** - Search users diff --git a/docs/remote-server.md b/docs/remote-server.md index 5f57f4961..b6f7fa61d 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -32,6 +32,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | Pull Requests | GitHub Pull Request related tools | https://api.githubcopilot.com/mcp/x/pull_requests | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/pull_requests/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%2Freadonly%22%7D) | | Repositories | GitHub Repository related tools | https://api.githubcopilot.com/mcp/x/repos | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/repos/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%2Freadonly%22%7D) | | Secret Protection | Secret protection related tools, such as GitHub Secret Scanning | https://api.githubcopilot.com/mcp/x/secret_protection | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/secret_protection/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%2Freadonly%22%7D) | +| Security Advisories | Security advisories related tools | https://api.githubcopilot.com/mcp/x/security_advisories | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-security_advisories&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecurity_advisories%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/security_advisories/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-security_advisories&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecurity_advisories%2Freadonly%22%7D) | | Users | GitHub User related tools | https://api.githubcopilot.com/mcp/x/users | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/users/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%2Freadonly%22%7D) | diff --git a/pkg/github/security_advisories.go b/pkg/github/security_advisories.go new file mode 100644 index 000000000..ee9af3af9 --- /dev/null +++ b/pkg/github/security_advisories.go @@ -0,0 +1,228 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v74/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +func ListGlobalSecurityAdvisories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_global_security_advisories", + mcp.WithDescription(t("TOOL_LIST_GLOBAL_SECURITY_ADVISORIES_DESCRIPTION", "List global security advisories from GitHub.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_GLOBAL_SECURITY_ADVISORIES_USER_TITLE", "List global security advisories"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("ghsaId", + mcp.Description("Filter by GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx)."), + ), + mcp.WithString("type", + mcp.Description("Advisory type."), + mcp.Enum("reviewed", "malware", "unreviewed"), + mcp.DefaultString("reviewed"), + ), + mcp.WithString("cveId", + mcp.Description("Filter by CVE ID."), + ), + mcp.WithString("ecosystem", + mcp.Description("Filter by package ecosystem."), + mcp.Enum("actions", "composer", "erlang", "go", "maven", "npm", "nuget", "other", "pip", "pub", "rubygems", "rust"), + ), + mcp.WithString("severity", + mcp.Description("Filter by severity."), + mcp.Enum("unknown", "low", "medium", "high", "critical"), + ), + mcp.WithArray("cwes", + mcp.Description("Filter by Common Weakness Enumeration IDs (e.g. [\"79\", \"284\", \"22\"])."), + mcp.Items(map[string]any{ + "type": "string", + }), + ), + mcp.WithBoolean("isWithdrawn", + mcp.Description("Whether to only return withdrawn advisories."), + ), + mcp.WithString("affects", + mcp.Description("Filter advisories by affected package or version (e.g. \"package1,package2@1.0.0\")."), + ), + mcp.WithString("published", + mcp.Description("Filter by publish date or date range (ISO 8601 date or range)."), + ), + mcp.WithString("updated", + mcp.Description("Filter by update date or date range (ISO 8601 date or range)."), + ), + mcp.WithString("modified", + mcp.Description("Filter by publish or update date or date range (ISO 8601 date or range)."), + ), + ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + ghsaID, err := OptionalParam[string](request, "ghsaId") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid ghsaId: %v", err)), nil + } + + typ, err := OptionalParam[string](request, "type") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid type: %v", err)), nil + } + + cveID, err := OptionalParam[string](request, "cveId") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid cveId: %v", err)), nil + } + + eco, err := OptionalParam[string](request, "ecosystem") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid ecosystem: %v", err)), nil + } + + sev, err := OptionalParam[string](request, "severity") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid severity: %v", err)), nil + } + + cwes, err := OptionalParam[[]string](request, "cwes") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid cwes: %v", err)), nil + } + + isWithdrawn, err := OptionalParam[bool](request, "isWithdrawn") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid isWithdrawn: %v", err)), nil + } + + affects, err := OptionalParam[string](request, "affects") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid affects: %v", err)), nil + } + + published, err := OptionalParam[string](request, "published") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid published: %v", err)), nil + } + + updated, err := OptionalParam[string](request, "updated") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid updated: %v", err)), nil + } + + modified, err := OptionalParam[string](request, "modified") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid modified: %v", err)), nil + } + + opts := &github.ListGlobalSecurityAdvisoriesOptions{} + + if ghsaID != "" { + opts.GHSAID = &ghsaID + } + if typ != "" { + opts.Type = &typ + } + if cveID != "" { + opts.CVEID = &cveID + } + if eco != "" { + opts.Ecosystem = &eco + } + if sev != "" { + opts.Severity = &sev + } + if len(cwes) > 0 { + opts.CWEs = cwes + } + + if isWithdrawn { + opts.IsWithdrawn = &isWithdrawn + } + + if affects != "" { + opts.Affects = &affects + } + if published != "" { + opts.Published = &published + } + if updated != "" { + opts.Updated = &updated + } + if modified != "" { + opts.Modified = &modified + } + + advisories, resp, err := client.SecurityAdvisories.ListGlobalSecurityAdvisories(ctx, opts) + if err != nil { + return nil, fmt.Errorf("failed to list global security advisories: %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 advisories: %s", string(body))), nil + } + + r, err := json.Marshal(advisories) + if err != nil { + return nil, fmt.Errorf("failed to marshal advisories: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +func GetGlobalSecurityAdvisory(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_global_security_advisory", + mcp.WithDescription(t("TOOL_GET_GLOBAL_SECURITY_ADVISORY_DESCRIPTION", "Get a global security advisory")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_GLOBAL_SECURITY_ADVISORY_USER_TITLE", "Get a global security advisory"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("ghsaId", + mcp.Description("GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx)."), + mcp.Required(), + ), + ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + ghsaID, err := RequiredParam[string](request, "ghsaId") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid ghsaId: %v", err)), nil + } + + advisory, resp, err := client.SecurityAdvisories.GetGlobalSecurityAdvisories(ctx, ghsaID) + if err != nil { + return nil, fmt.Errorf("failed to get advisory: %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 advisory: %s", string(body))), nil + } + + r, err := json.Marshal(advisory) + if err != nil { + return nil, fmt.Errorf("failed to marshal advisory: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} diff --git a/pkg/github/security_advisories_test.go b/pkg/github/security_advisories_test.go new file mode 100644 index 000000000..76a63390a --- /dev/null +++ b/pkg/github/security_advisories_test.go @@ -0,0 +1,243 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v74/github" + "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_ListGlobalSecurityAdvisories(t *testing.T) { + mockClient := github.NewClient(nil) + tool, _ := ListGlobalSecurityAdvisories(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "list_global_security_advisories", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "ecosystem") + assert.Contains(t, tool.InputSchema.Properties, "severity") + assert.Contains(t, tool.InputSchema.Properties, "ghsaId") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{}) + + // Setup mock advisory for success case + mockAdvisory := &github.GlobalSecurityAdvisory{ + SecurityAdvisory: github.SecurityAdvisory{ + GHSAID: github.Ptr("GHSA-xxxx-xxxx-xxxx"), + Summary: github.Ptr("Test advisory"), + Description: github.Ptr("This is a test advisory."), + Severity: github.Ptr("high"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedAdvisories []*github.GlobalSecurityAdvisory + expectedErrMsg string + }{ + { + name: "successful advisory fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetAdvisories, + []*github.GlobalSecurityAdvisory{mockAdvisory}, + ), + ), + requestArgs: map[string]interface{}{ + "type": "reviewed", + "ecosystem": "npm", + "severity": "high", + }, + expectError: false, + expectedAdvisories: []*github.GlobalSecurityAdvisory{mockAdvisory}, + }, + { + name: "invalid severity value", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetAdvisories, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Bad Request"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "type": "reviewed", + "severity": "extreme", + }, + expectError: true, + expectedErrMsg: "failed to list global security advisories", + }, + { + name: "API error handling", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetAdvisories, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message": "Internal Server Error"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{}, + expectError: true, + expectedErrMsg: "failed to list global security advisories", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ListGlobalSecurityAdvisories(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 returnedAdvisories []*github.GlobalSecurityAdvisory + err = json.Unmarshal([]byte(textContent.Text), &returnedAdvisories) + assert.NoError(t, err) + assert.Len(t, returnedAdvisories, len(tc.expectedAdvisories)) + for i, advisory := range returnedAdvisories { + assert.Equal(t, *tc.expectedAdvisories[i].GHSAID, *advisory.GHSAID) + assert.Equal(t, *tc.expectedAdvisories[i].Summary, *advisory.Summary) + assert.Equal(t, *tc.expectedAdvisories[i].Description, *advisory.Description) + assert.Equal(t, *tc.expectedAdvisories[i].Severity, *advisory.Severity) + } + }) + } +} + +func Test_GetGlobalSecurityAdvisory(t *testing.T) { + mockClient := github.NewClient(nil) + tool, _ := GetGlobalSecurityAdvisory(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "get_global_security_advisory", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "ghsaId") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"ghsaId"}) + + // Setup mock advisory for success case + mockAdvisory := &github.GlobalSecurityAdvisory{ + SecurityAdvisory: github.SecurityAdvisory{ + GHSAID: github.Ptr("GHSA-xxxx-xxxx-xxxx"), + Summary: github.Ptr("Test advisory"), + Description: github.Ptr("This is a test advisory."), + Severity: github.Ptr("high"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedAdvisory *github.GlobalSecurityAdvisory + expectedErrMsg string + }{ + { + name: "successful advisory fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetAdvisoriesByGhsaId, + mockAdvisory, + ), + ), + requestArgs: map[string]interface{}{ + "ghsaId": "GHSA-xxxx-xxxx-xxxx", + }, + expectError: false, + expectedAdvisory: mockAdvisory, + }, + { + name: "invalid ghsaId format", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetAdvisoriesByGhsaId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Bad Request"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "ghsaId": "invalid-ghsa-id", + }, + expectError: true, + expectedErrMsg: "failed to get advisory", + }, + { + name: "advisory not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetAdvisoriesByGhsaId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "ghsaId": "GHSA-xxxx-xxxx-xxxx", + }, + expectError: true, + expectedErrMsg: "failed to get advisory", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := GetGlobalSecurityAdvisory(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) + + // Verify the result + assert.Contains(t, textContent.Text, *tc.expectedAdvisory.GHSAID) + assert.Contains(t, textContent.Text, *tc.expectedAdvisory.Summary) + assert.Contains(t, textContent.Text, *tc.expectedAdvisory.Description) + assert.Contains(t, textContent.Text, *tc.expectedAdvisory.Severity) + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 513b93e42..591717a81 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -160,6 +160,12 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(DeleteWorkflowRunLogs(getClient, t)), ) + securityAdvisories := toolsets.NewToolset("security_advisories", "Security advisories related tools"). + AddReadTools( + toolsets.NewServerTool(ListGlobalSecurityAdvisories(getClient, t)), + toolsets.NewServerTool(GetGlobalSecurityAdvisory(getClient, t)), + ) + // Keep experiments alive so the system doesn't error out when it's always enabled experiments := toolsets.NewToolset("experiments", "Experimental features that are not considered stable yet") @@ -194,6 +200,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG tsg.AddToolset(experiments) tsg.AddToolset(discussions) tsg.AddToolset(gists) + tsg.AddToolset(securityAdvisories) return tsg } From d03072f1a9e193d9a99b634e9b2affa999e24165 Mon Sep 17 00:00:00 2001 From: Jurre Date: Thu, 21 Aug 2025 15:20:20 +0200 Subject: [PATCH 7/9] Repository security advisories (#925) * Add support for listing repo level security advisories * Add support for listing repo security advisories at the org level --- README.md | 13 ++ pkg/github/security_advisories.go | 169 +++++++++++++++ pkg/github/security_advisories_test.go | 283 +++++++++++++++++++++++++ pkg/github/tools.go | 2 + 4 files changed, 467 insertions(+) diff --git a/README.md b/README.md index 06d8d3f44..a6e740e66 100644 --- a/README.md +++ b/README.md @@ -942,6 +942,19 @@ The following sets of tools are available (all are on by default): - `type`: Advisory type. (string, optional) - `updated`: Filter by update date or date range (ISO 8601 date or range). (string, optional) +- **list_org_repository_security_advisories** - List org repository security advisories + - `direction`: Sort direction. (string, optional) + - `org`: The organization login. (string, required) + - `sort`: Sort field. (string, optional) + - `state`: Filter by advisory state. (string, optional) + +- **list_repository_security_advisories** - List repository security advisories + - `direction`: Sort direction. (string, optional) + - `owner`: The owner of the repository. (string, required) + - `repo`: The name of the repository. (string, required) + - `sort`: Sort field. (string, optional) + - `state`: Filter by advisory state. (string, optional) +
diff --git a/pkg/github/security_advisories.go b/pkg/github/security_advisories.go index ee9af3af9..6eaeebe47 100644 --- a/pkg/github/security_advisories.go +++ b/pkg/github/security_advisories.go @@ -182,6 +182,95 @@ func ListGlobalSecurityAdvisories(getClient GetClientFn, t translations.Translat } } +func ListRepositorySecurityAdvisories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_repository_security_advisories", + mcp.WithDescription(t("TOOL_LIST_REPOSITORY_SECURITY_ADVISORIES_DESCRIPTION", "List repository security advisories for a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_REPOSITORY_SECURITY_ADVISORIES_USER_TITLE", "List repository security advisories"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The owner of the repository."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("The name of the repository."), + ), + mcp.WithString("direction", + mcp.Description("Sort direction."), + mcp.Enum("asc", "desc"), + ), + mcp.WithString("sort", + mcp.Description("Sort field."), + mcp.Enum("created", "updated", "published"), + ), + mcp.WithString("state", + mcp.Description("Filter by advisory state."), + mcp.Enum("triage", "draft", "published", "closed"), + ), + ), 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 + } + + direction, err := OptionalParam[string](request, "direction") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + sortField, err := OptionalParam[string](request, "sort") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + state, err := OptionalParam[string](request, "state") + 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) + } + + opts := &github.ListRepositorySecurityAdvisoriesOptions{} + if direction != "" { + opts.Direction = direction + } + if sortField != "" { + opts.Sort = sortField + } + if state != "" { + opts.State = state + } + + advisories, resp, err := client.SecurityAdvisories.ListRepositorySecurityAdvisories(ctx, owner, repo, opts) + if err != nil { + return nil, fmt.Errorf("failed to list repository security advisories: %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 repository advisories: %s", string(body))), nil + } + + r, err := json.Marshal(advisories) + if err != nil { + return nil, fmt.Errorf("failed to marshal advisories: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + func GetGlobalSecurityAdvisory(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_global_security_advisory", mcp.WithDescription(t("TOOL_GET_GLOBAL_SECURITY_ADVISORY_DESCRIPTION", "Get a global security advisory")), @@ -226,3 +315,83 @@ func GetGlobalSecurityAdvisory(getClient GetClientFn, t translations.Translation return mcp.NewToolResultText(string(r)), nil } } + +func ListOrgRepositorySecurityAdvisories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_org_repository_security_advisories", + mcp.WithDescription(t("TOOL_LIST_ORG_REPOSITORY_SECURITY_ADVISORIES_DESCRIPTION", "List repository security advisories for a GitHub organization.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_ORG_REPOSITORY_SECURITY_ADVISORIES_USER_TITLE", "List org repository security advisories"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("org", + mcp.Required(), + mcp.Description("The organization login."), + ), + mcp.WithString("direction", + mcp.Description("Sort direction."), + mcp.Enum("asc", "desc"), + ), + mcp.WithString("sort", + mcp.Description("Sort field."), + mcp.Enum("created", "updated", "published"), + ), + mcp.WithString("state", + mcp.Description("Filter by advisory state."), + mcp.Enum("triage", "draft", "published", "closed"), + ), + ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + org, err := RequiredParam[string](request, "org") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + direction, err := OptionalParam[string](request, "direction") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + sortField, err := OptionalParam[string](request, "sort") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + state, err := OptionalParam[string](request, "state") + 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) + } + + opts := &github.ListRepositorySecurityAdvisoriesOptions{} + if direction != "" { + opts.Direction = direction + } + if sortField != "" { + opts.Sort = sortField + } + if state != "" { + opts.State = state + } + + advisories, resp, err := client.SecurityAdvisories.ListRepositorySecurityAdvisoriesForOrg(ctx, org, opts) + if err != nil { + return nil, fmt.Errorf("failed to list organization repository security advisories: %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 organization repository advisories: %s", string(body))), nil + } + + r, err := json.Marshal(advisories) + if err != nil { + return nil, fmt.Errorf("failed to marshal advisories: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} diff --git a/pkg/github/security_advisories_test.go b/pkg/github/security_advisories_test.go index 76a63390a..0640f917d 100644 --- a/pkg/github/security_advisories_test.go +++ b/pkg/github/security_advisories_test.go @@ -241,3 +241,286 @@ func Test_GetGlobalSecurityAdvisory(t *testing.T) { }) } } + +func Test_ListRepositorySecurityAdvisories(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ListRepositorySecurityAdvisories(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "list_repository_security_advisories", 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, "direction") + assert.Contains(t, tool.InputSchema.Properties, "sort") + assert.Contains(t, tool.InputSchema.Properties, "state") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + // Local endpoint pattern for repository security advisories + var GetReposSecurityAdvisoriesByOwnerByRepo = mock.EndpointPattern{ + Pattern: "/repos/{owner}/{repo}/security-advisories", + Method: "GET", + } + + // Setup mock advisories for success cases + adv1 := &github.SecurityAdvisory{ + GHSAID: github.Ptr("GHSA-1111-1111-1111"), + Summary: github.Ptr("Repo advisory one"), + Description: github.Ptr("First repo advisory."), + Severity: github.Ptr("high"), + } + adv2 := &github.SecurityAdvisory{ + GHSAID: github.Ptr("GHSA-2222-2222-2222"), + Summary: github.Ptr("Repo advisory two"), + Description: github.Ptr("Second repo advisory."), + Severity: github.Ptr("medium"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedAdvisories []*github.SecurityAdvisory + expectedErrMsg string + }{ + { + name: "successful advisories listing (no filters)", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + GetReposSecurityAdvisoriesByOwnerByRepo, + expect(t, expectations{ + path: "/repos/owner/repo/security-advisories", + queryParams: map[string]string{}, + }).andThen( + mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{adv1, adv2}), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + expectedAdvisories: []*github.SecurityAdvisory{adv1, adv2}, + }, + { + name: "successful advisories listing with filters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + GetReposSecurityAdvisoriesByOwnerByRepo, + expect(t, expectations{ + path: "/repos/octo/hello-world/security-advisories", + queryParams: map[string]string{ + "direction": "desc", + "sort": "updated", + "state": "published", + }, + }).andThen( + mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{adv1}), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octo", + "repo": "hello-world", + "direction": "desc", + "sort": "updated", + "state": "published", + }, + expectError: false, + expectedAdvisories: []*github.SecurityAdvisory{adv1}, + }, + { + name: "advisories listing fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + GetReposSecurityAdvisoriesByOwnerByRepo, + expect(t, expectations{ + path: "/repos/owner/repo/security-advisories", + queryParams: map[string]string{}, + }).andThen( + mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "Internal Server Error"}), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "failed to list repository security advisories", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + _, handler := ListRepositorySecurityAdvisories(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) + + textContent := getTextResult(t, result) + + var returnedAdvisories []*github.SecurityAdvisory + err = json.Unmarshal([]byte(textContent.Text), &returnedAdvisories) + assert.NoError(t, err) + assert.Len(t, returnedAdvisories, len(tc.expectedAdvisories)) + for i, advisory := range returnedAdvisories { + assert.Equal(t, *tc.expectedAdvisories[i].GHSAID, *advisory.GHSAID) + assert.Equal(t, *tc.expectedAdvisories[i].Summary, *advisory.Summary) + assert.Equal(t, *tc.expectedAdvisories[i].Description, *advisory.Description) + assert.Equal(t, *tc.expectedAdvisories[i].Severity, *advisory.Severity) + } + }) + } +} + +func Test_ListOrgRepositorySecurityAdvisories(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ListOrgRepositorySecurityAdvisories(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "list_org_repository_security_advisories", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "org") + assert.Contains(t, tool.InputSchema.Properties, "direction") + assert.Contains(t, tool.InputSchema.Properties, "sort") + assert.Contains(t, tool.InputSchema.Properties, "state") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"org"}) + + // Endpoint pattern for org repository security advisories + var GetOrgsSecurityAdvisoriesByOrg = mock.EndpointPattern{ + Pattern: "/orgs/{org}/security-advisories", + Method: "GET", + } + + adv1 := &github.SecurityAdvisory{ + GHSAID: github.Ptr("GHSA-aaaa-bbbb-cccc"), + Summary: github.Ptr("Org repo advisory 1"), + Description: github.Ptr("First advisory"), + Severity: github.Ptr("low"), + } + adv2 := &github.SecurityAdvisory{ + GHSAID: github.Ptr("GHSA-dddd-eeee-ffff"), + Summary: github.Ptr("Org repo advisory 2"), + Description: github.Ptr("Second advisory"), + Severity: github.Ptr("critical"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedAdvisories []*github.SecurityAdvisory + expectedErrMsg string + }{ + { + name: "successful listing (no filters)", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + GetOrgsSecurityAdvisoriesByOrg, + expect(t, expectations{ + path: "/orgs/octo/security-advisories", + queryParams: map[string]string{}, + }).andThen( + mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{adv1, adv2}), + ), + ), + ), + requestArgs: map[string]interface{}{ + "org": "octo", + }, + expectError: false, + expectedAdvisories: []*github.SecurityAdvisory{adv1, adv2}, + }, + { + name: "successful listing with filters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + GetOrgsSecurityAdvisoriesByOrg, + expect(t, expectations{ + path: "/orgs/octo/security-advisories", + queryParams: map[string]string{ + "direction": "asc", + "sort": "created", + "state": "triage", + }, + }).andThen( + mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{adv1}), + ), + ), + ), + requestArgs: map[string]interface{}{ + "org": "octo", + "direction": "asc", + "sort": "created", + "state": "triage", + }, + expectError: false, + expectedAdvisories: []*github.SecurityAdvisory{adv1}, + }, + { + name: "listing fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + GetOrgsSecurityAdvisoriesByOrg, + expect(t, expectations{ + path: "/orgs/octo/security-advisories", + queryParams: map[string]string{}, + }).andThen( + mockResponse(t, http.StatusForbidden, map[string]string{"message": "Forbidden"}), + ), + ), + ), + requestArgs: map[string]interface{}{ + "org": "octo", + }, + expectError: true, + expectedErrMsg: "failed to list organization repository security advisories", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + _, handler := ListOrgRepositorySecurityAdvisories(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) + + textContent := getTextResult(t, result) + + var returnedAdvisories []*github.SecurityAdvisory + err = json.Unmarshal([]byte(textContent.Text), &returnedAdvisories) + assert.NoError(t, err) + assert.Len(t, returnedAdvisories, len(tc.expectedAdvisories)) + for i, advisory := range returnedAdvisories { + assert.Equal(t, *tc.expectedAdvisories[i].GHSAID, *advisory.GHSAID) + assert.Equal(t, *tc.expectedAdvisories[i].Summary, *advisory.Summary) + assert.Equal(t, *tc.expectedAdvisories[i].Description, *advisory.Description) + assert.Equal(t, *tc.expectedAdvisories[i].Severity, *advisory.Severity) + } + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 591717a81..728d78097 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -164,6 +164,8 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG AddReadTools( toolsets.NewServerTool(ListGlobalSecurityAdvisories(getClient, t)), toolsets.NewServerTool(GetGlobalSecurityAdvisory(getClient, t)), + toolsets.NewServerTool(ListRepositorySecurityAdvisories(getClient, t)), + toolsets.NewServerTool(ListOrgRepositorySecurityAdvisories(getClient, t)), ) // Keep experiments alive so the system doesn't error out when it's always enabled From 6dc5540b36db04d1b74c49b94c6b845baef14049 Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:24:29 +0200 Subject: [PATCH 8/9] Update Cursor installation link (#940) * use new link * update local install link --- docs/installation-guides/install-cursor.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/installation-guides/install-cursor.md b/docs/installation-guides/install-cursor.md index b069addd3..654f0a788 100644 --- a/docs/installation-guides/install-cursor.md +++ b/docs/installation-guides/install-cursor.md @@ -1,17 +1,19 @@ # Install GitHub MCP Server in Cursor ## Prerequisites + 1. Cursor IDE installed (latest version) 2. [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new) with appropriate scopes 3. For local installation: [Docker](https://www.docker.com/) installed and running ## Remote Server Setup (Recommended) -[![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=github&config=eyJ1cmwiOiJodHRwczovL2FwaS5naXRodWJjb3BpbG90LmNvbS9tY3AvIiwiaGVhZGVycyI6eyJBdXRob3JpemF0aW9uIjoiQmVhcmVyIFlPVVJfR0lUSFVCX1BBVCJ9LCJ0eXBlIjoiaHR0cCJ9) +[![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=github&config=eyJ1cmwiOiJodHRwczovL2FwaS5naXRodWJjb3BpbG90LmNvbS9tY3AvIiwiaGVhZGVycyI6eyJBdXRob3JpemF0aW9uIjoiQmVhcmVyIFlPVVJfR0lUSFVCX1BBVCJ9fQ%3D%3D) Uses GitHub's hosted server at https://api.githubcopilot.com/mcp/. Requires Cursor v0.48.0+ for Streamable HTTP support. While Cursor supports OAuth for some MCP servers, the GitHub server currently requires a Personal Access Token. ### Install steps + 1. Click the install button above and follow the flow, or go directly to your global MCP configuration file at `~/.cursor/mcp.json` and enter the code block below 2. In Tools & Integrations > MCP tools, click the pencil icon next to "github" 3. Replace `YOUR_GITHUB_PAT` with your actual [GitHub Personal Access Token](https://github.com/settings/tokens) @@ -35,11 +37,12 @@ Uses GitHub's hosted server at https://api.githubcopilot.com/mcp/. Requires Curs ## Local Server Setup -[![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=github&config=eyJjb21tYW5kIjoiZG9ja2VyIiwiYXJncyI6WyJydW4iLCItaSIsIi0tcm0iLCItZSIsIkdJVEhVQl9QRVJTT05BTF9BQ0NFU1NfVE9LRU4iLCJnaGNyLmlvL2dpdGh1Yi9naXRodWItbWNwLXNlcnZlciJdLCJlbnYiOnsiR0lUSFVCX1BFUlNPTkFMX0FDQ0VTU19UT0tFTiI6IllPVVJfR0lUSFVCX1BHVCJ9fQ==) +[![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=github&config=eyJjb21tYW5kIjoiZG9ja2VyIHJ1biAtaSAtLXJtIC1lIEdJVEhVQl9QRVJTT05BTF9BQ0NFU1NfVE9LRU4gZ2hjci5pby9naXRodWIvZ2l0aHViLW1jcC1zZXJ2ZXIiLCJlbnYiOnsiR0lUSFVCX1BFUlNPTkFMX0FDQ0VTU19UT0tFTiI6IllPVVJfR0lUSFVCX1BBVCJ9fQ%3D%3D) The local GitHub MCP server runs via Docker and requires Docker Desktop to be installed and running. ### Install steps + 1. Click the install button above and follow the flow, or go directly to your global MCP configuration file at `~/.cursor/mcp.json` and enter the code block below 2. In Tools & Integrations > MCP tools, click the pencil icon next to "github" 3. Replace `YOUR_GITHUB_PAT` with your actual [GitHub Personal Access Token](https://github.com/settings/tokens) @@ -77,6 +80,7 @@ The local GitHub MCP server runs via Docker and requires Docker Desktop to be in - **Project-specific**: `.cursor/mcp.json` in project root ## Verify Installation + 1. Restart Cursor completely 2. Check for green dot in Settings → Tools & Integrations → MCP Tools 3. In chat/composer, check "Available Tools" @@ -85,16 +89,19 @@ The local GitHub MCP server runs via Docker and requires Docker Desktop to be in ## Troubleshooting ### Remote Server Issues + - **Streamable HTTP not working**: Ensure you're using Cursor v0.48.0 or later - **Authentication failures**: Verify PAT has correct scopes - **Connection errors**: Check firewall/proxy settings ### Local Server Issues + - **Docker errors**: Ensure Docker Desktop is running - **Image pull failures**: Try `docker logout ghcr.io` then retry - **Docker not found**: Install Docker Desktop and ensure it's running ### General Issues + - **MCP not loading**: Restart Cursor completely after configuration - **Invalid JSON**: Validate that json format is correct - **Tools not appearing**: Check server shows green dot in MCP settings From 0418808647d2de927e73a4236d1e9895414a0062 Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Fri, 22 Aug 2025 00:20:26 +0200 Subject: [PATCH 9/9] Change role from "system" to "user" in prompt messages for `AssignCodingAgentPrompt` and `IssueToFixWorkflowPrompt`. Role "system" is not allowed by Claude Code in MCP provided prompt (allowed only role "user" and "assistant") (#941) Co-authored-by: 0xGosu <0xGosu@gmail.com> --- pkg/github/issues.go | 2 +- pkg/github/workflow_prompts.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 80fe22f9d..89375ae90 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1552,7 +1552,7 @@ func AssignCodingAgentPrompt(t translations.TranslationHelperFunc) (tool mcp.Pro messages := []mcp.PromptMessage{ { - Role: "system", + Role: "user", Content: mcp.NewTextContent("You are a personal assistant for GitHub the Copilot GitHub Coding Agent. Your task is to help the user assign tasks to the Coding Agent based on their open GitHub issues. You can use `assign_copilot_to_issue` tool to assign the Coding Agent to issues that are suitable for autonomous work, and `search_issues` tool to find issues that match the user's criteria. You can also use `list_issues` to get a list of issues in the repository."), }, { diff --git a/pkg/github/workflow_prompts.go b/pkg/github/workflow_prompts.go index 8a9545a42..42b6d51c8 100644 --- a/pkg/github/workflow_prompts.go +++ b/pkg/github/workflow_prompts.go @@ -37,7 +37,7 @@ func IssueToFixWorkflowPrompt(t translations.TranslationHelperFunc) (tool mcp.Pr messages := []mcp.PromptMessage{ { - Role: "system", + Role: "user", Content: mcp.NewTextContent("You are a development workflow assistant helping to create GitHub issues and generate corresponding pull requests to fix them. You should: 1) Create a well-structured issue with clear problem description, 2) Assign it to Copilot coding agent to generate a solution, and 3) Monitor the PR creation process."), }, {