Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions pkg/github/notifications.go
Original file line number Diff line number Diff line change
@@ -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, err := OptionalBoolParamWithDefault(request, "all", false) // Default to false unless specified
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}

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
}
}
121 changes: 121 additions & 0 deletions pkg/github/notifications_test.go
Original file line number Diff line number Diff line change
@@ -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)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot is somewhat right here with:

[nitpick] Consider adding assertions for additional fields such as the URL to further improve test coverage of notification properties.

assert.Equal(t, *tc.expectedResponse[i].Reason, *notification.Reason)
assert.Equal(t, *tc.expectedResponse[i].Subject.Title, *notification.Subject.Title)
}
})
}
}
14 changes: 14 additions & 0 deletions pkg/github/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,20 @@ func OptionalIntParam(r mcp.CallToolRequest, p string) (int, error) {
return int(v), nil
}

// OptionalBoolParamWithDefault is a helper function that retrieves a boolean parameter from the request.
// If the parameter is not present, it returns the provided default value. If the parameter is present,
// it validates its type and returns the value.
func OptionalBoolParamWithDefault(request mcp.CallToolRequest, s string, b bool) (bool, error) {
v, err := OptionalParam[bool](request, s)
if err != nil {
return false, err
}
if !b {
return b, nil
}
return v, nil
}

// OptionalIntParamWithDefault is a helper function that can be used to fetch a requested parameter from the request
// similar to optionalIntParam, but it also takes a default value.
func OptionalIntParamWithDefault(r mcp.CallToolRequest, p string, d int) (int, error) {
Expand Down
6 changes: 6 additions & 0 deletions pkg/github/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down