|
| 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