#
tokens: 48411/50000 27/115 files (page 2/5)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 2 of 5. Use http://codebase.md/portainer/portainer-mcp?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .cursor
│   └── rules
│       └── integration-test.mdc
├── .github
│   └── workflows
│       ├── ci.yml
│       └── release.yml
├── .gitignore
├── CLAUDE.md
├── cloc.sh
├── cmd
│   ├── portainer-mcp
│   │   └── mcp.go
│   └── token-count
│       └── token.go
├── docs
│   ├── clients_and_models.md
│   ├── design
│   │   ├── 202503-1-external-tools-file.md
│   │   ├── 202503-2-tools-vs-mcp-resources.md
│   │   ├── 202503-3-specific-update-tools.md
│   │   ├── 202504-1-embedded-tools-yaml.md
│   │   ├── 202504-2-tools-yaml-versioning.md
│   │   ├── 202504-3-portainer-version-compatibility.md
│   │   └── 202504-4-read-only-mode.md
│   └── design_summary.md
├── go.mod
├── go.sum
├── internal
│   ├── k8sutil
│   │   ├── stripper_test.go
│   │   └── stripper.go
│   ├── mcp
│   │   ├── access_group_test.go
│   │   ├── access_group.go
│   │   ├── docker_test.go
│   │   ├── docker.go
│   │   ├── environment_test.go
│   │   ├── environment.go
│   │   ├── group_test.go
│   │   ├── group.go
│   │   ├── kubernetes_test.go
│   │   ├── kubernetes.go
│   │   ├── mocks_test.go
│   │   ├── schema_test.go
│   │   ├── schema.go
│   │   ├── server_test.go
│   │   ├── server.go
│   │   ├── settings_test.go
│   │   ├── settings.go
│   │   ├── stack_test.go
│   │   ├── stack.go
│   │   ├── tag_test.go
│   │   ├── tag.go
│   │   ├── team_test.go
│   │   ├── team.go
│   │   ├── testdata
│   │   │   ├── invalid_tools.yaml
│   │   │   └── valid_tools.yaml
│   │   ├── user_test.go
│   │   ├── user.go
│   │   ├── utils_test.go
│   │   └── utils.go
│   └── tooldef
│       ├── tooldef_test.go
│       ├── tooldef.go
│       └── tools.yaml
├── LICENSE
├── Makefile
├── pkg
│   ├── portainer
│   │   ├── client
│   │   │   ├── access_group_test.go
│   │   │   ├── access_group.go
│   │   │   ├── client_test.go
│   │   │   ├── client.go
│   │   │   ├── docker_test.go
│   │   │   ├── docker.go
│   │   │   ├── environment_test.go
│   │   │   ├── environment.go
│   │   │   ├── group_test.go
│   │   │   ├── group.go
│   │   │   ├── kubernetes_test.go
│   │   │   ├── kubernetes.go
│   │   │   ├── mocks_test.go
│   │   │   ├── settings_test.go
│   │   │   ├── settings.go
│   │   │   ├── stack_test.go
│   │   │   ├── stack.go
│   │   │   ├── tag_test.go
│   │   │   ├── tag.go
│   │   │   ├── team_test.go
│   │   │   ├── team.go
│   │   │   ├── user_test.go
│   │   │   ├── user.go
│   │   │   ├── version_test.go
│   │   │   └── version.go
│   │   ├── models
│   │   │   ├── access_group_test.go
│   │   │   ├── access_group.go
│   │   │   ├── access_policy_test.go
│   │   │   ├── access_policy.go
│   │   │   ├── docker.go
│   │   │   ├── environment_test.go
│   │   │   ├── environment.go
│   │   │   ├── group_test.go
│   │   │   ├── group.go
│   │   │   ├── kubernetes.go
│   │   │   ├── settings_test.go
│   │   │   ├── settings.go
│   │   │   ├── stack_test.go
│   │   │   ├── stack.go
│   │   │   ├── tag_test.go
│   │   │   ├── tag.go
│   │   │   ├── team_test.go
│   │   │   ├── team.go
│   │   │   ├── user_test.go
│   │   │   └── user.go
│   │   └── utils
│   │       ├── utils_test.go
│   │       └── utils.go
│   └── toolgen
│       ├── param_test.go
│       ├── param.go
│       ├── yaml_test.go
│       └── yaml.go
├── README.md
├── tests
│   └── integration
│       ├── access_group_test.go
│       ├── containers
│       │   └── portainer.go
│       ├── docker_test.go
│       ├── environment_test.go
│       ├── group_test.go
│       ├── helpers
│       │   └── test_env.go
│       ├── server_test.go
│       ├── settings_test.go
│       ├── stack_test.go
│       ├── tag_test.go
│       ├── team_test.go
│       └── user_test.go
└── token.sh
```

# Files

--------------------------------------------------------------------------------
/internal/mcp/stack.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"encoding/json"
  6 | 	"fmt"
  7 | 
  8 | 	"github.com/mark3labs/mcp-go/mcp"
  9 | 	"github.com/mark3labs/mcp-go/server"
 10 | 	"github.com/portainer/portainer-mcp/pkg/toolgen"
 11 | )
 12 | 
 13 | func (s *PortainerMCPServer) AddStackFeatures() {
 14 | 	s.addToolIfExists(ToolListStacks, s.HandleGetStacks())
 15 | 	s.addToolIfExists(ToolGetStackFile, s.HandleGetStackFile())
 16 | 
 17 | 	if !s.readOnly {
 18 | 		s.addToolIfExists(ToolCreateStack, s.HandleCreateStack())
 19 | 		s.addToolIfExists(ToolUpdateStack, s.HandleUpdateStack())
 20 | 	}
 21 | }
 22 | 
 23 | func (s *PortainerMCPServer) HandleGetStacks() server.ToolHandlerFunc {
 24 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 25 | 		stacks, err := s.cli.GetStacks()
 26 | 		if err != nil {
 27 | 			return mcp.NewToolResultErrorFromErr("failed to get stacks", err), nil
 28 | 		}
 29 | 
 30 | 		data, err := json.Marshal(stacks)
 31 | 		if err != nil {
 32 | 			return mcp.NewToolResultErrorFromErr("failed to marshal stacks", err), nil
 33 | 		}
 34 | 
 35 | 		return mcp.NewToolResultText(string(data)), nil
 36 | 	}
 37 | }
 38 | 
 39 | func (s *PortainerMCPServer) HandleGetStackFile() server.ToolHandlerFunc {
 40 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 41 | 		parser := toolgen.NewParameterParser(request)
 42 | 
 43 | 		id, err := parser.GetInt("id", true)
 44 | 		if err != nil {
 45 | 			return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
 46 | 		}
 47 | 
 48 | 		stackFile, err := s.cli.GetStackFile(id)
 49 | 		if err != nil {
 50 | 			return mcp.NewToolResultErrorFromErr("failed to get stack file", err), nil
 51 | 		}
 52 | 
 53 | 		return mcp.NewToolResultText(stackFile), nil
 54 | 	}
 55 | }
 56 | 
 57 | func (s *PortainerMCPServer) HandleCreateStack() server.ToolHandlerFunc {
 58 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 59 | 		parser := toolgen.NewParameterParser(request)
 60 | 
 61 | 		name, err := parser.GetString("name", true)
 62 | 		if err != nil {
 63 | 			return mcp.NewToolResultErrorFromErr("invalid name parameter", err), nil
 64 | 		}
 65 | 
 66 | 		file, err := parser.GetString("file", true)
 67 | 		if err != nil {
 68 | 			return mcp.NewToolResultErrorFromErr("invalid file parameter", err), nil
 69 | 		}
 70 | 
 71 | 		environmentGroupIds, err := parser.GetArrayOfIntegers("environmentGroupIds", true)
 72 | 		if err != nil {
 73 | 			return mcp.NewToolResultErrorFromErr("invalid environmentGroupIds parameter", err), nil
 74 | 		}
 75 | 
 76 | 		id, err := s.cli.CreateStack(name, file, environmentGroupIds)
 77 | 		if err != nil {
 78 | 			return mcp.NewToolResultErrorFromErr("error creating stack", err), nil
 79 | 		}
 80 | 
 81 | 		return mcp.NewToolResultText(fmt.Sprintf("Stack created successfully with ID: %d", id)), nil
 82 | 	}
 83 | }
 84 | 
 85 | func (s *PortainerMCPServer) HandleUpdateStack() server.ToolHandlerFunc {
 86 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 87 | 		parser := toolgen.NewParameterParser(request)
 88 | 
 89 | 		id, err := parser.GetInt("id", true)
 90 | 		if err != nil {
 91 | 			return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
 92 | 		}
 93 | 
 94 | 		file, err := parser.GetString("file", true)
 95 | 		if err != nil {
 96 | 			return mcp.NewToolResultErrorFromErr("invalid file parameter", err), nil
 97 | 		}
 98 | 
 99 | 		environmentGroupIds, err := parser.GetArrayOfIntegers("environmentGroupIds", true)
100 | 		if err != nil {
101 | 			return mcp.NewToolResultErrorFromErr("invalid environmentGroupIds parameter", err), nil
102 | 		}
103 | 
104 | 		err = s.cli.UpdateStack(id, file, environmentGroupIds)
105 | 		if err != nil {
106 | 			return mcp.NewToolResultErrorFromErr("failed to update stack", err), nil
107 | 		}
108 | 
109 | 		return mcp.NewToolResultText("Stack updated successfully"), nil
110 | 	}
111 | }
112 | 
```

--------------------------------------------------------------------------------
/pkg/portainer/client/settings_test.go:
--------------------------------------------------------------------------------

```go
  1 | package client
  2 | 
  3 | import (
  4 | 	"errors"
  5 | 	"testing"
  6 | 
  7 | 	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
  8 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
  9 | 	"github.com/stretchr/testify/assert"
 10 | )
 11 | 
 12 | func TestGetSettings(t *testing.T) {
 13 | 	tests := []struct {
 14 | 		name          string
 15 | 		mockSettings  *apimodels.PortainereeSettings
 16 | 		mockError     error
 17 | 		expected      models.PortainerSettings
 18 | 		expectedError bool
 19 | 	}{
 20 | 		{
 21 | 			name: "successful retrieval - internal auth",
 22 | 			mockSettings: &apimodels.PortainereeSettings{
 23 | 				AuthenticationMethod:      1, // internal
 24 | 				EnableEdgeComputeFeatures: true,
 25 | 				Edge: &apimodels.PortainereeEdge{
 26 | 					TunnelServerAddress: "tunnel.example.com",
 27 | 				},
 28 | 			},
 29 | 			expected: models.PortainerSettings{
 30 | 				Authentication: struct {
 31 | 					Method string `json:"method"`
 32 | 				}{
 33 | 					Method: models.AuthenticationMethodInternal,
 34 | 				},
 35 | 				Edge: struct {
 36 | 					Enabled   bool   `json:"enabled"`
 37 | 					ServerURL string `json:"server_url"`
 38 | 				}{
 39 | 					Enabled:   true,
 40 | 					ServerURL: "tunnel.example.com",
 41 | 				},
 42 | 			},
 43 | 		},
 44 | 		{
 45 | 			name: "successful retrieval - ldap auth",
 46 | 			mockSettings: &apimodels.PortainereeSettings{
 47 | 				AuthenticationMethod:      2, // ldap
 48 | 				EnableEdgeComputeFeatures: false,
 49 | 				Edge: &apimodels.PortainereeEdge{
 50 | 					TunnelServerAddress: "tunnel2.example.com",
 51 | 				},
 52 | 			},
 53 | 			expected: models.PortainerSettings{
 54 | 				Authentication: struct {
 55 | 					Method string `json:"method"`
 56 | 				}{
 57 | 					Method: models.AuthenticationMethodLDAP,
 58 | 				},
 59 | 				Edge: struct {
 60 | 					Enabled   bool   `json:"enabled"`
 61 | 					ServerURL string `json:"server_url"`
 62 | 				}{
 63 | 					Enabled:   false,
 64 | 					ServerURL: "tunnel2.example.com",
 65 | 				},
 66 | 			},
 67 | 		},
 68 | 		{
 69 | 			name: "successful retrieval - oauth auth",
 70 | 			mockSettings: &apimodels.PortainereeSettings{
 71 | 				AuthenticationMethod:      3, // oauth
 72 | 				EnableEdgeComputeFeatures: true,
 73 | 				Edge: &apimodels.PortainereeEdge{
 74 | 					TunnelServerAddress: "tunnel3.example.com",
 75 | 				},
 76 | 			},
 77 | 			expected: models.PortainerSettings{
 78 | 				Authentication: struct {
 79 | 					Method string `json:"method"`
 80 | 				}{
 81 | 					Method: models.AuthenticationMethodOAuth,
 82 | 				},
 83 | 				Edge: struct {
 84 | 					Enabled   bool   `json:"enabled"`
 85 | 					ServerURL string `json:"server_url"`
 86 | 				}{
 87 | 					Enabled:   true,
 88 | 					ServerURL: "tunnel3.example.com",
 89 | 				},
 90 | 			},
 91 | 		},
 92 | 		{
 93 | 			name: "successful retrieval - unknown auth",
 94 | 			mockSettings: &apimodels.PortainereeSettings{
 95 | 				AuthenticationMethod:      0, // unknown
 96 | 				EnableEdgeComputeFeatures: false,
 97 | 				Edge: &apimodels.PortainereeEdge{
 98 | 					TunnelServerAddress: "tunnel4.example.com",
 99 | 				},
100 | 			},
101 | 			expected: models.PortainerSettings{
102 | 				Authentication: struct {
103 | 					Method string `json:"method"`
104 | 				}{
105 | 					Method: models.AuthenticationMethodUnknown,
106 | 				},
107 | 				Edge: struct {
108 | 					Enabled   bool   `json:"enabled"`
109 | 					ServerURL string `json:"server_url"`
110 | 				}{
111 | 					Enabled:   false,
112 | 					ServerURL: "tunnel4.example.com",
113 | 				},
114 | 			},
115 | 		},
116 | 		{
117 | 			name:          "get settings error",
118 | 			mockError:     errors.New("failed to get settings"),
119 | 			expectedError: true,
120 | 		},
121 | 	}
122 | 
123 | 	for _, tt := range tests {
124 | 		t.Run(tt.name, func(t *testing.T) {
125 | 			mockAPI := new(MockPortainerAPI)
126 | 			mockAPI.On("GetSettings").Return(tt.mockSettings, tt.mockError)
127 | 
128 | 			client := &PortainerClient{cli: mockAPI}
129 | 
130 | 			settings, err := client.GetSettings()
131 | 
132 | 			if tt.expectedError {
133 | 				assert.Error(t, err)
134 | 				return
135 | 			}
136 | 			assert.NoError(t, err)
137 | 			assert.Equal(t, tt.expected, settings)
138 | 			mockAPI.AssertExpectations(t)
139 | 		})
140 | 	}
141 | }
142 | 
```

--------------------------------------------------------------------------------
/tests/integration/user_test.go:
--------------------------------------------------------------------------------

```go
 1 | package integration
 2 | 
 3 | import (
 4 | 	"encoding/json"
 5 | 	"testing"
 6 | 
 7 | 	mcpmodels "github.com/mark3labs/mcp-go/mcp"
 8 | 	"github.com/portainer/portainer-mcp/internal/mcp"
 9 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
10 | 	"github.com/portainer/portainer-mcp/tests/integration/helpers"
11 | 
12 | 	"github.com/stretchr/testify/assert"
13 | 	"github.com/stretchr/testify/require"
14 | )
15 | 
16 | const (
17 | 	testUsername     = "test-mcp-user"
18 | 	testUserPassword = "testpassword"
19 | 	userRoleStandard = 2 // Portainer API role ID for Standard User
20 | )
21 | 
22 | // prepareUserManagementTestEnvironment creates a test user and returns its ID
23 | func prepareUserManagementTestEnvironment(t *testing.T, env *helpers.TestEnv) int {
24 | 	testUserID, err := env.RawClient.CreateUser(testUsername, testUserPassword, userRoleStandard)
25 | 	require.NoError(t, err, "Failed to create test user via raw client")
26 | 	return int(testUserID)
27 | }
28 | 
29 | // TestUserManagement is an integration test suite that verifies the complete
30 | // lifecycle of user management in Portainer MCP. It tests user listing
31 | // and role updates.
32 | func TestUserManagement(t *testing.T) {
33 | 	env := helpers.NewTestEnv(t)
34 | 	defer env.Cleanup(t)
35 | 
36 | 	testUserID := prepareUserManagementTestEnvironment(t, env)
37 | 
38 | 	// Subtest: User Listing
39 | 	// Verifies listing users (admin + test user) via MCP handler and compares with direct API call.
40 | 	t.Run("User Listing", func(t *testing.T) {
41 | 		handler := env.MCPServer.HandleGetUsers()
42 | 		result, err := handler(env.Ctx, mcp.CreateMCPRequest(nil))
43 | 		require.NoError(t, err, "Failed to get users via MCP handler")
44 | 
45 | 		require.Len(t, result.Content, 1, "Expected exactly one content block in the result")
46 | 		textContent, ok := result.Content[0].(mcpmodels.TextContent)
47 | 		require.True(t, ok, "Expected text content in MCP response")
48 | 
49 | 		var retrievedUsers []models.User
50 | 		err = json.Unmarshal([]byte(textContent.Text), &retrievedUsers)
51 | 		require.NoError(t, err, "Failed to unmarshal retrieved users")
52 | 
53 | 		require.Equal(t, len(retrievedUsers), 2, "Expected 2 users (admin and test user)")
54 | 
55 | 		rawUsers, err := env.RawClient.ListUsers()
56 | 		require.NoError(t, err, "Failed to get users directly via client for comparison")
57 | 
58 | 		expectedConvertedUsers := make([]models.User, 0, len(rawUsers))
59 | 		for _, rawUser := range rawUsers {
60 | 			expectedConvertedUsers = append(expectedConvertedUsers, models.ConvertToUser(rawUser))
61 | 		}
62 | 
63 | 		assert.ElementsMatch(t, expectedConvertedUsers, retrievedUsers, "Mismatch between MCP handler users and converted client users")
64 | 	})
65 | 
66 | 	// Subtest: User Role Update
67 | 	// Verifies updating the test user's role from standard to admin via the MCP handler.
68 | 	t.Run("User Role Update", func(t *testing.T) {
69 | 		handler := env.MCPServer.HandleUpdateUserRole()
70 | 
71 | 		newRole := models.UserRoleAdmin
72 | 		updateRequest := mcp.CreateMCPRequest(map[string]any{
73 | 			"id":   float64(testUserID),
74 | 			"role": newRole,
75 | 		})
76 | 
77 | 		result, err := handler(env.Ctx, updateRequest)
78 | 		require.NoError(t, err, "Failed to update test user role to '%s' via MCP handler", newRole)
79 | 
80 | 		textContent, ok := result.Content[0].(mcpmodels.TextContent)
81 | 		require.True(t, ok, "Expected text content in MCP response for role update")
82 | 		assert.Contains(t, textContent.Text, "User updated successfully", "Success message mismatch for role update")
83 | 
84 | 		rawUpdatedUser, err := env.RawClient.GetUser(testUserID)
85 | 		require.NoError(t, err, "Failed to get test user directly via client after role update")
86 | 
87 | 		convertedUpdatedUser := models.ConvertToUser(rawUpdatedUser)
88 | 		assert.Equal(t, newRole, convertedUpdatedUser.Role, "User role was not updated to '%s' after conversion check", newRole)
89 | 	})
90 | }
91 | 
```

--------------------------------------------------------------------------------
/pkg/portainer/client/client.go:
--------------------------------------------------------------------------------

```go
 1 | package client
 2 | 
 3 | import (
 4 | 	"net/http"
 5 | 
 6 | 	"github.com/portainer/client-api-go/v2/client"
 7 | 	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
 8 | )
 9 | 
10 | // PortainerAPIClient defines the interface for the underlying Portainer API client
11 | type PortainerAPIClient interface {
12 | 	ListEdgeGroups() ([]*apimodels.EdgegroupsDecoratedEdgeGroup, error)
13 | 	CreateEdgeGroup(name string, environmentIds []int64) (int64, error)
14 | 	UpdateEdgeGroup(id int64, name *string, environmentIds *[]int64, tagIds *[]int64) error
15 | 	ListEdgeStacks() ([]*apimodels.PortainereeEdgeStack, error)
16 | 	CreateEdgeStack(name string, file string, environmentGroupIds []int64) (int64, error)
17 | 	UpdateEdgeStack(id int64, file string, environmentGroupIds []int64) error
18 | 	GetEdgeStackFile(id int64) (string, error)
19 | 	ListEndpointGroups() ([]*apimodels.PortainerEndpointGroup, error)
20 | 	CreateEndpointGroup(name string, associatedEndpoints []int64) (int64, error)
21 | 	UpdateEndpointGroup(id int64, name *string, userAccesses *map[int64]string, teamAccesses *map[int64]string) error
22 | 	AddEnvironmentToEndpointGroup(groupId int64, environmentId int64) error
23 | 	RemoveEnvironmentFromEndpointGroup(groupId int64, environmentId int64) error
24 | 	ListEndpoints() ([]*apimodels.PortainereeEndpoint, error)
25 | 	GetEndpoint(id int64) (*apimodels.PortainereeEndpoint, error)
26 | 	UpdateEndpoint(id int64, tagIds *[]int64, userAccesses *map[int64]string, teamAccesses *map[int64]string) error
27 | 	GetSettings() (*apimodels.PortainereeSettings, error)
28 | 	ListTags() ([]*apimodels.PortainerTag, error)
29 | 	CreateTag(name string) (int64, error)
30 | 	ListTeams() ([]*apimodels.PortainerTeam, error)
31 | 	ListTeamMemberships() ([]*apimodels.PortainerTeamMembership, error)
32 | 	CreateTeam(name string) (int64, error)
33 | 	UpdateTeamName(id int, name string) error
34 | 	DeleteTeamMembership(id int) error
35 | 	CreateTeamMembership(teamId int, userId int) error
36 | 	ListUsers() ([]*apimodels.PortainereeUser, error)
37 | 	UpdateUserRole(id int, role int64) error
38 | 	GetVersion() (string, error)
39 | 	ProxyDockerRequest(environmentId int, opts client.ProxyRequestOptions) (*http.Response, error)
40 | 	ProxyKubernetesRequest(environmentId int, opts client.ProxyRequestOptions) (*http.Response, error)
41 | }
42 | 
43 | // PortainerClient is a wrapper around the Portainer SDK client
44 | // that provides simplified access to Portainer API functionality.
45 | type PortainerClient struct {
46 | 	cli PortainerAPIClient
47 | }
48 | 
49 | // ClientOption defines a function that configures a PortainerClient.
50 | type ClientOption func(*clientOptions)
51 | 
52 | // clientOptions holds configuration options for the PortainerClient.
53 | type clientOptions struct {
54 | 	skipTLSVerify bool
55 | }
56 | 
57 | // WithSkipTLSVerify configures whether to skip TLS certificate verification.
58 | // Setting this to true is not recommended for production environments.
59 | func WithSkipTLSVerify(skip bool) ClientOption {
60 | 	return func(o *clientOptions) {
61 | 		o.skipTLSVerify = skip
62 | 	}
63 | }
64 | 
65 | // NewPortainerClient creates a new PortainerClient instance with the provided
66 | // server URL and authentication token.
67 | //
68 | // Parameters:
69 | //   - serverURL: The base URL of the Portainer server
70 | //   - token: The authentication token for API access
71 | //   - opts: Optional configuration options for the client
72 | //
73 | // Returns:
74 | //   - A configured PortainerClient ready for API operations
75 | func NewPortainerClient(serverURL string, token string, opts ...ClientOption) *PortainerClient {
76 | 	options := clientOptions{
77 | 		skipTLSVerify: false, // Default to secure TLS verification
78 | 	}
79 | 
80 | 	for _, opt := range opts {
81 | 		opt(&options)
82 | 	}
83 | 
84 | 	return &PortainerClient{
85 | 		cli: client.NewPortainerClient(serverURL, token, client.WithSkipTLSVerify(options.skipTLSVerify)),
86 | 	}
87 | }
88 | 
```

