diff --git a/README.md b/README.md index 5977763b..9af60222 100644 --- a/README.md +++ b/README.md @@ -524,6 +524,14 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `secret_type`: The secret types to be filtered for in a comma-separated list (string, optional) - `resolution`: The resolution status (string, optional) +### Notifications + +- **list_notifications** - List notifications for a GitHub user + + - `page`: Page number (number, optional, default: 1) + - `per_page`: Number of records per page (number, optional, default: 30) + - `all`: Whether to fetch all notifications, including read ones (boolean, optional, default: false) + ## Resources ### Repository Content diff --git a/pkg/github/notifications.go b/pkg/github/notifications.go new file mode 100644 index 00000000..512452d2 --- /dev/null +++ b/pkg/github/notifications.go @@ -0,0 +1,77 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v69/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// ListNotifications creates a tool to list notifications for a GitHub user. +func ListNotifications(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_notifications", + mcp.WithDescription(t("TOOL_LIST_NOTIFICATIONS_DESCRIPTION", "List notifications for a GitHub user")), + mcp.WithNumber("page", + mcp.Description("Page number"), + ), + mcp.WithNumber("per_page", + mcp.Description("Number of records per page"), + ), + mcp.WithBoolean("all", + mcp.Description("Whether to fetch all notifications, including read ones"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + 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 + } + all := false + if val, err := OptionalParam[bool](request, "all"); err == nil { + all = val + } + + opts := &github.NotificationListOptions{ + ListOptions: github.ListOptions{ + Page: page, + PerPage: perPage, + }, + All: all, // Include all notifications, even those already read. + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + notifications, resp, err := client.Activity.ListNotifications(ctx, opts) + if err != nil { + return nil, fmt.Errorf("failed to list notifications: %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 notifications: %s", string(body))), nil + } + + r, err := json.Marshal(notifications) + if err != nil { + return nil, fmt.Errorf("failed to marshal notifications: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} diff --git a/pkg/github/notifications_test.go b/pkg/github/notifications_test.go new file mode 100644 index 00000000..20c7967b --- /dev/null +++ b/pkg/github/notifications_test.go @@ -0,0 +1,121 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v69/github" + "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_ListNotifications(t *testing.T) { + // Verify tool definition + mockClient := github.NewClient(nil) + tool, _ := ListNotifications(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "list_notifications", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "page") + assert.Contains(t, tool.InputSchema.Properties, "per_page") + assert.Contains(t, tool.InputSchema.Properties, "all") + + // Setup mock notifications + mockNotifications := []*github.Notification{ + { + ID: github.Ptr("1"), + Reason: github.Ptr("mention"), + Subject: &github.NotificationSubject{ + Title: github.Ptr("Test Notification 1"), + }, + UpdatedAt: &github.Timestamp{Time: time.Now()}, + URL: github.Ptr("https://example.com/notifications/threads/1"), + }, + { + ID: github.Ptr("2"), + Reason: github.Ptr("team_mention"), + Subject: &github.NotificationSubject{ + Title: github.Ptr("Test Notification 2"), + }, + UpdatedAt: &github.Timestamp{Time: time.Now()}, + URL: github.Ptr("https://example.com/notifications/threads/1"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedResponse []*github.Notification + expectedErrMsg string + }{ + { + name: "list all notifications", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetNotifications, + mockNotifications, + ), + ), + requestArgs: map[string]interface{}{ + "all": true, + }, + expectError: false, + expectedResponse: mockNotifications, + }, + { + name: "list unread notifications", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetNotifications, + mockNotifications[:1], // Only the first notification + ), + ), + requestArgs: map[string]interface{}{ + "all": false, + }, + expectError: false, + expectedResponse: mockNotifications[:1], + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ListNotifications(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 returnedNotifications []*github.Notification + err = json.Unmarshal([]byte(textContent.Text), &returnedNotifications) + require.NoError(t, err) + assert.Equal(t, len(tc.expectedResponse), len(returnedNotifications)) + for i, notification := range returnedNotifications { + assert.Equal(t, *tc.expectedResponse[i].ID, *notification.ID) + assert.Equal(t, *tc.expectedResponse[i].Reason, *notification.Reason) + assert.Equal(t, *tc.expectedResponse[i].Subject.Title, *notification.Subject.Title) + } + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 35dabaef..60fdb538 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -81,6 +81,11 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn, // 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") + notifications := toolsets.NewToolset("notifications", "GitHub Notifications related tools"). + AddReadTools( + toolsets.NewServerTool(ListNotifications(getClient, t)), + ) + // Add toolsets to the group tsg.AddToolset(repos) tsg.AddToolset(issues) @@ -89,6 +94,7 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn, tsg.AddToolset(codeSecurity) tsg.AddToolset(secretProtection) tsg.AddToolset(experiments) + tsg.AddToolset(notifications) // Enable the requested features if err := tsg.EnableToolsets(passedToolsets); err != nil {