Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -857,7 +857,7 @@ The following sets of tools are available:
- `issue_number`: The number of the issue (number, required)
- `method`: The read operation to perform on a single issue.
Options are:
1. get - Get details of a specific issue.
1. get - Get issue details. Also returns best-effort hierarchy signals: `has_parent` and `has_children`; includes `parent` only when a parent is returned, and `sub_issues_summary` only when child count is nonzero. To read full hierarchy use `get_parent` / `get_sub_issues`. To change hierarchy use `sub_issue_write`; there is no writable parent field.
2. get_comments - Get issue comments.
3. get_sub_issues - Get sub-issues (children) of the issue.
4. get_parent - Get the parent issue, if this issue is a sub-issue of another.
Expand Down Expand Up @@ -934,7 +934,8 @@ The following sets of tools are available:
- 'add' - add a sub-issue to a parent issue in a GitHub repository.
- 'remove' - remove a sub-issue from a parent issue in a GitHub repository.
- 'reprioritize' - change the order of sub-issues within a parent issue in a GitHub repository. Use either 'after_id' or 'before_id' to specify the new position.
(string, required)
Writes issue hierarchy. To move a sub-issue to a new parent, use `add` with `replace_parent=true`. Read hierarchy via `issue_read get`, `get_parent`, or `get_sub_issues`; there is no writable parent field.
(string, required)
- `owner`: Repository owner (string, required)
- `replace_parent`: When true, replaces the sub-issue's current parent issue. Use with 'add' method only. (boolean, optional)
- `repo`: Repository name (string, required)
Expand Down
2 changes: 1 addition & 1 deletion pkg/github/__toolsnaps__/issue_read.snap
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"type": "number"
},
"method": {
"description": "The read operation to perform on a single issue.\nOptions are:\n1. get - Get details of a specific issue.\n2. get_comments - Get issue comments.\n3. get_sub_issues - Get sub-issues (children) of the issue.\n4. get_parent - Get the parent issue, if this issue is a sub-issue of another.\n5. get_labels - Get labels assigned to the issue.\n",
"description": "The read operation to perform on a single issue.\nOptions are:\n1. get - Get issue details. Also returns best-effort hierarchy signals: `has_parent` and `has_children`; includes `parent` only when a parent is returned, and `sub_issues_summary` only when child count is nonzero. To read full hierarchy use `get_parent` / `get_sub_issues`. To change hierarchy use `sub_issue_write`; there is no writable parent field.\n2. get_comments - Get issue comments.\n3. get_sub_issues - Get sub-issues (children) of the issue.\n4. get_parent - Get the parent issue, if this issue is a sub-issue of another.\n5. get_labels - Get labels assigned to the issue.\n",
"enum": [
"get",
"get_comments",
Expand Down
2 changes: 1 addition & 1 deletion pkg/github/__toolsnaps__/sub_issue_write.snap
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"type": "number"
},
"method": {
"description": "The action to perform on a single sub-issue\nOptions are:\n- 'add' - add a sub-issue to a parent issue in a GitHub repository.\n- 'remove' - remove a sub-issue from a parent issue in a GitHub repository.\n- 'reprioritize' - change the order of sub-issues within a parent issue in a GitHub repository. Use either 'after_id' or 'before_id' to specify the new position.\n\t\t\t\t",
"description": "The action to perform on a single sub-issue\nOptions are:\n- 'add' - add a sub-issue to a parent issue in a GitHub repository.\n- 'remove' - remove a sub-issue from a parent issue in a GitHub repository.\n- 'reprioritize' - change the order of sub-issues within a parent issue in a GitHub repository. Use either 'after_id' or 'before_id' to specify the new position.\nWrites issue hierarchy. To move a sub-issue to a new parent, use `add` with `replace_parent=true`. Read hierarchy via `issue_read get`, `get_parent`, or `get_sub_issues`; there is no writable parent field.\n",
"type": "string"
},
"owner": {
Expand Down
176 changes: 159 additions & 17 deletions pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/ifc"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/lockdown"
"github.com/github/github-mcp-server/pkg/sanitize"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/translations"
Expand Down Expand Up @@ -612,14 +613,13 @@ func IssueRead(t translations.TranslationHelperFunc) inventory.ServerTool {
Properties: map[string]*jsonschema.Schema{
"method": {
Type: "string",
Description: `The read operation to perform on a single issue.
Options are:
1. get - Get details of a specific issue.
2. get_comments - Get issue comments.
3. get_sub_issues - Get sub-issues (children) of the issue.
4. get_parent - Get the parent issue, if this issue is a sub-issue of another.
5. get_labels - Get labels assigned to the issue.
`,
Description: "The read operation to perform on a single issue.\n" +
"Options are:\n" +
"1. get - Get issue details. Also returns best-effort hierarchy signals: `has_parent` and `has_children`; includes `parent` only when a parent is returned, and `sub_issues_summary` only when child count is nonzero. To read full hierarchy use `get_parent` / `get_sub_issues`. To change hierarchy use `sub_issue_write`; there is no writable parent field.\n" +
"2. get_comments - Get issue comments.\n" +
"3. get_sub_issues - Get sub-issues (children) of the issue.\n" +
"4. get_parent - Get the parent issue, if this issue is a sub-issue of another.\n" +
"5. get_labels - Get labels assigned to the issue.\n",
Enum: []any{"get", "get_comments", "get_sub_issues", "get_parent", "get_labels"},
},
"owner": {
Expand Down Expand Up @@ -762,20 +762,70 @@ func GetIssue(ctx context.Context, client *github.Client, deps ToolDependencies,
minimalIssue := convertToMinimalIssue(issue)

// Always drop the verbose REST IssueFieldValues; enrich with the GraphQL
// field_values view instead.
// field_values view and the hierarchy relationship signals instead. The
// enrichment is best-effort: a failure here must never fail `get`.
minimalIssue.IssueFieldValues = nil
if issue != nil && issue.NodeID != nil && *issue.NodeID != "" {
gqlClient, err := deps.GetGQLClient(ctx)
if err == nil {
if fieldValuesByID, err := fetchIssueFieldValuesByNodeID(ctx, gqlClient, []*github.Issue{issue}); err == nil {
minimalIssue.FieldValues = fieldValuesByID[*issue.NodeID]
if enrichment, err := fetchIssueReadEnrichment(ctx, gqlClient, *issue.NodeID); err == nil {
applyIssueReadEnrichment(ctx, &minimalIssue, enrichment, cache, flags.LockdownMode)
}
}
}

return MarshalledTextResult(minimalIssue), nil
}

// lockdownRedactedTitle replaces a related issue's title when lockdown mode is enabled and the
// related content cannot be verified as safe. Numeric/structural fields stay intact.
const lockdownRedactedTitle = "[redacted - not from a trusted source]"

// applyIssueReadEnrichment populates the hierarchy relationship signals (has_parent/has_children,
// parent, sub_issues_summary) and field_values onto the minimal issue. In lockdown mode the parent
// title is redacted unless the parent content can be verified as safe; counts and booleans are pure
// numbers and are always safe to surface.
func applyIssueReadEnrichment(ctx context.Context, minimalIssue *MinimalIssue, enrichment *issueReadEnrichment, cache *lockdown.RepoAccessCache, lockdownMode bool) {
if enrichment == nil {
return
}

minimalIssue.FieldValues = enrichment.FieldValues

if parent := enrichment.Parent; parent != nil {
ref := parent.Ref
if lockdownMode && !isSafeParentContent(ctx, cache, parent) {
ref.Title = lockdownRedactedTitle
}
minimalIssue.Parent = &ref
minimalIssue.HasParent = true
}

if enrichment.SubIssuesSummary.Total > 0 {
summary := enrichment.SubIssuesSummary
minimalIssue.SubIssuesSummary = &summary
minimalIssue.HasChildren = true
}
}

// isSafeParentContent reports whether the parent issue's title can be exposed under lockdown mode.
// It fails closed: any inability to positively verify safe content (missing cache, missing author,
// unparseable repository, or a lookup error) results in redaction.
func isSafeParentContent(ctx context.Context, cache *lockdown.RepoAccessCache, parent *issueReadParent) bool {
if cache == nil || parent.AuthorLogin == "" {
return false
}
owner, repo, ok := strings.Cut(parent.Ref.Repository, "/")
if !ok || owner == "" || repo == "" {
return false
}
safe, err := cache.IsSafeContent(ctx, parent.AuthorLogin, owner, repo)
if err != nil {
return false
}
return safe
}

func GetIssueComments(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) {
cache, err := deps.GetRepoAccessCache(ctx)
if err != nil {
Expand Down Expand Up @@ -1206,12 +1256,12 @@ func SubIssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
Properties: map[string]*jsonschema.Schema{
"method": {
Type: "string",
Description: `The action to perform on a single sub-issue
Options are:
- 'add' - add a sub-issue to a parent issue in a GitHub repository.
- 'remove' - remove a sub-issue from a parent issue in a GitHub repository.
- 'reprioritize' - change the order of sub-issues within a parent issue in a GitHub repository. Use either 'after_id' or 'before_id' to specify the new position.
`,
Description: "The action to perform on a single sub-issue\n" +
"Options are:\n" +
"- 'add' - add a sub-issue to a parent issue in a GitHub repository.\n" +
"- 'remove' - remove a sub-issue from a parent issue in a GitHub repository.\n" +
"- 'reprioritize' - change the order of sub-issues within a parent issue in a GitHub repository. Use either 'after_id' or 'before_id' to specify the new position.\n" +
"Writes issue hierarchy. To move a sub-issue to a new parent, use `add` with `replace_parent=true`. Read hierarchy via `issue_read get`, `get_parent`, or `get_sub_issues`; there is no writable parent field.\n",
},
"owner": {
Type: "string",
Expand Down Expand Up @@ -1649,6 +1699,98 @@ func fetchIssueFieldValuesByNodeID(ctx context.Context, gqlClient *githubv4.Clie
return result, nil
}

// issueReadEnrichmentQuery fetches, in a single GraphQL round-trip, the custom field values,
// parent reference, and sub-issue summary counts for the issues identified by their node IDs.
// It powers the issue_read `get` relationship signals without adding extra round-trips.
type issueReadEnrichmentQuery struct {
Nodes []struct {
Issue struct {
ID githubv4.ID
IssueFieldValues struct {
Nodes []IssueFieldValueFragment
} `graphql:"issueFieldValues(first: 25)"`
Parent *struct {
Number githubv4.Int
Title githubv4.String
State githubv4.String
URL githubv4.String
Author struct {
Login githubv4.String
}
Repository struct {
NameWithOwner githubv4.String
}
}
SubIssuesSummary struct {
Total githubv4.Int
Completed githubv4.Int
PercentCompleted githubv4.Int
}
} `graphql:"... on Issue"`
} `graphql:"nodes(ids: $ids)"`
}

// issueReadParent is the parent reference plus the metadata needed to make a lockdown
// safe-content decision about whether the (possibly cross-repo) parent title may be exposed.
type issueReadParent struct {
Ref MinimalIssueRef
AuthorLogin string
}

// issueReadEnrichment is the flattened result of the issue_read `get` enrichment query.
type issueReadEnrichment struct {
FieldValues []MinimalFieldValue
Parent *issueReadParent
SubIssuesSummary MinimalSubIssuesSummary
}

// fetchIssueReadEnrichment runs one GraphQL nodes() query for the given issue node ID and returns
// its field values, parent reference, and sub-issue summary counts. The parent title is sanitized
// here because it may originate from a different repository.
func fetchIssueReadEnrichment(ctx context.Context, gqlClient *githubv4.Client, nodeID string) (*issueReadEnrichment, error) {
var q issueReadEnrichmentQuery
if err := gqlClient.Query(ctx, &q, map[string]any{"ids": []githubv4.ID{githubv4.ID(nodeID)}}); err != nil {
return nil, err
}

enrichment := &issueReadEnrichment{}
for _, n := range q.Nodes {
idStr, ok := n.Issue.ID.(string)
if !ok || idStr != nodeID {
continue
}

vals := make([]MinimalFieldValue, 0, len(n.Issue.IssueFieldValues.Nodes))
for _, fv := range n.Issue.IssueFieldValues.Nodes {
if m, ok := fragmentToMinimalFieldValue(fv); ok {
vals = append(vals, m)
}
}
enrichment.FieldValues = vals

if p := n.Issue.Parent; p != nil {
enrichment.Parent = &issueReadParent{
Ref: MinimalIssueRef{
Number: int(p.Number),
Title: sanitize.Sanitize(string(p.Title)),
State: string(p.State),
URL: string(p.URL),
Repository: string(p.Repository.NameWithOwner),
},
AuthorLogin: string(p.Author.Login),
}
}

enrichment.SubIssuesSummary = MinimalSubIssuesSummary{
Total: int(n.Issue.SubIssuesSummary.Total),
Completed: int(n.Issue.SubIssuesSummary.Completed),
PercentCompleted: int(n.Issue.SubIssuesSummary.PercentCompleted),
}
break
}
return enrichment, nil
}

// searchIssuesHandler runs the REST issues search, enriches each hit with custom field values
// fetched via a single follow-up GraphQL nodes() query, and applies any post-process options
// (e.g. IFC labelling).
Expand Down
Loading
Loading