--------------------------------------------------------------------------------
/tests/integration/tag_test.go:
--------------------------------------------------------------------------------

```go
 1 | package integration
 2 | 
 3 | import (
 4 | 	"encoding/json"
 5 | 	"testing"
 6 | 
 7 | 	mcpmodels "github.com/mark3labs/mcp-go/mcp"
 8 | 	"github.com/portainer/portainer-mcp/internal/mcp"
 9 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
10 | 	"github.com/portainer/portainer-mcp/tests/integration/helpers"
11 | 	"github.com/stretchr/testify/assert"
12 | 	"github.com/stretchr/testify/require"
13 | )
14 | 
15 | const (
16 | 	testTagName1 = "test-tag-integration-1"
17 | 	testTagName2 = "test-tag-integration-2"
18 | )
19 | 
20 | // TestTagManagement is an integration test suite that verifies the create
21 | // and list operations for environment tags in Portainer MCP.
22 | func TestTagManagement(t *testing.T) {
23 | 	env := helpers.NewTestEnv(t)
24 | 	defer env.Cleanup(t)
25 | 
26 | 	// Subtest: Tag Creation
27 | 	// Verifies that:
28 | 	// - A new tag can be created via the MCP handler.
29 | 	// - The handler response indicates success.
30 | 	// - The created tag exists in Portainer when checked directly via the Raw Client.
31 | 	t.Run("Tag Creation", func(t *testing.T) {
32 | 		handler := env.MCPServer.HandleCreateEnvironmentTag()
33 | 		request := mcp.CreateMCPRequest(map[string]any{
34 | 			"name": testTagName1,
35 | 		})
36 | 
37 | 		result, err := handler(env.Ctx, request)
38 | 		require.NoError(t, err, "Failed to create tag via MCP handler")
39 | 
40 | 		textContent, ok := result.Content[0].(mcpmodels.TextContent)
41 | 		require.True(t, ok, "Expected text content in MCP response")
42 | 		// Just check for the success prefix, no need to parse ID here
43 | 		assert.Contains(t, textContent.Text, "Environment tag created successfully with ID:", "Success message prefix mismatch")
44 | 
45 | 		// Verify by fetching the tag directly via the client and finding the created tag by name
46 | 		tag, err := env.RawClient.GetTagByName(testTagName1)
47 | 		require.NoError(t, err, "Failed to get tag directly via client after creation")
48 | 		assert.Equal(t, testTagName1, tag.Name, "Tag name mismatch")
49 | 	})
50 | 
51 | 	// Subtest: Tag Listing
52 | 	// Verifies that:
53 | 	// - Tags can be listed via the MCP handler.
54 | 	// - The list includes previously created tags.
55 | 	// - The data structure returned by the handler matches the expected local model.
56 | 	// - Compares MCP handler output with direct client API call result after conversion.
57 | 	t.Run("Tag Listing", func(t *testing.T) {
58 | 		// Create another tag directly for listing comparison
59 | 		_, err := env.RawClient.CreateTag(testTagName2)
60 | 		require.NoError(t, err, "Failed to create second tag directly")
61 | 
62 | 		handler := env.MCPServer.HandleGetEnvironmentTags()
63 | 		result, err := handler(env.Ctx, mcp.CreateMCPRequest(nil))
64 | 		require.NoError(t, err, "Failed to get tags via MCP handler")
65 | 
66 | 		require.Len(t, result.Content, 1, "Expected exactly one content block in the result")
67 | 		textContent, ok := result.Content[0].(mcpmodels.TextContent)
68 | 		require.True(t, ok, "Expected text content in MCP response")
69 | 
70 | 		// Unmarshal the result from the MCP handler
71 | 		var retrievedTags []models.EnvironmentTag
72 | 		err = json.Unmarshal([]byte(textContent.Text), &retrievedTags)
73 | 		require.NoError(t, err, "Failed to unmarshal retrieved tags")
74 | 
75 | 		// Fetch tags directly via client
76 | 		rawTags, err := env.RawClient.ListTags()
77 | 		require.NoError(t, err, "Failed to get tags directly via client for comparison")
78 | 
79 | 		// Convert the raw tags to the expected EnvironmentTag model
80 | 		expectedConvertedTags := make([]models.EnvironmentTag, 0, len(rawTags))
81 | 		for _, rawTag := range rawTags {
82 | 			expectedConvertedTags = append(expectedConvertedTags, models.ConvertTagToEnvironmentTag(rawTag))
83 | 		}
84 | 
85 | 		// Compare the tags from MCP handler with the ones converted from the direct client call
86 | 		// Use ElementsMatch as the order might not be guaranteed.
87 | 		assert.ElementsMatch(t, expectedConvertedTags, retrievedTags, "Mismatch between MCP handler tags and converted client tags")
88 | 	})
89 | }
90 | 
```

--------------------------------------------------------------------------------
/internal/mcp/environment.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"encoding/json"
  6 | 
  7 | 	"github.com/mark3labs/mcp-go/mcp"
  8 | 	"github.com/mark3labs/mcp-go/server"
  9 | 	"github.com/portainer/portainer-mcp/pkg/toolgen"
 10 | )
 11 | 
 12 | func (s *PortainerMCPServer) AddEnvironmentFeatures() {
 13 | 	s.addToolIfExists(ToolListEnvironments, s.HandleGetEnvironments())
 14 | 
 15 | 	if !s.readOnly {
 16 | 		s.addToolIfExists(ToolUpdateEnvironmentTags, s.HandleUpdateEnvironmentTags())
 17 | 		s.addToolIfExists(ToolUpdateEnvironmentUserAccesses, s.HandleUpdateEnvironmentUserAccesses())
 18 | 		s.addToolIfExists(ToolUpdateEnvironmentTeamAccesses, s.HandleUpdateEnvironmentTeamAccesses())
 19 | 	}
 20 | }
 21 | 
 22 | func (s *PortainerMCPServer) HandleGetEnvironments() server.ToolHandlerFunc {
 23 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 24 | 		environments, err := s.cli.GetEnvironments()
 25 | 		if err != nil {
 26 | 			return mcp.NewToolResultErrorFromErr("failed to get environments", err), nil
 27 | 		}
 28 | 
 29 | 		data, err := json.Marshal(environments)
 30 | 		if err != nil {
 31 | 			return mcp.NewToolResultErrorFromErr("failed to marshal environments", err), nil
 32 | 		}
 33 | 
 34 | 		return mcp.NewToolResultText(string(data)), nil
 35 | 	}
 36 | }
 37 | 
 38 | func (s *PortainerMCPServer) HandleUpdateEnvironmentTags() server.ToolHandlerFunc {
 39 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 40 | 		parser := toolgen.NewParameterParser(request)
 41 | 
 42 | 		id, err := parser.GetInt("id", true)
 43 | 		if err != nil {
 44 | 			return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
 45 | 		}
 46 | 
 47 | 		tagIds, err := parser.GetArrayOfIntegers("tagIds", true)
 48 | 		if err != nil {
 49 | 			return mcp.NewToolResultErrorFromErr("invalid tagIds parameter", err), nil
 50 | 		}
 51 | 
 52 | 		err = s.cli.UpdateEnvironmentTags(id, tagIds)
 53 | 		if err != nil {
 54 | 			return mcp.NewToolResultErrorFromErr("failed to update environment tags", err), nil
 55 | 		}
 56 | 
 57 | 		return mcp.NewToolResultText("Environment tags updated successfully"), nil
 58 | 	}
 59 | }
 60 | 
 61 | func (s *PortainerMCPServer) HandleUpdateEnvironmentUserAccesses() server.ToolHandlerFunc {
 62 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 63 | 		parser := toolgen.NewParameterParser(request)
 64 | 
 65 | 		id, err := parser.GetInt("id", true)
 66 | 		if err != nil {
 67 | 			return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
 68 | 		}
 69 | 
 70 | 		userAccesses, err := parser.GetArrayOfObjects("userAccesses", true)
 71 | 		if err != nil {
 72 | 			return mcp.NewToolResultErrorFromErr("invalid userAccesses parameter", err), nil
 73 | 		}
 74 | 
 75 | 		userAccessesMap, err := parseAccessMap(userAccesses)
 76 | 		if err != nil {
 77 | 			return mcp.NewToolResultErrorFromErr("invalid user accesses", err), nil
 78 | 		}
 79 | 
 80 | 		err = s.cli.UpdateEnvironmentUserAccesses(id, userAccessesMap)
 81 | 		if err != nil {
 82 | 			return mcp.NewToolResultErrorFromErr("failed to update environment user accesses", err), nil
 83 | 		}
 84 | 
 85 | 		return mcp.NewToolResultText("Environment user accesses updated successfully"), nil
 86 | 	}
 87 | }
 88 | 
 89 | func (s *PortainerMCPServer) HandleUpdateEnvironmentTeamAccesses() server.ToolHandlerFunc {
 90 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 91 | 		parser := toolgen.NewParameterParser(request)
 92 | 
 93 | 		id, err := parser.GetInt("id", true)
 94 | 		if err != nil {
 95 | 			return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
 96 | 		}
 97 | 
 98 | 		teamAccesses, err := parser.GetArrayOfObjects("teamAccesses", true)
 99 | 		if err != nil {
100 | 			return mcp.NewToolResultErrorFromErr("invalid teamAccesses parameter", err), nil
101 | 		}
102 | 
103 | 		teamAccessesMap, err := parseAccessMap(teamAccesses)
104 | 		if err != nil {
105 | 			return mcp.NewToolResultErrorFromErr("invalid team accesses", err), nil
106 | 		}
107 | 
108 | 		err = s.cli.UpdateEnvironmentTeamAccesses(id, teamAccessesMap)
109 | 		if err != nil {
110 | 			return mcp.NewToolResultErrorFromErr("failed to update environment team accesses", err), nil
111 | 		}
112 | 
113 | 		return mcp.NewToolResultText("Environment team accesses updated successfully"), nil
114 | 	}
115 | }
116 | 
```

--------------------------------------------------------------------------------
/internal/mcp/schema.go:
--------------------------------------------------------------------------------

```go
 1 | package mcp
 2 | 
 3 | import "slices"
 4 | 
 5 | // Tool names as defined in the YAML file
 6 | const (
 7 | 	ToolCreateEnvironmentGroup             = "createEnvironmentGroup"
 8 | 	ToolListEnvironmentGroups              = "listEnvironmentGroups"
 9 | 	ToolUpdateEnvironmentGroup             = "updateEnvironmentGroup"
10 | 	ToolCreateAccessGroup                  = "createAccessGroup"
11 | 	ToolListAccessGroups                   = "listAccessGroups"
12 | 	ToolUpdateAccessGroup                  = "updateAccessGroup"
13 | 	ToolAddEnvironmentToAccessGroup        = "addEnvironmentToAccessGroup"
14 | 	ToolRemoveEnvironmentFromAccessGroup   = "removeEnvironmentFromAccessGroup"
15 | 	ToolListEnvironments                   = "listEnvironments"
16 | 	ToolUpdateEnvironment                  = "updateEnvironment"
17 | 	ToolGetStackFile                       = "getStackFile"
18 | 	ToolCreateStack                        = "createStack"
19 | 	ToolListStacks                         = "listStacks"
20 | 	ToolUpdateStack                        = "updateStack"
21 | 	ToolCreateEnvironmentTag               = "createEnvironmentTag"
22 | 	ToolListEnvironmentTags                = "listEnvironmentTags"
23 | 	ToolCreateTeam                         = "createTeam"
24 | 	ToolListTeams                          = "listTeams"
25 | 	ToolUpdateTeamName                     = "updateTeamName"
26 | 	ToolUpdateTeamMembers                  = "updateTeamMembers"
27 | 	ToolListUsers                          = "listUsers"
28 | 	ToolUpdateUserRole                     = "updateUserRole"
29 | 	ToolGetSettings                        = "getSettings"
30 | 	ToolUpdateAccessGroupName              = "updateAccessGroupName"
31 | 	ToolUpdateAccessGroupUserAccesses      = "updateAccessGroupUserAccesses"
32 | 	ToolUpdateAccessGroupTeamAccesses      = "updateAccessGroupTeamAccesses"
33 | 	ToolUpdateEnvironmentTags              = "updateEnvironmentTags"
34 | 	ToolUpdateEnvironmentUserAccesses      = "updateEnvironmentUserAccesses"
35 | 	ToolUpdateEnvironmentTeamAccesses      = "updateEnvironmentTeamAccesses"
36 | 	ToolUpdateEnvironmentGroupName         = "updateEnvironmentGroupName"
37 | 	ToolUpdateEnvironmentGroupEnvironments = "updateEnvironmentGroupEnvironments"
38 | 	ToolUpdateEnvironmentGroupTags         = "updateEnvironmentGroupTags"
39 | 	ToolDockerProxy                        = "dockerProxy"
40 | 	ToolKubernetesProxy                    = "kubernetesProxy"
41 | 	ToolKubernetesProxyStripped            = "getKubernetesResourceStripped"
42 | )
43 | 
44 | // Access levels for users and teams
45 | const (
46 | 	// AccessLevelEnvironmentAdmin represents the environment administrator access level
47 | 	AccessLevelEnvironmentAdmin = "environment_administrator"
48 | 	// AccessLevelHelpdeskUser represents the helpdesk user access level
49 | 	AccessLevelHelpdeskUser = "helpdesk_user"
50 | 	// AccessLevelStandardUser represents the standard user access level
51 | 	AccessLevelStandardUser = "standard_user"
52 | 	// AccessLevelReadonlyUser represents the readonly user access level
53 | 	AccessLevelReadonlyUser = "readonly_user"
54 | 	// AccessLevelOperatorUser represents the operator user access level
55 | 	AccessLevelOperatorUser = "operator_user"
56 | )
57 | 
58 | // User roles
59 | const (
60 | 	// UserRoleAdmin represents an admin user role
61 | 	UserRoleAdmin = "admin"
62 | 	// UserRoleUser represents a regular user role
63 | 	UserRoleUser = "user"
64 | 	// UserRoleEdgeAdmin represents an edge admin user role
65 | 	UserRoleEdgeAdmin = "edge_admin"
66 | )
67 | 
68 | // All available access levels
69 | var AllAccessLevels = []string{
70 | 	AccessLevelEnvironmentAdmin,
71 | 	AccessLevelHelpdeskUser,
72 | 	AccessLevelStandardUser,
73 | 	AccessLevelReadonlyUser,
74 | 	AccessLevelOperatorUser,
75 | }
76 | 
77 | // All available user roles
78 | var AllUserRoles = []string{
79 | 	UserRoleAdmin,
80 | 	UserRoleUser,
81 | 	UserRoleEdgeAdmin,
82 | }
83 | 
84 | // isValidAccessLevel checks if a given string is a valid access level
85 | func isValidAccessLevel(access string) bool {
86 | 	return slices.Contains(AllAccessLevels, access)
87 | }
88 | 
89 | // isValidUserRole checks if a given string is a valid user role
90 | func isValidUserRole(role string) bool {
91 | 	return slices.Contains(AllUserRoles, role)
92 | }
93 | 
```

--------------------------------------------------------------------------------
/pkg/portainer/client/kubernetes_test.go:
--------------------------------------------------------------------------------

```go
  1 | package client
  2 | 
  3 | import (
  4 | 	"bytes"
  5 | 	"errors"
  6 | 	"io"
  7 | 	"net/http"
  8 | 	"strings"
  9 | 	"testing"
 10 | 
 11 | 	"github.com/portainer/client-api-go/v2/client"
 12 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
 13 | 	"github.com/stretchr/testify/assert"
 14 | )
 15 | 
 16 | func TestProxyKubernetesRequest(t *testing.T) {
 17 | 	tests := []struct {
 18 | 		name             string
 19 | 		opts             models.KubernetesProxyRequestOptions
 20 | 		mockResponse     *http.Response
 21 | 		mockError        error
 22 | 		expectedError    bool
 23 | 		expectedStatus   int
 24 | 		expectedRespBody string
 25 | 	}{
 26 | 		{
 27 | 			name: "GET request with query parameters",
 28 | 			opts: models.KubernetesProxyRequestOptions{
 29 | 				EnvironmentID: 1,
 30 | 				Method:        "GET",
 31 | 				Path:          "/api/v1/pods",
 32 | 				QueryParams:   map[string]string{"namespace": "default", "labelSelector": "app=myapp"},
 33 | 			},
 34 | 			mockResponse: &http.Response{
 35 | 				StatusCode: http.StatusOK,
 36 | 				Body:       io.NopCloser(strings.NewReader(`{"items": [{"metadata": {"name": "pod1"}}]}`)),
 37 | 			},
 38 | 			mockError:        nil,
 39 | 			expectedError:    false,
 40 | 			expectedStatus:   http.StatusOK,
 41 | 			expectedRespBody: `{"items": [{"metadata": {"name": "pod1"}}]}`,
 42 | 		},
 43 | 		{
 44 | 			name: "POST request with custom headers and body",
 45 | 			opts: models.KubernetesProxyRequestOptions{
 46 | 				EnvironmentID: 2,
 47 | 				Method:        "POST",
 48 | 				Path:          "/api/v1/namespaces/default/services",
 49 | 				Headers:       map[string]string{"X-Custom-Header": "value1", "Content-Type": "application/json"},
 50 | 				Body:          bytes.NewBufferString(`{"apiVersion": "v1", "kind": "Service", "metadata": {"name": "my-service"}}`),
 51 | 			},
 52 | 			mockResponse: &http.Response{
 53 | 				StatusCode: http.StatusCreated,
 54 | 				Body:       io.NopCloser(strings.NewReader(`{"metadata": {"name": "my-service"}}`)),
 55 | 			},
 56 | 			mockError:        nil,
 57 | 			expectedError:    false,
 58 | 			expectedStatus:   http.StatusCreated,
 59 | 			expectedRespBody: `{"metadata": {"name": "my-service"}}`,
 60 | 		},
 61 | 		{
 62 | 			name: "API error",
 63 | 			opts: models.KubernetesProxyRequestOptions{
 64 | 				EnvironmentID: 3,
 65 | 				Method:        "GET",
 66 | 				Path:          "/version",
 67 | 			},
 68 | 			mockResponse:     nil,
 69 | 			mockError:        errors.New("failed to proxy kubernetes request"),
 70 | 			expectedError:    true,
 71 | 			expectedStatus:   0,  // Not applicable
 72 | 			expectedRespBody: "", // Not applicable
 73 | 		},
 74 | 		{
 75 | 			name: "Request with no params, headers, or body",
 76 | 			opts: models.KubernetesProxyRequestOptions{
 77 | 				EnvironmentID: 4,
 78 | 				Method:        "GET",
 79 | 				Path:          "/healthz",
 80 | 			},
 81 | 			mockResponse: &http.Response{
 82 | 				StatusCode: http.StatusOK,
 83 | 				Body:       io.NopCloser(strings.NewReader("ok")),
 84 | 			},
 85 | 			mockError:        nil,
 86 | 			expectedError:    false,
 87 | 			expectedStatus:   http.StatusOK,
 88 | 			expectedRespBody: "ok",
 89 | 		},
 90 | 	}
 91 | 
 92 | 	for _, tt := range tests {
 93 | 		t.Run(tt.name, func(t *testing.T) {
 94 | 			mockAPI := new(MockPortainerAPI)
 95 | 			proxyOpts := client.ProxyRequestOptions{
 96 | 				Method:      tt.opts.Method,
 97 | 				APIPath:     tt.opts.Path,
 98 | 				QueryParams: tt.opts.QueryParams,
 99 | 				Headers:     tt.opts.Headers,
100 | 				Body:        tt.opts.Body,
101 | 			}
102 | 			mockAPI.On("ProxyKubernetesRequest", tt.opts.EnvironmentID, proxyOpts).Return(tt.mockResponse, tt.mockError)
103 | 
104 | 			portainerClient := &PortainerClient{cli: mockAPI}
105 | 
106 | 			resp, err := portainerClient.ProxyKubernetesRequest(tt.opts)
107 | 
108 | 			if tt.expectedError {
109 | 				assert.Error(t, err)
110 | 				assert.EqualError(t, err, tt.mockError.Error())
111 | 				assert.Nil(t, resp)
112 | 			} else {
113 | 				assert.NoError(t, err)
114 | 				assert.NotNil(t, resp)
115 | 				assert.Equal(t, tt.expectedStatus, resp.StatusCode)
116 | 
117 | 				// Read and verify the response body
118 | 				if assert.NotNil(t, resp.Body) { // Ensure body is not nil before reading
119 | 					defer resp.Body.Close()
120 | 					bodyBytes, readErr := io.ReadAll(resp.Body)
121 | 					assert.NoError(t, readErr)
122 | 					assert.Equal(t, tt.expectedRespBody, string(bodyBytes))
123 | 				} else if tt.expectedRespBody != "" {
124 | 					assert.Fail(t, "Expected a response body but got nil")
125 | 				}
126 | 			}
127 | 
128 | 			mockAPI.AssertExpectations(t)
129 | 		})
130 | 	}
131 | }
132 | 
```

--------------------------------------------------------------------------------
/pkg/portainer/client/access_group.go:
--------------------------------------------------------------------------------

```go
  1 | package client
  2 | 
  3 | import (
  4 | 	"fmt"
  5 | 
  6 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
  7 | 	"github.com/portainer/portainer-mcp/pkg/portainer/utils"
  8 | )
  9 | 
 10 | // GetAccessGroups retrieves all access groups from the Portainer server.
 11 | // Access groups are the equivalent of Endpoint Groups in Portainer.
 12 | //
 13 | // Returns:
 14 | //   - A slice of AccessGroup objects
 15 | //   - An error if the operation fails
 16 | func (c *PortainerClient) GetAccessGroups() ([]models.AccessGroup, error) {
 17 | 	groups, err := c.cli.ListEndpointGroups()
 18 | 	if err != nil {
 19 | 		return nil, err
 20 | 	}
 21 | 
 22 | 	endpoints, err := c.cli.ListEndpoints()
 23 | 	if err != nil {
 24 | 		return nil, err
 25 | 	}
 26 | 
 27 | 	accessGroups := make([]models.AccessGroup, len(groups))
 28 | 	for i, group := range groups {
 29 | 		accessGroups[i] = models.ConvertEndpointGroupToAccessGroup(group, endpoints)
 30 | 	}
 31 | 
 32 | 	return accessGroups, nil
 33 | }
 34 | 
 35 | // CreateAccessGroup creates a new access group in Portainer.
 36 | //
 37 | // Parameters:
 38 | //   - name: The name of the access group
 39 | //   - environmentIds: The IDs of the environments that are part of the access group
 40 | //
 41 | // Returns:
 42 | //   - An error if the operation fails
 43 | func (c *PortainerClient) CreateAccessGroup(name string, environmentIds []int) (int, error) {
 44 | 	groupID, err := c.cli.CreateEndpointGroup(name, utils.IntToInt64Slice(environmentIds))
 45 | 	if err != nil {
 46 | 		return 0, fmt.Errorf("failed to create access group: %w", err)
 47 | 	}
 48 | 
 49 | 	return int(groupID), nil
 50 | }
 51 | 
 52 | // UpdateAccessGroupName updates the name of an existing access group in Portainer.
 53 | //
 54 | // Parameters:
 55 | //   - id: The ID of the access group
 56 | //   - name: The new name for the access group
 57 | //
 58 | // Returns:
 59 | //   - An error if the operation fails
 60 | func (c *PortainerClient) UpdateAccessGroupName(id int, name string) error {
 61 | 	err := c.cli.UpdateEndpointGroup(int64(id), &name, nil, nil)
 62 | 	if err != nil {
 63 | 		return fmt.Errorf("failed to update access group name: %w", err)
 64 | 	}
 65 | 	return nil
 66 | }
 67 | 
 68 | // UpdateAccessGroupUserAccesses updates the user access policies of an existing access group in Portainer.
 69 | //
 70 | // Parameters:
 71 | //   - id: The ID of the access group
 72 | //   - userAccesses: Map of user IDs to their access level
 73 | //
 74 | // Valid access levels are:
 75 | //   - environment_administrator
 76 | //   - helpdesk_user
 77 | //   - standard_user
 78 | //   - readonly_user
 79 | //   - operator_user
 80 | //
 81 | // Returns:
 82 | //   - An error if the operation fails
 83 | func (c *PortainerClient) UpdateAccessGroupUserAccesses(id int, userAccesses map[int]string) error {
 84 | 	uac := utils.IntToInt64Map(userAccesses)
 85 | 	err := c.cli.UpdateEndpointGroup(int64(id), nil, &uac, nil)
 86 | 	if err != nil {
 87 | 		return fmt.Errorf("failed to update access group user accesses: %w", err)
 88 | 	}
 89 | 	return nil
 90 | }
 91 | 
 92 | // UpdateAccessGroupTeamAccesses updates the team access policies of an existing access group in Portainer.
 93 | //
 94 | // Parameters:
 95 | //   - id: The ID of the access group
 96 | //   - teamAccesses: Map of team IDs to their access level
 97 | //
 98 | // Valid access levels are:
 99 | //   - environment_administrator
