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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion docs/server-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ We currently support the following ways in which the GitHub MCP Server can be co
| Dynamic Mode | Not available | `--dynamic-toolsets` flag or `GITHUB_DYNAMIC_TOOLSETS` env var |
| Lockdown Mode | `X-MCP-Lockdown` header | `--lockdown-mode` flag or `GITHUB_LOCKDOWN_MODE` env var |
| Insiders Mode | `X-MCP-Insiders` header or `/insiders` URL | `--insiders` flag or `GITHUB_INSIDERS` env var |
| Feature Flags | `X-MCP-Features` header | `--features` flag |
| Scope Filtering | Always enabled | Always enabled |
| Server Name/Title | Not available | `GITHUB_MCP_SERVER_NAME` / `GITHUB_MCP_SERVER_TITLE` env vars or `github-mcp-server-config.json` |

Expand Down Expand Up @@ -390,7 +391,7 @@ Lockdown mode ensures the server only surfaces content in public repositories fr

**Best for:** Users who want early access to experimental features and new tools before they reach general availability.

Insiders Mode unlocks experimental features, such as [MCP Apps](./insiders-features.md#mcp-apps) support. We created this mode to have a way to roll out experimental features and collect feedback. So if you are using Insiders, please don't hesitate to share your feedback with us! Features in Insiders Mode may change, evolve, or be removed based on user feedback.
Insiders Mode unlocks experimental features, such as [MCP Apps](#mcp-apps) support. We created this mode to have a way to roll out experimental features and collect feedback. So if you are using Insiders, please don't hesitate to share your feedback with us! Features in Insiders Mode may change, evolve, or be removed based on user feedback.

<table>
<tr><th>Remote Server</th><th>Local Server</th></tr>
Expand Down Expand Up @@ -443,6 +444,62 @@ See [Insiders Features](./insiders-features.md) for a full list of what's availa

---

### MCP Apps

[MCP Apps](https://modelcontextprotocol.io/docs/extensions/apps) is an extension to the Model Context Protocol that enables servers to deliver interactive user interfaces to end users. Instead of returning plain text that the LLM must interpret and relay, tools can render forms, profiles, and dashboards right in the chat.

MCP Apps is enabled by [Insiders Mode](#insiders-mode), or independently via the `remote_mcp_ui_apps` feature flag.

**Supported tools:**

| Tool | Description |
|------|-------------|
| `get_me` | Displays your GitHub user profile with avatar, bio, and stats in a rich card |
| `issue_write` | Opens an interactive form to create or update issues |
| `create_pull_request` | Provides a full PR creation form to create a pull request (or a draft pull request) |

**Client requirements:** MCP Apps requires a host that supports the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps). Currently tested with VS Code (`chat.mcp.apps.enabled` setting).

<table>
<tr><th>Remote Server</th><th>Local Server</th></tr>
<tr valign="top">
<td>

```json
{
"type": "http",
"url": "https://api.githubcopilot.com/mcp/",
"headers": {
"X-MCP-Features": "remote_mcp_ui_apps"
}
}
```

</td>
<td>

```json
{
"type": "stdio",
"command": "go",
"args": [
"run",
"./cmd/github-mcp-server",
"stdio",
"--features=remote_mcp_ui_apps"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}"
}
}
```

</td>
</tr>
</table>

---

### Scope Filtering

**Automatic feature:** The server handles OAuth scopes differently depending on authentication type:
Expand Down
26 changes: 19 additions & 7 deletions internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http"
"os"
"os/signal"
"slices"
"strings"
"syscall"
"time"
Expand Down Expand Up @@ -114,8 +115,18 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se
return nil, fmt.Errorf("failed to create GitHub clients: %w", err)
}

// Create feature checker
featureChecker := createFeatureChecker(cfg.EnabledFeatures)
// Create feature checker — insiders mode expands InsidersFeatureFlags
enabledFeatures := cfg.EnabledFeatures
if cfg.InsidersMode {
enabledFeatures = slices.Clone(enabledFeatures)
for _, flag := range github.InsidersFeatureFlags {
if !slices.Contains(enabledFeatures, flag) {
enabledFeatures = append(enabledFeatures, flag)
}
}
}
featureChecker := createFeatureChecker(enabledFeatures)
mcpAppsEnabled := slices.Contains(enabledFeatures, github.MCPAppsFeatureFlag)

// Create dependencies for tool handlers
obs, err := observability.NewExporters(cfg.Logger, metrics.NewNoopMetrics())
Expand Down Expand Up @@ -145,7 +156,7 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se
WithExcludeTools(cfg.ExcludeTools).
WithServerInstructions().
WithFeatureChecker(featureChecker).
WithInsidersMode(cfg.InsidersMode)
WithMCPApps(mcpAppsEnabled)

// Apply token scope filtering if scopes are known (for PAT filtering)
if cfg.TokenScopes != nil {
Expand All @@ -162,10 +173,11 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se
return nil, fmt.Errorf("failed to create GitHub MCP server: %w", err)
}

// Register MCP App UI resources if available (requires running script/build-ui).
// We check availability to allow Insiders mode to work for non-UI features
// even when UI assets haven't been built.
if cfg.InsidersMode && github.UIAssetsAvailable() {
// Register MCP App UI resources if the remote_mcp_ui_apps feature flag is enabled
// and UI assets are available (requires running script/build-ui).
// We check availability to allow the feature flag to be enabled without
// requiring a UI build (graceful degradation).
if mcpAppsEnabled && github.UIAssetsAvailable() {
github.RegisterUIResources(ghServer)
}

Expand Down
11 changes: 11 additions & 0 deletions pkg/github/feature_flags.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
package github

// MCPAppsFeatureFlag is the feature flag name for MCP Apps (interactive UI forms).
const MCPAppsFeatureFlag = "remote_mcp_ui_apps"

// InsidersFeatureFlags is the list of feature flags that insiders mode enables.
// When insiders mode is active, all flags in this list are treated as enabled.
// This is the single source of truth for what "insiders" means in terms of
// feature flag expansion.
var InsidersFeatureFlags = []string{
MCPAppsFeatureFlag,
}

Comment on lines +3 to +13
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

InsidersFeatureFlags is an exported, mutable slice; downstream packages could modify it at runtime and unintentionally change feature-gating behavior. To prevent accidental mutation, consider making this unexported and exposing an accessor that returns a cloned slice (or returning a set/map), so insiders expansion remains a stable single source of truth.

Suggested change
// MCPAppsFeatureFlag is the feature flag name for MCP Apps (interactive UI forms).
const MCPAppsFeatureFlag = "remote_mcp_ui_apps"
// InsidersFeatureFlags is the list of feature flags that insiders mode enables.
// When insiders mode is active, all flags in this list are treated as enabled.
// This is the single source of truth for what "insiders" means in terms of
// feature flag expansion.
var InsidersFeatureFlags = []string{
MCPAppsFeatureFlag,
}
import "slices"
// MCPAppsFeatureFlag is the feature flag name for MCP Apps (interactive UI forms).
const MCPAppsFeatureFlag = "remote_mcp_ui_apps"
// insidersFeatureFlags is the list of feature flags that insiders mode enables.
// When insiders mode is active, all flags in this list are treated as enabled.
// This is the single source of truth for what "insiders" means in terms of
// feature flag expansion.
var insidersFeatureFlags = []string{
MCPAppsFeatureFlag,
}
// InsidersFeatureFlags returns the feature flags that insiders mode enables.
func InsidersFeatureFlags() []string {
return slices.Clone(insidersFeatureFlags)
}

Copilot uses AI. Check for mistakes.
// FeatureFlags defines runtime feature toggles that adjust tool behavior.
type FeatureFlags struct {
LockdownMode bool
Expand Down
4 changes: 2 additions & 2 deletions pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -1073,12 +1073,12 @@ Options are:
return utils.NewToolResultError(err.Error()), nil, nil
}

// When insiders mode is enabled and the client supports MCP Apps UI,
// When the MCP Apps feature flag is enabled and the client supports MCP Apps UI,
// check if this is a UI form submission. The UI sends _ui_submitted=true
// to distinguish form submissions from LLM calls.
uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted")

if deps.GetFlags(ctx).InsidersMode && clientSupportsUI(ctx, req) && !uiSubmitted {
if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted {
if method == "update" {
// Skip the UI form when a state change is requested because
// the form only handles title/body editing and would lose the
Expand Down
31 changes: 20 additions & 11 deletions pkg/github/issues_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -932,9 +932,9 @@ func Test_CreateIssue(t *testing.T) {
}
}

// Test_IssueWrite_InsidersMode_UIGate verifies the insiders mode UI gate
// Test_IssueWrite_MCPApps_UIGate verifies the MCP Apps feature flag UI gate
// behavior: UI clients get a form message, non-UI clients execute directly.
func Test_IssueWrite_InsidersMode_UIGate(t *testing.T) {
func Test_IssueWrite_MCPApps_UIGate(t *testing.T) {
t.Parallel()

mockIssue := &github.Issue{
Expand All @@ -949,11 +949,17 @@ func Test_IssueWrite_InsidersMode_UIGate(t *testing.T) {
PostReposIssuesByOwnerByRepo: mockResponse(t, http.StatusCreated, mockIssue),
}))

deps := BaseDeps{
Client: client,
GQLClient: githubv4.NewClient(nil),
Flags: FeatureFlags{InsidersMode: true},
mcpAppsChecker := func(_ context.Context, flag string) (bool, error) {
return flag == MCPAppsFeatureFlag, nil
}
deps := NewBaseDeps(
client, githubv4.NewClient(nil), nil, nil,
translations.NullTranslationHelper,
FeatureFlags{},
0,
mcpAppsChecker,
stubExporters(),
)
handler := serverTool.Handler(deps)

t.Run("UI client without _ui_submitted returns form message", func(t *testing.T) {
Expand Down Expand Up @@ -1066,11 +1072,14 @@ func Test_IssueWrite_InsidersMode_UIGate(t *testing.T) {
),
))

closeDeps := BaseDeps{
Client: closeClient,
GQLClient: closeGQLClient,
Flags: FeatureFlags{InsidersMode: true},
}
closeDeps := NewBaseDeps(
closeClient, closeGQLClient, nil, nil,
translations.NullTranslationHelper,
FeatureFlags{},
0,
mcpAppsChecker,
stubExporters(),
)
closeHandler := serverTool.Handler(closeDeps)

request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{
Expand Down
4 changes: 2 additions & 2 deletions pkg/github/pullrequests.go
Original file line number Diff line number Diff line change
Expand Up @@ -601,12 +601,12 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo
return utils.NewToolResultError(err.Error()), nil, nil
}

// When insiders mode is enabled and the client supports MCP Apps UI,
// When the MCP Apps feature flag is enabled and the client supports MCP Apps UI,
// check if this is a UI form submission. The UI sends _ui_submitted=true
// to distinguish form submissions from LLM calls.
uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted")

if deps.GetFlags(ctx).InsidersMode && clientSupportsUI(ctx, req) && !uiSubmitted {
if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted {
return utils.NewToolResultText(fmt.Sprintf("Ready to create a pull request in %s/%s. IMPORTANT: The PR has NOT been created yet. Do NOT tell the user the PR was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil
}

Expand Down
18 changes: 12 additions & 6 deletions pkg/github/pullrequests_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2312,9 +2312,9 @@ func Test_CreatePullRequest(t *testing.T) {
}
}

// Test_CreatePullRequest_InsidersMode_UIGate verifies the insiders mode UI gate
// Test_CreatePullRequest_MCPApps_UIGate verifies the MCP Apps feature flag UI gate
// behavior: UI clients get a form message, non-UI clients execute directly.
func Test_CreatePullRequest_InsidersMode_UIGate(t *testing.T) {
func Test_CreatePullRequest_MCPApps_UIGate(t *testing.T) {
t.Parallel()

mockPR := &github.PullRequest{
Expand All @@ -2332,11 +2332,17 @@ func Test_CreatePullRequest_InsidersMode_UIGate(t *testing.T) {
PostReposPullsByOwnerByRepo: mockResponse(t, http.StatusCreated, mockPR),
}))

deps := BaseDeps{
Client: client,
GQLClient: githubv4.NewClient(nil),
Flags: FeatureFlags{InsidersMode: true},
mcpAppsChecker := func(_ context.Context, flag string) (bool, error) {
return flag == MCPAppsFeatureFlag, nil
}
deps := NewBaseDeps(
client, githubv4.NewClient(nil), nil, nil,
translations.NullTranslationHelper,
FeatureFlags{},
0,
mcpAppsChecker,
stubExporters(),
)
handler := serverTool.Handler(deps)

t.Run("UI client without _ui_submitted returns form message", func(t *testing.T) {
Expand Down
9 changes: 9 additions & 0 deletions pkg/http/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"log/slog"
"net/http"
"slices"

ghcontext "github.com/github/github-mcp-server/pkg/context"
"github.com/github/github-mcp-server/pkg/github"
Expand Down Expand Up @@ -261,6 +262,14 @@ func InventoryFiltersForRequest(r *http.Request, builder *inventory.Builder) *in
builder = builder.WithReadOnly(true)
}

// Enable MCP Apps if the feature flag is present in the request headers
// or if insiders mode is active (insiders expands InsidersFeatureFlags).
headerFeatures := ghcontext.GetHeaderFeatures(ctx)
mcpApps := slices.Contains(headerFeatures, github.MCPAppsFeatureFlag) || ghcontext.IsInsidersMode(ctx)
if mcpApps {
builder = builder.WithMCPApps(true)
}

toolsets := ghcontext.GetToolsets(ctx)
tools := ghcontext.GetTools(ctx)

Expand Down
17 changes: 15 additions & 2 deletions pkg/http/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ import (

// knownFeatureFlags are the feature flags that can be enabled via X-MCP-Features header.
// Only these flags are accepted from headers.
var knownFeatureFlags = []string{}
var knownFeatureFlags = []string{
github.MCPAppsFeatureFlag,
}

type ServerConfig struct {
// Version of the server
Expand Down Expand Up @@ -212,18 +214,29 @@ func initGlobalToolScopeMap(t translations.TranslationHelperFunc) error {
}

// createHTTPFeatureChecker creates a feature checker that reads header features from context
// and validates them against the knownFeatureFlags whitelist
// and validates them against the knownFeatureFlags whitelist.
// When insiders mode is active, InsidersFeatureFlags are also treated as enabled.
func createHTTPFeatureChecker() inventory.FeatureFlagChecker {
// Pre-compute whitelist as set for O(1) lookup
knownSet := make(map[string]bool, len(knownFeatureFlags))
for _, f := range knownFeatureFlags {
knownSet[f] = true
}

// Pre-compute insiders flags as set for O(1) lookup
insidersSet := make(map[string]bool, len(github.InsidersFeatureFlags))
for _, f := range github.InsidersFeatureFlags {
insidersSet[f] = true
}

return func(ctx context.Context, flag string) (bool, error) {
if knownSet[flag] && slices.Contains(ghcontext.GetHeaderFeatures(ctx), flag) {
return true, nil
}
// Insiders mode enables all InsidersFeatureFlags
if insidersSet[flag] && ghcontext.IsInsidersMode(ctx) {
return true, nil
}
return false, nil
}
}
Loading
Loading