Repository: github/github-mcp-server

Issue:
Revisit use of comma-separated lists
## Context

In #18 and other PRs where parameters can be expressed through arrays, we are using comma-separated lists instead as they seemed to cause issues in the clients.

As things are moving quite fast, let's revisit its use.

Discussion:
@juruen: The issue was related to the array schemas not being compliant. This has been resolved, and we already have a couple of tools using arrays.

We can now migrate the existing comma-separated lists at our earliest convenience.@williammartin: Here are the places this is an issue:

```
conformance_test.go:254:
                Error Trace:    /Users/williammartin/workspace/github-mcp-server/conformance/conformance_test.go:254
                Error:          Received unexpected error:
                                inputSchema mismatch for tool "create_issue":
                                  conformance_test.inputSchema{
                                        Type: "object",
                                        Properties: map[string]any{
                                                "assignees": map[string]any{
                                                        ... // 1 ignored entry
                                -                       "items": map[string]any{"type": string("string")},
                                -                       "type":  string("array"),
                                +                       "type":  string("string"),
                                                },
                                                "body": map[string]any{"type": string("string"), ...},
                                                "labels": map[string]any{
                                                        ... // 1 ignored entry
                                -                       "items": map[string]any{"type": string("string")},
                                -                       "type":  string("array"),
                                +                       "type":  string("string"),
                                                },
                                -               "milestone": map[string]any{"description": string("Milestone number to assign"), "type": string("number")},
                                                "owner":     map[string]any{"type": string("string"), ...},
                                                "repo":      map[string]any{"type": string("string"), ...},
                                                "title":     map[string]any{"type": string("string"), ...},
                                        },
                                        Required: {"owner", "repo", "title"},
                                  }

                                inputSchema mismatch for tool "list_issues":
                                  conformance_test.inputSchema{
                                        Type: "object",
                                        Properties: map[string]any{
                                                "direction": map[string]any{"enum": []any{string("asc"), string("desc")}, "type": string("string"), ...},
                                                "labels": map[string]any{
                                                        ... // 1 ignored entry
                                -                       "items": map[string]any{"type": string("string")},
                                -                       "type":  string("array"),
                                +                       "type":  string("string"),
                                                },
                                                "owner": map[string]any{"type": string("string"), ...},
                                                "page":  map[string]any{"type": string("number"), ...},
                                                ... // 5 identical entries
                                        },
                                        Required: {"owner", "repo"},
                                  }

                                inputSchema mismatch for tool "update_issue":
                                  conformance_test.inputSchema{
                                        Type: "object",
                                        Properties: map[string]any{
                                                "assignees": map[string]any{
                                                        ... // 1 ignored entry
                                -                       "items": map[string]any{"type": string("string")},
                                -                       "type":  string("array"),
                                +                       "type":  string("string"),
                                                },
                                                "body":         map[string]any{"type": string("string"), ...},
                                                "issue_number": map[string]any{"type": string("number"), ...},
                                                "labels": map[string]any{
                                                        ... // 1 ignored entry
                                -                       "items": map[string]any{"type": string("string")},
                                -                       "type":  string("array"),
                                +                       "type":  string("string"),
                                                },
                                                "milestone": map[string]any{"type": string("number"), ...},
                                                "owner":     map[string]any{"type": string("string"), ...},
                                                ... // 3 identical entries
                                        },
                                        Required: {"owner", "repo", "issue_number"},
                                  }
```