100 | //   - helpdesk_user
101 | //   - standard_user
102 | //   - readonly_user
103 | //   - operator_user
104 | //
105 | // Returns:
106 | //   - An error if the operation fails
107 | func (c *PortainerClient) UpdateAccessGroupTeamAccesses(id int, teamAccesses map[int]string) error {
108 | 	tac := utils.IntToInt64Map(teamAccesses)
109 | 	err := c.cli.UpdateEndpointGroup(int64(id), nil, nil, &tac)
110 | 	if err != nil {
111 | 		return fmt.Errorf("failed to update access group team accesses: %w", err)
112 | 	}
113 | 	return nil
114 | }
115 | 
116 | // AddEnvironmentToAccessGroup adds an environment to an access group
117 | //
118 | // Parameters:
119 | //   - id: The ID of the access group
120 | //   - environmentId: The ID of the environment to add to the access group
121 | //
122 | // Returns:
123 | //   - An error if the operation fails
124 | func (c *PortainerClient) AddEnvironmentToAccessGroup(id int, environmentId int) error {
125 | 	return c.cli.AddEnvironmentToEndpointGroup(int64(id), int64(environmentId))
126 | }
127 | 
128 | // RemoveEnvironmentFromAccessGroup removes an environment from an access group
129 | //
130 | // Parameters:
131 | //   - id: The ID of the access group
132 | //   - environmentId: The ID of the environment to remove from the access group
133 | //
134 | // Returns:
135 | //   - An error if the operation fails
136 | func (c *PortainerClient) RemoveEnvironmentFromAccessGroup(id int, environmentId int) error {
137 | 	return c.cli.RemoveEnvironmentFromEndpointGroup(int64(id), int64(environmentId))
138 | }
139 | 
```

--------------------------------------------------------------------------------
/internal/mcp/tag_test.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"encoding/json"
  6 | 	"fmt"
  7 | 	"testing"
  8 | 
  9 | 	"github.com/mark3labs/mcp-go/mcp"
 10 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
 11 | 	"github.com/stretchr/testify/assert"
 12 | )
 13 | 
 14 | func TestHandleGetEnvironmentTags(t *testing.T) {
 15 | 	tests := []struct {
 16 | 		name         string
 17 | 		mockTags     []models.EnvironmentTag
 18 | 		mockError    error
 19 | 		expectError  bool
 20 | 		expectedJSON string
 21 | 	}{
 22 | 		{
 23 | 			name: "successful tags retrieval",
 24 | 			mockTags: []models.EnvironmentTag{
 25 | 				{ID: 1, Name: "tag1"},
 26 | 				{ID: 2, Name: "tag2"},
 27 | 			},
 28 | 			mockError:   nil,
 29 | 			expectError: false,
 30 | 		},
 31 | 		{
 32 | 			name:        "api error",
 33 | 			mockTags:    nil,
 34 | 			mockError:   fmt.Errorf("api error"),
 35 | 			expectError: true,
 36 | 		},
 37 | 	}
 38 | 
 39 | 	for _, tt := range tests {
 40 | 		t.Run(tt.name, func(t *testing.T) {
 41 | 			// Create mock client
 42 | 			mockClient := &MockPortainerClient{}
 43 | 			mockClient.On("GetEnvironmentTags").Return(tt.mockTags, tt.mockError)
 44 | 
 45 | 			// Create server with mock client
 46 | 			server := &PortainerMCPServer{
 47 | 				cli: mockClient,
 48 | 			}
 49 | 
 50 | 			// Call handler
 51 | 			handler := server.HandleGetEnvironmentTags()
 52 | 			result, err := handler(context.Background(), mcp.CallToolRequest{})
 53 | 
 54 | 			// Verify results
 55 | 			if tt.expectError {
 56 | 				assert.NoError(t, err)
 57 | 				assert.NotNil(t, result)
 58 | 				assert.True(t, result.IsError, "result.IsError should be true for API errors")
 59 | 				assert.Len(t, result.Content, 1)
 60 | 				textContent, ok := result.Content[0].(mcp.TextContent)
 61 | 				assert.True(t, ok, "Result content should be mcp.TextContent")
 62 | 				if tt.mockError != nil {
 63 | 					assert.Contains(t, textContent.Text, tt.mockError.Error())
 64 | 				}
 65 | 			} else {
 66 | 				assert.NoError(t, err)
 67 | 
 68 | 				// Verify JSON response
 69 | 				assert.Len(t, result.Content, 1)
 70 | 				textContent, ok := result.Content[0].(mcp.TextContent)
 71 | 				assert.True(t, ok)
 72 | 
 73 | 				var tags []models.EnvironmentTag
 74 | 				err = json.Unmarshal([]byte(textContent.Text), &tags)
 75 | 				assert.NoError(t, err)
 76 | 				assert.Equal(t, tt.mockTags, tags)
 77 | 			}
 78 | 
 79 | 			// Verify mock expectations
 80 | 			mockClient.AssertExpectations(t)
 81 | 		})
 82 | 	}
 83 | }
 84 | 
 85 | func TestHandleCreateEnvironmentTag(t *testing.T) {
 86 | 	tests := []struct {
 87 | 		name        string
 88 | 		inputName   string
 89 | 		mockID      int
 90 | 		mockError   error
 91 | 		expectError bool
 92 | 	}{
 93 | 		{
 94 | 			name:        "successful tag creation",
 95 | 			inputName:   "test-tag",
 96 | 			mockID:      123,
 97 | 			mockError:   nil,
 98 | 			expectError: false,
 99 | 		},
100 | 		{
101 | 			name:        "api error",
102 | 			inputName:   "test-tag",
103 | 			mockID:      0,
104 | 			mockError:   fmt.Errorf("api error"),
105 | 			expectError: true,
106 | 		},
107 | 		{
108 | 			name:        "missing name parameter",
109 | 			inputName:   "",
110 | 			mockID:      0,
111 | 			mockError:   nil,
112 | 			expectError: true,
113 | 		},
114 | 	}
115 | 
116 | 	for _, tt := range tests {
117 | 		t.Run(tt.name, func(t *testing.T) {
118 | 			// Create mock client
119 | 			mockClient := &MockPortainerClient{}
120 | 			if tt.inputName != "" {
121 | 				mockClient.On("CreateEnvironmentTag", tt.inputName).Return(tt.mockID, tt.mockError)
122 | 			}
123 | 
124 | 			// Create server with mock client
125 | 			server := &PortainerMCPServer{
126 | 				cli: mockClient,
127 | 			}
128 | 
129 | 			// Create request with parameters
130 | 			request := CreateMCPRequest(map[string]any{})
131 | 			if tt.inputName != "" {
132 | 				request.Params.Arguments = map[string]any{
133 | 					"name": tt.inputName,
134 | 				}
135 | 			}
136 | 
137 | 			// Call handler
138 | 			handler := server.HandleCreateEnvironmentTag()
139 | 			result, err := handler(context.Background(), request)
140 | 
141 | 			// Verify results
142 | 			if tt.expectError {
143 | 				assert.NoError(t, err)
144 | 				assert.NotNil(t, result)
145 | 				assert.True(t, result.IsError, "result.IsError should be true for expected errors")
146 | 				assert.Len(t, result.Content, 1)
147 | 				textContent, ok := result.Content[0].(mcp.TextContent)
148 | 				assert.True(t, ok, "Result content should be mcp.TextContent for errors")
149 | 				if tt.mockError != nil {
150 | 					assert.Contains(t, textContent.Text, tt.mockError.Error())
151 | 				} else {
152 | 					assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
153 | 					if tt.inputName == "" {
154 | 						assert.Contains(t, textContent.Text, "name")
155 | 					}
156 | 				}
157 | 			} else {
158 | 				assert.NoError(t, err)
159 | 				assert.Len(t, result.Content, 1)
160 | 				textContent, ok := result.Content[0].(mcp.TextContent)
161 | 				assert.True(t, ok)
162 | 				assert.Contains(t, textContent.Text,
163 | 					fmt.Sprintf("ID: %d", tt.mockID))
164 | 			}
165 | 
166 | 			// Verify mock expectations
167 | 			mockClient.AssertExpectations(t)
168 | 		})
169 | 	}
170 | }
171 | 
```

--------------------------------------------------------------------------------
/pkg/portainer/models/settings_test.go:
--------------------------------------------------------------------------------

```go
  1 | package models
  2 | 
  3 | import (
  4 | 	"testing"
  5 | 
  6 | 	"github.com/portainer/client-api-go/v2/pkg/models"
  7 | 	"github.com/stretchr/testify/assert"
  8 | )
  9 | 
 10 | func TestConvertAuthenticationMethod(t *testing.T) {
 11 | 	tests := []struct {
 12 | 		name           string
 13 | 		methodID       int64
 14 | 		expectedMethod string
 15 | 	}{
 16 | 		{
 17 | 			name:           "Internal authentication",
 18 | 			methodID:       1,
 19 | 			expectedMethod: AuthenticationMethodInternal,
 20 | 		},
 21 | 		{
 22 | 			name:           "LDAP authentication",
 23 | 			methodID:       2,
 24 | 			expectedMethod: AuthenticationMethodLDAP,
 25 | 		},
 26 | 		{
 27 | 			name:           "OAuth authentication",
 28 | 			methodID:       3,
 29 | 			expectedMethod: AuthenticationMethodOAuth,
 30 | 		},
 31 | 		{
 32 | 			name:           "Unknown authentication (0)",
 33 | 			methodID:       0,
 34 | 			expectedMethod: AuthenticationMethodUnknown,
 35 | 		},
 36 | 		{
 37 | 			name:           "Unknown authentication (negative)",
 38 | 			methodID:       -1,
 39 | 			expectedMethod: AuthenticationMethodUnknown,
 40 | 		},
 41 | 		{
 42 | 			name:           "Unknown authentication (large value)",
 43 | 			methodID:       999,
 44 | 			expectedMethod: AuthenticationMethodUnknown,
 45 | 		},
 46 | 	}
 47 | 
 48 | 	for _, tt := range tests {
 49 | 		t.Run(tt.name, func(t *testing.T) {
 50 | 			result := convertAuthenticationMethod(tt.methodID)
 51 | 			assert.Equal(t, tt.expectedMethod, result)
 52 | 		})
 53 | 	}
 54 | }
 55 | 
 56 | func TestConvertSettingsToPortainerSettings(t *testing.T) {
 57 | 	tests := []struct {
 58 | 		name           string
 59 | 		input          *models.PortainereeSettings
 60 | 		expectedOutput PortainerSettings
 61 | 		shouldPanic    bool
 62 | 	}{
 63 | 		{
 64 | 			name: "Complete settings conversion",
 65 | 			input: &models.PortainereeSettings{
 66 | 				AuthenticationMethod:      1,
 67 | 				EnableEdgeComputeFeatures: true,
 68 | 				Edge: &models.PortainereeEdge{
 69 | 					TunnelServerAddress: "https://edge.example.com",
 70 | 				},
 71 | 			},
 72 | 			expectedOutput: PortainerSettings{
 73 | 				Authentication: struct {
 74 | 					Method string `json:"method"`
 75 | 				}{
 76 | 					Method: AuthenticationMethodInternal,
 77 | 				},
 78 | 				Edge: struct {
 79 | 					Enabled   bool   `json:"enabled"`
 80 | 					ServerURL string `json:"server_url"`
 81 | 				}{
 82 | 					Enabled:   true,
 83 | 					ServerURL: "https://edge.example.com",
 84 | 				},
 85 | 			},
 86 | 		},
 87 | 		{
 88 | 			name: "Settings with LDAP authentication",
 89 | 			input: &models.PortainereeSettings{
 90 | 				AuthenticationMethod:      2,
 91 | 				EnableEdgeComputeFeatures: false,
 92 | 				Edge: &models.PortainereeEdge{
 93 | 					TunnelServerAddress: "",
 94 | 				},
 95 | 			},
 96 | 			expectedOutput: PortainerSettings{
 97 | 				Authentication: struct {
 98 | 					Method string `json:"method"`
 99 | 				}{
100 | 					Method: AuthenticationMethodLDAP,
101 | 				},
102 | 				Edge: struct {
103 | 					Enabled   bool   `json:"enabled"`
104 | 					ServerURL string `json:"server_url"`
105 | 				}{
106 | 					Enabled:   false,
107 | 					ServerURL: "",
108 | 				},
109 | 			},
110 | 		},
111 | 		{
112 | 			name: "Settings with OAuth authentication",
113 | 			input: &models.PortainereeSettings{
114 | 				AuthenticationMethod:      3,
115 | 				EnableEdgeComputeFeatures: true,
116 | 				Edge: &models.PortainereeEdge{
117 | 					TunnelServerAddress: "https://tunnel.portainer.io",
118 | 				},
119 | 			},
120 | 			expectedOutput: PortainerSettings{
121 | 				Authentication: struct {
122 | 					Method string `json:"method"`
123 | 				}{
124 | 					Method: AuthenticationMethodOAuth,
125 | 				},
126 | 				Edge: struct {
127 | 					Enabled   bool   `json:"enabled"`
128 | 					ServerURL string `json:"server_url"`
129 | 				}{
130 | 					Enabled:   true,
131 | 					ServerURL: "https://tunnel.portainer.io",
132 | 				},
133 | 			},
134 | 		},
135 | 		{
136 | 			name: "Settings with unknown authentication",
137 | 			input: &models.PortainereeSettings{
138 | 				AuthenticationMethod:      99,
139 | 				EnableEdgeComputeFeatures: false,
140 | 				Edge: &models.PortainereeEdge{
141 | 					TunnelServerAddress: "",
142 | 				},
143 | 			},
144 | 			expectedOutput: PortainerSettings{
145 | 				Authentication: struct {
146 | 					Method string `json:"method"`
147 | 				}{
148 | 					Method: AuthenticationMethodUnknown,
149 | 				},
150 | 				Edge: struct {
151 | 					Enabled   bool   `json:"enabled"`
152 | 					ServerURL string `json:"server_url"`
153 | 				}{
154 | 					Enabled:   false,
155 | 					ServerURL: "",
156 | 				},
157 | 			},
158 | 		},
159 | 		{
160 | 			name:        "Nil input",
161 | 			input:       nil,
162 | 			shouldPanic: true,
163 | 		},
164 | 	}
165 | 
166 | 	for _, tt := range tests {
167 | 		t.Run(tt.name, func(t *testing.T) {
168 | 			if tt.shouldPanic {
169 | 				assert.Panics(t, func() {
170 | 					ConvertSettingsToPortainerSettings(tt.input)
171 | 				})
172 | 				return
173 | 			}
174 | 
175 | 			result := ConvertSettingsToPortainerSettings(tt.input)
176 | 			assert.Equal(t, tt.expectedOutput.Authentication.Method, result.Authentication.Method)
177 | 			assert.Equal(t, tt.expectedOutput.Edge.Enabled, result.Edge.Enabled)
178 | 			assert.Equal(t, tt.expectedOutput.Edge.ServerURL, result.Edge.ServerURL)
179 | 		})
180 | 	}
181 | }
182 | 
```

--------------------------------------------------------------------------------
/internal/mcp/group.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"encoding/json"
  6 | 	"fmt"
  7 | 
  8 | 	"github.com/mark3labs/mcp-go/mcp"
  9 | 	"github.com/mark3labs/mcp-go/server"
 10 | 	"github.com/portainer/portainer-mcp/pkg/toolgen"
 11 | )
 12 | 
 13 | func (s *PortainerMCPServer) AddEnvironmentGroupFeatures() {
 14 | 	s.addToolIfExists(ToolListEnvironmentGroups, s.HandleGetEnvironmentGroups())
 15 | 
 16 | 	if !s.readOnly {
 17 | 		s.addToolIfExists(ToolCreateEnvironmentGroup, s.HandleCreateEnvironmentGroup())
 18 | 		s.addToolIfExists(ToolUpdateEnvironmentGroupName, s.HandleUpdateEnvironmentGroupName())
 19 | 		s.addToolIfExists(ToolUpdateEnvironmentGroupEnvironments, s.HandleUpdateEnvironmentGroupEnvironments())
 20 | 		s.addToolIfExists(ToolUpdateEnvironmentGroupTags, s.HandleUpdateEnvironmentGroupTags())
 21 | 	}
 22 | }
 23 | 
 24 | func (s *PortainerMCPServer) HandleGetEnvironmentGroups() server.ToolHandlerFunc {
 25 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 26 | 		edgeGroups, err := s.cli.GetEnvironmentGroups()
 27 | 		if err != nil {
 28 | 			return mcp.NewToolResultErrorFromErr("failed to get environment groups", err), nil
 29 | 		}
 30 | 
 31 | 		data, err := json.Marshal(edgeGroups)
 32 | 		if err != nil {
 33 | 			return mcp.NewToolResultErrorFromErr("failed to marshal environment groups", err), nil
 34 | 		}
 35 | 
 36 | 		return mcp.NewToolResultText(string(data)), nil
 37 | 	}
 38 | }
 39 | 
 40 | func (s *PortainerMCPServer) HandleCreateEnvironmentGroup() server.ToolHandlerFunc {
 41 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 42 | 		parser := toolgen.NewParameterParser(request)
 43 | 
 44 | 		name, err := parser.GetString("name", true)
 45 | 		if err != nil {
 46 | 			return mcp.NewToolResultErrorFromErr("invalid name parameter", err), nil
 47 | 		}
 48 | 
 49 | 		environmentIds, err := parser.GetArrayOfIntegers("environmentIds", true)
 50 | 		if err != nil {
 51 | 			return mcp.NewToolResultErrorFromErr("invalid environmentIds parameter", err), nil
 52 | 		}
 53 | 
 54 | 		id, err := s.cli.CreateEnvironmentGroup(name, environmentIds)
 55 | 		if err != nil {
 56 | 			return mcp.NewToolResultErrorFromErr("failed to create environment group", err), nil
 57 | 		}
 58 | 
 59 | 		return mcp.NewToolResultText(fmt.Sprintf("Environment group created successfully with ID: %d", id)), nil
 60 | 	}
 61 | }
 62 | 
 63 | func (s *PortainerMCPServer) HandleUpdateEnvironmentGroupName() server.ToolHandlerFunc {
 64 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 65 | 		parser := toolgen.NewParameterParser(request)
 66 | 
 67 | 		id, err := parser.GetInt("id", true)
 68 | 		if err != nil {
 69 | 			return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
 70 | 		}
 71 | 
 72 | 		name, err := parser.GetString("name", true)
 73 | 		if err != nil {
 74 | 			return mcp.NewToolResultErrorFromErr("invalid name parameter", err), nil
 75 | 		}
 76 | 
 77 | 		err = s.cli.UpdateEnvironmentGroupName(id, name)
 78 | 		if err != nil {
 79 | 			return mcp.NewToolResultErrorFromErr("failed to update environment group name", err), nil
 80 | 		}
 81 | 
 82 | 		return mcp.NewToolResultText("Environment group name updated successfully"), nil
 83 | 	}
 84 | }
 85 | 
 86 | func (s *PortainerMCPServer) HandleUpdateEnvironmentGroupEnvironments() server.ToolHandlerFunc {
 87 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 88 | 		parser := toolgen.NewParameterParser(request)
 89 | 
 90 | 		id, err := parser.GetInt("id", true)
 91 | 		if err != nil {
 92 | 			return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
 93 | 		}
 94 | 
 95 | 		environmentIds, err := parser.GetArrayOfIntegers("environmentIds", true)
 96 | 		if err != nil {
 97 | 			return mcp.NewToolResultErrorFromErr("invalid environmentIds parameter", err), nil
 98 | 		}
 99 | 
100 | 		err = s.cli.UpdateEnvironmentGroupEnvironments(id, environmentIds)
101 | 		if err != nil {
102 | 			return mcp.NewToolResultErrorFromErr("failed to update environment group environments", err), nil
103 | 		}
104 | 
105 | 		return mcp.NewToolResultText("Environment group environments updated successfully"), nil
106 | 	}
107 | }
108 | 
109 | func (s *PortainerMCPServer) HandleUpdateEnvironmentGroupTags() server.ToolHandlerFunc {
110 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
111 | 		parser := toolgen.NewParameterParser(request)
112 | 
113 | 		id, err := parser.GetInt("id", true)
114 | 		if err != nil {
115 | 			return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
116 | 		}
117 | 
118 | 		tagIds, err := parser.GetArrayOfIntegers("tagIds", true)
119 | 		if err != nil {
120 | 			return mcp.NewToolResultErrorFromErr("invalid tagIds parameter", err), nil
121 | 		}
122 | 
123 | 		err = s.cli.UpdateEnvironmentGroupTags(id, tagIds)
124 | 		if err != nil {
125 | 			return mcp.NewToolResultErrorFromErr("failed to update environment group tags", err), nil
126 | 		}
127 | 
128 | 		return mcp.NewToolResultText("Environment group tags updated successfully"), nil
129 | 	}
130 | }
131 | 
```

--------------------------------------------------------------------------------
/internal/mcp/utils_test.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"reflect"
  5 | 	"testing"
  6 | )
  7 | 
  8 | func TestParseAccessMap(t *testing.T) {
  9 | 	tests := []struct {
 10 | 		name    string
 11 | 		entries []any
 12 | 		want    map[int]string
 13 | 		wantErr bool
 14 | 	}{
 15 | 		{
 16 | 			name: "Valid single entry",
 17 | 			entries: []any{
 18 | 				map[string]any{
 19 | 					"id":     float64(1),
 20 | 					"access": AccessLevelEnvironmentAdmin,
 21 | 				},
 22 | 			},
 23 | 			want: map[int]string{
 24 | 				1: AccessLevelEnvironmentAdmin,
 25 | 			},
 26 | 			wantErr: false,
 27 | 		},
 28 | 		{
 29 | 			name: "Valid multiple entries",
 30 | 			entries: []any{
 31 | 				map[string]any{
 32 | 					"id":     float64(1),
 33 | 					"access": AccessLevelEnvironmentAdmin,
 34 | 				},
 35 | 				map[string]any{
 36 | 					"id":     float64(2),
 37 | 					"access": AccessLevelReadonlyUser,
 38 | 				},
 39 | 			},
 40 | 			want: map[int]string{
 41 | 				1: AccessLevelEnvironmentAdmin,
 42 | 				2: AccessLevelReadonlyUser,
 43 | 			},
 44 | 			wantErr: false,
 45 | 		},
 46 | 		{
 47 | 			name: "Invalid entry type",
 48 | 			entries: []any{
 49 | 				"not a map",
 50 | 			},
 51 | 			want:    nil,
 52 | 			wantErr: true,
 53 | 		},
 54 | 		{
 55 | 			name: "Invalid ID type",
 56 | 			entries: []any{
 57 | 				map[string]any{
 58 | 					"id":     "string-id",
 59 | 					"access": AccessLevelEnvironmentAdmin,
 60 | 				},
 61 | 			},
 62 | 			want:    nil,
 63 | 			wantErr: true,
 64 | 		},
 65 | 		{
 66 | 			name: "Invalid access type",
 67 | 			entries: []any{
 68 | 				map[string]any{
 69 | 					"id":     float64(1),
 70 | 					"access": 123,
 71 | 				},
 72 | 			},
 73 | 			want:    nil,
 74 | 			wantErr: true,
 75 | 		},
 76 | 		{
 77 | 			name: "Invalid access level",
 78 | 			entries: []any{
 79 | 				map[string]any{
 80 | 					"id":     float64(1),
 81 | 					"access": "invalid_access_level",
 82 | 				},
 83 | 			},
 84 | 			want:    nil,
 85 | 			wantErr: true,
 86 | 		},
 87 | 		{
 88 | 			name:    "Empty entries",
 89 | 			entries: []any{},
 90 | 			want:    map[int]string{},
 91 | 			wantErr: false,
 92 | 		},
 93 | 		{
 94 | 			name: "Missing ID field",
 95 | 			entries: []any{
 96 | 				map[string]any{
 97 | 					"access": AccessLevelEnvironmentAdmin,
 98 | 				},
 99 | 			},
100 | 			want:    nil,
101 | 			wantErr: true,
102 | 		},
103 | 		{
104 | 			name: "Missing access field",
105 | 			entries: []any{
106 | 				map[string]any{
107 | 					"id": float64(1),
108 | 				},
109 | 			},
110 | 			want:    nil,
111 | 			wantErr: true,
112 | 		},
113 | 	}
114 | 
115 | 	for _, tt := range tests {
116 | 		t.Run(tt.name, func(t *testing.T) {
117 | 			got, err := parseAccessMap(tt.entries)
118 | 			if (err != nil) != tt.wantErr {
119 | 				t.Errorf("parseAccessMap() error = %v, wantErr %v", err, tt.wantErr)
120 | 				return
121 | 			}
122 | 			if !reflect.DeepEqual(got, tt.want) {
123 | 				t.Errorf("parseAccessMap() = %v, want %v", got, tt.want)
124 | 			}
125 | 		})
126 | 	}
127 | }
128 | 
129 | func TestIsValidHTTPMethod(t *testing.T) {
130 | 	tests := []struct {
131 | 		name   string
132 | 		method string
133 | 		expect bool
134 | 	}{
135 | 		{"Valid GET", "GET", true},
136 | 		{"Valid POST", "POST", true},
137 | 		{"Valid PUT", "PUT", true},
138 | 		{"Valid DELETE", "DELETE", true},
139 | 		{"Valid HEAD", "HEAD", true},
140 | 		{"Invalid lowercase get", "get", false},
141 | 		{"Invalid PATCH", "PATCH", false},
142 | 		{"Invalid OPTIONS", "OPTIONS", false},
143 | 		{"Invalid Empty", "", false},
144 | 		{"Invalid Random", "RANDOM", false},
145 | 	}
146 | 
147 | 	for _, tt := range tests {
148 | 		t.Run(tt.name, func(t *testing.T) {
149 | 			got := isValidHTTPMethod(tt.method)
150 | 			if got != tt.expect {
151 | 				t.Errorf("isValidHTTPMethod(%q) = %v, want %v", tt.method, got, tt.expect)
152 | 			}
153 | 		})
154 | 	}
155 | }
156 | 
157 | func TestParseKeyValueMap(t *testing.T) {
158 | 	tests := []struct {
159 | 		name    string
160 | 		items   []any
161 | 		want    map[string]string
162 | 		wantErr bool
163 | 	}{
164 | 		{
165 | 			name: "Valid single entry",
166 | 			items: []any{
167 | 				map[string]any{"key": "k1", "value": "v1"},
168 | 			},
169 | 			want: map[string]string{
170 | 				"k1": "v1",
171 | 			},
172 | 			wantErr: false,
173 | 		},
174 | 		{
175 | 			name: "Valid multiple entries",
176 | 			items: []any{
177 | 				map[string]any{"key": "k1", "value": "v1"},
178 | 				map[string]any{"key": "k2", "value": "v2"},
179 | 			},
180 | 			want: map[string]string{
181 | 				"k1": "v1",
182 | 				"k2": "v2",
183 | 			},
184 | 			wantErr: false,
185 | 		},
186 | 		{
187 | 			name:    "Empty items",
188 | 			items:   []any{},
189 | 			want:    map[string]string{},
190 | 			wantErr: false,
191 | 		},
192 | 		{
193 | 			name: "Invalid item type",
194 | 			items: []any{
195 | 				"not a map",
196 | 			},
197 | 			want:    nil,
198 | 			wantErr: true,
199 | 		},
200 | 		{
201 | 			name: "Invalid key type",
202 | 			items: []any{
203 | 				map[string]any{"key": 123, "value": "v1"},
204 | 			},
205 | 			want:    nil,
206 | 			wantErr: true,
207 | 		},
208 | 		{
209 | 			name: "Invalid value type",
210 | 			items: []any{
211 | 				map[string]any{"key": "k1", "value": 123},
212 | 			},
213 | 			want:    nil,
214 | 			wantErr: true,
215 | 		},
216 | 		{
217 | 			name: "Missing key field",
218 | 			items: []any{
219 | 				map[string]any{"value": "v1"},
220 | 			},
221 | 			want:    nil,
222 | 			wantErr: true,
223 | 		},
224 | 		{
225 | 			name: "Missing value field",
226 | 			items: []any{
227 | 				map[string]any{"key": "k1"},
228 | 			},
229 | 			want:    nil,
230 | 			wantErr: true,
231 | 		},
232 | 	}
233 | 
234 | 	for _, tt := range tests {
235 | 		t.Run(tt.name, func(t *testing.T) {
236 | 			got, err := parseKeyValueMap(tt.items)
237 | 			if (err != nil) != tt.wantErr {
238 | 				t.Errorf("parseKeyValueMap() error = %v, wantErr %v", err, tt.wantErr)
239 | 				return
240 | 			}
241 | 			if !reflect.DeepEqual(got, tt.want) {
242 | 				t.Errorf("parseKeyValueMap() = %v, want %v", got, tt.want)
243 | 			}
244 | 		})
245 | 	}
246 | }
247 | 
```

