diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 38106b6d9..72080b96b 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -179,7 +179,7 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se // requiring a UI build (graceful degradation). mcpAppsEnabled, _ := featureChecker(context.Background(), github.MCPAppsFeatureFlag) if mcpAppsEnabled && github.UIAssetsAvailable() { - github.RegisterUIResources(ghServer) + github.RegisterUIResources(ghServer, cfg.ReadOnly) } ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(cfg, clients.restUATransp, clients.gqlHTTP)) diff --git a/pkg/github/ui_resources.go b/pkg/github/ui_resources.go index c41d2ac3f..5154c88d7 100644 --- a/pkg/github/ui_resources.go +++ b/pkg/github/ui_resources.go @@ -10,7 +10,7 @@ import ( // These are static resources (not templates) that serve HTML content for // MCP App-enabled tools. The HTML is built from React/Primer components // in the ui/ directory using `script/build-ui`. -func RegisterUIResources(s *mcp.Server) { +func RegisterUIResources(s *mcp.Server, readOnly bool) { // Register the get_me UI resource s.AddResource( &mcp.Resource{ @@ -43,6 +43,10 @@ func RegisterUIResources(s *mcp.Server) { }, ) + if readOnly { + return + } + // Register the issue_write UI resource s.AddResource( &mcp.Resource{ diff --git a/pkg/github/ui_resources_test.go b/pkg/github/ui_resources_test.go new file mode 100644 index 000000000..c1f0aed40 --- /dev/null +++ b/pkg/github/ui_resources_test.go @@ -0,0 +1,70 @@ +package github + +import ( + "context" + "slices" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/require" +) + +func listUIResourceNames(t *testing.T, readOnly bool) []string { + t.Helper() + + srv := mcp.NewServer(&mcp.Implementation{Name: "test"}, nil) + RegisterUIResources(srv, readOnly) + + st, ct := mcp.NewInMemoryTransports() + client := mcp.NewClient(&mcp.Implementation{Name: "test-client"}, nil) + + type clientResult struct { + res *mcp.ListResourcesResult + err error + } + clientResultCh := make(chan clientResult, 1) + go func() { + cs, err := client.Connect(context.Background(), ct, nil) + if err != nil { + clientResultCh <- clientResult{err: err} + return + } + defer func() { _ = cs.Close() }() + + res, err := cs.ListResources(context.Background(), nil) + clientResultCh <- clientResult{res: res, err: err} + }() + + ss, err := srv.Connect(context.Background(), st, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = ss.Close() }) + + got := <-clientResultCh + require.NoError(t, got.err) + require.NotNil(t, got.res) + + names := make([]string, 0, len(got.res.Resources)) + for _, res := range got.res.Resources { + names = append(names, res.Name) + } + slices.Sort(names) + return names +} + +func TestRegisterUIResources(t *testing.T) { + t.Parallel() + + t.Run("registers all UI resources by default", func(t *testing.T) { + t.Parallel() + + names := listUIResourceNames(t, false) + require.Equal(t, []string{"get_me_ui", "issue_write_ui", "pr_write_ui"}, names) + }) + + t.Run("skips write UI resources in read-only mode", func(t *testing.T) { + t.Parallel() + + names := listUIResourceNames(t, true) + require.Equal(t, []string{"get_me_ui"}, names) + }) +}