Solution patch:
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
new file mode 100644
index 0000000..52e6566
--- /dev/null
+++ b/.github/workflows/docker-publish.yml
@@ -0,0 +1,98 @@
+name: Docker
+
+# This workflow uses actions that are not certified by GitHub.
+# They are provided by a third-party and are governed by
+# separate terms of service, privacy policy, and support
+# documentation.
+
+on:
+  schedule:
+    - cron: '27 0 * * *'
+  push:
+    branches: [ "main" ]
+    # Publish semver tags as releases.
+    tags: [ 'v*.*.*' ]
+  pull_request:
+    branches: [ "main" ]
+
+env:
+  # Use docker.io for Docker Hub if empty
+  REGISTRY: ghcr.io
+  # github.repository as <account>/<repo>
+  IMAGE_NAME: ${{ github.repository }}
+
+
+jobs:
+  build:
+
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+      packages: write
+      # This is used to complete the identity challenge
+      # with sigstore/fulcio when running outside of PRs.
+      id-token: write
+
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+
+      # Install the cosign tool except on PR
+      # https://github.com/sigstore/cosign-installer
+      - name: Install cosign
+        if: github.event_name != 'pull_request'
+        uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0
+        with:
+          cosign-release: 'v2.2.4'
+
+      # Set up BuildKit Docker container builder to be able to build
+      # multi-platform images and export cache
+      # https://github.com/docker/setup-buildx-action
+      - name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
+
+      # Login against a Docker registry except on PR
+      # https://github.com/docker/login-action
+      - name: Log into registry ${{ env.REGISTRY }}
+        if: github.event_name != 'pull_request'
+        uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
+        with:
+          registry: ${{ env.REGISTRY }}
+          username: ${{ github.actor }}
+          password: ${{ secrets.GITHUB_TOKEN }}
+
+      # Extract metadata (tags, labels) for Docker
+      # https://github.com/docker/metadata-action
+      - name: Extract Docker metadata
+        id: meta
+        uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
+        with:
+          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+
+      # Build and push Docker image with Buildx (don't push on PR)
+      # https://github.com/docker/build-push-action
+      - name: Build and push Docker image
+        id: build-and-push
+        uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
+        with:
+          context: .
+          push: ${{ github.event_name != 'pull_request' }}
+          tags: ${{ steps.meta.outputs.tags }}
+          labels: ${{ steps.meta.outputs.labels }}
+          cache-from: type=gha
+          cache-to: type=gha,mode=max
+
+      # Sign the resulting Docker image digest except on PRs.
+      # This will only write to the public Rekor transparency log when the Docker
+      # repository is public to avoid leaking data.  If you would like to publish
+      # transparency data even for private images, pass --force to cosign below.
+      # https://github.com/sigstore/cosign
+      - name: Sign the published Docker image
+        if: ${{ github.event_name != 'pull_request' }}
+        env:
+          # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
+          TAGS: ${{ steps.meta.outputs.tags }}
+          DIGEST: ${{ steps.build-and-push.outputs.digest }}
+        # This step uses the identity token to provision an ephemeral certificate
+        # against the sigstore community Fulcio instance.
+        run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..6b66afb
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,17 @@
+FROM golang:1.23.7 AS build
+# Set the working directory
+WORKDIR /build
+# Copy the current directory contents into the working directory
+COPY . .
+# Install dependencies
+RUN go mod download
+# Build the server
+RUN CGO_ENABLED=0 go build -o github-mcp-server cmd/github-mcp-server/main.go
+# Make a stage to run the app
+FROM gcr.io/distroless/base-debian12
+# Set the working directory
+WORKDIR /server
+# Copy the binary from the build stage
+COPY --from=build /build/github-mcp-server .
+# Command to run the server
+CMD ["./github-mcp-server", "stdio"]
diff --git a/README.md b/README.md
index 16c5348..b836a1f 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,15 @@ and set it as the GITHUB_PERSONAL_ACCESS_TOKEN environment variable.
   - `repo`: Repository name (string, required)
   - `issue_number`: Issue number (number, required)
 
+- **create_issue** - Create a new issue in a GitHub repository
+
+  - `owner`: Repository owner (string, required)
+  - `repo`: Repository name (string, required)
+  - `title`: Issue title (string, required)
+  - `body`: Issue body content (string, optional)
+  - `assignees`: Comma-separated list of usernames to assign to this issue (string, optional)
+  - `labels`: Comma-separated list of labels to apply to this issue (string, optional)
+
 - **add_issue_comment** - Add a comment to an issue
 
   - `owner`: Repository owner (string, required)
@@ -313,16 +322,14 @@ Lots of things!
 Missing tools:
 
 - push_files (files array)