--------------------------------------------------------------------------------
/pkg/toolgen/yaml.go:
--------------------------------------------------------------------------------

```go
  1 | package toolgen
  2 | 
  3 | import (
  4 | 	"fmt"
  5 | 	"log"
  6 | 	"os"
  7 | 
  8 | 	"github.com/mark3labs/mcp-go/mcp"
  9 | 	"golang.org/x/mod/semver"
 10 | 	"gopkg.in/yaml.v3"
 11 | )
 12 | 
 13 | // ToolsConfig represents the entire YAML configuration
 14 | type ToolsConfig struct {
 15 | 	Version string           `yaml:"version"`
 16 | 	Tools   []ToolDefinition `yaml:"tools"`
 17 | }
 18 | 
 19 | // ToolDefinition represents a single tool in the YAML config
 20 | type ToolDefinition struct {
 21 | 	Name        string                `yaml:"name"`
 22 | 	Description string                `yaml:"description"`
 23 | 	Parameters  []ParameterDefinition `yaml:"parameters"`
 24 | 	Annotations Annotations           `yaml:"annotations"`
 25 | }
 26 | 
 27 | // ParameterDefinition represents a tool parameter in the YAML config
 28 | type ParameterDefinition struct {
 29 | 	Name        string         `yaml:"name"`
 30 | 	Type        string         `yaml:"type"`
 31 | 	Required    bool           `yaml:"required"`
 32 | 	Enum        []string       `yaml:"enum,omitempty"`
 33 | 	Description string         `yaml:"description"`
 34 | 	Items       map[string]any `yaml:"items,omitempty"`
 35 | }
 36 | 
 37 | // Annotations represents a tool annotations in the YAML config
 38 | type Annotations struct {
 39 | 	Title           string `yaml:"title"`
 40 | 	ReadOnlyHint    bool   `yaml:"readOnlyHint"`
 41 | 	DestructiveHint bool   `yaml:"destructiveHint"`
 42 | 	IdempotentHint  bool   `yaml:"idempotentHint"`
 43 | 	OpenWorldHint   bool   `yaml:"openWorldHint"`
 44 | }
 45 | 
 46 | // LoadToolsFromYAML loads tool definitions from a YAML file
 47 | // It returns the tools and the version of the tools.yaml file
 48 | func LoadToolsFromYAML(filePath string, minimumVersion string) (map[string]mcp.Tool, error) {
 49 | 	data, err := os.ReadFile(filePath)
 50 | 	if err != nil {
 51 | 		return nil, err
 52 | 	}
 53 | 
 54 | 	var config ToolsConfig
 55 | 	if err := yaml.Unmarshal(data, &config); err != nil {
 56 | 		return nil, err
 57 | 	}
 58 | 
 59 | 	if config.Version == "" {
 60 | 		return nil, fmt.Errorf("missing version in tools.yaml")
 61 | 	}
 62 | 
 63 | 	if !semver.IsValid(config.Version) {
 64 | 		return nil, fmt.Errorf("invalid version in tools.yaml: %s", config.Version)
 65 | 	}
 66 | 
 67 | 	if semver.Compare(config.Version, minimumVersion) < 0 {
 68 | 		return nil, fmt.Errorf("tools.yaml version %s is below the minimum required version %s", config.Version, minimumVersion)
 69 | 	}
 70 | 
 71 | 	return convertToolDefinitions(config.Tools), nil
 72 | }
 73 | 
 74 | // convertToolDefinitions converts YAML tool definitions to mcp.Tool objects
 75 | func convertToolDefinitions(defs []ToolDefinition) map[string]mcp.Tool {
 76 | 	tools := make(map[string]mcp.Tool, len(defs))
 77 | 
 78 | 	for _, def := range defs {
 79 | 		tool, err := convertToolDefinition(def)
 80 | 		if err != nil {
 81 | 			log.Printf("skipping invalid tool definition %s: %s", def.Name, err)
 82 | 			continue
 83 | 		}
 84 | 
 85 | 		tools[def.Name] = tool
 86 | 	}
 87 | 
 88 | 	return tools
 89 | }
 90 | 
 91 | // convertToolDefinition converts a single YAML tool definition to an mcp.Tool
 92 | func convertToolDefinition(def ToolDefinition) (mcp.Tool, error) {
 93 | 	if def.Name == "" {
 94 | 		return mcp.Tool{}, fmt.Errorf("tool name is required")
 95 | 	}
 96 | 
 97 | 	if def.Description == "" {
 98 | 		return mcp.Tool{}, fmt.Errorf("tool description is required for tool '%s'", def.Name)
 99 | 	}
100 | 
101 | 	var zeroAnnotations Annotations
102 | 	if def.Annotations == zeroAnnotations {
103 | 		return mcp.Tool{}, fmt.Errorf("annotations block is required for tool '%s'", def.Name)
104 | 	}
105 | 
106 | 	options := []mcp.ToolOption{
107 | 		mcp.WithDescription(def.Description),
108 | 	}
109 | 
110 | 	for _, param := range def.Parameters {
111 | 		options = append(options, convertParameter(param))
112 | 	}
113 | 
114 | 	options = append(options, convertAnnotation(def.Annotations))
115 | 
116 | 	return mcp.NewTool(def.Name, options...), nil
117 | }
118 | 
119 | // convertAnnotation converts a YAML annotation definition to an mcp option
120 | func convertAnnotation(annotation Annotations) mcp.ToolOption {
121 | 	return mcp.WithToolAnnotation(mcp.ToolAnnotation{
122 | 		Title:           annotation.Title,
123 | 		ReadOnlyHint:    &annotation.ReadOnlyHint,
124 | 		DestructiveHint: &annotation.DestructiveHint,
125 | 		IdempotentHint:  &annotation.IdempotentHint,
126 | 		OpenWorldHint:   &annotation.OpenWorldHint,
127 | 	})
128 | }
129 | 
130 | // convertParameter converts a YAML parameter definition to an mcp option
131 | func convertParameter(param ParameterDefinition) mcp.ToolOption {
132 | 	var options []mcp.PropertyOption
133 | 
134 | 	options = append(options, mcp.Description(param.Description))
135 | 
136 | 	if param.Required {
137 | 		options = append(options, mcp.Required())
138 | 	}
139 | 
140 | 	if param.Enum != nil {
141 | 		options = append(options, mcp.Enum(param.Enum...))
142 | 	}
143 | 
144 | 	if len(param.Items) > 0 {
145 | 		options = append(options, mcp.Items(param.Items))
146 | 	}
147 | 
148 | 	switch param.Type {
149 | 	case "string":
150 | 		return mcp.WithString(param.Name, options...)
151 | 	case "number":
152 | 		return mcp.WithNumber(param.Name, options...)
153 | 	case "boolean":
154 | 		return mcp.WithBoolean(param.Name, options...)
155 | 	case "array":
156 | 		return mcp.WithArray(param.Name, options...)
157 | 	case "object":
158 | 		return mcp.WithObject(param.Name, options...)
159 | 	default:
160 | 		// Default to string if type is unknown
161 | 		return mcp.WithString(param.Name, options...)
162 | 	}
163 | }
164 | 
```

--------------------------------------------------------------------------------
/tests/integration/server_test.go:
--------------------------------------------------------------------------------

```go
  1 | package integration
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"fmt"
  6 | 	"testing"
  7 | 
  8 | 	mcpmodels "github.com/mark3labs/mcp-go/mcp"
  9 | 	"github.com/portainer/portainer-mcp/internal/mcp"
 10 | 	"github.com/portainer/portainer-mcp/tests/integration/containers"
 11 | 	"github.com/stretchr/testify/assert"
 12 | 	"github.com/stretchr/testify/require"
 13 | )
 14 | 
 15 | const (
 16 | 	toolsPath        = "../../internal/tooldef/tools.yaml"
 17 | 	unsupportedImage = "portainer/portainer-ee:2.29.1" // Older version than SupportedPortainerVersion
 18 | )
 19 | 
 20 | // TestServerInitialization verifies that the Portainer MCP server
 21 | // can be successfully initialized with a real Portainer instance.
 22 | func TestServerInitialization(t *testing.T) {
 23 | 	// Start a Portainer container
 24 | 	ctx := context.Background()
 25 | 
 26 | 	portainer, err := containers.NewPortainerContainer(ctx)
 27 | 	require.NoError(t, err, "Failed to start Portainer container")
 28 | 
 29 | 	// Ensure container is terminated at the end of the test
 30 | 	defer func() {
 31 | 		if err := portainer.Terminate(ctx); err != nil {
 32 | 			t.Logf("Failed to terminate container: %v", err)
 33 | 		}
 34 | 	}()
 35 | 
 36 | 	// Get the host and port for the Portainer API
 37 | 	host, port := portainer.GetHostAndPort()
 38 | 	serverURL := fmt.Sprintf("%s:%s", host, port)
 39 | 	apiToken := portainer.GetAPIToken()
 40 | 
 41 | 	// Create the MCP server - this is the main test objective
 42 | 	mcpServer, err := mcp.NewPortainerMCPServer(serverURL, apiToken, toolsPath)
 43 | 
 44 | 	// Assert the server was created successfully
 45 | 	require.NoError(t, err, "Failed to create MCP server")
 46 | 	require.NotNil(t, mcpServer, "MCP server should not be nil")
 47 | }
 48 | 
 49 | // TestServerInitializationUnsupportedVersion verifies that the Portainer MCP server
 50 | // correctly rejects initialization with an unsupported Portainer version.
 51 | func TestServerInitializationUnsupportedVersion(t *testing.T) {
 52 | 	// Start a Portainer container with unsupported version
 53 | 	ctx := context.Background()
 54 | 
 55 | 	portainer, err := containers.NewPortainerContainer(ctx, containers.WithImage(unsupportedImage))
 56 | 	require.NoError(t, err, "Failed to start unsupported Portainer container")
 57 | 
 58 | 	// Ensure container is terminated at the end of the test
 59 | 	defer func() {
 60 | 		if err := portainer.Terminate(ctx); err != nil {
 61 | 			t.Logf("Failed to terminate container: %v", err)
 62 | 		}
 63 | 	}()
 64 | 
 65 | 	// Get the host and port for the Portainer API
 66 | 	host, port := portainer.GetHostAndPort()
 67 | 	serverURL := fmt.Sprintf("%s:%s", host, port)
 68 | 	apiToken := portainer.GetAPIToken()
 69 | 
 70 | 	// Try to create the MCP server - should fail with version error
 71 | 	mcpServer, err := mcp.NewPortainerMCPServer(serverURL, apiToken, toolsPath)
 72 | 
 73 | 	// Assert the server creation failed with correct error
 74 | 	assert.Error(t, err, "Server creation should fail with unsupported version")
 75 | 	assert.Contains(t, err.Error(), "unsupported Portainer server version", "Error should indicate version mismatch")
 76 | 	assert.Nil(t, mcpServer, "Server should be nil when version check fails")
 77 | }
 78 | 
 79 | // TestServerInitializationDisabledVersionCheck verifies that the Portainer MCP server
 80 | // can successfully connect to unsupported Portainer versions when version check is disabled.
 81 | func TestServerInitializationDisabledVersionCheck(t *testing.T) {
 82 | 	// Start a Portainer container with unsupported version
 83 | 	ctx := context.Background()
 84 | 
 85 | 	portainer, err := containers.NewPortainerContainer(ctx, containers.WithImage(unsupportedImage))
 86 | 	require.NoError(t, err, "Failed to start unsupported Portainer container")
 87 | 
 88 | 	// Ensure container is terminated at the end of the test
 89 | 	defer func() {
 90 | 		if err := portainer.Terminate(ctx); err != nil {
 91 | 			t.Logf("Failed to terminate container: %v", err)
 92 | 		}
 93 | 	}()
 94 | 
 95 | 	// Get the host and port for the Portainer API
 96 | 	host, port := portainer.GetHostAndPort()
 97 | 	serverURL := fmt.Sprintf("%s:%s", host, port)
 98 | 	apiToken := portainer.GetAPIToken()
 99 | 
100 | 	// Create the MCP server with disabled version check - should succeed despite unsupported version
101 | 	mcpServer, err := mcp.NewPortainerMCPServer(serverURL, apiToken, toolsPath, mcp.WithDisableVersionCheck(true))
102 | 
103 | 	// Assert the server was created successfully
104 | 	require.NoError(t, err, "Failed to create MCP server with disabled version check")
105 | 	require.NotNil(t, mcpServer, "MCP server should not be nil when version check is disabled")
106 | 
107 | 	// Verify basic functionality by testing settings retrieval
108 | 	handler := mcpServer.HandleGetSettings()
109 | 	request := mcp.CreateMCPRequest(nil) // GetSettings doesn't require parameters
110 | 
111 | 	result, err := handler(ctx, request)
112 | 	require.NoError(t, err, "Failed to get settings via MCP handler with disabled version check")
113 | 	require.NotNil(t, result, "Settings result should not be nil")
114 | 	require.Len(t, result.Content, 1, "Expected exactly one content block in settings result")
115 | 
116 | 	// Verify the response contains valid content
117 | 	textContent, ok := result.Content[0].(mcpmodels.TextContent)
118 | 	require.True(t, ok, "Expected text content in settings MCP response")
119 | 	assert.NotEmpty(t, textContent.Text, "Settings response should not be empty")
120 | }
121 | 
```

