Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1481,6 +1481,22 @@ The following sets of tools are available:

<summary><picture><source media="(prefers-color-scheme: dark)" srcset="pkg/octicons/icons/star-dark.png"><source media="(prefers-color-scheme: light)" srcset="pkg/octicons/icons/star-light.png"><img src="pkg/octicons/icons/star-light.png" width="20" height="20" alt="star"></picture> Stargazers</summary>

- **add_repository_to_list** - Add repository to star list
- **OAuth Challenge Scopes**: `user`, `repo`
- `list_name`: The name of the star list to add the repository to. (string, required)
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)

- **create_user_list** - Create star list
- **OAuth Challenge Scopes**: `user`
- `description`: A description of the list. (string, optional)
- `is_private`: Whether the list is private. (boolean, optional)
- `name`: The name of the new list. (string, required)

- **delete_user_list** - Delete star list
- **OAuth Challenge Scopes**: `user`
- `name`: The name of the list to delete. (string, required)

- **list_starred_repositories** - List starred repositories
- **OAuth Challenge Scopes**: `repo`
- `direction`: The direction to sort the results by. (string, optional)
Expand All @@ -1489,6 +1505,24 @@ The following sets of tools are available:
- `sort`: How to sort the results. Can be either 'created' (when the repository was starred) or 'updated' (when the repository was last pushed to). (string, optional)
- `username`: Username to list starred repositories for. Defaults to the authenticated user. (string, optional)

- **list_user_list_items** - List star list items
- **OAuth Challenge Scopes**: `read:user`, `repo`
- `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional)
- `name`: The name of the star list whose repositories should be listed. (string, required)
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)

- **list_user_lists** - List star lists
- **OAuth Challenge Scopes**: `read:user`, `repo`
- `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional)
- `include_items`: Whether to include up to 100 repositories and item cursor metadata for each returned list. (boolean, optional)
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)

- **remove_repository_from_list** - Remove repository from star list
- **OAuth Challenge Scopes**: `user`, `repo`
- `list_name`: The name of the star list to remove the repository from. (string, required)
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)

- **star_repository** - Star repository
- **OAuth Challenge Scopes**: `repo`
- `owner`: Repository owner (string, required)
Expand All @@ -1499,6 +1533,13 @@ The following sets of tools are available:
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)

- **update_user_list** - Update star list
- **OAuth Challenge Scopes**: `user`
- `description`: The new description for the list. (string, optional)
- `is_private`: Whether the list is private. (boolean, optional)
- `name`: The current name of the list to update. (string, required)
- `new_name`: The new name for the list. (string, optional)

</details>

<details>
Expand Down
168 changes: 168 additions & 0 deletions e2e/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1997,3 +1997,171 @@ func TestPullRequestReviewDeletion(t *testing.T) {
require.NoError(t, err, "expected to unmarshal text content successfully")
require.Len(t, noReviews, 0, "expected to find no reviews")
}

// TestUserLists exercises the star list (UserList) tools end-to-end. It creates
// a private list, renames it, adds a repository, verifies membership, removes the
// repository, and deletes the list. Because list management is a global
// (per-account) mutation with no repository boundary, the test creates its own
// uniquely named list and cleans it up in every exit path.
func TestUserLists(t *testing.T) {
t.Parallel()

mcpClient := setupMCPClient(t)
ctx := context.Background()

listName := fmt.Sprintf("github-mcp-server-e2e-%s-%d", t.Name(), time.Now().UnixMilli())
renamedList := listName + "-renamed"

// currentListName tracks the list's current name so cleanup deletes the
// right one even if the rename below fails after creation. It is only
// advanced once the rename succeeds.
currentListName := listName
t.Cleanup(func() {
t.Logf("Cleaning up list %q...", currentListName)
resp, err := mcpClient.CallTool(ctx, &mcp.CallToolParams{
Name: "delete_user_list",
Arguments: map[string]any{"name": currentListName},
})
if err == nil && resp.IsError {
t.Logf("Cleanup: failed to delete list %q: %+v", currentListName, resp)
}
})

// Create a list.
t.Logf("Creating list %q...", listName)
resp, err := mcpClient.CallTool(ctx, &mcp.CallToolParams{
Name: "create_user_list",
Arguments: map[string]any{
"name": listName,
"description": "e2e test list",
"is_private": true,
},
})
require.NoError(t, err, "expected to call 'create_user_list' tool successfully")
require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))

