Skip to content

Commit b44f196

Browse files
committed
feat: list user activity events
1 parent c59e8c1 commit b44f196

6 files changed

Lines changed: 527 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1628,6 +1628,12 @@ For negotiated supported protocol versions `2026-07-28` or newer, the consolidat
16281628

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

1631+
- **list_user_events** - List user activity
1632+
- **OAuth Challenge Scopes**: `repo`
1633+
- `page`: Page number for pagination (min 1) (integer, optional)
1634+
- `perPage`: Results per page for pagination (min 1, max 100) (integer, optional)
1635+
- `username`: GitHub username whose activity to list (string, required)
1636+
16311637
- **search_users** - Search users
16321638
- **OAuth Challenge Scopes**: `repo`
16331639
- `order`: Sort order (string, optional)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"annotations": {
3+
"idempotentHint": false,
4+
"readOnlyHint": true,
5+
"title": "List user activity"
6+
},
7+
"description": "List recent GitHub activity performed by a user, such as pushes, pull requests, issues, and stars. GitHub returns public events for other users and may include private events when querying the authenticated user's own activity, subject to token permissions. Returns event metadata, visibility, and event-specific payloads. GitHub limits the feed to up to 300 events from the past 30 days; events may be delayed from 30 seconds to 6 hours. This is not a complete or real-time contribution history.",
8+
"inputSchema": {
9+
"properties": {
10+
"page": {
11+
"description": "Page number for pagination (min 1)",
12+
"minimum": 1,
13+
"type": "integer"
14+
},
15+
"perPage": {
16+
"description": "Results per page for pagination (min 1, max 100)",
17+
"maximum": 100,
18+
"minimum": 1,
19+
"type": "integer"
20+
},
21+
"username": {
22+
"description": "GitHub username whose activity to list",
23+
"type": "string"
24+
}
25+
},
26+
"required": [
27+
"username"
28+
],
29+
"type": "object"
30+
},
31+
"name": "list_user_events"
32+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"inputSchema": null,
3+
"name": "list_user_events",
4+
"outputSchema": {
5+
"items": {
6+
"additionalProperties": false,
7+
"properties": {
8+
"actor": {
9+
"type": "string"
10+
},
11+
"created_at": {
12+
"type": [
13+
"null",
14+
"string"
15+
]
16+
},
17+
"id": {
18+
"type": "string"
19+
},
20+
"payload": true,
21+
"public": {
22+
"type": [
23+
"null",
24+
"boolean"
25+
]
26+
},
27+
"repo": {
28+
"type": "string"
29+
},
30+
"type": {
31+
"type": "string"
32+
}
33+
},
34+
"required": [
35+
"id",
36+
"type"
37+
],
38+
"type": "object"
39+
},
40+
"type": [
41+
"null",
42+
"array"
43+
]
44+
}
45+
}

‎pkg/github/tools.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []invent
267267

268268
// User tools
269269
SearchUsers(t),
270+
ListUserEvents(t),
270271

271272
// Organization tools
272273
SearchOrgs(t),