-- create_issue (assignees and labels arrays)
 - list_issues (labels array)
 - update_issue (labels and assignees arrays)
 - create_pull_request_review (comments array)
 
 Testing
 
-- Unit tests
 - Integration tests
-- Blackbox testing: ideally comparing output to Anthromorphic's server to make sure that this is a fully compatible drop-in replacement.
+- Blackbox testing: ideally comparing output to Anthropic's server to make sure that this is a fully compatible drop-in replacement.
 
 And some other stuff:
 
diff --git a/pkg/github/issues.go b/pkg/github/issues.go
index 67179de..3a23825 100644
--- a/pkg/github/issues.go
+++ b/pkg/github/issues.go
@@ -182,3 +182,83 @@ func searchIssues(client *github.Client) (tool mcp.Tool, handler server.ToolHand
 			return mcp.NewToolResultText(string(r)), nil
 		}
 }
+
+// createIssue creates a tool to create a new issue in a GitHub repository.
+func createIssue(client *github.Client) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+	return mcp.NewTool("create_issue",
+			mcp.WithDescription("Create a new issue in a GitHub repository"),
+			mcp.WithString("owner",
+				mcp.Required(),
+				mcp.Description("Repository owner"),
+			),
+			mcp.WithString("repo",
+				mcp.Required(),
+				mcp.Description("Repository name"),
+			),
+			mcp.WithString("title",
+				mcp.Required(),
+				mcp.Description("Issue title"),
+			),
+			mcp.WithString("body",
+				mcp.Description("Issue body content"),
+			),
+			mcp.WithString("assignees",
+				mcp.Description("Comma-separate list of usernames to assign to this issue"),
+			),
+			mcp.WithString("labels",
+				mcp.Description("Comma-separate list of labels to apply to this issue"),
+			),
+		),
+		func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+			owner := request.Params.Arguments["owner"].(string)
+			repo := request.Params.Arguments["repo"].(string)
+			title := request.Params.Arguments["title"].(string)
+
+			// Optional parameters
+			var body string
+			if b, ok := request.Params.Arguments["body"].(string); ok {
+				body = b
+			}
+
+			// Parse assignees if present
+			assignees := []string{} // default to empty slice, can't be nil
+			if a, ok := request.Params.Arguments["assignees"].(string); ok && a != "" {
+				assignees = parseCommaSeparatedList(a)
+			}
+
+			// Parse labels if present
+			labels := []string{} // default to empty slice, can't be nil
+			if l, ok := request.Params.Arguments["labels"].(string); ok && l != "" {
+				labels = parseCommaSeparatedList(l)
+			}
+
+			// Create the issue request
+			issueRequest := &github.IssueRequest{
+				Title:     github.Ptr(title),
+				Body:      github.Ptr(body),
+				Assignees: &assignees,
+				Labels:    &labels,
+			}
+
+			issue, resp, err := client.Issues.Create(ctx, owner, repo, issueRequest)
+			if err != nil {
+				return nil, fmt.Errorf("failed to create issue: %w", err)
+			}
+			defer func() { _ = resp.Body.Close() }()
+
+			if resp.StatusCode != http.StatusCreated {
+				body, err := io.ReadAll(resp.Body)
+				if err != nil {
+					return nil, fmt.Errorf("failed to read response body: %w", err)
+				}
+				return mcp.NewToolResultError(fmt.Sprintf("failed to create issue: %s", string(body))), nil
+			}
+
+			r, err := json.Marshal(issue)
+			if err != nil {
+				return nil, fmt.Errorf("failed to marshal response: %w", err)
+			}
+
+			return mcp.NewToolResultText(string(r)), nil
+		}
+}
diff --git a/pkg/github/server.go b/pkg/github/server.go
index 30d9b3b..31521fb 100644
--- a/pkg/github/server.go
+++ b/pkg/github/server.go
@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"io"
 	"net/http"
+	"strings"
 
 	"github.com/google/go-github/v69/github"
 	"github.com/mark3labs/mcp-go/mcp"
