diff --git a/pkg/github/issue_resource.go b/pkg/github/issue_resource.go
new file mode 100644
index 000000000..1bcb31efe
--- /dev/null
+++ b/pkg/github/issue_resource.go
@@ -0,0 +1,279 @@
+package github
+
+import (
+ "context"
+ "fmt"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "github.com/github/github-mcp-server/pkg/inventory"
+ "github.com/github/github-mcp-server/pkg/octicons"
+ "github.com/github/github-mcp-server/pkg/sanitize"
+ "github.com/github/github-mcp-server/pkg/translations"
+ "github.com/google/go-github/v82/github"
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+ "github.com/yosida95/uritemplate/v3"
+)
+
+var issueResourceURITemplate = uritemplate.MustNew("issue://{owner}/{repo}/issues/{issueNumber}.md")
+
+// htmlImagePattern matches HTML img tags:
+var htmlImagePattern = regexp.MustCompile(`
]*)\bsrc=["']([^"']+)["']([^>]*)/??>`)
+
+// htmlAltPattern extracts the alt attribute value from an img tag fragment.
+var htmlAltPattern = regexp.MustCompile(`\balt=["']([^"']*)["']`)
+
+// GetIssueResourceContent defines the resource template for reading issue content.
+func GetIssueResourceContent(t translations.TranslationHelperFunc) inventory.ServerResourceTemplate {
+ return inventory.NewServerResourceTemplate(
+ ToolsetMetadataIssues,
+ mcp.ResourceTemplate{
+ Name: "issue_content",
+ URITemplate: issueResourceURITemplate.Raw(),
+ Description: t("RESOURCE_ISSUE_CONTENT_DESCRIPTION", "Issue content with comments and embedded images as a single markdown document"),
+ Icons: octicons.Icons("issue-opened"),
+ },
+ issueResourceHandlerFunc(),
+ )
+}
+
+func issueResourceHandlerFunc() inventory.ResourceHandlerFunc {
+ return func(_ any) mcp.ResourceHandler {
+ return IssueResourceHandler()
+ }
+}
+
+// IssueResourceHandler returns a handler for issue resource requests.
+func IssueResourceHandler() mcp.ResourceHandler {
+ return func(ctx context.Context, request *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
+ deps := MustDepsFromContext(ctx)
+
+ uriValues := issueResourceURITemplate.Match(request.Params.URI)
+ if uriValues == nil {
+ return nil, fmt.Errorf("failed to match URI: %s", request.Params.URI)
+ }
+
+ owner := uriValues.Get("owner").String()
+ repo := uriValues.Get("repo").String()
+ issueNumberStr := uriValues.Get("issueNumber").String()
+
+ if owner == "" {
+ return nil, fmt.Errorf("owner is required")
+ }
+ if repo == "" {
+ return nil, fmt.Errorf("repo is required")
+ }
+ if issueNumberStr == "" {
+ return nil, fmt.Errorf("issueNumber is required")
+ }
+
+ issueNumber, err := strconv.Atoi(issueNumberStr)
+ if err != nil {
+ return nil, fmt.Errorf("invalid issue number: %w", err)
+ }
+
+ client, err := deps.GetClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ // Fetch the issue
+ issue, _, err := client.Issues.Get(ctx, owner, repo, issueNumber)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get issue: %w", err)
+ }
+
+ // Build unified markdown document
+ var doc strings.Builder
+
+ // Issue body with frontmatter
+ body := sanitize.Sanitize(issue.GetBody())
+ frontmatter := buildIssueFrontmatter(issue)
+ doc.WriteString(frontmatter)
+ doc.WriteString(convertHTMLImagesToMarkdown(body))
+
+ // Fetch all comments
+ comments, err := fetchAllIssueComments(ctx, client, owner, repo, issueNumber)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get issue comments: %w", err)
+ }
+
+ for _, comment := range comments {
+ doc.WriteString("\n\n---\n\n")
+ commentBody := sanitize.Sanitize(comment.GetBody())
+ commentFrontmatter := buildCommentFrontmatter(comment)
+ doc.WriteString(commentFrontmatter)
+ doc.WriteString(convertHTMLImagesToMarkdown(commentBody))
+ }
+
+ resourceURI := fmt.Sprintf("issue://%s/%s/issues/%d.md", owner, repo, issueNumber)
+ contents := []*mcp.ResourceContents{
+ {
+ URI: resourceURI,
+ MIMEType: "text/markdown",
+ Text: doc.String(),
+ },
+ }
+
+ return &mcp.ReadResourceResult{Contents: contents}, nil
+ }
+}
+
+func buildIssueFrontmatter(issue *github.Issue) string {
+ var b strings.Builder
+ b.WriteString("---\n")
+ b.WriteString(fmt.Sprintf("title: %q\n", sanitize.Sanitize(issue.GetTitle())))
+ b.WriteString(fmt.Sprintf("state: %s\n", issue.GetState()))
+ if user := issue.GetUser(); user != nil {
+ b.WriteString(fmt.Sprintf("author: %s\n", user.GetLogin()))
+ }
+ if issue.CreatedAt != nil {
+ b.WriteString(fmt.Sprintf("created_at: %s\n", issue.CreatedAt.Format("2006-01-02T15:04:05Z")))
+ }
+ if len(issue.Labels) > 0 {
+ b.WriteString("labels:\n")
+ for _, label := range issue.Labels {
+ if label != nil {
+ b.WriteString(fmt.Sprintf(" - %s\n", label.GetName()))
+ }
+ }
+ }
+ if issue.GetMilestone() != nil {
+ b.WriteString(fmt.Sprintf("milestone: %s\n", issue.GetMilestone().GetTitle()))
+ }
+ b.WriteString("---\n\n")
+ return b.String()
+}
+
+func buildCommentFrontmatter(comment *github.IssueComment) string {
+ var b strings.Builder
+ b.WriteString("---\n")
+ if user := comment.GetUser(); user != nil {
+ b.WriteString(fmt.Sprintf("author: %s\n", user.GetLogin()))
+ }
+ b.WriteString(fmt.Sprintf("author_association: %s\n", comment.GetAuthorAssociation()))
+ if comment.CreatedAt != nil {
+ b.WriteString(fmt.Sprintf("created_at: %s\n", comment.CreatedAt.Format("2006-01-02T15:04:05Z")))
+ }
+ if comment.UpdatedAt != nil {
+ b.WriteString(fmt.Sprintf("updated_at: %s\n", comment.UpdatedAt.Format("2006-01-02T15:04:05Z")))
+ }
+ b.WriteString("---\n\n")
+ return b.String()
+}
+
+// convertHTMLImagesToMarkdown converts HTML
tags to markdown image syntax.
+func convertHTMLImagesToMarkdown(body string) string {
+ return htmlImagePattern.ReplaceAllStringFunc(body, func(match string) string {
+ submatches := htmlImagePattern.FindStringSubmatch(match)
+ if len(submatches) < 3 {
+ return match
+ }
+ imageURL := submatches[2]
+ alt := ""
+ attrs := submatches[1] + submatches[3]
+ if altMatch := htmlAltPattern.FindStringSubmatch(attrs); len(altMatch) >= 2 {
+ alt = altMatch[1]
+ }
+ return fmt.Sprintf("", alt, imageURL)
+ })
+}
+
+func fetchAllIssueComments(ctx context.Context, client *github.Client, owner, repo string, issueNumber int) ([]*github.IssueComment, error) {
+ var allComments []*github.IssueComment
+ opts := &github.IssueListCommentsOptions{
+ ListOptions: github.ListOptions{PerPage: 100},
+ }
+ for {
+ comments, resp, err := client.Issues.ListComments(ctx, owner, repo, issueNumber, opts)
+ if err != nil {
+ return nil, err
+ }
+ _ = resp.Body.Close()
+ allComments = append(allComments, comments...)
+ if resp.NextPage == 0 {
+ break
+ }
+ opts.Page = resp.NextPage
+ }
+ return allComments, nil
+}
+
+// IssueResourceCompletionHandler returns a completion handler for issue resource URI templates.
+func IssueResourceCompletionHandler(getClient GetClientFn) func(ctx context.Context, req *mcp.CompleteRequest) (*mcp.CompleteResult, error) {
+ return func(ctx context.Context, req *mcp.CompleteRequest) (*mcp.CompleteResult, error) {
+ if req.Params.Ref.Type != "ref/resource" {
+ return nil, nil
+ }
+
+ argName := req.Params.Argument.Name
+ argValue := req.Params.Argument.Value
+ var resolved map[string]string
+ if req.Params.Context != nil && req.Params.Context.Arguments != nil {
+ resolved = req.Params.Context.Arguments
+ } else {
+ resolved = map[string]string{}
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ // Reuse owner and repo resolvers from repository resource completions
+ resolvers := map[string]CompleteHandler{
+ "owner": completeOwner,
+ "repo": completeRepo,
+ "issueNumber": completeIssueNumber,
+ }
+
+ resolver, ok := resolvers[argName]
+ if !ok {
+ return nil, fmt.Errorf("no resolver for argument: %s", argName)
+ }
+
+ values, err := resolver(ctx, client, resolved, argValue)
+ if err != nil {
+ return nil, err
+ }
+ if len(values) > 100 {
+ values = values[:100]
+ }
+
+ return &mcp.CompleteResult{
+ Completion: mcp.CompletionResultDetails{
+ Values: values,
+ Total: len(values),
+ HasMore: false,
+ },
+ }, nil
+ }
+}
+
+func completeIssueNumber(ctx context.Context, client *github.Client, resolved map[string]string, argValue string) ([]string, error) {
+ owner := resolved["owner"]
+ repo := resolved["repo"]
+ if owner == "" || repo == "" {
+ return nil, fmt.Errorf("owner or repo not specified")
+ }
+
+ issues, _, err := client.Search.Issues(ctx, fmt.Sprintf("repo:%s/%s is:issue", owner, repo), &github.SearchOptions{
+ ListOptions: github.ListOptions{PerPage: 100},
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ var values []string
+ for _, issue := range issues.Issues {
+ num := fmt.Sprintf("%d", issue.GetNumber())
+ if argValue == "" || strings.HasPrefix(num, argValue) {
+ values = append(values, num)
+ }
+ }
+ if len(values) > 100 {
+ values = values[:100]
+ }
+ return values, nil
+}
diff --git a/pkg/github/issue_resource_test.go b/pkg/github/issue_resource_test.go
new file mode 100644
index 000000000..49b8be324
--- /dev/null
+++ b/pkg/github/issue_resource_test.go
@@ -0,0 +1,439 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/google/go-github/v82/github"
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_issueResource(t *testing.T) {
+ t.Run("missing owner", func(t *testing.T) {
+ client := github.NewClient(MockHTTPClientWithHandler(nil))
+ deps := BaseDeps{Client: client}
+ ctx := ContextWithDeps(context.Background(), deps)
+ handler := IssueResourceHandler()
+
+ result, err := handler(ctx, &mcp.ReadResourceRequest{
+ Params: &mcp.ReadResourceParams{URI: "issue:///repo/issues/1.md"},
+ })
+ require.Error(t, err)
+ assert.Nil(t, result)
+ assert.Contains(t, err.Error(), "owner is required")
+ })
+
+ t.Run("missing repo", func(t *testing.T) {
+ client := github.NewClient(MockHTTPClientWithHandler(nil))
+ deps := BaseDeps{Client: client}
+ ctx := ContextWithDeps(context.Background(), deps)
+ handler := IssueResourceHandler()
+
+ result, err := handler(ctx, &mcp.ReadResourceRequest{
+ Params: &mcp.ReadResourceParams{URI: "issue://owner//issues/1.md"},
+ })
+ require.Error(t, err)
+ assert.Nil(t, result)
+ assert.Contains(t, err.Error(), "repo is required")
+ })
+
+ t.Run("missing issue number", func(t *testing.T) {
+ client := github.NewClient(MockHTTPClientWithHandler(nil))
+ deps := BaseDeps{Client: client}
+ ctx := ContextWithDeps(context.Background(), deps)
+ handler := IssueResourceHandler()
+
+ result, err := handler(ctx, &mcp.ReadResourceRequest{
+ Params: &mcp.ReadResourceParams{URI: "issue://owner/repo/issues/.md"},
+ })
+ require.Error(t, err)
+ assert.Nil(t, result)
+ assert.Contains(t, err.Error(), "issueNumber is required")
+ })
+
+ t.Run("invalid issue number", func(t *testing.T) {
+ client := github.NewClient(MockHTTPClientWithHandler(nil))
+ deps := BaseDeps{Client: client}
+ ctx := ContextWithDeps(context.Background(), deps)
+ handler := IssueResourceHandler()
+
+ result, err := handler(ctx, &mcp.ReadResourceRequest{
+ Params: &mcp.ReadResourceParams{URI: "issue://owner/repo/issues/abc.md"},
+ })
+ require.Error(t, err)
+ assert.Nil(t, result)
+ assert.Contains(t, err.Error(), "invalid issue number")
+ })
+
+ t.Run("issue with no comments and no images", func(t *testing.T) {
+ mockClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ GetReposIssuesByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, _ *http.Request) {
+ issue := &github.Issue{
+ Number: github.Ptr(42),
+ Title: github.Ptr("Test Issue"),
+ Body: github.Ptr("This is the issue body."),
+ State: github.Ptr("open"),
+ User: &github.User{Login: github.Ptr("testuser")},
+ Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("help wanted")}},
+ }
+ w.Header().Set("Content-Type", "application/json")
+ data, _ := json.Marshal(issue)
+ _, _ = w.Write(data)
+ },
+ GetReposIssuesCommentsByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte("[]"))
+ },
+ })
+
+ client := github.NewClient(mockClient)
+ deps := BaseDeps{Client: client}
+ ctx := ContextWithDeps(context.Background(), deps)
+ handler := IssueResourceHandler()
+
+ result, err := handler(ctx, &mcp.ReadResourceRequest{
+ Params: &mcp.ReadResourceParams{URI: "issue://owner/repo/issues/42.md"},
+ })
+ require.NoError(t, err)
+ require.Len(t, result.Contents, 1)
+
+ body := result.Contents[0]
+ assert.Equal(t, "issue://owner/repo/issues/42.md", body.URI)
+ assert.Equal(t, "text/markdown", body.MIMEType)
+ assert.Contains(t, body.Text, "title: \"Test Issue\"")
+ assert.Contains(t, body.Text, "state: open")
+ assert.Contains(t, body.Text, "author: testuser")
+ assert.Contains(t, body.Text, " - bug")
+ assert.Contains(t, body.Text, " - help wanted")
+ assert.Contains(t, body.Text, "This is the issue body.")
+ })
+
+ t.Run("issue with comments", func(t *testing.T) {
+ mockClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ GetReposIssuesByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, _ *http.Request) {
+ issue := &github.Issue{
+ Number: github.Ptr(7),
+ Title: github.Ptr("Feature request"),
+ Body: github.Ptr("Please add this feature."),
+ State: github.Ptr("open"),
+ User: &github.User{Login: github.Ptr("author")},
+ }
+ w.Header().Set("Content-Type", "application/json")
+ data, _ := json.Marshal(issue)
+ _, _ = w.Write(data)
+ },
+ GetReposIssuesCommentsByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, _ *http.Request) {
+ comments := []*github.IssueComment{
+ {
+ ID: github.Ptr(int64(101)),
+ Body: github.Ptr("I agree, this is needed."),
+ User: &github.User{Login: github.Ptr("commenter1")},
+ AuthorAssociation: github.Ptr("CONTRIBUTOR"),
+ },
+ {
+ ID: github.Ptr(int64(102)),
+ Body: github.Ptr("Working on it now."),
+ User: &github.User{Login: github.Ptr("maintainer")},
+ AuthorAssociation: github.Ptr("MEMBER"),
+ },
+ }
+ w.Header().Set("Content-Type", "application/json")
+ data, _ := json.Marshal(comments)
+ _, _ = w.Write(data)
+ },
+ })
+
+ client := github.NewClient(mockClient)
+ deps := BaseDeps{Client: client}
+ ctx := ContextWithDeps(context.Background(), deps)
+ handler := IssueResourceHandler()
+
+ result, err := handler(ctx, &mcp.ReadResourceRequest{
+ Params: &mcp.ReadResourceParams{URI: "issue://owner/repo/issues/7.md"},
+ })
+ require.NoError(t, err)
+ require.Len(t, result.Contents, 1)
+
+ content := result.Contents[0]
+ assert.Equal(t, "issue://owner/repo/issues/7.md", content.URI)
+
+ // Check body
+ assert.Contains(t, content.Text, "Please add this feature.")
+
+ // Check comment delimiters and metadata
+ assert.Contains(t, content.Text, "\n\n---\n\n")
+ assert.Contains(t, content.Text, "author: commenter1")
+ assert.Contains(t, content.Text, "author_association: CONTRIBUTOR")
+ assert.Contains(t, content.Text, "I agree, this is needed.")
+ assert.Contains(t, content.Text, "author: maintainer")
+ assert.Contains(t, content.Text, "Working on it now.")
+ })
+
+ t.Run("issue with markdown images preserved", func(t *testing.T) {
+ mockClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ GetReposIssuesByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, _ *http.Request) {
+ issue := &github.Issue{
+ Number: github.Ptr(10),
+ Title: github.Ptr("Bug with screenshot"),
+ Body: github.Ptr("See this:\n\nEnd."),
+ State: github.Ptr("closed"),
+ User: &github.User{Login: github.Ptr("reporter")},
+ }
+ w.Header().Set("Content-Type", "application/json")
+ data, _ := json.Marshal(issue)
+ _, _ = w.Write(data)
+ },
+ GetReposIssuesCommentsByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte("[]"))
+ },
+ })
+
+ client := github.NewClient(mockClient)
+ deps := BaseDeps{Client: client}
+ ctx := ContextWithDeps(context.Background(), deps)
+ handler := IssueResourceHandler()
+
+ result, err := handler(ctx, &mcp.ReadResourceRequest{
+ Params: &mcp.ReadResourceParams{URI: "issue://owner/repo/issues/10.md"},
+ })
+ require.NoError(t, err)
+ require.Len(t, result.Contents, 1)
+
+ // Markdown images should be kept as-is
+ assert.Contains(t, result.Contents[0].Text, "")
+ })
+
+ t.Run("issue with HTML img tags converted to markdown", func(t *testing.T) {
+ mockClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ GetReposIssuesByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, _ *http.Request) {
+ issue := &github.Issue{
+ Number: github.Ptr(11),
+ Title: github.Ptr("HTML images"),
+ Body: github.Ptr(`See:
`),
+ State: github.Ptr("open"),
+ User: &github.User{Login: github.Ptr("reporter")},
+ }
+ w.Header().Set("Content-Type", "application/json")
+ data, _ := json.Marshal(issue)
+ _, _ = w.Write(data)
+ },
+ GetReposIssuesCommentsByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte("[]"))
+ },
+ })
+
+ client := github.NewClient(mockClient)
+ deps := BaseDeps{Client: client}
+ ctx := ContextWithDeps(context.Background(), deps)
+ handler := IssueResourceHandler()
+
+ result, err := handler(ctx, &mcp.ReadResourceRequest{
+ Params: &mcp.ReadResourceParams{URI: "issue://owner/repo/issues/11.md"},
+ })
+ require.NoError(t, err)
+ require.Len(t, result.Contents, 1)
+
+ content := result.Contents[0]
+ // HTML img should be converted to markdown with original URL
+ assert.Contains(t, content.Text, "")
+ assert.NotContains(t, content.Text, "
`
+ expected := ""
+ assert.Equal(t, expected, convertHTMLImagesToMarkdown(body))
+ })
+
+ t.Run("html img without alt", func(t *testing.T) {
+ body := `
`
+ expected := ""
+ assert.Equal(t, expected, convertHTMLImagesToMarkdown(body))
+ })
+
+ t.Run("self-closing without space", func(t *testing.T) {
+ body := `
`
+ expected := ""
+ assert.Equal(t, expected, convertHTMLImagesToMarkdown(body))
+ })
+}
+
+func Test_buildIssueFrontmatter(t *testing.T) {
+ issue := &github.Issue{
+ Title: github.Ptr("My Issue"),
+ State: github.Ptr("open"),
+ User: &github.User{Login: github.Ptr("testuser")},
+ Labels: []*github.Label{
+ {Name: github.Ptr("bug")},
+ {Name: github.Ptr("priority")},
+ },
+ }
+
+ fm := buildIssueFrontmatter(issue)
+ assert.Contains(t, fm, "---\n")
+ assert.Contains(t, fm, `title: "My Issue"`)
+ assert.Contains(t, fm, "state: open")
+ assert.Contains(t, fm, "author: testuser")
+ assert.Contains(t, fm, " - bug")
+ assert.Contains(t, fm, " - priority")
+}
+
+func Test_buildCommentFrontmatter(t *testing.T) {
+ comment := &github.IssueComment{
+ User: &github.User{Login: github.Ptr("commenter")},
+ AuthorAssociation: github.Ptr("MEMBER"),
+ }
+
+ fm := buildCommentFrontmatter(comment)
+ assert.Contains(t, fm, "---\n")
+ assert.Contains(t, fm, "author: commenter")
+ assert.Contains(t, fm, "author_association: MEMBER")
+}
+
+func TestIssueResourceCompletionHandler(t *testing.T) {
+ t.Run("non-resource completion returns nil", func(t *testing.T) {
+ getClient := func(_ context.Context) (*github.Client, error) {
+ return &github.Client{}, nil
+ }
+ handler := IssueResourceCompletionHandler(getClient)
+ result, err := handler(t.Context(), &mcp.CompleteRequest{
+ Params: &mcp.CompleteParams{
+ Ref: &mcp.CompleteReference{Type: "something-else"},
+ },
+ })
+ require.NoError(t, err)
+ assert.Nil(t, result)
+ })
+
+ t.Run("unknown argument returns error", func(t *testing.T) {
+ getClient := func(_ context.Context) (*github.Client, error) {
+ return &github.Client{}, nil
+ }
+ handler := IssueResourceCompletionHandler(getClient)
+ _, err := handler(t.Context(), &mcp.CompleteRequest{
+ Params: &mcp.CompleteParams{
+ Ref: &mcp.CompleteReference{Type: "ref/resource"},
+ Context: &mcp.CompleteContext{},
+ Argument: mcp.CompleteParamsArgument{
+ Name: "unknown_arg",
+ Value: "test",
+ },
+ },
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "no resolver for argument")
+ })
+
+ t.Run("issueNumber completion", func(t *testing.T) {
+ mockClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "GET /search/issues": func(w http.ResponseWriter, _ *http.Request) {
+ result := &github.IssuesSearchResult{
+ Issues: []*github.Issue{
+ {Number: github.Ptr(42)},
+ {Number: github.Ptr(43)},
+ {Number: github.Ptr(100)},
+ },
+ }
+ w.Header().Set("Content-Type", "application/json")
+ data, _ := json.Marshal(result)
+ _, _ = w.Write(data)
+ },
+ })
+ client := github.NewClient(mockClient)
+ getClient := func(_ context.Context) (*github.Client, error) { return client, nil }
+ handler := IssueResourceCompletionHandler(getClient)
+
+ result, err := handler(t.Context(), &mcp.CompleteRequest{
+ Params: &mcp.CompleteParams{
+ Ref: &mcp.CompleteReference{Type: "ref/resource"},
+ Context: &mcp.CompleteContext{
+ Arguments: map[string]string{"owner": "testowner", "repo": "testrepo"},
+ },
+ Argument: mcp.CompleteParamsArgument{
+ Name: "issueNumber",
+ Value: "4",
+ },
+ },
+ })
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ assert.Contains(t, result.Completion.Values, "42")
+ assert.Contains(t, result.Completion.Values, "43")
+ })
+
+ t.Run("issueNumber requires owner and repo", func(t *testing.T) {
+ getClient := func(_ context.Context) (*github.Client, error) {
+ return github.NewClient(nil), nil
+ }
+ handler := IssueResourceCompletionHandler(getClient)
+ _, err := handler(t.Context(), &mcp.CompleteRequest{
+ Params: &mcp.CompleteParams{
+ Ref: &mcp.CompleteReference{Type: "ref/resource"},
+ Context: &mcp.CompleteContext{},
+ Argument: mcp.CompleteParamsArgument{
+ Name: "issueNumber",
+ Value: "",
+ },
+ },
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "owner or repo not specified")
+ })
+}
+
+func TestCompletionsHandler_IssueResource(t *testing.T) {
+ t.Run("routes issue:// to issue completion handler", func(t *testing.T) {
+ mockClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ "GET /search/issues": func(w http.ResponseWriter, _ *http.Request) {
+ result := &github.IssuesSearchResult{
+ Issues: []*github.Issue{
+ {Number: github.Ptr(1)},
+ },
+ }
+ w.Header().Set("Content-Type", "application/json")
+ data, _ := json.Marshal(result)
+ _, _ = w.Write(data)
+ },
+ })
+ client := github.NewClient(mockClient)
+ getClient := func(_ context.Context) (*github.Client, error) { return client, nil }
+
+ handler := CompletionsHandler(getClient)
+ result, err := handler(t.Context(), &mcp.CompleteRequest{
+ Params: &mcp.CompleteParams{
+ Ref: &mcp.CompleteReference{
+ Type: "ref/resource",
+ URI: "issue://{owner}/{repo}/issues/{issueNumber}.md",
+ },
+ Context: &mcp.CompleteContext{
+ Arguments: map[string]string{"owner": "o", "repo": "r"},
+ },
+ Argument: mcp.CompleteParamsArgument{
+ Name: "issueNumber",
+ Value: "",
+ },
+ },
+ })
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ assert.Equal(t, 1, result.Completion.Total)
+ })
+}
diff --git a/pkg/github/resources.go b/pkg/github/resources.go
index 2db7cac55..fbfeffe8a 100644
--- a/pkg/github/resources.go
+++ b/pkg/github/resources.go
@@ -15,5 +15,8 @@ func AllResources(t translations.TranslationHelperFunc) []inventory.ServerResour
GetRepositoryResourceCommitContent(t),
GetRepositoryResourceTagContent(t),
GetRepositoryResourcePrContent(t),
+
+ // Issue resources
+ GetIssueResourceContent(t),
}
}
diff --git a/pkg/github/server.go b/pkg/github/server.go
index 9a602e153..568851f12 100644
--- a/pkg/github/server.go
+++ b/pkg/github/server.go
@@ -190,10 +190,14 @@ func CompletionsHandler(getClient GetClientFn) func(ctx context.Context, req *mc
return func(ctx context.Context, req *mcp.CompleteRequest) (*mcp.CompleteResult, error) {
switch req.Params.Ref.Type {
case "ref/resource":
- if strings.HasPrefix(req.Params.Ref.URI, "repo://") {
+ switch {
+ case strings.HasPrefix(req.Params.Ref.URI, "repo://"):
return RepositoryResourceCompletionHandler(getClient)(ctx, req)
+ case strings.HasPrefix(req.Params.Ref.URI, "issue://"):
+ return IssueResourceCompletionHandler(getClient)(ctx, req)
+ default:
+ return nil, fmt.Errorf("unsupported resource URI: %s", req.Params.Ref.URI)
}
- return nil, fmt.Errorf("unsupported resource URI: %s", req.Params.Ref.URI)
case "ref/prompt":
return nil, nil
default: