diff --git a/README.md b/README.md index ed6c6f549..79e46b6b6 100644 --- a/README.md +++ b/README.md @@ -630,6 +630,13 @@ The following sets of tools are available: Discussions +- **create_discussion** - Create discussion + - `body`: Discussion body text in markdown format (string, required) + - `categoryId`: Category ID where the discussion should be created (obtainable via list_discussion_categories) (string, required) + - `owner`: Repository owner (string, required) + - `repo`: Repository name. If not provided, the discussion will be created at the organisation level. (string, optional) + - `title`: Discussion title (string, required) + - **get_discussion** - Get discussion - `discussionNumber`: Discussion Number (number, required) - `owner`: Repository owner (string, required) diff --git a/docs/remote-server.md b/docs/remote-server.md index 1030911ef..e06d41a75 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -19,7 +19,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) | |----------------|--------------------------------------------------|-------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Default | ["Default" toolset](../README.md#default-toolset) | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | +| all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | | Actions | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | | Code Security | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | | Dependabot | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | diff --git a/pkg/github/__toolsnaps__/create_discussion.snap b/pkg/github/__toolsnaps__/create_discussion.snap new file mode 100644 index 000000000..b0732206b --- /dev/null +++ b/pkg/github/__toolsnaps__/create_discussion.snap @@ -0,0 +1,38 @@ +{ + "annotations": { + "title": "Create discussion" + }, + "description": "Create a new discussion in a repository or organisation.", + "inputSchema": { + "type": "object", + "required": [ + "owner", + "categoryId", + "title", + "body" + ], + "properties": { + "body": { + "type": "string", + "description": "Discussion body text in markdown format" + }, + "categoryId": { + "type": "string", + "description": "Category ID where the discussion should be created (obtainable via list_discussion_categories)" + }, + "owner": { + "type": "string", + "description": "Repository owner" + }, + "repo": { + "type": "string", + "description": "Repository name. If not provided, the discussion will be created at the organisation level." + }, + "title": { + "type": "string", + "description": "Discussion title" + } + } + }, + "name": "create_discussion" +} \ No newline at end of file diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index 8a5019701..7e803cadf 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -493,6 +493,119 @@ func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.Translati } } +func CreateDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { + return mcp.Tool{ + Name: "create_discussion", + Description: t("TOOL_CREATE_DISCUSSION_DESCRIPTION", "Create a new discussion in a repository or organisation."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_CREATE_DISCUSSION_USER_TITLE", "Create discussion"), + }, + 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, the discussion will be created at the organisation level.", + }, + "categoryId": { + Type: "string", + Description: "Category ID where the discussion should be created (obtainable via list_discussion_categories)", + }, + "title": { + Type: "string", + Description: "Discussion title", + }, + "body": { + Type: "string", + Description: "Discussion body text in markdown format", + }, + }, + Required: []string{"owner", "categoryId", "title", "body"}, + }, + }, + func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := OptionalParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + // when not provided, default to the .github repository + // this will create the discussion at the organisation level + if repo == "" { + repo = ".github" + } + + categoryID, err := RequiredParam[string](args, "categoryId") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + title, err := RequiredParam[string](args, "title") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + body, err := RequiredParam[string](args, "body") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := getGQLClient(ctx) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil + } + + // Get repository ID first + repoID, err := getRepositoryID(ctx, client, owner, repo) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to get repository ID: %v", err)), nil, nil + } + + // Define the mutation + var mutation struct { + CreateDiscussion struct { + Discussion struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"createDiscussion(input: $input)"` + } + + input := githubv4.CreateDiscussionInput{ + RepositoryID: repoID, + CategoryID: githubv4.ID(categoryID), + Title: githubv4.String(title), + Body: githubv4.String(body), + } + + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to create discussion: %v", err)), nil, nil + } + + // Build response + response := map[string]interface{}{ + "id": fmt.Sprint(mutation.CreateDiscussion.Discussion.ID), + "number": int(mutation.CreateDiscussion.Discussion.Number), + "url": string(mutation.CreateDiscussion.Discussion.URL), + } + + out, err := json.Marshal(response) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal discussion: %w", err) + } + + return utils.NewToolResultText(string(out)), nil, nil + } +} + func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { return mcp.Tool{ Name: "list_discussion_categories", diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index 1a73d523e..b9dd7e8ef 100644 --- a/pkg/github/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -813,3 +813,253 @@ func Test_ListDiscussionCategories(t *testing.T) { }) } } + +func Test_CreateDiscussion(t *testing.T) { + mockClient := githubv4.NewClient(nil) + toolDef, _ := CreateDiscussion(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Name, toolDef)) + + assert.Equal(t, "create_discussion", toolDef.Name) + assert.NotEmpty(t, toolDef.Description) + 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, "categoryId") + assert.Contains(t, schema.Properties, "title") + assert.Contains(t, schema.Properties, "body") + assert.ElementsMatch(t, schema.Required, []string{"owner", "categoryId", "title", "body"}) + + // Query to get repository ID + qGetRepoID := "query($owner:String!$repo:String!){repository(owner: $owner, name: $repo){id}}" + + varsRepoID := map[string]interface{}{ + "owner": "owner", + "repo": "repo", + } + + varsRepoIDOrg := map[string]interface{}{ + "owner": "owner", + "repo": ".github", + } + + mockRepoIDResponse := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "id": "R_kgDOABC123", + }, + }) + + // Mutation to create discussion + qCreateDiscussion := "mutation($input:CreateDiscussionInput!){createDiscussion(input: $input){discussion{id,number,url}}}" + + varsCreateDiscussion := map[string]interface{}{ + "input": map[string]interface{}{ + "repositoryId": "R_kgDOABC123", + "categoryId": "DIC_kwDOABC456", + "title": "Test Discussion", + "body": "This is a test discussion body", + }, + } + + varsCreateDiscussionOrg := map[string]interface{}{ + "input": map[string]interface{}{ + "repositoryId": "R_kgDOABC123", + "categoryId": "DIC_kwDOABC456", + "title": "Org Level Discussion", + "body": "This is an org-level discussion", + }, + } + + mockCreateResponse := githubv4mock.DataResponse(map[string]any{ + "createDiscussion": map[string]any{ + "discussion": map[string]any{ + "id": "D_kwDOABC789", + "number": 42, + "url": "https://github.com/owner/repo/discussions/42", + }, + }, + }) + + mockErrorCategoryNotFound := githubv4mock.ErrorResponse("category not found") + mockErrorRepoNotFound := githubv4mock.ErrorResponse("repository not found") + + tests := []struct { + name string + reqParams map[string]interface{} + expectError bool + errContains string + expectedID string + expectedNumber int + expectedURL string + useOrgRepo bool + mockRepoNotFoundError bool + mockCategoryErrorInMut bool + }{ + { + name: "successful creation", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "categoryId": "DIC_kwDOABC456", + "title": "Test Discussion", + "body": "This is a test discussion body", + }, + expectError: false, + expectedID: "D_kwDOABC789", + expectedNumber: 42, + expectedURL: "https://github.com/owner/repo/discussions/42", + }, + { + name: "create at org level (no repo provided)", + reqParams: map[string]interface{}{ + "owner": "owner", + "categoryId": "DIC_kwDOABC456", + "title": "Org Level Discussion", + "body": "This is an org-level discussion", + }, + expectError: false, + expectedID: "D_kwDOABC789", + expectedNumber: 42, + expectedURL: "https://github.com/owner/repo/discussions/42", + useOrgRepo: true, + }, + { + name: "missing required categoryId", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "title": "Test Discussion", + "body": "This is a test discussion body", + }, + expectError: true, + errContains: "categoryId", + }, + { + name: "missing required title", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "categoryId": "DIC_kwDOABC456", + "body": "This is a test discussion body", + }, + expectError: true, + errContains: "title", + }, + { + name: "missing required body", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "categoryId": "DIC_kwDOABC456", + "title": "Test Discussion", + }, + expectError: true, + errContains: "body", + }, + { + name: "repository not found", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "nonexistent-repo", + "categoryId": "DIC_kwDOABC456", + "title": "Test Discussion", + "body": "This is a test discussion body", + }, + expectError: true, + errContains: "repository not found", + mockRepoNotFoundError: true, + }, + { + name: "category not found", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "categoryId": "DIC_invalid", + "title": "Test Discussion", + "body": "This is a test discussion body", + }, + expectError: true, + errContains: "category not found", + mockCategoryErrorInMut: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Skip tests that require missing parameters - they fail during parameter extraction + if tc.name == "missing required categoryId" || tc.name == "missing required title" || tc.name == "missing required body" { + _, handler := CreateDiscussion(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) + req := createMCPRequest(tc.reqParams) + res, _, _ := handler(context.Background(), &req, tc.reqParams) + text := getTextResult(t, res).Text + require.True(t, res.IsError) + assert.Contains(t, text, tc.errContains) + return + } + + var httpClient *http.Client + + switch { + case tc.mockRepoNotFoundError: + // Mock repository not found error + varsRepoIDNotFound := map[string]interface{}{ + "owner": "owner", + "repo": "nonexistent-repo", + } + matcher := githubv4mock.NewQueryMatcher(qGetRepoID, varsRepoIDNotFound, mockErrorRepoNotFound) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case tc.mockCategoryErrorInMut: + // Mock successful repo ID query but category not found in mutation + varsCategoryError := map[string]interface{}{ + "input": map[string]interface{}{ + "repositoryId": "R_kgDOABC123", + "categoryId": "DIC_invalid", + "title": "Test Discussion", + "body": "This is a test discussion body", + }, + } + repoMatcher := githubv4mock.NewQueryMatcher(qGetRepoID, varsRepoID, mockRepoIDResponse) + mutationMatcher := githubv4mock.NewQueryMatcher(qCreateDiscussion, varsCategoryError, mockErrorCategoryNotFound) + httpClient = githubv4mock.NewMockedHTTPClient(repoMatcher, mutationMatcher) + default: + // Normal successful flow + repoVars := varsRepoID + mutVars := varsCreateDiscussion + if tc.useOrgRepo { + repoVars = varsRepoIDOrg + mutVars = varsCreateDiscussionOrg + } + repoMatcher := githubv4mock.NewQueryMatcher(qGetRepoID, repoVars, mockRepoIDResponse) + mutationMatcher := githubv4mock.NewQueryMatcher(qCreateDiscussion, mutVars, mockCreateResponse) + httpClient = githubv4mock.NewMockedHTTPClient(repoMatcher, mutationMatcher) + } + + gqlClient := githubv4.NewClient(httpClient) + _, handler := CreateDiscussion(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + + req := createMCPRequest(tc.reqParams) + res, _, err := handler(context.Background(), &req, tc.reqParams) + text := getTextResult(t, res).Text + + if tc.expectError { + require.True(t, res.IsError) + assert.Contains(t, text, tc.errContains) + return + } + + require.NoError(t, err) + + var response struct { + ID string `json:"id"` + Number int `json:"number"` + URL string `json:"url"` + } + err = json.Unmarshal([]byte(text), &response) + require.NoError(t, err) + + assert.Equal(t, tc.expectedID, response.ID) + assert.Equal(t, tc.expectedNumber, response.Number) + assert.Equal(t, tc.expectedURL, response.URL) + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index d37af98b8..b1eababf3 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -273,6 +273,9 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(GetDiscussion(getGQLClient, t)), toolsets.NewServerTool(GetDiscussionComments(getGQLClient, t)), toolsets.NewServerTool(ListDiscussionCategories(getGQLClient, t)), + ). + AddWriteTools( + toolsets.NewServerTool(CreateDiscussion(getGQLClient, t)), ) actions := toolsets.NewToolset(ToolsetMetadataActions.ID, ToolsetMetadataActions.Description).