@@ -34,6 +35,7 @@ func NewServer(client *github.Client) *server.MCPServer {
 	// Add GitHub tools - Issues
 	s.AddTool(getIssue(client))
 	s.AddTool(addIssueComment(client))
+	s.AddTool(createIssue(client))
 	s.AddTool(searchIssues(client))
 
 	// Add GitHub tools - Pull Requests
@@ -106,3 +108,22 @@ func isAcceptedError(err error) bool {
 	var acceptedError *github.AcceptedError
 	return errors.As(err, &acceptedError)
 }
+
+// parseCommaSeparatedList is a helper function that parses a comma-separated list of strings from the input string.
+func parseCommaSeparatedList(input string) []string {
+	if input == "" {
+		return nil
+	}
+
+	parts := strings.Split(input, ",")
+	result := make([]string, 0, len(parts))
+
+	for _, part := range parts {
+		trimmed := strings.TrimSpace(part)
+		if trimmed != "" {
+			result = append(result, trimmed)
+		}
+	}
+
+	return result
+}



Test patch:
diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go
index 7e9944b..c1ebf6d 100644
--- a/pkg/github/issues_test.go
+++ b/pkg/github/issues_test.go
@@ -369,3 +369,158 @@ func Test_SearchIssues(t *testing.T) {
 		})
 	}
 }
+
+func Test_CreateIssue(t *testing.T) {
+	// Verify tool definition once
+	mockClient := github.NewClient(nil)
+	tool, _ := createIssue(mockClient)
+
+	assert.Equal(t, "create_issue", tool.Name)
+	assert.NotEmpty(t, tool.Description)
+	assert.Contains(t, tool.InputSchema.Properties, "owner")
+	assert.Contains(t, tool.InputSchema.Properties, "repo")
+	assert.Contains(t, tool.InputSchema.Properties, "title")
+	assert.Contains(t, tool.InputSchema.Properties, "body")
+	assert.Contains(t, tool.InputSchema.Properties, "assignees")
+	assert.Contains(t, tool.InputSchema.Properties, "labels")
+	assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "title"})
+
+	// Setup mock issue for success case
+	mockIssue := &github.Issue{
+		Number:    github.Ptr(123),
+		Title:     github.Ptr("Test Issue"),
+		Body:      github.Ptr("This is a test issue"),
+		State:     github.Ptr("open"),
+		HTMLURL:   github.Ptr("https://github.com/owner/repo/issues/123"),
+		Assignees: []*github.User{{Login: github.Ptr("user1")}, {Login: github.Ptr("user2")}},
+		Labels:    []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("help wanted")}},
+	}
+
+	tests := []struct {
+		name           string
+		mockedClient   *http.Client
+		requestArgs    map[string]interface{}
+		expectError    bool
+		expectedIssue  *github.Issue
+		expectedErrMsg string
+	}{
+		{
+			name: "successful issue creation with all fields",
+			mockedClient: mock.NewMockedHTTPClient(
+				mock.WithRequestMatchHandler(
+					mock.PostReposIssuesByOwnerByRepo,
+					mockResponse(t, http.StatusCreated, mockIssue),
+				),
+			),
+			requestArgs: map[string]interface{}{
+				"owner":     "owner",
+				"repo":      "repo",
+				"title":     "Test Issue",
+				"body":      "This is a test issue",
+				"assignees": []interface{}{"user1", "user2"},
+				"labels":    []interface{}{"bug", "help wanted"},
+			},
+			expectError:   false,
+			expectedIssue: mockIssue,
+		},
+		{
+			name: "successful issue creation with minimal fields",
+			mockedClient: mock.NewMockedHTTPClient(
+				mock.WithRequestMatchHandler(
+					mock.PostReposIssuesByOwnerByRepo,
+					mockResponse(t, http.StatusCreated, &github.Issue{
+						Number:  github.Ptr(124),
+						Title:   github.Ptr("Minimal Issue"),
+						HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"),
+						State:   github.Ptr("open"),
+					}),
+				),
+			),
+			requestArgs: map[string]interface{}{
+				"owner": "owner",
+				"repo":  "repo",
+				"title": "Minimal Issue",
+			},
+			expectError: false,
+			expectedIssue: &github.Issue{
+				Number:  github.Ptr(124),
+				Title:   github.Ptr("Minimal Issue"),
+				HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"),
+				State:   github.Ptr("open"),
+			},
+		},
+		{
+			name: "issue creation fails",
+			mockedClient: mock.NewMockedHTTPClient(
+				mock.WithRequestMatchHandler(
+					mock.PostReposIssuesByOwnerByRepo,
+					http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+						w.WriteHeader(http.StatusUnprocessableEntity)
+						_, _ = w.Write([]byte(`{"message": "Validation failed"}`))
+					}),
+				),
+			),
+			requestArgs: map[string]interface{}{
+				"owner": "owner",
+				"repo":  "repo",
+				"title": "",
+			},
+			expectError:    true,
+			expectedErrMsg: "failed to create issue",
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			// Setup client with mock
+			client := github.NewClient(tc.mockedClient)
+			_, handler := createIssue(client)
+
+			// Create call request
+			request := createMCPRequest(tc.requestArgs)
+
+			// Call handler
+			result, err := handler(context.Background(), request)
+
+			// Verify results
+			if tc.expectError {
+				require.Error(t, err)
+				assert.Contains(t, err.Error(), tc.expectedErrMsg)
+				return
+			}
+
+			require.NoError(t, err)
+			textContent := getTextResult(t, result)
+
+			// Unmarshal and verify the result
+			var returnedIssue github.Issue
+			err = json.Unmarshal([]byte(textContent.Text), &returnedIssue)
+			require.NoError(t, err)
+
+			assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number)
+			assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title)
+			assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State)
+			assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL)
+
+			if tc.expectedIssue.Body != nil {
+				assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body)
+			}
+
+			// Check assignees if expected
+			if len(tc.expectedIssue.Assignees) > 0 {
+				assert.Equal(t, len(tc.expectedIssue.Assignees), len(returnedIssue.Assignees))
+				for i, assignee := range returnedIssue.Assignees {
+					assert.Equal(t, *tc.expectedIssue.Assignees[i].Login, *assignee.Login)
+				}
+			}
+
+			// Check labels if expected
+			if len(tc.expectedIssue.Labels) > 0 {
+				assert.Equal(t, len(tc.expectedIssue.Labels), len(returnedIssue.Labels))
+				for i, label := range returnedIssue.Labels {
+					assert.Equal(t, *tc.expectedIssue.Labels[i].Name, *label.Name)
+				}
+			}
+		})
+	}
+}
diff --git a/pkg/github/server_test.go b/pkg/github/server_test.go
index d56993d..5515c88 100644
--- a/pkg/github/server_test.go
+++ b/pkg/github/server_test.go
@@ -166,3 +166,64 @@ func Test_IsAcceptedError(t *testing.T) {
 		})
 	}
 }
