Skip to content

Commit e110366

Browse files
committed
feat: initial support for discussions - Minimal
1 parent 023f59d commit e110366

File tree

5 files changed

+775
-0
lines changed

5 files changed

+775
-0
lines changed

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ The following sets of tools are available (all are on by default):
149149
| ----------------------- | ------------------------------------------------------------- |
150150
| `repos` | Repository-related tools (file operations, branches, commits) |
151151
| `issues` | Issue-related tools (create, read, update, comment) |
152+
| `discussions` | GitHub Discussions tools (list, get, comments) |
152153
| `users` | Anything relating to GitHub Users |
153154
| `pull_requests` | Pull request operations (create, merge, review) |
154155
| `code_security` | Code scanning alerts and security features |
@@ -581,6 +582,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
581582
- `secret_type`: The secret types to be filtered for in a comma-separated list (string, optional)
582583
- `resolution`: The resolution status (string, optional)
583584

585+
<<<<<<< HEAD
584586
### Notifications
585587

586588
- **list_notifications** – List notifications for a GitHub user
@@ -614,6 +616,32 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
614616
- `repo`: The name of the repository (string, required)
615617
- `action`: Action to perform: `ignore`, `watch`, or `delete` (string, required)
616618

619+
=======
620+
>>>>>>> 8d6b84c (doc: add support for GitHub discussions, with an example.)
621+
### Discussions
622+
623+
> [!NOTE]
624+
> As there is no support for discussions in the native GitHub go library, this toolset is deliberately limited to a few basic functions. The plan is to first implement native support in the GitHub go library, then use it for a better and consistent support in the MCP server.
625+
626+
- **list_discussions** - List discussions for a repository
627+
- `owner`: Repository owner (string, required)
628+
- `repo`: Repository name (string, required)
629+
- `state`: State filter (open, closed, all) (string, optional)
630+
- `labels`: Filter by label names (string[], optional)
631+
- `since`: Filter by date (ISO 8601 timestamp) (string, optional)
632+
- `page`: Page number (number, optional)
633+
- `perPage`: Results per page (number, optional)
634+
635+
- **get_discussion** - Get a specific discussion by ID
636+
- `owner`: Repository owner (string, required)
637+
- `repo`: Repository name (string, required)
638+
- `discussion_id`: Discussion ID (number, required)
639+
640+
- **get_discussion_comments** - Get comments from a discussion
641+
- `owner`: Repository owner (string, required)
642+
- `repo`: Repository name (string, required)
643+
- `discussion_id`: Discussion ID (number, required)
644+
617645
## Resources
618646

619647
### Repository Content

