šŸš€ Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more →
Sign In

mirror-github.paniser.workers.dev/github/github-mcp-server

Package Overview
Dependencies
Versions
77
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

mirror-github.paniser.workers.dev/github/github-mcp-server - go Package Compare versions

Comparing version
v1.3.0
to
v1.4.0
pkg/github/__toolsnaps__/get_code_quality_finding.snap

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

+155
package github
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/google/go-github/v87/github"
"github.com/google/jsonschema-go/jsonschema"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/github/github-mcp-server/internal/toolsnaps"
"github.com/github/github-mcp-server/pkg/translations"
)
func Test_GetCodeQualityFinding(t *testing.T) {
// Verify tool definition once
toolDef := GetCodeQualityFinding(translations.NullTranslationHelper)
require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool))
assert.Equal(t, "get_code_quality_finding", toolDef.Tool.Name)
assert.NotEmpty(t, toolDef.Tool.Description)
// InputSchema is of type any, need to cast to *jsonschema.Schema
schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema)
require.True(t, ok, "InputSchema should be *jsonschema.Schema")
assert.Contains(t, schema.Properties, "owner")
assert.Contains(t, schema.Properties, "repo")
assert.Contains(t, schema.Properties, "findingNumber")
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "findingNumber"})
type codeQualityRule struct {
ID *string `json:"id,omitempty"`
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
Help *string `json:"help,omitempty"`
Severity *string `json:"severity,omitempty"`
Category *string `json:"category,omitempty"`
}
type codeQualityLocation struct {
Path *string `json:"path,omitempty"`
StartLine *int `json:"start_line,omitempty"`
StartColumn *int `json:"start_column,omitempty"`
EndLine *int `json:"end_line,omitempty"`
EndColumn *int `json:"end_column,omitempty"`
}
type codeQualityMessage struct {
Text string `json:"text"`
Markdown string `json:"markdown"`
}
type codeQualityFinding struct {
Number *int `json:"number,omitempty"`
State *string `json:"state,omitempty"`
URL *string `json:"url,omitempty"`
Rule *codeQualityRule `json:"rule,omitempty"`
Location *codeQualityLocation `json:"location,omitempty"`
Message *codeQualityMessage `json:"message,omitempty"`
CreatedAt *github.Timestamp `json:"created_at,omitempty"`
}
// Setup mock finding for success case
mockFinding := &codeQualityFinding{
Number: github.Ptr(42),
State: github.Ptr("open"),
Rule: &codeQualityRule{
ID: github.Ptr("test-rule"),
Description: github.Ptr("Test Rule Description"),
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectedFinding *codeQualityFinding
expectedErrMsg string
}{
{
name: "successful finding fetch",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposCodeQualityFindingsByOwnerByRepoByFindingNumber: mockResponse(t, http.StatusOK, mockFinding),
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"findingNumber": float64(42),
},
expectError: false,
expectedFinding: mockFinding,
},
{
name: "finding fetch fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposCodeQualityFindingsByOwnerByRepoByFindingNumber: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
},
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"findingNumber": float64(9999),
},
expectError: true,
expectedErrMsg: "failed to get finding",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := mustNewGHClient(t, tc.mockedClient)
deps := BaseDeps{
Client: client,
}
handler := toolDef.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler with new signature
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
require.NoError(t, err)
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedFinding codeQualityFinding
err = json.Unmarshal([]byte(textContent.Text), &returnedFinding)
assert.NoError(t, err)
assert.Equal(t, *tc.expectedFinding.Number, *returnedFinding.Number)
assert.Equal(t, *tc.expectedFinding.State, *returnedFinding.State)
assert.Equal(t, *tc.expectedFinding.Rule.ID, *returnedFinding.Rule.ID)
})
}
}
package github
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/github/github-mcp-server/pkg/utils"
)
func GetCodeQualityFinding(t translations.TranslationHelperFunc) inventory.ServerTool {
return NewTool(
ToolsetMetadataCodeQuality,
mcp.Tool{
Name: "get_code_quality_finding",
Description: t("TOOL_GET_CODE_QUALITY_FINDING_DESCRIPTION", "Get details of a specific code quality finding in a GitHub repository."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_GET_CODE_QUALITY_FINDING_USER_TITLE", "Get code quality finding"),
ReadOnlyHint: true,
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {
Type: "string",
Description: "The owner of the repository.",
},
"repo": {
Type: "string",
Description: "The name of the repository.",
},
"findingNumber": {
Type: "number",
Description: "The number of the finding.",
},
},
Required: []string{"owner", "repo", "findingNumber"},
},
},
[]scopes.Scope{scopes.Repo},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
findingNumber, err := RequiredInt(args, "findingNumber")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
apiURL := fmt.Sprintf("repos/%s/%s/code-quality/findings/%d", owner, repo, findingNumber)
req, err := client.NewRequest(ctx, http.MethodGet, apiURL, nil)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil
}
finding := make(map[string]any)
resp, err := client.Do(req, &finding)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get finding", resp, err), nil, nil
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil
}
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get finding", resp, body), nil, nil
}
r, err := json.Marshal(finding)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal finding", err), nil, nil
}
return utils.NewToolResultText(string(r)), nil, nil
},
)
}
package github
import (
"context"
"encoding/json"
"net/http"
"testing"
"time"
"github.com/github/github-mcp-server/internal/githubv4mock"
"github.com/github/github-mcp-server/internal/toolsnaps"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/google/go-github/v87/github"
"github.com/google/jsonschema-go/jsonschema"
"github.com/shurcooL/githubv4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_UIGet(t *testing.T) {
// Verify tool definition
serverTool := UIGet(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "ui_get", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo")
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner"})
assert.True(t, tool.Annotations.ReadOnlyHint, "ui_get should be read-only")
assert.Equal(t, MCPAppsFeatureFlag, serverTool.FeatureFlagEnable, "ui_get should be gated on the MCP Apps feature flag")
// ui_get must be app-only so the host hides it from the agent's tool list
// while keeping it callable by the views (MCP Apps 2026-01-26 spec).
ui, ok := tool.Meta["ui"].(map[string]any)
require.True(t, ok, "ui_get should declare _meta.ui")
assert.Equal(t, []string{"app"}, ui["visibility"], "ui_get should be app-only")
// Setup mock data
mockAssignees := []*github.User{
{Login: github.Ptr("user1"), AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/1")},
{Login: github.Ptr("user2"), AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/2")},
}
mockBranches := []*github.Branch{
{Name: github.Ptr("main"), Protected: github.Ptr(true)},
{Name: github.Ptr("feature"), Protected: github.Ptr(false)},
}
dueDate := time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC)
mockMilestones := []*github.Milestone{
{Number: github.Ptr(1), Title: github.Ptr("with due date"), DueOn: &github.Timestamp{Time: dueDate}},
{Number: github.Ptr(2), Title: github.Ptr("no due date")},
}
mockIssueTypes := []*github.IssueType{
{Name: github.Ptr("Bug")},
{Name: github.Ptr("Feature")},
}
mockReviewers := []*github.User{
{Login: github.Ptr("octocat"), AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/583231")},
{Login: github.Ptr("dependabot[bot]"), AvatarURL: github.Ptr("https://avatars.githubusercontent.com/in/29110")},
{Login: github.Ptr("github-actions"), Type: github.Ptr("Bot")},
}
mockReviewerTeams := []*github.Team{
{Slug: github.Ptr("docs"), Name: github.Ptr("Docs")},
}
tests := []struct {
name string
mockedClient *http.Client
mockedGQLClient *http.Client
requestArgs map[string]any
expectError bool
expectedErrMsg string
validateResult func(t *testing.T, responseText string)
}{
{
name: "successful assignees fetch",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"GET /repos/owner/repo/assignees": mockResponse(t, http.StatusOK, mockAssignees),
}),
requestArgs: map[string]any{
"method": "assignees",
"owner": "owner",
"repo": "repo",
},
expectError: false,
validateResult: func(t *testing.T, responseText string) {
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(responseText), &response))
assert.Contains(t, response, "assignees")
assert.Contains(t, response, "totalCount")
},
},
{
name: "successful branches fetch",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"GET /repos/owner/repo/branches": mockResponse(t, http.StatusOK, mockBranches),
}),
requestArgs: map[string]any{
"method": "branches",
"owner": "owner",
"repo": "repo",
},
expectError: false,
validateResult: func(t *testing.T, responseText string) {
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(responseText), &response))
assert.Contains(t, response, "branches")
assert.Contains(t, response, "totalCount")
},
},
{
name: "successful milestones fetch",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"GET /repos/owner/repo/milestones": mockResponse(t, http.StatusOK, mockMilestones),
}),
requestArgs: map[string]any{
"method": "milestones",
"owner": "owner",
"repo": "repo",
},
expectError: false,
validateResult: func(t *testing.T, responseText string) {
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(responseText), &response))
milestones, ok := response["milestones"].([]any)
require.True(t, ok, "milestones should be a list")
require.Len(t, milestones, 2)
first := milestones[0].(map[string]any)
assert.Equal(t, "2026-01-31", first["due_on"], "milestone with a due date should be formatted")
second := milestones[1].(map[string]any)
assert.Equal(t, "", second["due_on"], "milestone without a due date should be empty, not zero time")
},
},
{
name: "successful issue_types fetch",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"GET /orgs/owner/issue-types": mockResponse(t, http.StatusOK, mockIssueTypes),
}),
requestArgs: map[string]any{
"method": "issue_types",
"owner": "owner",
},
expectError: false,
validateResult: func(t *testing.T, responseText string) {
var issueTypes []map[string]any
require.NoError(t, json.Unmarshal([]byte(responseText), &issueTypes))
require.Len(t, issueTypes, 2)
assert.Equal(t, "Bug", issueTypes[0]["name"])
},
},
{
name: "issue_types API error returns response context",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"GET /orgs/owner/issue-types": mockResponse(t, http.StatusForbidden, map[string]string{"message": "Forbidden"}),
}),
requestArgs: map[string]any{
"method": "issue_types",
"owner": "owner",
},
expectError: true,
expectedErrMsg: "failed to list issue types",
},
{
name: "successful labels fetch",
mockedGQLClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Labels struct {
Nodes []struct {
ID githubv4.ID
Name githubv4.String
Color githubv4.String
Description githubv4.String
}
TotalCount githubv4.Int
PageInfo struct {
HasNextPage githubv4.Boolean
EndCursor githubv4.String
}
} `graphql:"labels(first: 100, after: $cursor)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"cursor": (*githubv4.String)(nil),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"labels": map[string]any{
"nodes": []any{
map[string]any{
"id": githubv4.ID("label-1"),
"name": githubv4.String("bug"),
"color": githubv4.String("d73a4a"),
"description": githubv4.String("Something isn't working"),
},
},
"totalCount": githubv4.Int(1),
"pageInfo": map[string]any{
"hasNextPage": githubv4.Boolean(false),
"endCursor": githubv4.String(""),
},
},
},
}),
),
),
requestArgs: map[string]any{
"method": "labels",
"owner": "owner",
"repo": "repo",
},
expectError: false,
validateResult: func(t *testing.T, responseText string) {
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(responseText), &response))
labels, ok := response["labels"].([]any)
require.True(t, ok, "labels should be a list")
require.Len(t, labels, 1)
assert.Equal(t, "bug", labels[0].(map[string]any)["name"])
assert.Equal(t, float64(1), response["totalCount"])
},
},
{
name: "issue_fields feature disabled returns empty list",
requestArgs: map[string]any{
"method": "issue_fields",
"owner": "owner",
"repo": "repo",
},
expectError: false,
validateResult: func(t *testing.T, responseText string) {
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(responseText), &response))
fields, ok := response["fields"].([]any)
require.True(t, ok, "fields should be a list")
assert.Empty(t, fields)
assert.Equal(t, float64(0), response["totalCount"])
},
},
{
name: "successful reviewers fetch",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"GET /repos/owner/repo/collaborators": mockResponse(t, http.StatusOK, mockReviewers),
"GET /repos/owner/repo/teams": mockResponse(t, http.StatusOK, mockReviewerTeams),
}),
requestArgs: map[string]any{
"method": "reviewers",
"owner": "owner",
"repo": "repo",
},
expectError: false,
validateResult: func(t *testing.T, responseText string) {
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(responseText), &response))
users, ok := response["users"].([]any)
require.True(t, ok, "users should be a list")
require.Len(t, users, 1)
assert.Equal(t, "octocat", users[0].(map[string]any)["login"])
teams, ok := response["teams"].([]any)
require.True(t, ok, "teams should be a list")
require.Len(t, teams, 1)
assert.Equal(t, "docs", teams[0].(map[string]any)["slug"])
assert.Equal(t, "owner", teams[0].(map[string]any)["org"])
assert.Equal(t, float64(2), response["totalCount"])
},
},
{
name: "missing method parameter",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
},
expectError: true,
expectedErrMsg: "missing required parameter: method",
},
{
name: "missing owner parameter",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]any{
"method": "assignees",
"repo": "repo",
},
expectError: true,
expectedErrMsg: "missing required parameter: owner",
},
{
name: "missing repo parameter for assignees",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]any{
"method": "assignees",
"owner": "owner",
},
expectError: true,
expectedErrMsg: "missing required parameter: repo",
},
{
name: "unknown method",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]any{
"method": "unknown",
"owner": "owner",
"repo": "repo",
},
expectError: true,
expectedErrMsg: "unknown method: unknown",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup deps with REST and/or GraphQL mocks
deps := BaseDeps{}
if tc.mockedClient != nil {
client, err := github.NewClient(github.WithHTTPClient(tc.mockedClient))
require.NoError(t, err)
deps.Client = client
}
if tc.mockedGQLClient != nil {
deps.GQLClient = githubv4.NewClient(tc.mockedGQLClient)
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
if err != nil {
assert.Contains(t, err.Error(), tc.expectedErrMsg)
return
}
require.NotNil(t, result)
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
require.NotNil(t, result)
require.False(t, result.IsError)
textContent := getTextResult(t, result)
if tc.validateResult != nil {
tc.validateResult(t, textContent.Text)
}
})
}
}
func Test_marshalUIGetIssueFields_TrimsForUI(t *testing.T) {
priorityLow := 1
priorityHigh := 2
result, _, err := marshalUIGetIssueFields([]IssueField{
{
ID: "field-1",
DatabaseID: 123,
Name: "Priority",
Description: "How urgent this is",
DataType: "single_select",
Visibility: "public",
Options: []IssueSingleSelectFieldOption{
{ID: "option-2", Name: "High", Description: "High priority", Color: "red", Priority: &priorityHigh},
{ID: "option-1", Name: "Low", Description: "Low priority", Color: "blue", Priority: &priorityLow},
{ID: "option-3", Name: "No priority", Description: "No priority set", Color: "gray"},
},
},
{
ID: "field-2",
Name: "Unsupported",
DataType: "iteration",
},
{
ID: "field-3",
Name: "Notes",
DataType: "text",
},
})
require.NoError(t, err)
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response))
fields := response["fields"].([]any)
require.Len(t, fields, 2)
assert.Equal(t, float64(2), response["totalCount"])
singleSelectField := fields[0].(map[string]any)
assert.NotContains(t, singleSelectField, "full_database_id")
assert.NotContains(t, singleSelectField, "visibility")
options := singleSelectField["options"].([]any)
require.Len(t, options, 3)
assert.Equal(t, "Low", options[0].(map[string]any)["name"])
assert.Equal(t, "High", options[1].(map[string]any)["name"])
assert.Equal(t, "No priority", options[2].(map[string]any)["name"])
assert.NotContains(t, options[0].(map[string]any), "id")
assert.NotContains(t, options[0].(map[string]any), "priority")
textField := fields[1].(map[string]any)
assert.NotContains(t, textField, "options")
}
package github
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/github/github-mcp-server/pkg/utils"
"github.com/google/go-github/v87/github"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/shurcooL/githubv4"
)
// UIGet creates a tool to fetch UI data for MCP Apps.
func UIGet(t translations.TranslationHelperFunc) inventory.ServerTool {
st := NewTool(
ToolsetMetadataContext, // Use context toolset so it's always available
mcp.Tool{
Name: "ui_get",
Description: t("TOOL_UI_GET_DESCRIPTION", "Fetch UI data for MCP Apps (labels, assignees, milestones, issue types, branches, issue fields, reviewers)."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_UI_GET_USER_TITLE", "Get UI data"),
ReadOnlyHint: true,
},
// ui_get only backs MCP App views; declaring app-only visibility keeps
// it out of the agent's tool list while remaining callable by the views
// via tools/call (per the MCP Apps 2026-01-26 spec).
Meta: mcp.Meta{
"ui": map[string]any{
"visibility": []string{"app"},
},
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"method": {
Type: "string",
Enum: []any{"labels", "assignees", "milestones", "issue_types", "branches", "issue_fields", "reviewers"},
Description: "The type of data to fetch",
},
"owner": {
Type: "string",
Description: "Repository owner (required for all methods)",
},
"repo": {
Type: "string",
Description: "Repository name (required for labels, assignees, milestones, branches, issue fields, reviewers)",
},
},
Required: []string{"method", "owner"},
},
},
[]scopes.Scope{scopes.Repo, scopes.ReadOrg},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
method, err := RequiredParam[string](args, "method")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
switch method {
case "labels":
return uiGetLabels(ctx, deps, args, owner)
case "assignees":
return uiGetAssignees(ctx, deps, args, owner)
case "milestones":
return uiGetMilestones(ctx, deps, args, owner)
case "issue_types":
return uiGetIssueTypes(ctx, deps, owner)
case "branches":
return uiGetBranches(ctx, deps, args, owner)
case "issue_fields":
return uiGetIssueFields(ctx, deps, args, owner)
case "reviewers":
return uiGetReviewers(ctx, deps, args, owner)
default:
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil
}
})
st.FeatureFlagEnable = MCPAppsFeatureFlag
return st
}
func uiGetLabels(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) {
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
client, err := deps.GetGQLClient(ctx)
if err != nil {
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
var query struct {
Repository struct {
Labels struct {
Nodes []struct {
ID githubv4.ID
Name githubv4.String
Color githubv4.String
Description githubv4.String
}
TotalCount githubv4.Int
PageInfo struct {
HasNextPage githubv4.Boolean
EndCursor githubv4.String
}
} `graphql:"labels(first: 100, after: $cursor)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
vars := map[string]any{
"owner": githubv4.String(owner),
"repo": githubv4.String(repo),
"cursor": (*githubv4.String)(nil),
}
labels := make([]map[string]any, 0)
var totalCount int
for {
if err := client.Query(ctx, &query, vars); err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to list labels", err), nil, nil
}
for _, labelNode := range query.Repository.Labels.Nodes {
labels = append(labels, map[string]any{
"id": fmt.Sprintf("%v", labelNode.ID),
"name": string(labelNode.Name),
"color": string(labelNode.Color),
"description": string(labelNode.Description),
})
}
totalCount = int(query.Repository.Labels.TotalCount)
if !query.Repository.Labels.PageInfo.HasNextPage {
break
}
vars["cursor"] = githubv4.NewString(query.Repository.Labels.PageInfo.EndCursor)
}
response := map[string]any{
"labels": labels,
"totalCount": totalCount,
}
out, err := json.Marshal(response)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal labels: %w", err)
}
return utils.NewToolResultText(string(out)), nil, nil
}
func uiGetAssignees(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) {
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
opts := &github.ListOptions{PerPage: 100}
var allAssignees []*github.User
for {
assignees, resp, err := client.Issues.ListAssignees(ctx, owner, repo, opts)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list assignees", resp, err), nil, nil
}
allAssignees = append(allAssignees, assignees...)
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
}
if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
}
result := make([]map[string]string, len(allAssignees))
for i, u := range allAssignees {
result[i] = map[string]string{
"login": u.GetLogin(),
"avatar_url": u.GetAvatarURL(),
}
}
out, err := json.Marshal(map[string]any{
"assignees": result,
"totalCount": len(result),
})
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal assignees", err), nil, nil
}
return utils.NewToolResultText(string(out)), nil, nil
}
func uiGetMilestones(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) {
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
opts := &github.MilestoneListOptions{
State: "open",
ListOptions: github.ListOptions{PerPage: 100},
}
var allMilestones []*github.Milestone
for {
milestones, resp, err := client.Issues.ListMilestones(ctx, owner, repo, opts)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list milestones", resp, err), nil, nil
}
allMilestones = append(allMilestones, milestones...)
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
}
if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
}
result := make([]map[string]any, len(allMilestones))
for i, m := range allMilestones {
dueOn := ""
if m.DueOn != nil {
dueOn = m.GetDueOn().Format("2006-01-02")
}
result[i] = map[string]any{
"number": m.GetNumber(),
"title": m.GetTitle(),
"description": m.GetDescription(),
"state": m.GetState(),
"open_issues": m.GetOpenIssues(),
"due_on": dueOn,
}
}
out, err := json.Marshal(map[string]any{
"milestones": result,
"totalCount": len(result),
})
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal milestones", err), nil, nil
}
return utils.NewToolResultText(string(out)), nil, nil
}
func uiGetIssueTypes(ctx context.Context, deps ToolDependencies, owner string) (*mcp.CallToolResult, any, error) {
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
issueTypes, resp, err := client.Organizations.ListIssueTypes(ctx, owner)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list issue types", resp, err), nil, nil
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil
}
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list issue types", resp, body), nil, nil
}
r, err := json.Marshal(issueTypes)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal issue types", err), nil, nil
}
return utils.NewToolResultText(string(r)), nil, nil
}
func uiGetBranches(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) {
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
opts := &github.BranchListOptions{
ListOptions: github.ListOptions{PerPage: 100},
}
var allBranches []*github.Branch
for {
branches, resp, err := client.Repositories.ListBranches(ctx, owner, repo, opts)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list branches", resp, err), nil, nil
}
allBranches = append(allBranches, branches...)
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
}
if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
}
minimalBranches := make([]MinimalBranch, 0, len(allBranches))
for _, branch := range allBranches {
minimalBranches = append(minimalBranches, convertToMinimalBranch(branch))
}
r, err := json.Marshal(map[string]any{
"branches": minimalBranches,
"totalCount": len(minimalBranches),
})
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
}
return utils.NewToolResultText(string(r)), nil, nil
}
func uiGetIssueFields(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) {
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
if !deps.IsFeatureEnabled(ctx, FeatureFlagIssueFields) {
return marshalUIGetIssueFields(nil)
}
gqlClient, err := deps.GetGQLClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil
}
fields, err := fetchIssueFields(ctx, gqlClient, owner, repo)
if err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to list issue fields", err), nil, nil
}
return marshalUIGetIssueFields(fields)
}
func marshalUIGetIssueFields(fields []IssueField) (*mcp.CallToolResult, any, error) {
resultFields := make([]map[string]any, 0, len(fields))
for _, field := range fields {
if !uiSupportedIssueFieldDataType(field.DataType) {
continue
}
fieldResult := map[string]any{
"id": field.ID,
"name": field.Name,
"data_type": field.DataType,
"description": field.Description,
}
if field.DataType == "single_select" {
fieldOptions := append([]IssueSingleSelectFieldOption(nil), field.Options...)
sort.SliceStable(fieldOptions, func(i, j int) bool {
left, leftOK := issueFieldOptionPriority(fieldOptions[i])
right, rightOK := issueFieldOptionPriority(fieldOptions[j])
if leftOK != rightOK {
return leftOK
}
return left < right
})
options := make([]map[string]string, 0, len(fieldOptions))
for _, option := range fieldOptions {
options = append(options, map[string]string{
"name": option.Name,
"description": option.Description,
"color": option.Color,
})
}
fieldResult["options"] = options
}
resultFields = append(resultFields, fieldResult)
}
r, err := json.Marshal(map[string]any{
"fields": resultFields,
"totalCount": len(resultFields),
})
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal issue fields", err), nil, nil
}
return utils.NewToolResultText(string(r)), nil, nil
}
func uiSupportedIssueFieldDataType(dataType string) bool {
switch dataType {
case "text", "number", "date", "single_select":
return true
default:
return false
}
}
func issueFieldOptionPriority(option IssueSingleSelectFieldOption) (int, bool) {
if option.Priority == nil {
return 0, false
}
return *option.Priority, true
}
func uiGetReviewers(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) {
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
collaboratorOpts := &github.ListCollaboratorsOptions{
Affiliation: "all",
ListOptions: github.ListOptions{PerPage: 100},
}
var allCollaborators []*github.User
for {
collaborators, resp, err := client.Repositories.ListCollaborators(ctx, owner, repo, collaboratorOpts)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list reviewers", resp, err), nil, nil
}
allCollaborators = append(allCollaborators, collaborators...)
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
}
if resp.NextPage == 0 {
break
}
collaboratorOpts.Page = resp.NextPage
}
teamOpts := &github.ListOptions{PerPage: 100}
var allTeams []*github.Team
for {
teams, resp, err := client.Repositories.ListTeams(ctx, owner, repo, teamOpts)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list reviewer teams", resp, err), nil, nil
}
allTeams = append(allTeams, teams...)
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
}
if resp.NextPage == 0 {
break
}
teamOpts.Page = resp.NextPage
}
users := make([]map[string]string, 0, len(allCollaborators))
for _, user := range allCollaborators {
login := user.GetLogin()
if user.GetType() == "Bot" || strings.HasSuffix(login, "[bot]") {
continue
}
users = append(users, map[string]string{
"login": login,
"avatar_url": user.GetAvatarURL(),
})
}
teams := make([]map[string]string, len(allTeams))
for i, team := range allTeams {
teams[i] = map[string]string{
"slug": team.GetSlug(),
"name": team.GetName(),
"org": owner,
}
}
r, err := json.Marshal(map[string]any{
"users": users,
"teams": teams,
"totalCount": len(users) + len(teams),
})
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal reviewers", err), nil, nil
}
return utils.NewToolResultText(string(r)), nil, nil
}

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