+
+func Test_ParseCommaSeparatedList(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    string
+		expected []string
+	}{
+		{
+			name:     "simple comma separated values",
+			input:    "one,two,three",
+			expected: []string{"one", "two", "three"},
+		},
+		{
+			name:     "values with spaces",
+			input:    "one, two, three",
+			expected: []string{"one", "two", "three"},
+		},
+		{
+			name:     "values with extra spaces",
+			input:    "  one  ,  two  ,  three  ",
+			expected: []string{"one", "two", "three"},
+		},
+		{
+			name:     "empty values in between",
+			input:    "one,,three",
+			expected: []string{"one", "three"},
+		},
+		{
+			name:     "only spaces",
+			input:    " , , ",
+			expected: []string{},
+		},
+		{
+			name:     "empty string",
+			input:    "",
+			expected: nil,
+		},
+		{
+			name:     "single value",
+			input:    "one",
+			expected: []string{"one"},
+		},
+		{
+			name:     "trailing comma",
+			input:    "one,two,",
+			expected: []string{"one", "two"},
+		},
+		{
+			name:     "leading comma",
+			input:    ",one,two",
+			expected: []string{"one", "two"},
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			result := parseCommaSeparatedList(tc.input)
+			assert.Equal(t, tc.expected, result)
+		})
+	}
+}