pkg/github/discussions.go

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"time"
8+
9+
"github.com/github/github-mcp-server/pkg/translations"
10+
"github.com/go-viper/mapstructure/v2"
11+
"github.com/google/go-github/v69/github"
12+
"github.com/mark3labs/mcp-go/mcp"
13+
"github.com/mark3labs/mcp-go/server"
14+
"github.com/shurcooL/githubv4"
15+
)
16+
17+
func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
18+
return mcp.NewTool("list_discussions",
19+
mcp.WithDescription(t("TOOL_LIST_DISCUSSIONS_DESCRIPTION", "List discussions for a repository")),
20+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
21+
Title: t("TOOL_LIST_DISCUSSIONS_USER_TITLE", "List discussions"),
22+
ReadOnlyHint: toBoolPtr(true),
23+
}),
24+
mcp.WithString("owner",
25+
mcp.Required(),
26+
mcp.Description("Repository owner"),
27+
),
28+
mcp.WithString("repo",
29+
mcp.Required(),
30+
mcp.Description("Repository name"),
31+
),
32+
mcp.WithString("categoryId",
33+
mcp.Description("Category ID filter"),
34+
),
35+
mcp.WithString("since",
36+
mcp.Description("Filter by date (ISO 8601 timestamp)"),
37+
),
38+
mcp.WithString("sort",
39+
mcp.Description("Sort field"),
40+
mcp.DefaultString("CREATED_AT"),
41+
mcp.Enum("CREATED_AT", "UPDATED_AT"),
42+
),
43+
mcp.WithString("direction",
44+
mcp.Description("Sort direction"),
45+
mcp.DefaultString("DESC"),
46+
mcp.Enum("ASC", "DESC"),
47+
),
48+
mcp.WithNumber("first",
49+
mcp.Description("Number of discussions to return per page (min 1, max 100)"),
50+
mcp.Min(1),
51+
mcp.Max(100),
52+
),
53+
mcp.WithString("after",
54+
mcp.Description("Cursor for pagination, use the 'after' field from the previous response"),
55+
),
56+
),
57+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
58+
// Decode params
59+
var params struct {
60+
Owner string
61+
Repo string
62+
CategoryId string
63+
Since string
64+
Sort string
65+
Direction string
66+
First int32
67+
After string
68+
}
69+
if err := mapstructure.Decode(request.Params.Arguments, &params); err != nil {
70+
return mcp.NewToolResultError(err.Error()), nil
71+
}
72+
// Get GraphQL client
73+
client, err := getGQLClient(ctx)
74+
if err != nil {
75+
return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil
76+
}
77+
// Prepare GraphQL query
78+
var q struct {
79+
Repository struct {
80+
Discussions struct {
81+
Nodes []struct {
82+
Number githubv4.Int
83+
Title githubv4.String
84+
CreatedAt githubv4.DateTime
85+
Category struct {
86+
Name githubv4.String
87+
} `graphql:"category"`
88+
URL githubv4.String `graphql:"url"`
89+
}
90+
} `graphql:"discussions(categoryId: $categoryId, orderBy: {field: $sort, direction: $direction}, first: $first, after: $after)"`
91+
} `graphql:"repository(owner: $owner, name: $repo)"`
92+
}
93+
// Build query variables
94+
vars := map[string]interface{}{
95+
"owner": githubv4.String(params.Owner),
96+
"repo": githubv4.String(params.Repo),
97+
"categoryId": githubv4.ID(params.CategoryId),
98+
"sort": githubv4.DiscussionOrderField(params.Sort),
99+
"direction": githubv4.OrderDirection(params.Direction),
100+
"first": githubv4.Int(params.First),
101+
"after": githubv4.String(params.After),
102+
}
103+
// Execute query
104+
if err := client.Query(ctx, &q, vars); err != nil {
105+
return mcp.NewToolResultError(err.Error()), nil
106+
}
107+
// Map nodes to GitHub Issue objects - there is no discussion type in the GitHub API, so we use Issue to benefit from existing code
108+
var discussions []*github.Issue
109+
for _, n := range q.Repository.Discussions.Nodes {
110+
di := &github.Issue{
111+
Number: github.Ptr(int(n.Number)),
112+
Title: github.Ptr(string(n.Title)),
113+
HTMLURL: github.Ptr(string(n.URL)),
114+
CreatedAt: &github.Timestamp{Time: n.CreatedAt.Time},
115+
}
116+
discussions = append(discussions, di)
117+
}
118+
119+
// Post filtering discussions based on 'since' parameter
120+
if params.Since != "" {
121+
sinceTime, err := time.Parse(time.RFC3339, params.Since)
122+
if err != nil {
123+
return mcp.NewToolResultError(fmt.Sprintf("invalid 'since' timestamp: %v", err)), nil
124+
}
125+
var filteredDiscussions []*github.Issue
126+
for _, d := range discussions {
127+
if d.CreatedAt.Time.After(sinceTime) {
128+
filteredDiscussions = append(filteredDiscussions, d)
129+
}
130+
}
131+
discussions = filteredDiscussions
132+
}
133+
134+
// Marshal and return
135+
out, err := json.Marshal(discussions)
136+
if err != nil {
137+
return nil, fmt.Errorf("failed to marshal discussions: %w", err)
138+
}
139+
return mcp.NewToolResultText(string(out)), nil
140+
}
141+
}
142+
143+
func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
144+
return mcp.NewTool("get_discussion",
145+
mcp.WithDescription(t("TOOL_GET_DISCUSSION_DESCRIPTION", "Get a specific discussion by ID")),
146+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
147+
Title: t("TOOL_GET_DISCUSSION_USER_TITLE", "Get discussion"),
148+
ReadOnlyHint: toBoolPtr(true),
149+
}),
150+
mcp.WithString("owner",
151+
mcp.Required(),
152+
mcp.Description("Repository owner"),
153+
),
154+
mcp.WithString("repo",
155+
mcp.Required(),
156+
mcp.Description("Repository name"),
157+
),
158+
mcp.WithNumber("discussion_id",
159+
mcp.Required(),
160+
mcp.Description("Discussion ID"),
161+
),
162+
),
163+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
164+
owner, err := requiredParam[string](request, "owner")
165+
if err != nil {
166+
return mcp.NewToolResultError(err.Error()), nil
167+
}
168+
repo, err := requiredParam[string](request, "repo")
169+
if err != nil {
170+
return mcp.NewToolResultError(err.Error()), nil
171+
}
172+
discussionID, err := RequiredInt(request, "discussion_id")
173+
if err != nil {
174+
return mcp.NewToolResultError(err.Error()), nil
175+
}
176+
177+
client, err := getGQLClient(ctx)
178+
if err != nil {
179+
return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil
180+
}
181+
182+
var q struct {
183+
Repository struct {
184+
Discussion struct {
185+
Number githubv4.Int
186+
Body githubv4.String
187+
State githubv4.String
188+
CreatedAt githubv4.DateTime
189+
URL githubv4.String `graphql:"url"`
190+
} `graphql:"discussion(number: $discussionID)"`
191+
} `graphql:"repository(owner: $owner, name: $repo)"`
192+
}
193+
vars := map[string]interface{}{
194+
"owner": githubv4.String(owner),
195+
"repo": githubv4.String(repo),
196+
"discussionID": githubv4.Int(discussionID),
197+
}
198+
if err := client.Query(ctx, &q, vars); err != nil {
199+
return mcp.NewToolResultError(err.Error()), nil
200+
}
201+
d := q.Repository.Discussion
202+
discussion := &github.Issue{
203+
Number: github.Ptr(int(d.Number)),
204+
Body: github.Ptr(string(d.Body)),
205+
State: github.Ptr(string(d.State)),
206+
HTMLURL: github.Ptr(string(d.URL)),
207+
CreatedAt: &github.Timestamp{Time: d.CreatedAt.Time},
208+
}
209+
out, err := json.Marshal(discussion)
210+
if err != nil {
211+
return nil, fmt.Errorf("failed to marshal discussion: %w", err)
212+
}
213+
214+
return mcp.NewToolResultText(string(out)), nil
215+
}
216+
}
217+
218+
func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
219+
return mcp.NewTool("get_discussion_comments",
220+
mcp.WithDescription(t("TOOL_GET_DISCUSSION_COMMENTS_DESCRIPTION", "Get comments from a discussion")),
221+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
222+
Title: t("TOOL_GET_DISCUSSION_COMMENTS_USER_TITLE", "Get discussion comments"),
223+
ReadOnlyHint: toBoolPtr(true),
224+
}),
225+
mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner")),
226+
mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")),
227+
mcp.WithNumber("discussion_id", mcp.Required(), mcp.Description("Discussion ID")),
228+
),
229+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
230+
owner, err := requiredParam[string](request, "owner")
231+
if err != nil {
232+
return mcp.NewToolResultError(err.Error()), nil
233+
}
234+
repo, err := requiredParam[string](request, "repo")
235+
if err != nil {
236+
return mcp.NewToolResultError(err.Error()), nil
237+
}
238+
discussionID, err := RequiredInt(request, "discussion_id")
239+
if err != nil {
240+
return mcp.NewToolResultError(err.Error()), nil
241+
}
242+
243+
client, err := getGQLClient(ctx)
244+
if err != nil {
245+
return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil
246+
}
247+
248+
var q struct {
249+
Repository struct {
250+
Discussion struct {
251+
Comments struct {
252+
Nodes []struct {
253+
Body githubv4.String
254+
}
255+
} `graphql:"comments(first:100)"`
256+
} `graphql:"discussion(number: $discussionID)"`
257+
} `graphql:"repository(owner: $owner, name: $repo)"`
258+
}
259+
vars := map[string]interface{}{
260+
"owner": githubv4.String(owner),
261+
"repo": githubv4.String(repo),
262+
"discussionID": githubv4.Int(discussionID),
263+
}
264+
if err := client.Query(ctx, &q, vars); err != nil {
265+
return mcp.NewToolResultError(err.Error()), nil
266+
}
267+
var comments []*github.IssueComment
268+
for _, c := range q.Repository.Discussion.Comments.Nodes {
269+
comments = append(comments, &github.IssueComment{Body: github.Ptr(string(c.Body))})
270+
}
271+
272+
out, err := json.Marshal(comments)
273+
if err != nil {
274+
return nil, fmt.Errorf("failed to marshal comments: %w", err)
275+
}
276+
277+
return mcp.NewToolResultText(string(out)), nil
278+
}
279+
}
280+
281+
func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
282+
return mcp.NewTool("list_discussion_categories",
283+
mcp.WithDescription(t("TOOL_LIST_DISCUSSION_CATEGORIES_DESCRIPTION", "List discussion categorie with their id and name, for a repository")),
284+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
285+
Title: t("TOOL_LIST_DISCUSSION_CATEGORIES_USER_TITLE", "List discussion categories"),
286+
ReadOnlyHint: toBoolPtr(true),
287+
}),
288+
mcp.WithString("owner",
289+
mcp.Required(),
290+
mcp.Description("Repository owner"),
291+
),
292+
mcp.WithString("repo",
293+
mcp.Required(),
294+
mcp.Description("Repository name"),
295+
),
296+
),
297+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
298+
owner, err := requiredParam[string](request, "owner")
299+
if err != nil {
300+
return mcp.NewToolResultError(err.Error()), nil
301+
}
302+
repo, err := requiredParam[string](request, "repo")
303+
if err != nil {
304+
return mcp.NewToolResultError(err.Error()), nil
305+
}
306+
client, err := getGQLClient(ctx)
307+
if err != nil {
308+
return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil
309+
}
310+
var q struct {
311+
Repository struct {
312+
DiscussionCategories struct {
313+
Nodes []struct {
314+
ID githubv4.ID
315+
Name githubv4.String
316+
}
317+
} `graphql:"discussionCategories(first: 30)"`
318+
} `graphql:"repository(owner: $owner, name: $repo)"`
319+
}
320+
vars := map[string]interface{}{
321+
"owner": githubv4.String(owner),
322+
"repo": githubv4.String(repo),
323+
}
324+
if err := client.Query(ctx, &q, vars); err != nil {
325+
return mcp.NewToolResultError(err.Error()), nil
326+
}
327+
var categories []map[string]string
328+
for _, c := range q.Repository.DiscussionCategories.Nodes {
329+
categories = append(categories, map[string]string{
330+
"id": fmt.Sprint(c.ID),
331+
"name": string(c.Name),
332+
})
333+
}
334+
out, err := json.Marshal(categories)
335+
if err != nil {
336+
return nil, fmt.Errorf("failed to marshal discussion categories: %w", err)
337+
}
338+
return mcp.NewToolResultText(string(out)), nil
339+
}
340+
}

0 commit comments

Comments
 (0)