--------------------------------------------------------------------------------
/internal/mcp/kubernetes.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"fmt"
  6 | 	"io"
  7 | 	"strings"
  8 | 
  9 | 	"github.com/mark3labs/mcp-go/mcp"
 10 | 	"github.com/mark3labs/mcp-go/server"
 11 | 	"github.com/portainer/portainer-mcp/internal/k8sutil"
 12 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
 13 | 	"github.com/portainer/portainer-mcp/pkg/toolgen"
 14 | )
 15 | 
 16 | func (s *PortainerMCPServer) AddKubernetesProxyFeatures() {
 17 | 	s.addToolIfExists(ToolKubernetesProxyStripped, s.HandleKubernetesProxyStripped())
 18 | 
 19 | 	if !s.readOnly {
 20 | 		s.addToolIfExists(ToolKubernetesProxy, s.HandleKubernetesProxy())
 21 | 	}
 22 | }
 23 | 
 24 | func (s *PortainerMCPServer) HandleKubernetesProxyStripped() server.ToolHandlerFunc {
 25 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 26 | 		parser := toolgen.NewParameterParser(request)
 27 | 
 28 | 		environmentId, err := parser.GetInt("environmentId", true)
 29 | 		if err != nil {
 30 | 			return mcp.NewToolResultErrorFromErr("invalid environmentId parameter", err), nil
 31 | 		}
 32 | 
 33 | 		kubernetesAPIPath, err := parser.GetString("kubernetesAPIPath", true)
 34 | 		if err != nil {
 35 | 			return mcp.NewToolResultErrorFromErr("invalid kubernetesAPIPath parameter", err), nil
 36 | 		}
 37 | 		if !strings.HasPrefix(kubernetesAPIPath, "/") {
 38 | 			return mcp.NewToolResultError("kubernetesAPIPath must start with a leading slash"), nil
 39 | 		}
 40 | 
 41 | 		queryParams, err := parser.GetArrayOfObjects("queryParams", false)
 42 | 		if err != nil {
 43 | 			return mcp.NewToolResultErrorFromErr("invalid queryParams parameter", err), nil
 44 | 		}
 45 | 		queryParamsMap, err := parseKeyValueMap(queryParams)
 46 | 		if err != nil {
 47 | 			return mcp.NewToolResultErrorFromErr("invalid query params", err), nil
 48 | 		}
 49 | 
 50 | 		headers, err := parser.GetArrayOfObjects("headers", false)
 51 | 		if err != nil {
 52 | 			return mcp.NewToolResultErrorFromErr("invalid headers parameter", err), nil
 53 | 		}
 54 | 		headersMap, err := parseKeyValueMap(headers)
 55 | 		if err != nil {
 56 | 			return mcp.NewToolResultErrorFromErr("invalid headers", err), nil
 57 | 		}
 58 | 
 59 | 		opts := models.KubernetesProxyRequestOptions{
 60 | 			EnvironmentID: environmentId,
 61 | 			Path:          kubernetesAPIPath,
 62 | 			Method:        "GET",
 63 | 			QueryParams:   queryParamsMap,
 64 | 			Headers:       headersMap,
 65 | 		}
 66 | 
 67 | 		response, err := s.cli.ProxyKubernetesRequest(opts)
 68 | 		if err != nil {
 69 | 			return mcp.NewToolResultErrorFromErr("failed to send Kubernetes API request", err), nil
 70 | 		}
 71 | 
 72 | 		responseBody, err := k8sutil.ProcessRawKubernetesAPIResponse(response)
 73 | 		if err != nil {
 74 | 			return mcp.NewToolResultErrorFromErr("failed to process Kubernetes API response", err), nil
 75 | 		}
 76 | 
 77 | 		return mcp.NewToolResultText(string(responseBody)), nil
 78 | 	}
 79 | }
 80 | 
 81 | func (s *PortainerMCPServer) HandleKubernetesProxy() server.ToolHandlerFunc {
 82 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 83 | 		parser := toolgen.NewParameterParser(request)
 84 | 
 85 | 		environmentId, err := parser.GetInt("environmentId", true)
 86 | 		if err != nil {
 87 | 			return mcp.NewToolResultErrorFromErr("invalid environmentId parameter", err), nil
 88 | 		}
 89 | 
 90 | 		method, err := parser.GetString("method", true)
 91 | 		if err != nil {
 92 | 			return mcp.NewToolResultErrorFromErr("invalid method parameter", err), nil
 93 | 		}
 94 | 		if !isValidHTTPMethod(method) {
 95 | 			return mcp.NewToolResultError(fmt.Sprintf("invalid method: %s", method)), nil
 96 | 		}
 97 | 
 98 | 		kubernetesAPIPath, err := parser.GetString("kubernetesAPIPath", true)
 99 | 		if err != nil {
100 | 			return mcp.NewToolResultErrorFromErr("invalid kubernetesAPIPath parameter", err), nil
101 | 		}
102 | 		if !strings.HasPrefix(kubernetesAPIPath, "/") {
103 | 			return mcp.NewToolResultError("kubernetesAPIPath must start with a leading slash"), nil
104 | 		}
105 | 
106 | 		queryParams, err := parser.GetArrayOfObjects("queryParams", false)
107 | 		if err != nil {
108 | 			return mcp.NewToolResultErrorFromErr("invalid queryParams parameter", err), nil
109 | 		}
110 | 		queryParamsMap, err := parseKeyValueMap(queryParams)
111 | 		if err != nil {
112 | 			return mcp.NewToolResultErrorFromErr("invalid query params", err), nil
113 | 		}
114 | 
115 | 		headers, err := parser.GetArrayOfObjects("headers", false)
116 | 		if err != nil {
117 | 			return mcp.NewToolResultErrorFromErr("invalid headers parameter", err), nil
118 | 		}
119 | 		headersMap, err := parseKeyValueMap(headers)
120 | 		if err != nil {
121 | 			return mcp.NewToolResultErrorFromErr("invalid headers", err), nil
122 | 		}
123 | 
124 | 		body, err := parser.GetString("body", false)
125 | 		if err != nil {
126 | 			return mcp.NewToolResultErrorFromErr("invalid body parameter", err), nil
127 | 		}
128 | 
129 | 		opts := models.KubernetesProxyRequestOptions{
130 | 			EnvironmentID: environmentId,
131 | 			Path:          kubernetesAPIPath,
132 | 			Method:        method,
133 | 			QueryParams:   queryParamsMap,
134 | 			Headers:       headersMap,
135 | 		}
136 | 
137 | 		if body != "" {
138 | 			opts.Body = strings.NewReader(body)
139 | 		}
140 | 
141 | 		response, err := s.cli.ProxyKubernetesRequest(opts)
142 | 		if err != nil {
143 | 			return mcp.NewToolResultErrorFromErr("failed to send Kubernetes API request", err), nil
144 | 		}
145 | 
146 | 		responseBody, err := io.ReadAll(response.Body)
147 | 		if err != nil {
148 | 			return mcp.NewToolResultErrorFromErr("failed to read Kubernetes API response", err), nil
149 | 		}
150 | 
151 | 		return mcp.NewToolResultText(string(responseBody)), nil
152 | 	}
153 | }
154 | 
```

--------------------------------------------------------------------------------
/internal/mcp/server_test.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"errors"
  6 | 	"testing"
  7 | 
  8 | 	"github.com/mark3labs/mcp-go/mcp"
  9 | 	"github.com/mark3labs/mcp-go/server"
 10 | 	"github.com/stretchr/testify/assert"
 11 | 	"github.com/stretchr/testify/require"
 12 | )
 13 | 
 14 | func TestNewPortainerMCPServer(t *testing.T) {
 15 | 	// Define paths to test data files
 16 | 	validToolsPath := "testdata/valid_tools.yaml"
 17 | 	invalidToolsPath := "testdata/invalid_tools.yaml"
 18 | 
 19 | 	tests := []struct {
 20 | 		name          string
 21 | 		serverURL     string
 22 | 		token         string
 23 | 		toolsPath     string
 24 | 		mockSetup     func(*MockPortainerClient)
 25 | 		expectError   bool
 26 | 		errorContains string
 27 | 	}{
 28 | 		{
 29 | 			name:      "successful initialization with supported version",
 30 | 			serverURL: "https://portainer.example.com",
 31 | 			token:     "valid-token",
 32 | 			toolsPath: validToolsPath,
 33 | 			mockSetup: func(m *MockPortainerClient) {
 34 | 				m.On("GetVersion").Return(SupportedPortainerVersion, nil)
 35 | 			},
 36 | 			expectError: false,
 37 | 		},
 38 | 		{
 39 | 			name:          "invalid tools path",
 40 | 			serverURL:     "https://portainer.example.com",
 41 | 			token:         "valid-token",
 42 | 			toolsPath:     "testdata/nonexistent.yaml",
 43 | 			mockSetup:     func(m *MockPortainerClient) {},
 44 | 			expectError:   true,
 45 | 			errorContains: "failed to load tools",
 46 | 		},
 47 | 		{
 48 | 			name:          "invalid tools version",
 49 | 			serverURL:     "https://portainer.example.com",
 50 | 			token:         "valid-token",
 51 | 			toolsPath:     invalidToolsPath,
 52 | 			mockSetup:     func(m *MockPortainerClient) {},
 53 | 			expectError:   true,
 54 | 			errorContains: "invalid version in tools.yaml",
 55 | 		},
 56 | 		{
 57 | 			name:      "API communication error",
 58 | 			serverURL: "https://portainer.example.com",
 59 | 			token:     "valid-token",
 60 | 			toolsPath: validToolsPath,
 61 | 			mockSetup: func(m *MockPortainerClient) {
 62 | 				m.On("GetVersion").Return("", errors.New("connection error"))
 63 | 			},
 64 | 			expectError:   true,
 65 | 			errorContains: "failed to get Portainer server version",
 66 | 		},
 67 | 		{
 68 | 			name:      "unsupported Portainer version",
 69 | 			serverURL: "https://portainer.example.com",
 70 | 			token:     "valid-token",
 71 | 			toolsPath: validToolsPath,
 72 | 			mockSetup: func(m *MockPortainerClient) {
 73 | 				m.On("GetVersion").Return("2.0.0", nil)
 74 | 			},
 75 | 			expectError:   true,
 76 | 			errorContains: "unsupported Portainer server version",
 77 | 		},
 78 | 		{
 79 | 			name:      "unsupported version with disabled version check",
 80 | 			serverURL: "https://portainer.example.com",
 81 | 			token:     "valid-token",
 82 | 			toolsPath: validToolsPath,
 83 | 			mockSetup: func(m *MockPortainerClient) {
 84 | 				// No GetVersion call expected when version check is disabled
 85 | 			},
 86 | 			expectError: false,
 87 | 		},
 88 | 	}
 89 | 
 90 | 	for _, tt := range tests {
 91 | 		t.Run(tt.name, func(t *testing.T) {
 92 | 			// Create and configure the mock client
 93 | 			mockClient := new(MockPortainerClient)
 94 | 			tt.mockSetup(mockClient)
 95 | 
 96 | 			// Create server with mock client using the WithClient option
 97 | 			var options []ServerOption
 98 | 			options = append(options, WithClient(mockClient))
 99 | 
100 | 			// Add WithDisableVersionCheck for the specific test case
101 | 			if tt.name == "unsupported version with disabled version check" {
102 | 				options = append(options, WithDisableVersionCheck(true))
103 | 			}
104 | 
105 | 			server, err := NewPortainerMCPServer(
106 | 				tt.serverURL,
107 | 				tt.token,
108 | 				tt.toolsPath,
109 | 				options...,
110 | 			)
111 | 
112 | 			if tt.expectError {
113 | 				assert.Error(t, err)
114 | 				if tt.errorContains != "" {
115 | 					assert.Contains(t, err.Error(), tt.errorContains)
116 | 				}
117 | 				assert.Nil(t, server)
118 | 			} else {
119 | 				require.NoError(t, err)
120 | 				assert.NotNil(t, server)
121 | 				assert.NotNil(t, server.srv)
122 | 				assert.NotNil(t, server.cli)
123 | 				assert.NotNil(t, server.tools)
124 | 			}
125 | 
126 | 			// Verify that all expected methods were called
127 | 			mockClient.AssertExpectations(t)
128 | 		})
129 | 	}
130 | }
131 | 
132 | func TestAddToolIfExists(t *testing.T) {
133 | 	tests := []struct {
134 | 		name     string
135 | 		tools    map[string]mcp.Tool
136 | 		toolName string
137 | 		exists   bool
138 | 	}{
139 | 		{
140 | 			name: "existing tool",
141 | 			tools: map[string]mcp.Tool{
142 | 				"test_tool": {
143 | 					Name:        "test_tool",
144 | 					Description: "Test tool description",
145 | 					InputSchema: mcp.ToolInputSchema{
146 | 						Properties: map[string]any{},
147 | 					},
148 | 				},
149 | 			},
150 | 			toolName: "test_tool",
151 | 			exists:   true,
152 | 		},
153 | 		{
154 | 			name: "non-existing tool",
155 | 			tools: map[string]mcp.Tool{
156 | 				"test_tool": {
157 | 					Name:        "test_tool",
158 | 					Description: "Test tool description",
159 | 					InputSchema: mcp.ToolInputSchema{
160 | 						Properties: map[string]any{},
161 | 					},
162 | 				},
163 | 			},
164 | 			toolName: "nonexistent_tool",
165 | 			exists:   false,
166 | 		},
167 | 	}
168 | 
169 | 	for _, tt := range tests {
170 | 		t.Run(tt.name, func(t *testing.T) {
171 | 			// Create server with test tools
172 | 			mcpServer := server.NewMCPServer(
173 | 				"Test Server",
174 | 				"1.0.0",
175 | 				server.WithResourceCapabilities(true, true),
176 | 				server.WithLogging(),
177 | 			)
178 | 			server := &PortainerMCPServer{
179 | 				tools: tt.tools,
180 | 				srv:   mcpServer,
181 | 			}
182 | 
183 | 			// Create a handler function
184 | 			handler := func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
185 | 				return &mcp.CallToolResult{}, nil
186 | 			}
187 | 
188 | 			// Call addToolIfExists
189 | 			server.addToolIfExists(tt.toolName, handler)
190 | 
191 | 			// Verify if the tool exists in the tools map
192 | 			_, toolExists := server.tools[tt.toolName]
193 | 			assert.Equal(t, tt.exists, toolExists)
194 | 		})
195 | 	}
196 | }
197 | 
```

