From 39ff8409216be5499cc97952f45f5e5144d30432 Mon Sep 17 00:00:00 2001 From: Javier Uruen Val Date: Tue, 8 Apr 2025 07:55:26 +0200 Subject: [PATCH] add experimental get_latest_version tool --- README.md | 6 ++ pkg/github/server.go | 53 +++++++++++++++++ pkg/github/server_test.go | 116 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+) diff --git a/README.md b/README.md index cf53ce2a1..017660f20 100644 --- a/README.md +++ b/README.md @@ -382,6 +382,12 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `state`: Alert state (string, optional) - `severity`: Alert severity (string, optional) +### Version + +- **get_latest_version** - Get the latest version of the GitHub MCP server to see if you are up to date + + - No parameters required for this tool + ## Resources ### Repository Content diff --git a/pkg/github/server.go b/pkg/github/server.go index bf3583b92..2e235e996 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "time" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v69/github" @@ -77,6 +78,10 @@ func NewServer(client *github.Client, version string, readOnly bool, t translati // Add GitHub tools - Code Scanning s.AddTool(getCodeScanningAlert(client, t)) s.AddTool(listCodeScanningAlerts(client, t)) + + // Add GitHub tools - latest version + s.AddTool(getLatestVersion(client, version, t)) + return s } @@ -112,6 +117,54 @@ func getMe(client *github.Client, t translations.TranslationHelperFunc) (tool mc } } +// getLatestVersion creates a tool to get the latest version of the server. +// getMe creates a tool to get details of the authenticated user. +func getLatestVersion(client *github.Client, currentVersion string, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_latest_version", + mcp.WithDescription(t("TOOL_GET_ME_DESCRIPTION", "Checks the latest available version of the GitHub MCP server to see if it is up to date.")), + mcp.WithString("reason", + mcp.Description("Optional: reason the session was created"), + ), + ), + func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Get the latest release from GitHub API + release, resp, err := client.Repositories.GetLatestRelease(ctx, "github", "github-mcp-server") + if err != nil { + return nil, fmt.Errorf("failed to get latest release: %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 latest release: %s", string(body))), nil + } + + latestVersion := release.GetTagName() + if latestVersion == "" { + latestVersion = release.GetName() + } + + // Compare versions and create response + result := map[string]interface{}{ + "current_version": currentVersion, + "latest_version": latestVersion, + "up_to_date": currentVersion == latestVersion, + "release_url": release.GetHTMLURL(), + "published_at": release.GetPublishedAt().Format(time.RFC3339), + } + + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + // isAcceptedError checks if the error is an accepted error. func isAcceptedError(err error) bool { var acceptedError *github.AcceptedError diff --git a/pkg/github/server_test.go b/pkg/github/server_test.go index 149fb77ab..452ed63e6 100644 --- a/pkg/github/server_test.go +++ b/pkg/github/server_test.go @@ -634,3 +634,119 @@ func TestOptionalPaginationParams(t *testing.T) { }) } } + +func Test_GetLatestVersion(t *testing.T) { + // Verify tool definition + mockClient := github.NewClient(nil) + tool, _ := getLatestVersion(mockClient, "v1.0.0", translations.NullTranslationHelper) + + assert.Equal(t, "get_latest_version", tool.Name) + assert.NotEmpty(t, tool.Description) + + // Setup mock release response + mockRelease := &github.RepositoryRelease{ + TagName: github.Ptr("v1.1.0"), + Name: github.Ptr("Release v1.1.0"), + HTMLURL: github.Ptr("https://github.com/github/github-mcp-server/releases/tag/v1.1.0"), + PublishedAt: &github.Timestamp{Time: time.Now().Add(-24 * time.Hour)}, + } + + tests := []struct { + name string + mockedClient *http.Client + currentVersion string + expectError bool + expectedResult map[string]interface{} + expectedErrMsg string + }{ + { + name: "successful get latest version - up to date", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposReleasesLatestByOwnerByRepo, + mockResponse(t, http.StatusOK, mockRelease), + ), + ), + currentVersion: "v1.1.0", + expectError: false, + expectedResult: map[string]interface{}{ + "current_version": "v1.1.0", + "latest_version": "v1.1.0", + "up_to_date": true, + "release_url": "https://github.com/github/github-mcp-server/releases/tag/v1.1.0", + // We can't test exact published_at since it's dynamic + }, + }, + { + name: "successful get latest version - outdated", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposReleasesLatestByOwnerByRepo, + mockResponse(t, http.StatusOK, mockRelease), + ), + ), + currentVersion: "v1.0.0", + expectError: false, + expectedResult: map[string]interface{}{ + "current_version": "v1.0.0", + "latest_version": "v1.1.0", + "up_to_date": false, + "release_url": "https://github.com/github/github-mcp-server/releases/tag/v1.1.0", + // We can't test exact published_at since it's dynamic + }, + }, + { + name: "API request fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposReleasesLatestByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + currentVersion: "v1.0.0", + expectError: true, + expectedErrMsg: "failed to get latest release", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := getLatestVersion(client, tc.currentVersion, translations.NullTranslationHelper) + + // Create call request with empty parameters (none needed for this API) + request := createMCPRequest(map[string]interface{}{}) + + // 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 result and get text content + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var resultMap map[string]interface{} + err = json.Unmarshal([]byte(textContent.Text), &resultMap) + require.NoError(t, err) + + // Verify expected fields + assert.Equal(t, tc.expectedResult["current_version"], resultMap["current_version"]) + assert.Equal(t, tc.expectedResult["latest_version"], resultMap["latest_version"]) + assert.Equal(t, tc.expectedResult["up_to_date"], resultMap["up_to_date"]) + assert.Equal(t, tc.expectedResult["release_url"], resultMap["release_url"]) + assert.NotEmpty(t, resultMap["published_at"]) + }) + } +}