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)