--------------------------------------------------------------------------------
/internal/mcp/user_test.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"encoding/json"
  6 | 	"fmt"
  7 | 	"testing"
  8 | 
  9 | 	"github.com/mark3labs/mcp-go/mcp"
 10 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
 11 | 	"github.com/stretchr/testify/assert"
 12 | )
 13 | 
 14 | func TestHandleGetUsers(t *testing.T) {
 15 | 	tests := []struct {
 16 | 		name        string
 17 | 		mockUsers   []models.User
 18 | 		mockError   error
 19 | 		expectError bool
 20 | 	}{
 21 | 		{
 22 | 			name: "successful users retrieval",
 23 | 			mockUsers: []models.User{
 24 | 				{ID: 1, Username: "user1", Role: "admin"},
 25 | 				{ID: 2, Username: "user2", Role: "user"},
 26 | 			},
 27 | 			mockError:   nil,
 28 | 			expectError: false,
 29 | 		},
 30 | 		{
 31 | 			name:        "api error",
 32 | 			mockUsers:   nil,
 33 | 			mockError:   fmt.Errorf("api error"),
 34 | 			expectError: true,
 35 | 		},
 36 | 	}
 37 | 
 38 | 	for _, tt := range tests {
 39 | 		t.Run(tt.name, func(t *testing.T) {
 40 | 			// Create mock client
 41 | 			mockClient := &MockPortainerClient{}
 42 | 			mockClient.On("GetUsers").Return(tt.mockUsers, tt.mockError)
 43 | 
 44 | 			// Create server with mock client
 45 | 			server := &PortainerMCPServer{
 46 | 				cli: mockClient,
 47 | 			}
 48 | 
 49 | 			// Call handler
 50 | 			handler := server.HandleGetUsers()
 51 | 			result, err := handler(context.Background(), mcp.CallToolRequest{})
 52 | 
 53 | 			// Verify results
 54 | 			if tt.expectError {
 55 | 				assert.NoError(t, err)
 56 | 				assert.NotNil(t, result)
 57 | 				assert.True(t, result.IsError, "result.IsError should be true for API errors")
 58 | 				assert.Len(t, result.Content, 1)
 59 | 				textContent, ok := result.Content[0].(mcp.TextContent)
 60 | 				assert.True(t, ok, "Result content should be mcp.TextContent")
 61 | 				if tt.mockError != nil {
 62 | 					assert.Contains(t, textContent.Text, tt.mockError.Error())
 63 | 				}
 64 | 			} else {
 65 | 				assert.NoError(t, err)
 66 | 				assert.Len(t, result.Content, 1)
 67 | 				textContent, ok := result.Content[0].(mcp.TextContent)
 68 | 				assert.True(t, ok)
 69 | 
 70 | 				var users []models.User
 71 | 				err = json.Unmarshal([]byte(textContent.Text), &users)
 72 | 				assert.NoError(t, err)
 73 | 				assert.Equal(t, tt.mockUsers, users)
 74 | 			}
 75 | 
 76 | 			// Verify mock expectations
 77 | 			mockClient.AssertExpectations(t)
 78 | 		})
 79 | 	}
 80 | }
 81 | 
 82 | func TestHandleUpdateUserRole(t *testing.T) {
 83 | 	tests := []struct {
 84 | 		name        string
 85 | 		inputID     int
 86 | 		inputRole   string
 87 | 		mockError   error
 88 | 		expectError bool
 89 | 		setupParams func(request *mcp.CallToolRequest)
 90 | 	}{
 91 | 		{
 92 | 			name:        "successful role update",
 93 | 			inputID:     1,
 94 | 			inputRole:   "admin",
 95 | 			mockError:   nil,
 96 | 			expectError: false,
 97 | 			setupParams: func(request *mcp.CallToolRequest) {
 98 | 				request.Params.Arguments = map[string]any{
 99 | 					"id":   float64(1),
100 | 					"role": "admin",
101 | 				}
102 | 			},
103 | 		},
104 | 		{
105 | 			name:        "api error",
106 | 			inputID:     1,
107 | 			inputRole:   "admin",
108 | 			mockError:   fmt.Errorf("api error"),
109 | 			expectError: true,
110 | 			setupParams: func(request *mcp.CallToolRequest) {
111 | 				request.Params.Arguments = map[string]any{
112 | 					"id":   float64(1),
113 | 					"role": "admin",
114 | 				}
115 | 			},
116 | 		},
117 | 		{
118 | 			name:        "missing id parameter",
119 | 			inputID:     0,
120 | 			inputRole:   "admin",
121 | 			mockError:   nil,
122 | 			expectError: true,
123 | 			setupParams: func(request *mcp.CallToolRequest) {
124 | 				request.Params.Arguments = map[string]any{
125 | 					"role": "admin",
126 | 				}
127 | 			},
128 | 		},
129 | 		{
130 | 			name:        "missing role parameter",
131 | 			inputID:     1,
132 | 			inputRole:   "",
133 | 			mockError:   nil,
134 | 			expectError: true,
135 | 			setupParams: func(request *mcp.CallToolRequest) {
136 | 				request.Params.Arguments = map[string]any{
137 | 					"id": float64(1),
138 | 				}
139 | 			},
140 | 		},
141 | 		{
142 | 			name:        "invalid role",
143 | 			inputID:     1,
144 | 			inputRole:   "invalid_role",
145 | 			mockError:   nil,
146 | 			expectError: true,
147 | 			setupParams: func(request *mcp.CallToolRequest) {
148 | 				request.Params.Arguments = map[string]any{
149 | 					"id":   float64(1),
150 | 					"role": "invalid_role",
151 | 				}
152 | 			},
153 | 		},
154 | 	}
155 | 
156 | 	for _, tt := range tests {
157 | 		t.Run(tt.name, func(t *testing.T) {
158 | 			// Create mock client
159 | 			mockClient := &MockPortainerClient{}
160 | 			if !tt.expectError || tt.mockError != nil {
161 | 				mockClient.On("UpdateUserRole", tt.inputID, tt.inputRole).Return(tt.mockError)
162 | 			}
163 | 
164 | 			// Create server with mock client
165 | 			server := &PortainerMCPServer{
166 | 				cli: mockClient,
167 | 			}
168 | 
169 | 			// Create request with parameters
170 | 			request := CreateMCPRequest(map[string]any{})
171 | 			tt.setupParams(&request)
172 | 
173 | 			// Call handler
174 | 			handler := server.HandleUpdateUserRole()
175 | 			result, err := handler(context.Background(), request)
176 | 
177 | 			// Verify results
178 | 			if tt.expectError {
179 | 				assert.NoError(t, err)
180 | 				assert.NotNil(t, result)
181 | 				assert.True(t, result.IsError, "result.IsError should be true for expected errors")
182 | 				assert.Len(t, result.Content, 1)
183 | 				textContent, ok := result.Content[0].(mcp.TextContent)
184 | 				assert.True(t, ok, "Result content should be mcp.TextContent for errors")
185 | 				if tt.mockError != nil {
186 | 					assert.Contains(t, textContent.Text, tt.mockError.Error())
187 | 				} else {
188 | 					assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter/validation errors")
189 | 					if tt.inputRole == "invalid_role" {
190 | 						assert.Contains(t, textContent.Text, "invalid role")
191 | 					}
192 | 				}
193 | 			} else {
194 | 				assert.NoError(t, err)
195 | 				assert.Len(t, result.Content, 1)
196 | 				textContent, ok := result.Content[0].(mcp.TextContent)
197 | 				assert.True(t, ok)
198 | 				assert.Contains(t, textContent.Text, "successfully")
199 | 			}
200 | 
201 | 			// Verify mock expectations
202 | 			mockClient.AssertExpectations(t)
203 | 		})
204 | 	}
205 | }
206 | 
```

--------------------------------------------------------------------------------
/cloc.sh:
--------------------------------------------------------------------------------

```bash
  1 | #!/bin/bash
  2 | 
  3 | # This scripts counts the lines of code (LOC) and comments in Go source files
  4 | # within this project directory. It uses the commandline tool "cloc".
  5 | # Requires `cloc` to be installed (e.g., `sudo apt install cloc` or `brew install cloc`).
  6 | # Modified from: https://schneegans.github.io/tutorials/2022/04/18/badges
  7 | #
  8 | # Usage:
  9 | #   Run from the repository root:
 10 | #     ./cloc.sh
 11 | #
 12 | # Default Output:
 13 | #   Displays a summary of code statistics:
 14 | #     Total lines of code:                   <value>k
 15 | #     Lines of source code:                  <value>k
 16 | #     Lines of comments (source code):       <value>k
 17 | #     Lines of test code:                    <value>k
 18 | #     Comment Percentage:                    <value>%
 19 | #     Test Percentage:                       <value>%
 20 | #
 21 | # Flags for Specific Metrics:
 22 | #   You can request individual metrics using the following flags:
 23 | #     --loc             : Lines of source code (Go files, excluding tests).
 24 | #     --comments        : Lines of comments in source code.
 25 | #     --percentage      : Comment percentage in source code.
 26 | #     --test-loc        : Lines of test code (_test.go files + tests/integration/ dir).
 27 | #     --test-percentage : Percentage of test code compared to total code.
 28 | #     --total-loc       : Total lines of code (source + test).
 29 | #
 30 | # Example:
 31 | #   ./cloc.sh --test-percentage
 32 | #   # Output: 19.0 (example value)
 33 | 
 34 | # Get the location of this script.
 35 | SCRIPT_DIR="$( cd "$( dirname "$0" )" && pwd )"
 36 | 
 37 | # Run cloc for source code - this counts code lines, blank lines and comment lines
 38 | # for the specified languages, excluding test files.
 39 | # We are only interested in the summary, therefore the tail -1
 40 | SUMMARY_SRC="$(cloc "${SCRIPT_DIR}" --include-lang="Go" --not-match-f="_test\.go$" --not-match-d="tests/integration" --md | tail -1)"
 41 | 
 42 | # Run cloc for test files ending in _test.go
 43 | SUMMARY_TEST_FILES="$(cloc "${SCRIPT_DIR}" --include-lang="Go" --match-f='_test\.go$' --md | tail -1)"
 44 | 
 45 | # Run cloc for the tests/integration directory if it exists
 46 | SUMMARY_TEST_DIR=""
 47 | if [[ -d "${SCRIPT_DIR}/tests/integration" ]]; then
 48 |   SUMMARY_TEST_DIR="$(cloc "${SCRIPT_DIR}/tests/integration" --include-lang="Go" --md | tail -1)"
 49 | fi
 50 | 
 51 | 
 52 | # The SUMMARY strings are lines of a markdown table and look like this:
 53 | # SUM:|files|blank|comment|code
 54 | # We use the following command to split it into an array.
 55 | IFS='|' read -r -a TOKENS_SRC <<< "$SUMMARY_SRC"
 56 | IFS='|' read -r -a TOKENS_TEST_FILES <<< "$SUMMARY_TEST_FILES"
 57 | IFS='|' read -r -a TOKENS_TEST_DIR <<< "$SUMMARY_TEST_DIR"
 58 | 
 59 | # Store the individual tokens for better readability.
 60 | # Source Code
 61 | NUMBER_OF_FILES_SRC=${TOKENS_SRC[1]:-0} # Default to 0 if empty
 62 | COMMENT_LINES_SRC=${TOKENS_SRC[3]:-0}
 63 | LINES_OF_CODE_SRC=${TOKENS_SRC[4]:-0}
 64 | 
 65 | # Test Code (_test.go files)
 66 | LINES_OF_CODE_TEST_FILES=${TOKENS_TEST_FILES[4]:-0}
 67 | 
 68 | # Test Code (tests/integration dir)
 69 | LINES_OF_CODE_TEST_DIR=${TOKENS_TEST_DIR[4]:-0}
 70 | 
 71 | # Total Test Code
 72 | LINES_OF_TEST_CODE=$((LINES_OF_CODE_TEST_FILES + LINES_OF_CODE_TEST_DIR))
 73 | 
 74 | # Total Code (Source + Test)
 75 | TOTAL_LINES_OF_CODE=$((LINES_OF_CODE_SRC + LINES_OF_TEST_CODE))
 76 | 
 77 | 
 78 | # Print all results if no arguments are given.
 79 | if [[ $# -eq 0 ]] ; then
 80 |   awk -v loc_src=$LINES_OF_CODE_SRC \
 81 |       -v comments_src=$COMMENT_LINES_SRC \
 82 |       -v loc_test=$LINES_OF_TEST_CODE \
 83 |       -v loc_total=$TOTAL_LINES_OF_CODE \
 84 |       'BEGIN {
 85 |           label_width = 35 # Define a width for the labels
 86 |           printf "%-*s %6.1fk\n", label_width, "Total lines of code:", loc_total/1000;
 87 |           printf "%-*s %6.1fk\n", label_width, "Lines of source code:", loc_src/1000;
 88 |           printf "%-*s %6.1fk\n", label_width, "Lines of comments (source code):", comments_src/1000;
 89 |           printf "%-*s %6.1fk\n", label_width, "Lines of test code:", loc_test/1000;
 90 |           if (loc_src + comments_src > 0) {
 91 |             printf "%-*s %6.1f%%\n", label_width, "Comment Percentage:", 100*comments_src/(loc_src + comments_src);
 92 |           } else {
 93 |             printf "%-*s %6s\n", label_width, "Comment Percentage:", "N/A"; # Adjusted N/A alignment
 94 |           }
 95 |           if (loc_src + loc_test > 0) { 
 96 |             printf "%-*s %6.1f%%\n", label_width, "Test Percentage:", 100*loc_test/(loc_src + loc_test);
 97 |           } else {
 98 |             printf "%-*s %6s\n", label_width, "Test Percentage:", "N/A"; # Adjusted N/A alignment
 99 |           }
100 |       }'
101 |   exit 0
102 | fi
103 | 
104 | # --- Argument Parsing ---
105 | 
106 | # Show lines of source code if --loc is given.
107 | if [[ $* == *--loc* ]]
108 | then
109 |   awk -v a=$LINES_OF_CODE_SRC \
110 |       'BEGIN {printf "%.1fk\n", a/1000}'
111 | fi
112 | 
113 | # Show lines of comments if --comments is given.
114 | if [[ $* == *--comments* ]]
115 | then
116 |   awk -v a=$COMMENT_LINES_SRC \
117 |       'BEGIN {printf "%.1fk\n", a/1000}'
118 | fi
119 | 
120 | # Show percentage of comments if --percentage is given.
121 | if [[ $* == *--percentage* ]]
122 | then
123 |   awk -v a=$COMMENT_LINES_SRC -v b=$LINES_OF_CODE_SRC \
124 |       'BEGIN {if (a+b > 0) printf "%.1f\n", 100*a/(a+b); else print "N/A"}'
125 | fi
126 | 
127 | # Show lines of test code if --test-loc is given.
128 | if [[ $* == *--test-loc* ]]
129 | then
130 |   awk -v a=$LINES_OF_TEST_CODE \
131 |       'BEGIN {printf "%.1fk\n", a/1000}'
132 | fi
133 | 
134 | # Show test percentage if --test-percentage is given.
135 | if [[ $* == *--test-percentage* ]]
136 | then
137 |   awk -v a=$LINES_OF_TEST_CODE -v b=$LINES_OF_CODE_SRC \
138 |       'BEGIN {if (a+b > 0) printf "%.1f\n", 100*a/(a+b); else print "N/A"}'
139 | fi
140 | 
141 | # Show total lines of code if --total-loc is given.
142 | if [[ $* == *--total-loc* ]]
143 | then
144 |   awk -v a=$TOTAL_LINES_OF_CODE \
145 |       'BEGIN {printf "%.1fk\n", a/1000}'
146 | fi
```

--------------------------------------------------------------------------------
/pkg/portainer/client/stack_test.go:
--------------------------------------------------------------------------------

```go
  1 | package client
  2 | 
  3 | import (
  4 | 	"errors"
  5 | 	"testing"
  6 | 	"time"
  7 | 
  8 | 	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
  9 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
 10 | 	"github.com/portainer/portainer-mcp/pkg/portainer/utils"
 11 | 	"github.com/stretchr/testify/assert"
 12 | )
 13 | 
 14 | func TestGetStacks(t *testing.T) {
 15 | 	now := time.Now().Unix()
 16 | 	tests := []struct {
 17 | 		name          string
 18 | 		mockStacks    []*apimodels.PortainereeEdgeStack
 19 | 		mockError     error
 20 | 		expected      []models.Stack
 21 | 		expectedError bool
 22 | 	}{
 23 | 		{
 24 | 			name: "successful retrieval",
 25 | 			mockStacks: []*apimodels.PortainereeEdgeStack{
 26 | 				{
 27 | 					ID:           1,
 28 | 					Name:         "stack1",
 29 | 					CreationDate: now,
 30 | 					EdgeGroups:   []int64{1, 2},
 31 | 				},
 32 | 				{
 33 | 					ID:           2,
 34 | 					Name:         "stack2",
 35 | 					CreationDate: now,
 36 | 					EdgeGroups:   []int64{3},
 37 | 				},
 38 | 			},
 39 | 			expected: []models.Stack{
 40 | 				{
 41 | 					ID:                  1,
 42 | 					Name:                "stack1",
 43 | 					CreatedAt:           time.Unix(now, 0).Format(time.RFC3339),
 44 | 					EnvironmentGroupIds: []int{1, 2},
 45 | 				},
 46 | 				{
 47 | 					ID:                  2,
 48 | 					Name:                "stack2",
 49 | 					CreatedAt:           time.Unix(now, 0).Format(time.RFC3339),
 50 | 					EnvironmentGroupIds: []int{3},
 51 | 				},
 52 | 			},
 53 | 		},
 54 | 		{
 55 | 			name:       "empty stacks",
 56 | 			mockStacks: []*apimodels.PortainereeEdgeStack{},
 57 | 			expected:   []models.Stack{},
 58 | 		},
 59 | 		{
 60 | 			name:          "list error",
 61 | 			mockError:     errors.New("failed to list stacks"),
 62 | 			expectedError: true,
 63 | 		},
 64 | 	}
 65 | 
 66 | 	for _, tt := range tests {
 67 | 		t.Run(tt.name, func(t *testing.T) {
 68 | 			mockAPI := new(MockPortainerAPI)
 69 | 			mockAPI.On("ListEdgeStacks").Return(tt.mockStacks, tt.mockError)
 70 | 
 71 | 			client := &PortainerClient{cli: mockAPI}
 72 | 
 73 | 			stacks, err := client.GetStacks()
 74 | 
 75 | 			if tt.expectedError {
 76 | 				assert.Error(t, err)
 77 | 				return
 78 | 			}
 79 | 			assert.NoError(t, err)
 80 | 			assert.Equal(t, tt.expected, stacks)
 81 | 			mockAPI.AssertExpectations(t)
 82 | 		})
 83 | 	}
 84 | }
 85 | 
 86 | func TestGetStackFile(t *testing.T) {
 87 | 	tests := []struct {
 88 | 		name          string
 89 | 		stackID       int
 90 | 		mockFile      string
 91 | 		mockError     error
 92 | 		expected      string
 93 | 		expectedError bool
 94 | 	}{
 95 | 		{
 96 | 			name:     "successful retrieval",
 97 | 			stackID:  1,
 98 | 			mockFile: "version: '3'\nservices:\n  web:\n    image: nginx",
 99 | 			expected: "version: '3'\nservices:\n  web:\n    image: nginx",
100 | 		},
101 | 		{
102 | 			name:          "get file error",
103 | 			stackID:       2,
104 | 			mockError:     errors.New("failed to get stack file"),
105 | 			expectedError: true,
106 | 		},
107 | 	}
108 | 
109 | 	for _, tt := range tests {
110 | 		t.Run(tt.name, func(t *testing.T) {
111 | 			mockAPI := new(MockPortainerAPI)
112 | 			mockAPI.On("GetEdgeStackFile", int64(tt.stackID)).Return(tt.mockFile, tt.mockError)
113 | 
114 | 			client := &PortainerClient{cli: mockAPI}
115 | 
116 | 			file, err := client.GetStackFile(tt.stackID)
117 | 
118 | 			if tt.expectedError {
119 | 				assert.Error(t, err)
120 | 				return
121 | 			}
122 | 			assert.NoError(t, err)
123 | 			assert.Equal(t, tt.expected, file)
124 | 			mockAPI.AssertExpectations(t)
125 | 		})
126 | 	}
127 | }
128 | 
129 | func TestCreateStack(t *testing.T) {
130 | 	tests := []struct {
131 | 		name                string
132 | 		stackName           string
133 | 		stackFile           string
134 | 		environmentGroupIds []int
135 | 		mockID              int64
136 | 		mockError           error
137 | 		expected            int
138 | 		expectedError       bool
139 | 	}{
140 | 		{
141 | 			name:                "successful creation",
142 | 			stackName:           "test-stack",
143 | 			stackFile:           "version: '3'\nservices:\n  web:\n    image: nginx",
144 | 			environmentGroupIds: []int{1, 2},
145 | 			mockID:              1,
146 | 			expected:            1,
147 | 		},
148 | 		{
149 | 			name:                "create error",
150 | 			stackName:           "test-stack",
151 | 			stackFile:           "version: '3'\nservices:\n  web:\n    image: nginx",
152 | 			environmentGroupIds: []int{1},
153 | 			mockError:           errors.New("failed to create stack"),
154 | 			expectedError:       true,
155 | 		},
156 | 	}
157 | 
158 | 	for _, tt := range tests {
159 | 		t.Run(tt.name, func(t *testing.T) {
160 | 			mockAPI := new(MockPortainerAPI)
161 | 			mockAPI.On("CreateEdgeStack", tt.stackName, tt.stackFile, utils.IntToInt64Slice(tt.environmentGroupIds)).Return(tt.mockID, tt.mockError)
162 | 
163 | 			client := &PortainerClient{cli: mockAPI}
164 | 
165 | 			id, err := client.CreateStack(tt.stackName, tt.stackFile, tt.environmentGroupIds)
166 | 
167 | 			if tt.expectedError {
168 | 				assert.Error(t, err)
169 | 				return
170 | 			}
171 | 			assert.NoError(t, err)
172 | 			assert.Equal(t, tt.expected, id)
173 | 			mockAPI.AssertExpectations(t)
174 | 		})
175 | 	}
176 | }
177 | 
178 | func TestUpdateStack(t *testing.T) {
179 | 	tests := []struct {
180 | 		name                string
181 | 		stackID             int
182 | 		stackFile           string
183 | 		environmentGroupIds []int
184 | 		mockError           error
185 | 		expectedError       bool
186 | 	}{
187 | 		{
188 | 			name:                "successful update",
189 | 			stackID:             1,
190 | 			stackFile:           "version: '3'\nservices:\n  web:\n    image: nginx:latest",
191 | 			environmentGroupIds: []int{1, 2},
192 | 		},
193 | 		{
194 | 			name:                "update error",
195 | 			stackID:             2,
196 | 			stackFile:           "version: '3'\nservices:\n  web:\n    image: nginx:latest",
197 | 			environmentGroupIds: []int{1},
198 | 			mockError:           errors.New("failed to update stack"),
199 | 			expectedError:       true,
200 | 		},
201 | 	}
202 | 
203 | 	for _, tt := range tests {
204 | 		t.Run(tt.name, func(t *testing.T) {
205 | 			mockAPI := new(MockPortainerAPI)
206 | 			mockAPI.On("UpdateEdgeStack", int64(tt.stackID), tt.stackFile, utils.IntToInt64Slice(tt.environmentGroupIds)).Return(tt.mockError)
207 | 
208 | 			client := &PortainerClient{cli: mockAPI}
209 | 
210 | 			err := client.UpdateStack(tt.stackID, tt.stackFile, tt.environmentGroupIds)
211 | 
212 | 			if tt.expectedError {
213 | 				assert.Error(t, err)
214 | 				return
215 | 			}
216 | 			assert.NoError(t, err)
217 | 			mockAPI.AssertExpectations(t)
218 | 		})
219 | 	}
220 | }
221 | 
```

--------------------------------------------------------------------------------
/pkg/portainer/models/environment_test.go:
--------------------------------------------------------------------------------

```go
  1 | package models
  2 | 
  3 | import (
  4 | 	"reflect"
  5 | 	"testing"
  6 | 
  7 | 	"github.com/portainer/client-api-go/v2/pkg/models"
  8 | )
  9 | 
 10 | func TestConvertEndpointToEnvironment(t *testing.T) {
 11 | 	tests := []struct {
 12 | 		name     string
 13 | 		endpoint *models.PortainereeEndpoint
 14 | 		want     Environment
 15 | 	}{
 16 | 		{
 17 | 			name: "active docker-local environment with accesses",
 18 | 			endpoint: &models.PortainereeEndpoint{
 19 | 				ID:     1,
 20 | 				Name:   "local-docker",
 21 | 				Status: 1, // active
 22 | 				Type:   1, // docker-local
 23 | 				TagIds: []int64{1, 2},
 24 | 				UserAccessPolicies: models.PortainerUserAccessPolicies{
 25 | 					"1": models.PortainerAccessPolicy{RoleID: 1},
 26 | 					"2": models.PortainerAccessPolicy{RoleID: 3},
 27 | 				},
 28 | 				TeamAccessPolicies: models.PortainerTeamAccessPolicies{
 29 | 					"10": models.PortainerAccessPolicy{RoleID: 2},
 30 | 					"20": models.PortainerAccessPolicy{RoleID: 4},
 31 | 				},
 32 | 			},
 33 | 			want: Environment{
 34 | 				ID:     1,
 35 | 				Name:   "local-docker",
 36 | 				Status: EnvironmentStatusActive,
 37 | 				Type:   EnvironmentTypeDockerLocal,
 38 | 				TagIds: []int{1, 2},
 39 | 				UserAccesses: map[int]string{
 40 | 					1: "environment_administrator",
 41 | 					2: "standard_user",
 42 | 				},
 43 | 				TeamAccesses: map[int]string{
 44 | 					10: "helpdesk_user",
 45 | 					20: "readonly_user",
 46 | 				},
 47 | 			},
 48 | 		},
 49 | 		{
 50 | 			name: "inactive kubernetes-agent environment with empty accesses",
 51 | 			endpoint: &models.PortainereeEndpoint{
 52 | 				ID:                 2,
 53 | 				Name:               "k8s-agent",
 54 | 				Status:             2, // inactive
 55 | 				Type:               7, // kubernetes-edge-agent
 56 | 				TagIds:             []int64{1},
 57 | 				UserAccessPolicies: models.PortainerUserAccessPolicies{},
 58 | 				TeamAccessPolicies: models.PortainerTeamAccessPolicies{},
 59 | 			},
 60 | 			want: Environment{
 61 | 				ID:           2,
 62 | 				Name:         "k8s-agent",
 63 | 				Status:       EnvironmentStatusInactive,
 64 | 				Type:         EnvironmentTypeKubernetesEdgeAgent,
 65 | 				TagIds:       []int{1},
 66 | 				UserAccesses: map[int]string{},
 67 | 				TeamAccesses: map[int]string{},
 68 | 			},
 69 | 		},
 70 | 		{
 71 | 			name: "environment with invalid access IDs",
 72 | 			endpoint: &models.PortainereeEndpoint{
 73 | 				ID:     3,
 74 | 				Name:   "invalid-access",
 75 | 				Status: 1,
 76 | 				Type:   1,
 77 | 				TagIds: []int64{},
 78 | 				UserAccessPolicies: models.PortainerUserAccessPolicies{
 79 | 					"invalid": models.PortainerAccessPolicy{RoleID: 1},
 80 | 					"2":       models.PortainerAccessPolicy{RoleID: 3},
 81 | 				},
 82 | 				TeamAccessPolicies: models.PortainerTeamAccessPolicies{
 83 | 					"bad": models.PortainerAccessPolicy{RoleID: 2},
 84 | 					"20":  models.PortainerAccessPolicy{RoleID: 4},
 85 | 				},
 86 | 			},
 87 | 			want: Environment{
 88 | 				ID:     3,
 89 | 				Name:   "invalid-access",
 90 | 				Status: EnvironmentStatusActive,
 91 | 				Type:   EnvironmentTypeDockerLocal,
 92 | 				TagIds: []int{},
 93 | 				UserAccesses: map[int]string{
 94 | 					2: "standard_user",
 95 | 				},
 96 | 				TeamAccesses: map[int]string{
 97 | 					20: "readonly_user",
 98 | 				},
 99 | 			},
100 | 		},
101 | 	}
102 | 
103 | 	for _, tt := range tests {
104 | 		t.Run(tt.name, func(t *testing.T) {
105 | 			got := ConvertEndpointToEnvironment(tt.endpoint)
106 | 			if !reflect.DeepEqual(got, tt.want) {
107 | 				t.Errorf("ConvertEndpointToEnvironment() = %v, want %v", got, tt.want)
108 | 			}
109 | 		})
110 | 	}
111 | }
112 | 
113 | func TestConvertEnvironmentStatus(t *testing.T) {
114 | 	tests := []struct {
115 | 		name     string
116 | 		endpoint *models.PortainereeEndpoint
117 | 		want     string
118 | 	}{
119 | 		{
120 | 			name: "standard environment - active status",
121 | 			endpoint: &models.PortainereeEndpoint{
122 | 				Status: 1,
123 | 				Type:   1, // docker-local
124 | 			},
125 | 			want: EnvironmentStatusActive,
126 | 		},
127 | 		{
128 | 			name: "standard environment - inactive status",
129 | 			endpoint: &models.PortainereeEndpoint{
130 | 				Status: 2,
131 | 				Type:   2, // docker-agent
132 | 			},
133 | 			want: EnvironmentStatusInactive,
134 | 		},
135 | 		{
136 | 			name: "standard environment - unknown status",
137 | 			endpoint: &models.PortainereeEndpoint{
138 | 				Status: 0,
139 | 				Type:   3, // azure-aci
140 | 			},
141 | 			want: EnvironmentStatusUnknown,
142 | 		},
143 | 		{
144 | 			name: "edge environment - active with heartbeat",
145 | 			endpoint: &models.PortainereeEndpoint{
146 | 				Type:      4, // docker-edge-agent
147 | 				Heartbeat: true,
148 | 			},
149 | 			want: EnvironmentStatusActive,
150 | 		},
151 | 		{
152 | 			name: "edge environment - inactive without heartbeat",
153 | 			endpoint: &models.PortainereeEndpoint{
154 | 				Type:      7, // kubernetes-edge-agent
155 | 				Heartbeat: false,
156 | 			},
157 | 			want: EnvironmentStatusInactive,
158 | 		},
159 | 	}
160 | 
161 | 	for _, tt := range tests {
162 | 		t.Run(tt.name, func(t *testing.T) {
163 | 			got := convertEnvironmentStatus(tt.endpoint)
164 | 			if got != tt.want {
165 | 				t.Errorf("convertEnvironmentStatus() = %v, want %v", got, tt.want)
166 | 			}
167 | 		})
168 | 	}
169 | }
170 | 
171 | func TestConvertEnvironmentType(t *testing.T) {
172 | 	tests := []struct {
173 | 		name      string
174 | 		typeValue int
175 | 		want      string
176 | 	}{
177 | 		{
178 | 			name:      "docker-local type",
179 | 			typeValue: 1,
180 | 			want:      EnvironmentTypeDockerLocal,
181 | 		},
182 | 		{
183 | 			name:      "docker-agent type",
184 | 			typeValue: 2,
185 | 			want:      EnvironmentTypeDockerAgent,
186 | 		},
187 | 		{
188 | 			name:      "azure-aci type",
189 | 			typeValue: 3,
190 | 			want:      EnvironmentTypeAzureACI,
191 | 		},
192 | 		{
193 | 			name:      "docker-edge-agent type",
194 | 			typeValue: 4,
195 | 			want:      EnvironmentTypeDockerEdgeAgent,
196 | 		},
197 | 		{
198 | 			name:      "kubernetes-local type",
199 | 			typeValue: 5,
200 | 			want:      EnvironmentTypeKubernetesLocal,
201 | 		},
202 | 		{
203 | 			name:      "kubernetes-agent type",
204 | 			typeValue: 6,
205 | 			want:      EnvironmentTypeKubernetesAgent,
206 | 		},
207 | 		{
208 | 			name:      "kubernetes-edge-agent type",
209 | 			typeValue: 7,
210 | 			want:      EnvironmentTypeKubernetesEdgeAgent,
211 | 		},
212 | 		{
213 | 			name:      "unknown type",
214 | 			typeValue: 0,
215 | 			want:      EnvironmentTypeUnknown,
216 | 		},
217 | 		{
218 | 			name:      "invalid type",
219 | 			typeValue: 99,
220 | 			want:      EnvironmentTypeUnknown,
221 | 		},
222 | 	}
223 | 
224 | 	for _, tt := range tests {
225 | 		t.Run(tt.name, func(t *testing.T) {
226 | 			endpoint := &models.PortainereeEndpoint{Type: int64(tt.typeValue)}
227 | 			got := convertEnvironmentType(endpoint)
228 | 			if got != tt.want {
229 | 				t.Errorf("convertEnvironmentType() = %v, want %v", got, tt.want)
230 | 			}
231 | 		})
232 | 	}
233 | }
234 | 
```

--------------------------------------------------------------------------------
/pkg/portainer/client/group_test.go:
--------------------------------------------------------------------------------

```go
  1 | package client
  2 | 
  3 | import (
  4 | 	"errors"
  5 | 	"testing"
  6 | 
  7 | 	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
  8 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
  9 | 	"github.com/stretchr/testify/assert"
 10 | 	"github.com/stretchr/testify/mock"
 11 | )
 12 | 
 13 | func TestGetEnvironmentGroups(t *testing.T) {
 14 | 	tests := []struct {
 15 | 		name          string
 16 | 		mockGroups    []*apimodels.EdgegroupsDecoratedEdgeGroup
 17 | 		mockError     error
 18 | 		expected      []models.Group
 19 | 		expectedError bool
 20 | 	}{
 21 | 		{
 22 | 			name: "successful retrieval",
 23 | 			mockGroups: []*apimodels.EdgegroupsDecoratedEdgeGroup{
 24 | 				{
 25 | 					ID:        1,
 26 | 					Name:      "group1",
 27 | 					Endpoints: []int64{1, 2},
 28 | 					TagIds:    []int64{1, 2},
 29 | 				},
 30 | 				{
 31 | 					ID:        2,
 32 | 					Name:      "group2",
 33 | 					Endpoints: []int64{3},
 34 | 					TagIds:    []int64{3},
 35 | 				},
 36 | 			},
 37 | 			expected: []models.Group{
 38 | 				{
 39 | 					ID:             1,
 40 | 					Name:           "group1",
 41 | 					EnvironmentIds: []int{1, 2},
 42 | 					TagIds:         []int{1, 2},
 43 | 				},
 44 | 				{
 45 | 					ID:             2,
 46 | 					Name:           "group2",
 47 | 					EnvironmentIds: []int{3},
 48 | 					TagIds:         []int{3},
 49 | 				},
 50 | 			},
 51 | 		},
 52 | 		{
 53 | 			name:       "empty groups",
 54 | 			mockGroups: []*apimodels.EdgegroupsDecoratedEdgeGroup{},
 55 | 			expected:   []models.Group{},
 56 | 		},
 57 | 		{
 58 | 			name:          "list error",
 59 | 			mockError:     errors.New("failed to list edge groups"),
 60 | 			expectedError: true,
 61 | 		},
 62 | 	}
 63 | 
 64 | 	for _, tt := range tests {
 65 | 		t.Run(tt.name, func(t *testing.T) {
 66 | 			mockAPI := new(MockPortainerAPI)
 67 | 			mockAPI.On("ListEdgeGroups").Return(tt.mockGroups, tt.mockError)
 68 | 
 69 | 			client := &PortainerClient{cli: mockAPI}
 70 | 
 71 | 			groups, err := client.GetEnvironmentGroups()
 72 | 
 73 | 			if tt.expectedError {
 74 | 				assert.Error(t, err)
 75 | 				return
 76 | 			}
 77 | 			assert.NoError(t, err)
 78 | 			assert.Equal(t, tt.expected, groups)
 79 | 			mockAPI.AssertExpectations(t)
 80 | 		})
 81 | 	}
 82 | }
 83 | 
 84 | func TestCreateEnvironmentGroup(t *testing.T) {
 85 | 	tests := []struct {
 86 | 		name           string
 87 | 		groupName      string
 88 | 		environmentIds []int
 89 | 		mockID         int64
 90 | 		mockError      error
 91 | 		expectedID     int
 92 | 		expectedError  bool
 93 | 	}{
 94 | 		{
 95 | 			name:           "successful creation",
 96 | 			groupName:      "new-group",
 97 | 			environmentIds: []int{1, 2, 3},
 98 | 			mockID:         1,
 99 | 			expectedID:     1,
100 | 		},
101 | 		{
102 | 			name:           "creation error",
103 | 			groupName:      "error-group",
104 | 			environmentIds: []int{1},
105 | 			mockError:      errors.New("failed to create group"),
106 | 			expectedError:  true,
107 | 		},
108 | 		{
109 | 			name:           "empty environments",
110 | 			groupName:      "empty-group",
111 | 			environmentIds: []int{},
112 | 			mockID:         2,
113 | 			expectedID:     2,
114 | 		},
115 | 	}
116 | 
117 | 	for _, tt := range tests {
118 | 		t.Run(tt.name, func(t *testing.T) {
119 | 			mockAPI := new(MockPortainerAPI)
120 | 			mockAPI.On("CreateEdgeGroup", tt.groupName, mock.Anything).Return(tt.mockID, tt.mockError)
121 | 
122 | 			client := &PortainerClient{cli: mockAPI}
123 | 
124 | 			id, err := client.CreateEnvironmentGroup(tt.groupName, tt.environmentIds)
125 | 
126 | 			if tt.expectedError {
127 | 				assert.Error(t, err)
128 | 				return
129 | 			}
130 | 			assert.NoError(t, err)
131 | 			assert.Equal(t, tt.expectedID, id)
132 | 			mockAPI.AssertExpectations(t)
133 | 		})
134 | 	}
135 | }
136 | 
137 | func TestUpdateEnvironmentGroupName(t *testing.T) {
138 | 	tests := []struct {
139 | 		name          string
140 | 		groupID       int
141 | 		newName       string
142 | 		mockError     error
143 | 		expectedError bool
144 | 	}{
145 | 		{
146 | 			name:    "successful update",
147 | 			groupID: 1,
148 | 			newName: "updated-group",
149 | 		},
150 | 		{
151 | 			name:          "update error",
152 | 			groupID:       1,
153 | 			newName:       "error-group",
154 | 			mockError:     errors.New("failed to update group name"),
155 | 			expectedError: true,
156 | 		},
157 | 	}
158 | 
159 | 	for _, tt := range tests {
160 | 		t.Run(tt.name, func(t *testing.T) {
161 | 			mockAPI := new(MockPortainerAPI)
162 | 			mockAPI.On("UpdateEdgeGroup", int64(tt.groupID), &tt.newName, mock.Anything, mock.Anything).Return(tt.mockError)
163 | 
164 | 			client := &PortainerClient{cli: mockAPI}
165 | 
166 | 			err := client.UpdateEnvironmentGroupName(tt.groupID, tt.newName)
167 | 
168 | 			if tt.expectedError {
169 | 				assert.Error(t, err)
170 | 				return
171 | 			}
172 | 			assert.NoError(t, err)
173 | 			mockAPI.AssertExpectations(t)
174 | 		})
175 | 	}
176 | }
177 | 
178 | func TestUpdateEnvironmentGroupEnvironments(t *testing.T) {
179 | 	tests := []struct {
180 | 		name           string
181 | 		groupID        int
182 | 		environmentIds []int
183 | 		mockError      error
184 | 		expectedError  bool
185 | 	}{
186 | 		{
187 | 			name:           "successful update",
188 | 			groupID:        1,
189 | 			environmentIds: []int{1, 2, 3},
190 | 		},
191 | 		{
192 | 			name:           "update error",
193 | 			groupID:        1,
194 | 			environmentIds: []int{1},
195 | 			mockError:      errors.New("failed to update group environments"),
196 | 			expectedError:  true,
197 | 		},
198 | 		{
199 | 			name:           "empty environments",
200 | 			groupID:        1,
201 | 			environmentIds: []int{},
202 | 		},
203 | 	}
204 | 
205 | 	for _, tt := range tests {
206 | 		t.Run(tt.name, func(t *testing.T) {
207 | 			mockAPI := new(MockPortainerAPI)
208 | 			mockAPI.On("UpdateEdgeGroup", int64(tt.groupID), mock.Anything, mock.Anything, mock.Anything).Return(tt.mockError)
209 | 
210 | 			client := &PortainerClient{cli: mockAPI}
211 | 
212 | 			err := client.UpdateEnvironmentGroupEnvironments(tt.groupID, tt.environmentIds)
213 | 
214 | 			if tt.expectedError {
215 | 				assert.Error(t, err)
216 | 				return
217 | 			}
218 | 			assert.NoError(t, err)
219 | 			mockAPI.AssertExpectations(t)
220 | 		})
221 | 	}
222 | }
223 | 
224 | func TestUpdateEnvironmentGroupTags(t *testing.T) {
225 | 	tests := []struct {
226 | 		name          string
227 | 		groupID       int
228 | 		tagIds        []int
229 | 		mockError     error
230 | 		expectedError bool
231 | 	}{
232 | 		{
233 | 			name:    "successful update",
234 | 			groupID: 1,
235 | 			tagIds:  []int{1, 2, 3},
236 | 		},
237 | 		{
238 | 			name:          "update error",
239 | 			groupID:       1,
240 | 			tagIds:        []int{1},
241 | 			mockError:     errors.New("failed to update group tags"),
242 | 			expectedError: true,
243 | 		},
244 | 		{
245 | 			name:    "empty tags",
246 | 			groupID: 1,
247 | 			tagIds:  []int{},
248 | 		},
249 | 	}
250 | 
251 | 	for _, tt := range tests {
252 | 		t.Run(tt.name, func(t *testing.T) {
253 | 			mockAPI := new(MockPortainerAPI)
254 | 			mockAPI.On("UpdateEdgeGroup", int64(tt.groupID), mock.Anything, mock.Anything, mock.Anything).Return(tt.mockError)
255 | 
256 | 			client := &PortainerClient{cli: mockAPI}
257 | 
258 | 			err := client.UpdateEnvironmentGroupTags(tt.groupID, tt.tagIds)
259 | 
260 | 			if tt.expectedError {
261 | 				assert.Error(t, err)
262 | 				return
263 | 			}
264 | 			assert.NoError(t, err)
265 | 			mockAPI.AssertExpectations(t)
266 | 		})
267 | 	}
268 | }
269 | 
```

--------------------------------------------------------------------------------
/tests/integration/containers/portainer.go:
--------------------------------------------------------------------------------

```go
  1 | package containers
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"crypto/tls"
  6 | 	"fmt"
  7 | 	"net/http"
  8 | 	"time"
  9 | 
 10 | 	"github.com/docker/docker/api/types/container"
 11 | 	"github.com/docker/go-connections/nat"
 12 | 	"github.com/go-openapi/runtime"
 13 | 	httptransport "github.com/go-openapi/runtime/client"
 14 | 	"github.com/go-openapi/strfmt"
 15 | 	"github.com/portainer/client-api-go/v2/pkg/client"
 16 | 	"github.com/portainer/client-api-go/v2/pkg/client/auth"
 17 | 	"github.com/portainer/client-api-go/v2/pkg/client/users"
 18 | 	"github.com/portainer/client-api-go/v2/pkg/models"
 19 | 	"github.com/portainer/portainer-mcp/internal/mcp"
 20 | 	"github.com/testcontainers/testcontainers-go"
 21 | 	"github.com/testcontainers/testcontainers-go/wait"
 22 | )
 23 | 
 24 | const (
 25 | 	defaultPortainerImage = "portainer/portainer-ee:" + mcp.SupportedPortainerVersion
 26 | 	defaultAPIPortTCP     = "9443/tcp"
 27 | 	adminPassword         = "$2y$05$CiHrhW6R6whDVlu7Wdgl0eccb3rg1NWl/mMiO93vQiRIF1SHNFRsS" // Bcrypt hash of "adminpassword123"
 28 | 	// Timeout for the container to start and be ready to use
 29 | 	startupTimeout = time.Second * 5
 30 | )
 31 | 
 32 | // PortainerContainer represents a Portainer container for testing
 33 | type PortainerContainer struct {
 34 | 	testcontainers.Container
 35 | 	APIPort  nat.Port
 36 | 	APIHost  string
 37 | 	apiToken string
 38 | }
 39 | 
 40 | // portainerContainerConfig holds the configuration for creating a Portainer container
 41 | type portainerContainerConfig struct {
 42 | 	Image            string
 43 | 	BindDockerSocket bool
 44 | }
 45 | 
 46 | // PortainerContainerOption defines a function type for applying options to Portainer container configuration
 47 | type PortainerContainerOption func(*portainerContainerConfig)
 48 | 
 49 | // WithImage sets a custom Portainer image
 50 | func WithImage(image string) PortainerContainerOption {
 51 | 	return func(cfg *portainerContainerConfig) {
 52 | 		cfg.Image = image
 53 | 	}
 54 | }
 55 | 
 56 | // WithDockerSocketBind configures the container to bind mount the Docker socket
 57 | func WithDockerSocketBind(bind bool) PortainerContainerOption {
 58 | 	return func(cfg *portainerContainerConfig) {
 59 | 		cfg.BindDockerSocket = bind
 60 | 	}
 61 | }
 62 | 
 63 | // NewPortainerContainer creates and starts a new Portainer container with the specified options
 64 | func NewPortainerContainer(ctx context.Context, opts ...PortainerContainerOption) (*PortainerContainer, error) {
 65 | 	// Default configuration
 66 | 	cfg := &portainerContainerConfig{
 67 | 		Image:            defaultPortainerImage,
 68 | 		BindDockerSocket: false,
 69 | 	}
 70 | 
 71 | 	// Apply provided options
 72 | 	for _, opt := range opts {
 73 | 		opt(cfg)
 74 | 	}
 75 | 
 76 | 	// Container request configuration
 77 | 	req := testcontainers.ContainerRequest{
 78 | 		Image:        cfg.Image,
 79 | 		ExposedPorts: []string{defaultAPIPortTCP},
 80 | 		WaitingFor: wait.ForAll(
 81 | 			// Wait for the HTTPS server to start
 82 | 			wait.ForLog("starting HTTPS server").
 83 | 				WithStartupTimeout(startupTimeout),
 84 | 			// Then wait for the API to be responsive
 85 | 			wait.ForHTTP("/api/system/status").
 86 | 				WithTLS(true, nil).
 87 | 				WithAllowInsecure(true).
 88 | 				WithPort(defaultAPIPortTCP).
 89 | 				WithStatusCodeMatcher(
 90 | 					func(status int) bool {
 91 | 						return status == http.StatusOK
 92 | 					},
 93 | 				).
 94 | 				WithStartupTimeout(startupTimeout),
 95 | 		),
 96 | 		Cmd: []string{
 97 | 			"--admin-password",
 98 | 			adminPassword,
 99 | 			"--log-level",
100 | 			"DEBUG",
101 | 		},
102 | 		HostConfigModifier: func(hostConfig *container.HostConfig) {
103 | 			if cfg.BindDockerSocket {
104 | 				hostConfig.Binds = append(hostConfig.Binds, "/var/run/docker.sock:/var/run/docker.sock")
105 | 			}
106 | 		},
107 | 	}
108 | 
109 | 	// Create and start the container
110 | 	cntr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
111 | 		ContainerRequest: req,
112 | 		Started:          true,
113 | 	})
114 | 	if err != nil {
115 | 		return nil, fmt.Errorf("failed to start Portainer container: %w", err)
116 | 	}
117 | 
118 | 	// Get the host and port mapping
119 | 	host, err := cntr.Host(ctx)
120 | 	if err != nil {
121 | 		cntr.Terminate(ctx) // Clean up if we fail post-start
122 | 		return nil, fmt.Errorf("failed to get container host: %w", err)
123 | 	}
124 | 
125 | 	mappedPort, err := cntr.MappedPort(ctx, nat.Port(defaultAPIPortTCP))
126 | 	if err != nil {
127 | 		cntr.Terminate(ctx) // Clean up if we fail post-start
128 | 		return nil, fmt.Errorf("failed to get mapped port: %w", err)
129 | 	}
130 | 
131 | 	pc := &PortainerContainer{
132 | 		Container: cntr,
133 | 		APIPort:   mappedPort,
134 | 		APIHost:   host,
135 | 	}
136 | 
137 | 	// Register API token after successful container start and port mapping
138 | 	if err := pc.registerAPIToken(); err != nil {
139 | 		// Attempt to clean up the container if token registration fails
140 | 		cntr.Terminate(ctx)
141 | 		return nil, fmt.Errorf("failed to register API token: %w", err)
142 | 	}
143 | 
144 | 	return pc, nil
145 | }
146 | 
147 | // GetAPIBaseURL returns the base URL for the Portainer API
148 | func (pc *PortainerContainer) GetAPIBaseURL() string {
149 | 	return fmt.Sprintf("https://%s:%s", pc.APIHost, pc.APIPort.Port())
150 | }
151 | 
152 | // GetHostAndPort returns the host and port for the Portainer API
153 | func (pc *PortainerContainer) GetHostAndPort() (string, string) {
154 | 	return pc.APIHost, pc.APIPort.Port()
155 | }
156 | 
157 | func (pc *PortainerContainer) GetAPIToken() string {
158 | 	return pc.apiToken
159 | }
160 | 
161 | // registerAPIToken registers an API token for the admin user
162 | func (pc *PortainerContainer) registerAPIToken() error {
163 | 	transport := httptransport.New(
164 | 		fmt.Sprintf("%s:%s", pc.APIHost, pc.APIPort.Port()),
165 | 		"/api",
166 | 		[]string{"https"},
167 | 	)
168 | 
169 | 	transport.Transport = &http.Transport{
170 | 		TLSClientConfig: &tls.Config{
171 | 			InsecureSkipVerify: true,
172 | 		},
173 | 	}
174 | 
175 | 	portainerClient := client.New(transport, strfmt.Default)
176 | 
177 | 	username := "admin"
178 | 	password := "adminpassword123"
179 | 	params := auth.NewAuthenticateUserParams().WithBody(&models.AuthAuthenticatePayload{
180 | 		Username: &username,
181 | 		Password: &password,
182 | 	})
183 | 
184 | 	authResp, err := portainerClient.Auth.AuthenticateUser(params)
185 | 	if err != nil {
186 | 		return fmt.Errorf("failed to authenticate user: %w", err)
187 | 	}
188 | 
189 | 	token := authResp.Payload.Jwt
190 | 
191 | 	// Setup JWT authentication
192 | 	jwtAuth := runtime.ClientAuthInfoWriterFunc(func(r runtime.ClientRequest, _ strfmt.Registry) error {
193 | 		return r.SetHeaderParam("Authorization", fmt.Sprintf("Bearer %s", token))
194 | 	})
195 | 	transport.DefaultAuthentication = jwtAuth
196 | 
197 | 	description := "test-api-key"
198 | 	createTokenParams := users.NewUserGenerateAPIKeyParams().WithID(1).WithBody(&models.UsersUserAccessTokenCreatePayload{
199 | 		Description: &description,
200 | 		Password:    &password,
201 | 	})
202 | 
203 | 	createTokenResp, err := portainerClient.Users.UserGenerateAPIKey(createTokenParams, nil)
204 | 	if err != nil {
205 | 		return fmt.Errorf("failed to generate API key: %w", err)
206 | 	}
207 | 
208 | 	pc.apiToken = createTokenResp.Payload.RawAPIKey
209 | 
210 | 	return nil
211 | }
212 | 
```

--------------------------------------------------------------------------------
/tests/integration/stack_test.go:
--------------------------------------------------------------------------------

```go
  1 | package integration
  2 | 
  3 | import (
  4 | 	"encoding/json"
  5 | 	"fmt"
  6 | 	"testing"
  7 | 
  8 | 	mcpmodels "github.com/mark3labs/mcp-go/mcp"
  9 | 	"github.com/portainer/portainer-mcp/internal/mcp"
 10 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
 11 | 	"github.com/portainer/portainer-mcp/tests/integration/helpers"
 12 | 
 13 | 	"github.com/stretchr/testify/assert"
 14 | 	"github.com/stretchr/testify/require"
 15 | )
 16 | 
 17 | const (
 18 | 	testStackName        = "test-mcp-stack"
 19 | 	testStackFile        = "version: '3'\nservices:\n  web:\n    image: nginx:latest"
 20 | 	testStackFileUpdated = "version: '3'\nservices:\n  web:\n    image: nginx:alpine"
 21 | 	testEdgeGroupName    = "test-stack-group"
 22 | )
 23 | 
 24 | // prepareStackManagementTestEnvironment creates a test environment group needed for stack tests
 25 | func prepareStackManagementTestEnvironment(t *testing.T, env *helpers.TestEnv) int {
 26 | 	// First, enable Edge features in Portainer
 27 | 	host, port := env.Portainer.GetHostAndPort()
 28 | 	serverAddr := fmt.Sprintf("%s:%s", host, port)
 29 | 	tunnelAddr := fmt.Sprintf("%s:8000", host)
 30 | 
 31 | 	err := env.RawClient.UpdateSettings(true, serverAddr, tunnelAddr)
 32 | 	require.NoError(t, err, "Failed to update settings to enable Edge features")
 33 | 
 34 | 	// Create a test environment group for the stack to be associated with
 35 | 	testGroupID, err := env.RawClient.CreateEdgeGroup(testEdgeGroupName, []int64{})
 36 | 	require.NoError(t, err, "Failed to create test environment group via raw client")
 37 | 
 38 | 	return int(testGroupID)
 39 | }
 40 | 
 41 | // TestStackManagement is an integration test suite that verifies the complete
 42 | // lifecycle of stack management in Portainer MCP. It tests stack creation,
 43 | // retrieval, file content retrieval, and updates.
 44 | func TestStackManagement(t *testing.T) {
 45 | 	env := helpers.NewTestEnv(t)
 46 | 	defer env.Cleanup(t)
 47 | 
 48 | 	// Prepare the test environment
 49 | 	testGroupID := prepareStackManagementTestEnvironment(t, env)
 50 | 
 51 | 	var testStackID int
 52 | 
 53 | 	// Subtest: Stack Creation
 54 | 	// Verifies that:
 55 | 	// - A new stack can be created via the MCP handler
 56 | 	// - The handler response indicates success with an ID
 57 | 	// - The created stack exists in Portainer when checked directly via Raw Client
 58 | 	t.Run("Stack Creation", func(t *testing.T) {
 59 | 		handler := env.MCPServer.HandleCreateStack()
 60 | 		request := mcp.CreateMCPRequest(map[string]any{
 61 | 			"name":                testStackName,
 62 | 			"file":                testStackFile,
 63 | 			"environmentGroupIds": []any{float64(testGroupID)},
 64 | 		})
 65 | 
 66 | 		result, err := handler(env.Ctx, request)
 67 | 		require.NoError(t, err, "Failed to create stack via MCP handler")
 68 | 
 69 | 		textContent, ok := result.Content[0].(mcpmodels.TextContent)
 70 | 		require.True(t, ok, "Expected text content in MCP response")
 71 | 
 72 | 		// Check for success message and extract ID for later tests
 73 | 		assert.Contains(t, textContent.Text, "Stack created successfully with ID:", "Success message prefix mismatch")
 74 | 
 75 | 		// Verify by fetching stacks directly via client and finding the created stack by name
 76 | 		stack, err := env.RawClient.GetEdgeStackByName(testStackName)
 77 | 		require.NoError(t, err, "Failed to get stack directly via client after creation")
 78 | 		assert.Equal(t, testStackName, stack.Name, "Stack name mismatch")
 79 | 
 80 | 		// Extract stack ID for subsequent tests
 81 | 		testStackID = int(stack.ID)
 82 | 	})
 83 | 
 84 | 	// Subtest: Stack Listing
 85 | 	// Verifies that:
 86 | 	// - The stack list can be retrieved via the MCP handler
 87 | 	// - The list contains the expected stack
 88 | 	// - The stack data matches the expected properties
 89 | 	t.Run("Stack Listing", func(t *testing.T) {
 90 | 		handler := env.MCPServer.HandleGetStacks()
 91 | 		result, err := handler(env.Ctx, mcp.CreateMCPRequest(nil))
 92 | 		require.NoError(t, err, "Failed to get stacks via MCP handler")
 93 | 
 94 | 		assert.Len(t, result.Content, 1, "Expected exactly one content block in the result")
 95 | 		textContent, ok := result.Content[0].(mcpmodels.TextContent)
 96 | 		assert.True(t, ok, "Expected text content in MCP response")
 97 | 
 98 | 		var retrievedStacks []models.Stack
 99 | 		err = json.Unmarshal([]byte(textContent.Text), &retrievedStacks)
100 | 		require.NoError(t, err, "Failed to unmarshal retrieved stacks")
101 | 		require.Len(t, retrievedStacks, 1, "Expected exactly one stack after unmarshalling")
102 | 
103 | 		stack := retrievedStacks[0]
104 | 		assert.Equal(t, testStackName, stack.Name, "Stack name mismatch")
105 | 
106 | 		// Fetch the same stack directly via the client
107 | 		rawStack, err := env.RawClient.GetEdgeStack(int64(testStackID))
108 | 		require.NoError(t, err, "Failed to get stack directly via client")
109 | 
110 | 		// Convert the raw stack to the expected Stack model
111 | 		expectedStack := models.ConvertEdgeStackToStack(rawStack)
112 | 		assert.Equal(t, expectedStack, stack, "Stack mismatch between MCP handler and direct client call")
113 | 	})
114 | 
115 | 	// Subtest: Get Stack File
116 | 	// Verifies that:
117 | 	// - The stack file can be retrieved via the MCP handler
118 | 	// - The file content matches the content used during creation
119 | 	t.Run("Get Stack File", func(t *testing.T) {
120 | 		handler := env.MCPServer.HandleGetStackFile()
121 | 		request := mcp.CreateMCPRequest(map[string]any{
122 | 			"id": float64(testStackID),
123 | 		})
124 | 
125 | 		result, err := handler(env.Ctx, request)
126 | 		require.NoError(t, err, "Failed to get stack file via MCP handler")
127 | 
128 | 		textContent, ok := result.Content[0].(mcpmodels.TextContent)
129 | 		require.True(t, ok, "Expected text content in MCP response")
130 | 
131 | 		// Compare with the original content
132 | 		assert.Equal(t, testStackFile, textContent.Text, "Stack file content mismatch")
133 | 	})
134 | 
135 | 	// Subtest: Stack Update
136 | 	// Verifies that:
137 | 	// - A stack can be updated via the MCP handler
138 | 	// - The handler response indicates success
139 | 	// - The stack file is updated when checked directly via Raw Client
140 | 	t.Run("Stack Update", func(t *testing.T) {
141 | 		handler := env.MCPServer.HandleUpdateStack()
142 | 		request := mcp.CreateMCPRequest(map[string]any{
143 | 			"id":                  float64(testStackID),
144 | 			"file":                testStackFileUpdated,
145 | 			"environmentGroupIds": []any{float64(testGroupID)},
146 | 		})
147 | 
148 | 		result, err := handler(env.Ctx, request)
149 | 		require.NoError(t, err, "Failed to update stack via MCP handler")
150 | 
151 | 		textContent, ok := result.Content[0].(mcpmodels.TextContent)
152 | 		require.True(t, ok, "Expected text content in MCP response")
153 | 		assert.Contains(t, textContent.Text, "Stack updated successfully", "Success message mismatch")
154 | 
155 | 		// Verify by fetching stack file directly via raw client
156 | 		updatedFile, err := env.RawClient.GetEdgeStackFile(int64(testStackID))
157 | 		require.NoError(t, err, "Failed to get stack file via raw client after update")
158 | 		assert.Equal(t, testStackFileUpdated, updatedFile, "Stack file was not updated correctly")
159 | 	})
160 | }
161 | 
```

--------------------------------------------------------------------------------
/internal/mcp/access_group.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"encoding/json"
  6 | 	"fmt"
  7 | 
  8 | 	"github.com/mark3labs/mcp-go/mcp"
  9 | 	"github.com/mark3labs/mcp-go/server"
 10 | 	"github.com/portainer/portainer-mcp/pkg/toolgen"
 11 | )
 12 | 
 13 | func (s *PortainerMCPServer) AddAccessGroupFeatures() {
 14 | 	s.addToolIfExists(ToolListAccessGroups, s.HandleGetAccessGroups())
 15 | 
 16 | 	if !s.readOnly {
 17 | 		s.addToolIfExists(ToolCreateAccessGroup, s.HandleCreateAccessGroup())
 18 | 		s.addToolIfExists(ToolUpdateAccessGroupName, s.HandleUpdateAccessGroupName())
 19 | 		s.addToolIfExists(ToolUpdateAccessGroupUserAccesses, s.HandleUpdateAccessGroupUserAccesses())
 20 | 		s.addToolIfExists(ToolUpdateAccessGroupTeamAccesses, s.HandleUpdateAccessGroupTeamAccesses())
 21 | 		s.addToolIfExists(ToolAddEnvironmentToAccessGroup, s.HandleAddEnvironmentToAccessGroup())
 22 | 		s.addToolIfExists(ToolRemoveEnvironmentFromAccessGroup, s.HandleRemoveEnvironmentFromAccessGroup())
 23 | 	}
 24 | }
 25 | 
 26 | func (s *PortainerMCPServer) HandleGetAccessGroups() server.ToolHandlerFunc {
 27 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 28 | 		accessGroups, err := s.cli.GetAccessGroups()
 29 | 		if err != nil {
 30 | 			return mcp.NewToolResultErrorFromErr("failed to get access groups", err), nil
 31 | 		}
 32 | 
 33 | 		data, err := json.Marshal(accessGroups)
 34 | 		if err != nil {
 35 | 			return mcp.NewToolResultErrorFromErr("failed to marshal access groups", err), nil
 36 | 		}
 37 | 
 38 | 		return mcp.NewToolResultText(string(data)), nil
 39 | 	}
 40 | }
 41 | 
 42 | func (s *PortainerMCPServer) HandleCreateAccessGroup() server.ToolHandlerFunc {
 43 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 44 | 		parser := toolgen.NewParameterParser(request)
 45 | 
 46 | 		name, err := parser.GetString("name", true)
 47 | 		if err != nil {
 48 | 			return mcp.NewToolResultErrorFromErr("invalid name parameter", err), nil
 49 | 		}
 50 | 
 51 | 		environmentIds, err := parser.GetArrayOfIntegers("environmentIds", false)
 52 | 		if err != nil {
 53 | 			return mcp.NewToolResultErrorFromErr("invalid environmentIds parameter", err), nil
 54 | 		}
 55 | 
 56 | 		groupID, err := s.cli.CreateAccessGroup(name, environmentIds)
 57 | 		if err != nil {
 58 | 			return mcp.NewToolResultErrorFromErr("failed to create access group", err), nil
 59 | 		}
 60 | 
 61 | 		return mcp.NewToolResultText(fmt.Sprintf("Access group created successfully with ID: %d", groupID)), nil
 62 | 	}
 63 | }
 64 | 
 65 | func (s *PortainerMCPServer) HandleUpdateAccessGroupName() server.ToolHandlerFunc {
 66 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 67 | 		parser := toolgen.NewParameterParser(request)
 68 | 
 69 | 		id, err := parser.GetInt("id", true)
 70 | 		if err != nil {
 71 | 			return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
 72 | 		}
 73 | 
 74 | 		name, err := parser.GetString("name", true)
 75 | 		if err != nil {
 76 | 			return mcp.NewToolResultErrorFromErr("invalid name parameter", err), nil
 77 | 		}
 78 | 
 79 | 		err = s.cli.UpdateAccessGroupName(id, name)
 80 | 		if err != nil {
 81 | 			return mcp.NewToolResultErrorFromErr("failed to update access group name", err), nil
 82 | 		}
 83 | 
 84 | 		return mcp.NewToolResultText("Access group name updated successfully"), nil
 85 | 	}
 86 | }
 87 | 
 88 | func (s *PortainerMCPServer) HandleUpdateAccessGroupUserAccesses() server.ToolHandlerFunc {
 89 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 90 | 		parser := toolgen.NewParameterParser(request)
 91 | 
 92 | 		id, err := parser.GetInt("id", true)
 93 | 		if err != nil {
 94 | 			return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
 95 | 		}
 96 | 
 97 | 		userAccesses, err := parser.GetArrayOfObjects("userAccesses", true)
 98 | 		if err != nil {
 99 | 			return mcp.NewToolResultErrorFromErr("invalid userAccesses parameter", err), nil
100 | 		}
101 | 
102 | 		userAccessesMap, err := parseAccessMap(userAccesses)
103 | 		if err != nil {
104 | 			return mcp.NewToolResultErrorFromErr("invalid user accesses", err), nil
105 | 		}
106 | 
107 | 		err = s.cli.UpdateAccessGroupUserAccesses(id, userAccessesMap)
108 | 		if err != nil {
109 | 			return mcp.NewToolResultErrorFromErr("failed to update access group user accesses", err), nil
110 | 		}
111 | 
112 | 		return mcp.NewToolResultText("Access group user accesses updated successfully"), nil
113 | 	}
114 | }
115 | 
116 | func (s *PortainerMCPServer) HandleUpdateAccessGroupTeamAccesses() server.ToolHandlerFunc {
117 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
118 | 		parser := toolgen.NewParameterParser(request)
119 | 
120 | 		id, err := parser.GetInt("id", true)
121 | 		if err != nil {
122 | 			return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
123 | 		}
124 | 
125 | 		teamAccesses, err := parser.GetArrayOfObjects("teamAccesses", true)
126 | 		if err != nil {
127 | 			return mcp.NewToolResultErrorFromErr("invalid teamAccesses parameter", err), nil
128 | 		}
129 | 
130 | 		teamAccessesMap, err := parseAccessMap(teamAccesses)
131 | 		if err != nil {
132 | 			return mcp.NewToolResultErrorFromErr("invalid team accesses", err), nil
133 | 		}
134 | 
135 | 		err = s.cli.UpdateAccessGroupTeamAccesses(id, teamAccessesMap)
136 | 		if err != nil {
137 | 			return mcp.NewToolResultErrorFromErr("failed to update access group team accesses", err), nil
138 | 		}
139 | 
140 | 		return mcp.NewToolResultText("Access group team accesses updated successfully"), nil
141 | 	}
142 | }
143 | 
144 | func (s *PortainerMCPServer) HandleAddEnvironmentToAccessGroup() server.ToolHandlerFunc {
145 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
146 | 		parser := toolgen.NewParameterParser(request)
147 | 
148 | 		id, err := parser.GetInt("id", true)
149 | 		if err != nil {
150 | 			return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
151 | 		}
152 | 
153 | 		environmentId, err := parser.GetInt("environmentId", true)
154 | 		if err != nil {
155 | 			return mcp.NewToolResultErrorFromErr("invalid environmentId parameter", err), nil
156 | 		}
157 | 
158 | 		err = s.cli.AddEnvironmentToAccessGroup(id, environmentId)
159 | 		if err != nil {
160 | 			return mcp.NewToolResultErrorFromErr("failed to add environment to access group", err), nil
161 | 		}
162 | 
163 | 		return mcp.NewToolResultText("Environment added to access group successfully"), nil
164 | 	}
165 | }
166 | 
167 | func (s *PortainerMCPServer) HandleRemoveEnvironmentFromAccessGroup() server.ToolHandlerFunc {
168 | 	return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
169 | 		parser := toolgen.NewParameterParser(request)
170 | 
171 | 		id, err := parser.GetInt("id", true)
172 | 		if err != nil {
173 | 			return mcp.NewToolResultErrorFromErr("invalid id parameter", err), nil
174 | 		}
175 | 
176 | 		environmentId, err := parser.GetInt("environmentId", true)
177 | 		if err != nil {
178 | 			return mcp.NewToolResultErrorFromErr("invalid environmentId parameter", err), nil
179 | 		}
180 | 
181 | 		err = s.cli.RemoveEnvironmentFromAccessGroup(id, environmentId)
182 | 		if err != nil {
183 | 			return mcp.NewToolResultErrorFromErr("failed to remove environment from access group", err), nil
184 | 		}
185 | 
186 | 		return mcp.NewToolResultText("Environment removed from access group successfully"), nil
187 | 	}
188 | }
189 | 
```

