Skip to content

Commit 76df58c

Browse files
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 <img> 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>
1 parent 543a1fa commit 76df58c

File tree

4 files changed

+727
-2
lines changed

4 files changed

+727
-2
lines changed

pkg/github/issue_resource.go

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"regexp"
7+
"strconv"
8+
"strings"
9+
10+
"github.com/github/github-mcp-server/pkg/inventory"
11+
"github.com/github/github-mcp-server/pkg/octicons"
12+
"github.com/github/github-mcp-server/pkg/sanitize"
13+
"github.com/github/github-mcp-server/pkg/translations"
14+
"github.com/google/go-github/v82/github"
15+
"github.com/modelcontextprotocol/go-sdk/mcp"
16+
"github.com/yosida95/uritemplate/v3"
17+
)
18+
19+
var issueResourceURITemplate = uritemplate.MustNew("issue://{owner}/{repo}/issues/{issueNumber}.md")
20+
21+
// htmlImagePattern matches HTML img tags: <img ... src="url" ... />
22+
var htmlImagePattern = regexp.MustCompile(`<img\b([^>]*)\bsrc=["']([^"']+)["']([^>]*)/??>`)
23+
24+
// htmlAltPattern extracts the alt attribute value from an img tag fragment.
25+
var htmlAltPattern = regexp.MustCompile(`\balt=["']([^"']*)["']`)
26+
27+
// GetIssueResourceContent defines the resource template for reading issue content.
28+
func GetIssueResourceContent(t translations.TranslationHelperFunc) inventory.ServerResourceTemplate {
29+
return inventory.NewServerResourceTemplate(
30+
ToolsetMetadataIssues,
31+
mcp.ResourceTemplate{
32+
Name: "issue_content",
33+
URITemplate: issueResourceURITemplate.Raw(),
34+
Description: t("RESOURCE_ISSUE_CONTENT_DESCRIPTION", "Issue content with comments and embedded images as a single markdown document"),
35+
Icons: octicons.Icons("issue-opened"),
36+
},
37+
issueResourceHandlerFunc(),
38+
)
39+
}
40+
41+
func issueResourceHandlerFunc() inventory.ResourceHandlerFunc {
42+
return func(_ any) mcp.ResourceHandler {
43+
return IssueResourceHandler()
44+
}
45+
}
46+
47+
// IssueResourceHandler returns a handler for issue resource requests.
48+
func IssueResourceHandler() mcp.ResourceHandler {
49+
return func(ctx context.Context, request *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
50+
deps := MustDepsFromContext(ctx)
51+
52+
uriValues := issueResourceURITemplate.Match(request.Params.URI)
53+
if uriValues == nil {
54+
return nil, fmt.Errorf("failed to match URI: %s", request.Params.URI)
55+
}
56+
57+
owner := uriValues.Get("owner").String()
58+
repo := uriValues.Get("repo").String()
59+
issueNumberStr := uriValues.Get("issueNumber").String()
60+
61+
if owner == "" {
62+
return nil, fmt.Errorf("owner is required")
63+
}
64+
if repo == "" {
65+
return nil, fmt.Errorf("repo is required")
66+
}
67+
if issueNumberStr == "" {
68+
return nil, fmt.Errorf("issueNumber is required")
69+
}
70+
71+
issueNumber, err := strconv.Atoi(issueNumberStr)
72+
if err != nil {
73+
return nil, fmt.Errorf("invalid issue number: %w", err)
74+
}
75+
76+
client, err := deps.GetClient(ctx)
77+
if err != nil {
78+
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
79+
}
80+
81+
// Fetch the issue
82+
issue, _, err := client.Issues.Get(ctx, owner, repo, issueNumber)
83+
if err != nil {
84+
return nil, fmt.Errorf("failed to get issue: %w", err)
85+
}
86+
87+
// Build unified markdown document
88+
var doc strings.Builder
89+
90+
// Issue body with frontmatter
91+
body := sanitize.Sanitize(issue.GetBody())
92+
frontmatter := buildIssueFrontmatter(issue)
93+
doc.WriteString(frontmatter)
94+
doc.WriteString(convertHTMLImagesToMarkdown(body))
95+
96+
// Fetch all comments
97+
comments, err := fetchAllIssueComments(ctx, client, owner, repo, issueNumber)
98+
if err != nil {
99+
return nil, fmt.Errorf("failed to get issue comments: %w", err)
100+
}
101+
102+
for _, comment := range comments {
103+
doc.WriteString("\n\n---\n\n")
104+
commentBody := sanitize.Sanitize(comment.GetBody())
105+
commentFrontmatter := buildCommentFrontmatter(comment)
106+
doc.WriteString(commentFrontmatter)
107+
doc.WriteString(convertHTMLImagesToMarkdown(commentBody))
108+
}
109+
110+
resourceURI := fmt.Sprintf("issue://%s/%s/issues/%d.md", owner, repo, issueNumber)
111+
contents := []*mcp.ResourceContents{
112+
{
113+
URI: resourceURI,
114+
MIMEType: "text/markdown",
115+
Text: doc.String(),
116+
},
117+
}
118+
119+
return &mcp.ReadResourceResult{Contents: contents}, nil
120+
}
121+
}
122+
123+
func buildIssueFrontmatter(issue *github.Issue) string {
124+
var b strings.Builder
125+
b.WriteString("---\n")
126+
b.WriteString(fmt.Sprintf("title: %q\n", sanitize.Sanitize(issue.GetTitle())))
127+
b.WriteString(fmt.Sprintf("state: %s\n", issue.GetState()))
128+
if user := issue.GetUser(); user != nil {
129+
b.WriteString(fmt.Sprintf("author: %s\n", user.GetLogin()))
130+
}
131+
if issue.CreatedAt != nil {
132+
b.WriteString(fmt.Sprintf("created_at: %s\n", issue.CreatedAt.Format("2006-01-02T15:04:05Z")))
133+
}
134+
if len(issue.Labels) > 0 {
135+
b.WriteString("labels:\n")
136+
for _, label := range issue.Labels {
137+
if label != nil {
138+
b.WriteString(fmt.Sprintf(" - %s\n", label.GetName()))
139+
}
140+
}
141+
}
142+
if issue.GetMilestone() != nil {
143+
b.WriteString(fmt.Sprintf("milestone: %s\n", issue.GetMilestone().GetTitle()))
144+
}
145+
b.WriteString("---\n\n")
146+
return b.String()
147+
}
148+
149+
func buildCommentFrontmatter(comment *github.IssueComment) string {
150+
var b strings.Builder
151+
b.WriteString("---\n")
152+
if user := comment.GetUser(); user != nil {
153+
b.WriteString(fmt.Sprintf("author: %s\n", user.GetLogin()))
154+
}
155+
b.WriteString(fmt.Sprintf("author_association: %s\n", comment.GetAuthorAssociation()))
156+
if comment.CreatedAt != nil {
157+
b.WriteString(fmt.Sprintf("created_at: %s\n", comment.CreatedAt.Format("2006-01-02T15:04:05Z")))
158+
}
159+
if comment.UpdatedAt != nil {
160+
b.WriteString(fmt.Sprintf("updated_at: %s\n", comment.UpdatedAt.Format("2006-01-02T15:04:05Z")))
161+
}
162+
b.WriteString("---\n\n")
163+
return b.String()
164+
}
165+
166+
// convertHTMLImagesToMarkdown converts HTML <img> tags to markdown image syntax.
167+
func convertHTMLImagesToMarkdown(body string) string {
168+
return htmlImagePattern.ReplaceAllStringFunc(body, func(match string) string {
169+
submatches := htmlImagePattern.FindStringSubmatch(match)
170+
if len(submatches) < 3 {
171+
return match
172+
}
173+
imageURL := submatches[2]
174+
alt := ""
175+
attrs := submatches[1] + submatches[3]
176+
if altMatch := htmlAltPattern.FindStringSubmatch(attrs); len(altMatch) >= 2 {
177+
alt = altMatch[1]
178+
}
179+
return fmt.Sprintf("![%s](%s)", alt, imageURL)
180+
})
181+
}
182+
183+
func fetchAllIssueComments(ctx context.Context, client *github.Client, owner, repo string, issueNumber int) ([]*github.IssueComment, error) {
184+
var allComments []*github.IssueComment
185+
opts := &github.IssueListCommentsOptions{
186+
ListOptions: github.ListOptions{PerPage: 100},
187+
}
188+
for {
189+
comments, resp, err := client.Issues.ListComments(ctx, owner, repo, issueNumber, opts)
190+
if err != nil {
191+
return nil, err
192+
}
193+
_ = resp.Body.Close()
194+
allComments = append(allComments, comments...)
195+
if resp.NextPage == 0 {
196+
break
197+
}
198+
opts.Page = resp.NextPage
199+
}
200+
return allComments, nil
201+
}
202+
203+
// IssueResourceCompletionHandler returns a completion handler for issue resource URI templates.
204+
func IssueResourceCompletionHandler(getClient GetClientFn) func(ctx context.Context, req *mcp.CompleteRequest) (*mcp.CompleteResult, error) {
205+
return func(ctx context.Context, req *mcp.CompleteRequest) (*mcp.CompleteResult, error) {
206+
if req.Params.Ref.Type != "ref/resource" {
207+
return nil, nil
208+
}
209+
210+
argName := req.Params.Argument.Name
211+
argValue := req.Params.Argument.Value
212+
var resolved map[string]string
213+
if req.Params.Context != nil && req.Params.Context.Arguments != nil {
214+
resolved = req.Params.Context.Arguments
215+
} else {
216+
resolved = map[string]string{}
217+
}
218+
219+
client, err := getClient(ctx)
220+
if err != nil {
221+
return nil, err
222+
}
223+
224+
// Reuse owner and repo resolvers from repository resource completions
225+
resolvers := map[string]CompleteHandler{
226+
"owner": completeOwner,
227+
"repo": completeRepo,
228+
"issueNumber": completeIssueNumber,
229+
}
230+
231+
resolver, ok := resolvers[argName]
232+
if !ok {
233+
return nil, fmt.Errorf("no resolver for argument: %s", argName)
234+
}
235+
236+
values, err := resolver(ctx, client, resolved, argValue)
237+
if err != nil {
238+
return nil, err
239+
}
240+
if len(values) > 100 {
241+
values = values[:100]
242+
}
243+
244+
return &mcp.CompleteResult{
245+
Completion: mcp.CompletionResultDetails{
246+
Values: values,
247+
Total: len(values),
248+
HasMore: false,
249+
},
250+
}, nil
251+
}
252+
}
253+
254+
func completeIssueNumber(ctx context.Context, client *github.Client, resolved map[string]string, argValue string) ([]string, error) {
255+
owner := resolved["owner"]
256+
repo := resolved["repo"]
257+
if owner == "" || repo == "" {
258+
return nil, fmt.Errorf("owner or repo not specified")
259+
}
260+
261+
issues, _, err := client.Search.Issues(ctx, fmt.Sprintf("repo:%s/%s is:issue", owner, repo), &github.SearchOptions{
262+
ListOptions: github.ListOptions{PerPage: 100},
263+
})
264+
if err != nil {
265+
return nil, err
266+
}
267+
268+
var values []string
269+
for _, issue := range issues.Issues {
270+
num := fmt.Sprintf("%d", issue.GetNumber())
271+
if argValue == "" || strings.HasPrefix(num, argValue) {
272+
values = append(values, num)
273+
}
274+
}
275+
if len(values) > 100 {
276+
values = values[:100]
277+
}
278+
return values, nil
279+
}

0 commit comments

Comments
 (0)