diff --git a/pkg/github/__toolsnaps__/get_discussion.snap b/pkg/github/__toolsnaps__/get_discussion.snap new file mode 100644 index 000000000..feef0f057 --- /dev/null +++ b/pkg/github/__toolsnaps__/get_discussion.snap @@ -0,0 +1,30 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get discussion" + }, + "description": "Get a specific discussion by ID", + "inputSchema": { + "type": "object", + "required": [ + "owner", + "repo", + "discussionNumber" + ], + "properties": { + "discussionNumber": { + "type": "number", + "description": "Discussion Number" + }, + "owner": { + "type": "string", + "description": "Repository owner" + }, + "repo": { + "type": "string", + "description": "Repository name" + } + } + }, + "name": "get_discussion" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_discussion_comments.snap b/pkg/github/__toolsnaps__/get_discussion_comments.snap new file mode 100644 index 000000000..3af5edc8c --- /dev/null +++ b/pkg/github/__toolsnaps__/get_discussion_comments.snap @@ -0,0 +1,40 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get discussion comments" + }, + "description": "Get comments from a discussion", + "inputSchema": { + "type": "object", + "required": [ + "owner", + "repo", + "discussionNumber" + ], + "properties": { + "after": { + "type": "string", + "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs." + }, + "discussionNumber": { + "type": "number", + "description": "Discussion Number" + }, + "owner": { + "type": "string", + "description": "Repository owner" + }, + "perPage": { + "type": "number", + "description": "Results per page for pagination (min 1, max 100)", + "minimum": 1, + "maximum": 100 + }, + "repo": { + "type": "string", + "description": "Repository name" + } + } + }, + "name": "get_discussion_comments" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_discussion_categories.snap b/pkg/github/__toolsnaps__/list_discussion_categories.snap new file mode 100644 index 000000000..888ebbdca --- /dev/null +++ b/pkg/github/__toolsnaps__/list_discussion_categories.snap @@ -0,0 +1,24 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "List discussion categories" + }, + "description": "List discussion categories with their id and name, for a repository or organisation.", + "inputSchema": { + "type": "object", + "required": [ + "owner" + ], + "properties": { + "owner": { + "type": "string", + "description": "Repository owner" + }, + "repo": { + "type": "string", + "description": "Repository name. If not provided, discussion categories will be queried at the organisation level." + } + } + }, + "name": "list_discussion_categories" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_discussions.snap b/pkg/github/__toolsnaps__/list_discussions.snap new file mode 100644 index 000000000..95a8bebf5 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_discussions.snap @@ -0,0 +1,54 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "List discussions" + }, + "description": "List discussions for a repository or organisation.", + "inputSchema": { + "type": "object", + "required": [ + "owner" + ], + "properties": { + "after": { + "type": "string", + "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs." + }, + "category": { + "type": "string", + "description": "Optional filter by discussion category ID. If provided, only discussions with this category are listed." + }, + "direction": { + "type": "string", + "description": "Order direction.", + "enum": [ + "ASC", + "DESC" + ] + }, + "orderBy": { + "type": "string", + "description": "Order discussions by field. If provided, the 'direction' also needs to be provided.", + "enum": [ + "CREATED_AT", + "UPDATED_AT" + ] + }, + "owner": { + "type": "string", + "description": "Repository owner" + }, + "perPage": { + "type": "number", + "description": "Results per page for pagination (min 1, max 100)", + "minimum": 1, + "maximum": 100 + }, + "repo": { + "type": "string", + "description": "Repository name. If not provided, discussions will be queried at the organisation level." + } + } + }, + "name": "list_discussions" +} \ No newline at end of file diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index ac4077952..8a5019701 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -1,5 +1,3 @@ -//go:build ignore - package github import ( @@ -8,10 +6,11 @@ import ( "fmt" "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" "github.com/go-viper/mapstructure/v2" "github.com/google/go-github/v79/github" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" ) @@ -122,41 +121,51 @@ func getQueryType(useOrdering bool, categoryID *githubv4.ID) any { return &BasicNoOrder{} } -func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_discussions", - mcp.WithDescription(t("TOOL_LIST_DISCUSSIONS_DESCRIPTION", "List discussions for a repository or organisation.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ +func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { + return mcp.Tool{ + Name: "list_discussions", + Description: t("TOOL_LIST_DISCUSSIONS_DESCRIPTION", "List discussions for a repository or organisation."), + Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_LIST_DISCUSSIONS_USER_TITLE", "List discussions"), - ReadOnlyHint: ToBoolPtr(true), + ReadOnlyHint: true, + }, + InputSchema: WithCursorPagination(&jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name. If not provided, discussions will be queried at the organisation level.", + }, + "category": { + Type: "string", + Description: "Optional filter by discussion category ID. If provided, only discussions with this category are listed.", + }, + "orderBy": { + Type: "string", + Description: "Order discussions by field. If provided, the 'direction' also needs to be provided.", + Enum: []any{"CREATED_AT", "UPDATED_AT"}, + }, + "direction": { + Type: "string", + Description: "Order direction.", + Enum: []any{"ASC", "DESC"}, + }, + }, + Required: []string{"owner"}, }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Description("Repository name. If not provided, discussions will be queried at the organisation level."), - ), - mcp.WithString("category", - mcp.Description("Optional filter by discussion category ID. If provided, only discussions with this category are listed."), - ), - mcp.WithString("orderBy", - mcp.Description("Order discussions by field. If provided, the 'direction' also needs to be provided."), - mcp.Enum("CREATED_AT", "UPDATED_AT"), - ), - mcp.WithString("direction", - mcp.Description("Order direction."), - mcp.Enum("ASC", "DESC"), - ), - WithCursorPagination(), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := RequiredParam[string](request, "owner") + }, + func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } - repo, err := OptionalParam[string](request, "repo") + repo, err := OptionalParam[string](args, "repo") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } // when not provided, default to the .github repository // this will query discussions at the organisation level @@ -164,34 +173,34 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp repo = ".github" } - category, err := OptionalParam[string](request, "category") + category, err := OptionalParam[string](args, "category") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } - orderBy, err := OptionalParam[string](request, "orderBy") + orderBy, err := OptionalParam[string](args, "orderBy") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } - direction, err := OptionalParam[string](request, "direction") + direction, err := OptionalParam[string](args, "direction") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } // Get pagination parameters and convert to GraphQL format - pagination, err := OptionalCursorPaginationParams(request) + pagination, err := OptionalCursorPaginationParams(args) if err != nil { - return nil, err + return nil, nil, err } paginationParams, err := pagination.ToGraphQLParams() if err != nil { - return nil, err + return nil, nil, err } client, err := getGQLClient(ctx) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil + return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil } var categoryID *githubv4.ID @@ -225,7 +234,7 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp discussionQuery := getQueryType(useOrdering, categoryID) if err := client.Query(ctx, discussionQuery, vars); err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } // Extract and convert all discussion nodes using the common interface @@ -255,45 +264,52 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp out, err := json.Marshal(response) if err != nil { - return nil, fmt.Errorf("failed to marshal discussions: %w", err) + return nil, nil, fmt.Errorf("failed to marshal discussions: %w", err) } - return mcp.NewToolResultText(string(out)), nil + return utils.NewToolResultText(string(out)), nil, nil } } -func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_discussion", - mcp.WithDescription(t("TOOL_GET_DISCUSSION_DESCRIPTION", "Get a specific discussion by ID")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ +func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { + return mcp.Tool{ + Name: "get_discussion", + Description: t("TOOL_GET_DISCUSSION_DESCRIPTION", "Get a specific discussion by ID"), + Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_DISCUSSION_USER_TITLE", "Get discussion"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithNumber("discussionNumber", - mcp.Required(), - mcp.Description("Discussion Number"), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "discussionNumber": { + Type: "number", + Description: "Discussion Number", + }, + }, + Required: []string{"owner", "repo", "discussionNumber"}, + }, + }, + func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { // Decode params var params struct { Owner string Repo string DiscussionNumber int32 } - if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { - return mcp.NewToolResultError(err.Error()), nil + if err := mapstructure.Decode(args, ¶ms); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil } client, err := getGQLClient(ctx) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil + return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil } var q struct { @@ -319,7 +335,7 @@ func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelper "discussionNumber": githubv4.Int(params.DiscussionNumber), } if err := client.Query(ctx, &q, vars); err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } d := q.Repository.Discussion @@ -347,49 +363,64 @@ func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelper out, err := json.Marshal(response) if err != nil { - return nil, fmt.Errorf("failed to marshal discussion: %w", err) + return nil, nil, fmt.Errorf("failed to marshal discussion: %w", err) } - return mcp.NewToolResultText(string(out)), nil + return utils.NewToolResultText(string(out)), nil, nil } } -func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_discussion_comments", - mcp.WithDescription(t("TOOL_GET_DISCUSSION_COMMENTS_DESCRIPTION", "Get comments from a discussion")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ +func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { + return mcp.Tool{ + Name: "get_discussion_comments", + Description: t("TOOL_GET_DISCUSSION_COMMENTS_DESCRIPTION", "Get comments from a discussion"), + Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_DISCUSSION_COMMENTS_USER_TITLE", "Get discussion comments"), - ReadOnlyHint: ToBoolPtr(true), + ReadOnlyHint: true, + }, + InputSchema: WithCursorPagination(&jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "discussionNumber": { + Type: "number", + Description: "Discussion Number", + }, + }, + Required: []string{"owner", "repo", "discussionNumber"}, }), - mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner")), - mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")), - mcp.WithNumber("discussionNumber", mcp.Required(), mcp.Description("Discussion Number")), - WithCursorPagination(), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + }, + func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { // Decode params var params struct { Owner string Repo string DiscussionNumber int32 } - if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { - return mcp.NewToolResultError(err.Error()), nil + if err := mapstructure.Decode(args, ¶ms); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil } // Get pagination parameters and convert to GraphQL format - pagination, err := OptionalCursorPaginationParams(request) + pagination, err := OptionalCursorPaginationParams(args) if err != nil { - return nil, err + return nil, nil, err } // Check if pagination parameters were explicitly provided - _, perPageProvided := request.GetArguments()["perPage"] + _, perPageProvided := args["perPage"] paginationExplicit := perPageProvided paginationParams, err := pagination.ToGraphQLParams() if err != nil { - return nil, err + return nil, nil, err } // Use default of 30 if pagination was not explicitly provided @@ -400,7 +431,7 @@ func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.Translati client, err := getGQLClient(ctx) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil + return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil } var q struct { @@ -433,7 +464,7 @@ func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.Translati vars["after"] = (*githubv4.String)(nil) } if err := client.Query(ctx, &q, vars); err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } var comments []*github.IssueComment @@ -455,36 +486,44 @@ func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.Translati out, err := json.Marshal(response) if err != nil { - return nil, fmt.Errorf("failed to marshal comments: %w", err) + return nil, nil, fmt.Errorf("failed to marshal comments: %w", err) } - return mcp.NewToolResultText(string(out)), nil + return utils.NewToolResultText(string(out)), nil, nil } } -func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_discussion_categories", - mcp.WithDescription(t("TOOL_LIST_DISCUSSION_CATEGORIES_DESCRIPTION", "List discussion categories with their id and name, for a repository or organisation.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ +func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { + return mcp.Tool{ + Name: "list_discussion_categories", + Description: t("TOOL_LIST_DISCUSSION_CATEGORIES_DESCRIPTION", "List discussion categories with their id and name, for a repository or organisation."), + Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_LIST_DISCUSSION_CATEGORIES_USER_TITLE", "List discussion categories"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Description("Repository name. If not provided, discussion categories will be queried at the organisation level."), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := RequiredParam[string](request, "owner") + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name. If not provided, discussion categories will be queried at the organisation level.", + }, + }, + Required: []string{"owner"}, + }, + }, + func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } - repo, err := OptionalParam[string](request, "repo") + repo, err := OptionalParam[string](args, "repo") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } // when not provided, default to the .github repository // this will query discussion categories at the organisation level @@ -494,7 +533,7 @@ func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.Transl client, err := getGQLClient(ctx) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil + return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil } var q struct { @@ -520,7 +559,7 @@ func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.Transl "first": githubv4.Int(25), } if err := client.Query(ctx, &q, vars); err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } var categories []map[string]string @@ -545,8 +584,8 @@ func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.Transl out, err := json.Marshal(response) if err != nil { - return nil, fmt.Errorf("failed to marshal discussion categories: %w", err) + return nil, nil, fmt.Errorf("failed to marshal discussion categories: %w", err) } - return mcp.NewToolResultText(string(out)), nil + return utils.NewToolResultText(string(out)), nil, nil } } diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index 03dd4ae1d..1a73d523e 100644 --- a/pkg/github/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -1,5 +1,3 @@ -//go:build ignore - package github import ( @@ -9,8 +7,10 @@ import ( "testing" "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v79/github" + "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -215,13 +215,17 @@ var ( func Test_ListDiscussions(t *testing.T) { mockClient := githubv4.NewClient(nil) toolDef, _ := ListDiscussions(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Name, toolDef)) + assert.Equal(t, "list_discussions", toolDef.Name) assert.NotEmpty(t, toolDef.Description) - assert.Contains(t, toolDef.InputSchema.Properties, "owner") - assert.Contains(t, toolDef.InputSchema.Properties, "repo") - assert.Contains(t, toolDef.InputSchema.Properties, "orderBy") - assert.Contains(t, toolDef.InputSchema.Properties, "direction") - assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner"}) + schema, ok := toolDef.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "orderBy") + assert.Contains(t, schema.Properties, "direction") + assert.ElementsMatch(t, schema.Required, []string{"owner"}) // Variables matching what GraphQL receives after JSON marshaling/unmarshaling varsListAll := map[string]interface{}{ @@ -446,7 +450,7 @@ func Test_ListDiscussions(t *testing.T) { _, handler := ListDiscussions(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) req := createMCPRequest(tc.reqParams) - res, err := handler(context.Background(), req) + res, _, err := handler(context.Background(), &req, tc.reqParams) text := getTextResult(t, res).Text if tc.expectError { @@ -491,12 +495,16 @@ func Test_ListDiscussions(t *testing.T) { func Test_GetDiscussion(t *testing.T) { // Verify tool definition and schema toolDef, _ := GetDiscussion(nil, translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Name, toolDef)) + assert.Equal(t, "get_discussion", toolDef.Name) assert.NotEmpty(t, toolDef.Description) - assert.Contains(t, toolDef.InputSchema.Properties, "owner") - assert.Contains(t, toolDef.InputSchema.Properties, "repo") - assert.Contains(t, toolDef.InputSchema.Properties, "discussionNumber") - assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"}) + schema, ok := toolDef.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "discussionNumber") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "discussionNumber"}) // Use exact string query that matches implementation output qGetDiscussion := "query($discussionNumber:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){number,title,body,createdAt,closed,isAnswered,answerChosenAt,url,category{name}}}}" @@ -551,8 +559,9 @@ func Test_GetDiscussion(t *testing.T) { gqlClient := githubv4.NewClient(httpClient) _, handler := GetDiscussion(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) - req := createMCPRequest(map[string]interface{}{"owner": "owner", "repo": "repo", "discussionNumber": int32(1)}) - res, err := handler(context.Background(), req) + reqParams := map[string]interface{}{"owner": "owner", "repo": "repo", "discussionNumber": int32(1)} + req := createMCPRequest(reqParams) + res, _, err := handler(context.Background(), &req, reqParams) text := getTextResult(t, res).Text if tc.expectError { @@ -581,12 +590,16 @@ func Test_GetDiscussion(t *testing.T) { func Test_GetDiscussionComments(t *testing.T) { // Verify tool definition and schema toolDef, _ := GetDiscussionComments(nil, translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Name, toolDef)) + assert.Equal(t, "get_discussion_comments", toolDef.Name) assert.NotEmpty(t, toolDef.Description) - assert.Contains(t, toolDef.InputSchema.Properties, "owner") - assert.Contains(t, toolDef.InputSchema.Properties, "repo") - assert.Contains(t, toolDef.InputSchema.Properties, "discussionNumber") - assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"}) + schema, ok := toolDef.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "discussionNumber") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "discussionNumber"}) // Use exact string query that matches implementation output qGetComments := "query($after:String$discussionNumber:Int!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){comments(first: $first, after: $after){nodes{body},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}}" @@ -624,13 +637,14 @@ func Test_GetDiscussionComments(t *testing.T) { gqlClient := githubv4.NewClient(httpClient) _, handler := GetDiscussionComments(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) - request := createMCPRequest(map[string]interface{}{ + reqParams := map[string]interface{}{ "owner": "owner", "repo": "repo", "discussionNumber": int32(1), - }) + } + request := createMCPRequest(reqParams) - result, err := handler(context.Background(), request) + result, _, err := handler(context.Background(), &request, reqParams) require.NoError(t, err) textContent := getTextResult(t, result) @@ -659,12 +673,16 @@ func Test_GetDiscussionComments(t *testing.T) { func Test_ListDiscussionCategories(t *testing.T) { mockClient := githubv4.NewClient(nil) toolDef, _ := ListDiscussionCategories(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Name, toolDef)) + assert.Equal(t, "list_discussion_categories", toolDef.Name) assert.NotEmpty(t, toolDef.Description) assert.Contains(t, toolDef.Description, "or organisation") - assert.Contains(t, toolDef.InputSchema.Properties, "owner") - assert.Contains(t, toolDef.InputSchema.Properties, "repo") - assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner"}) + schema, ok := toolDef.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.ElementsMatch(t, schema.Required, []string{"owner"}) // Use exact string query that matches implementation output qListCategories := "query($first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussionCategories(first: $first){nodes{id,name},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" @@ -771,7 +789,7 @@ func Test_ListDiscussionCategories(t *testing.T) { _, handler := ListDiscussionCategories(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) req := createMCPRequest(tc.reqParams) - res, err := handler(context.Background(), req) + res, _, err := handler(context.Background(), &req, tc.reqParams) text := getTextResult(t, res).Text if tc.expectError { diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 54397750e..3a8ad37ea 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -21,11 +21,6 @@ import ( "github.com/shurcooL/githubv4" ) -const ( - // DefaultGraphQLPageSize is the default page size for GraphQL queries - DefaultGraphQLPageSize = 30 -) - // CloseIssueInput represents the input for closing an issue via the GraphQL API. // Used to extend the functionality of the githubv4 library to support closing issues as duplicates. type CloseIssueInput struct { diff --git a/pkg/github/tools.go b/pkg/github/tools.go index d80d16fd7..ee6d71c65 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -268,13 +268,13 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG // toolsets.NewServerTool(ManageRepositoryNotificationSubscription(getClient, t)), // ) - // discussions := toolsets.NewToolset(ToolsetMetadataDiscussions.ID, ToolsetMetadataDiscussions.Description). - // AddReadTools( - // toolsets.NewServerTool(ListDiscussions(getGQLClient, t)), - // toolsets.NewServerTool(GetDiscussion(getGQLClient, t)), - // toolsets.NewServerTool(GetDiscussionComments(getGQLClient, t)), - // toolsets.NewServerTool(ListDiscussionCategories(getGQLClient, t)), - // ) + discussions := toolsets.NewToolset(ToolsetMetadataDiscussions.ID, ToolsetMetadataDiscussions.Description). + AddReadTools( + toolsets.NewServerTool(ListDiscussions(getGQLClient, t)), + toolsets.NewServerTool(GetDiscussion(getGQLClient, t)), + toolsets.NewServerTool(GetDiscussionComments(getGQLClient, t)), + toolsets.NewServerTool(ListDiscussionCategories(getGQLClient, t)), + ) // actions := toolsets.NewToolset(ToolsetMetadataActions.ID, ToolsetMetadataActions.Description). // AddReadTools( @@ -372,7 +372,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG tsg.AddToolset(secretProtection) // tsg.AddToolset(notifications) // tsg.AddToolset(experiments) - // tsg.AddToolset(discussions) + tsg.AddToolset(discussions) tsg.AddToolset(gists) tsg.AddToolset(securityAdvisories) // tsg.AddToolset(projects)