--------------------------------------------------------------------------------
/internal/mcp/server.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"fmt"
  5 | 	"log"
  6 | 	"net/http"
  7 | 
  8 | 	"github.com/mark3labs/mcp-go/mcp"
  9 | 	"github.com/mark3labs/mcp-go/server"
 10 | 	"github.com/portainer/portainer-mcp/pkg/portainer/client"
 11 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
 12 | 	"github.com/portainer/portainer-mcp/pkg/toolgen"
 13 | )
 14 | 
 15 | const (
 16 | 	// MinimumToolsVersion is the minimum supported version of the tools.yaml file
 17 | 	MinimumToolsVersion = "1.0"
 18 | 	// SupportedPortainerVersion is the version of Portainer that is supported by this tool
 19 | 	SupportedPortainerVersion = "2.31.2"
 20 | )
 21 | 
 22 | // PortainerClient defines the interface for the wrapper client used by the MCP server
 23 | type PortainerClient interface {
 24 | 	// Tag methods
 25 | 	GetEnvironmentTags() ([]models.EnvironmentTag, error)
 26 | 	CreateEnvironmentTag(name string) (int, error)
 27 | 
 28 | 	// Environment methods
 29 | 	GetEnvironments() ([]models.Environment, error)
 30 | 	UpdateEnvironmentTags(id int, tagIds []int) error
 31 | 	UpdateEnvironmentUserAccesses(id int, userAccesses map[int]string) error
 32 | 	UpdateEnvironmentTeamAccesses(id int, teamAccesses map[int]string) error
 33 | 
 34 | 	// Environment Group methods
 35 | 	GetEnvironmentGroups() ([]models.Group, error)
 36 | 	CreateEnvironmentGroup(name string, environmentIds []int) (int, error)
 37 | 	UpdateEnvironmentGroupName(id int, name string) error
 38 | 	UpdateEnvironmentGroupEnvironments(id int, environmentIds []int) error
 39 | 	UpdateEnvironmentGroupTags(id int, tagIds []int) error
 40 | 
 41 | 	// Access Group methods
 42 | 	GetAccessGroups() ([]models.AccessGroup, error)
 43 | 	CreateAccessGroup(name string, environmentIds []int) (int, error)
 44 | 	UpdateAccessGroupName(id int, name string) error
 45 | 	UpdateAccessGroupUserAccesses(id int, userAccesses map[int]string) error
 46 | 	UpdateAccessGroupTeamAccesses(id int, teamAccesses map[int]string) error
 47 | 	AddEnvironmentToAccessGroup(id int, environmentId int) error
 48 | 	RemoveEnvironmentFromAccessGroup(id int, environmentId int) error
 49 | 
 50 | 	// Stack methods
 51 | 	GetStacks() ([]models.Stack, error)
 52 | 	GetStackFile(id int) (string, error)
 53 | 	CreateStack(name string, file string, environmentGroupIds []int) (int, error)
 54 | 	UpdateStack(id int, file string, environmentGroupIds []int) error
 55 | 
 56 | 	// Team methods
 57 | 	CreateTeam(name string) (int, error)
 58 | 	GetTeams() ([]models.Team, error)
 59 | 	UpdateTeamName(id int, name string) error
 60 | 	UpdateTeamMembers(id int, userIds []int) error
 61 | 
 62 | 	// User methods
 63 | 	GetUsers() ([]models.User, error)
 64 | 	UpdateUserRole(id int, role string) error
 65 | 
 66 | 	// Settings methods
 67 | 	GetSettings() (models.PortainerSettings, error)
 68 | 
 69 | 	// Version methods
 70 | 	GetVersion() (string, error)
 71 | 
 72 | 	// Docker Proxy methods
 73 | 	ProxyDockerRequest(opts models.DockerProxyRequestOptions) (*http.Response, error)
 74 | 
 75 | 	// Kubernetes Proxy methods
 76 | 	ProxyKubernetesRequest(opts models.KubernetesProxyRequestOptions) (*http.Response, error)
 77 | }
 78 | 
 79 | // PortainerMCPServer is the main server that handles MCP protocol communication
 80 | // with AI assistants and translates them into Portainer API calls.
 81 | type PortainerMCPServer struct {
 82 | 	srv      *server.MCPServer
 83 | 	cli      PortainerClient
 84 | 	tools    map[string]mcp.Tool
 85 | 	readOnly bool
 86 | }
 87 | 
 88 | // ServerOption is a function that configures the server
 89 | type ServerOption func(*serverOptions)
 90 | 
 91 | // serverOptions contains all configurable options for the server
 92 | type serverOptions struct {
 93 | 	client              PortainerClient
 94 | 	readOnly            bool
 95 | 	disableVersionCheck bool
 96 | }
 97 | 
 98 | // WithClient sets a custom client for the server.
 99 | // This is primarily used for testing to inject mock clients.