// Rename the list.
t.Logf("Renaming list %q -> %q...", listName, renamedList)
resp, err = mcpClient.CallTool(ctx, &mcp.CallToolParams{
Name: "update_user_list",
Arguments: map[string]any{
"name": listName,
"new_name": renamedList,
},
})
require.NoError(t, err, "expected to call 'update_user_list' tool successfully")
require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
currentListName = renamedList

// Use the user's own account to find a repository to add. We create one so
// the test is self-contained and does not depend on any pre-existing repo.
t.Log("Getting current user...")
resp, err = mcpClient.CallTool(ctx, &mcp.CallToolParams{Name: "get_me"})
require.NoError(t, err, "expected to call 'get_me' tool successfully")
require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))

textContent, ok := resp.Content[0].(*mcp.TextContent)
require.True(t, ok, "expected content to be of type TextContent")
var me struct {
Login string `json:"login"`
}
require.NoError(t, json.Unmarshal([]byte(textContent.Text), &me), "expected to unmarshal text content successfully")
currentOwner := me.Login

repoName := fmt.Sprintf("github-mcp-server-e2e-%s-%d", t.Name(), time.Now().UnixMilli())
t.Logf("Creating repository %s/%s...", currentOwner, repoName)
resp, err = mcpClient.CallTool(ctx, &mcp.CallToolParams{
Name: "create_repository",
Arguments: map[string]any{
"name": repoName,
"private": true,
"autoInit": true,
},
})
require.NoError(t, err, "expected to call 'create_repository' tool successfully")
require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
t.Cleanup(func() {
ghClient := getRESTClient(t)
t.Logf("Deleting repository %s/%s...", currentOwner, repoName)
_, err := ghClient.Repositories.Delete(context.Background(), currentOwner, repoName)
require.NoError(t, err, "expected to delete repository successfully")
})

// Add the repository to the list.
t.Logf("Adding %s/%s to list %q...", currentOwner, repoName, renamedList)
resp, err = mcpClient.CallTool(ctx, &mcp.CallToolParams{
Name: "add_repository_to_list",
Arguments: map[string]any{
"owner": currentOwner,
"repo": repoName,
"list_name": renamedList,
},
})
require.NoError(t, err, "expected to call 'add_repository_to_list' tool successfully")
require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))

// Verify membership by listing lists with items and finding ours.
t.Log("Verifying membership via 'list_user_lists'...")
resp, err = mcpClient.CallTool(ctx, &mcp.CallToolParams{
Name: "list_user_lists",
Arguments: map[string]any{"include_items": true},
})
require.NoError(t, err, "expected to call 'list_user_lists' tool successfully")
require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))

textContent, ok = resp.Content[0].(*mcp.TextContent)
require.True(t, ok, "expected content to be of type TextContent")
var listing struct {
Lists []struct {
Name string `json:"name"`
Items []struct {
Repository string `json:"repository"`
} `json:"items"`
} `json:"lists"`
}
require.NoError(t, json.Unmarshal([]byte(textContent.Text), &listing), "expected to unmarshal text content successfully")

var found *struct {
Name string `json:"name"`
Items []struct {
Repository string `json:"repository"`
} `json:"items"`
}
for i := range listing.Lists {
if listing.Lists[i].Name == renamedList {
found = &listing.Lists[i]
break
}
}
require.NotNil(t, found, "expected to find list %q in listing", renamedList)
var containsRepo bool
for _, item := range found.Items {
if item.Repository == currentOwner+"/"+repoName {
containsRepo = true
break
}
}
require.True(t, containsRepo, "expected list %q to contain %s/%s", renamedList, currentOwner, repoName)

