This is page 4 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
--------------------------------------------------------------------------------
/tests/integration/access_group_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 | 	testAccessGroupName      = "test-access-group"
 19 | 	testAccessGroupNewName   = "test-access-group-updated"
 20 | 	testTeamAccessGroupName  = "test-team-for-access-group"
 21 | 	testUserAccessGroupName  = "test-user-for-access-group"
 22 | 	testAccGroupPassword     = "testpassword"
 23 | 	accGroupUserRoleStandard = 2 // Portainer API role ID for Standard User
 24 | 	accGroupEndpointName     = "test-endpoint-for-access-group"
 25 | )
 26 | 
 27 | // prepareAccessGroupTestEnvironment creates test resources needed for access group tests
 28 | // including users, teams, and environments
 29 | func prepareAccessGroupTestEnvironment(t *testing.T, env *helpers.TestEnv) (int, int, int) {
 30 | 	host, port := env.Portainer.GetHostAndPort()
 31 | 	serverAddr := fmt.Sprintf("%s:%s", host, port)
 32 | 	tunnelAddr := fmt.Sprintf("%s:8000", host)
 33 | 
 34 | 	err := env.RawClient.UpdateSettings(true, serverAddr, tunnelAddr)
 35 | 	require.NoError(t, err, "Failed to update settings")
 36 | 
 37 | 	// Create a test user
 38 | 	testUserID, err := env.RawClient.CreateUser(testUserAccessGroupName, testAccGroupPassword, accGroupUserRoleStandard)
 39 | 	require.NoError(t, err, "Failed to create test user via raw client")
 40 | 
 41 | 	// Create a test team
 42 | 	testTeamID, err := env.RawClient.CreateTeam(testTeamAccessGroupName)
 43 | 	require.NoError(t, err, "Failed to create test team via raw client")
 44 | 
 45 | 	// Create a test environment
 46 | 	testEnvID, err := env.RawClient.CreateEdgeDockerEndpoint(accGroupEndpointName)
 47 | 	require.NoError(t, err, "Failed to create test environment via raw client")
 48 | 
 49 | 	return int(testUserID), int(testTeamID), int(testEnvID)
 50 | }
 51 | 
 52 | // TestAccessGroupManagement is an integration test suite that verifies the complete
 53 | // lifecycle of access group management in Portainer MCP. It tests creation, listing,
 54 | // name updates, user accesses, team accesses, and environment management.
 55 | func TestAccessGroupManagement(t *testing.T) {
 56 | 	env := helpers.NewTestEnv(t)
 57 | 	defer env.Cleanup(t)
 58 | 
 59 | 	// Prepare the test environment
 60 | 	testUserID, testTeamID, testEnvID := prepareAccessGroupTestEnvironment(t, env)
 61 | 
 62 | 	var testAccessGroupID int
 63 | 
 64 | 	// Subtest: Access Group Creation
 65 | 	// Verifies that:
 66 | 	// - A new access group can be created via the HandleCreateAccessGroup handler
 67 | 	// - The handler response indicates success with an ID
 68 | 	// - The created access group exists in Portainer when checked directly via Raw Client
 69 | 	t.Run("Access Group Creation", func(t *testing.T) {
 70 | 		handler := env.MCPServer.HandleCreateAccessGroup()
 71 | 		request := mcp.CreateMCPRequest(map[string]any{
 72 | 			"name":           testAccessGroupName,
 73 | 			"environmentIds": []any{float64(testEnvID)},
 74 | 		})
 75 | 
 76 | 		result, err := handler(env.Ctx, request)
 77 | 		require.NoError(t, err, "Failed to create access group via MCP handler")
 78 | 
 79 | 		textContent, ok := result.Content[0].(mcpmodels.TextContent)
 80 | 		require.True(t, ok, "Expected text content in MCP response")
 81 | 
 82 | 		// Check for success message and extract ID for later tests
 83 | 		assert.Contains(t, textContent.Text, "Access group created successfully with ID:", "Success message prefix mismatch")
 84 | 
 85 | 		// Verify by fetching access group directly via raw client
 86 | 		rawAccessGroup, err := env.RawClient.GetEndpointGroupByName(testAccessGroupName)
 87 | 		require.NoError(t, err, "Failed to get access group directly via raw client")
 88 | 		assert.Equal(t, testAccessGroupName, rawAccessGroup.Name, "Access group name mismatch")
 89 | 
 90 | 		// Extract group ID for subsequent tests
 91 | 		testAccessGroupID = int(rawAccessGroup.ID)
 92 | 	})
 93 | 
 94 | 	// Subtest: Access Groups Listing
 95 | 	// Verifies that:
 96 | 	// - The access group list can be retrieved via the HandleGetAccessGroups handler
 97 | 	// - The list contains the expected access group
 98 | 	// - The access group has the correct name and properties
 99 | 	t.Run("Access Groups Listing", func(t *testing.T) {
100 | 		handler := env.MCPServer.HandleGetAccessGroups()
101 | 		result, err := handler(env.Ctx, mcp.CreateMCPRequest(nil))
102 | 		require.NoError(t, err, "Failed to get access groups via MCP handler")
103 | 
104 | 		assert.Len(t, result.Content, 1, "Expected exactly one content block in the result")
105 | 		textContent, ok := result.Content[0].(mcpmodels.TextContent)
106 | 		assert.True(t, ok, "Expected text content in MCP response")
107 | 
108 | 		var retrievedAccessGroups []models.AccessGroup
109 | 		err = json.Unmarshal([]byte(textContent.Text), &retrievedAccessGroups)
110 | 		require.NoError(t, err, "Failed to unmarshal retrieved access groups")
111 | 		require.Len(t, retrievedAccessGroups, 2, "Expected exactly two access groups after unmarshalling")
112 | 
113 | 		accessGroup := retrievedAccessGroups[1]
114 | 		assert.Equal(t, testAccessGroupName, accessGroup.Name, "Access group name mismatch")
115 | 
116 | 		// Fetch the same access group directly via the client
117 | 		rawAccessGroup, err := env.RawClient.GetEndpointGroup(int64(testAccessGroupID))
118 | 		require.NoError(t, err, "Failed to get access group directly via client")
119 | 
120 | 		// Convert the raw access group to the expected AccessGroup model
121 | 		rawEndpoints, err := env.RawClient.ListEndpoints()
122 | 		require.NoError(t, err, "Failed to list endpoints")
123 | 
124 | 		expectedAccessGroup := models.ConvertEndpointGroupToAccessGroup(rawAccessGroup, rawEndpoints)
125 | 		assert.Equal(t, expectedAccessGroup, accessGroup, "Access group mismatch between MCP handler and direct client call")
126 | 	})
127 | 
128 | 	// Subtest: Access Group Name Update
129 | 	// Verifies that:
130 | 	// - An access group's name can be updated via the HandleUpdateAccessGroupName handler
131 | 	// - The handler response indicates success
132 | 	// - The access group name is actually updated when checked directly via Raw Client
133 | 	t.Run("Access Group Name Update", func(t *testing.T) {
134 | 		handler := env.MCPServer.HandleUpdateAccessGroupName()
135 | 		request := mcp.CreateMCPRequest(map[string]any{
136 | 			"id":   float64(testAccessGroupID),
137 | 			"name": testAccessGroupNewName,
138 | 		})
139 | 
140 | 		result, err := handler(env.Ctx, request)
141 | 		require.NoError(t, err, "Failed to update access group name via MCP handler")
142 | 
143 | 		textContent, ok := result.Content[0].(mcpmodels.TextContent)
144 | 		require.True(t, ok, "Expected text content in MCP response")
145 | 		assert.Contains(t, textContent.Text, "Access group name updated successfully", "Success message mismatch")
146 | 
147 | 		// Verify by fetching access group directly via raw client
148 | 		updatedAccessGroup, err := env.RawClient.GetEndpointGroup(int64(testAccessGroupID))
149 | 		require.NoError(t, err, "Failed to get access group directly via client")
150 | 		assert.Equal(t, testAccessGroupNewName, updatedAccessGroup.Name, "Access group name was not updated")
151 | 	})
152 | 
153 | 	// Subtest: Access Group User Accesses Update
154 | 	// Verifies that:
155 | 	// - User access policies can be updated via the HandleUpdateAccessGroupUserAccesses handler
156 | 	// - The handler response indicates success
157 | 	// - The access policies are correctly updated when checked directly via Raw Client
158 | 	t.Run("Access Group User Accesses Update", func(t *testing.T) {
159 | 		handler := env.MCPServer.HandleUpdateAccessGroupUserAccesses()
160 | 		request := mcp.CreateMCPRequest(map[string]any{
161 | 			"id": float64(testAccessGroupID),
162 | 			"userAccesses": []any{
163 | 				map[string]any{"id": float64(testUserID), "access": "environment_administrator"},
164 | 			},
165 | 		})
166 | 
167 | 		result, err := handler(env.Ctx, request)
168 | 		require.NoError(t, err, "Failed to update access group user accesses via MCP handler")
169 | 
170 | 		textContent, ok := result.Content[0].(mcpmodels.TextContent)
171 | 		require.True(t, ok, "Expected text content in MCP response")
172 | 		assert.Contains(t, textContent.Text, "Access group user accesses updated successfully", "Success message mismatch")
173 | 
174 | 		// Verify by fetching access group directly via raw client and checking user accesses
175 | 		updatedAccessGroup, err := env.RawClient.GetEndpointGroup(int64(testAccessGroupID))
176 | 		require.NoError(t, err, "Failed to get access group directly via client")
177 | 
178 | 		rawEndpoints, err := env.RawClient.ListEndpoints()
179 | 		require.NoError(t, err, "Failed to list endpoints")
180 | 
181 | 		convertedAccessGroup := models.ConvertEndpointGroupToAccessGroup(updatedAccessGroup, rawEndpoints)
182 | 		userAccess, exists := convertedAccessGroup.UserAccesses[testUserID]
183 | 		assert.True(t, exists, "User access policy not found")
184 | 		assert.Equal(t, "environment_administrator", userAccess, "User access level mismatch")
185 | 	})
186 | 
187 | 	// Subtest: Access Group Team Accesses Update
188 | 	// Verifies that:
189 | 	// - Team access policies can be updated via the HandleUpdateAccessGroupTeamAccesses handler
190 | 	// - The handler response indicates success
191 | 	// - The access policies are correctly updated when checked directly via Raw Client
192 | 	t.Run("Access Group Team Accesses Update", func(t *testing.T) {
193 | 		handler := env.MCPServer.HandleUpdateAccessGroupTeamAccesses()
194 | 		request := mcp.CreateMCPRequest(map[string]any{
195 | 			"id": float64(testAccessGroupID),
196 | 			"teamAccesses": []any{
197 | 				map[string]any{"id": float64(testTeamID), "access": "standard_user"},
198 | 			},
199 | 		})
200 | 
201 | 		result, err := handler(env.Ctx, request)
202 | 		require.NoError(t, err, "Failed to update access group team accesses via MCP handler")
203 | 
204 | 		textContent, ok := result.Content[0].(mcpmodels.TextContent)
205 | 		require.True(t, ok, "Expected text content in MCP response")
206 | 		assert.Contains(t, textContent.Text, "Access group team accesses updated successfully", "Success message mismatch")
207 | 
208 | 		// Verify by fetching access group directly via raw client and checking team accesses
209 | 		updatedAccessGroup, err := env.RawClient.GetEndpointGroup(int64(testAccessGroupID))
210 | 		require.NoError(t, err, "Failed to get access group directly via client")
211 | 
212 | 		rawEndpoints, err := env.RawClient.ListEndpoints()
213 | 		require.NoError(t, err, "Failed to list endpoints")
214 | 
215 | 		convertedAccessGroup := models.ConvertEndpointGroupToAccessGroup(updatedAccessGroup, rawEndpoints)
216 | 		teamAccess, exists := convertedAccessGroup.TeamAccesses[testTeamID]
217 | 		assert.True(t, exists, "Team access policy not found")
218 | 		assert.Equal(t, "standard_user", teamAccess, "Team access level mismatch")
219 | 	})
220 | 
221 | 	// Subtest: Remove Environment From Access Group
222 | 	// Verifies that:
223 | 	// - An environment can be removed from an access group via the HandleRemoveEnvironmentFromAccessGroup handler
224 | 	// - The handler response indicates success
225 | 	// - The environment is actually removed when checked directly via Raw Client
226 | 	t.Run("Remove Environment From Access Group", func(t *testing.T) {
227 | 		handler := env.MCPServer.HandleRemoveEnvironmentFromAccessGroup()
228 | 		request := mcp.CreateMCPRequest(map[string]any{
229 | 			"id":            float64(testAccessGroupID),
230 | 			"environmentId": float64(testEnvID),
231 | 		})
232 | 
233 | 		result, err := handler(env.Ctx, request)
234 | 		require.NoError(t, err, "Failed to remove environment from access group via MCP handler")
235 | 
236 | 		textContent, ok := result.Content[0].(mcpmodels.TextContent)
237 | 		require.True(t, ok, "Expected text content in MCP response")
238 | 		assert.Contains(t, textContent.Text, "Environment removed from access group successfully", "Success message mismatch")
239 | 
240 | 		// Verify by fetching access group directly via raw client and checking environments
241 | 		updatedAccessGroup, err := env.RawClient.GetEndpointGroup(int64(testAccessGroupID))
242 | 		require.NoError(t, err, "Failed to get access group directly via client")
243 | 
244 | 		rawEndpoints, err := env.RawClient.ListEndpoints()
245 | 		require.NoError(t, err, "Failed to list endpoints")
246 | 
247 | 		convertedAccessGroup := models.ConvertEndpointGroupToAccessGroup(updatedAccessGroup, rawEndpoints)
248 | 		assert.ElementsMatch(t, []int{}, convertedAccessGroup.EnvironmentIds, "Environment was not removed from access group")
249 | 	})
250 | 
251 | 	// Subtest: Add Environment To Access Group
252 | 	// Verifies that:
253 | 	// - An environment can be added back to an access group via the HandleAddEnvironmentToAccessGroup handler
254 | 	// - The handler response indicates success
255 | 	// - The environment is actually added when checked directly via Raw Client
256 | 	// Note: This test is run after the remove test to verify both operations work correctly
257 | 	t.Run("Add Environment To Access Group", func(t *testing.T) {
258 | 		handler := env.MCPServer.HandleAddEnvironmentToAccessGroup()
259 | 		request := mcp.CreateMCPRequest(map[string]any{
260 | 			"id":            float64(testAccessGroupID),
261 | 			"environmentId": float64(testEnvID),
262 | 		})
263 | 
264 | 		result, err := handler(env.Ctx, request)
265 | 		require.NoError(t, err, "Failed to add environment to access group via MCP handler")
266 | 
267 | 		textContent, ok := result.Content[0].(mcpmodels.TextContent)
268 | 		require.True(t, ok, "Expected text content in MCP response")
269 | 		assert.Contains(t, textContent.Text, "Environment added to access group successfully", "Success message mismatch")
270 | 
271 | 		// Verify by fetching access group directly via raw client and checking environments
272 | 		updatedAccessGroup, err := env.RawClient.GetEndpointGroup(int64(testAccessGroupID))
273 | 		require.NoError(t, err, "Failed to get access group directly via client")
274 | 
275 | 		rawEndpoints, err := env.RawClient.ListEndpoints()
276 | 		require.NoError(t, err, "Failed to list endpoints")
277 | 
278 | 		convertedAccessGroup := models.ConvertEndpointGroupToAccessGroup(updatedAccessGroup, rawEndpoints)
279 | 		assert.ElementsMatch(t, []int{testEnvID}, convertedAccessGroup.EnvironmentIds, "Environment was not added to access group")
280 | 	})
281 | }
282 | 
```
--------------------------------------------------------------------------------
/internal/mcp/group_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 TestHandleGetEnvironmentGroups(t *testing.T) {
 15 | 	tests := []struct {
 16 | 		name        string
 17 | 		mockGroups  []models.Group
 18 | 		mockError   error
 19 | 		expectError bool
 20 | 	}{
 21 | 		{
 22 | 			name: "successful groups retrieval",
 23 | 			mockGroups: []models.Group{
 24 | 				{ID: 1, Name: "group1"},
 25 | 				{ID: 2, Name: "group2"},
 26 | 			},
 27 | 			mockError:   nil,
 28 | 			expectError: false,
 29 | 		},
 30 | 		{
 31 | 			name:        "api error",
 32 | 			mockGroups:  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 | 			mockClient := &MockPortainerClient{}
 41 | 			mockClient.On("GetEnvironmentGroups").Return(tt.mockGroups, tt.mockError)
 42 | 
 43 | 			server := &PortainerMCPServer{
 44 | 				cli: mockClient,
 45 | 			}
 46 | 
 47 | 			handler := server.HandleGetEnvironmentGroups()
 48 | 			result, err := handler(context.Background(), mcp.CallToolRequest{})
 49 | 
 50 | 			if tt.expectError {
 51 | 				assert.NoError(t, err)
 52 | 				assert.NotNil(t, result)
 53 | 				assert.True(t, result.IsError, "result.IsError should be true for expected errors")
 54 | 				assert.Len(t, result.Content, 1)
 55 | 				textContent, ok := result.Content[0].(mcp.TextContent)
 56 | 				assert.True(t, ok, "Result content should be mcp.TextContent for errors")
 57 | 				if tt.mockError != nil {
 58 | 					assert.Contains(t, textContent.Text, tt.mockError.Error())
 59 | 				} else {
 60 | 					assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
 61 | 				}
 62 | 			} else {
 63 | 				assert.NoError(t, err)
 64 | 				assert.Len(t, result.Content, 1)
 65 | 				textContent, ok := result.Content[0].(mcp.TextContent)
 66 | 				assert.True(t, ok)
 67 | 
 68 | 				var groups []models.Group
 69 | 				err = json.Unmarshal([]byte(textContent.Text), &groups)
 70 | 				assert.NoError(t, err)
 71 | 				assert.Equal(t, tt.mockGroups, groups)
 72 | 			}
 73 | 
 74 | 			mockClient.AssertExpectations(t)
 75 | 		})
 76 | 	}
 77 | }
 78 | 
 79 | func TestHandleCreateEnvironmentGroup(t *testing.T) {
 80 | 	tests := []struct {
 81 | 		name        string
 82 | 		inputName   string
 83 | 		inputEnvIDs []int
 84 | 		mockID      int
 85 | 		mockError   error
 86 | 		expectError bool
 87 | 		setupParams func(request *mcp.CallToolRequest)
 88 | 	}{
 89 | 		{
 90 | 			name:        "successful group creation",
 91 | 			inputName:   "group1",
 92 | 			inputEnvIDs: []int{1, 2, 3},
 93 | 			mockID:      1,
 94 | 			mockError:   nil,
 95 | 			expectError: false,
 96 | 			setupParams: func(request *mcp.CallToolRequest) {
 97 | 				request.Params.Arguments = map[string]any{
 98 | 					"name":           "group1",
 99 | 					"environmentIds": []any{float64(1), float64(2), float64(3)},
100 | 				}
101 | 			},
102 | 		},
103 | 		{
104 | 			name:        "api error",
105 | 			inputName:   "group1",
106 | 			inputEnvIDs: []int{1, 2, 3},
107 | 			mockID:      0,
108 | 			mockError:   fmt.Errorf("api error"),
109 | 			expectError: true,
110 | 			setupParams: func(request *mcp.CallToolRequest) {
111 | 				request.Params.Arguments = map[string]any{
112 | 					"name":           "group1",
113 | 					"environmentIds": []any{float64(1), float64(2), float64(3)},
114 | 				}
115 | 			},
116 | 		},
117 | 		{
118 | 			name:        "missing name parameter",
119 | 			inputEnvIDs: []int{1, 2, 3},
120 | 			mockError:   nil,
121 | 			expectError: true,
122 | 			setupParams: func(request *mcp.CallToolRequest) {
123 | 				request.Params.Arguments = map[string]any{
124 | 					"environmentIds": []any{float64(1), float64(2), float64(3)},
125 | 				}
126 | 			},
127 | 		},
128 | 		{
129 | 			name:        "missing environmentIds parameter",
130 | 			inputName:   "group1",
131 | 			mockError:   nil,
132 | 			expectError: true,
133 | 			setupParams: func(request *mcp.CallToolRequest) {
134 | 				request.Params.Arguments = map[string]any{
135 | 					"name": "group1",
136 | 				}
137 | 			},
138 | 		},
139 | 	}
140 | 
141 | 	for _, tt := range tests {
142 | 		t.Run(tt.name, func(t *testing.T) {
143 | 			mockClient := &MockPortainerClient{}
144 | 			if !tt.expectError || tt.mockError != nil {
145 | 				mockClient.On("CreateEnvironmentGroup", tt.inputName, tt.inputEnvIDs).Return(tt.mockID, tt.mockError)
146 | 			}
147 | 
148 | 			server := &PortainerMCPServer{
149 | 				cli: mockClient,
150 | 			}
151 | 
152 | 			request := CreateMCPRequest(map[string]any{})
153 | 			tt.setupParams(&request)
154 | 
155 | 			handler := server.HandleCreateEnvironmentGroup()
156 | 			result, err := handler(context.Background(), request)
157 | 
158 | 			if tt.expectError {
159 | 				assert.NoError(t, err)
160 | 				assert.NotNil(t, result)
161 | 				assert.True(t, result.IsError, "result.IsError should be true for expected errors")
162 | 				assert.Len(t, result.Content, 1)
163 | 				textContent, ok := result.Content[0].(mcp.TextContent)
164 | 				assert.True(t, ok, "Result content should be mcp.TextContent for errors")
165 | 				if tt.mockError != nil {
166 | 					assert.Contains(t, textContent.Text, tt.mockError.Error())
167 | 				} else {
168 | 					assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
169 | 				}
170 | 			} else {
171 | 				assert.NoError(t, err)
172 | 				assert.Len(t, result.Content, 1)
173 | 				textContent, ok := result.Content[0].(mcp.TextContent)
174 | 				assert.True(t, ok)
175 | 				assert.Contains(t, textContent.Text, fmt.Sprintf("ID: %d", tt.mockID))
176 | 			}
177 | 
178 | 			mockClient.AssertExpectations(t)
179 | 		})
180 | 	}
181 | }
182 | 
183 | func TestHandleUpdateEnvironmentGroupName(t *testing.T) {
184 | 	tests := []struct {
185 | 		name        string
186 | 		inputID     int
187 | 		inputName   string
188 | 		mockError   error
189 | 		expectError bool
190 | 		setupParams func(request mcp.CallToolRequest) mcp.CallToolRequest
191 | 	}{
192 | 		{
193 | 			name:        "successful name update",
194 | 			inputID:     1,
195 | 			inputName:   "newname",
196 | 			mockError:   nil,
197 | 			expectError: false,
198 | 			setupParams: func(request mcp.CallToolRequest) mcp.CallToolRequest {
199 | 				request.Params.Arguments = map[string]any{
200 | 					"id":   float64(1),
201 | 					"name": "newname",
202 | 				}
203 | 				return request
204 | 			},
205 | 		},
206 | 		{
207 | 			name:        "api error",
208 | 			inputID:     1,
209 | 			inputName:   "newname",
210 | 			mockError:   fmt.Errorf("api error"),
211 | 			expectError: true,
212 | 			setupParams: func(request mcp.CallToolRequest) mcp.CallToolRequest {
213 | 				request.Params.Arguments = map[string]any{
214 | 					"id":   float64(1),
215 | 					"name": "newname",
216 | 				}
217 | 				return request
218 | 			},
219 | 		},
220 | 		{
221 | 			name:        "missing id parameter",
222 | 			inputName:   "newname",
223 | 			mockError:   nil,
224 | 			expectError: true,
225 | 			setupParams: func(request mcp.CallToolRequest) mcp.CallToolRequest {
226 | 				request.Params.Arguments = map[string]any{
227 | 					"name": "newname",
228 | 				}
229 | 				return request
230 | 			},
231 | 		},
232 | 		{
233 | 			name:        "missing name parameter",
234 | 			inputID:     1,
235 | 			mockError:   nil,
236 | 			expectError: true,
237 | 			setupParams: func(request mcp.CallToolRequest) mcp.CallToolRequest {
238 | 				request.Params.Arguments = map[string]any{
239 | 					"id": float64(1),
240 | 				}
241 | 				return request
242 | 			},
243 | 		},
244 | 	}
245 | 
246 | 	for _, tt := range tests {
247 | 		t.Run(tt.name, func(t *testing.T) {
248 | 			mockClient := &MockPortainerClient{}
249 | 			if !tt.expectError || tt.mockError != nil {
250 | 				mockClient.On("UpdateEnvironmentGroupName", tt.inputID, tt.inputName).Return(tt.mockError)
251 | 			}
252 | 
253 | 			server := &PortainerMCPServer{
254 | 				cli: mockClient,
255 | 			}
256 | 
257 | 			request := CreateMCPRequest(map[string]any{})
258 | 			request = tt.setupParams(request)
259 | 
260 | 			handler := server.HandleUpdateEnvironmentGroupName()
261 | 			result, err := handler(context.Background(), request)
262 | 
263 | 			if tt.expectError {
264 | 				assert.NoError(t, err)
265 | 				assert.NotNil(t, result)
266 | 				assert.True(t, result.IsError, "result.IsError should be true for expected errors")
267 | 				assert.Len(t, result.Content, 1)
268 | 				textContent, ok := result.Content[0].(mcp.TextContent)
269 | 				assert.True(t, ok, "Result content should be mcp.TextContent for errors")
270 | 				if tt.mockError != nil {
271 | 					assert.Contains(t, textContent.Text, tt.mockError.Error())
272 | 				} else {
273 | 					assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
274 | 				}
275 | 			} else {
276 | 				assert.NoError(t, err)
277 | 				assert.Len(t, result.Content, 1)
278 | 				textContent, ok := result.Content[0].(mcp.TextContent)
279 | 				assert.True(t, ok)
280 | 				assert.Contains(t, textContent.Text, "successfully")
281 | 			}
282 | 
283 | 			mockClient.AssertExpectations(t)
284 | 		})
285 | 	}
286 | }
287 | 
288 | func TestHandleUpdateEnvironmentGroupEnvironments(t *testing.T) {
289 | 	tests := []struct {
290 | 		name        string
291 | 		inputID     int
292 | 		inputEnvIDs []int
293 | 		mockError   error
294 | 		expectError bool
295 | 		setupParams func(request mcp.CallToolRequest) mcp.CallToolRequest
296 | 	}{
297 | 		{
298 | 			name:        "successful environments update",
299 | 			inputID:     1,
300 | 			inputEnvIDs: []int{1, 2, 3},
301 | 			mockError:   nil,
302 | 			expectError: false,
303 | 			setupParams: func(request mcp.CallToolRequest) mcp.CallToolRequest {
304 | 				request.Params.Arguments = map[string]any{
305 | 					"id":             float64(1),
306 | 					"environmentIds": []any{float64(1), float64(2), float64(3)},
307 | 				}
308 | 				return request
309 | 			},
310 | 		},
311 | 		{
312 | 			name:        "api error",
313 | 			inputID:     1,
314 | 			inputEnvIDs: []int{1, 2, 3},
315 | 			mockError:   fmt.Errorf("api error"),
316 | 			expectError: true,
317 | 			setupParams: func(request mcp.CallToolRequest) mcp.CallToolRequest {
318 | 				request.Params.Arguments = map[string]any{
319 | 					"id":             float64(1),
320 | 					"environmentIds": []any{float64(1), float64(2), float64(3)},
321 | 				}
322 | 				return request
323 | 			},
324 | 		},
325 | 		{
326 | 			name:        "missing id parameter",
327 | 			inputEnvIDs: []int{1, 2, 3},
328 | 			mockError:   nil,
329 | 			expectError: true,
330 | 			setupParams: func(request mcp.CallToolRequest) mcp.CallToolRequest {
331 | 				request.Params.Arguments = map[string]any{
332 | 					"environmentIds": []any{float64(1), float64(2), float64(3)},
333 | 				}
334 | 				return request
335 | 			},
336 | 		},
337 | 		{
338 | 			name:        "missing environmentIds parameter",
339 | 			inputID:     1,
340 | 			mockError:   nil,
341 | 			expectError: true,
342 | 			setupParams: func(request mcp.CallToolRequest) mcp.CallToolRequest {
343 | 				request.Params.Arguments = map[string]any{
344 | 					"id":   float64(1),
345 | 					"name": "group1",
346 | 				}
347 | 				return request
348 | 			},
349 | 		},
350 | 	}
351 | 
352 | 	for _, tt := range tests {
353 | 		t.Run(tt.name, func(t *testing.T) {
354 | 			mockClient := &MockPortainerClient{}
355 | 			if !tt.expectError || tt.mockError != nil {
356 | 				mockClient.On("UpdateEnvironmentGroupEnvironments", tt.inputID, tt.inputEnvIDs).Return(tt.mockError)
357 | 			}
358 | 
359 | 			server := &PortainerMCPServer{
360 | 				cli: mockClient,
361 | 			}
362 | 
363 | 			request := CreateMCPRequest(map[string]any{})
364 | 			request = tt.setupParams(request)
365 | 
366 | 			handler := server.HandleUpdateEnvironmentGroupEnvironments()
367 | 			result, err := handler(context.Background(), request)
368 | 
369 | 			if tt.expectError {
370 | 				assert.NoError(t, err)
371 | 				assert.NotNil(t, result)
372 | 				assert.True(t, result.IsError, "result.IsError should be true for expected errors")
373 | 				assert.Len(t, result.Content, 1)
374 | 				textContent, ok := result.Content[0].(mcp.TextContent)
375 | 				assert.True(t, ok, "Result content should be mcp.TextContent for errors")
376 | 				if tt.mockError != nil {
377 | 					assert.Contains(t, textContent.Text, tt.mockError.Error())
378 | 				} else {
379 | 					assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
380 | 				}
381 | 			} else {
382 | 				assert.NoError(t, err)
383 | 				assert.Len(t, result.Content, 1)
384 | 				textContent, ok := result.Content[0].(mcp.TextContent)
385 | 				assert.True(t, ok)
386 | 				assert.Contains(t, textContent.Text, "successfully")
387 | 			}
388 | 
389 | 			mockClient.AssertExpectations(t)
390 | 		})
391 | 	}
392 | }
393 | 
394 | func TestHandleUpdateEnvironmentGroupTags(t *testing.T) {
395 | 	tests := []struct {
396 | 		name        string
397 | 		inputID     int
398 | 		inputTagIDs []int
399 | 		mockError   error
400 | 		expectError bool
401 | 		setupParams func(request mcp.CallToolRequest) mcp.CallToolRequest
402 | 	}{
403 | 		{
404 | 			name:        "successful tags update",
405 | 			inputID:     1,
406 | 			inputTagIDs: []int{1, 2, 3},
407 | 			mockError:   nil,
408 | 			expectError: false,
409 | 			setupParams: func(request mcp.CallToolRequest) mcp.CallToolRequest {
410 | 				request.Params.Arguments = map[string]any{
411 | 					"id":     float64(1),
412 | 					"tagIds": []any{float64(1), float64(2), float64(3)},
413 | 				}
414 | 				return request
415 | 			},
416 | 		},
417 | 		{
418 | 			name:        "api error",
419 | 			inputID:     1,
420 | 			inputTagIDs: []int{1, 2, 3},
421 | 			mockError:   fmt.Errorf("api error"),
422 | 			expectError: true,
423 | 			setupParams: func(request mcp.CallToolRequest) mcp.CallToolRequest {
424 | 				request.Params.Arguments = map[string]any{
425 | 					"id":     float64(1),
426 | 					"tagIds": []any{float64(1), float64(2), float64(3)},
427 | 				}
428 | 				return request
429 | 			},
430 | 		},
431 | 		{
432 | 			name:        "missing id parameter",
433 | 			inputTagIDs: []int{1, 2, 3},
434 | 			mockError:   nil,
435 | 			expectError: true,
436 | 			setupParams: func(request mcp.CallToolRequest) mcp.CallToolRequest {
437 | 				request.Params.Arguments = map[string]any{
438 | 					"tagIds": []any{float64(1), float64(2), float64(3)},
439 | 				}
440 | 				return request
441 | 			},
442 | 		},
443 | 		{
444 | 			name:        "missing tagIds parameter",
445 | 			inputID:     1,
446 | 			mockError:   nil,
447 | 			expectError: true,
448 | 			setupParams: func(request mcp.CallToolRequest) mcp.CallToolRequest {
449 | 				request.Params.Arguments = map[string]any{
450 | 					"id": float64(1),
451 | 				}
452 | 				return request
453 | 			},
454 | 		},
455 | 	}
456 | 
457 | 	for _, tt := range tests {
458 | 		t.Run(tt.name, func(t *testing.T) {
459 | 			mockClient := &MockPortainerClient{}
460 | 			if !tt.expectError || tt.mockError != nil {
461 | 				mockClient.On("UpdateEnvironmentGroupTags", tt.inputID, tt.inputTagIDs).Return(tt.mockError)
462 | 			}
463 | 
464 | 			server := &PortainerMCPServer{
465 | 				cli: mockClient,
466 | 			}
467 | 
468 | 			request := CreateMCPRequest(map[string]any{})
469 | 			request = tt.setupParams(request)
470 | 
471 | 			handler := server.HandleUpdateEnvironmentGroupTags()
472 | 			result, err := handler(context.Background(), request)
473 | 
474 | 			if tt.expectError {
475 | 				assert.NoError(t, err)
476 | 				assert.NotNil(t, result)
477 | 				assert.True(t, result.IsError, "result.IsError should be true for expected errors")
478 | 				assert.Len(t, result.Content, 1)
479 | 				textContent, ok := result.Content[0].(mcp.TextContent)
480 | 				assert.True(t, ok, "Result content should be mcp.TextContent for errors")
481 | 				if tt.mockError != nil {
482 | 					assert.Contains(t, textContent.Text, tt.mockError.Error())
483 | 				} else {
484 | 					assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
485 | 				}
486 | 			} else {
487 | 				assert.NoError(t, err)
488 | 				assert.Len(t, result.Content, 1)
489 | 				textContent, ok := result.Content[0].(mcp.TextContent)
490 | 				assert.True(t, ok)
491 | 				assert.Contains(t, textContent.Text, "successfully")
492 | 			}
493 | 
494 | 			mockClient.AssertExpectations(t)
495 | 		})
496 | 	}
497 | }
498 | 
```
--------------------------------------------------------------------------------
/pkg/toolgen/yaml_test.go:
--------------------------------------------------------------------------------
```go
  1 | package toolgen
  2 | 
  3 | import (
  4 | 	"os"
  5 | 	"path/filepath"
  6 | 	"reflect"
  7 | 	"testing"
  8 | 
  9 | 	"github.com/mark3labs/mcp-go/mcp"
 10 | 	"github.com/stretchr/testify/assert"
 11 | )
 12 | 
 13 | func TestLoadToolsFromYAML(t *testing.T) {
 14 | 	// Create a minimal test YAML file
 15 | 	tmpDir := t.TempDir()
 16 | 	validYamlPath := filepath.Join(tmpDir, "valid.yaml")
 17 | 	validYamlContent := `version: "v1.0.0"
 18 | tools:
 19 |   - name: testTool
 20 |     description: A test tool
 21 |     parameters:
 22 |       - name: param1
 23 |         type: string
 24 |         required: true
 25 |         description: A test parameter
 26 |     annotations:
 27 |       title: Test Tool Title
 28 |       readOnlyHint: true
 29 |       destructiveHint: false
 30 |       idempotentHint: true
 31 |       openWorldHint: false`
 32 | 
 33 | 	err := os.WriteFile(validYamlPath, []byte(validYamlContent), 0644)
 34 | 	if err != nil {
 35 | 		t.Fatalf("Failed to create test YAML file: %v", err)
 36 | 	}
 37 | 
 38 | 	// Create a newer version YAML file
 39 | 	newerVersionPath := filepath.Join(tmpDir, "newer.yaml")
 40 | 	newerVersionContent := `version: "v1.2.0"
 41 | tools:
 42 |   - name: testTool
 43 |     description: A test tool
 44 |     parameters:
 45 |       - name: param1
 46 |         type: string
 47 |         required: true
 48 |         description: A test parameter
 49 |     annotations:
 50 |       title: Test Tool Title
 51 |       readOnlyHint: true
 52 |       destructiveHint: false
 53 |       idempotentHint: true
 54 |       openWorldHint: false`
 55 | 
 56 | 	err = os.WriteFile(newerVersionPath, []byte(newerVersionContent), 0644)
 57 | 	if err != nil {
 58 | 		t.Fatalf("Failed to create newer version YAML file: %v", err)
 59 | 	}
 60 | 
 61 | 	// Create an older version YAML file (will fail version check)
 62 | 	olderVersionPath := filepath.Join(tmpDir, "older.yaml")
 63 | 	olderVersionContent := `version: "v0.9.0"
 64 | tools:
 65 |   - name: testTool
 66 |     description: A test tool
 67 |     parameters:
 68 |       - name: param1
 69 |         type: string
 70 |         required: true
 71 |         description: A test parameter
 72 |     # Annotations potentially missing, but version check fails first
 73 | `
 74 | 
 75 | 	err = os.WriteFile(olderVersionPath, []byte(olderVersionContent), 0644)
 76 | 	if err != nil {
 77 | 		t.Fatalf("Failed to create older version YAML file: %v", err)
 78 | 	}
 79 | 
 80 | 	// Create a file with missing version (will fail version check)
 81 | 	missingVersionPath := filepath.Join(tmpDir, "missing_version.yaml")
 82 | 	missingVersionContent := `tools:
 83 |   - name: testTool
 84 |     description: A test tool
 85 |     parameters:
 86 |       - name: param1
 87 |         type: string
 88 |         required: true
 89 |         description: A test parameter
 90 |     # Annotations potentially missing, but version check fails first
 91 | `
 92 | 
 93 | 	err = os.WriteFile(missingVersionPath, []byte(missingVersionContent), 0644)
 94 | 	if err != nil {
 95 | 		t.Fatalf("Failed to create missing version YAML file: %v", err)
 96 | 	}
 97 | 
 98 | 	// Create a file with invalid version format (will fail version check)
 99 | 	invalidVersionPath := filepath.Join(tmpDir, "invalid_version.yaml")
100 | 	invalidVersionContent := `version: "1.0"
101 | tools:
102 |   - name: testTool
103 |     description: A test tool
104 |     parameters:
105 |       - name: param1
106 |         type: string
107 |         required: true
108 |         description: A test parameter
109 |     # Annotations potentially missing, but version check fails first
110 | `
111 | 
112 | 	err = os.WriteFile(invalidVersionPath, []byte(invalidVersionContent), 0644)
113 | 	if err != nil {
114 | 		t.Fatalf("Failed to create invalid version YAML file: %v", err)
115 | 	}
116 | 
117 | 	// Create a file with missing annotations block (should fail annotation check)
118 | 	missingAnnotationsPath := filepath.Join(tmpDir, "missing_annotations.yaml")
119 | 	missingAnnotationsContent := `version: "v1.0.0"
120 | tools:
121 |   - name: toolWithoutAnnotations
122 |     description: A test tool missing annotations
123 |     parameters:
124 |       - name: param1
125 |         type: string
126 |         required: true
127 |         description: A test parameter
128 |   - name: toolWithAnnotations
129 |     description: A test tool with annotations
130 |     annotations:
131 |       title: Some Title
132 |       readOnlyHint: false
133 |       destructiveHint: false
134 |       idempotentHint: false
135 |       openWorldHint: false
136 | `
137 | 	err = os.WriteFile(missingAnnotationsPath, []byte(missingAnnotationsContent), 0644)
138 | 	if err != nil {
139 | 		t.Fatalf("Failed to create missing annotations YAML file: %v", err)
140 | 	}
141 | 
142 | 	tests := []struct {
143 | 		name           string
144 | 		filePath       string
145 | 		minimumVersion string
146 | 		wantErr        bool
147 | 		wantTool       string // name of tool we expect to find
148 | 		wantToolCount  int    // expected number of tools loaded
149 | 	}{
150 | 		{
151 | 			name:           "valid yaml file",
152 | 			filePath:       validYamlPath,
153 | 			minimumVersion: "v1.0.0",
154 | 			wantErr:        false,
155 | 			wantTool:       "testTool",
156 | 			wantToolCount:  1,
157 | 		},
158 | 		{
159 | 			name:           "valid yaml file with newer minimum version",
160 | 			filePath:       validYamlPath,
161 | 			minimumVersion: "v1.1.0",
162 | 			wantErr:        true, // Error because file version is below minimum
163 | 		},
164 | 		{
165 | 			name:           "newer version yaml file",
166 | 			filePath:       newerVersionPath,
167 | 			minimumVersion: "v1.0.0",
168 | 			wantErr:        false,
169 | 			wantTool:       "testTool",
170 | 			wantToolCount:  1,
171 | 		},
172 | 		{
173 | 			name:           "older version yaml file",
174 | 			filePath:       olderVersionPath,
175 | 			minimumVersion: "v1.0.0",
176 | 			wantErr:        true, // Error because file version is below minimum
177 | 		},
178 | 		{
179 | 			name:           "missing version in yaml",
180 | 			filePath:       missingVersionPath,
181 | 			minimumVersion: "v1.0.0",
182 | 			wantErr:        true,
183 | 		},
184 | 		{
185 | 			name:           "invalid version format",
186 | 			filePath:       invalidVersionPath,
187 | 			minimumVersion: "v1.0.0",
188 | 			wantErr:        true, // Error because version format is invalid
189 | 		},
190 | 		{
191 | 			name:           "missing annotations block",
192 | 			filePath:       missingAnnotationsPath,
193 | 			minimumVersion: "v1.0.0",
194 | 			wantErr:        false,                 // LoadToolsFromYAML itself doesn't error, but skips the invalid tool
195 | 			wantTool:       "toolWithAnnotations", // Only the tool with annotations should load
196 | 			wantToolCount:  1,                     // Expect only one tool to be loaded successfully
197 | 		},
198 | 		{
199 | 			name:           "non-existent file",
200 | 			filePath:       "nonexistent.yaml",
201 | 			minimumVersion: "v1.0.0",
202 | 			wantErr:        true,
203 | 		},
204 | 		{
205 | 			name:           "invalid yaml content",
206 | 			filePath:       createInvalidYAMLFile(t),
207 | 			minimumVersion: "v1.0.0",
208 | 			wantErr:        true,
209 | 		},
210 | 	}
211 | 
212 | 	for _, tt := range tests {
213 | 		t.Run(tt.name, func(t *testing.T) {
214 | 			tools, err := LoadToolsFromYAML(tt.filePath, tt.minimumVersion)
215 | 			if (err != nil) != tt.wantErr {
216 | 				t.Errorf("LoadToolsFromYAML() error = %v, wantErr %v", err, tt.wantErr)
217 | 				return
218 | 			}
219 | 
220 | 			if !tt.wantErr {
221 | 				if len(tools) != tt.wantToolCount {
222 | 					t.Errorf("LoadToolsFromYAML() loaded %d tools, want %d", len(tools), tt.wantToolCount)
223 | 				}
224 | 				if tt.wantTool != "" {
225 | 					tool, exists := tools[tt.wantTool]
226 | 					if !exists {
227 | 						t.Errorf("Expected tool '%s' not found in loaded tools: %v", tt.wantTool, tools)
228 | 						return
229 | 					}
230 | 					if tool.Name != tt.wantTool {
231 | 						t.Errorf("Tool name mismatch, got %s, want %s", tool.Name, tt.wantTool)
232 | 					}
233 | 					if tool.Description == "" {
234 | 						t.Errorf("Tool %s has no description", tt.wantTool)
235 | 					}
236 | 					// Basic check to ensure annotations were processed (more detailed checks in TestConvertToolDefinition)
237 | 					if tool.Annotations.Title == "" { // Check a field within Annotations
238 | 						t.Errorf("Tool %s seems to be missing processed annotations", tt.wantTool)
239 | 					}
240 | 				}
241 | 			}
242 | 		})
243 | 	}
244 | }
245 | 
246 | // Helper function to create an invalid YAML file for testing
247 | func createInvalidYAMLFile(t *testing.T) string {
248 | 	tmpDir := t.TempDir()
249 | 	path := filepath.Join(tmpDir, "invalid.yaml")
250 | 	// Add annotations to avoid failing that check first
251 | 	content := `version: "v1.0.0"
252 | tools:
253 |   - name: invalid
254 |     description: [invalid yaml content
255 |     annotations:
256 |       title: Invalid Tool`
257 | 
258 | 	err := os.WriteFile(path, []byte(content), 0644)
259 | 	if err != nil {
260 | 		t.Fatalf("Failed to create invalid YAML file: %v", err)
261 | 	}
262 | 	return path
263 | }
264 | 
265 | func TestConvertToolDefinition(t *testing.T) {
266 | 	// Define a valid annotation struct to reuse
267 | 	validAnnotations := Annotations{
268 | 		Title:           "Valid Title",
269 | 		ReadOnlyHint:    true,
270 | 		DestructiveHint: false,
271 | 		IdempotentHint:  true,
272 | 		OpenWorldHint:   false,
273 | 	}
274 | 
275 | 	tests := []struct {
276 | 		name          string
277 | 		def           ToolDefinition
278 | 		wantErr       bool
279 | 		wantErrSubstr string              // Optional: check for specific error message content
280 | 		want          *mcp.ToolAnnotation // Expected annotation output
281 | 	}{
282 | 		{
283 | 			name: "valid tool definition",
284 | 			def: ToolDefinition{
285 | 				Name:        "validTool",
286 | 				Description: "A valid tool description",
287 | 				Annotations: validAnnotations,
288 | 			},
289 | 			wantErr: false,
290 | 			want: &mcp.ToolAnnotation{
291 | 				Title:           "Valid Title",
292 | 				ReadOnlyHint:    &validAnnotations.ReadOnlyHint,
293 | 				DestructiveHint: &validAnnotations.DestructiveHint,
294 | 				IdempotentHint:  &validAnnotations.IdempotentHint,
295 | 				OpenWorldHint:   &validAnnotations.OpenWorldHint,
296 | 			},
297 | 		},
298 | 		{
299 | 			name: "empty name",
300 | 			def: ToolDefinition{
301 | 				Name:        "",
302 | 				Description: "A tool with empty name",
303 | 				Annotations: validAnnotations, // Needs annotations even if name is invalid
304 | 			},
305 | 			wantErr:       true,
306 | 			wantErrSubstr: "tool name is required",
307 | 		},
308 | 		{
309 | 			name: "empty description",
310 | 			def: ToolDefinition{
311 | 				Name:        "noDescTool",
312 | 				Description: "",
313 | 				Annotations: validAnnotations, // Needs annotations even if desc is invalid
314 | 			},
315 | 			wantErr:       true,
316 | 			wantErrSubstr: "tool description is required",
317 | 		},
318 | 		{
319 | 			name: "missing annotations",
320 | 			def: ToolDefinition{
321 | 				Name:        "noAnnotationTool",
322 | 				Description: "Tool without annotations",
323 | 				Annotations: Annotations{}, // Zero value simulates missing block
324 | 			},
325 | 			wantErr:       true,
326 | 			wantErrSubstr: "annotations block is required",
327 | 		},
328 | 		{
329 | 			name: "with parameters",
330 | 			def: ToolDefinition{
331 | 				Name:        "paramTool",
332 | 				Description: "Tool with parameters",
333 | 				Parameters: []ParameterDefinition{
334 | 					{
335 | 						Name:        "param1",
336 | 						Type:        "string",
337 | 						Required:    true,
338 | 						Description: "A test parameter",
339 | 					},
340 | 				},
341 | 				Annotations: validAnnotations,
342 | 			},
343 | 			wantErr: false,
344 | 			want: &mcp.ToolAnnotation{
345 | 				Title:           "Valid Title",
346 | 				ReadOnlyHint:    &validAnnotations.ReadOnlyHint,
347 | 				DestructiveHint: &validAnnotations.DestructiveHint,
348 | 				IdempotentHint:  &validAnnotations.IdempotentHint,
349 | 				OpenWorldHint:   &validAnnotations.OpenWorldHint,
350 | 			},
351 | 		},
352 | 	}
353 | 
354 | 	for _, tt := range tests {
355 | 		t.Run(tt.name, func(t *testing.T) {
356 | 			tool, err := convertToolDefinition(tt.def)
357 | 			if tt.wantErr {
358 | 				assert.Error(t, err)
359 | 				if tt.wantErrSubstr != "" {
360 | 					assert.Contains(t, err.Error(), tt.wantErrSubstr)
361 | 				}
362 | 			} else {
363 | 				assert.NoError(t, err)
364 | 				assert.Equal(t, tt.def.Name, tool.Name)
365 | 				assert.Equal(t, tt.def.Description, tool.Description)
366 | 				assert.Equal(t, *tt.want, tool.Annotations)
367 | 
368 | 			}
369 | 		})
370 | 	}
371 | }
372 | 
373 | func TestConvertToolDefinitions(t *testing.T) {
374 | 	// Define a valid annotation struct to reuse
375 | 	validAnnotations := Annotations{
376 | 		Title:           "Valid Title",
377 | 		ReadOnlyHint:    true,
378 | 		DestructiveHint: false,
379 | 		IdempotentHint:  true,
380 | 		OpenWorldHint:   false,
381 | 	}
382 | 
383 | 	tests := []struct {
384 | 		name string
385 | 		defs []ToolDefinition
386 | 		want int // number of tools expected to be successfully converted
387 | 	}{
388 | 		{
389 | 			name: "empty definitions",
390 | 			defs: []ToolDefinition{},
391 | 			want: 0,
392 | 		},
393 | 		{
394 | 			name: "single valid tool",
395 | 			defs: []ToolDefinition{
396 | 				{
397 | 					Name:        "tool1",
398 | 					Description: "Test tool 1",
399 | 					Parameters: []ParameterDefinition{
400 | 						{
401 | 							Name:        "param1",
402 | 							Type:        "string",
403 | 							Required:    true,
404 | 							Description: "Test parameter",
405 | 						},
406 | 					},
407 | 					Annotations: validAnnotations,
408 | 				},
409 | 			},
410 | 			want: 1,
411 | 		},
412 | 		{
413 | 			name: "multiple valid tools",
414 | 			defs: []ToolDefinition{
415 | 				{
416 | 					Name:        "tool1",
417 | 					Description: "Test tool 1",
418 | 					Annotations: validAnnotations,
419 | 				},
420 | 				{
421 | 					Name:        "tool2",
422 | 					Description: "Test tool 2",
423 | 					Annotations: validAnnotations,
424 | 				},
425 | 			},
426 | 			want: 2,
427 | 		},
428 | 		{
429 | 			name: "invalid tools are skipped",
430 | 			defs: []ToolDefinition{
431 | 				{
432 | 					Name:        "validTool1",
433 | 					Description: "Test tool 1",
434 | 					Annotations: validAnnotations,
435 | 				},
436 | 				{
437 | 					Name:        "", // Invalid: empty name
438 | 					Description: "Tool with empty name",
439 | 					Annotations: validAnnotations,
440 | 				},
441 | 				{
442 | 					Name:        "noDescTool", // Invalid: empty description
443 | 					Description: "",
444 | 					Annotations: validAnnotations,
445 | 				},
446 | 				{
447 | 					Name:        "noAnnotationTool", // Invalid: missing annotations
448 | 					Description: "Tool missing annotations",
449 | 					Annotations: Annotations{}, // Zero value
450 | 				},
451 | 				{
452 | 					Name:        "validTool2",
453 | 					Description: "Test tool 2",
454 | 					Annotations: validAnnotations,
455 | 				},
456 | 			},
457 | 			want: 2, // Only 2 valid tools should be returned
458 | 		},
459 | 	}
460 | 
461 | 	for _, tt := range tests {
462 | 		t.Run(tt.name, func(t *testing.T) {
463 | 			got := convertToolDefinitions(tt.defs)
464 | 			assert.Len(t, got, tt.want)
465 | 
466 | 			// Verify each tool expected to be converted exists and is valid
467 | 			for _, def := range tt.defs {
468 | 				// Skip definitions that are expected to cause errors
469 | 				if def.Name == "" || def.Description == "" || (def.Annotations == Annotations{}) {
470 | 					continue
471 | 				}
472 | 
473 | 				tool, exists := got[def.Name]
474 | 				assert.True(t, exists, "Tool %s not found in result", def.Name)
475 | 				if exists {
476 | 					assert.Equal(t, def.Name, tool.Name)
477 | 					assert.Equal(t, def.Description, tool.Description)
478 | 					assert.NotEmpty(t, tool.Annotations.Title) // Basic check that title is populated
479 | 				}
480 | 			}
481 | 		})
482 | 	}
483 | }
484 | 
485 | func TestConvertParameter(t *testing.T) {
486 | 	tests := []struct {
487 | 		name  string
488 | 		param ParameterDefinition
489 | 		want  reflect.Type // We'll check the type of the returned option
490 | 	}{
491 | 		{
492 | 			name: "string parameter",
493 | 			param: ParameterDefinition{
494 | 				Name:        "strParam",
495 | 				Type:        "string",
496 | 				Required:    true,
497 | 				Description: "A string parameter",
498 | 			},
499 | 			want: reflect.TypeOf(mcp.WithString("", mcp.Description(""))),
500 | 		},
501 | 		{
502 | 			name: "number parameter",
503 | 			param: ParameterDefinition{
504 | 				Name:        "numParam",
505 | 				Type:        "number",
506 | 				Required:    true,
507 | 				Description: "A number parameter",
508 | 			},
509 | 			want: reflect.TypeOf(mcp.WithNumber("", mcp.Description(""))),
510 | 		},
511 | 		{
512 | 			name: "boolean parameter",
513 | 			param: ParameterDefinition{
514 | 				Name:        "boolParam",
515 | 				Type:        "boolean",
516 | 				Required:    true,
517 | 				Description: "A boolean parameter",
518 | 			},
519 | 			want: reflect.TypeOf(mcp.WithBoolean("", mcp.Description(""))),
520 | 		},
521 | 		{
522 | 			name: "array parameter",
523 | 			param: ParameterDefinition{
524 | 				Name:        "arrayParam",
525 | 				Type:        "array",
526 | 				Required:    true,
527 | 				Description: "An array parameter",
528 | 				Items: map[string]any{
529 | 					"type": "string",
530 | 				},
531 | 			},
532 | 			want: reflect.TypeOf(mcp.WithArray("", mcp.Description(""))),
533 | 		},
534 | 		{
535 | 			name: "object parameter",
536 | 			param: ParameterDefinition{
537 | 				Name:        "objParam",
538 | 				Type:        "object",
539 | 				Required:    true,
540 | 				Description: "An object parameter",
541 | 				Items: map[string]any{
542 | 					"type": "object",
543 | 					"properties": map[string]any{
544 | 						"key": map[string]any{
545 | 							"type": "string",
546 | 						},
547 | 					},
548 | 				},
549 | 			},
550 | 			want: reflect.TypeOf(mcp.WithObject("", mcp.Description(""))),
551 | 		},
552 | 		{
553 | 			name: "enum parameter",
554 | 			param: ParameterDefinition{
555 | 				Name:        "enumParam",
556 | 				Type:        "string",
557 | 				Required:    true,
558 | 				Description: "An enum parameter",
559 | 				Enum:        []string{"val1", "val2"},
560 | 			},
561 | 			want: reflect.TypeOf(mcp.WithString("", mcp.Description(""))),
562 | 		},
563 | 		{
564 | 			name: "unknown type parameter",
565 | 			param: ParameterDefinition{
566 | 				Name:        "unknownParam",
567 | 				Type:        "unknown",
568 | 				Required:    true,
569 | 				Description: "An unknown parameter",
570 | 			},
571 | 			want: reflect.TypeOf(mcp.WithString("", mcp.Description(""))), // defaults to string
572 | 		},
573 | 	}
574 | 
575 | 	for _, tt := range tests {
576 | 		t.Run(tt.name, func(t *testing.T) {
577 | 			got := convertParameter(tt.param)
578 | 			gotType := reflect.TypeOf(got)
579 | 			if gotType != tt.want {
580 | 				t.Errorf("convertParameter() returned %v, want %v", gotType, tt.want)
581 | 			}
582 | 		})
583 | 	}
584 | }
585 | 
586 | // Optional: Add a specific test for convertAnnotation if desired, though it's simple
587 | func TestConvertAnnotation(t *testing.T) {
588 | 	input := Annotations{
589 | 		Title:           "Test Title",
590 | 		ReadOnlyHint:    true,
591 | 		DestructiveHint: true,
592 | 		IdempotentHint:  false,
593 | 		OpenWorldHint:   false,
594 | 	}
595 | 	want := mcp.ToolAnnotation{
596 | 		Title:           "Test Title",
597 | 		ReadOnlyHint:    &input.ReadOnlyHint,
598 | 		DestructiveHint: &input.DestructiveHint,
599 | 		IdempotentHint:  &input.IdempotentHint,
600 | 		OpenWorldHint:   &input.OpenWorldHint,
601 | 	}
602 | 
603 | 	dummyTool := &mcp.Tool{}
604 | 	option := convertAnnotation(input)
605 | 	option(dummyTool)
606 | 
607 | 	assert.NotNil(t, option)
608 | 	assert.Equal(t, want, dummyTool.Annotations)
609 | }
610 | 
```
--------------------------------------------------------------------------------
/internal/mcp/kubernetes_test.go:
--------------------------------------------------------------------------------
```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"errors"
  6 | 	"net/http"
  7 | 	"testing"
  8 | 
  9 | 	"github.com/mark3labs/mcp-go/mcp"
 10 | 	"github.com/stretchr/testify/assert"
 11 | 	"github.com/stretchr/testify/mock"
 12 | )
 13 | 
 14 | func TestHandleKubernetesProxy_ParameterValidation(t *testing.T) {
 15 | 	tests := []struct {
 16 | 		name             string
 17 | 		inputParams      map[string]any
 18 | 		expectedErrorMsg string
 19 | 	}{
 20 | 		{
 21 | 			name: "invalid body type (not a string)",
 22 | 			inputParams: map[string]any{
 23 | 				"environmentId":     float64(2),
 24 | 				"kubernetesAPIPath": "/api/v1/pods",
 25 | 				"method":            "POST",
 26 | 				"body":              true, // Invalid type for body
 27 | 			},
 28 | 			expectedErrorMsg: "body must be a string",
 29 | 		},
 30 | 		{
 31 | 			name: "missing environmentId",
 32 | 			inputParams: map[string]any{
 33 | 				"kubernetesAPIPath": "/api/v1/pods",
 34 | 				"method":            "GET",
 35 | 			},
 36 | 			expectedErrorMsg: "environmentId is required",
 37 | 		},
 38 | 		{
 39 | 			name: "missing kubernetesAPIPath",
 40 | 			inputParams: map[string]any{
 41 | 				"environmentId": float64(1),
 42 | 				"method":        "GET",
 43 | 			},
 44 | 			expectedErrorMsg: "kubernetesAPIPath is required",
 45 | 		},
 46 | 		{
 47 | 			name: "missing method",
 48 | 			inputParams: map[string]any{
 49 | 				"environmentId":     float64(1),
 50 | 				"kubernetesAPIPath": "/api/v1/pods",
 51 | 			},
 52 | 			expectedErrorMsg: "method is required",
 53 | 		},
 54 | 		{
 55 | 			name: "invalid kubernetesAPIPath (no leading slash)",
 56 | 			inputParams: map[string]any{
 57 | 				"environmentId":     float64(1),
 58 | 				"kubernetesAPIPath": "api/v1/pods",
 59 | 				"method":            "GET",
 60 | 			},
 61 | 			expectedErrorMsg: "kubernetesAPIPath must start with a leading slash",
 62 | 		},
 63 | 		{
 64 | 			name: "invalid HTTP method",
 65 | 			inputParams: map[string]any{
 66 | 				"environmentId":     float64(1),
 67 | 				"kubernetesAPIPath": "/api/v1/pods",
 68 | 				"method":            "INVALID",
 69 | 			},
 70 | 			expectedErrorMsg: "invalid method: INVALID",
 71 | 		},
 72 | 		{
 73 | 			name: "invalid queryParams type (not an array)",
 74 | 			inputParams: map[string]any{
 75 | 				"environmentId":     float64(1),
 76 | 				"kubernetesAPIPath": "/api/v1/pods",
 77 | 				"method":            "GET",
 78 | 				"queryParams":       "not-an-array",
 79 | 			},
 80 | 			expectedErrorMsg: "queryParams must be an array",
 81 | 		},
 82 | 		{
 83 | 			name: "invalid queryParams content (value not string)",
 84 | 			inputParams: map[string]any{
 85 | 				"environmentId":     float64(1),
 86 | 				"kubernetesAPIPath": "/api/v1/pods",
 87 | 				"method":            "GET",
 88 | 				"queryParams":       []any{map[string]any{"key": "namespace", "value": false}},
 89 | 			},
 90 | 			expectedErrorMsg: "invalid query params: invalid value: false",
 91 | 		},
 92 | 		{
 93 | 			name: "invalid headers type (not an array)",
 94 | 			inputParams: map[string]any{
 95 | 				"environmentId":     float64(1),
 96 | 				"kubernetesAPIPath": "/api/v1/pods",
 97 | 				"method":            "GET",
 98 | 				"headers":           "header-string",
 99 | 			},
100 | 			expectedErrorMsg: "headers must be an array",
101 | 		},
102 | 		{
103 | 			name: "invalid headers content (missing value)",
104 | 			inputParams: map[string]any{
105 | 				"environmentId":     float64(1),
106 | 				"kubernetesAPIPath": "/api/v1/pods",
107 | 				"method":            "GET",
108 | 				"headers":           []any{map[string]any{"key": "Content-Type"}},
109 | 			},
110 | 			expectedErrorMsg: "invalid headers: invalid value: <nil>",
111 | 		},
112 | 	}
113 | 
114 | 	for _, tt := range tests {
115 | 		t.Run(tt.name, func(t *testing.T) {
116 | 			server := &PortainerMCPServer{} // No client needed for param validation
117 | 
118 | 			request := CreateMCPRequest(tt.inputParams)
119 | 			handler := server.HandleKubernetesProxy()
120 | 			result, err := handler(context.Background(), request)
121 | 
122 | 			// All parameter/validation errors now return (result{IsError: true}, nil)
123 | 			assert.NoError(t, err)   // Handler now returns nil error
124 | 			assert.NotNil(t, result) // Handler returns a result object
125 | 			assert.True(t, result.IsError, "result.IsError should be true for parameter validation errors")
126 | 			assert.Len(t, result.Content, 1)                       // Expect one content item for the error message
127 | 			textContent, ok := result.Content[0].(mcp.TextContent) // Content should be TextContent
128 | 			assert.True(t, ok, "Result content should be mcp.TextContent for errors")
129 | 			assert.Contains(t, textContent.Text, tt.expectedErrorMsg, "Error message mismatch")
130 | 		})
131 | 	}
132 | }
133 | 
134 | func TestHandleKubernetesProxy_ClientInteraction(t *testing.T) {
135 | 	type testCase struct {
136 | 		name  string
137 | 		input map[string]any // Parameters for the MCP request
138 | 		mock  struct {       // Details for mocking the client call
139 | 			response *http.Response
140 | 			err      error
141 | 		}
142 | 		expect struct { // Expected outcome
143 | 			errSubstring string // Check for error containing this text (if error expected)
144 | 			resultText   string // Expected text result (if success expected)
145 | 		}
146 | 	}
147 | 
148 | 	tests := []testCase{
149 | 		{
150 | 			name: "successful GET request with query params",
151 | 			input: map[string]any{
152 | 				"environmentId":     float64(1),
153 | 				"kubernetesAPIPath": "/api/v1/pods",
154 | 				"method":            "GET",
155 | 				"queryParams": []any{
156 | 					map[string]any{"key": "namespace", "value": "default"},
157 | 					map[string]any{"key": "labelSelector", "value": "app=myApp"},
158 | 				},
159 | 			},
160 | 			mock: struct {
161 | 				response *http.Response
162 | 				err      error
163 | 			}{
164 | 				response: createMockHttpResponse(http.StatusOK, `{"kind":"PodList","items":[]}`),
165 | 				err:      nil,
166 | 			},
167 | 			expect: struct {
168 | 				errSubstring string
169 | 				resultText   string
170 | 			}{
171 | 				resultText: `{"kind":"PodList","items":[]}`,
172 | 			},
173 | 		},
174 | 		{
175 | 			name: "successful POST request with body and headers",
176 | 			input: map[string]any{
177 | 				"environmentId":     float64(2),
178 | 				"kubernetesAPIPath": "/api/v1/namespaces/test/services",
179 | 				"method":            "POST",
180 | 				"body":              `{"apiVersion":"v1","kind":"Service","metadata":{"name":"my-service"}}`,
181 | 				"headers": []any{
182 | 					map[string]any{"key": "Content-Type", "value": "application/json"},
183 | 				},
184 | 			},
185 | 			mock: struct {
186 | 				response *http.Response
187 | 				err      error
188 | 			}{
189 | 				response: createMockHttpResponse(http.StatusCreated, `{"metadata":{"name":"my-service"}}`),
190 | 				err:      nil,
191 | 			},
192 | 			expect: struct {
193 | 				errSubstring string
194 | 				resultText   string
195 | 			}{
196 | 				resultText: `{"metadata":{"name":"my-service"}}`,
197 | 			},
198 | 		},
199 | 		{
200 | 			name: "client API error",
201 | 			input: map[string]any{
202 | 				"environmentId":     float64(3),
203 | 				"kubernetesAPIPath": "/version",
204 | 				"method":            "GET",
205 | 			},
206 | 			mock: struct {
207 | 				response *http.Response
208 | 				err      error
209 | 			}{
210 | 				response: nil,
211 | 				err:      errors.New("k8s api error"),
212 | 			},
213 | 			expect: struct {
214 | 				errSubstring string
215 | 				resultText   string
216 | 			}{
217 | 				errSubstring: "failed to send Kubernetes API request: k8s api error",
218 | 			},
219 | 		},
220 | 		{
221 | 			name: "error reading response body",
222 | 			input: map[string]any{
223 | 				"environmentId":     float64(4),
224 | 				"kubernetesAPIPath": "/healthz",
225 | 				"method":            "GET",
226 | 			},
227 | 			mock: struct {
228 | 				response *http.Response
229 | 				err      error
230 | 			}{
231 | 				response: &http.Response{
232 | 					StatusCode: http.StatusOK,
233 | 					Body:       &errorReader{}, // Simulate read error
234 | 				},
235 | 				err: nil,
236 | 			},
237 | 			expect: struct {
238 | 				errSubstring string
239 | 				resultText   string
240 | 			}{
241 | 				errSubstring: "failed to read Kubernetes API response: simulated read error",
242 | 			},
243 | 		},
244 | 	}
245 | 
246 | 	for _, tc := range tests {
247 | 		t.Run(tc.name, func(t *testing.T) {
248 | 			mockClient := new(MockPortainerClient)
249 | 
250 | 			mockClient.On("ProxyKubernetesRequest", mock.AnythingOfType("models.KubernetesProxyRequestOptions")).
251 | 				Return(tc.mock.response, tc.mock.err)
252 | 
253 | 			server := &PortainerMCPServer{
254 | 				cli: mockClient,
255 | 			}
256 | 
257 | 			request := CreateMCPRequest(tc.input)
258 | 			handler := server.HandleKubernetesProxy()
259 | 			result, err := handler(context.Background(), request)
260 | 
261 | 			if tc.expect.errSubstring != "" {
262 | 				assert.NoError(t, err)
263 | 				assert.NotNil(t, result)
264 | 				assert.True(t, result.IsError, "result.IsError should be true for errors")
265 | 				assert.Len(t, result.Content, 1)
266 | 				textContent, ok := result.Content[0].(mcp.TextContent)
267 | 				assert.True(t, ok, "Result content should be mcp.TextContent for errors")
268 | 				assert.Contains(t, textContent.Text, tc.expect.errSubstring)
269 | 			} else {
270 | 				assert.NoError(t, err)
271 | 				assert.NotNil(t, result)
272 | 				assert.Len(t, result.Content, 1)
273 | 				textContent, ok := result.Content[0].(mcp.TextContent)
274 | 				assert.True(t, ok)
275 | 				assert.Equal(t, tc.expect.resultText, textContent.Text)
276 | 			}
277 | 
278 | 			mockClient.AssertExpectations(t)
279 | 		})
280 | 	}
281 | }
282 | 
283 | func TestHandleKubernetesProxyStripped_ParameterValidation(t *testing.T) {
284 | 	tests := []struct {
285 | 		name             string
286 | 		inputParams      map[string]any
287 | 		expectedErrorMsg string
288 | 	}{
289 | 		{
290 | 			name: "missing environmentId",
291 | 			inputParams: map[string]any{
292 | 				"kubernetesAPIPath": "/api/v1/pods",
293 | 			},
294 | 			expectedErrorMsg: "environmentId is required",
295 | 		},
296 | 		{
297 | 			name: "missing kubernetesAPIPath",
298 | 			inputParams: map[string]any{
299 | 				"environmentId": float64(1),
300 | 			},
301 | 			expectedErrorMsg: "kubernetesAPIPath is required",
302 | 		},
303 | 		{
304 | 			name: "invalid kubernetesAPIPath (no leading slash)",
305 | 			inputParams: map[string]any{
306 | 				"environmentId":     float64(1),
307 | 				"kubernetesAPIPath": "api/v1/pods",
308 | 			},
309 | 			expectedErrorMsg: "kubernetesAPIPath must start with a leading slash",
310 | 		},
311 | 		{
312 | 			name: "invalid queryParams type (not an array)",
313 | 			inputParams: map[string]any{
314 | 				"environmentId":     float64(1),
315 | 				"kubernetesAPIPath": "/api/v1/pods",
316 | 				"queryParams":       "not-an-array",
317 | 			},
318 | 			expectedErrorMsg: "queryParams must be an array",
319 | 		},
320 | 		{
321 | 			name: "invalid queryParams content (value not string)",
322 | 			inputParams: map[string]any{
323 | 				"environmentId":     float64(1),
324 | 				"kubernetesAPIPath": "/api/v1/pods",
325 | 				"queryParams":       []any{map[string]any{"key": "namespace", "value": false}},
326 | 			},
327 | 			expectedErrorMsg: "invalid query params: invalid value: false",
328 | 		},
329 | 		{
330 | 			name: "invalid headers type (not an array)",
331 | 			inputParams: map[string]any{
332 | 				"environmentId":     float64(1),
333 | 				"kubernetesAPIPath": "/api/v1/pods",
334 | 				"headers":           "header-string",
335 | 			},
336 | 			expectedErrorMsg: "headers must be an array",
337 | 		},
338 | 		{
339 | 			name: "invalid headers content (missing value)",
340 | 			inputParams: map[string]any{
341 | 				"environmentId":     float64(1),
342 | 				"kubernetesAPIPath": "/api/v1/pods",
343 | 				"headers":           []any{map[string]any{"key": "Content-Type"}},
344 | 			},
345 | 			expectedErrorMsg: "invalid headers: invalid value: <nil>",
346 | 		},
347 | 	}
348 | 
349 | 	for _, tt := range tests {
350 | 		t.Run(tt.name, func(t *testing.T) {
351 | 			server := &PortainerMCPServer{} // No client needed for param validation
352 | 
353 | 			request := CreateMCPRequest(tt.inputParams)
354 | 			handler := server.HandleKubernetesProxyStripped()
355 | 			result, err := handler(context.Background(), request)
356 | 
357 | 			// All parameter/validation errors now return (result{IsError: true}, nil)
358 | 			assert.NoError(t, err)   // Handler now returns nil error
359 | 			assert.NotNil(t, result) // Handler returns a result object
360 | 			assert.True(t, result.IsError, "result.IsError should be true for parameter validation errors")
361 | 			assert.Len(t, result.Content, 1)                       // Expect one content item for the error message
362 | 			textContent, ok := result.Content[0].(mcp.TextContent) // Content should be TextContent
363 | 			assert.True(t, ok, "Result content should be mcp.TextContent for errors")
364 | 			assert.Contains(t, textContent.Text, tt.expectedErrorMsg, "Error message mismatch")
365 | 		})
366 | 	}
367 | }
368 | 
369 | func TestHandleKubernetesProxyStripped_ClientInteraction(t *testing.T) {
370 | 	type testCase struct {
371 | 		name  string
372 | 		input map[string]any // Parameters for the MCP request
373 | 		mock  struct {       // Details for mocking the client call
374 | 			response *http.Response
375 | 			err      error
376 | 		}
377 | 		expect struct { // Expected outcome
378 | 			errSubstring string // Check for error containing this text (if error expected)
379 | 			resultText   string // Expected text result (if success expected)
380 | 		}
381 | 	}
382 | 
383 | 	tests := []testCase{
384 | 		{
385 | 			name: "successful GET request with managedFields stripped",
386 | 			input: map[string]any{
387 | 				"environmentId":     float64(1),
388 | 				"kubernetesAPIPath": "/api/v1/pods",
389 | 				"queryParams": []any{
390 | 					map[string]any{"key": "namespace", "value": "default"},
391 | 					map[string]any{"key": "labelSelector", "value": "app=myApp"},
392 | 				},
393 | 			},
394 | 			mock: struct {
395 | 				response *http.Response
396 | 				err      error
397 | 			}{
398 | 				response: createMockHttpResponse(http.StatusOK, `{
399 | 					"apiVersion": "v1",
400 | 					"kind": "PodList",
401 | 					"items": [
402 | 						{
403 | 							"apiVersion": "v1",
404 | 							"kind": "Pod",
405 | 							"metadata": {
406 | 								"name": "test-pod-1",
407 | 								"namespace": "default",
408 | 								"managedFields": [
409 | 									{
410 | 										"manager": "kubectl-client-side-apply",
411 | 										"operation": "Update",
412 | 										"apiVersion": "v1",
413 | 										"time": "2023-01-01T00:00:00Z"
414 | 									}
415 | 								]
416 | 							},
417 | 							"spec": {
418 | 								"containers": [
419 | 									{
420 | 										"name": "test-container",
421 | 										"image": "nginx"
422 | 									}
423 | 								]
424 | 							}
425 | 						}
426 | 					]
427 | 				}`),
428 | 				err: nil,
429 | 			},
430 | 			expect: struct {
431 | 				errSubstring string
432 | 				resultText   string
433 | 			}{
434 | 				resultText: `{"apiVersion":"v1","items":[{"apiVersion":"v1","kind":"Pod","metadata":{"name":"test-pod-1","namespace":"default"},"spec":{"containers":[{"image":"nginx","name":"test-container"}]}}],"kind":"PodList"}`,
435 | 			},
436 | 		},
437 | 		{
438 | 			name: "successful GET request with headers",
439 | 			input: map[string]any{
440 | 				"environmentId":     float64(2),
441 | 				"kubernetesAPIPath": "/api/v1/namespaces/default/pods",
442 | 				"headers": []any{
443 | 					map[string]any{"key": "X-Custom-Header", "value": "test-value"},
444 | 					map[string]any{"key": "Authorization", "value": "Bearer abc"},
445 | 				},
446 | 			},
447 | 			mock: struct {
448 | 				response *http.Response
449 | 				err      error
450 | 			}{
451 | 				response: createMockHttpResponse(http.StatusOK, `{
452 | 					"apiVersion": "v1",
453 | 					"kind": "Pod",
454 | 					"metadata": {
455 | 						"name": "test-pod",
456 | 						"namespace": "default",
457 | 						"managedFields": [
458 | 							{
459 | 								"manager": "kubectl-client-side-apply",
460 | 								"operation": "Update",
461 | 								"apiVersion": "v1",
462 | 								"time": "2023-01-01T00:00:00Z"
463 | 							}
464 | 						]
465 | 					},
466 | 					"spec": {
467 | 						"containers": [
468 | 							{
469 | 								"name": "test-container",
470 | 								"image": "nginx"
471 | 							}
472 | 						]
473 | 					}
474 | 				}`),
475 | 				err: nil,
476 | 			},
477 | 			expect: struct {
478 | 				errSubstring string
479 | 				resultText   string
480 | 			}{
481 | 				resultText: `{"apiVersion":"v1","kind":"Pod","metadata":{"name":"test-pod","namespace":"default"},"spec":{"containers":[{"image":"nginx","name":"test-container"}]}}`,
482 | 			},
483 | 		},
484 | 		{
485 | 			name: "client API error",
486 | 			input: map[string]any{
487 | 				"environmentId":     float64(3),
488 | 				"kubernetesAPIPath": "/version",
489 | 			},
490 | 			mock: struct {
491 | 				response *http.Response
492 | 				err      error
493 | 			}{
494 | 				response: nil,
495 | 				err:      errors.New("k8s api error"),
496 | 			},
497 | 			expect: struct {
498 | 				errSubstring string
499 | 				resultText   string
500 | 			}{
501 | 				errSubstring: "failed to send Kubernetes API request: k8s api error",
502 | 			},
503 | 		},
504 | 		{
505 | 			name: "error processing response body",
506 | 			input: map[string]any{
507 | 				"environmentId":     float64(4),
508 | 				"kubernetesAPIPath": "/healthz",
509 | 			},
510 | 			mock: struct {
511 | 				response *http.Response
512 | 				err      error
513 | 			}{
514 | 				response: &http.Response{
515 | 					StatusCode: http.StatusOK,
516 | 					Body:       &errorReader{}, // Simulate read error
517 | 				},
518 | 				err: nil,
519 | 			},
520 | 			expect: struct {
521 | 				errSubstring string
522 | 				resultText   string
523 | 			}{
524 | 				errSubstring: "failed to process Kubernetes API response: failed to read response body: simulated read error",
525 | 			},
526 | 		},
527 | 		{
528 | 			name: "empty response body",
529 | 			input: map[string]any{
530 | 				"environmentId":     float64(5),
531 | 				"kubernetesAPIPath": "/api/v1/namespaces",
532 | 			},
533 | 			mock: struct {
534 | 				response *http.Response
535 | 				err      error
536 | 			}{
537 | 				response: createMockHttpResponse(http.StatusNoContent, ""),
538 | 				err:      nil,
539 | 			},
540 | 			expect: struct {
541 | 				errSubstring string
542 | 				resultText   string
543 | 			}{
544 | 				resultText: "",
545 | 			},
546 | 		},
547 | 		{
548 | 			name: "invalid JSON response",
549 | 			input: map[string]any{
550 | 				"environmentId":     float64(6),
551 | 				"kubernetesAPIPath": "/api/v1/pods",
552 | 			},
553 | 			mock: struct {
554 | 				response *http.Response
555 | 				err      error
556 | 			}{
557 | 				response: createMockHttpResponse(http.StatusOK, "invalid json"),
558 | 				err:      nil,
559 | 			},
560 | 			expect: struct {
561 | 				errSubstring string
562 | 				resultText   string
563 | 			}{
564 | 				errSubstring: "failed to process Kubernetes API response: failed to unmarshal JSON into Unstructured",
565 | 			},
566 | 		},
567 | 	}
568 | 
569 | 	for _, tc := range tests {
570 | 		t.Run(tc.name, func(t *testing.T) {
571 | 			mockClient := new(MockPortainerClient)
572 | 
573 | 			mockClient.On("ProxyKubernetesRequest", mock.AnythingOfType("models.KubernetesProxyRequestOptions")).
574 | 				Return(tc.mock.response, tc.mock.err)
575 | 
576 | 			server := &PortainerMCPServer{
577 | 				cli: mockClient,
578 | 			}
579 | 
580 | 			request := CreateMCPRequest(tc.input)
581 | 			handler := server.HandleKubernetesProxyStripped()
582 | 			result, err := handler(context.Background(), request)
583 | 
584 | 			if tc.expect.errSubstring != "" {
585 | 				assert.NoError(t, err)
586 | 				assert.NotNil(t, result)
587 | 				assert.True(t, result.IsError, "result.IsError should be true for errors")
588 | 				assert.Len(t, result.Content, 1)
589 | 				textContent, ok := result.Content[0].(mcp.TextContent)
590 | 				assert.True(t, ok, "Result content should be mcp.TextContent for errors")
591 | 				assert.Contains(t, textContent.Text, tc.expect.errSubstring)
592 | 			} else {
593 | 				assert.NoError(t, err)
594 | 				assert.NotNil(t, result)
595 | 				assert.Len(t, result.Content, 1)
596 | 				textContent, ok := result.Content[0].(mcp.TextContent)
597 | 				assert.True(t, ok)
598 | 				if tc.expect.resultText == "" {
599 | 					assert.Equal(t, tc.expect.resultText, textContent.Text)
600 | 				} else {
601 | 					assert.JSONEq(t, tc.expect.resultText, textContent.Text)
602 | 				}
603 | 			}
604 | 
605 | 			mockClient.AssertExpectations(t)
606 | 		})
607 | 	}
608 | }
609 | 
```
--------------------------------------------------------------------------------
/internal/mcp/access_group_test.go:
--------------------------------------------------------------------------------
```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"encoding/json"
  6 | 	"fmt"
  7 | 	"strings"
  8 | 	"testing"
  9 | 
 10 | 	"github.com/mark3labs/mcp-go/mcp"
 11 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
 12 | 	"github.com/stretchr/testify/assert"
 13 | )
 14 | 
 15 | func TestHandleGetAccessGroups(t *testing.T) {
 16 | 	tests := []struct {
 17 | 		name        string
 18 | 		mockGroups  []models.AccessGroup
 19 | 		mockError   error
 20 | 		expectError bool
 21 | 	}{
 22 | 		{
 23 | 			name: "successful groups retrieval",
 24 | 			mockGroups: []models.AccessGroup{
 25 | 				{ID: 1, Name: "group1"},
 26 | 				{ID: 2, Name: "group2"},
 27 | 			},
 28 | 			mockError:   nil,
 29 | 			expectError: false,
 30 | 		},
 31 | 		{
 32 | 			name:        "api error",
 33 | 			mockGroups:  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 | 			mockClient := &MockPortainerClient{}
 42 | 			mockClient.On("GetAccessGroups").Return(tt.mockGroups, tt.mockError)
 43 | 
 44 | 			server := &PortainerMCPServer{
 45 | 				cli: mockClient,
 46 | 			}
 47 | 
 48 | 			handler := server.HandleGetAccessGroups()
 49 | 			result, err := handler(context.Background(), mcp.CallToolRequest{})
 50 | 
 51 | 			if tt.expectError {
 52 | 				assert.NoError(t, err)
 53 | 				assert.NotNil(t, result)
 54 | 				assert.True(t, result.IsError, "result.IsError should be true for expected errors")
 55 | 				assert.Len(t, result.Content, 1)
 56 | 				textContent, ok := result.Content[0].(mcp.TextContent)
 57 | 				assert.True(t, ok, "Result content should be mcp.TextContent for errors")
 58 | 				if tt.mockError != nil {
 59 | 					assert.Contains(t, textContent.Text, tt.mockError.Error())
 60 | 				} else {
 61 | 					assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
 62 | 				}
 63 | 			} else {
 64 | 				assert.NoError(t, err)
 65 | 				assert.Len(t, result.Content, 1)
 66 | 				textContent, ok := result.Content[0].(mcp.TextContent)
 67 | 				assert.True(t, ok)
 68 | 
 69 | 				var groups []models.AccessGroup
 70 | 				err = json.Unmarshal([]byte(textContent.Text), &groups)
 71 | 				assert.NoError(t, err)
 72 | 				assert.Equal(t, tt.mockGroups, groups)
 73 | 			}
 74 | 
 75 | 			mockClient.AssertExpectations(t)
 76 | 		})
 77 | 	}
 78 | }
 79 | 
 80 | func TestHandleCreateAccessGroup(t *testing.T) {
 81 | 	tests := []struct {
 82 | 		name        string
 83 | 		inputName   string
 84 | 		inputEnvIDs []int
 85 | 		mockID      int
 86 | 		mockError   error
 87 | 		expectError bool
 88 | 		setupParams func(request *mcp.CallToolRequest)
 89 | 	}{
 90 | 		{
 91 | 			name:        "successful group creation",
 92 | 			inputName:   "group1",
 93 | 			inputEnvIDs: []int{1, 2, 3},
 94 | 			mockID:      1,
 95 | 			mockError:   nil,
 96 | 			expectError: false,
 97 | 			setupParams: func(request *mcp.CallToolRequest) {
 98 | 				request.Params.Arguments = map[string]any{
 99 | 					"name":           "group1",
100 | 					"environmentIds": []any{float64(1), float64(2), float64(3)},
101 | 				}
102 | 			},
103 | 		},
104 | 		{
105 | 			name:        "api error",
106 | 			inputName:   "group1",
107 | 			inputEnvIDs: []int{1, 2, 3},
108 | 			mockID:      0,
109 | 			mockError:   fmt.Errorf("api error"),
110 | 			expectError: true,
111 | 			setupParams: func(request *mcp.CallToolRequest) {
112 | 				request.Params.Arguments = map[string]any{
113 | 					"name":           "group1",
114 | 					"environmentIds": []any{float64(1), float64(2), float64(3)},
115 | 				}
116 | 			},
117 | 		},
118 | 		{
119 | 			name:        "missing name parameter",
120 | 			inputEnvIDs: []int{1, 2, 3},
121 | 			mockError:   nil,
122 | 			expectError: true,
123 | 			setupParams: func(request *mcp.CallToolRequest) {
124 | 				request.Params.Arguments = map[string]any{
125 | 					"environmentIds": []any{float64(1), float64(2), float64(3)},
126 | 				}
127 | 			},
128 | 		},
129 | 		{
130 | 			name:        "invalid environmentIds - not an array",
131 | 			inputName:   "group1",
132 | 			mockError:   nil,
133 | 			expectError: true,
134 | 			setupParams: func(request *mcp.CallToolRequest) {
135 | 				request.Params.Arguments = map[string]any{
136 | 					"name":           "group1",
137 | 					"environmentIds": "not an array",
138 | 				}
139 | 			},
140 | 		},
141 | 		{
142 | 			name:        "invalid environmentIds - array with non-numbers",
143 | 			inputName:   "group1",
144 | 			mockError:   nil,
145 | 			expectError: true,
146 | 			setupParams: func(request *mcp.CallToolRequest) {
147 | 				request.Params.Arguments = map[string]any{
148 | 					"name":           "group1",
149 | 					"environmentIds": []any{"1", "2", "3"},
150 | 				}
151 | 			},
152 | 		},
153 | 		{
154 | 			name:        "invalid environmentIds - array with mixed types",
155 | 			inputName:   "group1",
156 | 			mockError:   nil,
157 | 			expectError: true,
158 | 			setupParams: func(request *mcp.CallToolRequest) {
159 | 				request.Params.Arguments = map[string]any{
160 | 					"name":           "group1",
161 | 					"environmentIds": []any{float64(1), "2", float64(3)},
162 | 				}
163 | 			},
164 | 		},
165 | 	}
166 | 
167 | 	for _, tt := range tests {
168 | 		t.Run(tt.name, func(t *testing.T) {
169 | 			mockClient := &MockPortainerClient{}
170 | 			if !tt.expectError || tt.mockError != nil {
171 | 				mockClient.On("CreateAccessGroup", tt.inputName, tt.inputEnvIDs).Return(tt.mockID, tt.mockError)
172 | 			}
173 | 
174 | 			server := &PortainerMCPServer{
175 | 				cli: mockClient,
176 | 			}
177 | 
178 | 			request := CreateMCPRequest(map[string]any{})
179 | 			tt.setupParams(&request)
180 | 
181 | 			handler := server.HandleCreateAccessGroup()
182 | 			result, err := handler(context.Background(), request)
183 | 
184 | 			if tt.expectError {
185 | 				assert.NoError(t, err)
186 | 				assert.NotNil(t, result)
187 | 				assert.True(t, result.IsError, "result.IsError should be true for expected errors")
188 | 				assert.Len(t, result.Content, 1)
189 | 				textContent, ok := result.Content[0].(mcp.TextContent)
190 | 				assert.True(t, ok, "Result content should be mcp.TextContent for errors")
191 | 				if tt.mockError != nil {
192 | 					assert.Contains(t, textContent.Text, tt.mockError.Error())
193 | 				} else {
194 | 					assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
195 | 				}
196 | 			} else {
197 | 				assert.NoError(t, err)
198 | 				assert.Len(t, result.Content, 1)
199 | 				textContent, ok := result.Content[0].(mcp.TextContent)
200 | 				assert.True(t, ok)
201 | 				assert.Contains(t, textContent.Text, fmt.Sprintf("ID: %d", tt.mockID))
202 | 			}
203 | 
204 | 			mockClient.AssertExpectations(t)
205 | 		})
206 | 	}
207 | }
208 | 
209 | func TestHandleUpdateAccessGroupName(t *testing.T) {
210 | 	tests := []struct {
211 | 		name        string
212 | 		inputID     int
213 | 		inputName   string
214 | 		mockError   error
215 | 		expectError bool
216 | 		setupParams func(request *mcp.CallToolRequest)
217 | 	}{
218 | 		{
219 | 			name:        "successful name update",
220 | 			inputID:     1,
221 | 			inputName:   "newname",
222 | 			mockError:   nil,
223 | 			expectError: false,
224 | 			setupParams: func(request *mcp.CallToolRequest) {
225 | 				request.Params.Arguments = map[string]any{
226 | 					"id":   float64(1),
227 | 					"name": "newname",
228 | 				}
229 | 			},
230 | 		},
231 | 		{
232 | 			name:        "api error",
233 | 			inputID:     1,
234 | 			inputName:   "newname",
235 | 			mockError:   fmt.Errorf("api error"),
236 | 			expectError: true,
237 | 			setupParams: func(request *mcp.CallToolRequest) {
238 | 				request.Params.Arguments = map[string]any{
239 | 					"id":   float64(1),
240 | 					"name": "newname",
241 | 				}
242 | 			},
243 | 		},
244 | 		{
245 | 			name:        "missing id parameter",
246 | 			inputName:   "newname",
247 | 			mockError:   nil,
248 | 			expectError: true,
249 | 			setupParams: func(request *mcp.CallToolRequest) {
250 | 				request.Params.Arguments = map[string]any{
251 | 					"name": "newname",
252 | 				}
253 | 			},
254 | 		},
255 | 		{
256 | 			name:        "missing name parameter",
257 | 			inputID:     1,
258 | 			mockError:   nil,
259 | 			expectError: true,
260 | 			setupParams: func(request *mcp.CallToolRequest) {
261 | 				request.Params.Arguments = map[string]any{
262 | 					"id": float64(1),
263 | 				}
264 | 			},
265 | 		},
266 | 	}
267 | 
268 | 	for _, tt := range tests {
269 | 		t.Run(tt.name, func(t *testing.T) {
270 | 			mockClient := &MockPortainerClient{}
271 | 			if !tt.expectError || tt.mockError != nil {
272 | 				mockClient.On("UpdateAccessGroupName", tt.inputID, tt.inputName).Return(tt.mockError)
273 | 			}
274 | 
275 | 			server := &PortainerMCPServer{
276 | 				cli: mockClient,
277 | 			}
278 | 
279 | 			request := CreateMCPRequest(map[string]any{})
280 | 			tt.setupParams(&request)
281 | 
282 | 			handler := server.HandleUpdateAccessGroupName()
283 | 			result, err := handler(context.Background(), request)
284 | 
285 | 			if tt.expectError {
286 | 				assert.NoError(t, err)
287 | 				assert.NotNil(t, result)
288 | 				assert.True(t, result.IsError, "result.IsError should be true for expected errors")
289 | 				assert.Len(t, result.Content, 1)
290 | 				textContent, ok := result.Content[0].(mcp.TextContent)
291 | 				assert.True(t, ok, "Result content should be mcp.TextContent for errors")
292 | 				if tt.mockError != nil {
293 | 					assert.Contains(t, textContent.Text, tt.mockError.Error())
294 | 				} else {
295 | 					assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
296 | 				}
297 | 			} else {
298 | 				assert.NoError(t, err)
299 | 				assert.Len(t, result.Content, 1)
300 | 				textContent, ok := result.Content[0].(mcp.TextContent)
301 | 				assert.True(t, ok)
302 | 				assert.Contains(t, textContent.Text, "successfully")
303 | 			}
304 | 
305 | 			mockClient.AssertExpectations(t)
306 | 		})
307 | 	}
308 | }
309 | 
310 | func TestHandleUpdateAccessGroupUserAccesses(t *testing.T) {
311 | 	tests := []struct {
312 | 		name          string
313 | 		inputID       int
314 | 		inputAccesses []map[string]any
315 | 		mockError     error
316 | 		expectError   bool
317 | 		setupParams   func(request *mcp.CallToolRequest)
318 | 	}{
319 | 		{
320 | 			name:    "successful user accesses update",
321 | 			inputID: 1,
322 | 			inputAccesses: []map[string]any{
323 | 				{"id": float64(1), "access": "environment_administrator"},
324 | 				{"id": float64(2), "access": "standard_user"},
325 | 			},
326 | 			mockError:   nil,
327 | 			expectError: false,
328 | 			setupParams: func(request *mcp.CallToolRequest) {
329 | 				request.Params.Arguments = map[string]any{
330 | 					"id": float64(1),
331 | 					"userAccesses": []any{
332 | 						map[string]any{"id": float64(1), "access": "environment_administrator"},
333 | 						map[string]any{"id": float64(2), "access": "standard_user"},
334 | 					},
335 | 				}
336 | 			},
337 | 		},
338 | 		{
339 | 			name:    "api error",
340 | 			inputID: 1,
341 | 			inputAccesses: []map[string]any{
342 | 				{"id": float64(1), "access": "environment_administrator"},
343 | 			},
344 | 			mockError:   fmt.Errorf("api error"),
345 | 			expectError: true,
346 | 			setupParams: func(request *mcp.CallToolRequest) {
347 | 				request.Params.Arguments = map[string]any{
348 | 					"id": float64(1),
349 | 					"userAccesses": []any{
350 | 						map[string]any{"id": float64(1), "access": "environment_administrator"},
351 | 					},
352 | 				}
353 | 			},
354 | 		},
355 | 		{
356 | 			name:        "missing id parameter",
357 | 			mockError:   nil,
358 | 			expectError: true,
359 | 			setupParams: func(request *mcp.CallToolRequest) {
360 | 				request.Params.Arguments = map[string]any{
361 | 					"userAccesses": []any{
362 | 						map[string]any{"id": float64(1), "access": "environment_administrator"},
363 | 					},
364 | 				}
365 | 			},
366 | 		},
367 | 		{
368 | 			name:        "missing userAccesses parameter",
369 | 			inputID:     1,
370 | 			mockError:   nil,
371 | 			expectError: true,
372 | 			setupParams: func(request *mcp.CallToolRequest) {
373 | 				request.Params.Arguments = map[string]any{
374 | 					"id": float64(1),
375 | 				}
376 | 			},
377 | 		},
378 | 		{
379 | 			name:    "invalid access level",
380 | 			inputID: 1,
381 | 			inputAccesses: []map[string]any{
382 | 				{"id": float64(1), "access": "invalid_access"},
383 | 			},
384 | 			mockError:   nil,
385 | 			expectError: true,
386 | 			setupParams: func(request *mcp.CallToolRequest) {
387 | 				request.Params.Arguments = map[string]any{
388 | 					"id": float64(1),
389 | 					"userAccesses": []any{
390 | 						map[string]any{"id": float64(1), "access": "invalid_access"},
391 | 					},
392 | 				}
393 | 			},
394 | 		},
395 | 	}
396 | 
397 | 	for _, tt := range tests {
398 | 		t.Run(tt.name, func(t *testing.T) {
399 | 			mockClient := &MockPortainerClient{}
400 | 			if !tt.expectError || tt.mockError != nil {
401 | 				expectedMap := make(map[int]string)
402 | 				for _, access := range tt.inputAccesses {
403 | 					id := int(access["id"].(float64))
404 | 					expectedMap[id] = access["access"].(string)
405 | 				}
406 | 				mockClient.On("UpdateAccessGroupUserAccesses", tt.inputID, expectedMap).Return(tt.mockError)
407 | 			}
408 | 
409 | 			server := &PortainerMCPServer{
410 | 				cli: mockClient,
411 | 			}
412 | 
413 | 			request := CreateMCPRequest(map[string]any{})
414 | 			tt.setupParams(&request)
415 | 
416 | 			handler := server.HandleUpdateAccessGroupUserAccesses()
417 | 			result, err := handler(context.Background(), request)
418 | 
419 | 			if tt.expectError {
420 | 				assert.NoError(t, err)
421 | 				assert.NotNil(t, result)
422 | 				assert.True(t, result.IsError, "result.IsError should be true for expected errors")
423 | 				assert.Len(t, result.Content, 1)
424 | 				textContent, ok := result.Content[0].(mcp.TextContent)
425 | 				assert.True(t, ok, "Result content should be mcp.TextContent for errors")
426 | 				if tt.mockError != nil {
427 | 					assert.Contains(t, textContent.Text, tt.mockError.Error())
428 | 				} else {
429 | 					assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter/validation errors")
430 | 					if strings.Contains(tt.name, "invalid access level") {
431 | 						assert.Contains(t, textContent.Text, "invalid user accesses")
432 | 					}
433 | 				}
434 | 			} else {
435 | 				assert.NoError(t, err)
436 | 				assert.Len(t, result.Content, 1)
437 | 				textContent, ok := result.Content[0].(mcp.TextContent)
438 | 				assert.True(t, ok)
439 | 				assert.Contains(t, textContent.Text, "successfully")
440 | 			}
441 | 
442 | 			mockClient.AssertExpectations(t)
443 | 		})
444 | 	}
445 | }
446 | 
447 | func TestHandleUpdateAccessGroupTeamAccesses(t *testing.T) {
448 | 	tests := []struct {
449 | 		name          string
450 | 		inputID       int
451 | 		inputAccesses []map[string]any
452 | 		mockError     error
453 | 		expectError   bool
454 | 		setupParams   func(request *mcp.CallToolRequest)
455 | 	}{
456 | 		{
457 | 			name:    "successful team accesses update",
458 | 			inputID: 1,
459 | 			inputAccesses: []map[string]any{
460 | 				{"id": float64(1), "access": "environment_administrator"},
461 | 				{"id": float64(2), "access": "standard_user"},
462 | 			},
463 | 			mockError:   nil,
464 | 			expectError: false,
465 | 			setupParams: func(request *mcp.CallToolRequest) {
466 | 				request.Params.Arguments = map[string]any{
467 | 					"id": float64(1),
468 | 					"teamAccesses": []any{
469 | 						map[string]any{"id": float64(1), "access": "environment_administrator"},
470 | 						map[string]any{"id": float64(2), "access": "standard_user"},
471 | 					},
472 | 				}
473 | 			},
474 | 		},
475 | 		{
476 | 			name:    "api error",
477 | 			inputID: 1,
478 | 			inputAccesses: []map[string]any{
479 | 				{"id": float64(1), "access": "environment_administrator"},
480 | 			},
481 | 			mockError:   fmt.Errorf("api error"),
482 | 			expectError: true,
483 | 			setupParams: func(request *mcp.CallToolRequest) {
484 | 				request.Params.Arguments = map[string]any{
485 | 					"id": float64(1),
486 | 					"teamAccesses": []any{
487 | 						map[string]any{"id": float64(1), "access": "environment_administrator"},
488 | 					},
489 | 				}
490 | 			},
491 | 		},
492 | 		{
493 | 			name:        "missing id parameter",
494 | 			mockError:   nil,
495 | 			expectError: true,
496 | 			setupParams: func(request *mcp.CallToolRequest) {
497 | 				request.Params.Arguments = map[string]any{
498 | 					"teamAccesses": []any{
499 | 						map[string]any{"id": float64(1), "access": "environment_administrator"},
500 | 					},
501 | 				}
502 | 			},
503 | 		},
504 | 		{
505 | 			name:        "missing teamAccesses parameter",
506 | 			inputID:     1,
507 | 			mockError:   nil,
508 | 			expectError: true,
509 | 			setupParams: func(request *mcp.CallToolRequest) {
510 | 				request.Params.Arguments = map[string]any{
511 | 					"id": float64(1),
512 | 				}
513 | 			},
514 | 		},
515 | 		{
516 | 			name:    "invalid access level",
517 | 			inputID: 1,
518 | 			inputAccesses: []map[string]any{
519 | 				{"id": float64(1), "access": "invalid_access"},
520 | 			},
521 | 			mockError:   nil,
522 | 			expectError: true,
523 | 			setupParams: func(request *mcp.CallToolRequest) {
524 | 				request.Params.Arguments = map[string]any{
525 | 					"id": float64(1),
526 | 					"teamAccesses": []any{
527 | 						map[string]any{"id": float64(1), "access": "invalid_access"},
528 | 					},
529 | 				}
530 | 			},
531 | 		},
532 | 	}
533 | 
534 | 	for _, tt := range tests {
535 | 		t.Run(tt.name, func(t *testing.T) {
536 | 			mockClient := &MockPortainerClient{}
537 | 			if !tt.expectError || tt.mockError != nil {
538 | 				expectedMap := make(map[int]string)
539 | 				for _, access := range tt.inputAccesses {
540 | 					id := int(access["id"].(float64))
541 | 					expectedMap[id] = access["access"].(string)
542 | 				}
543 | 				mockClient.On("UpdateAccessGroupTeamAccesses", tt.inputID, expectedMap).Return(tt.mockError)
544 | 			}
545 | 
546 | 			server := &PortainerMCPServer{
547 | 				cli: mockClient,
548 | 			}
549 | 
550 | 			request := CreateMCPRequest(map[string]any{})
551 | 			tt.setupParams(&request)
552 | 
553 | 			handler := server.HandleUpdateAccessGroupTeamAccesses()
554 | 			result, err := handler(context.Background(), request)
555 | 
556 | 			if tt.expectError {
557 | 				assert.NoError(t, err)
558 | 				assert.NotNil(t, result)
559 | 				assert.True(t, result.IsError, "result.IsError should be true for expected errors")
560 | 				assert.Len(t, result.Content, 1)
561 | 				textContent, ok := result.Content[0].(mcp.TextContent)
562 | 				assert.True(t, ok, "Result content should be mcp.TextContent for errors")
563 | 				if tt.mockError != nil {
564 | 					assert.Contains(t, textContent.Text, tt.mockError.Error())
565 | 				} else {
566 | 					assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter/validation errors")
567 | 					if strings.Contains(tt.name, "invalid access level") {
568 | 						assert.Contains(t, textContent.Text, "invalid team accesses")
569 | 					}
570 | 				}
571 | 			} else {
572 | 				assert.NoError(t, err)
573 | 				assert.Len(t, result.Content, 1)
574 | 				textContent, ok := result.Content[0].(mcp.TextContent)
575 | 				assert.True(t, ok)
576 | 				assert.Contains(t, textContent.Text, "successfully")
577 | 			}
578 | 
579 | 			mockClient.AssertExpectations(t)
580 | 		})
581 | 	}
582 | }
583 | 
584 | func TestHandleAddEnvironmentToAccessGroup(t *testing.T) {
585 | 	tests := []struct {
586 | 		name        string
587 | 		inputID     int
588 | 		inputEnvID  int
589 | 		mockError   error
590 | 		expectError bool
591 | 		setupParams func(request *mcp.CallToolRequest)
592 | 	}{
593 | 		{
594 | 			name:        "successful environment addition",
595 | 			inputID:     1,
596 | 			inputEnvID:  2,
597 | 			mockError:   nil,
598 | 			expectError: false,
599 | 			setupParams: func(request *mcp.CallToolRequest) {
600 | 				request.Params.Arguments = map[string]any{
601 | 					"id":            float64(1),
602 | 					"environmentId": float64(2),
603 | 				}
604 | 			},
605 | 		},
606 | 		{
607 | 			name:        "api error",
608 | 			inputID:     1,
609 | 			inputEnvID:  2,
610 | 			mockError:   fmt.Errorf("api error"),
611 | 			expectError: true,
612 | 			setupParams: func(request *mcp.CallToolRequest) {
613 | 				request.Params.Arguments = map[string]any{
614 | 					"id":            float64(1),
615 | 					"environmentId": float64(2),
616 | 				}
617 | 			},
618 | 		},
619 | 		{
620 | 			name:        "missing id parameter",
621 | 			inputEnvID:  2,
622 | 			mockError:   nil,
623 | 			expectError: true,
624 | 			setupParams: func(request *mcp.CallToolRequest) {
625 | 				request.Params.Arguments = map[string]any{
626 | 					"environmentId": float64(2),
627 | 				}
628 | 			},
629 | 		},
630 | 		{
631 | 			name:        "missing environmentId parameter",
632 | 			inputID:     1,
633 | 			mockError:   nil,
634 | 			expectError: true,
635 | 			setupParams: func(request *mcp.CallToolRequest) {
636 | 				request.Params.Arguments = map[string]any{
637 | 					"id": float64(1),
638 | 				}
639 | 			},
640 | 		},
641 | 	}
642 | 
643 | 	for _, tt := range tests {
644 | 		t.Run(tt.name, func(t *testing.T) {
645 | 			mockClient := &MockPortainerClient{}
646 | 			if !tt.expectError || tt.mockError != nil {
647 | 				mockClient.On("AddEnvironmentToAccessGroup", tt.inputID, tt.inputEnvID).Return(tt.mockError)
648 | 			}
649 | 
650 | 			server := &PortainerMCPServer{
651 | 				cli: mockClient,
652 | 			}
653 | 
654 | 			request := CreateMCPRequest(map[string]any{})
655 | 			tt.setupParams(&request)
656 | 
657 | 			handler := server.HandleAddEnvironmentToAccessGroup()
658 | 			result, err := handler(context.Background(), request)
659 | 
660 | 			if tt.expectError {
661 | 				assert.NoError(t, err)
662 | 				assert.NotNil(t, result)
663 | 				assert.True(t, result.IsError, "result.IsError should be true for expected errors")
664 | 				assert.Len(t, result.Content, 1)
665 | 				textContent, ok := result.Content[0].(mcp.TextContent)
666 | 				assert.True(t, ok, "Result content should be mcp.TextContent for errors")
667 | 				if tt.mockError != nil {
668 | 					assert.Contains(t, textContent.Text, tt.mockError.Error())
669 | 				} else {
670 | 					assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
671 | 				}
672 | 			} else {
673 | 				assert.NoError(t, err)
674 | 				assert.Len(t, result.Content, 1)
675 | 				textContent, ok := result.Content[0].(mcp.TextContent)
676 | 				assert.True(t, ok)
677 | 				assert.Contains(t, textContent.Text, "successfully")
678 | 			}
679 | 
680 | 			mockClient.AssertExpectations(t)
681 | 		})
682 | 	}
683 | }
684 | 
685 | func TestHandleRemoveEnvironmentFromAccessGroup(t *testing.T) {
686 | 	tests := []struct {
687 | 		name        string
688 | 		inputID     int
689 | 		inputEnvID  int
690 | 		mockError   error
691 | 		expectError bool
692 | 		setupParams func(request *mcp.CallToolRequest)
693 | 	}{
694 | 		{
695 | 			name:        "successful environment removal",
696 | 			inputID:     1,
697 | 			inputEnvID:  2,
698 | 			mockError:   nil,
699 | 			expectError: false,
700 | 			setupParams: func(request *mcp.CallToolRequest) {
701 | 				request.Params.Arguments = map[string]any{
702 | 					"id":            float64(1),
703 | 					"environmentId": float64(2),
704 | 				}
705 | 			},
706 | 		},
707 | 		{
708 | 			name:        "api error",
709 | 			inputID:     1,
710 | 			inputEnvID:  2,
711 | 			mockError:   fmt.Errorf("api error"),
712 | 			expectError: true,
713 | 			setupParams: func(request *mcp.CallToolRequest) {
714 | 				request.Params.Arguments = map[string]any{
715 | 					"id":            float64(1),
716 | 					"environmentId": float64(2),
717 | 				}
718 | 			},
719 | 		},
720 | 		{
721 | 			name:        "missing id parameter",
722 | 			inputEnvID:  2,
723 | 			mockError:   nil,
724 | 			expectError: true,
725 | 			setupParams: func(request *mcp.CallToolRequest) {
726 | 				request.Params.Arguments = map[string]any{
727 | 					"environmentId": float64(2),
728 | 				}
729 | 			},
730 | 		},
731 | 		{
732 | 			name:        "missing environmentId parameter",
733 | 			inputID:     1,
734 | 			mockError:   nil,
735 | 			expectError: true,
736 | 			setupParams: func(request *mcp.CallToolRequest) {
737 | 				request.Params.Arguments = map[string]any{
738 | 					"id": float64(1),
739 | 				}
740 | 			},
741 | 		},
742 | 	}
743 | 
744 | 	for _, tt := range tests {
745 | 		t.Run(tt.name, func(t *testing.T) {
746 | 			mockClient := &MockPortainerClient{}
747 | 			if !tt.expectError || tt.mockError != nil {
748 | 				mockClient.On("RemoveEnvironmentFromAccessGroup", tt.inputID, tt.inputEnvID).Return(tt.mockError)
749 | 			}
750 | 
751 | 			server := &PortainerMCPServer{
752 | 				cli: mockClient,
753 | 			}
754 | 
755 | 			request := CreateMCPRequest(map[string]any{})
756 | 			tt.setupParams(&request)
757 | 
758 | 			handler := server.HandleRemoveEnvironmentFromAccessGroup()
759 | 			result, err := handler(context.Background(), request)
760 | 
761 | 			if tt.expectError {
762 | 				assert.NoError(t, err)
763 | 				assert.NotNil(t, result)
764 | 				assert.True(t, result.IsError, "result.IsError should be true for expected errors")
765 | 				assert.Len(t, result.Content, 1)
766 | 				textContent, ok := result.Content[0].(mcp.TextContent)
767 | 				assert.True(t, ok, "Result content should be mcp.TextContent for errors")
768 | 				if tt.mockError != nil {
769 | 					assert.Contains(t, textContent.Text, tt.mockError.Error())
770 | 				} else {
771 | 					assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
772 | 				}
773 | 			} else {
774 | 				assert.NoError(t, err)
775 | 				assert.Len(t, result.Content, 1)
776 | 				textContent, ok := result.Content[0].(mcp.TextContent)
777 | 				assert.True(t, ok)
778 | 				assert.Contains(t, textContent.Text, "successfully")
779 | 			}
780 | 
781 | 			mockClient.AssertExpectations(t)
782 | 		})
783 | 	}
784 | }
785 | 
```
--------------------------------------------------------------------------------
/internal/tooldef/tools.yaml:
--------------------------------------------------------------------------------
```yaml
  1 | ---
  2 | version: v1.2
  3 | tools:
  4 |   ## Access Groups
  5 |   ## An access group is the equivalent of an Endpoint Group in Portainer.
  6 |   ## ------------------------------------------------------------
  7 |   - name: listAccessGroups
  8 |     description: List all available access groups
  9 |     annotations:
 10 |       title: List Access Groups
 11 |       readOnlyHint: true
 12 |       destructiveHint: false
 13 |       idempotentHint: true
 14 |       openWorldHint: false
 15 |   - name: createAccessGroup
 16 |     description: Create a new access group. Use access groups when you want to define
 17 |       accesses on more than one environment. Otherwise, define the accesses on
 18 |       the environment level.
 19 |     parameters:
 20 |       - name: name
 21 |         description: The name of the access group
 22 |         type: string
 23 |         required: true
 24 |       - name: environmentIds
 25 |         description: "The IDs of the environments that are part of the access group.
 26 |           Must include all the environment IDs that are part of the group - this
 27 |           includes new environments and the existing environments that are
 28 |           already associated with the group. Example: [1, 2, 3]"
 29 |         type: array
 30 |         items:
 31 |           type: number
 32 |     annotations:
 33 |       title: Create Access Group
 34 |       readOnlyHint: false
 35 |       destructiveHint: false
 36 |       idempotentHint: false
 37 |       openWorldHint: false
 38 |   - name: updateAccessGroupName
 39 |     description: Update the name of an existing access group.
 40 |     parameters:
 41 |       - name: id
 42 |         description: The ID of the access group to update
 43 |         type: number
 44 |         required: true
 45 |       - name: name
 46 |         description: The name of the access group
 47 |         type: string
 48 |         required: true
 49 |     annotations:
 50 |       title: Update Access Group Name
 51 |       readOnlyHint: false
 52 |       destructiveHint: false
 53 |       idempotentHint: true
 54 |       openWorldHint: false
 55 |   - name: updateAccessGroupUserAccesses
 56 |     description: Update the user accesses of an existing access group.
 57 |     parameters:
 58 |       - name: id
 59 |         description: The ID of the access group to update
 60 |         type: number
 61 |         required: true
 62 |       - name: userAccesses
 63 |         description: "The user accesses that are associated with all the environments in
 64 |           the access group. The ID is the user ID of the user in Portainer.
 65 |           Example: [{id: 1, access: 'environment_administrator'}, {id: 2,
 66 |           access: 'standard_user'}]"
 67 |         type: array
 68 |         required: true
 69 |         items:
 70 |           type: object
 71 |           properties:
 72 |             id:
 73 |               description: The ID of the user
 74 |               type: number
 75 |             access:
 76 |               description: The access level of the user. Can be environment_administrator,
 77 |                 helpdesk_user, standard_user, readonly_user or operator_user
 78 |               type: string
 79 |               enum:
 80 |                 - environment_administrator
 81 |                 - helpdesk_user
 82 |                 - standard_user
 83 |                 - readonly_user
 84 |                 - operator_user
 85 |     annotations:
 86 |       title: Update Access Group User Accesses
 87 |       readOnlyHint: false
 88 |       destructiveHint: false
 89 |       idempotentHint: true
 90 |       openWorldHint: false
 91 |   - name: updateAccessGroupTeamAccesses
 92 |     description: Update the team accesses of an existing access group.
 93 |     parameters:
 94 |       - name: id
 95 |         description: The ID of the access group to update
 96 |         type: number
 97 |         required: true
 98 |       - name: teamAccesses
 99 |         description: "The team accesses that are associated with all the environments in
100 |           the access group. The ID is the team ID of the team in Portainer.
101 |           Example: [{id: 1, access: 'environment_administrator'}, {id: 2,
102 |           access: 'standard_user'}]"
103 |         type: array
104 |         required: true
105 |         items:
106 |           type: object
107 |           properties:
108 |             id:
109 |               description: The ID of the team
110 |               type: number
111 |             access:
112 |               description: The access level of the team. Can be environment_administrator,
113 |                 helpdesk_user, standard_user, readonly_user or operator_user
114 |               type: string
115 |               enum:
116 |                 - environment_administrator
117 |                 - helpdesk_user
118 |                 - standard_user
119 |                 - readonly_user
120 |                 - operator_user
121 |     annotations:
122 |       title: Update Access Group Team Accesses
123 |       readOnlyHint: false
124 |       destructiveHint: false
125 |       idempotentHint: true
126 |       openWorldHint: false
127 |   - name: addEnvironmentToAccessGroup
128 |     description: Add an environment to an access group.
129 |     parameters:
130 |       - name: id
131 |         description: The ID of the access group to update
132 |         type: number
133 |         required: true
134 |       - name: environmentId
135 |         description: The ID of the environment to add to the access group
136 |         type: number
137 |         required: true
138 |     annotations:
139 |       title: Add Environment To Access Group
140 |       readOnlyHint: false
141 |       destructiveHint: false
142 |       idempotentHint: true
143 |       openWorldHint: false
144 |   - name: removeEnvironmentFromAccessGroup
145 |     description: Remove an environment from an access group.
146 |     parameters:
147 |       - name: id
148 |         description: The ID of the access group to update
149 |         type: number
150 |         required: true
151 |       - name: environmentId
152 |         description: The ID of the environment to remove from the access group
153 |         type: number
154 |         required: true
155 |     annotations:
156 |       title: Remove Environment From Access Group
157 |       readOnlyHint: false
158 |       destructiveHint: true
159 |       idempotentHint: true
160 |       openWorldHint: false
161 |   ## Environment
162 |   ## ------------------------------------------------------------
163 |   - name: listEnvironments
164 |     description: List all available environments
165 |     annotations:
166 |       title: List Environments
167 |       readOnlyHint: true
168 |       destructiveHint: false
169 |       idempotentHint: true
170 |       openWorldHint: false
171 |   - name: updateEnvironmentTags
172 |     description: Update the tags associated with an environment
173 |     parameters:
174 |       - name: id
175 |         description: The ID of the environment to update
176 |         type: number
177 |         required: true
178 |       - name: tagIds
179 |         description: >-
180 |           The IDs of the tags that are associated with the environment.
181 |           Must include all the tag IDs that should be associated with the environment - this includes new tags and existing tags.
182 |           Providing an empty array will remove all tags.
183 |           Example: [1, 2, 3]
184 |         type: array
185 |         required: true
186 |         items:
187 |           type: number
188 |     annotations:
189 |       title: Update Environment Tags
190 |       readOnlyHint: false
191 |       destructiveHint: false
192 |       idempotentHint: true
193 |       openWorldHint: false
194 |   - name: updateEnvironmentUserAccesses
195 |     description: Update the user access policies of an environment
196 |     parameters:
197 |       - name: id
198 |         description: The ID of the environment to update
199 |         type: number
200 |         required: true
201 |       - name: userAccesses
202 |         description: >-
203 |           The user accesses that are associated with the environment.
204 |           The ID is the user ID of the user in Portainer.
205 |           Must include all the access policies for all users that should be associated with the environment.
206 |           Providing an empty array will remove all user accesses.
207 |           Example: [{id: 1, access: 'environment_administrator'}, {id: 2, access: 'standard_user'}]
208 |         type: array
209 |         required: true
210 |         items:
211 |           type: object
212 |           properties:
213 |             id:
214 |               description: The ID of the user
215 |               type: number
216 |             access:
217 |               description: The access level of the user
218 |               type: string
219 |               enum:
220 |                 - environment_administrator
221 |                 - helpdesk_user
222 |                 - standard_user
223 |                 - readonly_user
224 |                 - operator_user
225 |     annotations:
226 |       title: Update Environment User Accesses
227 |       readOnlyHint: false
228 |       destructiveHint: false
229 |       idempotentHint: true
230 |       openWorldHint: false
231 |   - name: updateEnvironmentTeamAccesses
232 |     description: Update the team access policies of an environment
233 |     parameters:
234 |       - name: id
235 |         description: The ID of the environment to update
236 |         type: number
237 |         required: true
238 |       - name: teamAccesses
239 |         description: >-
240 |           The team accesses that are associated with the environment.
241 |           The ID is the team ID of the team in Portainer.
242 |           Must include all the access policies for all teams that should be associated with the environment.
243 |           Providing an empty array will remove all team accesses.
244 |           Example: [{id: 1, access: 'environment_administrator'}, {id: 2, access: 'standard_user'}]
245 |         type: array
246 |         required: true
247 |         items:
248 |           type: object
249 |           properties:
250 |             id:
251 |               description: The ID of the team
252 |               type: number
253 |             access:
254 |               description: The access level of the team
255 |               type: string
256 |               enum:
257 |                 - environment_administrator
258 |                 - helpdesk_user
259 |                 - standard_user
260 |                 - readonly_user
261 |                 - operator_user
262 |     annotations:
263 |       title: Update Environment Team Accesses
264 |       readOnlyHint: false
265 |       destructiveHint: false
266 |       idempotentHint: true
267 |       openWorldHint: false
268 |   ## Environment Groups
269 |   ## An environment group is the equivalent of an Edge Group in Portainer.
270 |   ## ------------------------------------------------------------
271 |   - name: createEnvironmentGroup
272 |     description: Create a new environment group. Environment groups are the equivalent of Edge Groups in Portainer.
273 |     parameters:
274 |       - name: name
275 |         description: The name of the environment group
276 |         type: string
277 |         required: true
278 |       - name: environmentIds
279 |         description: The IDs of the environments to add to the group
280 |         type: array
281 |         required: true
282 |         items:
283 |           type: number
284 |     annotations:
285 |       title: Create Environment Group
286 |       readOnlyHint: false
287 |       destructiveHint: false
288 |       idempotentHint: false
289 |       openWorldHint: false
290 |   - name: listEnvironmentGroups
291 |     description: List all available environment groups. Environment groups are the equivalent of Edge Groups in Portainer.
292 |     annotations:
293 |       title: List Environment Groups
294 |       readOnlyHint: true
295 |       destructiveHint: false
296 |       idempotentHint: true
297 |       openWorldHint: false
298 |   - name: updateEnvironmentGroupName
299 |     description: Update the name of an environment group. Environment groups are the equivalent of Edge Groups in Portainer.
300 |     parameters:
301 |       - name: id
302 |         description: The ID of the environment group to update
303 |         type: number
304 |         required: true
305 |       - name: name
306 |         description: The new name for the environment group
307 |         type: string
308 |         required: true
309 |     annotations:
310 |       title: Update Environment Group Name
311 |       readOnlyHint: false
312 |       destructiveHint: false
313 |       idempotentHint: true
314 |       openWorldHint: false
315 |   - name: updateEnvironmentGroupEnvironments
316 |     description: Update the environments associated with an environment group. Environment groups are the equivalent of Edge Groups in Portainer.
317 |     parameters:
318 |       - name: id
319 |         description: The ID of the environment group to update
320 |         type: number
321 |         required: true
322 |       - name: environmentIds
323 |         description: >-
324 |           The IDs of the environments that should be part of the group.
325 |           Must include all environment IDs that should be associated with the group.
326 |           Providing an empty array will remove all environments from the group.
327 |           Example: [1, 2, 3]
328 |         type: array
329 |         required: true
330 |         items:
331 |           type: number
332 |     annotations:
333 |       title: Update Environment Group Environments
334 |       readOnlyHint: false
335 |       destructiveHint: false
336 |       idempotentHint: true
337 |       openWorldHint: false
338 |   - name: updateEnvironmentGroupTags
339 |     description: Update the tags associated with an environment group. Environment groups are the equivalent of Edge Groups in Portainer.
340 |     parameters:
341 |       - name: id
342 |         description: The ID of the environment group to update
343 |         type: number
344 |         required: true
345 |       - name: tagIds
346 |         description: >-
347 |           The IDs of the tags that should be associated with the group.
348 |           Must include all tag IDs that should be associated with the group.
349 |           Providing an empty array will remove all tags from the group.
350 |           Example: [1, 2, 3]
351 |         type: array
352 |         required: true
353 |         items:
354 |           type: number
355 |     annotations:
356 |       title: Update Environment Group Tags
357 |       readOnlyHint: false
358 |       destructiveHint: false
359 |       idempotentHint: true
360 |       openWorldHint: false
361 |   ## Settings
362 |   ## ------------------------------------------------------------
363 |   - name: getSettings
364 |     description: Get the settings of the Portainer instance
365 |     annotations:
366 |       title: Get Settings
367 |       readOnlyHint: true
368 |       destructiveHint: false
369 |       idempotentHint: true
370 |       openWorldHint: false
371 |   ## Stacks
372 |   ## ------------------------------------------------------------
373 |   - name: listStacks
374 |     description: List all available stacks
375 |     annotations:
376 |       title: List Stacks
377 |       readOnlyHint: true
378 |       destructiveHint: false
379 |       idempotentHint: true
380 |       openWorldHint: false
381 |   - name: getStackFile
382 |     description: Get the compose file for a specific stack ID
383 |     parameters:
384 |       - name: id
385 |         description: The ID of the stack to get the compose file for
386 |         type: number
387 |         required: true
388 |     annotations:
389 |       title: Get Stack File
390 |       readOnlyHint: true
391 |       destructiveHint: false
392 |       idempotentHint: true
393 |       openWorldHint: false
394 |   - name: createStack
395 |     description: Create a new stack
396 |     parameters:
397 |       - name: name
398 |         description: Name of the stack. Stack name must only consist of lowercase alpha
399 |           characters, numbers, hyphens, or underscores as well as start with a
400 |           lowercase character or number
401 |         type: string
402 |         required: true
403 |       - name: file
404 |         description: >-
405 |           Content of the stack file. The file must be a valid
406 |           docker-compose.yml file. example: services:
407 |            web:
408 |              image:nginx
409 |         type: string
410 |         required: true
411 |       - name: environmentGroupIds
412 |         description: "The IDs of the environment groups that the stack belongs to. Must
413 |           include at least one environment group ID. Example: [1, 2, 3]"
414 |         type: array
415 |         required: true
416 |         items:
417 |           type: number
418 |     annotations:
419 |       title: Create Stack
420 |       readOnlyHint: false
421 |       destructiveHint: false
422 |       idempotentHint: false
423 |       openWorldHint: false
424 |   - name: updateStack
425 |     description: Update an existing stack
426 |     parameters:
427 |       - name: id
428 |         description: The ID of the stack to update
429 |         type: number
430 |         required: true
431 |       - name: file
432 |         description: >-
433 |           Content of the stack file. The file must be a valid
434 |           docker-compose.yml file. example: version: 3
435 |            services:
436 |              web:
437 |                image:nginx
438 |         type: string
439 |         required: true
440 |       - name: environmentGroupIds
441 |         description: "The IDs of the environment groups that the stack belongs to. Must
442 |           include at least one environment group ID. Example: [1, 2, 3]"
443 |         type: array
444 |         required: true
445 |         items:
446 |           type: number
447 |     annotations:
448 |       title: Update Stack
449 |       readOnlyHint: false
450 |       destructiveHint: false
451 |       idempotentHint: true
452 |       openWorldHint: false
453 |   ## Tags
454 |   ## ------------------------------------------------------------
455 |   - name: createEnvironmentTag
456 |     description: Create a new environment tag
457 |     parameters:
458 |       - name: name
459 |         description: The name of the tag
460 |         type: string
461 |         required: true
462 |     annotations:
463 |       title: Create Environment Tag
464 |       readOnlyHint: false
465 |       destructiveHint: false
466 |       idempotentHint: false
467 |       openWorldHint: false
468 |   - name: listEnvironmentTags
469 |     description: List all available environment tags
470 |     annotations:
471 |       title: List Environment Tags
472 |       readOnlyHint: true
473 |       destructiveHint: false
474 |       idempotentHint: true
475 |       openWorldHint: false
476 |   ## Teams
477 |   ## ------------------------------------------------------------
478 |   - name: createTeam
479 |     description: Create a new team
480 |     parameters:
481 |       - name: name
482 |         description: The name of the team
483 |         type: string
484 |         required: true
485 |     annotations:
486 |       title: Create Team
487 |       readOnlyHint: false
488 |       destructiveHint: false
489 |       idempotentHint: false
490 |       openWorldHint: false
491 |   - name: listTeams
492 |     description: List all available teams
493 |     annotations:
494 |       title: List Teams
495 |       readOnlyHint: true
496 |       destructiveHint: false
497 |       idempotentHint: true
498 |       openWorldHint: false
499 |   - name: updateTeamName
500 |     description: Update the name of an existing team
501 |     parameters:
502 |       - name: id
503 |         description: The ID of the team to update
504 |         type: number
505 |         required: true
506 |       - name: name
507 |         description: The new name of the team
508 |         type: string
509 |         required: true
510 |     annotations:
511 |       title: Update Team Name
512 |       readOnlyHint: false
513 |       destructiveHint: false
514 |       idempotentHint: true
515 |       openWorldHint: false
516 |   - name: updateTeamMembers
517 |     description: Update the members of an existing team
518 |     parameters:
519 |       - name: id
520 |         description: The ID of the team to update
521 |         type: number
522 |         required: true
523 |       - name: userIds
524 |         description: "The IDs of the users that are part of the team. Must include all
525 |           the user IDs that are part of the team - this includes new users and
526 |           the existing users that are already associated with the team. Example:
527 |           [1, 2, 3]"
528 |         type: array
529 |         required: true
530 |         items:
531 |           type: number
532 |     annotations:
533 |       title: Update Team Members
534 |       readOnlyHint: false
535 |       destructiveHint: false
536 |       idempotentHint: true
537 |       openWorldHint: false
538 | 
539 |   ## Users
540 |   ## ------------------------------------------------------------
541 |   - name: listUsers
542 |     description: List all available users
543 |     annotations:
544 |       title: List Users
545 |       readOnlyHint: true
546 |       destructiveHint: false
547 |       idempotentHint: true
548 |       openWorldHint: false
549 |   - name: updateUserRole
550 |     description: Update an existing user
551 |     parameters:
552 |       - name: id
553 |         description: The ID of the user to update
554 |         type: number
555 |         required: true
556 |       - name: role
557 |         description: The role of the user. Can be admin, user or edge_admin
558 |         type: string
559 |         required: true
560 |         enum:
561 |           - admin
562 |           - user
563 |           - edge_admin
564 |     annotations:
565 |       title: Update User Role
566 |       readOnlyHint: false
567 |       destructiveHint: false
568 |       idempotentHint: true
569 |       openWorldHint: false
570 | 
571 |   ## Docker Proxy
572 |   ## ------------------------------------------------------------
573 |   - name: dockerProxy
574 |     description: Proxy Docker requests to a specific Portainer environment.
575 |       This tool can be used with any Docker API operation as documented in the Docker Engine API specification (https://docs.docker.com/reference/api/engine/version/v1.48/).
576 |     parameters:
577 |       - name: environmentId
578 |         description: The ID of the environment to proxy Docker requests to
579 |         type: number
580 |         required: true
581 |       - name: method
582 |         description: The HTTP method to use to proxy the Docker API operation
583 |         type: string
584 |         required: true
585 |         enum:
586 |           - GET
587 |           - POST
588 |           - PUT
589 |           - DELETE
590 |           - HEAD
591 |       - name: dockerAPIPath
592 |         description: "The route of the Docker API operation to proxy. Must include the leading slash. Example: /containers/json"
593 |         type: string
594 |         required: true
595 |       - name: queryParams
596 |         description: "The query parameters to include in the Docker API operation. Must be an array of key-value pairs.
597 |           Example: [{key: 'all', value: 'true'}, {key: 'filter', value: 'dangling'}]"
598 |         type: array
599 |         required: false
600 |         items:
601 |           type: object
602 |           properties:
603 |             key:
604 |               type: string
605 |               description: The key of the query parameter
606 |             value:
607 |               type: string
608 |               description: The value of the query parameter
609 |       - name: headers
610 |         description: "The headers to include in the Docker API operation. Must be an array of key-value pairs.
611 |           Example: [{key: 'Content-Type', value: 'application/json'}]"
612 |         type: array
613 |         required: false
614 |         items:
615 |           type: object
616 |           properties:
617 |             key:
618 |               type: string
619 |               description: The key of the header
620 |             value:
621 |               type: string
622 |               description: The value of the header
623 |       - name: body
624 |         description: "The body of the Docker API operation to proxy. Must be a JSON string.
625 |           Example: {'Image': 'nginx:latest', 'Name': 'my-container'}"
626 |         type: string
627 |         required: false
628 |     annotations:
629 |       title: Docker Proxy
630 |       readOnlyHint: true
631 |       destructiveHint: true
632 |       idempotentHint: true
633 |       openWorldHint: false
634 | 
635 |   ## Kubernetes Proxy
636 |   ## ------------------------------------------------------------
637 |   - name: kubernetesProxy
638 |     description: Proxy Kubernetes requests to a specific Portainer environment.
639 |       This tool can be used with any Kubernetes API operation as documented in the Kubernetes API specification (https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.32/).
640 |     parameters:
641 |       - name: environmentId
642 |         description: The ID of the environment to proxy Kubernetes requests to
643 |         type: number
644 |         required: true
645 |       - name: method
646 |         description: The HTTP method to use to proxy the Kubernetes API operation
647 |         type: string
648 |         required: true
649 |         enum:
650 |           - GET
651 |           - POST
652 |           - PUT
653 |           - DELETE
654 |           - HEAD
655 |       - name: kubernetesAPIPath
656 |         description: "The route of the Kubernetes API operation to proxy. Must include the leading slash. Example: /api/v1/namespaces/default/pods"
657 |         type: string
658 |         required: true
659 |       - name: queryParams
660 |         description: "The query parameters to include in the Kubernetes API operation. Must be an array of key-value pairs.
661 |           Example: [{key: 'watch', value: 'true'}, {key: 'fieldSelector', value: 'metadata.name=my-pod'}]"
662 |         type: array
663 |         required: false
664 |         items:
665 |           type: object
666 |           properties:
667 |             key:
668 |               type: string
669 |               description: The key of the query parameter
670 |             value:
671 |               type: string
672 |               description: The value of the query parameter
673 |       - name: headers
674 |         description: "The headers to include in the Kubernetes API operation. Must be an array of key-value pairs.
675 |           Example: [{key: 'Content-Type', value: 'application/json'}]"
676 |         type: array
677 |         required: false
678 |         items:
679 |           type: object
680 |           properties:
681 |             key:
682 |               type: string
683 |               description: The key of the header
684 |             value:
685 |               type: string
686 |               description: The value of the header
687 |       - name: body
688 |         description: "The body of the Kubernetes API operation to proxy. Must be a JSON string.
689 |           Example: {'apiVersion': 'v1', 'kind': 'Pod', 'metadata': {'name': 'my-pod'}}"
690 |         type: string
691 |         required: false
692 |     annotations:
693 |       title: Kubernetes Proxy
694 |       readOnlyHint: true
695 |       destructiveHint: true
696 |       idempotentHint: true
697 |       openWorldHint: false
698 |   - name: getKubernetesResourceStripped
699 |     description: >-
700 |       Proxy GET requests to a specific Portainer environment for Kubernetes resources,
701 |       and automatically strips verbose metadata fields (such as 'managedFields') from the API response
702 |       to reduce its size. This tool is intended for retrieving Kubernetes resource
703 |       information where a leaner payload is desired.
704 |       This tool can be used with any GET Kubernetes API operation as documented
705 |       in the Kubernetes API specification (https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.32/).
706 |       For other methods (POST, PUT, DELETE, HEAD), use the 'kubernetesProxy' tool.
707 |     parameters:
708 |       - name: environmentId
709 |         description: The ID of the environment to proxy Kubernetes GET requests to
710 |         type: number
711 |         required: true
712 |       - name: kubernetesAPIPath
713 |         description: "The route of the Kubernetes API GET operation to proxy. Must include the leading slash. Example: /api/v1/namespaces/default/pods"
714 |         type: string
715 |         required: true
716 |       - name: queryParams
717 |         description: "The query parameters to include in the Kubernetes API operation. Must be an array of key-value pairs.
718 |           Example: [{key: 'watch', value: 'true'}, {key: 'fieldSelector', value: 'metadata.name=my-pod'}]"
719 |         type: array
720 |         required: false
721 |         items:
722 |           type: object
723 |           properties:
724 |             key:
725 |               type: string
726 |               description: The key of the query parameter
727 |             value:
728 |               type: string
729 |               description: The value of the query parameter
730 |       - name: headers
731 |         description: "The headers to include in the Kubernetes API operation. Must be an array of key-value pairs.
732 |           Example: [{key: 'Accept', value: 'application/json'}]"
733 |         type: array
734 |         required: false
735 |         items:
736 |           type: object
737 |           properties:
738 |             key:
739 |               type: string
740 |               description: The key of the header
741 |             value:
742 |               type: string
743 |               description: The value of the header
744 |     annotations:
745 |       title: Get Kubernetes Resource (Stripped)
746 |       readOnlyHint: true
747 |       destructiveHint: false
748 |       idempotentHint: true
749 |       openWorldHint: false
```