100 | func WithClient(client PortainerClient) ServerOption {
101 | 	return func(opts *serverOptions) {
102 | 		opts.client = client
103 | 	}
104 | }
105 | 
106 | // WithReadOnly sets the server to read-only mode.
107 | // This will prevent the server from registering write tools.
108 | func WithReadOnly(readOnly bool) ServerOption {
109 | 	return func(opts *serverOptions) {
110 | 		opts.readOnly = readOnly
111 | 	}
112 | }
113 | 
114 | // WithDisableVersionCheck disables the Portainer server version check.
115 | // This allows connecting to unsupported Portainer versions.
116 | func WithDisableVersionCheck(disable bool) ServerOption {
117 | 	return func(opts *serverOptions) {
118 | 		opts.disableVersionCheck = disable
119 | 	}
120 | }
121 | 
122 | // NewPortainerMCPServer creates a new Portainer MCP server.
123 | //
124 | // This server provides an implementation of the MCP protocol for Portainer,
125 | // allowing AI assistants to interact with Portainer through a structured API.
126 | //
127 | // Parameters:
128 | //   - serverURL: The base URL of the Portainer server (e.g., "https://portainer.example.com")
129 | //   - token: The API token for authenticating with the Portainer server
130 | //   - toolsPath: Path to the tools.yaml file that defines the available MCP tools
131 | //   - options: Optional functional options for customizing server behavior (e.g., WithClient)
132 | //
133 | // Returns:
134 | //   - A configured PortainerMCPServer instance ready to be started
135 | //   - An error if initialization fails
136 | //
137 | // Possible errors:
138 | //   - Failed to load tools from the specified path
139 | //   - Failed to communicate with the Portainer server
140 | //   - Incompatible Portainer server version
141 | func NewPortainerMCPServer(serverURL, token, toolsPath string, options ...ServerOption) (*PortainerMCPServer, error) {
142 | 	opts := &serverOptions{}
143 | 
144 | 	for _, option := range options {
145 | 		option(opts)
146 | 	}
147 | 
148 | 	tools, err := toolgen.LoadToolsFromYAML(toolsPath, MinimumToolsVersion)
149 | 	if err != nil {
150 | 		return nil, fmt.Errorf("failed to load tools: %w", err)
151 | 	}
152 | 
153 | 	var portainerClient PortainerClient
154 | 	if opts.client != nil {
155 | 		portainerClient = opts.client
156 | 	} else {
157 | 		portainerClient = client.NewPortainerClient(serverURL, token, client.WithSkipTLSVerify(true))
158 | 	}
159 | 
160 | 	if !opts.disableVersionCheck {
161 | 		version, err := portainerClient.GetVersion()
162 | 		if err != nil {
163 | 			return nil, fmt.Errorf("failed to get Portainer server version: %w", err)
164 | 		}
165 | 
166 | 		if version != SupportedPortainerVersion {
167 | 			return nil, fmt.Errorf("unsupported Portainer server version: %s, only version %s is supported", version, SupportedPortainerVersion)
168 | 		}
169 | 	}
170 | 
171 | 	return &PortainerMCPServer{
172 | 		srv: server.NewMCPServer(
173 | 			"Portainer MCP Server",
174 | 			"0.5.1",
175 | 			server.WithToolCapabilities(true),
176 | 			server.WithLogging(),
177 | 		),
178 | 		cli:      portainerClient,
179 | 		tools:    tools,
180 | 		readOnly: opts.readOnly,
181 | 	}, nil
182 | }
183 | 
184 | // Start begins listening for MCP protocol messages on standard input/output.
185 | // This is a blocking call that will run until the connection is closed.
186 | func (s *PortainerMCPServer) Start() error {
187 | 	return server.ServeStdio(s.srv)
188 | }
189 | 
190 | // addToolIfExists adds a tool to the server if it exists in the tools map
191 | func (s *PortainerMCPServer) addToolIfExists(toolName string, handler server.ToolHandlerFunc) {
192 | 	if tool, exists := s.tools[toolName]; exists {
193 | 		s.srv.AddTool(tool, handler)
194 | 	} else {
195 | 		log.Printf("Tool %s not found, will not be registered for MCP usage", toolName)
196 | 	}
197 | }
198 | 
```

--------------------------------------------------------------------------------
/docs/clients_and_models.md:
--------------------------------------------------------------------------------

```markdown
 1 | # Portainer MCP Client and Model Usage Guide
 2 | 
 3 | This document clarifies the different client implementations and model structures used within the `portainer-mcp` project to prevent confusion and aid development.
 4 | 
 5 | ## Overview
 6 | 
 7 | The project interacts with the Portainer API using two main client layers and involves two primary sets of data models:
 8 | 
 9 | 1.  **Raw Client & Models:** Provided by the `portainer/client-api-go` library.
10 | 2.  **Wrapper Client & Local Models:** Defined within `portainer-mcp/pkg/portainer/`.
11 | 
12 | Understanding the distinction and interaction between these layers is crucial.
13 | 
14 | ## Clients
15 | 
16 | ### 1. Raw Client (`portainer/client-api-go/v2`)
17 | 
18 | *   **Package:** `github.com/portainer/client-api-go/v2`
19 | *   **Role:** This is the underlying library that directly communicates with the Portainer API.
20 | *   **Usage:** It's instantiated within the Wrapper Client. It's also often used directly within **integration tests** (`tests/integration/`) to fetch the ground-truth state from Portainer for comparison against the MCP handler's output.
21 | *   **Models Used:** Interacts primarily with the Raw Models defined in `github.com/portainer/client-api-go/v2/pkg/models`.
22 | 
23 | ### 2. Wrapper Client (`portainer-mcp/pkg/portainer/client`)
24 | 
25 | *   **Package:** `github.com/portainer/portainer-mcp/pkg/portainer/client`
26 | *   **Role:** This client acts as an **abstraction layer** on top of the Raw Client. Its primary purposes are:
27 |     *   To simplify the interface exposed to the rest of the `portainer-mcp` application (specifically the MCP server handlers in `internal/mcp/`).
28 |     *   To perform necessary **data transformations**, converting Raw Models from the API into the simpler, tailored Local Models.
29 |     *   To encapsulate common logic or error handling related to Portainer API interactions.
30 | *   **Usage:** This is the client used by the **MCP server handlers** (`internal/mcp/server.go` instantiates it and passes it to handlers).
31 | *   **Models Used:** Takes Raw Models as input from the Raw Client but typically **returns Local Models** (`portainer-mcp/pkg/portainer/models`) after performing conversions.
32 | 
33 | ## Models
34 | 
35 | ### 1. Raw Models (`portainer/client-api-go/v2/pkg/models`)
36 | 
37 | *   **Package:** `github.com/portainer/client-api-go/v2/pkg/models`
38 | *   **Role:** These structs directly map to the data structures returned by the Portainer API.
39 | *   **Characteristics:** Can be complex, may contain fields not relevant to MCP, and might use types (like numeric enums) that are less convenient for MCP's purposes.
40 | *   **Examples:** `models.PortainereeSettings`, `models.PortainereeEndpoint`.
41 | *   **Usage:** Returned by the Raw Client, used as input to the conversion functions within the Wrapper Client / Local Models package.
42 | *   **Naming Convention:** To improve clarity, variables holding instances of these Raw Models are typically prefixed with `raw` (e.g., `rawSettings`, `rawEndpoint`).
43 | 
44 | ### 2. Local Models (`portainer-mcp/pkg/portainer/models`)
45 | 
46 | *   **Package:** `github.com/portainer/portainer-mcp/pkg/portainer/models`
47 | *   **Role:** These are simplified, tailored structs designed specifically for use within the `portainer-mcp` application and for exposure via the MCP tools.
48 | *   **Characteristics:** Simpler structure, contain only relevant fields, often use more convenient types (like string enums).
49 | *   **Examples:** `models.PortainerSettings`, `models.Environment`, `models.EnvironmentTag`.
50 | *   **Usage:** Returned by the Wrapper Client, used within MCP server handlers, and ultimately determine the structure of data returned by MCP tools.
51 | 
52 | ### 3. Conversion Functions
53 | 
54 | *   **Location:** Typically reside within `portainer-mcp/pkg/portainer/models`.
55 | *   **Role:** Bridge the gap between Raw Models and Local Models.
56 | *   **Examples:** `ConvertSettingsToPortainerSettings`, `ConvertEndpointToEnvironment`.
57 | *   **Usage:** Called by the Wrapper Client methods to transform data before returning it. The function parameters accepting Raw Models typically follow the `raw` prefix naming convention (e.g., `func ConvertSettingsToPortainerSettings(rawSettings *apimodels.PortainereeSettings)`).
58 | 
59 | ## Typical Workflow Example (`GetSettings`)
60 | 
61 | 1.  **MCP Handler (`internal/mcp/settings.go`)**: Receives a tool call.
62 | 2.  Calls `s.cli.GetSettings()`. Here, `s.cli` is an instance of the **Wrapper Client** (`PortainerClient`).
63 | 3.  **Wrapper Client (`pkg/portainer/client/settings.go`)**: Its `GetSettings` method is executed.
64 | 4.  Calls the **Raw Client**'s `GetSettings` method (e.g., `c.cli.GetSettings()`).
65 | 5.  Raw Client interacts with the Portainer API and returns a **Raw Model** (`*portainermodels.PortainereeSettings`).
66 | 6.  Wrapper Client calls the **Conversion Function** (`models.ConvertSettingsToPortainerSettings`) with the Raw Model.
67 | 7.  Conversion Function returns a **Local Model** (`models.PortainerSettings`).
68 | 8.  Wrapper Client returns the Local Model to the MCP Handler.
69 | 9.  MCP Handler marshals the **Local Model** (`models.PortainerSettings`) into JSON and returns it as the tool result.
70 | 
71 | ## Import Conventions
72 | 
73 | To improve clarity, especially in files where both model types might appear (like tests), consider using consistent import aliases. Leaving the local `portainer-mcp/pkg/portainer/models` package as the default `models` and aliasing the external library is recommended:
74 | 
75 | ```go
76 | import (
77 |     "github.com/portainer/portainer-mcp/pkg/portainer/models" // Default: models (Local MCP Models)
78 |     apimodels "github.com/portainer/client-api-go/v2/pkg/models"      // Alias: apimodels (Raw Client-API-Go Models)
79 | )
80 | ```
81 | 
82 | This approach keeps code cleaner for the more frequently used local models while clearly indicating when the raw API models are involved.
83 | 
84 | ## Testing Implications
85 | 
86 | *   **Unit Tests** (like `pkg/portainer/client/settings_test.go`): Should mock the Raw Client interface and verify that the Wrapper Client correctly calls the Raw Client and performs the necessary conversions, returning the expected Local Model.
87 | *   **Integration Tests** (like `tests/integration/settings_test.go`): 
88 |     *   Call the MCP handler, which uses the Wrapper Client internally and returns JSON representing a Local Model.
89 |     *   Often need to *also* call the Raw Client directly to get the ground-truth state from the live Portainer instance (variables holding this state should follow the `raw` prefix convention, e.g., `rawEndpoint`).
90 |     *   May need to manually apply the same Conversion Function to the Raw Model obtained from the Raw Client to create an expected Local Model for comparison against the handler's result.
91 | 
92 | By understanding these distinct layers and their interactions, development and testing within `portainer-mcp` should be clearer. 
```
Page 2/5FirstPrevNextLast