import { StrictMode, useState, useCallback, useEffect, useMemo } from "react";
import { createRoot } from "react-dom/client";
import {
Box,
Text,
TextInput,
Button,
Flash,
Spinner,
FormControl,
ActionMenu,
ActionList,
Checkbox,
ButtonGroup,
CounterLabel,
Label,
} from "@primer/react";
import {
GitPullRequestIcon,
CheckCircleIcon,
GitBranchIcon,
LockIcon,
PersonIcon,
PeopleIcon,
} from "@primer/octicons-react";
import { AppProvider } from "../../components/AppProvider";
import { useMcpApp } from "../../hooks/useMcpApp";
import { MarkdownEditor } from "../../components/MarkdownEditor";
interface PRResult {
ID?: string;
number?: number;
title?: string;
url?: string;
html_url?: string;
URL?: string;
}
interface BranchItem {
name: string;
protected: boolean;
}
type ReviewerItem = { kind: "user" | "team"; id: string; text: string; avatar?: string; org?: string };
type PRState = "open" | "closed";
interface InitialPRState {
title: string;
body: string;
state: PRState;
base: string;
draft: boolean;
maintainerCanModify: boolean;
reviewers: string[];
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
}
function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function asBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
function asNumber(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}
function reviewerFromValue(value: string): ReviewerItem {
if (value.includes("/")) {
const [org, slug] = value.split("/", 2);
return { kind: "team", id: `${org}/${slug}`, text: `${org}/${slug}`, org };
}
return { kind: "user", id: value, text: value };
}
function reviewerValue(reviewer: ReviewerItem): string {
return reviewer.kind === "team" ? reviewer.id : reviewer.text;
}
function sameReviewerValues(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return sortedA.every((value, index) => value === sortedB[index]);
}
function parseUserReviewer(value: unknown): ReviewerItem | null {
if (typeof value === "string") return reviewerFromValue(value);
const record = asRecord(value);
const login = asString(record?.login);
if (!login) return null;
return { kind: "user", id: login, text: login, avatar: asString(record?.avatar_url) };
}
function parseTeamReviewer(value: unknown, fallbackOrg: string): ReviewerItem | null {
if (typeof value === "string") {
if (value.includes("/")) return reviewerFromValue(value);
return { kind: "team", id: `${fallbackOrg}/${value}`, text: `${fallbackOrg}/${value}`, org: fallbackOrg };
}
const record = asRecord(value);
const slug = asString(record?.slug) || asString(record?.name);
if (!slug) return null;
const organization = asRecord(record?.organization);
const org = asString(record?.org) || asString(organization?.login) || fallbackOrg;
const id = org ? `${org}/${slug}` : slug;
return { kind: "team", id, text: id, org };
}
function reviewersFromValues(values: unknown): ReviewerItem[] | undefined {
if (!Array.isArray(values)) return undefined;
return values
.map((value) => (typeof value === "string" ? reviewerFromValue(value) : null))
.filter((value): value is ReviewerItem => value !== null);
}
function extractRequestedReviewers(prData: Record<string, unknown>, owner: string): ReviewerItem[] {
const requestedReviewers = Array.isArray(prData.requested_reviewers) ? prData.requested_reviewers : [];
const requestedTeams = Array.isArray(prData.requested_teams) ? prData.requested_teams : [];
return [
...requestedReviewers.map(parseUserReviewer).filter((value): value is ReviewerItem => value !== null),
...requestedTeams.map((team) => parseTeamReviewer(team, owner)).filter((value): value is ReviewerItem => value !== null),
];
}
function parsePRState(value: unknown): PRState {
return value === "closed" ? "closed" : "open";
}
function buildInitialState(prData: Record<string, unknown>, owner: string): InitialPRState {
const base = asRecord(prData.base);
const requestedReviewers = extractRequestedReviewers(prData, owner);
return {
title: asString(prData.title) || "",
body: asString(prData.body) || "",
state: parsePRState(prData.state),
base: asString(base?.ref) || "",
draft: asBoolean(prData.draft) || false,
maintainerCanModify: asBoolean(prData.maintainer_can_modify) || false,
reviewers: requestedReviewers.map(reviewerValue),
};
}
function SuccessView({
pr,
owner,
repo,
submittedTitle,
openLink,
}: {
pr: PRResult;
owner: string;
repo: string;
submittedTitle: string;
openLink: (url: string) => Promise<void>;
}) {
const prUrl = pr.html_url || pr.url || pr.URL || "#";
return (
<Box
borderWidth={1}
borderStyle="solid"
borderColor="border.default"
borderRadius={2}
bg="canvas.subtle"
p={3}
>
<Box
display="flex"
alignItems="center"
mb={3}
pb={3}
borderBottomWidth={1}
borderBottomStyle="solid"
borderBottomColor="border.default"
>
<Box sx={{ color: "success.fg", flexShrink: 0, mr: 2 }}>
<CheckCircleIcon size={16} />
</Box>
<Text sx={{ fontWeight: "semibold" }}>
Pull request updated successfully
</Text>
</Box>
<Box
display="flex"
alignItems="flex-start"
gap={2}
p={3}
bg="canvas.subtle"
borderRadius={2}
borderWidth={1}
borderStyle="solid"
borderColor="border.default"
>
<Box sx={{ color: "open.fg", flexShrink: 0, mt: "2px", mr: 1 }}>
<GitPullRequestIcon size={16} />
</Box>
<Box sx={{ minWidth: 0 }}>
<a
href={prUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => {
e.preventDefault();
if (prUrl === "#") return;
void openLink(prUrl);
}}
style={{
fontWeight: 600,
fontSize: "14px",
display: "block",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
color: "var(--fgColor-accent, var(--color-accent-fg))",
textDecoration: "none",
}}
>
{pr.title || submittedTitle}
{pr.number && (
<Text sx={{ color: "fg.muted", fontWeight: "normal", ml: 1 }}>
#{pr.number}
</Text>
)}
</a>
<Text sx={{ color: "fg.muted", fontSize: 0 }}>
{owner}/{repo}
</Text>
</Box>
</Box>
</Box>
);
}
function EditPRApp() {
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [prState, setPRState] = useState<PRState>("open");
const [isDraft, setIsDraft] = useState(false);
const [maintainerCanModify, setMaintainerCanModify] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successPR, setSuccessPR] = useState<PRResult | null>(null);
const [initialValues, setInitialValues] = useState<InitialPRState | null>(null);
const [isLoadingPR, setIsLoadingPR] = useState(false);
const [submittedTitle, setSubmittedTitle] = useState("");
const [availableBranches, setAvailableBranches] = useState<BranchItem[]>([]);
const [baseBranch, setBaseBranch] = useState<string>("");
const [branchesLoading, setBranchesLoading] = useState(false);
const [baseFilter, setBaseFilter] = useState("");
const [availableReviewers, setAvailableReviewers] = useState<ReviewerItem[]>([]);
const [selectedReviewers, setSelectedReviewers] = useState<ReviewerItem[]>([]);
const [reviewersLoading, setReviewersLoading] = useState(false);
const [reviewersFilter, setReviewersFilter] = useState("");
const { app, error: appError, toolInput, callTool, hostContext, setModelContext, openLink } = useMcpApp({
appName: "github-mcp-server-edit-pull-request",
});
const owner = (toolInput?.owner as string) || "";
const repo = (toolInput?.repo as string) || "";
const pullNumber = asNumber(toolInput?.pullNumber);
useEffect(() => {
setTitle("");
setBody("");
setPRState("open");
setIsDraft(false);
setMaintainerCanModify(false);
setBaseBranch("");
setSelectedReviewers([]);
setAvailableBranches([]);
setAvailableReviewers([]);
setBaseFilter("");
setReviewersFilter("");
setInitialValues(null);
setSuccessPR(null);
setError(null);
setSubmittedTitle("");
}, [toolInput]);
useEffect(() => {
if (!app || !owner || !repo || !pullNumber) return;
let cancelled = false;
const loadPullRequest = async () => {
setIsLoadingPR(true);
try {
const result = await callTool("pull_request_read", { method: "get", owner, repo, pullNumber });
if (cancelled) return;
if (result.isError) {
const textContent = result.content?.find((c) => c.type === "text");
const errorMessage = textContent && textContent.type === "text" ? textContent.text : "Failed to load pull request";
setError(errorMessage);
return;
}
const textContent = result.content?.find((c) => c.type === "text");
if (!textContent || textContent.type !== "text" || !textContent.text) {
setError("Pull request details were not returned");
return;
}
const prData = JSON.parse(textContent.text) as Record<string, unknown>;
const initialState = buildInitialState(prData, owner);
const toolInputReviewers = reviewersFromValues(toolInput?.reviewers);
setInitialValues(initialState);
setTitle(asString(toolInput?.title) ?? initialState.title);
setBody(asString(toolInput?.body) ?? initialState.body);
setPRState(parsePRState(asString(toolInput?.state) ?? initialState.state));
setIsDraft(asBoolean(toolInput?.draft) ?? initialState.draft);
setBaseBranch(asString(toolInput?.base) ?? initialState.base);
setMaintainerCanModify(asBoolean(toolInput?.maintainer_can_modify) ?? initialState.maintainerCanModify);
setSelectedReviewers(toolInputReviewers ?? extractRequestedReviewers(prData, owner));
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : "Failed to load pull request");
}
} finally {
if (!cancelled) setIsLoadingPR(false);
}
};
loadPullRequest();
return () => {
cancelled = true;
};
}, [app, callTool, owner, repo, pullNumber, toolInput]);
useEffect(() => {
if (!owner || !repo || !app) return;
let cancelled = false;
const loadBranches = async () => {
setBranchesLoading(true);
try {
const result = await callTool("ui_get", { method: "branches", owner, repo });
if (cancelled) return;
if (result && !result.isError && result.content) {
const textContent = result.content.find((c: { type: string }) => c.type === "text");
if (textContent && "text" in textContent) {
const data = JSON.parse(textContent.text as string);
const branches = (data.branches || data || []).map(
(b: { name: string; protected?: boolean }) => ({ name: b.name, protected: b.protected || false })
);
setAvailableBranches(branches);
const defaultBranch = branches.find((b: BranchItem) => b.name === "main" || b.name === "master");
if (defaultBranch) setBaseBranch((prev) => prev || defaultBranch.name);
}
}
} catch (e) {
console.error("Failed to load branches:", e);
} finally {
if (!cancelled) setBranchesLoading(false);
}
};
const loadReviewers = async () => {
setReviewersLoading(true);
try {
const result = await callTool("ui_get", { method: "reviewers", owner, repo });
if (cancelled) return;
if (result && !result.isError && result.content) {
const textContent = result.content.find((c: { type: string }) => c.type === "text");
if (textContent && "text" in textContent) {
const data = JSON.parse(textContent.text as string);
const users = (data.users || []).map(
(u: { login: string; avatar_url?: string }) => ({
kind: "user" as const,
id: u.login,
text: u.login,
avatar: u.avatar_url,
})
);
const teams = (data.teams || []).map(
(t: { slug: string; name?: string; org: string }) => ({
kind: "team" as const,
id: `${t.org}/${t.slug}`,
text: `${t.org}/${t.slug}`,
org: t.org,
})
);
setAvailableReviewers([...users, ...teams]);
}
}
} catch (e) {
console.error("Failed to load reviewers:", e);
} finally {
if (!cancelled) setReviewersLoading(false);
}
};
loadBranches();
loadReviewers();
return () => {
cancelled = true;
};
}, [owner, repo, app, callTool]);
useEffect(() => {
if (availableReviewers.length === 0) return;
setSelectedReviewers((prev) =>
prev.map((reviewer) =>
availableReviewers.find((available) => available.id === reviewer.id || available.text === reviewer.text) || reviewer
)
);
}, [availableReviewers]);
const filteredBaseBranches = useMemo(() => {
if (!baseFilter.trim()) return availableBranches;
return availableBranches.filter((branch) => branch.name.toLowerCase().includes(baseFilter.toLowerCase()));
}, [availableBranches, baseFilter]);
const filteredReviewers = useMemo(() => {
if (!reviewersFilter.trim()) return availableReviewers;
const lowerFilter = reviewersFilter.toLowerCase();
return availableReviewers.filter((reviewer) =>
reviewer.text.toLowerCase().includes(lowerFilter) || reviewer.id.toLowerCase().includes(lowerFilter)
);
}, [availableReviewers, reviewersFilter]);
const handleSubmit = useCallback(async () => {
if (!title.trim()) { setError("Title is required"); return; }
if (!owner || !repo || !pullNumber) { setError("Pull request information not available"); return; }
if (!baseBranch) { setError("Base branch is required"); return; }
if (!initialValues) { setError("Pull request details are still loading"); return; }
const selectedReviewerValues = selectedReviewers.map(reviewerValue);
const params: Record<string, unknown> = { owner, repo, pullNumber, _ui_submitted: true };
if (title.trim() !== initialValues.title) params.title = title.trim();
if (body !== initialValues.body) params.body = body;
if (prState !== initialValues.state) params.state = prState;
if (baseBranch !== initialValues.base) params.base = baseBranch;
if (isDraft !== initialValues.draft) params.draft = isDraft;
if (maintainerCanModify !== initialValues.maintainerCanModify) params.maintainer_can_modify = maintainerCanModify;
if (!sameReviewerValues(selectedReviewerValues, initialValues.reviewers)) params.reviewers = selectedReviewerValues;
const hasChanges = Object.keys(params).some((key) => !["owner", "repo", "pullNumber", "_ui_submitted"].includes(key));
if (!hasChanges) {
setError("No changes to update");
return;
}
setIsSubmitting(true);
setError(null);
setSubmittedTitle(title);
try {
const result = await callTool("update_pull_request", params);
if (result.isError) {
const textContent = result.content?.find((c) => c.type === "text");
const errorMessage = textContent && textContent.type === "text" ? textContent.text : "Failed to update pull request";
setError(errorMessage);
} else {
const textContent = result.content?.find((c) => c.type === "text");
if (textContent && textContent.type === "text" && textContent.text) {
const prData = JSON.parse(textContent.text);
setSuccessPR(prData);
void setModelContext({
structuredContent: prData,
content: [
{
type: "text",
text: `Pull request #${pullNumber} in ${owner}/${repo} was updated by the user via the edit-pull-request view.`,
},
],
});
}
}
} catch (e) {
setError(e instanceof Error ? e.message : "An error occurred");
} finally {
setIsSubmitting(false);
}
}, [title, body, owner, repo, pullNumber, baseBranch, initialValues, selectedReviewers, prState, isDraft, maintainerCanModify, callTool, setModelContext]);
if (successPR) {
return (
<AppProvider hostContext={hostContext}>
<SuccessView pr={successPR} owner={owner} repo={repo} submittedTitle={submittedTitle} openLink={openLink} />
</AppProvider>
);
}
if (!app && !appError) {
return (
<AppProvider hostContext={hostContext}>
<Box display="flex" alignItems="center" justifyContent="center" p={4}>
<Spinner size="medium" />
</Box>
</AppProvider>
);
}
if (appError) {
return (
<AppProvider hostContext={hostContext}>
<Flash variant="danger">{appError.message}</Flash>
</AppProvider>
);
}
if (toolInput === null) {
return (
<AppProvider hostContext={hostContext}>
<Box display="flex" alignItems="center" justifyContent="center" p={4}>
<Spinner size="medium" />
</Box>
</AppProvider>
);
}
if (!owner || !repo || !pullNumber) {
return (
<AppProvider hostContext={hostContext}>
<Flash variant="danger">Pull request owner, repo, and pullNumber are required.</Flash>
</AppProvider>
);
}
return (
<AppProvider hostContext={hostContext}>
<Box
borderWidth={1}
borderStyle="solid"
borderColor="border.default"
borderRadius={2}
bg="canvas.subtle"
p={3}
>
<Box
display="flex"
alignItems="center"
gap={2}
mb={3}
pb={2}
borderBottomWidth={1}
borderBottomStyle="solid"
borderBottomColor="border.default"
sx={{ minWidth: 0, overflow: "hidden" }}
>
<Box sx={{ color: "open.fg", flexShrink: 0 }}>
<GitPullRequestIcon size={16} />
</Box>
<Box sx={{ minWidth: 0 }}>
<Text sx={{ fontWeight: "semibold" }}>#{pullNumber} Ā· {owner}/{repo}</Text>
{title && (
<Text as="div" sx={{ color: "fg.muted", fontSize: 0, mt: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{title}
</Text>
)}
</Box>
</Box>
{error && <Flash variant="danger" sx={{ mb: 3 }}>{error}</Flash>}
{isLoadingPR && !initialValues ? (
<Box display="flex" alignItems="center" justifyContent="center" p={4}>
<Spinner size="medium" />
<Text sx={{ color: "fg.muted", ml: 2 }}>Loading pull request...</Text>
</Box>
) : (
<>
<FormControl sx={{ mb: 3 }}>
<FormControl.Label sx={{ fontWeight: "semibold" }}>Title</FormControl.Label>
<TextInput
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Title"
block
contrast
/>
</FormControl>
<Box sx={{ mb: 3 }}>
<Text as="label" sx={{ fontWeight: "semibold", fontSize: 1, display: "block", mb: 2 }}>
Description
</Text>
<MarkdownEditor value={body} onChange={setBody} placeholder="Add a description..." />
</Box>
<Box sx={{ mb: 3, flex: "1 1 240px", minWidth: 0 }}>
<Text sx={{ fontSize: 0, color: "fg.muted", mb: 1, display: "block" }}>base</Text>
<ActionMenu>
<ActionMenu.Button size="small" leadingVisual={GitBranchIcon} sx={{ width: "100%", "& > span": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}>
{baseBranch || "Select base"}
</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<ActionList selectionVariant="single">
<Box p={2}>
<TextInput
placeholder="Filter branches..."
value={baseFilter}
onChange={(e) => setBaseFilter(e.target.value)}
size="small"
block
/>
</Box>
<ActionList.Divider />
{branchesLoading ? (
<ActionList.Item disabled><Spinner size="small" /> Loading...</ActionList.Item>
) : filteredBaseBranches.length === 0 ? (
<ActionList.Item disabled>No branches found</ActionList.Item>
) : (
filteredBaseBranches.map((branch) => (
<ActionList.Item
key={branch.name}
selected={baseBranch === branch.name}
onSelect={() => { setBaseBranch(branch.name); setBaseFilter(""); }}
>
{branch.name}
{branch.protected && <ActionList.TrailingVisual><LockIcon size={12} /></ActionList.TrailingVisual>}
</ActionList.Item>
))
)}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</Box>
<Box display="flex" justifyContent="space-between" alignItems="flex-end" flexWrap="wrap" gap={3} mb={3}>
<Box>
<Text sx={{ fontSize: 0, color: "fg.muted", mb: 1, display: "block" }}>state</Text>
<ButtonGroup>
<Button size="small" variant={prState === "open" ? "primary" : "default"} onClick={() => setPRState("open")}>
Open
</Button>
<Button size="small" variant={prState === "closed" ? "primary" : "default"} onClick={() => setPRState("closed")}>
Closed
</Button>
</ButtonGroup>
</Box>
<Box as="label" display="flex" alignItems="center" sx={{ cursor: "pointer", gap: 2, mb: "6px" }}>
<Checkbox checked={isDraft} onChange={(e) => setIsDraft(e.target.checked)} />
<Text sx={{ fontSize: 1, color: "fg.muted" }}>Mark as draft</Text>
</Box>
</Box>
<Box sx={{ mb: 3 }}>
<Text sx={{ fontSize: 0, color: "fg.muted", mb: 1, display: "block" }}>reviewers</Text>
<ActionMenu>
<ActionMenu.Button size="small" leadingVisual={PersonIcon} sx={{ minWidth: 160 }}>
{selectedReviewers.length === 0 ? (
"No reviewers"
) : (
<>
Reviewers
<CounterLabel sx={{ ml: 1 }}>{selectedReviewers.length}</CounterLabel>
</>
)}
</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<Box p={2} borderBottomWidth={1} borderBottomStyle="solid" borderBottomColor="border.default">
<TextInput
placeholder="Search reviewers"
value={reviewersFilter}
onChange={(e) => setReviewersFilter(e.target.value)}
size="small"
block
/>
</Box>
<ActionList selectionVariant="multiple">
{reviewersLoading ? (
<ActionList.Item disabled><Spinner size="small" /> Loading...</ActionList.Item>
) : filteredReviewers.length === 0 ? (
<ActionList.Item disabled>No reviewers available</ActionList.Item>
) : (
filteredReviewers.map((reviewer) => (
<ActionList.Item
key={reviewer.id}
selected={selectedReviewers.some((r) => r.id === reviewer.id)}
onSelect={() => {
setSelectedReviewers((prev) =>
prev.some((r) => r.id === reviewer.id)
? prev.filter((r) => r.id !== reviewer.id)
: [...prev, reviewer]
);
}}
>
<ActionList.LeadingVisual>
{reviewer.kind === "user" ? (
reviewer.avatar ? (
<img
src={reviewer.avatar}
alt=""
width={16}
height={16}
style={{ borderRadius: "50%", display: "block" }}
/>
) : (
<PersonIcon />
)
) : (
<PeopleIcon />
)}
</ActionList.LeadingVisual>
{reviewer.text}
</ActionList.Item>
))
)}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
{selectedReviewers.length > 0 && (
<Box display="flex" gap={1} mt={2} flexWrap="wrap">
{selectedReviewers.map((reviewer) => (
<Label
key={reviewer.id}
sx={{
backgroundColor: "canvas.inset",
color: "fg.muted",
borderColor: "border.default",
}}
>
{reviewer.text}
</Label>
))}
</Box>
)}
</Box>
<Box display="flex" justifyContent="space-between" alignItems="flex-end" flexWrap="wrap" gap={3}>
<Box as="label" display="flex" alignItems="center" sx={{ cursor: "pointer", gap: 2, mt: 1 }}>
<Checkbox checked={maintainerCanModify} onChange={(e) => setMaintainerCanModify(e.target.checked)} />
<Text sx={{ fontSize: 1, color: "fg.muted" }}>Allow maintainer edits</Text>
</Box>
<Button
variant="primary"
onClick={handleSubmit}
disabled={isSubmitting || !initialValues || !title.trim() || !baseBranch}
>
{isSubmitting ? (
<><Spinner size="small" sx={{ mr: 1 }} />Updating...</>
) : (
"Update pull request"
)}
</Button>
</Box>
</>
)}
</Box>
</AppProvider>
);
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<EditPRApp />
</StrictMode>
);
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Edit pull request</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./App.tsx"></script>
</body>
</html>
+2
-1

@@ -15,3 +15,4 @@ name: Build UI

pkg/github/ui_dist/pr-write.html
key: ui-dist-v1-${{ hashFiles('ui/package-lock.json', 'ui/package.json', 'ui/index.html', 'ui/tsconfig*.json', 'ui/vite.config.ts', 'ui/src/**', 'ui/scripts/**') }}
pkg/github/ui_dist/pr-edit.html
key: ui-dist-v2-${{ hashFiles('ui/package-lock.json', 'ui/package.json', 'ui/index.html', 'ui/tsconfig*.json', 'ui/vite.config.ts', 'ui/src/**', 'ui/scripts/**') }}
enableCrossOsArchive: true

@@ -18,0 +19,0 @@

@@ -224,3 +224,11 @@ package main

if len(tool.RequiredScopes) > 0 {
fmt.Fprintf(buf, " - **Required OAuth Scopes**: `%s`\n", strings.Join(tool.RequiredScopes, "`, `"))
// Scope filtering uses "any of" semantics (see scopes.HasRequiredScopes),
// so when multiple required scopes are listed, render them as alternatives
// rather than implying all are required.
scopeList := "`" + strings.Join(tool.RequiredScopes, "`, `") + "`"
if len(tool.RequiredScopes) > 1 {
fmt.Fprintf(buf, " - **Required OAuth Scopes (any of)**: %s\n", scopeList)
} else {
fmt.Fprintf(buf, " - **Required OAuth Scopes**: %s\n", scopeList)
}

@@ -261,2 +269,4 @@ // Only show accepted scopes if they differ from required scopes

conditional := inventory.ConditionalSchemaPropertyDescriptions()
for i, propName := range paramNames {

@@ -287,3 +297,7 @@ prop := schema.Properties[propName]

fmt.Fprintf(buf, " - `%s`: %s (%s, %s)", propName, description, typeStr, requiredStr)
if cond, isConditional := conditional[propName]; isConditional {
fmt.Fprintf(buf, " - `%s`: %s (%s, %s, conditional — %s)", propName, description, typeStr, requiredStr, cond)
} else {
fmt.Fprintf(buf, " - `%s`: %s (%s, %s)", propName, description, typeStr, requiredStr)
}
if i < len(paramNames)-1 {

@@ -290,0 +304,0 @@ buf.WriteString("\n")

@@ -141,2 +141,3 @@ package main

Port: viper.GetInt("port"),
ListenHost: viper.GetString("listen-host"),
BaseURL: viper.GetString("base-url"),

@@ -188,2 +189,3 @@ ResourcePath: viper.GetString("base-path"),

httpCmd.Flags().Int("port", 8082, "HTTP server port")
httpCmd.Flags().String("listen-host", "", "Host the HTTP server binds to (e.g. 127.0.0.1). Empty binds to all interfaces.")
httpCmd.Flags().String("base-url", "", "Base URL where this server is publicly accessible (for OAuth resource metadata)")

@@ -209,2 +211,3 @@ httpCmd.Flags().String("base-path", "", "Externally visible base path for the HTTP server (for OAuth resource metadata)")

_ = viper.BindPFlag("port", httpCmd.Flags().Lookup("port"))
_ = viper.BindPFlag("listen-host", httpCmd.Flags().Lookup("listen-host"))
_ = viper.BindPFlag("base-url", httpCmd.Flags().Lookup("base-url"))

@@ -211,0 +214,0 @@ _ = viper.BindPFlag("base-path", httpCmd.Flags().Lookup("base-path"))

@@ -1,2 +0,2 @@

FROM node:26-alpine@sha256:144769ec3f32e8ee36b3cfde91e82bee25d9367b20f31a151f3f7eea3a2a8541 AS ui-build
FROM node:26-alpine@sha256:3ad34ca6292aec4a91d8ddeb9229e29d9c2f689efd0dd242860889ac71842eba AS ui-build
WORKDIR /app

@@ -10,3 +10,3 @@ COPY ui/package*.json ./ui/

FROM golang:1.25.11-alpine@sha256:cd2fb3559df6e13bc93b7f0734a4eabe1d21e7b64eec211ed90784f00a17a56a AS build
FROM golang:1.25.11-alpine@sha256:8d95af53d0d58e1759ddb4028285d9b1239067e4fbf4f544618cad0f60fbc354 AS build
ARG VERSION="dev"

@@ -34,3 +34,3 @@

# Make a stage to run the app
FROM gcr.io/distroless/base-debian12@sha256:58695f439f772a00009c8f6be4c183f824c1f556d74b313c30900f167e4772f8
FROM gcr.io/distroless/base-debian12@sha256:e7e678c88c59e70e105a46549bb3fbfb3d732ee3b4afd3a19fdab2e15afaa6b3

@@ -37,0 +37,0 @@ # Add required MCP server annotation

@@ -47,2 +47,4 @@ # Feature Flags

- `repo`: Repository name (string, required)
- `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional)
- `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like reviewers) and the user has already confirmed the action. (boolean, optional, conditional — visible when remote_mcp_ui_apps is enabled unless the client explicitly indicates it does not support io.modelcontextprotocol/ui)
- `title`: PR title (string, required)

@@ -70,7 +72,29 @@

- `repo`: Repository name (string, required)
- `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, or state changes) and the user has already confirmed the action. (boolean, optional, conditional — visible when remote_mcp_ui_apps is enabled unless the client explicitly indicates it does not support io.modelcontextprotocol/ui)
- `state`: New state (string, optional)
- `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional)
- `title`: Issue title (string, optional)
- `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional)
- `type`: Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string, optional)
- **ui_get** - Get UI data
- **Required OAuth Scopes**: `repo`, `read:org`
- **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org`
- `method`: The type of data to fetch (string, required)
- `owner`: Repository owner (required for all methods) (string, required)
- `repo`: Repository name (required for labels, assignees, milestones, branches, issue fields, reviewers) (string, optional)
- **update_pull_request** - Edit pull request
- **Required OAuth Scopes**: `repo`
- **MCP App UI**: `ui://github-mcp-server/pr-edit`
- `base`: New base branch name (string, optional)
- `body`: New description (string, optional)
- `draft`: Mark pull request as draft (true) or ready for review (false) (boolean, optional)
- `maintainer_can_modify`: Allow maintainer edits (boolean, optional)
- `owner`: Repository owner (string, required)
- `pullNumber`: Pull request number to update (number, required)
- `repo`: Repository name (string, required)
- `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional)
- `state`: New state (string, optional)
- `title`: New title (string, optional)
### `remote_mcp_issue_fields`

@@ -97,6 +121,6 @@

- `title`: Issue title (string, optional)
- `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional)
- `type`: Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string, optional)
- **list_issue_fields** - List issue fields
- **Required OAuth Scopes**: `repo`, `read:org`
- **Required OAuth Scopes (any of)**: `repo`, `read:org`
- **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org`

@@ -204,3 +228,3 @@ - `owner`: The account owner of the repository or organization. The name is not case sensitive. (string, required)

- **Required OAuth Scopes**: `repo`
- `confidence`: How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal. (string, optional)
- `confidence`: How confident you are in this choice. Use 'HIGH' for clear signal or explicit user request, 'MEDIUM' for reasonable inference with some ambiguity, 'LOW' for best guess with limited signal. (string, optional)
- `is_suggestion`: If true, this issue type change is sent to the API as a suggestion (suggest:true) rather than an applied value. Whether the type is applied or recorded as a proposal is determined by the API. (boolean, optional)

@@ -207,0 +231,0 @@ - `issue_number`: The issue number to update (number, required)

@@ -41,2 +41,4 @@ # Insiders Features

- `repo`: Repository name (string, required)
- `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional)
- `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like reviewers) and the user has already confirmed the action. (boolean, optional, conditional — visible when remote_mcp_ui_apps is enabled unless the client explicitly indicates it does not support io.modelcontextprotocol/ui)
- `title`: PR title (string, required)

@@ -64,7 +66,29 @@

- `repo`: Repository name (string, required)
- `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, or state changes) and the user has already confirmed the action. (boolean, optional, conditional — visible when remote_mcp_ui_apps is enabled unless the client explicitly indicates it does not support io.modelcontextprotocol/ui)
- `state`: New state (string, optional)
- `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional)
- `title`: Issue title (string, optional)
- `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional)
- `type`: Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string, optional)
- **ui_get** - Get UI data
- **Required OAuth Scopes**: `repo`, `read:org`
- **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org`
- `method`: The type of data to fetch (string, required)
- `owner`: Repository owner (required for all methods) (string, required)
- `repo`: Repository name (required for labels, assignees, milestones, branches, issue fields, reviewers) (string, optional)
- **update_pull_request** - Edit pull request
- **Required OAuth Scopes**: `repo`
- **MCP App UI**: `ui://github-mcp-server/pr-edit`
- `base`: New base branch name (string, optional)
- `body`: New description (string, optional)
- `draft`: Mark pull request as draft (true) or ready for review (false) (boolean, optional)
- `maintainer_can_modify`: Allow maintainer edits (boolean, optional)
- `owner`: Repository owner (string, required)
- `pullNumber`: Pull request number to update (number, required)
- `repo`: Repository name (string, required)
- `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional)
- `state`: New state (string, optional)
- `title`: New title (string, optional)
### `remote_mcp_issue_fields`

@@ -91,6 +115,6 @@

- `title`: Issue title (string, optional)
- `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional)
- `type`: Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string, optional)
- **list_issue_fields** - List issue fields
- **Required OAuth Scopes**: `repo`, `read:org`
- **Required OAuth Scopes (any of)**: `repo`, `read:org`
- **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org`

@@ -97,0 +121,0 @@ - `owner`: The account owner of the repository or organization. The name is not case sensitive. (string, required)

@@ -25,2 +25,3 @@ # Remote GitHub MCP Server šŸš€

| <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/workflow-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/workflow-light.png"><img src="../pkg/octicons/icons/workflow-light.png" width="20" height="20" alt="workflow"></picture><br>`actions` | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) |
| <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/code-square-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/code-square-light.png"><img src="../pkg/octicons/icons/code-square-light.png" width="20" height="20" alt="code-square"></picture><br>`code_quality` | GitHub Code Quality related tools | https://api.githubcopilot.com/mcp/x/code_quality | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_quality&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_quality%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_quality/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_quality&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_quality%2Freadonly%22%7D) |
| <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/codescan-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/codescan-light.png"><img src="../pkg/octicons/icons/codescan-light.png" width="20" height="20" alt="codescan"></picture><br>`code_security` | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) |

@@ -27,0 +28,0 @@ | <picture><source media="(prefers-color-scheme: dark)" srcset="../pkg/octicons/icons/copilot-dark.png"><source media="(prefers-color-scheme: light)" srcset="../pkg/octicons/icons/copilot-light.png"><img src="../pkg/octicons/icons/copilot-light.png" width="20" height="20" alt="copilot"></picture><br>`copilot` | Copilot related tools | https://api.githubcopilot.com/mcp/x/copilot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%2Freadonly%22%7D) |

@@ -154,2 +154,3 @@ # Toolsets and Icons

| Actions | `workflow` |
| Code Quality | `code-square` |
| Code Security | `codescan` |

@@ -156,0 +157,0 @@ | Secret Protection | `shield-lock` |

@@ -6,5 +6,2 @@ package errors

"fmt"
"net/http"
"testing"
"time"
"github.com/google/go-github/v87/github"

@@ -14,2 +11,5 @@ "github.com/modelcontextprotocol/go-sdk/mcp"

"github.com/stretchr/testify/require"
"net/http"
"testing"
"time"
)

@@ -692,2 +692,1 @@

}

@@ -279,3 +279,3 @@ package github

// follows repo visibility.
result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelListIssues)
result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoUserContent)
return result, nil, nil

@@ -388,3 +388,3 @@ },

// follows repo visibility.
result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelListIssues)
result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelRepoUserContent)
return result, nil, nil

@@ -597,3 +597,3 @@ },

// follows repo visibility.
result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelListIssues)
result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelRepoUserContent)
return result, nil, nil

@@ -600,0 +600,0 @@ },

@@ -104,9 +104,3 @@ package github

result := utils.NewToolResultText(string(r))
// Gist contents are user-authored (untrusted); confidentiality is
// the IFC join of each gist's own public/secret flag.
visibilities := make([]bool, 0, len(gists))
for _, g := range gists {
visibilities = append(visibilities, g.GetPublic())
}
result = attachJoinedIFCLabel(ctx, deps, result, visibilities, ifc.LabelGistList)
result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGistList())
return result, nil, nil

@@ -171,5 +165,3 @@ },

result := utils.NewToolResultText(string(r))
// Gist contents are user-authored (untrusted); confidentiality
// derives from the gist's own public/secret flag.
result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGist(gist.GetPublic()))
result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGist())
return result, nil, nil

@@ -176,0 +168,0 @@ },

@@ -479,3 +479,3 @@ package github

"labels": []any{
map[string]any{"name": "bug", "confidence": "high"},
map[string]any{"name": "bug", "confidence": "HIGH"},
},

@@ -485,3 +485,3 @@ },

"labels": []any{
map[string]any{"name": "bug", "confidence": "high"},
map[string]any{"name": "bug", "confidence": "HIGH"},
},

@@ -497,3 +497,3 @@ },

"labels": []any{
map[string]any{"name": "bug", "rationale": "Reports a crash", "confidence": "medium"},
map[string]any{"name": "bug", "rationale": "Reports a crash", "confidence": "MEDIUM"},
},

@@ -503,3 +503,3 @@ },

"labels": []any{
map[string]any{"name": "bug", "rationale": "Reports a crash", "confidence": "medium"},
map[string]any{"name": "bug", "rationale": "Reports a crash", "confidence": "MEDIUM"},
},

@@ -509,2 +509,18 @@ },

{
name: "label confidence is normalized",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(1),
"labels": []any{
map[string]any{"name": "bug", "confidence": " high\t"},
},
},
expectedReq: map[string]any{
"labels": []any{
map[string]any{"name": "bug", "confidence": "HIGH"},
},
},
},
{
name: "invalid confidence value",

@@ -536,3 +552,3 @@ requestArgs: map[string]any{

errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, "confidence must be one of: low, medium, high")
assert.Contains(t, errorContent.Text, "confidence must be one of: LOW, MEDIUM, HIGH")
return

@@ -751,3 +767,3 @@ }

"issue_type": "bug",
"confidence": "high",
"confidence": "HIGH",
},

@@ -757,3 +773,3 @@ expectedReq: map[string]any{

"value": "bug",
"confidence": "high",
"confidence": "HIGH",
},

@@ -770,3 +786,3 @@ },

"rationale": "Asks for dark mode support",
"confidence": "medium",
"confidence": "MEDIUM",
},

@@ -777,3 +793,3 @@ expectedReq: map[string]any{

"rationale": "Asks for dark mode support",
"confidence": "medium",
"confidence": "MEDIUM",
},

@@ -789,3 +805,3 @@ },

"issue_type": "bug",
"confidence": "low",
"confidence": "LOW",
},

@@ -795,6 +811,22 @@ expectedReq: map[string]any{

"value": "bug",
"confidence": "low",
"confidence": "LOW",
},
},
},
{
name: "type confidence is normalized",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(1),
"issue_type": "bug",
"confidence": " medium ",
},
expectedReq: map[string]any{
"type": map[string]any{
"value": "bug",
"confidence": "MEDIUM",
},
},
},
}

@@ -835,3 +867,3 @@

},
expectedErrText: "confidence must be one of: low, medium, high",
expectedErrText: "confidence must be one of: LOW, MEDIUM, HIGH",
},

@@ -1615,3 +1647,3 @@ {

t.Run("successful set with confidence", func(t *testing.T) {
confidence := "high"
confidence := "HIGH"
matchers := []githubv4mock.Matcher{

@@ -1697,3 +1729,3 @@ githubv4mock.NewQueryMatcher(

"text_value": "hello",
"confidence": "high",
"confidence": " high ",
},

@@ -1727,7 +1759,7 @@ },

textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, "confidence must be one of: low, medium, high")
assert.Contains(t, textContent.Text, "confidence must be one of: LOW, MEDIUM, HIGH")
})
t.Run("confidence is sent when supplied", func(t *testing.T) {
confidence := "high"
confidence := "HIGH"
matchers := []githubv4mock.Matcher{

@@ -1813,3 +1845,3 @@ githubv4mock.NewQueryMatcher(

"text_value": "hello",
"confidence": "high",
"confidence": "HIGH",
},

@@ -1816,0 +1848,0 @@ },

@@ -25,2 +25,3 @@ package github

GetUser = "GET /user"
GetUsersByUsername = "GET /users/{username}"
GetUserStarred = "GET /user/starred"

@@ -105,2 +106,5 @@ GetUsersGistsByUsername = "GET /users/{username}/gists"

// Code quality endpoints
GetReposCodeQualityFindingsByOwnerByRepoByFindingNumber = "GET /repos/{owner}/{repo}/code-quality/findings/{finding_number}"
// Code scanning endpoints

@@ -107,0 +111,0 @@ GetReposCodeScanningAlertsByOwnerByRepo = "GET /repos/{owner}/{repo}/code-scanning/alerts"

@@ -20,2 +20,6 @@ package github

func shouldAttachIFCLabel(ctx context.Context, deps ToolDependencies, r *mcp.CallToolResult) bool {
return r != nil && !r.IsError && deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels)
}
// attachStaticIFCLabel attaches a fixed IFC label to a successful tool result

@@ -29,3 +33,3 @@ // when IFC labels are enabled. It is used by tools whose label does not depend

func attachStaticIFCLabel(ctx context.Context, deps ToolDependencies, r *mcp.CallToolResult, label ifc.SecurityLabel) *mcp.CallToolResult {
if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) {
if !shouldAttachIFCLabel(ctx, deps, r) {
return r

@@ -54,3 +58,3 @@ }

) *mcp.CallToolResult {
if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) {
if !shouldAttachIFCLabel(ctx, deps, r) {
return r

@@ -92,3 +96,3 @@ }

) *mcp.CallToolResult {
if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) {
if !shouldAttachIFCLabel(ctx, deps, r) {
return r

@@ -104,8 +108,7 @@ }

// attachJoinedIFCLabel attaches an IFC label computed by joining a set of
// per-item visibilities (true == private for repositories, true == public for
// gists) when IFC labels are enabled. joinFn is the lattice join for the
// relevant item kind (e.g. ifc.LabelSearchIssues or ifc.LabelGistList). The
// visibility slice is cheap to build from an already-fetched response, so
// callers may construct it unconditionally and let this helper own the
// feature-flag gate.
// per-item visibilities (true == private) when IFC labels are enabled. joinFn
// is the lattice join for the relevant item kind (e.g. ifc.LabelSearchIssues or
// ifc.LabelProjectList). The visibility slice is cheap to build from an
// already-fetched response, so callers may construct it unconditionally and let
// this helper own the feature-flag gate.
func attachJoinedIFCLabel(

@@ -118,3 +121,3 @@ ctx context.Context,

) *mcp.CallToolResult {
if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) {
if !shouldAttachIFCLabel(ctx, deps, r) {
return r

@@ -126,2 +129,16 @@ }

func attachProjectVisibilityIFCLabel(
ctx context.Context,
deps ToolDependencies,
r *mcp.CallToolResult,
isPrivate bool,
labelFn func(isPrivate bool) ifc.SecurityLabel,
) *mcp.CallToolResult {
if !shouldAttachIFCLabel(ctx, deps, r) {
return r
}
setIFCLabel(r, labelFn(isPrivate))
return r
}
// newRepoVisibilityIFCLabeler returns a closure that attaches a repo-visibility

@@ -128,0 +145,0 @@ // IFC label to a tool result, for handlers that have several return paths and

@@ -22,2 +22,6 @@ package github

func normalizeConfidence(confidence string) string {
return strings.ToUpper(strings.TrimSpace(confidence))
}
// issueUpdateTool is a helper to create single-field issue update tools.

@@ -285,3 +289,3 @@ func issueUpdateTool(

Name: "update_issue_labels",
Description: t("TOOL_UPDATE_ISSUE_LABELS_DESCRIPTION", "Update the labels of an existing issue. This replaces the current labels with the provided list. When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice."),
Description: t("TOOL_UPDATE_ISSUE_LABELS_DESCRIPTION", "Update the labels of an existing issue. This replaces the current labels with the provided list. When setting values, include a confidence level (LOW, MEDIUM, or HIGH) reflecting how certain you are about the choice."),
Annotations: &mcp.ToolAnnotations{

@@ -330,4 +334,4 @@ Title: t("TOOL_UPDATE_ISSUE_LABELS_USER_TITLE", "Update Issue Labels"),

Type: "string",
Description: "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.",
Enum: []any{"low", "medium", "high"},
Description: "How confident you are in this choice. Use 'HIGH' for clear signal or explicit user request, 'MEDIUM' for reasonable inference with some ambiguity, 'LOW' for best guess with limited signal.",
Enum: []any{"LOW", "MEDIUM", "HIGH"},
},

@@ -404,4 +408,5 @@ "is_suggestion": {

}
if confidence != "" && confidence != "low" && confidence != "medium" && confidence != "high" {
return utils.NewToolResultError("confidence must be one of: low, medium, high"), nil, nil
confidence = normalizeConfidence(confidence)
if confidence != "" && confidence != "LOW" && confidence != "MEDIUM" && confidence != "HIGH" {
return utils.NewToolResultError("confidence must be one of: LOW, MEDIUM, HIGH"), nil, nil
}

@@ -512,3 +517,3 @@ isSuggestion, err := OptionalParam[bool](v, "is_suggestion")

Name: "update_issue_type",
Description: t("TOOL_UPDATE_ISSUE_TYPE_DESCRIPTION", "Update the type of an existing issue (e.g. 'bug', 'feature'). When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice."),
Description: t("TOOL_UPDATE_ISSUE_TYPE_DESCRIPTION", "Update the type of an existing issue (e.g. 'bug', 'feature'). When setting values, include a confidence level (LOW, MEDIUM, or HIGH) reflecting how certain you are about the choice."),
Annotations: &mcp.ToolAnnotations{

@@ -548,4 +553,4 @@ Title: t("TOOL_UPDATE_ISSUE_TYPE_USER_TITLE", "Update Issue Type"),

Type: "string",
Description: "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.",
Enum: []any{"low", "medium", "high"},
Description: "How confident you are in this choice. Use 'HIGH' for clear signal or explicit user request, 'MEDIUM' for reasonable inference with some ambiguity, 'LOW' for best guess with limited signal.",
Enum: []any{"LOW", "MEDIUM", "HIGH"},
},

@@ -591,4 +596,5 @@ "is_suggestion": {

}
if confidence != "" && confidence != "low" && confidence != "medium" && confidence != "high" {
return utils.NewToolResultError("confidence must be one of: low, medium, high"), nil, nil
confidence = normalizeConfidence(confidence)
if confidence != "" && confidence != "LOW" && confidence != "MEDIUM" && confidence != "HIGH" {
return utils.NewToolResultError("confidence must be one of: LOW, MEDIUM, HIGH"), nil, nil
}

@@ -997,4 +1003,4 @@ isSuggestion, err := OptionalParam[bool](args, "is_suggestion")

Type: "string",
Description: "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.",
Enum: []any{"low", "medium", "high"},
Description: "How confident you are in this choice. Use 'HIGH' for clear signal or explicit user request, 'MEDIUM' for reasonable inference with some ambiguity, 'LOW' for best guess with limited signal.",
Enum: []any{"LOW", "MEDIUM", "HIGH"},
},

@@ -1122,4 +1128,5 @@ "is_suggestion": {

}
if confidence != "" && confidence != "low" && confidence != "medium" && confidence != "high" {
return utils.NewToolResultError("confidence must be one of: low, medium, high"), nil, nil
confidence = normalizeConfidence(confidence)
if confidence != "" && confidence != "LOW" && confidence != "MEDIUM" && confidence != "HIGH" {
return utils.NewToolResultError("confidence must be one of: LOW, MEDIUM, HIGH"), nil, nil
}

@@ -1126,0 +1133,0 @@ if confidence != "" {

@@ -369,2 +369,192 @@ package github

func Test_detectOwnerType(t *testing.T) {
t.Run("uses organization account type", func(t *testing.T) {
mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetUsersByUsername: mockResponse(t, http.StatusOK, map[string]any{
"login": "github",
"type": "Organization",
}),
})
client := mustNewGHClient(t, mockedClient)
ownerType, err := detectOwnerType(context.Background(), client, "github", 1)
require.NoError(t, err)
assert.Equal(t, "org", ownerType)
})
t.Run("uses user account type", func(t *testing.T) {
mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetUsersByUsername: mockResponse(t, http.StatusOK, map[string]any{
"login": "octocat",
"type": "User",
}),
})
client := mustNewGHClient(t, mockedClient)
ownerType, err := detectOwnerType(context.Background(), client, "octocat", 1)
require.NoError(t, err)
assert.Equal(t, "user", ownerType)
})
t.Run("falls back to project probes", func(t *testing.T) {
mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetUsersProjectsV2ByUsernameByProject: mockResponse(t, http.StatusNotFound, nil),
GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, map[string]any{"id": 1}),
})
client := mustNewGHClient(t, mockedClient)
ownerType, err := detectOwnerType(context.Background(), client, "octo-org", 1)
require.NoError(t, err)
assert.Equal(t, "org", ownerType)
})
}
func Test_ProjectsList_IFC_InsidersMode(t *testing.T) {
toolDef := ProjectsList(translations.NullTranslationHelper)
t.Run("list_projects joins returned project visibilities", func(t *testing.T) {
projects := []map[string]any{
{"id": 1, "node_id": "NODE1", "title": "Public Project", "public": true},
{"id": 2, "node_id": "NODE2", "title": "Private Project", "public": false},
}
mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetOrgsProjectsV2: mockResponse(t, http.StatusOK, projects),
})
client := mustNewGHClient(t, mockedClient)
deps := BaseDeps{
Client: client,
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "list_projects",
"owner": "octo-org",
"owner_type": "org",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
require.NotNil(t, result.Meta)
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
assert.Equal(t, "untrusted", ifcMap["integrity"])
assert.Equal(t, "private", ifcMap["confidentiality"])
})
t.Run("list_project_fields uses project metadata label", func(t *testing.T) {
fields := []map[string]any{{"id": 101, "name": "Status", "data_type": "single_select"}}
project := map[string]any{"id": 1, "node_id": "NODE1", "title": "Private Project", "public": false}
mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetOrgsProjectsV2FieldsByProject: mockResponse(t, http.StatusOK, fields),
GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project),
})
client := mustNewGHClient(t, mockedClient)
deps := BaseDeps{
Client: client,
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "list_project_fields",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(1),
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
require.NotNil(t, result.Meta)
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
assert.Equal(t, "trusted", ifcMap["integrity"])
assert.Equal(t, "private", ifcMap["confidentiality"])
})
t.Run("list_project_items uses project content label", func(t *testing.T) {
items := []map[string]any{verbosePullRequestProjectItemFixture()}
project := map[string]any{"id": 1, "node_id": "NODE1", "title": "Private Project", "public": false}
mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetOrgsProjectsV2ItemsByProject: mockResponse(t, http.StatusOK, items),
GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project),
})
client := mustNewGHClient(t, mockedClient)
deps := BaseDeps{
Client: client,
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "list_project_items",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(1),
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
require.NotNil(t, result.Meta)
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
assert.Equal(t, "untrusted", ifcMap["integrity"])
assert.Equal(t, "private", ifcMap["confidentiality"])
})
t.Run("list_project_status_updates uses GraphQL project visibility", func(t *testing.T) {
gqlMockedClient := githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
statusUpdatesOrgQuery{},
map[string]any{
"owner": githubv4.String("octo-org"),
"projectNumber": githubv4.Int(1),
"first": githubv4.Int(50),
"after": (*githubv4.String)(nil),
},
githubv4mock.DataResponse(map[string]any{
"organization": map[string]any{
"projectV2": map[string]any{
"public": true,
"statusUpdates": map[string]any{
"nodes": []map[string]any{},
"pageInfo": map[string]any{
"hasNextPage": false,
"hasPreviousPage": false,
"startCursor": "",
"endCursor": "",
},
},
},
},
}),
),
)
deps := BaseDeps{
Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})),
GQLClient: githubv4.NewClient(gqlMockedClient),
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "list_project_status_updates",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(1),
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
require.NotNil(t, result.Meta)
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
assert.Equal(t, "untrusted", ifcMap["integrity"])
assert.Equal(t, "public", ifcMap["confidentiality"])
})
}
func Test_ProjectsGet(t *testing.T) {

@@ -442,2 +632,75 @@ // Verify tool definition once

func Test_ProjectsGet_IFC_InsidersMode(t *testing.T) {
toolDef := ProjectsGet(translations.NullTranslationHelper)
t.Run("get_project uses project metadata label", func(t *testing.T) {
project := map[string]any{"id": 123, "node_id": "NODE1", "title": "Private Project", "public": false}
mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project),
})
client := mustNewGHClient(t, mockedClient)
deps := BaseDeps{
Client: client,
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "get_project",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(1),
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
require.NotNil(t, result.Meta)
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
assert.Equal(t, "trusted", ifcMap["integrity"])
assert.Equal(t, "private", ifcMap["confidentiality"])
})
t.Run("get_project_status_update uses GraphQL project visibility", func(t *testing.T) {
gqlMockedClient := githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
statusUpdateNodeQuery{},
map[string]any{
"id": githubv4.ID("SU_abc123"),
},
githubv4mock.DataResponse(map[string]any{
"node": map[string]any{
"id": "SU_abc123",
"body": "On track",
"status": "ON_TRACK",
"createdAt": "2026-01-15T10:00:00Z",
"startDate": "2026-01-01",
"targetDate": "2026-03-01",
"creator": map[string]any{"login": "octocat"},
"project": map[string]any{"public": true},
},
}),
),
)
deps := BaseDeps{
GQLClient: githubv4.NewClient(gqlMockedClient),
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "get_project_status_update",
"status_update_id": "SU_abc123",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
require.NotNil(t, result.Meta)
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
assert.Equal(t, "untrusted", ifcMap["integrity"])
assert.Equal(t, "public", ifcMap["confidentiality"])
})
}
func Test_ProjectsGet_GetProjectField(t *testing.T) {

@@ -1096,2 +1359,3 @@ toolDef := ProjectsGet(translations.NullTranslationHelper)

"projectV2": map[string]any{
"public": false,
"statusUpdates": map[string]any{

@@ -1167,2 +1431,3 @@ "nodes": []map[string]any{

"creator": map[string]any{"login": "octocat"},
"project": map[string]any{"public": false},
},

@@ -1169,0 +1434,0 @@ }),

@@ -241,3 +241,3 @@ package github

t.Run("insiders mode any private match emits private untrusted", func(t *testing.T) {
t.Run("insiders mode mixed public and private emits private untrusted", func(t *testing.T) {
deps := BaseDeps{

@@ -244,0 +244,0 @@ Client: mustNewGHClient(t, makeMockClient([]repoFixture{

@@ -176,4 +176,5 @@ package github

// no extra API call. The join math is shared with search_issues via
// ifc.LabelSearchIssues: integrity is always untrusted; confidentiality is
// private if any matched repository is private, otherwise public. The
// ifc.LabelSearchIssues: public-only results stay public-untrusted,
// mixed-visibility results become private-untrusted, and all-private results
// become private-trusted. The
// feature-flag check is centralized here (mirroring the attach* helpers in

@@ -306,5 +307,5 @@ // ifc_labels.go) so the handler can call this unconditionally.

callResult := utils.NewToolResultText(string(r))
// Code search spans repositories and exposes file contents
// (untrusted). Confidentiality is the IFC join across every matched
// repository's visibility, read directly from the search response.
// Code search spans repositories; the IFC label is the conservative
// join across every matched repository's visibility, read directly
// from the search response.
visibilities := make([]bool, 0, len(result.CodeResults))

@@ -598,5 +599,5 @@ for _, code := range result.CodeResults {

callResult := utils.NewToolResultText(string(r))
// Commit search spans repositories and exposes commit content
// (untrusted). Confidentiality is the IFC join across every matched
// repository's visibility, read directly from the search response.
// Commit search spans repositories; the IFC label is the conservative
// join across every matched repository's visibility, read directly
// from the search response.
visibilities := make([]bool, 0, len(result.Commits))

@@ -603,0 +604,0 @@ for _, commit := range result.Commits {

@@ -8,6 +8,7 @@ package github

"github.com/google/go-github/v87/github"
"github.com/shurcooL/githubv4"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/google/go-github/v87/github"
"github.com/shurcooL/githubv4"
)

@@ -80,2 +81,7 @@

}
ToolsetMetadataCodeQuality = inventory.ToolsetMetadata{
ID: "code_quality",
Description: "GitHub Code Quality related tools",
Icon: "code-square",
}
ToolsetMetadataCodeSecurity = inventory.ToolsetMetadata{

@@ -240,2 +246,5 @@ ID: "code_security",

// Code quality tools
GetCodeQualityFinding(t),
// Code security tools

@@ -297,2 +306,5 @@ GetCodeScanningAlert(t),

// UI tools (insiders only)
UIGet(t),
// Granular issue tools (feature-flagged, replace consolidated issue_write/sub_issue_write)

@@ -299,0 +311,0 @@ GranularCreateIssue(t),

@@ -58,2 +58,3 @@ package github

PullRequestWriteUIResourceURI,
PullRequestEditUIResourceURI,
}

@@ -60,0 +61,0 @@ for _, uri := range uris {

@@ -110,2 +110,29 @@ package github

)
s.AddResource(
&mcp.Resource{
URI: PullRequestEditUIResourceURI,
Name: "pr_edit_ui",
Description: "MCP App UI for editing GitHub pull requests",
MIMEType: MCPAppMIMEType,
},
func(_ context.Context, _ *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
html := MustGetUIAsset("pr-edit.html")
return &mcp.ReadResourceResult{
Contents: []*mcp.ResourceContents{
{
URI: PullRequestEditUIResourceURI,
MIMEType: MCPAppMIMEType,
Text: html,
Meta: mcp.Meta{
"ui": map[string]any{
"csp": map[string]any{},
"prefersBorder": true,
},
},
},
},
}, nil
},
)
}

@@ -128,2 +128,43 @@ package http

func TestResolveListenAddress(t *testing.T) {
tests := []struct {
name string
host string
port int
want string
}{
{
name: "empty host falls back to :port",
host: "",
port: 8082,
want: ":8082",
},
{
name: "ipv4 host is joined with port",
host: "127.0.0.1",
port: 9090,
want: "127.0.0.1:9090",
},
{
name: "ipv6 host is bracketed and joined with port",
host: "::1",
port: 9090,
want: "[::1]:9090",
},
{
name: "hostname is joined with port",
host: "localhost",
port: 8082,
want: "localhost:8082",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveListenAddress(tt.host, tt.port)
assert.Equal(t, tt.want, got)
})
}
}
func TestHeaderAllowedFeatureFlagsMatchesAllowed(t *testing.T) {

@@ -130,0 +171,0 @@ // Ensure HeaderAllowedFeatureFlags delegates to AllowedFeatureFlags

@@ -8,5 +8,7 @@ package http

"log/slog"
"net"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"

@@ -36,5 +38,9 @@ "time"

// Port to listen on (default: 8082)
// Port to listen on (default: 8082).
Port int
// ListenHost is the host the HTTP server binds to (e.g. "127.0.0.1").
// When empty, the server binds to all interfaces. Combined with Port.
ListenHost string
// BaseURL is the publicly accessible URL of this server for OAuth resource metadata.

@@ -197,3 +203,3 @@ // If not set, the server will derive the URL from incoming request headers.

addr := fmt.Sprintf(":%d", cfg.Port)
addr := resolveListenAddress(cfg.ListenHost, cfg.Port)
httpSvr := http.Server{

@@ -229,2 +235,12 @@ Addr: addr,

// resolveListenAddress returns the address string passed to http.Server.
// When host is empty the server binds to all interfaces on the given port;
// otherwise host and port are joined into a single address.
func resolveListenAddress(host string, port int) string {
if host == "" {
return fmt.Sprintf(":%d", port)
}
return net.JoinHostPort(host, strconv.Itoa(port))
}
func initGlobalToolScopeMap(t translations.TranslationHelperFunc) error {

@@ -231,0 +247,0 @@ // Build inventory with all tools to extract scope information

@@ -9,2 +9,38 @@ package ifc

func TestLabelListIssues(t *testing.T) {
t.Parallel()
t.Run("public repo issues are untrusted and public", func(t *testing.T) {
t.Parallel()
label := LabelListIssues(false)
assert.Equal(t, IntegrityUntrusted, label.Integrity)
assert.Equal(t, ConfidentialityPublic, label.Confidentiality)
})
t.Run("private repo issues are trusted and private", func(t *testing.T) {
t.Parallel()
label := LabelListIssues(true)
assert.Equal(t, IntegrityTrusted, label.Integrity)
assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
})
}
func TestLabelRepoUserContent(t *testing.T) {
t.Parallel()
t.Run("public repo user content is untrusted and public", func(t *testing.T) {
t.Parallel()
label := LabelRepoUserContent(false)
assert.Equal(t, IntegrityUntrusted, label.Integrity)
assert.Equal(t, ConfidentialityPublic, label.Confidentiality)
})
t.Run("private repo user content is trusted and private", func(t *testing.T) {
t.Parallel()
label := LabelRepoUserContent(true)
assert.Equal(t, IntegrityTrusted, label.Integrity)
assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
})
}
func TestLabelSearchIssues(t *testing.T) {

@@ -15,3 +51,4 @@ t.Parallel()

name string
visibilities []bool
visibilities []bool // true == private
wantIntegrity Integrity
wantConfidential Confidentiality

@@ -21,2 +58,3 @@ }{

name: "empty result is treated as public",
wantIntegrity: IntegrityUntrusted,
wantConfidential: ConfidentialityPublic,

@@ -27,2 +65,3 @@ },

visibilities: []bool{false},
wantIntegrity: IntegrityUntrusted,
wantConfidential: ConfidentialityPublic,

@@ -33,12 +72,15 @@ },

visibilities: []bool{false, false, false},
wantIntegrity: IntegrityUntrusted,
wantConfidential: ConfidentialityPublic,
},
{
name: "any private match flips to private",
name: "mixed public and private repos become untrusted private",
visibilities: []bool{false, true, false},
wantIntegrity: IntegrityUntrusted,
wantConfidential: ConfidentialityPrivate,
},
{
name: "all private repos stay private",
name: "all private repos stay trusted private",
visibilities: []bool{true, true},
wantIntegrity: IntegrityTrusted,
wantConfidential: ConfidentialityPrivate,

@@ -52,3 +94,3 @@ },

label := LabelSearchIssues(tc.visibilities)
assert.Equal(t, IntegrityUntrusted, label.Integrity)
assert.Equal(t, tc.wantIntegrity, label.Integrity)
assert.Equal(t, tc.wantConfidential, label.Confidentiality)

@@ -217,3 +259,3 @@ })

t.Parallel()
label := LabelGist(true)
label := LabelGist()
assert.Equal(t, IntegrityUntrusted, label.Integrity)

@@ -223,7 +265,7 @@ assert.Equal(t, ConfidentialityPublic, label.Confidentiality)

t.Run("secret gist is untrusted and private", func(t *testing.T) {
t.Run("secret gist is untrusted and public", func(t *testing.T) {
t.Parallel()
label := LabelGist(false)
label := LabelGist()
assert.Equal(t, IntegrityUntrusted, label.Integrity)
assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
assert.Equal(t, ConfidentialityPublic, label.Confidentiality)
})

@@ -235,5 +277,32 @@ }

label := LabelGistList()
assert.Equal(t, IntegrityUntrusted, label.Integrity)
assert.Equal(t, ConfidentialityPublic, label.Confidentiality)
}
func TestLabelProject(t *testing.T) {
t.Parallel()
t.Run("public project is untrusted and public", func(t *testing.T) {
t.Parallel()
label := LabelProject(false)
assert.Equal(t, IntegrityUntrusted, label.Integrity)
assert.Equal(t, ConfidentialityPublic, label.Confidentiality)
})
t.Run("private project metadata is trusted and private", func(t *testing.T) {
t.Parallel()
label := LabelProject(true)
assert.Equal(t, IntegrityTrusted, label.Integrity)
assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
})
}
func TestLabelProjectList(t *testing.T) {
t.Parallel()
tests := []struct {
name string
visibilities []bool // true == public
visibilities []bool // true == private
wantIntegrity Integrity
wantConfidential Confidentiality

@@ -243,17 +312,21 @@ }{

name: "empty result is treated as public",
wantIntegrity: IntegrityUntrusted,
wantConfidential: ConfidentialityPublic,
},
{
name: "all public gists stay public",
visibilities: []bool{true, true},
name: "all public projects stay public",
visibilities: []bool{false, false},
wantIntegrity: IntegrityUntrusted,
wantConfidential: ConfidentialityPublic,
},
{
name: "any secret gist flips to private",
visibilities: []bool{true, false, true},
name: "mixed public and private projects become untrusted private",
visibilities: []bool{false, true},
wantIntegrity: IntegrityUntrusted,
wantConfidential: ConfidentialityPrivate,
},
{
name: "all secret gists stay private",
visibilities: []bool{false, false},
name: "all private projects stay trusted private",
visibilities: []bool{true, true},
wantIntegrity: IntegrityTrusted,
wantConfidential: ConfidentialityPrivate,

@@ -266,4 +339,4 @@ },

t.Parallel()
label := LabelGistList(tc.visibilities)
assert.Equal(t, IntegrityUntrusted, label.Integrity)
label := LabelProjectList(tc.visibilities)
assert.Equal(t, tc.wantIntegrity, label.Integrity)
assert.Equal(t, tc.wantConfidential, label.Confidentiality)

@@ -274,8 +347,8 @@ })

func TestLabelProject(t *testing.T) {
func TestLabelProjectContent(t *testing.T) {
t.Parallel()
t.Run("public project is untrusted and public", func(t *testing.T) {
t.Run("public project content is untrusted and public", func(t *testing.T) {
t.Parallel()
label := LabelProject(true)
label := LabelProjectContent(false)
assert.Equal(t, IntegrityUntrusted, label.Integrity)

@@ -285,5 +358,5 @@ assert.Equal(t, ConfidentialityPublic, label.Confidentiality)

t.Run("private project is untrusted and private", func(t *testing.T) {
t.Run("private project content is untrusted and private", func(t *testing.T) {
t.Parallel()
label := LabelProject(false)
label := LabelProjectContent(true)
assert.Equal(t, IntegrityUntrusted, label.Integrity)

@@ -290,0 +363,0 @@ assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)

@@ -79,6 +79,7 @@ // Package ifc provides Information Flow Control labels for annotating MCP tool outputs.

// restricted to their collaborators (resolved client-side from the marker).
// Issue contents are attacker-controllable, so integrity is always untrusted.
// Public repository issue contents are attacker-controllable, while private
// repository issues are treated as trusted collaborator-authored data.
func LabelListIssues(isPrivate bool) SecurityLabel {
if isPrivate {
return PrivateUntrusted()
return PrivateTrusted()
}

@@ -88,2 +89,14 @@ return PublicUntrusted()

// LabelRepoUserContent returns the IFC label for user-authored content scoped
// to a repository when that tool has not opted into a more specific integrity
// policy. Public repository content is untrusted because it may be authored by
// outside contributors. Private repository content is trusted because users who
// can read it are trusted collaborators.
func LabelRepoUserContent(isPrivate bool) SecurityLabel {
if isPrivate {
return PrivateTrusted()
}
return PublicUntrusted()
}
// LabelGetFileContents returns the IFC label for a get_file_contents result.

@@ -104,7 +117,8 @@ // Public repository file contents may be authored by anyone via pull requests

//
// Integrity is always untrusted because results expose user-authored content.
//
// Confidentiality follows the IFC meet (greatest lower bound): if any matched
// repository is private the joined label is private; otherwise public. The
// reader set is opaque (the "private" marker); the client engine resolves
// Public-only results are untrusted and public. All-private results are trusted
// and private because private repository content is treated as trusted
// collaborator-authored data. Mixed public/private results are untrusted and
// private: the public items keep the joined payload's integrity untrusted,
// while the private items keep the joined payload's confidentiality private.
// The reader set is opaque (the "private" marker); the client engine resolves
// concrete readers on demand at egress decision time.

@@ -125,8 +139,18 @@ //

func LabelSearchIssues(repoVisibilities []bool) SecurityLabel {
var anyPrivate, anyPublic bool
for _, isPrivate := range repoVisibilities {
if isPrivate {
return PrivateUntrusted()
anyPrivate = true
} else {
anyPublic = true
}
}
return PublicUntrusted()
switch {
case anyPrivate && anyPublic:
return PrivateUntrusted()
case anyPrivate:
return PrivateTrusted()
default:
return PublicUntrusted()
}
}

@@ -268,11 +292,6 @@

// Integrity is untrusted: gist contents are arbitrary user-authored text.
// Confidentiality derives from the gist's own visibility rather than any
// repository — public gists are universally readable, while secret gists are
// restricted to those who hold the gist URL (modeled with the opaque "private"
// marker).
func LabelGist(isPublic bool) SecurityLabel {
if isPublic {
return PublicUntrusted()
}
return PrivateUntrusted()
// Confidentiality is public because secret gists are URL-accessible and cannot
// be modeled as private to a GitHub reader set.
func LabelGist() SecurityLabel {
return PublicUntrusted()
}

@@ -283,13 +302,23 @@

//
// Integrity is untrusted (user-authored content). Confidentiality follows the
// IFC meet: if any gist in the result is secret the joined label is private;
// otherwise public. An empty result is treated as public-untrusted.
// Integrity is untrusted (user-authored content). Confidentiality is public
// because even secret gists are URL-accessible.
//
// See LabelSearchIssues for why list results carry a single joined label
// rather than one label per item.
func LabelGistList(gistVisibilities []bool) SecurityLabel {
for _, isPublic := range gistVisibilities {
if !isPublic {
return PrivateUntrusted()
}
func LabelGistList() SecurityLabel {
return PublicUntrusted()
}
// LabelProject returns the IFC label for GitHub Project metadata (Projects v2),
// such as get_project results and project field definitions.
//
// Public project metadata can contain public user-authored text, so it is
// untrusted. Private project metadata is treated as trusted
// collaborator-controlled data.
//
// Confidentiality derives from the project's own privacy — private projects
// restrict the reader set, while public projects are universally readable.
func LabelProject(isPrivate bool) SecurityLabel {
if isPrivate {
return PrivateTrusted()
}

@@ -299,16 +328,39 @@ return PublicUntrusted()

// LabelProject returns the IFC label for a GitHub Project (Projects v2) and its
// items, status updates, and field definitions.
// LabelProjectList returns the IFC label for a list_projects result, joining
// the per-project labels across every returned project.
//
// Integrity is untrusted: project titles, item content, and status update
// bodies are user-authored free text. Confidentiality derives from the
// project's own public flag — public projects are universally readable, while
// private projects restrict the reader set.
func LabelProject(isPublic bool) SecurityLabel {
if isPublic {
// Public-only results are untrusted and public. All-private results are trusted
// and private. Mixed public/private results are untrusted and private: public
// items keep the joined payload's integrity untrusted, while private items keep
// the joined payload's confidentiality private.
func LabelProjectList(projectVisibilities []bool) SecurityLabel {
var anyPrivate, anyPublic bool
for _, isPrivate := range projectVisibilities {
if isPrivate {
anyPrivate = true
} else {
anyPublic = true
}
}
switch {
case anyPrivate && anyPublic:
return PrivateUntrusted()
case anyPrivate:
return PrivateTrusted()
default:
return PublicUntrusted()
}
return PrivateUntrusted()
}
// LabelProjectContent returns the IFC label for project results that can include
// item content, field values, or status update bodies. These can aggregate
// content from a variety of sources, so integrity remains untrusted even when
// the project is private.
func LabelProjectContent(isPrivate bool) SecurityLabel {
if isPrivate {
return PrivateUntrusted()
}
return PublicUntrusted()
}
// LabelTeam returns the IFC label for organization team membership data

@@ -315,0 +367,0 @@ // (get_teams, get_team_members).

@@ -10,2 +10,4 @@ package inventory

"strings"
"github.com/google/jsonschema-go/jsonschema"
)

@@ -410,2 +412,93 @@

// uiOnlySchemaProperties lists input-schema property names that should only
// be visible to clients that advertise MCP Apps UI support. They live on the
// static schema (so toolsnaps and the feature-flag / insiders docs document
// the full UI-capable surface; the main README renders the stripped
// non-UI schema) and are stripped per-request when the same gate that hides
// _meta.ui is true.
var uiOnlySchemaProperties = []string{
"show_ui", // explicit "render the MCP App form" toggle on form-backed write tools
}
// ConditionalSchemaPropertyDescriptions returns a map of schema property name
// to a human-readable description of the condition under which the property
// is visible to clients. The doc generator uses this to annotate conditional
// parameters so readers can see at a glance which fields are not always
// available. This is the single source of truth for the conditional-property
// surface — entries here must correspond to a strip rule in
// ToolsForRegistration.
func ConditionalSchemaPropertyDescriptions() map[string]string {
const uiOnlyCondition = "visible when remote_mcp_ui_apps is enabled unless the client explicitly indicates it does not support io.modelcontextprotocol/ui"
out := make(map[string]string, len(uiOnlySchemaProperties))
for _, name := range uiOnlySchemaProperties {
out[name] = uiOnlyCondition
}
return out
}
// stripUIOnlySchemaProperties removes UI-capability-gated input-schema
// properties (currently just "show_ui") from each tool's static schema.
// Tools whose InputSchema is not a *jsonschema.Schema (e.g. json.RawMessage)
// are passed through untouched — no such tool currently declares a gated
// property, and inferring intent from an opaque schema is not safe.
// Tools without any gated property are returned as-is so we only allocate
// when a change is actually made (mirrors the stripMetaKeys pattern).
func stripUIOnlySchemaProperties(tools []ServerTool) []ServerTool {
result := make([]ServerTool, 0, len(tools))
for _, tool := range tools {
if stripped := stripSchemaProperties(tool, uiOnlySchemaProperties); stripped != nil {
result = append(result, *stripped)
} else {
result = append(result, tool)
}
}
return result
}
// stripSchemaProperties removes the named keys from tool.Tool.InputSchema's
// Properties map (and Required list, if present) and returns a modified copy.
// Returns nil when the schema is not a *jsonschema.Schema or no listed key
// is present, signalling no change.
func stripSchemaProperties(tool ServerTool, keys []string) *ServerTool {
if tool.Tool.InputSchema == nil || len(keys) == 0 {
return nil
}
schema, ok := tool.Tool.InputSchema.(*jsonschema.Schema)
if !ok || schema == nil || len(schema.Properties) == 0 {
return nil
}
hasKey := false
for _, key := range keys {
if _, exists := schema.Properties[key]; exists {
hasKey = true
break
}
}
if !hasKey {
return nil
}
toolCopy := tool
schemaCopy := *schema
newProps := make(map[string]*jsonschema.Schema, len(schema.Properties))
for k, v := range schema.Properties {
if !slices.Contains(keys, k) {
newProps[k] = v
}
}
schemaCopy.Properties = newProps
if len(schemaCopy.Required) > 0 {
newRequired := make([]string, 0, len(schemaCopy.Required))
for _, r := range schemaCopy.Required {
if !slices.Contains(keys, r) {
newRequired = append(newRequired, r)
}
}
schemaCopy.Required = newRequired
}
toolCopy.Tool.InputSchema = &schemaCopy
return &toolCopy
}
// stripMetaKeys removes the specified Meta keys from a single tool.

@@ -412,0 +505,0 @@ // Returns a modified copy if changes were made, nil otherwise.

@@ -172,4 +172,5 @@ package inventory

// ToolsForRegistration returns AvailableTools(ctx) post-processed exactly as
// RegisterTools would expose them: with MCP Apps UI metadata stripped when
// the client cannot consume it. Useful for documentation generators and
// RegisterTools would expose them: with MCP Apps UI metadata stripped and
// UI-capability-gated input-schema properties (e.g. show_ui) removed when
// the client cannot consume them. Useful for documentation generators and
// diagnostics that need the same view of the tool surface the server would

@@ -190,2 +191,3 @@ // register.

tools = stripMCPAppsMetadata(tools)
tools = stripUIOnlySchemaProperties(tools)
}

@@ -211,5 +213,6 @@ return tools

//
// MCP Apps UI metadata (`_meta.ui`) is stripped from the registered tools
// when either the MCP Apps feature flag is not enabled for this request, or
// the client did not advertise the io.modelcontextprotocol/ui extension. The
// MCP Apps UI metadata (`_meta.ui`) and UI-capability-gated input-schema
// properties (e.g. `show_ui`) are stripped from the registered tools when
// either the MCP Apps feature flag is not enabled for this request, or the
// client did not advertise the io.modelcontextprotocol/ui extension. The
// strip happens here (rather than at Build() time) so the per-request

@@ -216,0 +219,0 @@ // context is in scope — HTTP feature checkers that read insiders mode or

@@ -22,2 +22,3 @@ # Required Octicons for the GitHub MCP Server

codescan
code-square
comment-discussion

@@ -24,0 +25,0 @@ copilot

@@ -62,1 +62,25 @@ package utils //nolint:revive //TODO: figure out a better name for this package

}
// NewToolResultAwaitingFormSubmission signals to the agent that a tool call
// has been intercepted to show an MCP App form to the user and has NOT
// performed the requested operation. The agent must stop, not chain dependent
// tool calls, and not claim the operation succeeded. The result is marked
// IsError=true so agents that bail on error don't proceed; the host still
// renders the UI because rendering is keyed off the tool's _meta.ui, not the
// result. The MCP App form will submit the operation directly when the user
// clicks submit, after which a ui/update-model-context call delivers the real
// outcome to the agent.
func NewToolResultAwaitingFormSubmission(message string) *mcp.CallToolResult {
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{
Text: message,
},
},
StructuredContent: map[string]any{
"status": "awaiting_user_submission",
"reason": "An interactive form is being shown to the user. The operation has not been performed.",
},
IsError: true,
}
}

@@ -32,5 +32,5 @@ {

"typescript": "^5.7.0",
"vite": "^8.0.13",
"vite": "^8.0.16",
"vite-plugin-singlefile": "^2.3.3"
}
}
// Build all UI apps in a single Node process.
//
// Replaces three serial `cross-env APP=<app> vite build` invocations: doing it
// in one process avoids paying Vite/plugin startup cost three times and is
// Replaces serial `cross-env APP=<app> vite build` invocations: doing it
// in one process avoids paying Vite/plugin startup cost for each app and is
// portable without `cross-env`.

@@ -9,3 +9,3 @@

const apps = ["get-me", "issue-write", "pr-write"];
const apps = ["get-me", "issue-write", "pr-write", "pr-edit"];

@@ -12,0 +12,0 @@ for (const app of apps) {

@@ -1,2 +0,2 @@

import { StrictMode, useState, useCallback, useEffect } from "react";
import { StrictMode, useState, useCallback, useEffect, useMemo, useRef } from "react";
import { createRoot } from "react-dom/client";

@@ -11,2 +11,6 @@ import {

FormControl,
CounterLabel,
ActionMenu,
ActionList,
Label,
} from "@primer/react";

@@ -16,2 +20,7 @@ import {

CheckCircleIcon,
TagIcon,
PersonIcon,
RepoIcon,
MilestoneIcon,
LockIcon,
} from "@primer/octicons-react";

@@ -32,2 +41,241 @@ import { AppProvider } from "../../components/AppProvider";

interface LabelItem {
id: string;
text: string;
color: string;
}
interface AssigneeItem {
id: string;
text: string;
}
interface MilestoneItem {
id: string;
number: number;
text: string;
description: string;
}
interface IssueTypeItem {
id: string;
text: string;
}
type IssueState = "open" | "closed";
type StateReason = "completed" | "not_planned" | "duplicate";
type IssueFieldPrimitive = string | number | boolean;
interface IssueFieldOption {
id: string;
name: string;
description: string;
color: string;
}
interface IssueFieldItem {
id: string;
name: string;
data_type: string;
description: string;
options: IssueFieldOption[];
}
interface IssueFieldValue {
value?: IssueFieldPrimitive;
optionName?: string;
cleared?: boolean;
}
interface IssueFieldSubmission {
field_name: string;
value?: IssueFieldPrimitive;
field_option_name?: string;
delete?: boolean;
}
interface RepositoryItem {
id: string;
owner: string;
name: string;
fullName: string;
isPrivate: boolean;
}
// Calculate text color based on background luminance
function getContrastColor(hexColor: string): string {
const r = parseInt(hexColor.substring(0, 2), 16);
const g = parseInt(hexColor.substring(2, 4), 16);
const b = parseInt(hexColor.substring(4, 6), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? "#000000" : "#ffffff";
}
const stateReasonOptions: Array<{ value: StateReason; label: string; description: string }> = [
{ value: "completed", label: "Completed", description: "The work is done" },
{ value: "not_planned", label: "Not planned", description: "The issue won't be worked on" },
{ value: "duplicate", label: "Duplicate", description: "Another issue tracks this" },
];
function normalizeSwatchColor(color: string): string {
const trimmed = color.trim();
if (!trimmed) return "var(--borderColor-default, var(--color-border-default))";
if (/^#?[0-9a-fA-F]{6}$/.test(trimmed)) {
return trimmed.startsWith("#") ? trimmed : `#${trimmed}`;
}
return trimmed.toLowerCase();
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function stringValue(value: unknown): string | undefined {
if (typeof value === "string" && value.trim()) return value;
if (typeof value === "number" && Number.isFinite(value)) return String(value);
return undefined;
}
function parseIssueState(value: unknown): IssueState | null {
return value === "open" || value === "closed" ? value : null;
}
function parseStateReason(value: unknown): StateReason | null {
return value === "completed" || value === "not_planned" || value === "duplicate" ? value : null;
}
function normalizeRawIssueFieldValue(
field: IssueFieldItem | undefined,
rawValue: unknown
): IssueFieldValue | null {
if (rawValue === null || rawValue === undefined) return null;
if (isRecord(rawValue)) {
const optionName =
stringValue(rawValue.optionName) ||
stringValue(rawValue.field_option_name) ||
stringValue(rawValue.name);
if (field?.data_type === "single_select" && optionName) {
return { optionName };
}
return normalizeRawIssueFieldValue(
field,
rawValue.value ?? rawValue.text ?? rawValue.number ?? rawValue.date ?? rawValue.name
);
}
if (field?.data_type === "single_select") {
const optionName = stringValue(rawValue);
return optionName ? { optionName } : null;
}
if (
typeof rawValue === "string" ||
typeof rawValue === "number" ||
typeof rawValue === "boolean"
) {
return { value: rawValue };
}
return null;
}
function parseStringIssueFieldValue(
entry: string,
fieldsByName: Map<string, IssueFieldItem>
): [string, IssueFieldValue] | null {
const match = entry.match(/^([^:=]+)\s*[:=]\s*(.*)$/);
if (!match) return null;
const fieldName = match[1].trim();
const field = fieldsByName.get(fieldName);
if (!field) return null;
const normalized = normalizeRawIssueFieldValue(field, match[2].trim());
return normalized ? [fieldName, normalized] : null;
}
function normalizeIssueFieldEntry(
entry: unknown,
fieldsByName: Map<string, IssueFieldItem>
): [string, IssueFieldValue] | null {
if (typeof entry === "string") return parseStringIssueFieldValue(entry, fieldsByName);
if (!isRecord(entry)) return null;
const fieldRecord = isRecord(entry.field) ? entry.field : undefined;
const entryName = stringValue(entry.name);
const fieldName =
stringValue(entry.field_name) ||
stringValue(entry.fieldName) ||
(fieldRecord ? stringValue(fieldRecord.name) : undefined) ||
entryName;
if (!fieldName) return null;
const field = fieldsByName.get(fieldName);
if (!field) return null;
if (entry.delete === true || entry.cleared === true) {
return [fieldName, { cleared: true }];
}
const directOptionName =
stringValue(entry.field_option_name) ||
stringValue(entry.fieldOptionName) ||
stringValue(entry.optionName) ||
(field.data_type === "single_select" && entryName && entryName !== fieldName ? entryName : undefined);
if (directOptionName) return [fieldName, { optionName: directOptionName }];
const optionRecord = isRecord(entry.option) ? entry.option : undefined;
const optionName = optionRecord ? stringValue(optionRecord.name) : undefined;
if (optionName) return [fieldName, { optionName }];
const normalized = normalizeRawIssueFieldValue(
field,
entry.value ?? entry.text ?? entry.number ?? entry.date
);
return normalized ? [fieldName, normalized] : null;
}
function normalizeIssueFieldValues(
input: unknown,
fields: IssueFieldItem[]
): Record<string, IssueFieldValue> {
const fieldsByName = new Map(fields.map((field) => [field.name, field]));
const values: Record<string, IssueFieldValue> = {};
if (Array.isArray(input)) {
for (const item of input) {
const normalized = normalizeIssueFieldEntry(item, fieldsByName);
if (normalized) values[normalized[0]] = normalized[1];
}
return values;
}
if (!isRecord(input)) return values;
const normalizedEntry = normalizeIssueFieldEntry(input, fieldsByName);
if (normalizedEntry) {
values[normalizedEntry[0]] = normalizedEntry[1];
return values;
}
for (const [fieldName, rawValue] of Object.entries(input)) {
const field = fieldsByName.get(fieldName);
if (!field) continue;
if (isRecord(rawValue)) {
const nested = normalizeIssueFieldEntry({ ...rawValue, field_name: fieldName }, fieldsByName);
if (nested) {
values[fieldName] = nested[1];
continue;
}
}
const normalized = normalizeRawIssueFieldValue(field, rawValue);
if (normalized) values[fieldName] = normalized;
}
return values;
}
function SuccessView({

@@ -38,2 +286,3 @@ issue,

submittedTitle,
submittedLabels,
isUpdate,

@@ -46,2 +295,3 @@ openLink,

submittedTitle: string;
submittedLabels: LabelItem[];
isUpdate: boolean;

@@ -126,2 +376,18 @@ openLink: (url: string) => Promise<void>;

</Text>
{submittedLabels.length > 0 && (
<Box display="flex" gap={1} mt={2} flexWrap="wrap">
{submittedLabels.map((label) => (
<Label
key={label.id}
sx={{
backgroundColor: `#${label.color}`,
color: getContrastColor(label.color),
borderColor: `#${label.color}`,
}}
>
{label.text}
</Label>
))}
</Box>
)}
</Box>

@@ -140,2 +406,40 @@ </Box>

// Labels state
const [availableLabels, setAvailableLabels] = useState<LabelItem[]>([]);
const [selectedLabels, setSelectedLabels] = useState<LabelItem[]>([]);
const [labelsLoading, setLabelsLoading] = useState(false);
const [labelsFilter, setLabelsFilter] = useState("");
// Assignees state
const [availableAssignees, setAvailableAssignees] = useState<AssigneeItem[]>([]);
const [selectedAssignees, setSelectedAssignees] = useState<AssigneeItem[]>([]);
const [assigneesLoading, setAssigneesLoading] = useState(false);
const [assigneesFilter, setAssigneesFilter] = useState("");
// Milestones state
const [availableMilestones, setAvailableMilestones] = useState<MilestoneItem[]>([]);
const [selectedMilestone, setSelectedMilestone] = useState<MilestoneItem | null>(null);
const [milestonesLoading, setMilestonesLoading] = useState(false);
// Issue types state
const [availableIssueTypes, setAvailableIssueTypes] = useState<IssueTypeItem[]>([]);
const [selectedIssueType, setSelectedIssueType] = useState<IssueTypeItem | null>(null);
const [issueTypesLoading, setIssueTypesLoading] = useState(false);
// State transition state
const [currentState, setCurrentState] = useState<IssueState>("open");
const [stateReason, setStateReason] = useState<StateReason>("completed");
const [duplicateOf, setDuplicateOf] = useState("");
const [prefilledStateChange, setPrefilledStateChange] = useState<IssueState | null>(null);
// Issue fields state
const [availableIssueFields, setAvailableIssueFields] = useState<IssueFieldItem[]>([]);
const [fieldValues, setFieldValues] = useState<Record<string, IssueFieldValue>>({});
// Repository state
const [selectedRepo, setSelectedRepo] = useState<RepositoryItem | null>(null);
const [repoSearchResults, setRepoSearchResults] = useState<RepositoryItem[]>([]);
const [repoSearchLoading, setRepoSearchLoading] = useState(false);
const [repoFilter, setRepoFilter] = useState("");
const { app, error: appError, toolInput, callTool, hostContext, setModelContext, openLink } = useMcpApp({

@@ -145,15 +449,530 @@ appName: "github-mcp-server-issue-write",

// Get method and issue_number from toolInput
const method = (toolInput?.method as string) || "create";
const issueNumber = toolInput?.issue_number as number | undefined;
const isUpdateMode = method === "update" && issueNumber !== undefined;
const owner = (toolInput?.owner as string) || "";
const repo = (toolInput?.repo as string) || "";
// Pre-fill from toolInput
// Initialize from toolInput or selected repo
const owner = selectedRepo?.owner || (toolInput?.owner as string) || "";
const repo = selectedRepo?.name || (toolInput?.repo as string) || "";
// Search repositories when filter changes
useEffect(() => {
if (toolInput?.title) setTitle(toolInput.title as string);
if (toolInput?.body) setBody(toolInput.body as string);
if (!app || !repoFilter.trim()) {
setRepoSearchResults([]);
return;
}
const searchRepos = async () => {
setRepoSearchLoading(true);
try {
const result = await callTool("search_repositories", {
query: repoFilter,
perPage: 10,
});
if (result && !result.isError && result.content) {
const textContent = result.content.find(
(c) => c.type === "text"
);
if (textContent && textContent.type === "text" && textContent.text) {
const data = JSON.parse(textContent.text);
const repos = (data.repositories || data.items || []).map(
(r: { id?: number; owner?: { login?: string } | string; name?: string; full_name?: string; private?: boolean }) => ({
id: String(r.id || r.full_name),
owner:
typeof r.owner === "string"
? r.owner
: r.owner?.login || r.full_name?.split("/")[0] || "",
name: r.name || r.full_name?.split("/")[1] || "",
fullName: r.full_name || "",
isPrivate: r.private || false,
})
);
setRepoSearchResults(repos);
}
}
} catch (e) {
console.error("Failed to search repositories:", e);
} finally {
setRepoSearchLoading(false);
}
};
const debounce = setTimeout(searchRepos, 300);
return () => clearTimeout(debounce);
}, [app, callTool, repoFilter]);
// Load labels, assignees, milestones, issue types, and issue fields when owner/repo available
useEffect(() => {
if (!owner || !repo || !app) return;
const loadLabels = async () => {
setLabelsLoading(true);
try {
const result = await callTool("ui_get", { method: "labels", owner, repo });
if (result && !result.isError && result.content) {
const textContent = result.content.find(
(c: { type: string }) => c.type === "text"
);
if (textContent && "text" in textContent) {
const data = JSON.parse(textContent.text as string);
const labels = (data.labels || []).map(
(l: { name: string; color: string; id: string }) => ({
id: l.id || l.name,
text: l.name,
color: l.color,
})
);
setAvailableLabels(labels);
}
}
} catch (e) {
console.error("Failed to load labels:", e);
} finally {
setLabelsLoading(false);
}
};
const loadAssignees = async () => {
setAssigneesLoading(true);
try {
const result = await callTool("ui_get", { method: "assignees", owner, repo });
if (result && !result.isError && result.content) {
const textContent = result.content.find(
(c: { type: string }) => c.type === "text"
);
if (textContent && "text" in textContent) {
const data = JSON.parse(textContent.text as string);
const assignees = (data.assignees || []).map(
(a: { login: string }) => ({
id: a.login,
text: a.login,
})
);
setAvailableAssignees(assignees);
}
}
} catch (e) {
console.error("Failed to load assignees:", e);
} finally {
setAssigneesLoading(false);
}
};
const loadMilestones = async () => {
setMilestonesLoading(true);
try {
const result = await callTool("ui_get", { method: "milestones", owner, repo });
if (result && !result.isError && result.content) {
const textContent = result.content.find(
(c: { type: string }) => c.type === "text"
);
if (textContent && "text" in textContent) {
const data = JSON.parse(textContent.text as string);
const milestones = (data.milestones || []).map(
(m: { number: number; title: string; description: string }) => ({
id: String(m.number),
number: m.number,
text: m.title,
description: m.description || "",
})
);
setAvailableMilestones(milestones);
}
}
} catch (e) {
console.error("Failed to load milestones:", e);
} finally {
setMilestonesLoading(false);
}
};
const loadIssueTypes = async () => {
setIssueTypesLoading(true);
try {
const result = await callTool("ui_get", { method: "issue_types", owner });
if (result && !result.isError && result.content) {
const textContent = result.content.find(
(c: { type: string }) => c.type === "text"
);
if (textContent && "text" in textContent) {
const data = JSON.parse(textContent.text as string);
// ui_get returns array directly or wrapped in issue_types/types
const typesArray = Array.isArray(data) ? data : (data.issue_types || data.types || []);
const types = typesArray.map(
(t: { id: number; name: string; description?: string } | string) => {
if (typeof t === "string") {
return { id: t, text: t };
}
return { id: String(t.id || t.name), text: t.name };
}
);
setAvailableIssueTypes(types);
}
}
} catch (e) {
// Issue types may not be available for all repos/orgs
console.debug("Issue types not available:", e);
} finally {
setIssueTypesLoading(false);
}
};
const loadIssueFields = async () => {
try {
const result = await callTool("ui_get", { method: "issue_fields", owner, repo });
if (result && !result.isError && result.content) {
const textContent = result.content.find(
(c: { type: string }) => c.type === "text"
);
if (textContent && "text" in textContent) {
const data = JSON.parse(textContent.text as string);
const fields = (data.fields || [])
.map(
(field: {
id?: string;
name?: string;
data_type?: string;
description?: string;
options?: Array<{ id?: string; name?: string; description?: string; color?: string }>;
}) => ({
id: String(field.id || field.name || ""),
name: field.name || "",
data_type: field.data_type || "text",
description: field.description || "",
options: (field.options || [])
.map((option) => ({
id: String(option.id || option.name || ""),
name: option.name || "",
description: option.description || "",
color: option.color || "",
}))
.filter((option) => option.name),
})
)
.filter((field: IssueFieldItem) => field.name);
setAvailableIssueFields(fields);
}
}
} catch (e) {
console.debug("Issue fields not available:", e);
setAvailableIssueFields([]);
}
};
loadLabels();
loadAssignees();
loadMilestones();
loadIssueTypes();
loadIssueFields();
}, [owner, repo, app, callTool]);
// Track which prefill fields have been applied to avoid re-applying after user edits
const prefillApplied = useRef<{
title: boolean;
body: boolean;
labels: boolean;
assignees: boolean;
milestone: boolean;
type: boolean;
issueFields: boolean;
}>({
title: false,
body: false,
labels: false,
assignees: false,
milestone: false,
type: false,
issueFields: false,
});
// Store existing issue data for matching when available lists load
interface ExistingIssueData {
labels: string[];
assignees: string[];
milestoneNumber: number | null;
issueType: string | null;
fieldValues: unknown;
}
const [existingIssueData, setExistingIssueData] = useState<ExistingIssueData | null>(null);
// Reset all transient form/result state when toolInput changes (new invocation).
// Without this, the SuccessView from a previous submit stays visible and stale
// form values (e.g. body) bleed through because prefill effects use truthy guards
// that won't overwrite with empty values. The repo is re-initialized from the new
// invocation here (rather than in a separate effect) so it isn't wiped by this reset.
useEffect(() => {
prefillApplied.current = {
title: false,
body: false,
labels: false,
assignees: false,
milestone: false,
type: false,
issueFields: false,
};
setExistingIssueData(null);
setTitle("");
setBody("");
setSelectedLabels([]);
setSelectedAssignees([]);
setSelectedMilestone(null);
setSelectedIssueType(null);
setCurrentState("open");
setStateReason("completed");
setDuplicateOf("");
setPrefilledStateChange(null);
setFieldValues({});
setSuccessIssue(null);
setError(null);
// Clear available metadata (and filters) so prefill effects, which are gated
// on these lists being non-empty, can't match against the previous repo's data
// before the new repo's ui_get calls resolve.
setAvailableLabels([]);
setAvailableAssignees([]);
setAvailableMilestones([]);
setAvailableIssueTypes([]);
setAvailableIssueFields([]);
setLabelsFilter("");
setAssigneesFilter("");
if (toolInput?.owner && toolInput?.repo) {
setSelectedRepo({
id: `${toolInput.owner}/${toolInput.repo}`,
owner: toolInput.owner as string,
name: toolInput.repo as string,
fullName: `${toolInput.owner}/${toolInput.repo}`,
isPrivate: false,
});
} else {
setSelectedRepo(null);
}
}, [toolInput]);
const handleSubmit = useCallback(async () => {
// Load existing issue data when in update mode
useEffect(() => {
if (!isUpdateMode || !owner || !repo || !issueNumber || !app || existingIssueData !== null) {
return;
}
const loadExistingIssue = async () => {
try {
const result = await callTool("issue_read", {
method: "get",
owner,
repo,
issue_number: issueNumber,
});
if (result && !result.isError && result.content) {
const textContent = result.content.find(
(c) => c.type === "text"
);
if (textContent && textContent.type === "text" && textContent.text) {
const issueData = JSON.parse(textContent.text);
const issueState = parseIssueState(issueData.state);
if (issueState) {
setCurrentState(issueState);
}
// Pre-fill title and body immediately
if (issueData.title && !prefillApplied.current.title) {
setTitle(issueData.title);
prefillApplied.current.title = true;
}
if (issueData.body && !prefillApplied.current.body) {
setBody(issueData.body);
prefillApplied.current.body = true;
}
// Pre-fill assignees immediately from issue data
const assigneeLogins = (issueData.assignees || [])
.map((a: { login?: string } | string) => typeof a === 'string' ? a : a.login)
.filter(Boolean) as string[];
if (assigneeLogins.length > 0 && !prefillApplied.current.assignees) {
setSelectedAssignees(assigneeLogins.map(login => ({ id: login, text: login })));
prefillApplied.current.assignees = true;
}
// Pre-fill issue type immediately from issue data
const issueTypeName = issueData.type?.name || (typeof issueData.type === 'string' ? issueData.type : null);
if (issueTypeName && !prefillApplied.current.type) {
setSelectedIssueType({ id: issueTypeName, text: issueTypeName });
prefillApplied.current.type = true;
}
// Extract data for deferred matching when available lists load (for labels and milestones)
const labelNames = (issueData.labels || [])
.map((l: { name?: string } | string) => typeof l === 'string' ? l : l.name)
.filter(Boolean) as string[];
const milestoneNumber = issueData.milestone
? (typeof issueData.milestone === 'object' ? issueData.milestone.number : issueData.milestone)
: null;
setExistingIssueData({
labels: labelNames,
assignees: assigneeLogins,
milestoneNumber,
issueType: issueTypeName,
fieldValues: issueData.field_values || issueData.fieldValues || [],
});
}
}
} catch (e) {
console.error("Error loading existing issue:", e);
}
};
loadExistingIssue();
}, [isUpdateMode, owner, repo, issueNumber, app, callTool, existingIssueData]);
// Apply existing labels when available labels load
useEffect(() => {
if (!existingIssueData?.labels.length || !availableLabels.length || prefillApplied.current.labels) return;
const matched = availableLabels.filter((l) => existingIssueData.labels.includes(l.text));
if (matched.length > 0) {
setSelectedLabels(matched);
prefillApplied.current.labels = true;
}
}, [existingIssueData, availableLabels]);
// Apply existing milestone when available milestones load
useEffect(() => {
if (!existingIssueData?.milestoneNumber || !availableMilestones.length || prefillApplied.current.milestone) return;
const matched = availableMilestones.find((m) => m.number === existingIssueData.milestoneNumber);
if (matched) {
setSelectedMilestone(matched);
}
prefillApplied.current.milestone = true;
}, [existingIssueData, availableMilestones]);
// Pre-fill title and body immediately (don't wait for data loading)
useEffect(() => {
if (toolInput?.title && !prefillApplied.current.title) {
setTitle(toolInput.title as string);
prefillApplied.current.title = true;
}
if (toolInput?.body && !prefillApplied.current.body) {
setBody(toolInput.body as string);
prefillApplied.current.body = true;
}
}, [toolInput]);
// Pre-fill requested state transition controls from tool input
useEffect(() => {
const state = parseIssueState(toolInput?.state);
if (state) {
setPrefilledStateChange(state);
}
const reason = parseStateReason(toolInput?.state_reason);
if (reason) {
setStateReason(reason);
}
if (toolInput?.duplicate_of !== undefined && toolInput?.duplicate_of !== null) {
setDuplicateOf(String(toolInput.duplicate_of));
}
}, [toolInput]);
// Pre-fill labels once available data is loaded
useEffect(() => {
if (
toolInput?.labels &&
Array.isArray(toolInput.labels) &&
availableLabels.length > 0 &&
!prefillApplied.current.labels
) {
const prefillLabels = availableLabels.filter((l) =>
(toolInput.labels as string[]).includes(l.text)
);
if (prefillLabels.length > 0) {
setSelectedLabels(prefillLabels);
prefillApplied.current.labels = true;
}
}
}, [toolInput, availableLabels]);
// Pre-fill assignees once available data is loaded
useEffect(() => {
if (
toolInput?.assignees &&
Array.isArray(toolInput.assignees) &&
availableAssignees.length > 0 &&
!prefillApplied.current.assignees
) {
const prefillAssignees = availableAssignees.filter((a) =>
(toolInput.assignees as string[]).includes(a.text)
);
if (prefillAssignees.length > 0) {
setSelectedAssignees(prefillAssignees);
prefillApplied.current.assignees = true;
}
}
}, [toolInput, availableAssignees]);
// Pre-fill milestone once available data is loaded
useEffect(() => {
if (
toolInput?.milestone &&
availableMilestones.length > 0 &&
!prefillApplied.current.milestone
) {
const milestone = availableMilestones.find(
(m) => m.number === Number(toolInput.milestone)
);
if (milestone) {
setSelectedMilestone(milestone);
prefillApplied.current.milestone = true;
}
}
}, [toolInput, availableMilestones]);
// Pre-fill issue type once available data is loaded
useEffect(() => {
if (
toolInput?.type &&
availableIssueTypes.length > 0 &&
!prefillApplied.current.type
) {
const issueType = availableIssueTypes.find(
(t) => t.text === toolInput.type
);
if (issueType) {
setSelectedIssueType(issueType);
prefillApplied.current.type = true;
}
}
}, [toolInput, availableIssueTypes]);
// Pre-fill custom fields once field definitions are loaded
useEffect(() => {
if (!availableIssueFields.length || prefillApplied.current.issueFields) return;
const toolInputValues = normalizeIssueFieldValues(toolInput?.issue_fields, availableIssueFields);
if (Object.keys(toolInputValues).length > 0) {
setFieldValues(toolInputValues);
prefillApplied.current.issueFields = true;
return;
}
const existingValues = normalizeIssueFieldValues(existingIssueData?.fieldValues, availableIssueFields);
if (Object.keys(existingValues).length > 0) {
setFieldValues(existingValues);
prefillApplied.current.issueFields = true;
}
}, [toolInput, existingIssueData, availableIssueFields]);
const issueFieldsByName = useMemo(
() => new Map(availableIssueFields.map((field) => [field.name, field])),
[availableIssueFields]
);
const updateIssueFieldValue = useCallback((fieldName: string, value: IssueFieldValue) => {
prefillApplied.current.issueFields = true;
setFieldValues((prev) => ({ ...prev, [fieldName]: value }));
}, []);
const handleSubmit = useCallback(async (stateChange?: IssueState) => {
if (!title.trim()) {

@@ -168,2 +987,12 @@ setError("Title is required");

const requestedState = isUpdateMode ? stateChange || prefilledStateChange : null;
let duplicateIssueNumber: number | undefined;
if (requestedState === "closed" && stateReason === "duplicate") {
duplicateIssueNumber = Number(duplicateOf);
if (!Number.isInteger(duplicateIssueNumber) || duplicateIssueNumber <= 0) {
setError("Duplicate issue number is required");
return;
}
}
setIsSubmitting(true);

@@ -183,2 +1012,7 @@ setError(null);

delete params.state;
delete params.state_reason;
delete params.duplicate_of;
delete params.issue_fields;
if (isUpdateMode && issueNumber) {

@@ -188,2 +1022,47 @@ params.issue_number = issueNumber;

if (selectedLabels.length > 0) {
params.labels = selectedLabels.map((l) => l.text);
}
if (selectedAssignees.length > 0) {
params.assignees = selectedAssignees.map((a) => a.text);
}
if (selectedMilestone) {
params.milestone = selectedMilestone.number;
}
if (selectedIssueType) {
params.type = selectedIssueType.text;
}
if (requestedState) {
params.state = requestedState;
if (requestedState === "closed") {
params.state_reason = stateReason;
if (stateReason === "duplicate" && duplicateIssueNumber !== undefined) {
params.duplicate_of = duplicateIssueNumber;
}
}
}
const issueFields = Object.entries(fieldValues)
.map(([fieldName, value]): IssueFieldSubmission | null => {
if (value.cleared) return { field_name: fieldName, delete: true };
if (value.optionName !== undefined) {
return { field_name: fieldName, field_option_name: value.optionName };
}
if (value.value !== undefined && value.value !== "") {
const field = issueFieldsByName.get(fieldName);
const fieldValue =
field?.data_type === "number" && typeof value.value === "string"
? Number(value.value)
: value.value;
if (typeof fieldValue === "number" && Number.isNaN(fieldValue)) return null;
return { field_name: fieldName, value: fieldValue };
}
return null;
})
.filter((field): field is IssueFieldSubmission => field !== null);
if (issueFields.length > 0) {
params.issue_fields = issueFields;
}
const result = await callTool("issue_write", params);

@@ -229,4 +1108,101 @@

}
}, [title, body, owner, repo, isUpdateMode, issueNumber, toolInput, callTool, setModelContext]);
}, [
title,
body,
owner,
repo,
selectedLabels,
selectedAssignees,
selectedMilestone,
selectedIssueType,
isUpdateMode,
issueNumber,
stateReason,
duplicateOf,
prefilledStateChange,
fieldValues,
issueFieldsByName,
toolInput,
callTool,
setModelContext,
]);
// Filtered items for dropdowns
const filteredLabels = useMemo(() => {
if (!labelsFilter) return availableLabels;
const lowerFilter = labelsFilter.toLowerCase();
return availableLabels.filter((l) =>
l.text.toLowerCase().includes(lowerFilter)
);
}, [availableLabels, labelsFilter]);
const filteredAssignees = useMemo(() => {
if (!assigneesFilter) return availableAssignees;
const lowerFilter = assigneesFilter.toLowerCase();
return availableAssignees.filter((a) =>
a.text.toLowerCase().includes(lowerFilter)
);
}, [availableAssignees, assigneesFilter]);
const selectedStateReason = stateReasonOptions.find((option) => option.value === stateReason) || stateReasonOptions[0];
const renderIssueFieldInput = (field: IssueFieldItem) => {
const fieldValue = fieldValues[field.name] || {};
if (field.data_type === "single_select") {
const selectedOptionName = fieldValue.cleared ? undefined : fieldValue.optionName;
const selectedOption = field.options.find((option) => option.name === selectedOptionName);
return (
<Box sx={{ flex: 1, minWidth: 0 }}>
<ActionMenu>
<ActionMenu.Button size="small" sx={{ maxWidth: "100%" }}>
{selectedOption ? selectedOption.name : "Select option"}
</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<ActionList selectionVariant="single">
{field.options.length === 0 ? (
<ActionList.Item disabled>No options available</ActionList.Item>
) : (
field.options.map((option) => (
<ActionList.Item
key={option.id || option.name}
selected={selectedOptionName === option.name}
onSelect={() => updateIssueFieldValue(field.name, { optionName: option.name })}
>
<ActionList.LeadingVisual>
<Box
sx={{
width: 14,
height: 14,
borderRadius: "50%",
backgroundColor: normalizeSwatchColor(option.color),
borderWidth: 1,
borderStyle: "solid",
borderColor: "border.default",
}}
/>
</ActionList.LeadingVisual>
{option.name}
</ActionList.Item>
))
)}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</Box>
);
}
return (
<TextInput
type={field.data_type === "number" ? "number" : field.data_type === "date" ? "date" : "text"}
value={fieldValue.cleared ? "" : String(fieldValue.value ?? "")}
onChange={(e) => updateIssueFieldValue(field.name, { value: e.target.value })}
block
contrast
sx={{ flex: 1 }}
/>
);
};
const body_node = (() => {

@@ -256,2 +1232,3 @@ if (appError) {

submittedTitle={title}
submittedLabels={selectedLabels}
isUpdate={isUpdateMode}

@@ -272,3 +1249,3 @@ openLink={openLink}

>
{/* Header */}
{/* Repository picker */}
<Box

@@ -283,12 +1260,79 @@ display="flex"

borderBottomColor="border.default"
sx={{ minWidth: 0, overflow: "hidden" }}
>
<Box sx={{ color: "fg.default", flexShrink: 0, display: "flex", mr: 1 }}>
<IssueOpenedIcon size={16} />
<Box sx={{ minWidth: 0, maxWidth: "100%" }}>
<ActionMenu>
<ActionMenu.Button
size="small"
leadingVisual={selectedRepo?.isPrivate ? LockIcon : RepoIcon}
sx={{ maxWidth: "100%", overflow: "hidden", "& > span:last-child": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}
>
{selectedRepo ? selectedRepo.fullName : "Select repository"}
</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<ActionList selectionVariant="single">
<Box px={3} py={2}>
<TextInput
placeholder="Search repositories..."
value={repoFilter}
onChange={(e) => setRepoFilter(e.target.value)}
sx={{ width: "100%" }}
size="small"
autoFocus
/>
</Box>
<ActionList.Divider />
{repoSearchLoading ? (
<Box display="flex" justifyContent="center" p={3}>
<Spinner size="small" />
</Box>
) : repoSearchResults.length > 0 ? (
repoSearchResults.map((r) => (
<ActionList.Item
key={r.id}
selected={selectedRepo?.id === r.id}
onSelect={() => {
setSelectedRepo(r);
setRepoFilter("");
// Clear metadata when switching repos
setAvailableLabels([]);
setSelectedLabels([]);
setAvailableAssignees([]);
setSelectedAssignees([]);
setAvailableMilestones([]);
setSelectedMilestone(null);
setAvailableIssueTypes([]);
setSelectedIssueType(null);
setAvailableIssueFields([]);
setFieldValues({});
}}
>
<ActionList.LeadingVisual>
{r.isPrivate ? <LockIcon /> : <RepoIcon />}
</ActionList.LeadingVisual>
{r.fullName}
</ActionList.Item>
))
) : selectedRepo ? (
<ActionList.Item
key={selectedRepo.id}
selected
onSelect={() => setRepoFilter("")}
>
<ActionList.LeadingVisual>
{selectedRepo.isPrivate ? <LockIcon /> : <RepoIcon />}
</ActionList.LeadingVisual>
{selectedRepo.fullName}
</ActionList.Item>
) : (
<Box px={3} py={2}>
<Text sx={{ color: "fg.muted", fontSize: 1 }}>
Type to search repositories...
</Text>
</Box>
)}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</Box>
<Text sx={{ fontWeight: "semibold", whiteSpace: "nowrap" }}>
{isUpdateMode ? `Update issue #${issueNumber}` : "New issue"}
</Text>
<Text sx={{ color: "fg.muted", fontSize: 0, ml: 1 }}>
{owner}/{repo}
</Text>
</Box>

@@ -332,7 +1376,340 @@

{/* Submit button */}
<Box display="flex" justifyContent="flex-end" gap={2}>
{/* Metadata section */}
<Box display="flex" gap={4} mb={3} sx={{ flexWrap: "wrap" }}>
{/* Labels dropdown */}
<ActionMenu>
<ActionMenu.Button size="small" leadingVisual={TagIcon}>
Labels
{selectedLabels.length > 0 && (
<CounterLabel sx={{ ml: 1 }}>{selectedLabels.length}</CounterLabel>
)}
</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<Box p={2} borderBottomWidth={1} borderBottomStyle="solid" borderBottomColor="border.default">
<TextInput
placeholder="Filter labels"
value={labelsFilter}
onChange={(e) => setLabelsFilter(e.target.value)}
size="small"
block
/>
</Box>
<ActionList selectionVariant="multiple">
{labelsLoading ? (
<ActionList.Item disabled>
<Spinner size="small" /> Loading...
</ActionList.Item>
) : filteredLabels.length === 0 ? (
<ActionList.Item disabled>No labels available</ActionList.Item>
) : (
filteredLabels.map((label) => (
<ActionList.Item
key={label.id}
selected={selectedLabels.some((l) => l.id === label.id)}
onSelect={() => {
setSelectedLabels((prev) =>
prev.some((l) => l.id === label.id)
? prev.filter((l) => l.id !== label.id)
: [...prev, label]
);
}}
>
<ActionList.LeadingVisual>
<Box
sx={{
width: 14,
height: 14,
borderRadius: "50%",
backgroundColor: `#${label.color}`,
}}
/>
</ActionList.LeadingVisual>
{label.text}
</ActionList.Item>
))
)}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
{/* Assignees dropdown */}
<ActionMenu>
<ActionMenu.Button size="small" leadingVisual={PersonIcon}>
Assignees
{selectedAssignees.length > 0 && (
<CounterLabel sx={{ ml: 1 }}>{selectedAssignees.length}</CounterLabel>
)}
</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<Box p={2} borderBottomWidth={1} borderBottomStyle="solid" borderBottomColor="border.default">
<TextInput
placeholder="Search people"
value={assigneesFilter}
onChange={(e) => setAssigneesFilter(e.target.value)}
size="small"
block
/>
</Box>
<ActionList selectionVariant="multiple">
{assigneesLoading ? (
<ActionList.Item disabled>
<Spinner size="small" /> Loading...
</ActionList.Item>
) : filteredAssignees.length === 0 ? (
<ActionList.Item disabled>No assignees available</ActionList.Item>
) : (
filteredAssignees.map((assignee) => (
<ActionList.Item
key={assignee.id}
selected={selectedAssignees.some((a) => a.id === assignee.id)}
onSelect={() => {
setSelectedAssignees((prev) =>
prev.some((a) => a.id === assignee.id)
? prev.filter((a) => a.id !== assignee.id)
: [...prev, assignee]
);
}}
>
{assignee.text}
</ActionList.Item>
))
)}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
{/* Milestones dropdown */}
<ActionMenu>
<ActionMenu.Button size="small" leadingVisual={MilestoneIcon}>
{selectedMilestone ? selectedMilestone.text : "Milestone"}
</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<ActionList selectionVariant="single">
{milestonesLoading ? (
<ActionList.Item disabled>
<Spinner size="small" /> Loading...
</ActionList.Item>
) : availableMilestones.length === 0 ? (
<ActionList.Item disabled>No milestones</ActionList.Item>
) : (
<>
{selectedMilestone && (
<ActionList.Item
onSelect={() => setSelectedMilestone(null)}
>
Clear selection
</ActionList.Item>
)}
{availableMilestones.map((milestone) => (
<ActionList.Item
key={milestone.id}
selected={selectedMilestone?.id === milestone.id}
onSelect={() => setSelectedMilestone(milestone)}
>
{milestone.text}
{milestone.description && (
<ActionList.Description>
{milestone.description}
</ActionList.Description>
)}
</ActionList.Item>
))}
</>
)}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
{/* Issue Types dropdown */}
<ActionMenu>
<ActionMenu.Button size="small" leadingVisual={IssueOpenedIcon}>
{selectedIssueType ? selectedIssueType.text : "Type"}
</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<ActionList selectionVariant="single">
{issueTypesLoading ? (
<ActionList.Item disabled>
<Spinner size="small" /> Loading...
</ActionList.Item>
) : availableIssueTypes.length === 0 ? (
<ActionList.Item disabled>No issue types</ActionList.Item>
) : (
<>
{selectedIssueType && (
<ActionList.Item
onSelect={() => setSelectedIssueType(null)}
>
Clear selection
</ActionList.Item>
)}
{availableIssueTypes.map((type) => (
<ActionList.Item
key={type.id}
selected={selectedIssueType?.id === type.id}
onSelect={() => setSelectedIssueType(type)}
>
{type.text}
</ActionList.Item>
))}
</>
)}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</Box>
{/* Fields section */}
{availableIssueFields.length > 0 && (
<Box mb={3}>
<Text sx={{ fontWeight: "semibold", display: "block", mb: 3 }}>
Fields
</Text>
<Box
display="grid"
sx={{ gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 2 }}
>
{availableIssueFields.map((field) => {
const fieldValue = fieldValues[field.name];
const hasFieldValue =
fieldValue &&
!fieldValue.cleared &&
(fieldValue.optionName !== undefined ||
(fieldValue.value !== undefined && fieldValue.value !== ""));
return (
<Box key={field.id || field.name}>
<Text sx={{ fontWeight: "semibold", fontSize: 1, display: "block" }}>
{field.name}
</Text>
{field.description && (
<Text sx={{ color: "fg.muted", fontSize: 0, display: "block", mt: 1, mb: 2 }}>
{field.description}
</Text>
)}
<Box display="flex" alignItems="center" gap={2} mt={field.description ? 0 : 2}>
{renderIssueFieldInput(field)}
{hasFieldValue && (
<Button
variant="invisible"
size="small"
sx={{ fontSize: 0, color: "fg.muted" }}
onClick={() => updateIssueFieldValue(field.name, { cleared: true })}
>
Clear
</Button>
)}
</Box>
</Box>
);
})}
</Box>
</Box>
)}
{/* Selected labels display */}
{selectedLabels.length > 0 && (
<Box display="flex" gap={1} mb={3} flexWrap="wrap">
{selectedLabels.map((label) => (
<Label
key={label.id}
sx={{
backgroundColor: `#${label.color}`,
color: getContrastColor(label.color),
borderColor: `#${label.color}`,
}}
>
{label.text}
</Label>
))}
</Box>
)}
{/* Selected metadata display */}
{(selectedAssignees.length > 0 || selectedMilestone) && (
<Box mb={3} sx={{ fontSize: 0, color: "fg.muted" }}>
{selectedAssignees.length > 0 && (
<Text as="div">
Assigned to: {selectedAssignees.map((a) => a.text).join(", ")}
</Text>
)}
{selectedMilestone && (
<Text as="div">Milestone: {selectedMilestone.text}</Text>
)}
</Box>
)}
{/* State and submit actions */}
<Box
display="flex"
justifyContent={isUpdateMode ? "space-between" : "flex-end"}
alignItems="center"
gap={3}
sx={{ flexWrap: "wrap" }}
>
{isUpdateMode && (
<Box>
{currentState === "open" ? (
<>
<Box display="flex" alignItems="center" gap={0}>
<Button
size="small"
variant="danger"
onClick={() => void handleSubmit("closed")}
disabled={isSubmitting || !title.trim() || (stateReason === "duplicate" && !duplicateOf.trim())}
sx={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
>
Close issue
</Button>
<ActionMenu>
<ActionMenu.Button
size="small"
sx={{ ml: "-1px", borderTopLeftRadius: 0, borderBottomLeftRadius: 0 }}
>
{selectedStateReason.label}
</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<ActionList selectionVariant="single">
{stateReasonOptions.map((option) => (
<ActionList.Item
key={option.value}
selected={stateReason === option.value}
onSelect={() => setStateReason(option.value)}
>
{option.label}
<ActionList.Description>{option.description}</ActionList.Description>
</ActionList.Item>
))}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</Box>
{stateReason === "duplicate" && (
<FormControl sx={{ mt: 2 }}>
<FormControl.Label sx={{ fontSize: 0 }}>Duplicate of</FormControl.Label>
<TextInput
type="number"
placeholder="Issue number"
value={duplicateOf}
onChange={(e) => setDuplicateOf(e.target.value)}
size="small"
sx={{ width: 140 }}
/>
</FormControl>
)}
</>
) : (
<Button
size="small"
onClick={() => void handleSubmit("open")}
disabled={isSubmitting || !title.trim()}
>
Reopen issue
</Button>
)}
</Box>
)}
<Button
variant="primary"
onClick={handleSubmit}
onClick={() => void handleSubmit()}
disabled={isSubmitting || !title.trim()}

@@ -339,0 +1716,0 @@ >

@@ -1,2 +0,2 @@

import { StrictMode, useState, useCallback, useEffect } from "react";
import { StrictMode, useState, useCallback, useEffect, useMemo } from "react";
import { createRoot } from "react-dom/client";

@@ -15,2 +15,4 @@ import {

ButtonGroup,
CounterLabel,
Label,
} from "@primer/react";

@@ -20,3 +22,8 @@ import {

CheckCircleIcon,
RepoIcon,
LockIcon,
GitBranchIcon,
TriangleDownIcon,
PersonIcon,
PeopleIcon,
} from "@primer/octicons-react";

@@ -36,2 +43,29 @@ import { AppProvider } from "../../components/AppProvider";

interface RepositoryItem {
id: string;
owner: string;
name: string;
fullName: string;
isPrivate: boolean;
}
interface BranchItem {
name: string;
protected: boolean;
}
type ReviewerItem = { kind: "user" | "team"; id: string; text: string; avatar?: string; org?: string };
function reviewerFromValue(value: string): ReviewerItem {
if (value.includes("/")) {
const [org, slug] = value.split("/", 2);
return { kind: "team", id: `${org}/${slug}`, text: `${org}/${slug}`, org };
}
return { kind: "user", id: value, text: value };
}
function reviewerValue(reviewer: ReviewerItem): string {
return reviewer.kind === "team" ? reviewer.id : reviewer.text;
}
function SuccessView({

@@ -139,5 +173,24 @@ pr,

// Branch state
const [availableBranches, setAvailableBranches] = useState<BranchItem[]>([]);
const [baseBranch, setBaseBranch] = useState<string>("");
const [headBranch, setHeadBranch] = useState<string>("");
const [branchesLoading, setBranchesLoading] = useState(false);
const [baseFilter, setBaseFilter] = useState("");
const [headFilter, setHeadFilter] = useState("");
// Options
const [isDraft, setIsDraft] = useState(false);
const [maintainerCanModify, setMaintainerCanModify] = useState(true);
const [availableReviewers, setAvailableReviewers] = useState<ReviewerItem[]>([]);
const [selectedReviewers, setSelectedReviewers] = useState<ReviewerItem[]>([]);
const [reviewersLoading, setReviewersLoading] = useState(false);
const [reviewersFilter, setReviewersFilter] = useState("");
// Repository state
const [selectedRepo, setSelectedRepo] = useState<RepositoryItem | null>(null);
const [repoSearchResults, setRepoSearchResults] = useState<RepositoryItem[]>([]);
const [repoSearchLoading, setRepoSearchLoading] = useState(false);
const [repoFilter, setRepoFilter] = useState("");
const { app, error: appError, toolInput, callTool, hostContext, setModelContext, openLink } = useMcpApp({

@@ -147,8 +200,44 @@ appName: "github-mcp-server-create-pull-request",

const owner = (toolInput?.owner as string) || "";
const repo = (toolInput?.repo as string) || "";
const head = (toolInput?.head as string) || "";
const base = (toolInput?.base as string) || "";
const owner = selectedRepo?.owner || (toolInput?.owner as string) || "";
const repo = selectedRepo?.name || (toolInput?.repo as string) || "";
const [submittedTitle, setSubmittedTitle] = useState("");
// Reset all transient form/result state when toolInput changes (new invocation).
// Without this, the SuccessView from a previous submit stays visible and stale
// form values bleed through because the prefill effect below only sets when
// toolInput has truthy values and never clears. The repo is re-initialized from
// the new invocation here (rather than in a separate effect) so it isn't wiped
// by this reset.
useEffect(() => {
setTitle("");
setBody("");
setHeadBranch("");
setBaseBranch("");
setIsDraft(false);
setMaintainerCanModify(true);
setSuccessPR(null);
setError(null);
setSubmittedTitle("");
// Clear branch list and filters so a new invocation doesn't briefly show stale
// branches from the previous repo (or allow selecting invalid options) before the
// new repo's ui_get branches call resolves.
setAvailableBranches([]);
setBaseFilter("");
setHeadFilter("");
setAvailableReviewers([]);
setSelectedReviewers([]);
setReviewersFilter("");
if (toolInput?.owner && toolInput?.repo) {
setSelectedRepo({
id: `${toolInput.owner}/${toolInput.repo}`,
owner: toolInput.owner as string,
name: toolInput.repo as string,
fullName: `${toolInput.owner}/${toolInput.repo}`,
isPrivate: false,
});
} else {
setSelectedRepo(null);
}
}, [toolInput]);
// Pre-fill from toolInput

@@ -158,2 +247,4 @@ useEffect(() => {

if (toolInput?.body) setBody(toolInput.body as string);
if (toolInput?.head) setHeadBranch(toolInput.head as string);
if (toolInput?.base) setBaseBranch(toolInput.base as string);
if (toolInput?.draft) setIsDraft(toolInput.draft as boolean);

@@ -163,7 +254,149 @@ if (toolInput?.maintainer_can_modify !== undefined) {

}
if (Array.isArray(toolInput?.reviewers)) {
setSelectedReviewers((toolInput.reviewers as string[]).map(reviewerFromValue));
}
}, [toolInput]);
// Search repositories
useEffect(() => {
if (!app || !repoFilter.trim()) {
setRepoSearchResults([]);
return;
}
const searchRepos = async () => {
setRepoSearchLoading(true);
try {
const result = await callTool("search_repositories", { query: repoFilter, perPage: 10 });
if (result && !result.isError && result.content) {
const textContent = result.content.find((c) => c.type === "text");
if (textContent && textContent.type === "text" && textContent.text) {
const data = JSON.parse(textContent.text);
const repos = (data.repositories || data.items || []).map(
(r: { id?: number; owner?: { login?: string } | string; name?: string; full_name?: string; private?: boolean }) => ({
id: String(r.id || r.full_name),
owner: typeof r.owner === 'string' ? r.owner : r.owner?.login || r.full_name?.split('/')[0] || '',
name: r.name || '',
fullName: r.full_name || '',
isPrivate: r.private || false,
})
);
setRepoSearchResults(repos);
}
}
} catch (e) {
console.error("Failed to search repositories:", e);
} finally {
setRepoSearchLoading(false);
}
};
const debounce = setTimeout(searchRepos, 300);
return () => clearTimeout(debounce);
}, [app, callTool, repoFilter]);
// Load branches and reviewers when repo is selected
useEffect(() => {
if (!owner || !repo || !app) return;
const loadBranches = async () => {
setBranchesLoading(true);
try {
const result = await callTool("ui_get", { method: "branches", owner, repo });
if (result && !result.isError && result.content) {
const textContent = result.content.find((c: { type: string }) => c.type === "text");
if (textContent && "text" in textContent) {
const data = JSON.parse(textContent.text as string);
const branches = (data.branches || data || []).map(
(b: { name: string; protected?: boolean }) => ({ name: b.name, protected: b.protected || false })
);
setAvailableBranches(branches);
if (branches.length > 0) {
const defaultBranch = branches.find((b: BranchItem) => b.name === 'main' || b.name === 'master');
// Functional update so a base branch already prefilled from
// toolInput.base (or chosen by the user) isn't overwritten by a
// stale closure value captured before the request resolved.
if (defaultBranch) setBaseBranch((prev) => prev || defaultBranch.name);
}
}
}
} catch (e) {
console.error("Failed to load branches:", e);
} finally {
setBranchesLoading(false);
}
};
const loadReviewers = async () => {
setReviewersLoading(true);
try {
const result = await callTool("ui_get", { method: "reviewers", owner, repo });
if (result && !result.isError && result.content) {
const textContent = result.content.find((c: { type: string }) => c.type === "text");
if (textContent && "text" in textContent) {
const data = JSON.parse(textContent.text as string);
const users = (data.users || []).map(
(u: { login: string; avatar_url?: string }) => ({
kind: "user" as const,
id: u.login,
text: u.login,
avatar: u.avatar_url,
})
);
const teams = (data.teams || []).map(
(t: { slug: string; name?: string; org: string }) => ({
kind: "team" as const,
id: `${t.org}/${t.slug}`,
text: `${t.org}/${t.slug}`,
org: t.org,
})
);
setAvailableReviewers([...users, ...teams]);
}
}
} catch (e) {
console.error("Failed to load reviewers:", e);
} finally {
setReviewersLoading(false);
}
};
loadBranches();
loadReviewers();
}, [owner, repo, app, callTool]);
useEffect(() => {
if (availableReviewers.length === 0) return;
setSelectedReviewers((prev) =>
prev.map((reviewer) =>
availableReviewers.find((available) => available.id === reviewer.id || available.text === reviewer.text) || reviewer
)
);
}, [availableReviewers]);
// Filters
const filteredBaseBranches = useMemo(() => {
if (!baseFilter.trim()) return availableBranches;
return availableBranches.filter((b) => b.name.toLowerCase().includes(baseFilter.toLowerCase()));
}, [availableBranches, baseFilter]);
const filteredHeadBranches = useMemo(() => {
if (!headFilter.trim()) return availableBranches;
return availableBranches.filter((b) => b.name.toLowerCase().includes(headFilter.toLowerCase()));
}, [availableBranches, headFilter]);
const filteredReviewers = useMemo(() => {
if (!reviewersFilter.trim()) return availableReviewers;
const lowerFilter = reviewersFilter.toLowerCase();
return availableReviewers.filter((reviewer) =>
reviewer.text.toLowerCase().includes(lowerFilter) || reviewer.id.toLowerCase().includes(lowerFilter)
);
}, [availableReviewers, reviewersFilter]);
const handleSubmit = useCallback(async () => {
if (!title.trim()) { setError("Title is required"); return; }
if (!owner || !repo) { setError("Repository information not available"); return; }
if (!baseBranch) { setError("Base branch is required"); return; }
if (!headBranch) { setError("Head branch is required"); return; }
if (baseBranch === headBranch) { setError("Base and head branches cannot be the same"); return; }

@@ -180,6 +413,7 @@ setIsSubmitting(true);

body: body.trim(),
head,
base,
head: headBranch,
base: baseBranch,
draft: isDraft,
maintainer_can_modify: maintainerCanModify,
reviewers: selectedReviewers.map(reviewerValue),
_ui_submitted: true

@@ -215,3 +449,3 @@ });

}
}, [title, body, owner, repo, head, base, isDraft, maintainerCanModify, toolInput, callTool, setModelContext]);
}, [title, body, owner, repo, baseBranch, headBranch, isDraft, maintainerCanModify, selectedReviewers, toolInput, callTool, setModelContext]);

@@ -254,3 +488,3 @@ if (successPR) {

>
{/* Header */}
{/* Repository picker */}
<Box

@@ -265,17 +499,152 @@ display="flex"

borderBottomColor="border.default"
sx={{ minWidth: 0, overflow: "hidden" }}
>
<Box sx={{ color: "fg.default", flexShrink: 0, display: "flex", mr: 1 }}>
<GitPullRequestIcon size={16} />
<Box sx={{ minWidth: 0, maxWidth: "100%" }}>
<ActionMenu>
<ActionMenu.Button
size="small"
leadingVisual={selectedRepo?.isPrivate ? LockIcon : RepoIcon}
sx={{ maxWidth: "100%", overflow: "hidden", "& > span:last-child": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}
>
{selectedRepo ? selectedRepo.fullName : "Select repository"}
</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<ActionList selectionVariant="single">
<Box px={3} py={2}>
<TextInput
placeholder="Search repositories..."
value={repoFilter}
onChange={(e) => setRepoFilter(e.target.value)}
sx={{ width: "100%" }}
size="small"
autoFocus
/>
</Box>
<ActionList.Divider />
{repoSearchLoading ? (
<Box display="flex" justifyContent="center" p={3}>
<Spinner size="small" />
</Box>
) : repoSearchResults.length > 0 ? (
repoSearchResults.map((r) => (
<ActionList.Item
key={r.id}
selected={selectedRepo?.id === r.id}
onSelect={() => {
setSelectedRepo(r);
setRepoFilter("");
setAvailableBranches([]);
setBaseBranch("");
setHeadBranch("");
setAvailableReviewers([]);
setSelectedReviewers([]);
setReviewersFilter("");
}}
>
<ActionList.LeadingVisual>
{r.isPrivate ? <LockIcon /> : <RepoIcon />}
</ActionList.LeadingVisual>
{r.fullName}
</ActionList.Item>
))
) : selectedRepo ? (
<ActionList.Item key={selectedRepo.id} selected onSelect={() => setRepoFilter("")}>
<ActionList.LeadingVisual>
{selectedRepo.isPrivate ? <LockIcon /> : <RepoIcon />}
</ActionList.LeadingVisual>
{selectedRepo.fullName}
</ActionList.Item>
) : (
<Box px={3} py={2}>
<Text sx={{ color: "fg.muted", fontSize: 1 }}>Type to search repositories...</Text>
</Box>
)}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</Box>
<Text sx={{ fontWeight: "semibold", whiteSpace: "nowrap" }}>New pull request</Text>
<Text sx={{ color: "fg.muted", fontSize: 0, ml: 1 }}>
{owner}/{repo}
</Text>
{head && base && (
<Text sx={{ color: "fg.muted", fontSize: 0 }}>
{base} ← {head}
</Text>
)}
</Box>
{/* Branch selectors */}
<Box display="flex" gap={2} mb={3} alignItems="flex-end" sx={{ minWidth: 0, flexWrap: "wrap" }}>
<Box sx={{ flex: "1 1 120px", minWidth: 0 }}>
<Text sx={{ fontSize: 0, color: "fg.muted", mb: 1, display: "block" }}>base</Text>
<ActionMenu>
<ActionMenu.Button size="small" leadingVisual={GitBranchIcon} sx={{ width: "100%", "& > span": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}>
{baseBranch || "Select base"}
</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<ActionList selectionVariant="single">
<Box p={2}>
<TextInput
placeholder="Filter branches..."
value={baseFilter}
onChange={(e) => setBaseFilter(e.target.value)}
size="small"
block
/>
</Box>
<ActionList.Divider />
{branchesLoading ? (
<ActionList.Item disabled><Spinner size="small" /> Loading...</ActionList.Item>
) : filteredBaseBranches.length === 0 ? (
<ActionList.Item disabled>No branches found</ActionList.Item>
) : (
filteredBaseBranches.map((branch) => (
<ActionList.Item
key={branch.name}
selected={baseBranch === branch.name}
onSelect={() => { setBaseBranch(branch.name); setBaseFilter(""); }}
>
{branch.name}
{branch.protected && <ActionList.TrailingVisual><LockIcon size={12} /></ActionList.TrailingVisual>}
</ActionList.Item>
))
)}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</Box>
<Text sx={{ color: "fg.muted", pb: 1, px: 1, flexShrink: 0 }}>←</Text>
<Box sx={{ flex: "1 1 120px", minWidth: 0 }}>
<Text sx={{ fontSize: 0, color: "fg.muted", mb: 1, display: "block" }}>compare</Text>
<ActionMenu>
<ActionMenu.Button size="small" leadingVisual={GitBranchIcon} sx={{ width: "100%", "& > span": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}>
{headBranch || "Select head"}
</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<ActionList selectionVariant="single">
<Box p={2}>
<TextInput
placeholder="Filter branches..."
value={headFilter}
onChange={(e) => setHeadFilter(e.target.value)}
size="small"
block
/>
</Box>
<ActionList.Divider />
{branchesLoading ? (
<ActionList.Item disabled><Spinner size="small" /> Loading...</ActionList.Item>
) : filteredHeadBranches.length === 0 ? (
<ActionList.Item disabled>No branches found</ActionList.Item>
) : (
filteredHeadBranches.map((branch) => (
<ActionList.Item
key={branch.name}
selected={headBranch === branch.name}
onSelect={() => { setHeadBranch(branch.name); setHeadFilter(""); }}
>
{branch.name}
</ActionList.Item>
))
)}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</Box>
</Box>
{/* Error banner */}

@@ -304,5 +673,89 @@ {error && <Flash variant="danger" sx={{ mb: 3 }}>{error}</Flash>}

{/* Reviewers */}
<Box sx={{ mb: 3 }}>
<Text sx={{ fontSize: 0, color: "fg.muted", mb: 1, display: "block" }}>reviewers</Text>
<ActionMenu>
<ActionMenu.Button size="small" leadingVisual={PersonIcon} sx={{ minWidth: 160 }}>
{selectedReviewers.length === 0 ? (
"No reviewers"
) : (
<>
Reviewers
<CounterLabel sx={{ ml: 1 }}>{selectedReviewers.length}</CounterLabel>
</>
)}
</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<Box p={2} borderBottomWidth={1} borderBottomStyle="solid" borderBottomColor="border.default">
<TextInput
placeholder="Search reviewers"
value={reviewersFilter}
onChange={(e) => setReviewersFilter(e.target.value)}
size="small"
block
/>
</Box>
<ActionList selectionVariant="multiple">
{reviewersLoading ? (
<ActionList.Item disabled><Spinner size="small" /> Loading...</ActionList.Item>
) : filteredReviewers.length === 0 ? (
<ActionList.Item disabled>No reviewers available</ActionList.Item>
) : (
filteredReviewers.map((reviewer) => (
<ActionList.Item
key={reviewer.id}
selected={selectedReviewers.some((r) => r.id === reviewer.id)}
onSelect={() => {
setSelectedReviewers((prev) =>
prev.some((r) => r.id === reviewer.id)
? prev.filter((r) => r.id !== reviewer.id)
: [...prev, reviewer]
);
}}
>
<ActionList.LeadingVisual>
{reviewer.kind === "user" ? (
reviewer.avatar ? (
<img
src={reviewer.avatar}
alt=""
width={16}
height={16}
style={{ borderRadius: "50%", display: "block" }}
/>
) : (
<PersonIcon />
)
) : (
<PeopleIcon />
)}
</ActionList.LeadingVisual>
{reviewer.text}
</ActionList.Item>
))
)}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
{selectedReviewers.length > 0 && (
<Box display="flex" gap={1} mt={2} flexWrap="wrap">
{selectedReviewers.map((reviewer) => (
<Label
key={reviewer.id}
sx={{
backgroundColor: "canvas.inset",
color: "fg.muted",
borderColor: "border.default",
}}
>
{reviewer.text}
</Label>
))}
</Box>
)}
</Box>
{/* Options and Submit */}
<Box display="flex" justifyContent="space-between" alignItems="flex-end" flexWrap="wrap" gap={3}>
<Box as="label" display="flex" alignItems="center" sx={{ cursor: "pointer", gap: 2 }}>
<Box as="label" display="flex" alignItems="center" sx={{ cursor: "pointer", gap: 2, mt: 1 }}>
<Checkbox checked={maintainerCanModify} onChange={(e) => setMaintainerCanModify(e.target.checked)} />

@@ -316,3 +769,3 @@ <Text sx={{ fontSize: 1, color: "fg.muted" }}>Allow maintainer edits</Text>

onClick={handleSubmit}
disabled={isSubmitting || !owner || !repo}
disabled={isSubmitting || !owner || !repo || !baseBranch || !headBranch}
>

@@ -331,3 +784,3 @@ {isSubmitting ? (

variant="primary"
disabled={isSubmitting || !owner || !repo}
disabled={isSubmitting || !owner || !repo || !baseBranch || !headBranch}
sx={{ px: 2 }}

@@ -334,0 +787,0 @@ aria-label="Select pull request type"

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display