‎pkg/github/users.go‎

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package github
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"net/url"
8+
"time"
9+
10+
ghErrors "github.com/github/github-mcp-server/v2/pkg/errors"
11+
"github.com/github/github-mcp-server/v2/pkg/ifc"
12+
"github.com/github/github-mcp-server/v2/pkg/inventory"
13+
"github.com/github/github-mcp-server/v2/pkg/sanitize"
14+
"github.com/github/github-mcp-server/v2/pkg/scopes"
15+
"github.com/github/github-mcp-server/v2/pkg/translations"
16+
"github.com/github/github-mcp-server/v2/pkg/utils"
17+
"github.com/google/go-github/v92/github"
18+
"github.com/google/jsonschema-go/jsonschema"
19+
"github.com/modelcontextprotocol/go-sdk/mcp"
20+
)
21+
22+
// MinimalUserEvent retains event-specific data without repeated user and repository metadata.
23+
type MinimalUserEvent struct {
24+
ID string `json:"id"`
25+
Type string `json:"type"`
26+
Public *bool `json:"public,omitempty"`
27+
Actor string `json:"actor,omitempty"`
28+
Repo string `json:"repo,omitempty"`
29+
CreatedAt *time.Time `json:"created_at,omitempty"`
30+
Payload any `json:"payload,omitempty"`
31+
}
32+
33+
type ListUserEventsInput struct {
34+
Username string `json:"username"`
35+
Page *int `json:"page,omitempty"`
36+
PerPage *int `json:"perPage,omitempty"`
37+
}
38+
39+
// ListUserEvents creates a read-only tool for activity visible to the current GitHub credentials.
40+
func ListUserEvents(t translations.TranslationHelperFunc) inventory.ServerTool {
41+
schema := WithPagination(&jsonschema.Schema{
42+
Type: "object",
43+
Properties: map[string]*jsonschema.Schema{
44+
"username": {
45+
Type: "string",
46+
Description: "GitHub username whose activity to list",
47+
},
48+
},
49+
Required: []string{"username"},
50+
})
51+
schema.Properties["page"].Type = "integer"
52+
schema.Properties["perPage"].Type = "integer"
53+
return NewTool[ListUserEventsInput, []MinimalUserEvent](
54+
ToolsetMetadataUsers,
55+
mcp.Tool{
56+
Name: "list_user_events",
57+
Description: t("TOOL_LIST_USER_EVENTS_DESCRIPTION", "List recent GitHub activity performed by a user, such as pushes, pull requests, issues, and stars. GitHub returns public events for other users and may include private events when querying the authenticated user's own activity, subject to token permissions. Returns event metadata, visibility, and event-specific payloads. GitHub limits the feed to up to 300 events from the past 30 days; events may be delayed from 30 seconds to 6 hours. This is not a complete or real-time contribution history."),
58+
Annotations: &mcp.ToolAnnotations{
59+
Title: t("TOOL_LIST_USER_EVENTS_USER_TITLE", "List user activity"),
60+
ReadOnlyHint: true,
61+
},
62+
InputSchema: schema,
63+
},
64+
scopes.PublicRead(scopes.Repo),
65+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input ListUserEventsInput) (*mcp.CallToolResult, []MinimalUserEvent, error) {
66+
if input.Username == "" {
67+
return utils.NewToolResultError("missing required parameter: username"), nil, nil
68+
}
69+
page, perPage := 1, 30
70+
if input.Page != nil {
71+
page = *input.Page
72+
}
73+
if input.PerPage != nil {
74+
perPage = *input.PerPage
75+
}
76+
if page < 1 {
77+
return utils.NewToolResultError("page must be at least 1"), nil, nil
78+
}
79+
if perPage < 1 || perPage > 100 {
80+
return utils.NewToolResultError("perPage must be between 1 and 100"), nil, nil
81+
}
82+
83+
client, err := deps.GetClient(ctx)
84+
if err != nil {
85+
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
86+
}
87+
events, resp, err := client.Activity.ListEventsPerformedByUser(ctx, url.PathEscape(input.Username), false, &github.ListOptions{
88+
Page: page, PerPage: perPage,
89+
})
90+
if err != nil {
91+
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list user events", resp, err), nil, nil
92+
}
93+
defer func() { _ = resp.Body.Close() }()
94+
95+
minimalEvents := make([]MinimalUserEvent, 0, len(events))
96+
label := ifc.PublicUntrusted()
97+
for _, event := range events {
98+
if event == nil {
99+
continue
100+
}
101+
// Missing visibility is conservatively private. A mixed page may
102+
// also contain untrusted public content, so integrity stays untrusted.
103+
if !event.GetPublic() {
104+
label = ifc.PrivateUntrusted()
105+
}
106+
var payload any
107+
if raw := event.GetRawPayload(); len(raw) > 0 {
108+
decoder := json.NewDecoder(bytes.NewReader(raw))
109+
// Event payloads can contain IDs larger than float64's exact integer range.
110+
decoder.UseNumber()
111+
if err := decoder.Decode(&payload); err != nil {
112+
return utils.NewToolResultErrorFromErr("failed to decode event payload", err), nil, nil
113+
}
114+
payload = sanitizeUserEventPayload(payload)
115+
}
116+
var createdAt *time.Time
117+
if event.CreatedAt != nil {
118+
createdAt = &event.CreatedAt.Time
119+
}
120+
minimalEvents = append(minimalEvents, MinimalUserEvent{
121+
ID: event.GetID(), Type: event.GetType(), Public: event.Public,
122+
Actor: event.Actor.GetLogin(), Repo: event.Repo.GetName(),
123+
CreatedAt: createdAt, Payload: payload,
124+
})
125+
}
126+
result := MarshalledTextResult(minimalEvents)
127+
return attachStaticIFCLabel(ctx, deps, result, label), minimalEvents, nil
128+
},
129+
)
130+
}
131+
132+
// Payloads vary by event type. Preserve unknown fields while filtering user-authored text.
133+
func sanitizeUserEventPayload(value any) any {
134+
switch value := value.(type) {
135+
case string:
136+
return sanitize.Content(value)
137+
case map[string]any:
138+
for key, item := range value {
139+
value[key] = sanitizeUserEventPayload(item)
140+
}
141+
return value
142+
case []any:
143+
for i, item := range value {
144+
value[i] = sanitizeUserEventPayload(item)
145+
}
146+
return value
147+
default:
148+
return value
149+
}
150+
}

0 commit comments

Comments
 (0)