From f2d79f988ebff83f709a10b26b4eee33d17ec947 Mon Sep 17 00:00:00 2001 From: Artem Saveliev <artemsaveliev@github.com> Date: Wed, 9 Apr 2025 23:01:39 -0700 Subject: [PATCH 1/8] Added get_issue_events , get_issue_timeline and get_issue_event tools --- README.md | 21 +++ pkg/github/issues.go | 212 +++++++++++++++++++++++ pkg/github/issues_test.go | 355 ++++++++++++++++++++++++++++++++++++++ pkg/github/server.go | 13 ++ pkg/github/tools.go | 3 + 5 files changed, 604 insertions(+) diff --git a/README.md b/README.md index b9ef26a0a..e46bb85d8 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,27 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `repo`: Repository name (string, required) - `issue_number`: Issue number (number, required) +- **get_issue_events** - Get events for a GitHub issue + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `issue_number`: Issue number (number, required) + - `page`: Page number (number, optional) + - `perPage`: Results per page (number, optional) + +- **get_issue_timeline** - Get the timeline of events for a GitHub issue + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `issue_number`: Issue number (number, required) + - `page`: Page number (number, optional) + - `perPage`: Results per page (number, optional) + +- **get_issue_event** - Get a specific event for a GitHub issue + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `event_id`: Event ID (number, required) + - **create_issue** - Create a new issue in a GitHub repository - `owner`: Repository owner (string, required) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 0fcc2502f..4fd1c86d1 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -711,6 +711,218 @@ func GetIssueComments(getClient GetClientFn, t translations.TranslationHelperFun } } +// GetIssueTimeline creates a tool to get timeline for a GitHub issue. +func GetIssueTimeline(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_issue_timeline", + mcp.WithDescription(t("TOOL_GET_ISSUE_TIMELINE_DESCRIPTION", "Get timeline for a GitHub issue")), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("issue_number", + mcp.Required(), + mcp.Description("Issue number"), + ), + mcp.WithNumber("page", + mcp.Description("Page number"), + ), + mcp.WithNumber("per_page", + mcp.Description("Number of records per page"), + ), + ), + 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 + } + issueNumber, err := RequiredInt(request, "issue_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + page, err := OptionalIntParamWithDefault(request, "page", 1) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + perPage, err := OptionalIntParamWithDefault(request, "per_page", 30) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &github.ListOptions{ + Page: page, + PerPage: perPage, + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + events, resp, err := client.Issues.ListIssueTimeline(ctx, owner, repo, issueNumber, opts) + if err != nil { + return nil, fmt.Errorf("failed to get issue timeline: %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 issue timeline: %s", string(body))), nil + } + + r, err := json.Marshal(events) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// GetIssueEvents creates a tool to get events for a GitHub issue. +func GetIssueEvents(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_issue_events", + mcp.WithDescription(t("TOOL_GET_ISSUE_EVENTS_DESCRIPTION", "Get list of events for a GitHub issue")), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("issue_number", + mcp.Required(), + mcp.Description("Issue number"), + ), + mcp.WithNumber("page", + mcp.Description("Page number"), + ), + mcp.WithNumber("per_page", + mcp.Description("Number of records per page"), + ), + ), + 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 + } + issueNumber, err := RequiredInt(request, "issue_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + page, err := OptionalIntParamWithDefault(request, "page", 1) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + perPage, err := OptionalIntParamWithDefault(request, "per_page", 30) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &github.ListOptions{ + Page: page, + PerPage: perPage, + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + events, resp, err := client.Issues.ListIssueEvents(ctx, owner, repo, issueNumber, opts) + if err != nil { + return nil, fmt.Errorf("failed to get issue events: %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 issue events: %s", string(body))), nil + } + + r, err := json.Marshal(events) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// GetIssueEvent creates a tool to get an event for a GitHub issue. +func GetIssueEvent(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_issue_event", + mcp.WithDescription(t("TOOL_GET_ISSUE_EVENT_DESCRIPTION", "Get single event for a GitHub issue")), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("event_id", + mcp.Required(), + mcp.Description("Event ID"), + ), + ), + 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 + } + eventID, err := RequiredInt64(request, "event_id") + 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) + } + event, resp, err := client.Issues.GetEvent(ctx, owner, repo, eventID) + if err != nil { + return nil, fmt.Errorf("failed to get issue event: %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 issue event: %s", string(body))), nil + } + + r, err := json.Marshal(event) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + // parseISOTimestamp parses an ISO 8601 timestamp string into a time.Time object. // Returns the parsed time or an error if parsing fails. // Example formats supported: "2023-01-15T14:30:00Z", "2023-01-15" diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 61ca0ae7a..fdf0a40fc 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -1130,3 +1130,358 @@ func Test_GetIssueComments(t *testing.T) { }) } } + +func Test_GetIssueTimeline(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := GetIssueTimeline(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "get_issue_timeline", 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, "issue_number") + assert.Contains(t, tool.InputSchema.Properties, "page") + assert.Contains(t, tool.InputSchema.Properties, "per_page") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number"}) + + // Setup mock timeline for success case + mockTimeline := []*github.Timeline{ + { + ID: github.Ptr(int64(123)), + URL: github.Ptr("https://api.github.com/repos/owner/repo/issues/events/17196710688"), + User: &github.User{ + Login: github.Ptr("user1"), + }, + Event: github.Ptr("connected"), + }, + { + ID: github.Ptr(int64(456)), + URL: github.Ptr("https://api.github.com/repos/owner/repo/issues/events/17196710689"), + User: &github.User{ + Login: github.Ptr("user2"), + }, + Event: github.Ptr("disconnected"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedTimeline []*github.Timeline + expectedErrMsg string + }{ + { + name: "successful timeline retrieval", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposIssuesTimelineByOwnerByRepoByIssueNumber, + mockTimeline, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + expectError: false, + expectedTimeline: mockTimeline, + }, + { + name: "successful timeline retrieval with pagination", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposIssuesTimelineByOwnerByRepoByIssueNumber, + expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockTimeline), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "page": float64(2), + "per_page": float64(10), + }, + expectError: false, + expectedTimeline: mockTimeline, + }, + { + name: "issue not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposIssuesTimelineByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to get issue timeline", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := GetIssueTimeline(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) + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedTimeline []*github.Timeline + err = json.Unmarshal([]byte(textContent.Text), &returnedTimeline) + require.NoError(t, err) + assert.Equal(t, len(tc.expectedTimeline), len(returnedTimeline)) + if len(returnedTimeline) > 0 { + assert.Equal(t, *tc.expectedTimeline[0].URL, *returnedTimeline[0].URL) + assert.Equal(t, *tc.expectedTimeline[0].User.Login, *returnedTimeline[0].User.Login) + } + }) + } +} + +func Test_GetIssueEvents(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := GetIssueEvents(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "get_issue_events", 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, "issue_number") + assert.Contains(t, tool.InputSchema.Properties, "page") + assert.Contains(t, tool.InputSchema.Properties, "per_page") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number"}) + + // Setup mock events for success case + mockEvents := []*github.IssueEvent{ + { + ID: github.Ptr(int64(123)), + URL: github.Ptr("https://api.github.com/repos/owner/repo/issues/events/17196710688"), + Event: github.Ptr("connected"), + }, + { + ID: github.Ptr(int64(456)), + URL: github.Ptr("https://api.github.com/repos/owner/repo/issues/events/17196710689"), + Event: github.Ptr("disconnected"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedEvents []*github.IssueEvent + expectedErrMsg string + }{ + { + name: "successful events retrieval", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposIssuesEventsByOwnerByRepoByIssueNumber, + mockEvents, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + expectError: false, + expectedEvents: mockEvents, + }, + { + name: "successful events retrieval with pagination", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposIssuesEventsByOwnerByRepoByIssueNumber, + expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockEvents), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "page": float64(2), + "per_page": float64(10), + }, + expectError: false, + expectedEvents: mockEvents, + }, + { + name: "issue not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposIssuesEventsByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to get issue events", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := GetIssueEvents(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) + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedEvents []*github.IssueEvent + err = json.Unmarshal([]byte(textContent.Text), &returnedEvents) + require.NoError(t, err) + assert.Equal(t, len(tc.expectedEvents), len(returnedEvents)) + if len(returnedEvents) > 0 { + assert.Equal(t, *tc.expectedEvents[0].URL, *returnedEvents[0].URL) + assert.Equal(t, *tc.expectedEvents[0].Event, *returnedEvents[0].Event) + } + }) + } +} + +func Test_GetIssueEvent(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := GetIssueEvent(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "get_issue_event", 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, "event_id") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "event_id"}) + + // Setup mock event for success case + mockEvent := github.IssueEvent{ + ID: github.Ptr(int64(17196710688)), + URL: github.Ptr("https://api.github.com/repos/owner/repo/issues/events/17196710688"), + Event: github.Ptr("connected"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedEvent github.IssueEvent + expectedErrMsg string + }{ + { + name: "successful event retrieval", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposIssuesEventsByOwnerByRepoByEventId, + mockEvent, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "event_id": float64(42), + }, + expectError: false, + expectedEvent: mockEvent, + }, + { + name: "event not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposIssuesEventsByOwnerByRepoByEventId, + mockResponse(t, http.StatusNotFound, `{"message": "Event not found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "event_id": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to get issue event", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := GetIssueEvent(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) + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedEvent github.IssueEvent + err = json.Unmarshal([]byte(textContent.Text), &returnedEvent) + require.NoError(t, err) + assert.Equal(t, *tc.expectedEvent.URL, *returnedEvent.URL) + assert.Equal(t, *tc.expectedEvent.Event, *returnedEvent.Event) + }) + } +} diff --git a/pkg/github/server.go b/pkg/github/server.go index e4c241716..07de01edf 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -98,6 +98,19 @@ func RequiredInt(r mcp.CallToolRequest, p string) (int, error) { return int(v), nil } +// RequiredInt64 is a helper function that can be used to fetch a requested parameter from the request. +// It does the following checks: +// 1. Checks if the parameter is present in the request. +// 2. Checks if the parameter is of the expected type. +// 3. Checks if the parameter is not empty, i.e: non-zero value +func RequiredInt64(r mcp.CallToolRequest, p string) (int64, error) { + v, err := requiredParam[float64](r, p) + if err != nil { + return 0, err + } + return int64(v), nil +} + // OptionalParam is a helper function that can be used to fetch a requested parameter from the request. // It does the following checks: // 1. Checks if the parameter is present in the request, if not, it returns its zero-value diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 1a4a3b4d1..f120ca899 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -41,6 +41,9 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn, toolsets.NewServerTool(SearchIssues(getClient, t)), toolsets.NewServerTool(ListIssues(getClient, t)), toolsets.NewServerTool(GetIssueComments(getClient, t)), + toolsets.NewServerTool(GetIssueTimeline(getClient, t)), + toolsets.NewServerTool(GetIssueEvents(getClient, t)), + toolsets.NewServerTool(GetIssueEvent(getClient, t)), ). AddWriteTools( toolsets.NewServerTool(CreateIssue(getClient, t)), From 022e7131c9c3159574fae75f390606a1b6bb9d5b Mon Sep 17 00:00:00 2001 From: Artem Saveliev <artemsaveliev@github.com> Date: Mon, 19 May 2025 19:29:46 -0700 Subject: [PATCH 2/8] Switched to using WithPagination helper --- pkg/github/issues.go | 36 ++++++++++-------------------------- pkg/github/issues_test.go | 8 ++++---- 2 files changed, 14 insertions(+), 30 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 4449e20a5..823f91a08 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -727,12 +727,7 @@ func GetIssueTimeline(getClient GetClientFn, t translations.TranslationHelperFun mcp.Required(), mcp.Description("Issue number"), ), - mcp.WithNumber("page", - mcp.Description("Page number"), - ), - mcp.WithNumber("per_page", - mcp.Description("Number of records per page"), - ), + WithPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := requiredParam[string](request, "owner") @@ -747,18 +742,15 @@ func GetIssueTimeline(getClient GetClientFn, t translations.TranslationHelperFun if err != nil { return mcp.NewToolResultError(err.Error()), nil } - page, err := OptionalIntParamWithDefault(request, "page", 1) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - perPage, err := OptionalIntParamWithDefault(request, "per_page", 30) + + pagination, err := OptionalPaginationParams(request) if err != nil { return mcp.NewToolResultError(err.Error()), nil } opts := &github.ListOptions{ - Page: page, - PerPage: perPage, + Page: pagination.page, + PerPage: pagination.perPage, } client, err := getClient(ctx) @@ -804,12 +796,7 @@ func GetIssueEvents(getClient GetClientFn, t translations.TranslationHelperFunc) mcp.Required(), mcp.Description("Issue number"), ), - mcp.WithNumber("page", - mcp.Description("Page number"), - ), - mcp.WithNumber("per_page", - mcp.Description("Number of records per page"), - ), + WithPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := requiredParam[string](request, "owner") @@ -824,18 +811,15 @@ func GetIssueEvents(getClient GetClientFn, t translations.TranslationHelperFunc) if err != nil { return mcp.NewToolResultError(err.Error()), nil } - page, err := OptionalIntParamWithDefault(request, "page", 1) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - perPage, err := OptionalIntParamWithDefault(request, "per_page", 30) + + pagination, err := OptionalPaginationParams(request) if err != nil { return mcp.NewToolResultError(err.Error()), nil } opts := &github.ListOptions{ - Page: page, - PerPage: perPage, + Page: pagination.page, + PerPage: pagination.perPage, } client, err := getClient(ctx) diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index fdf0a40fc..f2561ec4e 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -1142,7 +1142,7 @@ func Test_GetIssueTimeline(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "issue_number") assert.Contains(t, tool.InputSchema.Properties, "page") - assert.Contains(t, tool.InputSchema.Properties, "per_page") + assert.Contains(t, tool.InputSchema.Properties, "perPage") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number"}) // Setup mock timeline for success case @@ -1207,7 +1207,7 @@ func Test_GetIssueTimeline(t *testing.T) { "repo": "repo", "issue_number": float64(42), "page": float64(2), - "per_page": float64(10), + "perPage": float64(10), }, expectError: false, expectedTimeline: mockTimeline, @@ -1276,7 +1276,7 @@ func Test_GetIssueEvents(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "issue_number") assert.Contains(t, tool.InputSchema.Properties, "page") - assert.Contains(t, tool.InputSchema.Properties, "per_page") + assert.Contains(t, tool.InputSchema.Properties, "perPage") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number"}) // Setup mock events for success case @@ -1335,7 +1335,7 @@ func Test_GetIssueEvents(t *testing.T) { "repo": "repo", "issue_number": float64(42), "page": float64(2), - "per_page": float64(10), + "perPage": float64(10), }, expectError: false, expectedEvents: mockEvents, From 1f1e2245b35bfe8990e05dcfc1867385c60f7ade Mon Sep 17 00:00:00 2001 From: Artem Saveliev <artemsaveliev@github.com> Date: Mon, 19 May 2025 19:36:16 -0700 Subject: [PATCH 3/8] Added tool annotations and updated descriptions --- pkg/github/issues.go | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 823f91a08..a9f92d6ce 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -714,7 +714,11 @@ func GetIssueComments(getClient GetClientFn, t translations.TranslationHelperFun // GetIssueTimeline creates a tool to get timeline for a GitHub issue. func GetIssueTimeline(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_issue_timeline", - mcp.WithDescription(t("TOOL_GET_ISSUE_TIMELINE_DESCRIPTION", "Get timeline for a GitHub issue")), + mcp.WithDescription(t("TOOL_GET_ISSUE_TIMELINE_DESCRIPTION", "Get timeline of a specific issue in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_ISSUE_TIMELINE_USER_TITLE", "Get issue timeline"), + ReadOnlyHint: toBoolPtr(true), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), @@ -783,7 +787,11 @@ func GetIssueTimeline(getClient GetClientFn, t translations.TranslationHelperFun // GetIssueEvents creates a tool to get events for a GitHub issue. func GetIssueEvents(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_issue_events", - mcp.WithDescription(t("TOOL_GET_ISSUE_EVENTS_DESCRIPTION", "Get list of events for a GitHub issue")), + mcp.WithDescription(t("TOOL_GET_ISSUE_EVENTS_DESCRIPTION", "Get a list of events for a specific issue in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_ISSUE_EVENTS_USER_TITLE", "Get issue events"), + ReadOnlyHint: toBoolPtr(true), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), @@ -852,7 +860,11 @@ func GetIssueEvents(getClient GetClientFn, t translations.TranslationHelperFunc) // GetIssueEvent creates a tool to get an event for a GitHub issue. func GetIssueEvent(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_issue_event", - mcp.WithDescription(t("TOOL_GET_ISSUE_EVENT_DESCRIPTION", "Get single event for a GitHub issue")), + mcp.WithDescription(t("TOOL_GET_ISSUE_EVENT_DESCRIPTION", "Get a single event for an issue in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_ISSUE_EVENT_USER_TITLE", "Get an issue event"), + ReadOnlyHint: toBoolPtr(true), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner"), From 8e5cd11d11b99f45fb64d93ecec8f70aae3bdffc Mon Sep 17 00:00:00 2001 From: Artem Saveliev <artemsaveliev@github.com> Date: Thu, 12 Jun 2025 11:05:51 -0700 Subject: [PATCH 4/8] Updated methods capitalization --- pkg/github/issues.go | 18 +++++++++--------- pkg/github/server.go | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 1503f79b4..f1f19d733 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -720,7 +720,7 @@ func GetIssueTimeline(getClient GetClientFn, t translations.TranslationHelperFun mcp.WithDescription(t("TOOL_GET_ISSUE_TIMELINE_DESCRIPTION", "Get timeline of a specific issue in a GitHub repository.")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_GET_ISSUE_TIMELINE_USER_TITLE", "Get issue timeline"), - ReadOnlyHint: toBoolPtr(true), + ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner", mcp.Required(), @@ -737,11 +737,11 @@ func GetIssueTimeline(getClient GetClientFn, t translations.TranslationHelperFun WithPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := requiredParam[string](request, "owner") + owner, err := RequiredParam[string](request, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - repo, err := requiredParam[string](request, "repo") + repo, err := RequiredParam[string](request, "repo") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -793,7 +793,7 @@ func GetIssueEvents(getClient GetClientFn, t translations.TranslationHelperFunc) mcp.WithDescription(t("TOOL_GET_ISSUE_EVENTS_DESCRIPTION", "Get a list of events for a specific issue in a GitHub repository.")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_GET_ISSUE_EVENTS_USER_TITLE", "Get issue events"), - ReadOnlyHint: toBoolPtr(true), + ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner", mcp.Required(), @@ -810,11 +810,11 @@ func GetIssueEvents(getClient GetClientFn, t translations.TranslationHelperFunc) WithPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := requiredParam[string](request, "owner") + owner, err := RequiredParam[string](request, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - repo, err := requiredParam[string](request, "repo") + repo, err := RequiredParam[string](request, "repo") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -866,7 +866,7 @@ func GetIssueEvent(getClient GetClientFn, t translations.TranslationHelperFunc) mcp.WithDescription(t("TOOL_GET_ISSUE_EVENT_DESCRIPTION", "Get a single event for an issue in a GitHub repository.")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_GET_ISSUE_EVENT_USER_TITLE", "Get an issue event"), - ReadOnlyHint: toBoolPtr(true), + ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner", mcp.Required(), @@ -882,11 +882,11 @@ func GetIssueEvent(getClient GetClientFn, t translations.TranslationHelperFunc) ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := requiredParam[string](request, "owner") + owner, err := RequiredParam[string](request, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - repo, err := requiredParam[string](request, "repo") + repo, err := RequiredParam[string](request, "repo") if err != nil { return mcp.NewToolResultError(err.Error()), nil } diff --git a/pkg/github/server.go b/pkg/github/server.go index f3d8fa0a7..917f1e373 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -105,7 +105,7 @@ func RequiredInt(r mcp.CallToolRequest, p string) (int, error) { // 2. Checks if the parameter is of the expected type. // 3. Checks if the parameter is not empty, i.e: non-zero value func RequiredInt64(r mcp.CallToolRequest, p string) (int64, error) { - v, err := requiredParam[float64](r, p) + v, err := RequiredParam[float64](r, p) if err != nil { return 0, err } From 4b070ebfec3a79f70a0a93abde2cb5407e417d44 Mon Sep 17 00:00:00 2001 From: Artem Saveliev <artemsaveliev@github.com> Date: Mon, 7 Jul 2025 12:40:14 -0700 Subject: [PATCH 5/8] Moved docs into issues section --- README.md | 43 ++++++++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index d92b9d5c9..bf3aa15f8 100644 --- a/README.md +++ b/README.md @@ -450,25 +450,15 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `artifact_id`: The unique identifier of the artifact (number, required) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - -- **get_issue_events** - Get events for a GitHub issue - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `issue_number`: Issue number (number, required) - - `page`: Page number (number, optional) - - `perPage`: Results per page (number, optional) - -- **get_issue_timeline** - Get the timeline of events for a GitHub issue + +- **get_job_logs** - Get job logs + - `failed_only`: When true, gets logs for all failed jobs in run_id (boolean, optional) + - `job_id`: The unique identifier of the workflow job (required for single job logs) (number, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - - `issue_number`: Issue number (number, required) - - `page`: Page number (number, optional) - - `perPage`: Results per page (number, optional) - -- **get_issue_event** - Get a specific event for a GitHub issue - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `event_id`: Event ID (number, required) + - `return_content`: Returns actual log content instead of URLs (boolean, optional) + - `run_id`: Workflow run ID (required when using failed_only) (number, optional) + - `tail_lines`: Number of lines to return from the end of the log (number, optional) - **get_workflow_run** - Get workflow run - `owner`: Repository owner (string, required) @@ -676,6 +666,25 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `repo`: Repository name (string, required) - `state`: New state (string, optional) - `title`: New title (string, optional) + +- **get_issue_events** - Get events for a GitHub issue + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `issue_number`: Issue number (number, required) + - `page`: Page number (number, optional) + - `perPage`: Results per page (number, optional) + +- **get_issue_timeline** - Get the timeline of events for a GitHub issue + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `issue_number`: Issue number (number, required) + - `page`: Page number (number, optional) + - `perPage`: Results per page (number, optional) + +- **get_issue_event** - Get a specific event for a GitHub issue + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `event_id`: Event ID (number, required) </details> From e329a7e00c928bbdb72360897746df01bcbf3673 Mon Sep 17 00:00:00 2001 From: Artem Saveliev <artemsaveliev@github.com> Date: Mon, 7 Jul 2025 12:41:55 -0700 Subject: [PATCH 6/8] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bf3aa15f8..b03134ed2 100644 --- a/README.md +++ b/README.md @@ -450,7 +450,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `artifact_id`: The unique identifier of the artifact (number, required) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - + - **get_job_logs** - Get job logs - `failed_only`: When true, gets logs for all failed jobs in run_id (boolean, optional) - `job_id`: The unique identifier of the workflow job (required for single job logs) (number, optional) From 3cfbf55f8946c0852ffdc1c8d2b17be124521252 Mon Sep 17 00:00:00 2001 From: Artem Saveliev <artemsaveliev@github.com> Date: Tue, 8 Jul 2025 08:51:25 -0700 Subject: [PATCH 7/8] Re-generated docs --- README.md | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index b03134ed2..936ff7bbe 100644 --- a/README.md +++ b/README.md @@ -636,6 +636,25 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) +- **get_issue_event** - Get an issue event + - `event_id`: Event ID (number, required) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + +- **get_issue_events** - Get issue events + - `issue_number`: Issue number (number, required) + - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `repo`: Repository name (string, required) + +- **get_issue_timeline** - Get issue timeline + - `issue_number`: Issue number (number, required) + - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `repo`: Repository name (string, required) + - **list_issues** - List issues - `direction`: Sort direction (string, optional) - `labels`: Filter by labels (string[], optional) @@ -666,25 +685,6 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `repo`: Repository name (string, required) - `state`: New state (string, optional) - `title`: New title (string, optional) - -- **get_issue_events** - Get events for a GitHub issue - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `issue_number`: Issue number (number, required) - - `page`: Page number (number, optional) - - `perPage`: Results per page (number, optional) - -- **get_issue_timeline** - Get the timeline of events for a GitHub issue - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `issue_number`: Issue number (number, required) - - `page`: Page number (number, optional) - - `perPage`: Results per page (number, optional) - -- **get_issue_event** - Get a specific event for a GitHub issue - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `event_id`: Event ID (number, required) </details> From 070897f5dc61a685f67a463267c0108a837e2453 Mon Sep 17 00:00:00 2001 From: Artem Saveliev <artemsaveliev@github.com> Date: Thu, 24 Jul 2025 10:25:59 -0700 Subject: [PATCH 8/8] Updated pagination params casing --- pkg/github/issues.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 8d81de650..d37aeec48 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1107,8 +1107,8 @@ func GetIssueTimeline(getClient GetClientFn, t translations.TranslationHelperFun } opts := &github.ListOptions{ - Page: pagination.page, - PerPage: pagination.perPage, + Page: pagination.Page, + PerPage: pagination.PerPage, } client, err := getClient(ctx) @@ -1180,8 +1180,8 @@ func GetIssueEvents(getClient GetClientFn, t translations.TranslationHelperFunc) } opts := &github.ListOptions{ - Page: pagination.page, - PerPage: pagination.perPage, + Page: pagination.Page, + PerPage: pagination.PerPage, } client, err := getClient(ctx)