// Remove the repository from the list.
t.Logf("Removing %s/%s from list %q...", currentOwner, repoName, renamedList)
resp, err = mcpClient.CallTool(ctx, &mcp.CallToolParams{
Name: "remove_repository_from_list",
Arguments: map[string]any{
"owner": currentOwner,
"repo": repoName,
"list_name": renamedList,
},
})
require.NoError(t, err, "expected to call 'remove_repository_from_list' tool successfully")
require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))

// Delete the list (cleanup also deletes it, but this asserts the tool works).
t.Logf("Deleting list %q...", renamedList)
resp, err = mcpClient.CallTool(ctx, &mcp.CallToolParams{
Name: "delete_user_list",
Arguments: map[string]any{"name": renamedList},
})
require.NoError(t, err, "expected to call 'delete_user_list' tool successfully")
require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
}
32 changes: 32 additions & 0 deletions pkg/github/__toolsnaps__/add_repository_to_list.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"annotations": {
"destructiveHint": false,
"idempotentHint": true,
"readOnlyHint": false,
"title": "Add repository to star list"
},
"description": "Add a repository to a star list (UserList). List membership is independent of star state.",
"inputSchema": {
"properties": {
"list_name": {
"description": "The name of the star list to add the repository to.",
"type": "string"
},
"owner": {
"description": "Repository owner",
"type": "string"
},
"repo": {
"description": "Repository name",
"type": "string"
}
},
"required": [
"owner",
"repo",
"list_name"
],
"type": "object"
},
"name": "add_repository_to_list"
}
30 changes: 30 additions & 0 deletions pkg/github/__toolsnaps__/create_user_list.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"annotations": {
"destructiveHint": false,
"idempotentHint": false,
"readOnlyHint": false,
"title": "Create star list"
},
"description": "Create a new star list (UserList) for the authenticated user.",
"inputSchema": {
"properties": {
"description": {
"description": "A description of the list.",
"type": "string"
},
"is_private": {
"description": "Whether the list is private.",
"type": "boolean"
},
"name": {
"description": "The name of the new list.",
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
},
"name": "create_user_list"
}
22 changes: 22 additions & 0 deletions pkg/github/__toolsnaps__/delete_user_list.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"annotations": {
"destructiveHint": true,
"idempotentHint": false,
"readOnlyHint": false,
"title": "Delete star list"
},
"description": "Delete a star list (UserList) owned by the authenticated user.",
"inputSchema": {
"properties": {
"name": {
"description": "The name of the list to delete.",
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
},
"name": "delete_user_list"
}
31 changes: 31 additions & 0 deletions pkg/github/__toolsnaps__/list_user_list_items.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"annotations": {
"idempotentHint": false,
"readOnlyHint": true,
"title": "List star list items"
},
"description": "List a page of repositories in one star list (UserList).",
"inputSchema": {
"properties": {
"after": {
"description": "Cursor for pagination. Use the cursor from the previous response.",
"type": "string"
},
"name": {
"description": "The name of the star list whose repositories should be listed.",
"type": "string"
},
"perPage": {
"description": "Results per page for pagination (min 1, max 100)",
"maximum": 100,
"minimum": 1,
"type": "number"
}
},
"required": [
"name"
],
"type": "object"
},
"name": "list_user_list_items"
}
28 changes: 28 additions & 0 deletions pkg/github/__toolsnaps__/list_user_lists.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"annotations": {
"idempotentHint": false,
"readOnlyHint": true,
"title": "List star lists"
},
"description": "List a page of the authenticated user's star lists (UserLists), optionally including the first page of repositories in each list.",
"inputSchema": {
"properties": {
"after": {
"description": "Cursor for pagination. Use the cursor from the previous response.",
"type": "string"
},
"include_items": {
"description": "Whether to include up to 100 repositories and item cursor metadata for each returned list.",
"type": "boolean"
},
"perPage": {
"description": "Results per page for pagination (min 1, max 100)",
"maximum": 100,
"minimum": 1,
"type": "number"
}
},
"type": "object"
},
"name": "list_user_lists"
}
Loading