From 76df58ca3e159a75a39a4d1569f5de7a51a03ca5 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Tue, 17 Feb 2026 20:29:31 +0100 Subject: [PATCH] feat(resources): return issue resource as single markdown document Consolidate the issue resource into a single ResourceContents item containing the issue body, frontmatter metadata, and all comments separated by --- delimiters. - Return one markdown file instead of multiple content items - Use .md extension in URI template for editor preview support - Convert HTML tags to markdown image syntax - Keep original image URLs (private images won't render in preview) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/github/issue_resource.go | 279 +++++++++++++++++++ pkg/github/issue_resource_test.go | 439 ++++++++++++++++++++++++++++++ pkg/github/resources.go | 3 + pkg/github/server.go | 8 +- 4 files changed, 727 insertions(+), 2 deletions(-) create mode 100644 pkg/github/issue_resource.go create mode 100644 pkg/github/issue_resource_test.go 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("![%s](%s)", 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![screenshot](https://user-images.githubusercontent.com/123/image.png)\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, "![screenshot](https://user-images.githubusercontent.com/123/image.png)") + }) + + 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: Image`), + 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, "![Image](https://user-images.githubusercontent.com/123/screenshot.png)") + assert.NotContains(t, content.Text, "` + expected := "![Screenshot](https://example.com/img.png)" + assert.Equal(t, expected, convertHTMLImagesToMarkdown(body)) + }) + + t.Run("html img without alt", func(t *testing.T) { + body := `` + expected := "![](https://example.com/img.png)" + assert.Equal(t, expected, convertHTMLImagesToMarkdown(body)) + }) + + t.Run("self-closing without space", func(t *testing.T) { + body := `` + expected := "![](https://example.com/img.png)" + 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: