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
```